moshcode 0.39.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 +90 -0
- package/bin/moshcode.mjs +9 -0
- package/package.json +1 -1
- package/prd/0010-cloud-settings-sync.md +132 -0
- package/prd/README.md +1 -0
- package/src/cli-schema.mjs +65 -0
- package/src/dns-system.mjs +176 -6
- package/src/dns.mjs +33 -1
- 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/settings-sync.mjs +659 -0
- package/src/tui.mjs +18 -0
- package/src/ui.mjs +3 -1
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// Pac-Man, arcade-sized. One maze, 240-odd dots, four power pellets and three
|
|
2
|
+
// ghosts that are not quite as clever as the ones in 1980 — on purpose. This is
|
|
3
|
+
// a game you can win on a coffee break.
|
|
4
|
+
import { acid, amber, ash, dim, rgb } from "./ui.mjs";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `#` wall · `.` dot · `o` power pellet · `P` where pac starts · `G` the pen.
|
|
8
|
+
*
|
|
9
|
+
* Symmetric, fully connected, and small enough that the whole board fits above
|
|
10
|
+
* the pit's prompt without scrolling.
|
|
11
|
+
*/
|
|
12
|
+
export const MAZE = [
|
|
13
|
+
"###################",
|
|
14
|
+
"#........#........#",
|
|
15
|
+
"#o##.###.#.###.##o#",
|
|
16
|
+
"#.................#",
|
|
17
|
+
"#.##.#.#####.#.##.#",
|
|
18
|
+
"#....#...G...#....#",
|
|
19
|
+
"####.##.###.##.####",
|
|
20
|
+
"#........P........#",
|
|
21
|
+
"#.##.####.####.##.#",
|
|
22
|
+
"#o...............o#",
|
|
23
|
+
"###################",
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
export const WIDTH = MAZE[0].length;
|
|
27
|
+
export const HEIGHT = MAZE.length;
|
|
28
|
+
|
|
29
|
+
const WALL = ash("██");
|
|
30
|
+
const DOT = dim("· ");
|
|
31
|
+
const POWER = amber("✳ ");
|
|
32
|
+
const PAC = acid("● ");
|
|
33
|
+
const BLANK = " ";
|
|
34
|
+
const GHOST_COLORS = [rgb(255, 77, 61), rgb(255, 120, 180), rgb(90, 220, 250)];
|
|
35
|
+
const SCARED = rgb(90, 140, 255);
|
|
36
|
+
|
|
37
|
+
const DIRS = { up: [0, -1], down: [0, 1], left: [-1, 0], right: [1, 0] };
|
|
38
|
+
const OPPOSITE = { up: "down", down: "up", left: "right", right: "left" };
|
|
39
|
+
const FRIGHT_TICKS = 40;
|
|
40
|
+
|
|
41
|
+
export const isWall = (x, y) => MAZE[y]?.[x] === "#" || MAZE[y]?.[x] === undefined;
|
|
42
|
+
const cellKey = (x, y) => `${x},${y}`;
|
|
43
|
+
const distance = (a, b) => Math.abs(a.x - b.x) + Math.abs(a.y - b.y);
|
|
44
|
+
|
|
45
|
+
/** Every dot and pellet in the maze, as a fresh map. */
|
|
46
|
+
export function pellets() {
|
|
47
|
+
const map = new Map();
|
|
48
|
+
for (let y = 0; y < HEIGHT; y++) {
|
|
49
|
+
for (let x = 0; x < WIDTH; x++) {
|
|
50
|
+
const cell = MAZE[y][x];
|
|
51
|
+
if (cell === "." || cell === "o") map.set(cellKey(x, y), cell);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return map;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function find(char) {
|
|
58
|
+
for (let y = 0; y < HEIGHT; y++) {
|
|
59
|
+
const x = MAZE[y].indexOf(char);
|
|
60
|
+
if (x >= 0) return { x, y };
|
|
61
|
+
}
|
|
62
|
+
return { x: 1, y: 1 };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Where everything stands at the start of a life. */
|
|
66
|
+
function positions() {
|
|
67
|
+
const pen = find("G");
|
|
68
|
+
const pac = find("P");
|
|
69
|
+
return {
|
|
70
|
+
pac: { ...pac, dir: "left", want: "left" },
|
|
71
|
+
// Three ghosts abreast in the pen — the two beside it are open corridor in
|
|
72
|
+
// this maze, which is what keeps them from stepping on each other at spawn.
|
|
73
|
+
ghosts: [
|
|
74
|
+
{ x: pen.x, y: pen.y },
|
|
75
|
+
{ x: pen.x - 1, y: pen.y },
|
|
76
|
+
{ x: pen.x + 1, y: pen.y },
|
|
77
|
+
].map((g, i) => ({ ...g, home: { x: g.x, y: g.y }, dir: "up", color: i })),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Directions a ghost may take from where it stands. */
|
|
82
|
+
export function options(ghost, { allowReverse = false } = {}) {
|
|
83
|
+
const open = Object.entries(DIRS)
|
|
84
|
+
.filter(([name, [dx, dy]]) => !isWall(ghost.x + dx, ghost.y + dy)
|
|
85
|
+
&& (allowReverse || name !== OPPOSITE[ghost.dir]));
|
|
86
|
+
// A dead end is the one place reversing is the only move there is.
|
|
87
|
+
return open.length ? open : Object.entries(DIRS).filter(([, [dx, dy]]) => !isWall(ghost.x + dx, ghost.y + dy));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* One ghost step. Chases by Manhattan distance, flees while you are lit up,
|
|
92
|
+
* and takes a random legal turn one time in five so the three of them do not
|
|
93
|
+
* arrive in a single-file line.
|
|
94
|
+
*/
|
|
95
|
+
export function moveGhost(ghost, pac, { frightened = false, rng = Math.random } = {}) {
|
|
96
|
+
const open = options(ghost);
|
|
97
|
+
if (!open.length) return ghost;
|
|
98
|
+
const scored = open.map(([name, [dx, dy]]) => ({
|
|
99
|
+
name,
|
|
100
|
+
dx,
|
|
101
|
+
dy,
|
|
102
|
+
d: distance({ x: ghost.x + dx, y: ghost.y + dy }, pac),
|
|
103
|
+
}));
|
|
104
|
+
let choice;
|
|
105
|
+
if (rng() < 0.2) choice = scored[Math.floor(rng() * scored.length) % scored.length];
|
|
106
|
+
else {
|
|
107
|
+
const sorted = scored.slice().sort((a, b) => (frightened ? b.d - a.d : a.d - b.d));
|
|
108
|
+
choice = sorted[0];
|
|
109
|
+
}
|
|
110
|
+
ghost.dir = choice.name;
|
|
111
|
+
ghost.x += choice.dx;
|
|
112
|
+
ghost.y += choice.dy;
|
|
113
|
+
return ghost;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function caught(state) {
|
|
117
|
+
const hits = state.ghosts.filter((g) => g.x === state.pac.x && g.y === state.pac.y);
|
|
118
|
+
if (!hits.length) return false;
|
|
119
|
+
if (state.fright > 0) {
|
|
120
|
+
for (const g of hits) {
|
|
121
|
+
state.score += 200;
|
|
122
|
+
Object.assign(g, { x: g.home.x, y: g.home.y, dir: "up" });
|
|
123
|
+
}
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
state.lives--;
|
|
127
|
+
if (state.lives <= 0) { state.over = "game over"; return true; }
|
|
128
|
+
Object.assign(state, positions(), { fright: 0 });
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export const PACMAN = {
|
|
133
|
+
key: "pacman",
|
|
134
|
+
aliases: ["pac", "pacmam", "puckman"],
|
|
135
|
+
title: "PAC-MAN",
|
|
136
|
+
blurb: "eat the dots, dodge the ghosts, ✳ makes them edible",
|
|
137
|
+
keys: "← ↑ ↓ → steer · q quit",
|
|
138
|
+
tickMs: 150,
|
|
139
|
+
|
|
140
|
+
create({ rng = Math.random } = {}) {
|
|
141
|
+
return { ...positions(), dots: pellets(), score: 0, lives: 3, fright: 0, frame: 0, over: null, rng };
|
|
142
|
+
},
|
|
143
|
+
|
|
144
|
+
tick(state) {
|
|
145
|
+
state.frame++;
|
|
146
|
+
const pac = state.pac;
|
|
147
|
+
// A turn is remembered until it becomes legal, which is what makes a corner
|
|
148
|
+
// feel like a corner rather than a keypress you have to time.
|
|
149
|
+
const wanted = DIRS[pac.want];
|
|
150
|
+
if (wanted && !isWall(pac.x + wanted[0], pac.y + wanted[1])) pac.dir = pac.want;
|
|
151
|
+
const [dx, dy] = DIRS[pac.dir];
|
|
152
|
+
if (!isWall(pac.x + dx, pac.y + dy)) { pac.x += dx; pac.y += dy; }
|
|
153
|
+
|
|
154
|
+
const here = state.dots.get(cellKey(pac.x, pac.y));
|
|
155
|
+
if (here) {
|
|
156
|
+
state.dots.delete(cellKey(pac.x, pac.y));
|
|
157
|
+
state.score += here === "o" ? 50 : 10;
|
|
158
|
+
if (here === "o") state.fright = FRIGHT_TICKS;
|
|
159
|
+
}
|
|
160
|
+
if (!state.dots.size) { state.over = "maze cleared 🤘"; return state; }
|
|
161
|
+
if (caught(state)) return state;
|
|
162
|
+
|
|
163
|
+
// Ghosts move at half speed, and slower still while they are running away.
|
|
164
|
+
const beat = state.fright > 0 ? 3 : 2;
|
|
165
|
+
if (state.frame % beat === 0) {
|
|
166
|
+
for (const ghost of state.ghosts) {
|
|
167
|
+
moveGhost(ghost, pac, { frightened: state.fright > 0, rng: state.rng });
|
|
168
|
+
}
|
|
169
|
+
caught(state);
|
|
170
|
+
}
|
|
171
|
+
if (state.fright > 0) state.fright--;
|
|
172
|
+
return state;
|
|
173
|
+
},
|
|
174
|
+
|
|
175
|
+
onKey(state, pressed) {
|
|
176
|
+
if (DIRS[pressed]) state.pac.want = pressed;
|
|
177
|
+
return state;
|
|
178
|
+
},
|
|
179
|
+
|
|
180
|
+
status(state) {
|
|
181
|
+
const left = state.dots.size;
|
|
182
|
+
if (state.over) return `${state.over} · ${state.score} points`;
|
|
183
|
+
return `score ${state.score} · lives ${"●".repeat(Math.max(0, state.lives))} · dots ${left}`
|
|
184
|
+
+ (state.fright > 0 ? " · RUN" : "");
|
|
185
|
+
},
|
|
186
|
+
|
|
187
|
+
render(state) {
|
|
188
|
+
const rows = [];
|
|
189
|
+
for (let y = 0; y < HEIGHT; y++) {
|
|
190
|
+
let row = "";
|
|
191
|
+
for (let x = 0; x < WIDTH; x++) {
|
|
192
|
+
const ghost = state.ghosts.find((g) => g.x === x && g.y === y);
|
|
193
|
+
if (state.pac.x === x && state.pac.y === y) row += PAC;
|
|
194
|
+
else if (ghost) row += (state.fright > 0 ? SCARED : GHOST_COLORS[ghost.color])("▲ ");
|
|
195
|
+
else if (MAZE[y][x] === "#") row += WALL;
|
|
196
|
+
else {
|
|
197
|
+
const pellet = state.dots.get(cellKey(x, y));
|
|
198
|
+
row += pellet === "o" ? POWER : pellet === "." ? DOT : BLANK;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
rows.push(row);
|
|
202
|
+
}
|
|
203
|
+
return rows;
|
|
204
|
+
},
|
|
205
|
+
};
|