moshcode 0.40.0 → 0.42.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.
@@ -0,0 +1,241 @@
1
+ // Centipede. It comes down through the mushrooms, and every piece you shoot out
2
+ // of the middle leaves you two of them.
3
+ //
4
+ // The trick that makes this cheap: the centipede is not a linked body, it is a
5
+ // list of segments that each obey the same rule — walk sideways, and when
6
+ // something is in the way, drop a row and turn round. Kept that way, a shot to
7
+ // the middle needs no surgery at all. You remove one segment and the ones
8
+ // behind it simply carry on, which is exactly what splitting looks like.
9
+ import { acid, amber, danger, dim, rgb } from "./ui.mjs";
10
+
11
+ export const WIDTH = 38;
12
+ export const HEIGHT = 18;
13
+
14
+ /** The bottom strip is yours; the centipede comes down into it. */
15
+ export const ZONE_TOP = HEIGHT - 5;
16
+ const MUSHROOM_HP = 4;
17
+ const LIVES = 3;
18
+ const SHOTS = 3;
19
+ const SHOT_SPEED = 2;
20
+
21
+ const shroom = rgb(120, 190, 40);
22
+ const MUSH_ART = ["▁", "▄", "▆", "█"]; // fuller the healthier
23
+
24
+ const key = (x, y) => `${x},${y}`;
25
+
26
+ /** A field of mushrooms, none of them in the row you stand on. */
27
+ export function seedField(rng, count = 34) {
28
+ const field = new Map();
29
+ for (let i = 0; i < count; i++) {
30
+ const x = Math.floor(rng() * WIDTH);
31
+ const y = 1 + Math.floor(rng() * (HEIGHT - 3));
32
+ if (y >= HEIGHT - 1) continue;
33
+ field.set(key(x, y), MUSHROOM_HP);
34
+ }
35
+ return field;
36
+ }
37
+
38
+ /** Chip a mushroom, and say whether one was there. A dead one is worth points. */
39
+ export function bite(field, x, y) {
40
+ const hp = field.get(key(x, y));
41
+ if (!hp) return 0;
42
+ if (hp <= 1) { field.delete(key(x, y)); return 5; }
43
+ field.set(key(x, y), hp - 1);
44
+ return 1;
45
+ }
46
+
47
+ /** A fresh centipede, strung out along the top row. */
48
+ export function newCentipede(length = 10) {
49
+ return Array.from({ length }, (_, i) => ({ x: length - 1 - i, y: 0, dir: 1, down: 1 }));
50
+ }
51
+
52
+ const blocked = (state, x, y) => x < 0 || x >= WIDTH || state.field.has(key(x, y));
53
+
54
+ /** Walk one segment: sideways if it can, otherwise down a row and about turn. */
55
+ export function walk(state, seg) {
56
+ if (!blocked(state, seg.x + seg.dir, seg.y)) { seg.x += seg.dir; return seg; }
57
+ seg.dir *= -1;
58
+ seg.y += seg.down;
59
+ // It bounces off the floor and climbs back up rather than vanishing, which is
60
+ // what keeps the bottom of the board dangerous instead of a safe corner.
61
+ if (seg.y >= HEIGHT - 1) { seg.y = HEIGHT - 1; seg.down = -1; }
62
+ if (seg.y <= 0) { seg.y = 0; seg.down = 1; }
63
+ return seg;
64
+ }
65
+
66
+ /** How many ticks between steps — shorter as the wave goes on. */
67
+ export const cadence = (state) => Math.max(2, 7 - state.wave);
68
+
69
+ /**
70
+ * The spider: in from the side, bouncing diagonally through your strip, eating
71
+ * mushrooms as it goes.
72
+ *
73
+ * It is here because without it the bottom of the board is safe. The centipede
74
+ * only reaches your row on the sweeps it happens to end on, so a player who
75
+ * simply never moves can survive a long time — which is not a game. The spider
76
+ * is the reason you cannot stand still.
77
+ */
78
+ export function spiderStep(state) {
79
+ const spider = state.spider;
80
+ if (!spider) return state;
81
+ spider.x += spider.dx;
82
+ spider.y += spider.dy;
83
+ if (spider.y < ZONE_TOP) { spider.y = ZONE_TOP; spider.dy = 1; }
84
+ if (spider.y > HEIGHT - 1) { spider.y = HEIGHT - 1; spider.dy = -1; }
85
+ // It leaves the way it came in rather than turning round at the wall, so it
86
+ // is a visitor and not a permanent resident.
87
+ if (spider.x < 0 || spider.x >= WIDTH) { state.spider = null; return state; }
88
+ state.field.delete(key(spider.x, spider.y));
89
+ return state;
90
+ }
91
+
92
+ export function spawnSpider(state) {
93
+ const fromLeft = state.rng() < 0.5;
94
+ state.spider = {
95
+ x: fromLeft ? 0 : WIDTH - 1,
96
+ y: HEIGHT - 1 - Math.floor(state.rng() * 3),
97
+ dx: fromLeft ? 1 : -1,
98
+ dy: state.rng() < 0.5 ? -1 : 1,
99
+ };
100
+ return state;
101
+ }
102
+
103
+ function hitPlayer(state) {
104
+ state.lives--;
105
+ if (state.lives <= 0) {
106
+ state.lives = 0;
107
+ state.over = `eaten · ${state.score} points`;
108
+ return state;
109
+ }
110
+ state.centipede = newCentipede(10);
111
+ state.shots = [];
112
+ state.spider = null;
113
+ state.player = { x: Math.floor(WIDTH / 2), y: HEIGHT - 1 };
114
+ return state;
115
+ }
116
+
117
+ /** One tick. Exported so a test can clear a wave with no clock. */
118
+ export function step(state) {
119
+ for (let i = 0; i < SHOT_SPEED; i++) {
120
+ for (const shot of [...state.shots]) {
121
+ shot.y -= 1;
122
+ if (shot.y < 0) { state.shots = state.shots.filter((s) => s !== shot); continue; }
123
+ const points = bite(state.field, shot.x, shot.y);
124
+ if (points) { state.score += points; state.shots = state.shots.filter((s) => s !== shot); continue; }
125
+ if (state.spider && state.spider.x === shot.x && state.spider.y === shot.y) {
126
+ state.spider = null;
127
+ state.shots = state.shots.filter((s) => s !== shot);
128
+ state.score += 300;
129
+ continue;
130
+ }
131
+ const seg = state.centipede.find((s) => s.x === shot.x && s.y === shot.y);
132
+ if (!seg) continue;
133
+ state.shots = state.shots.filter((s) => s !== shot);
134
+ state.centipede = state.centipede.filter((s) => s !== seg);
135
+ state.score += 10;
136
+ // Every piece you take out of it leaves a mushroom where it fell, which
137
+ // is how the field thickens and the next wave gets harder for free.
138
+ state.field.set(key(seg.x, seg.y), MUSHROOM_HP);
139
+ }
140
+ }
141
+
142
+ if (!state.centipede.length) {
143
+ state.wave++;
144
+ state.centipede = newCentipede(10);
145
+ state.score += 100;
146
+ return state;
147
+ }
148
+
149
+ state.clock++;
150
+ if (state.clock >= cadence(state)) {
151
+ state.clock = 0;
152
+ for (const seg of state.centipede) walk(state, seg);
153
+ if (state.centipede.some((s) => s.x === state.player.x && s.y === state.player.y)) return hitPlayer(state);
154
+ }
155
+
156
+ state.spiderClock++;
157
+ if (state.spiderClock % 3 === 0) {
158
+ spiderStep(state);
159
+ const spider = state.spider;
160
+ if (spider && spider.x === state.player.x && spider.y === state.player.y) return hitPlayer(state);
161
+ }
162
+ if (!state.spider && state.rng() < 0.02) spawnSpider(state);
163
+ return state;
164
+ }
165
+
166
+ export const CENTIPEDE = {
167
+ key: "centipede",
168
+ aliases: ["cent", "bug", "millipede"],
169
+ title: "CENTIPEDE",
170
+ blurb: "shoot it in the middle and now there are two of them",
171
+ keys: "← ↑ ↓ → move · space fire · q quit",
172
+ tickMs: 55,
173
+
174
+ create({ rng = Math.random } = {}) {
175
+ return {
176
+ field: seedField(rng),
177
+ centipede: newCentipede(10),
178
+ player: { x: Math.floor(WIDTH / 2), y: HEIGHT - 1 },
179
+ shots: [],
180
+ spider: null,
181
+ spiderClock: 0,
182
+ score: 0,
183
+ lives: LIVES,
184
+ wave: 1,
185
+ clock: 0,
186
+ over: null,
187
+ rng,
188
+ };
189
+ },
190
+
191
+ tick: step,
192
+
193
+ onKey(state, pressed) {
194
+ const p = state.player;
195
+ // You are free in the bottom strip and nowhere else — the whole game is
196
+ // fought in five rows.
197
+ if (pressed === "left") p.x = Math.max(0, p.x - 1);
198
+ else if (pressed === "right") p.x = Math.min(WIDTH - 1, p.x + 1);
199
+ else if (pressed === "up") p.y = Math.max(ZONE_TOP, p.y - 1);
200
+ else if (pressed === "down") p.y = Math.min(HEIGHT - 1, p.y + 1);
201
+ else if (pressed === "space" || pressed === "enter") {
202
+ if (state.shots.length < SHOTS) state.shots.push({ x: p.x, y: p.y - 1 });
203
+ return state;
204
+ }
205
+ // Walking into a mushroom is walking into a wall.
206
+ if (state.field.has(key(p.x, p.y))) {
207
+ if (pressed === "left") p.x += 1;
208
+ else if (pressed === "right") p.x -= 1;
209
+ else if (pressed === "up") p.y += 1;
210
+ else if (pressed === "down") p.y -= 1;
211
+ }
212
+ return state;
213
+ },
214
+
215
+ status(state) {
216
+ return state.over
217
+ ? state.over
218
+ : `${state.score} · wave ${state.wave} · ${"▲".repeat(state.lives)}`;
219
+ },
220
+
221
+ render(state) {
222
+ const grid = Array.from({ length: HEIGHT }, () => Array.from({ length: WIDTH }, () => null));
223
+ const put = (x, y, glyph) => {
224
+ if (x < 0 || x >= WIDTH || y < 0 || y >= HEIGHT) return;
225
+ grid[y][x] = glyph;
226
+ };
227
+
228
+ for (const [at, hp] of state.field) {
229
+ const [x, y] = at.split(",").map(Number);
230
+ put(x, y, shroom(MUSH_ART[hp - 1] ?? "▁"));
231
+ }
232
+ for (const seg of state.centipede) put(seg.x, seg.y, danger("◍"));
233
+ if (state.spider) put(state.spider.x, state.spider.y, rgb(190, 130, 255)("✻"));
234
+ for (const shot of state.shots) put(shot.x, shot.y, amber("│"));
235
+ put(state.player.x, state.player.y, state.over ? danger("✷") : acid("▲"));
236
+
237
+ return grid.map((row, y) => row.map((cell) => (
238
+ cell ?? (y === ZONE_TOP - 1 ? dim("┈") : " ")
239
+ )).join(""));
240
+ },
241
+ };
@@ -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
+ };