@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.
Files changed (91) hide show
  1. package/.claude-plugin/plugin.json +10 -0
  2. package/.codex-plugin/plugin.json +2 -2
  3. package/README.md +29 -7
  4. package/dist/index.js +284 -18
  5. package/overlay/dist/app.js +74 -0
  6. package/overlay/dist/index.html +1589 -0
  7. package/overlay-release.json +2 -2
  8. package/package.json +27 -4
  9. package/scripts/install-adapters.sh +6 -6
  10. package/scripts/install-adapters.test.sh +2 -2
  11. package/scripts/materialize-agents-plugin.sh +3 -3
  12. package/scripts/npm-pack.test.ts +51 -0
  13. package/scripts/overlay.ts +1 -1
  14. package/src/analytics/events.test.ts +77 -0
  15. package/src/analytics/events.ts +85 -0
  16. package/src/analytics/posthog.test.ts +85 -0
  17. package/src/analytics/posthog.ts +88 -0
  18. package/src/auth-pages.ts +108 -0
  19. package/src/auth.test.ts +255 -0
  20. package/src/auth.ts +511 -0
  21. package/src/core/game.ts +35 -0
  22. package/src/core/key.ts +12 -0
  23. package/src/core/rng.ts +41 -0
  24. package/src/data/bc-export.test.ts +153 -0
  25. package/src/data/bc-export.ts +125 -0
  26. package/src/data/trajectory.ts +103 -0
  27. package/src/games/connect4/connect4.ts +81 -0
  28. package/src/games/connect4/engine.test.ts +77 -0
  29. package/src/games/connect4/engine.ts +89 -0
  30. package/src/games/g2048/engine.test.ts +138 -0
  31. package/src/games/g2048/engine.ts +109 -0
  32. package/src/games/g2048/game2048.ts +104 -0
  33. package/src/games/minesweeper/engine.test.ts +83 -0
  34. package/src/games/minesweeper/engine.ts +112 -0
  35. package/src/games/minesweeper/minesweeper.ts +107 -0
  36. package/src/games/registry.test.ts +16 -0
  37. package/src/games/registry.ts +130 -0
  38. package/src/games/snake/engine.test.ts +89 -0
  39. package/src/games/snake/engine.ts +95 -0
  40. package/src/games/snake/snake.ts +77 -0
  41. package/src/games/sokoban/engine.test.ts +49 -0
  42. package/src/games/sokoban/engine.ts +152 -0
  43. package/src/games/sokoban/sokoban.ts +103 -0
  44. package/src/games/sudoku/engine.test.ts +88 -0
  45. package/src/games/sudoku/engine.ts +99 -0
  46. package/src/games/sudoku/sudoku.ts +100 -0
  47. package/src/games/tetris/engine.test.ts +378 -0
  48. package/src/games/tetris/engine.ts +236 -0
  49. package/src/games/tetris/tetris.ts +220 -0
  50. package/src/index.ts +82 -0
  51. package/src/recording/chunks.ts +93 -0
  52. package/src/recording/config.test.ts +31 -0
  53. package/src/recording/config.ts +37 -0
  54. package/src/recording/contracts.test.ts +71 -0
  55. package/src/recording/gameplay.test.ts +70 -0
  56. package/src/recording/gameplay.ts +18 -0
  57. package/src/recording/http-transport.test.ts +197 -0
  58. package/src/recording/identity.test.ts +26 -0
  59. package/src/recording/identity.ts +38 -0
  60. package/src/recording/ids.ts +27 -0
  61. package/src/recording/permissions.ts +10 -0
  62. package/src/recording/recorder.test.ts +388 -0
  63. package/src/recording/recorder.ts +464 -0
  64. package/src/recording/store.test.ts +231 -0
  65. package/src/recording/store.ts +305 -0
  66. package/src/recording/terminal-grid.test.ts +88 -0
  67. package/src/recording/terminal-grid.ts +248 -0
  68. package/src/recording/trace-recorder.test.ts +287 -0
  69. package/src/recording/trace-recorder.ts +560 -0
  70. package/src/recording/transport.ts +170 -0
  71. package/src/recording/types.test.ts +18 -0
  72. package/src/recording/types.ts +326 -0
  73. package/src/recording/uploader.test.ts +193 -0
  74. package/src/recording/uploader.ts +103 -0
  75. package/src/results/completion.ts +100 -0
  76. package/src/results/http-result-transport.test.ts +75 -0
  77. package/src/results/local-score.test.ts +32 -0
  78. package/src/results/local-score.ts +39 -0
  79. package/src/results/results.test.ts +383 -0
  80. package/src/results/store.ts +149 -0
  81. package/src/results/transport.ts +79 -0
  82. package/src/results/types.ts +20 -0
  83. package/src/results/uploader.ts +84 -0
  84. package/src/sync/config.test.ts +108 -0
  85. package/src/sync/config.ts +115 -0
  86. package/src/sync/coordinator.test.ts +423 -0
  87. package/src/sync/coordinator.ts +277 -0
  88. package/src/sync/game-start-queue.test.ts +93 -0
  89. package/src/sync/game-start-queue.ts +45 -0
  90. package/src/sync/identity-client.test.ts +34 -0
  91. package/src/sync/identity-client.ts +43 -0
@@ -0,0 +1,71 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { describe, expect, it } from "vitest";
4
+ import type { GameEventChunk, TraceEventChunk } from "./types.js";
5
+
6
+ function contract(path: string): unknown {
7
+ return JSON.parse(readFileSync(join(import.meta.dir, "../../contracts", path), "utf8"));
8
+ }
9
+
10
+ describe("recording wire contracts", () => {
11
+ it("ships parseable version-one request and acknowledgment schemas", () => {
12
+ const request = contract("game-event-chunk-v1.schema.json") as {
13
+ properties: { schema_version: { const: number } };
14
+ required: string[];
15
+ };
16
+ const response = contract("ingest-response-v1.schema.json") as {
17
+ additionalProperties: boolean;
18
+ required: string[];
19
+ properties: Record<string, { const?: unknown }>;
20
+ };
21
+ expect(request.properties.schema_version.const).toBe(1);
22
+ expect(request.required).toContain("events");
23
+ expect(response.required).toContain("accepted_event_range");
24
+ expect(response.required).toEqual([
25
+ "accepted",
26
+ "duplicate",
27
+ "game_id",
28
+ "chunk_id",
29
+ "chunk_sequence",
30
+ "accepted_event_range",
31
+ "queued",
32
+ "server_received_at_ms",
33
+ ]);
34
+ expect(response.properties.queued!.const).toBe(true);
35
+ expect(response.additionalProperties).toBe(false);
36
+ expect(Object.keys(response.properties)).not.toContain("broker_partition");
37
+ expect(Object.keys(response.properties)).not.toContain("broker_offset");
38
+ });
39
+
40
+ it("ships a consistent valid request fixture", () => {
41
+ const fixture = contract("fixtures/valid/game-event-chunk-v1.json") as GameEventChunk;
42
+ expect(fixture.schema_version).toBe(1);
43
+ expect(fixture.event_count).toBe(fixture.events.length);
44
+ expect(fixture.first_event_sequence).toBe(fixture.events[0]!.event_sequence);
45
+ expect(fixture.last_event_sequence).toBe(fixture.events.at(-1)!.event_sequence);
46
+ expect(fixture.events.map((event) => event.event_sequence)).toEqual([1, 2, 3]);
47
+ });
48
+
49
+ it("ships a schema-two trace fixture with universal event identities", () => {
50
+ const schema = contract("interaction-trace-chunk-v2.schema.json") as {
51
+ properties: { schema_version: { const: number } };
52
+ required: string[];
53
+ $defs: { event: { required: string[] } };
54
+ };
55
+ const fixture = contract("fixtures/valid/interaction-trace-chunk-v2.json") as TraceEventChunk;
56
+ expect(schema.properties.schema_version.const).toBe(2);
57
+ expect(schema.required).toEqual(expect.arrayContaining(["user_id", "game_id", "chunk_id"]));
58
+ expect(schema.$defs.event.required).toEqual(
59
+ expect.arrayContaining(["event_id", "user_id", "game_id", "chunk_id"]),
60
+ );
61
+ expect(fixture.schema_version).toBe(2);
62
+ expect(fixture.client.recording_sdk_version).toBe("2");
63
+ expect(fixture.event_count).toBe(fixture.events.length);
64
+ expect(fixture.events.every((event) =>
65
+ event.user_id === fixture.user_id &&
66
+ event.game_id === fixture.game_id &&
67
+ event.chunk_id === fixture.chunk_id &&
68
+ event.event_id.startsWith("evt_")
69
+ )).toBe(true);
70
+ });
71
+ });
@@ -0,0 +1,70 @@
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 { Game, StepResult } from "../core/game.js";
6
+ import { recordGameAction } from "./gameplay.js";
7
+ import { GameplayRecorder } from "./recorder.js";
8
+ import { RecordingStore } from "./store.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
+ class TestGame implements Game<{ value: number }, "advance" | "noop"> {
16
+ readonly name = "test";
17
+ readonly actions = ["advance", "noop"] as const;
18
+ value = 0;
19
+ reset(): void {
20
+ this.value = 0;
21
+ }
22
+ step(action: "advance" | "noop"): StepResult {
23
+ if (action === "noop") {
24
+ return { reward: 0, moved: false, done: false, success: false };
25
+ }
26
+ this.value++;
27
+ return { reward: 1, moved: true, done: false, success: false };
28
+ }
29
+ state(): { value: number } {
30
+ return { value: this.value };
31
+ }
32
+ score(): number {
33
+ return this.value;
34
+ }
35
+ }
36
+
37
+ describe("recordGameAction", () => {
38
+ it("records state before and after both player no-ops and environment actions", () => {
39
+ const root = mkdtempSync(join(tmpdir(), "gamepigeon-gameplay-"));
40
+ roots.push(root);
41
+ const store = new RecordingStore(root);
42
+ const recorder = new GameplayRecorder({
43
+ store,
44
+ gameType: "test",
45
+ gameVersion: "1",
46
+ seed: 1,
47
+ initialState: { value: 0 },
48
+ createGameId: () => "game_test",
49
+ });
50
+ const game = new TestGame();
51
+
52
+ expect(recordGameAction(game, recorder, "noop", "player").moved).toBe(false);
53
+ expect(recordGameAction(game, recorder, "advance", "environment").moved).toBe(true);
54
+ recorder.flush();
55
+
56
+ const actions = store.listPending()[0]!.chunk.events.filter(
57
+ (event) => event.event_type === "action",
58
+ );
59
+ expect(actions.map((event) => [
60
+ event.source,
61
+ event.payload.action,
62
+ event.payload.state_before,
63
+ event.payload.state_after,
64
+ ])).toEqual([
65
+ ["player", "noop", { value: 0 }, { value: 0 }],
66
+ ["environment", "advance", { value: 0 }, { value: 1 }],
67
+ ]);
68
+ recorder.shutdown();
69
+ });
70
+ });
@@ -0,0 +1,18 @@
1
+ import type { Game, StepResult } from "../core/game.js";
2
+ import type { GameplayRecorder } from "./recorder.js";
3
+ import type { ActionSource } from "./types.js";
4
+
5
+ export function recordGameAction<State, Action extends string>(
6
+ game: Game<State, Action>,
7
+ recorder: GameplayRecorder | null,
8
+ action: Action,
9
+ source: ActionSource,
10
+ ): StepResult {
11
+ const stateBefore = game.state();
12
+ const result = game.step(action);
13
+ const stateAfter = game.state();
14
+ recorder?.recordAction({
15
+ action, source, stateBefore, result, stateAfter,
16
+ });
17
+ return result;
18
+ }
@@ -0,0 +1,197 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ DisabledTransport,
4
+ HttpTransport,
5
+ TransportError,
6
+ createChunkTransport,
7
+ } from "./transport.js";
8
+ import type { GameEventChunk, IngestAcknowledgment, PersistedChunk, RecordedEventChunk } from "./types.js";
9
+
10
+ function persisted(): PersistedChunk {
11
+ const chunk: GameEventChunk = {
12
+ schema_version: 1,
13
+ game_id: "game_test",
14
+ game_type: "2048",
15
+ game_version: "1",
16
+ chunk_id: "chk_test",
17
+ chunk_sequence: 1,
18
+ first_event_sequence: 1,
19
+ last_event_sequence: 1,
20
+ event_count: 1,
21
+ created_at_ms: 1,
22
+ client: {
23
+ platform: "tui",
24
+ application_version: "test",
25
+ recording_sdk_version: "1",
26
+ },
27
+ events: [{
28
+ event_id: "evt_test",
29
+ event_sequence: 1,
30
+ event_type: "game_started",
31
+ event_version: 1,
32
+ source: "system",
33
+ client_time_ms: 1,
34
+ monotonic_time_ms: 0,
35
+ payload: {
36
+ game_type: "2048",
37
+ game_version: "1",
38
+ seed: 1,
39
+ initial_state: {},
40
+ },
41
+ }],
42
+ };
43
+ return {
44
+ chunk,
45
+ path: "/pending/chunk.json",
46
+ bytes: JSON.stringify(chunk),
47
+ };
48
+ }
49
+
50
+ function acknowledgment(chunk: RecordedEventChunk): IngestAcknowledgment {
51
+ return {
52
+ accepted: true,
53
+ duplicate: false,
54
+ game_id: chunk.game_id,
55
+ chunk_id: chunk.chunk_id,
56
+ chunk_sequence: chunk.chunk_sequence,
57
+ accepted_event_range: {
58
+ first: chunk.first_event_sequence,
59
+ last: chunk.last_event_sequence,
60
+ },
61
+ queued: true,
62
+ server_received_at_ms: 2,
63
+ };
64
+ }
65
+
66
+ describe("HttpTransport", () => {
67
+ it("posts the exact persisted bytes and returns the acknowledgment", async () => {
68
+ const value = persisted();
69
+ let requestUrl = "";
70
+ let requestInit: RequestInit | undefined;
71
+ const transport = new HttpTransport(
72
+ "http://127.0.0.1:8765/v1/game-event-chunks",
73
+ {
74
+ apiKey: "test-secret",
75
+ fetch: async (input, init) => {
76
+ requestUrl = String(input);
77
+ requestInit = init;
78
+ return Response.json(acknowledgment(value.chunk), { status: 202 });
79
+ },
80
+ },
81
+ );
82
+
83
+ expect(await transport.send(value)).toEqual(acknowledgment(value.chunk));
84
+ expect(requestUrl).toBe("http://127.0.0.1:8765/v1/game-event-chunks");
85
+ expect(requestInit?.method).toBe("POST");
86
+ expect(requestInit?.body).toBe(value.bytes);
87
+ expect(requestInit?.headers).toEqual({
88
+ Authorization: "Bearer test-secret",
89
+ "Content-Type": "application/json",
90
+ });
91
+ });
92
+
93
+ it.each([408, 425, 429, 500, 503])("treats HTTP %s as retryable", async (status) => {
94
+ const transport = new HttpTransport("http://localhost/ingest", {
95
+ fetch: async () => new Response("unavailable", { status }),
96
+ });
97
+ await expect(transport.send(persisted())).rejects.toMatchObject({
98
+ name: "TransportError",
99
+ retryable: true,
100
+ });
101
+ });
102
+
103
+ it.each([400, 401, 409, 413])("treats HTTP %s as permanent", async (status) => {
104
+ const transport = new HttpTransport("http://localhost/ingest", {
105
+ fetch: async () => new Response("invalid", { status }),
106
+ });
107
+ await expect(transport.send(persisted())).rejects.toMatchObject({
108
+ name: "TransportError",
109
+ retryable: false,
110
+ });
111
+ });
112
+
113
+ it("treats network failures as retryable", async () => {
114
+ const transport = new HttpTransport("http://localhost/ingest", {
115
+ fetch: async () => { throw new TypeError("connection refused"); },
116
+ });
117
+ await expect(transport.send(persisted())).rejects.toEqual(
118
+ new TransportError("connection refused", true),
119
+ );
120
+ });
121
+
122
+ it("keeps malformed successful acknowledgments ambiguous", async () => {
123
+ const transport = new HttpTransport("http://localhost/ingest", {
124
+ fetch: async () => Response.json({ accepted: true }, { status: 202 }),
125
+ });
126
+ await expect(transport.send(persisted())).rejects.toMatchObject({
127
+ retryable: false,
128
+ quarantine: false,
129
+ message: "ingestion returned a malformed acknowledgment",
130
+ });
131
+ });
132
+
133
+ it("accepts an acknowledgment without the retired broker fields", async () => {
134
+ const value = persisted();
135
+ const ack = acknowledgment(value.chunk);
136
+ const transport = new HttpTransport("http://localhost/ingest", {
137
+ fetch: async () => Response.json(ack, { status: 202 }),
138
+ });
139
+
140
+ expect(await transport.send(value)).toEqual(ack);
141
+ expect(ack).not.toHaveProperty("broker_partition");
142
+ expect(ack).not.toHaveProperty("broker_offset");
143
+ });
144
+
145
+ it.each([
146
+ ["omits queued", (ack: IngestAcknowledgment) => {
147
+ const { queued: _queued, ...rest } = ack;
148
+ return rest;
149
+ }],
150
+ ["reports queued false", (ack: IngestAcknowledgment) => ({ ...ack, queued: false })],
151
+ ])("rejects an acknowledgment that %s", async (_name, mutate) => {
152
+ const value = persisted();
153
+ const transport = new HttpTransport("http://localhost/ingest", {
154
+ fetch: async () => Response.json(mutate(acknowledgment(value.chunk)), { status: 202 }),
155
+ });
156
+
157
+ await expect(transport.send(value)).rejects.toMatchObject({
158
+ retryable: false,
159
+ quarantine: false,
160
+ message: "ingestion returned a malformed acknowledgment",
161
+ });
162
+ });
163
+ });
164
+
165
+ describe("createChunkTransport", () => {
166
+ it("stays disabled without an ingestion URL", () => {
167
+ expect(createChunkTransport({})).toBeInstanceOf(DisabledTransport);
168
+ });
169
+
170
+ it("enables HTTP when the ingestion URL is configured", () => {
171
+ const transport = createChunkTransport({
172
+ GAMEPIGEON_INGEST_URL: "http://127.0.0.1:8765/v1/game-event-chunks",
173
+ GAMEPIGEON_INGEST_API_KEY: "test-secret",
174
+ });
175
+ expect(transport).toBeInstanceOf(HttpTransport);
176
+ expect(transport.enabled).toBe(true);
177
+ });
178
+
179
+ it("targets Cloudflare by default when only the backend token is configured", () => {
180
+ const transport = createChunkTransport({
181
+ GAMEPIGEON_BACKEND_TOKEN: "retro_user_token",
182
+ }) as HttpTransport;
183
+
184
+ expect(transport).toBeInstanceOf(HttpTransport);
185
+ expect((transport as unknown as { endpoint: string }).endpoint)
186
+ .toBe("https://api.runretroarcade.com/v1/game-event-chunks");
187
+ });
188
+
189
+ it("prefers the backend URL and token", () => {
190
+ const transport = createChunkTransport({
191
+ GAMEPIGEON_BACKEND_URL: "https://retro.example",
192
+ GAMEPIGEON_BACKEND_TOKEN: "retro_user_token",
193
+ });
194
+ expect(transport).toBeInstanceOf(HttpTransport);
195
+ expect(transport.enabled).toBe(true);
196
+ });
197
+ });
@@ -0,0 +1,26 @@
1
+ import { mkdtempSync, readFileSync, 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 { resolveRecordingUserId } from "./identity.js";
6
+
7
+ const roots: string[] = [];
8
+ afterEach(() => {
9
+ for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
10
+ });
11
+
12
+ describe("recording user identity", () => {
13
+ it("persists one pseudonymous user id for offline sessions", () => {
14
+ const root = mkdtempSync(join(tmpdir(), "gamepigeon-identity-"));
15
+ roots.push(root);
16
+ const uuid = () => "00000000-0000-4000-8000-000000000001";
17
+ const first = resolveRecordingUserId({ root, uuid });
18
+ const second = resolveRecordingUserId({
19
+ root,
20
+ uuid: () => "00000000-0000-4000-8000-000000000002",
21
+ });
22
+ expect(first).toBe("usr_00000000-0000-4000-8000-000000000001");
23
+ expect(second).toBe(first);
24
+ expect(readFileSync(join(root, "user_id"), "utf8")).toBe(`${first}\n`);
25
+ });
26
+ });
@@ -0,0 +1,38 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import {
3
+ existsSync,
4
+ mkdirSync,
5
+ readFileSync,
6
+ renameSync,
7
+ writeFileSync,
8
+ } from "node:fs";
9
+ import { homedir } from "node:os";
10
+ import { dirname, join } from "node:path";
11
+ import { createUserId } from "./ids.js";
12
+
13
+ export interface RecordingIdentityOptions {
14
+ root?: string;
15
+ uuid?: () => string;
16
+ }
17
+
18
+ export function resolveRecordingUserId(
19
+ options: RecordingIdentityOptions = {},
20
+ ): string {
21
+ const root = options.root ?? join(homedir(), ".tui-gamepigeon");
22
+ const path = join(root, "user_id");
23
+ try {
24
+ if (existsSync(path)) {
25
+ const existing = readFileSync(path, "utf8").trim();
26
+ if (/^usr_[A-Za-z0-9._-]+$/.test(existing)) return existing;
27
+ }
28
+ } catch {
29
+ // Regenerate an invalid or unreadable local identity.
30
+ }
31
+
32
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
33
+ const userId = createUserId(options.uuid ?? randomUUID);
34
+ const temporary = `${path}.tmp-${process.pid}-${randomUUID()}`;
35
+ writeFileSync(temporary, `${userId}\n`, { mode: 0o600 });
36
+ renameSync(temporary, path);
37
+ return userId;
38
+ }
@@ -0,0 +1,27 @@
1
+ import { randomUUID } from "node:crypto";
2
+
3
+ type UuidFactory = () => string;
4
+
5
+ function createPrefixedId(prefix: string, uuid: UuidFactory): string {
6
+ return `${prefix}_${uuid()}`;
7
+ }
8
+
9
+ export function createUserId(uuid: UuidFactory = randomUUID): string {
10
+ return createPrefixedId("usr", uuid);
11
+ }
12
+
13
+ export function createGameId(uuid: UuidFactory = randomUUID): string {
14
+ return createPrefixedId("game", uuid);
15
+ }
16
+
17
+ export function createEventId(uuid: UuidFactory = randomUUID): string {
18
+ return createPrefixedId("evt", uuid);
19
+ }
20
+
21
+ export function createChunkId(uuid: UuidFactory = randomUUID): string {
22
+ return createPrefixedId("chk", uuid);
23
+ }
24
+
25
+ export function createObservationId(uuid: UuidFactory = randomUUID): string {
26
+ return createPrefixedId("obs", uuid);
27
+ }
@@ -0,0 +1,10 @@
1
+ import { chmodSync, mkdirSync } from "node:fs";
2
+
3
+ export function ensurePrivateDirectory(path: string): void {
4
+ mkdirSync(path, { recursive: true, mode: 0o700 });
5
+ chmodSync(path, 0o700);
6
+ }
7
+
8
+ export function ensurePrivateFile(path: string): void {
9
+ chmodSync(path, 0o600);
10
+ }