@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.
Files changed (91) hide show
  1. package/.claude-plugin/plugin.json +10 -0
  2. package/.codex-plugin/plugin.json +21 -0
  3. package/README.md +32 -8
  4. package/dist/index.js +286 -19
  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 +28 -4
  9. package/scripts/install-adapters.sh +25 -14
  10. package/scripts/install-adapters.test.sh +12 -2
  11. package/scripts/materialize-agents-plugin.sh +8 -6
  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,84 @@
1
+ import { TransportError } from "../recording/transport.js";
2
+ import { ResultStore } from "./store.js";
3
+ import type { ResultTransport } from "./transport.js";
4
+
5
+ export interface ResultUploadResult {
6
+ acknowledged: number;
7
+ failed: number;
8
+ pending: number;
9
+ }
10
+
11
+ export interface ResultUploaderOptions {
12
+ maxAttempts?: number;
13
+ random?: () => number;
14
+ delay?: (milliseconds: number, signal?: AbortSignal) => Promise<void>;
15
+ }
16
+
17
+ const sleep = (milliseconds: number, signal?: AbortSignal) =>
18
+ new Promise<void>((resolve) => {
19
+ if (signal?.aborted) {
20
+ resolve();
21
+ return;
22
+ }
23
+ const timer = setTimeout(finish, milliseconds);
24
+ timer.unref?.();
25
+ signal?.addEventListener("abort", finish, { once: true });
26
+ function finish() {
27
+ clearTimeout(timer);
28
+ signal?.removeEventListener("abort", finish);
29
+ resolve();
30
+ }
31
+ });
32
+
33
+ export class ResultUploader {
34
+ private readonly maxAttempts: number;
35
+ private readonly random: () => number;
36
+ private readonly delay: (milliseconds: number, signal?: AbortSignal) => Promise<void>;
37
+
38
+ constructor(
39
+ private readonly store: ResultStore,
40
+ private readonly transport: ResultTransport,
41
+ options: ResultUploaderOptions = {},
42
+ ) {
43
+ this.maxAttempts = options.maxAttempts ?? 5;
44
+ this.random = options.random ?? Math.random;
45
+ this.delay = options.delay ?? sleep;
46
+ }
47
+
48
+ async uploadPending(userId: string, signal?: AbortSignal): Promise<ResultUploadResult> {
49
+ if (signal?.aborted) return { acknowledged: 0, failed: 0, pending: 0 };
50
+ const initial = this.store.listPending(userId);
51
+ let acknowledged = 0;
52
+ let failed = 0;
53
+ for (const result of initial) {
54
+ if (signal?.aborted) break;
55
+ for (let attempt = 1; attempt <= this.maxAttempts; attempt++) {
56
+ if (signal?.aborted) break;
57
+ try {
58
+ await this.transport.send(result, signal);
59
+ this.store.acknowledge(result);
60
+ acknowledged++;
61
+ break;
62
+ } catch (error) {
63
+ if (signal?.aborted) break;
64
+ if (error instanceof TransportError && !error.retryable) {
65
+ if (error.quarantine) {
66
+ this.store.markFailed(result, error.message);
67
+ failed++;
68
+ }
69
+ break;
70
+ }
71
+ if (attempt === this.maxAttempts) break;
72
+ const exponential = 1_000 * 2 ** (attempt - 1);
73
+ const jittered = Math.round(exponential * (0.5 + this.random()));
74
+ await this.delay(Math.min(jittered, 60_000), signal);
75
+ }
76
+ }
77
+ }
78
+ return {
79
+ acknowledged,
80
+ failed,
81
+ pending: this.store.listPending(userId).length,
82
+ };
83
+ }
84
+ }
@@ -0,0 +1,108 @@
1
+ import { chmodSync, mkdtempSync, rmSync, statSync, writeFileSync } 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 {
6
+ backendConfigPath,
7
+ clearBackendSession,
8
+ loadBackendConfig,
9
+ resolveBackendConfig,
10
+ saveBackendSession,
11
+ } from "./config.js";
12
+
13
+ const roots: string[] = [];
14
+ afterEach(() => {
15
+ for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
16
+ });
17
+
18
+ describe("resolveBackendConfig", () => {
19
+ it("uses the sync backend by default when only a token is configured", () => {
20
+ expect(resolveBackendConfig({
21
+ GAMEPIGEON_BACKEND_TOKEN: "retro_user_token",
22
+ })).toEqual({
23
+ baseUrl: "https://api.runretroarcade.com",
24
+ token: "retro_user_token",
25
+ meUrl: "https://api.runretroarcade.com/v1/me",
26
+ eventChunksUrl: "https://api.runretroarcade.com/v1/game-event-chunks",
27
+ gameResultsUrl: "https://api.runretroarcade.com/v1/game-results",
28
+ });
29
+ });
30
+
31
+ it("prefers the backend URL and opaque token", () => {
32
+ expect(resolveBackendConfig({
33
+ GAMEPIGEON_BACKEND_URL: "https://retro.example/",
34
+ GAMEPIGEON_BACKEND_TOKEN: "retro_user_token",
35
+ GAMEPIGEON_INGEST_URL: "https://legacy.example/v1/game-event-chunks",
36
+ GAMEPIGEON_INGEST_API_KEY: "legacy-token",
37
+ })).toEqual({
38
+ baseUrl: "https://retro.example",
39
+ token: "retro_user_token",
40
+ meUrl: "https://retro.example/v1/me",
41
+ eventChunksUrl: "https://retro.example/v1/game-event-chunks",
42
+ gameResultsUrl: "https://retro.example/v1/game-results",
43
+ });
44
+ });
45
+
46
+ it("supports the temporary ingest aliases as a bearer-token pair", () => {
47
+ expect(resolveBackendConfig({
48
+ GAMEPIGEON_INGEST_URL: "http://127.0.0.1:8000/v1/game-event-chunks",
49
+ GAMEPIGEON_INGEST_API_KEY: "retro_legacy_user_token",
50
+ }))?.toMatchObject({
51
+ baseUrl: "http://127.0.0.1:8000",
52
+ token: "retro_legacy_user_token",
53
+ gameResultsUrl: "http://127.0.0.1:8000/v1/game-results",
54
+ });
55
+ });
56
+
57
+ it("keeps remote sync disabled for incomplete configuration", () => {
58
+ expect(resolveBackendConfig({ GAMEPIGEON_BACKEND_URL: "https://retro.example" }))
59
+ .toBeNull();
60
+ expect(resolveBackendConfig({})).toBeNull();
61
+ });
62
+
63
+ it("reloads and tightens the private backend config file", () => {
64
+ const home = mkdtempSync(join(tmpdir(), "gamepigeon-backend-config-"));
65
+ roots.push(home);
66
+ const path = backendConfigPath(home);
67
+ writeFileSync(path, "https://retro.example\nfirst-token\n", { mode: 0o666 });
68
+ chmodSync(join(home, ".tui-gamepigeon"), 0o777);
69
+ expect(loadBackendConfig({}, path)?.token).toBe("first-token");
70
+ expect(statSync(join(home, ".tui-gamepigeon")).mode & 0o777).toBe(0o700);
71
+ expect(statSync(path).mode & 0o777).toBe(0o600);
72
+
73
+ writeFileSync(path, "https://retro.example\nrotated-token\n", { mode: 0o600 });
74
+ expect(loadBackendConfig({}, path)?.token).toBe("rotated-token");
75
+ });
76
+
77
+ it("allows direct environment overrides while launcher processes prefer the file", () => {
78
+ const home = mkdtempSync(join(tmpdir(), "gamepigeon-backend-priority-"));
79
+ roots.push(home);
80
+ const path = backendConfigPath(home);
81
+ writeFileSync(path, "https://file.example\nfile-token\n", { mode: 0o600 });
82
+ const environment = {
83
+ GAMEPIGEON_BACKEND_URL: "https://direct.example",
84
+ GAMEPIGEON_BACKEND_TOKEN: "direct-token",
85
+ };
86
+ expect(loadBackendConfig(environment, path)?.token).toBe("direct-token");
87
+ expect(loadBackendConfig({
88
+ ...environment,
89
+ GAMEPIGEON_BACKEND_CONFIG_PREFER_FILE: "1",
90
+ }, path)?.token).toBe("file-token");
91
+ });
92
+
93
+ it("atomically stores and clears a private backend session", () => {
94
+ const home = mkdtempSync(join(tmpdir(), "gamepigeon-backend-session-"));
95
+ roots.push(home);
96
+ const path = backendConfigPath(home);
97
+
98
+ saveBackendSession("session-token", "https://retro.example/", path);
99
+ expect(loadBackendConfig({}, path)).toMatchObject({
100
+ baseUrl: "https://retro.example",
101
+ token: "session-token",
102
+ });
103
+ expect(statSync(path).mode & 0o777).toBe(0o600);
104
+
105
+ clearBackendSession(path);
106
+ expect(loadBackendConfig({}, path)).toBeNull();
107
+ });
108
+ });
@@ -0,0 +1,115 @@
1
+ import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ import { ensurePrivateDirectory, ensurePrivateFile } from "../recording/permissions.js";
5
+
6
+ export interface BackendConfig {
7
+ baseUrl: string;
8
+ token: string;
9
+ meUrl: string;
10
+ eventChunksUrl: string;
11
+ gameResultsUrl: string;
12
+ }
13
+
14
+ export const defaultBackendBaseUrl = "https://api.runretroarcade.com";
15
+
16
+ function normalizedBaseUrl(value: string): string {
17
+ const parsed = new URL(value);
18
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
19
+ throw new Error("GAMEPIGEON_BACKEND_URL must use HTTP or HTTPS");
20
+ }
21
+ parsed.pathname = parsed.pathname.replace(/\/$/, "");
22
+ return parsed.toString().replace(/\/$/, "");
23
+ }
24
+
25
+ function baseFromLegacyEndpoint(value: string): string {
26
+ const parsed = new URL(value);
27
+ parsed.pathname = parsed.pathname.replace(/\/v1\/game-event-chunks\/?$/, "");
28
+ parsed.search = "";
29
+ parsed.hash = "";
30
+ return normalizedBaseUrl(parsed.toString());
31
+ }
32
+
33
+ export function resolveBackendConfig(
34
+ environment: Record<string, string | undefined> = process.env,
35
+ ): BackendConfig | null {
36
+ const configuredBase = environment.GAMEPIGEON_BACKEND_URL?.trim();
37
+ const configuredToken = environment.GAMEPIGEON_BACKEND_TOKEN?.trim();
38
+ const legacyEndpoint = environment.GAMEPIGEON_INGEST_URL?.trim();
39
+ const legacyToken = environment.GAMEPIGEON_INGEST_API_KEY?.trim();
40
+
41
+ const token = configuredToken || legacyToken;
42
+ const baseUrl = configuredBase
43
+ ? normalizedBaseUrl(configuredBase)
44
+ : legacyEndpoint
45
+ ? baseFromLegacyEndpoint(legacyEndpoint)
46
+ // Only the modern token opts into the default backend; a lone legacy key
47
+ // was issued for a legacy endpoint and must not be sent elsewhere.
48
+ : configuredToken
49
+ ? defaultBackendBaseUrl
50
+ : null;
51
+ if (!baseUrl || !token) return null;
52
+
53
+ return {
54
+ baseUrl,
55
+ token,
56
+ meUrl: `${baseUrl}/v1/me`,
57
+ eventChunksUrl: `${baseUrl}/v1/game-event-chunks`,
58
+ gameResultsUrl: `${baseUrl}/v1/game-results`,
59
+ };
60
+ }
61
+
62
+ export function backendConfigPath(home = process.env.HOME ?? homedir()): string {
63
+ const directory = join(home, ".tui-gamepigeon");
64
+ ensurePrivateDirectory(directory);
65
+ return join(directory, "backend.conf");
66
+ }
67
+
68
+ export function saveBackendSession(
69
+ token: string,
70
+ baseUrl?: string,
71
+ path = backendConfigPath(),
72
+ ): void {
73
+ if (!token) throw new Error("Backend session token is required");
74
+ const normalized = normalizedBaseUrl(baseUrl ?? defaultBackendBaseUrl);
75
+ ensurePrivateDirectory(dirname(path));
76
+ const temporary = `${path}.tmp-${process.pid}`;
77
+ try {
78
+ writeFileSync(temporary, `${normalized}\n${token}\n`, { mode: 0o600 });
79
+ renameSync(temporary, path);
80
+ ensurePrivateFile(path);
81
+ } finally {
82
+ rmSync(temporary, { force: true });
83
+ }
84
+ }
85
+
86
+ export function clearBackendSession(path = backendConfigPath()): void {
87
+ rmSync(path, { force: true });
88
+ }
89
+
90
+ function configFromFile(path: string): BackendConfig | null {
91
+ if (!existsSync(path)) return null;
92
+ ensurePrivateDirectory(dirname(path));
93
+ ensurePrivateFile(path);
94
+ const [baseUrl, token] = readFileSync(path, "utf8").split("\n");
95
+ if (!baseUrl?.trim() || !token?.trim()) return null;
96
+ try {
97
+ return resolveBackendConfig({
98
+ GAMEPIGEON_BACKEND_URL: baseUrl,
99
+ GAMEPIGEON_BACKEND_TOKEN: token,
100
+ });
101
+ } catch {
102
+ return null;
103
+ }
104
+ }
105
+
106
+ export function loadBackendConfig(
107
+ environment: Record<string, string | undefined> = process.env,
108
+ path = backendConfigPath(),
109
+ ): BackendConfig | null {
110
+ const fromEnvironment = () => resolveBackendConfig(environment);
111
+ if (environment.GAMEPIGEON_BACKEND_CONFIG_PREFER_FILE === "1") {
112
+ return configFromFile(path) ?? fromEnvironment();
113
+ }
114
+ return fromEnvironment() ?? configFromFile(path);
115
+ }