diff --git a/gogobee_expedition_difficulty.md b/gogobee_expedition_difficulty.md index b8cd3b0..8246293 100644 --- a/gogobee_expedition_difficulty.md +++ b/gogobee_expedition_difficulty.md @@ -253,6 +253,61 @@ density) or supply margin. on top of e=23/d=1 to push T1-T3 toward target band and lift T4-T5 off the floor. +#### Phase 3-B — nick-floor + supply-burn sweep (shipped) + +Wired two more harness overrides (`SurpriseNickFloorOverride`, +`SupplyBurnRatePctOverride`) and swept a 3×3 grid (floor ∈ {0, 1, tier +(=live)} × burn% ∈ {50, 75, 100 (=live)}) on top of the Phase 3-A best +cell (e=23, d=1). Test: `TestExpeditionBalance_Phase3B_NickSupplySweep`. + +**Strong partial positive for T5 supply margin; nick-floor lever inert.** + +Tier-mean completion (Fighter, centerline level, 10 zones × 200 trials): + +| (floor, burn%) | T1 mean | T2 | T3 | T4 | T5 | +|----------------|--------:|----:|----:|-----:|-----:| +| 0, 50 | 20.8% | 5.0%| 3.8%| 7.5% | 29.5%| +| 0, 75 | 26.5% | 6.0%| 2.8%| 9.0% | 0.0% | +| 0, 100 | 25.8% | 5.0%| 3.0%| 0.0% | 0.0% | +| 1, 50 | 25.5% | 6.5%| 1.8%| 8.5% | 27.8%| +| 1, 75 | 28.5% | 6.2%| 4.0%|11.5% | 0.0% | +| 1, 100 | 26.8% | 7.5%| 2.5%| 0.0% | 0.0% | +| tier, 50 | 26.8% | 6.2%| 2.5%| 7.8% | 27.5%| +| tier, 75 (live floor) | **29.5%** | **7.8%** | 3.0% | **12.8%** | 0.0% | +| tier,100 (live)| 28.5% | 6.8%| 3.0%| 0.0% | 0.0% | + +Three readings: + +1. **Supply burn is the T5 unlock.** dragons_lair completes ~55% at + burn=50 (versus 0% at burn=75/100) — the fighter survives elites + but starves en route. burn=75 isn't enough margin; burn=50 + converts the starvation-out into a completion. +2. **Supply burn is the T4 modulator.** underdark/feywild step from + 0% (burn=100) → 12% (burn=75) → 8% (burn=50). The burn=75 peak is + the sweet spot; halving further leaves the fighter alive *into* + more elite fights, where it dies in combat instead. +3. **Nick-floor lever is inert.** Across all burn levels, dropping + the surprise-round floor from `tier` → `1` → `0` moves T1-T3 + means by at most ~3pp. The wounded-clamp (Phase 2b, + `clampSurpriseNick`) already eats the chip-damage budget on + repeat entries; the raw floor only matters on the first fresh-HP + swing, which the engine recovers from. **Recommendation: discard + this lever from the live-tuning candidate list.** + +**T2-T3 wall persists.** Even at the best (floor=tier, burn=75) cell: +T2 reads 7.8% (target 62-82%) and T3 reads 3.0% (target 54-74%). +forest_shadows T2, manor_blackspire T3, and abyss_portal T5 sit at +~0% across every combo — these are zone-specific outliers, not +addressable by any global lever. **Phase 4 (per-zone outlier pass) +is the next move.** + +**Next:** Phase 4 walks the matrix zone-by-zone. The shipped Phase 3 +sweep already names the worst offenders (forest_shadows T2, +manor_blackspire T3, abyss_portal T5, crypt_valdris T1); each needs a +diagnostic trace (boss stat-block, roster mix, supply DC) to pick the +right per-zone tool from `Phase 4`'s list. Optional: revisit shipping +the burn=75 global if Phase 4 outlier fixes leave T4 still stuck. + ### Phase 4 — per-zone outlier pass After globals settle, the matrix will name outlier zones — tiers diff --git a/internal/plugin/dnd_expedition_combat.go b/internal/plugin/dnd_expedition_combat.go index c628648..534b69f 100644 --- a/internal/plugin/dnd_expedition_combat.go +++ b/internal/plugin/dnd_expedition_combat.go @@ -203,11 +203,24 @@ func (p *AdventurePlugin) runHarvestInterrupt( // surpriseRoundNick computes a small HP nick representing the enemy's // free first swing. Roughly attack-bonus + 1d4, with a tier-based floor. func surpriseRoundNick(m DnDMonsterTemplate, tier int) int { + return surpriseRoundNickF(m, tier, -1) +} + +// surpriseRoundNickF is the floor-parameterized form used by the Phase +// 3-B sim harness lever sweep. floorOverride < 0 means "use live" +// (floor = tier); floorOverride >= 0 substitutes that absolute value +// as the floor (0 disables the floor entirely). Live callers always go +// through surpriseRoundNick. See gogobee_expedition_difficulty.md +// Phase 3-B. +func surpriseRoundNickF(m DnDMonsterTemplate, tier, floorOverride int) int { if tier < 1 { tier = 1 } dmg := 1 + rand.IntN(4) + m.AttackBonus/2 floor := tier + if floorOverride >= 0 { + floor = floorOverride + } if dmg < floor { dmg = floor } diff --git a/internal/plugin/dnd_expedition_supplies.go b/internal/plugin/dnd_expedition_supplies.go index c0b4c45..fcb0eac 100644 --- a/internal/plugin/dnd_expedition_supplies.go +++ b/internal/plugin/dnd_expedition_supplies.go @@ -164,6 +164,15 @@ func makeSupplies(tier ZoneTier, p SupplyPurchase) ExpeditionSupplies { // where HarshMod is 1×) — the dungeon is actively starving you out. // - otherwise, harshActive applies HarshMod (zone-tier scaled). func applyDailyBurn(s ExpeditionSupplies, harshActive, siege bool) (ExpeditionSupplies, float32) { + return applyDailyBurnP(s, harshActive, siege, 0) +} + +// applyDailyBurnP is the rate-parameterized form used by the Phase 3-B +// sim harness lever sweep. burnRatePct == 0 means "use live" (100%); +// any positive value scales the final per-day burn by that percent +// (e.g. 50 = half burn). Live callers always go through applyDailyBurn. +// See gogobee_expedition_difficulty.md Phase 3-B. +func applyDailyBurnP(s ExpeditionSupplies, harshActive, siege bool, burnRatePct int) (ExpeditionSupplies, float32) { burn := s.DailyBurn switch { case siege: @@ -179,6 +188,9 @@ func applyDailyBurn(s ExpeditionSupplies, harshActive, siege bool) (ExpeditionSu } burn *= mult } + if burnRatePct > 0 { + burn = burn * float32(burnRatePct) / 100 + } s.Current -= burn if s.Current < 0 { s.Current = 0 diff --git a/internal/plugin/expedition_balance.go b/internal/plugin/expedition_balance.go index 63bccbf..7ea231d 100644 --- a/internal/plugin/expedition_balance.go +++ b/internal/plugin/expedition_balance.go @@ -121,6 +121,26 @@ type expeditionBalanceProfile struct { // TestExpeditionBalance_Phase3_GlobalLeverSweep. EliteInterruptThresholdOverride int ThreatDriftBaseOverride int + + // Phase 3-B global-lever overrides. Zero means "use live": + // + // SurpriseNickFloorOverride — absolute floor for the raw + // surprise-round nick. Live floor is the zone tier (1..5). + // Convention: 0 (the zero value) = "use live tier floor"; a + // positive value sets the floor directly; -1 disables the floor + // entirely (floor = 0). Lower floor = less per-fight chip damage + // on fresh entries. + // + // SupplyBurnRatePctOverride — percent multiplier on the per-day + // supply burn. Zero means "use live" (100%); 50 = half burn. + // Lower = more supply margin for T4/T5 where starvation is the + // dominant failure mode post-Phase-3-A. + // + // Both wired into the harness day loop only; live callers go + // through the shipped helpers. See + // TestExpeditionBalance_Phase3B_NickSupplySweep. + SurpriseNickFloorOverride int + SupplyBurnRatePctOverride int } // expeditionTrialResult is the outcome of one simulated expedition. @@ -248,9 +268,11 @@ func (h *expeditionHarness) advanceExpeditionOneDay() expeditionTrialResult { } } - // 1. Morning rollover — supply burn + day++. + // 1. Morning rollover — supply burn + day++. Phase 3-B sweep can + // scale the per-day burn via the harness profile; live callers go + // through applyDailyBurn at 100%. harsh := h.exp.ThreatLevel > 60 - newSupplies, _ := applyDailyBurn(h.exp.Supplies, harsh, h.exp.SiegeMode) + newSupplies, _ := applyDailyBurnP(h.exp.Supplies, harsh, h.exp.SiegeMode, h.supplyBurnRatePctOverride) h.exp.Supplies = newSupplies h.exp.CurrentDay++ @@ -394,6 +416,22 @@ func (h *expeditionHarness) resolvedNickDivisor() int { return liveSurpriseNickDivisor } +// resolvedNickFloor translates the harness profile's surprise-nick +// floor override into the int contract surpriseRoundNickF expects +// (<0 = use live tier-floor, >=0 = absolute floor value). The profile +// uses 0 for "use live" so the struct's zero-value is the safe +// default; -1 in the profile means "disable floor" (floor = 0). +func (h *expeditionHarness) resolvedNickFloor() int { + switch { + case h.surpriseNickFloorOverride == 0: + return -1 + case h.surpriseNickFloorOverride < 0: + return 0 + default: + return h.surpriseNickFloorOverride + } +} + // 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 { @@ -438,7 +476,7 @@ func (h *expeditionHarness) runHarnessFight(zone ZoneDefinition, elite bool) Com // spiral. Mirror live exactly so the harness measures the same // lever the live caller applies. nick := clampSurpriseNickD( - surpriseRoundNick(monster, int(zone.Tier)), + surpriseRoundNickF(monster, int(zone.Tier), h.resolvedNickFloor()), h.char.HPCurrent, h.char.HPMax, h.resolvedNickDivisor(), ) @@ -560,6 +598,10 @@ type expeditionHarness struct { // (eliteInterruptThreshold=19, threatDriftBase=3). eliteInterruptThresholdOverride int threatDriftBaseOverride int + // Phase 3-B levers. See expeditionBalanceProfile field doc; both + // zero-sentinel for "use live". + surpriseNickFloorOverride int + supplyBurnRatePctOverride 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 @@ -612,6 +654,8 @@ func runExpeditionBalanceTrial(p expeditionBalanceProfile, seed uint64) expediti surpriseNickDivisorOverride: p.SurpriseNickDivisorOverride, eliteInterruptThresholdOverride: p.EliteInterruptThresholdOverride, threatDriftBaseOverride: p.ThreatDriftBaseOverride, + surpriseNickFloorOverride: p.SurpriseNickFloorOverride, + supplyBurnRatePctOverride: p.SupplyBurnRatePctOverride, } for { res := h.advanceExpeditionOneDay() diff --git a/internal/plugin/expedition_balance_test.go b/internal/plugin/expedition_balance_test.go index 3ab1e89..94c8a67 100644 --- a/internal/plugin/expedition_balance_test.go +++ b/internal/plugin/expedition_balance_test.go @@ -720,6 +720,141 @@ func TestExpeditionBalance_Phase3_GlobalLeverSweep(t *testing.T) { } } +// 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 T1–T3 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) + } + } + } +} + // 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.