Skip to content

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.

  • /sim (sim/sim.gd and friends) is pure logic — no Node/render/Input/Engine/Time APIs. The one change in this plan that touches sim/sim.gd (Task 1) is a single additive, guarded stats_sink call — same pattern as the two hooks that already exist there.
  • Every new/changed .gd file 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_sink calls — provably trace-invisible, see Task 1’s reuse of the existing test_attached_sink_does_not_change_the_trace pattern) or (b) dev-only tooling under ai/skilled_auto_player.gd / tools/soak/ that main.gd and production code never reach. Run tests/test_determinism_survival.gd after 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, use SimContentFixture.db() for content, assert_eq/assert_true/assert_gt/assert_lt style matching tests/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, currently sim/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: Object and Sim.spawn_phase: int already exist.

  • Produces: SoakStats.damage_taken_by_phase: Dictionary (phase int -> total float), which Task 3 reads to extend SoakRun’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 taken

After 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
Terminal window
git add sim/sim.gd tools/soak/soak_stats.gd tests/test_soak_stats.gd
git 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 in ai/auto_player.gd — do not modify that file, SkilledAutoPlayer only extends it.

  • Produces: class_name SkilledAutoPlayer extends AutoPlayer, overriding compute_move_for(sim: Sim, pilot: PlayerState) -> Vector2 and pick_upgrade(choice_ids: Array) -> String. Task 3’s SoakRun instantiates this when configured, assigning it to the existing var auto: AutoPlayer field (valid because SkilledAutoPlayer IS-AN AutoPlayer).

  • 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 SkilledAutoPlayer
extends 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
Terminal window
git add ai/skilled_auto_player.gd tests/test_skilled_auto_player.gd
git 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}, flag one of "", "dud", "overpowered"). Task 4’s soak_runner.gd reads 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_impacts to SoakStats

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 = 180
const IMPACT_DUD_THRESHOLD: float = 0.15
const IMPACT_OVERPOWERED_THRESHOLD: float = 1.5
const 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 = 0
var _last_dealt_total: float = 0.0
var _last_taken_total: float = 0.0
var _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_pending

In 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
Terminal window
git add tools/soak/soak_run.gd tools/soak/soak_stats.gd tests/test_soak_run.gd
git 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 existing ticks_run, max_ticks, game_over, upgrades_applied.

  • Produces: a fun_signals section 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
Terminal window
git add tools/soak/soak_runner.gd
git 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 existing reports/ 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 the bh-overnight-swarm skill’s own format, not duplicated here)

Interfaces:

  • Consumes: the finished tool from Tasks 1-4.

  • Produces: a large-scale fun_signals result 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)
Terminal window
git add docs/overnight-swarm-report-2026-07-16.md
git 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) accepts SkilledAutoPlayer polymorphically (Task 2 declares extends 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 on SoakRun.auto unnecessarily. Inheritance keeps AutoPlayer itself 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.