/** * txt-game — number guessing game * 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; // ─── 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} */ const sessions = new Map(); function newGame() { return { target: Math.floor(Math.random() * 100) + 1, attempts: 0, won: false, lost: false, history: [], }; } /** * 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})/); if (match) { const id = match[1]; if (sessions.has(id)) return { id, session: /** @type {Session} */ (sessions.get(id)) }; } const id = randomUUID(); const session = newGame(); sessions.set(id, session); 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>} */ 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 ↓"; return "Correct!"; } function clue(guess, target) { const diff = Math.abs(guess - target); if (diff === 0) return "🎯 Spot on!"; if (diff <= 5) return "🔥 Very hot"; if (diff <= 15) return "♨️ Warm"; if (diff <= 30) return "🌡️ Cool"; return "🧊 Cold"; } /** * @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( (g, i) => `${i + 1}${g}${hint(g, session.won && i === session.history.length - 1 ? g : session.target)}${clue(g, session.target)}` ) .join(""); const historySection = session.history.length > 0 ? `${historyRows}
#GuessHintTemperature
` : ""; const playAgainBtn = `
`; let formSection = ""; if (!session.won && !session.lost) { formSection = `
`; } let statusSection = ""; if (session.won) { statusSection = `

🎉 You won! The number was ${session.target}. You used ${session.attempts} attempt${session.attempts === 1 ? "" : "s"}.

${playAgainBtn}`; } else if (session.lost) { statusSection = `

💀 Game over! The number was ${session.target}.

${playAgainBtn}`; } else { statusSection = `

Attempts left: ${attemptsLeft}

`; } const messageHtml = message ? `

${message}

` : ""; // ── "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 ? `

Your best

${statsPlayed}Played
${statsWon}Won
${statsLost}Lost
${statsBestAttempts}Best attempts
${statsCurStreak}Streak
${statsBestStreak}Best streak
` : ""; // ── 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 `${i + 1}${rowPrefix}${meLabel}${row.best_attempts}${row.games_won}${date}`; }) .join(""); scoreboardHtml = `

Scoreboard

${rows}
#PlayerBestWinsLast played
`; } else if (pool) { scoreboardHtml = `

Scoreboard

No scores yet — win a game to appear here!

`; } return ` Number Guessing Game

🔢 Number Guessing Game

I'm thinking of a number between 1 and 100. You have ${MAX_ATTEMPTS} attempts.

${statusSection} ${messageHtml} ${formSection} ${historySection} ${statsPanel} ${scoreboardHtml}
`; } // ─── Utilities ─────────────────────────────────────────────────────────────── function parseBody(req) { return new Promise((resolve) => { let body = ""; req.on("data", (chunk) => (body += chunk)); req.on("end", () => { const params = new URLSearchParams(body); resolve(Object.fromEntries(params.entries())); }); }); } /** * 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 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 === "/") { 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, 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) { // Ignore guesses after game ends } else if (!Number.isInteger(guess) || guess < 1 || guess > 100) { message = "Please enter a number between 1 and 100."; } else { session.attempts++; session.history.push(guess); if (guess === session.target) { session.won = true; } 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(), ]); setCookies(res, id, pid); res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); res.end(renderPage(session, message, guess, pid, playerStats, scoreboard)); return; } if (req.method === "POST" && url === "/reset") { const fresh = newGame(); sessions.set(id, fresh); setCookies(res, id, pid); res.writeHead(303, { Location: "/" }); res.end(); return; } res.writeHead(404, { "Content-Type": "text/plain" }); res.end("Not found"); }); server.listen(PORT, () => { console.log(`txt-game listening on port ${PORT}`); });