@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.
- package/.claude-plugin/plugin.json +10 -0
- package/.codex-plugin/plugin.json +21 -0
- package/README.md +32 -8
- package/dist/index.js +286 -19
- package/overlay/dist/app.js +74 -0
- package/overlay/dist/index.html +1589 -0
- package/overlay-release.json +2 -2
- package/package.json +28 -4
- package/scripts/install-adapters.sh +25 -14
- package/scripts/install-adapters.test.sh +12 -2
- package/scripts/materialize-agents-plugin.sh +8 -6
- 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,388 @@
|
|
|
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 {
|
|
6
|
+
GameplayRecorder,
|
|
7
|
+
recoverActiveRecordings,
|
|
8
|
+
recoverActiveRecordingsBatched,
|
|
9
|
+
} from "./recorder.js";
|
|
10
|
+
import { RecordingStore } from "./store.js";
|
|
11
|
+
import { FakeTransport } from "./transport.js";
|
|
12
|
+
import { isGameEvent, type IngestAcknowledgment, type RecordedEventChunk } from "./types.js";
|
|
13
|
+
|
|
14
|
+
const roots: string[] = [];
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
vi.useRealTimers();
|
|
17
|
+
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
function setup(overrides: Partial<ConstructorParameters<typeof GameplayRecorder>[0]> = {}) {
|
|
21
|
+
const root = mkdtempSync(join(tmpdir(), "gamepigeon-recorder-"));
|
|
22
|
+
roots.push(root);
|
|
23
|
+
const store = new RecordingStore(root);
|
|
24
|
+
let wall = 1_000;
|
|
25
|
+
let monotonic = 500;
|
|
26
|
+
let id = 0;
|
|
27
|
+
const recorder = new GameplayRecorder({
|
|
28
|
+
store,
|
|
29
|
+
gameType: "2048",
|
|
30
|
+
gameVersion: "1",
|
|
31
|
+
seed: 7,
|
|
32
|
+
initialState: { board: [[2]], score: 0 },
|
|
33
|
+
applicationVersion: "test",
|
|
34
|
+
wallTimeMs: () => wall++,
|
|
35
|
+
monotonicTimeMs: () => monotonic++,
|
|
36
|
+
createGameId: () => "game_test",
|
|
37
|
+
createEventId: () => `evt_${++id}`,
|
|
38
|
+
createChunkId: () => `chk_${++id}`,
|
|
39
|
+
...overrides,
|
|
40
|
+
});
|
|
41
|
+
return { recorder, store, root };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe("GameplayRecorder", () => {
|
|
45
|
+
it("records a complete lifecycle with immutable snapshots and explicit sources", () => {
|
|
46
|
+
const { recorder, store } = setup();
|
|
47
|
+
const before = { board: [[2]], score: 0 };
|
|
48
|
+
const after = { board: [[2]], score: 0 };
|
|
49
|
+
recorder.recordAction({
|
|
50
|
+
action: "left",
|
|
51
|
+
source: "player",
|
|
52
|
+
stateBefore: before,
|
|
53
|
+
result: { reward: 0, moved: false, done: false, success: false },
|
|
54
|
+
stateAfter: after,
|
|
55
|
+
});
|
|
56
|
+
before.board[0]![0] = 4096;
|
|
57
|
+
after.score = 99;
|
|
58
|
+
recorder.recordAction({
|
|
59
|
+
action: "tick",
|
|
60
|
+
source: "environment",
|
|
61
|
+
stateBefore: { y: 0 },
|
|
62
|
+
result: {
|
|
63
|
+
reward: 0,
|
|
64
|
+
moved: true,
|
|
65
|
+
done: false,
|
|
66
|
+
success: false,
|
|
67
|
+
randomEffects: { queuedPiece: "T" },
|
|
68
|
+
},
|
|
69
|
+
stateAfter: { y: 1 },
|
|
70
|
+
});
|
|
71
|
+
recorder.endGame({
|
|
72
|
+
outcome: "abandoned",
|
|
73
|
+
finalState: { board: [[2]] },
|
|
74
|
+
finalScore: 0,
|
|
75
|
+
success: false,
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const events = store.listCompletedGames()[0]!.events.filter(isGameEvent);
|
|
79
|
+
expect(events.map((event) => [event.event_sequence, event.event_type, event.source])).toEqual([
|
|
80
|
+
[1, "game_started", "system"],
|
|
81
|
+
[2, "action", "player"],
|
|
82
|
+
[3, "action", "environment"],
|
|
83
|
+
[4, "game_ended", "system"],
|
|
84
|
+
]);
|
|
85
|
+
expect(events[0]!.monotonic_time_ms).toBe(0);
|
|
86
|
+
const noop = events[1]!;
|
|
87
|
+
expect(noop.event_type).toBe("action");
|
|
88
|
+
if (noop.event_type === "action") {
|
|
89
|
+
expect(noop.payload.result.moved).toBe(false);
|
|
90
|
+
expect(noop.payload.state_before).toEqual({ board: [[2]], score: 0 });
|
|
91
|
+
expect(noop.payload.state_after).toEqual({ board: [[2]], score: 0 });
|
|
92
|
+
}
|
|
93
|
+
const tick = events[2]!;
|
|
94
|
+
if (tick.event_type === "action") {
|
|
95
|
+
expect(tick.payload.random_effects).toEqual({ queuedPiece: "T" });
|
|
96
|
+
expect(tick.payload.result).not.toHaveProperty("randomEffects");
|
|
97
|
+
}
|
|
98
|
+
const ended = events[3]!;
|
|
99
|
+
if (ended.event_type === "game_ended") {
|
|
100
|
+
expect(ended.payload.final_event_sequence).toBe(4);
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("flushes immutable chunks at the configured event-count boundary", () => {
|
|
105
|
+
const { recorder, store } = setup({ config: { maxEventsPerChunk: 2 } });
|
|
106
|
+
recorder.recordAction({
|
|
107
|
+
action: "left",
|
|
108
|
+
source: "player",
|
|
109
|
+
stateBefore: {},
|
|
110
|
+
result: { reward: 0, moved: false, done: false, success: false },
|
|
111
|
+
stateAfter: {},
|
|
112
|
+
});
|
|
113
|
+
const chunks = store.listPending();
|
|
114
|
+
expect(chunks).toHaveLength(1);
|
|
115
|
+
expect(chunks[0]!.chunk.events.map((event) => event.event_sequence)).toEqual([1, 2]);
|
|
116
|
+
expect(chunks[0]!.chunk.chunk_sequence).toBe(1);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("flushes when the estimated byte limit is reached", () => {
|
|
120
|
+
const { recorder, store } = setup({ config: { maxEstimatedChunkBytes: 1 } });
|
|
121
|
+
expect(store.listPending()).toHaveLength(1);
|
|
122
|
+
expect(store.listPending()[0]!.chunk.events).toHaveLength(1);
|
|
123
|
+
recorder.shutdown();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("flushes after the configured interval", async () => {
|
|
127
|
+
vi.useFakeTimers();
|
|
128
|
+
const { recorder, store } = setup({ config: { flushIntervalMs: 25 } });
|
|
129
|
+
expect(store.listPending()).toHaveLength(0);
|
|
130
|
+
vi.advanceTimersByTime(25);
|
|
131
|
+
await Promise.resolve();
|
|
132
|
+
expect(store.listPending()).toHaveLength(1);
|
|
133
|
+
recorder.shutdown();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("keeps gameplay alive and exposes degraded health after persistence failure", () => {
|
|
137
|
+
const root = mkdtempSync(join(tmpdir(), "gamepigeon-recorder-failure-"));
|
|
138
|
+
roots.push(root);
|
|
139
|
+
class FailingStore extends RecordingStore {
|
|
140
|
+
override appendEvent(): void {
|
|
141
|
+
throw new Error("disk full");
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
const recorder = new GameplayRecorder({
|
|
145
|
+
store: new FailingStore(root),
|
|
146
|
+
gameType: "2048",
|
|
147
|
+
gameVersion: "1",
|
|
148
|
+
seed: 1,
|
|
149
|
+
initialState: {},
|
|
150
|
+
});
|
|
151
|
+
expect(() => recorder.recordAction({
|
|
152
|
+
action: "left",
|
|
153
|
+
source: "player",
|
|
154
|
+
stateBefore: {},
|
|
155
|
+
result: { reward: 0, moved: false, done: false, success: false },
|
|
156
|
+
stateAfter: {},
|
|
157
|
+
})).not.toThrow();
|
|
158
|
+
expect(recorder.health()).toEqual({ status: "degraded", last_error: "disk full" });
|
|
159
|
+
recorder.shutdown();
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("does not interrupt gameplay when a state cannot be cloned", () => {
|
|
163
|
+
const { recorder } = setup();
|
|
164
|
+
expect(() => recorder.recordAction({
|
|
165
|
+
action: "left",
|
|
166
|
+
source: "player",
|
|
167
|
+
stateBefore: { invalid: () => undefined },
|
|
168
|
+
result: { reward: 0, moved: false, done: false, success: false },
|
|
169
|
+
stateAfter: {},
|
|
170
|
+
})).not.toThrow();
|
|
171
|
+
expect(recorder.health().status).toBe("degraded");
|
|
172
|
+
expect(recorder.health().last_error).toContain("cloned");
|
|
173
|
+
expect(() => recorder.endGame({
|
|
174
|
+
outcome: "abandoned",
|
|
175
|
+
finalState: { invalid: () => undefined },
|
|
176
|
+
finalScore: 0,
|
|
177
|
+
success: false,
|
|
178
|
+
})).not.toThrow();
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("turns chunk construction failures into degraded health", () => {
|
|
182
|
+
const { recorder } = setup({
|
|
183
|
+
createChunkId: () => { throw new Error("id source unavailable"); },
|
|
184
|
+
});
|
|
185
|
+
expect(() => recorder.flush()).not.toThrow();
|
|
186
|
+
expect(recorder.health()).toEqual({
|
|
187
|
+
status: "degraded", last_error: "id source unavailable",
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("uploads a chunk created while the previous upload is in flight", async () => {
|
|
192
|
+
let releaseFirst: (() => void) | undefined;
|
|
193
|
+
const acknowledgment = (chunk: RecordedEventChunk): IngestAcknowledgment => ({
|
|
194
|
+
accepted: true,
|
|
195
|
+
duplicate: false,
|
|
196
|
+
game_id: chunk.game_id,
|
|
197
|
+
chunk_id: chunk.chunk_id,
|
|
198
|
+
chunk_sequence: chunk.chunk_sequence,
|
|
199
|
+
accepted_event_range: {
|
|
200
|
+
first: chunk.first_event_sequence,
|
|
201
|
+
last: chunk.last_event_sequence,
|
|
202
|
+
},
|
|
203
|
+
queued: true,
|
|
204
|
+
server_received_at_ms: 2_000,
|
|
205
|
+
});
|
|
206
|
+
const transport = new FakeTransport(async (persisted) => {
|
|
207
|
+
if (transport.requests.length === 1) {
|
|
208
|
+
await new Promise<void>((resolve) => {
|
|
209
|
+
releaseFirst = resolve;
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
return acknowledgment(persisted.chunk);
|
|
213
|
+
});
|
|
214
|
+
const { recorder } = setup({ config: { maxEventsPerChunk: 1 }, transport });
|
|
215
|
+
recorder.recordAction({
|
|
216
|
+
action: "left",
|
|
217
|
+
source: "player",
|
|
218
|
+
stateBefore: {},
|
|
219
|
+
result: { reward: 0, moved: false, done: false, success: false },
|
|
220
|
+
stateAfter: {},
|
|
221
|
+
});
|
|
222
|
+
expect(transport.requests).toHaveLength(1);
|
|
223
|
+
releaseFirst!();
|
|
224
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
225
|
+
expect(transport.requests).toHaveLength(2);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("drains the final upload before a normal process exit", async () => {
|
|
229
|
+
let releaseFirst: (() => void) | undefined;
|
|
230
|
+
const transport = new FakeTransport(async (persisted) => {
|
|
231
|
+
if (transport.requests.length === 1) {
|
|
232
|
+
await new Promise<void>((resolve) => {
|
|
233
|
+
releaseFirst = resolve;
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
return {
|
|
237
|
+
accepted: true,
|
|
238
|
+
duplicate: false,
|
|
239
|
+
game_id: persisted.chunk.game_id,
|
|
240
|
+
chunk_id: persisted.chunk.chunk_id,
|
|
241
|
+
chunk_sequence: persisted.chunk.chunk_sequence,
|
|
242
|
+
accepted_event_range: {
|
|
243
|
+
first: persisted.chunk.first_event_sequence,
|
|
244
|
+
last: persisted.chunk.last_event_sequence,
|
|
245
|
+
},
|
|
246
|
+
queued: true,
|
|
247
|
+
server_received_at_ms: 2_000,
|
|
248
|
+
};
|
|
249
|
+
});
|
|
250
|
+
const { recorder, store } = setup({
|
|
251
|
+
config: { maxEventsPerChunk: 1 },
|
|
252
|
+
transport,
|
|
253
|
+
});
|
|
254
|
+
recorder.endGame({
|
|
255
|
+
outcome: "abandoned",
|
|
256
|
+
finalState: {},
|
|
257
|
+
finalScore: 0,
|
|
258
|
+
success: false,
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
let drained = false;
|
|
262
|
+
const draining = recorder.drainUploads().then(() => {
|
|
263
|
+
drained = true;
|
|
264
|
+
});
|
|
265
|
+
await Promise.resolve();
|
|
266
|
+
expect(drained).toBe(false);
|
|
267
|
+
expect(store.listPending()).toHaveLength(2);
|
|
268
|
+
|
|
269
|
+
releaseFirst!();
|
|
270
|
+
await draining;
|
|
271
|
+
expect(transport.requests).toHaveLength(2);
|
|
272
|
+
expect(store.listPending()).toHaveLength(0);
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
describe("recording recovery", () => {
|
|
277
|
+
it("indexes chunks once and reads each active game once", async () => {
|
|
278
|
+
const root = mkdtempSync(join(tmpdir(), "gamepigeon-linear-recovery-"));
|
|
279
|
+
roots.push(root);
|
|
280
|
+
class CountingStore extends RecordingStore {
|
|
281
|
+
indexCalls = 0;
|
|
282
|
+
activeReads = 0;
|
|
283
|
+
|
|
284
|
+
override async buildRecoveryChunkIndex(
|
|
285
|
+
options: Parameters<RecordingStore["buildRecoveryChunkIndex"]>[0],
|
|
286
|
+
) {
|
|
287
|
+
this.indexCalls++;
|
|
288
|
+
return super.buildRecoveryChunkIndex(options);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
override readActiveEvents(gameId: string) {
|
|
292
|
+
this.activeReads++;
|
|
293
|
+
return super.readActiveEvents(gameId);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
const store = new CountingStore(root);
|
|
297
|
+
for (let index = 0; index < 20; index++) {
|
|
298
|
+
store.appendEvent(`game_${index}`, {
|
|
299
|
+
event_id: `evt_${index}`,
|
|
300
|
+
event_sequence: 1,
|
|
301
|
+
event_type: "game_started",
|
|
302
|
+
event_version: 1,
|
|
303
|
+
source: "system",
|
|
304
|
+
client_time_ms: 1,
|
|
305
|
+
monotonic_time_ms: 1,
|
|
306
|
+
payload: {
|
|
307
|
+
game_type: "2048",
|
|
308
|
+
game_version: "1",
|
|
309
|
+
seed: index,
|
|
310
|
+
initial_state: {},
|
|
311
|
+
},
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
await recoverActiveRecordingsBatched(store, {
|
|
316
|
+
batchSize: 4,
|
|
317
|
+
createChunkId: (() => {
|
|
318
|
+
let id = 0;
|
|
319
|
+
return () => `chk_linear_${++id}`;
|
|
320
|
+
})(),
|
|
321
|
+
});
|
|
322
|
+
expect(store.indexCalls).toBe(1);
|
|
323
|
+
expect(store.activeReads).toBe(20);
|
|
324
|
+
expect(store.listPending()).toHaveLength(20);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it("chunks only events that were not already assigned before restart", () => {
|
|
328
|
+
const { recorder, store } = setup({ config: { maxEventsPerChunk: 2 } });
|
|
329
|
+
recorder.recordAction({
|
|
330
|
+
action: "left",
|
|
331
|
+
source: "player",
|
|
332
|
+
stateBefore: {},
|
|
333
|
+
result: { reward: 0, moved: false, done: false, success: false },
|
|
334
|
+
stateAfter: {},
|
|
335
|
+
});
|
|
336
|
+
recorder.recordAction({
|
|
337
|
+
action: "right",
|
|
338
|
+
source: "player",
|
|
339
|
+
stateBefore: {},
|
|
340
|
+
result: { reward: 0, moved: true, done: false, success: false },
|
|
341
|
+
stateAfter: {},
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
const recovered = recoverActiveRecordings(store, {
|
|
345
|
+
applicationVersion: "test",
|
|
346
|
+
createChunkId: () => "chk_recovered",
|
|
347
|
+
wallTimeMs: () => 2_000,
|
|
348
|
+
});
|
|
349
|
+
expect(recovered).toEqual({ games: 1, chunks: 1, events: 1 });
|
|
350
|
+
expect(store.listPending().map((item) =>
|
|
351
|
+
item.chunk.events.map((event) => event.event_sequence)
|
|
352
|
+
)).toEqual([[1, 2], [3]]);
|
|
353
|
+
recorder.shutdown();
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it("finalizes an ended log whose events were all chunked before a crash", () => {
|
|
357
|
+
const root = mkdtempSync(join(tmpdir(), "gamepigeon-finalize-recovery-"));
|
|
358
|
+
roots.push(root);
|
|
359
|
+
class InterruptedStore extends RecordingStore {
|
|
360
|
+
override finalizeGame(): string | null {
|
|
361
|
+
throw new Error("simulated crash");
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
const interrupted = new InterruptedStore(root);
|
|
365
|
+
const recorder = new GameplayRecorder({
|
|
366
|
+
store: interrupted,
|
|
367
|
+
gameType: "2048",
|
|
368
|
+
gameVersion: "1",
|
|
369
|
+
seed: 1,
|
|
370
|
+
initialState: {},
|
|
371
|
+
createGameId: () => "game_interrupted",
|
|
372
|
+
});
|
|
373
|
+
recorder.endGame({
|
|
374
|
+
outcome: "abandoned",
|
|
375
|
+
finalState: {},
|
|
376
|
+
finalScore: 0,
|
|
377
|
+
success: false,
|
|
378
|
+
});
|
|
379
|
+
expect(interrupted.listActiveGameIds()).toEqual(["game_interrupted"]);
|
|
380
|
+
|
|
381
|
+
const recoveredStore = new RecordingStore(root);
|
|
382
|
+
expect(recoverActiveRecordings(recoveredStore)).toEqual({
|
|
383
|
+
games: 0, chunks: 0, events: 0,
|
|
384
|
+
});
|
|
385
|
+
expect(recoveredStore.listActiveGameIds()).toEqual([]);
|
|
386
|
+
expect(recoveredStore.listCompletedGames()[0]!.gameId).toBe("game_interrupted");
|
|
387
|
+
});
|
|
388
|
+
});
|