@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,236 @@
1
+ import { Rng } from "../../core/rng.js";
2
+
3
+ /**
4
+ * Pure Tetris rules: 10x20 board, the 7 tetrominoes with SRS-style rotation
5
+ * (including standard wall kicks), gravity/drop mechanics, line clearing,
6
+ * guideline scoring, level progression, and the 7-bag randomizer. Everything
7
+ * here is deterministic given a seeded Rng, so (seed, action sequence)
8
+ * reproduces an episode exactly.
9
+ */
10
+
11
+ export const BOARD_W = 10;
12
+ export const BOARD_H = 20;
13
+
14
+ export type PieceType = "I" | "O" | "T" | "S" | "Z" | "J" | "L";
15
+ export const PIECE_TYPES: readonly PieceType[] = ["I", "O", "T", "S", "Z", "J", "L"];
16
+
17
+ /** Board cell: null = empty, otherwise the piece type that locked there. */
18
+ export type Cell = PieceType | null;
19
+ export type Board = Cell[][]; // BOARD_H rows × BOARD_W cols
20
+
21
+ export type Rotation = 0 | 1 | 2 | 3;
22
+
23
+ export interface ActivePiece {
24
+ type: PieceType;
25
+ rotation: Rotation;
26
+ /** Top-left of the piece's bounding box in board coordinates (y grows down). */
27
+ x: number;
28
+ y: number;
29
+ }
30
+
31
+ // ── Shapes ────────────────────────────────────────────────────────────
32
+ // Spawn-state grids per SRS: JLSTZ/T live in a 3x3 box, I in 4x4, O in 2x2
33
+ // (rotation-invariant). The other rotation states are derived by rotating
34
+ // the bounding box clockwise, which reproduces the SRS orientations.
35
+
36
+ const SHAPE_GRIDS: Record<PieceType, string[]> = {
37
+ I: ["....", "XXXX", "....", "...."],
38
+ O: ["XX", "XX"],
39
+ T: [".X.", "XXX", "..."],
40
+ S: [".XX", "XX.", "..."],
41
+ Z: ["XX.", ".XX", "..."],
42
+ J: ["X..", "XXX", "..."],
43
+ L: ["..X", "XXX", "..."],
44
+ };
45
+
46
+ type Offsets = ReadonlyArray<readonly [number, number]>;
47
+
48
+ function gridToOffsets(grid: string[]): Offsets {
49
+ const out: Array<[number, number]> = [];
50
+ grid.forEach((row, y) => {
51
+ for (let x = 0; x < row.length; x++) {
52
+ if (row[x] === "X") out.push([x, y]);
53
+ }
54
+ });
55
+ return out;
56
+ }
57
+
58
+ function rotateGridCw(grid: string[]): string[] {
59
+ const n = grid.length;
60
+ return Array.from({ length: n }, (_, y) =>
61
+ Array.from({ length: n }, (_, x) => grid[n - 1 - x]![y]!).join(""),
62
+ );
63
+ }
64
+
65
+ /** OFFSETS[type][rotation] → block offsets within the bounding box. */
66
+ const OFFSETS: Record<PieceType, [Offsets, Offsets, Offsets, Offsets]> = (() => {
67
+ const table = {} as Record<PieceType, [Offsets, Offsets, Offsets, Offsets]>;
68
+ for (const type of PIECE_TYPES) {
69
+ let grid = SHAPE_GRIDS[type];
70
+ const rots: Offsets[] = [];
71
+ for (let r = 0; r < 4; r++) {
72
+ rots.push(gridToOffsets(grid));
73
+ grid = rotateGridCw(grid);
74
+ }
75
+ table[type] = rots as [Offsets, Offsets, Offsets, Offsets];
76
+ }
77
+ return table;
78
+ })();
79
+
80
+ // ── Wall kicks (SRS) ──────────────────────────────────────────────────
81
+ // Tables use SRS convention (+y up); we negate y when applying because our
82
+ // board y grows downward. Keyed by "from>to".
83
+
84
+ const KICKS_JLSTZ: Record<string, Offsets> = {
85
+ "0>1": [[0, 0], [-1, 0], [-1, 1], [0, -2], [-1, -2]],
86
+ "1>0": [[0, 0], [1, 0], [1, -1], [0, 2], [1, 2]],
87
+ "1>2": [[0, 0], [1, 0], [1, -1], [0, 2], [1, 2]],
88
+ "2>1": [[0, 0], [-1, 0], [-1, 1], [0, -2], [-1, -2]],
89
+ "2>3": [[0, 0], [1, 0], [1, 1], [0, -2], [1, -2]],
90
+ "3>2": [[0, 0], [-1, 0], [-1, -1], [0, 2], [-1, 2]],
91
+ "3>0": [[0, 0], [-1, 0], [-1, -1], [0, 2], [-1, 2]],
92
+ "0>3": [[0, 0], [1, 0], [1, 1], [0, -2], [1, -2]],
93
+ };
94
+
95
+ const KICKS_I: Record<string, Offsets> = {
96
+ "0>1": [[0, 0], [-2, 0], [1, 0], [-2, -1], [1, 2]],
97
+ "1>0": [[0, 0], [2, 0], [-1, 0], [2, 1], [-1, -2]],
98
+ "1>2": [[0, 0], [-1, 0], [2, 0], [-1, 2], [2, -1]],
99
+ "2>1": [[0, 0], [1, 0], [-2, 0], [1, -2], [-2, 1]],
100
+ "2>3": [[0, 0], [2, 0], [-1, 0], [2, 1], [-1, -2]],
101
+ "3>2": [[0, 0], [-2, 0], [1, 0], [-2, -1], [1, 2]],
102
+ "3>0": [[0, 0], [1, 0], [-2, 0], [1, -2], [-2, 1]],
103
+ "0>3": [[0, 0], [-1, 0], [2, 0], [-1, 2], [2, -1]],
104
+ };
105
+
106
+ function kicksFor(type: PieceType, from: Rotation, to: Rotation): Offsets {
107
+ if (type === "O") return [[0, 0]];
108
+ const table = type === "I" ? KICKS_I : KICKS_JLSTZ;
109
+ return table[`${from}>${to}`]!;
110
+ }
111
+
112
+ // ── Board helpers ─────────────────────────────────────────────────────
113
+
114
+ export function emptyBoard(): Board {
115
+ return Array.from({ length: BOARD_H }, () => Array<Cell>(BOARD_W).fill(null));
116
+ }
117
+
118
+ export function cloneBoard(board: Board): Board {
119
+ return board.map((row) => [...row]);
120
+ }
121
+
122
+ /** Absolute board coordinates of the piece's four blocks. */
123
+ export function pieceCells(piece: ActivePiece): Array<[number, number]> {
124
+ return OFFSETS[piece.type][piece.rotation].map(([dx, dy]) => [piece.x + dx, piece.y + dy]);
125
+ }
126
+
127
+ /** True if any block is out of bounds (sides/floor) or overlaps a locked cell. */
128
+ export function collides(board: Board, piece: ActivePiece): boolean {
129
+ for (const [x, y] of pieceCells(piece)) {
130
+ if (x < 0 || x >= BOARD_W || y >= BOARD_H) return true;
131
+ if (y >= 0 && board[y]![x] !== null) return true; // y < 0 is above the field
132
+ }
133
+ return false;
134
+ }
135
+
136
+ /** New piece at the standard spawn column, topmost block on row 0. */
137
+ export function spawnPiece(type: PieceType): ActivePiece {
138
+ const size = SHAPE_GRIDS[type].length;
139
+ const minRow = Math.min(...OFFSETS[type][0].map(([, dy]) => dy));
140
+ return { type, rotation: 0, x: Math.floor((BOARD_W - size) / 2), y: -minRow };
141
+ }
142
+
143
+ /** Translate by (dx, dy); null if the result collides. */
144
+ export function tryMove(board: Board, piece: ActivePiece, dx: number, dy: number): ActivePiece | null {
145
+ const next = { ...piece, x: piece.x + dx, y: piece.y + dy };
146
+ return collides(board, next) ? null : next;
147
+ }
148
+
149
+ /** SRS rotate (dir = 1 cw, -1 ccw), testing wall kicks in order. */
150
+ export function tryRotate(board: Board, piece: ActivePiece, dir: 1 | -1): ActivePiece | null {
151
+ const to = (((piece.rotation + dir) % 4) + 4) % 4 as Rotation;
152
+ for (const [kx, ky] of kicksFor(piece.type, piece.rotation, to)) {
153
+ const next = { ...piece, rotation: to, x: piece.x + kx, y: piece.y - ky };
154
+ if (!collides(board, next)) return next;
155
+ }
156
+ return null;
157
+ }
158
+
159
+ /** Rows the piece would fall before locking (hard-drop distance). */
160
+ export function dropDistance(board: Board, piece: ActivePiece): number {
161
+ let d = 0;
162
+ while (tryMove(board, piece, 0, d + 1)) d++;
163
+ return d;
164
+ }
165
+
166
+ export interface LockResult {
167
+ board: Board;
168
+ /** Number of lines cleared by this lock (0–4). */
169
+ cleared: number;
170
+ /** True when the piece locked (partly) above the visible field → game over. */
171
+ toppedOut: boolean;
172
+ }
173
+
174
+ /** Merge the piece into the board, then clear any full rows. Pure. */
175
+ export function lockPiece(board: Board, piece: ActivePiece): LockResult {
176
+ const next = cloneBoard(board);
177
+ let toppedOut = false;
178
+ for (const [x, y] of pieceCells(piece)) {
179
+ if (y < 0) {
180
+ toppedOut = true;
181
+ continue;
182
+ }
183
+ next[y]![x] = piece.type;
184
+ }
185
+ const { board: cleared, count } = clearLines(next);
186
+ return { board: cleared, cleared: count, toppedOut };
187
+ }
188
+
189
+ /** Remove full rows and add empty rows on top. Pure. */
190
+ export function clearLines(board: Board): { board: Board; count: number } {
191
+ const kept = board.filter((row) => row.some((cell) => cell === null));
192
+ const count = BOARD_H - kept.length;
193
+ const fresh = Array.from({ length: count }, () => Array<Cell>(BOARD_W).fill(null));
194
+ return { board: [...fresh, ...kept.map((row) => [...row])], count };
195
+ }
196
+
197
+ // ── Scoring / levels ──────────────────────────────────────────────────
198
+
199
+ /** Guideline base points for 1/2/3/4 lines (single/double/triple/tetris). */
200
+ export const LINE_SCORES = [0, 100, 300, 500, 800] as const;
201
+
202
+ export function lineScore(cleared: number, level: number): number {
203
+ return LINE_SCORES[cleared]! * (level + 1);
204
+ }
205
+
206
+ /** Level increases every 10 lines. */
207
+ export function levelForLines(lines: number): number {
208
+ return Math.floor(lines / 10);
209
+ }
210
+
211
+ /** Gravity interval in ms for a level — faster as levels climb. */
212
+ export function gravityMs(level: number): number {
213
+ return Math.max(60, Math.round(800 * Math.pow(0.85, level)));
214
+ }
215
+
216
+ // ── 7-bag randomizer ──────────────────────────────────────────────────
217
+
218
+ /** Deals pieces in shuffled bags of all 7 — deterministic per seeded Rng. */
219
+ export class SevenBag {
220
+ private bag: PieceType[] = [];
221
+
222
+ constructor(private rng: Rng, private useBag = true) {}
223
+
224
+ next(): PieceType {
225
+ if (!this.useBag) return PIECE_TYPES[this.rng.int(PIECE_TYPES.length)]!;
226
+ if (this.bag.length === 0) {
227
+ this.bag = [...PIECE_TYPES];
228
+ // Fisher–Yates with the seeded Rng.
229
+ for (let i = this.bag.length - 1; i > 0; i--) {
230
+ const j = this.rng.int(i + 1);
231
+ [this.bag[i], this.bag[j]] = [this.bag[j]!, this.bag[i]!];
232
+ }
233
+ }
234
+ return this.bag.pop()!;
235
+ }
236
+ }
@@ -0,0 +1,220 @@
1
+ import type { Game, GameVariation, StepResult } from "../../core/game.js";
2
+ import { Rng } from "../../core/rng.js";
3
+ import {
4
+ ActivePiece,
5
+ Board,
6
+ PieceType,
7
+ SevenBag,
8
+ collides,
9
+ dropDistance,
10
+ emptyBoard,
11
+ gravityMs,
12
+ levelForLines,
13
+ lineScore,
14
+ lockPiece,
15
+ spawnPiece,
16
+ tryMove,
17
+ tryRotate,
18
+ } from "./engine.js";
19
+
20
+ export type TetrisAction =
21
+ | "left"
22
+ | "right"
23
+ | "rotate-cw"
24
+ | "rotate-ccw"
25
+ | "soft-drop"
26
+ | "hard-drop"
27
+ | "tick"; // gravity pulse — issued by the app timer, logged like any action
28
+
29
+ export interface TetrisState {
30
+ board: Board;
31
+ active: ActivePiece | null;
32
+ next: PieceType[];
33
+ score: number;
34
+ lines: number;
35
+ level: number;
36
+ over: boolean;
37
+ }
38
+
39
+ const NEXT_PREVIEW = 3;
40
+
41
+ export class TetrisGame implements Game<TetrisState, TetrisAction> {
42
+ static readonly VARIATIONS: readonly GameVariation[] = [
43
+ { id: "classic", title: "Standard", description: "Standard seven-bag Tetris.", goal: "Survive as long as possible" },
44
+ { id: "sprint", title: "Sprint", description: "Clear 10 lines to finish.", goal: "Clear 10 lines" },
45
+ { id: "chaos", title: "Chaos", description: "Pieces are fully random instead of bagged.", goal: "Survive the chaos" },
46
+ ];
47
+
48
+ readonly name = "tetris";
49
+ readonly actions = [
50
+ "left",
51
+ "right",
52
+ "rotate-cw",
53
+ "rotate-ccw",
54
+ "soft-drop",
55
+ "hard-drop",
56
+ "tick",
57
+ ] as const;
58
+ readonly variation: GameVariation;
59
+
60
+ private board: Board = emptyBoard();
61
+ private active: ActivePiece | null = null;
62
+ private queue: PieceType[] = [];
63
+ private bag = new SevenBag(new Rng(0));
64
+ private points = 0;
65
+ private lines = 0;
66
+ private over = false;
67
+ private readonly targetLines: number;
68
+ private gravityScale = 1;
69
+
70
+ constructor(variationId = "classic") {
71
+ this.variation = TetrisGame.VARIATIONS.find(({ id }) => id === variationId) ?? TetrisGame.VARIATIONS[0]!;
72
+ this.targetLines = this.variation.id === "sprint" ? 10 : 0;
73
+ }
74
+
75
+ reset(seed: number): void {
76
+ this.bag = new SevenBag(new Rng(seed), this.variation.id !== "chaos");
77
+ this.gravityScale = this.variation.id === "chaos" ? 0.75 + new Rng(seed).next() * 0.5 : 1;
78
+ this.board = emptyBoard();
79
+ this.points = 0;
80
+ this.lines = 0;
81
+ this.over = false;
82
+ this.queue = Array.from({ length: NEXT_PREVIEW + 1 }, () => this.bag.next());
83
+ this.active = spawnPiece(this.queue.shift()!);
84
+ }
85
+
86
+ step(action: TetrisAction): StepResult {
87
+ if (this.over || !this.active) return { reward: 0, moved: false, done: true, success: false };
88
+
89
+ switch (action) {
90
+ case "left":
91
+ case "right": {
92
+ const next = tryMove(this.board, this.active, action === "left" ? -1 : 1, 0);
93
+ if (!next) return this.noop();
94
+ this.active = next;
95
+ return { reward: 0, moved: true, done: false, success: false };
96
+ }
97
+ case "rotate-cw":
98
+ case "rotate-ccw": {
99
+ const next = tryRotate(this.board, this.active, action === "rotate-cw" ? 1 : -1);
100
+ if (!next) return this.noop();
101
+ this.active = next;
102
+ return { reward: 0, moved: true, done: false, success: false };
103
+ }
104
+ case "soft-drop": {
105
+ const next = tryMove(this.board, this.active, 0, 1);
106
+ if (next) {
107
+ this.active = next;
108
+ this.points += 1; // guideline: +1 per soft-dropped cell
109
+ return { reward: 1, moved: true, done: false, success: false };
110
+ }
111
+ const locked = this.lockActive();
112
+ return { reward: locked.reward, moved: true, done: this.over, success: this.hasWon(), randomEffects: locked.randomEffects };
113
+ }
114
+ case "tick": {
115
+ const next = tryMove(this.board, this.active, 0, 1);
116
+ if (next) {
117
+ this.active = next;
118
+ return { reward: 0, moved: true, done: false, success: false };
119
+ }
120
+ const locked = this.lockActive();
121
+ return { reward: locked.reward, moved: true, done: this.over, success: this.hasWon(), randomEffects: locked.randomEffects };
122
+ }
123
+ case "hard-drop": {
124
+ const dist = dropDistance(this.board, this.active);
125
+ this.active = { ...this.active, y: this.active.y + dist };
126
+ const dropPoints = 2 * dist; // guideline: +2 per hard-dropped cell
127
+ this.points += dropPoints;
128
+ const locked = this.lockActive();
129
+ return {
130
+ reward: dropPoints + locked.reward,
131
+ moved: true,
132
+ done: this.over,
133
+ success: this.hasWon(),
134
+ randomEffects: locked.randomEffects,
135
+ };
136
+ }
137
+ }
138
+ }
139
+
140
+ private noop(): StepResult {
141
+ return { reward: 0, moved: false, done: false, success: false };
142
+ }
143
+
144
+ /** Lock the active piece, score cleared lines, and spawn the next one. */
145
+ private lockActive(): {
146
+ reward: number;
147
+ randomEffects?: { kind: "piece_drawn"; active_piece: PieceType; queued_piece: PieceType };
148
+ } {
149
+ const piece = this.active!;
150
+ const res = lockPiece(this.board, piece);
151
+ this.board = res.board;
152
+ const gained = lineScore(res.cleared, this.level());
153
+ this.points += gained;
154
+ this.lines += res.cleared;
155
+ if (this.targetLines > 0 && this.lines >= this.targetLines) this.over = true;
156
+
157
+ if (res.toppedOut) {
158
+ this.over = true;
159
+ this.active = null;
160
+ return { reward: gained };
161
+ }
162
+
163
+ const nextType = this.queue.shift()!;
164
+ const queuedType = this.bag.next();
165
+ this.queue.push(queuedType);
166
+ const spawned = spawnPiece(nextType);
167
+ if (collides(this.board, spawned)) {
168
+ this.over = true;
169
+ this.active = null;
170
+ } else {
171
+ this.active = spawned;
172
+ }
173
+ return {
174
+ reward: gained,
175
+ randomEffects: {
176
+ kind: "piece_drawn",
177
+ active_piece: nextType,
178
+ queued_piece: queuedType,
179
+ },
180
+ };
181
+ }
182
+
183
+ state(): TetrisState {
184
+ return {
185
+ board: this.board.map((row) => [...row]),
186
+ active: this.active ? { ...this.active } : null,
187
+ next: [...this.queue],
188
+ score: this.points,
189
+ lines: this.lines,
190
+ level: this.level(),
191
+ over: this.over,
192
+ };
193
+ }
194
+
195
+ score(): number {
196
+ return this.points;
197
+ }
198
+
199
+ level(): number {
200
+ return levelForLines(this.lines);
201
+ }
202
+
203
+ linesCleared(): number {
204
+ return this.lines;
205
+ }
206
+
207
+ isOver(): boolean {
208
+ return this.over;
209
+ }
210
+
211
+ /** Classic has no win condition; goal variants use their target instead. */
212
+ hasWon(): boolean {
213
+ return this.targetLines > 0 && this.lines >= this.targetLines;
214
+ }
215
+
216
+ /** Current gravity interval so the app can pace its tick timer. */
217
+ gravityMs(): number {
218
+ return Math.max(60, Math.round(gravityMs(this.level()) * this.gravityScale));
219
+ }
220
+ }
package/src/index.ts ADDED
@@ -0,0 +1,82 @@
1
+ #!/usr/bin/env bun
2
+ import { join, dirname } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { createAuthAnalyticsEvent } from "./analytics/events.js";
5
+ import { analytics } from "./analytics/posthog.js";
6
+ import { runAuthCommand, type AuthSuccess } from "./auth.js";
7
+ import { findGame, isPlayable, REGISTRY } from "./games/registry.js";
8
+ import { launchOverlay as showOverlay } from "../scripts/overlay.js";
9
+
10
+ const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
11
+
12
+ function captureAuthSuccess(result: AuthSuccess): void {
13
+ if (result.userId) analytics.setDistinctId(result.userId);
14
+ analytics.capture(createAuthAnalyticsEvent(result));
15
+ }
16
+
17
+ function printUsage(): void {
18
+ const ids = REGISTRY.filter(isPlayable).map((entry) => entry.id).join(", ");
19
+ console.log("usage: gamepigeon [game]");
20
+ console.log(" gamepigeon auth [signup|login|oauth|status|logout]");
21
+ console.log(" gamepigeon auth oauth <google|github>");
22
+ console.log(" gamepigeon install [--codex|--claude|--opencode|--hermes|--openclaw|--antigravity|--all]");
23
+ console.log("");
24
+ console.log("With no argument, opens the arcade overlay menu.");
25
+ console.log(`Available games: ${ids}`);
26
+ }
27
+
28
+ async function installAdapters(target?: string): Promise<void> {
29
+ const proc = Bun.spawn(["bash", join(ROOT, "scripts/install-adapters.sh"), target ?? "--all"], {
30
+ cwd: ROOT,
31
+ env: { ...process.env, GAMEPIGEON_WORKSPACE_DIR: process.cwd() },
32
+ stdin: "ignore",
33
+ stdout: "inherit",
34
+ stderr: "inherit",
35
+ });
36
+ await proc.exited;
37
+ if (proc.exitCode !== 0) process.exit(proc.exitCode ?? 1);
38
+ }
39
+
40
+ async function launchOverlay(gameId?: string): Promise<void> {
41
+ await showOverlay("show", ROOT, { gameId });
42
+ }
43
+
44
+ async function main(): Promise<void> {
45
+ let arg = process.argv[2];
46
+ if (arg === "--game") arg = process.argv[3] ?? "";
47
+ if (arg === "--help" || arg === "-h") {
48
+ printUsage();
49
+ return;
50
+ }
51
+ if (arg === "auth") {
52
+ try {
53
+ const result = await runAuthCommand(process.argv[3]);
54
+ if (result) captureAuthSuccess(result);
55
+ } finally {
56
+ await analytics.shutdown();
57
+ }
58
+ return;
59
+ }
60
+ if (arg === "install") {
61
+ await installAdapters(process.argv[3]);
62
+ return;
63
+ }
64
+
65
+ let gameId: string | undefined;
66
+ if (arg !== undefined) {
67
+ const entry = findGame(arg);
68
+ if (!entry || !isPlayable(entry)) {
69
+ const ids = REGISTRY.filter(isPlayable).map((entry) => entry.id).join(", ");
70
+ console.error(`Unknown or unavailable game "${arg}". Available games: ${ids}`);
71
+ process.exit(1);
72
+ }
73
+ gameId = entry.id;
74
+ }
75
+
76
+ await launchOverlay(gameId);
77
+ }
78
+
79
+ main().catch((error: unknown) => {
80
+ console.error(error instanceof Error ? error.message : String(error));
81
+ process.exit(1);
82
+ });
@@ -0,0 +1,93 @@
1
+ import { RECORDING_SDK_VERSION } from "./types.js";
2
+ import type {
3
+ GameEvent,
4
+ GameEventChunk,
5
+ TraceEvent,
6
+ TraceEventChunk,
7
+ } from "./types.js";
8
+
9
+ export interface BuildChunkInput {
10
+ gameId: string;
11
+ gameType: string;
12
+ gameVersion: string;
13
+ chunkId: string;
14
+ chunkSequence: number;
15
+ createdAtMs: number;
16
+ applicationVersion: string;
17
+ events: GameEvent[];
18
+ }
19
+
20
+ export function buildChunk(input: BuildChunkInput): GameEventChunk {
21
+ if (input.events.length === 0) throw new Error("cannot build an empty recording chunk");
22
+ for (let index = 1; index < input.events.length; index++) {
23
+ if (input.events[index]!.event_sequence <= input.events[index - 1]!.event_sequence) {
24
+ throw new Error("chunk events must have strictly increasing sequences");
25
+ }
26
+ }
27
+ return {
28
+ schema_version: 1,
29
+ game_id: input.gameId,
30
+ game_type: input.gameType,
31
+ game_version: input.gameVersion,
32
+ chunk_id: input.chunkId,
33
+ chunk_sequence: input.chunkSequence,
34
+ first_event_sequence: input.events[0]!.event_sequence,
35
+ last_event_sequence: input.events.at(-1)!.event_sequence,
36
+ event_count: input.events.length,
37
+ created_at_ms: input.createdAtMs,
38
+ client: {
39
+ platform: "tui",
40
+ application_version: input.applicationVersion,
41
+ recording_sdk_version: RECORDING_SDK_VERSION,
42
+ },
43
+ events: input.events,
44
+ };
45
+ }
46
+
47
+ export interface BuildTraceChunkInput {
48
+ userId: string;
49
+ gameId: string;
50
+ gameType: string;
51
+ gameVersion: string;
52
+ chunkId: string;
53
+ chunkSequence: number;
54
+ createdAtMs: number;
55
+ applicationVersion: string;
56
+ events: TraceEvent[];
57
+ }
58
+
59
+ export function buildTraceChunk(input: BuildTraceChunkInput): TraceEventChunk {
60
+ if (input.events.length === 0) throw new Error("cannot build an empty trace chunk");
61
+ for (let index = 0; index < input.events.length; index++) {
62
+ const event = input.events[index]!;
63
+ if (event.user_id !== input.userId) throw new Error("trace event user_id must match its chunk");
64
+ if (event.game_id !== input.gameId) throw new Error("trace event game_id must match its chunk");
65
+ if (event.chunk_id !== input.chunkId) throw new Error("trace event chunk_id must match its chunk");
66
+ if (index > 0 && event.event_sequence <= input.events[index - 1]!.event_sequence) {
67
+ throw new Error("trace events must have strictly increasing sequences");
68
+ }
69
+ }
70
+ return {
71
+ schema_version: 2,
72
+ user_id: input.userId,
73
+ game_id: input.gameId,
74
+ game_type: input.gameType,
75
+ game_version: input.gameVersion,
76
+ chunk_id: input.chunkId,
77
+ chunk_sequence: input.chunkSequence,
78
+ first_event_sequence: input.events[0]!.event_sequence,
79
+ last_event_sequence: input.events.at(-1)!.event_sequence,
80
+ event_count: input.events.length,
81
+ created_at_ms: input.createdAtMs,
82
+ client: {
83
+ platform: "tui",
84
+ application_version: input.applicationVersion,
85
+ recording_sdk_version: RECORDING_SDK_VERSION,
86
+ },
87
+ events: input.events,
88
+ };
89
+ }
90
+
91
+ export function estimatedBytes(value: unknown): number {
92
+ return Buffer.byteLength(JSON.stringify(value), "utf8");
93
+ }
@@ -0,0 +1,31 @@
1
+ import { chmodSync, existsSync, mkdtempSync, rmSync, statSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, describe, expect, it } from "vitest";
5
+ import { recordingDir } from "./config.js";
6
+
7
+ const roots: string[] = [];
8
+ afterEach(() => {
9
+ for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
10
+ });
11
+
12
+ describe("recordingDir", () => {
13
+ it("creates and tightens the application and recording roots to 0700", () => {
14
+ const home = mkdtempSync(join(tmpdir(), "gamepigeon-home-"));
15
+ roots.push(home);
16
+ const appRoot = join(home, ".tui-gamepigeon");
17
+ const recordings = join(appRoot, "recordings");
18
+ const previous = process.umask(0);
19
+ try {
20
+ const path = recordingDir(home);
21
+ chmodSync(appRoot, 0o777);
22
+ chmodSync(recordings, 0o777);
23
+ expect(recordingDir(home)).toBe(path);
24
+ expect(existsSync(path)).toBe(true);
25
+ expect(statSync(appRoot).mode & 0o777).toBe(0o700);
26
+ expect(statSync(recordings).mode & 0o777).toBe(0o700);
27
+ } finally {
28
+ process.umask(previous);
29
+ }
30
+ });
31
+ });
@@ -0,0 +1,37 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+ import { ensurePrivateDirectory } from "./permissions.js";
4
+
5
+ export interface RecordingConfig {
6
+ flushIntervalMs: number;
7
+ maxEventsPerChunk: number;
8
+ maxEstimatedChunkBytes: number;
9
+ maxInFlightChunksPerGame: 1;
10
+ }
11
+
12
+ export const DEFAULT_RECORDING_CONFIG: RecordingConfig = {
13
+ flushIntervalMs: 2_000,
14
+ maxEventsPerChunk: 50,
15
+ maxEstimatedChunkBytes: 128 * 1024,
16
+ maxInFlightChunksPerGame: 1,
17
+ };
18
+
19
+ export function resolveRecordingConfig(
20
+ overrides: Partial<RecordingConfig> = {},
21
+ ): RecordingConfig {
22
+ const config = { ...DEFAULT_RECORDING_CONFIG, ...overrides };
23
+ for (const [name, value] of Object.entries(config)) {
24
+ if (!Number.isFinite(value) || value <= 0) {
25
+ throw new Error(`recording config ${name} must be greater than zero`);
26
+ }
27
+ }
28
+ return config;
29
+ }
30
+
31
+ export function recordingDir(home = homedir()): string {
32
+ const root = join(home, ".tui-gamepigeon");
33
+ const recordings = join(root, "recordings");
34
+ ensurePrivateDirectory(root);
35
+ ensurePrivateDirectory(recordings);
36
+ return recordings;
37
+ }