Phase 5a (diag): tier-wide sensitivity sweep on T2/T3/T5 under-band zones

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) <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-05-15 11:35:38 -07:00
parent cf092742bb
commit d0a8505c76
2 changed files with 268 additions and 1 deletions

View File

@@ -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

View File

@@ -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