@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,287 @@
|
|
|
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 { RecordingStore } from "./store.js";
|
|
6
|
+
import { parseTerminalGrid } from "./terminal-grid.js";
|
|
7
|
+
import { InteractionTraceRecorder, recoverActiveTraces } from "./trace-recorder.js";
|
|
8
|
+
import { isTraceEvent, isTraceEventChunk } from "./types.js";
|
|
9
|
+
|
|
10
|
+
const roots: string[] = [];
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
function sequenceFactory(prefix: string): () => string {
|
|
16
|
+
let next = 0;
|
|
17
|
+
return () => `${prefix}_${++next}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function setup(maxEventsPerChunk = 50) {
|
|
21
|
+
const root = mkdtempSync(join(tmpdir(), "gamepigeon-trace-"));
|
|
22
|
+
roots.push(root);
|
|
23
|
+
const store = new RecordingStore(root);
|
|
24
|
+
let wall = 1_000;
|
|
25
|
+
let monotonic = 10;
|
|
26
|
+
const recorder = new InteractionTraceRecorder({
|
|
27
|
+
store,
|
|
28
|
+
userId: "usr_test",
|
|
29
|
+
gameType: "tetris",
|
|
30
|
+
gameVersion: "1",
|
|
31
|
+
rulesetId: "classic",
|
|
32
|
+
seed: 7,
|
|
33
|
+
initialState: { board: [], active: "T" },
|
|
34
|
+
controlSchema: ["left", "right", "rotate-cw", "tick"],
|
|
35
|
+
viewport: { width: 8, height: 3 },
|
|
36
|
+
applicationVersion: "test",
|
|
37
|
+
config: { maxEventsPerChunk },
|
|
38
|
+
wallTimeMs: () => wall++,
|
|
39
|
+
monotonicTimeMs: () => monotonic++,
|
|
40
|
+
createGameId: () => "game_test",
|
|
41
|
+
createEventId: sequenceFactory("evt"),
|
|
42
|
+
createChunkId: sequenceFactory("chk"),
|
|
43
|
+
createObservationId: sequenceFactory("obs"),
|
|
44
|
+
});
|
|
45
|
+
return { recorder, store };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe("InteractionTraceRecorder", () => {
|
|
49
|
+
it("never scans the global upload backlog from an individual recorder", () => {
|
|
50
|
+
const root = mkdtempSync(join(tmpdir(), "gamepigeon-trace-no-scan-"));
|
|
51
|
+
roots.push(root);
|
|
52
|
+
let scans = 0;
|
|
53
|
+
class CountingStore extends RecordingStore {
|
|
54
|
+
override listPending() {
|
|
55
|
+
scans++;
|
|
56
|
+
return super.listPending();
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const recorder = new InteractionTraceRecorder({
|
|
60
|
+
store: new CountingStore(root),
|
|
61
|
+
userId: "usr_test",
|
|
62
|
+
gameType: "2048",
|
|
63
|
+
gameVersion: "1",
|
|
64
|
+
rulesetId: "classic",
|
|
65
|
+
seed: 1,
|
|
66
|
+
initialState: {},
|
|
67
|
+
controlSchema: ["left"],
|
|
68
|
+
viewport: { width: 80, height: 24 },
|
|
69
|
+
config: { maxEventsPerChunk: 1 },
|
|
70
|
+
});
|
|
71
|
+
recorder.endEpisode({ reason: "user_quit", finalScore: 0, completed: false });
|
|
72
|
+
expect(scans).toBe(0);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("records a game-agnostic causal trace with four universal identity fields", () => {
|
|
76
|
+
const { recorder, store } = setup();
|
|
77
|
+
const initial = recorder.recordObservation({
|
|
78
|
+
grid: parseTerminalGrid(["TETRIS", " []"], { width: 8, height: 3 }),
|
|
79
|
+
simulationTick: 0,
|
|
80
|
+
});
|
|
81
|
+
const control = recorder.recordControl({
|
|
82
|
+
control: "ArrowLeft",
|
|
83
|
+
action: "left",
|
|
84
|
+
actor: "human",
|
|
85
|
+
simulationTick: 1,
|
|
86
|
+
receivedTimeUs: 12_000,
|
|
87
|
+
});
|
|
88
|
+
recorder.recordObservation({
|
|
89
|
+
grid: parseTerminalGrid(["TETRIS", " [] "], { width: 8, height: 3 }),
|
|
90
|
+
simulationTick: 1,
|
|
91
|
+
causedByEventId: control,
|
|
92
|
+
});
|
|
93
|
+
recorder.recordOutcome({
|
|
94
|
+
scoreBefore: 0,
|
|
95
|
+
scoreAfter: 4,
|
|
96
|
+
engineReward: 4,
|
|
97
|
+
terminal: false,
|
|
98
|
+
causedByEventId: control,
|
|
99
|
+
simulationTick: 1,
|
|
100
|
+
performance: {
|
|
101
|
+
action_received_us: 12_000,
|
|
102
|
+
action_applied_us: 13_000,
|
|
103
|
+
action_completed_us: 15_000,
|
|
104
|
+
queueing_delay_us: 1_000,
|
|
105
|
+
simulation_tick_duration_us: 2_000,
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
recorder.recordCheckpoint({
|
|
109
|
+
state: { board: [["T"]] },
|
|
110
|
+
reason: "random_outcome",
|
|
111
|
+
randomEffects: { queuedPiece: "I" },
|
|
112
|
+
causedByEventId: control,
|
|
113
|
+
simulationTick: 1,
|
|
114
|
+
});
|
|
115
|
+
recorder.endEpisode({ reason: "user_quit", finalScore: 4, completed: false });
|
|
116
|
+
|
|
117
|
+
const completed = store.listCompletedGames()[0]!;
|
|
118
|
+
const events = completed.events.filter(isTraceEvent);
|
|
119
|
+
expect(events.map((event) => event.event_type)).toEqual([
|
|
120
|
+
"episode_started",
|
|
121
|
+
"engine_checkpoint",
|
|
122
|
+
"observation",
|
|
123
|
+
"control_changed",
|
|
124
|
+
"observation",
|
|
125
|
+
"outcome_changed",
|
|
126
|
+
"engine_checkpoint",
|
|
127
|
+
"episode_ended",
|
|
128
|
+
]);
|
|
129
|
+
for (const event of events) {
|
|
130
|
+
expect(event.schema_version).toBe(2);
|
|
131
|
+
expect(event.user_id).toBe("usr_test");
|
|
132
|
+
expect(event.game_id).toBe("game_test");
|
|
133
|
+
expect(event.chunk_id).toMatch(/^chk_/);
|
|
134
|
+
expect(event.event_id).toMatch(/^evt_/);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const firstObservation = events[2]!;
|
|
138
|
+
expect(firstObservation.event_type).toBe("observation");
|
|
139
|
+
if (firstObservation.event_type !== "observation") throw new Error("expected observation");
|
|
140
|
+
expect(firstObservation.payload.observation_id).toBe(initial);
|
|
141
|
+
|
|
142
|
+
const controlEvent = events[3]!;
|
|
143
|
+
expect(controlEvent.event_type).toBe("control_changed");
|
|
144
|
+
if (controlEvent.event_type !== "control_changed") throw new Error("expected control");
|
|
145
|
+
expect(controlEvent.payload).toEqual({
|
|
146
|
+
based_on_observation_id: initial,
|
|
147
|
+
control: "ArrowLeft",
|
|
148
|
+
action: "left",
|
|
149
|
+
kind: "impulse",
|
|
150
|
+
actor: "human",
|
|
151
|
+
action_received_us: 12_000,
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
const resultingObservation = events[4]!;
|
|
155
|
+
if (resultingObservation.event_type !== "observation") throw new Error("expected observation");
|
|
156
|
+
expect(resultingObservation.payload).toMatchObject({
|
|
157
|
+
representation: "terminal_grid_delta_v1",
|
|
158
|
+
base_observation_id: initial,
|
|
159
|
+
caused_by_event_id: control,
|
|
160
|
+
});
|
|
161
|
+
const checkpoint = events[6]!;
|
|
162
|
+
expect(checkpoint.visibility).toBe("privileged");
|
|
163
|
+
expect(checkpoint.payload).toMatchObject({
|
|
164
|
+
reason: "random_outcome",
|
|
165
|
+
random_effects: { queuedPiece: "I" },
|
|
166
|
+
state_hash: expect.stringMatching(/^sha256:/),
|
|
167
|
+
});
|
|
168
|
+
const outcome = events[5]!;
|
|
169
|
+
if (outcome.event_type !== "outcome_changed") throw new Error("expected outcome");
|
|
170
|
+
expect(outcome.payload.performance).toEqual({
|
|
171
|
+
action_received_us: 12_000,
|
|
172
|
+
action_applied_us: 13_000,
|
|
173
|
+
action_completed_us: 15_000,
|
|
174
|
+
queueing_delay_us: 1_000,
|
|
175
|
+
simulation_tick_duration_us: 2_000,
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
const chunks = store.listPending();
|
|
179
|
+
expect(chunks).toHaveLength(1);
|
|
180
|
+
const chunk = chunks[0]!.chunk;
|
|
181
|
+
if (!isTraceEventChunk(chunk)) throw new Error("expected trace chunk");
|
|
182
|
+
expect(chunk).toMatchObject({
|
|
183
|
+
schema_version: 2,
|
|
184
|
+
user_id: "usr_test",
|
|
185
|
+
game_id: "game_test",
|
|
186
|
+
chunk_id: events[0]!.chunk_id,
|
|
187
|
+
});
|
|
188
|
+
expect(chunk.events.every((event) =>
|
|
189
|
+
event.chunk_id === chunk.chunk_id
|
|
190
|
+
)).toBe(true);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("assigns the next chunk id before appending events after a flush boundary", () => {
|
|
194
|
+
const { recorder, store } = setup(2);
|
|
195
|
+
recorder.recordObservation({
|
|
196
|
+
grid: parseTerminalGrid(["one"], { width: 8, height: 3 }),
|
|
197
|
+
simulationTick: 0,
|
|
198
|
+
});
|
|
199
|
+
recorder.recordControl({
|
|
200
|
+
control: "ArrowRight",
|
|
201
|
+
action: "right",
|
|
202
|
+
actor: "human",
|
|
203
|
+
simulationTick: 1,
|
|
204
|
+
});
|
|
205
|
+
recorder.endEpisode({ reason: "user_quit", finalScore: 0, completed: false });
|
|
206
|
+
|
|
207
|
+
const chunks = store.listPending();
|
|
208
|
+
expect(chunks.length).toBeGreaterThan(1);
|
|
209
|
+
expect(new Set(chunks.map((chunk) => chunk.chunk.chunk_id)).size).toBe(chunks.length);
|
|
210
|
+
for (const persisted of chunks) {
|
|
211
|
+
if (!isTraceEventChunk(persisted.chunk)) throw new Error("expected trace chunk");
|
|
212
|
+
expect(persisted.chunk.events.every((event) =>
|
|
213
|
+
event.chunk_id === persisted.chunk.chunk_id
|
|
214
|
+
)).toBe(true);
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("keeps gameplay alive when a privileged checkpoint cannot be serialized", () => {
|
|
219
|
+
const { recorder } = setup();
|
|
220
|
+
expect(() => recorder.recordCheckpoint({
|
|
221
|
+
state: { unsupported: () => undefined },
|
|
222
|
+
reason: "periodic",
|
|
223
|
+
})).not.toThrow();
|
|
224
|
+
expect(recorder.health().status).toBe("degraded");
|
|
225
|
+
expect(recorder.health().last_error).toContain("cloned");
|
|
226
|
+
recorder.endEpisode({ reason: "user_quit", finalScore: 0, completed: false });
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("recovers missing chunks with the ids assigned before a crash", () => {
|
|
230
|
+
const root = mkdtempSync(join(tmpdir(), "gamepigeon-trace-recovery-"));
|
|
231
|
+
roots.push(root);
|
|
232
|
+
class InterruptedStore extends RecordingStore {
|
|
233
|
+
private writes = 0;
|
|
234
|
+
|
|
235
|
+
override persistChunk(chunk: Parameters<RecordingStore["persistChunk"]>[0]) {
|
|
236
|
+
this.writes++;
|
|
237
|
+
if (this.writes > 1) throw new Error("simulated crash");
|
|
238
|
+
return super.persistChunk(chunk);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const interrupted = new InterruptedStore(root);
|
|
243
|
+
const recorder = new InteractionTraceRecorder({
|
|
244
|
+
store: interrupted,
|
|
245
|
+
userId: "usr_test",
|
|
246
|
+
gameType: "tetris",
|
|
247
|
+
gameVersion: "1",
|
|
248
|
+
rulesetId: "classic",
|
|
249
|
+
seed: 7,
|
|
250
|
+
initialState: { board: [] },
|
|
251
|
+
controlSchema: ["left"],
|
|
252
|
+
viewport: { width: 8, height: 3 },
|
|
253
|
+
config: { maxEventsPerChunk: 2 },
|
|
254
|
+
createGameId: () => "game_recovery",
|
|
255
|
+
createEventId: sequenceFactory("evt"),
|
|
256
|
+
createChunkId: sequenceFactory("chk"),
|
|
257
|
+
createObservationId: sequenceFactory("obs"),
|
|
258
|
+
});
|
|
259
|
+
recorder.recordObservation({
|
|
260
|
+
grid: parseTerminalGrid(["TETRIS"], { width: 8, height: 3 }),
|
|
261
|
+
});
|
|
262
|
+
const control = recorder.recordControl({
|
|
263
|
+
control: "left",
|
|
264
|
+
action: "left",
|
|
265
|
+
actor: "human",
|
|
266
|
+
});
|
|
267
|
+
recorder.recordObservation({
|
|
268
|
+
grid: parseTerminalGrid([" TETRIS"], { width: 8, height: 3 }),
|
|
269
|
+
causedByEventId: control,
|
|
270
|
+
});
|
|
271
|
+
recorder.endEpisode({ reason: "user_quit", finalScore: 0, completed: false });
|
|
272
|
+
|
|
273
|
+
const store = new RecordingStore(root);
|
|
274
|
+
expect(recoverActiveTraces(store, { applicationVersion: "test" })).toEqual({
|
|
275
|
+
games: 1,
|
|
276
|
+
chunks: 1,
|
|
277
|
+
events: 4,
|
|
278
|
+
});
|
|
279
|
+
const chunks = store.listPending();
|
|
280
|
+
expect(chunks.map((item) => item.chunk.chunk_id)).toEqual(["chk_1", "chk_2"]);
|
|
281
|
+
expect(chunks.map((item) => item.chunk.chunk_sequence)).toEqual([1, 2]);
|
|
282
|
+
const recovered = chunks[1]!.chunk;
|
|
283
|
+
if (!isTraceEventChunk(recovered)) throw new Error("expected trace chunk");
|
|
284
|
+
expect(recovered.events.every((event) => event.chunk_id === "chk_2")).toBe(true);
|
|
285
|
+
expect(store.listActiveGameIds()).toEqual([]);
|
|
286
|
+
});
|
|
287
|
+
});
|