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 +42 -0
- package/bin/moshcode.mjs +6 -0
- package/package.json +1 -1
- package/src/cli-schema.mjs +24 -0
- package/src/games-chess.mjs +376 -0
- package/src/games-hangman.mjs +97 -0
- package/src/games-pacman.mjs +205 -0
- package/src/games-snake.mjs +111 -0
- package/src/games-tetris.mjs +221 -0
- package/src/games-tictactoe.mjs +124 -0
- package/src/games.mjs +337 -0
- package/src/tui.mjs +13 -0
- package/src/ui.mjs +3 -1
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
package/src/cli-schema.mjs
CHANGED
|
@@ -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
|
+
};
|