@trolleroof/tui 0.2.4 → 0.2.6

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.
Files changed (91) hide show
  1. package/.claude-plugin/plugin.json +10 -0
  2. package/.codex-plugin/plugin.json +21 -0
  3. package/README.md +32 -8
  4. package/dist/index.js +286 -19
  5. package/overlay/dist/app.js +74 -0
  6. package/overlay/dist/index.html +1589 -0
  7. package/overlay-release.json +2 -2
  8. package/package.json +28 -4
  9. package/scripts/install-adapters.sh +25 -14
  10. package/scripts/install-adapters.test.sh +12 -2
  11. package/scripts/materialize-agents-plugin.sh +8 -6
  12. package/scripts/npm-pack.test.ts +51 -0
  13. package/scripts/overlay.ts +1 -1
  14. package/src/analytics/events.test.ts +77 -0
  15. package/src/analytics/events.ts +85 -0
  16. package/src/analytics/posthog.test.ts +85 -0
  17. package/src/analytics/posthog.ts +88 -0
  18. package/src/auth-pages.ts +108 -0
  19. package/src/auth.test.ts +255 -0
  20. package/src/auth.ts +511 -0
  21. package/src/core/game.ts +35 -0
  22. package/src/core/key.ts +12 -0
  23. package/src/core/rng.ts +41 -0
  24. package/src/data/bc-export.test.ts +153 -0
  25. package/src/data/bc-export.ts +125 -0
  26. package/src/data/trajectory.ts +103 -0
  27. package/src/games/connect4/connect4.ts +81 -0
  28. package/src/games/connect4/engine.test.ts +77 -0
  29. package/src/games/connect4/engine.ts +89 -0
  30. package/src/games/g2048/engine.test.ts +138 -0
  31. package/src/games/g2048/engine.ts +109 -0
  32. package/src/games/g2048/game2048.ts +104 -0
  33. package/src/games/minesweeper/engine.test.ts +83 -0
  34. package/src/games/minesweeper/engine.ts +112 -0
  35. package/src/games/minesweeper/minesweeper.ts +107 -0
  36. package/src/games/registry.test.ts +16 -0
  37. package/src/games/registry.ts +130 -0
  38. package/src/games/snake/engine.test.ts +89 -0
  39. package/src/games/snake/engine.ts +95 -0
  40. package/src/games/snake/snake.ts +77 -0
  41. package/src/games/sokoban/engine.test.ts +49 -0
  42. package/src/games/sokoban/engine.ts +152 -0
  43. package/src/games/sokoban/sokoban.ts +103 -0
  44. package/src/games/sudoku/engine.test.ts +88 -0
  45. package/src/games/sudoku/engine.ts +99 -0
  46. package/src/games/sudoku/sudoku.ts +100 -0
  47. package/src/games/tetris/engine.test.ts +378 -0
  48. package/src/games/tetris/engine.ts +236 -0
  49. package/src/games/tetris/tetris.ts +220 -0
  50. package/src/index.ts +82 -0
  51. package/src/recording/chunks.ts +93 -0
  52. package/src/recording/config.test.ts +31 -0
  53. package/src/recording/config.ts +37 -0
  54. package/src/recording/contracts.test.ts +71 -0
  55. package/src/recording/gameplay.test.ts +70 -0
  56. package/src/recording/gameplay.ts +18 -0
  57. package/src/recording/http-transport.test.ts +197 -0
  58. package/src/recording/identity.test.ts +26 -0
  59. package/src/recording/identity.ts +38 -0
  60. package/src/recording/ids.ts +27 -0
  61. package/src/recording/permissions.ts +10 -0
  62. package/src/recording/recorder.test.ts +388 -0
  63. package/src/recording/recorder.ts +464 -0
  64. package/src/recording/store.test.ts +231 -0
  65. package/src/recording/store.ts +305 -0
  66. package/src/recording/terminal-grid.test.ts +88 -0
  67. package/src/recording/terminal-grid.ts +248 -0
  68. package/src/recording/trace-recorder.test.ts +287 -0
  69. package/src/recording/trace-recorder.ts +560 -0
  70. package/src/recording/transport.ts +170 -0
  71. package/src/recording/types.test.ts +18 -0
  72. package/src/recording/types.ts +326 -0
  73. package/src/recording/uploader.test.ts +193 -0
  74. package/src/recording/uploader.ts +103 -0
  75. package/src/results/completion.ts +100 -0
  76. package/src/results/http-result-transport.test.ts +75 -0
  77. package/src/results/local-score.test.ts +32 -0
  78. package/src/results/local-score.ts +39 -0
  79. package/src/results/results.test.ts +383 -0
  80. package/src/results/store.ts +149 -0
  81. package/src/results/transport.ts +79 -0
  82. package/src/results/types.ts +20 -0
  83. package/src/results/uploader.ts +84 -0
  84. package/src/sync/config.test.ts +108 -0
  85. package/src/sync/config.ts +115 -0
  86. package/src/sync/coordinator.test.ts +423 -0
  87. package/src/sync/coordinator.ts +277 -0
  88. package/src/sync/game-start-queue.test.ts +93 -0
  89. package/src/sync/game-start-queue.ts +45 -0
  90. package/src/sync/identity-client.test.ts +34 -0
  91. package/src/sync/identity-client.ts +43 -0
@@ -0,0 +1,138 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { Rng } from "../../core/rng.js";
3
+ import { canMove, emptyBoard, highestTile, move, spawnTile, type Board } from "./engine.js";
4
+ import { Game2048 } from "./game2048.js";
5
+
6
+ const B = (rows: number[][]): Board => rows;
7
+
8
+ describe("move", () => {
9
+ it("slides tiles left into empty space", () => {
10
+ const res = move(B([[0, 2, 0, 2], [0, 0, 4, 0], [0, 0, 0, 0], [0, 0, 0, 0]]), "left");
11
+ expect(res.board[0]).toEqual([4, 0, 0, 0]);
12
+ expect(res.board[1]).toEqual([4, 0, 0, 0]);
13
+ expect(res.gained).toBe(4);
14
+ expect(res.moved).toBe(true);
15
+ });
16
+
17
+ it("merges each tile at most once per move", () => {
18
+ const res = move(B([[2, 2, 2, 2], [4, 4, 8, 8], [0, 0, 0, 0], [0, 0, 0, 0]]), "left");
19
+ expect(res.board[0]).toEqual([4, 4, 0, 0]);
20
+ expect(res.board[1]).toEqual([8, 16, 0, 0]);
21
+ expect(res.gained).toBe(4 + 4 + 8 + 16);
22
+ });
23
+
24
+ it("does not merge cascades in a single move (4,2,2 → 4,4 not 8)", () => {
25
+ const res = move(B([[4, 2, 2, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0]]), "left");
26
+ expect(res.board[0]).toEqual([4, 4, 0, 0]);
27
+ });
28
+
29
+ it("merges toward the move direction on right", () => {
30
+ const res = move(B([[2, 2, 2, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0]]), "right");
31
+ expect(res.board[0]).toEqual([0, 0, 2, 4]);
32
+ });
33
+
34
+ it("handles up and down via transpose", () => {
35
+ const start = B([[2, 0, 0, 0], [2, 0, 0, 0], [4, 0, 0, 0], [0, 0, 0, 0]]);
36
+ const up = move(start, "up");
37
+ expect(up.board.map((r) => r[0])).toEqual([4, 4, 0, 0]);
38
+ const down = move(start, "down");
39
+ expect(down.board.map((r) => r[0])).toEqual([0, 0, 4, 4]);
40
+ });
41
+
42
+ it("reports moved=false when nothing changes", () => {
43
+ const res = move(B([[2, 4, 8, 16], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0]]), "up");
44
+ expect(res.moved).toBe(false);
45
+ expect(res.gained).toBe(0);
46
+ });
47
+
48
+ it("never mutates the input board", () => {
49
+ const start = B([[2, 2, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0]]);
50
+ const copy = start.map((r) => [...r]);
51
+ move(start, "left");
52
+ expect(start).toEqual(copy);
53
+ });
54
+ });
55
+
56
+ describe("spawnTile", () => {
57
+ it("adds exactly one 2 or 4 to an empty cell", () => {
58
+ const board = spawnTile(emptyBoard(), new Rng(42));
59
+ const tiles = board.flat().filter((v) => v !== 0);
60
+ expect(tiles).toHaveLength(1);
61
+ expect([2, 4]).toContain(tiles[0]!);
62
+ });
63
+
64
+ it("is deterministic for a given seed", () => {
65
+ const a = spawnTile(spawnTile(emptyBoard(), new Rng(7)), new Rng(7));
66
+ const b = spawnTile(spawnTile(emptyBoard(), new Rng(7)), new Rng(7));
67
+ expect(a).toEqual(b);
68
+ });
69
+ });
70
+
71
+ describe("canMove", () => {
72
+ it("is true with empty cells or adjacent equal tiles", () => {
73
+ expect(canMove(emptyBoard())).toBe(true);
74
+ expect(canMove(B([[2, 4, 2, 4], [4, 2, 4, 2], [2, 4, 2, 4], [4, 2, 4, 4]]))).toBe(true);
75
+ });
76
+
77
+ it("is false on a full checkerboard", () => {
78
+ expect(canMove(B([[2, 4, 2, 4], [4, 2, 4, 2], [2, 4, 2, 4], [4, 2, 4, 2]]))).toBe(false);
79
+ });
80
+ });
81
+
82
+ describe("Game2048 episodes", () => {
83
+ it("plays a full seeded episode deterministically", () => {
84
+ const play = (seed: number) => {
85
+ const g = new Game2048();
86
+ g.reset(seed);
87
+ const dirs = ["up", "left", "down", "right"] as const;
88
+ let total = 0;
89
+ for (let i = 0; i < 5000 && !g.isOver(); i++) {
90
+ total += g.step(dirs[i % 4]!).reward;
91
+ }
92
+ return { score: g.score(), total, state: g.state() };
93
+ };
94
+ const a = play(123);
95
+ const b = play(123);
96
+ expect(a).toEqual(b);
97
+ expect(a.score).toBe(a.total); // score is the sum of rewards
98
+ expect(highestTile(a.state.board)).toBeGreaterThanOrEqual(8);
99
+ });
100
+
101
+ it("ignores illegal moves without reward", () => {
102
+ const g = new Game2048();
103
+ g.reset(1);
104
+ // Exhaust one direction until it becomes illegal, then verify no-op.
105
+ let res = g.step("left");
106
+ while (res.moved && !res.done) res = g.step("left");
107
+ if (!res.done) {
108
+ const before = g.state();
109
+ const again = g.step("left");
110
+ expect(again.moved).toBe(false);
111
+ expect(again.reward).toBe(0);
112
+ expect(g.state()).toEqual(before);
113
+ }
114
+ });
115
+
116
+ it("reports the realized tile spawn for recording", () => {
117
+ const g = new Game2048();
118
+ g.reset(9);
119
+ const result = g.step("left");
120
+ if (!result.moved) throw new Error("seed should permit a left move");
121
+ expect(result.randomEffects).toMatchObject({
122
+ kind: "tile_spawned",
123
+ value: expect.any(Number),
124
+ x: expect.any(Number),
125
+ y: expect.any(Number),
126
+ });
127
+ });
128
+
129
+ it("keeps variants deterministic while changing their rules", () => {
130
+ const rush = new Game2048("rush");
131
+ const chaos = new Game2048("chaos");
132
+ rush.reset(42);
133
+ chaos.reset(42);
134
+ expect(rush.variation.goal).toBe("Reach 512");
135
+ expect(chaos.variation.id).toBe("chaos");
136
+ expect(rush.state()).not.toEqual(chaos.state());
137
+ });
138
+ });
@@ -0,0 +1,109 @@
1
+ import { Rng } from "../../core/rng.js";
2
+
3
+ export type Direction = "up" | "down" | "left" | "right";
4
+ export type Board = number[][]; // 4x4, 0 = empty, otherwise tile value (2, 4, 8, ...)
5
+
6
+ export const SIZE = 4;
7
+ export const WIN_TILE = 2048;
8
+
9
+ export interface MoveResult {
10
+ board: Board;
11
+ /** Sum of merged tile values created by this move (standard 2048 scoring). */
12
+ gained: number;
13
+ /** False when the move changes nothing (illegal in that direction). */
14
+ moved: boolean;
15
+ }
16
+
17
+ export function emptyBoard(): Board {
18
+ return Array.from({ length: SIZE }, () => Array<number>(SIZE).fill(0));
19
+ }
20
+
21
+ export function cloneBoard(board: Board): Board {
22
+ return board.map((row) => [...row]);
23
+ }
24
+
25
+ function emptyCells(board: Board): Array<[number, number]> {
26
+ const cells: Array<[number, number]> = [];
27
+ for (let r = 0; r < SIZE; r++) {
28
+ for (let c = 0; c < SIZE; c++) {
29
+ if (board[r]![c] === 0) cells.push([r, c]);
30
+ }
31
+ }
32
+ return cells;
33
+ }
34
+
35
+ /** Place a random tile (90% → 2, 10% → 4) in a random empty cell. */
36
+ export function spawnTile(board: Board, rng: Rng, fourChance = 0.1): Board {
37
+ const cells = emptyCells(board);
38
+ if (cells.length === 0) return board;
39
+ const [r, c] = cells[rng.int(cells.length)]!;
40
+ const next = cloneBoard(board);
41
+ next[r]![c] = rng.next() < 1 - fourChance ? 2 : 4;
42
+ return next;
43
+ }
44
+
45
+ /** Slide and merge one row toward index 0. Each tile merges at most once. */
46
+ function collapseRow(row: number[]): { row: number[]; gained: number } {
47
+ const tiles = row.filter((v) => v !== 0);
48
+ const out: number[] = [];
49
+ let gained = 0;
50
+ for (let i = 0; i < tiles.length; i++) {
51
+ if (i + 1 < tiles.length && tiles[i] === tiles[i + 1]) {
52
+ const merged = tiles[i]! * 2;
53
+ out.push(merged);
54
+ gained += merged;
55
+ i++; // skip the tile we merged with
56
+ } else {
57
+ out.push(tiles[i]!);
58
+ }
59
+ }
60
+ while (out.length < SIZE) out.push(0);
61
+ return { row: out, gained };
62
+ }
63
+
64
+ function transpose(board: Board): Board {
65
+ return board[0]!.map((_, c) => board.map((row) => row[c]!));
66
+ }
67
+
68
+ function reverseRows(board: Board): Board {
69
+ return board.map((row) => [...row].reverse());
70
+ }
71
+
72
+ /** Apply a move without spawning. Pure: never mutates the input. */
73
+ export function move(board: Board, dir: Direction): MoveResult {
74
+ // Normalize every direction to "collapse rows leftward".
75
+ let grid = cloneBoard(board);
76
+ if (dir === "up") grid = transpose(grid);
77
+ else if (dir === "down") grid = reverseRows(transpose(grid));
78
+ else if (dir === "right") grid = reverseRows(grid);
79
+
80
+ let gained = 0;
81
+ grid = grid.map((row) => {
82
+ const res = collapseRow(row);
83
+ gained += res.gained;
84
+ return res.row;
85
+ });
86
+
87
+ if (dir === "up") grid = transpose(grid);
88
+ else if (dir === "down") grid = transpose(reverseRows(grid));
89
+ else if (dir === "right") grid = reverseRows(grid);
90
+
91
+ const moved = grid.some((row, r) => row.some((v, c) => v !== board[r]![c]));
92
+ return { board: grid, gained, moved };
93
+ }
94
+
95
+ export function highestTile(board: Board): number {
96
+ return Math.max(...board.flat());
97
+ }
98
+
99
+ export function canMove(board: Board): boolean {
100
+ if (emptyCells(board).length > 0) return true;
101
+ for (let r = 0; r < SIZE; r++) {
102
+ for (let c = 0; c < SIZE; c++) {
103
+ const v = board[r]![c]!;
104
+ if (r + 1 < SIZE && board[r + 1]![c] === v) return true;
105
+ if (c + 1 < SIZE && board[r]![c + 1] === v) return true;
106
+ }
107
+ }
108
+ return false;
109
+ }
@@ -0,0 +1,104 @@
1
+ import type { Game, GameVariation, StepResult } from "../../core/game.js";
2
+ import { Rng } from "../../core/rng.js";
3
+ import {
4
+ Board,
5
+ Direction,
6
+ WIN_TILE,
7
+ canMove,
8
+ emptyBoard,
9
+ highestTile,
10
+ move,
11
+ spawnTile,
12
+ } from "./engine.js";
13
+
14
+ export interface State2048 {
15
+ board: Board;
16
+ score: number;
17
+ highest: number;
18
+ }
19
+
20
+ export class Game2048 implements Game<State2048, Direction> {
21
+ static readonly VARIATIONS: readonly GameVariation[] = [
22
+ { id: "classic", title: "Standard", description: "Standard 2048 rules.", goal: "Reach 2048" },
23
+ { id: "rush", title: "Rush", description: "Reach 512 before the board fills.", goal: "Reach 512" },
24
+ { id: "chaos", title: "Chaos", description: "More 4 tiles, faster-changing boards.", goal: "Reach 1024" },
25
+ ];
26
+
27
+ readonly name = "2048";
28
+ readonly actions = ["up", "down", "left", "right"] as const;
29
+ readonly variation: GameVariation;
30
+
31
+ private board: Board = emptyBoard();
32
+ private points = 0;
33
+ private rng = new Rng(0);
34
+ private won = false;
35
+ private over = false;
36
+ private readonly winTile: number;
37
+ private fourChance = 0.1;
38
+
39
+ constructor(variationId = "classic") {
40
+ this.variation = Game2048.VARIATIONS.find(({ id }) => id === variationId) ?? Game2048.VARIATIONS[0]!;
41
+ this.winTile = this.variation.id === "rush" ? 512 : this.variation.id === "chaos" ? 1024 : WIN_TILE;
42
+ this.fourChance = this.variation.id === "chaos" ? 0.35 : 0.1;
43
+ }
44
+
45
+ reset(seed: number): void {
46
+ this.rng = new Rng(seed);
47
+ this.fourChance = this.variation.id === "chaos" ? 0.2 + new Rng(seed).next() * 0.3 : 0.1;
48
+ this.points = 0;
49
+ this.won = false;
50
+ this.over = false;
51
+ this.board = spawnTile(spawnTile(emptyBoard(), this.rng, this.fourChance), this.rng, this.fourChance);
52
+ }
53
+
54
+ step(action: Direction): StepResult {
55
+ if (this.over) return { reward: 0, moved: false, done: true, success: this.won };
56
+
57
+ const res = move(this.board, action);
58
+ if (!res.moved) {
59
+ return { reward: 0, moved: false, done: false, success: this.won };
60
+ }
61
+
62
+ this.board = spawnTile(res.board, this.rng, this.fourChance);
63
+ let spawned: { kind: "tile_spawned"; x: number; y: number; value: number } | undefined;
64
+ for (let y = 0; y < this.board.length; y++) {
65
+ for (let x = 0; x < this.board[y]!.length; x++) {
66
+ if (res.board[y]![x] === 0 && this.board[y]![x] !== 0) {
67
+ spawned = { kind: "tile_spawned", x, y, value: this.board[y]![x]! };
68
+ }
69
+ }
70
+ }
71
+ this.points += res.gained;
72
+ if (!this.won && highestTile(this.board) >= this.winTile) this.won = true;
73
+ if (!canMove(this.board)) this.over = true;
74
+
75
+ return {
76
+ reward: res.gained,
77
+ moved: true,
78
+ done: this.over,
79
+ success: this.won,
80
+ ...(spawned ? { randomEffects: spawned } : {}),
81
+ };
82
+ }
83
+
84
+ state(): State2048 {
85
+ return {
86
+ board: this.board.map((row) => [...row]),
87
+ score: this.points,
88
+ highest: highestTile(this.board),
89
+ };
90
+ }
91
+
92
+ score(): number {
93
+ return this.points;
94
+ }
95
+
96
+ isOver(): boolean {
97
+ return this.over;
98
+ }
99
+
100
+ hasWon(): boolean {
101
+ return this.won;
102
+ }
103
+
104
+ }
@@ -0,0 +1,83 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { Rng } from "../../core/rng.js";
3
+ import { HEIGHT, MINES, WIDTH, isCleared, newField, placeMines, reveal, toggleFlag } from "./engine.js";
4
+ import { MinesweeperGame } from "./minesweeper.js";
5
+
6
+ describe("minesweeper engine", () => {
7
+ it("places exactly MINES mines, none near the first click", () => {
8
+ const field = newField();
9
+ placeMines(field, 4, 4, new Rng(3));
10
+ const mines = field.cells.flat().filter((c) => c.mine).length;
11
+ expect(mines).toBe(MINES);
12
+ for (let dy = -1; dy <= 1; dy++) {
13
+ for (let dx = -1; dx <= 1; dx++) {
14
+ expect(field.cells[4 + dy]![4 + dx]!.mine).toBe(false);
15
+ }
16
+ }
17
+ });
18
+
19
+ it("first reveal is always safe and flood-fills zeros", () => {
20
+ const field = newField();
21
+ const gained = reveal(field, 4, 4, new Rng(9));
22
+ expect(gained).toBeGreaterThan(0);
23
+ expect(field.exploded).toBe(false);
24
+ });
25
+
26
+ it("revealing a mine explodes", () => {
27
+ const field = newField();
28
+ reveal(field, 0, 0, new Rng(5));
29
+ let mineAt: [number, number] | null = null;
30
+ field.cells.forEach((row, y) =>
31
+ row.forEach((c, x) => {
32
+ if (c.mine && !mineAt) mineAt = [x, y];
33
+ }),
34
+ );
35
+ expect(mineAt).not.toBeNull();
36
+ const res = reveal(field, mineAt![0], mineAt![1], new Rng(5));
37
+ expect(res).toBe(-1);
38
+ expect(field.exploded).toBe(true);
39
+ });
40
+
41
+ it("flags block reveals and toggle off again", () => {
42
+ const field = newField();
43
+ expect(toggleFlag(field, 2, 2)).toBe(true);
44
+ expect(reveal(field, 2, 2, new Rng(1))).toBe(0);
45
+ expect(toggleFlag(field, 2, 2)).toBe(true);
46
+ expect(field.cells[2]![2]!.flagged).toBe(false);
47
+ });
48
+
49
+ it("clearing all safe cells wins", () => {
50
+ const field = newField();
51
+ reveal(field, 4, 4, new Rng(11));
52
+ for (let y = 0; y < HEIGHT; y++) {
53
+ for (let x = 0; x < WIDTH; x++) {
54
+ if (!field.cells[y]![x]!.mine) reveal(field, x, y, new Rng(11));
55
+ }
56
+ }
57
+ expect(isCleared(field)).toBe(true);
58
+ });
59
+ });
60
+
61
+ describe("MinesweeperGame", () => {
62
+ it("reports the realized mine layout on the first reveal", () => {
63
+ const g = new MinesweeperGame();
64
+ g.reset(123);
65
+ const result = g.step("reveal");
66
+ const effects = result.randomEffects as { kind: string; positions: unknown[] };
67
+ expect(effects.kind).toBe("mine_layout");
68
+ expect(Array.isArray(effects.positions)).toBe(true);
69
+ expect(effects.positions).toHaveLength(MINES);
70
+ expect(g.state()).not.toHaveProperty("mines");
71
+ expect(g.checkpointState().mines).toHaveLength(MINES);
72
+ });
73
+
74
+ it("moves the cursor within bounds and rewards reveals", () => {
75
+ const g = new MinesweeperGame();
76
+ g.reset(123);
77
+ for (let i = 0; i < 20; i++) g.step("left"); // clamp at edge
78
+ expect(g.state().cursor.x).toBe(0);
79
+ const res = g.step("reveal");
80
+ expect(res.moved).toBe(true);
81
+ expect(Math.abs(res.reward)).toBeGreaterThan(0);
82
+ });
83
+ });
@@ -0,0 +1,112 @@
1
+ import type { Rng } from "../../core/rng.js";
2
+
3
+ /**
4
+ * Pure minesweeper rules on a 9×9 / 10-mine beginner board. Mines are laid
5
+ * out on the first reveal so the opening click (and its neighbors) is
6
+ * always safe.
7
+ */
8
+
9
+ export const WIDTH = 9;
10
+ export const HEIGHT = 9;
11
+ export const MINES = 10;
12
+
13
+ export interface Cell {
14
+ mine: boolean;
15
+ revealed: boolean;
16
+ flagged: boolean;
17
+ /** Adjacent mine count, valid once mines are placed. */
18
+ adjacent: number;
19
+ }
20
+
21
+ export interface Field {
22
+ cells: Cell[][];
23
+ minesPlaced: boolean;
24
+ exploded: boolean;
25
+ }
26
+
27
+ export function newField(): Field {
28
+ const cells = Array.from({ length: HEIGHT }, () =>
29
+ Array.from({ length: WIDTH }, (): Cell => ({ mine: false, revealed: false, flagged: false, adjacent: 0 })),
30
+ );
31
+ return { cells, minesPlaced: false, exploded: false };
32
+ }
33
+
34
+ function neighbors(x: number, y: number): Array<[number, number]> {
35
+ const out: Array<[number, number]> = [];
36
+ for (let dy = -1; dy <= 1; dy++) {
37
+ for (let dx = -1; dx <= 1; dx++) {
38
+ if (dx === 0 && dy === 0) continue;
39
+ const nx = x + dx;
40
+ const ny = y + dy;
41
+ if (nx >= 0 && ny >= 0 && nx < WIDTH && ny < HEIGHT) out.push([nx, ny]);
42
+ }
43
+ }
44
+ return out;
45
+ }
46
+
47
+ /** Lay mines everywhere except the first-clicked cell and its neighbors. */
48
+ export function placeMines(field: Field, safeX: number, safeY: number, rng: Rng): void {
49
+ const banned = new Set([`${safeX},${safeY}`, ...neighbors(safeX, safeY).map(([x, y]) => `${x},${y}`)]);
50
+ const spots: Array<[number, number]> = [];
51
+ for (let y = 0; y < HEIGHT; y++) {
52
+ for (let x = 0; x < WIDTH; x++) {
53
+ if (!banned.has(`${x},${y}`)) spots.push([x, y]);
54
+ }
55
+ }
56
+ for (let i = 0; i < MINES; i++) {
57
+ const idx = rng.int(spots.length);
58
+ const [x, y] = spots.splice(idx, 1)[0]!;
59
+ field.cells[y]![x]!.mine = true;
60
+ }
61
+ for (let y = 0; y < HEIGHT; y++) {
62
+ for (let x = 0; x < WIDTH; x++) {
63
+ field.cells[y]![x]!.adjacent = neighbors(x, y).filter(([nx, ny]) => field.cells[ny]![nx]!.mine).length;
64
+ }
65
+ }
66
+ field.minesPlaced = true;
67
+ }
68
+
69
+ /**
70
+ * Reveal a cell (flood-filling zeros). Returns how many cells were newly
71
+ * revealed, or -1 if a mine went off.
72
+ */
73
+ export function reveal(field: Field, x: number, y: number, rng: Rng): number {
74
+ const cell = field.cells[y]?.[x];
75
+ if (!cell || cell.revealed || cell.flagged || field.exploded) return 0;
76
+ if (!field.minesPlaced) placeMines(field, x, y, rng);
77
+ if (cell.mine) {
78
+ field.exploded = true;
79
+ cell.revealed = true;
80
+ return -1;
81
+ }
82
+ let count = 0;
83
+ const stack: Array<[number, number]> = [[x, y]];
84
+ while (stack.length > 0) {
85
+ const [cx, cy] = stack.pop()!;
86
+ const c = field.cells[cy]![cx]!;
87
+ if (c.revealed || c.flagged || c.mine) continue;
88
+ c.revealed = true;
89
+ count++;
90
+ if (c.adjacent === 0) stack.push(...neighbors(cx, cy));
91
+ }
92
+ return count;
93
+ }
94
+
95
+ export function toggleFlag(field: Field, x: number, y: number): boolean {
96
+ const cell = field.cells[y]?.[x];
97
+ if (!cell || cell.revealed || field.exploded) return false;
98
+ cell.flagged = !cell.flagged;
99
+ return true;
100
+ }
101
+
102
+ export function revealedCount(field: Field): number {
103
+ return field.cells.flat().filter((c) => c.revealed && !c.mine).length;
104
+ }
105
+
106
+ export function isCleared(field: Field): boolean {
107
+ return !field.exploded && revealedCount(field) === WIDTH * HEIGHT - MINES;
108
+ }
109
+
110
+ export function flagCount(field: Field): number {
111
+ return field.cells.flat().filter((c) => c.flagged).length;
112
+ }
@@ -0,0 +1,107 @@
1
+ import type { Game, StepResult } from "../../core/game.js";
2
+ import { Rng } from "../../core/rng.js";
3
+ import {
4
+ HEIGHT,
5
+ MINES,
6
+ WIDTH,
7
+ flagCount,
8
+ isCleared,
9
+ newField,
10
+ reveal,
11
+ revealedCount,
12
+ toggleFlag,
13
+ type Field,
14
+ } from "./engine.js";
15
+
16
+ export type MinesweeperAction = "up" | "down" | "left" | "right" | "reveal" | "flag";
17
+
18
+ export interface MinesweeperState {
19
+ /** -1 hidden, -2 flagged, -3 mine (shown once exploded), 0-8 revealed counts. */
20
+ view: number[][];
21
+ cursor: { x: number; y: number };
22
+ revealed: number;
23
+ exploded: boolean;
24
+ }
25
+
26
+
27
+ export class MinesweeperGame implements Game<MinesweeperState, MinesweeperAction> {
28
+ readonly name = "minesweeper";
29
+ readonly actions = ["up", "down", "left", "right", "reveal", "flag"] as const;
30
+ readonly keymap = { space: "reveal", enter: "reveal", f: "flag" } as const;
31
+
32
+ private rng = new Rng(0);
33
+ private field: Field = newField();
34
+ private cx = Math.floor(WIDTH / 2);
35
+ private cy = Math.floor(HEIGHT / 2);
36
+ private cleared = false;
37
+
38
+ reset(seed: number): void {
39
+ this.rng = new Rng(seed);
40
+ this.field = newField();
41
+ this.cx = Math.floor(WIDTH / 2);
42
+ this.cy = Math.floor(HEIGHT / 2);
43
+ this.cleared = false;
44
+ }
45
+
46
+ step(action: MinesweeperAction): StepResult {
47
+ if (this.isOver()) return { reward: 0, moved: false, done: true, success: this.cleared };
48
+ if (action === "up" || action === "down" || action === "left" || action === "right") {
49
+ const nx = this.cx + (action === "left" ? -1 : action === "right" ? 1 : 0);
50
+ const ny = this.cy + (action === "up" ? -1 : action === "down" ? 1 : 0);
51
+ if (nx < 0 || ny < 0 || nx >= WIDTH || ny >= HEIGHT) return { reward: 0, moved: false, done: false, success: false };
52
+ this.cx = nx;
53
+ this.cy = ny;
54
+ return { reward: 0, moved: true, done: false, success: false };
55
+ }
56
+ if (action === "flag") {
57
+ const moved = toggleFlag(this.field, this.cx, this.cy);
58
+ return { reward: 0, moved, done: false, success: false };
59
+ }
60
+ const placedMines = !this.field.minesPlaced;
61
+ const gained = reveal(this.field, this.cx, this.cy, this.rng);
62
+ const randomEffects = placedMines
63
+ ? {
64
+ kind: "mine_layout",
65
+ positions: this.field.cells.flatMap((row, y) =>
66
+ row.flatMap((cell, x) => cell.mine ? [{ x, y }] : [])
67
+ ),
68
+ }
69
+ : undefined;
70
+ if (gained === 0) return { reward: 0, moved: false, done: false, success: false };
71
+ if (gained < 0) return { reward: 0, moved: true, done: true, success: false, randomEffects };
72
+ this.cleared = isCleared(this.field);
73
+ return { reward: gained, moved: true, done: this.cleared, success: this.cleared, randomEffects };
74
+ }
75
+
76
+ state(): MinesweeperState {
77
+ const view = this.field.cells.map((row) =>
78
+ row.map((c) => {
79
+ if (c.revealed) return c.mine ? -3 : c.adjacent;
80
+ if (c.flagged) return -2;
81
+ return -1;
82
+ }),
83
+ );
84
+ return { view, cursor: { x: this.cx, y: this.cy }, revealed: revealedCount(this.field), exploded: this.field.exploded };
85
+ }
86
+
87
+ checkpointState(): MinesweeperState & { mines: Array<{ x: number; y: number }> } {
88
+ return {
89
+ ...this.state(),
90
+ mines: this.field.cells.flatMap((row, y) =>
91
+ row.flatMap((cell, x) => cell.mine ? [{ x, y }] : [])
92
+ ),
93
+ };
94
+ }
95
+
96
+ score(): number {
97
+ return revealedCount(this.field);
98
+ }
99
+
100
+ isOver(): boolean {
101
+ return this.field.exploded || this.cleared;
102
+ }
103
+
104
+ hasWon(): boolean {
105
+ return this.cleared;
106
+ }
107
+ }
@@ -0,0 +1,16 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { playableGames } from "./registry.js";
3
+
4
+ describe("registry", () => {
5
+ for (const entry of playableGames()) {
6
+ for (const variation of entry.variations) {
7
+ it(`${entry.id} (${variation.id}) is not over immediately after reset`, () => {
8
+ for (const seed of [0, 1, 2, 42, 12345]) {
9
+ const game = entry.create!(variation.id);
10
+ game.reset(seed);
11
+ expect(game.isOver()).toBe(false);
12
+ }
13
+ });
14
+ }
15
+ }
16
+ });