@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.
- package/.claude-plugin/plugin.json +10 -0
- package/.codex-plugin/plugin.json +21 -0
- package/README.md +32 -8
- package/dist/index.js +286 -19
- package/overlay/dist/app.js +74 -0
- package/overlay/dist/index.html +1589 -0
- package/overlay-release.json +2 -2
- package/package.json +28 -4
- package/scripts/install-adapters.sh +25 -14
- package/scripts/install-adapters.test.sh +12 -2
- package/scripts/materialize-agents-plugin.sh +8 -6
- 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,16 +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",
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 '
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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": "
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
+
});
|
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();
|