Files
gogobee/internal/plugin/expedition_balance_test.go
prosolis 25ea23f9d9 Phase 5C (rosters): T3 polish + feywild T4-spread fix
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 under the shipped Phase 5-B cell and applies
roster knobs only (IsElite + SpawnWeight; no bestiary stat-block
touches).

TestExpeditionBalance_Phase5C_OutlierDiagnostic names the killer
per zone:

  manor_blackspire (53%): Vampire Spawn (72% win, 42 kills) +
    Revenant (16% win, 31 kills at SW=1, can't go lower). First
    attempt trimmed VS SW 3 -> 2 and backfired by concentrating
    elite share on Revenant (kills jumped to 48); reverted.
  underforge (49.5%): Fire Elemental (57 kills, 61hp/win) +
    Salamander (33 kills) carry the lethality; Helmed Horror only
    100%-win elite but SW=1, no dilution effect.
  feywild_crossing (54%): only 2 elites in the pool; Fomorian
    (50% win, SW=1) still pulls 25% of elite picks and owns 65
    kills.
  underdark (86%): Drow at SW=7 fills nearly half of standard
    rolls at 100% win / 1.1hp loss -- free-HP filler.

Roster changes:

  manor    Banshee promoted to elite SW=2 (was standard SW=3,
           99.6% win). Soft 4th elite slot dilutes Revenant share
           ~14% -> ~11%. Standards collapse to Shadow+Poltergeist
           (>=99% win).

  underforge  Helmed Horror SW 1 -> 3. Three-way elite pool drops
              Fire Elemental's share from ~44% to ~33%.

  feywild  Green Hag promoted to elite SW=2 (was standard SW=4).
           Adds soft 3rd elite + removes a 16hp/win standard.
           Standards become Redcap+Will-o-Wisp+Quickling.

  underdark  Drow SW 7 -> 5 (light trim per the user's "lift
             trailers, don't nerf leaders" stance). Standards
             shift toward Hook Horror / Drow Mage.

Phase 1 matrix after Phase 5-C (200 trials, Fighter @ centerline):

  T1  88.5%  spread  1.0   (in band 70-90)
  T2  74.5%  spread 15.0   (in band 62-82)
  T3  56.7%  spread  0.5   (in band 55-75)  +13.5pp
  T4  77.0%  spread 13.0   (over band 45-65, matches 5-B target;
                            spread halved from 25.5)
  T5  58.0%  spread 40.0   (abyss_portal 38% is the residual)

T3 mean lifted +13.5pp; both zones inside band with 0.5pp residual
spread. T4 spread halved (25.5 -> 13.0pp) by lifting feywild
+11.5pp and trimming underdark -4.5pp. T1/T2/T5 untouched.

No test debt -- no production tests pin SpawnWeight or IsElite on
the changed entries (TestMonsterKillTags_GatesKnownMonsters checks
vampire_spawn tags only, unaffected). -short suite green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 12:24:03 -07:00

1631 lines
57 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package plugin
import (
"fmt"
"math"
"sort"
"testing"
)
// TestExpeditionBalance_Phase0_Spike is the sanity check the
// expedition-difficulty plan doc's Phase 0 calls for: one cell, 100
// trials, sensible numbers, fully deterministic from the seed.
//
// The cell — T2 zone (Crypt Valdris) × L5 Fighter — was chosen
// because it's the smallest cell that actually exercises every part
// of the seam: tier scaling raises monster AC/atk, supply burn lands
// at 1.5×, the player is built off the class-balance loadout ladder
// at the T2/+1 step, and the night phase has roster entries to draw
// from. If the seam works here, Phase 1's full-matrix expansion is
// mostly typing.
//
// Pass criteria are intentionally loose — we are not asserting a
// target band yet (Phase 2 does that), only that nothing is wired up
// so badly the result is degenerate (0%/100%/NaN/zero days). Anything
// in (0..100%) completion at this cell is a green Phase 0.
func TestExpeditionBalance_Phase0_Spike(t *testing.T) {
const trials = 100
profile := expeditionBalanceProfile{
ZoneID: ZoneCryptValdris,
Class: ClassFighter,
Level: 5,
Supplies: makeSupplies(ZoneTierApprentice, SupplyPurchase{
StandardPacks: 3,
DeluxePacks: 0,
}),
CampType: CampTypeStandard,
}
res := runExpeditionBalanceCell(profile, trials, 0xC0FFEE)
t.Logf("cell: %s L%d %s — completions=%d/%d (%.1f%%), deaths=%d, starved=%d, "+
"median_days=%d, median_threat=%d, avg_encounters=%.1f, avg_hp_remaining=%.1f%%",
profile.ZoneID, profile.Level, profile.Class,
res.Completions, res.Trials, res.CompletionRate()*100,
res.Deaths, res.StarvedOuts,
res.MedianDays, res.MedianThreatEnd,
res.AvgEncounters, res.AvgHPRemainingPct*100,
)
// Degenerate-outcome gates. These are harness-broken sentinels,
// not difficulty assertions — Phase 2 layers the real band on
// top of this same test.
if res.Trials != trials {
t.Fatalf("trial count mismatch: got %d, want %d", res.Trials, trials)
}
if res.Completions == 0 {
t.Errorf("zero completions in %d trials at T2/L5 Fighter — the spike cell should not be unwinnable", trials)
}
// Phase 5-B player floor lifted the at-tier completion rate
// substantially; 100%% completion at the spike cell is now the
// expected "fairly breezy" outcome, not a harness-broken signal.
// (Cells that 0% out remain a harness-broken signal — checked
// above.)
_ = trials
if res.MedianDays == 0 {
t.Fatalf("median days == 0; day loop never advanced")
}
if res.MedianDays > harnessMaxDays {
t.Fatalf("median days %d > cap %d; termination wiring broken", res.MedianDays, harnessMaxDays)
}
}
// TestExpeditionBalance_Phase0_SeedSpread confirms the RNG seam is
// actually wired — different seeds produce different trial outcomes
// across a small sample. Full byte-for-byte reproducibility under
// the same seed is *not* asserted at Phase 0: a couple of production
// helpers we lean on (surpriseRoundNick, pickWanderingMonster) draw
// from the package-global math/rand/v2, same as the class-balance
// harness. Phase 1 lifts those to seeded variants if matrix
// reproducibility becomes a real requirement; until then, "seeds
// differentiate" is the contract we can honestly hold.
func TestExpeditionBalance_Phase0_SeedSpread(t *testing.T) {
profile := expeditionBalanceProfile{
ZoneID: ZoneCryptValdris,
Class: ClassFighter,
Level: 5,
Supplies: makeSupplies(ZoneTierApprentice, SupplyPurchase{
StandardPacks: 3,
}),
CampType: CampTypeStandard,
}
const seedA uint64 = 0xDEADBEEF
const seedB uint64 = 0xFEEDFACE
a := runExpeditionBalanceTrial(profile, seedA)
b := runExpeditionBalanceTrial(profile, seedB)
if a == b {
t.Fatalf("two distinct seeds produced byte-identical trials — RNG seam may not be wired:\n a = %+v\n b = %+v", a, b)
}
}
// phase1TierCenterline maps each tier to its centerline player level.
// Originally the design doc's median per tier; bumped where the
// gear-ladder boundaries (gearTier in dnd_class_balance.go: 5/9/13/17)
// pushed the median into a gear bracket *below* the zone's tier. The
// harness's classLoadout is shared with the class-balance pass, and
// retuning its boundaries would shift class-balance numbers too, so the
// adjustment lands here instead: pick the lowest level inside each
// tier's design-doc level range that resolves to gearTier == tier.
//
// T1 (L1-4) → 3 gearTier 1 = mundane ✓
// T2 (L3-7) → 5 gearTier 2 = +1 weapon/armor ✓
// T3 (L5-10) → 9 gearTier 3 = +2 (was L8 → +1)
// T4 (L7-15) → 13 gearTier 4 = +3 (was L11 → +2)
// T5 (L10-20) → 17 gearTier 5 = +3 (was L15 → +3, but bumped
// for consistency with the rule, no stat impact)
//
// All centerlines stay inside their design-doc range. One level per
// tier keeps the matrix at zones × 1, not zones × range. Phase 4 may
// widen this if a tier band looks level-sensitive.
var phase1TierCenterline = map[ZoneTier]int{
ZoneTierBeginner: 3,
ZoneTierApprentice: 5,
ZoneTierJourneyman: 9,
ZoneTierVeteran: 13,
ZoneTierLegendary: 17,
}
// TestExpeditionBalance_Phase1_FullMatrix is the Phase 1 baseline-
// measurement test: every registered zone × its tier centerline level,
// 200 trials/cell, class fixed to Fighter so the cell-to-cell delta
// isolates zone difficulty (class parity is the class-balance pass's
// job, not this one).
//
// Per the plan doc, Phase 1's gate is *pathology-only*: no zone at T1
// reads 0% completion, no zone at T5 reads 100% completion, no NaN /
// zero-day cells. The real target-band assertion lands in Phase 2 after
// the global levers have been tuned to centerline. Until then the
// matrix's job is to *log numbers we can read*, so the per-cell line
// and per-tier mean+spread go through t.Log for the commit diff.
func TestExpeditionBalance_Phase1_FullMatrix(t *testing.T) {
if testing.Short() {
t.Skip("phase 1 matrix is heavy; -short skips it")
}
const trialsPerCell = 200
const baseSeed uint64 = 0xE0FFEE1
// Walk the zone registry in declared order (matches zoneOrder so
// the log table is stable across runs / commits).
type cellRow struct {
zone ZoneDefinition
result expeditionBalanceResult
}
rows := make([]cellRow, 0, len(zoneOrder))
for i, id := range zoneOrder {
zone, ok := getZone(id)
if !ok {
t.Fatalf("zoneOrder[%d]=%q not in registry", i, id)
}
level, ok := phase1TierCenterline[zone.Tier]
if !ok {
t.Fatalf("zone %q has tier %d with no phase1 centerline mapping", id, zone.Tier)
}
profile := expeditionBalanceProfile{
ZoneID: id,
Class: ClassFighter,
Level: level,
Supplies: makeSupplies(zone.Tier, SupplyPurchase{StandardPacks: 3}),
CampType: CampTypeStandard,
}
// Per-cell seed offset keeps cells independent — same seed
// across cells would correlate their RNG streams.
res := runExpeditionBalanceCell(profile, trialsPerCell, baseSeed+uint64(i)*1_000_003)
rows = append(rows, cellRow{zone: zone, result: res})
}
// Per-cell log. The format is grep-able: leading "CELL" tag, fixed
// columns. Future me will diff this between commits to see what
// Phase 2's lever moves actually did.
t.Logf("phase1 matrix — %d zones × %d trials, Fighter @ tier-centerline level", len(rows), trialsPerCell)
t.Logf("CELL %-18s %-3s %-3s %-12s %-12s %-13s %-11s %-13s %-9s %-12s",
"zone", "tier", "lvl", "comp%", "death%", "starve%", "med_days", "med_threat", "encs", "hp_left%")
for _, row := range rows {
r := row.result
t.Logf("CELL %-18s T%d L%-2d comp=%5.1f%% death=%5.1f%% starve=%5.1f%% med_days=%2d med_threat=%3d encs=%4.1f hp_left=%5.1f%%",
row.zone.ID, row.zone.Tier, r.Profile.Level,
r.CompletionRate()*100,
r.DeathRate()*100,
float64(r.StarvedOuts)/float64(r.Trials)*100,
r.MedianDays, r.MedianThreatEnd,
r.AvgEncounters, r.AvgHPRemainingPct*100,
)
}
// Per-tier diagnostic: mean completion% across the zones in the
// tier, and the spread (max min). Mirrors the class-balance
// per-(level,tier) spread diagnostic. A tight spread means the
// global lever pass alone can drag the tier onto target; a wide
// spread is a signal that Phase 3 per-zone outlier work is
// unavoidable.
tierRows := map[ZoneTier][]cellRow{}
for _, row := range rows {
tierRows[row.zone.Tier] = append(tierRows[row.zone.Tier], row)
}
tiers := []ZoneTier{
ZoneTierBeginner, ZoneTierApprentice, ZoneTierJourneyman,
ZoneTierVeteran, ZoneTierLegendary,
}
for _, tier := range tiers {
group := tierRows[tier]
if len(group) == 0 {
continue
}
var sum, lo, hi float64
lo = math.Inf(1)
hi = math.Inf(-1)
zoneNames := make([]string, 0, len(group))
for _, row := range group {
c := row.result.CompletionRate() * 100
sum += c
if c < lo {
lo = c
}
if c > hi {
hi = c
}
zoneNames = append(zoneNames, fmt.Sprintf("%s=%.1f%%", row.zone.ID, c))
}
sort.Strings(zoneNames)
mean := sum / float64(len(group))
t.Logf("TIER T%d n=%d mean_comp=%5.1f%% spread=%5.1f pp [%s]",
tier, len(group), mean, hi-lo, joinZones(zoneNames))
}
// Gates split into two buckets:
//
// *Wiring* pathologies (zero-day loop, days > cap) — fatal. These
// would mean the harness itself is broken and the matrix numbers
// above are noise.
//
// *Difficulty* sentinels (0% at T1, 100% at T5) — logged as WARN,
// not fatal. Phase 1's contract is "log a baseline"; the band
// assertion is Phase 2's job once the global levers have been
// tuned to land within ±10pp of target. Promoting these to
// t.Errorf here would turn Phase 1 into a tuning gate before
// Phase 2 has a chance to move the levers, which the plan doc
// explicitly defers.
for _, row := range rows {
r := row.result
if r.MedianDays == 0 {
t.Errorf("%s: median days == 0; day loop never advanced", row.zone.ID)
}
if r.MedianDays > harnessMaxDays {
t.Errorf("%s: median days %d > cap %d; termination wiring broken",
row.zone.ID, r.MedianDays, harnessMaxDays)
}
if row.zone.Tier == ZoneTierBeginner && r.Completions == 0 {
t.Logf("WARN %s (T1): 0%% completions in %d trials — Phase 2 should lift this",
row.zone.ID, r.Trials)
}
if row.zone.Tier == ZoneTierLegendary && r.Completions == r.Trials {
t.Logf("WARN %s (T5): 100%% completions in %d trials — Phase 2 should pressure this",
row.zone.ID, r.Trials)
}
}
}
// TestExpeditionBalance_Phase2_CadenceCalibration sweeps the harness's
// daytime combat-interrupt cadence across {1,2,3,4} rolls/day and logs
// the full matrix per cadence. Diagnostic-only — no gates beyond the
// Phase 1 wiring pathologies.
//
// Why this exists: Phase 1's baseline came back uniformly 0% / 100%
// death at every cell, and the commit message named cadence as the
// suspected lever. We can't compare against a live trace
// (prod is too low-traffic; the corpus is one in-flight expedition
// with zero interrupt rows), so the cadence constant is itself a
// tunable. This test surfaces the curve so Phase 2's global-lever
// pass starts from a cadence where the matrix has signal — i.e. where
// T1 is roughly in the 7090% band without flooring T5 to 100%.
//
// Cell shape mirrors Phase 1: every registered zone × its tier-
// centerline level, Fighter, 200 trials. The only thing that varies
// across runs is HarvestRollsPerDay.
func TestExpeditionBalance_Phase2_CadenceCalibration(t *testing.T) {
if testing.Short() {
t.Skip("phase 2 cadence sweep is heavy; -short skips it")
}
const trialsPerCell = 200
const baseSeed uint64 = 0xCAFEC1DE
cadences := []int{1, 2, 3, 4}
t.Logf("phase2 cadence calibration — %d zones × %d cadences × %d trials, Fighter @ tier-centerline level",
len(zoneOrder), len(cadences), trialsPerCell)
for _, rolls := range cadences {
// Per-tier aggregation for the headline view.
type tierStat struct {
cells int
sumC float64
lo float64
hi float64
}
tierStats := map[ZoneTier]*tierStat{}
t.Logf("─── HarvestRollsPerDay=%d ───", rolls)
for i, id := range zoneOrder {
zone, ok := getZone(id)
if !ok {
t.Fatalf("zoneOrder[%d]=%q not in registry", i, id)
}
level, ok := phase1TierCenterline[zone.Tier]
if !ok {
t.Fatalf("zone %q has tier %d with no phase1 centerline mapping", id, zone.Tier)
}
profile := expeditionBalanceProfile{
ZoneID: id,
Class: ClassFighter,
Level: level,
Supplies: makeSupplies(zone.Tier, SupplyPurchase{StandardPacks: 3}),
CampType: CampTypeStandard,
HarvestRollsPerDay: rolls,
}
// Seed schedule: same base + cell offset as Phase 1, plus a
// cadence-dependent salt so different cadences don't sample
// the same correlated streams.
seed := baseSeed + uint64(i)*1_000_003 + uint64(rolls)*7919
r := runExpeditionBalanceCell(profile, trialsPerCell, seed)
c := r.CompletionRate() * 100
t.Logf("CELL rolls=%d %-18s T%d L%-2d comp=%5.1f%% death=%5.1f%% starve=%5.1f%% med_days=%2d med_threat=%3d encs=%4.1f hp_left=%5.1f%%",
rolls, zone.ID, zone.Tier, r.Profile.Level,
c,
r.DeathRate()*100,
float64(r.StarvedOuts)/float64(r.Trials)*100,
r.MedianDays, r.MedianThreatEnd,
r.AvgEncounters, r.AvgHPRemainingPct*100,
)
ts, ok := tierStats[zone.Tier]
if !ok {
ts = &tierStat{lo: math.Inf(1), hi: math.Inf(-1)}
tierStats[zone.Tier] = ts
}
ts.cells++
ts.sumC += c
if c < ts.lo {
ts.lo = c
}
if c > ts.hi {
ts.hi = c
}
}
tiers := []ZoneTier{
ZoneTierBeginner, ZoneTierApprentice, ZoneTierJourneyman,
ZoneTierVeteran, ZoneTierLegendary,
}
for _, tier := range tiers {
ts := tierStats[tier]
if ts == nil || ts.cells == 0 {
continue
}
mean := ts.sumC / float64(ts.cells)
t.Logf("TIER rolls=%d T%d n=%d mean_comp=%5.1f%% spread=%5.1f pp",
rolls, tier, ts.cells, mean, ts.hi-ts.lo)
}
}
}
// TestExpeditionBalance_Phase2_LethalityProbe runs a handful of trials
// at the cleanest cell (T1 goblin_warrens L3 Fighter, gear-aligned so
// gear mismatch isn't a factor) and logs per-fight details: monster,
// max HP, surprise nick, HP pre/post fight, enemy AC/atk, outcome.
//
// The cadence sweep (TestExpeditionBalance_Phase2_CadenceCalibration)
// proved cadence isn't the dominant lever — even at rolls=1 the T1
// cell sits at 2% completion with bimodal hp_left (100% for survivors,
// 0% for the dead). This probe is the next diagnostic step: see
// whether the lethality comes from the pre-combat nick, the picked
// monster's tier-floored stats, or the combat fold itself.
//
// Diagnostic-only — no assertions beyond "produced trace lines."
// Next session reads the output and picks Phase 2's real first lever.
func TestExpeditionBalance_Phase2_LethalityProbe(t *testing.T) {
if testing.Short() {
t.Skip("phase 2 lethality probe writes verbose log; -short skips it")
}
const trials = 5
const baseSeed uint64 = 0x1E7A11
profile := expeditionBalanceProfile{
ZoneID: ZoneGoblinWarrens,
Class: ClassFighter,
Level: 3,
Supplies: makeSupplies(ZoneTierBeginner, SupplyPurchase{StandardPacks: 3}),
CampType: CampTypeStandard,
HarvestRollsPerDay: 1, // sparse cadence so each fight is legible
}
t.Logf("phase2 lethality probe — %d trials at %s L%d %s (rolls=1)",
trials, profile.ZoneID, profile.Level, profile.Class)
for trial := 0; trial < trials; trial++ {
exp := newHarnessExpedition(profile)
char := buildHarnessCharacter(classBalanceProfile{
Class: profile.Class,
Level: profile.Level,
})
t.Logf("─── trial %d: hp_max=%d ───", trial, char.HPMax)
h := &expeditionHarness{
exp: exp,
char: char,
rng: newHarnessRNG(baseSeed + uint64(trial)),
rollsPerDay: profile.HarvestRollsPerDay,
traceFight: func(line string) {
t.Logf(" %s", line)
},
}
for {
res := h.advanceExpeditionOneDay()
if res.EndedReason != "" {
t.Logf("END trial %d: reason=%s days=%d threat=%d encs=%d hp_left_pct=%.1f%%",
trial, res.EndedReason, res.DaysElapsed, res.ThreatAtEnd,
res.CombatEncounters, res.HPRemainingPct*100)
break
}
}
}
}
// TestExpeditionBalance_Phase2_TierLethality is the tier-walking
// companion to the T1-only lethality probe. Phase 1's matrix shows
// uniform 0% across every tier at the default rolls=4 cadence, which
// the T1/rolls=1 probe alone can't explain (its bimodal-warchief
// finding wouldn't show at T5 vs an L17 fighter). This probe traces
// every fight at the matrix cadence across one zone per tier so we
// can read whether the deaths are elite-driven, standard-fight
// chained, or something the harness combat fold itself is doing.
//
// Diagnostic-only — no assertions.
func TestExpeditionBalance_Phase2_TierLethality(t *testing.T) {
if testing.Short() {
t.Skip("phase 2 tier-lethality probe writes verbose log; -short skips it")
}
const trialsPerTier = 3
const baseSeed uint64 = 0x7E11A1
cells := []struct {
zone ZoneID
tier ZoneTier
}{
{ZoneGoblinWarrens, ZoneTierBeginner},
{ZoneForestShadows, ZoneTierApprentice},
{ZoneUnderforge, ZoneTierJourneyman},
{ZoneUnderdark, ZoneTierVeteran},
{ZoneDragonsLair, ZoneTierLegendary},
}
for _, cell := range cells {
level := phase1TierCenterline[cell.tier]
profile := expeditionBalanceProfile{
ZoneID: cell.zone,
Class: ClassFighter,
Level: level,
Supplies: makeSupplies(cell.tier, SupplyPurchase{StandardPacks: 3}),
CampType: CampTypeStandard,
HarvestRollsPerDay: 4, // matrix default, not the sparse probe
}
t.Logf("═══ %s T%d L%d Fighter rolls=4 ═══", cell.zone, cell.tier, level)
for trial := 0; trial < trialsPerTier; trial++ {
exp := newHarnessExpedition(profile)
char := buildHarnessCharacter(classBalanceProfile{
Class: profile.Class,
Level: profile.Level,
})
t.Logf("─── %s trial %d: hp_max=%d ───", cell.zone, trial, char.HPMax)
h := &expeditionHarness{
exp: exp,
char: char,
rng: newHarnessRNG(baseSeed + uint64(trial) + uint64(cell.tier)*101),
rollsPerDay: profile.HarvestRollsPerDay,
traceFight: func(line string) {
t.Logf(" %s", line)
},
}
for {
res := h.advanceExpeditionOneDay()
if res.EndedReason != "" {
t.Logf("END %s trial %d: reason=%s days=%d threat=%d encs=%d hp_left_pct=%.1f%%",
cell.zone, trial, res.EndedReason, res.DaysElapsed, res.ThreatAtEnd,
res.CombatEncounters, res.HPRemainingPct*100)
break
}
}
}
}
}
// TestExpeditionBalance_Phase2_LeverSweep is the tuning sweep the
// plan doc's Phase 2 "global lever tuning" step calls for. Phases 2a
// and 2b surfaced two knobs — retreatThreatBump (the threat penalty
// on a wounded-but-alive break-off) and the surprise-nick wounded-
// entrant divisor (the wounded-fighter lethality clamp) — but the
// post-2b matrix still reads uniform 0% completion across every
// tier. The question this test answers: do alternate settings of
// those two knobs lift the centerline off the floor, and if so by
// how much per tier?
//
// Shape mirrors Phase2_CadenceCalibration: one cell per tier (zone
// at tier centerline level), Fighter, default cadence (rolls=4),
// 200 trials, full grid of (bump × divisor). Diagnostic-only — no
// assertions; the plan-doc test that gates the ±10pp band is added
// after this sweep names a winner.
//
// Cost: 9 lever combos × |zones| × 200 trials. -short skips the
// sweep entirely.
func TestExpeditionBalance_Phase2_LeverSweep(t *testing.T) {
if testing.Short() {
t.Skip("phase 2 lever sweep is heavy; -short skips it")
}
const trialsPerCell = 200
const baseSeed uint64 = 0xB001E5
// Live values are bump=5 (Phase 2a) and divisor=5 (Phase 2b). The
// sweep walks two settings tighter (gentler) and one looser
// (harsher) for each knob so the live point sits in the middle of
// the grid and we can read the slope in both directions.
bumps := []int{2, 5, 10} // threat-bump per retreat
divisors := []int{3, 5, 8, 12} // /N wounded-nick divisor; bigger = gentler
t.Logf("phase2 lever sweep — %d zones × %d bump × %d divisor × %d trials, Fighter @ tier centerline (rolls=4)",
len(zoneOrder), len(bumps), len(divisors), trialsPerCell)
type tierStat struct {
cells int
sumC float64
lo float64
hi float64
}
for _, bump := range bumps {
for _, div := range divisors {
tierStats := map[ZoneTier]*tierStat{}
t.Logf("─── retreatThreatBump=%d surpriseNickDivisor=%d ───", bump, div)
for i, id := range zoneOrder {
zone, ok := getZone(id)
if !ok {
t.Fatalf("zoneOrder[%d]=%q not in registry", i, id)
}
level, ok := phase1TierCenterline[zone.Tier]
if !ok {
t.Fatalf("zone %q has tier %d with no phase1 centerline mapping", id, zone.Tier)
}
profile := expeditionBalanceProfile{
ZoneID: id,
Class: ClassFighter,
Level: level,
Supplies: makeSupplies(zone.Tier, SupplyPurchase{StandardPacks: 3}),
CampType: CampTypeStandard,
RetreatThreatBumpOverride: bump,
SurpriseNickDivisorOverride: div,
}
// Seed schedule: Phase 1 base + cell offset, plus a
// lever-dependent salt so each (bump, div) cell sees a
// fresh RNG stream rather than aliasing earlier sweeps.
seed := baseSeed + uint64(i)*1_000_003 + uint64(bump)*101 + uint64(div)*7919
r := runExpeditionBalanceCell(profile, trialsPerCell, seed)
c := r.CompletionRate() * 100
t.Logf("CELL b=%-2d d=%-2d %-18s T%d L%-2d comp=%5.1f%% death=%5.1f%% starve=%5.1f%% med_days=%2d med_threat=%3d encs=%4.1f hp_left=%5.1f%%",
bump, div, zone.ID, zone.Tier, r.Profile.Level,
c,
r.DeathRate()*100,
float64(r.StarvedOuts)/float64(r.Trials)*100,
r.MedianDays, r.MedianThreatEnd,
r.AvgEncounters, r.AvgHPRemainingPct*100,
)
ts, ok := tierStats[zone.Tier]
if !ok {
ts = &tierStat{lo: math.Inf(1), hi: math.Inf(-1)}
tierStats[zone.Tier] = ts
}
ts.cells++
ts.sumC += c
if c < ts.lo {
ts.lo = c
}
if c > ts.hi {
ts.hi = c
}
}
tiers := []ZoneTier{
ZoneTierBeginner, ZoneTierApprentice, ZoneTierJourneyman,
ZoneTierVeteran, ZoneTierLegendary,
}
for _, tier := range tiers {
ts := tierStats[tier]
if ts == nil || ts.cells == 0 {
continue
}
mean := ts.sumC / float64(ts.cells)
t.Logf("TIER b=%-2d d=%-2d T%d n=%d mean_comp=%5.1f%% spread=%5.1f pp",
bump, div, tier, ts.cells, mean, ts.hi-ts.lo)
}
}
}
}
// TestExpeditionBalance_Phase3_GlobalLeverSweep is the global-tuning
// sweep the plan doc's "Phase 3 — global lever tuning" step calls for.
// Phase 2c (roster gate) lifted T1 goblin_warrens off the floor (~3%)
// but every other tier still reads 0% in the post-2c Phase 1 matrix.
// The Phase 2 lever sweep proved the wounded-cascade knobs are inert
// once the clamp is in place — deaths are now fresh-entry elite
// one-shots and multi-day AC/init creep, not chained nicks.
//
// This sweep walks two of the global knobs called out in the plan-doc
// "Phase 3 — global lever tuning" section:
//
// eliteInterruptThreshold (live=19, total roll cutoff for Elite
// bracket) — directly controls how often fresh-entry elite fights
// trigger during the daytime harvest pipeline. Sweep {17, 19, 23}:
// 17 = more elites (slope check below live), 19 = live baseline,
// 23 = elites only when roll+mod ≥ 23 (rare even at T5).
//
// threatDriftBase (live=3, daily threat clock drift before mood-mod)
// — slows the multi-day AC/init/supply-burn creep that compounds
// over a 14-day expedition. Sweep {1, 3, 5}: 1 = nearly flat, 3 =
// live, 5 = harsher.
//
// 3×3 = 9 combos × 10 zones × 200 trials/cell. Diagnostic-only — no
// gates beyond the Phase 1 wiring sanity. -short skips.
func TestExpeditionBalance_Phase3_GlobalLeverSweep(t *testing.T) {
if testing.Short() {
t.Skip("phase 3 global-lever sweep is heavy; -short skips it")
}
const trialsPerCell = 200
const baseSeed uint64 = 0x9101E5
eliteThresholds := []int{17, 19, 23}
driftBases := []int{1, 3, 5}
t.Logf("phase3 global-lever sweep — %d zones × %d elite-thresholds × %d drift-bases × %d trials, Fighter @ tier centerline (rolls=4)",
len(zoneOrder), len(eliteThresholds), len(driftBases), trialsPerCell)
type tierStat struct {
cells int
sumC float64
lo float64
hi float64
}
for _, elite := range eliteThresholds {
for _, drift := range driftBases {
tierStats := map[ZoneTier]*tierStat{}
t.Logf("─── eliteInterruptThreshold=%d threatDriftBase=%d ───", elite, drift)
for i, id := range zoneOrder {
zone, ok := getZone(id)
if !ok {
t.Fatalf("zoneOrder[%d]=%q not in registry", i, id)
}
level, ok := phase1TierCenterline[zone.Tier]
if !ok {
t.Fatalf("zone %q has tier %d with no phase1 centerline mapping", id, zone.Tier)
}
profile := expeditionBalanceProfile{
ZoneID: id,
Class: ClassFighter,
Level: level,
Supplies: makeSupplies(zone.Tier, SupplyPurchase{StandardPacks: 3}),
CampType: CampTypeStandard,
EliteInterruptThresholdOverride: elite,
ThreatDriftBaseOverride: drift,
}
seed := baseSeed + uint64(i)*1_000_003 + uint64(elite)*101 + uint64(drift)*7919
r := runExpeditionBalanceCell(profile, trialsPerCell, seed)
c := r.CompletionRate() * 100
t.Logf("CELL e=%-2d d=%-1d %-18s T%d L%-2d comp=%5.1f%% death=%5.1f%% starve=%5.1f%% med_days=%2d med_threat=%3d encs=%4.1f hp_left=%5.1f%%",
elite, drift, zone.ID, zone.Tier, r.Profile.Level,
c,
r.DeathRate()*100,
float64(r.StarvedOuts)/float64(r.Trials)*100,
r.MedianDays, r.MedianThreatEnd,
r.AvgEncounters, r.AvgHPRemainingPct*100,
)
ts, ok := tierStats[zone.Tier]
if !ok {
ts = &tierStat{lo: math.Inf(1), hi: math.Inf(-1)}
tierStats[zone.Tier] = ts
}
ts.cells++
ts.sumC += c
if c < ts.lo {
ts.lo = c
}
if c > ts.hi {
ts.hi = c
}
}
tiers := []ZoneTier{
ZoneTierBeginner, ZoneTierApprentice, ZoneTierJourneyman,
ZoneTierVeteran, ZoneTierLegendary,
}
for _, tier := range tiers {
ts := tierStats[tier]
if ts == nil || ts.cells == 0 {
continue
}
mean := ts.sumC / float64(ts.cells)
t.Logf("TIER e=%-2d d=%-1d T%d n=%d mean_comp=%5.1f%% spread=%5.1f pp",
elite, drift, tier, ts.cells, mean, ts.hi-ts.lo)
}
}
}
}
// TestExpeditionBalance_Phase3B_NickSupplySweep is the second
// global-tuning sweep from gogobee_expedition_difficulty.md. Phase 3-A
// surfaced the elite-bracket threshold as the dominant T1T3 lever
// (e=23/d=1 lifted T1 from 3% → 24%) but exposed a tail-side
// fingerprint shift: T4/T5 dragons_lair death dropped 60% → 24% while
// starvation climbed to 75% — the fighter now survives elites long
// enough to run out of food. Phase 3-A picked the best cell but it
// still leaves every tier well below target band (T1 70-90%, T2
// 62-82%, …, T5 ~40%).
//
// Phase 3-B holds the Phase 3-A best cell (eliteInterruptThreshold=23,
// threatDriftBase=1) and walks two more global knobs:
//
// surpriseNickFloor (live=tier, i.e. 1..5 by zone tier) — the raw
// surprise-round nick on a fresh-HP entry. Lower floor = less
// per-fight chip on T4/T5 standard fights, where the live tier=4-5
// floor is the biggest single contributor to the wear-down curve
// that funnels survivors into starvation. Sweep {-1 (disable, =0),
// 1 (flat), 0-sentinel (live tier)}.
//
// supplyBurnRatePct (live=100, %) — daily supply burn scalar. Lower
// = more days of margin. T4 and T5 burns are 3×/4× the T1 baseline;
// halving them ought to convert the starvation outs we saw in
// Phase 3-A into actual completions if survivability is the only
// remaining blocker, or leave them dying in combat if it isn't.
// Sweep {100 (live), 75, 50}.
//
// 3×3 = 9 combos × 10 zones × 200 trials/cell, all on top of the
// Phase 3-A best cell. Diagnostic-only — no gates beyond Phase 1
// wiring sanity. -short skips.
func TestExpeditionBalance_Phase3B_NickSupplySweep(t *testing.T) {
if testing.Short() {
t.Skip("phase 3-B nick/supply sweep is heavy; -short skips it")
}
const trialsPerCell = 200
const baseSeed uint64 = 0xB1C5E2
// Phase 3-A best cell — held constant across this sweep.
const eliteThreshold = 23
const driftBase = 1
nickFloors := []int{-1, 1, 0} // -1 disables floor; 1 flat-1; 0 = use live tier
burnPcts := []int{50, 75, 100}
t.Logf("phase3-B nick/supply sweep — %d zones × %d nick-floors × %d burn-pcts × %d trials, Fighter @ tier centerline (rolls=4); base e=%d d=%d",
len(zoneOrder), len(nickFloors), len(burnPcts), trialsPerCell, eliteThreshold, driftBase)
type tierStat struct {
cells int
sumC float64
lo float64
hi float64
}
nickLabel := func(f int) string {
switch {
case f == 0:
return "tier"
case f < 0:
return "0"
default:
return fmt.Sprintf("%d", f)
}
}
for _, floor := range nickFloors {
for _, burn := range burnPcts {
tierStats := map[ZoneTier]*tierStat{}
t.Logf("─── surpriseNickFloor=%s supplyBurnPct=%d ───", nickLabel(floor), burn)
for i, id := range zoneOrder {
zone, ok := getZone(id)
if !ok {
t.Fatalf("zoneOrder[%d]=%q not in registry", i, id)
}
level, ok := phase1TierCenterline[zone.Tier]
if !ok {
t.Fatalf("zone %q has tier %d with no phase1 centerline mapping", id, zone.Tier)
}
profile := expeditionBalanceProfile{
ZoneID: id,
Class: ClassFighter,
Level: level,
Supplies: makeSupplies(zone.Tier, SupplyPurchase{StandardPacks: 3}),
CampType: CampTypeStandard,
EliteInterruptThresholdOverride: eliteThreshold,
ThreatDriftBaseOverride: driftBase,
SurpriseNickFloorOverride: floor,
SupplyBurnRatePctOverride: burn,
}
seed := baseSeed + uint64(i)*1_000_003 +
uint64(floor+2)*1009 + uint64(burn)*7919
r := runExpeditionBalanceCell(profile, trialsPerCell, seed)
c := r.CompletionRate() * 100
t.Logf("CELL f=%-4s b=%-3d %-18s T%d L%-2d comp=%5.1f%% death=%5.1f%% starve=%5.1f%% med_days=%2d med_threat=%3d encs=%4.1f hp_left=%5.1f%%",
nickLabel(floor), burn, zone.ID, zone.Tier, r.Profile.Level,
c,
r.DeathRate()*100,
float64(r.StarvedOuts)/float64(r.Trials)*100,
r.MedianDays, r.MedianThreatEnd,
r.AvgEncounters, r.AvgHPRemainingPct*100,
)
ts, ok := tierStats[zone.Tier]
if !ok {
ts = &tierStat{lo: math.Inf(1), hi: math.Inf(-1)}
tierStats[zone.Tier] = ts
}
ts.cells++
ts.sumC += c
if c < ts.lo {
ts.lo = c
}
if c > ts.hi {
ts.hi = c
}
}
tiers := []ZoneTier{
ZoneTierBeginner, ZoneTierApprentice, ZoneTierJourneyman,
ZoneTierVeteran, ZoneTierLegendary,
}
for _, tier := range tiers {
ts := tierStats[tier]
if ts == nil || ts.cells == 0 {
continue
}
mean := ts.sumC / float64(ts.cells)
t.Logf("TIER f=%-4s b=%-3d T%d n=%d mean_comp=%5.1f%% spread=%5.1f pp",
nickLabel(floor), burn, tier, ts.cells, mean, ts.hi-ts.lo)
}
}
}
}
// 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)
}
}
}
// TestExpeditionBalance_Phase5A_TierWideSensitivity is the tier-wide
// counterpart to Phase 4-A. Phase 4-B closed the four named per-zone
// outliers, but the b=50 sweep still left T2/T3/T5 sibling *pairs*
// below the target band as a group (T2 7-13% vs 62-82%; T3 3-14% vs
// 54-74%; T5 25-57% vs 36-56%). Both zones at each problem tier
// underperform together, so no further per-zone tool will close it —
// the lever has to be tier-wide. The plan doc names three candidate
// moves:
//
// 1. Gear-tier centerline remap (phase1TierCenterline) → axis L
// 2. Per-tier elite-bracket threshold (vs global) → axis E
// 3. Ship burn=75 globally (Phase 3-B's high-burn cell) → axis B
//
// This diagnostic does *not* pick the lever. It runs a one-axis-at-a-
// time sensitivity sweep on the six under-band zones and lets the
// numbers name the lever. Each axis holds the other two at Phase 3-B
// best (e=23, d=1, burn=50) and walks the named axis through three
// values. Three points per axis is enough to read the slope: flat-line
// means the lever is inert for that tier, monotone climb means it's
// the lever, non-monotone means a confound.
//
// L: player level = {centerline-2, centerline, centerline+2}
// E: elite threshold = {18, 23, 28}
// B: supply burn pct = {40, 50, 60}
//
// Per cell we log comp%/death%/starve% plus the elite-vs-standard
// fight share (so axis E's slope can be cross-checked against the
// fight-mix shift it actually produced). 200 trials/cell × 6 zones ×
// 9 cells = 10.8k trials — heavier than Phase 4-A (1.6k) but bounded
// and skipped under -short.
//
// Diagnostic-only — no gates. Output names the lever Phase 5-B
// should pull.
func TestExpeditionBalance_Phase5A_TierWideSensitivity(t *testing.T) {
if testing.Short() {
t.Skip("phase 5-A sensitivity sweep walks 6 zones × 9 cells × 200 trials; -short skips it")
}
const trialsPerCell = 200
const baseSeed uint64 = 0xF50A5E
// Phase 3-B best cell (held constant on the two non-axis levers).
const eliteBaseline = 23
const driftBase = 1
const burnBaseline = 50
type tierGroup struct {
tier ZoneTier
zones []ZoneID
}
groups := []tierGroup{
{ZoneTierApprentice, []ZoneID{ZoneForestShadows, ZoneSunkenTemple}},
{ZoneTierJourneyman, []ZoneID{ZoneManorBlackspire, ZoneUnderforge}},
{ZoneTierLegendary, []ZoneID{ZoneAbyssPortal, ZoneDragonsLair}},
}
// runCell is the inner-loop closure: one (zone, lever-triple) cell.
// Returns comp/death/starve counts plus elite/standard fight share
// so the caller can attribute the slope across axis values.
type cellOut struct {
comp, death, starve int
eliteFights, stdFights int
}
runCell := func(zid ZoneID, tier ZoneTier, level, elite, burn int) cellOut {
out := cellOut{}
profile := expeditionBalanceProfile{
ZoneID: zid,
Class: ClassFighter,
Level: level,
Supplies: makeSupplies(tier, SupplyPurchase{StandardPacks: 3}),
CampType: CampTypeStandard,
EliteInterruptThresholdOverride: elite,
ThreatDriftBaseOverride: driftBase,
SupplyBurnRatePctOverride: burn,
}
for trial := 0; trial < trialsPerCell; trial++ {
exp := newHarnessExpedition(profile)
char := buildHarnessCharacter(classBalanceProfile{
Class: profile.Class,
Level: profile.Level,
})
// Seed mixes trial, tier, zone, and the *axis values* so
// cells across the sweep draw distinct RNG streams. Without
// the lever mix-in, e.g. L=centerline cells would
// byte-match across axes and the slope reading would be
// degenerate on the shared row.
seed := baseSeed + uint64(trial) +
uint64(tier)*131 + zoneSeedSalt(zid) +
uint64(level)*1_000_003 +
uint64(elite)*7_919 +
uint64(burn)*17
h := &expeditionHarness{
exp: exp,
char: char,
rng: newHarnessRNG(seed),
rollsPerDay: harnessHarvestRollsPerDay,
eliteInterruptThresholdOverride: elite,
threatDriftBaseOverride: driftBase,
supplyBurnRatePctOverride: burn,
traceFightStruct: func(ft harnessFightTrace) {
if ft.Elite {
out.eliteFights++
} else {
out.stdFights++
}
},
}
var trialEnd expeditionTrialResult
for {
res := h.advanceExpeditionOneDay()
if res.EndedReason != "" {
trialEnd = res
break
}
}
switch {
case trialEnd.Completed:
out.comp++
case trialEnd.Died:
out.death++
case trialEnd.StarvedOut:
out.starve++
}
}
return out
}
logRow := func(axis, label string, zid ZoneID, val int, c cellOut) {
total := trialsPerCell
t.Logf(" %s %-18s %s=%-3d comp=%5.1f%% death=%5.1f%% starve=%5.1f%% elite_fights=%-4d std_fights=%-4d",
axis, zid, label, val,
float64(c.comp)/float64(total)*100,
float64(c.death)/float64(total)*100,
float64(c.starve)/float64(total)*100,
c.eliteFights, c.stdFights)
}
for _, g := range groups {
center := phase1TierCenterline[g.tier]
t.Logf("═══ T%d zones=%v centerline=L%d (Fighter, %d trials/cell, baselines e=%d d=%d burn=%d) ═══",
g.tier, g.zones, center, trialsPerCell,
eliteBaseline, driftBase, burnBaseline)
// Axis L — player level. Centerline ± 2 keeps the move inside
// each tier's design-doc level range for T2/T3/T5 (T2: L3-7,
// centerline 5 → 3,5,7; T3: L5-10, centerline 9 → 7,9,11; T5:
// L10-20, centerline 17 → 15,17,19). No gear-tier boundary is
// crossed within ±2 for any of these (boundaries 5/9/13/17),
// so axis L isolates the "level inside same gear bracket"
// sensitivity. Phase 5-B may widen this if the slope is flat
// here but the boundary is the real lever.
t.Logf(" AXIS-L player level ±2 (gear-tier mapping sensitivity)")
for _, zid := range g.zones {
for _, dl := range []int{-2, 0, +2} {
lvl := center + dl
out := runCell(zid, g.tier, lvl, eliteBaseline, burnBaseline)
logRow("L", "lvl", zid, lvl, out)
}
}
// Axis E — elite threshold. 23 is the Phase 3-B baseline; 18
// is "more elites" (lower bar → more interrupts roll elite);
// 28 is "fewer elites". Slope here disambiguates whether the
// tier is gated by elite mix vs baseline fights.
t.Logf(" AXIS-E elite threshold ±5 (monster-side gate)")
for _, zid := range g.zones {
for _, eth := range []int{18, 23, 28} {
out := runCell(zid, g.tier, center, eth, burnBaseline)
logRow("E", "eth", zid, eth, out)
}
}
// Axis B — supply burn pct. Phase 3-B's negative result on
// burn=75 was tier-mean; reading this axis per under-band
// tier may surface a tier where burn is *the* lever (e.g. T5
// borderline 25-57% may close on burn alone). 40/50/60 brackets
// the Phase 3-B baseline and the candidate burn=75 direction.
t.Logf(" AXIS-B supply burn ±10pp (resource pressure)")
for _, zid := range g.zones {
for _, b := range []int{40, 50, 60} {
out := runCell(zid, g.tier, center, eliteBaseline, b)
logRow("B", "burn", zid, b, out)
}
}
}
}
// TestExpeditionBalance_Phase5B_GearBonusSweep walks the full matrix
// at gear magic-bonus delta ∈ {0, +1, +2} on top of the Phase 3-B
// best cell (e=23, d=1, burn=50, nick-floor=tier). Phase 5-A named
// player level as the dominant lever at T2/T3 but showed the
// within-bracket slope can't close the band — the live magic-bonus
// ladder (0/1/2/3/3) tops the player-power knob, so the question is
// what flat delta on top of the ladder lands T1-T5 in band.
//
// Bands (gogobee_expedition_difficulty.md):
// T1 80% (70-90%) T2 72% (62-82%) T3 65% (55-75%)
// T4 55% (45-65%) T5 45% (35-55%)
//
// 10 zones × 3 deltas × 200 trials = 6k trials; runs in ~1s.
// Diagnostic-only — picks the delta Phase 5-B ships in
// magicBonusForTier.
func TestExpeditionBalance_Phase5B_GearBonusSweep(t *testing.T) {
if testing.Short() {
t.Skip("phase 5-B gear sweep walks 10 zones × 3 deltas × 200 trials; -short skips it")
}
const trialsPerCell = 200
const baseSeed uint64 = 0xF50B1E
// Phase 3-B best cell, held constant.
const eliteThreshold = 23
const driftBase = 1
const supplyBurnPct = 50
// Two-axis grid. Gear delta is the to-hit/AC/damage lever (Phase
// 5-B's first read showed it dominates at T1/T2/T4); HP multiplier
// is the durability lever needed to close T3/T5 where gear alone
// stalled at ~5pp/delta. Smallest combination that lands all
// tiers in band gets shipped.
gearDeltas := []int{2, 3, 4}
hpMults := []float64{1.0, 1.25, 1.5}
type cellOut struct {
comp, death, starve int
}
runCell := func(zid ZoneID, tier ZoneTier, level, delta int, hpMult float64) cellOut {
profile := expeditionBalanceProfile{
ZoneID: zid,
Class: ClassFighter,
Level: level,
Supplies: makeSupplies(tier, SupplyPurchase{StandardPacks: 3}),
CampType: CampTypeStandard,
EliteInterruptThresholdOverride: eliteThreshold,
ThreatDriftBaseOverride: driftBase,
SupplyBurnRatePctOverride: supplyBurnPct,
GearMagicBonusOverride: delta,
PlayerHPMultOverride: hpMult,
}
out := cellOut{}
for trial := 0; trial < trialsPerCell; trial++ {
seed := baseSeed + uint64(trial) +
uint64(tier)*131 + zoneSeedSalt(zid) +
uint64(delta)*7_919 +
uint64(hpMult*1000)*23
res := runExpeditionBalanceTrial(profile, seed)
switch {
case res.Completed:
out.comp++
case res.Died:
out.death++
case res.StarvedOut:
out.starve++
}
}
return out
}
t.Logf("phase5-B gear×hp sweep — %d zones × %d gear × %d hp × %d trials, Fighter @ tier-centerline, baselines e=%d d=%d burn=%d",
len(zoneOrder), len(gearDeltas), len(hpMults), trialsPerCell,
eliteThreshold, driftBase, supplyBurnPct)
type comboKey struct {
gear int
hp float64
}
type tierAgg struct{ sum, count float64 }
perCombo := map[comboKey]map[ZoneTier]*tierAgg{}
for _, delta := range gearDeltas {
for _, hp := range hpMults {
key := comboKey{delta, hp}
perCombo[key] = map[ZoneTier]*tierAgg{}
t.Logf("═══ gear=+%d hp×%.2f ═══", delta, hp)
for _, zid := range zoneOrder {
zone, ok := getZone(zid)
if !ok {
continue
}
level := phase1TierCenterline[zone.Tier]
out := runCell(zid, zone.Tier, level, delta, hp)
compPct := float64(out.comp) / float64(trialsPerCell) * 100
t.Logf(" CELL g=+%d h=%.2f %-18s T%d L%-2d comp=%5.1f%% death=%5.1f%% starve=%5.1f%%",
delta, hp, zid, zone.Tier, level, compPct,
float64(out.death)/float64(trialsPerCell)*100,
float64(out.starve)/float64(trialsPerCell)*100)
ag, ok := perCombo[key][zone.Tier]
if !ok {
ag = &tierAgg{}
perCombo[key][zone.Tier] = ag
}
ag.sum += compPct
ag.count++
}
}
}
// Combo × tier headline. Bands per gogobee_expedition_difficulty.md:
// the "fairly breezy" target the user picked for Phase 5 means we
// can land at or above band-center across all tiers.
t.Logf("─── tier means by (gear, hp) — bands: T1 70-90, T2 62-82, T3 55-75, T4 45-65, T5 35-55 ───")
tiers := []ZoneTier{
ZoneTierBeginner, ZoneTierApprentice, ZoneTierJourneyman,
ZoneTierVeteran, ZoneTierLegendary,
}
for _, delta := range gearDeltas {
for _, hp := range hpMults {
key := comboKey{delta, hp}
parts := make([]string, 0, len(tiers))
for _, ti := range tiers {
ag := perCombo[key][ti]
if ag == nil || ag.count == 0 {
continue
}
parts = append(parts, fmt.Sprintf("T%d=%.1f%%", ti, ag.sum/ag.count))
}
t.Logf(" TIER-MEANS g=+%d h=%.2f %s", delta, hp, joinZones(parts))
}
}
}
// 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
}
// 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.
func joinZones(parts []string) string {
out := ""
for i, p := range parts {
if i > 0 {
out += ", "
}
out += p
}
return out
}