@trolleroof/tui 0.2.5 → 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 +2 -2
- package/README.md +29 -7
- package/dist/index.js +284 -18
- package/overlay/dist/app.js +74 -0
- package/overlay/dist/index.html +1589 -0
- package/overlay-release.json +2 -2
- package/package.json +27 -4
- package/scripts/install-adapters.sh +6 -6
- package/scripts/install-adapters.test.sh +2 -2
- package/scripts/materialize-agents-plugin.sh +3 -3
- 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,88 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { Rng } from "../../core/rng.js";
|
|
3
|
+
import { HOLES, SIZE, conflicts, generateSolved, isComplete, isValidPlacement, makePuzzle } from "./engine.js";
|
|
4
|
+
import { SudokuGame } from "./sudoku.js";
|
|
5
|
+
|
|
6
|
+
describe("sudoku engine", () => {
|
|
7
|
+
it("generates a fully valid solved grid", () => {
|
|
8
|
+
const grid = generateSolved(new Rng(5));
|
|
9
|
+
expect(isComplete(grid)).toBe(true);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("different seeds give different grids", () => {
|
|
13
|
+
const a = generateSolved(new Rng(1));
|
|
14
|
+
const b = generateSolved(new Rng(2));
|
|
15
|
+
expect(JSON.stringify(a)).not.toBe(JSON.stringify(b));
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("puzzles blank exactly HOLES cells and stay consistent with the solution", () => {
|
|
19
|
+
const { givens, solution } = makePuzzle(new Rng(8));
|
|
20
|
+
const holes = givens.flat().filter((v) => v === 0).length;
|
|
21
|
+
expect(holes).toBe(HOLES);
|
|
22
|
+
for (let r = 0; r < SIZE; r++) {
|
|
23
|
+
for (let c = 0; c < SIZE; c++) {
|
|
24
|
+
if (givens[r]![c] !== 0) expect(givens[r]![c]).toBe(solution[r]![c]!);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("flags conflicting placements", () => {
|
|
30
|
+
const grid = generateSolved(new Rng(3));
|
|
31
|
+
const v = grid[0]![0]!;
|
|
32
|
+
// Duplicate the corner value somewhere else in row 0.
|
|
33
|
+
grid[0]![5] = v;
|
|
34
|
+
expect(isValidPlacement(grid, 0, 5, v)).toBe(false);
|
|
35
|
+
expect(conflicts(grid).has("0,5")).toBe(true);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe("SudokuGame", () => {
|
|
40
|
+
it("exposes the hidden solution only through privileged checkpoints", () => {
|
|
41
|
+
const g = new SudokuGame();
|
|
42
|
+
g.reset(21);
|
|
43
|
+
expect(g.state()).not.toHaveProperty("solution");
|
|
44
|
+
expect(g.checkpointState().solution).toHaveLength(9);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("filling every hole from the solution completes the game", () => {
|
|
48
|
+
const g = new SudokuGame();
|
|
49
|
+
g.reset(21);
|
|
50
|
+
const st = g.state();
|
|
51
|
+
// Drive the cursor to each hole and type the solution digit.
|
|
52
|
+
const solution = (g as unknown as { puzzle: { solution: number[][] } }).puzzle.solution;
|
|
53
|
+
for (let r = 0; r < SIZE; r++) {
|
|
54
|
+
for (let c = 0; c < SIZE; c++) {
|
|
55
|
+
if (st.givens[r]![c] !== 0) continue;
|
|
56
|
+
moveTo(g, r, c);
|
|
57
|
+
const res = g.step(String(solution[r]![c]) as "1");
|
|
58
|
+
expect(res.reward).toBe(1);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
expect(g.isOver()).toBe(true);
|
|
62
|
+
expect(g.hasWon()).toBe(true);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("givens cannot be overwritten", () => {
|
|
66
|
+
const g = new SudokuGame();
|
|
67
|
+
g.reset(21);
|
|
68
|
+
const st = g.state();
|
|
69
|
+
outer: for (let r = 0; r < SIZE; r++) {
|
|
70
|
+
for (let c = 0; c < SIZE; c++) {
|
|
71
|
+
if (st.givens[r]![c] !== 0) {
|
|
72
|
+
moveTo(g, r, c);
|
|
73
|
+
expect(g.step("5").moved).toBe(false);
|
|
74
|
+
break outer;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
function moveTo(g: SudokuGame, row: number, col: number): void {
|
|
82
|
+
const cur = g.state().cursor;
|
|
83
|
+
const stepDir = (dir: "up" | "down" | "left" | "right", n: number): void => {
|
|
84
|
+
for (let i = 0; i < n; i++) g.step(dir);
|
|
85
|
+
};
|
|
86
|
+
stepDir(cur.row < row ? "down" : "up", Math.abs(cur.row - row));
|
|
87
|
+
stepDir(cur.col < col ? "right" : "left", Math.abs(cur.col - col));
|
|
88
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import type { Rng } from "../../core/rng.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Pure sudoku generation and validation. Puzzles are built by generating a
|
|
5
|
+
* full solution with seeded backtracking, then blanking cells. (Uniqueness
|
|
6
|
+
* isn't enforced — completion is judged against sudoku rules, not against
|
|
7
|
+
* the one generator solution.)
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** 9×9 grid, 0 = empty. */
|
|
11
|
+
export type Grid = number[][];
|
|
12
|
+
|
|
13
|
+
export const SIZE = 9;
|
|
14
|
+
/** Cells blanked out of the solved grid. */
|
|
15
|
+
export const HOLES = 44;
|
|
16
|
+
|
|
17
|
+
export function emptyGrid(): Grid {
|
|
18
|
+
return Array.from({ length: SIZE }, () => Array(SIZE).fill(0));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function isValidPlacement(grid: Grid, row: number, col: number, value: number): boolean {
|
|
22
|
+
for (let i = 0; i < SIZE; i++) {
|
|
23
|
+
if (i !== col && grid[row]![i] === value) return false;
|
|
24
|
+
if (i !== row && grid[i]![col] === value) return false;
|
|
25
|
+
}
|
|
26
|
+
const br = Math.floor(row / 3) * 3;
|
|
27
|
+
const bc = Math.floor(col / 3) * 3;
|
|
28
|
+
for (let r = br; r < br + 3; r++) {
|
|
29
|
+
for (let c = bc; c < bc + 3; c++) {
|
|
30
|
+
if ((r !== row || c !== col) && grid[r]![c] === value) return false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function shuffled(rng: Rng): number[] {
|
|
37
|
+
const values = [1, 2, 3, 4, 5, 6, 7, 8, 9];
|
|
38
|
+
for (let i = values.length - 1; i > 0; i--) {
|
|
39
|
+
const j = rng.int(i + 1);
|
|
40
|
+
[values[i], values[j]] = [values[j]!, values[i]!];
|
|
41
|
+
}
|
|
42
|
+
return values;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Full valid solution via randomized backtracking. */
|
|
46
|
+
export function generateSolved(rng: Rng): Grid {
|
|
47
|
+
const grid = emptyGrid();
|
|
48
|
+
const fill = (idx: number): boolean => {
|
|
49
|
+
if (idx === SIZE * SIZE) return true;
|
|
50
|
+
const row = Math.floor(idx / SIZE);
|
|
51
|
+
const col = idx % SIZE;
|
|
52
|
+
for (const v of shuffled(rng)) {
|
|
53
|
+
if (isValidPlacement(grid, row, col, v)) {
|
|
54
|
+
grid[row]![col] = v;
|
|
55
|
+
if (fill(idx + 1)) return true;
|
|
56
|
+
grid[row]![col] = 0;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return false;
|
|
60
|
+
};
|
|
61
|
+
fill(0);
|
|
62
|
+
return grid;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface Puzzle {
|
|
66
|
+
/** Starting grid with holes; nonzero cells are fixed givens. */
|
|
67
|
+
givens: Grid;
|
|
68
|
+
solution: Grid;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function makePuzzle(rng: Rng): Puzzle {
|
|
72
|
+
const solution = generateSolved(rng);
|
|
73
|
+
const givens = solution.map((r) => [...r]);
|
|
74
|
+
const cells = Array.from({ length: SIZE * SIZE }, (_, i) => i);
|
|
75
|
+
for (let i = cells.length - 1; i > 0; i--) {
|
|
76
|
+
const j = rng.int(i + 1);
|
|
77
|
+
[cells[i], cells[j]] = [cells[j]!, cells[i]!];
|
|
78
|
+
}
|
|
79
|
+
for (const idx of cells.slice(0, HOLES)) {
|
|
80
|
+
givens[Math.floor(idx / SIZE)]![idx % SIZE] = 0;
|
|
81
|
+
}
|
|
82
|
+
return { givens, solution };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Cells whose value breaks a row/column/box constraint. */
|
|
86
|
+
export function conflicts(grid: Grid): Set<string> {
|
|
87
|
+
const bad = new Set<string>();
|
|
88
|
+
for (let r = 0; r < SIZE; r++) {
|
|
89
|
+
for (let c = 0; c < SIZE; c++) {
|
|
90
|
+
const v = grid[r]![c]!;
|
|
91
|
+
if (v !== 0 && !isValidPlacement(grid, r, c, v)) bad.add(`${r},${c}`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return bad;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function isComplete(grid: Grid): boolean {
|
|
98
|
+
return grid.every((row) => row.every((v) => v !== 0)) && conflicts(grid).size === 0;
|
|
99
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import type { Game, StepResult } from "../../core/game.js";
|
|
2
|
+
import { Rng } from "../../core/rng.js";
|
|
3
|
+
import { SIZE, conflicts, isComplete, makePuzzle, type Grid, type Puzzle } from "./engine.js";
|
|
4
|
+
|
|
5
|
+
export type SudokuAction =
|
|
6
|
+
| "up"
|
|
7
|
+
| "down"
|
|
8
|
+
| "left"
|
|
9
|
+
| "right"
|
|
10
|
+
| "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9"
|
|
11
|
+
| "clear";
|
|
12
|
+
|
|
13
|
+
export interface SudokuState {
|
|
14
|
+
grid: Grid;
|
|
15
|
+
givens: Grid;
|
|
16
|
+
cursor: { row: number; col: number };
|
|
17
|
+
correct: number;
|
|
18
|
+
filled: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
export class SudokuGame implements Game<SudokuState, SudokuAction> {
|
|
23
|
+
readonly name = "sudoku";
|
|
24
|
+
readonly actions = ["up", "down", "left", "right", "1", "2", "3", "4", "5", "6", "7", "8", "9", "clear"] as const;
|
|
25
|
+
readonly keymap = { "0": "clear", backspace: "clear", x: "clear", space: "clear" } as const;
|
|
26
|
+
|
|
27
|
+
private puzzle: Puzzle = { givens: [], solution: [] };
|
|
28
|
+
private grid: Grid = [];
|
|
29
|
+
private row = 4;
|
|
30
|
+
private col = 4;
|
|
31
|
+
|
|
32
|
+
reset(seed: number): void {
|
|
33
|
+
this.puzzle = makePuzzle(new Rng(seed));
|
|
34
|
+
this.grid = this.puzzle.givens.map((r) => [...r]);
|
|
35
|
+
this.row = 4;
|
|
36
|
+
this.col = 4;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
step(action: SudokuAction): StepResult {
|
|
40
|
+
if (this.isOver()) return { reward: 0, moved: false, done: true, success: true };
|
|
41
|
+
if (action === "up" || action === "down" || action === "left" || action === "right") {
|
|
42
|
+
const nr = this.row + (action === "up" ? -1 : action === "down" ? 1 : 0);
|
|
43
|
+
const nc = this.col + (action === "left" ? -1 : action === "right" ? 1 : 0);
|
|
44
|
+
if (nr < 0 || nc < 0 || nr >= SIZE || nc >= SIZE) return { reward: 0, moved: false, done: false, success: false };
|
|
45
|
+
this.row = nr;
|
|
46
|
+
this.col = nc;
|
|
47
|
+
return { reward: 0, moved: true, done: false, success: false };
|
|
48
|
+
}
|
|
49
|
+
if (this.puzzle.givens[this.row]![this.col] !== 0) return { reward: 0, moved: false, done: false, success: false };
|
|
50
|
+
const value = action === "clear" ? 0 : Number(action);
|
|
51
|
+
if (this.grid[this.row]![this.col] === value) return { reward: 0, moved: false, done: false, success: false };
|
|
52
|
+
this.grid[this.row]![this.col] = value;
|
|
53
|
+
const done = isComplete(this.grid);
|
|
54
|
+
const reward = value !== 0 && value === this.puzzle.solution[this.row]![this.col] ? 1 : 0;
|
|
55
|
+
return { reward, moved: true, done, success: done };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
state(): SudokuState {
|
|
59
|
+
let correct = 0;
|
|
60
|
+
let filled = 0;
|
|
61
|
+
for (let r = 0; r < SIZE; r++) {
|
|
62
|
+
for (let c = 0; c < SIZE; c++) {
|
|
63
|
+
if (this.puzzle.givens[r]![c] !== 0) continue;
|
|
64
|
+
const v = this.grid[r]![c]!;
|
|
65
|
+
if (v !== 0) filled++;
|
|
66
|
+
if (v !== 0 && v === this.puzzle.solution[r]![c]) correct++;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
70
|
+
grid: this.grid.map((r) => [...r]),
|
|
71
|
+
givens: this.puzzle.givens.map((r) => [...r]),
|
|
72
|
+
cursor: { row: this.row, col: this.col },
|
|
73
|
+
correct,
|
|
74
|
+
filled,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
checkpointState(): SudokuState & { solution: Grid } {
|
|
79
|
+
return {
|
|
80
|
+
...this.state(),
|
|
81
|
+
solution: this.puzzle.solution.map((row) => [...row]),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
score(): number {
|
|
86
|
+
return this.state().correct;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
isOver(): boolean {
|
|
90
|
+
return isComplete(this.grid);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
hasWon(): boolean {
|
|
94
|
+
return this.isOver();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
private givenCount(): number {
|
|
98
|
+
return this.puzzle.givens.flat().filter((v) => v !== 0).length;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { Rng } from "../../core/rng.js";
|
|
3
|
+
import {
|
|
4
|
+
BOARD_H,
|
|
5
|
+
BOARD_W,
|
|
6
|
+
PIECE_TYPES,
|
|
7
|
+
SevenBag,
|
|
8
|
+
clearLines,
|
|
9
|
+
collides,
|
|
10
|
+
dropDistance,
|
|
11
|
+
emptyBoard,
|
|
12
|
+
gravityMs,
|
|
13
|
+
levelForLines,
|
|
14
|
+
lineScore,
|
|
15
|
+
lockPiece,
|
|
16
|
+
pieceCells,
|
|
17
|
+
spawnPiece,
|
|
18
|
+
tryMove,
|
|
19
|
+
tryRotate,
|
|
20
|
+
type ActivePiece,
|
|
21
|
+
type Board,
|
|
22
|
+
type PieceType,
|
|
23
|
+
} from "./engine.js";
|
|
24
|
+
import { TetrisGame, type TetrisAction } from "./tetris.js";
|
|
25
|
+
|
|
26
|
+
const sortCells = (cells: Array<[number, number]>) =>
|
|
27
|
+
[...cells].sort((a, b) => a[1] - b[1] || a[0] - b[0]);
|
|
28
|
+
|
|
29
|
+
describe("shapes and spawning", () => {
|
|
30
|
+
it("every piece has exactly 4 blocks in every rotation", () => {
|
|
31
|
+
for (const type of PIECE_TYPES) {
|
|
32
|
+
for (const rotation of [0, 1, 2, 3] as const) {
|
|
33
|
+
const piece: ActivePiece = { type, rotation, x: 3, y: 5 };
|
|
34
|
+
expect(pieceCells(piece)).toHaveLength(4);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("spawns with the topmost block on row 0, inside the board", () => {
|
|
40
|
+
for (const type of PIECE_TYPES) {
|
|
41
|
+
const cells = pieceCells(spawnPiece(type));
|
|
42
|
+
expect(Math.min(...cells.map(([, y]) => y))).toBe(0);
|
|
43
|
+
for (const [x] of cells) {
|
|
44
|
+
expect(x).toBeGreaterThanOrEqual(0);
|
|
45
|
+
expect(x).toBeLessThan(BOARD_W);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("spawns I flat on row 0 at columns 3-6", () => {
|
|
51
|
+
expect(sortCells(pieceCells(spawnPiece("I")))).toEqual([[3, 0], [4, 0], [5, 0], [6, 0]]);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("spawns O at columns 4-5", () => {
|
|
55
|
+
expect(sortCells(pieceCells(spawnPiece("O")))).toEqual([[4, 0], [5, 0], [4, 1], [5, 1]]);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("rotation (SRS)", () => {
|
|
60
|
+
it("rotates T through all four states and back", () => {
|
|
61
|
+
const board = emptyBoard();
|
|
62
|
+
let piece: ActivePiece = { type: "T", rotation: 0, x: 3, y: 5 };
|
|
63
|
+
const seen = [sortCells(pieceCells(piece))];
|
|
64
|
+
for (let i = 0; i < 4; i++) {
|
|
65
|
+
piece = tryRotate(board, piece, 1)!;
|
|
66
|
+
expect(piece).not.toBeNull();
|
|
67
|
+
seen.push(sortCells(pieceCells(piece)));
|
|
68
|
+
}
|
|
69
|
+
// Four cw rotations return to the original orientation.
|
|
70
|
+
expect(seen[4]).toEqual(seen[0]);
|
|
71
|
+
expect(piece.rotation).toBe(0);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("cw then ccw is a no-op in open space", () => {
|
|
75
|
+
const board = emptyBoard();
|
|
76
|
+
const start: ActivePiece = { type: "J", rotation: 0, x: 4, y: 5 };
|
|
77
|
+
const cw = tryRotate(board, start, 1)!;
|
|
78
|
+
const back = tryRotate(board, cw, -1)!;
|
|
79
|
+
expect(sortCells(pieceCells(back))).toEqual(sortCells(pieceCells(start)));
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("T pointing up: rotating cw puts the nub on the right", () => {
|
|
83
|
+
const piece: ActivePiece = { type: "T", rotation: 0, x: 3, y: 5 };
|
|
84
|
+
// spawn: nub above center
|
|
85
|
+
expect(sortCells(pieceCells(piece))).toEqual([[4, 5], [3, 6], [4, 6], [5, 6]]);
|
|
86
|
+
const cw = tryRotate(emptyBoard(), piece, 1)!;
|
|
87
|
+
expect(sortCells(pieceCells(cw))).toEqual([[4, 5], [4, 6], [5, 6], [4, 7]]);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("O rotation never changes its cells", () => {
|
|
91
|
+
const piece: ActivePiece = { type: "O", rotation: 0, x: 4, y: 3 };
|
|
92
|
+
const rotated = tryRotate(emptyBoard(), piece, 1)!;
|
|
93
|
+
expect(sortCells(pieceCells(rotated))).toEqual(sortCells(pieceCells(piece)));
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("wall-kicks a J off the left wall", () => {
|
|
97
|
+
const board = emptyBoard();
|
|
98
|
+
// Vertical J hugging the left wall; rotating would poke through x=-1 without a kick.
|
|
99
|
+
let piece: ActivePiece = { type: "J", rotation: 1, x: -1, y: 5 };
|
|
100
|
+
expect(collides(board, piece)).toBe(false);
|
|
101
|
+
const rotated = tryRotate(board, piece, 1);
|
|
102
|
+
expect(rotated).not.toBeNull();
|
|
103
|
+
expect(pieceCells(rotated!).every(([x]) => x >= 0 && x < BOARD_W)).toBe(true);
|
|
104
|
+
expect(rotated!.x).not.toBe(piece.x); // a kick was applied
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("wall-kicks a vertical I off the right wall", () => {
|
|
108
|
+
const board = emptyBoard();
|
|
109
|
+
const piece: ActivePiece = { type: "I", rotation: 1, x: 7, y: 5 }; // blocks in column 9
|
|
110
|
+
expect(collides(board, piece)).toBe(false);
|
|
111
|
+
const rotated = tryRotate(board, piece, 1);
|
|
112
|
+
expect(rotated).not.toBeNull();
|
|
113
|
+
expect(pieceCells(rotated!).every(([x]) => x >= 0 && x < BOARD_W)).toBe(true);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("returns null when no kick fits", () => {
|
|
117
|
+
// Box the T in completely so every kick position collides.
|
|
118
|
+
const board = emptyBoard();
|
|
119
|
+
for (let y = 0; y < BOARD_H; y++) {
|
|
120
|
+
for (let x = 0; x < BOARD_W; x++) board[y]![x] = "I";
|
|
121
|
+
}
|
|
122
|
+
// Carve out exactly the T's spawn cells.
|
|
123
|
+
const piece: ActivePiece = { type: "T", rotation: 0, x: 3, y: 5 };
|
|
124
|
+
for (const [x, y] of pieceCells(piece)) board[y]![x] = null;
|
|
125
|
+
expect(collides(board, piece)).toBe(false);
|
|
126
|
+
expect(tryRotate(board, piece, 1)).toBeNull();
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe("movement and collision", () => {
|
|
131
|
+
it("blocks movement into the walls", () => {
|
|
132
|
+
const board = emptyBoard();
|
|
133
|
+
let piece = spawnPiece("O");
|
|
134
|
+
while (tryMove(board, piece, -1, 0)) piece = tryMove(board, piece, -1, 0)!;
|
|
135
|
+
expect(Math.min(...pieceCells(piece).map(([x]) => x))).toBe(0);
|
|
136
|
+
expect(tryMove(board, piece, -1, 0)).toBeNull();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("blocks movement into locked cells", () => {
|
|
140
|
+
const board = emptyBoard();
|
|
141
|
+
board[5]![4] = "Z";
|
|
142
|
+
const piece: ActivePiece = { type: "O", rotation: 0, x: 4, y: 3 }; // cells rows 3-4, cols 4-5
|
|
143
|
+
expect(tryMove(board, piece, 0, 1)).toBeNull(); // would overlap (4,5)
|
|
144
|
+
expect(tryMove(board, piece, 1, 1)).not.toBeNull();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("dropDistance measures the fall to the floor", () => {
|
|
148
|
+
const piece = spawnPiece("O"); // occupies rows 0-1
|
|
149
|
+
expect(dropDistance(emptyBoard(), piece)).toBe(BOARD_H - 2);
|
|
150
|
+
const board = emptyBoard();
|
|
151
|
+
board[10]![4] = "T";
|
|
152
|
+
expect(dropDistance(board, piece)).toBe(8); // lands on top of row 10
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
describe("line clears", () => {
|
|
157
|
+
const fullRow = (): Array<PieceType | null> => Array(BOARD_W).fill("I") as PieceType[];
|
|
158
|
+
|
|
159
|
+
it("clears full rows and shifts the stack down", () => {
|
|
160
|
+
const board: Board = emptyBoard();
|
|
161
|
+
board[19] = fullRow();
|
|
162
|
+
board[18] = fullRow();
|
|
163
|
+
board[17]![0] = "T";
|
|
164
|
+
const { board: cleared, count } = clearLines(board);
|
|
165
|
+
expect(count).toBe(2);
|
|
166
|
+
expect(cleared[19]![0]).toBe("T");
|
|
167
|
+
expect(cleared[18]!.every((c) => c === null)).toBe(true);
|
|
168
|
+
expect(cleared).toHaveLength(BOARD_H);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("lockPiece merges the piece and clears completed lines", () => {
|
|
172
|
+
const board = emptyBoard();
|
|
173
|
+
// Fill the bottom row except columns 4-5, then drop an O into the gap.
|
|
174
|
+
for (let x = 0; x < BOARD_W; x++) {
|
|
175
|
+
if (x !== 4 && x !== 5) {
|
|
176
|
+
board[19]![x] = "L";
|
|
177
|
+
board[18]![x] = "L";
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
const piece: ActivePiece = { type: "O", rotation: 0, x: 4, y: 18 };
|
|
181
|
+
const res = lockPiece(board, piece);
|
|
182
|
+
expect(res.cleared).toBe(2);
|
|
183
|
+
expect(res.toppedOut).toBe(false);
|
|
184
|
+
expect(res.board[19]!.every((c) => c === null)).toBe(true);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("flags a lock-out when blocks rest above the field", () => {
|
|
188
|
+
const board = emptyBoard();
|
|
189
|
+
for (let x = 0; x < BOARD_W; x++) board[0]![x] = x === 4 ? null : "I";
|
|
190
|
+
board[0]![4] = null;
|
|
191
|
+
const piece: ActivePiece = { type: "I", rotation: 1, x: 2, y: -2 }; // vertical I, blocks y -2..1 at x=4
|
|
192
|
+
const res = lockPiece(board, piece);
|
|
193
|
+
expect(res.toppedOut).toBe(true);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("never mutates the input board", () => {
|
|
197
|
+
const board = emptyBoard();
|
|
198
|
+
board[19]![0] = "S";
|
|
199
|
+
const copy = board.map((r) => [...r]);
|
|
200
|
+
lockPiece(board, { type: "O", rotation: 0, x: 4, y: 18 });
|
|
201
|
+
expect(board).toEqual(copy);
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
describe("scoring and levels", () => {
|
|
206
|
+
it("scores single/double/triple/tetris at level 0", () => {
|
|
207
|
+
expect(lineScore(1, 0)).toBe(100);
|
|
208
|
+
expect(lineScore(2, 0)).toBe(300);
|
|
209
|
+
expect(lineScore(3, 0)).toBe(500);
|
|
210
|
+
expect(lineScore(4, 0)).toBe(800);
|
|
211
|
+
expect(lineScore(0, 0)).toBe(0);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("multiplies by level + 1", () => {
|
|
215
|
+
expect(lineScore(4, 3)).toBe(3200);
|
|
216
|
+
expect(lineScore(1, 9)).toBe(1000);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("levels up every 10 lines and speeds up gravity", () => {
|
|
220
|
+
expect(levelForLines(0)).toBe(0);
|
|
221
|
+
expect(levelForLines(9)).toBe(0);
|
|
222
|
+
expect(levelForLines(10)).toBe(1);
|
|
223
|
+
expect(levelForLines(35)).toBe(3);
|
|
224
|
+
expect(gravityMs(1)).toBeLessThan(gravityMs(0));
|
|
225
|
+
expect(gravityMs(5)).toBeLessThan(gravityMs(1));
|
|
226
|
+
expect(gravityMs(30)).toBeGreaterThanOrEqual(60); // clamped floor
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
describe("7-bag randomizer", () => {
|
|
231
|
+
it("deals all 7 pieces exactly once per bag", () => {
|
|
232
|
+
const bag = new SevenBag(new Rng(99));
|
|
233
|
+
for (let round = 0; round < 5; round++) {
|
|
234
|
+
const drawn = Array.from({ length: 7 }, () => bag.next());
|
|
235
|
+
expect([...drawn].sort()).toEqual([...PIECE_TYPES].sort());
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("is deterministic for a given seed", () => {
|
|
240
|
+
const draw = (seed: number) => {
|
|
241
|
+
const bag = new SevenBag(new Rng(seed));
|
|
242
|
+
return Array.from({ length: 21 }, () => bag.next());
|
|
243
|
+
};
|
|
244
|
+
expect(draw(7)).toEqual(draw(7));
|
|
245
|
+
expect(draw(7)).not.toEqual(draw(8)); // overwhelmingly likely to differ
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
describe("TetrisGame episodes", () => {
|
|
250
|
+
it("hard drop scores 2 points per cell dropped", () => {
|
|
251
|
+
const g = new TetrisGame();
|
|
252
|
+
g.reset(1);
|
|
253
|
+
const active = g.state().active!;
|
|
254
|
+
const dist = dropDistance(g.state().board, active);
|
|
255
|
+
const res = g.step("hard-drop");
|
|
256
|
+
expect(res.reward).toBe(2 * dist);
|
|
257
|
+
expect(g.score()).toBe(2 * dist);
|
|
258
|
+
expect(res.randomEffects).toMatchObject({
|
|
259
|
+
kind: "piece_drawn",
|
|
260
|
+
active_piece: expect.any(String),
|
|
261
|
+
queued_piece: expect.any(String),
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("soft drop scores 1 point per cell", () => {
|
|
266
|
+
const g = new TetrisGame();
|
|
267
|
+
g.reset(1);
|
|
268
|
+
const res = g.step("soft-drop");
|
|
269
|
+
expect(res.moved).toBe(true);
|
|
270
|
+
expect(res.reward).toBe(1);
|
|
271
|
+
expect(g.score()).toBe(1);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("reports moved=false for a blocked shift", () => {
|
|
275
|
+
const g = new TetrisGame();
|
|
276
|
+
g.reset(2);
|
|
277
|
+
let res = g.step("left");
|
|
278
|
+
while (res.moved) res = g.step("left"); // walk into the wall
|
|
279
|
+
expect(res.moved).toBe(false);
|
|
280
|
+
expect(res.reward).toBe(0);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it("shows the queued piece as the next active piece", () => {
|
|
284
|
+
const g = new TetrisGame();
|
|
285
|
+
g.reset(3);
|
|
286
|
+
const upNext = g.state().next[0]!;
|
|
287
|
+
g.step("hard-drop");
|
|
288
|
+
expect(g.state().active!.type).toBe(upNext);
|
|
289
|
+
expect(g.state().next).toHaveLength(3);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it("plays a full seeded episode deterministically until game over", () => {
|
|
293
|
+
const play = (seed: number) => {
|
|
294
|
+
const g = new TetrisGame();
|
|
295
|
+
g.reset(seed);
|
|
296
|
+
const cycle: TetrisAction[] = ["left", "rotate-cw", "right", "hard-drop"];
|
|
297
|
+
let total = 0;
|
|
298
|
+
let steps = 0;
|
|
299
|
+
while (!g.isOver() && steps < 10000) {
|
|
300
|
+
total += g.step(cycle[steps % cycle.length]!).reward;
|
|
301
|
+
steps++;
|
|
302
|
+
}
|
|
303
|
+
return { score: g.score(), total, steps, state: g.state() };
|
|
304
|
+
};
|
|
305
|
+
const a = play(1234);
|
|
306
|
+
const b = play(1234);
|
|
307
|
+
expect(a).toEqual(b);
|
|
308
|
+
expect(a.state.over).toBe(true); // hard-dropping forever must top out
|
|
309
|
+
expect(a.score).toBe(a.total); // score is the sum of rewards
|
|
310
|
+
expect(a.score).toBeGreaterThan(0);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it("clears a line during real play and scores it", () => {
|
|
314
|
+
// Drop flat I pieces across the board; with seed search we find a seed
|
|
315
|
+
// that clears at least one line within a bounded number of drops.
|
|
316
|
+
const g = new TetrisGame();
|
|
317
|
+
g.reset(42);
|
|
318
|
+
let cleared = false;
|
|
319
|
+
for (let i = 0; i < 400 && !g.isOver(); i++) {
|
|
320
|
+
const st = g.state();
|
|
321
|
+
const active = st.active!;
|
|
322
|
+
// Greedy: shove piece toward the least-filled side alternating.
|
|
323
|
+
const dir: TetrisAction = i % 2 === 0 ? "left" : "right";
|
|
324
|
+
for (let k = 0; k < 5; k++) g.step(dir);
|
|
325
|
+
g.step("hard-drop");
|
|
326
|
+
if (g.linesCleared() > 0) {
|
|
327
|
+
cleared = true;
|
|
328
|
+
break;
|
|
329
|
+
}
|
|
330
|
+
void active;
|
|
331
|
+
}
|
|
332
|
+
if (cleared) {
|
|
333
|
+
expect(g.linesCleared()).toBeGreaterThan(0);
|
|
334
|
+
expect(g.score()).toBeGreaterThanOrEqual(100);
|
|
335
|
+
} else {
|
|
336
|
+
// Fallback: engine-level clear is already covered above; ensure game ended sanely.
|
|
337
|
+
expect(g.isOver() || g.linesCleared() === 0).toBe(true);
|
|
338
|
+
}
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it("returns done=true and ignores actions after game over", () => {
|
|
342
|
+
const g = new TetrisGame();
|
|
343
|
+
g.reset(5);
|
|
344
|
+
let guard = 0;
|
|
345
|
+
while (!g.isOver() && guard++ < 10000) g.step("hard-drop");
|
|
346
|
+
expect(g.isOver()).toBe(true);
|
|
347
|
+
const res = g.step("left");
|
|
348
|
+
expect(res.done).toBe(true);
|
|
349
|
+
expect(res.moved).toBe(false);
|
|
350
|
+
expect(g.state().active).toBeNull();
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it("gravity tick moves the piece down one row, then locks at the floor", () => {
|
|
354
|
+
const g = new TetrisGame();
|
|
355
|
+
g.reset(6);
|
|
356
|
+
const y0 = g.state().active!.y;
|
|
357
|
+
const res = g.step("tick");
|
|
358
|
+
expect(res.moved).toBe(true);
|
|
359
|
+
expect(g.state().active!.y).toBe(y0 + 1);
|
|
360
|
+
// Tick until the first piece locks: the active piece type changes.
|
|
361
|
+
const first = g.state().active!.type;
|
|
362
|
+
let guard = 0;
|
|
363
|
+
while (g.state().active?.type === first && g.state().active!.y >= y0 && guard++ < 50) {
|
|
364
|
+
g.step("tick");
|
|
365
|
+
if (g.state().active && g.state().active!.y <= 1 && guard > 5) break; // locked & respawned
|
|
366
|
+
}
|
|
367
|
+
expect(guard).toBeLessThan(50);
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it("exposes deterministic gameplay variants", () => {
|
|
371
|
+
const classic = new TetrisGame("classic");
|
|
372
|
+
const chaos = new TetrisGame("chaos");
|
|
373
|
+
classic.reset(42);
|
|
374
|
+
chaos.reset(42);
|
|
375
|
+
expect(classic.variation.goal).toContain("Survive");
|
|
376
|
+
expect(chaos.state().next).not.toEqual(classic.state().next);
|
|
377
|
+
});
|
|
378
|
+
});
|