Shop economy, Plan 2 (Field Medic, value sweep, price proposal)
Shop economy, Plan 2 Implementation Plan
Section titled “Shop economy, Plan 2 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: Remove the Field Medic cliff, fix the naming and buy-policy defects Plan 1 found, measure every shop item’s value per gold, and hand Chris a price proposal that hits his tier targets.
Architecture: Game changes are data plus one routing tweak: data/bible.json (names, Field Medic levels and magnitude) and ui/shop_categories.gd / ui/meta_shop_panel.gd (the renamed pair moves to the General drone tab). The rest is dev tooling under tools/economy_sim/: one shared career-save helper, a paired-seed value sweep (GDScript runs, Python statistics), a second buy policy, and the report. The sim itself is not touched, so both determinism baselines stay pinned.
Tech Stack: Godot 4.7.1 typed GDScript, GUT 9.7.1, Python 3.14 stdlib, bash.
Spec: docs/superpowers/specs/2026-09-27-shop-economy-sim-design.md (§3.5-3.8, §5 steps 4, 6, 7). First results and Plan 1 findings: docs/research/2026-09-27-shop-economy-first-curve.md. Procedure skill: .claude/skills/bh-economy-sim/SKILL.md.
Chris’s decisions (2026-09-27), which this plan implements
Section titled “Chris’s decisions (2026-09-27), which this plan implements”- Field Medic: tone the heal down AND make it multi-level, so it grows across purchases instead of one 60 g buy turning ~1-minute runs into 5-20-minute runs.
- Core Pilot + all Arsenal owned: about run 15 for the median profile (today’s pace, keep).
- Whole catalogue owned: about run 30 for the median profile (today’s pace, keep). Today’s 30 is produced by the Field Medic cliff. Toning Field Medic will slow gold income, so the price proposal (Task 9) is what brings the curve back to ~30.
- “Decoy Power” / “Decoy Duration” vs “Decoy: Power” / “Decoy: Endurance”: rename so no two names collide, and change “Sentinel” to “Decoy” in the marketing site’s changelog.
Global Constraints
Section titled “Global Constraints”- Feature freeze on
main(docs/RELEASE-0.1.md): every game-affecting change goes on that list first (Task 0), then gets ticked✅ DONE (commit)in place. - The decoy drone is ALWAYS “Decoy” to players, never “Sentinel”. Internal ids (
sentinel,drone-sentinel-*,decoy-*) never change: saves, telemetry and the economy sim key on them. /simis pure logic. Nothing in this plan editssim/*.gdexcept where a task says so (none do).- Both determinism baselines (
tests/test_determinism*.gd) stay green with no re-pin. Every change here is content data the pinned survival baseline never reads (it runs with no shop save) or dev tooling. - Any price, magnitude or gold-rate change to
data/bible.jsonneeds Chris’s approval before it is committed (bh-economy-simskill). Tasks 5 and 9 stop and ask. - Merge gate:
bash scripts/gate.shexit 0 before every merge tomain.git pull --rebasebefore,git push origin mainright after every merge (Toby also pushes tomain). - Single GUT script:
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit -gselect=<script_name_without_.gd>(-gtestdoes not narrow the run). Fresh worktree:godot --headless --path . --importonce first. - Code-writing subagents:
bh-implementeron Opus (subagents default to Haiku here, passmodel: "opus"). Review:bh-reviewer. reports/is gitignored. Regenerate outputs, never hunt for them.- Copy the user sees: no em dashes in marketing-site copy (Lumara house rule). In-game copy follows existing bible style.
Out of scope (with the reason, so nobody re-adds it)
Section titled “Out of scope (with the reason, so nobody re-adds it)”- Strong-profile autopilot work (spec §3.5). Its purpose was to explain the gap between the autopilot and real players. The main confound is that the human sample owned upgrades while the autopilot starts fresh. The fix for that is recalibrating against real saves via
runs.meta_levels, and on 2026-09-27 the live D1 had 0 human runs withmeta_levels(12 human runs, max build 46). Chris’s targets are for the median profile, which is calibrated. Revisit whenmeta_levelshas 15+ human rows: recalibrate (tools/economy_sim/calibrate_runner.gd) against those saves first, and only then decide if the AI needs work. - Shop tip order: DONE separately 2026-09-27 before Plan 2 started. The tip now asks for one
drone slot before each new class (
ui/shop_advice.gdDRONE_PRIORITY, “drone-slots:1” / “drone-slots:2” entries). Plan 1 careers predate this, so re-run careers before Task 5.
File map
Section titled “File map”| File | Change | Task |
|---|---|---|
docs/RELEASE-0.1.md |
add punch-list items | 0 |
data/bible.json |
rename decoy-power/decoy-duration; Field Medic max_level + desc (magnitude after approval) |
1, 5 |
ui/shop_categories.gd |
decoy-power/decoy-duration route to “General” |
1 |
ui/meta_shop_panel.gd |
decoy_* effect icon becomes drones |
1 |
tests/test_shop_categories.gd, tests/test_decoy_copy.gd, tests/test_meta_shop_panel.gd, tests/test_shop_icons.gd |
follow the rename | 1 |
~/Claude/bullet-heaven-site/index.html:223 |
Sentinel becomes Decoy | 2 |
tools/economy_sim/shop_tip_policy.gd |
fallback skips unowned drone classes; public cheapest_useful() |
3 |
tests/test_shop_tip_policy.gd |
fallback test | 3 |
tools/economy_sim/summarize.py |
“core” milestone, per-policy grouping | 4 |
tools/economy_sim/career_save.gd (new) |
CareerSave.at() + CareerSave.play() |
5 |
tests/test_career_save.gd (new) |
save rebuild | 5 |
tools/economy_sim/ablate.gd |
use CareerSave; --mag=id:value |
5 |
tests/test_regen.gd |
Field Medic levels stack | 5 |
tools/economy_sim/value_sweep.gd (new) |
raw paired runs per item | 6 |
scripts/economy-value-sweep.sh (new) |
shard fan-out | 6 |
tools/economy_sim/value_report.py (new) |
paired deltas, bootstrap interval, value/gold | 7 |
tools/economy_sim/value_per_gold_policy.gd (new) |
policy B | 8 |
tests/test_value_per_gold_policy.gd (new) |
policy B | 8 |
tools/economy_sim/economy_career.gd, tools/economy_sim/career_runner.gd, scripts/economy-careers.sh |
--value= selects policy B |
8 |
docs/research/2026-09-27-shop-economy-first-curve.md or a new dated research file, Artifact |
report + price proposal | 9 |
.claude/skills/bh-economy-sim/SKILL.md |
document the new tools | 9 |
Task 0: Put the game-affecting items on the release punch list
Section titled “Task 0: Put the game-affecting items on the release punch list”Files:
-
Modify:
docs/RELEASE-0.1.md(section## 4 · Fix-if-cheap, directly after the ticked “decoy drone must always be called Decoy” item, around line 951) -
Step 1: Add three items
Insert after the Decoy-copy item (keep its “Open, Chris’s call” sentence, then append Decided 2026-09-27: rename, see the item below.):
- [ ] **COPY — "Decoy Power"/"Decoy Duration" boost EVERY drone, and their names collide with "Decoy: Power"/"Decoy: Endurance".** `decoy_power_mult` scales Bomber, Interceptor and Decoy damage (`sim/drone_director.gd:243,267,478`); `decoy_life_mult` scales every drone's lifespan (`:402`). Rename to "Drone Power"/"Drone Lifespan" and move them to the General drone tab (Chris, 2026-09-27). Ids unchanged. Also the site changelog's "Sentinel" (BUILD 78 entry).- [ ] **BALANCE — Field Medic is a one-purchase cliff.** 60 g, one level, stand-still heal up to 40 HP/s: turns ~1-minute runs into 5-20-minute runs (paired-seed ablation, `bh-economy-sim`). Chris, 2026-09-27: tone it down and make it multi-level. Plan `docs/superpowers/plans/2026-09-27-shop-economy-plan-2.md` Task 5.- [ ] **BALANCE — shop prices vs tier targets** (median player: core Pilot + all Arsenal ~run 15, whole shop ~run 30). Price proposal from the economy sim, Chris approves. Plan 2 Task 9.- Step 2: Commit and push
git add docs/RELEASE-0.1.mdgit commit -m "docs(release): drone-stat rename, Field Medic cliff, shop price items"git pull --rebase && git push origin mainTask 1: Rename the all-drone stat pair and move it to the General tab
Section titled “Task 1: Rename the all-drone stat pair and move it to the General tab”Why: decoy-power and decoy-duration are not Decoy upgrades. effect: decoy_power sets PlayerState.decoy_power_mult, which multiplies Bomber damage (sim/drone_director.gd:243), Interceptor damage (:267) and the Decoy pulse (:478). effect: decoy_life sets decoy_life_mult, which sets every deployed drone’s lifespan (:402). Their names say “Decoy” and sit in the Decoy tab next to “Decoy: Power” and “Decoy: Endurance”, which really are Decoy-only.
Files:
- Modify:
data/bible.json(entries"id": "decoy-power"~line 1631 and"id": "decoy-duration"~line 1642) - Modify:
ui/shop_categories.gd:46-56(drone_subcategory) - Modify:
ui/meta_shop_panel.gd:1268-1271(icon routing) - Test:
tests/test_shop_categories.gd,tests/test_decoy_copy.gd,tests/test_meta_shop_panel.gd:605-622,tests/test_shop_icons.gd:27-29(comment only)
Interfaces:
-
Produces:
ShopCategories.drone_subcategory({"id": "decoy-power"}) == "General"(same fordecoy-duration). Everything else indrone_subcategoryunchanged. -
Step 1: Write the failing tests
In tests/test_shop_categories.gd, replace test_decoy_and_sentinel_ids_both_route_to_the_decoy_tab with:
func test_decoy_class_ids_route_to_the_decoy_tab() -> void: # The decoy drone (class id "sentinel") predates the "drone-sentinel-*" naming; its unlocks use # "unlock-decoy-*". All of them land in the player-facing "Decoy" tab. assert_eq(ShopCategories.drone_subcategory({"id": "unlock-decoy-ultimate"}), "Decoy") assert_eq(ShopCategories.drone_subcategory({"id": "drone-sentinel-power"}), "Decoy") assert_eq(ShopCategories.drone_subcategory({"id": "drone-sentinel-reflect"}), "Decoy")
func test_all_drone_stats_route_to_general() -> void: # decoy-power / decoy-duration scale EVERY drone (sim/drone_director.gd decoy_power_mult, # decoy_life_mult), so they live in General with slots and recharge, not the Decoy tab. assert_eq(ShopCategories.drone_subcategory({"id": "decoy-power"}), "General") assert_eq(ShopCategories.drone_subcategory({"id": "decoy-duration"}), "General")In tests/test_decoy_copy.gd, inside test_drone_shop_tab_section_is_titled_decoy, replace the line assert_true("decoy-power" in ids, "legacy decoy card in the Decoy section") with:
assert_false("decoy-power" in ids, "Drone Power boosts every drone, so it is not in the Decoy section")and add a new test at the end of the file:
func test_no_two_drone_shop_names_collide() -> void: # "Decoy Power" vs "Decoy: Power" read as the same card (Chris, 2026-09-27). Compare names with # punctuation and spaces stripped, lower-cased. var seen := {} for d: Variant in SimContentFixture.db().meta_upgrades(): var dd: Dictionary = d if String(dd.get("category", "")) != "Drones": continue var key := String(dd["name"]).to_lower().replace(":", "").replace(" ", "") assert_false(seen.has(key), "drone shop names collide: %s / %s" % [seen.get(key, ""), dd["name"]]) seen[key] = dd["name"]
func test_all_drone_stat_names_say_drone() -> void: var names := {} for d: Variant in SimContentFixture.db().meta_upgrades(): names[String((d as Dictionary)["id"])] = String((d as Dictionary)["name"]) assert_eq(names["decoy-power"], "Drone Power") assert_eq(names["decoy-duration"], "Drone Lifespan")In tests/test_meta_shop_panel.gd change the assertion at ~line 620:
assert_eq(decoy_stat_icon.icon_key, "drones", "Drone Power/Lifespan boost every drone: drone icon")- Step 2: Run to verify they fail
Run -gselect=test_shop_categories, -gselect=test_decoy_copy, -gselect=test_meta_shop_panel.
Expected: FAIL on the General routing, the collision test (decoypower twice), the name test, and the icon key.
- Step 3: Implement
data/bible.json, the two entries become (ids, effects, magnitudes, prices unchanged):
{ "id": "decoy-power", "name": "Drone Power", "effect": "decoy_power", "magnitude": 1.15, "max_level": 4, "base_cost": 80, "cost_growth": 1.6, "desc": "+15% damage for every drone per level", "category": "Drones" }, { "id": "decoy-duration", "name": "Drone Lifespan", "effect": "decoy_life", "magnitude": 1.18, "max_level": 4, "base_cost": 80, "cost_growth": 1.6, "desc": "+18% lifespan for every drone per level", "category": "Drones" },ui/shop_categories.gd drone_subcategory, first check becomes:
if id == "drone-slots" or id == "drone-recharge" or id == "decoy-power" or id == "decoy-duration": return "General" # these apply to every drone (decoy-* ids predate the rename, 2026-09-27)Update the comment above the function: the decoy-* stat ids now route to General; only unlock-decoy-* and drone-sentinel-* land in the Decoy tab.
ui/meta_shop_panel.gd at ~1268:
elif effect.begins_with("decoy_"): # "Drone Power"/"Drone Lifespan" (ids decoy-power/decoy-duration) scale every drone, so # they get the drone icon, not the Decoy diamond (renamed 2026-09-27). icon_key = "drones"tests/test_shop_icons.gd:27-29: update the comment to say decoy_* stats now use the drones icon; the decoy diamond is still drawn for the unlock-decoy-* cards, so the test body stays.
Also grep for any other player-facing “Decoy Power”/“Decoy Duration” string:
/usr/bin/grep -rn "Decoy Power\|Decoy Duration" --include='*.gd' --include='*.json' --include='*.tscn' --exclude-dir=.claude --exclude-dir=_archive --exclude-dir=.godot --exclude-dir=builds .Fix code comments that describe them as Decoy-family (ui/meta_shop_panel.gd, ui/shop_icons.gd:151, tests/test_shop_icons.gd:7); leave docs/superpowers/plans/2026-06-28-shop-overhaul.md (history).
- Step 4: Run tests, then the gate
Run the three -gselect commands (expected PASS), then bash scripts/gate.sh (exit 0).
- Step 5: Visual check
Use the bh-visual-qa agent on the meta shop’s Drones tab (General and Decoy sub-tabs) at iPhone, iPad and Apple TV sizes. Expected: “Drone Power” and “Drone Lifespan” in General with the drone icon; Decoy tab shows only Decoy: * cards.
- Step 6: Commit, tick, push
git add data/bible.json ui/shop_categories.gd ui/meta_shop_panel.gd ui/shop_icons.gd tests/git commit -m "copy(drones): Drone Power/Lifespan boost every drone; move to General tab"Tick the Task 0 rename item ✅ DONE (<sha>) (site half pending Task 2), commit, git pull --rebase && git push origin main.
Task 2: Site changelog says Decoy
Section titled “Task 2: Site changelog says Decoy”Files:
-
Modify:
~/Claude/bullet-heaven-site/index.html:223 -
Step 1: Read that repo’s
CLAUDE.mdfirst (deploy rules; deploy only viascripts/deploy-site.sh, never a rawwrangler pages deploy .). -
Step 2: Edit
Five classes, the Sentinel, Bomber, Interceptor, Disruptor and Logistics, becomes Five classes, the Decoy, Bomber, Interceptor, Disruptor and Logistics,. Then /usr/bin/grep -n "Sentinel" index.html and change any other hit that means the drone (the boss keeps its name).
- Step 3: Commit, push, deploy, verify
cd ~/Claude/bullet-heaven-sitegit add index.html && git commit -m "copy(changelog): the decoy drone is Decoy, not Sentinel"git pull --rebase && git pushbash scripts/deploy-site.shcurl -s https://darkcosmos.lumara.digital/ | /usr/bin/grep -c "the Sentinel, Bomber" # expect 0If the deploy fails, stop and report it; don’t improvise another route. Then tick the Task 0 rename item fully.
Task 3: Buy-policy fallback stops buying stats for drones the career doesn’t own
Section titled “Task 3: Buy-policy fallback stops buying stats for drones the career doesn’t own”Why: once the tip rows run out, ShopTipPolicy._next_pick buys the cheapest affordable item. The cheapest items are 40 g drone-<class>-* stats, bought for classes the career hasn’t unlocked, so they do nothing and delay real purchases (including Field Medic).
Files:
- Modify:
tools/economy_sim/shop_tip_policy.gd:34-43 - Test:
tests/test_shop_tip_policy.gd
Interfaces:
-
Produces:
static func cheapest_useful(defs: Array, meta: MetaState) -> StringonShopTipPolicy: id of the cheapest affordable, not-maxed item that is not a stat for an unowned drone class;""if none. Ties go to the earlier catalogue entry. Task 8 reuses it. -
Produces:
static func is_dead_drone_stat(id: String, meta: MetaState) -> bool: true fordrone-<class>-<stat>ids (notdrone-slots/drone-recharge) whose classmeta.owns_class(class)is false.sentinelis always owned. -
Step 1: Write the failing tests (append to
tests/test_shop_tip_policy.gd)
func test_dead_drone_stat_detection() -> void: var meta := MetaState.new() assert_true(ShopTipPolicy.is_dead_drone_stat("drone-bomber-power", meta), "bomber not owned") assert_false(ShopTipPolicy.is_dead_drone_stat("drone-sentinel-power", meta), "the Decoy is free") assert_false(ShopTipPolicy.is_dead_drone_stat("drone-slots", meta)) assert_false(ShopTipPolicy.is_dead_drone_stat("drone-recharge", meta)) assert_false(ShopTipPolicy.is_dead_drone_stat("vitality", meta)) meta.levels["unlock-drone-bomber"] = 1 assert_false(ShopTipPolicy.is_dead_drone_stat("drone-bomber-power", meta), "bomber owned now")
func test_fallback_never_buys_stats_for_unowned_drone_classes() -> void: # Own every tip target so the fallback path runs, but no drone class beyond the free Decoy. var defs := _defs() var meta := MetaState.new() for d: Dictionary in defs: var id := String(d["id"]) if id.begins_with("unlock-drone-"): continue if id in ["vitality", "bulwark", "drone-slots", "drone-recharge"] or String(d.get("category", "")) == "Arsenal": meta.levels[id] = int(d.get("max_level", 1)) meta.levels["unlock-drone-bomber"] = 1 # the tip's first drone pick, so tips move on meta.levels["unlock-drone-interceptor"] = 1 assert_eq(ShopAdvice.recommend(defs, meta).size(), 0, "precondition: no tip rows left") meta.banked_gold = 3000 for id: String in ShopTipPolicy.buy_all(defs, meta): assert_false(id.begins_with("drone-disruptor-") or id.begins_with("drone-logistics-"), "bought %s for a drone class the career does not own" % id)Before relying on the precondition, confirm meta.owns_class("bomber") reads unlock-drone-bomber (read sim/meta_state.gd:94-102); adjust the unlock ids in the test to what owns_class actually checks.
-
Step 2: Run to verify they fail —
-gselect=test_shop_tip_policy. Expected: “Nonexistent function ‘is_dead_drone_stat’”. -
Step 3: Implement in
tools/economy_sim/shop_tip_policy.gd, replacing the fallback loop in_next_pickwithreturn cheapest_useful(defs, meta)and adding:
# Cheapest affordable item that does something for this career. Skips per-class drone stats for a# class the career hasn't unlocked (40 g each, the cheapest rows in the shop, and useless until the# class is owned). "" when nothing useful is affordable.static func cheapest_useful(defs: Array, meta: MetaState) -> String: var best := "" var best_cost := 1 << 30 for d: Variant in defs: if not (d is Dictionary): continue var dd: Dictionary = d var id := String(dd["id"]) if is_dead_drone_stat(id, meta) or not meta.can_afford(dd): continue if meta.cost(dd) < best_cost: best = id best_cost = meta.cost(dd) return best
const _SHARED_DRONE_IDS := ["drone-slots", "drone-recharge"]
static func is_dead_drone_stat(id: String, meta: MetaState) -> bool: if not id.begins_with("drone-") or id in _SHARED_DRONE_IDS: return false var klass := id.trim_prefix("drone-").get_slice("-", 0) return not meta.owns_class(klass)Update the header comment’s fallback sentence to match. Note a career that never unlocks a class will now never buy that class’s stats, so EconomyCareer stops only at max_runs for it. That is correct: those levels are dead weight. Task 4’s “100% spent” milestone becomes “not reached”; that is expected and gets reported, not hidden.
-
Step 4: Run tests —
-gselect=test_shop_tip_policyand-gselect=test_economy_careerPASS, thenbash scripts/gate.sh. -
Step 5: Commit and push
git add tools/economy_sim/shop_tip_policy.gd tests/test_shop_tip_policy.gdgit commit -m "fix(economy-sim): fallback skips stats for drone classes the career doesn't own"git pull --rebase && git push origin mainTask 4: Summary reports the core tier and the useful-catalogue total
Section titled “Task 4: Summary reports the core tier and the useful-catalogue total”Files:
- Modify:
tools/economy_sim/summarize.py
Definitions (state them in the report):
- core = every
category == "Pilot"item at itsmax_levelAND all 13 Arsenal unlocks owned. Pilot today:vitality, bulwark, haste, swiftness, field-medic, thrusters. Read fromdata/bible.jsonat run time, never hard-coded, so Field Medic’s newmax_levelis picked up. - whole shop = every item at max level (“100% spent”), as today.
Interfaces:
-
Consumes: career JSON (
runs[].boughtlists one id per level bought) plus optional top-level"policy"("tip"if absent, set by Task 8). -
Produces: one table per policy, rows per profile, with a new
corecolumn betweenall Arsenaland25% spent. -
Step 1: Implement
BIBLE = "data/bible.json"
def _find_meta(o): if isinstance(o, dict): if "meta_upgrades" in o: return o["meta_upgrades"] for v in o.values(): r = _find_meta(v) if r: return r return None
with open(BIBLE) as _f: META = _find_meta(json.load(_f))PILOT_MAX = {m["id"]: m.get("max_level", 1) for m in META if m.get("category") == "Pilot"}In milestones(), track levels instead of a set (levels[id] = levels.get(id, 0) + 1 per bought id), keep owned = set(levels), and add:
if ("core" not in out and ARSENAL_IDS <= owned and all(levels.get(i, 0) >= mx for i, mx in PILOT_MAX.items())): out["core"] = r["i"]keys becomes ["first buy", "all Arsenal", "core", "25% spent", "50% spent", "100% spent"]. In main, group by (c.get("policy", "tip"), c["profile"]) and print one table per policy under a ### policy: <name> heading.
- Step 2: Verify on real data
godot --headless --path . -s res://tools/economy_sim/career_runner.gd -- --profile=median --seed0=10000 --runs=35python3 tools/economy_sim/summarize.py reports/economy/career-median-10000.jsonExpected: a core value is printed; hand-check it by scanning that career’s bought lists for the run where the last Pilot level lands. Record the number in the task report.
- Step 3: Commit and push
git add tools/economy_sim/summarize.pygit commit -m "tools(economy-sim): core-tier milestone, per-policy tables"git pull --rebase && git push origin mainTask 5: Field Medic becomes a toned, multi-level upgrade
Section titled “Task 5: Field Medic becomes a toned, multi-level upgrade”Mechanics today (sim/sim.gd:2203-2233): heal = still_heal_mult × min(6 + 8 × t_still, 40) HP/s. still_heal_mult is the sum of magnitude over owned levels (StatEffects op add, applied once per level by MetaState.apply_to). Field Medic: magnitude 1.0, max_level 1, 60 g. So lowering magnitude scales the whole curve (start rate, ramp and cap) and levels add up. No sim code change is needed. The tuning is purely max_level + magnitude in data/bible.json.
Files:
- Create:
tools/economy_sim/career_save.gd - Create:
tests/test_career_save.gd - Modify:
tools/economy_sim/ablate.gd(useCareerSave; add--mag) - Modify:
data/bible.json(field-medicentry ~line 1477) - Modify:
tests/test_regen.gd
Interfaces:
-
Produces:
class_name CareerSave extends RefCountedwithstatic func at(career: Dictionary, at_run: int) -> MetaState: levels = every purchase in runs1..at_run-1.at_run == 1is a fresh save.static func play(content: ContentDB, defs: Array, meta: MetaState, seed: int, profile: String, ticks: int) -> Dictionary: copiesmeta,CareerEquip.equip, runsSoakRun, returns the SoakRun report (keys used later:run_time,run_gold,game_over).
-
Produces:
ablate.gd --mag=<id>:<value>[,...]setsmagnitudeon those defs after the replay check (the override changes the save’s behaviour, so the recorded career would no longer replay). -
Step 1: Failing tests
tests/test_career_save.gd:
extends GutTest
func _career() -> Dictionary: return {"profile": "median", "runs": [ {"i": 1, "bought": ["vitality"]}, {"i": 2, "bought": ["vitality", "greed"]}, {"i": 3, "bought": ["bulwark"]}, ]}
func test_run_one_is_a_fresh_save() -> void: assert_eq(CareerSave.at(_career(), 1).levels, {})
func test_save_at_run_n_holds_purchases_before_it() -> void: assert_eq(CareerSave.at(_career(), 3).levels, {"vitality": 2, "greed": 1})
func test_save_after_last_run_holds_everything() -> void: assert_eq(CareerSave.at(_career(), 4).levels, {"vitality": 2, "greed": 1, "bulwark": 1})Append to tests/test_regen.gd:
func test_field_medic_is_multi_level_and_levels_stack() -> void: var defs := SimContentFixture.db().meta_upgrades() var fm: Dictionary = {} for d: Dictionary in defs: if String(d["id"]) == "field-medic": fm = d assert_gte(int(fm["max_level"]), 2, "Field Medic has more than one level (Chris, 2026-09-27)") assert_lt(float(fm["magnitude"]), 1.0, "one level heals less than the old single level did") var meta := MetaState.new() meta.levels["field-medic"] = 2 var p := PlayerState.new() meta.apply_to(p, defs) assert_almost_eq(p.still_heal_mult, 2.0 * float(fm["magnitude"]), 0.0001, "levels add")-
Step 2: Run to verify they fail —
-gselect=test_career_save(class missing),-gselect=test_regen(max_level is 1). -
Step 3: Implement
CareerSaveby moving the save rebuild and_playout ofablate.gd:
class_name CareerSaveextends RefCounted
# Shared by ablate.gd and value_sweep.gd (economy sim, DEV ONLY). A career JSON records one id per# level bought in runs[].bought; the save at the START of run N is every purchase before it.
static func at(career: Dictionary, at_run: int) -> MetaState: var m := MetaState.new() for r: Dictionary in career["runs"]: if int(r["i"]) >= at_run: break for id: String in r["bought"]: m.levels[id] = int(m.levels.get(id, 0)) + 1 return m
# One run on a copy of `meta`, started the way a career starts it (CareerEquip, then SoakRun,# which applies the save through MetaRunSetup).static func play(content: ContentDB, defs: Array, meta: MetaState, seed: int, profile: String, ticks: int) -> Dictionary: var m := MetaState.new() m.from_dict(meta.to_dict()) CareerEquip.equip(m, defs) return SoakRun.new({"seed": seed, "ship": m.selected_ship, "coop": false, "max_ticks": ticks, "content": content, "player": "skilled", "profile": profile, "meta": m}).execute()In ablate.gd: replace the rebuild loop with base = CareerSave.at(career, at); replace _play(...) calls with CareerSave.play(...) and delete _play; after the replay check (and before the paired loop) add:
for kv in String(a.get("mag", "")).split(",", false): var p := kv.split(":") for d: Variant in defs: if d is Dictionary and String((d as Dictionary)["id"]) == p[0]: (d as Dictionary)["magnitude"] = float(p[1]) # same dict ContentDB serves MetaRunSetup (d as Dictionary)["max_level"] = maxi(int((d as Dictionary).get("max_level", 1)), 3) change += " mag %s" % kvUpdate the header usage comment with --mag. ContentDB._entries returns the live array, so editing these dicts reaches MetaRunSetup. Confirm with the replay check still printing ok for a run without --mag.
- Step 4: Verify the refactor didn’t change ablate
godot --headless --path . -s res://tools/economy_sim/career_runner.gd -- --profile=median --seed0=10000 --runs=25 # if Task 4 didn't leave onegodot --headless --path . -s res://tools/economy_sim/ablate.gd -- --career=reports/economy/career-median-10000.json --at=22 --drop=field-medic --seed0=10021 --n=8Expected: replay ... ok. Median base/variant in the same range as the skill’s reference (287 s / 98 s); the career itself changed in Task 3, so exact equality isn’t expected. Record the numbers.
- Step 5: Measure candidate magnitudes (level grid)
Use the first run in the new median-10000 career where field-medic has been bought as --at (grep the career JSON). For each candidate M in 0.2 0.25 0.33, run three ablations on the same seeds:
for M in 0.2 0.25 0.33; do for V in "--drop=field-medic" "--add=field-medic:2" "--add=field-medic:3"; do godot --headless --path . -s res://tools/economy_sim/ablate.gd -- \ --career=reports/economy/career-median-10000.json --at=<AT> --mag=field-medic:$M $V --seed0=10021 --n=8 \ | /usr/bin/grep "median run_time" donedoneBase in each line is level 1 at M. This gives median run_time for L0, L1, L2, L3 per M. Build a table: M | L0 | L1 | L2 | L3 | runs>240s at L3.
Decision rule (proposal, Chris approves): the largest M where (a) every one-level step multiplies median run_time by at most 2.0×, and (b) L3’s median is below today’s single-level median at the same save (the Step 4 base). If no candidate passes, propose 0.2 and say so. max_level 3, base_cost 60, cost_growth 1.6 (60 / 96 / 154 g) stay as the starting price; Task 9 re-proposes prices with everything else.
-
Step 6: STOP — show Chris the table and the proposed
M. Do not commit bible values until he approves. -
Step 7: Apply the approved values
data/bible.json field-medic: "magnitude": <M>, "max_level": 3, "desc": "Stand still to recover health, faster the longer you hold. More per level" (or Chris’s wording). Run -gselect=test_regen, -gselect=test_career_save, then bash scripts/gate.sh. Also run the tutorial check the gate includes: the stand-still lesson only fires for still_heal_mult > 0 (sim/story_director.gd:81), which any level still satisfies.
- Step 8: Commit (two commits: tooling, then balance), tick Task 0’s Field Medic item, push
git add tools/economy_sim/career_save.gd tools/economy_sim/ablate.gd tests/test_career_save.gdgit commit -m "tools(economy-sim): CareerSave shared by ablate; --mag override"git add data/bible.json tests/test_regen.gdgit commit -m "balance(shop): Field Medic toned to <M>/level, 3 levels (Chris, 2026-09-27)"git pull --rebase && git push origin mainTask 6: Value sweep runner (raw paired runs)
Section titled “Task 6: Value sweep runner (raw paired runs)”Checkpoints (deviation from spec §3.7, on purpose): the spec’s run 20 / run 60 assumed a long catalogue. With the core tier at ~15 and the whole shop at ~30, run 60 would be a fully-owned save with nothing to measure. Use the policy-A median career’s saves at run 1 (fresh), run 10 and run 20.
Files:
- Create:
tools/economy_sim/value_sweep.gd - Create:
scripts/economy-value-sweep.sh
Interfaces:
- Consumes:
CareerSave.at,CareerSave.play(Task 5);ShopTipPolicy.is_dead_drone_stat(Task 3). - Produces:
reports/economy/value-<profile>-at<N>-s<k>.jsonper shard:
{"career": "...", "at": 10, "profile": "median", "n": 10, "seed0": 60000, "ticks": 72000, "spent": 1234, "levels": {"vitality": 2}, "base": [{"seed": 60000, "run_time": 61.2, "gold": 210}], "items": {"haste": {"cost": 96, "runs": [{"seed": 60000, "run_time": 64.0, "gold": 222}]}}}Items skipped: maxed at the checkpoint, and dead drone stats (reported under "skipped" with the reason, so the report can list them).
- Step 1: Implement
value_sweep.gd
extends SceneTree
# Item value sweep (spec §3.7), raw half. DEV ONLY. Skill: bh-economy-sim.# godot --headless --path . -s res://tools/economy_sim/value_sweep.gd -- \# --career=reports/economy/career-median-10000.json --at=10 [--n=10] [--seed0=60000] \# [--ticks=72000] [--shard=0 --shards=1]# For every item not maxed at the checkpoint save: +1 level vs the save as-is, same seeds both# arms. Shards split the item list round-robin; every shard also plays the base arm (cheap, and# value_report.py checks all shards' base arms are identical, which catches nondeterminism).
func _init() -> void: var a := _parse(OS.get_cmdline_user_args()) var content := ContentLoader.load_from_path("res://data/bible.json") if content == null: push_error("value_sweep: failed to load bible.json"); quit(2); return var defs := content.meta_upgrades() var career := _read_json(String(a.get("career", ""))) var at := int(a.get("at", "0")) if career.is_empty() or at < 1: push_error("value_sweep: need --career=<json> and --at>=1"); quit(2); return var profile := String(a.get("profile", career["profile"])) var n := int(a.get("n", "10")) var seed0 := int(a.get("seed0", "60000")) var ticks := int(a.get("ticks", "72000")) var shard := int(a.get("shard", "0")) var shards := maxi(int(a.get("shards", "1")), 1) var meta := CareerSave.at(career, at)
var base: Array = [] for s in range(seed0, seed0 + n): base.append(_row(s, CareerSave.play(content, defs, meta, s, profile, ticks))) var items := {} var skipped := {} var idx := -1 for d: Variant in defs: if not (d is Dictionary): continue var dd: Dictionary = d var id := String(dd["id"]) if meta.is_maxed(dd): skipped[id] = "maxed"; continue if ShopTipPolicy.is_dead_drone_stat(id, meta): skipped[id] = "class not owned"; continue idx += 1 if idx % shards != shard: continue var v := MetaState.new() v.from_dict(meta.to_dict()) v.levels[id] = int(v.levels.get(id, 0)) + 1 var runs: Array = [] for s in range(seed0, seed0 + n): runs.append(_row(s, CareerSave.play(content, defs, v, s, profile, ticks))) items[id] = {"cost": meta.cost(dd), "runs": runs} print("[value_sweep] at=%d %s done" % [at, id])
var out := {"career": String(a["career"]), "at": at, "profile": profile, "n": n, "seed0": seed0, "ticks": ticks, "spent": meta.total_spent(defs), "levels": meta.levels, "base": base, "items": items, "skipped": skipped} DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path("res://reports/economy")) var path := "res://reports/economy/value-%s-at%d-s%d.json" % [profile, at, shard] var f := FileAccess.open(path, FileAccess.WRITE) if f == null: push_error("value_sweep: cannot write %s" % path); quit(3); return f.store_string(JSON.stringify(out, " ")) f.close() print("[value_sweep] at=%d shard %d/%d: %d items -> %s" % [at, shard, shards, items.size(), path]) quit(0)
func _row(s: int, rep: Dictionary) -> Dictionary: return {"seed": s, "run_time": float(rep["run_time"]), "gold": int(rep["run_gold"])}Copy _read_json and _parse verbatim from ablate.gd.
- Step 2: Smoke test on a tiny slice
godot --headless --path . -s res://tools/economy_sim/value_sweep.gd -- --career=reports/economy/career-median-10000.json --at=1 --n=2 --shards=20 --shard=0Expected: a file with 2 base rows, a few items with 2 runs each, and skipped listing the four unowned classes’ stats. Hand-check one item: re-run ablate.gd --career=... --at=1 --add=<id>:1 --seed0=60000 --n=2 and compare the variant run_times.
- Step 3:
scripts/economy-value-sweep.sh, modelled onscripts/economy-careers.sh(samejobs, import pass, positional args intosh -c, exit status,git checkout -- project.godotat the end):
#!/usr/bin/env bash# Fan the item value sweep out over shards (spec §3.7). Headless, overnight-safe.# Usage: scripts/economy-value-sweep.sh <career.json> [checkpoints="1 10 20"] [n=10] [ticks=72000]# Output: reports/economy/value-<profile>-at<N>-s<k>.json (gitignored). Then:# python3 tools/economy_sim/value_report.py reports/economy/value-median-at10-s*.jsonset -uo pipefailcd "$(dirname "$0")/.."career=$1; cps=${2:-"1 10 20"}; n=${3:-10}; ticks=${4:-72000}jobs=$(( $(sysctl -n hw.ncpu) - 2 )); [ "$jobs" -lt 1 ] && jobs=1godot --headless --path . --import >/dev/null 2>&1 || truefor at in $cps; do for k in $(seq 0 $((jobs - 1))); do echo "$career $at $k $jobs $n $ticks"; donedone | xargs -P "$jobs" -L 1 sh -c ' out=$(godot --headless --path . -s res://tools/economy_sim/value_sweep.gd -- \ --career="$0" --at="$1" --shard="$2" --shards="$3" --n="$4" --ticks="$5" 2>&1); rc=$? printf "%s\n" "$out" | grep -E "shard|SCRIPT ERROR|ERROR:" [ "$rc" -eq 0 ] || echo "[value_sweep] FAILED at=$1 shard=$2 exit=$rc" exit $rc'status=$?git checkout -- project.godot 2>/dev/null || trueexit $statuschmod +x. Run it with n=2 on one checkpoint to confirm the fan-out, and time it: run count × (wall per run) tells you the full sweep’s wall time. Put the measured estimate in the task report. If the full 3 checkpoints × n=10 exceed ~4 h wall, drop to n=8 and say so.
- Step 4: Commit and push (
tools(economy-sim): value sweep runner + shard script), afterbash scripts/gate.sh(the new script must parse on import).
Task 7: Value report (statistics in Python)
Section titled “Task 7: Value report (statistics in Python)”Files:
- Create:
tools/economy_sim/value_report.py
Interfaces:
- Consumes: all shard files of ONE checkpoint.
- Produces:
reports/economy/value-<profile>-at<N>.summary.json:
{"at": 10, "profile": "median", "n": 10, "spent": 1234, "items": {"haste": {"cost": 96, "d_gold_median": 12.0, "d_time_median": 3.1, "ci90_gold": [2.0, 20.5], "measurable": true, "value_per_gold": 0.125}}, "skipped": {"drone-bomber-power": "class not owned"}}plus a markdown ranking on stdout.
- Step 1: Implement
#!/usr/bin/env python3"""Value-sweep shards (one checkpoint) -> value per gold per item. Run from the repo root: python3 tools/economy_sim/value_report.py reports/economy/value-median-at10-s*.jsonPer item: paired deltas (variant - base, same seed) of run gold and run time. 90% interval =percentile bootstrap of the MEAN gold delta (random.Random(0), 4000 resamples, deterministic).An item whose interval includes 0 is "no measurable effect": value_per_gold = 0, not ranked.value_per_gold = median gold delta / price of that level."""import json, random, statistics, sys
RESAMPLES = 4000
def ci90(deltas): rng = random.Random(0) means = sorted(statistics.fmean(rng.choices(deltas, k=len(deltas))) for _ in range(RESAMPLES)) return [means[int(0.05 * RESAMPLES)], means[int(0.95 * RESAMPLES) - 1]]
def main(paths): shards = [json.load(open(p)) for p in paths] first = shards[0] for s in shards[1:]: if s["base"] != first["base"] or s["at"] != first["at"] or s["profile"] != first["profile"]: sys.exit(f"shards disagree on base arm/checkpoint: {paths[0]} vs another; nondeterminism or mixed inputs") base = {r["seed"]: r for r in first["base"]} items, skipped = {}, dict(first.get("skipped", {})) for s in shards: for iid, it in s["items"].items(): dg = [r["gold"] - base[r["seed"]]["gold"] for r in it["runs"]] dt = [r["run_time"] - base[r["seed"]]["run_time"] for r in it["runs"]] lo, hi = ci90(dg) measurable = lo > 0 or hi < 0 med = statistics.median(dg) items[iid] = {"cost": it["cost"], "d_gold_median": med, "d_time_median": statistics.median(dt), "ci90_gold": [lo, hi], "measurable": measurable, "value_per_gold": (med / it["cost"]) if measurable and it["cost"] > 0 else 0.0} out = {"at": first["at"], "profile": first["profile"], "n": first["n"], "spent": first["spent"], "items": items, "skipped": skipped} path = f"reports/economy/value-{first['profile']}-at{first['at']}.summary.json" with open(path, "w") as f: json.dump(out, f, indent=2) print(f"## Value per gold, {first['profile']} at run {first['at']} (n={first['n']} paired seeds, spent {first['spent']} g)\n") print("| item | price | Δgold (median) | Δtime s (median) | 90% interval Δgold | gold per gold spent |") print("|---|---|---|---|---|---|") ranked = sorted(items.items(), key=lambda kv: -kv[1]["value_per_gold"]) for iid, it in ranked: if it["measurable"]: print(f"| {iid} | {it['cost']} | {it['d_gold_median']:+.0f} | {it['d_time_median']:+.1f} | " f"{it['ci90_gold'][0]:+.0f} to {it['ci90_gold'][1]:+.0f} | {it['value_per_gold']:.3f} |") quiet = [i for i, it in items.items() if not it["measurable"]] print(f"\nNo measurable effect ({len(quiet)}): " + ", ".join(sorted(quiet))) print(f"Skipped ({len(skipped)}): " + ", ".join(f"{k} ({v})" for k, v in sorted(skipped.items()))) print(f"\n-> {path}")
if __name__ == "__main__": main(sys.argv[1:])-
Step 2: Verify on the Task 6 smoke shards. Hand-compute one item’s paired deltas from its shard file and check the printed median matches. Then feed two copies of a shard where one
baserow is edited, and confirm it exits with “shards disagree”. -
Step 3: Commit and push (
tools(economy-sim): value report, paired bootstrap interval).
Task 8: Policy B (value per gold) and the career switch
Section titled “Task 8: Policy B (value per gold) and the career switch”Files:
- Create:
tools/economy_sim/value_per_gold_policy.gd - Create:
tests/test_value_per_gold_policy.gd - Modify:
tools/economy_sim/economy_career.gd,tools/economy_sim/career_runner.gd,scripts/economy-careers.sh
Interfaces:
- Consumes: summary dicts from Task 7;
ShopTipPolicy.cheapest_useful(Task 3). - Produces:
class_name ValuePerGoldPolicywithstatic func buy_all(defs: Array, meta: MetaState, tables: Array) -> Array[String]andstatic func nearest_table(tables: Array, spent: int) -> Dictionary. - Produces:
EconomyCareer._init(content_db, profile_name, first_seed, runs, ticks, value_tables: Array = []); output gains"policy": "tip" | "value".career_runner.gd --value=a.summary.json,b.summary.jsonwritescareer-value-<profile>-<seed0>.json; without it, output name and behaviour are unchanged.
Rule: pick the table whose spent is nearest the career’s current meta.total_spent(defs). Among affordable, not-maxed items, buy the highest value_per_gold above 0 (ties: earlier catalogue entry). Repeat until nothing with value > 0 is affordable. If no item in the table has value > 0 affordable AND no item with value > 0 exists at all in that table, fall back to ShopTipPolicy.cheapest_useful so the career keeps spending. Otherwise save up. The +1-level value measured at the checkpoint stands in for later levels too; say so in the report.
- Step 1: Failing tests
extends GutTest
func _defs() -> Array: return SimContentFixture.db().meta_upgrades()
func _table(spent: int, values: Dictionary) -> Dictionary: var items := {} for id: String in values: items[id] = {"value_per_gold": float(values[id]), "measurable": float(values[id]) > 0.0} return {"spent": spent, "items": items}
func test_buys_highest_value_per_gold_first() -> void: var meta := MetaState.new() meta.banked_gold = 60 var t := [_table(0, {"vitality": 0.1, "haste": 0.5, "greed": 0.2})] assert_eq(ValuePerGoldPolicy.buy_all(_defs(), meta, t)[0], "haste")
func test_skips_unaffordable_best_and_buys_next() -> void: var meta := MetaState.new() meta.banked_gold = 45 # haste costs 60, greed 40 var t := [_table(0, {"haste": 0.5, "greed": 0.2})] assert_eq(ValuePerGoldPolicy.buy_all(_defs(), meta, t), ["greed"] as Array[String])
func test_saves_up_when_nothing_valuable_is_affordable() -> void: var meta := MetaState.new() meta.banked_gold = 50 var t := [_table(0, {"haste": 0.5})] assert_eq(ValuePerGoldPolicy.buy_all(_defs(), meta, t), [] as Array[String]) assert_eq(meta.banked_gold, 50)
func test_falls_back_to_cheapest_useful_when_table_has_no_value() -> void: var meta := MetaState.new() meta.banked_gold = 40 var t := [_table(0, {"haste": 0.0})] var bought := ValuePerGoldPolicy.buy_all(_defs(), meta, t) assert_eq(bought.size(), 1) assert_false(ShopTipPolicy.is_dead_drone_stat(bought[0], MetaState.new()))
func test_nearest_table_by_spent() -> void: var t := [_table(0, {}), _table(1000, {}), _table(3000, {})] assert_eq(int(ValuePerGoldPolicy.nearest_table(t, 1800)["spent"]), 1000) assert_eq(int(ValuePerGoldPolicy.nearest_table(t, 2600)["spent"]), 3000)
func test_gold_is_conserved() -> void: var meta := MetaState.new() meta.banked_gold = 1500 ValuePerGoldPolicy.buy_all(_defs(), meta, [_table(0, {"vitality": 0.3, "bulwark": 0.2})]) assert_eq(meta.banked_gold + meta.total_spent(_defs()), 1500)Check the real prices first (vitality 40, haste 60, greed 40 today) and adjust gold amounts if Task 5/9 changed them.
-
Step 2: Run to verify fail —
-gselect=test_value_per_gold_policy. -
Step 3: Implement
class_name ValuePerGoldPolicyextends RefCounted
# Economy-sim buy policy B (spec §3.6): buy the affordable item with the highest measured# value per gold, from the value-sweep table (value_report.py summary) whose checkpoint spend is# nearest this career's. Saves up while something valuable is unaffordable. DEV ONLY.
const MAX_BUYS_PER_VISIT := 500
static func buy_all(defs: Array, meta: MetaState, tables: Array) -> Array[String]: var bought: Array[String] = [] for _i in range(MAX_BUYS_PER_VISIT): var table := nearest_table(tables, meta.total_spent(defs)) var id := _pick(defs, meta, table) 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 nearest_table(tables: Array, spent: int) -> Dictionary: var best: Dictionary = {} for t: Dictionary in tables: if best.is_empty() or absi(int(t["spent"]) - spent) < absi(int(best["spent"]) - spent): best = t return best
static func _pick(defs: Array, meta: MetaState, table: Dictionary) -> String: var items: Dictionary = table.get("items", {}) var best := "" var best_v := 0.0 var any_valuable := false for d: Variant in defs: if not (d is Dictionary): continue var dd: Dictionary = d var id := String(dd["id"]) var v := float((items.get(id, {}) as Dictionary).get("value_per_gold", 0.0)) if v <= 0.0 or meta.is_maxed(dd): continue any_valuable = true if meta.can_afford(dd) and v > best_v: best = id best_v = v if best == "" and not any_valuable: return ShopTipPolicy.cheapest_useful(defs, meta) return best
static func _def(defs: Array, id: String) -> Dictionary: for d: Variant in defs: if d is Dictionary and String((d as Dictionary).get("id", "")) == id: return d return {}economy_career.gd: add var value_tables: Array, the optional constructor param, and replace the buy line with:
var bought := ShopTipPolicy.buy_all(defs, meta) if value_tables.is_empty() \ else ValuePerGoldPolicy.buy_all(defs, meta, value_tables)and add "policy": "tip" if value_tables.is_empty() else "value" to the returned dict.
career_runner.gd: parse --value= (comma list), read each with _read_json (copy from ablate.gd), error-exit 2 on an unreadable file, pass them in, and name the output career-value-%s-%d.json when set.
scripts/economy-careers.sh: optional 5th arg value (comma list of summary files) passed through as --value="$4" only when non-empty; document it in the usage line.
-
Step 4: Tests + gate —
-gselect=test_value_per_gold_policy,-gselect=test_economy_career(policy A output unchanged apart from the new"policy": "tip"key),bash scripts/gate.sh. -
Step 5: Commit and push (
tools(economy-sim): policy B, value per gold).
Task 9: Run it all, report, price proposal
Section titled “Task 9: Run it all, report, price proposal”Runs overnight-safe; see the /loop sleep note in the global CLAUDE.md if Chris leaves the lid closed.
- Step 1: Policy-A careers on the new Field Medic
scripts/economy-careers.sh 5 150 72000 "weak median strong"python3 tools/economy_sim/summarize.py reports/economy/career-*.json- Step 2: Value sweep at runs 1, 10, 20 of median-10000, then reports
scripts/economy-value-sweep.sh reports/economy/career-median-10000.json "1 10 20" 10for N in 1 10 20; do python3 tools/economy_sim/value_report.py reports/economy/value-median-at$N-s*.json; doneRe-check the top 10 and bottom 10 measurable items per checkpoint with --profile=weak and --profile=strong (spec §3.7) using ablate.gd --add=<id>:<level+1> on the same seeds.
- Step 3: Policy-B careers
scripts/economy-careers.sh 5 150 72000 "weak median strong" reports/economy/value-median-at1.summary.json,reports/economy/value-median-at10.summary.json,reports/economy/value-median-at20.summary.jsonpython3 tools/economy_sim/summarize.py reports/economy/career-*.json-
Step 4: Price proposal. Compare the median profile’s
coreand100% spentmilestones (both policies) with the targets (~15, ~30).- If both are off by roughly the same factor, propose ONE gold-rate change (
GOLD_PER_KILL/BOSS_GOLDinsim/sim.gd, a sim const: that DOES touch the determinism baseline only if the baseline reads gold; checktests/test_determinism_survival.gd’s checksum fields before proposing it, and prefer prices if it does). - Otherwise propose per-item
base_cost/cost_growth: Pilot + Arsenal priced socorelands ~15; the rest so the whole catalogue lands ~30. Items with high value per gold get pricier; “no measurable effect” items are flagged as a design question (buff or cut), not just repriced. - Test the proposal before showing it: edit
data/bible.jsonin a scratch worktree, re-run median careers (5 seeds), confirm the milestones. Iterate at most three times, then show what you have.
- If both are off by roughly the same factor, propose ONE gold-rate change (
-
Step 5: Write-up. New research file
docs/research/2026-09-2X-shop-economy-price-proposal.md(withtitle:frontmatter for the docs site): Field Medic result, career curves vs targets per profile and policy, value-per-gold ranking per checkpoint, the proposal table (old vs newbase_cost/cost_growthper item), and every number with its sample size. Label calibration targets as rough; items whose interval crosses zero listed as “no measurable effect”. -
Step 6: Report page. Load
artifact-design(via the Artifact tool’s quickstart), then publish one Artifact for Chris and Toby: curves vs tier targets, runs-to-tier table, per-checkpoint ranking, proposal table. Use thedatavizskill for the charts. -
Step 7: STOP — Chris approves the proposal. Then apply it to
data/bible.jsonin its own commit (balance(shop): prices from the economy sim (Chris, <date>)),bash scripts/gate.sh, re-run median careers to confirm the targets, tick Task 0’s price item, push. -
Step 8: Update the skill and memory.
.claude/skills/bh-economy-sim/SKILL.md: add the value sweep, value report, policy B and--magsections. Update memorybullet-heaven-shop-cost-autopilot-plan.
Self-review notes
Section titled “Self-review notes”- Spec §3.5 (strong autopilot): deliberately out of scope, reason and re-entry trigger recorded above.
- Spec §3.6 policy B: Task 8. Policy A fallback defect: Task 3.
- Spec §3.7: Tasks 6-7, checkpoints changed to 1/10/20 with the reason given.
- Spec §3.8 + §5 step 7: Task 9.
- Chris’s four answers: Field Medic (Task 5), tier targets (Tasks 4, 9), rename (Task 1), site changelog (Task 2).
- Names used across tasks:
CareerSave.at/play,ShopTipPolicy.cheapest_useful/is_dead_drone_stat,ValuePerGoldPolicy.buy_all/nearest_table, summary keysspent,items,value_per_gold,measurable. Checked consistent.