mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Phase 3a (sweep): elite-bracket + drift sweep, strong partial positive
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) <noreply@anthropic.com>
This commit is contained in:
@@ -201,7 +201,7 @@ before the dragon roll lands.
|
|||||||
Stat-block tuning of the bosses themselves is off-limits per the
|
Stat-block tuning of the bosses themselves is off-limits per the
|
||||||
Phase 3 constraint (bestiary is shared across systems).
|
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:
|
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
|
Goal: every tier centerline lands within ±5pp of target. Bands stay
|
||||||
±10pp.
|
±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
|
After globals settle, the matrix will name outlier zones — tiers
|
||||||
where one zone reads dramatically off-band while sibling zones are
|
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.
|
**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
|
If post-Phase-3 the bands hold but feel wrong subjectively
|
||||||
(e.g. "median days too short", "no expedition ever hits Siege"), add a
|
(e.g. "median days too short", "no expedition ever hits Siege"), add a
|
||||||
|
|||||||
@@ -103,6 +103,24 @@ type expeditionBalanceProfile struct {
|
|||||||
// TestExpeditionBalance_Phase2_LeverSweep.
|
// TestExpeditionBalance_Phase2_LeverSweep.
|
||||||
RetreatThreatBumpOverride int
|
RetreatThreatBumpOverride int
|
||||||
SurpriseNickDivisorOverride 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.
|
// 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);
|
// 3. Daily threat drift — math is in dailyThreatDrift (pure);
|
||||||
// applyDailyThreatDrift's DB write is bypassed but the in-memory
|
// 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 {
|
if !h.exp.BossDefeated {
|
||||||
delta, _ := dailyThreatDrift(h.exp.DMMood)
|
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
|
h.exp.ThreatLevel += delta
|
||||||
if h.exp.ThreatLevel < 0 {
|
if h.exp.ThreatLevel < 0 {
|
||||||
h.exp.ThreatLevel = 0
|
h.exp.ThreatLevel = 0
|
||||||
@@ -258,9 +282,21 @@ func (h *expeditionHarness) advanceExpeditionOneDay() expeditionTrialResult {
|
|||||||
|
|
||||||
// 4. Daytime combat-interrupt rolls.
|
// 4. Daytime combat-interrupt rolls.
|
||||||
for i := 0; i < h.rollsPerDay; i++ {
|
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,
|
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 {
|
switch kind {
|
||||||
case InterruptNone:
|
case InterruptNone:
|
||||||
continue
|
continue
|
||||||
@@ -520,6 +556,10 @@ type expeditionHarness struct {
|
|||||||
// live behavior (retreatThreatBump=5, /5 surprise-nick divisor).
|
// live behavior (retreatThreatBump=5, /5 surprise-nick divisor).
|
||||||
retreatThreatBumpOverride int
|
retreatThreatBumpOverride int
|
||||||
surpriseNickDivisorOverride 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
|
// traceFight, if non-nil, is invoked once per fight inside
|
||||||
// runHarnessFight with a human-readable summary. Used by the
|
// runHarnessFight with a human-readable summary. Used by the
|
||||||
// Phase 2 lethality probe to spot whether the nick, the picked
|
// Phase 2 lethality probe to spot whether the nick, the picked
|
||||||
@@ -570,6 +610,8 @@ func runExpeditionBalanceTrial(p expeditionBalanceProfile, seed uint64) expediti
|
|||||||
rollsPerDay: rolls,
|
rollsPerDay: rolls,
|
||||||
retreatThreatBumpOverride: p.RetreatThreatBumpOverride,
|
retreatThreatBumpOverride: p.RetreatThreatBumpOverride,
|
||||||
surpriseNickDivisorOverride: p.SurpriseNickDivisorOverride,
|
surpriseNickDivisorOverride: p.SurpriseNickDivisorOverride,
|
||||||
|
eliteInterruptThresholdOverride: p.EliteInterruptThresholdOverride,
|
||||||
|
threatDriftBaseOverride: p.ThreatDriftBaseOverride,
|
||||||
}
|
}
|
||||||
for {
|
for {
|
||||||
res := h.advanceExpeditionOneDay()
|
res := h.advanceExpeditionOneDay()
|
||||||
|
|||||||
@@ -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
|
// 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
|
// per-tier log line reads in one logical chunk without pulling in
|
||||||
// strings.Join's import for production code.
|
// strings.Join's import for production code.
|
||||||
|
|||||||
Reference in New Issue
Block a user