@trolleroof/tui 0.2.5 → 0.2.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +10 -0
- package/.codex-plugin/plugin.json +2 -2
- package/README.md +29 -7
- package/dist/index.js +284 -18
- package/overlay/dist/app.js +74 -0
- package/overlay/dist/index.html +1589 -0
- package/overlay-release.json +2 -2
- package/package.json +27 -4
- package/scripts/install-adapters.sh +6 -6
- package/scripts/install-adapters.test.sh +2 -2
- package/scripts/materialize-agents-plugin.sh +3 -3
- package/scripts/npm-pack.test.ts +51 -0
- package/scripts/overlay.ts +1 -1
- package/src/analytics/events.test.ts +77 -0
- package/src/analytics/events.ts +85 -0
- package/src/analytics/posthog.test.ts +85 -0
- package/src/analytics/posthog.ts +88 -0
- package/src/auth-pages.ts +108 -0
- package/src/auth.test.ts +255 -0
- package/src/auth.ts +511 -0
- package/src/core/game.ts +35 -0
- package/src/core/key.ts +12 -0
- package/src/core/rng.ts +41 -0
- package/src/data/bc-export.test.ts +153 -0
- package/src/data/bc-export.ts +125 -0
- package/src/data/trajectory.ts +103 -0
- package/src/games/connect4/connect4.ts +81 -0
- package/src/games/connect4/engine.test.ts +77 -0
- package/src/games/connect4/engine.ts +89 -0
- package/src/games/g2048/engine.test.ts +138 -0
- package/src/games/g2048/engine.ts +109 -0
- package/src/games/g2048/game2048.ts +104 -0
- package/src/games/minesweeper/engine.test.ts +83 -0
- package/src/games/minesweeper/engine.ts +112 -0
- package/src/games/minesweeper/minesweeper.ts +107 -0
- package/src/games/registry.test.ts +16 -0
- package/src/games/registry.ts +130 -0
- package/src/games/snake/engine.test.ts +89 -0
- package/src/games/snake/engine.ts +95 -0
- package/src/games/snake/snake.ts +77 -0
- package/src/games/sokoban/engine.test.ts +49 -0
- package/src/games/sokoban/engine.ts +152 -0
- package/src/games/sokoban/sokoban.ts +103 -0
- package/src/games/sudoku/engine.test.ts +88 -0
- package/src/games/sudoku/engine.ts +99 -0
- package/src/games/sudoku/sudoku.ts +100 -0
- package/src/games/tetris/engine.test.ts +378 -0
- package/src/games/tetris/engine.ts +236 -0
- package/src/games/tetris/tetris.ts +220 -0
- package/src/index.ts +82 -0
- package/src/recording/chunks.ts +93 -0
- package/src/recording/config.test.ts +31 -0
- package/src/recording/config.ts +37 -0
- package/src/recording/contracts.test.ts +71 -0
- package/src/recording/gameplay.test.ts +70 -0
- package/src/recording/gameplay.ts +18 -0
- package/src/recording/http-transport.test.ts +197 -0
- package/src/recording/identity.test.ts +26 -0
- package/src/recording/identity.ts +38 -0
- package/src/recording/ids.ts +27 -0
- package/src/recording/permissions.ts +10 -0
- package/src/recording/recorder.test.ts +388 -0
- package/src/recording/recorder.ts +464 -0
- package/src/recording/store.test.ts +231 -0
- package/src/recording/store.ts +305 -0
- package/src/recording/terminal-grid.test.ts +88 -0
- package/src/recording/terminal-grid.ts +248 -0
- package/src/recording/trace-recorder.test.ts +287 -0
- package/src/recording/trace-recorder.ts +560 -0
- package/src/recording/transport.ts +170 -0
- package/src/recording/types.test.ts +18 -0
- package/src/recording/types.ts +326 -0
- package/src/recording/uploader.test.ts +193 -0
- package/src/recording/uploader.ts +103 -0
- package/src/results/completion.ts +100 -0
- package/src/results/http-result-transport.test.ts +75 -0
- package/src/results/local-score.test.ts +32 -0
- package/src/results/local-score.ts +39 -0
- package/src/results/results.test.ts +383 -0
- package/src/results/store.ts +149 -0
- package/src/results/transport.ts +79 -0
- package/src/results/types.ts +20 -0
- package/src/results/uploader.ts +84 -0
- package/src/sync/config.test.ts +108 -0
- package/src/sync/config.ts +115 -0
- package/src/sync/coordinator.test.ts +423 -0
- package/src/sync/coordinator.ts +277 -0
- package/src/sync/game-start-queue.test.ts +93 -0
- package/src/sync/game-start-queue.ts +45 -0
- package/src/sync/identity-client.test.ts +34 -0
- package/src/sync/identity-client.ts +43 -0
|
@@ -0,0 +1,423 @@
|
|
|
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 } from "vitest";
|
|
5
|
+
import type { ResultTransport } from "../results/transport.js";
|
|
6
|
+
import { RecordingStore } from "../recording/store.js";
|
|
7
|
+
import { FakeTransport } from "../recording/transport.js";
|
|
8
|
+
import type { IngestAcknowledgment, TraceEventChunk } from "../recording/types.js";
|
|
9
|
+
import type { BackendConfig } from "./config.js";
|
|
10
|
+
import { RecordingSyncCoordinator } from "./coordinator.js";
|
|
11
|
+
import { GameStartQueue } from "./game-start-queue.js";
|
|
12
|
+
|
|
13
|
+
const roots: string[] = [];
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const config: BackendConfig = {
|
|
19
|
+
baseUrl: "https://retro.example",
|
|
20
|
+
token: "retro_token",
|
|
21
|
+
meUrl: "https://retro.example/v1/me",
|
|
22
|
+
eventChunksUrl: "https://retro.example/v1/game-event-chunks",
|
|
23
|
+
gameResultsUrl: "https://retro.example/v1/game-results",
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
function chunk(userId: string, suffix: string): TraceEventChunk {
|
|
27
|
+
return {
|
|
28
|
+
schema_version: 2,
|
|
29
|
+
user_id: userId,
|
|
30
|
+
game_id: `game_${suffix}`,
|
|
31
|
+
game_type: "2048",
|
|
32
|
+
game_version: "1",
|
|
33
|
+
chunk_id: `chk_${suffix}`,
|
|
34
|
+
chunk_sequence: 1,
|
|
35
|
+
first_event_sequence: 1,
|
|
36
|
+
last_event_sequence: 1,
|
|
37
|
+
event_count: 1,
|
|
38
|
+
created_at_ms: 1,
|
|
39
|
+
client: { platform: "tui", application_version: "test", recording_sdk_version: "2" },
|
|
40
|
+
events: [{
|
|
41
|
+
schema_version: 2,
|
|
42
|
+
user_id: userId,
|
|
43
|
+
game_id: `game_${suffix}`,
|
|
44
|
+
chunk_id: `chk_${suffix}`,
|
|
45
|
+
event_id: `evt_${suffix}`,
|
|
46
|
+
event_sequence: 1,
|
|
47
|
+
event_version: 1,
|
|
48
|
+
event_type: "episode_ended",
|
|
49
|
+
visibility: "policy",
|
|
50
|
+
client_time_ms: 1,
|
|
51
|
+
monotonic_time_us: 1,
|
|
52
|
+
payload: {
|
|
53
|
+
reason: "user_quit",
|
|
54
|
+
final_event_sequence: 1,
|
|
55
|
+
final_score: 0,
|
|
56
|
+
duration_us: 1,
|
|
57
|
+
completed: false,
|
|
58
|
+
},
|
|
59
|
+
}],
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function ack(value: TraceEventChunk): IngestAcknowledgment {
|
|
64
|
+
return {
|
|
65
|
+
accepted: true,
|
|
66
|
+
duplicate: false,
|
|
67
|
+
game_id: value.game_id,
|
|
68
|
+
chunk_id: value.chunk_id,
|
|
69
|
+
chunk_sequence: value.chunk_sequence,
|
|
70
|
+
accepted_event_range: { first: 1, last: 1 },
|
|
71
|
+
queued: true,
|
|
72
|
+
server_received_at_ms: 2,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
describe("RecordingSyncCoordinator", () => {
|
|
77
|
+
it("keeps a direct offline launch queued until deferred recovery completes", async () => {
|
|
78
|
+
const root = mkdtempSync(join(tmpdir(), "gamepigeon-sync-offline-race-"));
|
|
79
|
+
roots.push(root);
|
|
80
|
+
const store = new RecordingStore(root);
|
|
81
|
+
let releaseRecovery!: () => void;
|
|
82
|
+
const recoveryGate = new Promise<void>((resolve) => { releaseRecovery = resolve; });
|
|
83
|
+
let recoveryStarted!: () => void;
|
|
84
|
+
const startedRecovery = new Promise<void>((resolve) => { recoveryStarted = resolve; });
|
|
85
|
+
let activeDuringRecovery: string[] = [];
|
|
86
|
+
const coordinator = new RecordingSyncCoordinator(store, {
|
|
87
|
+
config: null,
|
|
88
|
+
recoverLocal: async () => {
|
|
89
|
+
recoveryStarted();
|
|
90
|
+
await recoveryGate;
|
|
91
|
+
activeDuringRecovery = store.listActiveGameIds();
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
const starts: Array<{ game: string; userId: string | null }> = [];
|
|
95
|
+
const queue = new GameStartQueue({
|
|
96
|
+
resolveRecording: () => coordinator.resolveRecordingIdentity("usr_local"),
|
|
97
|
+
start: (request, recording) => {
|
|
98
|
+
starts.push({ game: request.game, userId: recording.userId });
|
|
99
|
+
store.appendEvent("game_live", chunk("usr_local", "live").events[0]!);
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const pending = queue.request({ game: "2048", variation: "classic", source: "direct" });
|
|
104
|
+
await startedRecovery;
|
|
105
|
+
expect(starts).toEqual([]);
|
|
106
|
+
expect(store.listActiveGameIds()).toEqual([]);
|
|
107
|
+
releaseRecovery();
|
|
108
|
+
await pending;
|
|
109
|
+
expect(activeDuringRecovery).toEqual([]);
|
|
110
|
+
expect(starts).toEqual([{ game: "2048", userId: "usr_local" }]);
|
|
111
|
+
expect(store.listActiveGameIds()).toEqual(["game_live"]);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("keeps an episode unrecorded when local recovery cannot complete", async () => {
|
|
115
|
+
const root = mkdtempSync(join(tmpdir(), "gamepigeon-sync-recovery-failure-"));
|
|
116
|
+
roots.push(root);
|
|
117
|
+
const coordinator = new RecordingSyncCoordinator(new RecordingStore(root), {
|
|
118
|
+
config: null,
|
|
119
|
+
recoverLocal: async () => { throw new Error("corrupt local journal"); },
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
await expect(coordinator.resolveRecordingIdentity("usr_local")).resolves.toEqual({
|
|
123
|
+
userId: null,
|
|
124
|
+
remote: false,
|
|
125
|
+
recordable: false,
|
|
126
|
+
});
|
|
127
|
+
expect(coordinator.lastError).toBe("corrupt local journal");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("does not expose the local fallback while configured identity is unresolved", async () => {
|
|
131
|
+
const root = mkdtempSync(join(tmpdir(), "gamepigeon-sync-race-"));
|
|
132
|
+
roots.push(root);
|
|
133
|
+
let finish!: (value: string) => void;
|
|
134
|
+
const identity = new Promise<string>((resolve) => { finish = resolve; });
|
|
135
|
+
const coordinator = new RecordingSyncCoordinator(new RecordingStore(root), {
|
|
136
|
+
config,
|
|
137
|
+
identityClient: { resolve: async () => identity },
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
const pending = coordinator.resolveRecordingIdentity("usr_local");
|
|
141
|
+
await Promise.resolve();
|
|
142
|
+
expect(coordinator.userId).toBeNull();
|
|
143
|
+
finish("usr_backend");
|
|
144
|
+
await expect(pending).resolves.toEqual({
|
|
145
|
+
userId: "usr_backend",
|
|
146
|
+
remote: true,
|
|
147
|
+
recordable: true,
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("leaves a failed configured episode unrecorded and retries identity later", async () => {
|
|
152
|
+
const root = mkdtempSync(join(tmpdir(), "gamepigeon-sync-local-only-"));
|
|
153
|
+
roots.push(root);
|
|
154
|
+
const store = new RecordingStore(root);
|
|
155
|
+
store.persistChunk(chunk("usr_local", "local"));
|
|
156
|
+
const transport = new FakeTransport(async (persisted) => ack(persisted.chunk as TraceEventChunk));
|
|
157
|
+
let calls = 0;
|
|
158
|
+
const coordinator = new RecordingSyncCoordinator(store, {
|
|
159
|
+
config,
|
|
160
|
+
chunkTransport: transport,
|
|
161
|
+
identityClient: {
|
|
162
|
+
async resolve() {
|
|
163
|
+
calls++;
|
|
164
|
+
if (calls === 1) throw new Error("backend offline");
|
|
165
|
+
return "usr_backend";
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
await expect(coordinator.resolveRecordingIdentity("usr_local")).resolves.toEqual({
|
|
171
|
+
userId: null,
|
|
172
|
+
remote: false,
|
|
173
|
+
recordable: false,
|
|
174
|
+
});
|
|
175
|
+
await expect(coordinator.resolveRecordingIdentity("usr_local")).resolves.toEqual({
|
|
176
|
+
userId: "usr_backend",
|
|
177
|
+
remote: true,
|
|
178
|
+
recordable: true,
|
|
179
|
+
});
|
|
180
|
+
await coordinator.requestDrain();
|
|
181
|
+
expect(calls).toBe(2);
|
|
182
|
+
expect(transport.requests).toEqual([]);
|
|
183
|
+
expect(store.listPending()).toHaveLength(1);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("yields before completed-game recovery starts", async () => {
|
|
187
|
+
const root = mkdtempSync(join(tmpdir(), "gamepigeon-sync-yield-"));
|
|
188
|
+
roots.push(root);
|
|
189
|
+
let release!: () => void;
|
|
190
|
+
const gate = new Promise<void>((resolve) => { release = resolve; });
|
|
191
|
+
let listCalls = 0;
|
|
192
|
+
class CountingStore extends RecordingStore {
|
|
193
|
+
override async *iterateCompletedGamesBatched(
|
|
194
|
+
options: Parameters<RecordingStore["iterateCompletedGamesBatched"]>[0],
|
|
195
|
+
) {
|
|
196
|
+
listCalls++;
|
|
197
|
+
yield* super.iterateCompletedGamesBatched(options);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
let localRecoveryCalls = 0;
|
|
201
|
+
const coordinator = new RecordingSyncCoordinator(new CountingStore(root), {
|
|
202
|
+
config: null,
|
|
203
|
+
yieldControl: async () => gate,
|
|
204
|
+
recoverLocal: () => { localRecoveryCalls++; },
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
const pending = coordinator.bootstrapAndDrain();
|
|
208
|
+
expect(listCalls).toBe(0);
|
|
209
|
+
expect(localRecoveryCalls).toBe(0);
|
|
210
|
+
release();
|
|
211
|
+
await pending;
|
|
212
|
+
expect(listCalls).toBe(1);
|
|
213
|
+
expect(localRecoveryCalls).toBe(1);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("cancels during the second recovery yield before later batches run", async () => {
|
|
217
|
+
const root = mkdtempSync(join(tmpdir(), "gamepigeon-sync-recovery-cancel-"));
|
|
218
|
+
roots.push(root);
|
|
219
|
+
let yieldCount = 0;
|
|
220
|
+
let releaseSecond!: () => void;
|
|
221
|
+
let reachedSecond!: () => void;
|
|
222
|
+
const secondReached = new Promise<void>((resolve) => { reachedSecond = resolve; });
|
|
223
|
+
const secondGate = new Promise<void>((resolve) => { releaseSecond = resolve; });
|
|
224
|
+
const processed: number[] = [];
|
|
225
|
+
const coordinator = new RecordingSyncCoordinator(new RecordingStore(root), {
|
|
226
|
+
config: null,
|
|
227
|
+
yieldControl: async () => {
|
|
228
|
+
yieldCount++;
|
|
229
|
+
if (yieldCount === 2) {
|
|
230
|
+
reachedSecond();
|
|
231
|
+
await secondGate;
|
|
232
|
+
}
|
|
233
|
+
},
|
|
234
|
+
recoverLocal: async (yieldControl, signal) => {
|
|
235
|
+
for (let batch = 0; batch < 4; batch++) {
|
|
236
|
+
if (signal.aborted) return;
|
|
237
|
+
processed.push(batch);
|
|
238
|
+
await yieldControl();
|
|
239
|
+
}
|
|
240
|
+
},
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
const recovery = coordinator.bootstrapAndDrain();
|
|
244
|
+
await secondReached;
|
|
245
|
+
coordinator.cancel();
|
|
246
|
+
releaseSecond();
|
|
247
|
+
await recovery;
|
|
248
|
+
expect(processed).toEqual([0]);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("cancels an active drain and leaves work pending for the next launch", async () => {
|
|
252
|
+
const root = mkdtempSync(join(tmpdir(), "gamepigeon-sync-cancel-"));
|
|
253
|
+
roots.push(root);
|
|
254
|
+
const store = new RecordingStore(root);
|
|
255
|
+
store.persistChunk(chunk("usr_backend", "cancel"));
|
|
256
|
+
let aborted = false;
|
|
257
|
+
let started!: () => void;
|
|
258
|
+
const sending = new Promise<void>((resolve) => { started = resolve; });
|
|
259
|
+
const transport = {
|
|
260
|
+
enabled: true,
|
|
261
|
+
async send(_persisted: unknown, signal?: AbortSignal) {
|
|
262
|
+
started();
|
|
263
|
+
await new Promise<void>((resolve) => {
|
|
264
|
+
signal?.addEventListener("abort", () => {
|
|
265
|
+
aborted = true;
|
|
266
|
+
resolve();
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
throw new DOMException("aborted", "AbortError");
|
|
270
|
+
},
|
|
271
|
+
};
|
|
272
|
+
const coordinator = new RecordingSyncCoordinator(store, {
|
|
273
|
+
config,
|
|
274
|
+
identityClient: { resolve: async () => "usr_backend" },
|
|
275
|
+
chunkTransport: transport,
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
const draining = coordinator.requestDrain();
|
|
279
|
+
await sending;
|
|
280
|
+
coordinator.cancel();
|
|
281
|
+
await draining;
|
|
282
|
+
expect(aborted).toBe(true);
|
|
283
|
+
expect(store.listPending()).toHaveLength(1);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it("resolves identity once, stays single-flight, and drains only that user", async () => {
|
|
287
|
+
const root = mkdtempSync(join(tmpdir(), "gamepigeon-sync-"));
|
|
288
|
+
roots.push(root);
|
|
289
|
+
const store = new RecordingStore(root);
|
|
290
|
+
store.persistChunk(chunk("usr_a", "a"));
|
|
291
|
+
store.persistChunk(chunk("usr_b", "b"));
|
|
292
|
+
let identityCalls = 0;
|
|
293
|
+
const transport = new FakeTransport(async (persisted) => ack(persisted.chunk as TraceEventChunk));
|
|
294
|
+
const resultTransport: ResultTransport = {
|
|
295
|
+
async send() {
|
|
296
|
+
return { accepted: true, personal_best: 0, high_score_updated: false };
|
|
297
|
+
},
|
|
298
|
+
};
|
|
299
|
+
const coordinator = new RecordingSyncCoordinator(store, {
|
|
300
|
+
config,
|
|
301
|
+
identityClient: {
|
|
302
|
+
async resolve() {
|
|
303
|
+
identityCalls++;
|
|
304
|
+
await Promise.resolve();
|
|
305
|
+
return "usr_b";
|
|
306
|
+
},
|
|
307
|
+
},
|
|
308
|
+
chunkTransport: transport,
|
|
309
|
+
resultTransport,
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
const first = coordinator.bootstrapAndDrain();
|
|
313
|
+
const second = coordinator.bootstrapAndDrain();
|
|
314
|
+
expect(first).toBe(second);
|
|
315
|
+
await first;
|
|
316
|
+
expect(identityCalls).toBe(1);
|
|
317
|
+
expect(transport.requests.map((item) =>
|
|
318
|
+
item.chunk.schema_version === 2 ? item.chunk.user_id : null
|
|
319
|
+
)).toEqual(["usr_b"]);
|
|
320
|
+
expect(store.listPending().map((item) => item.chunk.game_id)).toEqual(["game_a"]);
|
|
321
|
+
await expect(coordinator.resolveRecordingIdentity("usr_local")).resolves.toEqual({
|
|
322
|
+
userId: "usr_b",
|
|
323
|
+
remote: true,
|
|
324
|
+
recordable: true,
|
|
325
|
+
});
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it("reloads rotated backend credentials before a later game", async () => {
|
|
329
|
+
const root = mkdtempSync(join(tmpdir(), "gamepigeon-sync-rotation-"));
|
|
330
|
+
roots.push(root);
|
|
331
|
+
let current = { ...config, token: "token_a" };
|
|
332
|
+
const authorizations: string[] = [];
|
|
333
|
+
const coordinator = new RecordingSyncCoordinator(new RecordingStore(root), {
|
|
334
|
+
config: null,
|
|
335
|
+
configProvider: () => current,
|
|
336
|
+
fetch: async (_input, init) => {
|
|
337
|
+
const authorization = new Headers(init?.headers).get("Authorization") ?? "";
|
|
338
|
+
authorizations.push(authorization);
|
|
339
|
+
const userId = authorization === "Bearer token_a" ? "usr_a" : "usr_b";
|
|
340
|
+
return new Response(JSON.stringify({ user_id: userId }), { status: 200 });
|
|
341
|
+
},
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
await expect(coordinator.resolveRecordingIdentity("usr_local")).resolves.toMatchObject({
|
|
345
|
+
userId: "usr_a",
|
|
346
|
+
recordable: true,
|
|
347
|
+
});
|
|
348
|
+
current = { ...config, token: "token_b" };
|
|
349
|
+
await expect(coordinator.resolveRecordingIdentity("usr_local")).resolves.toMatchObject({
|
|
350
|
+
userId: "usr_b",
|
|
351
|
+
recordable: true,
|
|
352
|
+
});
|
|
353
|
+
expect(authorizations).toEqual(["Bearer token_a", "Bearer token_b"]);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it("never mixes a rotated token into an in-flight user's drain", async () => {
|
|
357
|
+
const root = mkdtempSync(join(tmpdir(), "gamepigeon-sync-rotation-drain-"));
|
|
358
|
+
roots.push(root);
|
|
359
|
+
const store = new RecordingStore(root);
|
|
360
|
+
store.persistChunk(chunk("usr_a", "rotation"));
|
|
361
|
+
let current = { ...config, token: "token_a" };
|
|
362
|
+
let releaseChunk!: () => void;
|
|
363
|
+
const chunkGate = new Promise<void>((resolve) => { releaseChunk = resolve; });
|
|
364
|
+
let markChunkStarted!: () => void;
|
|
365
|
+
const chunkStarted = new Promise<void>((resolve) => { markChunkStarted = resolve; });
|
|
366
|
+
const resultAuthorizations: string[] = [];
|
|
367
|
+
const coordinator = new RecordingSyncCoordinator(store, {
|
|
368
|
+
config: null,
|
|
369
|
+
configProvider: () => current,
|
|
370
|
+
fetch: async (input, init) => {
|
|
371
|
+
const url = String(input);
|
|
372
|
+
const authorization = new Headers(init?.headers).get("Authorization") ?? "";
|
|
373
|
+
if (url.endsWith("/v1/me")) {
|
|
374
|
+
const userId = authorization === "Bearer token_a" ? "usr_a" : "usr_b";
|
|
375
|
+
return new Response(JSON.stringify({ user_id: userId }), { status: 200 });
|
|
376
|
+
}
|
|
377
|
+
if (url.endsWith("/v1/game-event-chunks")) {
|
|
378
|
+
markChunkStarted();
|
|
379
|
+
await chunkGate;
|
|
380
|
+
return new Response(JSON.stringify(ack(chunk("usr_a", "rotation"))), { status: 202 });
|
|
381
|
+
}
|
|
382
|
+
resultAuthorizations.push(authorization);
|
|
383
|
+
return new Response(JSON.stringify({
|
|
384
|
+
accepted: true,
|
|
385
|
+
personal_best: 1,
|
|
386
|
+
high_score_updated: true,
|
|
387
|
+
}), { status: 200 });
|
|
388
|
+
},
|
|
389
|
+
});
|
|
390
|
+
coordinator.results.persist("usr_a", {
|
|
391
|
+
result_id: "res_rotation",
|
|
392
|
+
game_id: "game_rotation",
|
|
393
|
+
game_type: "2048",
|
|
394
|
+
score: 1,
|
|
395
|
+
completed_at: "2026-01-01T00:00:00.000Z",
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
await coordinator.resolveRecordingIdentity("usr_local");
|
|
399
|
+
await chunkStarted;
|
|
400
|
+
current = { ...config, token: "token_b" };
|
|
401
|
+
await coordinator.resolveRecordingIdentity("usr_local");
|
|
402
|
+
releaseChunk();
|
|
403
|
+
await coordinator.requestDrain();
|
|
404
|
+
expect(resultAuthorizations).toEqual([]);
|
|
405
|
+
expect(coordinator.results.listPending("usr_a")).toHaveLength(1);
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
it("keeps offline recording available without backend configuration", async () => {
|
|
409
|
+
const root = mkdtempSync(join(tmpdir(), "gamepigeon-sync-offline-"));
|
|
410
|
+
roots.push(root);
|
|
411
|
+
const store = new RecordingStore(root);
|
|
412
|
+
const coordinator = new RecordingSyncCoordinator(store, {
|
|
413
|
+
config: null,
|
|
414
|
+
});
|
|
415
|
+
await coordinator.bootstrapAndDrain();
|
|
416
|
+
expect(coordinator.userId).toBeNull();
|
|
417
|
+
await expect(coordinator.resolveRecordingIdentity("usr_local")).resolves.toEqual({
|
|
418
|
+
userId: "usr_local",
|
|
419
|
+
remote: false,
|
|
420
|
+
recordable: true,
|
|
421
|
+
});
|
|
422
|
+
});
|
|
423
|
+
});
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import { recoverCompletedResultsBatched } from "../results/completion.js";
|
|
2
|
+
import { enqueueCompletedResult } from "../results/completion.js";
|
|
3
|
+
import { ResultStore } from "../results/store.js";
|
|
4
|
+
import { HttpResultTransport, type ResultTransport } from "../results/transport.js";
|
|
5
|
+
import { ResultUploader } from "../results/uploader.js";
|
|
6
|
+
import { RecordingStore } from "../recording/store.js";
|
|
7
|
+
import { HttpTransport, type ChunkTransport } from "../recording/transport.js";
|
|
8
|
+
import { ChunkUploader } from "../recording/uploader.js";
|
|
9
|
+
import type { InteractionTraceRecorder } from "../recording/trace-recorder.js";
|
|
10
|
+
import type { BackendConfig } from "./config.js";
|
|
11
|
+
import { IdentityClient } from "./identity-client.js";
|
|
12
|
+
|
|
13
|
+
type Fetch = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
|
14
|
+
|
|
15
|
+
export interface RecordingSyncCoordinatorOptions {
|
|
16
|
+
resultStore?: ResultStore;
|
|
17
|
+
config: BackendConfig | null;
|
|
18
|
+
configProvider?: () => BackendConfig | null;
|
|
19
|
+
fetch?: Fetch;
|
|
20
|
+
identityClient?: Pick<IdentityClient, "resolve">;
|
|
21
|
+
chunkTransport?: ChunkTransport;
|
|
22
|
+
resultTransport?: ResultTransport;
|
|
23
|
+
yieldControl?: () => Promise<void>;
|
|
24
|
+
recoverLocal?: (
|
|
25
|
+
yieldControl: () => Promise<void>,
|
|
26
|
+
signal: AbortSignal,
|
|
27
|
+
batchSize: number,
|
|
28
|
+
) => void | Promise<void>;
|
|
29
|
+
recoveryBatchSize?: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface RecordingIdentity {
|
|
33
|
+
userId: string | null;
|
|
34
|
+
remote: boolean;
|
|
35
|
+
recordable: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export class RecordingSyncCoordinator {
|
|
39
|
+
readonly results: ResultStore;
|
|
40
|
+
private readonly configProvider: () => BackendConfig | null;
|
|
41
|
+
private readonly request: Fetch;
|
|
42
|
+
private readonly providedIdentity: Pick<IdentityClient, "resolve"> | undefined;
|
|
43
|
+
private readonly providedChunkTransport: ChunkTransport | undefined;
|
|
44
|
+
private readonly providedResultTransport: ResultTransport | undefined;
|
|
45
|
+
private readonly yieldControl: () => Promise<void>;
|
|
46
|
+
private readonly recoverLocal: (
|
|
47
|
+
yieldControl: () => Promise<void>,
|
|
48
|
+
signal: AbortSignal,
|
|
49
|
+
batchSize: number,
|
|
50
|
+
) => void | Promise<void>;
|
|
51
|
+
private readonly recoveryBatchSize: number;
|
|
52
|
+
private readonly cancellation = new AbortController();
|
|
53
|
+
private config: BackendConfig | null = null;
|
|
54
|
+
private configFingerprint: string | null = null;
|
|
55
|
+
private identity: Pick<IdentityClient, "resolve"> | null = null;
|
|
56
|
+
private chunks: ChunkUploader | null = null;
|
|
57
|
+
private resultUploader: ResultUploader | null = null;
|
|
58
|
+
private backendGeneration = 0;
|
|
59
|
+
private currentDrain: Promise<void> | null = null;
|
|
60
|
+
private bootstrapPromise: Promise<void> | null = null;
|
|
61
|
+
private recoveryPromise: Promise<void> | null = null;
|
|
62
|
+
private identityResolution: Promise<string | null> | null = null;
|
|
63
|
+
private drainRequested = false;
|
|
64
|
+
private recoverySucceeded = false;
|
|
65
|
+
private verifiedUserId: string | null = null;
|
|
66
|
+
private error: string | null = null;
|
|
67
|
+
|
|
68
|
+
constructor(private readonly recordings: RecordingStore, options: RecordingSyncCoordinatorOptions) {
|
|
69
|
+
this.results = options.resultStore ?? new ResultStore(recordings.root);
|
|
70
|
+
this.configProvider = options.configProvider ?? (() => options.config);
|
|
71
|
+
this.request = options.fetch ?? globalThis.fetch;
|
|
72
|
+
this.providedIdentity = options.identityClient;
|
|
73
|
+
this.providedChunkTransport = options.chunkTransport;
|
|
74
|
+
this.providedResultTransport = options.resultTransport;
|
|
75
|
+
this.yieldControl = options.yieldControl ?? (() =>
|
|
76
|
+
new Promise<void>((resolve) => setTimeout(resolve, 0))
|
|
77
|
+
);
|
|
78
|
+
this.recoverLocal = options.recoverLocal ?? (() => undefined);
|
|
79
|
+
this.recoveryBatchSize = Math.max(1, options.recoveryBatchSize ?? 16);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
get userId(): string | null {
|
|
83
|
+
return this.verifiedUserId;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
get lastError(): string | null {
|
|
87
|
+
return this.error;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async resolveRecordingIdentity(localUserId: string): Promise<RecordingIdentity> {
|
|
91
|
+
await this.ready();
|
|
92
|
+
if (this.cancellation.signal.aborted) {
|
|
93
|
+
return { userId: null, remote: false, recordable: false };
|
|
94
|
+
}
|
|
95
|
+
if (!this.recoverySucceeded) {
|
|
96
|
+
return { userId: null, remote: false, recordable: false };
|
|
97
|
+
}
|
|
98
|
+
if (!this.refreshBackend()) {
|
|
99
|
+
return { userId: localUserId, remote: false, recordable: true };
|
|
100
|
+
}
|
|
101
|
+
const remoteUserId = await this.resolveVerifiedIdentity();
|
|
102
|
+
if (!remoteUserId) return { userId: null, remote: false, recordable: false };
|
|
103
|
+
void this.requestDrain();
|
|
104
|
+
return { userId: remoteUserId, remote: true, recordable: true };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
bootstrapAndDrain(): Promise<void> {
|
|
108
|
+
this.bootstrapPromise ??= this.bootstrap();
|
|
109
|
+
return this.bootstrapPromise;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
cancel(): void {
|
|
113
|
+
this.drainRequested = false;
|
|
114
|
+
this.cancellation.abort();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
enqueueCompletedGame(
|
|
118
|
+
recorder: InteractionTraceRecorder,
|
|
119
|
+
score: number,
|
|
120
|
+
completedAtMs: number,
|
|
121
|
+
): boolean {
|
|
122
|
+
try {
|
|
123
|
+
enqueueCompletedResult({
|
|
124
|
+
store: this.results,
|
|
125
|
+
recorder,
|
|
126
|
+
score,
|
|
127
|
+
completedAtMs,
|
|
128
|
+
});
|
|
129
|
+
void this.requestDrain();
|
|
130
|
+
return true;
|
|
131
|
+
} catch (error) {
|
|
132
|
+
this.error = error instanceof Error ? error.message : String(error);
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
requestDrain(): Promise<void> {
|
|
138
|
+
if (this.cancellation.signal.aborted) {
|
|
139
|
+
return Promise.resolve();
|
|
140
|
+
}
|
|
141
|
+
this.drainRequested = true;
|
|
142
|
+
if (this.currentDrain) return this.currentDrain;
|
|
143
|
+
this.currentDrain = this.drainLoop().finally(() => {
|
|
144
|
+
this.currentDrain = null;
|
|
145
|
+
if (this.drainRequested) void this.requestDrain();
|
|
146
|
+
});
|
|
147
|
+
return this.currentDrain;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
private async drainLoop(): Promise<void> {
|
|
151
|
+
try {
|
|
152
|
+
while (this.drainRequested && !this.cancellation.signal.aborted) {
|
|
153
|
+
this.drainRequested = false;
|
|
154
|
+
if (!this.refreshBackend()) return;
|
|
155
|
+
if (!this.identity || !this.chunks || !this.resultUploader) return;
|
|
156
|
+
const generation = this.backendGeneration;
|
|
157
|
+
const chunks = this.chunks;
|
|
158
|
+
const resultUploader = this.resultUploader;
|
|
159
|
+
const userId = await this.resolveVerifiedIdentity();
|
|
160
|
+
if (
|
|
161
|
+
!userId || this.cancellation.signal.aborted ||
|
|
162
|
+
generation !== this.backendGeneration
|
|
163
|
+
) return;
|
|
164
|
+
await chunks.uploadPending(userId, this.cancellation.signal);
|
|
165
|
+
if (generation !== this.backendGeneration) return;
|
|
166
|
+
await resultUploader.uploadPending(userId, this.cancellation.signal);
|
|
167
|
+
if (!this.cancellation.signal.aborted) this.error = null;
|
|
168
|
+
}
|
|
169
|
+
} catch (error) {
|
|
170
|
+
if (!this.cancellation.signal.aborted) {
|
|
171
|
+
this.error = error instanceof Error ? error.message : String(error);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
private async bootstrap(): Promise<void> {
|
|
177
|
+
await this.ready();
|
|
178
|
+
if (this.cancellation.signal.aborted) return;
|
|
179
|
+
await this.yieldControl();
|
|
180
|
+
if (this.cancellation.signal.aborted) return;
|
|
181
|
+
await this.requestDrain();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
private ready(): Promise<void> {
|
|
185
|
+
this.recoveryPromise ??= this.recover();
|
|
186
|
+
return this.recoveryPromise;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
private async recover(): Promise<void> {
|
|
190
|
+
await this.yieldControl();
|
|
191
|
+
if (this.cancellation.signal.aborted) return;
|
|
192
|
+
try {
|
|
193
|
+
await this.recoverLocal(
|
|
194
|
+
this.yieldControl,
|
|
195
|
+
this.cancellation.signal,
|
|
196
|
+
this.recoveryBatchSize,
|
|
197
|
+
);
|
|
198
|
+
if (this.cancellation.signal.aborted) return;
|
|
199
|
+
await recoverCompletedResultsBatched(this.recordings, this.results, {
|
|
200
|
+
batchSize: this.recoveryBatchSize,
|
|
201
|
+
yieldControl: this.yieldControl,
|
|
202
|
+
signal: this.cancellation.signal,
|
|
203
|
+
});
|
|
204
|
+
if (this.cancellation.signal.aborted) return;
|
|
205
|
+
this.recoverySucceeded = true;
|
|
206
|
+
} catch (error) {
|
|
207
|
+
this.error = error instanceof Error ? error.message : String(error);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
private refreshBackend(): BackendConfig | null {
|
|
212
|
+
let next: BackendConfig | null;
|
|
213
|
+
try {
|
|
214
|
+
next = this.configProvider();
|
|
215
|
+
} catch (error) {
|
|
216
|
+
this.error = error instanceof Error ? error.message : String(error);
|
|
217
|
+
next = null;
|
|
218
|
+
}
|
|
219
|
+
const fingerprint = next ? `${next.baseUrl}\0${next.token}` : null;
|
|
220
|
+
if (fingerprint === this.configFingerprint) return this.config;
|
|
221
|
+
this.backendGeneration++;
|
|
222
|
+
this.config = next;
|
|
223
|
+
this.configFingerprint = fingerprint;
|
|
224
|
+
this.verifiedUserId = null;
|
|
225
|
+
this.identityResolution = null;
|
|
226
|
+
if (!next) {
|
|
227
|
+
this.identity = null;
|
|
228
|
+
this.chunks = null;
|
|
229
|
+
this.resultUploader = null;
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
this.identity = this.providedIdentity ?? new IdentityClient(next, this.request);
|
|
233
|
+
this.chunks = new ChunkUploader(
|
|
234
|
+
this.recordings,
|
|
235
|
+
this.providedChunkTransport ?? new HttpTransport(next.eventChunksUrl, {
|
|
236
|
+
token: next.token,
|
|
237
|
+
fetch: this.request,
|
|
238
|
+
}),
|
|
239
|
+
);
|
|
240
|
+
this.resultUploader = new ResultUploader(
|
|
241
|
+
this.results,
|
|
242
|
+
this.providedResultTransport ?? new HttpResultTransport(
|
|
243
|
+
next.gameResultsUrl,
|
|
244
|
+
next.token,
|
|
245
|
+
this.request,
|
|
246
|
+
),
|
|
247
|
+
);
|
|
248
|
+
return next;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
private resolveVerifiedIdentity(): Promise<string | null> {
|
|
252
|
+
if (!this.identity || this.cancellation.signal.aborted) {
|
|
253
|
+
return Promise.resolve(null);
|
|
254
|
+
}
|
|
255
|
+
if (this.verifiedUserId) return Promise.resolve(this.verifiedUserId);
|
|
256
|
+
if (this.identityResolution) return this.identityResolution;
|
|
257
|
+
const identity = this.identity;
|
|
258
|
+
const generation = this.backendGeneration;
|
|
259
|
+
const resolution = identity.resolve(this.cancellation.signal)
|
|
260
|
+
.then((userId) => {
|
|
261
|
+
if (this.cancellation.signal.aborted || generation !== this.backendGeneration) return null;
|
|
262
|
+
this.verifiedUserId = userId;
|
|
263
|
+
return userId;
|
|
264
|
+
})
|
|
265
|
+
.catch((error: unknown) => {
|
|
266
|
+
if (!this.cancellation.signal.aborted) {
|
|
267
|
+
this.error = error instanceof Error ? error.message : String(error);
|
|
268
|
+
}
|
|
269
|
+
return null;
|
|
270
|
+
})
|
|
271
|
+
.finally(() => {
|
|
272
|
+
if (this.identityResolution === resolution) this.identityResolution = null;
|
|
273
|
+
});
|
|
274
|
+
this.identityResolution = resolution;
|
|
275
|
+
return resolution;
|
|
276
|
+
}
|
|
277
|
+
}
|