mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
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:
@@ -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 2–3 across the grid
|
||||||
|
with median 2.6–7.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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user