From d0a8505c76f4e3d17b715ca2233ca57df7e752d2 Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Fri, 15 May 2026 11:35:38 -0700 Subject: [PATCH] Phase 5a (diag): tier-wide sensitivity sweep on T2/T3/T5 under-band zones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 4-B closed the per-zone outliers but T2/T3/T5 sibling pairs sit below band as a group (T2 7-13% vs 62-82%; T3 3-14% vs 54-74%; T5 25-57% vs 36-56%). The plan doc's three tier-wide candidates (gear- tier centerline remap, per-tier elite threshold, ship burn=75) needed disambiguation before pulling any of them. TestExpeditionBalance_Phase5A_TierWideSensitivity runs the 6 problem zones through three one-axis sweeps holding the other two levers at Phase 3-B best (e=23, d=1, burn=50): Axis L player level {centerline-2, centerline, centerline+2} Axis E elite threshold {18, 23, 28} Axis B supply burn pct {40, 50, 60} 200 trials/cell × 6 zones × 9 cells = 10.8k trials; runs in 0.31s. Reuses the harness's traceFightStruct plumbing for elite-vs-standard fight share alongside comp/death/starve. Reading (full numbers in the plan doc): - Player level is the dominant lever at T2/T3. +2 levels lifts completion meaningfully; the elite gate and burn are inert. - Elite threshold is already at its sweet spot at e=23 — e=18 floods elites and collapses every tier; e=28 is flat. Per-tier threshold candidate is killed. - burn=75 globally is killed. burn=60 alone produces 36%/61% starve at T5; the Phase 3-B negative result holds tier-wide. - T5 dragons_lair is already in-band at the baseline (60.5%); the T5 gap is really an abyss_portal-only gap. - The level-bump path has a catch: L7 at T2 (max of design range, still gearTier=2) only reaches ~27%, well below the 62-82% band. Closing T2 properly likely needs a cross-gearTier-boundary centerline (T2 → L9 = gearTier 3) — a design call for Phase 5-B, not a knob twist. Diagnostic-only — no gates. -short skips. Plan doc updated with numbers and three Phase 5-B candidate moves (cross-bracket centerline bump recommended; player combat-math retune is the bigger option; lowering the band target is the fallback). Co-Authored-By: Claude Opus 4.7 (1M context) --- gogobee_expedition_difficulty.md | 82 ++++++++- internal/plugin/expedition_balance_test.go | 187 +++++++++++++++++++++ 2 files changed, 268 insertions(+), 1 deletion(-) diff --git a/gogobee_expedition_difficulty.md b/gogobee_expedition_difficulty.md index 9662d5a..fee2e38 100644 --- a/gogobee_expedition_difficulty.md +++ b/gogobee_expedition_difficulty.md @@ -393,7 +393,87 @@ gear-tier mapping at T2/T3 (the centerline level vs gear ladder, see per-tier instead of global, or ship `burn=75` as the new live global since Phase 4-B already converted T4 to playable at b=50. -### Phase 5 — optional MAD / second-order +### Phase 5 — tier-wide pass (T2/T3/T5 under-band) + +Phase 4-B closed the per-zone outliers but left T2/T3/T5 sibling pairs +below the target band as a *tier-wide* gap. The plan doc's three +candidate moves (gear-tier centerline remap, per-tier elite threshold, +ship `burn=75`) needed disambiguation before any of them got pulled — +hence a measure-first sub-phase. + +#### Phase 5-A — tier-wide sensitivity sweep (shipped) + +`TestExpeditionBalance_Phase5A_TierWideSensitivity` runs the 6 under- +band zones (T2: forest_shadows + sunken_temple; T3: +manor_blackspire + underforge; T5: abyss_portal + dragons_lair) through +three one-axis sweeps with the other two levers held at Phase 3-B best +(e=23, d=1, burn=50): player level ±2 around tier centerline, elite +threshold ∈ {18, 23, 28}, supply burn pct ∈ {40, 50, 60}. 200 +trials/cell × 6 zones × 9 cells = 10.8k trials; runs in <1s. + +Numbers (200 trials/cell, comp%): + +``` +T2 (band 62-82%) + L forest=0→6.5→19 sunken=0→13.5→34.5 STRONG slope + E forest=0.5→9→8 sunken=0→14.5→13 saturated @23; 18 collapses + B forest=3.5→7→6.5 sunken=14→15.5→11 inert +T3 (band 54-74%) + L manor=3→10.5→13.5 under=0→4→8.5 clear slope + E manor=0→9.5→9.5 under=0→3→4 saturated @23; 18 collapses + B manor=13→9.5→9.5 under=2.5→4→2.5 inert +T5 (band 36-56%) + L abyss=21.5→28.5→32 dragons=60→60.5→53 gentle; dragons in-band + E abyss=1→28.5→31 dragons=10→60.5→57.5 saturated @23; 18 collapses + B abyss=27.5→28.5→0 dragons=59→60.5→0 burn=60 STARVE CLIFF +``` + +**Reading:** +- **Player level is the dominant lever at T2/T3.** Every +2 levels + lifts completion meaningfully; the elite gate and supply burn are + inert or actively harmful around the Phase 3-B baseline. +- **Elite threshold is already at its sweet spot at e=23.** Going to + e=18 floods elites and collapses every tier; e=28 is flat. The per- + tier-threshold candidate from Phase 4 close-out is killed: there's + no per-tier value to be found in the explored range. +- **`burn=75` globally is killed.** burn=60 alone produces 36% / 61% + starve at T5; burn=75 would be catastrophic. The Phase 3-B negative + result holds tier-wide. burn must stay ≤50. +- **T5 dragons_lair is already in-band** at the baseline (60.5%). + The T5 tier-wide gap is really an abyss_portal gap (28.5%); the + Phase 4-B re-class lifted it from outlier to "low-but-not-isolated," + but it still sits below band by itself. +- **The level-bump path has a catch.** L7 at T2 (still inside the + L3-7 design range and gearTier=2) only reaches ~27% mean — still + well below the 62-82% band. Closing T2 to band likely requires a + *cross-gearTier-boundary* centerline (T2 → L9 = gearTier 3) and/or + a player-side combat-math adjustment, not just a within-bracket + centerline shift. That's a design call, not a knob twist. + +**Next:** Phase 5-B is a design choice between three concrete moves +that the measurement now backs: + +1. **Cross-bracket centerline bump** for T2 (→ L9) and T3 (→ L13), + plus T5 abyss-only level tweak. Lifts player gear bracket into the + next tier; closes the band gap directly but is a player-side power + move that affects the live game (chars at the centerline level + keep their existing gear; centerline only governs the harness's + measurement cell). The change is *in the harness*, but it admits + the live difficulty target was wrong for these tiers — Phase 5-C + would then carry it back to the live gear-acquisition curve. +2. **Player combat-math retune** at T2/T3 (HP scaling, proficiency + curve, attack-bonus floor) — bigger lift, touches the class-balance + harness too, deserves its own pass. +3. **Lower the band target** for T2/T3/T5 to ~30-50% mean and accept + that completion is meant to be hard. This is the "do nothing" + option dressed up as a design decision; surfaces the question of + whether the original 70% T2 target was ever the right number. + +Recommend option (1) for Phase 5-B since the harness already measures +it cleanly and the data points at it; option (3) is the fallback if +the cross-bracket numbers still don't close the band. + +### Phase 6 — 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_test.go b/internal/plugin/expedition_balance_test.go index 5412cef..993d162 100644 --- a/internal/plugin/expedition_balance_test.go +++ b/internal/plugin/expedition_balance_test.go @@ -1082,6 +1082,193 @@ func TestExpeditionBalance_Phase4A_OutlierDiagnostic(t *testing.T) { } } +// 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) + } + } + } +} + // 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