moshcode 0.40.0 → 0.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -50,6 +50,7 @@ or miss one that does. A test fails the build when it drifts.
50
50
  | `moshcode doh` | hosting | run the DNS-over-HTTPS resolver |
51
51
  | `moshcode site` <br>`serve` | hosting | install web-server config for a Moshpit name |
52
52
  | `moshcode template` <br>`templates` | hosting | scaffold a stack for a Moshpit-hosted service |
53
+ | `moshcode games` <br>`game` `arcade` | arcade | the moshcode arcade — six games, no menus |
53
54
  | `moshcode pwd` <br>`where` | system | show the current directory and git context |
54
55
  | `moshcode engines` | engines | list engines and installation status |
55
56
  | `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
581
582
  confirmation in the browser. If the pit is remote or headless, `/post` prints
582
583
  the composer URL instead.
583
584
 
585
+ ## The arcade (`/games`)
586
+
587
+ Six games, in the pit or straight from a shell. There are no menus, no options
588
+ screens and no difficulty prompts — `/games tetris` is already playing.
589
+
590
+ ```sh
591
+ moshcode games # the cabinet (pit: /games)
592
+ moshcode games tetris # play one (pit: /games tetris)
593
+ moshcode games --json # the roster, for a machine
594
+ ```
595
+
596
+ ```
597
+ TETRIS score 1200 · lines 12
598
+ ┌────────────────────────────────┐
599
+ │ · · · · ████· · · · NEXT │
600
+ │ · · · · ████· · · · │
601
+ │ · · · · · · · · · · ████████ │
602
+ │ · · · · · · · · · · │
603
+ │ · · · ████· · · · · LVL 2 │
604
+ │ ████████████· ██████ │
605
+ └────────────────────────────────┘
606
+ ← → move · ↑ rotate · ↓ drop one · space slam · q quit
607
+ ```
608
+
609
+ | game | |
610
+ |---|---|
611
+ | `tetris` | stack the bricks, clear the lines, outrun gravity |
612
+ | `snake` | eat, grow, and try not to eat yourself |
613
+ | `pacman` | eat the dots, dodge the ghosts, `✳` makes them edible |
614
+ | `tictactoe` | three in a row against an opponent that cannot be beaten |
615
+ | `chess` | full rules — castling, en passant, promotion — and it plays back |
616
+ | `hangman` | six wrong letters and you are done for |
617
+
618
+ Every one of them works the same way: arrows move, `q` quits, `r` starts
619
+ another, and the controls are written along the bottom of the game itself. Each
620
+ draws in place rather than on the alternate screen, so the board you finished on
621
+ stays in your scrollback.
622
+
623
+ Playing needs a real terminal, because they read single keypresses — `moshcode
624
+ games` on its own lists them anywhere, including a pipe.
625
+
584
626
  ## Settings sync (`/save` and `/load`)
585
627
 
586
628
  Your pit becomes yours by accretion — a dozen aliases, herd rules you tuned until
package/bin/moshcode.mjs CHANGED
@@ -22,6 +22,7 @@ import { describeUninstall, uninstallPlan } from "../src/uninstall.mjs";
22
22
  import { mcpCommand, pluginCommand, skillCommand } from "../src/integrations.mjs";
23
23
  import { stocksCommand } from "../src/advisor.mjs";
24
24
  import { cryptoCommand } from "../src/crypto.mjs";
25
+ import { gamesCommand } from "../src/games.mjs";
25
26
  import { canOpenBrowser, openBrowser } from "../src/open-url.mjs";
26
27
  import { locate, tilde } from "../src/pwd.mjs";
27
28
  import { createPrd, listPrds, authoringPrompt } from "../src/prd.mjs";
@@ -398,6 +399,11 @@ async function main() {
398
399
  if (code) process.exitCode = code;
399
400
  return;
400
401
  }
402
+ if (cmd === "games" || cmd === "game" || cmd === "arcade") {
403
+ const code = await gamesCommand(rest);
404
+ if (code) process.exitCode = code;
405
+ return;
406
+ }
401
407
  if (cmd === "console") {
402
408
  const code = await consoleCommand(rest);
403
409
  if (code) process.exitCode = code;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "moshcode",
3
- "version": "0.40.0",
3
+ "version": "0.41.0",
4
4
  "type": "module",
5
5
  "description": "moshcode — a metal wrapper for coding engines and native UGig/CoinPay workflow CLIs, with OpenPRD and moshscript",
6
6
  "repository": {
@@ -24,6 +24,7 @@ export const COMMAND_GROUPS = [
24
24
  { key: "tools", title: "tools" },
25
25
  { key: "extend", title: "extend" },
26
26
  { key: "script", title: "script" },
27
+ { key: "arcade", title: "arcade" },
27
28
  { key: "account", title: "account" },
28
29
  { key: "hosting", title: "hosting" },
29
30
  { key: "system", title: "system" },
@@ -392,6 +393,27 @@ export const CORE_CLI_COMMANDS = [
392
393
  seeAlso: ["site"],
393
394
  },
394
395
  { name: "templates", aliasOf: "template", description: "alias for template" },
396
+ {
397
+ name: "games",
398
+ group: "arcade",
399
+ description: "the moshcode arcade — six games, no menus",
400
+ synopsis: [
401
+ ["moshcode games", "the cabinet, and what each one is"],
402
+ ["moshcode games <game>", "play it, right here in the terminal"],
403
+ ],
404
+ flags: [["--json", "the roster, machine-readable", ""]],
405
+ examples: [
406
+ ["moshcode games", "what is in the arcade"],
407
+ ["moshcode games tetris", ""],
408
+ ["moshcode games chess", "real rules, and it plays back"],
409
+ ["moshcode games pacman", "dots, ghosts, three lives"],
410
+ ],
411
+ seeAlso: ["help"],
412
+ note: "every game works the same way: arrows move, q quits, r starts another. "
413
+ + "Playing needs a real terminal because they read single keypresses — `moshcode games` on its own lists them anywhere.",
414
+ },
415
+ { name: "game", aliasOf: "games", description: "alias for games" },
416
+ { name: "arcade", aliasOf: "games", description: "alias for games" },
395
417
  {
396
418
  name: "pwd",
397
419
  group: "system",
@@ -907,6 +929,8 @@ export const PIT_COMMANDS = [
907
929
  description: "crypto market data from advis0r.com" },
908
930
  { name: "plugin", aliases: ["plugins"], args: "<verb> [name]", cli: "plugin",
909
931
  description: "install moshcode's slash commands into Claude Code" },
932
+ { name: "games", aliases: ["game", "arcade", "play"], args: "[game]", cli: "games",
933
+ description: "the arcade — tetris, snake, pac-man, tic-tac-toe, chess, hangman" },
910
934
  { name: "socials", aliases: ["social"], pitOnly: true,
911
935
  description: "list social networks available for posting" },
912
936
  { name: "post", args: '<social> "message"', pitOnly: true,
@@ -0,0 +1,376 @@
1
+ // Chess. Real rules — castling, en passant, promotion, check, checkmate,
2
+ // stalemate — against an alpha-beta search that is about as strong as a friend
3
+ // who plays sometimes. You are white; the machine answers immediately.
4
+ //
5
+ // The board is 64 squares of FEN letters: uppercase white, lowercase black,
6
+ // null empty, index 0 = a8 and index 63 = h1. Everything below is pure, so a
7
+ // position can be set up in a test and asked what it thinks.
8
+ import { acid, amber, ash, bone, danger, dim, rgb } from "./ui.mjs";
9
+
10
+ export const START = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR";
11
+
12
+ const VALUE = { p: 100, n: 320, b: 330, r: 500, q: 900, k: 20000 };
13
+ const KNIGHT_STEPS = [[1, 2], [2, 1], [-1, 2], [-2, 1], [1, -2], [2, -1], [-1, -2], [-2, -1]];
14
+ const DIAGONALS = [[1, 1], [1, -1], [-1, 1], [-1, -1]];
15
+ const STRAIGHTS = [[1, 0], [-1, 0], [0, 1], [0, -1]];
16
+ const ROYAL = [...DIAGONALS, ...STRAIGHTS];
17
+
18
+ export const isWhite = (piece) => Boolean(piece) && piece === piece.toUpperCase();
19
+ const friendly = (a, b) => Boolean(a) && Boolean(b) && isWhite(a) === isWhite(b);
20
+ const fileOf = (i) => i % 8;
21
+ const rankOf = (i) => Math.floor(i / 8);
22
+ const square = (x, y) => y * 8 + x;
23
+ const inside = (x, y) => x >= 0 && x < 8 && y >= 0 && y < 8;
24
+ /** The piece on a square, `null` if empty and `undefined` if off the board. */
25
+ const at = (board, x, y) => (inside(x, y) ? board[square(x, y)] : undefined);
26
+
27
+ /** Board rows of a FEN position (the placement field only). */
28
+ export function parseBoard(fen = START) {
29
+ const board = Array.from({ length: 64 }, () => null);
30
+ let i = 0;
31
+ for (const char of fen.split(" ")[0]) {
32
+ if (char === "/") continue;
33
+ if (/\d/.test(char)) { i += Number(char); continue; }
34
+ board[i++] = char;
35
+ }
36
+ return board;
37
+ }
38
+
39
+ /** Algebraic name of a square — for the move list down the side. */
40
+ export const name = (i) => "abcdefgh"[fileOf(i)] + (8 - rankOf(i));
41
+
42
+ /**
43
+ * Every move the piece on `from` could make if the king's safety were somebody
44
+ * else's problem. `attacksOnly` drops pawn pushes and castling, because a pawn
45
+ * does not attack the square in front of it and a rook cannot be taken by a
46
+ * castle — that distinction is what makes check detection correct.
47
+ */
48
+ export function pseudoMoves(state, from, { attacksOnly = false } = {}) {
49
+ const { board } = state;
50
+ const piece = board[from];
51
+ if (!piece) return [];
52
+ const white = isWhite(piece);
53
+ const type = piece.toLowerCase();
54
+ const x = fileOf(from);
55
+ const y = rankOf(from);
56
+ const moves = [];
57
+ const add = (to, extra = {}) => moves.push({ from, to, ...extra });
58
+
59
+ const slide = (dirs) => {
60
+ for (const [dx, dy] of dirs) {
61
+ for (let step = 1; step < 8; step++) {
62
+ const nx = x + dx * step;
63
+ const ny = y + dy * step;
64
+ const target = at(board, nx, ny);
65
+ if (target === undefined) break;
66
+ if (target === null) { add(square(nx, ny)); continue; }
67
+ if (!friendly(piece, target)) add(square(nx, ny), { capture: true });
68
+ break;
69
+ }
70
+ }
71
+ };
72
+ const hop = (steps) => {
73
+ for (const [dx, dy] of steps) {
74
+ const nx = x + dx;
75
+ const ny = y + dy;
76
+ const target = at(board, nx, ny);
77
+ if (target === undefined) continue;
78
+ if (target === null) add(square(nx, ny));
79
+ else if (!friendly(piece, target)) add(square(nx, ny), { capture: true });
80
+ }
81
+ };
82
+
83
+ if (type === "p") {
84
+ const dir = white ? -1 : 1;
85
+ const start = white ? 6 : 1;
86
+ const last = white ? 0 : 7;
87
+ if (!attacksOnly) {
88
+ if (at(board, x, y + dir) === null) {
89
+ add(square(x, y + dir), y + dir === last ? { promotion: true } : {});
90
+ if (y === start && at(board, x, y + 2 * dir) === null) {
91
+ add(square(x, y + 2 * dir), { double: true });
92
+ }
93
+ }
94
+ }
95
+ for (const dx of [-1, 1]) {
96
+ const nx = x + dx;
97
+ const ny = y + dir;
98
+ const target = at(board, nx, ny);
99
+ if (target === undefined) continue;
100
+ if (attacksOnly) { add(square(nx, ny)); continue; }
101
+ if (target && !friendly(piece, target)) {
102
+ add(square(nx, ny), ny === last ? { capture: true, promotion: true } : { capture: true });
103
+ } else if (target === null && state.ep === square(nx, ny)) {
104
+ add(square(nx, ny), { capture: true, enPassant: true });
105
+ }
106
+ }
107
+ return moves;
108
+ }
109
+ if (type === "n") hop(KNIGHT_STEPS);
110
+ else if (type === "b") slide(DIAGONALS);
111
+ else if (type === "r") slide(STRAIGHTS);
112
+ else if (type === "q") slide(ROYAL);
113
+ else if (type === "k") {
114
+ hop(ROYAL);
115
+ if (!attacksOnly) {
116
+ const home = white ? 60 : 4;
117
+ const rights = state.castling || {};
118
+ const empty = (...squares) => squares.every((s) => board[s] === null);
119
+ const safe = (...squares) => squares.every((s) => !attacked(state, s, !white));
120
+ if (from === home && !attacked(state, home, !white)) {
121
+ if (rights[white ? "K" : "k"] && empty(home + 1, home + 2) && safe(home + 1, home + 2)) {
122
+ add(home + 2, { castle: "K" });
123
+ }
124
+ if (rights[white ? "Q" : "q"] && empty(home - 1, home - 2, home - 3) && safe(home - 1, home - 2)) {
125
+ add(home - 2, { castle: "Q" });
126
+ }
127
+ }
128
+ }
129
+ }
130
+ return moves;
131
+ }
132
+
133
+ /** Is `target` attacked by the side `byWhite`? */
134
+ export function attacked(state, target, byWhite) {
135
+ for (let i = 0; i < 64; i++) {
136
+ const piece = state.board[i];
137
+ if (!piece || isWhite(piece) !== byWhite) continue;
138
+ for (const move of pseudoMoves(state, i, { attacksOnly: true })) {
139
+ if (move.to === target) return true;
140
+ }
141
+ }
142
+ return false;
143
+ }
144
+
145
+ export const findKing = (board, white) => board.indexOf(white ? "K" : "k");
146
+
147
+ export function inCheck(state, white) {
148
+ const king = findKing(state.board, white);
149
+ return king >= 0 && attacked(state, king, !white);
150
+ }
151
+
152
+ /** A new position with the move played. Never mutates what it was given. */
153
+ export function apply(state, move) {
154
+ const board = state.board.slice();
155
+ const piece = board[move.from];
156
+ const white = isWhite(piece);
157
+ const castling = { ...state.castling };
158
+
159
+ board[move.from] = null;
160
+ board[move.to] = move.promotion ? (white ? "Q" : "q") : piece;
161
+ if (move.enPassant) board[square(fileOf(move.to), rankOf(move.from))] = null;
162
+ if (move.castle) {
163
+ const home = white ? 60 : 4;
164
+ const [rookFrom, rookTo] = move.castle === "K" ? [home + 3, home + 1] : [home - 4, home - 1];
165
+ board[rookTo] = board[rookFrom];
166
+ board[rookFrom] = null;
167
+ }
168
+
169
+ // Rights are lost by moving the king or a rook, and by capturing a rook on
170
+ // the square it started on — the last one is the case everybody forgets.
171
+ if (piece === "K") { castling.K = false; castling.Q = false; }
172
+ if (piece === "k") { castling.k = false; castling.q = false; }
173
+ for (const [corner, right] of [[63, "K"], [56, "Q"], [7, "k"], [0, "q"]]) {
174
+ if (move.from === corner || move.to === corner) castling[right] = false;
175
+ }
176
+
177
+ return {
178
+ ...state,
179
+ board,
180
+ castling,
181
+ ep: move.double ? square(fileOf(move.from), (rankOf(move.from) + rankOf(move.to)) / 2) : null,
182
+ turn: white ? "b" : "w",
183
+ };
184
+ }
185
+
186
+ /** Pseudo-legal minus everything that walks into check. */
187
+ export function legalMoves(state, from) {
188
+ const piece = state.board[from];
189
+ if (!piece) return [];
190
+ const white = isWhite(piece);
191
+ return pseudoMoves(state, from).filter((move) => !inCheck(apply(state, move), white));
192
+ }
193
+
194
+ export function allMoves(state, white = state.turn === "w") {
195
+ const moves = [];
196
+ for (let i = 0; i < 64; i++) {
197
+ if (state.board[i] && isWhite(state.board[i]) === white) moves.push(...legalMoves(state, i));
198
+ }
199
+ return moves;
200
+ }
201
+
202
+ /** "checkmate", "stalemate", or null. */
203
+ export function outcome(state) {
204
+ if (allMoves(state).length) return null;
205
+ return inCheck(state, state.turn === "w") ? "checkmate" : "stalemate";
206
+ }
207
+
208
+ /* --------------------------------------------------------------------- ai */
209
+
210
+ // Material, plus a nudge toward the middle. Enough to make it take free pieces
211
+ // and develop rather than shuffle a rook, which is all this needs to be.
212
+ const CENTER = [0, 1, 2, 3, 3, 2, 1, 0];
213
+
214
+ export function evaluate(state) {
215
+ let total = 0;
216
+ for (let i = 0; i < 64; i++) {
217
+ const piece = state.board[i];
218
+ if (!piece) continue;
219
+ const worth = VALUE[piece.toLowerCase()] + CENTER[fileOf(i)] + CENTER[rankOf(i)];
220
+ total += isWhite(piece) ? worth : -worth;
221
+ }
222
+ return state.turn === "w" ? total : -total;
223
+ }
224
+
225
+ function negamax(state, depth, alpha, beta) {
226
+ if (depth === 0) return evaluate(state);
227
+ const moves = allMoves(state);
228
+ if (!moves.length) return inCheck(state, state.turn === "w") ? -90000 - depth : 0;
229
+ // Captures first: cheap ordering, and it is most of what alpha-beta needs to
230
+ // prune a depth-3 search down to something that answers instantly.
231
+ moves.sort((a, b) => Number(Boolean(b.capture)) - Number(Boolean(a.capture)));
232
+ let best = -Infinity;
233
+ for (const move of moves) {
234
+ const value = -negamax(apply(state, move), depth - 1, -beta, -alpha);
235
+ if (value > best) best = value;
236
+ if (best > alpha) alpha = best;
237
+ if (alpha >= beta) break;
238
+ }
239
+ return best;
240
+ }
241
+
242
+ /** The machine's reply. Ties broken at random so it is not the same game twice. */
243
+ export function chooseMove(state, { depth = 3, rng = Math.random } = {}) {
244
+ const moves = allMoves(state);
245
+ if (!moves.length) return null;
246
+ let best = -Infinity;
247
+ let picks = [];
248
+ for (const move of moves) {
249
+ const value = -negamax(apply(state, move), depth - 1, -Infinity, Infinity);
250
+ if (value > best) { best = value; picks = [move]; }
251
+ else if (value === best) picks.push(move);
252
+ }
253
+ return picks[Math.floor(rng() * picks.length) % picks.length];
254
+ }
255
+
256
+ /* ------------------------------------------------------------------- game */
257
+
258
+ // White is upper case and black is lower, the way a FEN reads — so the board is
259
+ // still playable with NO_COLOR set, where the two colours are the same colour.
260
+ const GLYPH = (piece) => (isWhite(piece) ? piece.toUpperCase() : piece.toLowerCase());
261
+ const WHITE_PIECE = bone;
262
+ const BLACK_PIECE = rgb(255, 120, 180);
263
+
264
+ function settle(state) {
265
+ const done = outcome(state);
266
+ if (!done) {
267
+ state.note = inCheck(state, state.turn === "w") ? "check" : "";
268
+ return state;
269
+ }
270
+ state.over = done === "stalemate"
271
+ ? "stalemate — nobody wins"
272
+ : state.turn === "w" ? "checkmate — the machine takes it" : "checkmate — you win 🤘";
273
+ return state;
274
+ }
275
+
276
+ export const CHESS = {
277
+ key: "chess",
278
+ aliases: ["ches", "kasparov"],
279
+ title: "CHESS",
280
+ blurb: "full rules, real opponent, pawns auto-queen",
281
+ keys: "← ↑ ↓ → move · enter pick then place · r new game · q quit",
282
+ // The search blocks for a few hundred milliseconds in an open position, so it
283
+ // runs on the clock rather than inside the keypress: your own move is on the
284
+ // board and drawn before the machine starts thinking about it. The idle beat
285
+ // is slow because nothing happens on it — the driver skips a redraw that
286
+ // would change nothing.
287
+ tickMs: (state) => (state.turn === "b" ? 80 : 400),
288
+
289
+ create() {
290
+ return {
291
+ board: parseBoard(START),
292
+ turn: "w",
293
+ castling: { K: true, Q: true, k: true, q: true },
294
+ ep: null,
295
+ cursor: 52, // e2, where most games start
296
+ selected: null,
297
+ targets: [],
298
+ played: [],
299
+ note: "",
300
+ over: null,
301
+ };
302
+ },
303
+
304
+ onKey(state, pressed, { rng = Math.random } = {}) {
305
+ const x = fileOf(state.cursor);
306
+ const y = rankOf(state.cursor);
307
+ if (pressed === "left") state.cursor = square((x + 7) % 8, y);
308
+ else if (pressed === "right") state.cursor = square((x + 1) % 8, y);
309
+ else if (pressed === "up") state.cursor = square(x, (y + 7) % 8);
310
+ else if (pressed === "down") state.cursor = square(x, (y + 1) % 8);
311
+ else if (pressed === "enter" || pressed === "space") {
312
+ if (state.selected === null) {
313
+ const piece = state.board[state.cursor];
314
+ if (!piece || !isWhite(piece)) return state;
315
+ state.selected = state.cursor;
316
+ state.targets = legalMoves(state, state.cursor);
317
+ return state;
318
+ }
319
+ // Enter on the piece again (or on a square it cannot reach) puts it back
320
+ // down. No cancel key to learn, and no way to get stuck holding a rook.
321
+ const move = state.targets.find((m) => m.to === state.cursor);
322
+ state.selected = null;
323
+ state.targets = [];
324
+ if (!move) return state;
325
+
326
+ Object.assign(state, apply(state, move));
327
+ state.played.push(`${name(move.from)}${move.capture ? "x" : "-"}${name(move.to)}`);
328
+ settle(state);
329
+ }
330
+ return state;
331
+ },
332
+
333
+ /** The machine's turn, one move per beat. Idle while white is thinking. */
334
+ tick(state, { rng = Math.random } = {}) {
335
+ if (state.turn !== "b" || state.over) return state;
336
+ const reply = chooseMove(state, { rng });
337
+ if (!reply) return settle(state);
338
+ Object.assign(state, apply(state, reply));
339
+ state.played.push(`${name(reply.from)}${reply.capture ? "x" : "-"}${name(reply.to)}`);
340
+ return settle(state);
341
+ },
342
+
343
+ status(state) {
344
+ if (state.over) return state.over;
345
+ if (state.turn === "b") return `${ash("black is thinking…")}`;
346
+ const last = state.played.slice(-1)[0];
347
+ return `you ${bone("white")}${last ? ash(` · last ${last}`) : ""}${state.note ? ` · ${danger(state.note.toUpperCase())}` : ""}`;
348
+ },
349
+
350
+ render(state) {
351
+ const targets = new Set(state.targets.map((m) => m.to));
352
+ const rows = [];
353
+ for (let y = 0; y < 8; y++) {
354
+ let row = `${ash(String(8 - y))} `;
355
+ for (let x = 0; x < 8; x++) {
356
+ const i = square(x, y);
357
+ const piece = state.board[i];
358
+ const dark = (x + y) % 2 === 1;
359
+ let glyph = piece
360
+ ? (isWhite(piece) ? WHITE_PIECE : BLACK_PIECE)(GLYPH(piece))
361
+ : targets.has(i) ? acid("◦") : dark ? dim("·") : " ";
362
+ // A piece you could take is lit up rather than dotted — the dot would
363
+ // be hidden underneath it.
364
+ if (targets.has(i) && piece) glyph = amber(GLYPH(piece));
365
+ if (i === state.cursor) row += `${acid("[")}${glyph}${acid("]")}`;
366
+ else if (i === state.selected) row += `${amber("‹")}${glyph}${amber("›")}`;
367
+ else row += ` ${glyph} `;
368
+ }
369
+ rows.push(row);
370
+ }
371
+ rows.push(` ${ash(" a b c d e f g h ")}`);
372
+ rows.push("");
373
+ rows.push(` ${dim(state.selected === null ? "enter picks a piece up" : "enter puts it down")}`);
374
+ return rows;
375
+ },
376
+ };
@@ -0,0 +1,97 @@
1
+ // Hangman. Type a letter; the gallows does the rest.
2
+ import { acid, amber, ash, bone, danger, dim } from "./ui.mjs";
3
+
4
+ /** Six wrong guesses, and the gallows is finished. */
5
+ export const GALLOWS = [
6
+ [" ┌────┐", " │ ", " │ ", " │ ", " │ ", " ═╧══════"],
7
+ [" ┌────┐", " │ ○", " │ ", " │ ", " │ ", " ═╧══════"],
8
+ [" ┌────┐", " │ ○", " │ │", " │ ", " │ ", " ═╧══════"],
9
+ [" ┌────┐", " │ ○", " │ ╱│", " │ ", " │ ", " ═╧══════"],
10
+ [" ┌────┐", " │ ○", " │ ╱│╲", " │ ", " │ ", " ═╧══════"],
11
+ [" ┌────┐", " │ ○", " │ ╱│╲", " │ │", " │ ╱ ", " ═╧══════"],
12
+ [" ┌────┐", " │ ☹", " │ ╱│╲", " │ │", " │ ╱ ╲", " ═╧══════"],
13
+ ];
14
+
15
+ export const MISSES_ALLOWED = GALLOWS.length - 1;
16
+
17
+ /** Words a moshcoder might actually shout. Nothing needing a hyphen. */
18
+ export const WORDS = [
19
+ "moshcode", "distortion", "compiler", "kernel", "segfault", "refactor",
20
+ "closure", "promise", "daemon", "binary", "pointer", "monorepo", "terminal",
21
+ "runtime", "abstraction", "recursion", "interface", "payload", "protocol",
22
+ "semaphore", "mutex", "stacktrace", "breakpoint", "heuristic", "idempotent",
23
+ "bytecode", "checksum", "firewall", "namespace", "regression", "sandbox",
24
+ "throughput", "waveform", "amplifier", "feedback", "headbang", "overdrive",
25
+ "fretboard", "downbeat", "crowdsurf", "backline", "encryption", "quantum",
26
+ ];
27
+
28
+ const LETTERS = "abcdefghijklmnopqrstuvwxyz";
29
+
30
+ /** The word with everything unguessed still hidden. */
31
+ export function mask(state) {
32
+ return state.word.split("").map((c) => (state.guessed.has(c) ? c.toUpperCase() : "_"));
33
+ }
34
+
35
+ /**
36
+ * Apply one letter. Repeats are free — guessing `e` twice costs nothing but
37
+ * also tells you nothing, which is the same deal every hangman has ever run.
38
+ */
39
+ export function guess(state, letter) {
40
+ const c = String(letter).toLowerCase();
41
+ if (!LETTERS.includes(c) || state.guessed.has(c) || state.missed.includes(c)) return state;
42
+ if (state.word.includes(c)) {
43
+ state.guessed.add(c);
44
+ if (state.word.split("").every((ch) => state.guessed.has(ch))) state.over = "got it 🤘";
45
+ return state;
46
+ }
47
+ state.missed.push(c);
48
+ if (state.missed.length >= MISSES_ALLOWED) state.over = `hanged — it was ${state.word.toUpperCase()}`;
49
+ return state;
50
+ }
51
+
52
+ export const HANGMAN = {
53
+ key: "hangman",
54
+ aliases: ["hang", "gallows"],
55
+ title: "HANGMAN",
56
+ blurb: "six wrong letters and you are done for",
57
+ keys: "a–z guess · r new word · q quit",
58
+
59
+ create({ rng = Math.random } = {}) {
60
+ return {
61
+ word: WORDS[Math.floor(rng() * WORDS.length) % WORDS.length],
62
+ guessed: new Set(),
63
+ missed: [],
64
+ over: null,
65
+ };
66
+ },
67
+
68
+ onKey(state, pressed) {
69
+ if (pressed.length !== 1) return state;
70
+ return guess(state, pressed);
71
+ },
72
+
73
+ status(state) {
74
+ if (state.over) return state.over;
75
+ const left = MISSES_ALLOWED - state.missed.length;
76
+ return `${left} wrong ${left === 1 ? "guess" : "guesses"} left`;
77
+ },
78
+
79
+ render(state) {
80
+ const art = GALLOWS[Math.min(state.missed.length, MISSES_ALLOWED)];
81
+ const shown = state.over && state.over.startsWith("hanged")
82
+ ? state.word.split("").map((c) => c.toUpperCase())
83
+ : mask(state);
84
+ const word = shown.map((c) => (c === "_" ? ash("_") : acid(c))).join(" ");
85
+ const missed = state.missed.length
86
+ ? `${ash("missed ")}${danger(state.missed.join(" ").toUpperCase())}`
87
+ : dim("no wrong letters yet");
88
+ return [
89
+ ...art.map((line) => bone(line)),
90
+ "",
91
+ ` ${word}`,
92
+ "",
93
+ ` ${missed}`,
94
+ ` ${amber("✳".repeat(MISSES_ALLOWED - state.missed.length))}${dim("✳".repeat(state.missed.length))}`,
95
+ ];
96
+ },
97
+ };