@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.
- package/.claude-plugin/plugin.json +10 -0
- package/.codex-plugin/plugin.json +21 -0
- package/README.md +32 -8
- package/dist/index.js +286 -19
- package/overlay/dist/app.js +74 -0
- package/overlay/dist/index.html +1589 -0
- package/overlay-release.json +2 -2
- package/package.json +28 -4
- package/scripts/install-adapters.sh +25 -14
- package/scripts/install-adapters.test.sh +12 -2
- package/scripts/materialize-agents-plugin.sh +8 -6
- package/scripts/npm-pack.test.ts +51 -0
- package/scripts/overlay.ts +1 -1
- package/src/analytics/events.test.ts +77 -0
- package/src/analytics/events.ts +85 -0
- package/src/analytics/posthog.test.ts +85 -0
- package/src/analytics/posthog.ts +88 -0
- package/src/auth-pages.ts +108 -0
- package/src/auth.test.ts +255 -0
- package/src/auth.ts +511 -0
- package/src/core/game.ts +35 -0
- package/src/core/key.ts +12 -0
- package/src/core/rng.ts +41 -0
- package/src/data/bc-export.test.ts +153 -0
- package/src/data/bc-export.ts +125 -0
- package/src/data/trajectory.ts +103 -0
- package/src/games/connect4/connect4.ts +81 -0
- package/src/games/connect4/engine.test.ts +77 -0
- package/src/games/connect4/engine.ts +89 -0
- package/src/games/g2048/engine.test.ts +138 -0
- package/src/games/g2048/engine.ts +109 -0
- package/src/games/g2048/game2048.ts +104 -0
- package/src/games/minesweeper/engine.test.ts +83 -0
- package/src/games/minesweeper/engine.ts +112 -0
- package/src/games/minesweeper/minesweeper.ts +107 -0
- package/src/games/registry.test.ts +16 -0
- package/src/games/registry.ts +130 -0
- package/src/games/snake/engine.test.ts +89 -0
- package/src/games/snake/engine.ts +95 -0
- package/src/games/snake/snake.ts +77 -0
- package/src/games/sokoban/engine.test.ts +49 -0
- package/src/games/sokoban/engine.ts +152 -0
- package/src/games/sokoban/sokoban.ts +103 -0
- package/src/games/sudoku/engine.test.ts +88 -0
- package/src/games/sudoku/engine.ts +99 -0
- package/src/games/sudoku/sudoku.ts +100 -0
- package/src/games/tetris/engine.test.ts +378 -0
- package/src/games/tetris/engine.ts +236 -0
- package/src/games/tetris/tetris.ts +220 -0
- package/src/index.ts +82 -0
- package/src/recording/chunks.ts +93 -0
- package/src/recording/config.test.ts +31 -0
- package/src/recording/config.ts +37 -0
- package/src/recording/contracts.test.ts +71 -0
- package/src/recording/gameplay.test.ts +70 -0
- package/src/recording/gameplay.ts +18 -0
- package/src/recording/http-transport.test.ts +197 -0
- package/src/recording/identity.test.ts +26 -0
- package/src/recording/identity.ts +38 -0
- package/src/recording/ids.ts +27 -0
- package/src/recording/permissions.ts +10 -0
- package/src/recording/recorder.test.ts +388 -0
- package/src/recording/recorder.ts +464 -0
- package/src/recording/store.test.ts +231 -0
- package/src/recording/store.ts +305 -0
- package/src/recording/terminal-grid.test.ts +88 -0
- package/src/recording/terminal-grid.ts +248 -0
- package/src/recording/trace-recorder.test.ts +287 -0
- package/src/recording/trace-recorder.ts +560 -0
- package/src/recording/transport.ts +170 -0
- package/src/recording/types.test.ts +18 -0
- package/src/recording/types.ts +326 -0
- package/src/recording/uploader.test.ts +193 -0
- package/src/recording/uploader.ts +103 -0
- package/src/results/completion.ts +100 -0
- package/src/results/http-result-transport.test.ts +75 -0
- package/src/results/local-score.test.ts +32 -0
- package/src/results/local-score.ts +39 -0
- package/src/results/results.test.ts +383 -0
- package/src/results/store.ts +149 -0
- package/src/results/transport.ts +79 -0
- package/src/results/types.ts +20 -0
- package/src/results/uploader.ts +84 -0
- package/src/sync/config.test.ts +108 -0
- package/src/sync/config.ts +115 -0
- package/src/sync/coordinator.test.ts +423 -0
- package/src/sync/coordinator.ts +277 -0
- package/src/sync/game-start-queue.test.ts +93 -0
- package/src/sync/game-start-queue.ts +45 -0
- package/src/sync/identity-client.test.ts +34 -0
- package/src/sync/identity-client.ts +43 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import type { Game, GameVariation } from "../core/game.js";
|
|
2
|
+
import { Connect4Game } from "./connect4/connect4.js";
|
|
3
|
+
import { Game2048 } from "./g2048/game2048.js";
|
|
4
|
+
import { MinesweeperGame } from "./minesweeper/minesweeper.js";
|
|
5
|
+
import { SnakeGame } from "./snake/snake.js";
|
|
6
|
+
import { SokobanGame } from "./sokoban/sokoban.js";
|
|
7
|
+
import { SudokuGame } from "./sudoku/sudoku.js";
|
|
8
|
+
import { TetrisGame } from "./tetris/tetris.js";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Central game registry. The menu, the CLI (`gamepigeon <id>`), and any
|
|
12
|
+
* future launcher all read from here — adding a game is one entry.
|
|
13
|
+
* Entries without a factory show up as "coming soon" and can't be launched.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** What the TUI shell needs beyond the core gym-style contract. */
|
|
17
|
+
export interface PlayableGame extends Game<unknown, string> {
|
|
18
|
+
isOver(): boolean;
|
|
19
|
+
hasWon(): boolean;
|
|
20
|
+
/**
|
|
21
|
+
* Auto-advance interval in ms. Games that implement this must accept a
|
|
22
|
+
* "tick" action; the shell steps it on this cadence (Tetris gravity,
|
|
23
|
+
* Snake movement).
|
|
24
|
+
*/
|
|
25
|
+
tickMs?(): number;
|
|
26
|
+
/**
|
|
27
|
+
* Optional key → action translation so action ids can stay semantic
|
|
28
|
+
* ("reveal") while keys stay generic ("space"). Keys are logical key
|
|
29
|
+
* names, single lowercase chars, or "space".
|
|
30
|
+
*/
|
|
31
|
+
readonly keymap?: Readonly<Record<string, string>>;
|
|
32
|
+
/** Full engine state for privileged replay checkpoints, when it exceeds the visible state. */
|
|
33
|
+
checkpointState?(): unknown;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface GameEntry {
|
|
37
|
+
/** Stable id used for CLI launch (`gamepigeon 2048`) and lookups. */
|
|
38
|
+
id: string;
|
|
39
|
+
/** Version of the game rules/state contract captured in recordings. */
|
|
40
|
+
version: string;
|
|
41
|
+
title: string;
|
|
42
|
+
/** One-liner shown next to the title in the menu. */
|
|
43
|
+
description: string;
|
|
44
|
+
/** Null while the game is still in development ("coming soon"). */
|
|
45
|
+
create: ((variationId?: string) => PlayableGame) | null;
|
|
46
|
+
variations: readonly GameVariation[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const CLASSIC_VARIATION: GameVariation = {
|
|
50
|
+
id: "classic",
|
|
51
|
+
title: "Standard",
|
|
52
|
+
description: "Standard rules.",
|
|
53
|
+
goal: "Standard rules",
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/** Launch trio — shown first in the arcade menu; everything else stays below. */
|
|
57
|
+
export const FEATURED_GAME_IDS = ["2048", "tetris", "minesweeper"] as const;
|
|
58
|
+
|
|
59
|
+
export const REGISTRY: readonly GameEntry[] = [
|
|
60
|
+
{
|
|
61
|
+
id: "2048",
|
|
62
|
+
version: "1",
|
|
63
|
+
title: "2048",
|
|
64
|
+
description: "Merge tiles to reach 2048.",
|
|
65
|
+
create: (variationId) => new Game2048(variationId),
|
|
66
|
+
variations: Game2048.VARIATIONS,
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
id: "tetris",
|
|
70
|
+
version: "1",
|
|
71
|
+
title: "Tetris",
|
|
72
|
+
description: "Clear lines with falling blocks.",
|
|
73
|
+
create: (variationId) => new TetrisGame(variationId),
|
|
74
|
+
variations: TetrisGame.VARIATIONS,
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
id: "minesweeper",
|
|
78
|
+
version: "1",
|
|
79
|
+
title: "Minesweeper",
|
|
80
|
+
description: "Clear the field, avoid the mines.",
|
|
81
|
+
create: () => new MinesweeperGame(),
|
|
82
|
+
variations: [CLASSIC_VARIATION],
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
id: "snake",
|
|
86
|
+
version: "1",
|
|
87
|
+
title: "Snake",
|
|
88
|
+
description: "Eat, grow, don't crash.",
|
|
89
|
+
create: () => new SnakeGame(),
|
|
90
|
+
variations: [CLASSIC_VARIATION],
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
id: "sokoban",
|
|
94
|
+
version: "1",
|
|
95
|
+
title: "Sokoban",
|
|
96
|
+
description: "Push crates onto goals.",
|
|
97
|
+
create: () => new SokobanGame(),
|
|
98
|
+
variations: [CLASSIC_VARIATION],
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
id: "connect4",
|
|
102
|
+
version: "1",
|
|
103
|
+
title: "Connect Four",
|
|
104
|
+
description: "Line up four before the bot.",
|
|
105
|
+
create: () => new Connect4Game(),
|
|
106
|
+
variations: [CLASSIC_VARIATION],
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
id: "sudoku",
|
|
110
|
+
version: "1",
|
|
111
|
+
title: "Sudoku",
|
|
112
|
+
description: "Fill every row, column, and box 1–9.",
|
|
113
|
+
create: () => new SudokuGame(),
|
|
114
|
+
variations: [CLASSIC_VARIATION],
|
|
115
|
+
},
|
|
116
|
+
];
|
|
117
|
+
|
|
118
|
+
export function isPlayable(entry: GameEntry): boolean {
|
|
119
|
+
return entry.create !== null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Case-insensitive lookup by registry id. */
|
|
123
|
+
export function findGame(id: string): GameEntry | undefined {
|
|
124
|
+
const norm = id.trim().toLowerCase();
|
|
125
|
+
return REGISTRY.find((e) => e.id === norm);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function playableGames(): GameEntry[] {
|
|
129
|
+
return REGISTRY.filter(isPlayable);
|
|
130
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { Rng } from "../../core/rng.js";
|
|
3
|
+
import { HEIGHT, WIDTH, newSnake, opposite, placeFood, tick } from "./engine.js";
|
|
4
|
+
import { SnakeGame } from "./snake.js";
|
|
5
|
+
|
|
6
|
+
describe("snake engine", () => {
|
|
7
|
+
it("starts alive, length 3, heading right", () => {
|
|
8
|
+
const s = newSnake(new Rng(1));
|
|
9
|
+
expect(s.alive).toBe(true);
|
|
10
|
+
expect(s.snake).toHaveLength(3);
|
|
11
|
+
expect(s.dir).toBe("right");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("never places food on the snake", () => {
|
|
15
|
+
const rng = new Rng(7);
|
|
16
|
+
const s = newSnake(rng);
|
|
17
|
+
for (let i = 0; i < 50; i++) {
|
|
18
|
+
const f = placeFood(s, rng);
|
|
19
|
+
expect(s.snake.some((p) => p.x === f.x && p.y === f.y)).toBe(false);
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("grows and scores when eating", () => {
|
|
24
|
+
const rng = new Rng(1);
|
|
25
|
+
const s = newSnake(rng);
|
|
26
|
+
s.food = { x: s.snake[0]!.x + 1, y: s.snake[0]!.y };
|
|
27
|
+
const res = tick(s, rng);
|
|
28
|
+
expect(res.ate).toBe(true);
|
|
29
|
+
expect(s.score).toBe(1);
|
|
30
|
+
expect(s.snake).toHaveLength(4);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("dies on the wall", () => {
|
|
34
|
+
const rng = new Rng(1);
|
|
35
|
+
const s = newSnake(rng);
|
|
36
|
+
let died = false;
|
|
37
|
+
for (let i = 0; i < WIDTH + 2 && !died; i++) died = tick(s, rng).died;
|
|
38
|
+
expect(died).toBe(true);
|
|
39
|
+
expect(s.alive).toBe(false);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("opposite is symmetric", () => {
|
|
43
|
+
for (const d of ["up", "down", "left", "right"] as const) {
|
|
44
|
+
expect(opposite(opposite(d))).toBe(d);
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("SnakeGame", () => {
|
|
50
|
+
it("reports the next food position when food is eaten", () => {
|
|
51
|
+
const g = new SnakeGame();
|
|
52
|
+
g.reset(42);
|
|
53
|
+
const state = g.state();
|
|
54
|
+
state.food = { x: state.snake[0]!.x + 1, y: state.snake[0]!.y };
|
|
55
|
+
(g as unknown as { st: typeof state }).st = state;
|
|
56
|
+
expect(g.step("tick").randomEffects).toMatchObject({
|
|
57
|
+
kind: "food_spawned",
|
|
58
|
+
position: { x: expect.any(Number), y: expect.any(Number) },
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("ignores reversal, accepts turns, and dies deterministically per seed", () => {
|
|
63
|
+
const g = new SnakeGame();
|
|
64
|
+
g.reset(42);
|
|
65
|
+
expect(g.step("left").moved).toBe(false); // reverse of initial "right"
|
|
66
|
+
expect(g.step("up").moved).toBe(true);
|
|
67
|
+
let steps = 0;
|
|
68
|
+
while (!g.isOver() && steps < WIDTH * HEIGHT * 4) {
|
|
69
|
+
g.step("tick");
|
|
70
|
+
steps++;
|
|
71
|
+
}
|
|
72
|
+
expect(g.isOver()).toBe(true);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("prevents double-turn self-collision crash", () => {
|
|
76
|
+
const g = new SnakeGame();
|
|
77
|
+
g.reset(42);
|
|
78
|
+
// Heading right. Pressing "up" (legal turn) then "left" (opposite of "right") before tick.
|
|
79
|
+
expect(g.step("up").moved).toBe(true);
|
|
80
|
+
expect(g.step("left").moved).toBe(false); // Should be ignored because it is opposite to last movement dir ("right")
|
|
81
|
+
|
|
82
|
+
// Check that state dir remains "up"
|
|
83
|
+
expect(g.state().dir).toBe("up");
|
|
84
|
+
|
|
85
|
+
// After tick, the snake should successfully move up and stay alive
|
|
86
|
+
expect(g.step("tick").done).toBe(false);
|
|
87
|
+
expect(g.isOver()).toBe(false);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type { Rng } from "../../core/rng.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Pure snake rules: a head-first body on a walled grid, one food at a time.
|
|
5
|
+
* The shell drives movement with "tick" actions on a fixed cadence.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface Point {
|
|
9
|
+
x: number;
|
|
10
|
+
y: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type Dir = "up" | "down" | "left" | "right";
|
|
14
|
+
|
|
15
|
+
export interface SnakeState {
|
|
16
|
+
width: number;
|
|
17
|
+
height: number;
|
|
18
|
+
/** Head first. */
|
|
19
|
+
snake: Point[];
|
|
20
|
+
dir: Dir;
|
|
21
|
+
food: Point;
|
|
22
|
+
score: number;
|
|
23
|
+
alive: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const WIDTH = 20;
|
|
27
|
+
export const HEIGHT = 13;
|
|
28
|
+
/** Eat this many foods to "win" the episode (play continues after). */
|
|
29
|
+
export const WIN_SCORE = 25;
|
|
30
|
+
|
|
31
|
+
const DELTA: Record<Dir, Point> = {
|
|
32
|
+
up: { x: 0, y: -1 },
|
|
33
|
+
down: { x: 0, y: 1 },
|
|
34
|
+
left: { x: -1, y: 0 },
|
|
35
|
+
right: { x: 1, y: 0 },
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export function opposite(dir: Dir): Dir {
|
|
39
|
+
return dir === "up" ? "down" : dir === "down" ? "up" : dir === "left" ? "right" : "left";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function newSnake(rng: Rng): SnakeState {
|
|
43
|
+
const cy = Math.floor(HEIGHT / 2);
|
|
44
|
+
const cx = Math.floor(WIDTH / 2);
|
|
45
|
+
const snake = [
|
|
46
|
+
{ x: cx, y: cy },
|
|
47
|
+
{ x: cx - 1, y: cy },
|
|
48
|
+
{ x: cx - 2, y: cy },
|
|
49
|
+
];
|
|
50
|
+
const state: SnakeState = { width: WIDTH, height: HEIGHT, snake, dir: "right", food: { x: 0, y: 0 }, score: 0, alive: true };
|
|
51
|
+
state.food = placeFood(state, rng);
|
|
52
|
+
return state;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Random empty cell for the next food. */
|
|
56
|
+
export function placeFood(state: SnakeState, rng: Rng): Point {
|
|
57
|
+
const taken = new Set(state.snake.map((p) => `${p.x},${p.y}`));
|
|
58
|
+
const free: Point[] = [];
|
|
59
|
+
for (let y = 0; y < state.height; y++) {
|
|
60
|
+
for (let x = 0; x < state.width; x++) {
|
|
61
|
+
if (!taken.has(`${x},${y}`)) free.push({ x, y });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return free[rng.int(free.length)] ?? { x: 0, y: 0 };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface TickResult {
|
|
68
|
+
ate: boolean;
|
|
69
|
+
died: boolean;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Advance one cell in the current direction, mutating `state`. */
|
|
73
|
+
export function tick(state: SnakeState, rng: Rng): TickResult {
|
|
74
|
+
if (!state.alive) return { ate: false, died: false };
|
|
75
|
+
const head = state.snake[0]!;
|
|
76
|
+
const d = DELTA[state.dir];
|
|
77
|
+
const next = { x: head.x + d.x, y: head.y + d.y };
|
|
78
|
+
|
|
79
|
+
const hitWall = next.x < 0 || next.y < 0 || next.x >= state.width || next.y >= state.height;
|
|
80
|
+
// Tail cell frees up this tick unless we grow into it by eating.
|
|
81
|
+
const ate = next.x === state.food.x && next.y === state.food.y;
|
|
82
|
+
const body = ate ? state.snake : state.snake.slice(0, -1);
|
|
83
|
+
const hitSelf = body.some((p) => p.x === next.x && p.y === next.y);
|
|
84
|
+
if (hitWall || hitSelf) {
|
|
85
|
+
state.alive = false;
|
|
86
|
+
return { ate: false, died: true };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
state.snake = [next, ...body];
|
|
90
|
+
if (ate) {
|
|
91
|
+
state.score += 1;
|
|
92
|
+
state.food = placeFood(state, rng);
|
|
93
|
+
}
|
|
94
|
+
return { ate, died: false };
|
|
95
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { Game, StepResult } from "../../core/game.js";
|
|
2
|
+
import { Rng } from "../../core/rng.js";
|
|
3
|
+
import { WIN_SCORE, newSnake, opposite, tick, type Dir, type SnakeState } from "./engine.js";
|
|
4
|
+
|
|
5
|
+
export type SnakeAction = Dir | "tick";
|
|
6
|
+
|
|
7
|
+
function getLastMovementDir(snake: Array<{ x: number; y: number }>): Dir | null {
|
|
8
|
+
if (snake.length < 2) return null;
|
|
9
|
+
const head = snake[0]!;
|
|
10
|
+
const neck = snake[1]!;
|
|
11
|
+
if (head.x > neck.x) return "right";
|
|
12
|
+
if (head.x < neck.x) return "left";
|
|
13
|
+
if (head.y > neck.y) return "down";
|
|
14
|
+
if (head.y < neck.y) return "up";
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export class SnakeGame implements Game<SnakeState, SnakeAction> {
|
|
19
|
+
readonly name = "snake";
|
|
20
|
+
readonly actions = ["up", "down", "left", "right", "tick"] as const;
|
|
21
|
+
|
|
22
|
+
private rng = new Rng(0);
|
|
23
|
+
private st: SnakeState = newSnake(this.rng);
|
|
24
|
+
private won = false;
|
|
25
|
+
|
|
26
|
+
reset(seed: number): void {
|
|
27
|
+
this.rng = new Rng(seed);
|
|
28
|
+
this.st = newSnake(this.rng);
|
|
29
|
+
this.won = false;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
tickMs(): number {
|
|
33
|
+
return Math.max(70, 150 - this.st.score * 4);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
step(action: SnakeAction): StepResult {
|
|
37
|
+
if (!this.st.alive) return { reward: 0, moved: false, done: true, success: this.won };
|
|
38
|
+
if (action !== "tick") {
|
|
39
|
+
// Reversing into yourself is never legal; ignore it instead of dying.
|
|
40
|
+
// We check against both the current dir and the last actual movement direction to prevent double-turn self-collision.
|
|
41
|
+
const lastMoveDir = getLastMovementDir(this.st.snake);
|
|
42
|
+
const isOppositeToLastMove = lastMoveDir && action === opposite(lastMoveDir);
|
|
43
|
+
if (action === this.st.dir || action === opposite(this.st.dir) || isOppositeToLastMove) {
|
|
44
|
+
return { reward: 0, moved: false, done: false, success: this.won };
|
|
45
|
+
}
|
|
46
|
+
this.st.dir = action;
|
|
47
|
+
return { reward: 0, moved: true, done: false, success: this.won };
|
|
48
|
+
}
|
|
49
|
+
const res = tick(this.st, this.rng);
|
|
50
|
+
if (res.ate && this.st.score >= WIN_SCORE) this.won = true;
|
|
51
|
+
return {
|
|
52
|
+
reward: res.ate ? 1 : 0,
|
|
53
|
+
moved: true,
|
|
54
|
+
done: !this.st.alive,
|
|
55
|
+
success: this.won,
|
|
56
|
+
...(res.ate
|
|
57
|
+
? { randomEffects: { kind: "food_spawned", position: { ...this.st.food } } }
|
|
58
|
+
: {}),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
state(): SnakeState {
|
|
63
|
+
return { ...this.st, snake: this.st.snake.map((p) => ({ ...p })), food: { ...this.st.food } };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
score(): number {
|
|
67
|
+
return this.st.score;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
isOver(): boolean {
|
|
71
|
+
return !this.st.alive;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
hasWon(): boolean {
|
|
75
|
+
return this.won;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { LEVELS, isSolvedLevel, move, parseLevel, solvable } from "./engine.js";
|
|
3
|
+
import { SokobanGame } from "./sokoban.js";
|
|
4
|
+
|
|
5
|
+
describe("sokoban engine", () => {
|
|
6
|
+
it("every packed level is solvable (BFS proof)", () => {
|
|
7
|
+
for (const level of LEVELS) {
|
|
8
|
+
expect(solvable(level)).toBe(true);
|
|
9
|
+
}
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("walking, pushing, and blocked pushes behave", () => {
|
|
13
|
+
const l = parseLevel(["#####", "#@$.#", "#####"]);
|
|
14
|
+
const res = move(l, "right");
|
|
15
|
+
expect(res.moved).toBe(true);
|
|
16
|
+
expect(res.pushed).toBe(true);
|
|
17
|
+
expect(isSolvedLevel(l)).toBe(true);
|
|
18
|
+
// Box now sits against the wall; pushing further is illegal.
|
|
19
|
+
expect(move(l, "right").moved).toBe(false);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("cannot push two boxes at once", () => {
|
|
23
|
+
const l = parseLevel(["######", "#@$$.#", "######"]);
|
|
24
|
+
expect(move(l, "right").moved).toBe(false);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe("SokobanGame", () => {
|
|
29
|
+
it("solving level 1 advances to level 2 and pays the level reward", () => {
|
|
30
|
+
const g = new SokobanGame();
|
|
31
|
+
g.reset(0);
|
|
32
|
+
const res = g.step("right");
|
|
33
|
+
expect(res.reward).toBe(100);
|
|
34
|
+
expect(res.done).toBe(false);
|
|
35
|
+
expect(g.state().level).toBe(1);
|
|
36
|
+
expect(g.score()).toBe(100);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("undo restores the previous position", () => {
|
|
40
|
+
const g = new SokobanGame();
|
|
41
|
+
g.reset(0);
|
|
42
|
+
g.step("right"); // now on level 2
|
|
43
|
+
const before = g.state();
|
|
44
|
+
g.step("up");
|
|
45
|
+
g.step("undo");
|
|
46
|
+
expect(g.state().player).toEqual(before.player);
|
|
47
|
+
expect(g.state().boxes).toEqual(before.boxes);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure Sokoban rules plus a small built-in level pack. Levels use the
|
|
3
|
+
* classic text format: # wall, @ player, $ box, . goal, * box-on-goal,
|
|
4
|
+
* + player-on-goal, space floor. Solvability of every level is asserted
|
|
5
|
+
* by a BFS solver in the tests.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface Point {
|
|
9
|
+
x: number;
|
|
10
|
+
y: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type Dir = "up" | "down" | "left" | "right";
|
|
14
|
+
|
|
15
|
+
export interface Level {
|
|
16
|
+
width: number;
|
|
17
|
+
height: number;
|
|
18
|
+
walls: Set<string>;
|
|
19
|
+
goals: Set<string>;
|
|
20
|
+
boxes: Set<string>;
|
|
21
|
+
player: Point;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const LEVELS: readonly string[][] = [
|
|
25
|
+
[
|
|
26
|
+
"#####",
|
|
27
|
+
"#@$.#",
|
|
28
|
+
"#####",
|
|
29
|
+
],
|
|
30
|
+
[
|
|
31
|
+
"######",
|
|
32
|
+
"# #",
|
|
33
|
+
"# $$ #",
|
|
34
|
+
"# .. #",
|
|
35
|
+
"#@ #",
|
|
36
|
+
"######",
|
|
37
|
+
],
|
|
38
|
+
[
|
|
39
|
+
"#######",
|
|
40
|
+
"# #",
|
|
41
|
+
"# .$@ #",
|
|
42
|
+
"# .$ #",
|
|
43
|
+
"# # #",
|
|
44
|
+
"#######",
|
|
45
|
+
],
|
|
46
|
+
[
|
|
47
|
+
"#######",
|
|
48
|
+
"# . #",
|
|
49
|
+
"# $ #",
|
|
50
|
+
"#@ $ .#",
|
|
51
|
+
"# #",
|
|
52
|
+
"#######",
|
|
53
|
+
],
|
|
54
|
+
[
|
|
55
|
+
"########",
|
|
56
|
+
"# . . #",
|
|
57
|
+
"# $ $ #",
|
|
58
|
+
"# @ #",
|
|
59
|
+
"########",
|
|
60
|
+
],
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
const key = (x: number, y: number): string => `${x},${y}`;
|
|
64
|
+
|
|
65
|
+
export function parseLevel(rows: readonly string[]): Level {
|
|
66
|
+
const walls = new Set<string>();
|
|
67
|
+
const goals = new Set<string>();
|
|
68
|
+
const boxes = new Set<string>();
|
|
69
|
+
let player: Point = { x: 0, y: 0 };
|
|
70
|
+
rows.forEach((row, y) => {
|
|
71
|
+
[...row].forEach((ch, x) => {
|
|
72
|
+
if (ch === "#") walls.add(key(x, y));
|
|
73
|
+
if (ch === "." || ch === "*" || ch === "+") goals.add(key(x, y));
|
|
74
|
+
if (ch === "$" || ch === "*") boxes.add(key(x, y));
|
|
75
|
+
if (ch === "@" || ch === "+") player = { x, y };
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
return { width: Math.max(...rows.map((r) => r.length)), height: rows.length, walls, goals, boxes, player };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const DELTA: Record<Dir, Point> = {
|
|
82
|
+
up: { x: 0, y: -1 },
|
|
83
|
+
down: { x: 0, y: 1 },
|
|
84
|
+
left: { x: -1, y: 0 },
|
|
85
|
+
right: { x: 1, y: 0 },
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
export interface MoveResult {
|
|
89
|
+
moved: boolean;
|
|
90
|
+
pushed: boolean;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Walk/push one step, mutating the level. */
|
|
94
|
+
export function move(level: Level, dir: Dir): MoveResult {
|
|
95
|
+
const d = DELTA[dir];
|
|
96
|
+
const nx = level.player.x + d.x;
|
|
97
|
+
const ny = level.player.y + d.y;
|
|
98
|
+
if (level.walls.has(key(nx, ny))) return { moved: false, pushed: false };
|
|
99
|
+
if (level.boxes.has(key(nx, ny))) {
|
|
100
|
+
const bx = nx + d.x;
|
|
101
|
+
const by = ny + d.y;
|
|
102
|
+
if (level.walls.has(key(bx, by)) || level.boxes.has(key(bx, by))) return { moved: false, pushed: false };
|
|
103
|
+
level.boxes.delete(key(nx, ny));
|
|
104
|
+
level.boxes.add(key(bx, by));
|
|
105
|
+
level.player = { x: nx, y: ny };
|
|
106
|
+
return { moved: true, pushed: true };
|
|
107
|
+
}
|
|
108
|
+
level.player = { x: nx, y: ny };
|
|
109
|
+
return { moved: true, pushed: false };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function isSolvedLevel(level: Level): boolean {
|
|
113
|
+
return [...level.boxes].every((b) => level.goals.has(b));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Snapshot for undo. */
|
|
117
|
+
export function snapshot(level: Level): { boxes: string[]; player: Point } {
|
|
118
|
+
return { boxes: [...level.boxes], player: { ...level.player } };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function restore(level: Level, snap: { boxes: string[]; player: Point }): void {
|
|
122
|
+
level.boxes = new Set(snap.boxes);
|
|
123
|
+
level.player = { ...snap.player };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* BFS solver used by tests to guarantee every packed level is solvable.
|
|
128
|
+
* State space is tiny for the built-in pack.
|
|
129
|
+
*/
|
|
130
|
+
export function solvable(rows: readonly string[], maxStates = 200_000): boolean {
|
|
131
|
+
const start = parseLevel(rows);
|
|
132
|
+
const enc = (l: Level): string => `${l.player.x},${l.player.y}|${[...l.boxes].sort().join(";")}`;
|
|
133
|
+
const seen = new Set([enc(start)]);
|
|
134
|
+
let frontier = [start];
|
|
135
|
+
while (frontier.length > 0 && seen.size < maxStates) {
|
|
136
|
+
const next: Level[] = [];
|
|
137
|
+
for (const state of frontier) {
|
|
138
|
+
if (isSolvedLevel(state)) return true;
|
|
139
|
+
for (const dir of ["up", "down", "left", "right"] as const) {
|
|
140
|
+
const copy: Level = { ...state, boxes: new Set(state.boxes), player: { ...state.player } };
|
|
141
|
+
if (!move(copy, dir).moved) continue;
|
|
142
|
+
const id = enc(copy);
|
|
143
|
+
if (!seen.has(id)) {
|
|
144
|
+
seen.add(id);
|
|
145
|
+
next.push(copy);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
frontier = next;
|
|
150
|
+
}
|
|
151
|
+
return frontier.some(isSolvedLevel);
|
|
152
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type { Game, StepResult } from "../../core/game.js";
|
|
2
|
+
import {
|
|
3
|
+
LEVELS,
|
|
4
|
+
isSolvedLevel,
|
|
5
|
+
move,
|
|
6
|
+
parseLevel,
|
|
7
|
+
restore,
|
|
8
|
+
snapshot,
|
|
9
|
+
type Dir,
|
|
10
|
+
type Level,
|
|
11
|
+
} from "./engine.js";
|
|
12
|
+
|
|
13
|
+
export type SokobanAction = Dir | "undo";
|
|
14
|
+
|
|
15
|
+
export interface SokobanState {
|
|
16
|
+
level: number;
|
|
17
|
+
player: { x: number; y: number };
|
|
18
|
+
boxes: string[];
|
|
19
|
+
goals: string[];
|
|
20
|
+
moves: number;
|
|
21
|
+
pushes: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
const LEVEL_REWARD = 100;
|
|
26
|
+
|
|
27
|
+
export class SokobanGame implements Game<SokobanState, SokobanAction> {
|
|
28
|
+
readonly name = "sokoban";
|
|
29
|
+
readonly actions = ["up", "down", "left", "right", "undo"] as const;
|
|
30
|
+
readonly keymap = { z: "undo", u: "undo" } as const;
|
|
31
|
+
|
|
32
|
+
private levelIndex = 0;
|
|
33
|
+
private level: Level = parseLevel(LEVELS[0]!);
|
|
34
|
+
private history: Array<{ boxes: string[]; player: { x: number; y: number } }> = [];
|
|
35
|
+
private moves = 0;
|
|
36
|
+
private pushes = 0;
|
|
37
|
+
private solvedLevels = 0;
|
|
38
|
+
private finished = false;
|
|
39
|
+
|
|
40
|
+
reset(_seed: number): void {
|
|
41
|
+
this.levelIndex = 0;
|
|
42
|
+
this.solvedLevels = 0;
|
|
43
|
+
this.finished = false;
|
|
44
|
+
this.loadLevel(0);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
private loadLevel(index: number): void {
|
|
48
|
+
this.levelIndex = index;
|
|
49
|
+
this.level = parseLevel(LEVELS[index]!);
|
|
50
|
+
this.history = [];
|
|
51
|
+
this.moves = 0;
|
|
52
|
+
this.pushes = 0;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
step(action: SokobanAction): StepResult {
|
|
56
|
+
if (this.finished) return { reward: 0, moved: false, done: true, success: true };
|
|
57
|
+
if (action === "undo") {
|
|
58
|
+
const snap = this.history.pop();
|
|
59
|
+
if (!snap) return { reward: 0, moved: false, done: false, success: false };
|
|
60
|
+
restore(this.level, snap);
|
|
61
|
+
this.moves = Math.max(0, this.moves - 1);
|
|
62
|
+
return { reward: 0, moved: true, done: false, success: false };
|
|
63
|
+
}
|
|
64
|
+
const snap = snapshot(this.level);
|
|
65
|
+
const res = move(this.level, action);
|
|
66
|
+
if (!res.moved) return { reward: 0, moved: false, done: false, success: false };
|
|
67
|
+
this.history.push(snap);
|
|
68
|
+
this.moves++;
|
|
69
|
+
if (res.pushed) this.pushes++;
|
|
70
|
+
if (!isSolvedLevel(this.level)) return { reward: 0, moved: true, done: false, success: false };
|
|
71
|
+
|
|
72
|
+
this.solvedLevels++;
|
|
73
|
+
if (this.levelIndex + 1 >= LEVELS.length) {
|
|
74
|
+
this.finished = true;
|
|
75
|
+
return { reward: LEVEL_REWARD, moved: true, done: true, success: true };
|
|
76
|
+
}
|
|
77
|
+
this.loadLevel(this.levelIndex + 1);
|
|
78
|
+
return { reward: LEVEL_REWARD, moved: true, done: false, success: false };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
state(): SokobanState {
|
|
82
|
+
return {
|
|
83
|
+
level: this.levelIndex,
|
|
84
|
+
player: { ...this.level.player },
|
|
85
|
+
boxes: [...this.level.boxes].sort(),
|
|
86
|
+
goals: [...this.level.goals].sort(),
|
|
87
|
+
moves: this.moves,
|
|
88
|
+
pushes: this.pushes,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
score(): number {
|
|
93
|
+
return this.solvedLevels * LEVEL_REWARD;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
isOver(): boolean {
|
|
97
|
+
return this.finished;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
hasWon(): boolean {
|
|
101
|
+
return this.finished;
|
|
102
|
+
}
|
|
103
|
+
}
|