mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user