|
|
@@ -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}`); |
|
|
|
|
|
}); |