diff --git a/gogobee_expedition_difficulty.md b/gogobee_expedition_difficulty.md index df7f151..bb56ede 100644 --- a/gogobee_expedition_difficulty.md +++ b/gogobee_expedition_difficulty.md @@ -553,6 +553,90 @@ hump and is fine for "some death" tier shape. Remaining work (Phase 5-C+) is T3 roster polish + feywild T4-spread fix — separate session. +#### Phase 5-C — T3 polish + T4 spread fix (rosters only) + +Phase 5-B's exit named two follow-ups: T3 below band (manor 39 / +underforge 47 vs 55-75), and the T4 spread (feywild 59 vs underdark +88 = 30pp asymmetry). Phase 5-C re-runs the Phase 4-A trace on the +four affected zones at the shipped Phase 5-B cell +(`TestExpeditionBalance_Phase5C_OutlierDiagnostic`) and identifies +which monsters own the lethality budget per zone, then applies the +Phase 4-B toolset (IsElite toggle + SpawnWeight rebalance only — no +bestiary stat-block touches). + +Per-zone reads from the diagnostic (200 trials, L9/L13 Fighter, +e=23 d=1 burn=50): + + manor_blackspire (53% comp): standards all ≥99% win. Elite pool + is the lethality source — Vampire Spawn (72% win, 42 kills) and + Revenant (16% win, 31 kills at SW=1) split the kill budget. + Revenant can't drop below SW=1, and trimming Vampire Spawn + (first attempt: SW 3 → 2) backfired by shifting elite share onto + Revenant — kills jumped to 48 and completion fell to 44.5%. + + underforge (49.5% comp): Fire Elemental (57 kills, 61hp/win) + + Salamander (33 kills) own elite share. Helmed Horror was the + only 100%-win elite but sat at SW=1, providing almost no + dilution. + + feywild_crossing (54% comp): Fomorian alone (65 kills, 50% win, + SW=1) owns the elite kill budget — only 2 elites in the pool + (Night Hag SW=3 + Fomorian SW=1), so Fomorian still gets 25% of + elite picks despite the low weight. + + underdark (86% comp): Drow at SW=7 (1801/4164 standard rolls, + 100% win / 1.1hp loss) is a free-HP filler dominating the + standard pool. + +Roster changes shipped (`dnd_zone.go`): + + manor_blackspire — Banshee promoted to elite at SW=2 (was SW=3 + standard, 99.6% win, 11hp loss): adds a soft 4th elite slot, + dropping Revenant's elite share ~14% → 11% without changing + its presence. Standard floor collapses to Shadow + Poltergeist, + both ≥99% win at ≤5hp loss. + + underforge — Helmed Horror SW 1 → 3 (98%+ win, 15hp loss): three + roughly-equal elite slots (Salamander 3 + Fire Elemental 3 + + Helmed Horror 3), reducing Fire Elemental's elite share from + ~44% to ~33%. + + feywild_crossing — Green Hag promoted to elite at SW=2 (was SW=4 + standard, 98% win, 16hp loss): adds a softer 3rd elite slot, + diluting Fomorian's share, AND pulls a moderate-attrition fight + out of the standard pool. Standard floor becomes Redcap + + Will-o-Wisp + Quickling (all ≥99% win, ≤11hp loss). + + underdark (light trim per user's "lift trailers, don't nerf + leaders" stance) — Drow SW 7 → 5: standards shift toward Hook + Horror (17.8hp/win) and Drow Mage (16.4hp/win), still safe but + with real attrition. + +Phase 1 matrix after Phase 5-C (200 trials, Fighter @ centerline): + +``` +T1 88.5% spread 1.0 (crypt 89, goblin 88) ⬆ band 70-90 — IN BAND +T2 74.5% spread 15.0 (forest 82, sunken 67) ⬆ band 62-82 — IN BAND +T3 56.7% spread 0.5 (manor 57, underforge 56.5) ⬆ band 55-75 — IN BAND ⬆ +13.5pp +T4 77.0% spread 13.0 (feywild 70.5, underdark 83.5) ⬆ band 45-65 — OVER (matches 5-B target) +T5 58.0% spread 40.0 (abyss 38, dragons 78) ⬆ band 35-55 — borderline (abyss the residual) +``` + +T3 mean lifted +13.5pp; both zones land inside the band with a +0.5pp residual spread. T4 spread halved (25.5 → 13.0pp) by lifting +feywild +11.5pp and trimming underdark -4.5pp, matching the user's +chosen "lift mostly, trim lightly" direction. T1/T2/T5 untouched. + +**Test debt:** none — no production tests pinned the changed +SpawnWeight or IsElite values. `TestMonsterKillTags_GatesKnownMonsters` +checks `vampire_spawn` tags but is unaffected by weight. + +**Exit:** T1-T4 all in or above band; T5's abyss_portal (38%) is +the only remaining sub-band cell and is a tier-asymmetry residual, +not a per-zone outlier. Phase 5-D could revisit abyss specifically, +but it's optional — the user's "breezy with some death" target is +hit across the live tier ladder. + ### Phase 6 — optional MAD / second-order If post-Phase-3 the bands hold but feel wrong subjectively diff --git a/internal/plugin/dnd_zone.go b/internal/plugin/dnd_zone.go index 7d5a0c3..97a912b 100644 --- a/internal/plugin/dnd_zone.go +++ b/internal/plugin/dnd_zone.go @@ -433,9 +433,23 @@ func zoneManorBlackspire() ZoneDefinition { // becomes a 3-elite pool with balanced dilution, leaving // Shadow/Poltergeist/Banshee as a clean standard floor // (all ≥92% win in the trace). + // + // Phase 5-C: under shipped HP×1.5/+3 floor, manor still + // trailed at 53% (band 55-75). Trace named both Vampire + // Spawn (72% win, 42 kills) and Revenant (16% win, 31 + // kills) as elite-pool killers — Revenant the worse per + // appearance, but at SW=1 already. A first attempt + // trimmed Vampire Spawn SW 3 → 2; that backfired by + // shifting elite share onto Revenant (kills jumped to + // 48). Final move: keep the killers' weights, dilute + // the elite pool by promoting Banshee (99.6% win as + // standard, 11hp loss) to elite at SW=2 — drops + // Revenant's elite share ~14% → 11% without removing + // it from rotation. Standard floor collapses to + // Shadow + Poltergeist (both ≥99% win, ≤5hp loss). {BestiaryID: "shadow", SpawnWeight: 6}, {BestiaryID: "poltergeist", SpawnWeight: 5}, - {BestiaryID: "banshee", SpawnWeight: 3}, + {BestiaryID: "banshee", SpawnWeight: 2, IsElite: true}, {BestiaryID: "wraith", SpawnWeight: 3, IsElite: true}, {BestiaryID: "vampire_spawn", SpawnWeight: 3, IsElite: true}, {BestiaryID: "revenant", SpawnWeight: 1, IsElite: true}, @@ -480,12 +494,21 @@ func zoneUnderforge() ZoneDefinition { MinRooms: 7, MaxRooms: 9, Enemies: []ZoneEnemy{ + // Phase 5-C: underforge trailed at 49.5% (band 55-75) + // under the shipped HP×1.5/+3 floor. Trace named Fire + // Elemental (57 kills, 61hp loss/win) as the dominant + // elite killer with Salamander (33 kills) second. + // Helmed Horror was the only 100%-win elite but sat at + // SW=1, contributing almost no dilution. Lifted Helmed + // Horror SW 1 → 3 so the elite pool runs three roughly- + // equal slots and Fire Elemental's share drops from + // ~44% to ~33% of elite picks. {BestiaryID: "magmin", SpawnWeight: 6}, {BestiaryID: "azer", SpawnWeight: 5}, {BestiaryID: "flameskull", SpawnWeight: 4}, {BestiaryID: "salamander", SpawnWeight: 3, IsElite: true}, {BestiaryID: "fire_elemental", SpawnWeight: 3, IsElite: true}, - {BestiaryID: "helmed_horror", SpawnWeight: 1, IsElite: true}, + {BestiaryID: "helmed_horror", SpawnWeight: 3, IsElite: true}, }, Boss: ZoneBoss{ BestiaryID: "boss_emberlord_thyrak", @@ -528,7 +551,17 @@ func zoneUnderdark() ZoneDefinition { MinRooms: 8, MaxRooms: 10, Enemies: []ZoneEnemy{ - {BestiaryID: "drow", SpawnWeight: 7}, + // Phase 5-C: underdark ran 88% (band 45-65, way over) + // at the T4 centerline — its sibling feywild sat at + // 54%, a 30pp asymmetry. Per [[feedback_difficulty_target]] + // the bulk of Phase 5-C lifts feywild rather than nerfing + // the leader; this is the "light underdark trim" piece. + // Drow at SW=7 owned nearly half of all standard rolls + // (1801/4164) at 100% win / 1.1hp loss — a free-HP + // filler. Trimmed SW 7 → 5 so the standard pool shifts + // toward Hook Horror (17.8hp/win) and Drow Mage + // (16.4hp/win) — still safe but with real attrition. + {BestiaryID: "drow", SpawnWeight: 5}, {BestiaryID: "drow_elite_warrior", SpawnWeight: 4, IsElite: true}, {BestiaryID: "drow_mage", SpawnWeight: 3}, {BestiaryID: "mind_flayer", SpawnWeight: 2}, @@ -575,10 +608,22 @@ func zoneFeywildCrossing() ZoneDefinition { MinRooms: 8, MaxRooms: 10, Enemies: []ZoneEnemy{ + // Phase 5-C: feywild trailed at 54% (band 45-65, but + // the design goal was to close the 30pp gap with its + // underdark sibling at 88%). Trace named Fomorian as + // the dominant killer (65 kills, 50% win, SW=1) — a + // 2-elite pool with Night Hag at SW=3 meant Fomorian + // owned ~25% of elite picks despite its low weight, + // and that bracket is where the deaths came from. + // Promoted Green Hag to elite (SW=2): adds a softer + // 98%-win elite slot to dilute Fomorian's share, and + // pulls a 16hp/win standard out of the standard pool + // so the standard floor collapses to Redcap + Will-o- + // Wisp + Quickling — all ≥99% win, ≤11hp loss. {BestiaryID: "redcap", SpawnWeight: 6}, {BestiaryID: "will_o_wisp", SpawnWeight: 5}, {BestiaryID: "quickling", SpawnWeight: 5}, - {BestiaryID: "green_hag", SpawnWeight: 4}, + {BestiaryID: "green_hag", SpawnWeight: 2, IsElite: true}, {BestiaryID: "night_hag", SpawnWeight: 3, IsElite: true}, {BestiaryID: "fomorian", SpawnWeight: 1, IsElite: true}, }, diff --git a/internal/plugin/expedition_balance_test.go b/internal/plugin/expedition_balance_test.go index c020741..00194a6 100644 --- a/internal/plugin/expedition_balance_test.go +++ b/internal/plugin/expedition_balance_test.go @@ -1417,6 +1417,204 @@ func zoneSeedSalt(id ZoneID) uint64 { return s } +// TestExpeditionBalance_Phase5C_OutlierDiagnostic is the per-zone +// trace pass for Phase 5-C — the second roster-polish round. Phase +// 5-B's shipped baseline (HP×1.5, +3 player floor, e=23, d=1, burn=50) +// landed T1/T2/T4/T5 at or above band, but left T3 at 43% mean +// (manor 39 / underforge 47 vs band 55-75) and produced a 30pp +// T4 spread (feywild 59 vs underdark 88) with feywild trailing. +// +// This test re-runs the Phase 4-A trace on the four affected zones +// (manor + underforge as both-trail, feywild as lift-target, underdark +// as healthy reference / candidate for one mild elite demotion). Same +// monster-attribution, day-of-end, and elite/std-mix aggregates as +// Phase 4-A; the difference is the candidate zone set and that all +// four are reported side-by-side rather than as outlier+sibling pairs. +// +// Constraint per the plan doc and Phase 4-B's commit: do not touch +// monster stat blocks. Tools allowed are IsElite toggle, SpawnWeight +// rebalance, and zone-scoped boss tuning. +// +// Diagnostic-only — no gates. -short skips. +func TestExpeditionBalance_Phase5C_OutlierDiagnostic(t *testing.T) { + if testing.Short() { + t.Skip("phase 5-C diagnostic walks 4 zones × 200 trials; -short skips it") + } + + const trialsPerZone = 200 + const baseSeed uint64 = 0xF50C5D + // Phase 5-B shipped cell. HP×1.5 and the +3 combat floor are + // already wired into the harness via the live constants + // (phase5BHPMult, applyPhase5BPlayerFloor); only the three + // expedition-side overrides need restating. + const eliteThreshold = 23 + const driftBase = 1 + const supplyBurnPct = 50 + + zones := []struct { + id ZoneID + tier ZoneTier + role string + }{ + {ZoneManorBlackspire, ZoneTierJourneyman, "TRAIL-T3"}, + {ZoneUnderforge, ZoneTierJourneyman, "TRAIL-T3"}, + {ZoneFeywildCrossing, ZoneTierVeteran, "TRAIL-T4"}, + {ZoneUnderdark, ZoneTierVeteran, "HEALTHY-T4"}, + } + + type monsterStat struct { + appearances int + standardCount int + eliteCount int + wins int + hpLossOnWin int + killAttributed int + } + type zoneAgg struct { + profile expeditionBalanceProfile + completions int + deaths int + starves int + monsters map[string]*monsterStat + dayHist [16]int + eliteFights int + stdFights int + 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("─── %-11s %-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) + 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) + } + 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 _, z := range zones { + level := phase1TierCenterline[z.tier] + t.Logf("═══ %-11s %s (L%d Fighter, e=%d d=%d burn=%d, %d trials) ═══", + z.role, z.id, level, + eliteThreshold, driftBase, supplyBurnPct, trialsPerZone) + + profile := expeditionBalanceProfile{ + ZoneID: z.id, + Class: ClassFighter, + Level: level, + Supplies: makeSupplies(z.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(z.tier)*131 + zoneSeedSalt(z.id)), + 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++ + 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]++ + } + + report(z.role, agg) + } +} + // 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.