Phase 2 (sweep): lever-tuning sweep, negative result

Sweep the two knobs surfaced by Phases 2a/2b — retreatThreatBump
and clampSurpriseNick's wounded-entrant divisor — across a full
3×4 grid (bump ∈ {2, 5, 10} × divisor ∈ {3, 5, 8, 12}) at 200
trials/cell across every matrix zone.

Wiring is harness-only: clampSurpriseNick keeps its live shape and
delegates to a new clampSurpriseNickD(divisor) variant; the harness
profile gains RetreatThreatBumpOverride/SurpriseNickDivisorOverride
fields threaded onto expeditionHarness; resolvedRetreatBump and
resolvedNickDivisor pick override-or-live. Zero on either field
falls back to the shipped value so live runHarvestInterrupt is
untouched.

Sweep test: TestExpeditionBalance_Phase2_LeverSweep, -short skipped,
mirrors Phase2_CadenceCalibration's per-tier digest shape.

Outcome: across 24,000 trial-cells (12 lever combos × 10 zones
× 200 trials), every cell reports 0.0% completion / ~100% death.
The knobs are inert on the headline metric — even (b=2, d=12)
can't lift any tier off the floor. Confirms the post-2b
tier-lethality trace: remaining deaths are fresh-entry elite
one-shots (Warchief, Hag, Roper, Young Red Dragon), not chained-
interrupt cascades. Justifies Phase 2c (roster dilution) rather
than further tuning of these two levers.

Plan doc updated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-05-15 10:39:45 -07:00
parent 159daf8fc8
commit 8cdd64b383
4 changed files with 198 additions and 8 deletions

View File

@@ -130,6 +130,32 @@ Roper, Young Red Dragon) — that's the Phase 2c roster signal.
**Tuning surface:** the `/5` divisor in `clampSurpriseNick` is the **Tuning surface:** the `/5` divisor in `clampSurpriseNick` is the
wounded-fighter lethality knob. wounded-fighter lethality knob.
### Phase 2 lever sweep (shipped) — negative result
Before adding Phase 2c, the two surfaced knobs (`retreatThreatBump`,
`clampSurpriseNick` divisor) got swept across a full 3×4 grid
(bump ∈ {2, 5, 10} × divisor ∈ {3, 5, 8, 12}) at 200 trials/cell
across every zone in the matrix. Wired via
`expeditionBalanceProfile.{RetreatThreatBumpOverride,
SurpriseNickDivisorOverride}` threaded into `expeditionHarness`;
the live caller (`runHarvestInterrupt`) is untouched.
Sweep file: `TestExpeditionBalance_Phase2_LeverSweep` in
`expedition_balance_test.go`. `-short` skips.
**Outcome:** every one of the 24,000 trial-cells reports 0.0%
completion / ~100% death. The two knobs are inert on the headline
metric — even the gentlest combo (b=2, d=12) cannot lift any tier
off the floor. Median days-to-end stays at 23 across the grid
with median 2.67.3 encounters before death.
**Read:** confirms the post-2b tier-lethality trace — deaths are
fresh-entry elite one-shots (Warchief @ HP19/20, Hag @ HP41, Roper,
Young Red Dragon), not chained-interrupt cascades. The two levers
target the cascade path that's now mostly resolved; they have no
remaining death-fraction to convert. Phase 2c (roster dilution) is
justified rather than premature.
### Phase 2c — roster gate (next) ### Phase 2c — roster gate (next)
Promote softer mid-tier monsters to `IsElite: true` in zones whose Promote softer mid-tier monsters to `IsElite: true` in zones whose

View File

@@ -232,14 +232,30 @@ func surpriseRoundNick(m DnDMonsterTemplate, tier int) int {
// //
// Tuning surface: the /5 divisor is the wounded-fighter lethality knob. // Tuning surface: the /5 divisor is the wounded-fighter lethality knob.
// Tighter (e.g. /10) is gentler; looser (/3) re-opens the cascade. See // Tighter (e.g. /10) is gentler; looser (/3) re-opens the cascade. See
// gogobee_expedition_difficulty.md Phase 2b. // gogobee_expedition_difficulty.md Phase 2b. liveSurpriseNickDivisor
// names the shipped value; the harness Phase 2 lever sweep
// (TestExpeditionBalance_Phase2_LeverSweep) calls clampSurpriseNickD
// directly with alternate divisors. Live callers always go through
// clampSurpriseNick.
const liveSurpriseNickDivisor = 5
func clampSurpriseNick(rawNick, hpCurrent, hpMax int) int { func clampSurpriseNick(rawNick, hpCurrent, hpMax int) int {
return clampSurpriseNickD(rawNick, hpCurrent, hpMax, liveSurpriseNickDivisor)
}
// clampSurpriseNickD is the divisor-parameterized form used by the
// sim harness lever sweep. divisor <= 0 falls back to the shipped
// value so a zero-valued harness override behaves as "use live."
func clampSurpriseNickD(rawNick, hpCurrent, hpMax, divisor int) int {
if rawNick <= 0 || hpCurrent <= 0 { if rawNick <= 0 || hpCurrent <= 0 {
return 0 return 0
} }
if divisor <= 0 {
divisor = liveSurpriseNickDivisor
}
nick := rawNick nick := rawNick
if hpCurrent < hpMax { if hpCurrent < hpMax {
cap := hpCurrent / 5 cap := hpCurrent / divisor
if cap < 1 { if cap < 1 {
cap = 1 cap = 1
} }

View File

@@ -97,6 +97,12 @@ type expeditionBalanceProfile struct {
// calibration sweep sets this per cell; everyday callers leave it // calibration sweep sets this per cell; everyday callers leave it
// zero. // zero.
HarvestRollsPerDay int HarvestRollsPerDay int
// Phase 2 lever overrides (sweep-only). Zero means "use the live
// shipped value." Wired into the harness day loop / runHarnessFight
// path; the live runHarvestInterrupt is untouched. See
// TestExpeditionBalance_Phase2_LeverSweep.
RetreatThreatBumpOverride int
SurpriseNickDivisorOverride int
} }
// expeditionTrialResult is the outcome of one simulated expedition. // expeditionTrialResult is the outcome of one simulated expedition.
@@ -274,7 +280,7 @@ func (h *expeditionHarness) advanceExpeditionOneDay() expeditionTrialResult {
// dnd_expedition_combat.go (Phase 2). Run continues // dnd_expedition_combat.go (Phase 2). Run continues
// with carryover HP and a threat bump; the harvest // with carryover HP and a threat bump; the harvest
// slot's loot is just forfeit. // slot's loot is just forfeit.
h.exp.ThreatLevel += retreatThreatBump h.exp.ThreatLevel += h.resolvedRetreatBump()
if h.exp.ThreatLevel > 100 { if h.exp.ThreatLevel > 100 {
h.exp.ThreatLevel = 100 h.exp.ThreatLevel = 100
} }
@@ -333,6 +339,25 @@ func (h *expeditionHarness) advanceExpeditionOneDay() expeditionTrialResult {
return expeditionTrialResult{} return expeditionTrialResult{}
} }
// resolvedRetreatBump returns the per-harness lever override or the
// shipped retreatThreatBump if no override is set. Zero is the "use
// live" sentinel so the field's zero value remains safe.
func (h *expeditionHarness) resolvedRetreatBump() int {
if h.retreatThreatBumpOverride > 0 {
return h.retreatThreatBumpOverride
}
return retreatThreatBump
}
// resolvedNickDivisor returns the per-harness override or the shipped
// liveSurpriseNickDivisor. Same zero-sentinel contract as above.
func (h *expeditionHarness) resolvedNickDivisor() int {
if h.surpriseNickDivisorOverride > 0 {
return h.surpriseNickDivisorOverride
}
return liveSurpriseNickDivisor
}
// terminate stamps the final trial result with shared bookkeeping // terminate stamps the final trial result with shared bookkeeping
// (days elapsed, threat at end, encounter count, HP%). // (days elapsed, threat at end, encounter count, HP%).
func (h *expeditionHarness) terminate(reason string, completed, died, starved bool) expeditionTrialResult { func (h *expeditionHarness) terminate(reason string, completed, died, starved bool) expeditionTrialResult {
@@ -376,7 +401,11 @@ func (h *expeditionHarness) runHarnessFight(zone ZoneDefinition, elite bool) Com
// (clampSurpriseNick) that breaks the chained-interrupt death // (clampSurpriseNick) that breaks the chained-interrupt death
// spiral. Mirror live exactly so the harness measures the same // spiral. Mirror live exactly so the harness measures the same
// lever the live caller applies. // lever the live caller applies.
nick := clampSurpriseNick(surpriseRoundNick(monster, int(zone.Tier)), h.char.HPCurrent, h.char.HPMax) nick := clampSurpriseNickD(
surpriseRoundNick(monster, int(zone.Tier)),
h.char.HPCurrent, h.char.HPMax,
h.resolvedNickDivisor(),
)
h.char.HPCurrent -= nick h.char.HPCurrent -= nick
player := buildHarnessPlayer(h.char) player := buildHarnessPlayer(h.char)
@@ -487,6 +516,10 @@ type expeditionHarness struct {
rng *harnessRNG rng *harnessRNG
encounters int encounters int
rollsPerDay int // resolved from profile + default; never zero rollsPerDay int // resolved from profile + default; never zero
// Lever overrides for the Phase 2 sweep. Zero on both fields ⇒
// live behavior (retreatThreatBump=5, /5 surprise-nick divisor).
retreatThreatBumpOverride int
surpriseNickDivisorOverride 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
@@ -531,10 +564,12 @@ func runExpeditionBalanceTrial(p expeditionBalanceProfile, seed uint64) expediti
rolls = harnessHarvestRollsPerDay rolls = harnessHarvestRollsPerDay
} }
h := &expeditionHarness{ h := &expeditionHarness{
exp: exp, exp: exp,
char: char, char: char,
rng: newHarnessRNG(seed), rng: newHarnessRNG(seed),
rollsPerDay: rolls, rollsPerDay: rolls,
retreatThreatBumpOverride: p.RetreatThreatBumpOverride,
surpriseNickDivisorOverride: p.SurpriseNickDivisorOverride,
} }
for { for {
res := h.advanceExpeditionOneDay() res := h.advanceExpeditionOneDay()

View File

@@ -495,6 +495,119 @@ func TestExpeditionBalance_Phase2_TierLethality(t *testing.T) {
} }
} }
// TestExpeditionBalance_Phase2_LeverSweep is the tuning sweep the
// plan doc's Phase 2 "global lever tuning" step calls for. Phases 2a
// and 2b surfaced two knobs — retreatThreatBump (the threat penalty
// on a wounded-but-alive break-off) and the surprise-nick wounded-
// entrant divisor (the wounded-fighter lethality clamp) — but the
// post-2b matrix still reads uniform 0% completion across every
// tier. The question this test answers: do alternate settings of
// those two knobs lift the centerline off the floor, and if so by
// how much per tier?
//
// Shape mirrors Phase2_CadenceCalibration: one cell per tier (zone
// at tier centerline level), Fighter, default cadence (rolls=4),
// 200 trials, full grid of (bump × divisor). Diagnostic-only — no
// assertions; the plan-doc test that gates the ±10pp band is added
// after this sweep names a winner.
//
// Cost: 9 lever combos × |zones| × 200 trials. -short skips the
// sweep entirely.
func TestExpeditionBalance_Phase2_LeverSweep(t *testing.T) {
if testing.Short() {
t.Skip("phase 2 lever sweep is heavy; -short skips it")
}
const trialsPerCell = 200
const baseSeed uint64 = 0xB001E5
// Live values are bump=5 (Phase 2a) and divisor=5 (Phase 2b). The
// sweep walks two settings tighter (gentler) and one looser
// (harsher) for each knob so the live point sits in the middle of
// the grid and we can read the slope in both directions.
bumps := []int{2, 5, 10} // threat-bump per retreat
divisors := []int{3, 5, 8, 12} // /N wounded-nick divisor; bigger = gentler
t.Logf("phase2 lever sweep — %d zones × %d bump × %d divisor × %d trials, Fighter @ tier centerline (rolls=4)",
len(zoneOrder), len(bumps), len(divisors), trialsPerCell)
type tierStat struct {
cells int
sumC float64
lo float64
hi float64
}
for _, bump := range bumps {
for _, div := range divisors {
tierStats := map[ZoneTier]*tierStat{}
t.Logf("─── retreatThreatBump=%d surpriseNickDivisor=%d ───", bump, div)
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,
RetreatThreatBumpOverride: bump,
SurpriseNickDivisorOverride: div,
}
// Seed schedule: Phase 1 base + cell offset, plus a
// lever-dependent salt so each (bump, div) cell sees a
// fresh RNG stream rather than aliasing earlier sweeps.
seed := baseSeed + uint64(i)*1_000_003 + uint64(bump)*101 + uint64(div)*7919
r := runExpeditionBalanceCell(profile, trialsPerCell, seed)
c := r.CompletionRate() * 100
t.Logf("CELL b=%-2d d=%-2d %-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%%",
bump, div, 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 b=%-2d d=%-2d T%d n=%d mean_comp=%5.1f%% spread=%5.1f pp",
bump, div, 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.