@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,383 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync, statSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
5
|
+
import { RecordingStore } from "../recording/store.js";
|
|
6
|
+
import { TransportError } from "../recording/transport.js";
|
|
7
|
+
import type { EpisodeStartedTraceEvent, EpisodeEndedTraceEvent } from "../recording/types.js";
|
|
8
|
+
import type { InteractionTraceRecorder } from "../recording/trace-recorder.js";
|
|
9
|
+
import {
|
|
10
|
+
enqueueCompletedResult,
|
|
11
|
+
recoverCompletedResults,
|
|
12
|
+
recoverCompletedResultsBatched,
|
|
13
|
+
resultIdForGame,
|
|
14
|
+
} from "./completion.js";
|
|
15
|
+
import { ResultStore } from "./store.js";
|
|
16
|
+
import type { ResultTransport } from "./transport.js";
|
|
17
|
+
import { ResultUploader } from "./uploader.js";
|
|
18
|
+
|
|
19
|
+
const roots: string[] = [];
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
function stores() {
|
|
25
|
+
const root = mkdtempSync(join(tmpdir(), "gamepigeon-results-"));
|
|
26
|
+
roots.push(root);
|
|
27
|
+
return { recordings: new RecordingStore(root), results: new ResultStore(root) };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function recorder(gameId = "game_test", userId = "usr_test") {
|
|
31
|
+
return { gameId, userId, gameType: "2048" } as InteractionTraceRecorder;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function appendCompletedTrace(recordings: RecordingStore, index: number): void {
|
|
35
|
+
const gameId = `game_${index}`;
|
|
36
|
+
recordings.appendEvent(gameId, {
|
|
37
|
+
schema_version: 2,
|
|
38
|
+
user_id: "usr_test",
|
|
39
|
+
game_id: gameId,
|
|
40
|
+
chunk_id: `chk_${index}`,
|
|
41
|
+
event_id: `evt_${index}_start`,
|
|
42
|
+
event_sequence: 1,
|
|
43
|
+
event_version: 1,
|
|
44
|
+
event_type: "episode_started",
|
|
45
|
+
visibility: "policy",
|
|
46
|
+
client_time_ms: 1,
|
|
47
|
+
monotonic_time_us: 0,
|
|
48
|
+
payload: {
|
|
49
|
+
game_type: "2048",
|
|
50
|
+
game_version: "1",
|
|
51
|
+
ruleset_id: "classic",
|
|
52
|
+
telemetry_version: 2,
|
|
53
|
+
platform: "tui",
|
|
54
|
+
viewport: { width: 80, height: 24 },
|
|
55
|
+
control_schema: ["left"],
|
|
56
|
+
input_semantics: "terminal_impulse",
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
recordings.appendEvent(gameId, {
|
|
60
|
+
schema_version: 2,
|
|
61
|
+
user_id: "usr_test",
|
|
62
|
+
game_id: gameId,
|
|
63
|
+
chunk_id: `chk_${index}`,
|
|
64
|
+
event_id: `evt_${index}_end`,
|
|
65
|
+
event_sequence: 2,
|
|
66
|
+
event_version: 1,
|
|
67
|
+
event_type: "episode_ended",
|
|
68
|
+
visibility: "policy",
|
|
69
|
+
client_time_ms: 2,
|
|
70
|
+
monotonic_time_us: 1,
|
|
71
|
+
payload: {
|
|
72
|
+
reason: "game_over",
|
|
73
|
+
final_event_sequence: 2,
|
|
74
|
+
final_score: index,
|
|
75
|
+
duration_us: 1,
|
|
76
|
+
completed: true,
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
recordings.finalizeGame(gameId);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
describe("result outbox", () => {
|
|
83
|
+
it("keeps result directories and files private under a permissive umask", () => {
|
|
84
|
+
const previous = process.umask(0);
|
|
85
|
+
try {
|
|
86
|
+
const { results } = stores();
|
|
87
|
+
const persisted = enqueueCompletedResult({
|
|
88
|
+
store: results,
|
|
89
|
+
recorder: recorder(),
|
|
90
|
+
score: 4,
|
|
91
|
+
completedAtMs: 1,
|
|
92
|
+
});
|
|
93
|
+
const failed = results.markFailed(persisted, "private reason");
|
|
94
|
+
expect(statSync(results.root).mode & 0o777).toBe(0o700);
|
|
95
|
+
for (const state of ["pending", "completed", "failed"]) {
|
|
96
|
+
expect(statSync(join(results.root, state)).mode & 0o777).toBe(0o700);
|
|
97
|
+
}
|
|
98
|
+
expect(statSync(join(results.root, "failed", "usr_test")).mode & 0o777).toBe(0o700);
|
|
99
|
+
expect(statSync(failed).mode & 0o777).toBe(0o600);
|
|
100
|
+
expect(statSync(`${failed}.error.txt`).mode & 0o777).toBe(0o600);
|
|
101
|
+
} finally {
|
|
102
|
+
process.umask(previous);
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it.each([true, false])("enqueues one result for a terminal completion (success=%s)", (success) => {
|
|
107
|
+
const { results } = stores();
|
|
108
|
+
const persisted = enqueueCompletedResult({
|
|
109
|
+
store: results,
|
|
110
|
+
recorder: recorder(),
|
|
111
|
+
score: success ? 2048 : 256,
|
|
112
|
+
completedAtMs: 1_752_758_400_000,
|
|
113
|
+
});
|
|
114
|
+
enqueueCompletedResult({
|
|
115
|
+
store: results,
|
|
116
|
+
recorder: recorder(),
|
|
117
|
+
score: success ? 2048 : 256,
|
|
118
|
+
completedAtMs: 1_752_758_400_000,
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
expect(results.listPending()).toHaveLength(1);
|
|
122
|
+
expect(persisted.request).toEqual({
|
|
123
|
+
result_id: resultIdForGame("game_test"),
|
|
124
|
+
game_id: "game_test",
|
|
125
|
+
game_type: "2048",
|
|
126
|
+
score: success ? 2048 : 256,
|
|
127
|
+
completed_at: "2025-07-17T13:20:00.000Z",
|
|
128
|
+
});
|
|
129
|
+
expect(persisted.bytes).toBe(JSON.stringify(persisted.request));
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("yields while indexing a large existing result backlog", async () => {
|
|
133
|
+
const { results } = stores();
|
|
134
|
+
for (let index = 0; index < 12; index++) {
|
|
135
|
+
enqueueCompletedResult({
|
|
136
|
+
store: results,
|
|
137
|
+
recorder: recorder(`game_${index}`),
|
|
138
|
+
score: index,
|
|
139
|
+
completedAtMs: 1,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
let yields = 0;
|
|
143
|
+
const keys = await results.listResultKeysBatched({
|
|
144
|
+
batchSize: 5,
|
|
145
|
+
yieldControl: async () => { yields++; },
|
|
146
|
+
});
|
|
147
|
+
expect(keys).toHaveLength(12);
|
|
148
|
+
expect(yields).toBe(2);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("stops result indexing promptly when recovery is cancelled", async () => {
|
|
152
|
+
const { results } = stores();
|
|
153
|
+
for (let index = 0; index < 8; index++) {
|
|
154
|
+
enqueueCompletedResult({
|
|
155
|
+
store: results,
|
|
156
|
+
recorder: recorder(`game_${index}`),
|
|
157
|
+
score: index,
|
|
158
|
+
completedAtMs: 1,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
const cancellation = new AbortController();
|
|
162
|
+
let yields = 0;
|
|
163
|
+
const keys = await results.listResultKeysBatched({
|
|
164
|
+
batchSize: 2,
|
|
165
|
+
signal: cancellation.signal,
|
|
166
|
+
yieldControl: async () => {
|
|
167
|
+
yields++;
|
|
168
|
+
if (yields === 2) cancellation.abort();
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
expect(keys).toHaveLength(4);
|
|
173
|
+
expect(results.listPending()).toHaveLength(8);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("recovers completed episodes and ignores abandoned episodes", () => {
|
|
177
|
+
const { recordings, results } = stores();
|
|
178
|
+
const started = {
|
|
179
|
+
schema_version: 2,
|
|
180
|
+
user_id: "usr_test",
|
|
181
|
+
game_id: "game_completed",
|
|
182
|
+
chunk_id: "chk_1",
|
|
183
|
+
event_id: "evt_1",
|
|
184
|
+
event_sequence: 1,
|
|
185
|
+
event_version: 1,
|
|
186
|
+
event_type: "episode_started",
|
|
187
|
+
visibility: "policy",
|
|
188
|
+
client_time_ms: 100,
|
|
189
|
+
monotonic_time_us: 0,
|
|
190
|
+
payload: {
|
|
191
|
+
game_type: "2048",
|
|
192
|
+
game_version: "1",
|
|
193
|
+
ruleset_id: "classic",
|
|
194
|
+
telemetry_version: 2,
|
|
195
|
+
platform: "tui",
|
|
196
|
+
viewport: { width: 80, height: 24 },
|
|
197
|
+
control_schema: ["left"],
|
|
198
|
+
input_semantics: "terminal_impulse",
|
|
199
|
+
},
|
|
200
|
+
} satisfies EpisodeStartedTraceEvent;
|
|
201
|
+
const ended = {
|
|
202
|
+
...started,
|
|
203
|
+
event_id: "evt_2",
|
|
204
|
+
event_sequence: 2,
|
|
205
|
+
event_type: "episode_ended",
|
|
206
|
+
client_time_ms: 1_752_758_400_000,
|
|
207
|
+
payload: {
|
|
208
|
+
reason: "game_over",
|
|
209
|
+
final_event_sequence: 2,
|
|
210
|
+
final_score: 512,
|
|
211
|
+
duration_us: 100,
|
|
212
|
+
completed: true,
|
|
213
|
+
},
|
|
214
|
+
} satisfies EpisodeEndedTraceEvent;
|
|
215
|
+
recordings.appendEvent("game_completed", started);
|
|
216
|
+
recordings.appendEvent("game_completed", ended);
|
|
217
|
+
recordings.finalizeGame("game_completed");
|
|
218
|
+
recordings.appendEvent("game_abandoned", { ...started, game_id: "game_abandoned" });
|
|
219
|
+
recordings.appendEvent("game_abandoned", {
|
|
220
|
+
...ended,
|
|
221
|
+
game_id: "game_abandoned",
|
|
222
|
+
payload: { ...ended.payload, completed: false, reason: "user_quit" },
|
|
223
|
+
});
|
|
224
|
+
recordings.finalizeGame("game_abandoned");
|
|
225
|
+
|
|
226
|
+
expect(recoverCompletedResults(recordings, results)).toBe(1);
|
|
227
|
+
expect(recoverCompletedResults(recordings, results)).toBe(0);
|
|
228
|
+
expect(results.listPending().map((item) => item.request.game_id)).toEqual([
|
|
229
|
+
"game_completed",
|
|
230
|
+
]);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("indexes existing result ids once for a large recovery backlog", () => {
|
|
234
|
+
const { recordings, results } = stores();
|
|
235
|
+
const calls = { pending: 0, completed: 0, failed: 0 };
|
|
236
|
+
const pending = results.listPending.bind(results);
|
|
237
|
+
const completed = results.listCompleted.bind(results);
|
|
238
|
+
const failed = results.listFailed.bind(results);
|
|
239
|
+
results.listPending = (userId) => { calls.pending++; return pending(userId); };
|
|
240
|
+
results.listCompleted = (userId) => { calls.completed++; return completed(userId); };
|
|
241
|
+
results.listFailed = (userId) => { calls.failed++; return failed(userId); };
|
|
242
|
+
for (let index = 0; index < 40; index++) {
|
|
243
|
+
appendCompletedTrace(recordings, index);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
expect(recoverCompletedResults(recordings, results)).toBe(40);
|
|
247
|
+
expect(calls).toEqual({ pending: 1, completed: 1, failed: 1 });
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it("yields between bounded completed-game recovery batches", async () => {
|
|
251
|
+
const { recordings, results } = stores();
|
|
252
|
+
for (let index = 0; index < 20; index++) appendCompletedTrace(recordings, index);
|
|
253
|
+
const releases: Array<() => void> = [];
|
|
254
|
+
const recovery = recoverCompletedResultsBatched(recordings, results, {
|
|
255
|
+
batchSize: 5,
|
|
256
|
+
yieldControl: () => new Promise<void>((resolve) => { releases.push(resolve); }),
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
while (releases.length === 0) {
|
|
260
|
+
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
|
261
|
+
}
|
|
262
|
+
expect(results.listPending()).toHaveLength(5);
|
|
263
|
+
releases.shift()!();
|
|
264
|
+
while (releases.length === 0) {
|
|
265
|
+
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
|
266
|
+
}
|
|
267
|
+
expect(results.listPending()).toHaveLength(10);
|
|
268
|
+
releases.shift()!();
|
|
269
|
+
while (releases.length === 0) {
|
|
270
|
+
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
|
271
|
+
}
|
|
272
|
+
expect(results.listPending()).toHaveLength(15);
|
|
273
|
+
releases.shift()!();
|
|
274
|
+
await expect(recovery).resolves.toBe(20);
|
|
275
|
+
expect(results.listPending()).toHaveLength(20);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it("cancels completed-game recovery between batches", async () => {
|
|
279
|
+
const { recordings, results } = stores();
|
|
280
|
+
for (let index = 0; index < 10; index++) appendCompletedTrace(recordings, index);
|
|
281
|
+
const cancellation = new AbortController();
|
|
282
|
+
let yields = 0;
|
|
283
|
+
const recovered = await recoverCompletedResultsBatched(recordings, results, {
|
|
284
|
+
batchSize: 2,
|
|
285
|
+
signal: cancellation.signal,
|
|
286
|
+
yieldControl: async () => {
|
|
287
|
+
yields++;
|
|
288
|
+
if (yields === 2) cancellation.abort();
|
|
289
|
+
},
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
expect(recovered).toBe(4);
|
|
293
|
+
expect(results.listPending()).toHaveLength(4);
|
|
294
|
+
expect(recordings.listCompletedGames()).toHaveLength(10);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it("retries identical bytes after an ambiguous lost response", async () => {
|
|
298
|
+
const { results } = stores();
|
|
299
|
+
const persisted = enqueueCompletedResult({
|
|
300
|
+
store: results,
|
|
301
|
+
recorder: recorder(),
|
|
302
|
+
score: 1024,
|
|
303
|
+
completedAtMs: 1_752_758_400_000,
|
|
304
|
+
});
|
|
305
|
+
const bytes: string[] = [];
|
|
306
|
+
let attempts = 0;
|
|
307
|
+
const transport: ResultTransport = {
|
|
308
|
+
async send(result) {
|
|
309
|
+
bytes.push(result.bytes);
|
|
310
|
+
attempts++;
|
|
311
|
+
if (attempts === 1) throw new TransportError("connection reset", true);
|
|
312
|
+
return { accepted: true, personal_best: 1024, high_score_updated: true };
|
|
313
|
+
},
|
|
314
|
+
};
|
|
315
|
+
const uploader = new ResultUploader(results, transport, {
|
|
316
|
+
maxAttempts: 2,
|
|
317
|
+
delay: async () => undefined,
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
expect((await uploader.uploadPending("usr_test")).acknowledged).toBe(1);
|
|
321
|
+
expect(bytes).toEqual([persisted.bytes, persisted.bytes]);
|
|
322
|
+
expect(results.listPending()).toHaveLength(0);
|
|
323
|
+
expect(results.listCompleted()).toHaveLength(1);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it("cancels result retry waits without clearing the outbox", async () => {
|
|
327
|
+
const { results } = stores();
|
|
328
|
+
enqueueCompletedResult({
|
|
329
|
+
store: results,
|
|
330
|
+
recorder: recorder(),
|
|
331
|
+
score: 32,
|
|
332
|
+
completedAtMs: 1,
|
|
333
|
+
});
|
|
334
|
+
const controller = new AbortController();
|
|
335
|
+
let waiting = false;
|
|
336
|
+
let requests = 0;
|
|
337
|
+
const uploader = new ResultUploader(results, {
|
|
338
|
+
async send() {
|
|
339
|
+
requests++;
|
|
340
|
+
throw new TransportError("offline", true);
|
|
341
|
+
},
|
|
342
|
+
}, {
|
|
343
|
+
delay: async (_ms, signal) => {
|
|
344
|
+
waiting = true;
|
|
345
|
+
await new Promise<void>((resolve) => signal?.addEventListener("abort", () => resolve()));
|
|
346
|
+
},
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
const pending = uploader.uploadPending("usr_test", controller.signal);
|
|
350
|
+
await Promise.resolve();
|
|
351
|
+
expect(waiting).toBe(true);
|
|
352
|
+
controller.abort();
|
|
353
|
+
await pending;
|
|
354
|
+
expect(requests).toBe(1);
|
|
355
|
+
expect(results.listPending("usr_test")).toHaveLength(1);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it("never drains another user's results", async () => {
|
|
359
|
+
const { results } = stores();
|
|
360
|
+
enqueueCompletedResult({
|
|
361
|
+
store: results,
|
|
362
|
+
recorder: recorder("game_a", "usr_a"),
|
|
363
|
+
score: 4,
|
|
364
|
+
completedAtMs: 1,
|
|
365
|
+
});
|
|
366
|
+
enqueueCompletedResult({
|
|
367
|
+
store: results,
|
|
368
|
+
recorder: recorder("game_b", "usr_b"),
|
|
369
|
+
score: 8,
|
|
370
|
+
completedAtMs: 2,
|
|
371
|
+
});
|
|
372
|
+
const sent: string[] = [];
|
|
373
|
+
const transport: ResultTransport = {
|
|
374
|
+
async send(result) {
|
|
375
|
+
sent.push(result.userId);
|
|
376
|
+
return { accepted: true, personal_best: result.request.score, high_score_updated: true };
|
|
377
|
+
},
|
|
378
|
+
};
|
|
379
|
+
await new ResultUploader(results, transport).uploadPending("usr_b");
|
|
380
|
+
expect(sent).toEqual(["usr_b"]);
|
|
381
|
+
expect(results.listPending().map((item) => item.userId)).toEqual(["usr_a"]);
|
|
382
|
+
});
|
|
383
|
+
});
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
existsSync,
|
|
4
|
+
readFileSync,
|
|
5
|
+
readdirSync,
|
|
6
|
+
renameSync,
|
|
7
|
+
writeFileSync,
|
|
8
|
+
} from "node:fs";
|
|
9
|
+
import { opendir } from "node:fs/promises";
|
|
10
|
+
import { basename, dirname, join } from "node:path";
|
|
11
|
+
import type { GameResultRequest, PersistedGameResult } from "./types.js";
|
|
12
|
+
import { ensurePrivateDirectory, ensurePrivateFile } from "../recording/permissions.js";
|
|
13
|
+
|
|
14
|
+
const SAFE_ID = /^[A-Za-z0-9._-]+$/;
|
|
15
|
+
|
|
16
|
+
function assertSafeId(kind: string, value: string): void {
|
|
17
|
+
if (!SAFE_ID.test(value)) throw new Error(`${kind} is not safe for durable storage`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class ResultStore {
|
|
21
|
+
readonly root: string;
|
|
22
|
+
|
|
23
|
+
constructor(recordingRoot: string) {
|
|
24
|
+
this.root = join(recordingRoot, "results");
|
|
25
|
+
ensurePrivateDirectory(this.root);
|
|
26
|
+
for (const directory of ["pending", "completed", "failed"]) {
|
|
27
|
+
ensurePrivateDirectory(join(this.root, directory));
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
persist(userId: string, request: GameResultRequest): PersistedGameResult {
|
|
32
|
+
assertSafeId("user_id", userId);
|
|
33
|
+
assertSafeId("result_id", request.result_id);
|
|
34
|
+
const bytes = JSON.stringify(request);
|
|
35
|
+
const path = join(this.root, "pending", userId, `${request.result_id}.json`);
|
|
36
|
+
for (const state of ["pending", "completed", "failed"] as const) {
|
|
37
|
+
const existingPath = join(this.root, state, userId, `${request.result_id}.json`);
|
|
38
|
+
if (!existsSync(existingPath)) continue;
|
|
39
|
+
ensurePrivateFile(existingPath);
|
|
40
|
+
const existing = readFileSync(existingPath, "utf8");
|
|
41
|
+
if (existing !== bytes) {
|
|
42
|
+
throw new Error(`result ${request.result_id} has conflicting bytes`);
|
|
43
|
+
}
|
|
44
|
+
return { userId, request, path: existingPath, bytes: existing };
|
|
45
|
+
}
|
|
46
|
+
ensurePrivateDirectory(dirname(path));
|
|
47
|
+
const temporary = `${path}.tmp-${process.pid}-${randomUUID()}`;
|
|
48
|
+
writeFileSync(temporary, bytes, { flag: "wx", mode: 0o600 });
|
|
49
|
+
renameSync(temporary, path);
|
|
50
|
+
ensurePrivateFile(path);
|
|
51
|
+
return { userId, request, path, bytes };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
listPending(userId?: string): PersistedGameResult[] {
|
|
55
|
+
return this.list("pending", userId);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
listCompleted(userId?: string): PersistedGameResult[] {
|
|
59
|
+
return this.list("completed", userId);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
listFailed(userId?: string): PersistedGameResult[] {
|
|
63
|
+
return this.list("failed", userId);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async listResultKeysBatched(options: {
|
|
67
|
+
batchSize?: number;
|
|
68
|
+
yieldControl?: () => Promise<void>;
|
|
69
|
+
signal?: AbortSignal;
|
|
70
|
+
} = {}): Promise<string[]> {
|
|
71
|
+
const batchSize = Math.max(1, options.batchSize ?? 16);
|
|
72
|
+
const yieldControl = options.yieldControl ?? (() =>
|
|
73
|
+
new Promise<void>((resolve) => setTimeout(resolve, 0))
|
|
74
|
+
);
|
|
75
|
+
const keys = new Set<string>();
|
|
76
|
+
let processed = 0;
|
|
77
|
+
for (const state of ["pending", "completed", "failed"] as const) {
|
|
78
|
+
const base = join(this.root, state);
|
|
79
|
+
if (options.signal?.aborted) return [...keys];
|
|
80
|
+
const users = await opendir(base);
|
|
81
|
+
for await (const user of users) {
|
|
82
|
+
if (options.signal?.aborted) return [...keys];
|
|
83
|
+
if (!user.isDirectory()) continue;
|
|
84
|
+
const directory = join(base, user.name);
|
|
85
|
+
ensurePrivateDirectory(directory);
|
|
86
|
+
const files = await opendir(directory);
|
|
87
|
+
for await (const file of files) {
|
|
88
|
+
if (options.signal?.aborted) return [...keys];
|
|
89
|
+
if (!file.isFile() || !file.name.endsWith(".json")) continue;
|
|
90
|
+
if (processed > 0 && processed % batchSize === 0) {
|
|
91
|
+
await yieldControl();
|
|
92
|
+
if (options.signal?.aborted) return [...keys];
|
|
93
|
+
}
|
|
94
|
+
ensurePrivateFile(join(directory, file.name));
|
|
95
|
+
keys.add(`${user.name}\0${file.name.slice(0, -".json".length)}`);
|
|
96
|
+
processed++;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return [...keys];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
acknowledge(result: PersistedGameResult): string {
|
|
104
|
+
return this.move(result, "completed");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
markFailed(result: PersistedGameResult, reason: string): string {
|
|
108
|
+
const destination = this.move(result, "failed");
|
|
109
|
+
writeFileSync(`${destination}.error.txt`, `${reason}\n`, { mode: 0o600 });
|
|
110
|
+
ensurePrivateFile(`${destination}.error.txt`);
|
|
111
|
+
return destination;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
private move(result: PersistedGameResult, state: "completed" | "failed"): string {
|
|
115
|
+
const destination = join(this.root, state, result.userId, basename(result.path));
|
|
116
|
+
ensurePrivateDirectory(dirname(destination));
|
|
117
|
+
renameSync(result.path, destination);
|
|
118
|
+
ensurePrivateFile(destination);
|
|
119
|
+
return destination;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
private list(state: "pending" | "completed" | "failed", userId?: string): PersistedGameResult[] {
|
|
123
|
+
const base = join(this.root, state);
|
|
124
|
+
const users = userId
|
|
125
|
+
? [userId]
|
|
126
|
+
: readdirSync(base, { withFileTypes: true })
|
|
127
|
+
.filter((entry) => entry.isDirectory())
|
|
128
|
+
.map((entry) => entry.name);
|
|
129
|
+
const results: PersistedGameResult[] = [];
|
|
130
|
+
for (const owner of users.sort()) {
|
|
131
|
+
const directory = join(base, owner);
|
|
132
|
+
if (!existsSync(directory)) continue;
|
|
133
|
+
ensurePrivateDirectory(directory);
|
|
134
|
+
for (const name of readdirSync(directory).filter((entry) => entry.endsWith(".json")).sort()) {
|
|
135
|
+
const path = join(directory, name);
|
|
136
|
+
ensurePrivateFile(path);
|
|
137
|
+
const bytes = readFileSync(path, "utf8");
|
|
138
|
+
results.push({
|
|
139
|
+
userId: owner,
|
|
140
|
+
request: JSON.parse(bytes) as GameResultRequest,
|
|
141
|
+
path,
|
|
142
|
+
bytes,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return results;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { TransportError } from "../recording/transport.js";
|
|
2
|
+
import type {
|
|
3
|
+
GameResultAcknowledgment,
|
|
4
|
+
PersistedGameResult,
|
|
5
|
+
} from "./types.js";
|
|
6
|
+
|
|
7
|
+
type Fetch = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
|
8
|
+
|
|
9
|
+
export interface ResultTransport {
|
|
10
|
+
send(result: PersistedGameResult, signal?: AbortSignal): Promise<GameResultAcknowledgment>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function validAcknowledgment(value: unknown): value is GameResultAcknowledgment {
|
|
14
|
+
return typeof value === "object" && value !== null &&
|
|
15
|
+
"accepted" in value && value.accepted === true &&
|
|
16
|
+
"personal_best" in value && Number.isInteger(value.personal_best) &&
|
|
17
|
+
"high_score_updated" in value && typeof value.high_score_updated === "boolean";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class HttpResultTransport implements ResultTransport {
|
|
21
|
+
constructor(
|
|
22
|
+
private readonly endpoint: string,
|
|
23
|
+
private readonly token: string,
|
|
24
|
+
private readonly request: Fetch = globalThis.fetch,
|
|
25
|
+
private readonly timeoutMs = 10_000,
|
|
26
|
+
) {}
|
|
27
|
+
|
|
28
|
+
async send(
|
|
29
|
+
result: PersistedGameResult,
|
|
30
|
+
signal?: AbortSignal,
|
|
31
|
+
): Promise<GameResultAcknowledgment> {
|
|
32
|
+
const controller = new AbortController();
|
|
33
|
+
const abort = () => controller.abort(signal?.reason);
|
|
34
|
+
if (signal?.aborted) abort();
|
|
35
|
+
else signal?.addEventListener("abort", abort, { once: true });
|
|
36
|
+
const timeout = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
37
|
+
timeout.unref?.();
|
|
38
|
+
try {
|
|
39
|
+
const response = await this.request(this.endpoint, {
|
|
40
|
+
method: "POST",
|
|
41
|
+
headers: {
|
|
42
|
+
Authorization: `Bearer ${this.token}`,
|
|
43
|
+
"Content-Type": "application/json",
|
|
44
|
+
},
|
|
45
|
+
body: result.bytes,
|
|
46
|
+
signal: controller.signal,
|
|
47
|
+
});
|
|
48
|
+
if (response.status !== 200 && response.status !== 202) {
|
|
49
|
+
const retryable = response.status === 408 || response.status === 425 ||
|
|
50
|
+
response.status === 429 || response.status >= 500;
|
|
51
|
+
throw new TransportError(
|
|
52
|
+
`game result returned HTTP ${response.status}: ${await response.text()}`.trim(),
|
|
53
|
+
retryable,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
let value: unknown;
|
|
57
|
+
try {
|
|
58
|
+
value = await response.json();
|
|
59
|
+
} catch {
|
|
60
|
+
throw new TransportError("game result returned a malformed acknowledgment", false, false);
|
|
61
|
+
}
|
|
62
|
+
if (!validAcknowledgment(value)) {
|
|
63
|
+
throw new TransportError("game result returned a malformed acknowledgment", false, false);
|
|
64
|
+
}
|
|
65
|
+
return value;
|
|
66
|
+
} catch (error) {
|
|
67
|
+
if (error instanceof TransportError) throw error;
|
|
68
|
+
throw new TransportError(
|
|
69
|
+
error instanceof DOMException && error.name === "AbortError"
|
|
70
|
+
? "game result request timed out"
|
|
71
|
+
: error instanceof Error ? error.message : String(error),
|
|
72
|
+
true,
|
|
73
|
+
);
|
|
74
|
+
} finally {
|
|
75
|
+
clearTimeout(timeout);
|
|
76
|
+
signal?.removeEventListener("abort", abort);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export interface GameResultRequest {
|
|
2
|
+
result_id: string;
|
|
3
|
+
game_id: string;
|
|
4
|
+
game_type: string;
|
|
5
|
+
score: number;
|
|
6
|
+
completed_at: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface GameResultAcknowledgment {
|
|
10
|
+
accepted: true;
|
|
11
|
+
personal_best: number;
|
|
12
|
+
high_score_updated: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface PersistedGameResult {
|
|
16
|
+
userId: string;
|
|
17
|
+
request: GameResultRequest;
|
|
18
|
+
path: string;
|
|
19
|
+
bytes: string;
|
|
20
|
+
}
|