Small text-based online game — a HAL mesh module
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

250 line
7.6KB

  1. /**
  2. * txt-game — number guessing game
  3. * Single-file Node.js HTTP server using only stdlib.
  4. * Listens on port 3000, serves HTML form UI, maintains per-session state via cookie.
  5. */
  6. import { createServer } from "node:http";
  7. import { randomUUID } from "node:crypto";
  8. const PORT = 3000;
  9. const MAX_ATTEMPTS = 7;
  10. /** @type {Map<string, { target: number, attempts: number, won: boolean, lost: boolean, history: number[] }>} */
  11. const sessions = new Map();
  12. function newGame() {
  13. return {
  14. target: Math.floor(Math.random() * 100) + 1,
  15. attempts: 0,
  16. won: false,
  17. lost: false,
  18. history: [],
  19. };
  20. }
  21. function getSession(cookies) {
  22. const match = (cookies || "").match(/sid=([a-f0-9-]{36})/);
  23. if (match) {
  24. const id = match[1];
  25. if (sessions.has(id)) return { id, session: sessions.get(id) };
  26. }
  27. const id = randomUUID();
  28. const session = newGame();
  29. sessions.set(id, session);
  30. return { id, session };
  31. }
  32. function hint(guess, target) {
  33. if (guess < target) return "Too low ↑";
  34. if (guess > target) return "Too high ↓";
  35. return "Correct!";
  36. }
  37. function clue(guess, target) {
  38. const diff = Math.abs(guess - target);
  39. if (diff === 0) return "🎯 Spot on!";
  40. if (diff <= 5) return "🔥 Very hot";
  41. if (diff <= 15) return "♨️ Warm";
  42. if (diff <= 30) return "🌡️ Cool";
  43. return "🧊 Cold";
  44. }
  45. function renderPage(session, message, guess) {
  46. const attemptsLeft = MAX_ATTEMPTS - session.attempts;
  47. const historyRows = session.history
  48. .map(
  49. (g, i) =>
  50. `<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>`
  51. )
  52. .join("");
  53. const historySection =
  54. session.history.length > 0
  55. ? `<table>
  56. <thead><tr><th>#</th><th>Guess</th><th>Hint</th><th>Temperature</th></tr></thead>
  57. <tbody>${historyRows}</tbody>
  58. </table>`
  59. : "";
  60. const playAgainBtn = `<form method="POST" action="/reset"><button type="submit">Play again</button></form>`;
  61. let formSection = "";
  62. if (!session.won && !session.lost) {
  63. formSection = `
  64. <form method="POST" action="/guess">
  65. <label for="guess">Enter a number (1–100):</label>
  66. <input id="guess" name="guess" type="number" min="1" max="100" required autofocus />
  67. <button type="submit">Guess</button>
  68. </form>`;
  69. }
  70. let statusSection = "";
  71. if (session.won) {
  72. statusSection = `<p class="success">🎉 You won! The number was <strong>${session.target}</strong>. You used ${session.attempts} attempt${session.attempts === 1 ? "" : "s"}.</p>${playAgainBtn}`;
  73. } else if (session.lost) {
  74. statusSection = `<p class="failure">💀 Game over! The number was <strong>${session.target}</strong>.</p>${playAgainBtn}`;
  75. } else {
  76. statusSection = `<p class="attempts">Attempts left: <strong>${attemptsLeft}</strong></p>`;
  77. }
  78. const messageHtml = message
  79. ? `<p class="message">${message}</p>`
  80. : "";
  81. return `<!DOCTYPE html>
  82. <html lang="en">
  83. <head>
  84. <meta charset="UTF-8" />
  85. <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  86. <title>Number Guessing Game</title>
  87. <style>
  88. *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
  89. body {
  90. font-family: 'Courier New', Courier, monospace;
  91. background: #0d0d0d;
  92. color: #e0e0e0;
  93. min-height: 100vh;
  94. display: flex;
  95. align-items: center;
  96. justify-content: center;
  97. padding: 2rem;
  98. }
  99. .card {
  100. background: #1a1a1a;
  101. border: 1px solid #333;
  102. border-radius: 8px;
  103. padding: 2rem;
  104. max-width: 600px;
  105. width: 100%;
  106. }
  107. h1 { color: #7ec8e3; margin-bottom: 0.5rem; font-size: 1.6rem; }
  108. p.subtitle { color: #888; margin-bottom: 1.5rem; font-size: 0.9rem; }
  109. form { display: flex; gap: 0.75rem; align-items: center; margin-bottom: 1rem; flex-wrap: wrap; }
  110. input[type="number"] {
  111. background: #111;
  112. border: 1px solid #444;
  113. color: #e0e0e0;
  114. padding: 0.5rem 0.75rem;
  115. border-radius: 4px;
  116. font-family: inherit;
  117. font-size: 1rem;
  118. width: 120px;
  119. }
  120. button {
  121. background: #2a5c8a;
  122. color: #e0e0e0;
  123. border: none;
  124. padding: 0.5rem 1.25rem;
  125. border-radius: 4px;
  126. font-family: inherit;
  127. font-size: 1rem;
  128. cursor: pointer;
  129. }
  130. button:hover { background: #3a7cbd; }
  131. .message { color: #f0c040; margin: 0.75rem 0; }
  132. .success { color: #5dbb63; margin: 0.75rem 0; font-weight: bold; }
  133. .failure { color: #e05252; margin: 0.75rem 0; font-weight: bold; }
  134. .attempts { color: #aaa; margin: 0.75rem 0; }
  135. label { color: #aaa; font-size: 0.9rem; display: block; margin-bottom: 0.5rem; }
  136. table { width: 100%; border-collapse: collapse; margin-top: 1rem; font-size: 0.9rem; }
  137. th, td { text-align: left; padding: 0.4rem 0.6rem; border-bottom: 1px solid #2a2a2a; }
  138. th { color: #7ec8e3; }
  139. td:first-child { color: #666; }
  140. td:nth-child(2) { font-weight: bold; color: #e0e0e0; }
  141. </style>
  142. </head>
  143. <body>
  144. <div class="card">
  145. <h1>🔢 Number Guessing Game</h1>
  146. <p class="subtitle">I'm thinking of a number between 1 and 100. You have ${MAX_ATTEMPTS} attempts.</p>
  147. ${statusSection}
  148. ${messageHtml}
  149. ${formSection}
  150. ${historySection}
  151. </div>
  152. </body>
  153. </html>`;
  154. }
  155. function parseBody(req) {
  156. return new Promise((resolve) => {
  157. let body = "";
  158. req.on("data", (chunk) => (body += chunk));
  159. req.on("end", () => {
  160. const params = new URLSearchParams(body);
  161. resolve(Object.fromEntries(params.entries()));
  162. });
  163. });
  164. }
  165. function setCookie(res, id) {
  166. res.setHeader(
  167. "Set-Cookie",
  168. `sid=${id}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400`
  169. );
  170. }
  171. const server = createServer(async (req, res) => {
  172. // Forward x-forwarded-host so URLs resolve correctly behind Traefik
  173. const fwdHost = req.headers["x-forwarded-host"];
  174. if (fwdHost) req.headers.host = fwdHost;
  175. const { id, session } = getSession(req.headers["cookie"]);
  176. const url = req.url.split("?")[0];
  177. if ((req.method === "GET" || req.method === "HEAD") && url === "/") {
  178. setCookie(res, id);
  179. res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
  180. res.end(req.method === "HEAD" ? "" : renderPage(session, "", null));
  181. return;
  182. }
  183. if (req.method === "POST" && url === "/guess") {
  184. const body = await parseBody(req);
  185. const guess = parseInt(body.guess, 10);
  186. let message = "";
  187. if (session.won || session.lost) {
  188. // Ignore guesses after game ends
  189. } else if (!Number.isInteger(guess) || guess < 1 || guess > 100) {
  190. message = "Please enter a number between 1 and 100.";
  191. } else {
  192. session.attempts++;
  193. session.history.push(guess);
  194. if (guess === session.target) {
  195. session.won = true;
  196. // Fix last history row hint (show "Correct!")
  197. } else if (session.attempts >= MAX_ATTEMPTS) {
  198. session.lost = true;
  199. message = `${hint(guess, session.target)} — ${clue(guess, session.target)}`;
  200. } else {
  201. message = `${hint(guess, session.target)} — ${clue(guess, session.target)}`;
  202. }
  203. }
  204. setCookie(res, id);
  205. res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
  206. res.end(renderPage(session, message, guess));
  207. return;
  208. }
  209. if (req.method === "POST" && url === "/reset") {
  210. const fresh = newGame();
  211. sessions.set(id, fresh);
  212. setCookie(res, id);
  213. res.writeHead(303, { Location: "/" });
  214. res.end();
  215. return;
  216. }
  217. res.writeHead(404, { "Content-Type": "text/plain" });
  218. res.end("Not found");
  219. });
  220. server.listen(PORT, () => {
  221. console.log(`txt-game listening on port ${PORT}`);
  222. });