Small text-based online game — a HAL mesh module
25개 이상의 토픽을 선택하실 수 없습니다. Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

548 lines
19KB

  1. /**
  2. * txt-game — number guessing game
  3. * Single-file Node.js HTTP server using only stdlib + pg.
  4. * Listens on port 3000, serves HTML form UI, maintains per-session state via cookie.
  5. * Persists scores to Postgres when DB env vars are present; degrades gracefully otherwise.
  6. */
  7. import { createServer } from "node:http";
  8. import { randomUUID } from "node:crypto";
  9. import pg from "pg";
  10. const PORT = 3000;
  11. const MAX_ATTEMPTS = 7;
  12. // ─── DB Pool ────────────────────────────────────────────────────────────────
  13. const DB_ENV_KEYS = [
  14. "TXT_GAME_DB_HOST",
  15. "TXT_GAME_DB_PORT",
  16. "TXT_GAME_DB_USER",
  17. "TXT_GAME_DB_PASSWORD",
  18. "TXT_GAME_DB_NAME",
  19. ];
  20. const hasDb = DB_ENV_KEYS.every((k) => process.env[k]);
  21. /** @type {pg.Pool | null} */
  22. let pool = null;
  23. if (hasDb) {
  24. pool = new pg.Pool({
  25. host: process.env.TXT_GAME_DB_HOST,
  26. port: parseInt(process.env.TXT_GAME_DB_PORT ?? "5432", 10),
  27. user: process.env.TXT_GAME_DB_USER,
  28. password: process.env.TXT_GAME_DB_PASSWORD,
  29. database: process.env.TXT_GAME_DB_NAME,
  30. max: 5,
  31. idleTimeoutMillis: 30000,
  32. });
  33. pool.on("error", (err) => {
  34. console.error("[txt-game] pg pool error:", err.message);
  35. });
  36. console.log(
  37. `[txt-game] DB pool initialised → ${process.env.TXT_GAME_DB_HOST}:${process.env.TXT_GAME_DB_PORT}/${process.env.TXT_GAME_DB_NAME}`
  38. );
  39. } else {
  40. console.warn(
  41. "[txt-game] DB env vars missing — running in degraded mode (scores disabled)"
  42. );
  43. }
  44. // ─── Session Store ──────────────────────────────────────────────────────────
  45. /**
  46. * @typedef {{ target: number, attempts: number, won: boolean, lost: boolean, history: number[] }} Session
  47. * @type {Map<string, Session>}
  48. */
  49. const sessions = new Map();
  50. function newGame() {
  51. return {
  52. target: Math.floor(Math.random() * 100) + 1,
  53. attempts: 0,
  54. won: false,
  55. lost: false,
  56. history: [],
  57. };
  58. }
  59. /**
  60. * Get or create the 24 h session keyed by the `sid` cookie.
  61. * @param {string | undefined} cookies
  62. * @returns {{ id: string, session: Session }}
  63. */
  64. function getSession(cookies) {
  65. const match = (cookies ?? "").match(/sid=([a-f0-9-]{36})/);
  66. if (match) {
  67. const id = match[1];
  68. if (sessions.has(id)) return { id, session: /** @type {Session} */ (sessions.get(id)) };
  69. }
  70. const id = randomUUID();
  71. const session = newGame();
  72. sessions.set(id, session);
  73. return { id, session };
  74. }
  75. /**
  76. * Extract the persistent player ID from cookies, or generate a fresh UUID.
  77. * The caller is responsible for setting a new `pid` cookie when the value is new.
  78. * @param {string | undefined} cookies
  79. * @returns {string}
  80. */
  81. function getPid(cookies) {
  82. const match = (cookies ?? "").match(/pid=([a-f0-9-]{36})/);
  83. return match ? match[1] : randomUUID();
  84. }
  85. // ─── DB Helpers ─────────────────────────────────────────────────────────────
  86. /**
  87. * Insert a player row if it doesn't exist yet. Silently no-ops when DB is unavailable.
  88. * @param {string} pid
  89. */
  90. async function upsertPlayer(pid) {
  91. if (!pool) return;
  92. try {
  93. await pool.query(
  94. `INSERT INTO players (id) VALUES ($1) ON CONFLICT (id) DO NOTHING`,
  95. [pid]
  96. );
  97. } catch (err) {
  98. console.error("[txt-game] upsertPlayer error:", /** @type {Error} */ (err).message);
  99. }
  100. }
  101. /**
  102. * Transactionally record a finished round: insert into games + update player aggregates.
  103. * Logs + rolls back on any DB error — never propagates.
  104. * @param {string} pid
  105. * @param {Session} session
  106. */
  107. async function recordRoundEnd(pid, session) {
  108. if (!pool) return;
  109. const client = await pool.connect().catch((err) => {
  110. console.error("[txt-game] recordRoundEnd connect error:", /** @type {Error} */ (err).message);
  111. return null;
  112. });
  113. if (!client) return;
  114. try {
  115. await client.query("BEGIN");
  116. await client.query(
  117. `INSERT INTO games (player_id, target, attempts, won) VALUES ($1, $2, $3, $4)`,
  118. [pid, session.target, session.attempts, session.won]
  119. );
  120. if (session.won) {
  121. // Update win aggregates; best_attempts is only set/updated on wins.
  122. await client.query(
  123. `UPDATE players SET
  124. games_played = games_played + 1,
  125. games_won = games_won + 1,
  126. best_attempts = LEAST(COALESCE(best_attempts, $1::int), $1::int),
  127. current_streak = current_streak + 1,
  128. best_streak = GREATEST(best_streak, current_streak + 1),
  129. last_played_at = now()
  130. WHERE id = $2`,
  131. [session.attempts, pid]
  132. );
  133. } else {
  134. // Loss: reset streak, leave best_attempts unchanged.
  135. await client.query(
  136. `UPDATE players SET
  137. games_played = games_played + 1,
  138. games_lost = games_lost + 1,
  139. current_streak = 0,
  140. last_played_at = now()
  141. WHERE id = $1`,
  142. [pid]
  143. );
  144. }
  145. await client.query("COMMIT");
  146. } catch (err) {
  147. console.error("[txt-game] recordRoundEnd error:", /** @type {Error} */ (err).message);
  148. try {
  149. await client.query("ROLLBACK");
  150. } catch {
  151. // ignore rollback error
  152. }
  153. } finally {
  154. client.release();
  155. }
  156. }
  157. /**
  158. * Fetch the current player's stats row. Returns null when DB is unavailable or player not found.
  159. * @param {string} pid
  160. * @returns {Promise<{games_played:number, games_won:number, games_lost:number, best_attempts:number|null, current_streak:number, best_streak:number} | null>}
  161. */
  162. async function fetchPlayerStats(pid) {
  163. if (!pool) return null;
  164. try {
  165. const result = await pool.query(
  166. `SELECT games_played, games_won, games_lost, best_attempts, current_streak, best_streak
  167. FROM players WHERE id = $1`,
  168. [pid]
  169. );
  170. return result.rows[0] ?? null;
  171. } catch (err) {
  172. console.error("[txt-game] fetchPlayerStats error:", /** @type {Error} */ (err).message);
  173. return null;
  174. }
  175. }
  176. /**
  177. * Fetch the global top-10 scoreboard ordered by fewest wins-required attempts, then earliest last_played.
  178. * Excludes players who have never won (best_attempts IS NULL).
  179. * @returns {Promise<Array<{id:string, best_attempts:number, games_won:number, last_played_at:Date}>>}
  180. */
  181. async function fetchScoreboard() {
  182. if (!pool) return [];
  183. try {
  184. const result = await pool.query(
  185. `SELECT id, best_attempts, games_won, last_played_at
  186. FROM players
  187. WHERE best_attempts IS NOT NULL
  188. ORDER BY best_attempts ASC, last_played_at ASC
  189. LIMIT 10`
  190. );
  191. return result.rows;
  192. } catch (err) {
  193. console.error("[txt-game] fetchScoreboard error:", /** @type {Error} */ (err).message);
  194. return [];
  195. }
  196. }
  197. // ─── Rendering ──────────────────────────────────────────────────────────────
  198. function hint(guess, target) {
  199. if (guess < target) return "Too low ↑";
  200. if (guess > target) return "Too high ↓";
  201. return "Correct!";
  202. }
  203. function clue(guess, target) {
  204. const diff = Math.abs(guess - target);
  205. if (diff === 0) return "🎯 Spot on!";
  206. if (diff <= 5) return "🔥 Very hot";
  207. if (diff <= 15) return "♨️ Warm";
  208. if (diff <= 30) return "🌡️ Cool";
  209. return "🧊 Cold";
  210. }
  211. /**
  212. * @param {Session} session
  213. * @param {string} message
  214. * @param {number | null} guess
  215. * @param {string} pid
  216. * @param {{games_played:number, games_won:number, games_lost:number, best_attempts:number|null, current_streak:number, best_streak:number} | null} playerStats
  217. * @param {Array<{id:string, best_attempts:number, games_won:number, last_played_at:Date}>} scoreboard
  218. */
  219. function renderPage(session, message, guess, pid, playerStats, scoreboard) {
  220. const attemptsLeft = MAX_ATTEMPTS - session.attempts;
  221. const historyRows = session.history
  222. .map(
  223. (g, i) =>
  224. `<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>`
  225. )
  226. .join("");
  227. const historySection =
  228. session.history.length > 0
  229. ? `<table>
  230. <thead><tr><th>#</th><th>Guess</th><th>Hint</th><th>Temperature</th></tr></thead>
  231. <tbody>${historyRows}</tbody>
  232. </table>`
  233. : "";
  234. const playAgainBtn = `<form method="POST" action="/reset"><button type="submit">Play again</button></form>`;
  235. let formSection = "";
  236. if (!session.won && !session.lost) {
  237. formSection = `
  238. <form method="POST" action="/guess">
  239. <label for="guess">Enter a number (1–100):</label>
  240. <input id="guess" name="guess" type="number" min="1" max="100" required autofocus />
  241. <button type="submit">Guess</button>
  242. </form>`;
  243. }
  244. let statusSection = "";
  245. if (session.won) {
  246. statusSection = `<p class="success">🎉 You won! The number was <strong>${session.target}</strong>. You used ${session.attempts} attempt${session.attempts === 1 ? "" : "s"}.</p>${playAgainBtn}`;
  247. } else if (session.lost) {
  248. statusSection = `<p class="failure">💀 Game over! The number was <strong>${session.target}</strong>.</p>${playAgainBtn}`;
  249. } else {
  250. statusSection = `<p class="attempts">Attempts left: <strong>${attemptsLeft}</strong></p>`;
  251. }
  252. const messageHtml = message ? `<p class="message">${message}</p>` : "";
  253. // ── "Your best" stats panel ──
  254. const sp = playerStats;
  255. const statsBestAttempts = sp?.best_attempts != null ? String(sp.best_attempts) : "—";
  256. const statsPlayed = sp ? String(sp.games_played) : "0";
  257. const statsWon = sp ? String(sp.games_won) : "0";
  258. const statsLost = sp ? String(sp.games_lost) : "0";
  259. const statsCurStreak = sp ? String(sp.current_streak) : "0";
  260. const statsBestStreak = sp ? String(sp.best_streak) : "0";
  261. const statsPanel = pool
  262. ? `<div class="stats-panel">
  263. <h2>Your best</h2>
  264. <div class="stats-grid">
  265. <div class="stat"><span class="stat-val">${statsPlayed}</span><span class="stat-lbl">Played</span></div>
  266. <div class="stat"><span class="stat-val">${statsWon}</span><span class="stat-lbl">Won</span></div>
  267. <div class="stat"><span class="stat-val">${statsLost}</span><span class="stat-lbl">Lost</span></div>
  268. <div class="stat"><span class="stat-val">${statsBestAttempts}</span><span class="stat-lbl">Best attempts</span></div>
  269. <div class="stat"><span class="stat-val">${statsCurStreak}</span><span class="stat-lbl">Streak</span></div>
  270. <div class="stat"><span class="stat-val">${statsBestStreak}</span><span class="stat-lbl">Best streak</span></div>
  271. </div>
  272. </div>`
  273. : "";
  274. // ── Scoreboard ──
  275. let scoreboardHtml = "";
  276. if (pool && scoreboard.length > 0) {
  277. const pidPrefix = pid.slice(0, 8);
  278. const rows = scoreboard
  279. .map((row, i) => {
  280. const rowPrefix = row.id.slice(0, 8);
  281. const isMe = row.id === pid;
  282. const cls = isMe ? ` class="me"` : "";
  283. const meLabel = isMe ? " ◀" : "";
  284. const date = row.last_played_at
  285. ? new Date(row.last_played_at).toLocaleDateString(undefined, { month: "short", day: "numeric" })
  286. : "—";
  287. 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>`;
  288. })
  289. .join("");
  290. scoreboardHtml = `<div class="scoreboard">
  291. <h2>Scoreboard</h2>
  292. <table>
  293. <thead><tr><th>#</th><th>Player</th><th>Best</th><th>Wins</th><th>Last played</th></tr></thead>
  294. <tbody>${rows}</tbody>
  295. </table>
  296. </div>`;
  297. } else if (pool) {
  298. scoreboardHtml = `<div class="scoreboard"><h2>Scoreboard</h2><p class="no-scores">No scores yet — win a game to appear here!</p></div>`;
  299. }
  300. return `<!DOCTYPE html>
  301. <html lang="en">
  302. <head>
  303. <meta charset="UTF-8" />
  304. <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  305. <title>Number Guessing Game</title>
  306. <style>
  307. *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
  308. body {
  309. font-family: 'Courier New', Courier, monospace;
  310. background: #0d0d0d;
  311. color: #e0e0e0;
  312. min-height: 100vh;
  313. display: flex;
  314. align-items: flex-start;
  315. justify-content: center;
  316. padding: 2rem;
  317. }
  318. .card {
  319. background: #1a1a1a;
  320. border: 1px solid #333;
  321. border-radius: 8px;
  322. padding: 2rem;
  323. max-width: 600px;
  324. width: 100%;
  325. }
  326. h1 { color: #7ec8e3; margin-bottom: 0.5rem; font-size: 1.6rem; }
  327. h2 { color: #7ec8e3; font-size: 1rem; margin-bottom: 0.75rem; margin-top: 0; }
  328. p.subtitle { color: #888; margin-bottom: 1.5rem; font-size: 0.9rem; }
  329. form { display: flex; gap: 0.75rem; align-items: center; margin-bottom: 1rem; flex-wrap: wrap; }
  330. input[type="number"] {
  331. background: #111;
  332. border: 1px solid #444;
  333. color: #e0e0e0;
  334. padding: 0.5rem 0.75rem;
  335. border-radius: 4px;
  336. font-family: inherit;
  337. font-size: 1rem;
  338. width: 120px;
  339. }
  340. button {
  341. background: #2a5c8a;
  342. color: #e0e0e0;
  343. border: none;
  344. padding: 0.5rem 1.25rem;
  345. border-radius: 4px;
  346. font-family: inherit;
  347. font-size: 1rem;
  348. cursor: pointer;
  349. }
  350. button:hover { background: #3a7cbd; }
  351. .message { color: #f0c040; margin: 0.75rem 0; }
  352. .success { color: #5dbb63; margin: 0.75rem 0; font-weight: bold; }
  353. .failure { color: #e05252; margin: 0.75rem 0; font-weight: bold; }
  354. .attempts { color: #aaa; margin: 0.75rem 0; }
  355. label { color: #aaa; font-size: 0.9rem; display: block; margin-bottom: 0.5rem; }
  356. table { width: 100%; border-collapse: collapse; margin-top: 0.5rem; font-size: 0.9rem; }
  357. th, td { text-align: left; padding: 0.4rem 0.6rem; border-bottom: 1px solid #2a2a2a; }
  358. th { color: #7ec8e3; }
  359. td:first-child { color: #666; }
  360. td:nth-child(2) { font-weight: bold; color: #e0e0e0; }
  361. .divider { border: none; border-top: 1px solid #2a2a2a; margin: 1.5rem 0; }
  362. /* Stats panel */
  363. .stats-panel { margin-top: 1.5rem; }
  364. .stats-grid {
  365. display: grid;
  366. grid-template-columns: repeat(3, 1fr);
  367. gap: 0.75rem;
  368. }
  369. .stat {
  370. background: #111;
  371. border: 1px solid #2a2a2a;
  372. border-radius: 6px;
  373. padding: 0.6rem 0.75rem;
  374. display: flex;
  375. flex-direction: column;
  376. align-items: center;
  377. }
  378. .stat-val { font-size: 1.4rem; font-weight: bold; color: #7ec8e3; }
  379. .stat-lbl { font-size: 0.75rem; color: #666; margin-top: 0.2rem; }
  380. /* Scoreboard */
  381. .scoreboard { margin-top: 1.5rem; }
  382. .scoreboard table td:nth-child(2) { color: #aaa; font-weight: normal; font-family: monospace; font-size: 0.85rem; }
  383. .scoreboard table tr.me td { background: #1e2d1e; }
  384. .scoreboard table tr.me td:nth-child(2) { color: #5dbb63; }
  385. .no-scores { color: #555; font-size: 0.9rem; }
  386. </style>
  387. </head>
  388. <body>
  389. <div class="card">
  390. <h1>🔢 Number Guessing Game</h1>
  391. <p class="subtitle">I'm thinking of a number between 1 and 100. You have ${MAX_ATTEMPTS} attempts.</p>
  392. ${statusSection}
  393. ${messageHtml}
  394. ${formSection}
  395. ${historySection}
  396. ${statsPanel}
  397. ${scoreboardHtml}
  398. </div>
  399. </body>
  400. </html>`;
  401. }
  402. // ─── Utilities ───────────────────────────────────────────────────────────────
  403. function parseBody(req) {
  404. return new Promise((resolve) => {
  405. let body = "";
  406. req.on("data", (chunk) => (body += chunk));
  407. req.on("end", () => {
  408. const params = new URLSearchParams(body);
  409. resolve(Object.fromEntries(params.entries()));
  410. });
  411. });
  412. }
  413. /**
  414. * Set both the session cookie (24 h) and the persistent player cookie (365 d).
  415. * @param {import("node:http").ServerResponse} res
  416. * @param {string} sid
  417. * @param {string} pid
  418. */
  419. function setCookies(res, sid, pid) {
  420. res.setHeader("Set-Cookie", [
  421. `sid=${sid}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400`,
  422. `pid=${pid}; Path=/; HttpOnly; SameSite=Strict; Max-Age=31536000`,
  423. ]);
  424. }
  425. // ─── Server ──────────────────────────────────────────────────────────────────
  426. const server = createServer(async (req, res) => {
  427. // Forward x-forwarded-host so URLs resolve correctly behind Traefik
  428. const fwdHost = req.headers["x-forwarded-host"];
  429. if (fwdHost) req.headers.host = fwdHost;
  430. const cookies = req.headers["cookie"];
  431. const { id, session } = getSession(cookies);
  432. const pid = getPid(cookies);
  433. const url = (req.url ?? "/").split("?")[0];
  434. // Ensure a players row exists for this visitor (idempotent)
  435. await upsertPlayer(pid);
  436. if ((req.method === "GET" || req.method === "HEAD") && url === "/") {
  437. const [playerStats, scoreboard] = await Promise.all([
  438. fetchPlayerStats(pid),
  439. fetchScoreboard(),
  440. ]);
  441. setCookies(res, id, pid);
  442. res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
  443. res.end(
  444. req.method === "HEAD"
  445. ? ""
  446. : renderPage(session, "", null, pid, playerStats, scoreboard)
  447. );
  448. return;
  449. }
  450. if (req.method === "POST" && url === "/guess") {
  451. const body = await parseBody(req);
  452. const guess = parseInt(body.guess, 10);
  453. let message = "";
  454. if (session.won || session.lost) {
  455. // Ignore guesses after game ends
  456. } else if (!Number.isInteger(guess) || guess < 1 || guess > 100) {
  457. message = "Please enter a number between 1 and 100.";
  458. } else {
  459. session.attempts++;
  460. session.history.push(guess);
  461. if (guess === session.target) {
  462. session.won = true;
  463. } else if (session.attempts >= MAX_ATTEMPTS) {
  464. session.lost = true;
  465. message = `${hint(guess, session.target)} — ${clue(guess, session.target)}`;
  466. } else {
  467. message = `${hint(guess, session.target)} — ${clue(guess, session.target)}`;
  468. }
  469. // Persist completed round
  470. if (session.won || session.lost) {
  471. await recordRoundEnd(pid, session);
  472. }
  473. }
  474. const [playerStats, scoreboard] = await Promise.all([
  475. fetchPlayerStats(pid),
  476. fetchScoreboard(),
  477. ]);
  478. setCookies(res, id, pid);
  479. res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
  480. res.end(renderPage(session, message, guess, pid, playerStats, scoreboard));
  481. return;
  482. }
  483. if (req.method === "POST" && url === "/reset") {
  484. const fresh = newGame();
  485. sessions.set(id, fresh);
  486. setCookies(res, id, pid);
  487. res.writeHead(303, { Location: "/" });
  488. res.end();
  489. return;
  490. }
  491. res.writeHead(404, { "Content-Type": "text/plain" });
  492. res.end("Not found");
  493. });
  494. server.listen(PORT, () => {
  495. console.log(`txt-game listening on port ${PORT}`);
  496. });