Files
txt-game/server.mjs
T
jschoubben 737152d88b txt-game: become a nox mesh module
The mesh builds the game from this repository and a commit (novox/hq ADR
0069); registry-api.novox.be, where HAL's image came from, is retired, so
the running container can no longer be re-pulled. The HAL module.yml,
docker-compose.yml and migrations/ beside this manifest retire with HAL.

The score database is a postgres-database grant: host, port, login and
database come from the binding (in a file the container reads as its
environment), the password from a 0600 file named by
TXT_GAME_DB_PASSWORD_FILE (ADR 0086). TXT_GAME_DB_PASSWORD still works,
so HAL's deployment keeps running if it is rebuilt from main.

The app now creates its schema when the database has none. HAL's
provisioning migrations did that as the admin and then granted the app's
login; under the mesh the app's own login owns its database, so the app
makes its tables itself. A database that already has both tables - HAL's
or one restored from it - is left exactly as it is.

The base is node 22.23.2-alpine3.24 by digest, the image the running one
was built on (its base layers match).

Verified: the manifest parses on mesh-controller main and #149 and
renders for a zurag.be node. Built from this commit, against throwaway
postgres: on an empty granted database it creates both tables (owned by
the granted login) and records a game; on a HAL-shaped database (tables
owned by postgres, grants to the old login, password in the environment)
it plays and creates nothing; and a pg_dump/pg_restore --no-owner copy of
that database into a fresh granted one keeps every row (counts and md5
identical), is owned by the grant, and keeps taking games.
2026-09-30 12:05:34 +02:00

623 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 { readFileSync } from "node:fs";
import pg from "pg";
const PORT = 3000;
const MAX_ATTEMPTS = 7;
// ─── DB Pool ────────────────────────────────────────────────────────────────
// Where the database is comes from the environment; the password comes from a file when
// TXT_GAME_DB_PASSWORD_FILE names one (how the mesh delivers a secret — novox/hq ADR 0086: a
// secret in the environment is readable by anything that can inspect the container), and
// from TXT_GAME_DB_PASSWORD otherwise, which is how HAL still delivers it.
const DB_ENV_KEYS = [
"TXT_GAME_DB_HOST",
"TXT_GAME_DB_PORT",
"TXT_GAME_DB_USER",
"TXT_GAME_DB_NAME",
];
/** @returns {string | undefined} */
function dbPassword() {
const file = process.env.TXT_GAME_DB_PASSWORD_FILE;
if (file) {
try {
return readFileSync(file, "utf8").replace(/\r?\n$/, "") || undefined;
} catch (err) {
console.error(`[txt-game] cannot read TXT_GAME_DB_PASSWORD_FILE: ${err.message}`);
return undefined;
}
}
return process.env.TXT_GAME_DB_PASSWORD || undefined;
}
const password = dbPassword();
const hasDb = DB_ENV_KEYS.every((k) => process.env[k]) && password !== undefined;
/** @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,
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 settings missing — running in degraded mode (scores disabled)"
);
}
// ─── Schema ─────────────────────────────────────────────────────────────────
/**
* Create the score tables when the database has none — what HAL's provisioning migrations
* (migrations/provision/postgres) did as the admin before the app first started. Under the mesh
* the app's own login owns its database, so the app makes its schema itself. A database that
* already has both tables (HAL's, or one restored from it) is left exactly as it is: nothing
* here alters or grants on existing tables.
*/
async function ensureSchema() {
if (!pool) return;
try {
const { rows } = await pool.query(
"SELECT to_regclass('public.players') IS NOT NULL AS players, to_regclass('public.games') IS NOT NULL AS games"
);
if (rows[0].players && rows[0].games) return;
await pool.query(`
CREATE TABLE IF NOT EXISTS players (
id TEXT PRIMARY KEY,
games_played INT NOT NULL DEFAULT 0,
games_won INT NOT NULL DEFAULT 0,
games_lost INT NOT NULL DEFAULT 0,
best_attempts INT,
current_streak INT NOT NULL DEFAULT 0,
best_streak INT NOT NULL DEFAULT 0,
last_played_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS games (
id BIGSERIAL PRIMARY KEY,
player_id TEXT NOT NULL REFERENCES players(id) ON DELETE CASCADE,
target INT NOT NULL,
attempts INT NOT NULL,
won BOOLEAN NOT NULL,
finished_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
`);
await pool.query(`
CREATE INDEX IF NOT EXISTS idx_players_scoreboard
ON players (best_attempts NULLS LAST, last_played_at)
`);
await pool.query(`
CREATE INDEX IF NOT EXISTS idx_games_player_history
ON games (player_id, finished_at DESC)
`);
console.log("[txt-game] score schema created");
} catch (err) {
console.error("[txt-game] could not ensure the score schema:", err.message);
}
}
// ─── 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");
});
await ensureSchema();
server.listen(PORT, () => {
console.log(`txt-game listening on port ${PORT}`);
});