Skilled AutoPlayer + Balance-Signal Reporting Implementation Plan
Skilled AutoPlayer + Balance-Signal Reporting Implementation Plan
Section titled “Skilled AutoPlayer + Balance-Signal Reporting 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: Build a genuinely skill-plausible AI player plus extended soak-harness reporting, so hundreds of unattended headless runs become a numeric proxy for “is this fun” — bottlenecks, dead builds, anticlimax, and per-upgrade “did this feel like anything” scoring.
Architecture: A new SkilledAutoPlayer (inherits AutoPlayer, overrides movement/upgrade
choice only — dash/decoy stay identical) plugs into the existing SoakRun/soak_runner.gd
sweep infrastructure via a new player config key. A new stats_sink.on_player_damage hook
(mirroring the existing on_damage/on_kill hooks) feeds phase-tagged damage-taken tracking. A
ring-buffer window in SoakRun scores each upgrade pick’s before/after impact. soak_runner.gd
gains a cross-run fun-signal analysis pass alongside its existing anomaly detector.
Tech Stack: Godot 4.6.3 / typed GDScript, GUT 9.6.0 for tests, existing tools/soak/ CLI
harness.
Global Constraints
Section titled “Global Constraints”/sim(sim/sim.gdand friends) is pure logic — no Node/render/Input/Engine/Time APIs. The one change in this plan that touchessim/sim.gd(Task 1) is a single additive, guardedstats_sinkcall — same pattern as the two hooks that already exist there.- Every new/changed
.gdfile is TDD’d via GUT — write the failing test first. - Determinism: nothing in this plan may change the pinned baseline in
tests/test_determinism_survival.gd. All new code is either (a) a pure, guarded observer hook (stats_sinkcalls — provably trace-invisible, see Task 1’s reuse of the existingtest_attached_sink_does_not_change_the_tracepattern) or (b) dev-only tooling underai/skilled_auto_player.gd/tools/soak/thatmain.gdand production code never reach. Runtests/test_determinism_survival.gdafter every task as a cheap sanity check even though no task here should be able to move it. - Match existing project conventions exactly: GUT test files
extends GutTest, useSimContentFixture.db()for content,assert_eq/assert_true/assert_gt/assert_ltstyle matchingtests/test_soak_run.gd/tests/test_auto_player.gd/tests/test_soak_stats.gd. - Full suite command:
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit. Single-file: append-gselect=<script_name_without_.gd>.
Task 1: Player-damage stats hook + phase-aware tracking
Section titled “Task 1: Player-damage stats hook + phase-aware tracking”Files:
- Modify:
sim/sim.gd(_hurt_player, currentlysim/sim.gd:2680-2699) - Modify:
tools/soak/soak_stats.gd - Test:
tests/test_soak_stats.gd(extend existing file)
Interfaces:
-
Consumes: nothing new —
Sim.stats_sink: ObjectandSim.spawn_phase: intalready exist. -
Produces:
SoakStats.damage_taken_by_phase: Dictionary(phase int -> total float), which Task 3 reads to extendSoakRun’s report and Task 4’s cross-run analysis reads for the anticlimax signal. -
Step 1: Write the failing tests
Append to tests/test_soak_stats.gd (after the existing test_sink_records_kills_by_type, before
test_attached_sink_does_not_change_the_trace — leave that last test exactly as-is, it already
proves any new guarded hook is trace-invisible as long as it stays guarded and read-only):
func test_sink_records_player_damage_taken_by_phase() -> void: var stats := SoakStats.new() _run(1234, TICKS, stats) var total := 0.0 for phase in stats.damage_taken_by_phase: total += stats.damage_taken_by_phase[phase] assert_gt(total, 0.0, "900 ticks of survival takes SOME damage")- Step 2: Run test to verify it fails
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_soak_stats -gexit
Expected: FAIL — Invalid get index 'damage_taken_by_phase' (the field doesn’t exist yet).
- Step 3: Add the field + method to
SoakStats
In tools/soak/soak_stats.gd, after the existing kills_by_type field declaration:
var damage_taken_by_phase: Dictionary = {} # Sim.spawn_phase int -> total damage takenAfter the existing on_kill function:
func on_player_damage(amount: float, phase: int) -> void: damage_taken_by_phase[phase] = damage_taken_by_phase.get(phase, 0.0) + amount- Step 4: Wire the hook into
_hurt_player
In sim/sim.gd, inside _hurt_player (around line 2694), immediately after the existing line
pilot.hp -= taken:
pilot.hp -= taken if stats_sink: stats_sink.on_player_damage(taken, spawn_phase)- Step 5: Run test to verify it passes
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_soak_stats -gexit
Expected: PASS, all tests in the file green (including the pre-existing
test_attached_sink_does_not_change_the_trace, unchanged).
- Step 6: Determinism sanity check
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_determinism_survival -gexit
Expected: PASS, baseline unchanged (the new call is guarded by if stats_sink:, which is null
in every determinism-test Sim — see Global Constraints).
- Step 7: Commit
git add sim/sim.gd tools/soak/soak_stats.gd tests/test_soak_stats.gdgit commit -m "feat(soak): add stats_sink.on_player_damage phase-tagged hook"Task 2: SkilledAutoPlayer — smarter movement + upgrade choice
Section titled “Task 2: SkilledAutoPlayer — smarter movement + upgrade choice”Files:
- Create:
ai/skilled_auto_player.gd - Test: Create
tests/test_skilled_auto_player.gd
Interfaces:
-
Consumes:
AutoPlayer’s existing public consts (THREAT_RADIUS,GEM_SEEK_WEIGHT,WALL_MARGIN,WALL_WEIGHT,DASH_PANIC_RADIUS,DECOY_THREAT_RADIUS) and methods (compute_move(sim: Sim) -> Vector2,wants_dash(sim, pilot) -> bool,wants_decoy(sim, pilot) -> bool,pick_dimension(choice_ids: Array) -> String), all defined inai/auto_player.gd— do not modify that file,SkilledAutoPlayeronly extends it. -
Produces:
class_name SkilledAutoPlayer extends AutoPlayer, overridingcompute_move_for(sim: Sim, pilot: PlayerState) -> Vector2andpick_upgrade(choice_ids: Array) -> String. Task 3’sSoakRuninstantiates this when configured, assigning it to the existingvar auto: AutoPlayerfield (valid becauseSkilledAutoPlayerIS-ANAutoPlayer). -
Step 1: Write the failing tests
Create tests/test_skilled_auto_player.gd:
extends GutTest
func _content() -> ContentDB: return SimContentFixture.db()
func test_pick_upgrade_prefers_weapon_over_evolve_and_mods() -> void: var ai := SkilledAutoPlayer.new() assert_eq(ai.pick_upgrade(["wm:blade:pierce", "weapon:nova", "evolve:blade"]), "weapon:nova", "a new weapon slot beats an evolve or a per-weapon mod")
func test_pick_upgrade_prefers_evolve_over_mods() -> void: var ai := SkilledAutoPlayer.new() assert_eq(ai.pick_upgrade(["wm:blade:pierce", "evolve:blade", "aim:pierce"]), "evolve:blade", "an evolve beats per-weapon/aim mods when no weapon grant is offered")
func test_pick_upgrade_falls_back_to_first_generic() -> void: var ai := SkilledAutoPlayer.new() assert_eq(ai.pick_upgrade(["damage", "fire-rate"]), "damage", "no weapon/evolve/mod prefix offered -> first generic choice, same fallback as AutoPlayer")
func test_pick_upgrade_empty_is_blank() -> void: var ai := SkilledAutoPlayer.new() assert_eq(ai.pick_upgrade([]), "")
func test_elite_threat_is_weighted_harder_when_two_threats_compete() -> void: var sim := Sim.new(1, _content()) sim.player.pos = Vector2.ZERO sim.enemies.add(Vector2(100, 0), Vector2.ZERO, 14.0, 5.0) # threat A: directly right sim.enemies.add(Vector2(0, 100), Vector2.ZERO, 14.0, 5.0) # threat B: directly below, equal distance sim.hash.rebuild(sim.enemies) var dumb_move := AutoPlayer.new().compute_move(sim) sim.enemies.is_elite[0] = 1 # threat A becomes elite var skilled_move := SkilledAutoPlayer.new().compute_move(sim) assert_lt(skilled_move.x, dumb_move.x, "weighting threat A (directly right) as elite pushes the flee direction further left than the unweighted baseline")
func test_inherits_dash_behavior_from_auto_player() -> void: var sim := Sim.new(1, _content()) sim.player.pos = Vector2.ZERO sim.enemies.add(Vector2(50, 0), Vector2.ZERO, 14.0, 5.0) # inside DASH_PANIC_RADIUS sim.hash.rebuild(sim.enemies) assert_true(SkilledAutoPlayer.new().wants_dash(sim, sim.player), "dash/decoy timing is unchanged from AutoPlayer for v1 (no evidence yet it's a bottleneck)")- Step 2: Run tests to verify they fail
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_skilled_auto_player -gexit
Expected: FAIL — Could not find type "SkilledAutoPlayer" (class doesn’t exist yet).
- Step 3: Implement
SkilledAutoPlayer
Create ai/skilled_auto_player.gd:
class_name SkilledAutoPlayerextends AutoPlayer
# A more capable soak-testing AI than the base AutoPlayer, used ONLY for# balance-signal runs (never attract-mode or coverage soaks — AutoPlayer's# deliberate simplicity there is untouched by this file).
const ELITE_DANGER_WEIGHT: float = 1.6 # elites are steered away from harder than trash
func compute_move_for(sim: Sim, pilot: PlayerState) -> Vector2: var p := pilot.pos var flee := Vector2.ZERO var near := sim.hash.query_circle(p, THREAT_RADIUS, sim.enemies) for i in near: var d := p - sim.enemies.pos[i] var dist := d.length() if dist > 0.001: var w := (THREAT_RADIUS - dist) / THREAT_RADIUS var danger := ELITE_DANGER_WEIGHT if sim.enemies.is_elite[i] == 1 else 1.0 flee += d / dist * w * w * danger
var seek := Vector2.ZERO var best := -1 var best_d2 := INF for gi in range(sim.gems.count): var gd2 := p.distance_squared_to(sim.gems.pos[gi]) if gd2 < best_d2: best_d2 = gd2 best = gi if best != -1: seek = (sim.gems.pos[best] - p).normalized() * GEM_SEEK_WEIGHT
var wall := Vector2.ZERO var lim := Sim_Const.ARENA_HALF - WALL_MARGIN if p.x > lim: wall.x -= (p.x - lim) / WALL_MARGIN if p.x < -lim: wall.x += (-lim - p.x) / WALL_MARGIN if p.y > lim: wall.y -= (p.y - lim) / WALL_MARGIN if p.y < -lim: wall.y += (-lim - p.y) / WALL_MARGIN
var dir := flee + seek + wall * WALL_WEIGHT if dir.length() < 0.01: return Vector2.ZERO return dir.normalized()
# Score offered choices instead of AutoPlayer's "first weapon, else first" — id# prefixes are a stable contract (sim/upgrade_system.gd apply_upgrade): "weapon:"# grants a new slot, "evolve:" evolves an owned weapon, "wm:"/"aim:" are# per-weapon mods, anything else is a generic content-db stat/transformative pick.func pick_upgrade(choice_ids: Array) -> String: if choice_ids.is_empty(): return "" var best_id := String(choice_ids[0]) var best_score := -1 for raw in choice_ids: var id := String(raw) var score := 0 if id.begins_with("weapon:"): score = 3 elif id.begins_with("evolve:"): score = 2 elif id.begins_with("wm:") or id.begins_with("aim:"): score = 1 if score > best_score: best_score = score best_id = id return best_id- Step 4: Run tests to verify they pass
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_skilled_auto_player -gexit
Expected: PASS, all 6 tests green.
- Step 5: Full suite + determinism sanity check
Run: bash scripts/check-test-count.sh
Expected: exit 0, script count unchanged +1 file, no failures.
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_determinism_survival -gexit
Expected: PASS, baseline unchanged (this file is never imported by main.gd).
- Step 6: Commit
git add ai/skilled_auto_player.gd tests/test_skilled_auto_player.gdgit commit -m "feat(ai): add SkilledAutoPlayer — elite-aware movement + scored upgrade choice"Task 3: SoakRun — pluggable player + per-upgrade impact scoring
Section titled “Task 3: SoakRun — pluggable player + per-upgrade impact scoring”Files:
- Modify:
tools/soak/soak_run.gd - Modify:
tools/soak/soak_stats.gd - Test:
tests/test_soak_run.gd(extend existing file)
Interfaces:
-
Consumes:
SkilledAutoPlayer(Task 2),SoakStats.damage_taken_by_phase(Task 1),SoakStats.damage_by_context(existing). -
Produces:
SoakRun.execute()’s report Dictionary gains three keys —"player": String(echoes cfg,"dumb"or"skilled"),"damage_taken_by_phase": Dictionary,"upgrade_impacts": Array[Dictionary](each entry{id: String, dealt_pct: float, taken_pct: float, flag: String},flagone of"","dud","overpowered"). Task 4’ssoak_runner.gdreads all three for the cross-run fun-signal analysis and the new--player=CLI flag. -
Step 1: Write the failing tests
Append to tests/test_soak_run.gd:
func test_skilled_player_cfg_uses_skilled_auto_player() -> void: var run := SoakRun.new(_cfg({"player": "skilled", "max_ticks": 10})) run.execute() assert_true(run.auto is SkilledAutoPlayer, "player:skilled selects SkilledAutoPlayer")
func test_default_cfg_uses_dumb_auto_player() -> void: var run := SoakRun.new(_cfg({"max_ticks": 10})) run.execute() assert_false(run.auto is SkilledAutoPlayer, "no player key -> the existing dumb AutoPlayer, unchanged default")
func test_report_contains_player_and_phase_damage_and_impacts() -> void: var report := SoakRun.new(_cfg({"player": "skilled"})).execute() assert_eq(report.player, "skilled") assert_true(report.has("damage_taken_by_phase")) assert_true(report.has("upgrade_impacts")) assert_true(report.upgrade_impacts is Array)
func test_upgrade_impacts_are_finalized_after_the_window_closes() -> void: # 1200 ticks is comfortably more than one IMPACT_WINDOW_TICKS (180) past the run's # earliest level-ups (first XP starts flowing well before tick 600 at seed 1234 — # see test_soak_stats.gd's own tick-timing comment for the empirical basis). var report := SoakRun.new(_cfg({"player": "skilled", "max_ticks": 1200})).execute() assert_true(report.upgrades_applied.size() > 0, "sanity: the run actually leveled up") assert_true(report.upgrade_impacts.size() > 0, "at least one pick's post-window closed within 1200 ticks and was scored") for entry in report.upgrade_impacts: assert_true(entry.has("id") and entry.has("dealt_pct") and entry.has("taken_pct") and entry.has("flag")) assert_true(entry.flag in ["", "dud", "overpowered"])- Step 2: Run tests to verify they fail
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_soak_run -gexit
Expected: FAIL — Invalid get index 'player' / Invalid get index 'upgrade_impacts'.
- Step 3: Add
upgrade_impactstoSoakStats
In tools/soak/soak_stats.gd, after the damage_taken_by_phase field added in Task 1:
var upgrade_impacts: Array[Dictionary] = []
func record_upgrade_impact(entry: Dictionary) -> void: upgrade_impacts.append(entry)- Step 4: Implement pluggable player + impact ring buffer in
SoakRun
In tools/soak/soak_run.gd, change the auto field’s declared type so it can hold either class
(valid immediately since SkilledAutoPlayer extends AutoPlayer, no change needed — leave
var auto: AutoPlayer exactly as-is).
Add new consts and fields near the top of the class, after the existing _last_kill_tick: int = 0:
const IMPACT_WINDOW_TICKS: int = 180const IMPACT_DUD_THRESHOLD: float = 0.15const IMPACT_OVERPOWERED_THRESHOLD: float = 1.5const IMPACT_EPSILON: float = 1.0 # floor for pre-window totals, avoids divide-by-near-zero noise
var _dealt_ring: PackedFloat32Array = PackedFloat32Array()var _taken_ring: PackedFloat32Array = PackedFloat32Array()var _ring_pos: int = 0var _last_dealt_total: float = 0.0var _last_taken_total: float = 0.0var _pending_impacts: Array[Dictionary] = []Change the auto = AutoPlayer.new() line in execute() to:
auto = SkilledAutoPlayer.new() if String(cfg.get("player", "dumb")) == "skilled" else AutoPlayer.new()Add two new private methods (anywhere after execute()):
func _update_impact_ring() -> void: var dealt_total := 0.0 for v in stats.damage_by_context.values(): dealt_total += v var taken_total := 0.0 for v in stats.damage_taken_by_phase.values(): taken_total += v var dealt_delta := dealt_total - _last_dealt_total var taken_delta := taken_total - _last_taken_total _last_dealt_total = dealt_total _last_taken_total = taken_total if _dealt_ring.size() < IMPACT_WINDOW_TICKS: _dealt_ring.append(dealt_delta) _taken_ring.append(taken_delta) else: _dealt_ring[_ring_pos] = dealt_delta _taken_ring[_ring_pos] = taken_delta _ring_pos = (_ring_pos + 1) % IMPACT_WINDOW_TICKS
func _ring_sum(ring: PackedFloat32Array) -> float: var s := 0.0 for v in ring: s += v return s
func _finalize_due_impacts() -> void: var current := _tick() var still_pending: Array[Dictionary] = [] for p in _pending_impacts: if current >= int(p.due_tick): var post_dealt := _ring_sum(_dealt_ring) var post_taken := _ring_sum(_taken_ring) var dealt_pct := (post_dealt - float(p.pre_dealt)) / maxf(float(p.pre_dealt), IMPACT_EPSILON) var taken_pct := (post_taken - float(p.pre_taken)) / maxf(float(p.pre_taken), IMPACT_EPSILON) var flag := "" if absf(dealt_pct) < IMPACT_DUD_THRESHOLD and absf(taken_pct) < IMPACT_DUD_THRESHOLD: flag = "dud" elif dealt_pct > IMPACT_OVERPOWERED_THRESHOLD or taken_pct < -IMPACT_OVERPOWERED_THRESHOLD: flag = "overpowered" stats.record_upgrade_impact({"id": p.id, "dealt_pct": dealt_pct, "taken_pct": taken_pct, "flag": flag}) else: still_pending.append(p) _pending_impacts = still_pendingIn execute()’s main while loop, immediately after the existing _consume_levelups() call, add:
_update_impact_ring() _finalize_due_impacts()In _consume_levelups(), immediately after the existing upgrades_applied.append(picked) line,
add:
_pending_impacts.append({ "id": picked, "pre_dealt": _ring_sum(_dealt_ring), "pre_taken": _ring_sum(_taken_ring), "due_tick": _tick() + IMPACT_WINDOW_TICKS, })Finally, extend execute()’s returned Dictionary literal — add these three entries alongside the
existing "upgrades_applied": upgrades_applied, line:
"player": String(cfg.get("player", "dumb")), "damage_taken_by_phase": stats.damage_taken_by_phase, "upgrade_impacts": stats.upgrade_impacts,- Step 5: Run tests to verify they pass
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_soak_run -gexit
Expected: PASS, all tests in the file green (existing + 4 new).
- Step 6: Full suite + determinism sanity check
Run: bash scripts/check-test-count.sh
Expected: exit 0, no failures.
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_determinism_survival -gexit
Expected: PASS, baseline unchanged (all new state lives on SoakRun/SoakStats, never on Sim
itself — Task 1’s hook is the only Sim-side change, already verified guarded).
- Step 7: Commit
git add tools/soak/soak_run.gd tools/soak/soak_stats.gd tests/test_soak_run.gdgit commit -m "feat(soak): pluggable AI player + per-upgrade impact-window scoring"Task 4: soak_runner.gd — --player= flag + cross-run fun-signal analysis
Section titled “Task 4: soak_runner.gd — --player= flag + cross-run fun-signal analysis”Files:
- Modify:
tools/soak/soak_runner.gd - Test: manual CLI verification (this file is explicitly “DEV ONLY — never referenced by game
code or tests”, matching its existing header comment and the project convention stated in
tests/test_soak_run.gd’s own header — no GUT test file for this task).
Interfaces:
-
Consumes:
SoakRun.execute()’s extended report (Task 3) —player,damage_taken_by_phase,upgrade_impacts, plus existingticks_run,max_ticks,game_over,upgrades_applied. -
Produces: a
fun_signalssection in the written JSON report, and a human-readable summary block printed to stdout, in the same[soak] ...line style as the existing anomaly output. -
Step 1: Add the
--player=CLI flag
In tools/soak/soak_runner.gd, in _init(), after the existing var invuln := ... line:
var player := String(args.get("player", "dumb"))In the SoakRun.new({...}) call inside the nested loops, add "player": player to the
dictionary literal alongside the existing "invuln": invuln entry.
- Step 2: Add the cross-run fun-signal analysis function
In tools/soak/soak_runner.gd, after the existing _find_anomalies function, add:
# Cross-run fun-signal analysis (only over "skilled"-player reports — a dumb-AI# or invuln coverage run measures the AI, not the game, see the design doc's# "Key facts" section). Extends the same reports[] the anomaly pass already reads.func _analyze_fun_signals(reports: Array) -> Dictionary: var skilled: Array = [] for rep in reports: if String(rep.get("player", "dumb")) == "skilled": skilled.append(rep) if skilled.is_empty(): return {"note": "no skilled-player runs in this sweep — pass --player=skilled to get fun signals"}
var death_ticks: Array = [] var build_survival: Dictionary = {} # sorted-joined upgrades_applied string -> Array[ticks_run] var pre_boss_taken := 0.0 var boss_taken := 0.0 var upgrade_flags: Dictionary = {} # upgrade id -> {"dud": int, "overpowered": int, "total": int}
for rep in skilled: if bool(rep.game_over): death_ticks.append(int(rep.ticks_run)) var ids: Array = rep.upgrades_applied.duplicate() ids.sort() var build_key := ",".join(ids) if not build_survival.has(build_key): build_survival[build_key] = [] build_survival[build_key].append(int(rep.ticks_run)) for phase in rep.damage_taken_by_phase: var amt: float = rep.damage_taken_by_phase[phase] if int(phase) == Sim.PHASE_BOSS: boss_taken += amt else: pre_boss_taken += amt for entry in rep.upgrade_impacts: var uid := String(entry.id) if not upgrade_flags.has(uid): upgrade_flags[uid] = {"dud": 0, "overpowered": 0, "total": 0} upgrade_flags[uid].total += 1 if entry.flag == "dud": upgrade_flags[uid].dud += 1 elif entry.flag == "overpowered": upgrade_flags[uid].overpowered += 1
var anticlimax_ratio := pre_boss_taken / maxf(boss_taken, 1.0) var dud_rates := {} var overpowered_rates := {} for uid in upgrade_flags: var f: Dictionary = upgrade_flags[uid] var total: int = f.total if float(f.dud) / float(total) >= 0.9: dud_rates[uid] = f.dud / float(total) if float(f.overpowered) / float(total) >= 0.9: overpowered_rates[uid] = f.overpowered / float(total)
return { "skilled_runs": skilled.size(), "death_tick_distribution": death_ticks, "build_diversity": build_survival.size(), "builds_never_seen_surviving_full_run": build_survival.keys().filter( func(k): return build_survival[k].max() < skilled[0].max_ticks), "anticlimax_ratio_pre_boss_to_boss_damage_taken": anticlimax_ratio, "upgrade_dud_rate_ge_90pct": dud_rates, "upgrade_overpowered_rate_ge_90pct": overpowered_rates, }- Step 3: Wire the analysis into the written report + stdout summary
In _init(), after the existing var anomalies := _find_anomalies(reports) line, add:
var fun_signals := _analyze_fun_signals(reports)Change the JSON.stringify call to include it:
f.store_string(JSON.stringify({"reports": reports, "anomalies": anomalies, "fun_signals": fun_signals}, " "))After the existing for a in anomalies: print loop, add:
print("\n[soak] fun signals: %s" % JSON.stringify(fun_signals))- Step 4: Manual verification run
Run: godot --headless --path . -s res://tools/soak/soak_runner.gd -- --runs=2 --ticks=1200 --player=skilled --seed0=1000
Expected: exits 0 or 1 per existing convention (1 only on invariant failures/anomalies — a fun
signal by itself is not a failure), prints a [soak] fun signals: {...} line containing
skilled_runs: 2, and the written reports/soak-<unix>.json contains a top-level fun_signals
key. Read the file to confirm the shape matches Step 2’s returned Dictionary.
- Step 5: Full suite + count guard
Run: bash scripts/check-test-count.sh
Expected: exit 0 (this file has no GUT tests, so the count is unchanged from Task 3’s state —
confirms this task didn’t accidentally break anything reachable from main.gd, since nothing in
soak_runner.gd is).
- Step 6: Commit
git add tools/soak/soak_runner.gdgit commit -m "feat(soak): --player= flag + cross-run fun-signal analysis"Task 5: Overnight measurement run + first findings report
Section titled “Task 5: Overnight measurement run + first findings report”Files:
- Create:
reports/soak-<unix>.json(gitignored, per existingreports/convention — not committed) - Create: a findings summary as part of the overnight-swarm morning handoff report (NOT a plan
file — this belongs in
docs/overnight-swarm-report-2026-07-16.md, written per thebh-overnight-swarmskill’s own format, not duplicated here)
Interfaces:
-
Consumes: the finished tool from Tasks 1-4.
-
Produces: a large-scale
fun_signalsresult set, read by whichever swarm iteration handles Phase 3 (Guarded auto-tune) per the design doc’s Guardrails section. -
Step 1: Run a large sweep
Run: godot --headless --path . -s res://tools/soak/soak_runner.gd -- --runs=40 --ticks=36000 --ships=cobalt,obsidian --coop=solo --player=skilled --seed0=2000
This is 80 mortal runs (40 seeds x 2 ships) at 10 minutes of game-time each — expect real wall-clock time (headless soak is fast per-tick but this is a lot of ticks; if it runs long, that’s expected, not a hang). Exit 0/1 per existing convention.
- Step 2: Sanity-check the output
Read the written reports/soak-<unix>.json. Confirm fun_signals.skilled_runs == 80,
death_tick_distribution has up to 80 entries (fewer if some runs didn’t reach game_over by
max_ticks — that’s a legitimate “survived the whole window” outcome, not a bug), and
upgrade_dud_rate_ge_90pct/upgrade_overpowered_rate_ge_90pct are inspected by hand for
plausibility (e.g. a rate of 1.0 for an upgrade that only appeared once in the whole sweep is
noise, not signal — note this in the findings write-up if it comes up).
- Step 3: Write the findings into the morning handoff report
Following bh-overnight-swarm’s existing report format (docs/overnight-swarm-report-<date>.md):
summarize fun_signals in plain language (where mortal runs die, whether build diversity looks
healthy or dominated, the anticlimax ratio, which upgrades if any cross the 90% dud/overpowered
threshold). Per the design doc’s Guardrails: death-clustering/build-dominance/anticlimax findings
become "DECISION (Chris): ..." entries; a dud/overpowered upgrade crossing the 90% threshold is
a candidate for the Phase 3 guarded auto-tune procedure (own branch, single constant, ~15% cap,
re-measured before/after, never main) — do the tune as a follow-up iteration, not inside this
task.
- Step 4: Commit the report doc only (the JSON reports stay gitignored)
git add docs/overnight-swarm-report-2026-07-16.mdgit commit -m "docs(soak): first skilled-AI fun-signal findings (80-run sweep)"Self-review notes (completed during planning, not a separate pass)
Section titled “Self-review notes (completed during planning, not a separate pass)”- Spec coverage: all 4 architecture components from the design doc map to Tasks 1-4; the design’s “Execution flow” Phase 2 (Measure) maps to Task 5; Phase 3 (Guarded auto-tune) is deliberately NOT a task here — it’s a conditional follow-up procedure, fully specified in the design doc’s Guardrails section, executed only if Task 5’s findings actually cross a trigger threshold, by the overnight swarm orchestration directly (not a fixed code task with a predetermined outcome).
- Type consistency check:
SoakRun.auto: AutoPlayer(unchanged declared type) acceptsSkilledAutoPlayerpolymorphically (Task 2 declaresextends AutoPlayer) — verified this works instead of the design doc’s original “duplicate the tiny interface, no inheritance” framing, which turned out to fight GDScript’s static typing onSoakRun.autounnecessarily. Inheritance keepsAutoPlayeritself completely untouched (Task 2 only adds a new file), so the design intent (attract-mode/coverage soaks unaffected) is preserved — this is a refinement of the design’s implementation detail, not a change to its architecture or guardrails. - No placeholders: every step above has complete, file-and-line-grounded code — no TBD/TODO.