@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,464 @@
1
+ import type { StepResult } from "../core/game.js";
2
+ import { buildChunk, estimatedBytes } from "./chunks.js";
3
+ import {
4
+ resolveRecordingConfig,
5
+ type RecordingConfig,
6
+ } from "./config.js";
7
+ import { createChunkId, createEventId, createGameId } from "./ids.js";
8
+ import {
9
+ RecordingStore,
10
+ type ReadonlyRecoveryChunkIndex,
11
+ type RecoveryChunkIndex,
12
+ } from "./store.js";
13
+ import { DisabledTransport, type ChunkTransport } from "./transport.js";
14
+ import type {
15
+ ActionEvent,
16
+ ActionSource,
17
+ GameEndedEvent,
18
+ GameEvent,
19
+ EventSource,
20
+ GameOutcome,
21
+ GameStartedEvent,
22
+ RecordingHealth,
23
+ } from "./types.js";
24
+ import { ChunkUploader } from "./uploader.js";
25
+
26
+ export interface RecorderOptions {
27
+ store: RecordingStore;
28
+ gameType: string;
29
+ gameVersion: string;
30
+ seed: number;
31
+ initialState: unknown;
32
+ applicationVersion?: string;
33
+ config?: Partial<RecordingConfig>;
34
+ transport?: ChunkTransport;
35
+ wallTimeMs?: () => number;
36
+ monotonicTimeMs?: () => number;
37
+ createGameId?: () => string;
38
+ createEventId?: () => string;
39
+ createChunkId?: () => string;
40
+ }
41
+
42
+ export interface RecordActionInput {
43
+ action: string;
44
+ source: ActionSource;
45
+ stateBefore: unknown;
46
+ result: StepResult;
47
+ stateAfter: unknown;
48
+ }
49
+
50
+ export interface EndGameInput {
51
+ outcome: GameOutcome;
52
+ finalState: unknown;
53
+ finalScore: number;
54
+ success: boolean;
55
+ }
56
+
57
+ function cloneSnapshot<T>(value: T): T {
58
+ return structuredClone(value);
59
+ }
60
+
61
+ function errorMessage(error: unknown): string {
62
+ return error instanceof Error ? error.message : String(error);
63
+ }
64
+
65
+ export class GameplayRecorder {
66
+ readonly gameId: string;
67
+
68
+ private readonly store: RecordingStore;
69
+ private readonly gameType: string;
70
+ private readonly gameVersion: string;
71
+ private readonly applicationVersion: string;
72
+ private readonly config: RecordingConfig;
73
+ private readonly uploader: ChunkUploader;
74
+ private readonly wallTimeMs: () => number;
75
+ private monotonicOrigin: number | null = null;
76
+ private readonly monotonicTimeMs: () => number;
77
+ private readonly nextEventId: () => string;
78
+ private readonly nextChunkId: () => string;
79
+ private readonly buffered: GameEvent[] = [];
80
+ private nextEventSequence = 1;
81
+ private nextChunkSequence = 1;
82
+ private flushTimer: ReturnType<typeof setTimeout> | null = null;
83
+ private uploadRequested = false;
84
+ private uploadInFlight: Promise<void> | null = null;
85
+ private ended = false;
86
+ private recordingHealth: RecordingHealth = { status: "healthy" };
87
+
88
+ constructor(options: RecorderOptions) {
89
+ this.store = options.store;
90
+ this.gameType = options.gameType;
91
+ this.gameVersion = options.gameVersion;
92
+ this.applicationVersion = options.applicationVersion ?? "dev";
93
+ this.config = resolveRecordingConfig(options.config);
94
+ this.wallTimeMs = options.wallTimeMs ?? Date.now;
95
+ this.monotonicTimeMs = options.monotonicTimeMs ?? (() => performance.now());
96
+ this.gameId = (options.createGameId ?? createGameId)();
97
+ this.nextEventId = options.createEventId ?? createEventId;
98
+ this.nextChunkId = options.createChunkId ?? createChunkId;
99
+ this.uploader = new ChunkUploader(
100
+ this.store,
101
+ options.transport ?? new DisabledTransport(),
102
+ );
103
+
104
+ const event: GameStartedEvent = {
105
+ ...this.envelope("system"),
106
+ event_type: "game_started",
107
+ payload: {
108
+ game_type: this.gameType,
109
+ game_version: this.gameVersion,
110
+ seed: options.seed,
111
+ initial_state: cloneSnapshot(options.initialState),
112
+ },
113
+ };
114
+ this.append(event);
115
+ this.startUpload();
116
+ }
117
+
118
+ health(): RecordingHealth {
119
+ return { ...this.recordingHealth };
120
+ }
121
+
122
+ recordAction(input: RecordActionInput): void {
123
+ if (this.ended) return;
124
+ try {
125
+ const stateBefore = cloneSnapshot(input.stateBefore);
126
+ const stateAfter = cloneSnapshot(input.stateAfter);
127
+ const clonedRandomEffects = input.result.randomEffects === undefined
128
+ ? undefined
129
+ : cloneSnapshot(input.result.randomEffects);
130
+ const result = {
131
+ reward: input.result.reward,
132
+ moved: input.result.moved,
133
+ done: input.result.done,
134
+ success: input.result.success,
135
+ };
136
+ const event: ActionEvent = {
137
+ ...this.envelope(input.source),
138
+ event_type: "action",
139
+ payload: {
140
+ action: input.action,
141
+ state_before: stateBefore,
142
+ result,
143
+ state_after: stateAfter,
144
+ ...(clonedRandomEffects === undefined
145
+ ? {}
146
+ : { random_effects: clonedRandomEffects }),
147
+ },
148
+ };
149
+ this.append(event);
150
+ } catch (error) {
151
+ this.degrade(error);
152
+ }
153
+ }
154
+
155
+ endGame(input: EndGameInput): void {
156
+ if (this.ended) return;
157
+ try {
158
+ const finalSequence = this.nextEventSequence;
159
+ const event: GameEndedEvent = {
160
+ ...this.envelope("system"),
161
+ event_type: "game_ended",
162
+ payload: {
163
+ outcome: input.outcome,
164
+ final_state: cloneSnapshot(input.finalState),
165
+ final_score: input.finalScore,
166
+ success: input.success,
167
+ final_event_sequence: finalSequence,
168
+ },
169
+ };
170
+ this.append(event);
171
+ } catch (error) {
172
+ this.degrade(error);
173
+ }
174
+ this.ended = true;
175
+ this.flush();
176
+ if (this.buffered.length === 0) {
177
+ this.attempt(() => this.store.finalizeGame(this.gameId));
178
+ }
179
+ this.startUpload();
180
+ }
181
+
182
+ flush(): void {
183
+ this.clearFlushTimer();
184
+ try {
185
+ this.flushBuffered();
186
+ } catch (error) {
187
+ this.degrade(error);
188
+ }
189
+ if (this.buffered.length > 0) this.scheduleFlush();
190
+ this.startUpload();
191
+ }
192
+
193
+ private flushBuffered(): void {
194
+ while (this.buffered.length > 0) {
195
+ const events = this.takeChunkEvents();
196
+ const chunk = buildChunk({
197
+ gameId: this.gameId,
198
+ gameType: this.gameType,
199
+ gameVersion: this.gameVersion,
200
+ chunkId: this.nextChunkId(),
201
+ chunkSequence: this.nextChunkSequence,
202
+ createdAtMs: this.wallTimeMs(),
203
+ applicationVersion: this.applicationVersion,
204
+ events,
205
+ });
206
+ if (!this.attempt(() => this.store.persistChunk(chunk))) break;
207
+ this.buffered.splice(0, events.length);
208
+ this.nextChunkSequence++;
209
+ }
210
+ }
211
+
212
+ shutdown(): void {
213
+ this.clearFlushTimer();
214
+ this.flush();
215
+ this.startUpload();
216
+ }
217
+
218
+ async drainUploads(): Promise<void> {
219
+ this.flush();
220
+ while (this.uploadInFlight) {
221
+ await this.uploadInFlight;
222
+ }
223
+ }
224
+
225
+ private envelope<Source extends EventSource>(source: Source) {
226
+ const monotonicNow = this.monotonicTimeMs();
227
+ this.monotonicOrigin ??= monotonicNow;
228
+ return {
229
+ event_id: this.nextEventId(),
230
+ event_sequence: this.nextEventSequence++,
231
+ event_version: 1 as const,
232
+ source,
233
+ client_time_ms: this.wallTimeMs(),
234
+ monotonic_time_ms: Math.max(0, monotonicNow - this.monotonicOrigin),
235
+ };
236
+ }
237
+
238
+ private append(event: GameEvent): void {
239
+ this.attempt(() => this.store.appendEvent(this.gameId, event));
240
+ this.buffered.push(event);
241
+ if (
242
+ this.buffered.length >= this.config.maxEventsPerChunk ||
243
+ estimatedBytes(this.buffered) >= this.config.maxEstimatedChunkBytes
244
+ ) {
245
+ this.flush();
246
+ } else {
247
+ this.scheduleFlush();
248
+ }
249
+ }
250
+
251
+ private takeChunkEvents(): GameEvent[] {
252
+ const selected: GameEvent[] = [];
253
+ for (const event of this.buffered) {
254
+ if (selected.length >= this.config.maxEventsPerChunk) break;
255
+ if (
256
+ selected.length > 0 &&
257
+ estimatedBytes([...selected, event]) > this.config.maxEstimatedChunkBytes
258
+ ) {
259
+ break;
260
+ }
261
+ selected.push(event);
262
+ }
263
+ return selected;
264
+ }
265
+
266
+ private scheduleFlush(): void {
267
+ if (this.flushTimer || this.ended) return;
268
+ this.flushTimer = setTimeout(() => {
269
+ this.flushTimer = null;
270
+ this.flush();
271
+ }, this.config.flushIntervalMs);
272
+ this.flushTimer.unref?.();
273
+ }
274
+
275
+ private clearFlushTimer(): void {
276
+ if (!this.flushTimer) return;
277
+ clearTimeout(this.flushTimer);
278
+ this.flushTimer = null;
279
+ }
280
+
281
+ private startUpload(): void {
282
+ if (this.uploadInFlight) {
283
+ this.uploadRequested = true;
284
+ return;
285
+ }
286
+ this.uploadRequested = false;
287
+ this.uploadInFlight = this.uploader.uploadPending()
288
+ .then(() => undefined)
289
+ .catch((error: unknown) => {
290
+ this.degrade(error);
291
+ })
292
+ .finally(() => {
293
+ this.uploadInFlight = null;
294
+ if (this.uploadRequested) {
295
+ this.uploadRequested = false;
296
+ this.startUpload();
297
+ }
298
+ });
299
+ }
300
+
301
+ private attempt<T>(operation: () => T): T | undefined {
302
+ try {
303
+ return operation();
304
+ } catch (error) {
305
+ this.degrade(error);
306
+ return undefined;
307
+ }
308
+ }
309
+
310
+ private degrade(error: unknown): void {
311
+ this.recordingHealth = {
312
+ status: "degraded",
313
+ last_error: errorMessage(error),
314
+ };
315
+ }
316
+ }
317
+
318
+ export interface RecoveryOptions {
319
+ applicationVersion?: string;
320
+ config?: Partial<RecordingConfig>;
321
+ createChunkId?: () => string;
322
+ wallTimeMs?: () => number;
323
+ }
324
+
325
+ export interface RecoveryResult {
326
+ games: number;
327
+ chunks: number;
328
+ events: number;
329
+ }
330
+
331
+ export function recoverActiveRecordings(
332
+ store: RecordingStore,
333
+ options: RecoveryOptions = {},
334
+ ): RecoveryResult {
335
+ const persisted = [...store.listPending(), ...store.listFailed(), ...store.listCompletedChunks()];
336
+ return recoverActiveRecordingGames(
337
+ store,
338
+ options,
339
+ store.listActiveGameIds(),
340
+ indexChunksByGame(persisted),
341
+ );
342
+ }
343
+
344
+ export async function recoverActiveRecordingsBatched(
345
+ store: RecordingStore,
346
+ options: RecoveryOptions & {
347
+ batchSize?: number;
348
+ yieldControl?: () => Promise<void>;
349
+ signal?: AbortSignal;
350
+ persistedByGame?: ReadonlyRecoveryChunkIndex;
351
+ } = {},
352
+ ): Promise<RecoveryResult> {
353
+ const batchSize = Math.max(1, options.batchSize ?? 16);
354
+ const yieldControl = options.yieldControl ?? (() =>
355
+ new Promise<void>((resolve) => setTimeout(resolve, 0))
356
+ );
357
+ const persistedByGame = options.persistedByGame ?? await store.buildRecoveryChunkIndex({
358
+ batchSize,
359
+ yieldControl,
360
+ signal: options.signal,
361
+ });
362
+ if (options.signal?.aborted) return { games: 0, chunks: 0, events: 0 };
363
+ const total: RecoveryResult = { games: 0, chunks: 0, events: 0 };
364
+ for await (const gameId of store.iterateActiveGameIdsBatched({
365
+ batchSize,
366
+ yieldControl,
367
+ signal: options.signal,
368
+ })) {
369
+ if (options.signal?.aborted) break;
370
+ const batch = recoverActiveRecordingGames(
371
+ store,
372
+ options,
373
+ [gameId],
374
+ persistedByGame,
375
+ );
376
+ total.games += batch.games;
377
+ total.chunks += batch.chunks;
378
+ total.events += batch.events;
379
+ }
380
+ return total;
381
+ }
382
+
383
+ function recoverActiveRecordingGames(
384
+ store: RecordingStore,
385
+ options: RecoveryOptions,
386
+ gameIds: string[],
387
+ persistedByGame: ReadonlyRecoveryChunkIndex,
388
+ ): RecoveryResult {
389
+ const config = resolveRecordingConfig(options.config);
390
+ const nextChunkId = options.createChunkId ?? createChunkId;
391
+ const wallTimeMs = options.wallTimeMs ?? Date.now;
392
+ const result: RecoveryResult = { games: 0, chunks: 0, events: 0 };
393
+
394
+ for (const gameId of gameIds) {
395
+ const events = store.readActiveEvents(gameId);
396
+ const started = events.find(
397
+ (event): event is GameStartedEvent => event.event_type === "game_started",
398
+ );
399
+ if (!started) continue;
400
+ const existing = persistedByGame.get(gameId) ?? [];
401
+ const assignedThrough = Math.max(
402
+ 0,
403
+ ...existing.map((item) => item.chunk.last_event_sequence),
404
+ );
405
+ let chunkSequence = Math.max(
406
+ 0,
407
+ ...existing.map((item) => item.chunk.chunk_sequence),
408
+ ) + 1;
409
+ const unassigned = events.filter(
410
+ (event) => event.event_sequence > assignedThrough,
411
+ );
412
+ if (unassigned.length === 0) {
413
+ if (events.at(-1)?.event_type === "game_ended") {
414
+ store.finalizeGame(gameId);
415
+ }
416
+ continue;
417
+ }
418
+ result.games++;
419
+
420
+ while (unassigned.length > 0) {
421
+ const selected: GameEvent[] = [];
422
+ while (
423
+ unassigned.length > 0 &&
424
+ selected.length < config.maxEventsPerChunk
425
+ ) {
426
+ const event = unassigned[0]!;
427
+ if (
428
+ selected.length > 0 &&
429
+ estimatedBytes([...selected, event]) > config.maxEstimatedChunkBytes
430
+ ) {
431
+ break;
432
+ }
433
+ selected.push(unassigned.shift()!);
434
+ }
435
+ store.persistChunk(buildChunk({
436
+ gameId,
437
+ gameType: started.payload.game_type,
438
+ gameVersion: started.payload.game_version,
439
+ chunkId: nextChunkId(),
440
+ chunkSequence,
441
+ createdAtMs: wallTimeMs(),
442
+ applicationVersion: options.applicationVersion ?? "dev",
443
+ events: selected,
444
+ }));
445
+ chunkSequence++;
446
+ result.chunks++;
447
+ result.events += selected.length;
448
+ }
449
+ if (events.at(-1)?.event_type === "game_ended") store.finalizeGame(gameId);
450
+ }
451
+ return result;
452
+ }
453
+
454
+ function indexChunksByGame(
455
+ persisted: ReturnType<RecordingStore["listPending"]>,
456
+ ): RecoveryChunkIndex {
457
+ const persistedByGame: RecoveryChunkIndex = new Map();
458
+ for (const item of persisted) {
459
+ const game = persistedByGame.get(item.chunk.game_id) ?? [];
460
+ game.push(item);
461
+ persistedByGame.set(item.chunk.game_id, game);
462
+ }
463
+ return persistedByGame;
464
+ }
@@ -0,0 +1,231 @@
1
+ import { appendFileSync, chmodSync, existsSync, mkdtempSync, readFileSync, 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 { RecordingStore } from "./store.js";
6
+ import type { GameEvent, GameEventChunk } from "./types.js";
7
+
8
+ const roots: string[] = [];
9
+ afterEach(() => {
10
+ for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
11
+ });
12
+
13
+ function root(): string {
14
+ const dir = mkdtempSync(join(tmpdir(), "gamepigeon-recording-"));
15
+ roots.push(dir);
16
+ return dir;
17
+ }
18
+
19
+ function event(sequence: number): GameEvent {
20
+ return {
21
+ event_id: `evt_${sequence}`,
22
+ event_sequence: sequence,
23
+ event_type: sequence === 1 ? "game_started" : "action",
24
+ event_version: 1,
25
+ source: sequence === 1 ? "system" : "player",
26
+ client_time_ms: sequence,
27
+ monotonic_time_ms: sequence,
28
+ payload: sequence === 1
29
+ ? { game_type: "2048", game_version: "1", seed: 7, initial_state: { board: [] } }
30
+ : { action: "left", state_before: {}, result: { reward: 0, moved: false, done: false, success: false }, state_after: {} },
31
+ } as GameEvent;
32
+ }
33
+
34
+ function chunk(): GameEventChunk {
35
+ const events = [event(1), event(2)];
36
+ return {
37
+ schema_version: 1,
38
+ game_id: "game_1",
39
+ game_type: "2048",
40
+ game_version: "1",
41
+ chunk_id: "chk_1",
42
+ chunk_sequence: 1,
43
+ first_event_sequence: 1,
44
+ last_event_sequence: 2,
45
+ event_count: events.length,
46
+ created_at_ms: 10,
47
+ client: { platform: "tui", application_version: "dev", recording_sdk_version: "1" },
48
+ events,
49
+ };
50
+ }
51
+
52
+ describe("RecordingStore", () => {
53
+ it("enforces private directory and file modes under a permissive umask", () => {
54
+ const previous = process.umask(0);
55
+ try {
56
+ const dir = root();
57
+ const store = new RecordingStore(dir);
58
+ store.appendEvent("game_1", event(1));
59
+ const persisted = store.persistChunk(chunk());
60
+ const failed = store.markFailed(persisted, "private reason");
61
+ expect(statSync(dir).mode & 0o777).toBe(0o700);
62
+ for (const directory of ["active", "pending", "failed", "completed", "completed/games", "completed/chunks"]) {
63
+ expect(statSync(join(dir, directory)).mode & 0o777).toBe(0o700);
64
+ }
65
+ expect(statSync(store.activePath("game_1")).mode & 0o777).toBe(0o600);
66
+ expect(statSync(failed).mode & 0o777).toBe(0o600);
67
+ expect(statSync(`${failed}.error.txt`).mode & 0o777).toBe(0o600);
68
+ } finally {
69
+ process.umask(previous);
70
+ }
71
+ });
72
+
73
+ it("creates the recording directory contract", () => {
74
+ const dir = root();
75
+ new RecordingStore(dir);
76
+ for (const name of ["active", "pending", "failed", "completed/games", "completed/chunks"]) {
77
+ expect(existsSync(join(dir, name))).toBe(true);
78
+ }
79
+ });
80
+
81
+ it("appends events to an active JSONL log", () => {
82
+ const store = new RecordingStore(root());
83
+ store.appendEvent("game_1", event(1));
84
+ store.appendEvent("game_1", event(2));
85
+ expect(store.readActiveEvents("game_1").map((item) => item.event_sequence)).toEqual([1, 2]);
86
+ });
87
+
88
+ it("persists immutable exact chunk bytes", () => {
89
+ const store = new RecordingStore(root());
90
+ const value = chunk();
91
+ const persisted = store.persistChunk(value);
92
+ const bytes = readFileSync(persisted.path, "utf8");
93
+ expect(bytes).toBe(JSON.stringify(value));
94
+ expect(store.persistChunk(value).bytes).toBe(bytes);
95
+ expect(() => store.persistChunk({ ...value, created_at_ms: 11 })).toThrow("conflicting bytes");
96
+ });
97
+
98
+ it("yields while indexing a large persisted recovery backlog", async () => {
99
+ const store = new RecordingStore(root());
100
+ for (let index = 0; index < 12; index++) {
101
+ store.persistChunk({
102
+ ...chunk(),
103
+ game_id: `game_${index}`,
104
+ chunk_id: `chk_${index}`,
105
+ });
106
+ }
107
+ let yields = 0;
108
+ const persisted = await store.listRecoveryChunksBatched({
109
+ batchSize: 5,
110
+ yieldControl: async () => { yields++; },
111
+ });
112
+ expect(persisted).toHaveLength(12);
113
+ expect(yields).toBe(2);
114
+ });
115
+
116
+ it("cancels chunk indexing at a batch boundary without touching later files", async () => {
117
+ const store = new RecordingStore(root());
118
+ for (let index = 0; index < 8; index++) {
119
+ store.persistChunk({
120
+ ...chunk(),
121
+ game_id: `game_${index}`,
122
+ chunk_id: `chk_${index}`,
123
+ });
124
+ }
125
+ const cancellation = new AbortController();
126
+ let yields = 0;
127
+ const indexed = await store.buildRecoveryChunkIndex({
128
+ batchSize: 2,
129
+ signal: cancellation.signal,
130
+ yieldControl: async () => {
131
+ yields++;
132
+ if (yields === 2) cancellation.abort();
133
+ },
134
+ });
135
+
136
+ expect(yields).toBe(2);
137
+ expect([...indexed.values()].flat()).toHaveLength(4);
138
+ expect(store.listPending()).toHaveLength(8);
139
+ });
140
+
141
+ it("builds one linear per-game chunk index", async () => {
142
+ class CountingStore extends RecordingStore {
143
+ reads = 0;
144
+
145
+ protected override readChunk(path: string) {
146
+ this.reads++;
147
+ return super.readChunk(path);
148
+ }
149
+ }
150
+ const store = new CountingStore(root());
151
+ for (let game = 0; game < 6; game++) {
152
+ for (let sequence = 1; sequence <= 3; sequence++) {
153
+ store.persistChunk({
154
+ ...chunk(),
155
+ game_id: `game_${game}`,
156
+ chunk_id: `chk_${game}_${sequence}`,
157
+ chunk_sequence: sequence,
158
+ });
159
+ }
160
+ }
161
+ let yields = 0;
162
+ const indexed = await store.buildRecoveryChunkIndex({
163
+ batchSize: 4,
164
+ yieldControl: async () => { yields++; },
165
+ });
166
+
167
+ expect(indexed.size).toBe(6);
168
+ expect([...indexed.values()].every((items) => items.length === 3)).toBe(true);
169
+ expect(store.reads).toBe(18);
170
+ expect(yields).toBe(4);
171
+ });
172
+
173
+ it("moves only acknowledged chunks into completed storage", () => {
174
+ const store = new RecordingStore(root());
175
+ const persisted = store.persistChunk(chunk());
176
+ const destination = store.acknowledge(persisted);
177
+ expect(existsSync(persisted.path)).toBe(false);
178
+ expect(existsSync(destination)).toBe(true);
179
+ });
180
+
181
+ it("quarantines permanent failures", () => {
182
+ const store = new RecordingStore(root());
183
+ const persisted = store.persistChunk(chunk());
184
+ const destination = store.markFailed(persisted, "invalid schema");
185
+ expect(existsSync(destination)).toBe(true);
186
+ expect(readFileSync(`${destination}.error.txt`, "utf8")).toContain("invalid schema");
187
+ });
188
+
189
+ it("finalizes the active game log", () => {
190
+ const store = new RecordingStore(root());
191
+ store.appendEvent("game_1", event(1));
192
+ const destination = store.finalizeGame("game_1");
193
+ expect(destination).not.toBeNull();
194
+ expect(existsSync(destination!)).toBe(true);
195
+ expect(store.readActiveEvents("game_1")).toEqual([]);
196
+ });
197
+
198
+ it("lists completed game logs for local inspection", () => {
199
+ const store = new RecordingStore(root());
200
+ store.appendEvent("game_1", event(1));
201
+ store.finalizeGame("game_1");
202
+ expect(store.listCompletedGames()).toEqual([{ gameId: "game_1", events: [event(1)] }]);
203
+ });
204
+
205
+ it("tightens preexisting completed-game directories and logs when listed", () => {
206
+ const previous = process.umask(0);
207
+ try {
208
+ const dir = root();
209
+ const store = new RecordingStore(dir);
210
+ store.appendEvent("game_legacy", event(1));
211
+ const path = store.finalizeGame("game_legacy")!;
212
+ chmodSync(join(dir, "completed"), 0o777);
213
+ chmodSync(join(dir, "completed", "games"), 0o777);
214
+ chmodSync(path, 0o666);
215
+
216
+ expect(store.listCompletedGames()).toHaveLength(1);
217
+ expect(statSync(join(dir, "completed")).mode & 0o777).toBe(0o700);
218
+ expect(statSync(join(dir, "completed", "games")).mode & 0o777).toBe(0o700);
219
+ expect(statSync(path).mode & 0o777).toBe(0o600);
220
+ } finally {
221
+ process.umask(previous);
222
+ }
223
+ });
224
+
225
+ it("recovers valid events before a torn final JSONL write", () => {
226
+ const store = new RecordingStore(root());
227
+ store.appendEvent("game_1", event(1));
228
+ appendFileSync(store.activePath("game_1"), "{\"event_id\":");
229
+ expect(store.readActiveEvents("game_1")).toEqual([event(1)]);
230
+ });
231
+ });