Skip to content

Shop economy simulator, plan 1 (implementation)

Shop Economy Simulator, Plan 1: Implementation Plan

Section titled “Shop Economy Simulator, Plan 1: 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.

Goal: Headless tooling that plays simulated multi-run “careers” (run → bank gold → buy from the shop → next run) with weak/median/strong autopilot profiles calibrated to human telemetry, ending with the FIRST career curve shown to Chris.

Architecture: One shared MetaRunSetup applies a shop save to a fresh Sim exactly as main.gd does, used by the game and by soak. ProfiledAutoPlayer wraps SkilledAutoPlayer with three skill knobs driven by its own RNG. EconomyCareer loops SoakRuns, banking gold and buying via ShopTipPolicy (the in-game ShopAdvice order). Python scripts pull human targets from D1 and summarise career JSON.

Tech Stack: Godot 4.7 typed GDScript, GUT, headless godot -s runners, Python 3.14 stdlib, wrangler D1.

Spec: docs/superpowers/specs/2026-09-27-shop-economy-sim-design.md (read it first).

Scope: spec §5 steps 1, 2, 3 and 5 (policy A only). Steps 4 (strong-profile autopilot work), 6 (value sweep, policy B) and 7 (report + price proposal) are Plan 2, written after Chris sets per-tier targets from this plan’s curve.

  • /sim is pure logic: every file there extends RefCounted, no Node/Engine/Input/Time APIs.
  • Never draw from Sim’s RNG streams (rng, upgrade_rng, drone_rng, player_rng) outside the sim. The autopilot uses its own SeededRng.
  • Determinism baselines in tests/test_determinism_survival.gd must stay green with NO re-pin.
  • main.gd behaviour must not change (pure extraction in Task 1).
  • Soak with no meta/profile in cfg must behave exactly as today (existing tests/test_soak_run.gd stays green unchanged).
  • Run one test script: godot --headless --path . -s addons/gut/gut_cmdln.gd -gselect=<script_name_without_.gd> -gexit (-gtest= does NOT narrow the run).
  • Merge gate: bash scripts/gate.sh must print GATE PASS. Then git pull --rebase && git push origin main right after each merge (two developers push to main).
  • Code-writing subagents: bh-implementer (Opus). Reviews: bh-reviewer. Never Haiku for code.
  • New class_name files need an import pass before tests see them: godot --headless --path . --import.
  • Commit messages end with the two attribution lines from the session’s system reminder.
File Responsibility
sim/meta_run_setup.gd (new) Apply a MetaState to a fresh Sim at run start. The single source of truth for main.gd and soak.
main.gd (modify ~:875-893, ~:2721-2727) Call MetaRunSetup instead of inline code.
tools/soak/soak_run.gd (modify) Accept cfg meta (a MetaState) and profile; report run_gold, run_time, meta_spent, profile.
tools/soak/soak_runner.gd (modify) --meta=<path.json>, --profile=<name> flags.
ai/skill_profile.gd (new) The three profiles’ knob values (constants).
ai/profiled_auto_player.gd (new) SkilledAutoPlayer + reaction interval, aim jitter, dash skill.
tools/economy_sim/shop_tip_policy.gd (new) Policy A: what a tip-following player buys with their gold.
tools/economy_sim/career_equip.gd (new) Equip the best owned hull, decoy and drone loadout before a run.
tools/economy_sim/economy_career.gd (new) The career loop.
tools/economy_sim/career_runner.gd (new) CLI: one career → reports/economy/career-<profile>-<seed0>.json.
tools/economy_sim/calibrate_runner.gd (new) CLI: each profile × 20 seeds on a fresh save vs human targets.
tools/economy_sim/human_targets.py (new) D1 → tools/economy_sim/human_targets.json.
tools/economy_sim/summarize.py (new) Career JSONs → milestone table (markdown).
scripts/economy-careers.sh (new) Run 3 profiles × 5 seeds in parallel.
tests/test_meta_run_setup.gd, tests/test_profiled_auto_player.gd, tests/test_shop_tip_policy.gd, tests/test_career_equip.gd, tests/test_economy_career.gd (new); tests/test_soak_run.gd (modify) Tests.

Task 1: MetaRunSetup, one shared run-start path

Section titled “Task 1: MetaRunSetup, one shared run-start path”

Files:

  • Create: sim/meta_run_setup.gd
  • Modify: main.gd (the block starting # Apply persistent meta upgrades to the fresh run's player ~:873, and the "reset_upgrades": dev command ~:2716)
  • Test: tests/test_meta_run_setup.gd

Interfaces:

  • Produces: MetaRunSetup.apply_stats(sim: Sim, meta: MetaState, ship: String) -> void (hull base + meta stats + hull perk + locked weapons); MetaRunSetup.apply(sim: Sim, meta: MetaState, ship: String, tutorial: bool) -> void (apply_stats + decoy + drone recharge + drone loadout).

  • Step 1: Write the failing tests

extends GutTest
# MetaRunSetup: the ONE place a shop save is applied to a fresh run (main.gd + soak + economy sim).
func _sim() -> Sim:
return Sim.new(1234, SimContentFixture.db())
func _defs() -> Array:
return SimContentFixture.db().meta_upgrades()
func test_purchased_hull_levels_raise_max_hp() -> void:
var fresh := _sim()
MetaRunSetup.apply(fresh, MetaState.new(), ShipBonuses.DEFAULT_SHIP, false)
var bought := _sim()
var meta := MetaState.new()
meta.levels = {"vitality": 2}
MetaRunSetup.apply(bought, meta, ShipBonuses.DEFAULT_SHIP, false)
assert_gt(bought.player.max_hp, fresh.player.max_hp, "vitality levels reach the run")
func test_fresh_save_locks_unbought_weapons() -> void:
var sim := _sim()
var meta := MetaState.new()
MetaRunSetup.apply(sim, meta, ShipBonuses.DEFAULT_SHIP, false)
assert_eq(sim.locked_weapons, meta.locked_weapons(_defs()))
assert_true(sim.locked_weapons.has("scatter"), "scatter is an Arsenal unlock")
func test_bought_weapon_is_unlocked() -> void:
var sim := _sim()
var meta := MetaState.new()
meta.levels = {"unlock-scatter": 1}
MetaRunSetup.apply(sim, meta, ShipBonuses.DEFAULT_SHIP, false)
assert_false(sim.locked_weapons.has("scatter"))
func test_owned_decoy_is_equipped_outside_the_tutorial_only() -> void:
var meta := MetaState.new()
meta.levels = {"unlock-decoy-tank": 1}
meta.selected_decoy = "tank"
var run := _sim()
MetaRunSetup.apply(run, meta, ShipBonuses.DEFAULT_SHIP, false)
assert_eq(run.decoy_type, "tank")
var tut := _sim()
MetaRunSetup.apply(tut, meta, ShipBonuses.DEFAULT_SHIP, true)
assert_eq(tut.decoy_type, "basic", "the tutorial keeps the basic decoy")
func test_unowned_selected_decoy_falls_back_to_basic() -> void:
var meta := MetaState.new()
meta.selected_decoy = "tank" # not bought
var sim := _sim()
MetaRunSetup.apply(sim, meta, ShipBonuses.DEFAULT_SHIP, false)
assert_eq(sim.decoy_type, "basic")
func test_drone_recharge_upgrade_reaches_the_sim() -> void:
var meta := MetaState.new()
meta.levels = {"drone-recharge": 1}
var sim := _sim()
MetaRunSetup.apply(sim, meta, ShipBonuses.DEFAULT_SHIP, false)
assert_almost_eq(sim.drone_recharge_mult, meta.drone_recharge_mult(_defs()), 0.0001)
assert_gt(sim.drone_recharge_mult, 1.0)
  • Step 2: Run to verify failure

Run: godot --headless --path . --import >/dev/null 2>&1; godot --headless --path . -s addons/gut/gut_cmdln.gd -gselect=test_meta_run_setup -gexit Expected: FAIL / SCRIPT ERROR “Identifier MetaRunSetup not declared”.

  • Step 3: Implement sim/meta_run_setup.gd
class_name MetaRunSetup
extends RefCounted
# Applies a shop save (MetaState) to a fresh run's Sim, in the exact order main.gd's _new_run has
# always used. The ONE copy of this sequence: main.gd, soak and the economy simulator all call it,
# so a simulated run can't quietly start differently from a real one (spec
# docs/superpowers/specs/2026-09-27-shop-economy-sim-design.md §3.1). Render-side input, never
# called by the pinned determinism baselines.
# Hull base stats + slot counts, purchased stat upgrades, the hull's headline perk, and which
# weapons are still behind an Arsenal unlock. Also what the dev "reset_upgrades" command re-runs.
static func apply_stats(sim: Sim, meta: MetaState, ship: String) -> void:
var defs := sim.content.meta_upgrades()
ShipBonuses.apply_base(ship, sim, meta) # hull base stats + slot counts FIRST
meta.apply_to(sim.player, defs) # meta upgrades stack on top
ShipBonuses.apply_to(ship, sim.player, sim.mods) # the chosen hull's headline perk
sim.locked_weapons = meta.locked_weapons(defs) # Arsenal unlocks gate level-up offers
# Everything a run start applies. The tutorial flies the basic decoy and the default drone
# loadout (spec 2026-09-24-tutorial-lesson-track §4); stat upgrades and drone recharge still apply.
static func apply(sim: Sim, meta: MetaState, ship: String, tutorial: bool) -> void:
var defs := sim.content.meta_upgrades()
apply_stats(sim, meta, ship)
if not tutorial and meta.owns_decoy(meta.selected_decoy, defs):
sim.decoy_type = meta.selected_decoy
sim.drone_recharge_mult = meta.drone_recharge_mult(defs)
if not tutorial:
sim.drone_director.set_loadout(sim, meta.build_drone_loadout(sim.drone_director.sentinel_cfg(sim), defs))
  • Step 4: Run tests, verify pass

Run: godot --headless --path . --import >/dev/null 2>&1; godot --headless --path . -s addons/gut/gut_cmdln.gd -gselect=test_meta_run_setup -gexit Expected: 6 passing.

  • Step 5: Switch main.gd to it

Read main.gd first (line numbers drift). Replace this block in _new_run (starts right after sim.areas_enabled = false):

if meta != null:
var defs := sim.content.meta_upgrades()
# The tutorial flies the starter hull with the basic decoy and the default drone loadout, so the
# run matches what it teaches (spec 2026-09-24-tutorial-lesson-track §4). Purchased meta STAT
# upgrades (apply_to) and the global drone-recharge upgrade still apply.
var ship := run_ship_for(_mode, meta.selected_ship)
ShipBonuses.apply_base(ship, sim, meta) # hull base stats + slot counts FIRST
meta.apply_to(sim.player, defs) # meta upgrades stack on top
ShipBonuses.apply_to(ship, sim.player, sim.mods) # the chosen hull's headline perk
# Weapons behind an Arsenal unlock stay out of level-up offers until bought.
sim.locked_weapons = meta.locked_weapons(defs)
# Equip the chosen decoy type (if still owned; else basic). Not in the tutorial (basic decoy).
if _mode != "tutorial" and meta.owns_decoy(meta.selected_decoy, defs):
sim.decoy_type = meta.selected_decoy
sim.drone_recharge_mult = meta.drone_recharge_mult(defs) # global drone-recharge shop upgrade
# Feed the saved drone loadout so the drone button quick-deploys it (Bay edits it mid-run);
# defs supplies the per-class upgrade magnitudes so purchased drone trees scale the specs.
# Not in the tutorial: it keeps the default loadout (see the starter-hull note above).
if _mode != "tutorial":
sim.drone_director.set_loadout(sim, meta.build_drone_loadout(sim.drone_director.sentinel_cfg(sim), defs))

with:

if meta != null:
# The tutorial flies the starter hull with the basic decoy and the default drone loadout
# (spec 2026-09-24-tutorial-lesson-track §4). One shared sequence: sim/meta_run_setup.gd.
MetaRunSetup.apply(sim, meta, run_ship_for(_mode, meta.selected_ship), _mode == "tutorial")

In the "reset_upgrades": dev command, replace:

if meta != null:
var defs := sim.content.meta_upgrades()
var ship := run_ship_for(_mode, meta.selected_ship) # tutorial: starter hull, as _new_run
ShipBonuses.apply_base(ship, sim, meta)
meta.apply_to(sim.player, defs)
ShipBonuses.apply_to(ship, sim.player, sim.mods)
sim.locked_weapons = meta.locked_weapons(defs)

with:

if meta != null:
MetaRunSetup.apply_stats(sim, meta, run_ship_for(_mode, meta.selected_ship)) # tutorial: starter hull, as _new_run

Keep the comment lines above the reset_upgrades block as they are.

  • Step 6: Gate

Run: bash scripts/gate.sh Expected: GATE PASS (tutorial trace lessons: ok, boot smoke ok, determinism green).

  • Step 7: Commit + push
Terminal window
git add sim/meta_run_setup.gd sim/meta_run_setup.gd.uid tests/test_meta_run_setup.gd tests/test_meta_run_setup.gd.uid main.gd
git commit -m "refactor(meta): one MetaRunSetup applies the shop at run start"
git pull --rebase && git push origin main

Task 2: Soak accepts a shop save and reports gold

Section titled “Task 2: Soak accepts a shop save and reports gold”

Files:

  • Modify: tools/soak/soak_run.gd (execute() setup block and the returned dict)
  • Modify: tools/soak/soak_runner.gd (_init arg parsing + per-run cfg)
  • Test: tests/test_soak_run.gd (append)

Interfaces:

  • Consumes: MetaRunSetup.apply(sim, meta, ship, false) (Task 1); MetaState.total_spent(defs) -> int (already on main).

  • Produces: SoakRun cfg key "meta": MetaState (optional). Report keys "run_gold": int, "run_time": float (seconds), "meta_spent": int (0 without meta). CLI --meta=<path to MetaState.to_dict() JSON>.

  • Step 1: Write the failing tests (append to tests/test_soak_run.gd)

# ── Shop save + gold (economy simulator, 2026-09-27) ──────────────────────
func test_report_records_run_gold_and_run_time() -> void:
var report := SoakRun.new(_cfg({"player": "skilled"})).execute()
assert_true(report.has("run_gold"))
assert_true(report.has("run_time"))
assert_gt(float(report.run_time), 0.0)
assert_eq(int(report.meta_spent), 0, "no meta in cfg means nothing spent")
func test_meta_cfg_applies_purchased_stats() -> void:
var meta := MetaState.new()
meta.levels = {"vitality": 3}
var bought := SoakRun.new(_cfg({"meta": meta, "max_ticks": 1}))
bought.execute()
var fresh := SoakRun.new(_cfg({"meta": MetaState.new(), "max_ticks": 1}))
fresh.execute()
assert_gt(bought.sim.player.max_hp, fresh.sim.player.max_hp)
func test_meta_cfg_locks_unbought_weapons_like_the_real_game() -> void:
var run := SoakRun.new(_cfg({"meta": MetaState.new(), "max_ticks": 1}))
run.execute()
assert_true(run.sim.locked_weapons.has("scatter"))
func test_meta_spent_is_reported() -> void:
var meta := MetaState.new()
meta.levels = {"vitality": 1}
var report := SoakRun.new(_cfg({"meta": meta, "max_ticks": 1})).execute()
assert_eq(int(report.meta_spent), meta.total_spent(SimContentFixture.db().meta_upgrades()))
func test_meta_run_is_deterministic_per_seed() -> void:
var a := SoakRun.new(_cfg({"meta": MetaState.new(), "player": "skilled"})).execute()
var b := SoakRun.new(_cfg({"meta": MetaState.new(), "player": "skilled"})).execute()
assert_eq(a.final_checksum, b.final_checksum)
assert_eq(a.run_gold, b.run_gold)
  • Step 2: Run to verify failure

Run: godot --headless --path . -s addons/gut/gut_cmdln.gd -gselect=test_soak_run -gexit Expected: the 5 new tests FAIL (missing keys / max_hp equal).

  • Step 3: Implement in soak_run.gd

In execute(), the existing lines are:

sim = Sim.new(int(cfg.seed), cfg.content)
sim.enable_survival()

and further down:

var meta := MetaState.new()
ShipBonuses.apply_base(String(cfg.ship), sim, meta)
ShipBonuses.apply_to(String(cfg.ship), sim.player, sim.mods)

Replace the first pair with:

sim = Sim.new(int(cfg.seed), cfg.content)
# A shop save in cfg means "start this run exactly as the real game would" (main.gd order:
# MetaRunSetup BEFORE enable_survival). Without one, the legacy soak setup below is unchanged.
var shop: MetaState = cfg.get("meta", null)
if shop != null:
MetaRunSetup.apply(sim, shop, String(cfg.ship), false)
sim.enable_survival()

and the second block with:

if shop == null:
var meta := MetaState.new()
ShipBonuses.apply_base(String(cfg.ship), sim, meta)
ShipBonuses.apply_to(String(cfg.ship), sim.player, sim.mods)

In the returned dict, after "upgrade_impacts": stats.upgrade_impacts, add:

"run_gold": sim.run_gold,
"run_time": sim.run_time,
"meta_spent": shop.total_spent(sim.content.meta_upgrades()) if shop != null else 0,
  • Step 4: soak_runner.gd --meta flag

After var maximize := String(args.get("maximize", "")) add:

var shop: MetaState = null
if args.has("meta"):
var mf := FileAccess.open(String(args["meta"]), FileAccess.READ)
if mf == null:
push_error("soak: can't read --meta file %s" % args["meta"]); quit(2); return
var parsed = JSON.parse_string(mf.get_as_text())
if not (parsed is Dictionary):
push_error("soak: --meta file is not a JSON object"); quit(2); return
shop = MetaState.new()
shop.from_dict(parsed)

and add "meta": shop to the per-run cfg dictionary passed to SoakRun.new({...}). (A null value means no shop; cfg.get("meta", null) handles both.) Also add to the header usage comment: # --meta=path.json apply a MetaState.to_dict() shop save (real run-start order).

  • Step 5: Run tests, verify pass

Run: godot --headless --path . -s addons/gut/gut_cmdln.gd -gselect=test_soak_run -gexit Expected: all pass, including every pre-existing test unchanged.

  • Step 6: Smoke the CLI
Terminal window
echo '{"levels": {"vitality": 2}}' > /tmp/meta.json
godot --headless --path . -s res://tools/soak/soak_runner.gd -- --runs=1 --ticks=3600 --player=skilled --meta=/tmp/meta.json 2>&1 | grep "\[soak\]"
python3 -c "import json,glob;f=sorted(glob.glob('reports/soak-*.json'))[-1];r=json.load(open(f))['reports'][0];print(r['run_gold'],r['run_time'],r['meta_spent'])"

Expected: one run line; the printed meta_spent > 0 and run_gold ≥ 0.

  • Step 7: Gate, commit, push
Terminal window
bash scripts/gate.sh
git add tools/soak/soak_run.gd tools/soak/soak_runner.gd tests/test_soak_run.gd
git commit -m "feat(soak): --meta shop save + run_gold/run_time/meta_spent in reports"
git pull --rebase && git push origin main

Task 3: Skill profiles (ProfiledAutoPlayer)

Section titled “Task 3: Skill profiles (ProfiledAutoPlayer)”

Files:

  • Create: ai/skill_profile.gd, ai/profiled_auto_player.gd
  • Modify: tools/soak/soak_run.gd (player selection), tools/soak/soak_runner.gd (--profile)
  • Test: tests/test_profiled_auto_player.gd

Interfaces:

  • Produces: SkillProfile.knobs(name: String) -> Dictionary returning {"decide_every": int, "aim_jitter_rad": float, "dash_skill": float}; SkillProfile.NAMES: Array[String] = ["weak", "median", "strong"]; ProfiledAutoPlayer.new(knobs: Dictionary, seed: int). SoakRun cfg key "profile": String (“” or “off” = plain SkilledAutoPlayer); report key "profile". CLI --profile=weak|median|strong|off (implies --player=skilled).

  • Step 1: Write the failing tests (tests/test_profiled_auto_player.gd)

extends GutTest
# ProfiledAutoPlayer: SkilledAutoPlayer with human-like limits (reaction interval, aim error,
# dash reliability) for the economy simulator's weak/median/strong players.
func _cfg(overrides: Dictionary = {}) -> Dictionary:
var cfg := {"seed": 1234, "ship": "cobalt", "coop": false, "max_ticks": 1200,
"content": SimContentFixture.db(), "player": "skilled"}
cfg.merge(overrides, true)
return cfg
const PERFECT := {"decide_every": 1, "aim_jitter_rad": 0.0, "dash_skill": 1.0}
func test_perfect_knobs_play_byte_identical_to_skilled() -> void:
var plain := SoakRun.new(_cfg()).execute()
var run := SoakRun.new(_cfg())
run.cfg["profile_knobs"] = PERFECT # test hook: explicit knobs instead of a named profile
var profiled := run.execute()
assert_eq(profiled.final_checksum, plain.final_checksum,
"zero-cost knobs must not change a single decision")
func test_off_profile_is_plain_skilled_auto_player() -> void:
var run := SoakRun.new(_cfg({"profile": "off", "max_ticks": 1}))
run.execute()
assert_true(run.auto is SkilledAutoPlayer)
assert_false(run.auto is ProfiledAutoPlayer)
func test_named_profile_uses_profiled_auto_player() -> void:
var run := SoakRun.new(_cfg({"profile": "weak", "max_ticks": 1}))
run.execute()
assert_true(run.auto is ProfiledAutoPlayer)
func test_profiled_run_is_deterministic_per_seed() -> void:
var a := SoakRun.new(_cfg({"profile": "weak"})).execute()
var b := SoakRun.new(_cfg({"profile": "weak"})).execute()
assert_eq(a.final_checksum, b.final_checksum)
func test_weak_profile_changes_play() -> void:
var plain := SoakRun.new(_cfg()).execute()
var weak := SoakRun.new(_cfg({"profile": "weak"})).execute()
assert_ne(weak.final_checksum, plain.final_checksum, "the knobs must actually do something")
func test_holds_its_move_between_decisions() -> void:
var sim := Sim.new(1234, SimContentFixture.db())
sim.enable_survival()
var ai := ProfiledAutoPlayer.new({"decide_every": 10, "aim_jitter_rad": 0.0, "dash_skill": 1.0}, 7)
var first := ai.compute_move_for(sim, sim.player)
sim.player.pos += Vector2(300, 0) # the world changes, but no new decision is due yet
sim.run_time += Sim_Const.DT
assert_eq(ai.compute_move_for(sim, sim.player), first)
func test_dash_fires_only_on_a_decision_tick() -> void:
var sim := Sim.new(1234, SimContentFixture.db())
sim.enable_survival()
var ai := ProfiledAutoPlayer.new({"decide_every": 10, "aim_jitter_rad": 0.0, "dash_skill": 1.0}, 7)
ai.compute_move_for(sim, sim.player) # decision at tick 0
sim.run_time += Sim_Const.DT # tick 1: no decision
assert_false(ai.wants_dash(sim, sim.player))
func test_every_profile_name_has_knobs() -> void:
for n in SkillProfile.NAMES:
var k := SkillProfile.knobs(n)
assert_true(int(k.decide_every) >= 1, n)
assert_true(float(k.dash_skill) >= 0.0 and float(k.dash_skill) <= 1.0, n)
  • Step 2: Run to verify failure

Run: godot --headless --path . --import >/dev/null 2>&1; godot --headless --path . -s addons/gut/gut_cmdln.gd -gselect=test_profiled_auto_player -gexit Expected: FAIL, ProfiledAutoPlayer / SkillProfile not declared.

  • Step 3: ai/skill_profile.gd
class_name SkillProfile
extends RefCounted
# Knob values for the economy simulator's three simulated players (spec
# docs/superpowers/specs/2026-09-27-shop-economy-sim-design.md §3.3-3.4). Starting guesses;
# Task 5 calibrates them against human telemetry and records the fitted values + targets here.
# decide_every : re-decide move/aim/dash only every N ticks (60 ticks = 1 s). 1 = every tick.
# aim_jitter_rad : uniform ± aim error per decision.
# dash_skill : chance a dash the skilled AI wants is actually taken at a decision tick.
const NAMES: Array[String] = ["weak", "median", "strong"]
const KNOBS := {
"weak": {"decide_every": 18, "aim_jitter_rad": 0.35, "dash_skill": 0.4},
"median": {"decide_every": 10, "aim_jitter_rad": 0.20, "dash_skill": 0.7},
"strong": {"decide_every": 4, "aim_jitter_rad": 0.05, "dash_skill": 0.95},
}
static func knobs(profile_name: String) -> Dictionary:
return (KNOBS[profile_name] as Dictionary).duplicate()
  • Step 4: ai/profiled_auto_player.gd
class_name ProfiledAutoPlayer
extends SkilledAutoPlayer
# SkilledAutoPlayer with human limits, for the economy simulator (spec §3.3). Every N ticks it
# makes ONE decision (move, aim, dash) from the skilled AI and holds it until the next. Aim gets a
# uniform ± error and a wanted dash is only taken with probability dash_skill. Its randomness comes
# from its OWN SeededRng, never Sim's streams, so a run stays deterministic per seed. Two draws per
# decision whatever the branch, so the draw order never depends on game state.
const RNG_SALT: int = 0x5eed_a1 # separates this stream from anything else seeded by the run seed
var decide_every: int = 1
var aim_jitter_rad: float = 0.0
var dash_skill: float = 1.0
var _rng: SeededRng
var _state: Dictionary = {} # pilot instance id -> {"tick", "move", "aim", "dash"}
func _init(knobs: Dictionary, seed_value: int) -> void:
decide_every = maxi(int(knobs.get("decide_every", 1)), 1)
aim_jitter_rad = float(knobs.get("aim_jitter_rad", 0.0))
dash_skill = float(knobs.get("dash_skill", 1.0))
_rng = SeededRng.new(seed_value ^ RNG_SALT)
func _tick_of(sim: Sim) -> int:
return int(round(sim.run_time / Sim_Const.DT))
func _decision(sim: Sim, pilot: PlayerState) -> Dictionary:
var key := pilot.get_instance_id()
var now := _tick_of(sim)
var st: Dictionary = _state.get(key, {})
if st.is_empty() or now - int(st["tick"]) >= decide_every:
var jitter := _rng.randf_range(-1.0, 1.0) * aim_jitter_rad
var roll := _rng.randf()
var aim := super.compute_aim_for(sim, pilot)
if aim != Vector2.ZERO and jitter != 0.0:
aim = aim.rotated(jitter)
st = {"tick": now,
"move": super.compute_move_for(sim, pilot),
"aim": aim,
"dash": super.wants_dash(sim, pilot) and roll < dash_skill}
_state[key] = st
return st
func compute_move_for(sim: Sim, pilot: PlayerState) -> Vector2:
return _decision(sim, pilot)["move"]
func compute_aim_for(sim: Sim, pilot: PlayerState) -> Vector2:
return _decision(sim, pilot)["aim"]
# A dash is a single press, so it only fires on the decision tick itself, never held.
func wants_dash(sim: Sim, pilot: PlayerState) -> bool:
var st := _decision(sim, pilot)
return bool(st["dash"]) and int(st["tick"]) == _tick_of(sim)

Note for the implementer: with PERFECT knobs every tick is a decision, roll < 1.0 is always true and jitter == 0.0, so outputs equal SkilledAutoPlayer’s exactly. Check one subtlety: SkilledAutoPlayer caches its threat map per (pilot, run_time); calling compute_aim_for before compute_move_for within the same tick is safe because of that cache, and it does not consume Sim RNG. If the equivalence test fails, compare call order with SoakRun._input_for (move, aim, decoy, dash) before changing anything else.

  • Step 5: Wire into soak_run.gd

In execute(), the final else: branch of the player selection is:

else:
auto = SkilledAutoPlayer.new()

Replace with:

else:
var knobs: Dictionary = cfg.get("profile_knobs", {})
var profile_name := String(cfg.get("profile", ""))
if knobs.is_empty() and profile_name != "" and profile_name != "off":
knobs = SkillProfile.knobs(profile_name)
auto = ProfiledAutoPlayer.new(knobs, int(cfg.seed)) if not knobs.is_empty() else SkilledAutoPlayer.new()

Add to the returned dict: "profile": String(cfg.get("profile", "")),.

  • Step 6: soak_runner.gd --profile

After the --meta parsing add:

var profile := String(args.get("profile", ""))
if profile != "" and profile != "off":
player = "skilled" # a profile is a skilled player with limits

Add "profile": profile to the per-run cfg. Header comment: # --profile=weak|median|strong|off economy-sim skill profile (implies --player=skilled).

  • Step 7: Run tests, verify pass

Run: godot --headless --path . --import >/dev/null 2>&1; godot --headless --path . -s addons/gut/gut_cmdln.gd -gselect=test_profiled_auto_player -gexit Expected: 8 passing.

  • Step 8: Gate, commit, push
Terminal window
bash scripts/gate.sh
git add ai/skill_profile.gd ai/skill_profile.gd.uid ai/profiled_auto_player.gd ai/profiled_auto_player.gd.uid tools/soak/soak_run.gd tools/soak/soak_runner.gd tests/test_profiled_auto_player.gd tests/test_profiled_auto_player.gd.uid
git commit -m "feat(ai): weak/median/strong ProfiledAutoPlayer for the economy sim"
git pull --rebase && git push origin main

Task 4: Human targets + calibration runner

Section titled “Task 4: Human targets + calibration runner”

Files:

  • Create: tools/economy_sim/human_targets.py, tools/economy_sim/calibrate_runner.gd
  • Output (committed): tools/economy_sim/human_targets.json

Interfaces:

  • Produces: human_targets.json = {"generated": "<iso date>", "filters": "<sql where>", "n": int, "weak": {"run_time": float, "gold": float}, "median": {...}, "strong": {...}} (p25 / p50 / p85). calibrate_runner.gd CLI: -- --seeds=20 --ticks=36000 [--profiles=weak,median,strong], prints one line per profile and exits 0.

  • Step 1: human_targets.py

#!/usr/bin/env python3
"""Human Survival-run percentiles from live telemetry -> human_targets.json (spec §3.4).
Needs wrangler logged in. Run from the repo root:
CLOUDFLARE_ACCOUNT_ID=06f5622d4192dcefefbe0da75d766bfc python3 tools/economy_sim/human_targets.py
"""
import datetime, json, os, subprocess
WHERE = ("ruleset='survival' AND COALESCE(is_auto,0)=0 AND build>=185 "
"AND death_cause!='' AND run_time<1800")
PCT = {"weak": 25, "median": 50, "strong": 85}
HERE = os.path.dirname(os.path.abspath(__file__))
def query(sql):
out = subprocess.run(
["npx", "wrangler", "d1", "execute", "bullet-heaven-telemetry", "--remote", "--json",
"--command", sql],
cwd=os.path.join(HERE, "..", "..", "telemetry"), capture_output=True, text=True, check=True)
return json.loads(out.stdout)[0]["results"]
def percentile(values, p):
"""Linear-interpolated percentile, p in 0..100."""
s = sorted(values)
if not s:
raise SystemExit("no rows matched; check the filters")
k = (len(s) - 1) * p / 100.0
lo, hi = int(k), min(int(k) + 1, len(s) - 1)
return s[lo] + (s[hi] - s[lo]) * (k - lo)
def main():
rows = query(f"SELECT run_time, gold FROM runs WHERE {WHERE}")
times = [float(r["run_time"]) for r in rows]
golds = [float(r["gold"]) for r in rows]
out = {"generated": datetime.date.today().isoformat(), "filters": WHERE, "n": len(rows)}
for name, p in PCT.items():
out[name] = {"run_time": round(percentile(times, p), 1), "gold": round(percentile(golds, p), 1)}
path = os.path.join(HERE, "human_targets.json")
with open(path, "w") as f:
json.dump(out, f, indent=2)
print(json.dumps(out, indent=2))
if __name__ == "__main__":
main()

Note: a run’s run_time and gold percentiles are computed independently (the p85 time and p85 gold may come from different runs). That’s intended: each is a separate matching target.

  • Step 2: Run it

Run: CLOUDFLARE_ACCOUNT_ID=06f5622d4192dcefefbe0da75d766bfc python3 tools/economy_sim/human_targets.py Expected: JSON with "n": 18 or more (18 as of 2026-09-27) and weak < median < strong on both metrics.

  • Step 3: calibrate_runner.gd
extends SceneTree
# Calibration check for the economy simulator's skill profiles (spec §3.4). DEV ONLY.
# godot --headless --path . -s res://tools/economy_sim/calibrate_runner.gd -- --seeds=20
# Each profile plays N seeds on a FRESH shop save (real run-start order via MetaRunSetup) and is
# compared with tools/economy_sim/human_targets.json. PASS = mean within ±20% on both run_time
# and gold. Prints a line per profile; always exits 0 (it's a measuring tool, not a gate).
const TOLERANCE := 0.20
func _init() -> void:
var args := _parse(OS.get_cmdline_user_args())
var content := ContentLoader.load_from_path("res://data/bible.json")
var tf := FileAccess.open("res://tools/economy_sim/human_targets.json", FileAccess.READ)
if content == null or tf == null:
push_error("calibrate: missing bible.json or human_targets.json"); quit(2); return
var targets: Dictionary = JSON.parse_string(tf.get_as_text())
var seeds := int(args.get("seeds", "20"))
var ticks := int(args.get("ticks", "36000"))
var profiles: PackedStringArray = String(args.get("profiles", "weak,median,strong")).split(",")
print("[calibrate] human n=%d (%s)" % [int(targets["n"]), String(targets["generated"])])
for p in profiles:
var t_sum := 0.0
var g_sum := 0.0
for s in range(seeds):
var rep := SoakRun.new({"seed": 5000 + s, "ship": ShipBonuses.DEFAULT_SHIP, "coop": false,
"max_ticks": ticks, "content": content, "player": "skilled", "profile": p,
"meta": MetaState.new()}).execute()
t_sum += float(rep["run_time"])
g_sum += float(rep["run_gold"])
var t_mean := t_sum / seeds
var g_mean := g_sum / seeds
var tgt: Dictionary = targets[p]
var t_ok := absf(t_mean - float(tgt["run_time"])) <= TOLERANCE * float(tgt["run_time"])
var g_ok := absf(g_mean - float(tgt["gold"])) <= TOLERANCE * float(tgt["gold"])
print("[calibrate] %-6s run_time %6.1fs (target %6.1f) %s | gold %6.1f (target %6.1f) %s" % [
p, t_mean, float(tgt["run_time"]), "ok" if t_ok else "MISS",
g_mean, float(tgt["gold"]), "ok" if g_ok else "MISS"])
quit(0)
func _parse(argv: PackedStringArray) -> Dictionary:
var out := {}
for a in argv:
if a.begins_with("--") and "=" in a:
var kv := a.substr(2).split("=", true, 1)
out[kv[0]] = kv[1]
return out
  • Step 4: Smoke it

Run: godot --headless --path . -s res://tools/economy_sim/calibrate_runner.gd -- --seeds=2 --ticks=3600 2>&1 | grep calibrate Expected: 4 lines (header + 3 profiles), no SCRIPT ERROR.

  • Step 5: Commit + push (tools only, no gate needed beyond a boot check, but run it anyway)
Terminal window
bash scripts/gate.sh
git add tools/economy_sim/human_targets.py tools/economy_sim/human_targets.json tools/economy_sim/calibrate_runner.gd tools/economy_sim/calibrate_runner.gd.uid
git commit -m "tool(economy_sim): human targets from D1 + profile calibration runner"
git pull --rebase && git push origin main

Task 5: Fit the profiles (measurement loop, done by the controller, not a subagent)

Section titled “Task 5: Fit the profiles (measurement loop, done by the controller, not a subagent)”

This is tuning, not code. Do it in the main session so Chris can see each iteration.

  • Step 1: Baseline. Run godot --headless --path . -s res://tools/economy_sim/calibrate_runner.gd -- --seeds=20 (time it with time; expected minutes, not hours). Record the output.
  • Step 2: Tune. Adjust SkillProfile.KNOBS one profile at a time: raise decide_every / aim_jitter_rad, lower dash_skill to make a profile weaker; the reverse to make it stronger. Re-run with --profiles=<that one>. Maximum 6 iterations per profile.
  • Step 3: Ceiling check. If strong still MISSES low with {"decide_every": 1, "aim_jitter_rad": 0.0, "dash_skill": 1.0} (the plain skilled AI), stop tuning it: set strong to those values, and record “strong is capped by SkilledAutoPlayer; autopilot improvements are Plan 2 (spec §3.5)”.
  • Step 4: Record. Put the fitted values in SkillProfile.KNOBS with a comment block: date, human_targets.json n, and each profile’s measured mean vs target. Run -gselect=test_profiled_auto_player (the weak-changes-play test must still pass), then gate, commit tune(ai): calibrated skill profiles vs human telemetry (n=<n>), push.

Files:

  • Create: tools/economy_sim/shop_tip_policy.gd, tools/economy_sim/career_equip.gd, tools/economy_sim/economy_career.gd, tools/economy_sim/career_runner.gd, scripts/economy-careers.sh
  • Test: tests/test_shop_tip_policy.gd, tests/test_career_equip.gd, tests/test_economy_career.gd

Interfaces:

  • Consumes: ShopAdvice.recommend(defs, meta) -> Array[Dictionary] (rows {"slot","id","name","cost","why","afford"}, ui/shop_advice.gd); MetaState.buy/cost/is_maxed/can_afford/total_spent/owns_ship/owns_decoy/owns_class; ShipBonuses.max_drone_slots(ship, meta) -> int; SoakRun cfg meta, profile; report run_gold, run_time, level_reached, death_cause, game_over.

  • Produces: ShopTipPolicy.buy_all(defs: Array, meta: MetaState) -> Array[String] (ids bought, in order); CareerEquip.equip(meta: MetaState, defs: Array) -> void; EconomyCareer.new(content: ContentDB, profile: String, seed0: int, max_runs: int, max_ticks: int) with .run() -> Dictionary = {"profile", "seed0", "total_cost", "runs": Array[Dictionary]}, each run {"i", "gold", "run_time", "level", "death_cause", "capped", "bought": Array[String], "banked_after": int, "spent_total": int}.

  • Step 1: Failing tests, tests/test_shop_tip_policy.gd

extends GutTest
# Policy A: a player who follows the shop tip. Buys the cheapest AFFORDABLE tip row, repeatedly;
# saves up when a tip exists but none is affordable; once every tip is bought, buys the cheapest
# affordable remaining item.
func _defs() -> Array:
return SimContentFixture.db().meta_upgrades()
func test_no_gold_buys_nothing() -> void:
var meta := MetaState.new()
assert_eq(ShopTipPolicy.buy_all(_defs(), meta), [] as Array[String])
func test_buys_the_cheapest_tip_row_it_can_afford() -> void:
var meta := MetaState.new()
var rows := ShopAdvice.recommend(_defs(), meta)
var cheapest: Dictionary = rows[0]
for r: Dictionary in rows:
if int(r["cost"]) < int(cheapest["cost"]):
cheapest = r
meta.banked_gold = int(cheapest["cost"])
var bought := ShopTipPolicy.buy_all(_defs(), meta)
assert_eq(bought, [String(cheapest["id"])] as Array[String])
assert_eq(meta.banked_gold, 0)
func test_gold_is_conserved() -> void:
var meta := MetaState.new()
meta.banked_gold = 1500
ShopTipPolicy.buy_all(_defs(), meta)
assert_eq(meta.banked_gold + meta.total_spent(_defs()), 1500)
func test_saves_up_rather_than_buying_off_tip() -> void:
var meta := MetaState.new()
var rows := ShopAdvice.recommend(_defs(), meta)
var min_tip := 1 << 30
for r: Dictionary in rows:
min_tip = mini(min_tip, int(r["cost"]))
meta.banked_gold = min_tip - 1 # can't afford any tip
var bought := ShopTipPolicy.buy_all(_defs(), meta)
assert_eq(bought, [] as Array[String], "even if some non-tip item is cheaper")
func test_falls_back_to_cheapest_item_once_tips_run_out() -> void:
var meta := MetaState.new()
for d: Dictionary in _defs(): # own every tip-eligible item
if String(d["id"]) in ShopAdvice.SURVIVAL_ORDER or String(d["id"]) in ShopAdvice.DRONE_PRIORITY \
or (String(d.get("type", "stat")) == "unlock" and String(d.get("category", "")) == "Arsenal"):
meta.levels[d["id"]] = int(d.get("max_level", 1))
assert_true(ShopAdvice.recommend(_defs(), meta).is_empty(), "precondition: no tips left")
var cheapest_cost := 1 << 30
for d: Dictionary in _defs():
if not meta.is_maxed(d):
cheapest_cost = mini(cheapest_cost, meta.cost(d))
meta.banked_gold = cheapest_cost
var bought := ShopTipPolicy.buy_all(_defs(), meta)
assert_eq(bought.size(), 1)
assert_eq(meta.banked_gold, 0)

Implementer check: confirm the Arsenal category string in data/bible.json is "Arsenal" and that ShopAdvice reads Arsenal via ShopCategories.in_category(defs, "Arsenal"). If the category field name differs, match the fixture condition to what ShopCategories.in_category actually tests.

  • Step 2: Failing tests, tests/test_career_equip.gd
extends GutTest
# Before each simulated run the career equips the best owned gear, as a player would in the
# start menu and Drone Bay: the cruiser once owned, the priciest owned decoy, and the Sentinel plus
# owned drone classes up to the hull's drone slots.
func _defs() -> Array:
return SimContentFixture.db().meta_upgrades()
func test_fresh_save_keeps_the_defaults() -> void:
var meta := MetaState.new()
CareerEquip.equip(meta, _defs())
assert_eq(meta.selected_ship, ShipBonuses.DEFAULT_SHIP)
assert_eq(meta.selected_decoy, "basic")
assert_eq(meta.drone_loadout, ["sentinel"])
func test_flies_the_cruiser_once_owned() -> void:
var meta := MetaState.new()
meta.levels = {"unlock-hull-obsidian": 1}
CareerEquip.equip(meta, _defs())
assert_eq(meta.selected_ship, "obsidian")
func test_equips_the_priciest_owned_decoy() -> void:
var meta := MetaState.new()
meta.levels = {"unlock-decoy-tank": 1, "unlock-decoy-healer": 1}
CareerEquip.equip(meta, _defs())
assert_eq(meta.selected_decoy, "healer")
func test_loadout_fills_slots_with_owned_classes() -> void:
var meta := MetaState.new()
meta.levels = {"unlock-drone-bomber": 1, "unlock-drone-interceptor": 1}
CareerEquip.equip(meta, _defs())
var slots := ShipBonuses.max_drone_slots(meta.selected_ship, meta)
assert_eq(meta.drone_loadout.size(), mini(slots, 3))
assert_eq(meta.drone_loadout[0], "sentinel")
  • Step 3: Failing tests, tests/test_economy_career.gd
extends GutTest
func test_short_career_banks_and_conserves_gold() -> void:
var career := EconomyCareer.new(SimContentFixture.db(), "median", 9000, 3, 1800)
var out := career.run()
assert_eq(out["runs"].size(), 3)
var earned := 0
for r: Dictionary in out["runs"]:
earned += int(r["gold"])
var last: Dictionary = out["runs"][2]
assert_eq(int(last["banked_after"]) + int(last["spent_total"]), earned, "every coin is banked or spent")
func test_career_is_deterministic() -> void:
var a := EconomyCareer.new(SimContentFixture.db(), "median", 9000, 2, 1200).run()
var b := EconomyCareer.new(SimContentFixture.db(), "median", 9000, 2, 1200).run()
assert_eq(JSON.stringify(a), JSON.stringify(b))
func test_total_cost_matches_the_catalogue() -> void:
var out := EconomyCareer.new(SimContentFixture.db(), "median", 9000, 1, 60).run()
var meta := MetaState.new()
var total := 0
for d: Dictionary in SimContentFixture.db().meta_upgrades():
meta.levels[d["id"]] = int(d.get("max_level", 1))
total = meta.total_spent(SimContentFixture.db().meta_upgrades())
assert_eq(int(out["total_cost"]), total)
  • Step 4: Run to verify failure

Run: godot --headless --path . --import >/dev/null 2>&1; for t in test_shop_tip_policy test_career_equip test_economy_career; do godot --headless --path . -s addons/gut/gut_cmdln.gd -gselect=$t -gexit 2>&1 | grep -E "Passing|Failing|SCRIPT ERROR" | head -3; done Expected: failures / “not declared” errors.

  • Step 5: tools/economy_sim/shop_tip_policy.gd
class_name ShopTipPolicy
extends RefCounted
# Economy-sim buy policy A (spec §3.6): a player who follows the in-game shop tip (ShopAdvice).
# Repeatedly buys the cheapest AFFORDABLE tip row. If tips exist but none is affordable, saves up
# (buys nothing else). Once every tip is bought, buys the cheapest affordable remaining item.
const MAX_BUYS_PER_VISIT := 500 # safety net; far above the 66-item catalogue's level count
static func buy_all(defs: Array, meta: MetaState) -> Array[String]:
var bought: Array[String] = []
for _i in range(MAX_BUYS_PER_VISIT):
var id := _next_pick(defs, meta)
if id == "":
break
var def := _def(defs, id)
if def.is_empty() or not meta.buy(def):
break
bought.append(id)
return bought
static func _next_pick(defs: Array, meta: MetaState) -> String:
var rows := ShopAdvice.recommend(defs, meta)
if not rows.is_empty():
var pick := ""
var pick_cost := 1 << 30
for r: Dictionary in rows:
if bool(r["afford"]) and int(r["cost"]) < pick_cost:
pick = String(r["id"])
pick_cost = int(r["cost"])
return pick # "" = tips exist but none affordable: save up
var best := ""
var best_cost := 1 << 30
for d in defs:
if d is Dictionary and meta.can_afford(d) and meta.cost(d) < best_cost:
best = String(d["id"])
best_cost = meta.cost(d)
return best
static func _def(defs: Array, id: String) -> Dictionary:
for d in defs:
if d is Dictionary and String(d.get("id", "")) == id:
return d
return {}
  • Step 6: tools/economy_sim/career_equip.gd
class_name CareerEquip
extends RefCounted
# Before each simulated run, equip the best owned gear the way a player would (spec §3.6).
# Judgement calls, flagged in the report: the cruiser beats the frigate once owned; the priciest
# owned decoy is the best; the Sentinel keeps slot 1 (it carries the decoy flavour) and newly
# owned classes fill the remaining drone slots in unlock order.
const DECOYS_BEST_FIRST: Array[String] = ["ultimate", "healer", "tank", "damage"]
const CLASS_ORDER: Array[String] = ["bomber", "interceptor", "disruptor", "logistics"]
const CRUISER := "obsidian"
static func equip(meta: MetaState, defs: Array) -> void:
meta.selected_ship = CRUISER if meta.owns_ship(CRUISER) else ShipBonuses.DEFAULT_SHIP
meta.selected_decoy = "basic"
for d in DECOYS_BEST_FIRST:
if meta.owns_decoy(d, defs):
meta.selected_decoy = d
break
var slots := maxi(ShipBonuses.max_drone_slots(meta.selected_ship, meta), 1)
var loadout: Array = ["sentinel"]
for k in CLASS_ORDER:
if loadout.size() >= slots:
break
if meta.owns_class(k):
loadout.append(k)
meta.drone_loadout = loadout

Implementer check: ShipBonuses.max_drone_slots(ship_id, meta) exists at sim/ship_bonuses.gd:74; confirm its return for a fresh cobalt save before trusting test_loadout_fills_slots_with_owned_classes (if a fresh frigate has 1 slot the expected size is 1).

  • Step 7: tools/economy_sim/economy_career.gd
class_name EconomyCareer
extends RefCounted
# One simulated player's career (spec §3.6): equip → play a run (SoakRun, real run-start via
# MetaRunSetup) → bank the run's gold → buy per ShopTipPolicy → next run. Stops at max_runs or
# once every item is owned. Pure and deterministic per (profile, seed0).
var content: ContentDB
var profile: String
var seed0: int
var max_runs: int
var max_ticks: int
func _init(content_db: ContentDB, profile_name: String, first_seed: int, runs: int, ticks: int) -> void:
content = content_db
profile = profile_name
seed0 = first_seed
max_runs = runs
max_ticks = ticks
func run() -> Dictionary:
var defs := content.meta_upgrades()
var meta := MetaState.new()
var total_cost := _total_cost(defs)
var runs: Array = []
for i in range(max_runs):
CareerEquip.equip(meta, defs)
var rep := SoakRun.new({"seed": seed0 + i, "ship": meta.selected_ship, "coop": false,
"max_ticks": max_ticks, "content": content, "player": "skilled", "profile": profile,
"meta": meta}).execute()
meta.banked_gold += int(rep["run_gold"])
var bought := ShopTipPolicy.buy_all(defs, meta)
var spent := meta.total_spent(defs)
runs.append({"i": i + 1, "gold": int(rep["run_gold"]), "run_time": float(rep["run_time"]),
"level": int(rep["level_reached"]), "death_cause": String(rep["death_cause"]),
"capped": not bool(rep["game_over"]), "bought": bought,
"banked_after": meta.banked_gold, "spent_total": spent})
if spent >= total_cost:
break
return {"profile": profile, "seed0": seed0, "total_cost": total_cost, "runs": runs}
static func _total_cost(defs: Array) -> int:
var maxed := MetaState.new()
for d in defs:
if d is Dictionary:
maxed.levels[d["id"]] = int(d.get("max_level", 1))
return maxed.total_spent(defs)
  • Step 8: Run the three test files, verify pass

Same loop command as Step 4. Expected: all passing.

  • Step 9: tools/economy_sim/career_runner.gd
extends SceneTree
# One economy-sim career → reports/economy/career-<profile>-<seed0>.json. DEV ONLY.
# godot --headless --path . -s res://tools/economy_sim/career_runner.gd -- \
# --profile=median --seed0=10000 --runs=150 --ticks=72000
# --ticks caps one run (72000 = 20 min game time); a capped run still banks its gold.
func _init() -> void:
var args := _parse(OS.get_cmdline_user_args())
var content := ContentLoader.load_from_path("res://data/bible.json")
if content == null:
push_error("career: failed to load bible.json"); quit(2); return
var profile := String(args.get("profile", "median"))
var seed0 := int(args.get("seed0", "10000"))
var started := Time.get_ticks_msec()
var out := EconomyCareer.new(content, profile, seed0, int(args.get("runs", "150")),
int(args.get("ticks", "72000"))).run()
out["wall_s"] = (Time.get_ticks_msec() - started) / 1000.0
DirAccess.make_dir_recursive_absolute("res://reports/economy")
var path := "res://reports/economy/career-%s-%d.json" % [profile, seed0]
var f := FileAccess.open(path, FileAccess.WRITE)
f.store_string(JSON.stringify(out, " "))
f.close()
var last: Dictionary = out["runs"][-1]
print("[career] %s seed0=%d runs=%d spent=%d/%d wall=%.0fs -> %s" % [profile, seed0,
out["runs"].size(), int(last["spent_total"]), int(out["total_cost"]), float(out["wall_s"]), path])
quit(0)
func _parse(argv: PackedStringArray) -> Dictionary:
var out := {}
for a in argv:
if a.begins_with("--") and "=" in a:
var kv := a.substr(2).split("=", true, 1)
out[kv[0]] = kv[1]
return out
  • Step 10: scripts/economy-careers.sh
#!/usr/bin/env bash
# Run economy-sim careers in parallel: 3 profiles x N seeds (spec §3.6). Headless, overnight-safe.
# Usage: scripts/economy-careers.sh [seeds=5] [runs=150] [ticks=72000]
set -uo pipefail
cd "$(dirname "$0")/.."
seeds=${1:-5}; runs=${2:-150}; ticks=${3:-72000}
jobs=$(( $(sysctl -n hw.ncpu) - 2 )); [ "$jobs" -lt 1 ] && jobs=1
for p in weak median strong; do
for s in $(seq 0 $((seeds - 1))); do echo "$p $((10000 + s * 1000))"; done
done | xargs -P "$jobs" -L 1 sh -c \
'godot --headless --path . -s res://tools/economy_sim/career_runner.gd -- --profile=$0 --seed0=$1 --runs='"$runs"' --ticks='"$ticks"' 2>&1 | grep "\[career\]"'

chmod +x scripts/economy-careers.sh. Seeds are spaced 1000 apart so careers never share a run seed.

  • Step 11: Smoke

Run: godot --headless --path . -s res://tools/economy_sim/career_runner.gd -- --profile=median --seed0=10000 --runs=3 --ticks=3600 2>&1 | grep career Expected: one [career] median seed0=10000 runs=3 ... line; the JSON file exists.

  • Step 12: Gate, commit, push
Terminal window
bash scripts/gate.sh
git add tools/economy_sim/*.gd tools/economy_sim/*.uid scripts/economy-careers.sh tests/test_shop_tip_policy.gd* tests/test_career_equip.gd* tests/test_economy_career.gd*
git commit -m "tool(economy_sim): career simulator with the shop-tip buy policy"
git pull --rebase && git push origin main

(reports/ is gitignored; check with git check-ignore reports/economy/x.json and never force-add reports.)


Task 7: Summary script + first curve for Chris

Section titled “Task 7: Summary script + first curve for Chris”

Files:

  • Create: tools/economy_sim/summarize.py
  • Create: docs/research/2026-09-27-shop-economy-first-curve.md (with the title: frontmatter line the docs site requires)

Interfaces:

  • Consumes: reports/economy/career-*.json (Task 6 shape).

  • Produces: a markdown table on stdout: per profile, median (and min–max) across seeds of runs to each milestone.

  • Step 1: summarize.py

#!/usr/bin/env python3
"""Economy-sim careers -> milestone table (markdown). Run from the repo root:
python3 tools/economy_sim/summarize.py reports/economy/career-*.json
Milestones (runs needed, per career): first purchase; all Arsenal unlocks owned; 25/50/100% of
the whole catalogue's cost spent. Reports the median and min-max across seeds per profile.
"""
import json, statistics, sys
from collections import defaultdict
ARSENAL_PREFIX = "unlock-"
ARSENAL_IDS = {"unlock-scatter", "unlock-pulse", "unlock-beam", "unlock-turret", "unlock-blade",
"unlock-scythe", "unlock-chalice", "unlock-momentum-core", "unlock-wildfire",
"unlock-thunder-wake", "unlock-scar-tissue", "unlock-railgun", "unlock-chronowell"}
def milestones(career):
total = career["total_cost"]
owned, out = set(), {}
for r in career["runs"]:
owned.update(r["bought"])
spent = r["spent_total"]
if r["bought"] and "first buy" not in out:
out["first buy"] = r["i"]
if ARSENAL_IDS <= owned and "all Arsenal" not in out:
out["all Arsenal"] = r["i"]
for pct in (25, 50, 100):
key = f"{pct}% spent"
if spent >= total * pct / 100 and key not in out:
out[key] = r["i"]
return out
def main(paths):
by_profile = defaultdict(list)
for p in paths:
c = json.load(open(p))
by_profile[c["profile"]].append(c)
keys = ["first buy", "all Arsenal", "25% spent", "50% spent", "100% spent"]
print("| profile | careers | gold/run (median) | " + " | ".join(keys) + " |")
print("|---|---|---|" + "---|" * len(keys))
for prof in ("weak", "median", "strong"):
cs = by_profile.get(prof, [])
if not cs:
continue
golds = [r["gold"] for c in cs for r in c["runs"]]
cells = []
for k in keys:
vals = [m[k] for m in map(milestones, cs) if k in m]
if len(vals) < len(cs):
cells.append(f"not reached in {len(cs) - len(vals)}/{len(cs)}" if not vals
else f"{statistics.median(vals):.0f} ({min(vals)}-{max(vals)}), {len(cs)-len(vals)} never")
else:
cells.append(f"{statistics.median(vals):.0f} ({min(vals)}-{max(vals)})")
print(f"| {prof} | {len(cs)} | {statistics.median(golds):.0f} | " + " | ".join(cells) + " |")
if __name__ == "__main__":
main(sys.argv[1:])

Implementer check: verify ARSENAL_IDS against data/bible.json (category == "Arsenal" and type == "unlock": 13 ids as of 2026-09-27) with a one-line python check, and remove the unused ARSENAL_PREFIX if you don’t use it.

  • Step 2: Measure one career’s wall time first

Run: time godot --headless --path . -s res://tools/economy_sim/career_runner.gd -- --profile=median --seed0=10000 --runs=150 --ticks=72000 2>&1 | grep career Use wall_s to project the full batch (15 careers / parallel jobs). If the projection is over ~3 h, drop to 3 seeds and tell Chris.

  • Step 3: Full batch (background; overnight-safe)

Run: scripts/economy-careers.sh 5 150 72000

  • Step 4: Summarise

Run: python3 tools/economy_sim/summarize.py reports/economy/career-*.json

  • Step 5: Write docs/research/2026-09-27-shop-economy-first-curve.md

Contents: the table; the calibration output from Task 5 (targets, n, fitted knobs); the known biases (spec §3.4 bias; strong capped if it was; equip judgement calls; ShopAdvice drone order is itself a pending judgement call); and the question for Chris: per-tier targets (first buy, core Pilot + Arsenal, drones) in runs, for Plan 2.

  • Step 6: Commit, push, show Chris
Terminal window
git add tools/economy_sim/summarize.py docs/research/2026-09-27-shop-economy-first-curve.md
git commit -m "docs(research): first shop-economy career curve"
git pull --rebase && git push origin main

Then tick the RELEASE-0.1.md §2 shop-pricing item’s progress (add Plan 1 ✅ (commit), item stays open) and present the table to Chris with the tier-target question. Plan 2 (spec steps 4, 6, 7) starts from his answer.