diff --git a/gogobee_expedition_difficulty.md b/gogobee_expedition_difficulty.md index 7e9d5c1..4747b59 100644 --- a/gogobee_expedition_difficulty.md +++ b/gogobee_expedition_difficulty.md @@ -130,6 +130,32 @@ Roper, Young Red Dragon) — that's the Phase 2c roster signal. **Tuning surface:** the `/5` divisor in `clampSurpriseNick` is the wounded-fighter lethality knob. +### Phase 2 lever sweep (shipped) — negative result + +Before adding Phase 2c, the two surfaced knobs (`retreatThreatBump`, +`clampSurpriseNick` divisor) got swept across a full 3×4 grid +(bump ∈ {2, 5, 10} × divisor ∈ {3, 5, 8, 12}) at 200 trials/cell +across every zone in the matrix. Wired via +`expeditionBalanceProfile.{RetreatThreatBumpOverride, +SurpriseNickDivisorOverride}` threaded into `expeditionHarness`; +the live caller (`runHarvestInterrupt`) is untouched. + +Sweep file: `TestExpeditionBalance_Phase2_LeverSweep` in +`expedition_balance_test.go`. `-short` skips. + +**Outcome:** every one of the 24,000 trial-cells reports 0.0% +completion / ~100% death. The two knobs are inert on the headline +metric — even the gentlest combo (b=2, d=12) cannot lift any tier +off the floor. Median days-to-end stays at 2–3 across the grid +with median 2.6–7.3 encounters before death. + +**Read:** confirms the post-2b tier-lethality trace — deaths are +fresh-entry elite one-shots (Warchief @ HP19/20, Hag @ HP41, Roper, +Young Red Dragon), not chained-interrupt cascades. The two levers +target the cascade path that's now mostly resolved; they have no +remaining death-fraction to convert. Phase 2c (roster dilution) is +justified rather than premature. + ### Phase 2c — roster gate (next) Promote softer mid-tier monsters to `IsElite: true` in zones whose diff --git a/internal/plugin/dnd_expedition_combat.go b/internal/plugin/dnd_expedition_combat.go index 6738d2a..c628648 100644 --- a/internal/plugin/dnd_expedition_combat.go +++ b/internal/plugin/dnd_expedition_combat.go @@ -232,14 +232,30 @@ func surpriseRoundNick(m DnDMonsterTemplate, tier int) int { // // Tuning surface: the /5 divisor is the wounded-fighter lethality knob. // Tighter (e.g. /10) is gentler; looser (/3) re-opens the cascade. See -// gogobee_expedition_difficulty.md Phase 2b. +// gogobee_expedition_difficulty.md Phase 2b. liveSurpriseNickDivisor +// names the shipped value; the harness Phase 2 lever sweep +// (TestExpeditionBalance_Phase2_LeverSweep) calls clampSurpriseNickD +// directly with alternate divisors. Live callers always go through +// clampSurpriseNick. +const liveSurpriseNickDivisor = 5 + func clampSurpriseNick(rawNick, hpCurrent, hpMax int) int { + return clampSurpriseNickD(rawNick, hpCurrent, hpMax, liveSurpriseNickDivisor) +} + +// clampSurpriseNickD is the divisor-parameterized form used by the +// sim harness lever sweep. divisor <= 0 falls back to the shipped +// value so a zero-valued harness override behaves as "use live." +func clampSurpriseNickD(rawNick, hpCurrent, hpMax, divisor int) int { if rawNick <= 0 || hpCurrent <= 0 { return 0 } + if divisor <= 0 { + divisor = liveSurpriseNickDivisor + } nick := rawNick if hpCurrent < hpMax { - cap := hpCurrent / 5 + cap := hpCurrent / divisor if cap < 1 { cap = 1 } diff --git a/internal/plugin/expedition_balance.go b/internal/plugin/expedition_balance.go index e981fe6..3200f89 100644 --- a/internal/plugin/expedition_balance.go +++ b/internal/plugin/expedition_balance.go @@ -97,6 +97,12 @@ type expeditionBalanceProfile struct { // calibration sweep sets this per cell; everyday callers leave it // zero. HarvestRollsPerDay int + // Phase 2 lever overrides (sweep-only). Zero means "use the live + // shipped value." Wired into the harness day loop / runHarnessFight + // path; the live runHarvestInterrupt is untouched. See + // TestExpeditionBalance_Phase2_LeverSweep. + RetreatThreatBumpOverride int + SurpriseNickDivisorOverride int } // expeditionTrialResult is the outcome of one simulated expedition. @@ -274,7 +280,7 @@ func (h *expeditionHarness) advanceExpeditionOneDay() expeditionTrialResult { // dnd_expedition_combat.go (Phase 2). Run continues // with carryover HP and a threat bump; the harvest // slot's loot is just forfeit. - h.exp.ThreatLevel += retreatThreatBump + h.exp.ThreatLevel += h.resolvedRetreatBump() if h.exp.ThreatLevel > 100 { h.exp.ThreatLevel = 100 } @@ -333,6 +339,25 @@ func (h *expeditionHarness) advanceExpeditionOneDay() expeditionTrialResult { return expeditionTrialResult{} } +// resolvedRetreatBump returns the per-harness lever override or the +// shipped retreatThreatBump if no override is set. Zero is the "use +// live" sentinel so the field's zero value remains safe. +func (h *expeditionHarness) resolvedRetreatBump() int { + if h.retreatThreatBumpOverride > 0 { + return h.retreatThreatBumpOverride + } + return retreatThreatBump +} + +// resolvedNickDivisor returns the per-harness override or the shipped +// liveSurpriseNickDivisor. Same zero-sentinel contract as above. +func (h *expeditionHarness) resolvedNickDivisor() int { + if h.surpriseNickDivisorOverride > 0 { + return h.surpriseNickDivisorOverride + } + return liveSurpriseNickDivisor +} + // terminate stamps the final trial result with shared bookkeeping // (days elapsed, threat at end, encounter count, HP%). func (h *expeditionHarness) terminate(reason string, completed, died, starved bool) expeditionTrialResult { @@ -376,7 +401,11 @@ func (h *expeditionHarness) runHarnessFight(zone ZoneDefinition, elite bool) Com // (clampSurpriseNick) that breaks the chained-interrupt death // spiral. Mirror live exactly so the harness measures the same // lever the live caller applies. - nick := clampSurpriseNick(surpriseRoundNick(monster, int(zone.Tier)), h.char.HPCurrent, h.char.HPMax) + nick := clampSurpriseNickD( + surpriseRoundNick(monster, int(zone.Tier)), + h.char.HPCurrent, h.char.HPMax, + h.resolvedNickDivisor(), + ) h.char.HPCurrent -= nick player := buildHarnessPlayer(h.char) @@ -487,6 +516,10 @@ type expeditionHarness struct { rng *harnessRNG encounters int rollsPerDay int // resolved from profile + default; never zero + // Lever overrides for the Phase 2 sweep. Zero on both fields ⇒ + // live behavior (retreatThreatBump=5, /5 surprise-nick divisor). + retreatThreatBumpOverride int + surpriseNickDivisorOverride int // traceFight, if non-nil, is invoked once per fight inside // runHarnessFight with a human-readable summary. Used by the // Phase 2 lethality probe to spot whether the nick, the picked @@ -531,10 +564,12 @@ func runExpeditionBalanceTrial(p expeditionBalanceProfile, seed uint64) expediti rolls = harnessHarvestRollsPerDay } h := &expeditionHarness{ - exp: exp, - char: char, - rng: newHarnessRNG(seed), - rollsPerDay: rolls, + exp: exp, + char: char, + rng: newHarnessRNG(seed), + rollsPerDay: rolls, + retreatThreatBumpOverride: p.RetreatThreatBumpOverride, + surpriseNickDivisorOverride: p.SurpriseNickDivisorOverride, } for { res := h.advanceExpeditionOneDay() diff --git a/internal/plugin/expedition_balance_test.go b/internal/plugin/expedition_balance_test.go index 9beb789..06f42a6 100644 --- a/internal/plugin/expedition_balance_test.go +++ b/internal/plugin/expedition_balance_test.go @@ -495,6 +495,119 @@ func TestExpeditionBalance_Phase2_TierLethality(t *testing.T) { } } +// 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) + } + } + } +} + // 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.