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:
@@ -232,14 +232,30 @@ func surpriseRoundNick(m DnDMonsterTemplate, tier int) int {
|
||||
//
|
||||
// Tuning surface: the /5 divisor is the wounded-fighter lethality knob.
|
||||
// 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 {
|
||||
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 {
|
||||
return 0
|
||||
}
|
||||
if divisor <= 0 {
|
||||
divisor = liveSurpriseNickDivisor
|
||||
}
|
||||
nick := rawNick
|
||||
if hpCurrent < hpMax {
|
||||
cap := hpCurrent / 5
|
||||
cap := hpCurrent / divisor
|
||||
if cap < 1 {
|
||||
cap = 1
|
||||
}
|
||||
|
||||
@@ -97,6 +97,12 @@ type expeditionBalanceProfile struct {
|
||||
// calibration sweep sets this per cell; everyday callers leave it
|
||||
// zero.
|
||||
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.
|
||||
@@ -274,7 +280,7 @@ func (h *expeditionHarness) advanceExpeditionOneDay() expeditionTrialResult {
|
||||
// dnd_expedition_combat.go (Phase 2). Run continues
|
||||
// with carryover HP and a threat bump; the harvest
|
||||
// slot's loot is just forfeit.
|
||||
h.exp.ThreatLevel += retreatThreatBump
|
||||
h.exp.ThreatLevel += h.resolvedRetreatBump()
|
||||
if h.exp.ThreatLevel > 100 {
|
||||
h.exp.ThreatLevel = 100
|
||||
}
|
||||
@@ -333,6 +339,25 @@ func (h *expeditionHarness) advanceExpeditionOneDay() 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
|
||||
// (days elapsed, threat at end, encounter count, HP%).
|
||||
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
|
||||
// spiral. Mirror live exactly so the harness measures the same
|
||||
// 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
|
||||
|
||||
player := buildHarnessPlayer(h.char)
|
||||
@@ -487,6 +516,10 @@ type expeditionHarness struct {
|
||||
rng *harnessRNG
|
||||
encounters int
|
||||
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
|
||||
// runHarnessFight with a human-readable summary. Used by the
|
||||
// Phase 2 lethality probe to spot whether the nick, the picked
|
||||
@@ -531,10 +564,12 @@ func runExpeditionBalanceTrial(p expeditionBalanceProfile, seed uint64) expediti
|
||||
rolls = harnessHarvestRollsPerDay
|
||||
}
|
||||
h := &expeditionHarness{
|
||||
exp: exp,
|
||||
char: char,
|
||||
rng: newHarnessRNG(seed),
|
||||
rollsPerDay: rolls,
|
||||
exp: exp,
|
||||
char: char,
|
||||
rng: newHarnessRNG(seed),
|
||||
rollsPerDay: rolls,
|
||||
retreatThreatBumpOverride: p.RetreatThreatBumpOverride,
|
||||
surpriseNickDivisorOverride: p.SurpriseNickDivisorOverride,
|
||||
}
|
||||
for {
|
||||
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
|
||||
// per-tier log line reads in one logical chunk without pulling in
|
||||
// strings.Join's import for production code.
|
||||
|
||||
Reference in New Issue
Block a user