From dcd9e4158fbbd7862b154586e0ae11136387f941 Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Fri, 15 May 2026 11:18:44 -0700 Subject: [PATCH] Phase 4a (diag): per-zone outlier attribution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- internal/plugin/expedition_balance.go | 39 ++++ internal/plugin/expedition_balance_test.go | 240 +++++++++++++++++++++ 2 files changed, 279 insertions(+) diff --git a/internal/plugin/expedition_balance.go b/internal/plugin/expedition_balance.go index 7ea231d..723e248 100644 --- a/internal/plugin/expedition_balance.go +++ b/internal/plugin/expedition_balance.go @@ -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 diff --git a/internal/plugin/expedition_balance_test.go b/internal/plugin/expedition_balance_test.go index 94c8a67..5412cef 100644 --- a/internal/plugin/expedition_balance_test.go +++ b/internal/plugin/expedition_balance_test.go @@ -855,6 +855,246 @@ func TestExpeditionBalance_Phase3B_NickSupplySweep(t *testing.T) { } } +// TestExpeditionBalance_Phase4A_OutlierDiagnostic is the per-zone +// diagnostic the plan doc's Phase 4 calls for. Phase 3-B's global +// sweep wrung out the matrix-wide knobs and named four outlier zones +// that still read ~0% completion at 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, sunken_temple, +// underforge, dragons_lair) that completes at the matrix mean. +// +// This test holds the Phase 3-A/3-B best cell (e=23, d=1, burn=50, +// nick-floor=tier — recall nick-floor was inert) and pairs each +// outlier with its sibling. For every (zone, trial) it captures the +// structured fight trace via traceFightStruct and aggregates: +// +// - Per-monster: appearances, win-rate, avg HP-loss-on-win, +// attribution of the final losing fight ("kills"). Surfaces the +// "one monster owns the lethality budget" pattern that single- +// boss-pool elite gates already mitigated for the live healthy +// zones (Phase 2c). +// - Day-of-end histogram (1..14): clusters early-day deaths +// (chained-interrupt collapse) vs late-day starve-outs vs +// survives-to-cap. +// - Elite vs Standard fight mix: confirms whether the outlier is +// drawing disproportionate elites (roster weight problem) or +// hitting normal cadence and losing baseline fights (stat-floor +// problem). +// +// Constraint per the plan doc: do not touch monster stat blocks +// (those are bestiary, shared across systems). The output of this +// test names the per-zone tool to reach for — SpawnWeight rebalance, +// elite-flag toggle, supply-DC pull, or boss tuning (boss is +// zone-scoped, not bestiary-scoped, so it's in-bounds). +// +// Diagnostic-only — no gates. -short skips. +func TestExpeditionBalance_Phase4A_OutlierDiagnostic(t *testing.T) { + if testing.Short() { + t.Skip("phase 4-A diagnostic walks 8 zones × 200 trials; -short skips it") + } + + const trialsPerZone = 200 + const baseSeed uint64 = 0xF40A4D + // Phase 3-A/3-B best cell — same as the Phase 3-B nick=tier b=50 row. + const eliteThreshold = 23 + const driftBase = 1 + const supplyBurnPct = 50 + + type pair struct { + outlier ZoneID + sibling ZoneID + tier ZoneTier + } + pairs := []pair{ + {ZoneCryptValdris, ZoneGoblinWarrens, ZoneTierBeginner}, + {ZoneForestShadows, ZoneSunkenTemple, ZoneTierApprentice}, + {ZoneManorBlackspire, ZoneUnderforge, ZoneTierJourneyman}, + {ZoneAbyssPortal, ZoneDragonsLair, ZoneTierLegendary}, + } + + type monsterStat struct { + appearances int + standardCount int + eliteCount int + wins int + hpLossOnWin int // sum, divided by wins at report time + killAttributed int // last fight of a died_combat trial + } + type zoneAgg struct { + profile expeditionBalanceProfile + completions int + deaths int + starves int + monsters map[string]*monsterStat + dayHist [16]int // 1..14 + overflow bucket; index 0 unused + eliteFights int + stdFights int + // for the trial currently in flight (mutated during traceFightStruct) + lastFight harnessFightTrace + } + + report := func(label string, agg *zoneAgg) { + total := trialsPerZone + compPct := float64(agg.completions) / float64(total) * 100 + deathPct := float64(agg.deaths) / float64(total) * 100 + starvePct := float64(agg.starves) / float64(total) * 100 + t.Logf("─── %-7s %-18s L%-2d comp=%5.1f%% death=%5.1f%% starve=%5.1f%% elite_fights=%d std_fights=%d ───", + label, agg.profile.ZoneID, + agg.profile.Level, + compPct, deathPct, starvePct, + agg.eliteFights, agg.stdFights) + + // Per-monster — sort by appearances desc for readability. + names := make([]string, 0, len(agg.monsters)) + for n := range agg.monsters { + names = append(names, n) + } + sort.Slice(names, func(i, j int) bool { + return agg.monsters[names[i]].appearances > agg.monsters[names[j]].appearances + }) + for _, n := range names { + m := agg.monsters[n] + winPct := 0.0 + if m.appearances > 0 { + winPct = float64(m.wins) / float64(m.appearances) * 100 + } + avgLoss := 0.0 + if m.wins > 0 { + avgLoss = float64(m.hpLossOnWin) / float64(m.wins) + } + t.Logf(" MON %-22s appear=%4d (std=%-3d elite=%-3d) win=%5.1f%% avg_hp_loss_on_win=%5.1f kills=%d", + n, m.appearances, m.standardCount, m.eliteCount, + winPct, avgLoss, m.killAttributed) + } + + // Day-of-end histogram — squashes 14 into "14+" since the cap + // terminates a trial at exactly day 14. + bins := make([]string, 0, 14) + for d := 1; d <= 14; d++ { + if agg.dayHist[d] > 0 { + bins = append(bins, fmt.Sprintf("d%d=%d", d, agg.dayHist[d])) + } + } + t.Logf(" END-DAYS %s", joinZones(bins)) + } + + for _, p := range pairs { + level := phase1TierCenterline[p.tier] + t.Logf("═══ T%d outlier=%s sibling=%s (L%d Fighter, e=%d d=%d burn=%d, %d trials each) ═══", + p.tier, p.outlier, p.sibling, level, + eliteThreshold, driftBase, supplyBurnPct, trialsPerZone) + + for _, zid := range []ZoneID{p.outlier, p.sibling} { + profile := expeditionBalanceProfile{ + ZoneID: zid, + Class: ClassFighter, + Level: level, + Supplies: makeSupplies(p.tier, SupplyPurchase{StandardPacks: 3}), + CampType: CampTypeStandard, + EliteInterruptThresholdOverride: eliteThreshold, + ThreatDriftBaseOverride: driftBase, + SupplyBurnRatePctOverride: supplyBurnPct, + } + agg := &zoneAgg{ + profile: profile, + monsters: map[string]*monsterStat{}, + } + + for trial := 0; trial < trialsPerZone; trial++ { + exp := newHarnessExpedition(profile) + char := buildHarnessCharacter(classBalanceProfile{ + Class: profile.Class, + Level: profile.Level, + }) + h := &expeditionHarness{ + exp: exp, + char: char, + rng: newHarnessRNG(baseSeed + uint64(trial) + uint64(p.tier)*131 + zoneSeedSalt(zid)), + rollsPerDay: harnessHarvestRollsPerDay, + eliteInterruptThresholdOverride: eliteThreshold, + threatDriftBaseOverride: driftBase, + supplyBurnRatePctOverride: supplyBurnPct, + traceFightStruct: func(ft harnessFightTrace) { + m, ok := agg.monsters[ft.MonsterName] + if !ok { + m = &monsterStat{} + agg.monsters[ft.MonsterName] = m + } + m.appearances++ + if ft.Elite { + m.eliteCount++ + agg.eliteFights++ + } else { + m.standardCount++ + agg.stdFights++ + } + if ft.Won { + m.wins++ + loss := ft.HPPre - ft.HPPost + if loss < 0 { + loss = 0 + } + m.hpLossOnWin += loss + } + agg.lastFight = ft + }, + } + var trialEnd expeditionTrialResult + for { + res := h.advanceExpeditionOneDay() + if res.EndedReason != "" { + trialEnd = res + break + } + } + switch { + case trialEnd.Completed: + agg.completions++ + case trialEnd.Died: + agg.deaths++ + // Attribute the kill to the last fight's monster + // (the one whose !PlayerWon return drove the + // terminate call). Sound because the day loop + // terminates immediately on combat loss. + if m, ok := agg.monsters[agg.lastFight.MonsterName]; ok && !agg.lastFight.Won { + m.killAttributed++ + } + case trialEnd.StarvedOut: + agg.starves++ + } + d := trialEnd.DaysElapsed + if d < 1 { + d = 1 + } + if d > 14 { + d = 14 + } + agg.dayHist[d]++ + } + + label := "OUTLIER" + if zid == p.sibling { + label = "SIBLING" + } + report(label, agg) + } + } +} + +// zoneSeedSalt produces a stable per-zone seed offset so each +// (outlier, sibling) draws from a distinct RNG stream without +// hand-numbering. fnv-like fold over the bytes; small footprint, no +// import needed. +func zoneSeedSalt(id ZoneID) uint64 { + var s uint64 = 1469598103934665603 + for i := 0; i < len(id); i++ { + s ^= uint64(id[i]) + s *= 1099511628211 + } + return s +} + // joinZones is a tiny helper kept local to the test file so the // per-tier log line reads in one logical chunk without pulling in // strings.Join's import for production code.