|
- /**
- * 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<string, Session>}
- */
- 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<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 ↓";
- 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) =>
- `<tr><td>${i + 1}</td><td>${g}</td><td>${hint(g, session.won && i === session.history.length - 1 ? g : session.target)}</td><td>${clue(g, session.target)}</td></tr>`
- )
- .join("");
-
- const historySection =
- session.history.length > 0
- ? `<table>
- <thead><tr><th>#</th><th>Guess</th><th>Hint</th><th>Temperature</th></tr></thead>
- <tbody>${historyRows}</tbody>
- </table>`
- : "";
-
- const playAgainBtn = `<form method="POST" action="/reset"><button type="submit">Play again</button></form>`;
-
- let formSection = "";
- if (!session.won && !session.lost) {
- formSection = `
- <form method="POST" action="/guess">
- <label for="guess">Enter a number (1–100):</label>
- <input id="guess" name="guess" type="number" min="1" max="100" required autofocus />
- <button type="submit">Guess</button>
- </form>`;
- }
-
- let statusSection = "";
- if (session.won) {
- statusSection = `<p class="success">🎉 You won! The number was <strong>${session.target}</strong>. You used ${session.attempts} attempt${session.attempts === 1 ? "" : "s"}.</p>${playAgainBtn}`;
- } else if (session.lost) {
- statusSection = `<p class="failure">💀 Game over! The number was <strong>${session.target}</strong>.</p>${playAgainBtn}`;
- } else {
- statusSection = `<p class="attempts">Attempts left: <strong>${attemptsLeft}</strong></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>
- <meta charset="UTF-8" />
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
- <title>Number Guessing Game</title>
- <style>
- *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
- body {
- font-family: 'Courier New', Courier, monospace;
- background: #0d0d0d;
- color: #e0e0e0;
- min-height: 100vh;
- display: flex;
- align-items: flex-start;
- justify-content: center;
- padding: 2rem;
- }
- .card {
- background: #1a1a1a;
- border: 1px solid #333;
- border-radius: 8px;
- padding: 2rem;
- max-width: 600px;
- 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"] {
- background: #111;
- border: 1px solid #444;
- color: #e0e0e0;
- padding: 0.5rem 0.75rem;
- border-radius: 4px;
- font-family: inherit;
- font-size: 1rem;
- width: 120px;
- }
- button {
- background: #2a5c8a;
- color: #e0e0e0;
- border: none;
- padding: 0.5rem 1.25rem;
- border-radius: 4px;
- font-family: inherit;
- font-size: 1rem;
- cursor: pointer;
- }
- button:hover { background: #3a7cbd; }
- .message { color: #f0c040; margin: 0.75rem 0; }
- .success { color: #5dbb63; margin: 0.75rem 0; font-weight: bold; }
- .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: 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>
- <div class="card">
- <h1>🔢 Number Guessing Game</h1>
- <p class="subtitle">I'm thinking of a number between 1 and 100. You have ${MAX_ATTEMPTS} attempts.</p>
- ${statusSection}
- ${messageHtml}
- ${formSection}
- ${historySection}
- ${statsPanel}
- ${scoreboardHtml}
- </div>
- </body>
- </html>`;
- }
-
- // ─── 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}`);
- });
|