Remote config (implementation plan)
Remote config Implementation Plan
Section titled “Remote config 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. Code-writing subagents: use thebh-implementeragent (opus); review withbh-reviewer.
Goal: Ship the client + Worker half of remote config in the launch binary, and convert the first batch of sim tuning consts, so balance can change per build without an App Store build.
Architecture: Render-side Tunables sets registered GDScript static vars by name at run start (reset-to-defaults first); a BibleOverlay patches numeric fields of the parsed bible.json before ContentDB is built; RemoteConfig fetches /config?build=N&install=ID from the existing telemetry Worker, caches the last good patch, and owns the crash-loop guard. The Worker serves per-build patches from KV with an older-build fallback. /sim only gains static var declarations.
Tech Stack: Godot 4.7.1 / typed GDScript, GUT 9.7.1, Cloudflare Worker (JS, KV, D1), Node node:test, wrangler.
Spec: docs/superpowers/specs/2026-09-27-remote-config-design.md
Global Constraints
Section titled “Global Constraints”/simstays pure: no FileAccess/JSON/HTTP/OS insim/*.gd. Sim files only declarestatic vartunables. All loading/fetching/setting lives innet/orcontent/.- The pinned determinism baselines (
tests/test_determinism_survival.gd:4217109746/3312602286, andtests/test_determinism_checksum.gd) must NOT change in any task. A const → static var swap that moves them is a bug, not a re-pin. - GUT tests must never do real network or disk IO for remote config:
tests/gut_pre_run.gdsetsRemoteConfig.io_enabled = false(Task 3) and every test that applies a patch callsTunables.reset()inafter_each(static vars are process-global and would leak into the determinism tests). - tvOS:
user://is NOT writable on tvOS; write touser://first, fall back toOS.get_cache_dir()(MetaStore’s pattern,meta/meta_store.gd:121-157). - Patches are numbers only (App Store guideline 2.5.2): no code, no added/removed content entries.
- Default bounds: 0.1×–10× the bundled value (sign-aware); a bundled default of 0 needs explicit
min/max. - Merge gate per task:
bash scripts/gate.shexit 0. Any sim-behaviour task also needs one cleanscripts/soak.sh --runs=2 --ticks=18000run. - Commit +
git push origin mainafter every merge (two devs onmain). Pull/rebase before starting each task. - Worker deploys:
CLOUDFLARE_ACCOUNT_ID=06f5622d4192dcefefbe0da75d766bfc npx wrangler …fromtelemetry/(see.claude/skills/bh-telemetry/SKILL.md). Never addaccount_idtowrangler.toml. - Endpoint base:
https://bullet-heaven-telemetry.chris-allen-06f.workers.dev.
File map
Section titled “File map”| File | Responsibility |
|---|---|
net/tunables.gd (new) |
reset/apply/hash over registered static vars |
net/tunables_registry.gd (new) |
the list of tunable entries + known non-tunable static vars |
content/bible_overlay.gd (new) |
validate + apply category.id.field numeric patches to a raw bible dict |
content/content_loader.gd (modify) |
add read_raw(path) |
content/game_content.gd (new) |
the ONE render-side “bible + active overlay → ContentDB” loader for main.gd |
net/local_json.gd (new) |
read/write a small JSON file, user:// then cache-dir fallback, IO flag |
net/remote_config_state.gd (new) |
pure patch state machine: cache, pending, kill switch, blacklist, crash marker |
net/remote_config.gd (new) |
Node: HTTP fetch, owns the state, static active id/hash/overlay |
sim/meta_state.gd (modify) |
persist install_id |
main.gd (modify) |
wire fetch, run-start apply, menu/codex content, crash guard, replay config |
input/replay_recorder.gd (modify) |
store the run’s patch in the replay |
net/telemetry.gd, net/gameplay_telemetry.gd, net/dash_encounter_telemetry.gd (modify) |
stamp config_id |
telemetry/src/worker.js, telemetry/schema.sql, telemetry/wrangler.toml, telemetry/package.json (new), telemetry/test/config.test.mjs (new) |
/config route, KV, config_id columns, tests |
tools/remote_config/dump_manifest.gd (new), scripts/publish-config.mjs (new), remote-config/ (new dir) |
manifest + validated publish |
tools/soak/soak_runner.gd (modify) |
--config=path to soak a patch before publishing |
sim/sim.gd, sim/elemental_system.gd (modify) |
batch 1 const → static var |
Task 1: Tunables core + registry
Section titled “Task 1: Tunables core + registry”Files:
- Create:
net/tunables.gd,net/tunables_registry.gd,tests/tunables_fixture.gd,tests/test_tunables.gd - Modify:
tests/gut_pre_run.gd
Interfaces:
-
Produces:
Tunables.reset(list: Array = TunablesRegistry.ENTRIES) -> void;Tunables.apply(patch: Dictionary, list: Array = TunablesRegistry.ENTRIES) -> Array(returns problem strings);Tunables.bounds(entry: Dictionary) -> Vector2;Tunables.default_of(key: String, list := TunablesRegistry.ENTRIES) -> Variant;Tunables.config_hash(bible_overlay: Dictionary, list := TunablesRegistry.ENTRIES) -> int. Entry shape:{"key": "Sim.BOSS_INTERVAL", "script": "res://sim/sim.gd", "name": "BOSS_INTERVAL", "min"?: float, "max"?: float}.TunablesRegistry.ENTRIES: Array,TunablesRegistry.NON_TUNABLE_STATIC_VARS: Array[String]("<path>:<name>"). -
Step 1: Fixture + failing tests
tests/tunables_fixture.gd:
class_name TunablesFixtureextends RefCounted
# Test-only static vars for tests/test_tunables.gd (not a test script: no test_ prefix).static var SPEED: float = 150.0static var COUNT: int = 10static var ZERO: float = 0.0
const P := "res://tests/tunables_fixture.gd"const ENTRIES := [ {"key": "Fx.SPEED", "script": P, "name": "SPEED"}, {"key": "Fx.COUNT", "script": P, "name": "COUNT"}, {"key": "Fx.ZERO", "script": P, "name": "ZERO"}, {"key": "Fx.ZERO_BOUNDED", "script": P, "name": "ZERO", "min": -1.0, "max": 1.0},]tests/test_tunables.gd:
extends GutTest
const E := TunablesFixture.ENTRIES
func after_each() -> void: Tunables.reset(E)
func test_apply_sets_value_and_reset_restores() -> void: var problems := Tunables.apply({"Fx.SPEED": 99.5}, E) assert_eq(problems, []) assert_eq(TunablesFixture.SPEED, 99.5) Tunables.reset(E) assert_eq(TunablesFixture.SPEED, 150.0)
func test_apply_is_not_cumulative() -> void: Tunables.apply({"Fx.SPEED": 99.5}, E) Tunables.apply({"Fx.COUNT": 12}, E) assert_eq(TunablesFixture.SPEED, 150.0, "second apply starts from defaults") assert_eq(TunablesFixture.COUNT, 12)
func test_unknown_key_skipped_and_reported() -> void: var problems := Tunables.apply({"Fx.NOPE": 1.0, "Fx.SPEED": 120.0}, E) assert_eq(problems.size(), 1) assert_string_contains(problems[0], "Fx.NOPE") assert_eq(TunablesFixture.SPEED, 120.0, "valid keys still apply")
func test_out_of_bounds_rejected() -> void: var problems := Tunables.apply({"Fx.SPEED": 1500.1}, E) # > 10x assert_eq(problems.size(), 1) assert_eq(TunablesFixture.SPEED, 150.0) assert_eq(Tunables.apply({"Fx.SPEED": 14.9}, E).size(), 1, "< 0.1x rejected")
func test_non_numeric_rejected() -> void: assert_eq(Tunables.apply({"Fx.SPEED": "fast"}, E).size(), 1) assert_eq(TunablesFixture.SPEED, 150.0)
func test_int_tunable_needs_whole_number_and_stays_int() -> void: assert_eq(Tunables.apply({"Fx.COUNT": 12.5}, E).size(), 1) assert_eq(Tunables.apply({"Fx.COUNT": 12.0}, E), []) # JSON numbers arrive as float assert_eq(typeof(TunablesFixture.COUNT), TYPE_INT) assert_eq(TunablesFixture.COUNT, 12)
func test_zero_default_needs_explicit_bounds() -> void: assert_eq(Tunables.apply({"Fx.ZERO": 0.5}, E).size(), 1, "no implicit bounds around 0") assert_eq(Tunables.apply({"Fx.ZERO_BOUNDED": 0.5}, E), []) assert_eq(TunablesFixture.ZERO, 0.5)
func test_config_hash_changes_with_values() -> void: var h0 := Tunables.config_hash({}, E) Tunables.apply({"Fx.SPEED": 120.0}, E) assert_ne(Tunables.config_hash({}, E), h0) Tunables.reset(E) assert_eq(Tunables.config_hash({}, E), h0) assert_ne(Tunables.config_hash({"weapons.fork.base_damage": 3.0}, E), h0)- Step 2: Run, expect FAIL (
Tunablesnot defined)
Run: godot --headless --path . --import >/dev/null 2>&1; godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_tunables -gexit
- Step 3: Implement
net/tunables_registry.gd:
class_name TunablesRegistryextends RefCounted
# Every remotely-tunable sim value (spec docs/superpowers/specs/2026-09-27-remote-config-design.md §3.1).# Converting a sim const: `const X: float = 1.0` -> `static var X: float = 1.0` in the sim file, plus# one entry here. Key = "<Class>.<NAME>". Optional "min"/"max" override the default 0.1x-10x bounds# (required when the bundled default is 0). tests/test_tunables_registry.gd keeps this list and the# sim's static vars in lockstep.const ENTRIES: Array = []
# Static vars in sim/*.gd that are runtime state, not tunables ("<path>:<name>").const NON_TUNABLE_STATIC_VARS: Array[String] = [ "res://sim/meta_state.gd:buy_attempts", "res://sim/meta_state.gd:buy_successes", "res://sim/spawn_table.gd:_table",]net/tunables.gd:
class_name Tunablesextends RefCounted
# Render-side setter for registered sim `static var` tunables (spec §3.1). Called ONLY at run start,# before Sim.new(). apply() always resets to the bundled defaults first, so values never compound.# Bundled defaults are captured from the scripts the first time an entry is touched, before anything# here has written to it.
static var _defaults: Dictionary = {} # key -> bundled default
static func _script(e: Dictionary) -> GDScript: return load(String(e["script"])) as GDScript
static func _ensure_defaults(list: Array) -> void: for e in list: var key := String(e["key"]) if not _defaults.has(key): _defaults[key] = _script(e).get(String(e["name"]))
static func default_of(key: String, list: Array = TunablesRegistry.ENTRIES) -> Variant: _ensure_defaults(list) return _defaults.get(key)
static func reset(list: Array = TunablesRegistry.ENTRIES) -> void: _ensure_defaults(list) for e in list: _script(e).set(String(e["name"]), _defaults[String(e["key"])])
# x = min, y = max. Sign-aware 0.1x-10x unless the entry overrides; (NAN, NAN) = no valid range.static func bounds(e: Dictionary) -> Vector2: var d := float(_defaults.get(String(e["key"]), 0.0)) if e.has("min") and e.has("max"): return Vector2(float(e["min"]), float(e["max"])) if is_zero_approx(d): return Vector2(NAN, NAN) return Vector2(minf(d * 0.1, d * 10.0), maxf(d * 0.1, d * 10.0))
static func apply(patch: Dictionary, list: Array = TunablesRegistry.ENTRIES) -> Array: reset(list) var by_key := {} for e in list: by_key[String(e["key"])] = e var problems: Array = [] for k in patch: var key := String(k) if not by_key.has(key): problems.append("unknown tunable %s" % key) continue var e: Dictionary = by_key[key] var v: Variant = patch[k] if not (typeof(v) in [TYPE_INT, TYPE_FLOAT]): problems.append("%s: not a number" % key) continue var b := bounds(e) if is_nan(b.x): problems.append("%s: default is 0, entry needs explicit min/max" % key) continue var f := float(v) if f < b.x or f > b.y: problems.append("%s: %s outside [%s, %s]" % [key, f, b.x, b.y]) continue if typeof(_defaults[key]) == TYPE_INT: if f != floorf(f): problems.append("%s: int tunable got %s" % [key, f]) continue _script(e).set(String(e["name"]), int(f)) else: _script(e).set(String(e["name"]), f) return problems
# Effective values of every tunable (in registry order) + the bible overlay (sorted keys).static func config_hash(bible_overlay: Dictionary, list: Array = TunablesRegistry.ENTRIES) -> int: var parts: Array = [] for e in list: parts.append([String(e["key"]), _script(e).get(String(e["name"]))]) var keys := bible_overlay.keys() keys.sort() for k in keys: parts.append([String(k), bible_overlay[k]]) return str(parts).hash()tests/gut_pre_run.gd: append in run():
Tunables.reset() # every registered tunable starts at its bundled default- Step 4: Run, expect PASS. Same command.
- Step 5:
bash scripts/gate.sh→ PASS. Commitfeat(remote-config): Tunables core + registry, push.
Task 2: Bible overlay + GameContent
Section titled “Task 2: Bible overlay + GameContent”Files:
- Create:
content/bible_overlay.gd,content/game_content.gd,tests/test_bible_overlay.gd - Modify:
content/content_loader.gd(addread_raw, reuse it inload_from_path)
Interfaces:
-
Consumes: none.
-
Produces:
BibleOverlay.apply(raw: Dictionary, overlay: Dictionary) -> Array(mutatesraw["data"], returns problems);BibleOverlay.overlayable_fields(raw: Dictionary) -> Dictionary("category.id.field" -> float default);ContentLoader.read_raw(path: String) -> Dictionary({}on failure);GameContent.load_db(overlay: Dictionary) -> ContentDB;GameContent.BIBLE_PATH. -
Step 1: Failing tests
tests/test_bible_overlay.gd:
extends GutTest
func _raw() -> Dictionary: return ContentLoader.read_raw(GameContent.BIBLE_PATH)
func _entry(raw: Dictionary, cat: String, id: String) -> Dictionary: for e in raw["data"][cat]: if e.get("id", "") == id: return e return {}
func test_patches_existing_numeric_field() -> void: var raw := _raw() var base := float(_entry(raw, "meta_upgrades", "vitality")["base_cost"]) var problems := BibleOverlay.apply(raw, {"meta_upgrades.vitality.base_cost": base * 2.0}) assert_eq(problems, []) assert_eq(float(_entry(raw, "meta_upgrades", "vitality")["base_cost"]), base * 2.0)
func test_rejects_unknown_id_field_and_non_numeric() -> void: var raw := _raw() assert_eq(BibleOverlay.apply(raw, {"weapons.nope.base_damage": 1.0}).size(), 1) assert_eq(BibleOverlay.apply(raw, {"weapons.blade.no_such_field": 1.0}).size(), 1) assert_eq(BibleOverlay.apply(raw, {"weapons.blade.name": 1.0}).size(), 1, "string field") assert_eq(BibleOverlay.apply(raw, {"weapons.blade.base_damage": "x"}).size(), 1) assert_eq(BibleOverlay.apply(raw, {"bad_key": 1.0}).size(), 1)
func test_rejects_out_of_bounds() -> void: var raw := _raw() var d := float(_entry(raw, "weapons", "blade")["base_damage"]) assert_eq(BibleOverlay.apply(raw, {"weapons.blade.base_damage": d * 11.0}).size(), 1) assert_eq(float(_entry(raw, "weapons", "blade")["base_damage"]), d, "unchanged")
func test_overlay_reaches_content_db() -> void: var db := GameContent.load_db({"weapons.blade.base_damage": 7.5}) assert_not_null(db) assert_eq(float(db.weapon("blade")["base_damage"]), 7.5) assert_ne(float(GameContent.load_db({}).weapon("blade")["base_damage"]), 7.5, "fresh load each call")
func test_overlayable_fields_lists_numbers_only() -> void: var f := BibleOverlay.overlayable_fields(_raw()) assert_true(f.has("weapons.blade.base_damage")) assert_false(f.has("weapons.blade.name")) assert_false(f.has("weapons.blade.live"), "bools are not numbers")-
Step 2: Run
-gselect=test_bible_overlay→ FAIL. -
Step 3: Implement.
content/content_loader.gd: add, and make load_from_path use it:
# Raw parsed bible (before validation), or {} on any read/parse failure. GameContent layers the# remote-config overlay onto this before load_from_dict validates it.static func read_raw(path: String) -> Dictionary: if not FileAccess.file_exists(path): push_error("ContentLoader: file not found: %s" % path) return {} var parsed: Variant = JSON.parse_string(FileAccess.get_file_as_string(path)) if not (parsed is Dictionary): push_error("ContentLoader: %s is not a JSON object" % path) return {} return parsed
static func load_from_path(path: String) -> ContentDB: var raw := read_raw(path) return null if raw.is_empty() else load_from_dict(raw)content/bible_overlay.gd:
class_name BibleOverlayextends RefCounted
# Remote-config patch over the parsed bible (spec §3.2). Keys are "category.id.field"; only EXISTING# numeric fields of EXISTING entries change (no adds/removes: App Store 2.5.2, numbers only). Bounds# are 0.1x-10x the bundled value; a bundled 0 can't be patched. Mutates raw["data"] in place.
static func _find(data: Dictionary, cat: String, id: String) -> Dictionary: var v: Variant = data.get(cat, []) if v is Array: for e in v: if e is Dictionary and String(e.get("id", "")) == id: return e return {}
static func _is_num(v: Variant) -> bool: return typeof(v) in [TYPE_INT, TYPE_FLOAT]
static func apply(raw: Dictionary, overlay: Dictionary) -> Array: var problems: Array = [] var data: Dictionary = raw.get("data", {}) for k in overlay: var key := String(k) var parts := key.split(".") if parts.size() != 3: problems.append("bible %s: key must be category.id.field" % key) continue var e := _find(data, parts[0], parts[1]) if e.is_empty(): problems.append("bible %s: no such entry" % key) continue if not e.has(parts[2]) or not _is_num(e[parts[2]]): problems.append("bible %s: not an existing numeric field" % key) continue var v: Variant = overlay[k] if not _is_num(v): problems.append("bible %s: not a number" % key) continue var d := float(e[parts[2]]) if is_zero_approx(d): problems.append("bible %s: bundled value is 0, not patchable" % key) continue var lo := minf(d * 0.1, d * 10.0) var hi := maxf(d * 0.1, d * 10.0) if float(v) < lo or float(v) > hi: problems.append("bible %s: %s outside [%s, %s]" % [key, float(v), lo, hi]) continue e[parts[2]] = float(v) return problems
# Every patchable key with its bundled value (for the manifest tool).static func overlayable_fields(raw: Dictionary) -> Dictionary: var out := {} var data: Dictionary = raw.get("data", {}) for cat in data: if not (data[cat] is Array): continue for e in data[cat]: if not (e is Dictionary) or String(e.get("id", "")) == "": continue for f in e: if _is_num(e[f]) and not is_zero_approx(float(e[f])): out["%s.%s.%s" % [cat, e["id"], f]] = float(e[f]) return out(JSON true/false parse as TYPE_BOOL, so _is_num excludes them.)
content/game_content.gd:
class_name GameContentextends RefCounted
# The ONE render-side loader main.gd uses for the bible: raw file + remote-config overlay -> ContentDB# (spec §3.2). Tools and tests keep calling ContentLoader directly (bundled defaults).const BIBLE_PATH := "res://data/bible.json"
static func load_db(overlay: Dictionary) -> ContentDB: var raw := ContentLoader.read_raw(BIBLE_PATH) if raw.is_empty(): return null for p in BibleOverlay.apply(raw, overlay): push_warning("remote config: " + String(p)) return ContentLoader.load_from_dict(raw)- Step 4: Run → PASS. Step 5: gate → PASS; commit
feat(remote-config): bible overlay + GameContent loader; push.
Task 3: RemoteConfigState, LocalJson, RemoteConfig node
Section titled “Task 3: RemoteConfigState, LocalJson, RemoteConfig node”Files:
- Create:
net/local_json.gd,net/remote_config_state.gd,net/remote_config.gd,tests/test_remote_config_state.gd - Modify:
tests/gut_pre_run.gd
Interfaces:
-
Consumes:
Tunables.apply/config_hash(Task 1). -
Produces:
LocalJson.read(name: String) -> Dictionary,LocalJson.write(name: String, d: Dictionary) -> bool,static var LocalJson.io_enabled: bool.RemoteConfigState(RefCounted):var store: Object(needsread() -> Dictionary,write(d: Dictionary)),boot() -> void,receive(patch: Dictionary) -> bool,peek_for_run() -> Dictionary,take_for_run() -> Dictionary,run_started(config_id: String),run_stable(),blacklist: Array,static func valid(patch: Dictionary) -> bool.RemoteConfig(Node):signal changed,static var io_enabled := true,static var active_id := "default",static var active_hash := 0,static var active_patch: Dictionary = {},var state: RemoteConfigState,func setup(build: int, install_id: String),func apply_for_run(override: Variant = null) -> Dictionary(applies Tunables, sets active_*, returns the patch),func next_bible_overlay() -> Dictionary,static func active_bible() -> Dictionary,func mark_run_started(),func mark_run_stable().
-
Step 1: Failing tests
tests/test_remote_config_state.gd:
extends GutTest
class MemStore: var d: Dictionary = {} func read() -> Dictionary: return d.duplicate(true) func write(x: Dictionary) -> void: d = x.duplicate(true)
var store: MemStorevar s: RemoteConfigState
func before_each() -> void: store = MemStore.new() s = RemoteConfigState.new() s.store = store s.boot()
func _p(id: String) -> Dictionary: return {"config_id": id, "tunables": {}, "bible": {}}
func test_no_config_means_defaults() -> void: assert_eq(s.take_for_run(), {})
func test_received_patch_is_used_and_cached() -> void: assert_true(s.receive(_p("b36-001"))) assert_eq(s.take_for_run()["config_id"], "b36-001") var s2 := RemoteConfigState.new() s2.store = store s2.boot() assert_eq(s2.take_for_run()["config_id"], "b36-001", "survives restart via cache")
func test_empty_patch_is_kill_switch() -> void: s.receive(_p("b36-001")) assert_true(s.receive({})) assert_eq(s.take_for_run(), {})
func test_malformed_patch_ignored() -> void: s.receive(_p("b36-001")) assert_false(s.receive({"tunables": {}}), "no config_id") assert_false(s.receive({"config_id": "x", "tunables": []}), "tunables not a dict") assert_eq(s.take_for_run()["config_id"], "b36-001")
func test_crash_marker_blacklists_on_next_boot() -> void: s.receive(_p("b36-bad")) s.run_started("b36-bad") # app dies before run_stable() var s2 := RemoteConfigState.new() s2.store = store s2.boot() assert_true(s2.blacklist.has("b36-bad")) assert_eq(s2.take_for_run(), {}, "blacklisted config falls back to defaults") assert_false(s2.receive(_p("b36-bad")), "refetch of the same id stays refused") assert_true(s2.receive(_p("b36-good")))
func test_stable_run_clears_marker() -> void: s.receive(_p("b36-001")) s.run_started("b36-001") s.run_stable() var s2 := RemoteConfigState.new() s2.store = store s2.boot() assert_false(s2.blacklist.has("b36-001"))
func test_default_run_writes_no_marker() -> void: s.run_started("default") var s2 := RemoteConfigState.new() s2.store = store s2.boot() assert_eq(s2.blacklist, [])
func test_peek_does_not_consume() -> void: s.receive(_p("b36-001")) assert_eq(s.peek_for_run()["config_id"], "b36-001") assert_eq(s.take_for_run()["config_id"], "b36-001")-
Step 2: Run
-gselect=test_remote_config_state→ FAIL. -
Step 3: Implement.
net/local_json.gd:
class_name LocalJsonextends RefCounted
# Small render-side JSON files. user:// first; tvOS can't write user:// (Documents is read-only there),# so fall back to the cache dir, same as MetaStore (meta/meta_store.gd). The cache dir can be purged:# only store things that are safe to lose (remote-config cache + crash marker).static var io_enabled: bool = true # tests/gut_pre_run.gd turns this off
static func _paths(name: String) -> Array: return ["user://" + name, OS.get_cache_dir().path_join(name)]
static func read(name: String) -> Dictionary: if not io_enabled: return {} for p in _paths(name): if FileAccess.file_exists(p): var parsed: Variant = JSON.parse_string(FileAccess.get_file_as_string(p)) if parsed is Dictionary: return parsed return {}
static func write(name: String, d: Dictionary) -> bool: if not io_enabled: return false var payload := JSON.stringify(d) for p in _paths(name): DirAccess.make_dir_recursive_absolute(String(p).get_base_dir()) var f := FileAccess.open(p, FileAccess.WRITE) if f != null: f.store_string(payload) f.close() return true return falsenet/remote_config_state.gd:
class_name RemoteConfigStateextends RefCounted
# Pure remote-config state (spec §3.3, §5): last good patch, a kill switch ({} received), a local# blacklist of config ids that crashed the app, and the crash marker. `store` persists it (a# LocalJson-backed object in the game, an in-memory one in tests).
var store: Objectvar cached: Dictionary = {}var pending: Dictionary = {}var pending_set: bool = falsevar blacklist: Array = []var marker: String = ""
static func valid(patch: Dictionary) -> bool: if patch.is_empty(): return true # kill switch return patch.get("config_id") is String and String(patch["config_id"]) != "" \ and patch.get("tunables", {}) is Dictionary and patch.get("bible", {}) is Dictionary
func boot() -> void: var d: Dictionary = store.read() cached = d.get("cached", {}) if d.get("cached", {}) is Dictionary else {} blacklist = d.get("blacklist", []) if d.get("blacklist", []) is Array else [] marker = String(d.get("marker", "")) if marker != "": if not blacklist.has(marker): blacklist.append(marker) marker = "" _save()
func _save() -> void: store.write({"cached": cached, "blacklist": blacklist, "marker": marker})
func receive(patch: Dictionary) -> bool: if not valid(patch): return false if not patch.is_empty() and blacklist.has(String(patch["config_id"])): return false pending = patch pending_set = true cached = patch _save() return true
func peek_for_run() -> Dictionary: var p: Dictionary = pending if pending_set else cached if not p.is_empty() and blacklist.has(String(p.get("config_id", ""))): return {} return p
func take_for_run() -> Dictionary: return peek_for_run()
func run_started(config_id: String) -> void: if config_id == "" or config_id == "default": return marker = config_id _save()
func run_stable() -> void: if marker == "": return marker = "" _save()net/remote_config.gd:
class_name RemoteConfigextends Node
# Fetches this build's remote-config patch at boot (spec §3.3), never blocking. main.gd calls# apply_for_run() at the top of every run, before Sim.new(). A fetch that lands mid-run is only# used from the next run. Silent in editor/headless and on any failure.
signal changed
const ENDPOINT := "https://bullet-heaven-telemetry.chris-allen-06f.workers.dev/config"const FILE := "remote_config.json"const STABLE_AFTER_S := 30.0
static var io_enabled: bool = truestatic var active_id: String = "default"static var active_hash: int = 0static var active_patch: Dictionary = {}
class _Store: func read() -> Dictionary: return LocalJson.read(RemoteConfig.FILE) func write(d: Dictionary) -> void: LocalJson.write(RemoteConfig.FILE, d)
var state := RemoteConfigState.new()var _http: HTTPRequest
func setup(build: int, install_id: String) -> void: state.store = _Store.new() state.boot() if not io_enabled or OS.has_feature("editor") or DisplayServer.get_name() == "headless": return _http = HTTPRequest.new() _http.timeout = 10.0 add_child(_http) _http.request_completed.connect(_on_done) _http.request("%s?build=%d&install=%s" % [ENDPOINT, build, install_id.uri_encode()])
func _on_done(result: int, code: int, _h: PackedStringArray, body: PackedByteArray) -> void: if result != HTTPRequest.RESULT_SUCCESS or code != 200: return var parsed: Variant = JSON.parse_string(body.get_string_from_utf8()) if parsed is Dictionary and state.receive(parsed): changed.emit()
# The patch the NEXT run will use (menu shop prices read this).func next_bible_overlay() -> Dictionary: var b: Variant = state.peek_for_run().get("bible", {}) return b if b is Dictionary else {}
static func active_bible() -> Dictionary: var b: Variant = active_patch.get("bible", {}) return b if b is Dictionary else {}
# `override` (a replay's recorded patch) wins over the fetched/cached one.func apply_for_run(override: Variant = null) -> Dictionary: var patch: Dictionary = override if override is Dictionary else state.take_for_run() var t: Variant = patch.get("tunables", {}) for p in Tunables.apply(t if t is Dictionary else {}): push_warning("remote config: " + String(p)) active_patch = patch active_id = String(patch.get("config_id", "default")) if not patch.is_empty() else "default" active_hash = Tunables.config_hash(active_bible()) return patch
func mark_run_started() -> void: state.run_started(active_id)
func mark_run_stable() -> void: state.run_stable()tests/gut_pre_run.gd run(): append
LocalJson.io_enabled = false RemoteConfig.io_enabled = false- Step 4: Run → PASS. Step 5: gate; commit
feat(remote-config): fetcher, cache, kill switch, crash-loop guard; push.
Task 4: Persistent install_id
Section titled “Task 4: Persistent install_id”Files: Modify sim/meta_state.gd (field + to_dict/from_dict); Test tests/test_meta_install_id.gd.
Interfaces: Produces MetaState.install_id: String ("" until main assigns one). The id lives in MetaState so tvOS’s NSUserDefaults bridge (which mirrors meta.json) keeps it across cache purges.
- Step 1: Failing test
extends GutTest
func test_install_id_round_trips() -> void: var m := MetaState.new() assert_eq(m.install_id, "") m.install_id = "abc123" var m2 := MetaState.new() m2.from_dict(m.to_dict()) assert_eq(m2.install_id, "abc123")
func test_old_save_without_install_id_loads_empty() -> void: var m := MetaState.new() var d := m.to_dict() d.erase("install_id") m.from_dict(d) assert_eq(m.install_id, "")- Step 2: run
-gselect=test_meta_install_id→ FAIL. - Step 3: In
sim/meta_state.gdaddvar install_id: String = "" # anonymous random id for remote config (spec 2026-09-27 §3.3); generated render-side by main.gd, add"install_id": install_idto theto_dict()dict, and infrom_dict()(after the schema-compat reset branch, alongside the other field reads)install_id = String(d.get("install_id", "")). Additive field, no schema bump (same asselected_ship). - Step 4: PASS. Step 5: gate; commit
feat(meta): persist anonymous install_id; push.
Task 5: Wire into main.gd + replays
Section titled “Task 5: Wire into main.gd + replays”Files: Modify main.gd, input/replay_recorder.gd; Test tests/test_remote_config_wiring.gd.
Interfaces: Consumes Tasks 1-4. ReplayRecorder.new(seed_v: int, config: Dictionary = {}), saved JSON gains "config".
- Step 1: Failing test (proves the value reaches the sim through the real run-start order):
extends GutTest
const FX := TunablesFixture.ENTRIES
func after_each() -> void: Tunables.reset() Tunables.reset(FX) RemoteConfig.active_patch = {}
func test_apply_for_run_sets_active_and_stamps() -> void: var rc := RemoteConfig.new() rc.state.store = RefCounted.new() # never touched: override path below var patch := {"config_id": "b36-t1", "tunables": {}, "bible": {"weapons.blade.base_damage": 5.0}} rc.apply_for_run(patch) assert_eq(RemoteConfig.active_id, "b36-t1") assert_eq(float(GameContent.load_db(RemoteConfig.active_bible()).weapon("blade")["base_damage"]), 5.0) rc.free()
func test_replay_records_config() -> void: var r := ReplayRecorder.new(7, {"config_id": "b36-t1"}) var path := "user://_test_replay_cfg.json" r.save(path) var d := ReplayLoader.load_file(path) assert_eq(d.get("config", {}).get("config_id"), "b36-t1") DirAccess.remove_absolute(ProjectSettings.globalize_path(path))- Step 2: run → FAIL (
ReplayRecorder.newarity / noconfig). - Step 3: Implement.
input/replay_recorder.gd: addvar config: Dictionary = {};_init(seed_v: int, config_v: Dictionary = {})sets both;save()writes{"seed": seed_value, "config": config, "ticks": ticks}.main.gd:- Near the other persistent nodes in
_ready()(aftermeta = MetaStore.load_state()at ~line 335):and declareif meta.install_id == "":meta.install_id = "%08x%08x%08x%08x" % [randi(), randi(), randi(), randi()]MetaStore.save_state(meta)remote_config = RemoteConfig.new() # persistent: fetches once per boot (spec 2026-09-27)add_child(remote_config)remote_config.setup(Sim_Const.BUILD, meta.install_id)remote_config.changed.connect(func() -> void: _menu_content_cache = null)var remote_config: RemoteConfigwith the other node fields. _new_run()(~line 851): replacevar content := ContentLoader.load_from_path("res://data/bible.json")withKeep the existing# Remote config (spec 2026-09-27): tunables + bible overlay applied BEFORE Sim.new(), never mid-run.# A replay re-applies the patch it was recorded under.remote_config.apply_for_run(_replay_play.get("config") if not _replay_play.is_empty() else null)var content := GameContent.load_db(RemoteConfig.active_bible())if content == null:error branch (update its message toGameContent.BIBLE_PATH)._replay_rec = ReplayRecorder.new(run_seed, RemoteConfig.active_patch) if RECORD_REPLAY else null.- Right after
sim = Sim.new(...)addremote_config.mark_run_started(), and a fieldvar _config_stable_marked := falsereset tofalsethere. - In
_process(delta)(line ~1606), wheresimis non-null:if not _config_stable_marked and sim.run_time >= RemoteConfig.STABLE_AFTER_S: _config_stable_marked = true; remote_config.mark_run_stable(). _menu_defs()(~line 762):_menu_content_cache = GameContent.load_db(remote_config.next_bible_overlay()).- Codex (~line 350): leave on
ContentLoader.load_from_path(bestiary text; numbers there are display-only and not in scope). Add a one-line comment saying so.
- Near the other persistent nodes in
- Step 4: run the test → PASS; boot smoke
godot --headless --path . --quit-after 600 2>&1 | grep "SCRIPT ERROR"→ nothing. - Step 5: gate +
scripts/soak.sh --runs=2 --ticks=18000clean; commitfeat(remote-config): apply at run start, menu prices, crash guard, replay config; push.
Task 6: Worker /config route + KV
Section titled “Task 6: Worker /config route + KV”Files: Modify telemetry/src/worker.js, telemetry/wrangler.toml; Create telemetry/package.json, telemetry/test/config.test.mjs.
Interfaces: Produces GET /config?build=N&install=ID → JSON patch ({} if none) with Cache-Control: public, max-age=300; exported resolveConfig(kv, build) -> Promise<string>; KV key format build-<N>.
- Step 1: Failing test
telemetry/test/config.test.mjs:
import { test } from "node:test";import assert from "node:assert/strict";import worker, { resolveConfig } from "../src/worker.js";
const kv = (m) => ({ get: async (k) => m[k] ?? null, list: async ({ prefix }) => ({ keys: Object.keys(m).filter((k) => k.startsWith(prefix)).map((name) => ({ name })) }),});
test("exact build wins", async () => { assert.equal(await resolveConfig(kv({ "build-36": '{"config_id":"a"}', "build-35": '{"config_id":"b"}' }), 36), '{"config_id":"a"}');});test("falls back to nearest OLDER build, never newer", async () => { const m = { "build-30": '{"config_id":"old"}', "build-40": '{"config_id":"new"}' }; assert.equal(await resolveConfig(kv(m), 36), '{"config_id":"old"}'); assert.equal(await resolveConfig(kv(m), 29), "{}");});test("no KV / bad build -> empty patch", async () => { assert.equal(await resolveConfig(undefined, 36), "{}"); assert.equal(await resolveConfig(kv({}), 0), "{}");});test("route returns JSON + short cache", async () => { const res = await worker.fetch(new Request("https://x/config?build=36&install=abc"), { CONFIG: kv({ "build-36": '{"config_id":"a"}' }) }); assert.equal(res.status, 200); assert.equal(await res.text(), '{"config_id":"a"}'); assert.match(res.headers.get("Cache-Control"), /max-age=300/);});telemetry/package.json: { "private": true, "type": "module", "scripts": { "test": "node --test test/" } }
- Step 2:
cd telemetry && npm test→ FAIL (resolveConfignot exported). - Step 3: Implement in
worker.js(addGET /configto the header comment list):
// Remote config (spec docs/superpowers/specs/2026-09-27-remote-config-design.md §3.4): the patch for// this exact build, else the nearest OLDER build's, else {}. `install` is accepted for future A/B// cohorts and ignored for now.export async function resolveConfig(kv, build) { if (!kv || !(build > 0)) return "{}"; const exact = await kv.get(`build-${build}`); if (exact) return exact; const { keys } = await kv.list({ prefix: "build-" }); let best = -1; for (const k of keys) { const n = parseInt(k.name.slice(6), 10); if (Number.isFinite(n) && n < build && n > best) best = n; } return best < 0 ? "{}" : ((await kv.get(`build-${best}`)) || "{}");}and inside fetch, before the /stats.json block:
if (req.method === "GET" && url.pathname === "/config") { const body = await resolveConfig(env.CONFIG, intt(url.searchParams.get("build"))); return new Response(body, { headers: { ...CORS, "Content-Type": "application/json", "Cache-Control": "public, max-age=300" } }); }- Step 4:
npm test→ PASS. - Step 5: Create KV + deploy (from
telemetry/):CLOUDFLARE_ACCOUNT_ID=06f5622d4192dcefefbe0da75d766bfc npx wrangler kv namespace create CONFIG→ add the printed block towrangler.toml:then[[kv_namespaces]]binding = "CONFIG"id = "<id printed by wrangler>"npx wrangler deploy. Verify:curl -s "https://bullet-heaven-telemetry.chris-allen-06f.workers.dev/config?build=36"→{}, andcurl -sI …showsmax-age=300. - Step 6: commit
feat(telemetry): /config route backed by KV; push.
Task 7: Stamp config_id on telemetry
Section titled “Task 7: Stamp config_id on telemetry”Files: Modify net/telemetry.gd (sample body ~line 120-135), net/gameplay_telemetry.gd (report_run body, feedback_body), net/dash_encounter_telemetry.gd (body ~line 103), telemetry/src/worker.js (4 INSERTs), telemetry/schema.sql; Test tests/test_config_stamp.gd, telemetry/test/stamp.test.mjs.
- Step 1: Failing GUT test
extends GutTest
func after_each() -> void: RemoteConfig.active_id = "default"
func test_feedback_body_carries_config_id() -> void: RemoteConfig.active_id = "b36-t9" var b := GameplayTelemetry.feedback_body("bug", "note", "s", "dev", null, "pad") assert_eq(b["config_id"], "b36-t9")and telemetry/test/stamp.test.mjs (fake D1 capturing binds):
import { test } from "node:test";import assert from "node:assert/strict";import worker from "../src/worker.js";
function fakeDB(sink) { return { prepare: (sql) => ({ bind: (...a) => ({ run: async () => { sink.push({ sql, a }); } }) }) };}for (const [path, body] of [ ["/sample", { build: 36, config_id: "b36-x" }], ["/gameplay", { build: 36, config_id: "b36-x" }], ["/encounter", { build: 36, config_id: "b36-x" }], ["/feedback", { build: 36, note: "hello", config_id: "b36-x" }],]) { test(`${path} stores config_id`, async () => { const sink = []; const res = await worker.fetch(new Request("https://x" + path, { method: "POST", body: JSON.stringify(body) }), { DB: fakeDB(sink) }); assert.equal(res.status, 200); const ins = sink.find((s) => /INSERT/.test(s.sql)); assert.match(ins.sql, /config_id/); assert.ok(ins.a.includes("b36-x")); });}- Step 2: both fail.
- Step 3: Implement.
- Add
"config_id": RemoteConfig.active_id,to: the/samplebody innet/telemetry.gd, thereport_runbody andfeedback_bodyreturn dict innet/gameplay_telemetry.gd, and the encounter body innet/dash_encounter_telemetry.gd. (Hitch batches: not stamped, they join samples by session.) worker.js: appendconfig_idto each of the 4 INSERT column lists, one more?, and bindstr(s.config_id, 40) || nulllast. Read the/feedbackINSERT first (starts ~line 130) and extend it the same way.schema.sql: addconfig_id TEXTto the 4 CREATE TABLEs and a dated migration comment block:-- 2026-09-27 remote config:-- ALTER TABLE samples ADD COLUMN config_id TEXT;-- ALTER TABLE runs ADD COLUMN config_id TEXT;-- ALTER TABLE encounters ADD COLUMN config_id TEXT;-- ALTER TABLE feedback ADD COLUMN config_id TEXT;
- Add
- Step 4: GUT
-gselect=test_config_stamp+npm test→ PASS. - Step 5: Migrate D1 BEFORE deploying (a deployed INSERT naming a missing column would fail every write): write the 4 ALTERs to
/tmp/rc-migration.sql,npx wrangler d1 execute bullet-heaven-telemetry --remote --file /tmp/rc-migration.sql, confirm withPRAGMA table_info(runs)(grepconfig_id), thennpx wrangler deploy. Smoke: POST a/feedbackwithsession:"smoketest", confirm the row hasconfig_id, delete it (bh-telemetry skill lines 160-165). - Step 6: gate; commit
feat(telemetry): stamp config_id on samples, runs, encounters, feedback; push.
Task 8: Manifest tool + validated publish script
Section titled “Task 8: Manifest tool + validated publish script”Files: Create tools/remote_config/dump_manifest.gd, scripts/publish-config.mjs, scripts/test/publish-config.test.mjs, remote-config/README.md; Modify .claude/skills/bh-appstore-release/SKILL.md (one step; gitignored local skill, edit in place in the main checkout) and scripts/release/release.sh (call the dump after the BUILD bump).
Interfaces: Manifest JSON remote-config/manifest/build-<N>.json = {"build": N, "tunables": {key: {"default": v, "type": "int"|"float", "min": x, "max": y}}, "bible": {key: {"default": v, "min": x, "max": y}}}. node scripts/publish-config.mjs <N> [--dry-run].
- Step 1: Failing test
scripts/test/publish-config.test.mjs:
import { test } from "node:test";import assert from "node:assert/strict";import { validate } from "../publish-config.mjs";
const manifest = { tunables: { "Sim.BOSS_INTERVAL": { default: 80, type: "float", min: 8, max: 800 }, "Sim.BIOMASS_TARGET": { default: 400, type: "int", min: 40, max: 4000 } }, bible: { "weapons.blade.base_damage": { default: 3.9, min: 0.39, max: 39 } },};const ok = { config_id: "b36-001", note: "x", tunables: { "Sim.BOSS_INTERVAL": 90 }, bible: { "weapons.blade.base_damage": 4.2 } };
test("valid patch passes", () => assert.deepEqual(validate(ok, manifest), []));test("empty patch (kill switch) passes", () => assert.deepEqual(validate({}, manifest), []));test("rejects missing config_id, unknown keys, bounds, non-int", () => { assert.equal(validate({ ...ok, config_id: "" }, manifest).length, 1); assert.equal(validate({ ...ok, tunables: { "Sim.NOPE": 1 } }, manifest).length, 1); assert.equal(validate({ ...ok, tunables: { "Sim.BOSS_INTERVAL": 900 } }, manifest).length, 1); assert.equal(validate({ ...ok, tunables: { "Sim.BIOMASS_TARGET": 12.5 } }, manifest).length, 1); assert.equal(validate({ ...ok, bible: { "weapons.blade.base_damage": "x" } }, manifest).length, 1);});- Step 2:
node --test scripts/test/→ FAIL. - Step 3: Implement.
tools/remote_config/dump_manifest.gd:
extends SceneTree
# Writes remote-config/manifest/build-<BUILD>.json: every knob THIS build accepts (spec §4).# Run: godot --headless --path . -s res://tools/remote_config/dump_manifest.gdfunc _init() -> void: Tunables.reset() var tun := {} for e in TunablesRegistry.ENTRIES: var d: Variant = Tunables.default_of(String(e["key"])) var b := Tunables.bounds(e) tun[e["key"]] = {"default": d, "type": "int" if typeof(d) == TYPE_INT else "float", "min": null if is_nan(b.x) else b.x, "max": null if is_nan(b.y) else b.y} var bib := {} var fields := BibleOverlay.overlayable_fields(ContentLoader.read_raw(GameContent.BIBLE_PATH)) for k in fields: var d := float(fields[k]) bib[k] = {"default": d, "min": minf(d * 0.1, d * 10.0), "max": maxf(d * 0.1, d * 10.0)} var out := {"build": Sim_Const.BUILD, "tunables": tun, "bible": bib} DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path("res://remote-config/manifest")) var path := "res://remote-config/manifest/build-%d.json" % Sim_Const.BUILD var f := FileAccess.open(path, FileAccess.WRITE) f.store_string(JSON.stringify(out, " ", true)) f.close() print("manifest -> %s (%d tunables, %d bible fields)" % [path, tun.size(), bib.size()]) quit()scripts/publish-config.mjs:
#!/usr/bin/env node// Validate remote-config/build-<N>.json against remote-config/manifest/build-<N>.json, then upload it// to the telemetry Worker's KV as build-<N> (spec §4). Usage: node scripts/publish-config.mjs <N> [--dry-run]import { readFileSync } from "node:fs";import { execFileSync } from "node:child_process";import { fileURLToPath } from "node:url";import path from "node:path";
const isNum = (v) => typeof v === "number" && Number.isFinite(v);
export function validate(patch, manifest) { const p = []; if (Object.keys(patch).length === 0) return p; // kill switch if (typeof patch.config_id !== "string" || patch.config_id === "") p.push("config_id missing"); for (const [sect, table] of [["tunables", manifest.tunables], ["bible", manifest.bible]]) { for (const [k, v] of Object.entries(patch[sect] || {})) { const m = table[k]; if (!m) { p.push(`${sect}: unknown key ${k}`); continue; } if (!isNum(v)) { p.push(`${sect}: ${k} not a number`); continue; } if (m.min == null || v < m.min || v > m.max) { p.push(`${sect}: ${k}=${v} outside [${m.min}, ${m.max}]`); continue; } if (m.type === "int" && !Number.isInteger(v)) p.push(`${sect}: ${k} must be a whole number`); } } return p;}
if (process.argv[1] === fileURLToPath(import.meta.url)) { const n = parseInt(process.argv[2], 10); if (!(n > 0)) { console.error("usage: publish-config.mjs <build> [--dry-run]"); process.exit(2); } const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const patchPath = path.join(root, `remote-config/build-${n}.json`); const patch = JSON.parse(readFileSync(patchPath, "utf8")); const manifest = JSON.parse(readFileSync(path.join(root, `remote-config/manifest/build-${n}.json`), "utf8")); const problems = validate(patch, manifest); if (problems.length) { console.error("REFUSED:\n - " + problems.join("\n - ")); process.exit(1); } console.log(`ok: ${patch.config_id || "(kill switch)"} for build ${n}`); if (process.argv.includes("--dry-run")) process.exit(0); execFileSync("npx", ["wrangler", "kv", "key", "put", "--binding=CONFIG", "--remote", `build-${n}`, "--path", patchPath], { cwd: path.join(root, "telemetry"), stdio: "inherit", env: { ...process.env, CLOUDFLARE_ACCOUNT_ID: "06f5622d4192dcefefbe0da75d766bfc" } }); console.log(`published build-${n}; live within ~5 min (max-age=300)`);}(scripts/test/ needs {"type":"module"}: name the files .mjs as above so no package.json is needed.)
remote-config/README.md: how to write a patch (shape, config_id convention b<build>-NNN, note field), generate the manifest, --dry-run then publish, soak it first with --config (Task 9), rollback = git revert + republish, kill switch = {}, and that a patch for build N also serves newer builds with no patch of their own.
scripts/release/release.sh: after the step that bumps Sim_Const.BUILD, run godot --headless --path . -s res://tools/remote_config/dump_manifest.gd and git add remote-config/manifest/. bh-appstore-release SKILL.md: one line in the build-bump section pointing at this.
- Step 4:
node --test scripts/test/→ PASS; run the dump tool → file written, bible field count > 0. - Step 5: commit
feat(remote-config): manifest tool + validated publish script; push.
Task 9: soak --config=path
Section titled “Task 9: soak --config=path”Files: Modify tools/soak/soak_runner.gd (arg parse ~line 12-30 and where each run builds its Sim), its header usage comment.
- Step 1: Add to the usage comment
# --config=path.json apply a remote-config patch (tunables + bible) to every run. - Step 2: After args parse:
var patch: Dictionary = {} if args.has("config"): patch = JSON.parse_string(FileAccess.get_file_as_string(String(args["config"]))) for p in Tunables.apply(patch.get("tunables", {})): push_error("soak --config: " + String(p)) content = GameContent.load_db(patch.get("bible", {}))(replacing the plain ContentLoader.load_from_path result when --config is given; place it after the existing var content := … line). Include "config_id": patch.get("config_id", "default") in the report’s top-level JSON.
- Step 3: Verify: write
/tmp/rc-test.json={"config_id":"t","tunables":{},"bible":{"weapons.blade.base_damage":7.0}}, runscripts/soak.sh --runs=1 --ticks=3600 --config=/tmp/rc-test.json→ report showsconfig_id: t, no errors. (Confirmscripts/soak.shpasses unknown--x=ythrough to the runner; if not, add--configto its pass-through list.) - Step 4: commit
feat(soak): --config to test a remote-config patch; push.
Task 10: Conversion batch 1 (wave/boss/elite pacing + Fork split)
Section titled “Task 10: Conversion batch 1 (wave/boss/elite pacing + Fork split)”Files: Modify sim/sim.gd, sim/elemental_system.gd, net/tunables_registry.gd; Create tests/test_tunables_registry.gd, tests/test_remote_config_sim_wiring.gd.
Recipe (deterministic): for each row, change the declaration’s leading const to static var (keep name, type annotation, value, comment exactly), and add a registry entry {"key": "<Class>.<NAME>", "script": "<path>", "name": "<NAME>"}. Verified 2026-09-27: none of these is referenced by another const, in a match pattern, or through an instance path other than Sim./ElementalSystem./bare name; static vars also work as default args and via instance access (probed on 4.7.1). Only the per-hit Fork read is inside a combat loop; it’s one read per split hit, not per entity, so no hoisting needed.
| Key | File | Type | Bundled value |
|---|---|---|---|
Sim.BOSS_FIRST_TIME |
res://sim/sim.gd |
float | 210.0 |
Sim.BOSS_INTERVAL |
sim.gd | float | 80.0 |
Sim.WAVE_PHASE_S |
sim.gd | float | 180.0 |
Sim.BOSS_PREP_S |
sim.gd | float | 10.0 |
Sim.BOSS_REST_S |
sim.gd | float | 10.0 |
Sim.BIOMASS_TRIGGER |
sim.gd | int | 100 |
Sim.BIOMASS_TARGET |
sim.gd | int | 400 |
Sim.WAVE_INCOMING_S |
sim.gd | float | 2.0 |
Sim.MAX_ALIVE_BRUTES |
sim.gd | int | 10 |
Sim.ELITE_WARDEN_TIME |
sim.gd | float | 90.0 |
Sim.ELITE_BOSS2_TIME |
sim.gd | float | 210.0 |
Sim.ELITE_WARDEN_BIOMASS |
sim.gd | int | 110 |
Sim.ELITE_BOSS2_BIOMASS |
sim.gd | int | 100 |
Sim.SWARM_BURST_TIME |
sim.gd | float | 180.0 |
Sim.SWARM_BURST_COUNT |
sim.gd | int | 70 |
Sim.BOSS_ADDS_DELAY_S |
sim.gd | float | 4.0 |
Sim.BOSS_ADD_RATE |
sim.gd | float | 1.0 |
Sim.LEECH_CAP_FRAC_PER_S |
sim.gd | float | 0.08 |
ElementalSystem.SPLIT_DMG_FRAC |
res://sim/elemental_system.gd |
float | 0.5 (add "min": 0.05, "max": 1.0) |
ElementalSystem.SPLIT_BOSS_DMG_FRAC |
elemental_system.gd | float | 0.25 (add "min": 0.0, "max": 1.0) |
- Step 1: Failing tests.
tests/test_tunables_registry.gd:
extends GutTest
# Registry <-> sim lockstep: every static var in sim/*.gd is either a registered tunable or listed as# known runtime state, and every registry entry names a real static var.func _sim_static_vars() -> Array: var out: Array = [] var re := RegEx.create_from_string("(?m)^static var ([A-Za-z_][A-Za-z0-9_]*)") for f in DirAccess.get_files_at("res://sim"): if not f.ends_with(".gd"): continue var path := "res://sim/" + f for m in re.search_all(FileAccess.get_file_as_string(path)): out.append("%s:%s" % [path, m.get_string(1)]) return out
func test_every_sim_static_var_is_accounted_for() -> void: var registered := {} for e in TunablesRegistry.ENTRIES: registered["%s:%s" % [e["script"], e["name"]]] = true for sv in _sim_static_vars(): assert_true(registered.has(sv) or TunablesRegistry.NON_TUNABLE_STATIC_VARS.has(sv), "%s is neither registered nor listed as non-tunable" % sv)
func test_every_entry_is_a_real_static_var() -> void: var real := _sim_static_vars() for e in TunablesRegistry.ENTRIES: assert_true(real.has("%s:%s" % [e["script"], e["name"]]), "%s has no static var" % e["key"]) assert_true(String(e["key"]).ends_with("." + String(e["name"])))
func test_every_entry_has_a_usable_range() -> void: Tunables.reset() for e in TunablesRegistry.ENTRIES: assert_false(is_nan(Tunables.bounds(e).x), "%s needs explicit min/max" % e["key"])tests/test_remote_config_sim_wiring.gd:
extends GutTest
# Proves a tunable reaches the sim through the real path, and that reset restores the pinned baseline# (spec §8). BIOMASS_TARGET is used because the first wave refill lands inside 600 ticks.func after_each() -> void: Tunables.reset()
func _run() -> int: var s := Sim.new(1234, SimContentFixture.db()) s.enable_survival() for i in range(600): var dir := Vector2(cos(float(i) * 0.05), sin(float(i) * 0.03)) s.tick_single(InputState.new(dir.normalized() if dir.length() > 0.0 else dir)) return s.state_checksum()
func test_patch_changes_sim_and_reset_restores_baseline() -> void: var base := _run() assert_eq(base, 3312602286, "defaults = pinned survival baseline") assert_eq(Tunables.apply({"Sim.BIOMASS_TARGET": 200.0}), []) assert_ne(_run(), base, "patched value reaches the sim") Tunables.reset() assert_eq(_run(), base, "reset restores the baseline")- Step 2: Run both → FAIL (unknown tunable / registry empty). If, once batch 1 is in,
BIOMASS_TARGET=200turns out NOT to move the 600-tick checksum (the plan author expects the first refill inside 10 s but did not run it), switch the test toSim.BIOMASS_TRIGGERorSim.WAVE_INCOMING_Srather than lengthening the run. - Step 3: Apply the recipe to all 20 rows; fill
TunablesRegistry.ENTRIES. - Step 4: Full gate. The two pinned determinism tests must pass unchanged; if either moves, a value or type was altered: diff and fix, never re-pin. Then
scripts/soak.sh --runs=2 --ticks=18000clean. - Step 5: Frame-time check —
bh-deploya dev build to the Apple TV 4K, play one 5-minute Survival run, comparesamples.frame_msp95 for this BUILD vs the previous one in D1 (bh-telemetry skill). Expect no measurable change (batch 1 has no per-entity reads). - Step 6: commit
feat(remote-config): batch 1 tunables (wave/boss/elite pacing, Fork split); push.
Task 11: Docs + release bookkeeping
Section titled “Task 11: Docs + release bookkeeping”Files: Create docs/architecture/remote-config.md (with title: frontmatter); Modify CLAUDE.md (one table row), docs/RELEASE-0.1.md (tick infra, list remaining batches), NEXT_TASKS.md.
- Step 1:
docs/architecture/remote-config.md: data flow (fetch → cache → apply at run start → stamp), how to convert a const (the recipe + hot-loop hoisting rule + “never referenced by another const / match”), how to publish (points atremote-config/README.md), the crash guard, the A/B-later note, and the privacy-label todo. - Step 2:
CLAUDE.md“Subsystem architecture” table: add a row| Remote config (tunables, bible overlay, /config Worker route, publishing a balance patch) | docs/architecture/remote-config.md |. - Step 3:
docs/RELEASE-0.1.mdremote-config item: tick infra + batch 1 with commit hashes; list next batches (bosses, elites/elite_kit, enemy behaviours/attacks, drone director, remaining sim.gd) as follow-on plans. Add the privacy-label check to §1 release gates. - Step 4: commit
docs(remote-config): architecture + release bookkeeping; push.