@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.
- package/.claude-plugin/plugin.json +10 -0
- package/.codex-plugin/plugin.json +2 -2
- package/README.md +29 -7
- package/dist/index.js +284 -18
- package/overlay/dist/app.js +74 -0
- package/overlay/dist/index.html +1589 -0
- package/overlay-release.json +2 -2
- package/package.json +27 -4
- package/scripts/install-adapters.sh +6 -6
- package/scripts/install-adapters.test.sh +2 -2
- package/scripts/materialize-agents-plugin.sh +3 -3
- package/scripts/npm-pack.test.ts +51 -0
- package/scripts/overlay.ts +1 -1
- package/src/analytics/events.test.ts +77 -0
- package/src/analytics/events.ts +85 -0
- package/src/analytics/posthog.test.ts +85 -0
- package/src/analytics/posthog.ts +88 -0
- package/src/auth-pages.ts +108 -0
- package/src/auth.test.ts +255 -0
- package/src/auth.ts +511 -0
- package/src/core/game.ts +35 -0
- package/src/core/key.ts +12 -0
- package/src/core/rng.ts +41 -0
- package/src/data/bc-export.test.ts +153 -0
- package/src/data/bc-export.ts +125 -0
- package/src/data/trajectory.ts +103 -0
- package/src/games/connect4/connect4.ts +81 -0
- package/src/games/connect4/engine.test.ts +77 -0
- package/src/games/connect4/engine.ts +89 -0
- package/src/games/g2048/engine.test.ts +138 -0
- package/src/games/g2048/engine.ts +109 -0
- package/src/games/g2048/game2048.ts +104 -0
- package/src/games/minesweeper/engine.test.ts +83 -0
- package/src/games/minesweeper/engine.ts +112 -0
- package/src/games/minesweeper/minesweeper.ts +107 -0
- package/src/games/registry.test.ts +16 -0
- package/src/games/registry.ts +130 -0
- package/src/games/snake/engine.test.ts +89 -0
- package/src/games/snake/engine.ts +95 -0
- package/src/games/snake/snake.ts +77 -0
- package/src/games/sokoban/engine.test.ts +49 -0
- package/src/games/sokoban/engine.ts +152 -0
- package/src/games/sokoban/sokoban.ts +103 -0
- package/src/games/sudoku/engine.test.ts +88 -0
- package/src/games/sudoku/engine.ts +99 -0
- package/src/games/sudoku/sudoku.ts +100 -0
- package/src/games/tetris/engine.test.ts +378 -0
- package/src/games/tetris/engine.ts +236 -0
- package/src/games/tetris/tetris.ts +220 -0
- package/src/index.ts +82 -0
- package/src/recording/chunks.ts +93 -0
- package/src/recording/config.test.ts +31 -0
- package/src/recording/config.ts +37 -0
- package/src/recording/contracts.test.ts +71 -0
- package/src/recording/gameplay.test.ts +70 -0
- package/src/recording/gameplay.ts +18 -0
- package/src/recording/http-transport.test.ts +197 -0
- package/src/recording/identity.test.ts +26 -0
- package/src/recording/identity.ts +38 -0
- package/src/recording/ids.ts +27 -0
- package/src/recording/permissions.ts +10 -0
- package/src/recording/recorder.test.ts +388 -0
- package/src/recording/recorder.ts +464 -0
- package/src/recording/store.test.ts +231 -0
- package/src/recording/store.ts +305 -0
- package/src/recording/terminal-grid.test.ts +88 -0
- package/src/recording/terminal-grid.ts +248 -0
- package/src/recording/trace-recorder.test.ts +287 -0
- package/src/recording/trace-recorder.ts +560 -0
- package/src/recording/transport.ts +170 -0
- package/src/recording/types.test.ts +18 -0
- package/src/recording/types.ts +326 -0
- package/src/recording/uploader.test.ts +193 -0
- package/src/recording/uploader.ts +103 -0
- package/src/results/completion.ts +100 -0
- package/src/results/http-result-transport.test.ts +75 -0
- package/src/results/local-score.test.ts +32 -0
- package/src/results/local-score.ts +39 -0
- package/src/results/results.test.ts +383 -0
- package/src/results/store.ts +149 -0
- package/src/results/transport.ts +79 -0
- package/src/results/types.ts +20 -0
- package/src/results/uploader.ts +84 -0
- package/src/sync/config.test.ts +108 -0
- package/src/sync/config.ts +115 -0
- package/src/sync/coordinator.test.ts +423 -0
- package/src/sync/coordinator.ts +277 -0
- package/src/sync/game-start-queue.test.ts +93 -0
- package/src/sync/game-start-queue.ts +45 -0
- package/src/sync/identity-client.test.ts +34 -0
- package/src/sync/identity-client.ts +43 -0
package/overlay-release.json
CHANGED
package/package.json
CHANGED
|
@@ -1,17 +1,39 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@trolleroof/tui",
|
|
3
|
-
"version": "0.2.
|
|
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
|
-
"
|
|
7
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 '
|
|
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
|
|
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": "
|
|
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
|
|
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
|
-
|
|
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
|
+
});
|
package/scripts/overlay.ts
CHANGED
|
@@ -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
|
-
"
|
|
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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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
|
+
}
|