From db678c023995b25b8cbc41d217711ff518f5c6e6 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Tue, 11 Aug 2026 10:44:04 +0000 Subject: [PATCH] =?UTF-8?q?feat(games):=20/games=20=E2=80=94=20a=20six-gam?= =?UTF-8?q?e=20arcade=20in=20the=20pit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `/games` lists the cabinet, `/games ` plays one. Six games — tetris, snake, pac-man, tic-tac-toe, chess and hangman — sharing one frame, one key decoder and one driver, so the arcade looks like an arcade rather than six weekend projects. No menus and no options screens: `/games tetris` is already playing when the frame lands, and every game reads arrows, quits on q and restarts on r, with its controls written along the bottom of the game itself. The games are pure — create a state, hand it a key, hand it a tick, ask it for rows — and everything touching a terminal lives in runGame(). That is what makes test/games.test.mjs able to finish real games of tetris and pac-man, prove the tic-tac-toe opponent cannot be beaten in 40 games of random play, and check chess's castling, en passant, promotion and mate detection without a TTY anywhere near it. Chess plays by the real rules against an alpha-beta search. Its reply runs on the clock rather than inside the keypress, so your own move is drawn before it starts thinking. Also available as `moshcode games` from a shell; listing works in a pipe, playing needs a real terminal and says so. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 42 +++ bin/moshcode.mjs | 6 + src/cli-schema.mjs | 24 ++ src/games-chess.mjs | 376 ++++++++++++++++++++++++ src/games-hangman.mjs | 97 ++++++ src/games-pacman.mjs | 205 +++++++++++++ src/games-snake.mjs | 111 +++++++ src/games-tetris.mjs | 221 ++++++++++++++ src/games-tictactoe.mjs | 124 ++++++++ src/games.mjs | 337 +++++++++++++++++++++ src/tui.mjs | 13 + src/ui.mjs | 4 +- test/games.test.mjs | 637 ++++++++++++++++++++++++++++++++++++++++ 13 files changed, 2196 insertions(+), 1 deletion(-) create mode 100644 src/games-chess.mjs create mode 100644 src/games-hangman.mjs create mode 100644 src/games-pacman.mjs create mode 100644 src/games-snake.mjs create mode 100644 src/games-tetris.mjs create mode 100644 src/games-tictactoe.mjs create mode 100644 src/games.mjs create mode 100644 test/games.test.mjs diff --git a/README.md b/README.md index 9021305..5c7b42d 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,7 @@ or miss one that does. A test fails the build when it drifts. | `moshcode doh` | hosting | run the DNS-over-HTTPS resolver | | `moshcode site`
`serve` | hosting | install web-server config for a Moshpit name | | `moshcode template`
`templates` | hosting | scaffold a stack for a Moshpit-hosted service | +| `moshcode games`
`game` `arcade` | arcade | the moshcode arcade — six games, no menus | | `moshcode pwd`
`where` | system | show the current directory and git context | | `moshcode engines` | engines | list engines and installation status | | `moshcode tools` | tools | list workflow tools and installation status | @@ -581,6 +582,47 @@ event, and publishes it to the displayed relays. Both flows leave the final confirmation in the browser. If the pit is remote or headless, `/post` prints the composer URL instead. +## The arcade (`/games`) + +Six games, in the pit or straight from a shell. There are no menus, no options +screens and no difficulty prompts — `/games tetris` is already playing. + +```sh +moshcode games # the cabinet (pit: /games) +moshcode games tetris # play one (pit: /games tetris) +moshcode games --json # the roster, for a machine +``` + +``` + TETRIS score 1200 · lines 12 + ┌────────────────────────────────┐ + │ · · · · ████· · · · NEXT │ + │ · · · · ████· · · · │ + │ · · · · · · · · · · ████████ │ + │ · · · · · · · · · · │ + │ · · · ████· · · · · LVL 2 │ + │ ████████████· ██████ │ + └────────────────────────────────┘ + ← → move · ↑ rotate · ↓ drop one · space slam · q quit +``` + +| game | | +|---|---| +| `tetris` | stack the bricks, clear the lines, outrun gravity | +| `snake` | eat, grow, and try not to eat yourself | +| `pacman` | eat the dots, dodge the ghosts, `✳` makes them edible | +| `tictactoe` | three in a row against an opponent that cannot be beaten | +| `chess` | full rules — castling, en passant, promotion — and it plays back | +| `hangman` | six wrong letters and you are done for | + +Every one of them works the same way: arrows move, `q` quits, `r` starts +another, and the controls are written along the bottom of the game itself. Each +draws in place rather than on the alternate screen, so the board you finished on +stays in your scrollback. + +Playing needs a real terminal, because they read single keypresses — `moshcode +games` on its own lists them anywhere, including a pipe. + ## Settings sync (`/save` and `/load`) Your pit becomes yours by accretion — a dozen aliases, herd rules you tuned until diff --git a/bin/moshcode.mjs b/bin/moshcode.mjs index f4f48e4..456721b 100755 --- a/bin/moshcode.mjs +++ b/bin/moshcode.mjs @@ -22,6 +22,7 @@ import { describeUninstall, uninstallPlan } from "../src/uninstall.mjs"; import { mcpCommand, pluginCommand, skillCommand } from "../src/integrations.mjs"; import { stocksCommand } from "../src/advisor.mjs"; import { cryptoCommand } from "../src/crypto.mjs"; +import { gamesCommand } from "../src/games.mjs"; import { canOpenBrowser, openBrowser } from "../src/open-url.mjs"; import { locate, tilde } from "../src/pwd.mjs"; import { createPrd, listPrds, authoringPrompt } from "../src/prd.mjs"; @@ -398,6 +399,11 @@ async function main() { if (code) process.exitCode = code; return; } + if (cmd === "games" || cmd === "game" || cmd === "arcade") { + const code = await gamesCommand(rest); + if (code) process.exitCode = code; + return; + } if (cmd === "console") { const code = await consoleCommand(rest); if (code) process.exitCode = code; diff --git a/src/cli-schema.mjs b/src/cli-schema.mjs index 81d612f..72ec2b1 100644 --- a/src/cli-schema.mjs +++ b/src/cli-schema.mjs @@ -24,6 +24,7 @@ export const COMMAND_GROUPS = [ { key: "tools", title: "tools" }, { key: "extend", title: "extend" }, { key: "script", title: "script" }, + { key: "arcade", title: "arcade" }, { key: "account", title: "account" }, { key: "hosting", title: "hosting" }, { key: "system", title: "system" }, @@ -392,6 +393,27 @@ export const CORE_CLI_COMMANDS = [ seeAlso: ["site"], }, { name: "templates", aliasOf: "template", description: "alias for template" }, + { + name: "games", + group: "arcade", + description: "the moshcode arcade — six games, no menus", + synopsis: [ + ["moshcode games", "the cabinet, and what each one is"], + ["moshcode games ", "play it, right here in the terminal"], + ], + flags: [["--json", "the roster, machine-readable", ""]], + examples: [ + ["moshcode games", "what is in the arcade"], + ["moshcode games tetris", ""], + ["moshcode games chess", "real rules, and it plays back"], + ["moshcode games pacman", "dots, ghosts, three lives"], + ], + seeAlso: ["help"], + note: "every game works the same way: arrows move, q quits, r starts another. " + + "Playing needs a real terminal because they read single keypresses — `moshcode games` on its own lists them anywhere.", + }, + { name: "game", aliasOf: "games", description: "alias for games" }, + { name: "arcade", aliasOf: "games", description: "alias for games" }, { name: "pwd", group: "system", @@ -907,6 +929,8 @@ export const PIT_COMMANDS = [ description: "crypto market data from advis0r.com" }, { name: "plugin", aliases: ["plugins"], args: " [name]", cli: "plugin", description: "install moshcode's slash commands into Claude Code" }, + { name: "games", aliases: ["game", "arcade", "play"], args: "[game]", cli: "games", + description: "the arcade — tetris, snake, pac-man, tic-tac-toe, chess, hangman" }, { name: "socials", aliases: ["social"], pitOnly: true, description: "list social networks available for posting" }, { name: "post", args: ' "message"', pitOnly: true, diff --git a/src/games-chess.mjs b/src/games-chess.mjs new file mode 100644 index 0000000..45a27da --- /dev/null +++ b/src/games-chess.mjs @@ -0,0 +1,376 @@ +// Chess. Real rules — castling, en passant, promotion, check, checkmate, +// stalemate — against an alpha-beta search that is about as strong as a friend +// who plays sometimes. You are white; the machine answers immediately. +// +// The board is 64 squares of FEN letters: uppercase white, lowercase black, +// null empty, index 0 = a8 and index 63 = h1. Everything below is pure, so a +// position can be set up in a test and asked what it thinks. +import { acid, amber, ash, bone, danger, dim, rgb } from "./ui.mjs"; + +export const START = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR"; + +const VALUE = { p: 100, n: 320, b: 330, r: 500, q: 900, k: 20000 }; +const KNIGHT_STEPS = [[1, 2], [2, 1], [-1, 2], [-2, 1], [1, -2], [2, -1], [-1, -2], [-2, -1]]; +const DIAGONALS = [[1, 1], [1, -1], [-1, 1], [-1, -1]]; +const STRAIGHTS = [[1, 0], [-1, 0], [0, 1], [0, -1]]; +const ROYAL = [...DIAGONALS, ...STRAIGHTS]; + +export const isWhite = (piece) => Boolean(piece) && piece === piece.toUpperCase(); +const friendly = (a, b) => Boolean(a) && Boolean(b) && isWhite(a) === isWhite(b); +const fileOf = (i) => i % 8; +const rankOf = (i) => Math.floor(i / 8); +const square = (x, y) => y * 8 + x; +const inside = (x, y) => x >= 0 && x < 8 && y >= 0 && y < 8; +/** The piece on a square, `null` if empty and `undefined` if off the board. */ +const at = (board, x, y) => (inside(x, y) ? board[square(x, y)] : undefined); + +/** Board rows of a FEN position (the placement field only). */ +export function parseBoard(fen = START) { + const board = Array.from({ length: 64 }, () => null); + let i = 0; + for (const char of fen.split(" ")[0]) { + if (char === "/") continue; + if (/\d/.test(char)) { i += Number(char); continue; } + board[i++] = char; + } + return board; +} + +/** Algebraic name of a square — for the move list down the side. */ +export const name = (i) => "abcdefgh"[fileOf(i)] + (8 - rankOf(i)); + +/** + * Every move the piece on `from` could make if the king's safety were somebody + * else's problem. `attacksOnly` drops pawn pushes and castling, because a pawn + * does not attack the square in front of it and a rook cannot be taken by a + * castle — that distinction is what makes check detection correct. + */ +export function pseudoMoves(state, from, { attacksOnly = false } = {}) { + const { board } = state; + const piece = board[from]; + if (!piece) return []; + const white = isWhite(piece); + const type = piece.toLowerCase(); + const x = fileOf(from); + const y = rankOf(from); + const moves = []; + const add = (to, extra = {}) => moves.push({ from, to, ...extra }); + + const slide = (dirs) => { + for (const [dx, dy] of dirs) { + for (let step = 1; step < 8; step++) { + const nx = x + dx * step; + const ny = y + dy * step; + const target = at(board, nx, ny); + if (target === undefined) break; + if (target === null) { add(square(nx, ny)); continue; } + if (!friendly(piece, target)) add(square(nx, ny), { capture: true }); + break; + } + } + }; + const hop = (steps) => { + for (const [dx, dy] of steps) { + const nx = x + dx; + const ny = y + dy; + const target = at(board, nx, ny); + if (target === undefined) continue; + if (target === null) add(square(nx, ny)); + else if (!friendly(piece, target)) add(square(nx, ny), { capture: true }); + } + }; + + if (type === "p") { + const dir = white ? -1 : 1; + const start = white ? 6 : 1; + const last = white ? 0 : 7; + if (!attacksOnly) { + if (at(board, x, y + dir) === null) { + add(square(x, y + dir), y + dir === last ? { promotion: true } : {}); + if (y === start && at(board, x, y + 2 * dir) === null) { + add(square(x, y + 2 * dir), { double: true }); + } + } + } + for (const dx of [-1, 1]) { + const nx = x + dx; + const ny = y + dir; + const target = at(board, nx, ny); + if (target === undefined) continue; + if (attacksOnly) { add(square(nx, ny)); continue; } + if (target && !friendly(piece, target)) { + add(square(nx, ny), ny === last ? { capture: true, promotion: true } : { capture: true }); + } else if (target === null && state.ep === square(nx, ny)) { + add(square(nx, ny), { capture: true, enPassant: true }); + } + } + return moves; + } + if (type === "n") hop(KNIGHT_STEPS); + else if (type === "b") slide(DIAGONALS); + else if (type === "r") slide(STRAIGHTS); + else if (type === "q") slide(ROYAL); + else if (type === "k") { + hop(ROYAL); + if (!attacksOnly) { + const home = white ? 60 : 4; + const rights = state.castling || {}; + const empty = (...squares) => squares.every((s) => board[s] === null); + const safe = (...squares) => squares.every((s) => !attacked(state, s, !white)); + if (from === home && !attacked(state, home, !white)) { + if (rights[white ? "K" : "k"] && empty(home + 1, home + 2) && safe(home + 1, home + 2)) { + add(home + 2, { castle: "K" }); + } + if (rights[white ? "Q" : "q"] && empty(home - 1, home - 2, home - 3) && safe(home - 1, home - 2)) { + add(home - 2, { castle: "Q" }); + } + } + } + } + return moves; +} + +/** Is `target` attacked by the side `byWhite`? */ +export function attacked(state, target, byWhite) { + for (let i = 0; i < 64; i++) { + const piece = state.board[i]; + if (!piece || isWhite(piece) !== byWhite) continue; + for (const move of pseudoMoves(state, i, { attacksOnly: true })) { + if (move.to === target) return true; + } + } + return false; +} + +export const findKing = (board, white) => board.indexOf(white ? "K" : "k"); + +export function inCheck(state, white) { + const king = findKing(state.board, white); + return king >= 0 && attacked(state, king, !white); +} + +/** A new position with the move played. Never mutates what it was given. */ +export function apply(state, move) { + const board = state.board.slice(); + const piece = board[move.from]; + const white = isWhite(piece); + const castling = { ...state.castling }; + + board[move.from] = null; + board[move.to] = move.promotion ? (white ? "Q" : "q") : piece; + if (move.enPassant) board[square(fileOf(move.to), rankOf(move.from))] = null; + if (move.castle) { + const home = white ? 60 : 4; + const [rookFrom, rookTo] = move.castle === "K" ? [home + 3, home + 1] : [home - 4, home - 1]; + board[rookTo] = board[rookFrom]; + board[rookFrom] = null; + } + + // Rights are lost by moving the king or a rook, and by capturing a rook on + // the square it started on — the last one is the case everybody forgets. + if (piece === "K") { castling.K = false; castling.Q = false; } + if (piece === "k") { castling.k = false; castling.q = false; } + for (const [corner, right] of [[63, "K"], [56, "Q"], [7, "k"], [0, "q"]]) { + if (move.from === corner || move.to === corner) castling[right] = false; + } + + return { + ...state, + board, + castling, + ep: move.double ? square(fileOf(move.from), (rankOf(move.from) + rankOf(move.to)) / 2) : null, + turn: white ? "b" : "w", + }; +} + +/** Pseudo-legal minus everything that walks into check. */ +export function legalMoves(state, from) { + const piece = state.board[from]; + if (!piece) return []; + const white = isWhite(piece); + return pseudoMoves(state, from).filter((move) => !inCheck(apply(state, move), white)); +} + +export function allMoves(state, white = state.turn === "w") { + const moves = []; + for (let i = 0; i < 64; i++) { + if (state.board[i] && isWhite(state.board[i]) === white) moves.push(...legalMoves(state, i)); + } + return moves; +} + +/** "checkmate", "stalemate", or null. */ +export function outcome(state) { + if (allMoves(state).length) return null; + return inCheck(state, state.turn === "w") ? "checkmate" : "stalemate"; +} + +/* --------------------------------------------------------------------- ai */ + +// Material, plus a nudge toward the middle. Enough to make it take free pieces +// and develop rather than shuffle a rook, which is all this needs to be. +const CENTER = [0, 1, 2, 3, 3, 2, 1, 0]; + +export function evaluate(state) { + let total = 0; + for (let i = 0; i < 64; i++) { + const piece = state.board[i]; + if (!piece) continue; + const worth = VALUE[piece.toLowerCase()] + CENTER[fileOf(i)] + CENTER[rankOf(i)]; + total += isWhite(piece) ? worth : -worth; + } + return state.turn === "w" ? total : -total; +} + +function negamax(state, depth, alpha, beta) { + if (depth === 0) return evaluate(state); + const moves = allMoves(state); + if (!moves.length) return inCheck(state, state.turn === "w") ? -90000 - depth : 0; + // Captures first: cheap ordering, and it is most of what alpha-beta needs to + // prune a depth-3 search down to something that answers instantly. + moves.sort((a, b) => Number(Boolean(b.capture)) - Number(Boolean(a.capture))); + let best = -Infinity; + for (const move of moves) { + const value = -negamax(apply(state, move), depth - 1, -beta, -alpha); + if (value > best) best = value; + if (best > alpha) alpha = best; + if (alpha >= beta) break; + } + return best; +} + +/** The machine's reply. Ties broken at random so it is not the same game twice. */ +export function chooseMove(state, { depth = 3, rng = Math.random } = {}) { + const moves = allMoves(state); + if (!moves.length) return null; + let best = -Infinity; + let picks = []; + for (const move of moves) { + const value = -negamax(apply(state, move), depth - 1, -Infinity, Infinity); + if (value > best) { best = value; picks = [move]; } + else if (value === best) picks.push(move); + } + return picks[Math.floor(rng() * picks.length) % picks.length]; +} + +/* ------------------------------------------------------------------- game */ + +// White is upper case and black is lower, the way a FEN reads — so the board is +// still playable with NO_COLOR set, where the two colours are the same colour. +const GLYPH = (piece) => (isWhite(piece) ? piece.toUpperCase() : piece.toLowerCase()); +const WHITE_PIECE = bone; +const BLACK_PIECE = rgb(255, 120, 180); + +function settle(state) { + const done = outcome(state); + if (!done) { + state.note = inCheck(state, state.turn === "w") ? "check" : ""; + return state; + } + state.over = done === "stalemate" + ? "stalemate — nobody wins" + : state.turn === "w" ? "checkmate — the machine takes it" : "checkmate — you win 🤘"; + return state; +} + +export const CHESS = { + key: "chess", + aliases: ["ches", "kasparov"], + title: "CHESS", + blurb: "full rules, real opponent, pawns auto-queen", + keys: "← ↑ ↓ → move · enter pick then place · r new game · q quit", + // The search blocks for a few hundred milliseconds in an open position, so it + // runs on the clock rather than inside the keypress: your own move is on the + // board and drawn before the machine starts thinking about it. The idle beat + // is slow because nothing happens on it — the driver skips a redraw that + // would change nothing. + tickMs: (state) => (state.turn === "b" ? 80 : 400), + + create() { + return { + board: parseBoard(START), + turn: "w", + castling: { K: true, Q: true, k: true, q: true }, + ep: null, + cursor: 52, // e2, where most games start + selected: null, + targets: [], + played: [], + note: "", + over: null, + }; + }, + + onKey(state, pressed, { rng = Math.random } = {}) { + const x = fileOf(state.cursor); + const y = rankOf(state.cursor); + if (pressed === "left") state.cursor = square((x + 7) % 8, y); + else if (pressed === "right") state.cursor = square((x + 1) % 8, y); + else if (pressed === "up") state.cursor = square(x, (y + 7) % 8); + else if (pressed === "down") state.cursor = square(x, (y + 1) % 8); + else if (pressed === "enter" || pressed === "space") { + if (state.selected === null) { + const piece = state.board[state.cursor]; + if (!piece || !isWhite(piece)) return state; + state.selected = state.cursor; + state.targets = legalMoves(state, state.cursor); + return state; + } + // Enter on the piece again (or on a square it cannot reach) puts it back + // down. No cancel key to learn, and no way to get stuck holding a rook. + const move = state.targets.find((m) => m.to === state.cursor); + state.selected = null; + state.targets = []; + if (!move) return state; + + Object.assign(state, apply(state, move)); + state.played.push(`${name(move.from)}${move.capture ? "x" : "-"}${name(move.to)}`); + settle(state); + } + return state; + }, + + /** The machine's turn, one move per beat. Idle while white is thinking. */ + tick(state, { rng = Math.random } = {}) { + if (state.turn !== "b" || state.over) return state; + const reply = chooseMove(state, { rng }); + if (!reply) return settle(state); + Object.assign(state, apply(state, reply)); + state.played.push(`${name(reply.from)}${reply.capture ? "x" : "-"}${name(reply.to)}`); + return settle(state); + }, + + status(state) { + if (state.over) return state.over; + if (state.turn === "b") return `${ash("black is thinking…")}`; + const last = state.played.slice(-1)[0]; + return `you ${bone("white")}${last ? ash(` · last ${last}`) : ""}${state.note ? ` · ${danger(state.note.toUpperCase())}` : ""}`; + }, + + render(state) { + const targets = new Set(state.targets.map((m) => m.to)); + const rows = []; + for (let y = 0; y < 8; y++) { + let row = `${ash(String(8 - y))} `; + for (let x = 0; x < 8; x++) { + const i = square(x, y); + const piece = state.board[i]; + const dark = (x + y) % 2 === 1; + let glyph = piece + ? (isWhite(piece) ? WHITE_PIECE : BLACK_PIECE)(GLYPH(piece)) + : targets.has(i) ? acid("◦") : dark ? dim("·") : " "; + // A piece you could take is lit up rather than dotted — the dot would + // be hidden underneath it. + if (targets.has(i) && piece) glyph = amber(GLYPH(piece)); + if (i === state.cursor) row += `${acid("[")}${glyph}${acid("]")}`; + else if (i === state.selected) row += `${amber("‹")}${glyph}${amber("›")}`; + else row += ` ${glyph} `; + } + rows.push(row); + } + rows.push(` ${ash(" a b c d e f g h ")}`); + rows.push(""); + rows.push(` ${dim(state.selected === null ? "enter picks a piece up" : "enter puts it down")}`); + return rows; + }, +}; diff --git a/src/games-hangman.mjs b/src/games-hangman.mjs new file mode 100644 index 0000000..d2363da --- /dev/null +++ b/src/games-hangman.mjs @@ -0,0 +1,97 @@ +// Hangman. Type a letter; the gallows does the rest. +import { acid, amber, ash, bone, danger, dim } from "./ui.mjs"; + +/** Six wrong guesses, and the gallows is finished. */ +export const GALLOWS = [ + [" ┌────┐", " │ ", " │ ", " │ ", " │ ", " ═╧══════"], + [" ┌────┐", " │ ○", " │ ", " │ ", " │ ", " ═╧══════"], + [" ┌────┐", " │ ○", " │ │", " │ ", " │ ", " ═╧══════"], + [" ┌────┐", " │ ○", " │ ╱│", " │ ", " │ ", " ═╧══════"], + [" ┌────┐", " │ ○", " │ ╱│╲", " │ ", " │ ", " ═╧══════"], + [" ┌────┐", " │ ○", " │ ╱│╲", " │ │", " │ ╱ ", " ═╧══════"], + [" ┌────┐", " │ ☹", " │ ╱│╲", " │ │", " │ ╱ ╲", " ═╧══════"], +]; + +export const MISSES_ALLOWED = GALLOWS.length - 1; + +/** Words a moshcoder might actually shout. Nothing needing a hyphen. */ +export const WORDS = [ + "moshcode", "distortion", "compiler", "kernel", "segfault", "refactor", + "closure", "promise", "daemon", "binary", "pointer", "monorepo", "terminal", + "runtime", "abstraction", "recursion", "interface", "payload", "protocol", + "semaphore", "mutex", "stacktrace", "breakpoint", "heuristic", "idempotent", + "bytecode", "checksum", "firewall", "namespace", "regression", "sandbox", + "throughput", "waveform", "amplifier", "feedback", "headbang", "overdrive", + "fretboard", "downbeat", "crowdsurf", "backline", "encryption", "quantum", +]; + +const LETTERS = "abcdefghijklmnopqrstuvwxyz"; + +/** The word with everything unguessed still hidden. */ +export function mask(state) { + return state.word.split("").map((c) => (state.guessed.has(c) ? c.toUpperCase() : "_")); +} + +/** + * Apply one letter. Repeats are free — guessing `e` twice costs nothing but + * also tells you nothing, which is the same deal every hangman has ever run. + */ +export function guess(state, letter) { + const c = String(letter).toLowerCase(); + if (!LETTERS.includes(c) || state.guessed.has(c) || state.missed.includes(c)) return state; + if (state.word.includes(c)) { + state.guessed.add(c); + if (state.word.split("").every((ch) => state.guessed.has(ch))) state.over = "got it 🤘"; + return state; + } + state.missed.push(c); + if (state.missed.length >= MISSES_ALLOWED) state.over = `hanged — it was ${state.word.toUpperCase()}`; + return state; +} + +export const HANGMAN = { + key: "hangman", + aliases: ["hang", "gallows"], + title: "HANGMAN", + blurb: "six wrong letters and you are done for", + keys: "a–z guess · r new word · q quit", + + create({ rng = Math.random } = {}) { + return { + word: WORDS[Math.floor(rng() * WORDS.length) % WORDS.length], + guessed: new Set(), + missed: [], + over: null, + }; + }, + + onKey(state, pressed) { + if (pressed.length !== 1) return state; + return guess(state, pressed); + }, + + status(state) { + if (state.over) return state.over; + const left = MISSES_ALLOWED - state.missed.length; + return `${left} wrong ${left === 1 ? "guess" : "guesses"} left`; + }, + + render(state) { + const art = GALLOWS[Math.min(state.missed.length, MISSES_ALLOWED)]; + const shown = state.over && state.over.startsWith("hanged") + ? state.word.split("").map((c) => c.toUpperCase()) + : mask(state); + const word = shown.map((c) => (c === "_" ? ash("_") : acid(c))).join(" "); + const missed = state.missed.length + ? `${ash("missed ")}${danger(state.missed.join(" ").toUpperCase())}` + : dim("no wrong letters yet"); + return [ + ...art.map((line) => bone(line)), + "", + ` ${word}`, + "", + ` ${missed}`, + ` ${amber("✳".repeat(MISSES_ALLOWED - state.missed.length))}${dim("✳".repeat(state.missed.length))}`, + ]; + }, +}; diff --git a/src/games-pacman.mjs b/src/games-pacman.mjs new file mode 100644 index 0000000..8a8cea2 --- /dev/null +++ b/src/games-pacman.mjs @@ -0,0 +1,205 @@ +// Pac-Man, arcade-sized. One maze, 240-odd dots, four power pellets and three +// ghosts that are not quite as clever as the ones in 1980 — on purpose. This is +// a game you can win on a coffee break. +import { acid, amber, ash, dim, rgb } from "./ui.mjs"; + +/** + * `#` wall · `.` dot · `o` power pellet · `P` where pac starts · `G` the pen. + * + * Symmetric, fully connected, and small enough that the whole board fits above + * the pit's prompt without scrolling. + */ +export const MAZE = [ + "###################", + "#........#........#", + "#o##.###.#.###.##o#", + "#.................#", + "#.##.#.#####.#.##.#", + "#....#...G...#....#", + "####.##.###.##.####", + "#........P........#", + "#.##.####.####.##.#", + "#o...............o#", + "###################", +]; + +export const WIDTH = MAZE[0].length; +export const HEIGHT = MAZE.length; + +const WALL = ash("██"); +const DOT = dim("· "); +const POWER = amber("✳ "); +const PAC = acid("● "); +const BLANK = " "; +const GHOST_COLORS = [rgb(255, 77, 61), rgb(255, 120, 180), rgb(90, 220, 250)]; +const SCARED = rgb(90, 140, 255); + +const DIRS = { up: [0, -1], down: [0, 1], left: [-1, 0], right: [1, 0] }; +const OPPOSITE = { up: "down", down: "up", left: "right", right: "left" }; +const FRIGHT_TICKS = 40; + +export const isWall = (x, y) => MAZE[y]?.[x] === "#" || MAZE[y]?.[x] === undefined; +const cellKey = (x, y) => `${x},${y}`; +const distance = (a, b) => Math.abs(a.x - b.x) + Math.abs(a.y - b.y); + +/** Every dot and pellet in the maze, as a fresh map. */ +export function pellets() { + const map = new Map(); + for (let y = 0; y < HEIGHT; y++) { + for (let x = 0; x < WIDTH; x++) { + const cell = MAZE[y][x]; + if (cell === "." || cell === "o") map.set(cellKey(x, y), cell); + } + } + return map; +} + +function find(char) { + for (let y = 0; y < HEIGHT; y++) { + const x = MAZE[y].indexOf(char); + if (x >= 0) return { x, y }; + } + return { x: 1, y: 1 }; +} + +/** Where everything stands at the start of a life. */ +function positions() { + const pen = find("G"); + const pac = find("P"); + return { + pac: { ...pac, dir: "left", want: "left" }, + // Three ghosts abreast in the pen — the two beside it are open corridor in + // this maze, which is what keeps them from stepping on each other at spawn. + ghosts: [ + { x: pen.x, y: pen.y }, + { x: pen.x - 1, y: pen.y }, + { x: pen.x + 1, y: pen.y }, + ].map((g, i) => ({ ...g, home: { x: g.x, y: g.y }, dir: "up", color: i })), + }; +} + +/** Directions a ghost may take from where it stands. */ +export function options(ghost, { allowReverse = false } = {}) { + const open = Object.entries(DIRS) + .filter(([name, [dx, dy]]) => !isWall(ghost.x + dx, ghost.y + dy) + && (allowReverse || name !== OPPOSITE[ghost.dir])); + // A dead end is the one place reversing is the only move there is. + return open.length ? open : Object.entries(DIRS).filter(([, [dx, dy]]) => !isWall(ghost.x + dx, ghost.y + dy)); +} + +/** + * One ghost step. Chases by Manhattan distance, flees while you are lit up, + * and takes a random legal turn one time in five so the three of them do not + * arrive in a single-file line. + */ +export function moveGhost(ghost, pac, { frightened = false, rng = Math.random } = {}) { + const open = options(ghost); + if (!open.length) return ghost; + const scored = open.map(([name, [dx, dy]]) => ({ + name, + dx, + dy, + d: distance({ x: ghost.x + dx, y: ghost.y + dy }, pac), + })); + let choice; + if (rng() < 0.2) choice = scored[Math.floor(rng() * scored.length) % scored.length]; + else { + const sorted = scored.slice().sort((a, b) => (frightened ? b.d - a.d : a.d - b.d)); + choice = sorted[0]; + } + ghost.dir = choice.name; + ghost.x += choice.dx; + ghost.y += choice.dy; + return ghost; +} + +function caught(state) { + const hits = state.ghosts.filter((g) => g.x === state.pac.x && g.y === state.pac.y); + if (!hits.length) return false; + if (state.fright > 0) { + for (const g of hits) { + state.score += 200; + Object.assign(g, { x: g.home.x, y: g.home.y, dir: "up" }); + } + return false; + } + state.lives--; + if (state.lives <= 0) { state.over = "game over"; return true; } + Object.assign(state, positions(), { fright: 0 }); + return true; +} + +export const PACMAN = { + key: "pacman", + aliases: ["pac", "pacmam", "puckman"], + title: "PAC-MAN", + blurb: "eat the dots, dodge the ghosts, ✳ makes them edible", + keys: "← ↑ ↓ → steer · q quit", + tickMs: 150, + + create({ rng = Math.random } = {}) { + return { ...positions(), dots: pellets(), score: 0, lives: 3, fright: 0, frame: 0, over: null, rng }; + }, + + tick(state) { + state.frame++; + const pac = state.pac; + // A turn is remembered until it becomes legal, which is what makes a corner + // feel like a corner rather than a keypress you have to time. + const wanted = DIRS[pac.want]; + if (wanted && !isWall(pac.x + wanted[0], pac.y + wanted[1])) pac.dir = pac.want; + const [dx, dy] = DIRS[pac.dir]; + if (!isWall(pac.x + dx, pac.y + dy)) { pac.x += dx; pac.y += dy; } + + const here = state.dots.get(cellKey(pac.x, pac.y)); + if (here) { + state.dots.delete(cellKey(pac.x, pac.y)); + state.score += here === "o" ? 50 : 10; + if (here === "o") state.fright = FRIGHT_TICKS; + } + if (!state.dots.size) { state.over = "maze cleared 🤘"; return state; } + if (caught(state)) return state; + + // Ghosts move at half speed, and slower still while they are running away. + const beat = state.fright > 0 ? 3 : 2; + if (state.frame % beat === 0) { + for (const ghost of state.ghosts) { + moveGhost(ghost, pac, { frightened: state.fright > 0, rng: state.rng }); + } + caught(state); + } + if (state.fright > 0) state.fright--; + return state; + }, + + onKey(state, pressed) { + if (DIRS[pressed]) state.pac.want = pressed; + return state; + }, + + status(state) { + const left = state.dots.size; + if (state.over) return `${state.over} · ${state.score} points`; + return `score ${state.score} · lives ${"●".repeat(Math.max(0, state.lives))} · dots ${left}` + + (state.fright > 0 ? " · RUN" : ""); + }, + + render(state) { + const rows = []; + for (let y = 0; y < HEIGHT; y++) { + let row = ""; + for (let x = 0; x < WIDTH; x++) { + const ghost = state.ghosts.find((g) => g.x === x && g.y === y); + if (state.pac.x === x && state.pac.y === y) row += PAC; + else if (ghost) row += (state.fright > 0 ? SCARED : GHOST_COLORS[ghost.color])("▲ "); + else if (MAZE[y][x] === "#") row += WALL; + else { + const pellet = state.dots.get(cellKey(x, y)); + row += pellet === "o" ? POWER : pellet === "." ? DOT : BLANK; + } + } + rows.push(row); + } + return rows; + }, +}; diff --git a/src/games-snake.mjs b/src/games-snake.mjs new file mode 100644 index 0000000..deaad8a --- /dev/null +++ b/src/games-snake.mjs @@ -0,0 +1,111 @@ +// Snake. The cheapest game in the arcade and the hardest to stop playing. +import { acid, amber, dim, rgb } from "./ui.mjs"; + +export const WIDTH = 28; +export const HEIGHT = 14; + +const HEAD = acid("█"); +const BODY = rgb(120, 190, 40)("▓"); +const FOOD = amber("✳"); +const EMPTY = dim("·"); + +const DIRS = { + up: [0, -1], + down: [0, 1], + left: [-1, 0], + right: [1, 0], +}; + +const same = (a, b) => a[0] === b[0] && a[1] === b[1]; + +/** A cell nothing is standing on, so the food never lands under the snake. */ +export function placeFood(snake, rng) { + const free = []; + for (let y = 0; y < HEIGHT; y++) { + for (let x = 0; x < WIDTH; x++) { + if (!snake.some((s) => same(s, [x, y]))) free.push([x, y]); + } + } + if (!free.length) return null; // the board is snake — that is a win + return free[Math.floor(rng() * free.length) % free.length]; +} + +/** + * One step. Returns the state; sets `over` when the head meets a wall or + * itself. Kept separate from the game object so a test can walk a snake into + * its own tail on purpose. + */ +export function step(state) { + const [dx, dy] = DIRS[state.dir]; + const head = [state.snake[0][0] + dx, state.snake[0][1] + dy]; + if (head[0] < 0 || head[0] >= WIDTH || head[1] < 0 || head[1] >= HEIGHT) { + state.over = "into the wall"; + return state; + } + // The tail cell is about to move out from under the head, so it is only a + // collision when the snake is about to grow into it. + const eating = state.food && same(head, state.food); + const body = eating ? state.snake : state.snake.slice(0, -1); + if (body.some((s) => same(s, head))) { + state.over = "ate itself"; + return state; + } + state.snake = [head, ...body]; + if (eating) { + state.score += 10; + state.food = placeFood(state.snake, state.rng); + if (!state.food) state.over = "the whole board — no notes"; + } + state.turned = false; + return state; +} + +export const SNAKE = { + key: "snake", + aliases: ["worm", "nibbles"], + title: "SNAKE", + blurb: "eat, grow, and try not to eat yourself", + keys: "← ↑ ↓ → turn · q quit", + tickMs: (state) => Math.max(60, 130 - state.snake.length * 2), + + create({ rng = Math.random } = {}) { + const mid = Math.floor(HEIGHT / 2); + const snake = [[6, mid], [5, mid], [4, mid]]; + return { snake, dir: "right", turned: false, score: 0, over: null, rng, food: placeFood(snake, rng) }; + }, + + tick: step, + + onKey(state, key) { + if (!DIRS[key]) return state; + // One turn per tick, and never a full reverse: without either, a fast + // left-then-up folds the snake back through its own neck. + const opposite = { up: "down", down: "up", left: "right", right: "left" }; + if (state.turned || opposite[key] === state.dir || key === state.dir) return state; + state.dir = key; + state.turned = true; + return state; + }, + + status(state) { + return state.over + ? `${state.over} · ${state.score} points` + : `score ${state.score} · length ${state.snake.length}`; + }, + + render(state) { + const rows = []; + for (let y = 0; y < HEIGHT; y++) { + let row = ""; + for (let x = 0; x < WIDTH; x++) { + const cell = [x, y]; + if (same(state.snake[0], cell)) row += HEAD; + else if (state.snake.some((s) => same(s, cell))) row += BODY; + else if (state.food && same(state.food, cell)) row += FOOD; + else row += EMPTY; + } + rows.push(row); + } + return rows; + }, +}; diff --git a/src/games-tetris.mjs b/src/games-tetris.mjs new file mode 100644 index 0000000..b84f280 --- /dev/null +++ b/src/games-tetris.mjs @@ -0,0 +1,221 @@ +// Tetris, for the moshcode arcade. Ten wide, twenty deep, seven bricks. +// +// Everything here is pure — rotate a shape, ask whether it collides, merge it, +// clear the full rows — so the whole game can be played in a test with no +// terminal anywhere near it. See src/games.mjs for the frame it is drawn in. +import { acid, amber, ash, danger, dim, rgb } from "./ui.mjs"; + +export const WIDTH = 10; +export const HEIGHT = 20; + +/** + * The seven tetrominoes, drawn as square grids so one rotate() handles them + * all. Each fills itself with its own letter, which is also its colour key — + * merging a piece into the board is then a copy, with no bookkeeping. + */ +export const SHAPES = { + I: ["....", "IIII", "....", "...."], + O: ["OO", "OO"], + T: [".T.", "TTT", "..."], + S: [".SS", "SS.", "..."], + Z: ["ZZ.", ".ZZ", "..."], + J: ["J..", "JJJ", "..."], + L: ["..L", "LLL", "..."], +}; + +const COLORS = { + I: rgb(90, 220, 250), + O: amber, + T: rgb(190, 130, 255), + S: acid, + Z: danger, + J: rgb(90, 140, 255), + L: rgb(255, 150, 60), +}; + +const BAG = Object.keys(SHAPES); +const BLOCK = "██"; +const EMPTY = dim("· "); +const GHOST = ash("░░"); + +/** Clockwise quarter turn of a square shape. */ +export function rotate(shape) { + return shape.map((_, i) => shape.map((row) => row[i]).reverse().join("")); +} + +export const emptyBoard = () => + Array.from({ length: HEIGHT }, () => Array.from({ length: WIDTH }, () => null)); + +/** Would this shape overlap a wall, the floor, or something already stacked? */ +export function collides(board, shape, px, py) { + for (let y = 0; y < shape.length; y++) { + for (let x = 0; x < shape[y].length; x++) { + if (shape[y][x] === ".") continue; + const bx = px + x; + const by = py + y; + if (bx < 0 || bx >= WIDTH || by >= HEIGHT) return true; + // Above the ceiling is legal — that is where a piece spawns from. + if (by >= 0 && board[by][bx]) return true; + } + } + return false; +} + +/** Stamp a piece into the board. Mutates, and is only ever called on a lock. */ +export function merge(board, piece) { + for (let y = 0; y < piece.shape.length; y++) { + for (let x = 0; x < piece.shape[y].length; x++) { + const cell = piece.shape[y][x]; + if (cell === ".") continue; + const by = piece.y + y; + if (by >= 0) board[by][piece.x + x] = cell; + } + } + return board; +} + +/** Drop out every full row, refill from the top. Returns how many went. */ +export function clearLines(board) { + const kept = board.filter((row) => row.some((cell) => !cell)); + const cleared = HEIGHT - kept.length; + while (kept.length < HEIGHT) kept.unshift(Array.from({ length: WIDTH }, () => null)); + for (let y = 0; y < HEIGHT; y++) board[y] = kept[y]; + return cleared; +} + +const pick = (rng) => BAG[Math.floor(rng() * BAG.length) % BAG.length]; + +function spawn(state, key) { + const shape = SHAPES[key]; + const piece = { key, shape, x: Math.floor((WIDTH - shape[0].length) / 2), y: 0 }; + if (collides(state.board, piece.shape, piece.x, piece.y)) state.over = "stacked out"; + state.piece = piece; + return state; +} + +export const level = (state) => 1 + Math.floor(state.lines / 10); + +/** Where the piece would land if you let go of it — drawn as the ghost. */ +export function landing(state) { + let y = state.piece.y; + while (!collides(state.board, state.piece.shape, state.piece.x, y + 1)) y++; + return y; +} + +function lock(state) { + merge(state.board, state.piece); + const cleared = clearLines(state.board); + if (cleared) { + state.lines += cleared; + state.score += [0, 100, 300, 500, 800][cleared] * level(state); + } + const key = state.next; + state.next = pick(state.rng); + return spawn(state, key); +} + +export const TETRIS = { + key: "tetris", + aliases: ["blocks", "bricks"], + title: "TETRIS", + blurb: "stack the bricks, clear the lines, outrun gravity", + keys: "← → move · ↑ rotate · ↓ drop one · space slam · q quit", + // Gravity is the level, and the level is the lines you have cleared. + tickMs: (state) => Math.max(90, 700 - (level(state) - 1) * 65), + + create({ rng = Math.random } = {}) { + const state = { board: emptyBoard(), score: 0, lines: 0, over: null, rng, next: pick(rng) }; + return spawn(state, pick(rng)); + }, + + tick(state) { + if (collides(state.board, state.piece.shape, state.piece.x, state.piece.y + 1)) return lock(state); + state.piece.y++; + return state; + }, + + onKey(state, key) { + const p = state.piece; + if (key === "left" && !collides(state.board, p.shape, p.x - 1, p.y)) p.x--; + else if (key === "right" && !collides(state.board, p.shape, p.x + 1, p.y)) p.x++; + else if (key === "down") return TETRIS.tick(state); + else if (key === "up" || key === "x") { + const turned = rotate(p.shape); + // Wall kicks, the simple kind: if the turn doesn't fit, shove it a column + // or two off the wall before giving up. Without this an I-piece can never + // stand up in the left gutter. + for (const dx of [0, -1, 1, -2, 2]) { + if (!collides(state.board, turned, p.x + dx, p.y)) { + p.shape = turned; + p.x += dx; + break; + } + } + } else if (key === "space") { + const drop = landing(state); + state.score += (drop - p.y) * 2; + p.y = drop; + return lock(state); + } + return state; + }, + + status(state) { + return state.over + ? `${state.over} · score ${state.score}` + : `score ${state.score} · lines ${state.lines}`; + }, + + render(state) { + const view = state.board.map((row) => row.slice()); + const p = state.piece; + if (!state.over) { + const gy = landing(state); + for (let y = 0; y < p.shape.length; y++) { + for (let x = 0; x < p.shape[y].length; x++) { + if (p.shape[y][x] === ".") continue; + if (gy + y >= 0 && gy + y < HEIGHT) view[gy + y][p.x + x] = "ghost"; + } + } + } + for (let y = 0; y < p.shape.length; y++) { + for (let x = 0; x < p.shape[y].length; x++) { + if (p.shape[y][x] === ".") continue; + if (p.y + y >= 0 && p.y + y < HEIGHT) view[p.y + y][p.x + x] = p.key; + } + } + const gutter = sidePanel(state); + return view.map((row, y) => { + const well = row.map((cell) => { + if (!cell) return EMPTY; + if (cell === "ghost") return GHOST; + return COLORS[cell](BLOCK); + }).join(""); + return `${well} ${gutter[y] ?? ""}`; + }); + }, +}; + +/** + * The strip down the right: what is coming, and what level you are on. + * + * It is also what makes the well look like a tetris cabinet rather than a + * column of bricks floating in a frame — the board sets the frame's width, so + * without it the box is wider than the game. + */ +function sidePanel(state) { + const colour = COLORS[state.next]; + const shape = SHAPES[state.next]; + const lines = [ + ash("NEXT"), + ...shape.map((row) => row.split("").map((c) => (c === "." ? " " : colour(BLOCK))).join("")), + "", + ash(`LVL ${level(state)}`), + ]; + // Padded to a fixed width so a narrow piece cannot make the frame breathe in + // and out as the bag turns over. + return lines.map((line) => { + const width = line.replace(/\x1b\[[0-9;]*m/g, "").length; + return line + " ".repeat(Math.max(0, 8 - width)); + }); +} diff --git a/src/games-tictactoe.mjs b/src/games-tictactoe.mjs new file mode 100644 index 0000000..881b291 --- /dev/null +++ b/src/games-tictactoe.mjs @@ -0,0 +1,124 @@ +// Tic-tac-toe against an opponent that cannot be beaten, only held. +// +// The AI is a full minimax — the search space is 9! at its very worst, which is +// nothing — so a draw is a win. It breaks ties at random, which is the only +// reason two games in a row are not the same game. +import { acid, ash, bone, danger, dim } from "./ui.mjs"; + +export const LINES = [ + [0, 1, 2], [3, 4, 5], [6, 7, 8], + [0, 3, 6], [1, 4, 7], [2, 5, 8], + [0, 4, 8], [2, 4, 6], +]; + +export const emptyBoard = () => Array.from({ length: 9 }, () => null); + +/** "X", "O", "draw", or null while there is still a game on. */ +export function winner(board) { + for (const [a, b, c] of LINES) { + if (board[a] && board[a] === board[b] && board[b] === board[c]) return board[a]; + } + return board.every(Boolean) ? "draw" : null; +} + +const other = (player) => (player === "X" ? "O" : "X"); + +/** + * Minimax with no pruning and no depth limit — 3×3 does not need either. + * Depth is in the score so it prefers winning sooner and losing later, which is + * what stops it from wandering into a fork it could have blocked. + */ +export function score(board, player, me, depth = 0) { + const done = winner(board); + if (done === me) return 10 - depth; + if (done === other(me)) return depth - 10; + if (done === "draw") return 0; + + const scores = []; + for (let i = 0; i < 9; i++) { + if (board[i]) continue; + board[i] = player; + scores.push(score(board, other(player), me, depth + 1)); + board[i] = null; + } + return player === me ? Math.max(...scores) : Math.min(...scores); +} + +/** The best square for `player`, chosen at random among equally good ones. */ +export function bestMove(board, player, rng = Math.random) { + let best = -Infinity; + let moves = []; + for (let i = 0; i < 9; i++) { + if (board[i]) continue; + board[i] = player; + const value = score(board, other(player), player, 1); + board[i] = null; + if (value > best) { best = value; moves = [i]; } + else if (value === best) moves.push(i); + } + if (!moves.length) return null; + return moves[Math.floor(rng() * moves.length) % moves.length]; +} + +function finish(state) { + const result = winner(state.board); + if (!result) return state; + state.over = result === "draw" ? "a draw — the only honest result" + : result === "X" ? "you win 🤘" : "the machine takes it"; + return state; +} + +export const TICTACTOE = { + key: "tictactoe", + // `tic-tac-toe` needs no alias — resolveGame drops dashes before it looks. + aliases: ["ttt", "tiktaktoe", "noughts", "xo"], + title: "TIC-TAC-TOE", + blurb: "three in a row against a perfect opponent", + keys: "← ↑ ↓ → move · enter mark · r new game · q quit", + + create() { + return { board: emptyBoard(), cursor: 4, over: null, turn: "X" }; + }, + + onKey(state, pressed, { rng = Math.random } = {}) { + const x = state.cursor % 3; + const y = Math.floor(state.cursor / 3); + if (pressed === "left") state.cursor = y * 3 + (x + 2) % 3; + else if (pressed === "right") state.cursor = y * 3 + (x + 1) % 3; + else if (pressed === "up") state.cursor = ((y + 2) % 3) * 3 + x; + else if (pressed === "down") state.cursor = ((y + 1) % 3) * 3 + x; + else if (pressed === "enter" || pressed === "space") { + if (state.board[state.cursor]) return state; + state.board[state.cursor] = "X"; + if (finish(state).over) return state; + const reply = bestMove(state.board, "O", rng); + if (reply != null) state.board[reply] = "O"; + finish(state); + } + return state; + }, + + status(state) { + return state.over ? state.over : `you ${acid("X")} ${ash("· machine")} ${bone("O")}`; + }, + + render(state) { + const mark = (i) => { + const value = state.board[i]; + const glyph = value === "X" ? acid("X") : value === "O" ? danger("O") : " "; + // The cursor is drawn as brackets rather than a highlight so it survives + // NO_COLOR, a pipe, and every terminal that lies about its capabilities. + return i === state.cursor && !state.over ? ` ${acid("[")}${glyph}${acid("]")} ` : ` ${glyph} `; + }; + const line = (l, m, r) => ash(`${l}─────${m}─────${m}─────${r}`); + const rows = []; + rows.push(line("┌", "┬", "┐")); + for (let y = 0; y < 3; y++) { + rows.push(`${ash("│")}${mark(y * 3)}${ash("│")}${mark(y * 3 + 1)}${ash("│")}${mark(y * 3 + 2)}${ash("│")}`); + rows.push(y < 2 ? line("├", "┼", "┤") : line("└", "┴", "┘")); + } + rows.push(""); + rows.push(dim(state.over ? "r for another" : "enter to mark the square")); + return rows; + }, +}; diff --git a/src/games.mjs b/src/games.mjs new file mode 100644 index 0000000..7a912a5 --- /dev/null +++ b/src/games.mjs @@ -0,0 +1,337 @@ +// The moshcode arcade — `/games` in the pit, `moshcode games` from a shell. +// +// Six games, one frame. Every game here is the same shape (see GAME_SHAPE +// below) and is drawn by the same `frame()`, so they look like one arcade +// rather than six weekend projects: a title, a status line, a boxed board, and +// one line of keys along the bottom. There is no menu, no options screen and no +// difficulty prompt — `/games tetris` is already playing by the time the frame +// lands, and `q` is always the way out. +// +// The split is deliberate: the games themselves (games-*.mjs) are pure — create +// a state, hand it a key, hand it a tick, ask it for rows — and everything that +// touches a terminal lives in `runGame` down the bottom. That is what makes an +// arcade testable: test/games.test.mjs plays entire games without a TTY. +import { acid, amber, ash, bone, danger, dim, rgb } from "./ui.mjs"; +import { TETRIS } from "./games-tetris.mjs"; +import { SNAKE } from "./games-snake.mjs"; +import { PACMAN } from "./games-pacman.mjs"; +import { TICTACTOE } from "./games-tictactoe.mjs"; +import { HANGMAN } from "./games-hangman.mjs"; +import { CHESS } from "./games-chess.mjs"; + +/** + * @typedef {object} Game — the whole contract, so a seventh game is an import. + * @property {string} key the name typed after /games + * @property {string[]} aliases other spellings (tiktaktoe is a real thing people type) + * @property {string} title shown in the frame's header + * @property {string} blurb one line, for /games list + * @property {string} keys the footer; the only place controls are ever explained + * @property {number|Function} [tickMs] real-time games only — a number, or (state) => number + * @property {Function} create ({ rng }) => state + * @property {Function} onKey (state, key, { rng }) => state + * @property {Function} [tick] (state, { rng }) => state + * @property {Function} render (state) => string[] the board, already coloured + * @property {Function} status (state) => string right of the title + */ + +/** The cabinet. Order is the order `/games` lists them. */ +export const GAMES = [TETRIS, SNAKE, PACMAN, TICTACTOE, CHESS, HANGMAN]; + +/** Games by name, following aliases. Case- and slash-insensitive. */ +export function resolveGame(name) { + const wanted = String(name ?? "").toLowerCase().replace(/^\//, "").replace(/[-_\s]/g, ""); + if (!wanted) return null; + return GAMES.find((g) => g.key === wanted || (g.aliases || []).includes(wanted)) ?? null; +} + +/* ------------------------------------------------------------------- frame */ + +// Colour codes are invisible but not zero-width to `.length`, so every pad in +// here measures the stripped string. Getting this wrong is how a board's right +// edge ends up ragged the moment someone wins. +const ANSI = /\x1b\[[0-9;]*m/g; +export const strip = (s) => String(s).replace(ANSI, ""); +export const visible = (s) => strip(s).length; +const pad = (s, width) => s + " ".repeat(Math.max(0, width - visible(s))); + +/** + * The one frame every game is drawn in. + * + * ``` + * TETRIS score 1200 · lines 12 + * ┌────────────────────┐ + * │ ██████ │ + * └────────────────────┘ + * ← → move · ↑ rotate · space slam · q quit + * ``` + * + * Returns a string with no trailing newline; `runGame` owns the cursor. + */ +export function frame({ title = "", status = "", rows = [], keys = "" } = {}) { + const body = rows.map((r) => String(r)); + // The board sets the width. The header and the key line sit outside the box, + // so letting either of them stretch it is how a 20-column tetris well ends up + // in a 54-column frame. + const inner = Math.max(...body.map(visible), 20); + const gap = inner - visible(title) - visible(status); + const head = !visible(status) ? acid(title) + // Right-align the status to the box edge when there is room for it, and + // fall back to a caption rather than pushing the board around when a long + // status (chess, mid-game) would not fit. + : gap >= 2 ? pad(acid(title), inner - visible(status)) + ash(status) + : `${acid(title)} ${ash(status)}`; + const out = [ + ` ${head}`, + ` ${ash(`┌${"─".repeat(inner + 2)}┐`)}`, + ...body.map((row) => ` ${ash("│")} ${pad(row, inner)} ${ash("│")}`), + ` ${ash(`└${"─".repeat(inner + 2)}┘`)}`, + ` ${ash(keys)}`, + ]; + return out.join("\n"); +} + +/* --------------------------------------------------------------------- keys */ + +/** + * Raw terminal bytes → key names the games understand. + * + * Games never see an escape sequence; they see "up", "enter", "a". A chunk can + * hold several keypresses (hold an arrow key down and they arrive in batches), + * which is why this returns a list. + */ +export function decodeKeys(chunk) { + const input = String(chunk); + const keys = []; + for (let i = 0; i < input.length; i++) { + const c = input[i]; + if (c === "\x1b") { + const seq = input.slice(i, i + 3); + const arrow = { "\x1b[A": "up", "\x1b[B": "down", "\x1b[C": "right", "\x1b[D": "left" }[seq]; + if (arrow) { keys.push(arrow); i += 2; continue; } + // A bare escape is a quit everywhere in the arcade; a longer sequence we + // don't know (mouse, function key) is swallowed rather than misread. + if (input[i + 1] === "[" || input[i + 1] === "O") { i += 2; continue; } + keys.push("escape"); + continue; + } + if (c === "\r" || c === "\n") { keys.push("enter"); continue; } + if (c === " ") { keys.push("space"); continue; } + if (c === "\x03" || c === "\x04") { keys.push("quit"); continue; } + if (c === "\x7f" || c === "\b") { keys.push("backspace"); continue; } + if (c === "\t") { keys.push("tab"); continue; } + // vim keys, everywhere, for free — every game reads arrows, so mapping + // hjkl here means no game has to know about them. + const vim = { h: "left", j: "down", k: "up", l: "right" }[c]; + if (vim) { keys.push(vim); continue; } + if (c >= " " && c <= "~") keys.push(c.toLowerCase()); + } + return keys; +} + +/* -------------------------------------------------------------------- list */ + +/** + * `/games` with no argument: the cabinet, and how to start one. + * + * `prefix` is how the caller is spelled — the pit says `/games tetris` and a + * shell says `moshcode games tetris`, and printing the wrong one is how a list + * teaches somebody a command that does not work where they are standing. + */ +export function renderList({ prefix = "moshcode games" } = {}) { + const width = Math.max(...GAMES.map((g) => g.key.length)); + return [ + ` ${acid("moshcode arcade")} ${ash(`— ${GAMES.length} games, no menus, no options screens`)}`, + "", + ...GAMES.map((g) => ` ${bone(g.key.padEnd(width))} ${ash(g.blurb)}`), + "", + ` ${ash("play one:")} ${acid(`${prefix} ${GAMES[0].key}`)}`, + ` ${ash("every game: arrows move · q quits · r starts another")}`, + ].join("\n"); +} + +/** The same cabinet, for something that cannot read a terminal. */ +export function gamesModel() { + return { + games: GAMES.map((g) => ({ + name: g.key, + aliases: g.aliases || [], + description: g.blurb, + keys: g.keys, + realtime: Boolean(g.tickMs), + })), + }; +} + +/* ------------------------------------------------------------------ driver */ + +const ESC = { + hideCursor: "\x1b[?25l", + showCursor: "\x1b[?25h", + up: (n) => (n > 0 ? `\x1b[${n}A` : ""), + eraseDown: "\x1b[0J", +}; + +/** + * Play one game until `q`. + * + * Drawn in place rather than on the alternate screen, so the final board — the + * score, the checkmate, the word you didn't get — stays in the pit's scrollback + * where you can look at it. Redrawing is "jump back up over the frame and + * write it again", which is why every frame is the same height. + */ +export async function runGame(game, deps = {}) { + const { + input = process.stdin, + output = process.stdout, + rng = Math.random, + // Tests hand in their own clock so a "real-time" game can be played turn by + // turn, deterministically, with no timers left running after the assertion. + setTimer = (fn, ms) => setTimeout(fn, ms), + clearTimer = (t) => clearTimeout(t), + } = deps; + + const ctx = { rng }; + let state = game.create(ctx); + let height = 0; + let timer = null; + let closed = false; + + let painted = null; + const draw = () => { + if (closed) return; + const text = frame({ + title: game.title, + status: game.status(state), + rows: game.render(state), + keys: state.over ? `${game.keys} · ${bone("r")} again` : game.keys, + }); + // A frame identical to the one already on the screen is not written at all. + // Chess idles on its clock while it is your move, and repainting the same + // board twice a second is exactly the flicker that would make it feel busy. + if (text === painted) return; + output.write(`${ESC.up(height)}${ESC.eraseDown}${text}\n`); + painted = text; + height = text.split("\n").length; + }; + + const stop = () => { if (timer !== null) { clearTimer(timer); timer = null; } }; + const schedule = () => { + stop(); + if (!game.tickMs || state.over) return; + const ms = typeof game.tickMs === "function" ? game.tickMs(state) : game.tickMs; + timer = setTimer(() => { + timer = null; + if (closed || state.over) return; + state = game.tick(state, ctx) || state; + draw(); + schedule(); + }, ms); + }; + + const wasRaw = Boolean(input.isRaw); + const restore = () => { + if (closed) return; + closed = true; + stop(); + output.write(ESC.showCursor); + try { input.setRawMode?.(wasRaw); } catch { /* already gone */ } + input.off?.("data", onData); + input.pause?.(); + }; + const onSignal = () => { restore(); process.exit(130); }; + + function onData(chunk) { + for (const key of decodeKeys(chunk)) { + if (key === "quit" || key === "q" || key === "escape") { restore(); resolve(); return; } + if (key === "r" && (state.over || game.restartable !== false)) { + state = game.create(ctx); + draw(); + schedule(); + continue; + } + if (state.over) continue; // a finished board takes r and q, nothing else + state = game.onKey(state, key, ctx) || state; + draw(); + // A key can end a real-time game (a hard drop into the ceiling) or start + // one moving again, so the clock is re-armed off every keypress. + if (game.tickMs) schedule(); + } + } + + let resolve; + const done = new Promise((res) => { resolve = res; }); + + output.write(ESC.hideCursor); + try { input.setRawMode?.(true); } catch { /* not a tty */ } + input.setEncoding?.("utf8"); + input.resume?.(); + input.on?.("data", onData); + process.on("SIGINT", onSignal); + process.on("SIGTERM", onSignal); + + draw(); + schedule(); + await done; + restore(); + process.off("SIGINT", onSignal); + process.off("SIGTERM", onSignal); + output.write(` ${ash("thanks for playing 🤘")}\n`); + return 0; +} + +/* ----------------------------------------------------------------- command */ + +/** + * `/games [name]` in the pit, `moshcode games [name]` from a shell. + * + * The two are the same call; only the exit code is read by the CLI. Listing + * works anywhere, including a pipe — starting a game does not, because raw mode + * is how every one of them reads a key. + */ +export async function gamesCommand(argv = [], deps = {}) { + const { + out = (s) => console.log(s), + fail = (s) => console.error(s), + input = process.stdin, + output = process.stdout, + interactive = Boolean(input.isTTY && output.isTTY), + prefix, + ...rest + } = deps; + + const args = argv.filter((a) => a !== undefined && a !== null).map(String); + const json = args.includes("--json"); + const positional = args.filter((a) => !a.startsWith("-")); + const [name] = positional; + + if (json && (!name || name === "list")) { out(JSON.stringify(gamesModel(), null, 2)); return 0; } + if (!name || name === "list" || name === "ls" || name === "games") { out(renderList({ prefix })); return 0; } + + const game = resolveGame(name); + if (!game) { + fail(`${danger("✗ ")}no game called "${name}". ${ash(`try: ${GAMES.map((g) => g.key).join(" · ")}`)}`); + return 1; + } + if (!interactive) { + fail(`${danger("✗ ")}${game.key} needs an interactive terminal — it reads single keypresses.`); + fail(`${ash("· ")}${ash("run it from the pit, or a real shell — `moshcode games list` works anywhere.")}`); + return 1; + } + + return runGame(game, { input, output, ...rest }); +} + +/* Shared by more than one game, and kept here so they agree on what a wall or a + * hazard looks like. A game that invents its own palette stops looking like the + * arcade it is in. */ +export const PALETTE = { + wall: (s) => ash(s), + empty: (s) => dim(s), + you: (s) => acid(s), + prize: (s) => amber(s), + hazard: (s) => danger(s), + piece: (s) => bone(s), + cool: rgb(90, 200, 250), + violet: rgb(190, 130, 255), + rose: rgb(255, 120, 180), +}; diff --git a/src/tui.mjs b/src/tui.mjs index 4287b86..87a69ff 100644 --- a/src/tui.mjs +++ b/src/tui.mjs @@ -23,6 +23,7 @@ import { moshVocabulary } from "./commands.mjs"; import { mcpCommand, pluginCommand, skillCommand } from "./integrations.mjs"; import { stocksCommand } from "./advisor.mjs"; import { cryptoCommand } from "./crypto.mjs"; +import { gamesCommand } from "./games.mjs"; import { canOpenBrowser, openBrowser } from "./open-url.mjs"; import { banner, hr, acid, ash, bone, dim, ok, err, warn, info, moshcodeVersion } from "./ui.mjs"; import { CORE_CLI_COMMAND_NAMES } from "./cli-schema.mjs"; @@ -882,6 +883,18 @@ export async function tui() { await pluginCommand(rest); continue; } + // The arcade (src/games.mjs). Takes the terminal the way an engine session + // does, because every game reads single keypresses and readline cannot hand + // those over while it owns stdin. Listing is just printing, so it keeps the + // prompt. + if (cmd === "games" || cmd === "game" || cmd === "arcade" || cmd === "play") { + const listing = !rest.length || rest[0] === "list" || rest[0] === "ls" || rest[0] === "--json"; + if (listing) { await gamesCommand(rest, { prefix: "/games" }); continue; } + rl.close(); + await gamesCommand(rest, { prefix: "/games" }); + rl = mkrl(); + continue; + } if (cmd === "socials" || cmd === "social") { printSocials(); continue; diff --git a/src/ui.mjs b/src/ui.mjs index 14fce4a..b214132 100644 --- a/src/ui.mjs +++ b/src/ui.mjs @@ -12,7 +12,9 @@ export function moshcodeVersion() { } const useColor = process.env.NO_COLOR == null && process.stdout.isTTY === true; -const rgb = (r, g, b) => (s) => (useColor ? `\x1b[38;2;${r};${g};${b}m${s}\x1b[39m` : String(s)); +// Exported so a module with its own hues (the arcade's seven tetrominoes) mixes +// them the same way, and honours NO_COLOR without knowing it exists. +export const rgb = (r, g, b) => (s) => (useColor ? `\x1b[38;2;${r};${g};${b}m${s}\x1b[39m` : String(s)); const wrap = (o, c) => (s) => (useColor ? `\x1b[${o}m${s}\x1b[${c}m` : String(s)); export const acid = rgb(158, 240, 26); diff --git a/test/games.test.mjs b/test/games.test.mjs new file mode 100644 index 0000000..ccfd53a --- /dev/null +++ b/test/games.test.mjs @@ -0,0 +1,637 @@ +// The arcade, played without a terminal. +// +// Every game is pure — a state, a key, a tick — which is what makes this +// possible: these tests finish real games of tetris, pac-man and chess, win +// hangman, and prove the tic-tac-toe opponent cannot be beaten, with no TTY and +// no timers left running. +import test from "node:test"; +import assert from "node:assert/strict"; +import { EventEmitter } from "node:events"; + +import { + GAMES, decodeKeys, frame, gamesCommand, gamesModel, renderList, resolveGame, runGame, strip, visible, +} from "../src/games.mjs"; +import { + TETRIS, SHAPES, clearLines, collides, emptyBoard, landing, rotate, HEIGHT as T_HEIGHT, WIDTH as T_WIDTH, +} from "../src/games-tetris.mjs"; +import { SNAKE, WIDTH as S_WIDTH, HEIGHT as S_HEIGHT, step } from "../src/games-snake.mjs"; +import { PACMAN, MAZE, isWall, pellets, WIDTH as P_WIDTH, HEIGHT as P_HEIGHT } from "../src/games-pacman.mjs"; +import { TICTACTOE, bestMove, emptyBoard as emptyGrid, winner } from "../src/games-tictactoe.mjs"; +import { HANGMAN, MISSES_ALLOWED, WORDS, guess, mask } from "../src/games-hangman.mjs"; +import { + CHESS, allMoves, apply, chooseMove, inCheck, legalMoves, name, outcome, parseBoard, +} from "../src/games-chess.mjs"; + +/** A predictable rng — the games take one, so a test can replay a session. */ +const seeded = (seed = 1) => () => { + seed = (seed * 16807) % 2147483647; + return (seed - 1) / 2147483646; +}; + +/* ------------------------------------------------------------- the cabinet */ + +test("every game satisfies the shape the driver expects", () => { + for (const game of GAMES) { + assert.match(game.key, /^[a-z]+$/, "a game key is what someone types"); + for (const field of ["title", "blurb", "keys"]) { + assert.ok(game[field]?.length, `${game.key} has no ${field}`); + } + assert.equal(typeof game.create, "function", `${game.key} cannot start`); + assert.equal(typeof game.onKey, "function", `${game.key} ignores the keyboard`); + assert.equal(typeof game.render, "function"); + assert.equal(typeof game.status, "function"); + // A real-time game must have something for the clock to call. + if (game.tickMs) assert.equal(typeof game.tick, "function", `${game.key} ticks into nothing`); + // Every game explains its own controls, and every one of them takes q. + assert.match(game.keys, /q quit/, `${game.key} does not say how to leave`); + } +}); + +test("game names are unique, aliases included", () => { + const seen = new Set(); + for (const game of GAMES) { + for (const label of [game.key, ...(game.aliases || [])]) { + assert.ok(!seen.has(label), `"${label}" names two games`); + seen.add(label); + } + } +}); + +test("a game resolves however it is spelled", () => { + assert.equal(resolveGame("tetris")?.key, "tetris"); + assert.equal(resolveGame("/TETRIS")?.key, "tetris"); + // The spelling in the request this was built from, and the one everybody + // types second. + assert.equal(resolveGame("tiktaktoe")?.key, "tictactoe"); + assert.equal(resolveGame("tic-tac-toe")?.key, "tictactoe"); + assert.equal(resolveGame("ttt")?.key, "tictactoe"); + assert.equal(resolveGame("pac")?.key, "pacman"); + assert.equal(resolveGame(""), null); + assert.equal(resolveGame("doom"), null); +}); + +test("the list names every game and how to start one", () => { + const listed = strip(renderList({ prefix: "/games" })); + for (const game of GAMES) { + assert.ok(listed.includes(game.key), `${game.key} is missing from the list`); + assert.ok(listed.includes(game.blurb), `${game.key} is listed with no blurb`); + } + assert.match(listed, /\/games tetris/, "the list should say how to play one"); + assert.match(strip(renderList()), /moshcode games tetris/, "and how to from a shell"); +}); + +test("the roster is available as data", () => { + const model = gamesModel(); + assert.equal(model.games.length, GAMES.length); + assert.deepEqual(model.games.map((g) => g.name), GAMES.map((g) => g.key)); + assert.equal(model.games.find((g) => g.name === "tetris").realtime, true); + assert.equal(model.games.find((g) => g.name === "chess").realtime, true); + assert.equal(model.games.find((g) => g.name === "hangman").realtime, false); +}); + +/* -------------------------------------------------------------- the frame */ + +test("the frame is a rectangle, whatever the board is made of", () => { + const rows = ["ab", "a much longer row than that one", "c"]; + const lines = frame({ title: "GAME", status: "score 3", rows, keys: "q quit" }).split("\n"); + const boxed = lines.slice(1, -1); // between header and key line + const widths = new Set(boxed.map(visible)); + assert.equal(widths.size, 1, `the box is ragged: ${[...widths].join(", ")}`); +}); + +test("the key line does not stretch the board", () => { + // The bug this replaces: a 20-column tetris well drawn inside a 54-column + // frame, because the footer was measured as if it were part of the box. + const narrow = frame({ title: "T", rows: ["####"], keys: "q quit" }); + const wide = frame({ title: "T", rows: ["####"], keys: "a very long line of key hints indeed, look at it go" }); + assert.equal(visible(narrow.split("\n")[1]), visible(wide.split("\n")[1])); +}); + +test("the status sits on the right when it fits, and beside the title when it does not", () => { + const roomy = frame({ title: "GAME", status: "ok", rows: ["x".repeat(40)] }).split("\n")[0]; + assert.match(strip(roomy), /GAME {2,}ok$/); + const tight = frame({ title: "GAME", status: "a status far too long for this board", rows: ["xx"] }).split("\n")[0]; + assert.match(strip(tight), /^ {2}GAME {2}a status/); +}); + +/* ---------------------------------------------------------------- the keys */ + +test("raw bytes become key names", () => { + assert.deepEqual(decodeKeys("\x1b[A\x1b[B\x1b[C\x1b[D"), ["up", "down", "right", "left"]); + assert.deepEqual(decodeKeys("\r"), ["enter"]); + assert.deepEqual(decodeKeys(" "), ["space"]); + assert.deepEqual(decodeKeys("\x03"), ["quit"]); + assert.deepEqual(decodeKeys("\x1b"), ["escape"]); + assert.deepEqual(decodeKeys("Q"), ["q"], "shift is not a different key here"); + // hjkl everywhere, for free. + assert.deepEqual(decodeKeys("hjkl"), ["left", "down", "up", "right"]); + // Holding a key down delivers several at once. + assert.deepEqual(decodeKeys("\x1b[Ax\x1b[A"), ["up", "x", "up"]); + // An unknown escape sequence is swallowed, not misread as three keys. + assert.deepEqual(decodeKeys("\x1b[Z"), []); +}); + +/* ------------------------------------------------------------------ tetris */ + +test("a shape turns without changing size", () => { + assert.deepEqual(rotate(SHAPES.O), SHAPES.O, "a square is a square"); + assert.deepEqual(rotate(SHAPES.I), ["..I.", "..I.", "..I.", "..I."]); + assert.deepEqual(rotate(rotate(rotate(rotate(SHAPES.T)))), SHAPES.T, "four turns is where you started"); +}); + +test("a piece collides with the walls, the floor, and the stack", () => { + const board = emptyBoard(); + assert.equal(collides(board, SHAPES.O, -1, 0), true, "off the left"); + assert.equal(collides(board, SHAPES.O, T_WIDTH - 1, 0), true, "off the right"); + assert.equal(collides(board, SHAPES.O, 0, T_HEIGHT - 1), true, "through the floor"); + assert.equal(collides(board, SHAPES.O, 0, 0), false); + board[4][0] = "I"; + assert.equal(collides(board, SHAPES.O, 0, 3), true, "into the stack"); +}); + +test("full rows clear and everything above drops", () => { + const board = emptyBoard(); + board[T_HEIGHT - 1] = Array.from({ length: T_WIDTH }, () => "I"); + board[T_HEIGHT - 2][3] = "T"; + assert.equal(clearLines(board), 1); + assert.equal(board[T_HEIGHT - 1][3], "T", "the row above fell into the gap"); + assert.equal(board[0].every((c) => c === null), true, "and the top is empty again"); +}); + +test("a slam drops the piece to its landing square and locks it", () => { + const state = TETRIS.create({ rng: seeded(3) }); + const target = landing(state); + TETRIS.onKey(state, "space"); + assert.notEqual(state.piece.y, target, "a lock spawns the next piece"); + const filled = state.board.filter((row) => row.some(Boolean)).length; + assert.ok(filled > 0, "the slammed piece is part of the board now"); + assert.ok(state.score > 0, "and a slam pays for the distance"); +}); + +test("tetris ends when the stack reaches the ceiling", () => { + const state = TETRIS.create({ rng: seeded(7) }); + for (let i = 0; i < 200 && !state.over; i++) TETRIS.onKey(state, "space"); + assert.ok(state.over, "200 slammed pieces should fill a ten-wide well"); + assert.equal(typeof TETRIS.status(state), "string"); + assert.match(strip(TETRIS.status(state)), /score/); +}); + +test("clearing a line pays, and the level follows the lines", () => { + const state = TETRIS.create({ rng: seeded(11) }); + state.board[T_HEIGHT - 1] = Array.from({ length: T_WIDTH }, (_, x) => (x < T_WIDTH - 2 ? "I" : null)); + state.piece = { key: "O", shape: SHAPES.O, x: T_WIDTH - 2, y: 0 }; + TETRIS.onKey(state, "space"); + assert.equal(state.lines, 1); + assert.ok(state.score >= 100, `a cleared line should pay: ${state.score}`); +}); + +test("a piece rotating in the gutter kicks off the wall", () => { + const state = TETRIS.create({ rng: seeded(5) }); + state.piece = { key: "I", shape: SHAPES.I, x: -1, y: 5 }; + TETRIS.onKey(state, "up"); + assert.equal(collides(state.board, state.piece.shape, state.piece.x, state.piece.y), false); +}); + +/* ------------------------------------------------------------------- snake */ + +test("the snake grows on food and keeps its length otherwise", () => { + const state = SNAKE.create({ rng: seeded(2) }); + const head = state.snake[0]; + state.food = [head[0] + 1, head[1]]; + const before = state.snake.length; + step(state); + assert.equal(state.snake.length, before + 1, "eating grows it"); + assert.equal(state.score, 10); + step(state); + assert.equal(state.snake.length, before + 1, "and a plain step does not"); +}); + +test("the snake dies on the wall and on itself", () => { + const wall = SNAKE.create({ rng: seeded(2) }); + wall.snake = [[S_WIDTH - 1, 0]]; + wall.dir = "right"; + step(wall); + assert.match(wall.over, /wall/); + + const self = SNAKE.create({ rng: seeded(2) }); + self.snake = [[5, 5], [6, 5], [6, 6], [5, 6], [4, 6]]; + self.dir = "down"; + self.food = null; + step(self); + assert.match(self.over, /itself/); +}); + +test("the snake cannot reverse into its own neck", () => { + const state = SNAKE.create({ rng: seeded(2) }); + SNAKE.onKey(state, "left"); // it is travelling right + assert.equal(state.dir, "right"); + SNAKE.onKey(state, "up"); + assert.equal(state.dir, "up"); + SNAKE.onKey(state, "left"); + assert.equal(state.dir, "up", "one turn per tick"); +}); + +test("food never lands under the snake", () => { + const state = SNAKE.create({ rng: seeded(9) }); + for (let i = 0; i < 300 && !state.over; i++) { + if (state.food) assert.ok(!state.snake.some(([x, y]) => x === state.food[0] && y === state.food[1])); + SNAKE.onKey(state, ["up", "right", "down", "left"][i % 4]); + step(state); + } +}); + +test("the snake board is drawn to size", () => { + const state = SNAKE.create({ rng: seeded(2) }); + const rows = SNAKE.render(state); + assert.equal(rows.length, S_HEIGHT); + assert.equal(visible(rows[0]), S_WIDTH); +}); + +/* ------------------------------------------------------------------ pacman */ + +test("the maze is one connected place — every dot is reachable", () => { + // A maze with a walled-off pocket is a game that cannot be won, and no + // amount of playing it by hand would reliably find the pocket. + const start = { x: MAZE.findIndex(() => true), y: 0 }; + let pac = null; + for (let y = 0; y < P_HEIGHT; y++) { + const x = MAZE[y].indexOf("P"); + if (x >= 0) pac = { x, y }; + } + assert.ok(pac, "the maze must say where pac starts"); + assert.ok(start); + + const seen = new Set([`${pac.x},${pac.y}`]); + const queue = [pac]; + while (queue.length) { + const { x, y } = queue.shift(); + for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) { + const nx = x + dx; + const ny = y + dy; + const key = `${nx},${ny}`; + if (isWall(nx, ny) || seen.has(key)) continue; + seen.add(key); + queue.push({ x: nx, y: ny }); + } + } + for (const key of pellets().keys()) { + assert.ok(seen.has(key), `the dot at ${key} is walled off from the start`); + } +}); + +test("the maze is a rectangle with a wall all the way round", () => { + for (const row of MAZE) assert.equal(row.length, P_WIDTH); + for (let x = 0; x < P_WIDTH; x++) { + assert.equal(MAZE[0][x], "#", "the top leaks"); + assert.equal(MAZE[P_HEIGHT - 1][x], "#", "the bottom leaks"); + } + for (const row of MAZE) { + assert.equal(row[0], "#"); + assert.equal(row[P_WIDTH - 1], "#"); + } +}); + +test("eating a dot scores, and a pellet makes the ghosts edible", () => { + const state = PACMAN.create({ rng: seeded(4) }); + const before = state.dots.size; + PACMAN.onKey(state, "left"); + PACMAN.tick(state); + assert.equal(state.dots.size, before - 1, "pac ate the dot it walked onto"); + assert.equal(state.score, 10); + + // Stand pac on a power pellet and let the tick eat it. + const pellet = [...state.dots.entries()].find(([, kind]) => kind === "o")[0]; + const [px, py] = pellet.split(",").map(Number); + state.pac = { x: px + 1, y: py, dir: "left", want: "left" }; + PACMAN.tick(state); + assert.ok(state.fright > 0, "the ghosts should be running"); +}); + +test("a ghost costs a life, and the third one ends it", () => { + const state = PACMAN.create({ rng: seeded(6) }); + state.lives = 1; + const ghost = state.ghosts[0]; + state.pac = { x: ghost.x, y: ghost.y, dir: "left", want: "left" }; + // The tick moves pac first; put a ghost where it is about to arrive. + state.ghosts[0] = { ...ghost, x: ghost.x - 1, y: ghost.y }; + PACMAN.tick(state); + assert.equal(state.over, "game over"); +}); + +test("eating the last dot wins the maze", () => { + const state = PACMAN.create({ rng: seeded(8) }); + const next = { x: state.pac.x - 1, y: state.pac.y }; + state.dots = new Map([[`${next.x},${next.y}`, "."]]); + PACMAN.tick(state); + assert.match(state.over, /cleared/); +}); + +test("a ghost never steps into a wall", () => { + const state = PACMAN.create({ rng: seeded(12) }); + for (let i = 0; i < 400 && !state.over; i++) { + PACMAN.onKey(state, ["left", "up", "right", "down"][i % 4]); + PACMAN.tick(state); + for (const ghost of state.ghosts) { + assert.equal(isWall(ghost.x, ghost.y), false, `a ghost is inside a wall at ${ghost.x},${ghost.y}`); + } + assert.equal(isWall(state.pac.x, state.pac.y), false, "pac is inside a wall"); + } +}); + +/* -------------------------------------------------------------- tictactoe */ + +test("three in a row is spotted in every direction", () => { + assert.equal(winner(["X", "X", "X", null, null, null, null, null, null]), "X"); + assert.equal(winner(["O", null, null, "O", null, null, "O", null, null]), "O"); + assert.equal(winner(["X", null, null, null, "X", null, null, null, "X"]), "X"); + assert.equal(winner(emptyGrid()), null); + assert.equal(winner(["X", "O", "X", "X", "O", "O", "O", "X", "X"]), "draw"); +}); + +test("the opponent takes the win in front of it, and blocks the one against it", () => { + assert.equal(bestMove(["O", "O", null, "X", "X", null, null, null, null], "O", () => 0), 2, "take the win"); + assert.equal(bestMove(["X", "X", null, "O", null, null, null, null, null], "O", () => 0), 2, "block the loss"); +}); + +test("the opponent cannot be beaten", () => { + // 40 games of random play against the search. A single loss here means the + // minimax is wrong, and it is the sort of wrong nobody notices by playing. + const rng = seeded(13); + for (let game = 0; game < 40; game++) { + const board = emptyGrid(); + for (;;) { + const open = board.map((c, i) => (c ? null : i)).filter((i) => i !== null); + if (!open.length || winner(board)) break; + board[open[Math.floor(rng() * open.length) % open.length]] = "X"; + if (winner(board)) break; + const reply = bestMove(board, "O", rng); + if (reply == null) break; + board[reply] = "O"; + } + assert.notEqual(winner(board), "X", `random play beat the machine: ${board.join(",")}`); + } +}); + +test("marking a square answers immediately, and a taken square is refused", () => { + const state = TICTACTOE.create(); + state.cursor = 0; + TICTACTOE.onKey(state, "enter", { rng: seeded(3) }); + assert.equal(state.board[0], "X"); + assert.equal(state.board.filter((c) => c === "O").length, 1, "the machine replied"); + const before = state.board.slice(); + TICTACTOE.onKey(state, "enter", { rng: seeded(3) }); + assert.deepEqual(state.board, before, "you cannot play a square twice"); +}); + +test("the cursor wraps rather than sticking to an edge", () => { + const state = TICTACTOE.create(); + state.cursor = 0; + TICTACTOE.onKey(state, "left"); + assert.equal(state.cursor, 2); + TICTACTOE.onKey(state, "up"); + assert.equal(state.cursor, 8); +}); + +/* ---------------------------------------------------------------- hangman */ + +test("a right letter is revealed and a wrong one costs a limb", () => { + const state = { word: "kernel", guessed: new Set(), missed: [], over: null }; + guess(state, "e"); + assert.equal(mask(state).join(""), "_E__E_"); + guess(state, "z"); + assert.deepEqual(state.missed, ["z"]); + guess(state, "z"); + assert.deepEqual(state.missed, ["z"], "a repeat is free and tells you nothing"); + guess(state, "3"); + guess(state, "enter"); + assert.deepEqual(state.missed, ["z"], "only letters count"); +}); + +test("hangman is winnable and losable", () => { + const won = { word: "mosh", guessed: new Set(), missed: [], over: null }; + for (const c of "mosh") guess(won, c); + assert.match(won.over, /got it/); + + const lost = { word: "mosh", guessed: new Set(), missed: [], over: null }; + for (const c of "bcdfgj".slice(0, MISSES_ALLOWED)) guess(lost, c); + assert.match(lost.over, /hanged/); + assert.match(lost.over, /MOSH/, "a loss shows the word"); + assert.ok(strip(HANGMAN.render(lost).join("\n")).includes("☹"), "and finishes the gallows"); +}); + +test("every hangman word is guessable with the keys the game offers", () => { + for (const word of WORDS) { + assert.match(word, /^[a-z]+$/, `"${word}" has a character nobody can type at it`); + } +}); + +/* ------------------------------------------------------------------ chess */ + +test("a game of chess opens with twenty legal moves", () => { + const state = CHESS.create(); + assert.equal(allMoves(state).length, 20); + assert.deepEqual(legalMoves(state, 52).map((m) => name(m.to)).sort(), ["e3", "e4"]); +}); + +test("castling is offered, moves the rook, and is refused through check", () => { + const open = { board: parseBoard("4k3/8/8/8/8/8/8/R3K2R"), turn: "w", castling: { K: true, Q: true }, ep: null }; + const both = legalMoves(open, 60).filter((m) => m.castle); + assert.equal(both.length, 2, "both sides should be available"); + const after = apply(open, both.find((m) => m.castle === "K")); + assert.equal(after.board[62], "K"); + assert.equal(after.board[61], "R", "the rook came with it"); + assert.equal(after.board[63], null); + assert.equal(after.castling.K, false, "and the right is spent"); + + const watched = { ...open, board: parseBoard("4kr2/8/8/8/8/8/8/R3K2R") }; + assert.equal(legalMoves(watched, 60).some((m) => m.castle === "K"), false, "not through an attacked square"); +}); + +test("en passant captures the pawn that ran past", () => { + const state = { board: parseBoard("4k3/8/8/3pP3/8/8/8/4K3"), turn: "w", castling: {}, ep: 19 }; + const ep = legalMoves(state, 28).find((m) => m.enPassant); + assert.ok(ep, "the capture should be on offer"); + const after = apply(state, ep); + assert.equal(after.board[19], "P"); + assert.equal(after.board[27], null, "the black pawn is gone"); +}); + +test("a pawn reaching the far rank becomes a queen", () => { + const state = { board: parseBoard("4k3/P7/8/8/8/8/8/4K3"), turn: "w", castling: {}, ep: null }; + const promo = legalMoves(state, 8).find((m) => m.promotion); + assert.equal(apply(state, promo).board[0], "Q"); +}); + +test("a king may not walk into check, or stay in it", () => { + const state = { board: parseBoard("4k3/8/8/8/8/8/4r3/4K3"), turn: "w", castling: {}, ep: null }; + assert.equal(inCheck(state, true), true); + for (const move of allMoves(state, true)) { + assert.equal(inCheck(apply(state, move), true), false, `${name(move.from)}-${name(move.to)} leaves the king in check`); + } +}); + +test("checkmate and stalemate are told apart", () => { + let mate = CHESS.create(); + for (const [from, to] of [[53, 45], [12, 28], [54, 38], [3, 39]]) { + mate = apply(mate, legalMoves(mate, from).find((m) => m.to === to)); + } + assert.equal(outcome(mate), "checkmate"); + + const stuck = { board: parseBoard("7k/5Q2/6K1/8/8/8/8/8"), turn: "b", castling: {}, ep: null }; + assert.equal(inCheck(stuck, false), false); + assert.equal(outcome(stuck), "stalemate"); +}); + +test("the machine takes a piece left hanging", () => { + // Black to move, white queen on d5 undefended, and a knight on c3 that can + // reach it. + const state = { board: parseBoard("4k3/8/8/3Q4/8/2n5/8/4K3"), turn: "b", castling: {}, ep: null }; + const move = chooseMove(state, { depth: 2, rng: seeded(3) }); + assert.equal(name(move.to), "d5", `it should take the queen, played ${name(move.from)}-${name(move.to)}`); +}); + +test("picking a piece up shows where it can go, and putting it down moves it", () => { + const state = CHESS.create(); + state.cursor = 52; // e2 + CHESS.onKey(state, "enter"); + assert.equal(state.selected, 52); + assert.equal(state.targets.length, 2); + state.cursor = 36; // e4 + CHESS.onKey(state, "enter"); + assert.equal(state.board[36], "P"); + assert.equal(state.board[52], null); + assert.equal(state.turn, "b", "and it is the machine's move"); + assert.equal(state.selected, null); + + // The reply comes on the clock, so the player's own move draws first. + CHESS.tick(state, { rng: seeded(4) }); + assert.equal(state.turn, "w"); + assert.equal(state.played.length, 2); +}); + +test("a piece can be put back down without moving", () => { + const state = CHESS.create(); + state.cursor = 52; + CHESS.onKey(state, "enter"); + CHESS.onKey(state, "enter"); // same square + assert.equal(state.selected, null); + assert.equal(state.board[52], "P", "the pawn never left"); + assert.equal(state.turn, "w"); +}); + +test("black's pieces are lower case, so the board reads without colour", () => { + const rows = CHESS.render(CHESS.create()).map(strip); + assert.match(rows[0], /r {2}n {2}b {2}q {2}k/, "rank 8 is black"); + assert.match(rows[7], /R {2}N {2}B {2}Q {2}K/, "rank 1 is white"); +}); + +/* ----------------------------------------------------------- the command */ + +test("with no game named, the command lists them", async () => { + const lines = []; + assert.equal(await gamesCommand([], { out: (s) => lines.push(s), interactive: false }), 0); + assert.match(strip(lines.join("\n")), /moshcode arcade/); +}); + +test("--json prints the roster and nothing else", async () => { + const lines = []; + await gamesCommand(["--json"], { out: (s) => lines.push(s), interactive: false }); + const parsed = JSON.parse(lines.join("\n")); + assert.equal(parsed.games.length, GAMES.length); +}); + +test("an unknown game names the ones that exist", async () => { + const errors = []; + const code = await gamesCommand(["doom"], { out: () => {}, fail: (s) => errors.push(s), interactive: false }); + assert.equal(code, 1); + assert.match(strip(errors.join("\n")), /no game called "doom"/); + assert.match(strip(errors.join("\n")), /tetris/); +}); + +test("a game refuses to start where there is no keyboard", async () => { + const errors = []; + const code = await gamesCommand(["tetris"], { out: () => {}, fail: (s) => errors.push(s), interactive: false }); + assert.equal(code, 1); + assert.match(strip(errors.join("\n")), /interactive terminal/); +}); + +/* ------------------------------------------------------------- the driver */ + +/** A stdin that is not a terminal, and an stdout that is a string. */ +function fakeIO() { + const input = new EventEmitter(); + input.setRawMode = () => {}; + input.setEncoding = () => {}; + input.resume = () => {}; + input.pause = () => {}; + input.off = input.removeListener; + const written = []; + return { input, output: { write: (s) => written.push(s) }, written }; +} + +test("q leaves the game, and the last frame stays on the screen", async () => { + const { input, output, written } = fakeIO(); + const done = runGame(TICTACTOE, { input, output, rng: seeded(3) }); + await new Promise((r) => setImmediate(r)); + input.emit("data", "q"); + assert.equal(await done, 0); + const screen = strip(written.join("")); + assert.match(screen, /TIC-TAC-TOE/); + assert.match(screen, /thanks for playing/); + assert.equal(written.join("").includes("\x1b[?25h"), true, "the cursor comes back"); +}); + +test("keys reach the game and the board is redrawn", async () => { + const { input, output, written } = fakeIO(); + const done = runGame(TICTACTOE, { input, output, rng: seeded(3) }); + await new Promise((r) => setImmediate(r)); + const before = written.length; + input.emit("data", "\x1b[A"); + assert.ok(written.length > before, "a keypress should repaint"); + input.emit("data", "q"); + await done; +}); + +test("an identical frame is not repainted", async () => { + const { input, output, written } = fakeIO(); + const done = runGame(HANGMAN, { input, output, rng: seeded(3) }); + await new Promise((r) => setImmediate(r)); + const before = written.length; + input.emit("data", "\t"); // a key hangman does nothing with + assert.equal(written.length, before, "nothing changed, so nothing was drawn"); + input.emit("data", "q"); + await done; +}); + +test("a real-time game runs on the clock it is given, and stops when it ends", async () => { + const { input, output } = fakeIO(); + let fire = null; + const done = runGame(SNAKE, { + input, + output, + rng: seeded(5), + setTimer: (fn) => { fire = fn; return 1; }, + clearTimer: () => { fire = null; }, + }); + await new Promise((r) => setImmediate(r)); + assert.equal(typeof fire, "function", "the game should have armed its clock"); + for (let i = 0; i < 40 && fire; i++) { + const next = fire; + fire = null; + next(); + } + // Twenty-eight columns of board and a snake pointed at the wall: by now it + // has either crashed (clock stopped) or is still going with the clock armed. + input.emit("data", "q"); + assert.equal(await done, 0); +}); + +test("r starts another game once the last one is over", async () => { + const { input, output } = fakeIO(); + const done = runGame(HANGMAN, { input, output, rng: seeded(21) }); + await new Promise((r) => setImmediate(r)); + input.emit("data", "bcdfgjkmpqvwxz".slice(0, 12)); // wrong letters, mostly + input.emit("data", "r"); + input.emit("data", "\x03"); + assert.equal(await done, 0); +});