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.
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules/
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
*.log
|
||||||
|
dist/
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
FROM node:22-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY server.mjs ./
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["node", "server.mjs"]
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
+18
@@ -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
|
||||||
+249
@@ -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<string, { target: number, attempts: number, won: boolean, lost: boolean, history: number[] }>} */
|
||||||
|
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) =>
|
||||||
|
`<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>`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
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: center;
|
||||||
|
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; }
|
||||||
|
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: 1rem; 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; }
|
||||||
|
</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}
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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}`);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user