@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,560 @@
1
+ import { createHash } from "node:crypto";
2
+ import { buildTraceChunk, estimatedBytes } from "./chunks.js";
3
+ import {
4
+ resolveRecordingConfig,
5
+ type RecordingConfig,
6
+ } from "./config.js";
7
+ import {
8
+ createChunkId,
9
+ createEventId,
10
+ createGameId,
11
+ createObservationId,
12
+ } from "./ids.js";
13
+ import {
14
+ RecordingStore,
15
+ type ReadonlyRecoveryChunkIndex,
16
+ type RecoveryChunkIndex,
17
+ } from "./store.js";
18
+ import {
19
+ encodeTerminalObservation,
20
+ type TerminalGrid,
21
+ } from "./terminal-grid.js";
22
+ import type { ChunkTransport } from "./transport.js";
23
+ import { isTraceEvent } from "./types.js";
24
+ import type {
25
+ CheckpointReason,
26
+ EngineCheckpointTraceEvent,
27
+ EpisodeEndedTraceEvent,
28
+ EpisodeEndReason,
29
+ EpisodeStartedTraceEvent,
30
+ ObservationTraceEvent,
31
+ OutcomeChangedTraceEvent,
32
+ RecordingHealth,
33
+ TraceActor,
34
+ TraceEvent,
35
+ TraceVisibility,
36
+ } from "./types.js";
37
+
38
+ export interface TraceRecorderOptions {
39
+ store: RecordingStore;
40
+ userId: string;
41
+ gameType: string;
42
+ gameVersion: string;
43
+ rulesetId: string;
44
+ seed: number;
45
+ initialState: unknown;
46
+ controlSchema: readonly string[];
47
+ viewport: { width: number; height: number };
48
+ applicationVersion?: string;
49
+ config?: Partial<RecordingConfig>;
50
+ /** @deprecated Uploads are coordinated by RecordingSyncCoordinator. */
51
+ transport?: ChunkTransport;
52
+ onChunkPersisted?: () => void;
53
+ wallTimeMs?: () => number;
54
+ monotonicTimeMs?: () => number;
55
+ createGameId?: () => string;
56
+ createEventId?: () => string;
57
+ createChunkId?: () => string;
58
+ createObservationId?: () => string;
59
+ }
60
+
61
+ export interface RecordObservationInput {
62
+ grid: TerminalGrid;
63
+ simulationTick?: number;
64
+ causedByEventId?: string;
65
+ captureTiming?: {
66
+ generated_time_us: number;
67
+ delivered_time_us: number;
68
+ dropped_observations: number;
69
+ };
70
+ }
71
+
72
+ export interface RecordControlInput {
73
+ control: string;
74
+ action: string;
75
+ actor: TraceActor;
76
+ simulationTick?: number;
77
+ receivedTimeUs?: number;
78
+ }
79
+
80
+ export interface RecordOutcomeInput {
81
+ scoreBefore: number;
82
+ scoreAfter: number;
83
+ engineReward: number;
84
+ terminal: boolean;
85
+ causedByEventId: string;
86
+ simulationTick?: number;
87
+ progress?: Record<string, number | string | boolean | null>;
88
+ performance?: {
89
+ action_received_us: number;
90
+ action_applied_us: number;
91
+ action_completed_us: number;
92
+ queueing_delay_us: number;
93
+ simulation_tick_duration_us: number;
94
+ };
95
+ }
96
+
97
+ export interface RecordCheckpointInput {
98
+ state: unknown;
99
+ reason: CheckpointReason;
100
+ simulationTick?: number;
101
+ seed?: number;
102
+ randomEffects?: unknown;
103
+ causedByEventId?: string;
104
+ }
105
+
106
+ export interface EndEpisodeInput {
107
+ reason: EpisodeEndReason;
108
+ finalScore: number;
109
+ completed: boolean;
110
+ simulationTick?: number;
111
+ }
112
+
113
+ export interface TraceRecoveryOptions {
114
+ applicationVersion?: string;
115
+ wallTimeMs?: () => number;
116
+ }
117
+
118
+ export interface TraceRecoveryResult {
119
+ games: number;
120
+ chunks: number;
121
+ events: number;
122
+ }
123
+
124
+ function hashState(state: unknown): string {
125
+ return `sha256:${createHash("sha256").update(JSON.stringify(state)).digest("hex")}`;
126
+ }
127
+
128
+ function errorMessage(error: unknown): string {
129
+ return error instanceof Error ? error.message : String(error);
130
+ }
131
+
132
+ export class InteractionTraceRecorder {
133
+ readonly gameId: string;
134
+ readonly userId: string;
135
+ readonly gameType: string;
136
+
137
+ private readonly store: RecordingStore;
138
+ private readonly gameVersion: string;
139
+ private readonly applicationVersion: string;
140
+ private readonly config: RecordingConfig;
141
+ private readonly wallTimeMs: () => number;
142
+ private readonly monotonicTimeMs: () => number;
143
+ private readonly nextEventId: () => string;
144
+ private readonly nextChunkId: () => string;
145
+ private readonly nextObservationId: () => string;
146
+ private readonly onChunkPersisted: () => void;
147
+ private readonly buffered: TraceEvent[] = [];
148
+ private monotonicOrigin: number | null = null;
149
+ private currentChunkId: string;
150
+ private nextEventSequence = 1;
151
+ private nextChunkSequence = 1;
152
+ private flushTimer: ReturnType<typeof setTimeout> | null = null;
153
+ private previousObservation: { observationId: string; grid: TerminalGrid } | undefined;
154
+ private ended = false;
155
+ private recordingHealth: RecordingHealth = { status: "healthy" };
156
+
157
+ constructor(options: TraceRecorderOptions) {
158
+ this.store = options.store;
159
+ this.userId = options.userId;
160
+ this.gameType = options.gameType;
161
+ this.gameVersion = options.gameVersion;
162
+ this.applicationVersion = options.applicationVersion ?? "dev";
163
+ this.config = resolveRecordingConfig(options.config);
164
+ this.wallTimeMs = options.wallTimeMs ?? Date.now;
165
+ this.monotonicTimeMs = options.monotonicTimeMs ?? (() => performance.now());
166
+ this.gameId = (options.createGameId ?? createGameId)();
167
+ this.nextEventId = options.createEventId ?? createEventId;
168
+ this.nextChunkId = options.createChunkId ?? createChunkId;
169
+ this.nextObservationId = options.createObservationId ?? createObservationId;
170
+ this.onChunkPersisted = options.onChunkPersisted ?? (() => undefined);
171
+ this.currentChunkId = this.nextChunkId();
172
+
173
+ const started: EpisodeStartedTraceEvent = {
174
+ ...this.envelope("policy"),
175
+ event_type: "episode_started",
176
+ visibility: "policy",
177
+ payload: {
178
+ game_type: this.gameType,
179
+ game_version: this.gameVersion,
180
+ ruleset_id: options.rulesetId,
181
+ telemetry_version: 2,
182
+ platform: "tui",
183
+ viewport: { ...options.viewport },
184
+ control_schema: [...options.controlSchema],
185
+ input_semantics: "terminal_impulse",
186
+ },
187
+ };
188
+ this.append(started);
189
+ this.recordCheckpoint({
190
+ state: options.initialState,
191
+ reason: "episode_start",
192
+ seed: options.seed,
193
+ simulationTick: 0,
194
+ });
195
+ }
196
+
197
+ get lastObservationId(): string | null {
198
+ return this.previousObservation?.observationId ?? null;
199
+ }
200
+
201
+ monotonicTimeUs(): number {
202
+ return this.elapsedUs();
203
+ }
204
+
205
+ health(): RecordingHealth {
206
+ return { ...this.recordingHealth };
207
+ }
208
+
209
+ recordObservation(input: RecordObservationInput): string {
210
+ const observationId = this.nextObservationId();
211
+ const payload = encodeTerminalObservation({
212
+ observationId,
213
+ grid: input.grid,
214
+ previous: this.previousObservation,
215
+ causedByEventId: input.causedByEventId,
216
+ captureTiming: input.captureTiming,
217
+ });
218
+ const event: ObservationTraceEvent = {
219
+ ...this.envelope("policy", input.simulationTick),
220
+ event_type: "observation",
221
+ visibility: "policy",
222
+ payload,
223
+ };
224
+ this.append(event);
225
+ this.previousObservation = { observationId, grid: input.grid };
226
+ return observationId;
227
+ }
228
+
229
+ recordControl(input: RecordControlInput): string {
230
+ const event = {
231
+ ...this.envelope("policy", input.simulationTick),
232
+ event_type: "control_changed" as const,
233
+ visibility: "policy" as const,
234
+ payload: {
235
+ based_on_observation_id: this.lastObservationId,
236
+ control: input.control,
237
+ action: input.action,
238
+ kind: "impulse" as const,
239
+ actor: input.actor,
240
+ ...(input.receivedTimeUs === undefined
241
+ ? {}
242
+ : { action_received_us: input.receivedTimeUs }),
243
+ },
244
+ };
245
+ this.append(event);
246
+ return event.event_id;
247
+ }
248
+
249
+ recordControlCheckpoint(heldControls: readonly string[], simulationTick?: number): string {
250
+ const event = {
251
+ ...this.envelope("policy", simulationTick),
252
+ event_type: "control_checkpoint" as const,
253
+ visibility: "policy" as const,
254
+ payload: {
255
+ held_controls: [...heldControls],
256
+ input_semantics: "terminal_impulse" as const,
257
+ },
258
+ };
259
+ this.append(event);
260
+ return event.event_id;
261
+ }
262
+
263
+ recordOutcome(input: RecordOutcomeInput): string {
264
+ const event: OutcomeChangedTraceEvent = {
265
+ ...this.envelope("policy", input.simulationTick),
266
+ event_type: "outcome_changed",
267
+ visibility: "policy",
268
+ payload: {
269
+ score_before: input.scoreBefore,
270
+ score_after: input.scoreAfter,
271
+ score_delta: input.scoreAfter - input.scoreBefore,
272
+ engine_reward: input.engineReward,
273
+ reward_spec_id: "engine_v1",
274
+ terminal: input.terminal,
275
+ caused_by_event_id: input.causedByEventId,
276
+ ...(input.progress ? { progress: { ...input.progress } } : {}),
277
+ ...(input.performance ? { performance: { ...input.performance } } : {}),
278
+ },
279
+ };
280
+ this.append(event);
281
+ return event.event_id;
282
+ }
283
+
284
+ recordCheckpoint(input: RecordCheckpointInput): string | null {
285
+ try {
286
+ const clonedState = structuredClone(input.state);
287
+ const event: EngineCheckpointTraceEvent = {
288
+ ...this.envelope("privileged", input.simulationTick),
289
+ event_type: "engine_checkpoint",
290
+ visibility: "privileged",
291
+ payload: {
292
+ state_version: 1,
293
+ reason: input.reason,
294
+ state: clonedState,
295
+ state_hash: hashState(clonedState),
296
+ ...(input.seed === undefined ? {} : { seed: input.seed }),
297
+ ...(input.randomEffects === undefined
298
+ ? {}
299
+ : { random_effects: structuredClone(input.randomEffects) }),
300
+ ...(input.causedByEventId
301
+ ? { caused_by_event_id: input.causedByEventId }
302
+ : {}),
303
+ },
304
+ };
305
+ this.append(event);
306
+ return event.event_id;
307
+ } catch (error) {
308
+ this.degrade(error);
309
+ return null;
310
+ }
311
+ }
312
+
313
+ endEpisode(input: EndEpisodeInput): EpisodeEndedTraceEvent | null {
314
+ if (this.ended) return null;
315
+ const event: EpisodeEndedTraceEvent = {
316
+ ...this.envelope("policy", input.simulationTick),
317
+ event_type: "episode_ended",
318
+ visibility: "policy",
319
+ payload: {
320
+ reason: input.reason,
321
+ final_event_sequence: this.nextEventSequence - 1,
322
+ final_score: input.finalScore,
323
+ duration_us: this.elapsedUs(),
324
+ completed: input.completed,
325
+ },
326
+ };
327
+ event.payload.final_event_sequence = event.event_sequence;
328
+ this.append(event);
329
+ this.ended = true;
330
+ this.flush();
331
+ if (this.buffered.length === 0) {
332
+ this.attempt(() => this.store.finalizeGame(this.gameId));
333
+ }
334
+ return event;
335
+ }
336
+
337
+ flush(): void {
338
+ this.clearFlushTimer();
339
+ try {
340
+ this.flushBuffered();
341
+ } catch (error) {
342
+ this.degrade(error);
343
+ }
344
+ if (this.buffered.length > 0) this.scheduleFlush();
345
+ }
346
+
347
+ shutdown(): void {
348
+ this.flush();
349
+ }
350
+
351
+ /** @deprecated Uploads are coordinated by RecordingSyncCoordinator. */
352
+ async drainUploads(): Promise<void> {
353
+ this.flush();
354
+ }
355
+
356
+ private envelope(visibility: TraceVisibility, simulationTick?: number) {
357
+ return {
358
+ schema_version: 2 as const,
359
+ user_id: this.userId,
360
+ game_id: this.gameId,
361
+ chunk_id: this.currentChunkId,
362
+ event_id: this.nextEventId(),
363
+ event_sequence: this.nextEventSequence++,
364
+ event_version: 1 as const,
365
+ visibility,
366
+ client_time_ms: this.wallTimeMs(),
367
+ monotonic_time_us: this.elapsedUs(),
368
+ ...(simulationTick === undefined ? {} : { simulation_tick: simulationTick }),
369
+ };
370
+ }
371
+
372
+ private elapsedUs(): number {
373
+ const now = this.monotonicTimeMs();
374
+ this.monotonicOrigin ??= now;
375
+ return Math.max(0, Math.round((now - this.monotonicOrigin) * 1_000));
376
+ }
377
+
378
+ private append(event: TraceEvent): void {
379
+ if (this.ended) return;
380
+ this.attempt(() => this.store.appendEvent(this.gameId, event));
381
+ this.buffered.push(event);
382
+ if (
383
+ this.buffered.length >= this.config.maxEventsPerChunk ||
384
+ estimatedBytes(this.buffered) >= this.config.maxEstimatedChunkBytes
385
+ ) {
386
+ this.flush();
387
+ } else {
388
+ this.scheduleFlush();
389
+ }
390
+ }
391
+
392
+ private flushBuffered(): void {
393
+ if (this.buffered.length === 0) return;
394
+ const events = [...this.buffered];
395
+ const chunk = buildTraceChunk({
396
+ userId: this.userId,
397
+ gameId: this.gameId,
398
+ gameType: this.gameType,
399
+ gameVersion: this.gameVersion,
400
+ chunkId: this.currentChunkId,
401
+ chunkSequence: this.nextChunkSequence,
402
+ createdAtMs: this.wallTimeMs(),
403
+ applicationVersion: this.applicationVersion,
404
+ events,
405
+ });
406
+ if (!this.attempt(() => this.store.persistChunk(chunk))) return;
407
+ this.attempt(this.onChunkPersisted);
408
+ this.buffered.splice(0, events.length);
409
+ this.nextChunkSequence++;
410
+ this.currentChunkId = this.nextChunkId();
411
+ }
412
+
413
+ private scheduleFlush(): void {
414
+ if (this.flushTimer || this.ended) return;
415
+ this.flushTimer = setTimeout(() => {
416
+ this.flushTimer = null;
417
+ this.flush();
418
+ }, this.config.flushIntervalMs);
419
+ this.flushTimer.unref?.();
420
+ }
421
+
422
+ private clearFlushTimer(): void {
423
+ if (!this.flushTimer) return;
424
+ clearTimeout(this.flushTimer);
425
+ this.flushTimer = null;
426
+ }
427
+
428
+ private attempt<T>(operation: () => T): T | undefined {
429
+ try {
430
+ return operation();
431
+ } catch (error) {
432
+ this.degrade(error);
433
+ return undefined;
434
+ }
435
+ }
436
+
437
+ private degrade(error: unknown): void {
438
+ this.recordingHealth = {
439
+ status: "degraded",
440
+ last_error: errorMessage(error),
441
+ };
442
+ }
443
+ }
444
+
445
+ export function recoverActiveTraces(
446
+ store: RecordingStore,
447
+ options: TraceRecoveryOptions = {},
448
+ ): TraceRecoveryResult {
449
+ const persisted = [...store.listPending(), ...store.listFailed(), ...store.listCompletedChunks()];
450
+ return recoverActiveTraceGames(
451
+ store,
452
+ options,
453
+ store.listActiveGameIds(),
454
+ indexTraceChunksByGame(persisted),
455
+ );
456
+ }
457
+
458
+ export async function recoverActiveTracesBatched(
459
+ store: RecordingStore,
460
+ options: TraceRecoveryOptions & {
461
+ batchSize?: number;
462
+ yieldControl?: () => Promise<void>;
463
+ signal?: AbortSignal;
464
+ persistedByGame?: ReadonlyRecoveryChunkIndex;
465
+ } = {},
466
+ ): Promise<TraceRecoveryResult> {
467
+ const batchSize = Math.max(1, options.batchSize ?? 16);
468
+ const yieldControl = options.yieldControl ?? (() =>
469
+ new Promise<void>((resolve) => setTimeout(resolve, 0))
470
+ );
471
+ const persistedByGame = options.persistedByGame ?? await store.buildRecoveryChunkIndex({
472
+ batchSize,
473
+ yieldControl,
474
+ signal: options.signal,
475
+ });
476
+ if (options.signal?.aborted) return { games: 0, chunks: 0, events: 0 };
477
+ const total: TraceRecoveryResult = { games: 0, chunks: 0, events: 0 };
478
+ for await (const gameId of store.iterateActiveGameIdsBatched({
479
+ batchSize,
480
+ yieldControl,
481
+ signal: options.signal,
482
+ })) {
483
+ if (options.signal?.aborted) break;
484
+ const batch = recoverActiveTraceGames(
485
+ store,
486
+ options,
487
+ [gameId],
488
+ persistedByGame,
489
+ );
490
+ total.games += batch.games;
491
+ total.chunks += batch.chunks;
492
+ total.events += batch.events;
493
+ }
494
+ return total;
495
+ }
496
+
497
+ function recoverActiveTraceGames(
498
+ store: RecordingStore,
499
+ options: TraceRecoveryOptions,
500
+ gameIds: string[],
501
+ persistedByGame: ReadonlyRecoveryChunkIndex,
502
+ ): TraceRecoveryResult {
503
+ const result: TraceRecoveryResult = { games: 0, chunks: 0, events: 0 };
504
+ const wallTimeMs = options.wallTimeMs ?? Date.now;
505
+
506
+ for (const gameId of gameIds) {
507
+ const events = store.readActiveRecordedEvents(gameId).filter(isTraceEvent);
508
+ if (events.length === 0) continue;
509
+ const started = events.find(
510
+ (event): event is EpisodeStartedTraceEvent => event.event_type === "episode_started",
511
+ );
512
+ if (!started) continue;
513
+
514
+ const existing = (persistedByGame.get(gameId) ?? []).filter(
515
+ (item) => item.chunk.schema_version === 2,
516
+ );
517
+ const existingIds = new Set(existing.map((item) => item.chunk.chunk_id));
518
+ const groups: Array<{ chunkId: string; events: TraceEvent[] }> = [];
519
+ for (const event of events) {
520
+ const current = groups.at(-1);
521
+ if (current?.chunkId === event.chunk_id) current.events.push(event);
522
+ else groups.push({ chunkId: event.chunk_id, events: [event] });
523
+ }
524
+
525
+ let recoveredGame = false;
526
+ groups.forEach((group, index) => {
527
+ if (existingIds.has(group.chunkId)) return;
528
+ store.persistChunk(buildTraceChunk({
529
+ userId: started.user_id,
530
+ gameId,
531
+ gameType: started.payload.game_type,
532
+ gameVersion: started.payload.game_version,
533
+ chunkId: group.chunkId,
534
+ chunkSequence: index + 1,
535
+ createdAtMs: wallTimeMs(),
536
+ applicationVersion: options.applicationVersion ?? "dev",
537
+ events: group.events,
538
+ }));
539
+ recoveredGame = true;
540
+ result.chunks++;
541
+ result.events += group.events.length;
542
+ });
543
+ if (recoveredGame) result.games++;
544
+ if (events.at(-1)?.event_type === "episode_ended") store.finalizeGame(gameId);
545
+ }
546
+ return result;
547
+ }
548
+
549
+ function indexTraceChunksByGame(
550
+ persisted: ReturnType<RecordingStore["listPending"]>,
551
+ ): RecoveryChunkIndex {
552
+ const persistedByGame: RecoveryChunkIndex = new Map();
553
+ for (const item of persisted) {
554
+ if (item.chunk.schema_version !== 2) continue;
555
+ const game = persistedByGame.get(item.chunk.game_id) ?? [];
556
+ game.push(item);
557
+ persistedByGame.set(item.chunk.game_id, game);
558
+ }
559
+ return persistedByGame;
560
+ }