@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
@@ -1,4 +1,4 @@
1
1
  {
2
- "tag": "overlay-v0.2.3",
3
- "version": "0.2.3"
2
+ "tag": "overlay-v0.2.6",
3
+ "version": "0.2.6"
4
4
  }
package/package.json CHANGED
@@ -1,17 +1,39 @@
1
1
  {
2
2
  "name": "@trolleroof/tui",
3
- "version": "0.2.5",
3
+ "version": "0.2.6",
4
4
  "description": "Play familiar single-player games in a lightweight HTML overlay while your agents work.",
5
5
  "type": "module",
6
- "bin": {
7
- "gamepigeon": "./dist/index.js"
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/Trolleroof/tui-gamepigeon.git"
8
10
  },
11
+ "homepage": "https://github.com/Trolleroof/tui-gamepigeon#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/Trolleroof/tui-gamepigeon/issues"
14
+ },
15
+ "keywords": [
16
+ "arcade",
17
+ "overlay",
18
+ "claude-code",
19
+ "codex",
20
+ "games"
21
+ ],
9
22
  "files": [
10
23
  "dist",
11
24
  "scripts",
25
+ "src",
26
+ "overlay/dist",
12
27
  ".codex-plugin",
28
+ ".claude-plugin/plugin.json",
13
29
  "overlay-release.json"
14
30
  ],
31
+ "publishConfig": {
32
+ "access": "public"
33
+ },
34
+ "bin": {
35
+ "gamepigeon": "./dist/index.js"
36
+ },
15
37
  "packageManager": "bun@1.3.11",
16
38
  "scripts": {
17
39
  "start": "bun src/index.ts",
@@ -29,10 +51,11 @@
29
51
  "test:overlay": "bun test scripts/overlay.test.ts scripts/plugin-update.test.ts scripts/version-bump.test.ts overlay/session.test.ts overlay/persistence.test.ts overlay/recording.test.ts overlay/renderers.test.ts overlay/motion.test.ts overlay/connect4-layout.test.ts overlay/analytics.test.ts overlay/tauri-bridge.test.ts",
30
52
  "test:plugin-update": "bun test scripts/plugin-update.test.ts scripts/run-plugin-update.test.ts",
31
53
  "test:launcher": "bun run test:overlay",
54
+ "test:npm-pack": "bun test scripts/npm-pack.test.ts",
32
55
  "typecheck": "tsc --noEmit && tsc -p overlay/tsconfig.json",
33
56
  "export:bc": "bun scripts/export-bc.ts",
34
57
  "version:bump": "bun scripts/version-bump.ts",
35
- "prepack": "bun run build"
58
+ "prepack": "bun run build && bun run build:overlay:web"
36
59
  },
37
60
  "engines": {
38
61
  "bun": ">=1.3"
@@ -112,7 +112,7 @@ if [[ "$install_codex" == true || "$install_claude" == true ]]; then
112
112
  cat > "$target/SKILL.md" <<EOF
113
113
  ---
114
114
  name: arcade
115
- description: Open or restore the native GamePigeon HTML overlay while the coding harness works.
115
+ description: Open or restore the native Tui HTML overlay while the coding harness works.
116
116
  ---
117
117
 
118
118
  # Arcade
@@ -125,7 +125,7 @@ Execute this command exactly once:
125
125
 
126
126
  Do not inspect the user's repository. Do not spawn another agent or task. Do not run any other command.
127
127
 
128
- GamePigeon opens as a borderless HTML overlay without a Dock or taskbar entry. Running Arcade again focuses the existing overlay.
128
+ Tui opens as a borderless HTML overlay without a Dock or taskbar entry. Running Arcade again focuses the existing overlay.
129
129
 
130
130
  On success, respond only with \`Arcade opened.\`. On failure, relay the launcher's error without attempting another launch method.
131
131
  EOF
@@ -137,14 +137,14 @@ if [[ "$install_opencode" == true ]]; then
137
137
  mkdir -p "$(dirname "$opencode_command")"
138
138
  cat > "$opencode_command" <<EOF
139
139
  ---
140
- description: Open or restore the GamePigeon Arcade overlay
140
+ description: Open or restore the Tui Arcade overlay
141
141
  ---
142
142
 
143
143
  <!-- $MARKER -->
144
144
 
145
145
  !\`$launcher_command\`
146
146
 
147
- The command opens GamePigeon as a borderless HTML overlay without a Dock or taskbar entry. Running \`/arcade\` again focuses the existing overlay.
147
+ The command opens Tui as a borderless HTML overlay without a Dock or taskbar entry. Running \`/arcade\` again focuses the existing overlay.
148
148
 
149
149
  Respond only with \`Arcade opened.\` when the launcher succeeds. If its output reports an error, relay that error without attempting another launch method.
150
150
  EOF
@@ -166,7 +166,7 @@ if [[ "$install_openclaw" == true ]]; then
166
166
  cat > "$OPENCLAW_HOME/hooks/arcade/HOOK.md" <<EOF
167
167
  ---
168
168
  name: arcade
169
- description: "Open or restore the GamePigeon Arcade overlay"
169
+ description: "Open or restore the Tui Arcade overlay"
170
170
  metadata:
171
171
  {
172
172
  "openclaw":
@@ -178,7 +178,7 @@ metadata:
178
178
  }
179
179
  ---
180
180
  # Arcade Hook
181
- This hook manages the GamePigeon Arcade overlay lifecycle.
181
+ This hook manages the Tui Arcade overlay lifecycle.
182
182
  EOF
183
183
 
184
184
  cat > "$OPENCLAW_HOME/hooks/arcade/handler.js" <<EOF
@@ -22,7 +22,7 @@ for skill_file in \
22
22
  grep -q '^name: arcade$' "$skill_file"
23
23
  grep -Fq "bun $ROOT_DIR/scripts/overlay.ts --enable" "$skill_file"
24
24
  grep -Fq 'Do not spawn another agent or task.' "$skill_file"
25
- grep -Fq 'GamePigeon opens as a borderless HTML overlay' "$skill_file"
25
+ grep -Fq 'Tui opens as a borderless HTML overlay' "$skill_file"
26
26
  done
27
27
 
28
28
  test -f "$TEMP_DIR/openclaw/hooks/arcade/HOOK.md"
@@ -31,7 +31,7 @@ test -f "$TEMP_DIR/antigravity/.agents/plugins/tui-gamepigeon/plugin.json"
31
31
 
32
32
  opencode_command="$TEMP_DIR/opencode/commands/arcade.md"
33
33
  test -f "$opencode_command"
34
- grep -q '^description: Open or restore the GamePigeon Arcade overlay$' "$opencode_command"
34
+ grep -q '^description: Open or restore the Tui Arcade overlay$' "$opencode_command"
35
35
  grep -Fq "!\`bun $ROOT_DIR/scripts/overlay.ts --enable\`" "$opencode_command"
36
36
  grep -Fq 'Running `/arcade` again focuses the existing overlay.' "$opencode_command"
37
37
 
@@ -19,7 +19,7 @@ cat > "$WORKSPACE_DIR/.agents/plugins/marketplace.json" <<EOF
19
19
  {
20
20
  "name": "tui-gamepigeon-dev",
21
21
  "interface": {
22
- "displayName": "GamePigeon (local checkout)"
22
+ "displayName": "Tui (local checkout)"
23
23
  },
24
24
  "plugins": [
25
25
  {
@@ -40,7 +40,7 @@ EOF
40
40
  cat > "$PLUGIN_DIR/skills/arcade/SKILL.md" <<EOF
41
41
  ---
42
42
  name: arcade
43
- description: Open or restore the native GamePigeon HTML overlay while the coding harness works.
43
+ description: Open or restore the native Tui HTML overlay while the coding harness works.
44
44
  ---
45
45
 
46
46
  # Arcade
@@ -53,7 +53,7 @@ Execute this command exactly once:
53
53
 
54
54
  Do not inspect the user's repository. Do not spawn another agent or task. Do not run any other command.
55
55
 
56
- GamePigeon opens as a borderless HTML overlay without a Dock or taskbar entry. Running Arcade again focuses the existing overlay.
56
+ Tui opens as a borderless HTML overlay without a Dock or taskbar entry. Running Arcade again focuses the existing overlay.
57
57
 
58
58
  On success, respond only with \`Arcade opened.\`. On failure, relay the launcher's error without attempting another launch method.
59
59
  EOF
@@ -0,0 +1,51 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { spawnSync } from "node:child_process";
3
+ import { mkdtempSync, readFileSync, rmSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { tmpdir } from "node:os";
6
+
7
+ describe("npm pack layout", () => {
8
+ test("tarball includes overlay web bundle and excludes native build tree", () => {
9
+ const dir = mkdtempSync(join(tmpdir(), "gamepigeon-pack-list-"));
10
+ try {
11
+ const pack = spawnSync("npm", ["pack", "--pack-destination", dir], {
12
+ encoding: "utf8",
13
+ cwd: join(import.meta.dir, ".."),
14
+ });
15
+ expect(pack.status).toBe(0);
16
+ const tgz = pack.stdout.trim().split("\n").pop()!;
17
+ const list = spawnSync("tar", ["-tzf", join(dir, tgz)], { encoding: "utf8" });
18
+ expect(list.status).toBe(0);
19
+ const paths = list.stdout.split("\n").filter(Boolean);
20
+ expect(paths.some((p) => p.includes("package/overlay/dist/"))).toBe(true);
21
+ // install-adapters.sh generates the Codex/Claude "Arcade" skill as a direct
22
+ // `bun scripts/overlay.ts --enable` invocation; dist/index.js has no equivalent
23
+ // flag, so the raw script (and its ../src imports) must ship too.
24
+ expect(paths.some((p) => p.endsWith("package/scripts/overlay.ts"))).toBe(true);
25
+ expect(paths.some((p) => p.endsWith("package/dist/index.js"))).toBe(true);
26
+ expect(paths.some((p) => p.endsWith("package/overlay-release.json"))).toBe(true);
27
+ expect(paths.some((p) => p.includes("/tauri/"))).toBe(false);
28
+ } finally {
29
+ rmSync(dir, { recursive: true, force: true });
30
+ }
31
+ });
32
+
33
+ test("packed install exposes gamepigeon bin", () => {
34
+ const dir = mkdtempSync(join(tmpdir(), "gamepigeon-pack-"));
35
+ try {
36
+ const pack = spawnSync("npm", ["pack", "--pack-destination", dir], {
37
+ encoding: "utf8",
38
+ cwd: join(import.meta.dir, ".."),
39
+ });
40
+ expect(pack.status).toBe(0);
41
+ const tgz = pack.stdout.trim().split("\n").pop()!;
42
+ const tgzPath = join(dir, tgz);
43
+ const extract = spawnSync("tar", ["-xzf", tgzPath, "-C", dir], { encoding: "utf8" });
44
+ expect(extract.status).toBe(0);
45
+ const pkg = JSON.parse(readFileSync(join(dir, "package", "package.json"), "utf8"));
46
+ expect(pkg.bin.gamepigeon).toBe("./dist/index.js");
47
+ } finally {
48
+ rmSync(dir, { recursive: true, force: true });
49
+ }
50
+ });
51
+ });
@@ -161,7 +161,7 @@ export function resolveOverlayBinary(root = ROOT_DIR, manifest = readReleaseMani
161
161
  if (isLaunchable(candidate)) return candidate;
162
162
  }
163
163
  throw new Error(
164
- "GamePigeon overlay binary was not found. Run `bun run build:overlay` or install a packaged release.",
164
+ "Tui overlay binary was not found. Run `bun run build:overlay` or install a packaged release.",
165
165
  );
166
166
  }
167
167
 
@@ -0,0 +1,77 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ ANALYTICS_EVENT_TYPES,
4
+ createAnalyticsEvent,
5
+ createAuthAnalyticsEvent,
6
+ } from "./events.js";
7
+
8
+ describe("analytics CloudEvents contract", () => {
9
+ it("builds a CloudEvents 1.0 envelope with typed product data", () => {
10
+ expect(createAnalyticsEvent(
11
+ ANALYTICS_EVENT_TYPES.gameEnded,
12
+ {
13
+ game_type: "2048",
14
+ variation_id: "classic",
15
+ outcome: "completed",
16
+ score: 2048,
17
+ success: true,
18
+ duration_ms: 12_000,
19
+ actions_taken: 42,
20
+ },
21
+ {
22
+ id: "evt_test",
23
+ subject: "game_test",
24
+ time: new Date("2026-07-17T12:00:00.000Z"),
25
+ },
26
+ )).toEqual({
27
+ specversion: "1.0",
28
+ id: "evt_test",
29
+ source: "/tui-gamepigeon/client",
30
+ type: "game.ended",
31
+ time: "2026-07-17T12:00:00.000Z",
32
+ datacontenttype: "application/json",
33
+ subject: "game_test",
34
+ data: {
35
+ game_type: "2048",
36
+ variation_id: "classic",
37
+ outcome: "completed",
38
+ score: 2048,
39
+ success: true,
40
+ duration_ms: 12_000,
41
+ actions_taken: 42,
42
+ },
43
+ });
44
+ });
45
+
46
+ it("keeps application and game-start payloads to their public product fields", () => {
47
+ const options = { id: "evt_test", time: new Date("2026-07-17T12:00:00.000Z") };
48
+
49
+ expect(createAnalyticsEvent(
50
+ ANALYTICS_EVENT_TYPES.appOpened,
51
+ { app_version: "0.2.0", platform: "tui" },
52
+ options,
53
+ ).data).toEqual({ app_version: "0.2.0", platform: "tui" });
54
+
55
+ expect(createAnalyticsEvent(
56
+ ANALYTICS_EVENT_TYPES.gameStarted,
57
+ { game_type: "2048", variation_id: "classic" },
58
+ { ...options, subject: "game_test" },
59
+ )).toMatchObject({
60
+ type: "game.started",
61
+ subject: "game_test",
62
+ data: { game_type: "2048", variation_id: "classic" },
63
+ });
64
+ });
65
+
66
+ it("keeps auth events pseudonymous and limited to the sign-in method", () => {
67
+ expect(createAuthAnalyticsEvent({
68
+ kind: "signed_in",
69
+ method: "github",
70
+ userId: "usr_test",
71
+ })).toMatchObject({
72
+ type: "account.signed_in",
73
+ subject: "usr_test",
74
+ data: { method: "github" },
75
+ });
76
+ });
77
+ });
@@ -0,0 +1,85 @@
1
+ import { createEventId } from "../recording/ids.js";
2
+
3
+ export const ANALYTICS_EVENT_TYPES = {
4
+ appOpened: "app.opened",
5
+ accountSignedUp: "account.signed_up",
6
+ accountSignedIn: "account.signed_in",
7
+ gameStarted: "game.started",
8
+ gameEnded: "game.ended",
9
+ pluginLaunched: "plugin.launched",
10
+ pluginReattached: "plugin.reattached",
11
+ agentTaskCompleted: "agent.task_completed",
12
+ siteVisited: "site.visited",
13
+ referralCreated: "referral.created",
14
+ referralRedeemed: "referral.redeemed",
15
+ paymentSucceeded: "payment.succeeded",
16
+ } as const;
17
+
18
+ export type AnalyticsEventType =
19
+ (typeof ANALYTICS_EVENT_TYPES)[keyof typeof ANALYTICS_EVENT_TYPES];
20
+ export type AuthMethod = "password" | "google" | "github";
21
+
22
+ export interface AnalyticsEventDataMap {
23
+ "app.opened": { app_version: string; platform: "tui" | "overlay" };
24
+ "account.signed_up": { method: AuthMethod };
25
+ "account.signed_in": { method: AuthMethod };
26
+ "game.started": { game_type: string; variation_id: string };
27
+ "game.ended": {
28
+ game_type: string;
29
+ variation_id: string;
30
+ outcome: "completed" | "abandoned";
31
+ score: number;
32
+ success: boolean;
33
+ duration_ms: number;
34
+ actions_taken: number;
35
+ };
36
+ "plugin.launched": { harness: string };
37
+ "plugin.reattached": { harness: string };
38
+ "agent.task_completed": { harness: string };
39
+ "site.visited": { referrer_host?: string; campaign?: string };
40
+ "referral.created": { channel?: string };
41
+ "referral.redeemed": { channel?: string };
42
+ "payment.succeeded": { plan: string; currency: string; amount_minor: number };
43
+ }
44
+
45
+ export interface AnalyticsCloudEvent<T extends AnalyticsEventType = AnalyticsEventType> {
46
+ specversion: "1.0";
47
+ id: string;
48
+ source: string;
49
+ type: T;
50
+ time: string;
51
+ datacontenttype: "application/json";
52
+ subject?: string;
53
+ data: AnalyticsEventDataMap[T];
54
+ }
55
+
56
+ export function createAnalyticsEvent<T extends AnalyticsEventType>(
57
+ type: T,
58
+ data: AnalyticsEventDataMap[T],
59
+ options: { id?: string; source?: string; subject?: string; time?: Date } = {},
60
+ ): AnalyticsCloudEvent<T> {
61
+ return {
62
+ specversion: "1.0",
63
+ id: options.id ?? createEventId(),
64
+ source: options.source ?? "/tui-gamepigeon/client",
65
+ type,
66
+ time: (options.time ?? new Date()).toISOString(),
67
+ datacontenttype: "application/json",
68
+ ...(options.subject ? { subject: options.subject } : {}),
69
+ data,
70
+ };
71
+ }
72
+
73
+ export function createAuthAnalyticsEvent(input: {
74
+ kind: "signed_up" | "signed_in";
75
+ method: AuthMethod;
76
+ userId?: string;
77
+ }): AnalyticsCloudEvent<"account.signed_up" | "account.signed_in"> {
78
+ return createAnalyticsEvent(
79
+ input.kind === "signed_up"
80
+ ? ANALYTICS_EVENT_TYPES.accountSignedUp
81
+ : ANALYTICS_EVENT_TYPES.accountSignedIn,
82
+ { method: input.method },
83
+ { subject: input.userId },
84
+ );
85
+ }
@@ -0,0 +1,85 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { PostHog } from "posthog-node";
3
+ import { ANALYTICS_EVENT_TYPES, createAnalyticsEvent } from "./events.js";
4
+ import { Analytics } from "./posthog.js";
5
+
6
+ type Client = Pick<PostHog, "capture" | "shutdown">;
7
+
8
+ const gameEnded = createAnalyticsEvent(
9
+ ANALYTICS_EVENT_TYPES.gameEnded,
10
+ {
11
+ game_type: "2048",
12
+ variation_id: "classic",
13
+ outcome: "completed",
14
+ score: 2048,
15
+ success: true,
16
+ duration_ms: 12_000,
17
+ actions_taken: 42,
18
+ },
19
+ {
20
+ id: "evt_test",
21
+ subject: "game_test",
22
+ time: new Date("2026-07-17T12:00:00.000Z"),
23
+ },
24
+ );
25
+
26
+ describe("PostHog analytics", () => {
27
+ it("maps a CloudEvent into one PostHog event", () => {
28
+ const captures: unknown[] = [];
29
+ const client = {
30
+ capture: (event: unknown) => { captures.push(event); },
31
+ shutdown: async () => {},
32
+ } as Client;
33
+
34
+ new Analytics(client, "usr_test").capture(gameEnded);
35
+
36
+ expect(captures).toEqual([{
37
+ distinctId: "usr_test",
38
+ event: "game.ended",
39
+ timestamp: new Date("2026-07-17T12:00:00.000Z"),
40
+ properties: {
41
+ game_type: "2048",
42
+ variation_id: "classic",
43
+ outcome: "completed",
44
+ score: 2048,
45
+ success: true,
46
+ duration_ms: 12_000,
47
+ actions_taken: 42,
48
+ $insert_id: "evt_test",
49
+ cloud_event_id: "evt_test",
50
+ cloud_event_source: "/tui-gamepigeon/client",
51
+ cloud_event_specversion: "1.0",
52
+ cloud_event_datacontenttype: "application/json",
53
+ cloud_event_subject: "game_test",
54
+ },
55
+ }]);
56
+ });
57
+
58
+ it("is a no-op without a configured client", () => {
59
+ expect(() => new Analytics(null, "usr_test").capture(gameEnded)).not.toThrow();
60
+ });
61
+
62
+ it("does not leak capture failures into gameplay", () => {
63
+ const client = {
64
+ capture: () => { throw new Error("offline"); },
65
+ shutdown: async () => {},
66
+ } as Client;
67
+
68
+ expect(() => new Analytics(client, "usr_test").capture(gameEnded)).not.toThrow();
69
+ });
70
+
71
+ it("uses a newly authenticated user id for later events", () => {
72
+ const distinctIds: unknown[] = [];
73
+ const client = {
74
+ capture: (event: { distinctId?: string }) => { distinctIds.push(event.distinctId); },
75
+ shutdown: async () => {},
76
+ } as Client;
77
+ const instance = new Analytics(client, "device_test");
78
+
79
+ instance.capture(gameEnded);
80
+ instance.setDistinctId("usr_test");
81
+ instance.capture(gameEnded);
82
+
83
+ expect(distinctIds).toEqual(["device_test", "usr_test"]);
84
+ });
85
+ });
@@ -0,0 +1,88 @@
1
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { randomUUID } from "node:crypto";
5
+ import { PostHog } from "posthog-node";
6
+ import { storedUserId } from "../auth.js";
7
+ import type { AnalyticsCloudEvent } from "./events.js";
8
+
9
+ /**
10
+ * Product analytics (DAU / feature usage), separate from the RL trajectory
11
+ * pipeline in data/trajectory.ts. This never blocks or crashes gameplay —
12
+ * if there's no API key or a network error, capture() calls are no-ops.
13
+ */
14
+
15
+ function createClient(): PostHog | null {
16
+ const apiKey = process.env.POSTHOG_API_KEY;
17
+ return apiKey
18
+ ? new PostHog(apiKey, { host: process.env.POSTHOG_HOST ?? "https://us.i.posthog.com" })
19
+ : null;
20
+ }
21
+
22
+ function deviceIdFile(): string {
23
+ return join(homedir(), ".tui-gamepigeon", "device_id");
24
+ }
25
+
26
+ function getDeviceId(): string {
27
+ const file = deviceIdFile();
28
+ try {
29
+ if (existsSync(file)) return readFileSync(file, "utf8").trim();
30
+ } catch {
31
+ // fall through to generating a fresh id
32
+ }
33
+ const id = randomUUID();
34
+ try {
35
+ mkdirSync(join(homedir(), ".tui-gamepigeon"), { recursive: true });
36
+ writeFileSync(file, id);
37
+ } catch {
38
+ // best-effort; an in-memory id still works for this process
39
+ }
40
+ return id;
41
+ }
42
+
43
+ export class Analytics {
44
+ constructor(
45
+ private client: Pick<PostHog, "capture" | "shutdown"> | null = createClient(),
46
+ private distinctId = storedUserId() ?? getDeviceId(),
47
+ ) {}
48
+
49
+ setDistinctId(distinctId: string): void {
50
+ if (distinctId) this.distinctId = distinctId;
51
+ }
52
+
53
+ resetDistinctId(): void {
54
+ this.distinctId = getDeviceId();
55
+ }
56
+
57
+ capture(event: AnalyticsCloudEvent): void {
58
+ if (!this.client) return;
59
+ try {
60
+ this.client.capture({
61
+ distinctId: this.distinctId,
62
+ event: event.type,
63
+ timestamp: new Date(event.time),
64
+ properties: {
65
+ ...event.data,
66
+ $insert_id: event.id,
67
+ cloud_event_id: event.id,
68
+ cloud_event_source: event.source,
69
+ cloud_event_specversion: event.specversion,
70
+ cloud_event_datacontenttype: event.datacontenttype,
71
+ ...(event.subject ? { cloud_event_subject: event.subject } : {}),
72
+ },
73
+ });
74
+ } catch {
75
+ // analytics must never crash gameplay
76
+ }
77
+ }
78
+
79
+ async shutdown(): Promise<void> {
80
+ try {
81
+ await this.client?.shutdown();
82
+ } catch {
83
+ // ignore
84
+ }
85
+ }
86
+ }
87
+
88
+ export const analytics = new Analytics();
@@ -0,0 +1,108 @@
1
+ // The pages the browser lands on at the end of a sign-in handoff, served by the
2
+ // localhost listener in `auth.ts`. This is the last thing a user sees before
3
+ // returning to the terminal, so it confirms the outcome instead of leaving a
4
+ // bare sentence on a white page.
5
+ //
6
+ // The same design is rendered by `cloudflare/auth-worker/src/handoff-page.ts`
7
+ // and `tauri/src/main.rs`; the three copies exist because they run in three
8
+ // different runtimes. Keep the copy and palette in sync when changing one.
9
+
10
+ const STYLE = `
11
+ :root{color-scheme:light}
12
+ *{box-sizing:border-box}
13
+ body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px;
14
+ background:radial-gradient(900px 500px at 50% -20%,#fff 0%,#eef2f8 60%,#e3eaf4 100%);color:#0f172a;
15
+ font:15px/1.6 system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",sans-serif;-webkit-font-smoothing:antialiased}
16
+ .card{position:relative;overflow:hidden;width:min(100%,420px);padding:34px 28px 30px;text-align:center;
17
+ background:#fff;border:1px solid #e2e8f0;border-radius:16px;
18
+ box-shadow:0 20px 50px rgba(15,23,42,.12),0 8px 20px rgba(15,23,42,.06)}
19
+ .card::before{content:"";position:absolute;top:0;left:0;right:0;height:4px;
20
+ background:linear-gradient(90deg,#2955c9 0%,#3772ff 38%,#3bb273 72%,#e6a830 100%)}
21
+ .badge{display:flex;align-items:center;justify-content:center;width:44px;height:44px;margin:0 auto 16px;border-radius:999px}
22
+ .badge svg{width:22px;height:22px}
23
+ [data-tone=ok] .badge{background:rgba(59,178,115,.14);color:#2e8f5c}
24
+ [data-tone=warn] .badge{background:rgba(223,41,53,.12);color:#b81f2a}
25
+ .eyebrow{margin:0 0 6px;font-size:11px;font-weight:600;letter-spacing:.14em;text-transform:uppercase;color:#2955c9}
26
+ h1{margin:0 0 10px;font-size:23px;line-height:1.25;letter-spacing:-.01em}
27
+ .lead{margin:0;color:#475569}
28
+ .steps{margin:18px 0 0;padding:0;list-style:none;text-align:left;border-top:1px solid #e2e8f0}
29
+ .steps li{position:relative;margin:0;padding:10px 0 0 18px;font-size:13.5px;color:#475569}
30
+ .steps li:first-child{padding-top:14px}
31
+ .steps li::before{content:"";position:absolute;left:2px;top:18px;width:5px;height:5px;border-radius:999px;background:#cbd5e1}
32
+ .steps li:first-child::before{top:22px}
33
+ .hint{margin:16px 0 0;font-size:12.5px;color:#64748b}
34
+ code{font:600 12.5px/1.4 ui-monospace,"SF Mono",Menlo,monospace;background:#f3f6fb;border:1px solid #e2e8f0;border-radius:5px;padding:2px 5px;overflow-wrap:anywhere;-webkit-box-decoration-break:clone;box-decoration-break:clone}
35
+ @media (max-width:400px){.card{padding:28px 20px 24px}h1{font-size:21px}}
36
+ `.trim();
37
+
38
+ const CHECK_ICON =
39
+ '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 6 9 17l-5-5"/></svg>';
40
+ const ALERT_ICON =
41
+ '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 7.5v5.5"/><path d="M12 16.5h.01"/></svg>';
42
+
43
+ export function escapeHtml(value: string): string {
44
+ return value.replace(
45
+ /[&<>"']/g,
46
+ (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[char]!,
47
+ );
48
+ }
49
+
50
+ // `lead` and `steps` are interpolated as trusted HTML so copy can carry <code>
51
+ // spans; the title is escaped. Escape anything request-derived at the call site.
52
+ interface PageOptions {
53
+ tone: "ok" | "warn";
54
+ title: string;
55
+ lead: string;
56
+ steps?: string[];
57
+ hint?: string;
58
+ }
59
+
60
+ /** Renders one self-contained Arcade page — no external CSS, fonts, or images. */
61
+ function arcadePage({ tone, title, lead, steps, hint }: PageOptions): string {
62
+ const list = steps?.length
63
+ ? `<ul class="steps">${steps.map((step) => `<li>${step}</li>`).join("")}</ul>`
64
+ : "";
65
+ return `<!doctype html>
66
+ <html lang="en">
67
+ <head>
68
+ <meta charset="utf-8">
69
+ <meta name="viewport" content="width=device-width, initial-scale=1">
70
+ <meta name="robots" content="noindex">
71
+ <title>Retro Arcade — ${escapeHtml(title)}</title>
72
+ <style>${STYLE}</style>
73
+ </head>
74
+ <body>
75
+ <main class="card" data-tone="${tone}">
76
+ <span class="badge">${tone === "ok" ? CHECK_ICON : ALERT_ICON}</span>
77
+ <p class="eyebrow">Retro Arcade</p>
78
+ <h1>${escapeHtml(title)}</h1>
79
+ <p class="lead">${lead}</p>
80
+ ${list}
81
+ ${hint ? `<p class="hint">${hint}</p>` : ""}
82
+ </main>
83
+ </body>
84
+ </html>`;
85
+ }
86
+
87
+ export function signedInPage(): string {
88
+ return arcadePage({
89
+ tone: "ok",
90
+ title: "You're signed in",
91
+ lead: "Retro Arcade has your session. You can close this tab and return to your terminal.",
92
+ hint: "Your session is saved in <code>~/.tui-gamepigeon/auth.json</code>, so you stay signed in next time.",
93
+ });
94
+ }
95
+
96
+ /** `reason` is shown verbatim — pass short, already-escaped copy. */
97
+ export function signInFailedPage(reason: string): string {
98
+ return arcadePage({
99
+ tone: "warn",
100
+ title: "Sign-in didn't finish",
101
+ lead: reason,
102
+ steps: [
103
+ "Return to your terminal and run <code>gamepigeon auth login</code> again.",
104
+ "If the browser blocked the redirect back, allow pop-ups for the Arcade login page.",
105
+ "Signing in with Google or GitHub? Approve the consent screen rather than closing it.",
106
+ ],
107
+ });
108
+ }