|
|
|
@@ -1,16 +1,59 @@ |
|
|
|
/** |
|
|
|
* txt-game — number guessing game |
|
|
|
* Single-file Node.js HTTP server using only stdlib. |
|
|
|
* Single-file Node.js HTTP server using only stdlib + pg. |
|
|
|
* Listens on port 3000, serves HTML form UI, maintains per-session state via cookie. |
|
|
|
* Persists scores to Postgres when DB env vars are present; degrades gracefully otherwise. |
|
|
|
*/ |
|
|
|
|
|
|
|
import { createServer } from "node:http"; |
|
|
|
import { randomUUID } from "node:crypto"; |
|
|
|
import pg from "pg"; |
|
|
|
|
|
|
|
const PORT = 3000; |
|
|
|
const MAX_ATTEMPTS = 7; |
|
|
|
|
|
|
|
/** @type {Map<string, { target: number, attempts: number, won: boolean, lost: boolean, history: number[] }>} */ |
|
|
|
// ─── DB Pool ──────────────────────────────────────────────────────────────── |
|
|
|
|
|
|
|
const DB_ENV_KEYS = [ |
|
|
|
"TXT_GAME_DB_HOST", |
|
|
|
"TXT_GAME_DB_PORT", |
|
|
|
"TXT_GAME_DB_USER", |
|
|
|
"TXT_GAME_DB_PASSWORD", |
|
|
|
"TXT_GAME_DB_NAME", |
|
|
|
]; |
|
|
|
const hasDb = DB_ENV_KEYS.every((k) => process.env[k]); |
|
|
|
|
|
|
|
/** @type {pg.Pool | null} */ |
|
|
|
let pool = null; |
|
|
|
|
|
|
|
if (hasDb) { |
|
|
|
pool = new pg.Pool({ |
|
|
|
host: process.env.TXT_GAME_DB_HOST, |
|
|
|
port: parseInt(process.env.TXT_GAME_DB_PORT ?? "5432", 10), |
|
|
|
user: process.env.TXT_GAME_DB_USER, |
|
|
|
password: process.env.TXT_GAME_DB_PASSWORD, |
|
|
|
database: process.env.TXT_GAME_DB_NAME, |
|
|
|
max: 5, |
|
|
|
idleTimeoutMillis: 30000, |
|
|
|
}); |
|
|
|
pool.on("error", (err) => { |
|
|
|
console.error("[txt-game] pg pool error:", err.message); |
|
|
|
}); |
|
|
|
console.log( |
|
|
|
`[txt-game] DB pool initialised → ${process.env.TXT_GAME_DB_HOST}:${process.env.TXT_GAME_DB_PORT}/${process.env.TXT_GAME_DB_NAME}` |
|
|
|
); |
|
|
|
} else { |
|
|
|
console.warn( |
|
|
|
"[txt-game] DB env vars missing — running in degraded mode (scores disabled)" |
|
|
|
); |
|
|
|
} |
|
|
|
|
|
|
|
// ─── Session Store ────────────────────────────────────────────────────────── |
|
|
|
|
|
|
|
/** |
|
|
|
* @typedef {{ target: number, attempts: number, won: boolean, lost: boolean, history: number[] }} Session |
|
|
|
* @type {Map<string, Session>} |
|
|
|
*/ |
|
|
|
const sessions = new Map(); |
|
|
|
|
|
|
|
function newGame() { |
|
|
|
@@ -23,11 +66,16 @@ function newGame() { |
|
|
|
}; |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* Get or create the 24 h session keyed by the `sid` cookie. |
|
|
|
* @param {string | undefined} cookies |
|
|
|
* @returns {{ id: string, session: Session }} |
|
|
|
*/ |
|
|
|
function getSession(cookies) { |
|
|
|
const match = (cookies || "").match(/sid=([a-f0-9-]{36})/); |
|
|
|
const match = (cookies ?? "").match(/sid=([a-f0-9-]{36})/); |
|
|
|
if (match) { |
|
|
|
const id = match[1]; |
|
|
|
if (sessions.has(id)) return { id, session: sessions.get(id) }; |
|
|
|
if (sessions.has(id)) return { id, session: /** @type {Session} */ (sessions.get(id)) }; |
|
|
|
} |
|
|
|
const id = randomUUID(); |
|
|
|
const session = newGame(); |
|
|
|
@@ -35,6 +83,140 @@ function getSession(cookies) { |
|
|
|
return { id, session }; |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* Extract the persistent player ID from cookies, or generate a fresh UUID. |
|
|
|
* The caller is responsible for setting a new `pid` cookie when the value is new. |
|
|
|
* @param {string | undefined} cookies |
|
|
|
* @returns {string} |
|
|
|
*/ |
|
|
|
function getPid(cookies) { |
|
|
|
const match = (cookies ?? "").match(/pid=([a-f0-9-]{36})/); |
|
|
|
return match ? match[1] : randomUUID(); |
|
|
|
} |
|
|
|
|
|
|
|
// ─── DB Helpers ───────────────────────────────────────────────────────────── |
|
|
|
|
|
|
|
/** |
|
|
|
* Insert a player row if it doesn't exist yet. Silently no-ops when DB is unavailable. |
|
|
|
* @param {string} pid |
|
|
|
*/ |
|
|
|
async function upsertPlayer(pid) { |
|
|
|
if (!pool) return; |
|
|
|
try { |
|
|
|
await pool.query( |
|
|
|
`INSERT INTO players (id) VALUES ($1) ON CONFLICT (id) DO NOTHING`, |
|
|
|
[pid] |
|
|
|
); |
|
|
|
} catch (err) { |
|
|
|
console.error("[txt-game] upsertPlayer error:", /** @type {Error} */ (err).message); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* Transactionally record a finished round: insert into games + update player aggregates. |
|
|
|
* Logs + rolls back on any DB error — never propagates. |
|
|
|
* @param {string} pid |
|
|
|
* @param {Session} session |
|
|
|
*/ |
|
|
|
async function recordRoundEnd(pid, session) { |
|
|
|
if (!pool) return; |
|
|
|
const client = await pool.connect().catch((err) => { |
|
|
|
console.error("[txt-game] recordRoundEnd connect error:", /** @type {Error} */ (err).message); |
|
|
|
return null; |
|
|
|
}); |
|
|
|
if (!client) return; |
|
|
|
|
|
|
|
try { |
|
|
|
await client.query("BEGIN"); |
|
|
|
|
|
|
|
await client.query( |
|
|
|
`INSERT INTO games (player_id, target, attempts, won) VALUES ($1, $2, $3, $4)`, |
|
|
|
[pid, session.target, session.attempts, session.won] |
|
|
|
); |
|
|
|
|
|
|
|
if (session.won) { |
|
|
|
// Update win aggregates; best_attempts is only set/updated on wins. |
|
|
|
await client.query( |
|
|
|
`UPDATE players SET |
|
|
|
games_played = games_played + 1, |
|
|
|
games_won = games_won + 1, |
|
|
|
best_attempts = LEAST(COALESCE(best_attempts, $1::int), $1::int), |
|
|
|
current_streak = current_streak + 1, |
|
|
|
best_streak = GREATEST(best_streak, current_streak + 1), |
|
|
|
last_played_at = now() |
|
|
|
WHERE id = $2`, |
|
|
|
[session.attempts, pid] |
|
|
|
); |
|
|
|
} else { |
|
|
|
// Loss: reset streak, leave best_attempts unchanged. |
|
|
|
await client.query( |
|
|
|
`UPDATE players SET |
|
|
|
games_played = games_played + 1, |
|
|
|
games_lost = games_lost + 1, |
|
|
|
current_streak = 0, |
|
|
|
last_played_at = now() |
|
|
|
WHERE id = $1`, |
|
|
|
[pid] |
|
|
|
); |
|
|
|
} |
|
|
|
|
|
|
|
await client.query("COMMIT"); |
|
|
|
} catch (err) { |
|
|
|
console.error("[txt-game] recordRoundEnd error:", /** @type {Error} */ (err).message); |
|
|
|
try { |
|
|
|
await client.query("ROLLBACK"); |
|
|
|
} catch { |
|
|
|
// ignore rollback error |
|
|
|
} |
|
|
|
} finally { |
|
|
|
client.release(); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* Fetch the current player's stats row. Returns null when DB is unavailable or player not found. |
|
|
|
* @param {string} pid |
|
|
|
* @returns {Promise<{games_played:number, games_won:number, games_lost:number, best_attempts:number|null, current_streak:number, best_streak:number} | null>} |
|
|
|
*/ |
|
|
|
async function fetchPlayerStats(pid) { |
|
|
|
if (!pool) return null; |
|
|
|
try { |
|
|
|
const result = await pool.query( |
|
|
|
`SELECT games_played, games_won, games_lost, best_attempts, current_streak, best_streak |
|
|
|
FROM players WHERE id = $1`, |
|
|
|
[pid] |
|
|
|
); |
|
|
|
return result.rows[0] ?? null; |
|
|
|
} catch (err) { |
|
|
|
console.error("[txt-game] fetchPlayerStats error:", /** @type {Error} */ (err).message); |
|
|
|
return null; |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* Fetch the global top-10 scoreboard ordered by fewest wins-required attempts, then earliest last_played. |
|
|
|
* Excludes players who have never won (best_attempts IS NULL). |
|
|
|
* @returns {Promise<Array<{id:string, best_attempts:number, games_won:number, last_played_at:Date}>>} |
|
|
|
*/ |
|
|
|
async function fetchScoreboard() { |
|
|
|
if (!pool) return []; |
|
|
|
try { |
|
|
|
const result = await pool.query( |
|
|
|
`SELECT id, best_attempts, games_won, last_played_at |
|
|
|
FROM players |
|
|
|
WHERE best_attempts IS NOT NULL |
|
|
|
ORDER BY best_attempts ASC, last_played_at ASC |
|
|
|
LIMIT 10` |
|
|
|
); |
|
|
|
return result.rows; |
|
|
|
} catch (err) { |
|
|
|
console.error("[txt-game] fetchScoreboard error:", /** @type {Error} */ (err).message); |
|
|
|
return []; |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
// ─── Rendering ────────────────────────────────────────────────────────────── |
|
|
|
|
|
|
|
function hint(guess, target) { |
|
|
|
if (guess < target) return "Too low ↑"; |
|
|
|
if (guess > target) return "Too high ↓"; |
|
|
|
@@ -50,7 +232,15 @@ function clue(guess, target) { |
|
|
|
return "🧊 Cold"; |
|
|
|
} |
|
|
|
|
|
|
|
function renderPage(session, message, guess) { |
|
|
|
/** |
|
|
|
* @param {Session} session |
|
|
|
* @param {string} message |
|
|
|
* @param {number | null} guess |
|
|
|
* @param {string} pid |
|
|
|
* @param {{games_played:number, games_won:number, games_lost:number, best_attempts:number|null, current_streak:number, best_streak:number} | null} playerStats |
|
|
|
* @param {Array<{id:string, best_attempts:number, games_won:number, last_played_at:Date}>} scoreboard |
|
|
|
*/ |
|
|
|
function renderPage(session, message, guess, pid, playerStats, scoreboard) { |
|
|
|
const attemptsLeft = MAX_ATTEMPTS - session.attempts; |
|
|
|
const historyRows = session.history |
|
|
|
.map( |
|
|
|
@@ -88,10 +278,59 @@ function renderPage(session, message, guess) { |
|
|
|
statusSection = `<p class="attempts">Attempts left: <strong>${attemptsLeft}</strong></p>`; |
|
|
|
} |
|
|
|
|
|
|
|
const messageHtml = message |
|
|
|
? `<p class="message">${message}</p>` |
|
|
|
const messageHtml = message ? `<p class="message">${message}</p>` : ""; |
|
|
|
|
|
|
|
// ── "Your best" stats panel ── |
|
|
|
const sp = playerStats; |
|
|
|
const statsBestAttempts = sp?.best_attempts != null ? String(sp.best_attempts) : "—"; |
|
|
|
const statsPlayed = sp ? String(sp.games_played) : "0"; |
|
|
|
const statsWon = sp ? String(sp.games_won) : "0"; |
|
|
|
const statsLost = sp ? String(sp.games_lost) : "0"; |
|
|
|
const statsCurStreak = sp ? String(sp.current_streak) : "0"; |
|
|
|
const statsBestStreak = sp ? String(sp.best_streak) : "0"; |
|
|
|
|
|
|
|
const statsPanel = pool |
|
|
|
? `<div class="stats-panel"> |
|
|
|
<h2>Your best</h2> |
|
|
|
<div class="stats-grid"> |
|
|
|
<div class="stat"><span class="stat-val">${statsPlayed}</span><span class="stat-lbl">Played</span></div> |
|
|
|
<div class="stat"><span class="stat-val">${statsWon}</span><span class="stat-lbl">Won</span></div> |
|
|
|
<div class="stat"><span class="stat-val">${statsLost}</span><span class="stat-lbl">Lost</span></div> |
|
|
|
<div class="stat"><span class="stat-val">${statsBestAttempts}</span><span class="stat-lbl">Best attempts</span></div> |
|
|
|
<div class="stat"><span class="stat-val">${statsCurStreak}</span><span class="stat-lbl">Streak</span></div> |
|
|
|
<div class="stat"><span class="stat-val">${statsBestStreak}</span><span class="stat-lbl">Best streak</span></div> |
|
|
|
</div> |
|
|
|
</div>` |
|
|
|
: ""; |
|
|
|
|
|
|
|
// ── Scoreboard ── |
|
|
|
let scoreboardHtml = ""; |
|
|
|
if (pool && scoreboard.length > 0) { |
|
|
|
const pidPrefix = pid.slice(0, 8); |
|
|
|
const rows = scoreboard |
|
|
|
.map((row, i) => { |
|
|
|
const rowPrefix = row.id.slice(0, 8); |
|
|
|
const isMe = row.id === pid; |
|
|
|
const cls = isMe ? ` class="me"` : ""; |
|
|
|
const meLabel = isMe ? " ◀" : ""; |
|
|
|
const date = row.last_played_at |
|
|
|
? new Date(row.last_played_at).toLocaleDateString(undefined, { month: "short", day: "numeric" }) |
|
|
|
: "—"; |
|
|
|
return `<tr${cls}><td>${i + 1}</td><td>${rowPrefix}${meLabel}</td><td>${row.best_attempts}</td><td>${row.games_won}</td><td>${date}</td></tr>`; |
|
|
|
}) |
|
|
|
.join(""); |
|
|
|
|
|
|
|
scoreboardHtml = `<div class="scoreboard"> |
|
|
|
<h2>Scoreboard</h2> |
|
|
|
<table> |
|
|
|
<thead><tr><th>#</th><th>Player</th><th>Best</th><th>Wins</th><th>Last played</th></tr></thead> |
|
|
|
<tbody>${rows}</tbody> |
|
|
|
</table> |
|
|
|
</div>`; |
|
|
|
} else if (pool) { |
|
|
|
scoreboardHtml = `<div class="scoreboard"><h2>Scoreboard</h2><p class="no-scores">No scores yet — win a game to appear here!</p></div>`; |
|
|
|
} |
|
|
|
|
|
|
|
return `<!DOCTYPE html> |
|
|
|
<html lang="en"> |
|
|
|
<head> |
|
|
|
@@ -106,7 +345,7 @@ function renderPage(session, message, guess) { |
|
|
|
color: #e0e0e0; |
|
|
|
min-height: 100vh; |
|
|
|
display: flex; |
|
|
|
align-items: center; |
|
|
|
align-items: flex-start; |
|
|
|
justify-content: center; |
|
|
|
padding: 2rem; |
|
|
|
} |
|
|
|
@@ -119,6 +358,7 @@ function renderPage(session, message, guess) { |
|
|
|
width: 100%; |
|
|
|
} |
|
|
|
h1 { color: #7ec8e3; margin-bottom: 0.5rem; font-size: 1.6rem; } |
|
|
|
h2 { color: #7ec8e3; font-size: 1rem; margin-bottom: 0.75rem; margin-top: 0; } |
|
|
|
p.subtitle { color: #888; margin-bottom: 1.5rem; font-size: 0.9rem; } |
|
|
|
form { display: flex; gap: 0.75rem; align-items: center; margin-bottom: 1rem; flex-wrap: wrap; } |
|
|
|
input[type="number"] { |
|
|
|
@@ -147,11 +387,36 @@ function renderPage(session, message, guess) { |
|
|
|
.failure { color: #e05252; margin: 0.75rem 0; font-weight: bold; } |
|
|
|
.attempts { color: #aaa; margin: 0.75rem 0; } |
|
|
|
label { color: #aaa; font-size: 0.9rem; display: block; margin-bottom: 0.5rem; } |
|
|
|
table { width: 100%; border-collapse: collapse; margin-top: 1rem; font-size: 0.9rem; } |
|
|
|
table { width: 100%; border-collapse: collapse; margin-top: 0.5rem; font-size: 0.9rem; } |
|
|
|
th, td { text-align: left; padding: 0.4rem 0.6rem; border-bottom: 1px solid #2a2a2a; } |
|
|
|
th { color: #7ec8e3; } |
|
|
|
td:first-child { color: #666; } |
|
|
|
td:nth-child(2) { font-weight: bold; color: #e0e0e0; } |
|
|
|
.divider { border: none; border-top: 1px solid #2a2a2a; margin: 1.5rem 0; } |
|
|
|
/* Stats panel */ |
|
|
|
.stats-panel { margin-top: 1.5rem; } |
|
|
|
.stats-grid { |
|
|
|
display: grid; |
|
|
|
grid-template-columns: repeat(3, 1fr); |
|
|
|
gap: 0.75rem; |
|
|
|
} |
|
|
|
.stat { |
|
|
|
background: #111; |
|
|
|
border: 1px solid #2a2a2a; |
|
|
|
border-radius: 6px; |
|
|
|
padding: 0.6rem 0.75rem; |
|
|
|
display: flex; |
|
|
|
flex-direction: column; |
|
|
|
align-items: center; |
|
|
|
} |
|
|
|
.stat-val { font-size: 1.4rem; font-weight: bold; color: #7ec8e3; } |
|
|
|
.stat-lbl { font-size: 0.75rem; color: #666; margin-top: 0.2rem; } |
|
|
|
/* Scoreboard */ |
|
|
|
.scoreboard { margin-top: 1.5rem; } |
|
|
|
.scoreboard table td:nth-child(2) { color: #aaa; font-weight: normal; font-family: monospace; font-size: 0.85rem; } |
|
|
|
.scoreboard table tr.me td { background: #1e2d1e; } |
|
|
|
.scoreboard table tr.me td:nth-child(2) { color: #5dbb63; } |
|
|
|
.no-scores { color: #555; font-size: 0.9rem; } |
|
|
|
</style> |
|
|
|
</head> |
|
|
|
<body> |
|
|
|
@@ -162,11 +427,15 @@ function renderPage(session, message, guess) { |
|
|
|
${messageHtml} |
|
|
|
${formSection} |
|
|
|
${historySection} |
|
|
|
${statsPanel} |
|
|
|
${scoreboardHtml} |
|
|
|
</div> |
|
|
|
</body> |
|
|
|
</html>`; |
|
|
|
} |
|
|
|
|
|
|
|
// ─── Utilities ─────────────────────────────────────────────────────────────── |
|
|
|
|
|
|
|
function parseBody(req) { |
|
|
|
return new Promise((resolve) => { |
|
|
|
let body = ""; |
|
|
|
@@ -178,32 +447,52 @@ function parseBody(req) { |
|
|
|
}); |
|
|
|
} |
|
|
|
|
|
|
|
function setCookie(res, id) { |
|
|
|
res.setHeader( |
|
|
|
"Set-Cookie", |
|
|
|
`sid=${id}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400` |
|
|
|
); |
|
|
|
/** |
|
|
|
* Set both the session cookie (24 h) and the persistent player cookie (365 d). |
|
|
|
* @param {import("node:http").ServerResponse} res |
|
|
|
* @param {string} sid |
|
|
|
* @param {string} pid |
|
|
|
*/ |
|
|
|
function setCookies(res, sid, pid) { |
|
|
|
res.setHeader("Set-Cookie", [ |
|
|
|
`sid=${sid}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400`, |
|
|
|
`pid=${pid}; Path=/; HttpOnly; SameSite=Strict; Max-Age=31536000`, |
|
|
|
]); |
|
|
|
} |
|
|
|
|
|
|
|
// ─── Server ────────────────────────────────────────────────────────────────── |
|
|
|
|
|
|
|
const server = createServer(async (req, res) => { |
|
|
|
// Forward x-forwarded-host so URLs resolve correctly behind Traefik |
|
|
|
const fwdHost = req.headers["x-forwarded-host"]; |
|
|
|
if (fwdHost) req.headers.host = fwdHost; |
|
|
|
|
|
|
|
const { id, session } = getSession(req.headers["cookie"]); |
|
|
|
const url = req.url.split("?")[0]; |
|
|
|
const cookies = req.headers["cookie"]; |
|
|
|
const { id, session } = getSession(cookies); |
|
|
|
const pid = getPid(cookies); |
|
|
|
const url = (req.url ?? "/").split("?")[0]; |
|
|
|
|
|
|
|
// Ensure a players row exists for this visitor (idempotent) |
|
|
|
await upsertPlayer(pid); |
|
|
|
|
|
|
|
if ((req.method === "GET" || req.method === "HEAD") && url === "/") { |
|
|
|
setCookie(res, id); |
|
|
|
const [playerStats, scoreboard] = await Promise.all([ |
|
|
|
fetchPlayerStats(pid), |
|
|
|
fetchScoreboard(), |
|
|
|
]); |
|
|
|
setCookies(res, id, pid); |
|
|
|
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); |
|
|
|
res.end(req.method === "HEAD" ? "" : renderPage(session, "", null)); |
|
|
|
res.end( |
|
|
|
req.method === "HEAD" |
|
|
|
? "" |
|
|
|
: renderPage(session, "", null, pid, playerStats, scoreboard) |
|
|
|
); |
|
|
|
return; |
|
|
|
} |
|
|
|
|
|
|
|
if (req.method === "POST" && url === "/guess") { |
|
|
|
const body = await parseBody(req); |
|
|
|
const guess = parseInt(body.guess, 10); |
|
|
|
|
|
|
|
let message = ""; |
|
|
|
|
|
|
|
if (session.won || session.lost) { |
|
|
|
@@ -216,25 +505,34 @@ const server = createServer(async (req, res) => { |
|
|
|
|
|
|
|
if (guess === session.target) { |
|
|
|
session.won = true; |
|
|
|
// Fix last history row hint (show "Correct!") |
|
|
|
} else if (session.attempts >= MAX_ATTEMPTS) { |
|
|
|
session.lost = true; |
|
|
|
message = `${hint(guess, session.target)} — ${clue(guess, session.target)}`; |
|
|
|
} else { |
|
|
|
message = `${hint(guess, session.target)} — ${clue(guess, session.target)}`; |
|
|
|
} |
|
|
|
|
|
|
|
// Persist completed round |
|
|
|
if (session.won || session.lost) { |
|
|
|
await recordRoundEnd(pid, session); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
const [playerStats, scoreboard] = await Promise.all([ |
|
|
|
fetchPlayerStats(pid), |
|
|
|
fetchScoreboard(), |
|
|
|
]); |
|
|
|
|
|
|
|
setCookie(res, id); |
|
|
|
setCookies(res, id, pid); |
|
|
|
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); |
|
|
|
res.end(renderPage(session, message, guess)); |
|
|
|
res.end(renderPage(session, message, guess, pid, playerStats, scoreboard)); |
|
|
|
return; |
|
|
|
} |
|
|
|
|
|
|
|
if (req.method === "POST" && url === "/reset") { |
|
|
|
const fresh = newGame(); |
|
|
|
sessions.set(id, fresh); |
|
|
|
setCookie(res, id); |
|
|
|
setCookies(res, id, pid); |
|
|
|
res.writeHead(303, { Location: "/" }); |
|
|
|
res.end(); |
|
|
|
return; |
|
|
|
|