Phase 4a (diag): per-zone outlier attribution

The Phase 3-B sweep wrung out the global lever surface and named four
zones that read ~0% completion under every (elite-threshold, drift,
nick-floor, supply-burn) combo: crypt_valdris T1, forest_shadows T2,
manor_blackspire T3, abyss_portal T5. Each has a healthy sibling at
the same tier (goblin_warrens 44%, sunken_temple 13.5%, underforge
2.5%, dragons_lair 57.5%) so the gap is per-zone, not tier-wide.

Adds a structured per-fight trace hook (traceFightStruct + the
harnessFightTrace struct that mirrors the existing string trace) so
diagnostics can aggregate without parsing the formatted log line.
Mirror-format with traceFight; if a field is added, update both
paths.

TestExpeditionBalance_Phase4A_OutlierDiagnostic walks the four
outlier-vs-sibling pairs at 200 trials each on the Phase 3-A/3-B best
cell (e=23 d=1 burn=50) and reports per-monster appearances /
win-rate / avg HP loss / kill attribution + day-of-end histogram +
elite-vs-standard fight mix.

Findings:
  - crypt_valdris: dual-killer elite pool (Wight 99 kills, Flameskull
    68). Phase 2c left this zone untouched ("already dual-elite") but
    both elites are over-tier for T1.
  - forest_shadows: standard pool too lethal — Displacer Beast (53
    kills, 38% win standard) + Bandit Captain (48 kills, 57% win).
  - manor_blackspire: Wraith on the standard slot is dragging the
    floor (85 kills, 45hp loss per win). Vampire Spawn + Revenant
    elite pair is also rough.
  - abyss_portal: Nalfeshnee mis-classified standard (86 kills, 2.8%
    win at L17). Vrock at 79% win is borderline.

Phase 4-B applies per-zone roster tweaks (IsElite re-flag, drop a
deadly entry, soften a SpawnWeight) — no monster stat-block changes.
T3 may need a follow-up tier-wide pass since the sibling underforge
also sits at 2.5%; out of scope here.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-05-15 11:18:44 -07:00
parent 2d44c990f3
commit dcd9e4158f
2 changed files with 279 additions and 0 deletions

View File

@@ -504,6 +504,21 @@ func (h *expeditionHarness) runHarnessFight(zone ZoneDefinition, elite bool) Com
monster.Name, h.char.HPMax, nick, hpBeforeFight, result.PlayerEndHP,
enemy.Stats.AC, enemy.Stats.AttackBonus, outcome))
}
if h.traceFightStruct != nil {
h.traceFightStruct(harnessFightTrace{
Day: h.exp.CurrentDay,
Tier: int(zone.Tier),
Elite: elite,
MonsterName: monster.Name,
HPMax: h.char.HPMax,
Nick: nick,
HPPre: hpBeforeFight,
HPPost: result.PlayerEndHP,
EnemyAC: enemy.Stats.AC,
EnemyAtk: enemy.Stats.AttackBonus,
Won: result.PlayerWon,
})
}
return result
}
@@ -608,6 +623,30 @@ type expeditionHarness struct {
// monster, or the combat fold itself is driving deaths. Nil in
// production runs — has zero cost when unused.
traceFight func(line string)
// traceFightStruct, if non-nil, fires alongside traceFight with the
// same fight's data as a parsed struct. Lets aggregate diagnostics
// (e.g. Phase 4-A's per-monster attribution) skip the fragile parse
// of the formatted line. Retreat events are not surfaced here — the
// retreat trace is one-line only via traceFight. Nil in production.
traceFightStruct func(harnessFightTrace)
}
// harnessFightTrace is the structured per-fight record exposed via
// traceFightStruct. Mirrors the fields the formatted traceFight line
// already emits; if you add a field, update both paths so the human
// log and the structured aggregate stay in sync.
type harnessFightTrace struct {
Day int
Tier int
Elite bool
MonsterName string
HPMax int
Nick int
HPPre int
HPPost int
EnemyAC int
EnemyAtk int
Won bool
}
// harnessRNG is a thin wrapper around math/rand/v2 so the d20 helper