Replay auto-upload (Dev builds) — implementation plan
Replay Auto-Upload (Dev builds) Implementation Plan
Section titled “Replay Auto-Upload (Dev builds) Implementation Plan”For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking. In this repo, consult skillbh-sdd-concurrentbefore Task 1, dispatchbh-implementer(nomodelparam) into the plan worktree, review withbh-reviewer, merge withbh-merge-reviewed-branch.
Goal: Dev builds upload every archived input replay (replay-<unix>-<seed>.json) to R2 through the telemetry Worker on their own, and a Mac script pulls them into replays/, so human replays stop depending on someone remembering to devicectl them off the device. Unblocks #114 (bot fidelity score).
Architecture: A persistent ReplayUploader node in main treats the replay files on disk as the queue: one gzip upload at a time, oldest first, deleted only on the Worker’s {"ok":true} (or its own 400/413 verdict). The telemetry Worker gets POST /replay, which validates size, gzip, JSON shape and seed, enforces a 200/day cap in KV, and stores the original gzip bytes in R2 bucket bh-replays. scripts/replays-pull.sh lists the bucket (Cloudflare REST API) and downloads new objects with wrangler.
Tech Stack: Godot 4.7 typed GDScript + GUT; Cloudflare Worker (plain JS, node --test with hand mocks); R2; KV; Node 22 for the Mac script.
Spec: docs/superpowers/specs/2026-10-14-replay-auto-upload-design.md (approved 2026-10-14, auth option (a): no key, shape check + caps).
Global Constraints
Section titled “Global Constraints”- Gate:
BuildChannel.current() == "dev"AND notOS.has_feature("web")AND not muted (playback, capture, showcase) AND Share diagnostics on ANDEventQueue.live_platform(). The root Web preset carriescustom_features="dev", so channel alone is never enough. - Never upload
replay-latest.json(ortutorial-replay*.json); it stays on the device untouched. - Delete a local file ONLY after HTTP 200 with
{"ok":true,...}, or a 400/413 (the Worker’s own verdict). Network error, 429, 5xx, or a 200 withoutok:truekeep it. - Backoff mirrors the event queue:
EventQueueCore.BACKOFF_MIN_S(10 s) doubling toEventQueueCore.BACKOFF_MAX_S(300 s). - No pass while a human run is in progress.
- Body:
bytes.compress(FileAccess.COMPRESSION_GZIP)viaHTTPRequest.request_raw, headerContent-Type: application/gzip, querybuild,install,unix,seed. - Worker limits: gzip body ≤ 2 MB (else 413); decompressed ≤ 16 MB (else 413); shape
seedinteger,ticksarray,fresh_savebool,seed== query seed (else 400); 200 stored objects per UTC day (else 429). - R2 key
replays/<build>/<install>/<unix>-<seed>.json.gz, custom metadata build/install/unix/seed/ticks, original gzip bytes,contentType: application/gzipand NOcontentEncoding(sowrangler r2 object getreturns the gzip bytes unchanged). Existing key → 200. - Retention: R2 lifecycle rule, objects deleted after 365 days.
- Out-of-band steps each need Chris’s OK at the time: bucket create + lifecycle rule,
telemetry/deploy.sh, privacy site deploy, Dev install. - Never pull a whole GUT run into context:
scripts/check-test-count.sh(setGUT_LOG=<own path>when another worktree runs gates). - Not doing: no D1 table or dashboard for replays, no
tutorial-replay.jsonupload, no store/TestFlight path.
Deviations from the spec (decided while planning)
Section titled “Deviations from the spec (decided while planning)”- 413 also drops the file on the client. The spec lists 400 as the drop verdict; a 413 can never succeed on retry either, so keeping it would wedge the queue forever.
- The pull script lists the bucket with the Cloudflare REST API (
GET /accounts/{id}/r2/buckets/bh-replays/objects), because wrangler 4.152 has nor2 object list(only get/put/delete). Downloads still usewrangler r2 object get. - The Worker checks the daily cap before decompressing (cheaper under abuse). A bad file arriving while capped gets 429, is retried, and gets its 400 the next day.
- The boot pass waits for the first death when
record_replayautostarts a human run (the run is human from tick 0, so the “no pass during a human run” rule holds it). The pending flag survives, so it runs right after that death. - Caps raised to 8 MB gzip / 64 MB raw (Chris, 2026-10-22). Review found 20-min analog runs over the old 2 MB gzip cap (6.5 MB raw / 2.42 MB gzip; the client deletes on 413, so they would be lost). The Worker validates by streaming — head parse plus
],[row count, never materializing the decompressed body — to stay under the Worker’s 128 MB memory.
File Structure
Section titled “File Structure”| File | Task | Responsibility |
|---|---|---|
telemetry/src/worker.js (modify) |
1 | POST /replay route, handleReplay, gunzipCapped, exported limits |
telemetry/wrangler.toml (modify) |
1 | [[r2_buckets]] binding REPLAYS → bh-replays |
telemetry/test/replay.test.mjs (create) |
1 | Worker tests with in-memory R2 + KV mocks |
net/replay_uploader.gd (create) |
2 | ReplayUploader node: gate, queue scan, gzip body, verdict, backoff |
tests/test_replay_uploader.gd (create) |
2 | Uploader unit tests, injected transport, scratch dirs |
main.gd (modify) |
3 | Create/keep the uploader, wire dirs/busy/muted, kick at boot and after a save, diagnostics toggle |
tests/test_replay_upload_wiring.gd (create) |
3 | main wiring, incl. the DEFAULT dir list |
scripts/replays-pull.mjs, scripts/replays-pull.sh (create) |
4 | List R2, download new objects, gunzip into replays/ |
scripts/test/replays-pull.test.mjs (create) |
4 | Pure helper tests |
.gitignore (modify) |
4 | replays/ |
docs/architecture/dev-tools.md (modify) |
1, 4 | Replay auto-upload subsection in § Telemetry v2 |
marketing/appstore/compliance.md (modify) |
5 | Dev-only replay line |
~/Claude/bullet-heaven-site/privacy/index.html (modify, separate repo) |
5 | Dev-only replay line |
Tasks 1, 2, 4, 5 are independent. Task 3 needs Task 2. Task 6 (rollout) needs all of them merged to main.
Plan worktree: .claude/worktrees/replay-upload, branch replay-upload off origin/main. Task sub-worktrees per bh-sdd-concurrent.
Task 1: Worker POST /replay + R2 binding
Section titled “Task 1: Worker POST /replay + R2 binding”Files:
- Modify:
telemetry/src/worker.js(limits next toEVENTS_MAXat :55-56; handler nearhandleEventsat :198; route in thefetchif-chain at :347+, right after the/eventsline) - Modify:
telemetry/wrangler.toml(after the[[kv_namespaces]]block) - Create:
telemetry/test/replay.test.mjs - Modify:
docs/architecture/dev-tools.md(new### Replay auto-upload (Dev builds)inside § Telemetry v2 at :350, placed just before### Crash symbolicationat :542)
Interfaces:
-
Produces:
POST /replay?build=<1-6 digits>&install=<32 lowercase hex>&unix=<1-11 digits>&seed=<-?1-20 digits>, body = gzip bytes. Responses:200 {"ok":true,"key":...}(stored),200 {"ok":true,"dup":true,"key":...}(already stored),400 {"error":...},413 {"error":...},429 {"error":"daily cap"}. ExportsREPLAY_MAX_GZ,REPLAY_MAX_RAW,REPLAY_DAILY_CAP. Task 2 relies on exactly these codes. -
Step 1: Check nothing enumerates the CONFIG KV (the daily counter lives there as
replay_count:<YYYY-MM-DD>).
Run: rg -n "CONFIG\.list|\.list\(" telemetry/src scripts/publish-config.mjs
Expected: no match. If something lists CONFIG keys, make it skip the replay_count: prefix in this task.
- Step 2: Write the failing tests
telemetry/test/replay.test.mjs
import { test } from "node:test";import assert from "node:assert/strict";import { gzipSync } from "node:zlib";import worker, { REPLAY_MAX_GZ, REPLAY_MAX_RAW, REPLAY_DAILY_CAP } from "../src/worker.js";
const INST = "0123456789abcdef0123456789abcdef";const KEY = `replays/340/${INST}/1792006650-42.json.gz`;const r2 = () => { const m = new Map(); return { m, head: async (k) => (m.has(k) ? { key: k } : null), put: async (k, v, o) => { m.set(k, { bytes: new Uint8Array(v), o }); } };};const kv = (init = {}) => { const m = new Map(Object.entries(init)); return { m, get: async (k) => m.get(k) ?? null, put: async (k, v) => { m.set(k, v); } };};const envOf = (kvInit) => ({ REPLAYS: r2(), CONFIG: kv(kvInit) });const replay = (o = {}) => JSON.stringify({ seed: 42, ticks: [[0, 0, 0, 0, 0, 0], [1, 0, 0, 0, 0, 0]], fresh_save: true, ...o });const post = (env, body, q = {}) => { const p = new URLSearchParams({ build: "340", install: INST, unix: "1792006650", seed: "42", ...q }); return worker.fetch(new Request(`https://x/replay?${p}`, { method: "POST", body }), env);};const today = () => `replay_count:${new Date().toISOString().slice(0, 10)}`;
test("a valid upload stores the original gzip bytes with metadata and counts the day", async () => { const env = envOf(); const gz = gzipSync(replay()); const res = await post(env, gz); assert.equal(res.status, 200); assert.deepEqual(await res.json(), { ok: true, key: KEY }); const obj = env.REPLAYS.m.get(KEY); assert.deepEqual(Buffer.from(obj.bytes), gz); assert.deepEqual(obj.o.customMetadata, { build: "340", install: INST, unix: "1792006650", seed: "42", ticks: "2" }); assert.equal(obj.o.httpMetadata.contentType, "application/gzip"); assert.equal(obj.o.httpMetadata.contentEncoding, undefined, "wrangler get must return the gzip bytes as-is"); assert.equal(env.CONFIG.m.get(today()), "1");});
test("a retried upload of the same key is 200 dup, stored once, not counted twice", async () => { const env = envOf(); await post(env, gzipSync(replay())); const res = await post(env, gzipSync(replay())); assert.equal(res.status, 200); assert.equal((await res.json()).dup, true); assert.equal(env.REPLAYS.m.size, 1); assert.equal(env.CONFIG.m.get(today()), "1");});
test("an existing key answers 200 even at the daily cap", async () => { const env = envOf(); await post(env, gzipSync(replay())); env.CONFIG.m.set(today(), String(REPLAY_DAILY_CAP)); assert.equal((await post(env, gzipSync(replay()))).status, 200);});
test("a gzip body over REPLAY_MAX_GZ is 413", async () => { const env = envOf(); assert.equal((await post(env, new Uint8Array(REPLAY_MAX_GZ + 1))).status, 413); assert.equal(env.REPLAYS.m.size, 0);});
test("a body that decompresses past REPLAY_MAX_RAW is 413", async () => { const env = envOf(); assert.equal((await post(env, gzipSync(Buffer.alloc(REPLAY_MAX_RAW + 1, 0x20)))).status, 413); assert.equal(env.REPLAYS.m.size, 0);});
for (const [name, body] of [ ["not gzip", Buffer.from("hello")], ["gzip but not JSON", gzipSync("nope")], ["no ticks", gzipSync(JSON.stringify({ seed: 42, fresh_save: true }))], ["fresh_save not bool", gzipSync(replay({ fresh_save: "yes" }))], ["seed not an integer", gzipSync(replay({ seed: 42.5 }))], ["seed mismatch", gzipSync(replay({ seed: 43 }))],]) { test(`400 and nothing stored: ${name}`, async () => { const env = envOf(); const res = await post(env, body); assert.equal(res.status, 400); assert.ok((await res.json()).error); assert.equal(env.REPLAYS.m.size, 0); });}
for (const [k, v] of [["build", "x"], ["install", "ABC"], ["unix", ""], ["seed", "1.5"]]) { test(`400 on a bad ${k} query value`, async () => { assert.equal((await post(envOf(), gzipSync(replay()), { [k]: v })).status, 400); });}
test("a negative seed is accepted and keyed as-is", async () => { const env = envOf(); assert.equal((await post(env, gzipSync(replay({ seed: -7 })), { seed: "-7" })).status, 200); assert.ok(env.REPLAYS.m.has(`replays/340/${INST}/1792006650--7.json.gz`));});
test("429 at the daily cap, nothing stored", async () => { const env = envOf({ [today()]: String(REPLAY_DAILY_CAP) }); assert.equal((await post(env, gzipSync(replay()))).status, 429); assert.equal(env.REPLAYS.m.size, 0);});- Step 3: Run to verify it fails
Run: cd telemetry && node --test test/replay.test.mjs 2>&1 | tail -15
Expected: FAIL (REPLAY_MAX_GZ is not exported).
- Step 4: Implement in
telemetry/src/worker.js
Next to EVENTS_MAX_BYTES:
// POST /replay (Dev builds' archived input replays, R2 bh-replays). No client key: the shape check// plus the daily cap are the abuse bound (spec 2026-10-14, option (a)).export const REPLAY_MAX_GZ = 2 * 1024 * 1024;export const REPLAY_MAX_RAW = 16 * 1024 * 1024;export const REPLAY_DAILY_CAP = 200;const REPLAY_QUERY = { build: /^\d{1,6}$/, install: /^[0-9a-f]{32}$/, unix: /^\d{1,11}$/, seed: /^-?\d{1,20}$/ };Near handleEvents:
// Streams the gunzip so a small bomb is cut off at `max` instead of being inflated whole.async function gunzipCapped(bytes, max) { const reader = new Blob([bytes]).stream().pipeThrough(new DecompressionStream("gzip")).getReader(); const parts = []; let n = 0; for (;;) { const { done, value } = await reader.read(); if (done) break; n += value.byteLength; if (n > max) { await reader.cancel(); return null; } parts.push(value); } const out = new Uint8Array(n); let o = 0; for (const p of parts) { out.set(p, o); o += p.byteLength; } return out;}
async function handleReplay(req, env, url) { const q = {}; for (const [k, re] of Object.entries(REPLAY_QUERY)) { const v = url.searchParams.get(k) ?? ""; if (!re.test(v)) return json({ error: `bad ${k}` }, 400); q[k] = v; } const gz = new Uint8Array(await req.arrayBuffer()); if (gz.byteLength > REPLAY_MAX_GZ) return json({ error: "too large" }, 413); const key = `replays/${q.build}/${q.install}/${q.unix}-${q.seed}.json.gz`; // A retry after a lost response must neither duplicate nor fail. if (await env.REPLAYS.head(key)) return json({ ok: true, dup: true, key }); const day = `replay_count:${new Date().toISOString().slice(0, 10)}`; const count = parseInt((await env.CONFIG.get(day)) ?? "0", 10) || 0; if (count >= REPLAY_DAILY_CAP) return json({ error: "daily cap" }, 429); let raw; try { raw = await gunzipCapped(gz, REPLAY_MAX_RAW); } catch { return json({ error: "not gzip" }, 400); } if (raw === null) return json({ error: "decompressed too large" }, 413); let r; try { r = JSON.parse(new TextDecoder().decode(raw)); } catch { return json({ error: "not json" }, 400); } if (!r || typeof r !== "object" || !Number.isInteger(r.seed) || !Array.isArray(r.ticks) || typeof r.fresh_save !== "boolean") return json({ error: "not a replay" }, 400); if (String(r.seed) !== q.seed) return json({ error: "seed mismatch" }, 400); await env.REPLAYS.put(key, gz, { httpMetadata: { contentType: "application/gzip" }, customMetadata: { build: q.build, install: q.install, unix: q.unix, seed: q.seed, ticks: String(r.ticks.length) }, }); await env.CONFIG.put(day, String(count + 1), { expirationTtl: 2 * 86400 }); return json({ ok: true, key });}In the fetch if-chain, right after the /events line:
if (req.method === "POST" && url.pathname === "/replay") return handleReplay(req, env, url);(Check url is already defined at that point in fetch; it is used by the /config line at :362.)
telemetry/wrangler.toml, after [[kv_namespaces]]:
# Dev builds' input replays (POST /replay). Lifecycle rule: delete after 365 days (set by hand, see# docs/architecture/dev-tools.md § Replay auto-upload).[[r2_buckets]]binding = "REPLAYS"bucket_name = "bh-replays"- Step 5: Run to verify it passes, plus the rest of the telemetry suite
Run: cd telemetry && node --test test/*.test.mjs 2>&1 | tail -8
Expected: all pass, 0 fail.
- Step 6: Docs. In
docs/architecture/dev-tools.md, insert before### Crash symbolication:
### Replay auto-upload (Dev builds)
Dev builds upload each archived input replay (`replay-<unix>-<seed>.json`, never `replay-latest.json`)to `POST /replay` on the telemetry Worker (spec `docs/superpowers/specs/2026-10-14-replay-auto-upload-design.md`).- **Client** `net/replay_uploader.gd` (`ReplayUploader`, persistent child of main): gate = Dev channel, not web, not muted, Share diagnostics on, live platform. The files on disk are the queue: one at a time, oldest first, gzip body via `request_raw`; deleted on `200 {"ok":true}` or a 400/413, kept on network error/429/5xx with the event queue's 10 s → 300 s backoff. A pass is kicked at boot and after every replay save, and never runs while a human run is in progress.- **Worker:** gzip ≤ 2 MB, decompressed ≤ 16 MB (413), shape `seed` int / `ticks` array / `fresh_save` bool and seed == query seed (400), 200 objects per UTC day (429, KV `CONFIG` key `replay_count:<YYYY-MM-DD>`, 2-day TTL). Stores the original gzip at `replays/<build>/<install>/<unix>-<seed>.json.gz` in R2 `bh-replays` (binding `REPLAYS`), custom metadata build/install/unix/seed/ticks. An existing key answers 200 `dup`.- **Retention:** R2 lifecycle rule `expire-365` deletes objects after 365 days (the privacy page's 12 months), set once by hand: `cd telemetry && npx wrangler r2 bucket lifecycle add bh-replays expire-365 --expire-days 365`. R2 is not in `RETENTION_TABLES` (D1 only).- Step 7: Commit
git add telemetry/src/worker.js telemetry/wrangler.toml telemetry/test/replay.test.mjs docs/architecture/dev-tools.mdgit commit -m "Telemetry: POST /replay stores Dev input replays in R2 (size, gzip, shape, seed and daily-cap checks)"Task 2: ReplayUploader node
Section titled “Task 2: ReplayUploader node”Files:
- Create:
net/replay_uploader.gd - Test:
tests/test_replay_uploader.gd
Interfaces:
-
Consumes: Task 1’s response codes (200
ok:truestored/dup; 400/413 drop; 429/other retry).EventQueue.live_platform()(static,net/event_queue.gd:23),EventQueueCore.BACKOFF_MIN_S/BACKOFF_MAX_S(net/event_queue_core.gd:17-18),BuildChannel.current(),Sim_Const.BUILD. -
Produces (Task 3 uses these exact names):
class_name ReplayUploader extends Nodefunc setup(id: String, share_on: bool) -> void,func set_enabled(on: bool, id: String) -> void,func kick() -> void,func pump(dt: float) -> voidvar dirs_fn: Callable(() -> Array[String]),var busy_fn: Callable(() -> bool),var muted_fn: Callable(() -> bool),var transport: Callable((url: String, headers: PackedStringArray, body: PackedByteArray) -> void)var share: bool,var install_id: String,var backoff_s: float,var _pending: boolfunc on_response(ok_transport: bool, code: int, body: String) -> void- statics
gate_open(channel, web, muted, share_on, live) -> bool,parse_archive(file_name) -> Dictionary,queue_in(dirs: Array[String]) -> Array[String],body_for(path) -> PackedByteArray,url_for(path, build, install) -> String,verdict(ok_transport, code, body) -> int
-
Step 1: Write the failing tests
tests/test_replay_uploader.gd(scaffold conventions: skillbh-write-test)
extends GutTest
const INST := "0123456789abcdef0123456789abcdef"var _dir := ""var _sent: Array[Dictionary] = []
func before_each() -> void: _dir = OS.get_user_data_dir().path_join("gut-replay-up-%d" % Time.get_ticks_usec()) DirAccess.make_dir_recursive_absolute(_dir) _sent.clear()
func after_each() -> void: for f: String in DirAccess.get_files_at(_dir): DirAccess.remove_absolute(_dir.path_join(f)) DirAccess.remove_absolute(_dir)
func _write(file_name: String, text: String = '{"fresh_save":false,"seed":1,"ticks":[]}') -> String: var p := _dir.path_join(file_name) var f := FileAccess.open(p, FileAccess.WRITE) f.store_string(text) f.close() return p
func _u() -> ReplayUploader: var u := ReplayUploader.new() add_child_autofree(u) u.set_process(false) u.setup(INST, true) u.live_override = 1 u.channel_override = "dev" var dirs: Array[String] = [_dir] u.dirs_fn = func() -> Array[String]: return dirs u.transport = func(url: String, h: PackedStringArray, b: PackedByteArray) -> void: _sent.append({"url": url, "h": h, "b": b}) return u
func test_gate_truth_table() -> void: assert_true(ReplayUploader.gate_open("dev", false, false, true, true)) assert_false(ReplayUploader.gate_open("testflight", false, false, true, true), "not dev") assert_false(ReplayUploader.gate_open("appstore", false, false, true, true), "store") assert_false(ReplayUploader.gate_open("dev", true, false, true, true), "web carries the dev feature") assert_false(ReplayUploader.gate_open("dev", false, true, true, true), "muted") assert_false(ReplayUploader.gate_open("dev", false, false, false, true), "diagnostics off") assert_false(ReplayUploader.gate_open("dev", false, false, true, false), "headless/editor")
func test_real_gate_is_closed_headless() -> void: var u := _u() u.live_override = -1 u.channel_override = "" _write("replay-10-1.json") u.kick() u.pump(0.0) assert_eq(_sent.size(), 0, "live_platform() is false in GUT")
func test_no_install_id_sends_nothing() -> void: var u := _u() u.setup("", true) _write("replay-10-1.json") u.kick() u.pump(0.0) assert_eq(_sent.size(), 0)
func test_parse_archive() -> void: assert_eq(ReplayUploader.parse_archive("replay-1792006650-3876285654.json"), {"unix": 1792006650, "seed": 3876285654}) assert_eq(ReplayUploader.parse_archive("replay-5--7.json"), {"unix": 5, "seed": -7}) assert_eq(ReplayUploader.parse_archive("replay-latest.json"), {}) assert_eq(ReplayUploader.parse_archive("tutorial-replay-5-7.json"), {}) assert_eq(ReplayUploader.parse_archive("replay-5-7.json.tmp"), {})
func test_queue_is_oldest_first_and_skips_non_archives() -> void: _write("replay-30-1.json") _write("replay-10-2.json") _write("replay-latest.json") _write("tutorial-replay-5-3.json") _write("replay-20-3.json") var dirs: Array[String] = [_dir, _dir.path_join("missing")] var q := ReplayUploader.queue_in(dirs) assert_eq(q, [_dir.path_join("replay-10-2.json"), _dir.path_join("replay-20-3.json"), _dir.path_join("replay-30-1.json")] as Array[String])
func test_one_upload_at_a_time_with_gzip_and_query() -> void: var u := _u() _write("replay-10-1.json") _write("replay-20-2.json") u.kick() u.pump(0.0) u.pump(0.0) assert_eq(_sent.size(), 1, "second pump waits for the in-flight answer") var url: String = _sent[0].url assert_true(url.begins_with(ReplayUploader.ENDPOINT + "?")) assert_string_contains(url, "build=%d" % Sim_Const.BUILD) assert_string_contains(url, "install=" + INST) assert_string_contains(url, "unix=10") assert_string_contains(url, "seed=1") assert_true("Content-Type: application/gzip" in (_sent[0].h as PackedStringArray))
func test_ok_deletes_and_moves_to_the_next_file() -> void: var u := _u() var first := _write("replay-10-1.json") _write("replay-20-2.json") u.kick() u.pump(0.0) u.on_response(true, 200, '{"ok":true,"key":"k"}') assert_false(FileAccess.file_exists(first)) u.pump(0.0) assert_eq(_sent.size(), 2) assert_string_contains(_sent[1].url, "unix=20")
func test_dup_counts_as_stored() -> void: var u := _u() var p := _write("replay-10-1.json") u.kick() u.pump(0.0) u.on_response(true, 200, '{"ok":true,"dup":true,"key":"k"}') assert_false(FileAccess.file_exists(p))
func test_queue_empty_clears_pending() -> void: var u := _u() u.kick() u.pump(0.0) assert_false(u._pending)
func test_400_and_413_drop_the_file() -> void: for code: int in [400, 413]: var u := _u() var p := _write("replay-10-%d.json" % code) u.kick() u.pump(0.0) u.on_response(true, code, '{"error":"x"}') assert_false(FileAccess.file_exists(p), "code %d drops" % code) assert_eq(u.backoff_s, 0.0)
func test_network_error_429_5xx_and_200_without_ok_keep_the_file_and_back_off() -> void: for c: Array in [[false, 0, ""], [true, 429, '{"error":"daily cap"}'], [true, 500, ""], [true, 200, '{"ok":false}'], [true, 200, "garbage"]]: var u := _u() var p := _write("replay-10-1.json") u.kick() u.pump(0.0) u.on_response(c[0], c[1], c[2]) assert_true(FileAccess.file_exists(p), "kept on %s" % [c]) assert_eq(u.backoff_s, EventQueueCore.BACKOFF_MIN_S)
func test_backoff_doubles_to_the_cap_and_holds_the_queue() -> void: var u := _u() _write("replay-10-1.json") u.kick() u.pump(0.0) u.on_response(false, 0, "") u.pump(EventQueueCore.BACKOFF_MIN_S - 1.0) assert_eq(_sent.size(), 1, "still waiting") u.pump(1.0) # wait drains u.pump(0.0) assert_eq(_sent.size(), 2) u.on_response(false, 0, "") assert_eq(u.backoff_s, EventQueueCore.BACKOFF_MIN_S * 2.0)
func test_backoff_never_exceeds_the_cap() -> void: var u := _u() _write("replay-10-1.json") u.backoff_s = EventQueueCore.BACKOFF_MAX_S * 0.75 u.kick() u.pump(0.0) u.on_response(false, 0, "") assert_eq(u.backoff_s, EventQueueCore.BACKOFF_MAX_S)
func test_busy_holds_the_pass_until_the_run_ends() -> void: var u := _u() var busy := [true] u.busy_fn = func() -> bool: return busy[0] _write("replay-10-1.json") u.kick() u.pump(0.0) assert_eq(_sent.size(), 0, "human run in progress") busy[0] = false u.pump(0.0) assert_eq(_sent.size(), 1)
func test_muted_sends_nothing() -> void: var u := _u() u.muted_fn = func() -> bool: return true _write("replay-10-1.json") u.kick() u.pump(0.0) assert_eq(_sent.size(), 0)
func test_set_enabled_off_closes_the_gate() -> void: var u := _u() u.set_enabled(false, "") _write("replay-10-1.json") u.kick() u.pump(0.0) assert_eq(_sent.size(), 0)
func test_gzip_round_trip_of_a_real_recorder_file() -> void: var rec := ReplayRecorder.new(77) rec.fresh_save = true var p := _dir.path_join("replay-10-77.json") assert_true(rec.save(p)) var body := ReplayUploader.body_for(p) assert_eq(body[0], 0x1f, "gzip magic") assert_eq(body[1], 0x8b, "gzip magic") assert_eq(body.decompress_dynamic(-1, FileAccess.COMPRESSION_GZIP), FileAccess.get_file_as_bytes(p)) var parsed: Variant = JSON.parse_string(body.decompress_dynamic(-1, FileAccess.COMPRESSION_GZIP).get_string_from_utf8()) assert_eq(int(parsed["seed"]), 77, "the Worker's seed check will see the query seed")- Step 2: Run to verify it fails
Run: godot --headless --path . --import >/dev/null 2>&1; git checkout -- project.godot audio/bus_layout.tres; GUT_LOG=$TMPDIR/bh-gut-replay-up.log scripts/check-test-count.sh
Expected: FAIL; grep -n "replay_uploader" $TMPDIR/bh-gut-replay-up.log | head shows the missing ReplayUploader class.
- Step 3: Implement
net/replay_uploader.gd
class_name ReplayUploader extends Node## Dev builds: ships archived input replays (replay-<unix>-<seed>.json) to the telemetry Worker's## POST /replay. The files on disk ARE the queue: one upload at a time, oldest first; a file is deleted## only on the Worker's {"ok":true}, or on its own 400/413 verdict so one bad file can't wedge the queue.## Spec: docs/superpowers/specs/2026-10-14-replay-auto-upload-design.md
const ENDPOINT := "https://bullet-heaven-telemetry.chris-allen-06f.workers.dev/replay"const HTTP_TIMEOUT_S := 60.0const ARCHIVE_PATTERN := "^replay-(\\d+)-(-?\\d+)\\.json$"
enum Verdict { STORED, DROPPED, RETRY }
var install_id: String = ""var share: bool = falsevar dirs_fn: Callable # () -> Array[String]var busy_fn: Callable # () -> bool: a human run is in progressvar muted_fn: Callable # () -> bool: playback / capture / showcasevar transport: Callable # tests: (url, headers, body) -> void; invalid = HTTPRequestvar live_override: int = -1 # tests: 1/0 force EventQueue.live_platform(); -1 = realvar channel_override: String = "" # tests: replaces BuildChannel.current()var backoff_s: float = 0.0var _wait_s: float = 0.0var _pending: bool = falsevar _inflight: String = ""var _http: HTTPRequest = null
func setup(id: String, share_on: bool) -> void: install_id = id share = share_on
func set_enabled(on: bool, id: String) -> void: setup(id, on) if not on and _http != null and _inflight != "": _http.cancel_request() _inflight = ""
func kick() -> void: _pending = true
static func gate_open(channel: String, web: bool, muted: bool, share_on: bool, live: bool) -> bool: return channel == "dev" and not web and not muted and share_on and live
func is_open() -> bool: var ch := channel_override if channel_override != "" else BuildChannel.current() var live := EventQueue.live_platform() if live_override < 0 else live_override == 1 var muted := muted_fn.is_valid() and bool(muted_fn.call()) return install_id != "" and gate_open(ch, OS.has_feature("web"), muted, share, live)
static func parse_archive(file_name: String) -> Dictionary: var m := RegEx.create_from_string(ARCHIVE_PATTERN).search(file_name) if m == null: return {} return {"unix": int(m.get_string(1)), "seed": int(m.get_string(2))}
static func queue_in(dirs: Array[String]) -> Array[String]: var found: Array[Dictionary] = [] for d: String in dirs: if not DirAccess.dir_exists_absolute(d): continue for f: String in DirAccess.get_files_at(d): var p := parse_archive(f) if not p.is_empty(): found.append({"path": d.path_join(f), "unix": p["unix"], "name": f}) found.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return a["unix"] < b["unix"] or (a["unix"] == b["unix"] and a["name"] < b["name"])) var out: Array[String] = [] for e: Dictionary in found: out.append(e["path"]) return out
static func body_for(path: String) -> PackedByteArray: var raw := FileAccess.get_file_as_bytes(path) return raw.compress(FileAccess.COMPRESSION_GZIP) if raw.size() > 0 else PackedByteArray()
static func url_for(path: String, build: int, install: String) -> String: var p := parse_archive(path.get_file()) return "%s?build=%d&install=%s&unix=%d&seed=%d" % [ENDPOINT, build, install, p["unix"], p["seed"]]
static func verdict(ok_transport: bool, code: int, body: String) -> int: if not ok_transport: return Verdict.RETRY if code == 200: var j: Variant = JSON.parse_string(body) return Verdict.STORED if j is Dictionary and (j as Dictionary).get("ok", false) == true else Verdict.RETRY if code == 400 or code == 413: return Verdict.DROPPED return Verdict.RETRY
func _process(dt: float) -> void: pump(dt)
func pump(dt: float) -> void: if _inflight != "": return if _wait_s > 0.0: _wait_s = maxf(0.0, _wait_s - dt) return if not _pending or not is_open(): return if busy_fn.is_valid() and bool(busy_fn.call()): return var dirs: Array[String] = [] if dirs_fn.is_valid(): dirs.assign(dirs_fn.call()) var q := queue_in(dirs) if q.is_empty(): _pending = false return var body := body_for(q[0]) if body.is_empty(): push_warning("ReplayUploader: dropping empty/unreadable %s" % q[0]) DirAccess.remove_absolute(q[0]) return _inflight = q[0] var url := url_for(_inflight, Sim_Const.BUILD, install_id) var headers := PackedStringArray(["Content-Type: application/gzip"]) if transport.is_valid(): transport.call(url, headers, body) return if _http == null: _http = HTTPRequest.new() _http.timeout = HTTP_TIMEOUT_S add_child(_http) _http.request_completed.connect(_on_http_done) if _http.request_raw(url, headers, HTTPClient.METHOD_POST, body) != OK: on_response(false, 0, "")
func _on_http_done(result: int, code: int, _headers: PackedStringArray, body: PackedByteArray) -> void: on_response(result == HTTPRequest.RESULT_SUCCESS, code, body.get_string_from_utf8())
func on_response(ok_transport: bool, code: int, body: String) -> void: var path := _inflight _inflight = "" if path == "": return if verdict(ok_transport, code, body) == Verdict.RETRY: backoff_s = EventQueueCore.BACKOFF_MIN_S if backoff_s <= 0.0 else minf(backoff_s * 2.0, EventQueueCore.BACKOFF_MAX_S) _wait_s = backoff_s return DirAccess.remove_absolute(path) backoff_s = 0.0- Step 4: Run to verify it passes
Run: godot --headless --path . --import >/dev/null 2>&1; git checkout -- project.godot audio/bus_layout.tres; GUT_LOG=$TMPDIR/bh-gut-replay-up.log scripts/check-test-count.sh
Expected: PASS, script count up by 1. On failure, grep the log for test_replay_uploader, never print the whole log. Warnings-as-errors traps: annotate Dictionary.get() results as Variant, type every loop variable.
- Step 5: Commit
git add net/replay_uploader.gd tests/test_replay_uploader.gdgit commit -m "ReplayUploader: Dev replays leave the device on their own (files are the queue, delete only on ok)"Task 3: Wire the uploader into main.gd
Section titled “Task 3: Wire the uploader into main.gd”Files:
- Modify:
main.gd— var decl next tovar event_queue(:121); creation afterevent_queue.auto_fn(:496); keep-list in_sweep_run_children(:1139-1146);_save_replay_if_recording(:2901-2911);_finish_boot_prewarm(:758-765);_on_diagnostics_toggled(:3151-3156) - Test:
tests/test_replay_upload_wiring.gd
Interfaces:
-
Consumes: Task 2’s
ReplayUploaderAPI (exact names in Task 2’s Produces block). Existingmain.replay_dirs_for(override: Array[String]) -> Array[String](static, :2908),_replay_dirs,_auto,sim.game_over,event_queue.muted. -
Produces:
main.replay_uploader: ReplayUploader(persistent, never swept). -
Step 1: Write the failing tests
tests/test_replay_upload_wiring.gd
Copy _boot_main() and the scratch-dir/DevFlags.load_for(true, {"record_replay": true}) setup verbatim from tests/test_replay_autostart.gd (the helper that boots main, and the kill pattern m.sim.player.hp = 0.0 + m._physics_process(1.0 / 60.0)), including its after_each DevFlags reset. Then:
func test_uploader_exists_and_survives_a_new_run() -> void: var m := _boot_main() assert_not_null(m.replay_uploader) var u: ReplayUploader = m.replay_uploader m._new_run() assert_true(is_instance_valid(u) and u.get_parent() == m, "persistent, never swept")
# The 2026-10-14 bug was a test seam hiding the default branch: check the production default too.func test_default_dirs_are_main_replay_dirs() -> void: var m := _boot_main() m._replay_dirs = [] as Array[String] var dirs: Array[String] = [] dirs.assign(m.replay_uploader.dirs_fn.call()) assert_eq(dirs, ["user://", OS.get_cache_dir()] as Array[String])
func test_override_dirs_reach_the_uploader() -> void: var m := _boot_main() var scratch: Array[String] = ["user://gut-x"] m._replay_dirs = scratch var dirs: Array[String] = [] dirs.assign(m.replay_uploader.dirs_fn.call()) assert_eq(dirs, scratch)
func test_busy_during_a_human_run_and_free_after_death_with_a_kick() -> void: var m := _boot_main() m._replay_dirs = _scratch_dirs # the scratch list the copied setup builds m._new_run(true) m.replay_uploader._pending = false assert_true(m.replay_uploader.busy_fn.call(), "human run in progress") m.sim.player.hp = 0.0 m._physics_process(1.0 / 60.0) assert_false(m.replay_uploader.busy_fn.call(), "dead run is not busy") assert_true(m.replay_uploader._pending, "a replay save kicks a pass")
func test_boot_prewarm_finish_kicks_a_pass() -> void: var m := _boot_main() m.replay_uploader._pending = false m._finish_boot_prewarm() assert_true(m.replay_uploader._pending)
func test_muted_follows_the_event_queue() -> void: var m := _boot_main() m.event_queue.muted = true assert_true(m.replay_uploader.muted_fn.call()) m.event_queue.muted = false assert_false(m.replay_uploader.muted_fn.call())
func test_diagnostics_toggle_reaches_the_uploader() -> void: var m := _boot_main() m._on_diagnostics_toggled(false) assert_false(m.replay_uploader.share) m._on_diagnostics_toggled(true) assert_true(m.replay_uploader.share) assert_eq(m.replay_uploader.install_id, m.meta.install_id)(Use the scratch-dir variable name test_replay_autostart.gd actually uses in place of _scratch_dirs. If _finish_boot_prewarm() starts a run because sim == null, that is fine for this assert.)
- Step 2: Run to verify it fails
Run: GUT_LOG=$TMPDIR/bh-gut-replay-wire.log scripts/check-test-count.sh
Expected: FAIL in test_replay_upload_wiring.gd (replay_uploader is not a member of main).
- Step 3: Implement in
main.gd
Next to var event_queue (:121):
var replay_uploader: ReplayUploader # Dev: archived input replays -> telemetry /replay (persistent)After event_queue.auto_fn = ... (:496):
replay_uploader = ReplayUploader.new() # persistent (never swept) add_child(replay_uploader) replay_uploader.setup(meta.install_id, meta.share_diagnostics) replay_uploader.dirs_fn = func() -> Array[String]: return replay_dirs_for(_replay_dirs) # Uploads never compete with a human run's frames; attract, menu and results are fair game. replay_uploader.busy_fn = func() -> bool: return sim != null and not sim.game_over and not _auto replay_uploader.muted_fn = func() -> bool: return event_queue.mutedIn _sweep_run_children, add to the first condition line: or c == event_queue or c == replay_uploader or ....
In _save_replay_if_recording, the success branch becomes:
if rec.save(d.path_join(file_name)): rec.save(d.path_join(archive)) if replay_uploader != null: replay_uploader.kick() returnIn _finish_boot_prewarm, before boot_prewarm_finished.emit():
if replay_uploader != null: replay_uploader.kick() # archives left by earlier launches (waits out a record_replay human run)In _on_diagnostics_toggled, after event_queue.set_enabled(on, id):
replay_uploader.set_enabled(on, id)- Step 4: Run to verify it passes, plus the determinism and replay suites
Run: godot --headless --path . --import >/dev/null 2>&1; git checkout -- project.godot audio/bus_layout.tres; GUT_LOG=$TMPDIR/bh-gut-replay-wire.log scripts/check-test-count.sh
Expected: PASS (whole suite: the change is render-side only, determinism pins must not move). Then scripts/gate.sh 2>&1 | tail -15 → green.
- Step 5: Commit
git add main.gd tests/test_replay_upload_wiring.gdgit commit -m "main: persistent ReplayUploader, kicked at boot and after each replay save, gated with diagnostics"Task 4: Mac pull script + replays/ home
Section titled “Task 4: Mac pull script + replays/ home”Files:
- Create:
scripts/replays-pull.mjs,scripts/replays-pull.sh(chmod +x) - Test:
scripts/test/replays-pull.test.mjs(the gate’sscripts/test/*.test.mjsglob picks it up) - Modify:
.gitignore,docs/architecture/dev-tools.md(Task 1’s subsection),docs/research/2026-10-14-toby-replay-vs-autopilot.md:162-163
Interfaces:
-
Consumes: Task 1’s key layout
replays/<build>/<install>/<unix>-<seed>.json.gz. -
Produces: local layout
replays/<build>/<install>/<unix>-<seed>.json(gunzipped); hand-pulled copies inreplays/hand-pulled/. -
Step 1: Write the failing tests
scripts/test/replays-pull.test.mjs
import { test } from "node:test";import assert from "node:assert/strict";import { join } from "node:path";import { localPath, toFetch, summary } from "../replays-pull.mjs";
const I = "0123456789abcdef0123456789abcdef";
test("localPath mirrors the R2 layout, gunzipped name", () => { assert.equal(localPath("/r", `replays/340/${I}/1792006650-42.json.gz`), join("/r", "340", I, "1792006650-42.json")); assert.equal(localPath("/r", `replays/340/${I}/5--7.json.gz`), join("/r", "340", I, "5--7.json")); assert.equal(localPath("/r", "replays/x/y.json.gz"), null); assert.equal(localPath("/r", "other/340/a.json.gz"), null);});
test("toFetch skips keys already on disk and unknown keys", () => { const have = new Set([join("/r", "340", I, "1-1.json")]); const keys = [`replays/340/${I}/1-1.json.gz`, `replays/340/${I}/2-2.json.gz`, "junk"]; assert.deepEqual(toFetch("/r", keys, (p) => have.has(p)), [[`replays/340/${I}/2-2.json.gz`, join("/r", "340", I, "2-2.json")]]);});
test("summary reports ticks and seconds at 60 Hz", () => { assert.deepEqual(summary(JSON.stringify({ seed: 1, ticks: new Array(600).fill([0]), fresh_save: true })), { ticks: 600, seconds: 10 });});- Step 2: Run to verify it fails
Run: node --test scripts/test/replays-pull.test.mjs 2>&1 | tail -5
Expected: FAIL (cannot find ../replays-pull.mjs).
- Step 3: Implement
scripts/replays-pull.mjs
// Pulls Dev input replays from R2 bh-replays into replays/<build>/<install>/<unix>-<seed>.json.// Lists via the Cloudflare REST API (wrangler has no `r2 object list`), downloads via wrangler.import { execFileSync } from "node:child_process";import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";import { dirname, join, resolve } from "node:path";import { tmpdir } from "node:os";import { gunzipSync } from "node:zlib";import { fileURLToPath } from "node:url";
export const BUCKET = "bh-replays";const KEY_RE = /^replays\/(\d+)\/([0-9a-f]{32})\/(\d+--?\d+)\.json\.gz$/;
export function localPath(root, key) { const m = KEY_RE.exec(key); return m ? join(root, m[1], m[2], `${m[3]}.json`) : null;}
export function toFetch(root, keys, exists = existsSync) { return keys.map((k) => [k, localPath(root, k)]).filter(([, p]) => p !== null && !exists(p));}
export function summary(text) { const r = JSON.parse(text); const ticks = Array.isArray(r.ticks) ? r.ticks.length : 0; return { ticks, seconds: Math.round(ticks / 60) };}
async function listKeys(account, token) { const keys = []; let cursor = ""; do { const u = new URL(`https://api.cloudflare.com/client/v4/accounts/${account}/r2/buckets/${BUCKET}/objects`); u.searchParams.set("prefix", "replays/"); u.searchParams.set("per_page", "1000"); if (cursor) u.searchParams.set("cursor", cursor); const res = await fetch(u, { headers: { Authorization: `Bearer ${token}` } }); const j = await res.json(); if (!j.success) throw new Error(`R2 list failed (${res.status}): ${JSON.stringify(j.errors)}`); for (const o of j.result) keys.push(o.key); cursor = j.result_info?.is_truncated ? j.result_info.cursor : ""; } while (cursor); return keys;}
async function main() { const repo = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const root = join(repo, "replays"); const account = process.env.CLOUDFLARE_ACCOUNT_ID; const token = process.env.CF_R2_TOKEN; if (!account || !token) throw new Error("CLOUDFLARE_ACCOUNT_ID and CF_R2_TOKEN must be set (use scripts/replays-pull.sh)"); const todo = toFetch(root, await listKeys(account, token)); if (todo.length === 0) { console.log("replays-pull: nothing new"); return; } const tmp = join(tmpdir(), `bh-replay-${process.pid}.json.gz`); for (const [key, dest] of todo) { execFileSync("npx", ["wrangler", "r2", "object", "get", `${BUCKET}/${key}`, "--remote", "--file", tmp], { cwd: join(repo, "telemetry"), stdio: ["ignore", "ignore", "inherit"] }); const text = gunzipSync(readFileSync(tmp)).toString("utf8"); mkdirSync(dirname(dest), { recursive: true }); writeFileSync(dest, text); const s = summary(text); console.log(`${dest.slice(repo.length + 1)} ${s.ticks} ticks ${Math.floor(s.seconds / 60)}m${String(s.seconds % 60).padStart(2, "0")}s`); } rmSync(tmp, { force: true }); console.log(`replays-pull: ${todo.length} new`);}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { main().catch((e) => { console.error(e.message); process.exit(1); });}scripts/replays-pull.sh:
#!/bin/sh# Pull new Dev input replays from R2 into replays/ (gitignored). Needs an R2-read token.set -eucd "$(dirname "$0")/..". "$HOME/.secrets"CLOUDFLARE_ACCOUNT_ID=06f5622d4192dcefefbe0da75d766bfc CF_R2_TOKEN="${CF_LUMARA_DEPLOY_TOKEN:?not set in ~/.secrets}" \ exec node scripts/replays-pull.mjs "$@"(Which token can read R2 is only provable once the bucket exists: Task 6 Step 5. If CF_LUMARA_DEPLOY_TOKEN gets a 403 there, stop and ask Chris; token changes go through cloudflare-ops. Never echo the token.)
.gitignore: add a line replays/ (with a comment # Dev input replays pulled from R2 (scripts/replays-pull.sh)).
- Step 4: Run to verify it passes
Run: chmod +x scripts/replays-pull.sh && node --test scripts/test/replays-pull.test.mjs 2>&1 | tail -5
Expected: PASS.
- Step 5: Move the hand-pulled copies — in the MAIN checkout
/Users/chris/Claude/bullet-heaven, not the worktree (both dirs are gitignored, so the files exist only there; 4 files on 2026-10-22)
cd /Users/chris/Claude/bullet-heaven && mkdir -p replays/hand-pulled && mv -n .superpowers/replays/*.json replays/hand-pulled/ && ls replays/hand-pulledExpected: eve-2026-10-14-replay-1792006650-3876285654.json, toby-2026-10-14-1837537114.json, toby-2026-10-14-1837537114.run881.json, toby-2026-10-14-1837537114.withpicks.json. mv -n never overwrites; leave .superpowers/replays/ itself in place. Do this step only after the branch with the .gitignore line is merged (else replays/ shows as untracked in main).
- Step 6: Docs. In
docs/research/2026-10-14-toby-replay-vs-autopilot.md:162-163replace.superpowers/replays/withreplays/hand-pulled/. Append to Task 1’s### Replay auto-upload (Dev builds)subsection indocs/architecture/dev-tools.md:
- **Mac:** `scripts/replays-pull.sh` lists `bh-replays` (Cloudflare REST API; wrangler has no object list) and downloads every object not already in gitignored `replays/` (same `<build>/<install>/` layout, gunzipped), printing tick count and duration. Hand-pulled pre-upload copies live in `replays/hand-pulled/`. Compare against a bot run with `tools/replay_compare` (`--replay=replays/...`).- Step 7: Commit
git add scripts/replays-pull.mjs scripts/replays-pull.sh scripts/test/replays-pull.test.mjs .gitignore docs/architecture/dev-tools.md docs/research/2026-10-14-toby-replay-vs-autopilot.mdgit commit -m "replays-pull.sh: fetch Dev replays from R2 into gitignored replays/"Task 5: Privacy copy (no deploy)
Section titled “Task 5: Privacy copy (no deploy)”Files:
- Modify:
marketing/appstore/compliance.md(the “Multiplayer (LAN co-op, dev builds only today)” block at :68-75) - Modify:
~/Claude/bullet-heaven-site/privacy/index.html(after the dev-builds sentence at :88). Separate repo: read itsCLAUDE.mdfirst, commit there, do NOT deploy (Task 6).
Copy rule: player-facing copy, so no em dashes and plain English.
- Step 1: compliance.md. After the LAN co-op dev-only block, add:
**Gameplay input replays (internal development builds only):** Dev builds record each run's controllerinputs (stick and button state per frame, the run's seed and settings) and upload the file to our telemetryservice, keyed by the anonymous install id, when Share diagnostics is on. Kept 12 months, then deleted. AppStore and TestFlight builds never record or upload replays, so the App Store privacy label is unchanged.- Step 2: privacy/index.html. After the
:88paragraph (“Our internal development builds also have co-op over a local network…”), add a paragraph in the same markup style as its neighbours:
<p>Our internal development builds also record gameplay input replays (which way the stick and buttons were pressed each frame, plus the run's seed and settings) and send them to us with the anonymous install id when Share diagnostics is on, so we can study how people play. We keep them for 12 months. The App Store and TestFlight versions of the game never record or send replays.</p>Do not touch the data-privacy-covers attributes (read live by scripts/publish-config.mjs).
- Step 3: Check and commit both
Run: rg -n "—" marketing/appstore/compliance.md | tail -3 (no new em dash in the added lines) and open file:///Users/chris/Claude/bullet-heaven-site/privacy/index.html locally (or siteview_capture it) to check it renders.
git add marketing/appstore/compliance.md && git commit -m "compliance: Dev-only gameplay input replay upload"cd ~/Claude/bullet-heaven-site && git add privacy/index.html && git commit -m "Privacy: Dev-build gameplay input replays" && git push(Pushing the site repo does not deploy it; check that repo’s CLAUDE.md confirms that before pushing. If a push auto-deploys, hold the push for Task 6 Step 4.)
Task 6: Rollout (main session, each step needs Chris’s OK at the time)
Section titled “Task 6: Rollout (main session, each step needs Chris’s OK at the time)”Precondition: Tasks 1-5 reviewed (bh-reviewer) and merged to main via bh-merge-reviewed-branch, pushed. Ask Chris separately for each numbered OK below; a “yes” to one is not a yes to the next.
- Step 1 (OK #1): create the bucket + lifecycle rule
cd /Users/chris/Claude/bullet-heaven/telemetry && npx wrangler r2 bucket create bh-replaysnpx wrangler r2 bucket lifecycle add --help # confirm flag names on 4.152 before running the next linenpx wrangler r2 bucket lifecycle add bh-replays expire-365 --expire-days 365npx wrangler r2 bucket lifecycle list bh-replaysExpected: the rule shows 365-day expiry. If the account is ambiguous, wrangler.toml’s pinned account_id already applies inside telemetry/.
- Step 2 (OK #2): deploy the telemetry Worker (also clears any pending telemetry redeploy)
cd /Users/chris/Claude/bullet-heaven && telemetry/deploy.sh --check-only && telemetry/deploy.shSmoke, stores nothing (bad body → 400):
curl -s -X POST 'https://bullet-heaven-telemetry.chris-allen-06f.workers.dev/replay?build=0&install=00000000000000000000000000000000&unix=0&seed=1' --data-binary 'x'Expected: {"error":"not gzip"}. A 500 or REPLAYS undefined means the binding didn’t deploy: stop and tell Chris (deploy failures: never improvise another route).
-
Step 3 (OK #3): privacy site deploy via
~/Claude/bullet-heaven-site/scripts/deploy-site.shonly. Then confirm the live privacy page contains “gameplay input replays”. -
Step 4 (OK #4): Dev install on the Apple TV. Read
docs/reference/builds-and-releases.mdfirst (BUILD bump, one merger at a time), then skillbh-deploy. Before installing, check no human is playing (consolesessionshuman_playing): an install kills a live run. -
Step 5: device proof (no extra OK needed once the install is approved)
- Let the record_replay autostart human run die idle (no input).
- Within ~10 s of the results screen, the object exists:
scripts/replays-pull.shprints one newreplays/<build>/<install>/<unix>-<seed>.json. A 403 on the list = the token lacks R2 read: stop, ask Chris (cloudflare-ops). - On-device archive gone,
replay-latest.jsonkept:xcrun devicectl device info files --device <atv-id> --domain-type appDataContainer --domain-identifier digital.lumara.bulletheaven.dev --subdirectory Library/Cacheslistsreplay-latest.jsonand noreplay-<unix>-<seed>.json. - Content matches: copy
replay-latest.jsonoff the device (devicectl device copy from) andcmpit with the pulled file (same run, same bytes).
-
Step 6: close out. Tick the related RELEASE/Issue item, comment on #114 that human replays now arrive on their own, and update memory (
bullet-heaven-learning-autopilot,bullet-heaven-agent-evaluation-ideas): drop the “pull new ATV replays by hand” note.
Self-review (done while writing)
Section titled “Self-review (done while writing)”- Spec coverage: client gate/queue/when/body → Tasks 2-3; Worker validate/cap/store/idempotent/retention → Task 1 (+ lifecycle in Task 6); Mac pull + move hand-pulled copies → Task 4; privacy → Task 5; GUT, Worker and device tests → Tasks 1-3, 6; out-of-band steps → Task 6. Deviations listed above.
- Names consistent across tasks:
ReplayUploader,kick,pump,on_response,dirs_fn/busy_fn/muted_fn/transport,setup/set_enabled,REPLAY_MAX_GZ/REPLAY_MAX_RAW/REPLAY_DAILY_CAP, keyreplays/<build>/<install>/<unix>-<seed>.json.gz.