@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,103 @@
|
|
|
1
|
+
import { RecordingStore } from "./store.js";
|
|
2
|
+
import { TransportError, type ChunkTransport } from "./transport.js";
|
|
3
|
+
import type { IngestAcknowledgment, PersistedChunk } from "./types.js";
|
|
4
|
+
|
|
5
|
+
export interface UploadResult {
|
|
6
|
+
acknowledged: number;
|
|
7
|
+
failed: number;
|
|
8
|
+
pending: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface UploaderOptions {
|
|
12
|
+
maxAttempts?: number;
|
|
13
|
+
random?: () => number;
|
|
14
|
+
delay?: (milliseconds: number, signal?: AbortSignal) => Promise<void>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const sleep = (milliseconds: number, signal?: AbortSignal) => new Promise<void>((resolve) => {
|
|
18
|
+
if (signal?.aborted) {
|
|
19
|
+
resolve();
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
const timer = setTimeout(finish, milliseconds);
|
|
23
|
+
timer.unref?.();
|
|
24
|
+
signal?.addEventListener("abort", finish, { once: true });
|
|
25
|
+
function finish() {
|
|
26
|
+
clearTimeout(timer);
|
|
27
|
+
signal?.removeEventListener("abort", finish);
|
|
28
|
+
resolve();
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
function matches(chunk: PersistedChunk, ack: IngestAcknowledgment): boolean {
|
|
33
|
+
return ack.accepted &&
|
|
34
|
+
ack.game_id === chunk.chunk.game_id &&
|
|
35
|
+
ack.chunk_id === chunk.chunk.chunk_id &&
|
|
36
|
+
ack.chunk_sequence === chunk.chunk.chunk_sequence &&
|
|
37
|
+
ack.accepted_event_range.first === chunk.chunk.first_event_sequence &&
|
|
38
|
+
ack.accepted_event_range.last === chunk.chunk.last_event_sequence;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export class ChunkUploader {
|
|
42
|
+
private readonly maxAttempts: number;
|
|
43
|
+
private readonly delay: (milliseconds: number, signal?: AbortSignal) => Promise<void>;
|
|
44
|
+
private readonly random: () => number;
|
|
45
|
+
|
|
46
|
+
constructor(
|
|
47
|
+
private readonly store: RecordingStore,
|
|
48
|
+
private readonly transport: ChunkTransport,
|
|
49
|
+
options: UploaderOptions = {},
|
|
50
|
+
) {
|
|
51
|
+
this.maxAttempts = options.maxAttempts ?? 5;
|
|
52
|
+
this.delay = options.delay ?? sleep;
|
|
53
|
+
this.random = options.random ?? Math.random;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async uploadPending(userId?: string, signal?: AbortSignal): Promise<UploadResult> {
|
|
57
|
+
if (!this.transport.enabled) return { acknowledged: 0, failed: 0, pending: 0 };
|
|
58
|
+
if (signal?.aborted) return { acknowledged: 0, failed: 0, pending: 0 };
|
|
59
|
+
const allPending = this.store.listPending();
|
|
60
|
+
const initial = userId === undefined
|
|
61
|
+
? allPending
|
|
62
|
+
: allPending.filter((item) =>
|
|
63
|
+
item.chunk.schema_version === 2 && item.chunk.user_id === userId
|
|
64
|
+
);
|
|
65
|
+
let acknowledged = 0;
|
|
66
|
+
let failed = 0;
|
|
67
|
+
for (const chunk of initial) {
|
|
68
|
+
if (signal?.aborted) break;
|
|
69
|
+
const outcome = await this.uploadOne(chunk, signal);
|
|
70
|
+
if (outcome === "acknowledged") acknowledged++;
|
|
71
|
+
else if (outcome === "failed") failed++;
|
|
72
|
+
}
|
|
73
|
+
return { acknowledged, failed, pending: this.store.listPending().length };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
private async uploadOne(
|
|
77
|
+
chunk: PersistedChunk,
|
|
78
|
+
signal?: AbortSignal,
|
|
79
|
+
): Promise<"acknowledged" | "failed" | "pending"> {
|
|
80
|
+
for (let attempt = 1; attempt <= this.maxAttempts; attempt++) {
|
|
81
|
+
if (signal?.aborted) return "pending";
|
|
82
|
+
try {
|
|
83
|
+
const acknowledgment = await this.transport.send(chunk, signal);
|
|
84
|
+
if (!matches(chunk, acknowledgment)) return "pending";
|
|
85
|
+
this.store.acknowledge(chunk);
|
|
86
|
+
return "acknowledged";
|
|
87
|
+
} catch (error) {
|
|
88
|
+
if (signal?.aborted) return "pending";
|
|
89
|
+
const retryable = error instanceof TransportError && error.retryable;
|
|
90
|
+
if (!retryable && error instanceof TransportError && error.quarantine) {
|
|
91
|
+
this.store.markFailed(chunk, error instanceof Error ? error.message : String(error));
|
|
92
|
+
return "failed";
|
|
93
|
+
}
|
|
94
|
+
if (!retryable) return "pending";
|
|
95
|
+
if (attempt === this.maxAttempts) return "pending";
|
|
96
|
+
const exponential = 1000 * 2 ** (attempt - 1);
|
|
97
|
+
const jittered = Math.round(exponential * (0.5 + this.random()));
|
|
98
|
+
await this.delay(Math.min(jittered, 60_000), signal);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return "pending";
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import type { RecordingStore } from "../recording/store.js";
|
|
3
|
+
import { isTraceEvent } from "../recording/types.js";
|
|
4
|
+
import type { InteractionTraceRecorder } from "../recording/trace-recorder.js";
|
|
5
|
+
import { ResultStore } from "./store.js";
|
|
6
|
+
import type { GameResultRequest, PersistedGameResult } from "./types.js";
|
|
7
|
+
|
|
8
|
+
export function resultIdForGame(gameId: string): string {
|
|
9
|
+
return `res_${createHash("sha256").update(gameId).digest("hex").slice(0, 32)}`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function enqueueCompletedResult(input: {
|
|
13
|
+
store: ResultStore;
|
|
14
|
+
recorder: InteractionTraceRecorder;
|
|
15
|
+
score: number;
|
|
16
|
+
completedAtMs: number;
|
|
17
|
+
}): PersistedGameResult {
|
|
18
|
+
return input.store.persist(input.recorder.userId, {
|
|
19
|
+
result_id: resultIdForGame(input.recorder.gameId),
|
|
20
|
+
game_id: input.recorder.gameId,
|
|
21
|
+
game_type: input.recorder.gameType,
|
|
22
|
+
score: input.score,
|
|
23
|
+
completed_at: new Date(input.completedAtMs).toISOString(),
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function recoverCompletedResults(
|
|
28
|
+
recordings: RecordingStore,
|
|
29
|
+
results: ResultStore,
|
|
30
|
+
): number {
|
|
31
|
+
const existingResultIds = new Set([
|
|
32
|
+
...results.listPending(),
|
|
33
|
+
...results.listCompleted(),
|
|
34
|
+
...results.listFailed(),
|
|
35
|
+
].map((item) => `${item.userId}\0${item.request.result_id}`));
|
|
36
|
+
let recovered = 0;
|
|
37
|
+
for (const game of recordings.listCompletedGames()) {
|
|
38
|
+
recovered += recoverCompletedGame(game, results, existingResultIds);
|
|
39
|
+
}
|
|
40
|
+
return recovered;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface BatchedRecoveryOptions {
|
|
44
|
+
batchSize?: number;
|
|
45
|
+
yieldControl?: () => Promise<void>;
|
|
46
|
+
signal?: AbortSignal;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function recoverCompletedResultsBatched(
|
|
50
|
+
recordings: RecordingStore,
|
|
51
|
+
results: ResultStore,
|
|
52
|
+
options: BatchedRecoveryOptions = {},
|
|
53
|
+
): Promise<number> {
|
|
54
|
+
const batchSize = Math.max(1, options.batchSize ?? 16);
|
|
55
|
+
const yieldControl = options.yieldControl ?? (() =>
|
|
56
|
+
new Promise<void>((resolve) => setTimeout(resolve, 0))
|
|
57
|
+
);
|
|
58
|
+
const existingResultIds = new Set(await results.listResultKeysBatched({
|
|
59
|
+
batchSize,
|
|
60
|
+
yieldControl,
|
|
61
|
+
signal: options.signal,
|
|
62
|
+
}));
|
|
63
|
+
if (options.signal?.aborted) return 0;
|
|
64
|
+
let recovered = 0;
|
|
65
|
+
for await (const game of recordings.iterateCompletedGamesBatched({
|
|
66
|
+
batchSize,
|
|
67
|
+
yieldControl,
|
|
68
|
+
signal: options.signal,
|
|
69
|
+
})) {
|
|
70
|
+
if (options.signal?.aborted) break;
|
|
71
|
+
recovered += recoverCompletedGame(game, results, existingResultIds);
|
|
72
|
+
}
|
|
73
|
+
return recovered;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function recoverCompletedGame(
|
|
77
|
+
game: ReturnType<RecordingStore["readCompletedGame"]>,
|
|
78
|
+
results: ResultStore,
|
|
79
|
+
existingResultIds: Set<string>,
|
|
80
|
+
): number {
|
|
81
|
+
const events = game.events.filter(isTraceEvent);
|
|
82
|
+
const started = events.find((event) => event.event_type === "episode_started");
|
|
83
|
+
const ended = [...events].reverse().find((event) => event.event_type === "episode_ended");
|
|
84
|
+
if (!started || !ended || ended.event_type !== "episode_ended" || !ended.payload.completed) {
|
|
85
|
+
return 0;
|
|
86
|
+
}
|
|
87
|
+
const request: GameResultRequest = {
|
|
88
|
+
result_id: resultIdForGame(game.gameId),
|
|
89
|
+
game_id: game.gameId,
|
|
90
|
+
game_type: started.payload.game_type,
|
|
91
|
+
score: ended.payload.final_score,
|
|
92
|
+
completed_at: new Date(ended.client_time_ms).toISOString(),
|
|
93
|
+
};
|
|
94
|
+
const resultKey = `${started.user_id}\0${request.result_id}`;
|
|
95
|
+
const existed = existingResultIds.has(resultKey);
|
|
96
|
+
results.persist(started.user_id, request);
|
|
97
|
+
if (existed) return 0;
|
|
98
|
+
existingResultIds.add(resultKey);
|
|
99
|
+
return 1;
|
|
100
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import type { PersistedGameResult } from "./types.js";
|
|
3
|
+
import { HttpResultTransport } from "./transport.js";
|
|
4
|
+
|
|
5
|
+
const persisted: PersistedGameResult = {
|
|
6
|
+
userId: "usr_test",
|
|
7
|
+
path: "/pending/result.json",
|
|
8
|
+
request: {
|
|
9
|
+
result_id: "res_test",
|
|
10
|
+
game_id: "game_test",
|
|
11
|
+
game_type: "2048",
|
|
12
|
+
score: 1024,
|
|
13
|
+
completed_at: "2026-07-17T12:00:00.000Z",
|
|
14
|
+
},
|
|
15
|
+
bytes: '{"result_id":"res_test","game_id":"game_test","game_type":"2048","score":1024,"completed_at":"2026-07-17T12:00:00.000Z"}',
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
describe("HttpResultTransport", () => {
|
|
19
|
+
it.each([200, 202])("accepts HTTP %s and posts the exact outbox bytes", async (status) => {
|
|
20
|
+
let body: BodyInit | null | undefined;
|
|
21
|
+
let headers: HeadersInit | undefined;
|
|
22
|
+
const transport = new HttpResultTransport(
|
|
23
|
+
"https://retro.example/v1/game-results",
|
|
24
|
+
"retro_user_token",
|
|
25
|
+
async (_input, init) => {
|
|
26
|
+
body = init?.body;
|
|
27
|
+
headers = init?.headers;
|
|
28
|
+
return Response.json({
|
|
29
|
+
accepted: true,
|
|
30
|
+
personal_best: 1024,
|
|
31
|
+
high_score_updated: true,
|
|
32
|
+
}, { status });
|
|
33
|
+
},
|
|
34
|
+
);
|
|
35
|
+
await expect(transport.send(persisted)).resolves.toMatchObject({ accepted: true });
|
|
36
|
+
expect(body).toBe(persisted.bytes);
|
|
37
|
+
expect(headers).toEqual({
|
|
38
|
+
Authorization: "Bearer retro_user_token",
|
|
39
|
+
"Content-Type": "application/json",
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it.each([408, 425, 429, 500, 503])("treats HTTP %s as retryable", async (status) => {
|
|
44
|
+
const transport = new HttpResultTransport(
|
|
45
|
+
"https://retro.example/v1/game-results",
|
|
46
|
+
"retro_token",
|
|
47
|
+
async () => new Response("later", { status }),
|
|
48
|
+
);
|
|
49
|
+
await expect(transport.send(persisted)).rejects.toMatchObject({ retryable: true });
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it.each([400, 401, 403, 409, 413])("quarantines permanent HTTP %s failures", async (status) => {
|
|
53
|
+
const transport = new HttpResultTransport(
|
|
54
|
+
"https://retro.example/v1/game-results",
|
|
55
|
+
"retro_token",
|
|
56
|
+
async () => new Response("invalid", { status }),
|
|
57
|
+
);
|
|
58
|
+
await expect(transport.send(persisted)).rejects.toMatchObject({
|
|
59
|
+
retryable: false,
|
|
60
|
+
quarantine: true,
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("keeps malformed successful responses ambiguous", async () => {
|
|
65
|
+
const transport = new HttpResultTransport(
|
|
66
|
+
"https://retro.example/v1/game-results",
|
|
67
|
+
"retro_token",
|
|
68
|
+
async () => Response.json({ accepted: false }),
|
|
69
|
+
);
|
|
70
|
+
await expect(transport.send(persisted)).rejects.toMatchObject({
|
|
71
|
+
retryable: false,
|
|
72
|
+
quarantine: false,
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { localScoreForCompletion } from "./local-score.js";
|
|
3
|
+
|
|
4
|
+
const completion = {
|
|
5
|
+
gameId: "game_test",
|
|
6
|
+
gameType: "2048",
|
|
7
|
+
variationId: "classic",
|
|
8
|
+
score: 512,
|
|
9
|
+
success: false,
|
|
10
|
+
endedAtMs: 2_000,
|
|
11
|
+
startedAtMs: 500,
|
|
12
|
+
platform: "overlay" as const,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
describe("local score completion", () => {
|
|
16
|
+
it("builds the existing completed-result fields for a natural completion", () => {
|
|
17
|
+
expect(localScoreForCompletion({ ...completion, outcome: "completed" })).toEqual({
|
|
18
|
+
game_id: "game_test",
|
|
19
|
+
game_type: "2048",
|
|
20
|
+
variation_id: "classic",
|
|
21
|
+
score: 512,
|
|
22
|
+
success: false,
|
|
23
|
+
completed_at: "1970-01-01T00:00:02.000Z",
|
|
24
|
+
duration_ms: 1_500,
|
|
25
|
+
platform: "overlay",
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("does not create a score for quit, restart, or shutdown paths", () => {
|
|
30
|
+
expect(localScoreForCompletion({ ...completion, outcome: "abandoned" })).toBeNull();
|
|
31
|
+
});
|
|
32
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { GameResultRequest } from "./types.js";
|
|
2
|
+
|
|
3
|
+
export interface LocalScoreInput
|
|
4
|
+
extends Pick<GameResultRequest, "game_id" | "game_type" | "score" | "completed_at"> {
|
|
5
|
+
variation_id: string;
|
|
6
|
+
success: boolean;
|
|
7
|
+
duration_ms: number;
|
|
8
|
+
platform: "terminal" | "overlay";
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface LocalScoreEntry extends LocalScoreInput {
|
|
12
|
+
schema_version: 1;
|
|
13
|
+
personal_best: number;
|
|
14
|
+
high_score_updated: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function localScoreForCompletion(input: {
|
|
18
|
+
outcome: "completed" | "abandoned";
|
|
19
|
+
gameId: string;
|
|
20
|
+
gameType: string;
|
|
21
|
+
variationId: string;
|
|
22
|
+
score: number;
|
|
23
|
+
success: boolean;
|
|
24
|
+
endedAtMs: number;
|
|
25
|
+
startedAtMs: number;
|
|
26
|
+
platform: LocalScoreInput["platform"];
|
|
27
|
+
}): LocalScoreInput | null {
|
|
28
|
+
if (input.outcome !== "completed") return null;
|
|
29
|
+
return {
|
|
30
|
+
game_id: input.gameId,
|
|
31
|
+
game_type: input.gameType,
|
|
32
|
+
variation_id: input.variationId,
|
|
33
|
+
score: input.score,
|
|
34
|
+
success: input.success,
|
|
35
|
+
completed_at: new Date(input.endedAtMs).toISOString(),
|
|
36
|
+
duration_ms: Math.max(0, input.endedAtMs - input.startedAtMs),
|
|
37
|
+
platform: input.platform,
|
|
38
|
+
};
|
|
39
|
+
}
|