From 235122f2a13c3ce8989ffa0eec704557b1c6872e Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Fri, 15 May 2026 11:00:04 -0700 Subject: [PATCH] Phase 3a (sweep): elite-bracket + drift sweep, strong partial positive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wired two harness lever overrides — EliteInterruptThresholdOverride (live=19) and ThreatDriftBaseOverride (live=3) — into the day-loop in expedition_balance.go. Live runHarvestInterrupt / dailyThreatDrift are untouched; the harness re-buckets Standard↔Elite after the live call. TestExpeditionBalance_Phase3_GlobalLeverSweep walks a 3×3 grid (elite ∈ {17,19,23} × drift ∈ {1,3,5}) over the Phase 1 matrix at 200 trials/cell. -short skips. Elite-bracket threshold is the dominant lever for T1–T3. At e=23/d=1: T1 mean 24.0% (goblin_warrens 40.5%), T2 7.2% (sunken_temple 14.5%), T3 1.8%. Still well below target bands (T1 70-90%, T2 62-82%) — the lever moves the needle in the right direction but cannot land any tier on-band alone. T4/T5 fingerprint changed but didn't lift. At e=23 dragons_lair death drops 60% → 24% but starvation climbs to 75% — the fighter now survives elites long enough to run out of supplies. T4 cells shift the same way. Indicates a second lever is needed for the higher tiers (standard-fight survivability or supply margin), to be swept in Phase 3-B. Plan doc updated. Renumbered the trailing "per-zone outlier pass" to Phase 4 and "MAD / second-order" to Phase 5 so the test names align with phase numbers going forward. -short suite: same 2 pre-existing failures (TestAdv2Scenario_ZoneRunGoblinWarrens, TestMageSpellbookLineInRender). Co-Authored-By: Claude Opus 4.7 (1M context) --- gogobee_expedition_difficulty.md | 41 +++++++- internal/plugin/expedition_balance.go | 46 ++++++++- internal/plugin/expedition_balance_test.go | 112 +++++++++++++++++++++ 3 files changed, 193 insertions(+), 6 deletions(-) diff --git a/gogobee_expedition_difficulty.md b/gogobee_expedition_difficulty.md index 469c066..b8cd3b0 100644 --- a/gogobee_expedition_difficulty.md +++ b/gogobee_expedition_difficulty.md @@ -201,7 +201,7 @@ before the dragon roll lands. Stat-block tuning of the bosses themselves is off-limits per the Phase 3 constraint (bestiary is shared across systems). -### Phase 2 — global lever tuning +### Phase 3 — global lever tuning Tune the knobs that affect every zone uniformly: @@ -218,9 +218,42 @@ Tune the knobs that affect every zone uniformly: Goal: every tier centerline lands within ±5pp of target. Bands stay ±10pp. -**Exit:** test asserts the ±10pp band per tier; commit. +#### Phase 3-A — elite-bracket + drift sweep (shipped) -### Phase 3 — per-zone outlier pass +Wired two harness overrides +(`EliteInterruptThresholdOverride`, `ThreatDriftBaseOverride`) and +swept a 3×3 grid (elite ∈ {17, 19, 23} × drift ∈ {1, 3, 5}) over +the Phase 1 matrix. Test: `TestExpeditionBalance_Phase3_GlobalLeverSweep`. + +**Strong partial positive.** The elite-bracket threshold is the +dominant lever for T1–T3; threat drift is a small modulator on top. + +| (elite, drift) | T1 mean | T2 mean | T3 mean | T4 mean | T5 mean | +|----------------|--------:|--------:|--------:|--------:|--------:| +| 17, 1 (more elites) | 0.5% | 0.0% | 0.0% | 0.0% | 0.0% | +| 19, 3 (live) | 3.0% | 0.2% | 0.0% | 0.0% | 0.0% | +| 23, 1 (rare elites) | **24.0%** | **7.2%** | **1.8%** | 0.0% | 0.0% | +| 23, 3 | 16.5% | 4.0% | 2.0% | 0.0% | 0.0% | +| 23, 5 | 11.8% | 3.2% | 0.2% | 0.0% | 0.0% | + +Best cell at e=23/d=1: goblin_warrens 40.5%, sunken_temple 14.5%. +Still well below the T1 70-90% / T2 62-82% target bands — the lever +moves the needle in the right direction but cannot land any tier +on-band alone. + +**T4/T5 fingerprint changed but didn't lift.** At e=23 the dragons_lair +death-fraction drops from ~60% → 24% but starvation climbs to 75% — +the fighter now survives elites long enough to run out of supplies. +T4 (underdark/feywild) likewise sees death drop and starvation rise. +Indicates a second lever is needed for the higher tiers: either +standard-fight survivability (surprise-nick floor / standard roster +density) or supply margin. + +**Next:** Phase 3-B sweeps surprise-round nick and supply-burn knobs +on top of e=23/d=1 to push T1-T3 toward target band and lift T4-T5 +off the floor. + +### Phase 4 — per-zone outlier pass After globals settle, the matrix will name outlier zones — tiers where one zone reads dramatically off-band while sibling zones are @@ -238,7 +271,7 @@ not the monsters themselves. **Exit:** all per-zone cells within band; commit. -### Phase 4 — optional MAD / second-order +### Phase 5 — optional MAD / second-order If post-Phase-3 the bands hold but feel wrong subjectively (e.g. "median days too short", "no expedition ever hits Siege"), add a diff --git a/internal/plugin/expedition_balance.go b/internal/plugin/expedition_balance.go index 3200f89..63bccbf 100644 --- a/internal/plugin/expedition_balance.go +++ b/internal/plugin/expedition_balance.go @@ -103,6 +103,24 @@ type expeditionBalanceProfile struct { // TestExpeditionBalance_Phase2_LeverSweep. RetreatThreatBumpOverride int SurpriseNickDivisorOverride int + + // Phase 3 global-lever overrides. Zero means "use live": + // + // EliteInterruptThresholdOverride — total (raw + tier + threat-mod) + // at which the harvest-interrupt bracket promotes from Standard + // to Elite. Live value is 19 (dnd_expedition_combat.go: bracket + // table). Higher = fewer elites; lower = more. + // + // ThreatDriftBaseOverride — daily threat-clock drift base before + // DM-mood mod. Live value is 3 (dnd_expedition_threat.go: + // dailyThreatDrift). Lower = slower AC/init creep + slower + // bracket promotion via the +1/20-over-40 threat-mod path. + // + // Both wired into the harness day loop only; live callers go + // through the shipped constants. See + // TestExpeditionBalance_Phase3_GlobalLeverSweep. + EliteInterruptThresholdOverride int + ThreatDriftBaseOverride int } // expeditionTrialResult is the outcome of one simulated expedition. @@ -243,9 +261,15 @@ func (h *expeditionHarness) advanceExpeditionOneDay() expeditionTrialResult { // 3. Daily threat drift — math is in dailyThreatDrift (pure); // applyDailyThreatDrift's DB write is bypassed but the in-memory - // mutation is mirrored here. + // mutation is mirrored here. Phase 3 sweep can override the base + // drift constant via the harness profile. if !h.exp.BossDefeated { delta, _ := dailyThreatDrift(h.exp.DMMood) + if h.threatDriftBaseOverride > 0 { + // dailyThreatDrift returns base(3) + mood-mod. Swap the + // base while preserving the mood-mod component. + delta = h.threatDriftBaseOverride + (delta - 3) + } h.exp.ThreatLevel += delta if h.exp.ThreatLevel < 0 { h.exp.ThreatLevel = 0 @@ -258,9 +282,21 @@ func (h *expeditionHarness) advanceExpeditionOneDay() expeditionTrialResult { // 4. Daytime combat-interrupt rolls. for i := 0; i < h.rollsPerDay; i++ { - kind, _ := resolveCombatInterrupt( + kind, total := resolveCombatInterrupt( h.exp.ThreatLevel, int(zone.Tier), h.char.Class, h.exp.ZoneID, h.rng.d20, ) + // Phase 3 lever: re-bucket Standard ↔ Elite using the override + // threshold instead of the live 19+ cutoff. Patrol (≥22) and + // the Noise/None floor are unchanged. + if h.eliteInterruptThresholdOverride > 0 && + (kind == InterruptStandard || kind == InterruptElite) && + total < 22 { + if total >= h.eliteInterruptThresholdOverride { + kind = InterruptElite + } else if total >= 15 { + kind = InterruptStandard + } + } switch kind { case InterruptNone: continue @@ -520,6 +556,10 @@ type expeditionHarness struct { // live behavior (retreatThreatBump=5, /5 surprise-nick divisor). retreatThreatBumpOverride int surpriseNickDivisorOverride int + // Lever overrides for the Phase 3 sweep. Zero ⇒ live behavior + // (eliteInterruptThreshold=19, threatDriftBase=3). + eliteInterruptThresholdOverride int + threatDriftBaseOverride 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 @@ -570,6 +610,8 @@ func runExpeditionBalanceTrial(p expeditionBalanceProfile, seed uint64) expediti rollsPerDay: rolls, retreatThreatBumpOverride: p.RetreatThreatBumpOverride, surpriseNickDivisorOverride: p.SurpriseNickDivisorOverride, + eliteInterruptThresholdOverride: p.EliteInterruptThresholdOverride, + threatDriftBaseOverride: p.ThreatDriftBaseOverride, } for { res := h.advanceExpeditionOneDay() diff --git a/internal/plugin/expedition_balance_test.go b/internal/plugin/expedition_balance_test.go index 06f42a6..3ab1e89 100644 --- a/internal/plugin/expedition_balance_test.go +++ b/internal/plugin/expedition_balance_test.go @@ -608,6 +608,118 @@ func TestExpeditionBalance_Phase2_LeverSweep(t *testing.T) { } } +// 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) + } + } + } +} + // 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.