@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
@@ -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,16 +1,39 @@
1
1
  {
2
2
  "name": "@trolleroof/tui",
3
- "version": "0.2.4",
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",
27
+ ".codex-plugin",
28
+ ".claude-plugin/plugin.json",
12
29
  "overlay-release.json"
13
30
  ],
31
+ "publishConfig": {
32
+ "access": "public"
33
+ },
34
+ "bin": {
35
+ "gamepigeon": "./dist/index.js"
36
+ },
14
37
  "packageManager": "bun@1.3.11",
15
38
  "scripts": {
16
39
  "start": "bun src/index.ts",
@@ -28,10 +51,11 @@
28
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",
29
52
  "test:plugin-update": "bun test scripts/plugin-update.test.ts scripts/run-plugin-update.test.ts",
30
53
  "test:launcher": "bun run test:overlay",
54
+ "test:npm-pack": "bun test scripts/npm-pack.test.ts",
31
55
  "typecheck": "tsc --noEmit && tsc -p overlay/tsconfig.json",
32
56
  "export:bc": "bun scripts/export-bc.ts",
33
57
  "version:bump": "bun scripts/version-bump.ts",
34
- "prepack": "bun run build"
58
+ "prepack": "bun run build && bun run build:overlay:web"
35
59
  },
36
60
  "engines": {
37
61
  "bun": ">=1.3"
@@ -11,9 +11,9 @@ MARKER="Generated by tui-gamepigeon scripts/install-adapters.sh"
11
11
 
12
12
  usage() {
13
13
  cat <<'EOF'
14
- Usage: scripts/install-adapters.sh [--codex|--claude|--opencode|--hermes|--openclaw|--all]
14
+ Usage: scripts/install-adapters.sh [--codex|--claude|--opencode|--hermes|--openclaw|--antigravity|--all]
15
15
 
16
- Install the Arcade command for Codex, Claude Code, OpenCode, Hermes, OpenClaw, or all of them (the default).
16
+ Install the Arcade command for Codex, Claude Code, OpenCode, Hermes, OpenClaw, Antigravity, or all of them (the default).
17
17
  EOF
18
18
  }
19
19
 
@@ -22,6 +22,7 @@ install_claude=false
22
22
  install_opencode=false
23
23
  install_hermes=false
24
24
  install_openclaw=false
25
+ install_antigravity=false
25
26
 
26
27
  case "${1:---all}" in
27
28
  --all)
@@ -30,6 +31,7 @@ case "${1:---all}" in
30
31
  install_opencode=true
31
32
  install_hermes=true
32
33
  install_openclaw=true
34
+ install_antigravity=true
33
35
  ;;
34
36
  --codex)
35
37
  install_codex=true
@@ -46,6 +48,9 @@ case "${1:---all}" in
46
48
  --openclaw)
47
49
  install_openclaw=true
48
50
  ;;
51
+ --antigravity)
52
+ install_antigravity=true
53
+ ;;
49
54
  -h|--help)
50
55
  usage
51
56
  exit 0
@@ -86,12 +91,18 @@ if [[ "$install_opencode" == true ]]; then
86
91
  files_to_check+=("$opencode_command")
87
92
  fi
88
93
 
89
- for generated_file in "${files_to_check[@]}"; do
90
- if [[ -f "$generated_file" ]] && ! grep -Fq "$MARKER" "$generated_file"; then
91
- echo "Refusing to replace an existing adapter: $generated_file" >&2
92
- exit 1
93
- fi
94
- done
94
+ if (( ${#files_to_check[@]} )); then
95
+ for generated_file in "${files_to_check[@]}"; do
96
+ if [[ -f "$generated_file" ]] && ! grep -Fq "$MARKER" "$generated_file"; then
97
+ echo "Refusing to replace an existing adapter: $generated_file" >&2
98
+ exit 1
99
+ fi
100
+ done
101
+ fi
102
+
103
+ if [[ "$install_antigravity" == true ]]; then
104
+ bash "$ROOT_DIR/scripts/materialize-agents-plugin.sh" "${GAMEPIGEON_WORKSPACE_DIR:-$PWD}"
105
+ fi
95
106
 
96
107
  printf -v launcher_command 'bun %q --enable' "$ROOT_DIR/scripts/overlay.ts"
97
108
 
@@ -101,7 +112,7 @@ if [[ "$install_codex" == true || "$install_claude" == true ]]; then
101
112
  cat > "$target/SKILL.md" <<EOF
102
113
  ---
103
114
  name: arcade
104
- 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.
105
116
  ---
106
117
 
107
118
  # Arcade
@@ -114,7 +125,7 @@ Execute this command exactly once:
114
125
 
115
126
  Do not inspect the user's repository. Do not spawn another agent or task. Do not run any other command.
116
127
 
117
- 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.
118
129
 
119
130
  On success, respond only with \`Arcade opened.\`. On failure, relay the launcher's error without attempting another launch method.
120
131
  EOF
@@ -126,14 +137,14 @@ if [[ "$install_opencode" == true ]]; then
126
137
  mkdir -p "$(dirname "$opencode_command")"
127
138
  cat > "$opencode_command" <<EOF
128
139
  ---
129
- description: Open or restore the GamePigeon Arcade overlay
140
+ description: Open or restore the Tui Arcade overlay
130
141
  ---
131
142
 
132
143
  <!-- $MARKER -->
133
144
 
134
145
  !\`$launcher_command\`
135
146
 
136
- 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.
137
148
 
138
149
  Respond only with \`Arcade opened.\` when the launcher succeeds. If its output reports an error, relay that error without attempting another launch method.
139
150
  EOF
@@ -155,7 +166,7 @@ if [[ "$install_openclaw" == true ]]; then
155
166
  cat > "$OPENCLAW_HOME/hooks/arcade/HOOK.md" <<EOF
156
167
  ---
157
168
  name: arcade
158
- description: "Open or restore the GamePigeon Arcade overlay"
169
+ description: "Open or restore the Tui Arcade overlay"
159
170
  metadata:
160
171
  {
161
172
  "openclaw":
@@ -167,7 +178,7 @@ metadata:
167
178
  }
168
179
  ---
169
180
  # Arcade Hook
170
- This hook manages the GamePigeon Arcade overlay lifecycle.
181
+ This hook manages the Tui Arcade overlay lifecycle.
171
182
  EOF
172
183
 
173
184
  cat > "$OPENCLAW_HOME/hooks/arcade/handler.js" <<EOF
@@ -10,6 +10,7 @@ CLAUDE_CONFIG_DIR="$TEMP_DIR/claude" \
10
10
  OPENCODE_CONFIG_DIR="$TEMP_DIR/opencode" \
11
11
  HERMES_HOME="$TEMP_DIR/hermes" \
12
12
  OPENCLAW_HOME="$TEMP_DIR/openclaw" \
13
+ GAMEPIGEON_WORKSPACE_DIR="$TEMP_DIR/antigravity" \
13
14
  bash "$ROOT_DIR/scripts/install-adapters.sh"
14
15
 
15
16
  for skill_file in \
@@ -21,15 +22,16 @@ for skill_file in \
21
22
  grep -q '^name: arcade$' "$skill_file"
22
23
  grep -Fq "bun $ROOT_DIR/scripts/overlay.ts --enable" "$skill_file"
23
24
  grep -Fq 'Do not spawn another agent or task.' "$skill_file"
24
- grep -Fq 'GamePigeon opens as a borderless HTML overlay' "$skill_file"
25
+ grep -Fq 'Tui opens as a borderless HTML overlay' "$skill_file"
25
26
  done
26
27
 
27
28
  test -f "$TEMP_DIR/openclaw/hooks/arcade/HOOK.md"
28
29
  test -f "$TEMP_DIR/openclaw/hooks/arcade/handler.js"
30
+ test -f "$TEMP_DIR/antigravity/.agents/plugins/tui-gamepigeon/plugin.json"
29
31
 
30
32
  opencode_command="$TEMP_DIR/opencode/commands/arcade.md"
31
33
  test -f "$opencode_command"
32
- 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"
33
35
  grep -Fq "!\`bun $ROOT_DIR/scripts/overlay.ts --enable\`" "$opencode_command"
34
36
  grep -Fq 'Running `/arcade` again focuses the existing overlay.' "$opencode_command"
35
37
 
@@ -54,6 +56,14 @@ OPENCLAW_HOME="$TEMP_DIR/cli-openclaw" \
54
56
  bun "$ROOT_DIR/dist/index.js" install --codex
55
57
  test -f "$CLI_CODEX_HOME/skills/arcade/SKILL.md"
56
58
 
59
+ CLI_WORKSPACE="$TEMP_DIR/cli-antigravity"
60
+ mkdir -p "$CLI_WORKSPACE"
61
+ (
62
+ cd "$CLI_WORKSPACE"
63
+ bun "$ROOT_DIR/dist/index.js" install --antigravity
64
+ )
65
+ test -f "$CLI_WORKSPACE/.agents/plugins/tui-gamepigeon/plugin.json"
66
+
57
67
  OPENCODE_ONLY="$TEMP_DIR/opencode-only"
58
68
  CODEX_UNUSED="$TEMP_DIR/codex-unused"
59
69
  CLAUDE_UNUSED_2="$TEMP_DIR/claude-unused-2"
@@ -2,7 +2,8 @@
2
2
  set -euo pipefail
3
3
 
4
4
  ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
5
- PLUGIN_DIR="$ROOT_DIR/.agents/plugins/tui-gamepigeon"
5
+ WORKSPACE_DIR="${1:-${GAMEPIGEON_WORKSPACE_DIR:-$ROOT_DIR}}"
6
+ PLUGIN_DIR="$WORKSPACE_DIR/.agents/plugins/tui-gamepigeon"
6
7
  MARKER="Generated by tui-gamepigeon scripts/materialize-agents-plugin.sh"
7
8
 
8
9
  printf -v overlay_cmd 'bun %q' "$ROOT_DIR/scripts/overlay.ts"
@@ -13,11 +14,12 @@ rm -f "$PLUGIN_DIR/plugin.json" "$PLUGIN_DIR/hooks.json" "$PLUGIN_DIR/skills"
13
14
  mkdir -p "$PLUGIN_DIR/skills/arcade"
14
15
  install -m 644 "$ROOT_DIR/.codex-plugin/plugin.json" "$PLUGIN_DIR/plugin.json"
15
16
 
16
- cat > "$ROOT_DIR/.agents/plugins/marketplace.json" <<EOF
17
+ mkdir -p "$WORKSPACE_DIR/.agents/plugins"
18
+ cat > "$WORKSPACE_DIR/.agents/plugins/marketplace.json" <<EOF
17
19
  {
18
20
  "name": "tui-gamepigeon-dev",
19
21
  "interface": {
20
- "displayName": "GamePigeon (local checkout)"
22
+ "displayName": "Tui (local checkout)"
21
23
  },
22
24
  "plugins": [
23
25
  {
@@ -38,7 +40,7 @@ EOF
38
40
  cat > "$PLUGIN_DIR/skills/arcade/SKILL.md" <<EOF
39
41
  ---
40
42
  name: arcade
41
- 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.
42
44
  ---
43
45
 
44
46
  # Arcade
@@ -51,7 +53,7 @@ Execute this command exactly once:
51
53
 
52
54
  Do not inspect the user's repository. Do not spawn another agent or task. Do not run any other command.
53
55
 
54
- 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.
55
57
 
56
58
  On success, respond only with \`Arcade opened.\`. On failure, relay the launcher's error without attempting another launch method.
57
59
  EOF
@@ -82,4 +84,4 @@ cat > "$PLUGIN_DIR/hooks.json" <<EOF
82
84
  }
83
85
  EOF
84
86
 
85
- echo "Materialized Codex/Antigravity workspace plugin at $PLUGIN_DIR"
87
+ echo "Materialized Antigravity workspace plugin at $PLUGIN_DIR"
@@ -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();