From 17adb00cc3c992ca0f83b62c409ed67e981c9883 Mon Sep 17 00:00:00 2001 From: HAL Date: Wed, 8 Jul 2026 17:27:27 +0200 Subject: [PATCH] feat(txt-game): initial module scaffold Number guessing game (1-100, 7 attempts) as a HAL custom-app module. Serves HTML form UI with per-session state via cookie, no npm deps. Traefik labels for txt.${DOMAIN} on websecure with letsencrypt TLS. --- .gitignore | 6 ++ Dockerfile | 9 ++ README.md | 3 + docker-compose.yml | 19 ++++ module.yml | 18 ++++ server.mjs | 249 +++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 304 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 docker-compose.yml create mode 100644 module.yml create mode 100644 server.mjs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1dcef18 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +.env +.env.* +*.log +dist/ +.DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5479802 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,9 @@ +FROM node:22-alpine + +WORKDIR /app + +COPY server.mjs ./ + +EXPOSE 3000 + +CMD ["node", "server.mjs"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..9face4c --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# txt-game + +A small text-based number guessing game running as a HAL mesh module. The server picks a random number between 1 and 100, and you have 7 attempts to guess it — after each guess you receive a directional hint (too high / too low) and a temperature clue (cold → warm → hot) to help you zero in. The game runs entirely in the browser via HTML forms: no JavaScript required on the client side. Each player session is tracked by a lightweight cookie so multiple people can play concurrently without interference. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f41bb69 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,19 @@ +services: + txt-game: + build: . + restart: unless-stopped + environment: + - DOMAIN=${DOMAIN:-localhost} + labels: + - "traefik.enable=true" + - "traefik.http.routers.txt-game.rule=Host(`txt.${DOMAIN:-localhost}`)" + - "traefik.http.routers.txt-game.entrypoints=websecure" + - "traefik.http.routers.txt-game.tls.certresolver=letsencrypt" + - "traefik.http.services.txt-game.loadbalancer.server.port=3000" + networks: + - proxy + - default + +networks: + proxy: + external: true diff --git a/module.yml b/module.yml new file mode 100644 index 0000000..d10623e --- /dev/null +++ b/module.yml @@ -0,0 +1,18 @@ +manifest: 2 +name: txt-game +version: "1.0.0" +description: "Small text-based online game" + +service: + container: txt-game + +docker: + build: + context: . + dockerfile: Dockerfile + image: txt-game + +env: + DOMAIN: + from: node.domain + default: localhost diff --git a/server.mjs b/server.mjs new file mode 100644 index 0000000..2d64185 --- /dev/null +++ b/server.mjs @@ -0,0 +1,249 @@ +/** + * txt-game — number guessing game + * Single-file Node.js HTTP server using only stdlib. + * Listens on port 3000, serves HTML form UI, maintains per-session state via cookie. + */ + +import { createServer } from "node:http"; +import { randomUUID } from "node:crypto"; + +const PORT = 3000; +const MAX_ATTEMPTS = 7; + +/** @type {Map} */ +const sessions = new Map(); + +function newGame() { + return { + target: Math.floor(Math.random() * 100) + 1, + attempts: 0, + won: false, + lost: false, + history: [], + }; +} + +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: sessions.get(id) }; + } + const id = randomUUID(); + const session = newGame(); + sessions.set(id, session); + return { id, session }; +} + +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"; +} + +function renderPage(session, message, guess) { + 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}

` + : ""; + + 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} +
+ +`; +} + +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())); + }); + }); +} + +function setCookie(res, id) { + res.setHeader( + "Set-Cookie", + `sid=${id}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400` + ); +} + +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 { id, session } = getSession(req.headers["cookie"]); + const url = req.url.split("?")[0]; + + if (req.method === "GET" && url === "/") { + setCookie(res, id); + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + res.end(renderPage(session, "", null)); + 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; + // Fix last history row hint (show "Correct!") + } 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)}`; + } + } + + setCookie(res, id); + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + res.end(renderPage(session, message, guess)); + return; + } + + if (req.method === "POST" && url === "/reset") { + const fresh = newGame(); + sessions.set(id, fresh); + setCookie(res, id); + 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}`); +});