@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,170 @@
1
+ import type { IngestAcknowledgment, PersistedChunk } from "./types.js";
2
+ import { resolveBackendConfig } from "../sync/config.js";
3
+
4
+ type Fetch = (
5
+ input: string | URL | Request,
6
+ init?: RequestInit,
7
+ ) => Promise<Response>;
8
+
9
+ export interface ChunkTransport {
10
+ readonly enabled: boolean;
11
+ send(chunk: PersistedChunk, signal?: AbortSignal): Promise<IngestAcknowledgment>;
12
+ }
13
+
14
+ export class TransportError extends Error {
15
+ constructor(
16
+ message: string,
17
+ readonly retryable: boolean,
18
+ readonly quarantine = !retryable,
19
+ ) {
20
+ super(message);
21
+ this.name = "TransportError";
22
+ }
23
+ }
24
+
25
+ export class DisabledTransport implements ChunkTransport {
26
+ readonly enabled = false;
27
+
28
+ async send(): Promise<IngestAcknowledgment> {
29
+ throw new TransportError("gameplay ingestion is not configured", true);
30
+ }
31
+ }
32
+
33
+ export interface HttpTransportOptions {
34
+ token?: string;
35
+ /** @deprecated Use token. */
36
+ apiKey?: string;
37
+ fetch?: Fetch;
38
+ timeoutMs?: number;
39
+ }
40
+
41
+ function isObject(value: unknown): value is Record<string, unknown> {
42
+ return typeof value === "object" && value !== null && !Array.isArray(value);
43
+ }
44
+
45
+ function isAcknowledgment(value: unknown): value is IngestAcknowledgment {
46
+ if (!isObject(value) || !isObject(value.accepted_event_range)) return false;
47
+ return typeof value.accepted === "boolean" &&
48
+ typeof value.duplicate === "boolean" &&
49
+ typeof value.game_id === "string" &&
50
+ typeof value.chunk_id === "string" &&
51
+ Number.isInteger(value.chunk_sequence) &&
52
+ Number.isInteger(value.accepted_event_range.first) &&
53
+ Number.isInteger(value.accepted_event_range.last) &&
54
+ value.queued === true &&
55
+ typeof value.server_received_at_ms === "number" &&
56
+ Number.isInteger(value.server_received_at_ms) &&
57
+ value.server_received_at_ms >= 0;
58
+ }
59
+
60
+ function responseError(status: number, body: string): TransportError {
61
+ const retryable = status === 408 ||
62
+ status === 425 ||
63
+ status === 429 ||
64
+ (status >= 500 && status <= 599);
65
+ const detail = body.trim();
66
+ return new TransportError(
67
+ `ingestion returned HTTP ${status}${detail ? `: ${detail}` : ""}`,
68
+ retryable,
69
+ );
70
+ }
71
+
72
+ export class HttpTransport implements ChunkTransport {
73
+ readonly enabled = true;
74
+ private readonly endpoint: string;
75
+ private readonly apiKey: string | undefined;
76
+ private readonly request: Fetch;
77
+ private readonly timeoutMs: number;
78
+
79
+ constructor(endpoint: string, options: HttpTransportOptions = {}) {
80
+ const parsed = new URL(endpoint);
81
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
82
+ throw new Error("GAMEPIGEON_INGEST_URL must use HTTP or HTTPS");
83
+ }
84
+ this.endpoint = parsed.toString();
85
+ this.apiKey = options.token?.trim() || options.apiKey?.trim() || undefined;
86
+ this.request = options.fetch ?? globalThis.fetch;
87
+ this.timeoutMs = options.timeoutMs ?? 10_000;
88
+ }
89
+
90
+ async send(persisted: PersistedChunk, signal?: AbortSignal): Promise<IngestAcknowledgment> {
91
+ const controller = new AbortController();
92
+ const abort = () => controller.abort(signal?.reason);
93
+ if (signal?.aborted) abort();
94
+ else signal?.addEventListener("abort", abort, { once: true });
95
+ const timeout = setTimeout(() => controller.abort(), this.timeoutMs);
96
+ timeout.unref?.();
97
+ try {
98
+ const response = await this.request(this.endpoint, {
99
+ method: "POST",
100
+ headers: {
101
+ ...(this.apiKey ? { Authorization: `Bearer ${this.apiKey}` } : {}),
102
+ "Content-Type": "application/json",
103
+ },
104
+ body: persisted.bytes,
105
+ signal: controller.signal,
106
+ });
107
+ if (response.status !== 202) {
108
+ throw responseError(response.status, await response.text());
109
+ }
110
+ let value: unknown;
111
+ try {
112
+ value = await response.json();
113
+ } catch {
114
+ throw new TransportError(
115
+ "ingestion returned a malformed acknowledgment",
116
+ false,
117
+ false,
118
+ );
119
+ }
120
+ if (!isAcknowledgment(value)) {
121
+ throw new TransportError(
122
+ "ingestion returned a malformed acknowledgment",
123
+ false,
124
+ false,
125
+ );
126
+ }
127
+ return value;
128
+ } catch (error) {
129
+ if (error instanceof TransportError) throw error;
130
+ const message = error instanceof Error ? error.message : String(error);
131
+ throw new TransportError(
132
+ error instanceof DOMException && error.name === "AbortError"
133
+ ? "ingestion request timed out"
134
+ : message,
135
+ true,
136
+ );
137
+ } finally {
138
+ clearTimeout(timeout);
139
+ signal?.removeEventListener("abort", abort);
140
+ }
141
+ }
142
+ }
143
+
144
+ export function createChunkTransport(
145
+ environment: Record<string, string | undefined> = process.env,
146
+ ): ChunkTransport {
147
+ const config = resolveBackendConfig(environment);
148
+ return config
149
+ ? new HttpTransport(config.eventChunksUrl, {
150
+ token: config.token,
151
+ })
152
+ : new DisabledTransport();
153
+ }
154
+
155
+ export class FakeTransport implements ChunkTransport {
156
+ readonly enabled = true;
157
+ readonly requests: PersistedChunk[] = [];
158
+
159
+ constructor(
160
+ private readonly handler: (
161
+ chunk: PersistedChunk,
162
+ signal?: AbortSignal,
163
+ ) => Promise<IngestAcknowledgment>,
164
+ ) {}
165
+
166
+ async send(chunk: PersistedChunk, signal?: AbortSignal): Promise<IngestAcknowledgment> {
167
+ this.requests.push(chunk);
168
+ return this.handler(chunk, signal);
169
+ }
170
+ }
@@ -0,0 +1,18 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { createChunkId, createEventId, createGameId, createUserId } from "./ids.js";
3
+ import { RECORDING_SCHEMA_VERSION, RECORDING_SDK_VERSION } from "./types.js";
4
+
5
+ describe("recording contracts", () => {
6
+ it("uses schema version two", () => {
7
+ expect(RECORDING_SCHEMA_VERSION).toBe(2);
8
+ expect(RECORDING_SDK_VERSION).toBe("2");
9
+ });
10
+
11
+ it("creates prefixed stable identifiers", () => {
12
+ const uuid = () => "00000000-0000-4000-8000-000000000001";
13
+ expect(createUserId(uuid)).toBe("usr_00000000-0000-4000-8000-000000000001");
14
+ expect(createGameId(uuid)).toBe("game_00000000-0000-4000-8000-000000000001");
15
+ expect(createEventId(uuid)).toBe("evt_00000000-0000-4000-8000-000000000001");
16
+ expect(createChunkId(uuid)).toBe("chk_00000000-0000-4000-8000-000000000001");
17
+ });
18
+ });
@@ -0,0 +1,326 @@
1
+ import type { StepResult } from "../core/game.js";
2
+
3
+ export const RECORDING_SCHEMA_VERSION = 2 as const;
4
+ export const RECORDING_SDK_VERSION = "2";
5
+
6
+ export type TerminalColor = number | string | null;
7
+
8
+ export interface TerminalCell {
9
+ glyph: string;
10
+ foreground: TerminalColor;
11
+ background: TerminalColor;
12
+ attributes: number;
13
+ width: number;
14
+ }
15
+
16
+ export interface PositionedTerminalCell extends TerminalCell {
17
+ x: number;
18
+ y: number;
19
+ }
20
+
21
+ export interface TerminalCellChange {
22
+ x: number;
23
+ y: number;
24
+ cell: TerminalCell | null;
25
+ }
26
+
27
+ export interface ObservationCaptureTiming {
28
+ generated_time_us: number;
29
+ delivered_time_us: number;
30
+ dropped_observations: number;
31
+ }
32
+
33
+ export interface FullTerminalObservation {
34
+ observation_id: string;
35
+ representation: "terminal_grid_v1";
36
+ width: number;
37
+ height: number;
38
+ state_hash: string;
39
+ cells: PositionedTerminalCell[];
40
+ capture_timing?: ObservationCaptureTiming;
41
+ caused_by_event_id?: string;
42
+ }
43
+
44
+ export interface DeltaTerminalObservation {
45
+ observation_id: string;
46
+ representation: "terminal_grid_delta_v1";
47
+ base_observation_id: string;
48
+ width: number;
49
+ height: number;
50
+ state_hash: string;
51
+ changed_cells: TerminalCellChange[];
52
+ capture_timing?: ObservationCaptureTiming;
53
+ caused_by_event_id?: string;
54
+ }
55
+
56
+ export type TerminalObservation = FullTerminalObservation | DeltaTerminalObservation;
57
+
58
+ export interface EngineStateObservation {
59
+ observation_id: string;
60
+ representation: "engine_state_v1";
61
+ state: unknown;
62
+ caused_by_event_id?: string;
63
+ }
64
+
65
+ export type PolicyObservation = TerminalObservation | EngineStateObservation;
66
+
67
+ export type TraceVisibility = "policy" | "privileged";
68
+ export type TraceActor = "human" | "model" | "heuristic" | "random" | "environment";
69
+ export type EpisodeEndReason =
70
+ | "game_over"
71
+ | "won"
72
+ | "lost"
73
+ | "user_quit"
74
+ | "restart"
75
+ | "timeout"
76
+ | "client_closed"
77
+ | "engine_error";
78
+
79
+ interface TraceEventBase {
80
+ schema_version: typeof RECORDING_SCHEMA_VERSION;
81
+ user_id: string;
82
+ game_id: string;
83
+ chunk_id: string;
84
+ event_id: string;
85
+ event_sequence: number;
86
+ event_version: 1;
87
+ event_type: string;
88
+ visibility: TraceVisibility;
89
+ client_time_ms: number;
90
+ monotonic_time_us: number;
91
+ simulation_tick?: number;
92
+ payload: unknown;
93
+ }
94
+
95
+ export interface EpisodeStartedTraceEvent extends TraceEventBase {
96
+ event_type: "episode_started";
97
+ visibility: "policy";
98
+ payload: {
99
+ game_type: string;
100
+ game_version: string;
101
+ ruleset_id: string;
102
+ telemetry_version: 2;
103
+ platform: "tui" | "overlay";
104
+ viewport: { width: number; height: number };
105
+ control_schema: string[];
106
+ input_semantics: "terminal_impulse" | "engine_impulse";
107
+ };
108
+ }
109
+
110
+ export interface ObservationTraceEvent extends TraceEventBase {
111
+ event_type: "observation";
112
+ visibility: "policy";
113
+ payload: PolicyObservation;
114
+ }
115
+
116
+ export interface ControlChangedTraceEvent extends TraceEventBase {
117
+ event_type: "control_changed";
118
+ visibility: "policy";
119
+ payload: {
120
+ based_on_observation_id: string | null;
121
+ control: string;
122
+ action: string;
123
+ kind: "impulse";
124
+ actor: TraceActor;
125
+ action_received_us?: number;
126
+ };
127
+ }
128
+
129
+ export interface ControlCheckpointTraceEvent extends TraceEventBase {
130
+ event_type: "control_checkpoint";
131
+ visibility: "policy";
132
+ payload: {
133
+ held_controls: string[];
134
+ input_semantics: "terminal_impulse" | "engine_impulse";
135
+ };
136
+ }
137
+
138
+ export interface OutcomeChangedTraceEvent extends TraceEventBase {
139
+ event_type: "outcome_changed";
140
+ visibility: "policy";
141
+ payload: {
142
+ score_before: number;
143
+ score_after: number;
144
+ score_delta: number;
145
+ engine_reward: number;
146
+ reward_spec_id: "engine_v1";
147
+ terminal: boolean;
148
+ caused_by_event_id: string;
149
+ progress?: Record<string, number | string | boolean | null>;
150
+ performance?: {
151
+ action_received_us: number;
152
+ action_applied_us: number;
153
+ action_completed_us: number;
154
+ queueing_delay_us: number;
155
+ simulation_tick_duration_us: number;
156
+ };
157
+ };
158
+ }
159
+
160
+ export type CheckpointReason =
161
+ | "episode_start"
162
+ | "periodic"
163
+ | "random_outcome"
164
+ | "terminal"
165
+ | "episode_end";
166
+
167
+ export interface EngineCheckpointTraceEvent extends TraceEventBase {
168
+ event_type: "engine_checkpoint";
169
+ visibility: "privileged";
170
+ payload: {
171
+ state_version: 1;
172
+ reason: CheckpointReason;
173
+ state: unknown;
174
+ state_hash?: string;
175
+ seed?: number;
176
+ random_effects?: unknown;
177
+ caused_by_event_id?: string;
178
+ };
179
+ }
180
+
181
+ export interface EpisodeEndedTraceEvent extends TraceEventBase {
182
+ event_type: "episode_ended";
183
+ visibility: "policy";
184
+ payload: {
185
+ reason: EpisodeEndReason;
186
+ final_event_sequence: number;
187
+ final_score: number;
188
+ duration_us: number;
189
+ completed: boolean;
190
+ };
191
+ }
192
+
193
+ export type TraceEvent =
194
+ | EpisodeStartedTraceEvent
195
+ | ObservationTraceEvent
196
+ | ControlChangedTraceEvent
197
+ | ControlCheckpointTraceEvent
198
+ | OutcomeChangedTraceEvent
199
+ | EngineCheckpointTraceEvent
200
+ | EpisodeEndedTraceEvent;
201
+
202
+ export interface TraceEventChunk {
203
+ schema_version: typeof RECORDING_SCHEMA_VERSION;
204
+ user_id: string;
205
+ game_id: string;
206
+ game_type: string;
207
+ game_version: string;
208
+ chunk_id: string;
209
+ chunk_sequence: number;
210
+ first_event_sequence: number;
211
+ last_event_sequence: number;
212
+ event_count: number;
213
+ created_at_ms: number;
214
+ client: {
215
+ platform: "tui";
216
+ application_version: string;
217
+ recording_sdk_version: string;
218
+ };
219
+ events: TraceEvent[];
220
+ }
221
+
222
+ export type EventSource = "player" | "environment" | "system";
223
+ export type ActionSource = Exclude<EventSource, "system">;
224
+ export type GameOutcome = "completed" | "abandoned";
225
+
226
+ interface EventBase {
227
+ event_id: string;
228
+ event_sequence: number;
229
+ event_version: 1;
230
+ source: EventSource;
231
+ client_time_ms: number;
232
+ monotonic_time_ms: number;
233
+ }
234
+
235
+ export interface GameStartedEvent extends EventBase {
236
+ event_type: "game_started";
237
+ source: "system";
238
+ payload: {
239
+ game_type: string;
240
+ game_version: string;
241
+ seed: number;
242
+ initial_state: unknown;
243
+ };
244
+ }
245
+
246
+ export interface ActionEvent extends EventBase {
247
+ event_type: "action";
248
+ source: "player" | "environment";
249
+ payload: {
250
+ action: string;
251
+ state_before: unknown;
252
+ result: Omit<StepResult, "randomEffects">;
253
+ state_after: unknown;
254
+ random_effects?: unknown;
255
+ };
256
+ }
257
+
258
+ export interface GameEndedEvent extends EventBase {
259
+ event_type: "game_ended";
260
+ source: "system";
261
+ payload: {
262
+ outcome: GameOutcome;
263
+ final_state: unknown;
264
+ final_score: number;
265
+ success: boolean;
266
+ final_event_sequence: number;
267
+ };
268
+ }
269
+
270
+ export type GameEvent = GameStartedEvent | ActionEvent | GameEndedEvent;
271
+
272
+ export interface GameEventChunk {
273
+ schema_version: 1;
274
+ game_id: string;
275
+ game_type: string;
276
+ game_version: string;
277
+ chunk_id: string;
278
+ chunk_sequence: number;
279
+ first_event_sequence: number;
280
+ last_event_sequence: number;
281
+ event_count: number;
282
+ created_at_ms: number;
283
+ client: {
284
+ platform: "tui";
285
+ application_version: string;
286
+ recording_sdk_version: string;
287
+ };
288
+ events: GameEvent[];
289
+ }
290
+
291
+ export type RecordedEvent = GameEvent | TraceEvent;
292
+ export type RecordedEventChunk = GameEventChunk | TraceEventChunk;
293
+
294
+ export function isTraceEvent(event: RecordedEvent): event is TraceEvent {
295
+ return "schema_version" in event && event.schema_version === 2;
296
+ }
297
+
298
+ export function isGameEvent(event: RecordedEvent): event is GameEvent {
299
+ return !("schema_version" in event);
300
+ }
301
+
302
+ export function isTraceEventChunk(chunk: RecordedEventChunk): chunk is TraceEventChunk {
303
+ return chunk.schema_version === 2;
304
+ }
305
+
306
+ export interface PersistedChunk {
307
+ chunk: RecordedEventChunk;
308
+ path: string;
309
+ bytes: string;
310
+ }
311
+
312
+ export interface IngestAcknowledgment {
313
+ accepted: boolean;
314
+ duplicate: boolean;
315
+ game_id: string;
316
+ chunk_id: string;
317
+ chunk_sequence: number;
318
+ accepted_event_range: { first: number; last: number };
319
+ queued: true;
320
+ server_received_at_ms: number;
321
+ }
322
+
323
+ export interface RecordingHealth {
324
+ status: "healthy" | "degraded";
325
+ last_error?: string;
326
+ }
@@ -0,0 +1,193 @@
1
+ import { mkdtempSync, rmSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import { RecordingStore } from "./store.js";
6
+ import { DisabledTransport, FakeTransport, TransportError } from "./transport.js";
7
+ import { ChunkUploader } from "./uploader.js";
8
+ import type { GameEventChunk, IngestAcknowledgment } from "./types.js";
9
+
10
+ const roots: string[] = [];
11
+ afterEach(() => {
12
+ vi.useRealTimers();
13
+ for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
14
+ });
15
+
16
+ function setup(): { store: RecordingStore; chunk: GameEventChunk } {
17
+ const root = mkdtempSync(join(tmpdir(), "gamepigeon-upload-"));
18
+ roots.push(root);
19
+ const store = new RecordingStore(root);
20
+ const chunk: GameEventChunk = {
21
+ schema_version: 1,
22
+ game_id: "game_1",
23
+ game_type: "2048",
24
+ game_version: "1",
25
+ chunk_id: "chk_1",
26
+ chunk_sequence: 1,
27
+ first_event_sequence: 1,
28
+ last_event_sequence: 1,
29
+ event_count: 1,
30
+ created_at_ms: 1,
31
+ client: { platform: "tui", application_version: "dev", recording_sdk_version: "1" },
32
+ events: [{
33
+ event_id: "evt_1",
34
+ event_sequence: 1,
35
+ event_type: "game_started",
36
+ event_version: 1,
37
+ source: "system",
38
+ client_time_ms: 1,
39
+ monotonic_time_ms: 0,
40
+ payload: { game_type: "2048", game_version: "1", seed: 1, initial_state: {} },
41
+ }],
42
+ };
43
+ store.persistChunk(chunk);
44
+ return { store, chunk };
45
+ }
46
+
47
+ function ack(chunk: GameEventChunk): IngestAcknowledgment {
48
+ return {
49
+ accepted: true,
50
+ duplicate: false,
51
+ game_id: chunk.game_id,
52
+ chunk_id: chunk.chunk_id,
53
+ chunk_sequence: chunk.chunk_sequence,
54
+ accepted_event_range: { first: chunk.first_event_sequence, last: chunk.last_event_sequence },
55
+ queued: true,
56
+ server_received_at_ms: 2,
57
+ };
58
+ }
59
+
60
+ describe("ChunkUploader", () => {
61
+ it("returns before scanning pending files when transport is disabled", async () => {
62
+ const { store } = setup();
63
+ store.listPending = () => { throw new Error("pending backlog was scanned"); };
64
+ await expect(new ChunkUploader(store, new DisabledTransport()).uploadPending())
65
+ .resolves.toEqual({ acknowledged: 0, failed: 0, pending: 0 });
66
+ });
67
+
68
+ it("leaves pending chunks untouched when transport is disabled", async () => {
69
+ const { store } = setup();
70
+ const result = await new ChunkUploader(store, new DisabledTransport()).uploadPending();
71
+ expect(result).toEqual({ acknowledged: 0, failed: 0, pending: 0 });
72
+ expect(store.listPending()).toHaveLength(1);
73
+ });
74
+
75
+ it("moves a chunk only after a matching acknowledgment", async () => {
76
+ const { store, chunk } = setup();
77
+ const transport = new FakeTransport(async () => ack(chunk));
78
+ const result = await new ChunkUploader(store, transport).uploadPending();
79
+ expect(result.acknowledged).toBe(1);
80
+ expect(store.listPending()).toHaveLength(0);
81
+ expect(transport.requests[0]?.bytes).toBe(JSON.stringify(chunk));
82
+ });
83
+
84
+ it("retains a chunk when acknowledgment does not match", async () => {
85
+ const { store, chunk } = setup();
86
+ const transport = new FakeTransport(async () => ({ ...ack(chunk), chunk_id: "chk_wrong" }));
87
+ const result = await new ChunkUploader(store, transport).uploadPending();
88
+ expect(result.failed).toBe(0);
89
+ expect(store.listPending()).toHaveLength(1);
90
+ });
91
+
92
+ it("retains a chunk after an ambiguous malformed 202 response", async () => {
93
+ const { store } = setup();
94
+ const transport = new FakeTransport(async () => {
95
+ throw new TransportError("malformed acknowledgment", false, false);
96
+ });
97
+ const result = await new ChunkUploader(store, transport).uploadPending();
98
+ expect(result).toEqual({ acknowledged: 0, failed: 0, pending: 1 });
99
+ expect(store.listFailed()).toHaveLength(0);
100
+ });
101
+
102
+ it("retries retryable failures with backoff", async () => {
103
+ const { store, chunk } = setup();
104
+ let attempts = 0;
105
+ const delays: number[] = [];
106
+ const transport = new FakeTransport(async () => {
107
+ attempts++;
108
+ if (attempts < 3) throw new TransportError("offline", true);
109
+ return ack(chunk);
110
+ });
111
+ const uploader = new ChunkUploader(store, transport, {
112
+ maxAttempts: 3,
113
+ delay: async (ms) => { delays.push(ms); },
114
+ random: () => 0.5,
115
+ });
116
+ expect((await uploader.uploadPending()).acknowledged).toBe(1);
117
+ expect(delays).toEqual([1000, 2000]);
118
+ });
119
+
120
+ it("adds jitter and caps retry delays at one minute", async () => {
121
+ const { store } = setup();
122
+ const delays: number[] = [];
123
+ const transport = new FakeTransport(async () => {
124
+ throw new TransportError("offline", true);
125
+ });
126
+ const uploader = new ChunkUploader(store, transport, {
127
+ maxAttempts: 8,
128
+ delay: async (ms) => { delays.push(ms); },
129
+ random: () => 0.5,
130
+ });
131
+ expect((await uploader.uploadPending()).pending).toBe(1);
132
+ expect(delays).toEqual([
133
+ 1_000, 2_000, 4_000, 8_000, 16_000, 32_000, 60_000,
134
+ ]);
135
+ });
136
+
137
+ it("cancels retry waits and leaves persisted chunks pending", async () => {
138
+ const { store } = setup();
139
+ const controller = new AbortController();
140
+ let waiting = false;
141
+ const transport = new FakeTransport(async () => {
142
+ throw new TransportError("offline", true);
143
+ });
144
+ const uploader = new ChunkUploader(store, transport, {
145
+ maxAttempts: 5,
146
+ delay: async (_ms, signal) => {
147
+ waiting = true;
148
+ await new Promise<void>((resolve) => signal?.addEventListener("abort", () => resolve()));
149
+ },
150
+ });
151
+
152
+ const pending = uploader.uploadPending(undefined, controller.signal);
153
+ while (!waiting) await Promise.resolve();
154
+ expect(waiting).toBe(true);
155
+ controller.abort();
156
+ await pending;
157
+ expect(transport.requests).toHaveLength(1);
158
+ expect(store.listPending()).toHaveLength(1);
159
+ });
160
+
161
+ it("clears the default retry timer when cancellation is requested", async () => {
162
+ vi.useFakeTimers();
163
+ const { store } = setup();
164
+ const controller = new AbortController();
165
+ let requests = 0;
166
+ const uploader = new ChunkUploader(store, new FakeTransport(async () => {
167
+ requests++;
168
+ throw new TransportError("offline", true);
169
+ }));
170
+
171
+ const pending = uploader.uploadPending(undefined, controller.signal);
172
+ for (let index = 0; index < 20 && vi.getTimerCount() === 0; index++) {
173
+ await Promise.resolve();
174
+ }
175
+ expect(requests).toBe(1);
176
+ expect(vi.getTimerCount()).toBe(1);
177
+ controller.abort();
178
+ await pending;
179
+ expect(vi.getTimerCount()).toBe(0);
180
+ expect(store.listPending()).toHaveLength(1);
181
+ });
182
+
183
+ it("quarantines permanent failures", async () => {
184
+ const { store } = setup();
185
+ const transport = new FakeTransport(async () => {
186
+ throw new TransportError("invalid schema", false);
187
+ });
188
+ const result = await new ChunkUploader(store, transport).uploadPending();
189
+ expect(result.failed).toBe(1);
190
+ expect(store.listPending()).toHaveLength(0);
191
+ expect(store.listFailed()).toHaveLength(1);
192
+ });
193
+ });