@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,93 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { GameStartQueue } from "./game-start-queue.js";
3
+
4
+ describe("GameStartQueue", () => {
5
+ it("gates a direct launch until the configured backend identity is verified", async () => {
6
+ let resolveIdentity!: (userId: string) => void;
7
+ const identity = new Promise<string>((resolve) => { resolveIdentity = resolve; });
8
+ const starts: Array<{ game: string; userId: string }> = [];
9
+ const queue = new GameStartQueue({
10
+ resolveRecording: async () => ({ userId: await identity }),
11
+ start: (request, recording) => starts.push({ game: request.game, userId: recording.userId }),
12
+ });
13
+
14
+ const pending = queue.request({ game: "2048", variation: "classic", source: "direct" });
15
+ await Promise.resolve();
16
+ expect(starts).toEqual([]);
17
+ resolveIdentity("usr_backend");
18
+ await pending;
19
+ expect(starts).toEqual([{ game: "2048", userId: "usr_backend" }]);
20
+ });
21
+
22
+ it("starts only the latest fast menu selection after identity resolution", async () => {
23
+ let resolveIdentity!: (userId: string) => void;
24
+ const identity = new Promise<string>((resolve) => { resolveIdentity = resolve; });
25
+ const starts: string[] = [];
26
+ const queue = new GameStartQueue({
27
+ resolveRecording: async () => ({ userId: await identity }),
28
+ start: (request) => starts.push(request.game),
29
+ });
30
+
31
+ const first = queue.request({ game: "2048", variation: "classic", source: "menu" });
32
+ const second = queue.request({ game: "tetris", variation: "classic", source: "menu" });
33
+ resolveIdentity("usr_backend");
34
+ await Promise.all([first, second]);
35
+ expect(starts).toEqual(["tetris"]);
36
+ });
37
+
38
+ it("makes a delayed restart non-interactive until identity resolves", async () => {
39
+ let resolveIdentity!: (userId: string) => void;
40
+ const identity = new Promise<string>((resolve) => { resolveIdentity = resolve; });
41
+ let mutations = 0;
42
+ const starts: string[] = [];
43
+ const queue = new GameStartQueue({
44
+ resolveRecording: async () => ({ userId: await identity }),
45
+ start: (request) => starts.push(request.game),
46
+ });
47
+
48
+ const pending = queue.request({ game: "2048", variation: "classic", source: "restart" });
49
+ if (!queue.pending) mutations++;
50
+ expect(queue.pending).toBe(true);
51
+ expect(mutations).toBe(0);
52
+
53
+ resolveIdentity("usr_backend");
54
+ await pending;
55
+ expect(queue.pending).toBe(false);
56
+ expect(starts).toEqual(["2048"]);
57
+ });
58
+
59
+ it("cancels a delayed restart on quit so it cannot reopen later", async () => {
60
+ let resolveIdentity!: (userId: string) => void;
61
+ const identity = new Promise<string>((resolve) => { resolveIdentity = resolve; });
62
+ const starts: string[] = [];
63
+ const queue = new GameStartQueue({
64
+ resolveRecording: async () => ({ userId: await identity }),
65
+ start: (request) => starts.push(request.game),
66
+ });
67
+
68
+ const pending = queue.request({ game: "2048", variation: "classic", source: "restart" });
69
+ expect(queue.pending).toBe(true);
70
+ queue.cancel();
71
+ expect(queue.pending).toBe(false);
72
+ resolveIdentity("usr_backend");
73
+ await pending;
74
+ expect(starts).toEqual([]);
75
+ });
76
+
77
+ it("cancels a queued direct launch before identity resolves", async () => {
78
+ let resolveIdentity!: (userId: string) => void;
79
+ const identity = new Promise<string>((resolve) => { resolveIdentity = resolve; });
80
+ const starts: string[] = [];
81
+ const queue = new GameStartQueue({
82
+ resolveRecording: async () => ({ userId: await identity }),
83
+ start: (request) => starts.push(request.game),
84
+ });
85
+
86
+ const pending = queue.request({ game: "2048", variation: "classic", source: "direct" });
87
+ queue.cancel();
88
+ resolveIdentity("usr_backend");
89
+ await pending;
90
+ expect(queue.pending).toBe(false);
91
+ expect(starts).toEqual([]);
92
+ });
93
+ });
@@ -0,0 +1,45 @@
1
+ export interface GameStartRequest {
2
+ game: string;
3
+ variation: string;
4
+ source: "direct" | "menu" | "restart";
5
+ }
6
+
7
+ export interface GameStartQueueOptions<
8
+ T extends { source: GameStartRequest["source"] },
9
+ R,
10
+ > {
11
+ resolveRecording: () => Promise<R>;
12
+ start: (request: T, recording: R) => void;
13
+ }
14
+
15
+ export class GameStartQueue<
16
+ T extends { source: GameStartRequest["source"] } = GameStartRequest,
17
+ R = { userId: string },
18
+ > {
19
+ private generation = 0;
20
+ private pendingGeneration: number | null = null;
21
+
22
+ constructor(private readonly options: GameStartQueueOptions<T, R>) {}
23
+
24
+ get pending(): boolean {
25
+ return this.pendingGeneration !== null;
26
+ }
27
+
28
+ async request(request: T): Promise<void> {
29
+ const generation = ++this.generation;
30
+ this.pendingGeneration = generation;
31
+ try {
32
+ const recording = await this.options.resolveRecording();
33
+ if (generation !== this.generation) return;
34
+ this.pendingGeneration = null;
35
+ this.options.start(request, recording);
36
+ } finally {
37
+ if (this.pendingGeneration === generation) this.pendingGeneration = null;
38
+ }
39
+ }
40
+
41
+ cancel(): void {
42
+ this.generation++;
43
+ this.pendingGeneration = null;
44
+ }
45
+ }
@@ -0,0 +1,34 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { IdentityClient } from "./identity-client.js";
3
+ import type { BackendConfig } from "./config.js";
4
+
5
+ const config: BackendConfig = {
6
+ baseUrl: "https://retro.example",
7
+ token: "retro_user_token",
8
+ meUrl: "https://retro.example/v1/me",
9
+ eventChunksUrl: "https://retro.example/v1/game-event-chunks",
10
+ gameResultsUrl: "https://retro.example/v1/game-results",
11
+ };
12
+
13
+ describe("IdentityClient", () => {
14
+ it("returns the token-selected user from /v1/me", async () => {
15
+ let authorization = "";
16
+ const client = new IdentityClient(config, async (_input, init) => {
17
+ authorization = (init?.headers as Record<string, string>).Authorization ?? "";
18
+ return Response.json({ user_id: "usr_backend" });
19
+ });
20
+ await expect(client.resolve()).resolves.toBe("usr_backend");
21
+ expect(authorization).toBe("Bearer retro_user_token");
22
+ });
23
+
24
+ it("rejects unauthorized and malformed identity responses", async () => {
25
+ await expect(new IdentityClient(
26
+ config,
27
+ async () => new Response("unauthorized", { status: 401 }),
28
+ ).resolve()).rejects.toThrow("HTTP 401");
29
+ await expect(new IdentityClient(
30
+ config,
31
+ async () => Response.json({ user_id: "not a v2 user" }),
32
+ ).resolve()).rejects.toThrow("invalid user_id");
33
+ });
34
+ });
@@ -0,0 +1,43 @@
1
+ import type { BackendConfig } from "./config.js";
2
+
3
+ type Fetch = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
4
+
5
+ const USER_ID = /^usr_[A-Za-z0-9._-]+$/;
6
+
7
+ export class IdentityClient {
8
+ constructor(
9
+ private readonly config: BackendConfig,
10
+ private readonly request: Fetch = globalThis.fetch,
11
+ private readonly timeoutMs = 10_000,
12
+ ) {}
13
+
14
+ async resolve(signal?: AbortSignal): Promise<string> {
15
+ const controller = new AbortController();
16
+ const abort = () => controller.abort(signal?.reason);
17
+ if (signal?.aborted) abort();
18
+ else signal?.addEventListener("abort", abort, { once: true });
19
+ const timeout = setTimeout(() => controller.abort(), this.timeoutMs);
20
+ timeout.unref?.();
21
+ try {
22
+ const response = await this.request(this.config.meUrl, {
23
+ headers: { Authorization: `Bearer ${this.config.token}` },
24
+ signal: controller.signal,
25
+ });
26
+ if (response.status !== 200) {
27
+ throw new Error(`identity lookup returned HTTP ${response.status}`);
28
+ }
29
+ const value: unknown = await response.json();
30
+ if (
31
+ typeof value !== "object" || value === null ||
32
+ !("user_id" in value) || typeof value.user_id !== "string" ||
33
+ !USER_ID.test(value.user_id)
34
+ ) {
35
+ throw new Error("identity lookup returned an invalid user_id");
36
+ }
37
+ return value.user_id;
38
+ } finally {
39
+ clearTimeout(timeout);
40
+ signal?.removeEventListener("abort", abort);
41
+ }
42
+ }
43
+ }