Phase 2 (diag): cadence sweep, gear-tier fix, lethality probe

Three Phase 2 diagnostic artifacts. No tuning knob has moved on
production code yet — these tests calibrate the harness and surface
the real first lever for next session.

1) Cadence calibration sweep
   TestExpeditionBalance_Phase2_CadenceCalibration sweeps
   HarvestRollsPerDay ∈ {1,2,3,4} across the full Phase 1 matrix and
   logs per-cell + per-tier completion. Required a new
   HarvestRollsPerDay field on expeditionBalanceProfile so cells can
   override the package-default constant. Finding: cadence is NOT the
   dominant lever — at rolls=1 the T1 cell only reaches 2%, with
   bimodal hp_left (100% survivors / 0% deaths). Killed the cadence
   hypothesis from Phase 1's commit message.

2) Gear-tier centerline fix
   phase1TierCenterline bumped for T3/T4/T5 (8→9, 11→13, 15→17). The
   shared gearTier ladder (5/9/13/17 boundaries) was placing T3/T4/T5
   centerlines one gear bracket *below* the zone's tier, so those
   cells fought with under-spec'd weapons/armor. New centerlines are
   the lowest level in each tier's design-doc range where gearTier ==
   tier. All centerlines still inside their design-doc ranges. Effect
   in the sweep at rolls=1: underforge T3 1.0% → 10.5% comp, underdark
   T4 flipped from pure combat-death to 14% starve (i.e. fighter now
   survives combat, runs out of food). Real bug, but small — the
   structural lethality problem remains.

3) Lethality probe + traceFight hook
   TestExpeditionBalance_Phase2_LethalityProbe runs 5 trials at the
   cleanest cell (T1 goblin_warrens L3 fighter, rolls=1) with a new
   optional traceFight hook on expeditionHarness that logs
   monster/AC/atk/HP-pre/HP-post/outcome per fight. Hook is nil in
   production runs, zero cost when unused. Finding: at T1, the
   InterruptElite branch keeps drawing Hobgoblin Warchief (AC 18,
   atk +5) from goblin_warrens' elite roster, and an L3 fighter has
   ~coin-flip odds against a CR-6-ish elite. One bad draw = dead;
   that's the bimodal hp_left fingerprint from the sweep. Non-elite
   draws (Worg, AC 13) play out as normal multi-round combats and
   are winnable.

Next-session lever choices, in order of suspected impact:
  - Roster gate: Hobgoblin Warchief out of (or weighted down in)
    the T1 elite pool — it's tier-disproportionate for goblin_warrens.
  - InterruptElite threshold: rarer elite-bracket draws at low threat
    so a single d20 swing doesn't equal expedition end.
  - Tier-floor cap on already-over-tier bestiary entries.

Plan doc: gogobee_expedition_difficulty.md §Phase 2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-05-15 09:39:31 -07:00
parent 881cbfca2f
commit 0f09a421bc
2 changed files with 227 additions and 21 deletions

View File

@@ -1,6 +1,7 @@
package plugin package plugin
import ( import (
"fmt"
"math/rand/v2" "math/rand/v2"
"sort" "sort"
) )
@@ -90,6 +91,12 @@ type expeditionBalanceProfile struct {
// CampType is the camp the player establishes each night. // CampType is the camp the player establishes each night.
// Standard is the spike default; Phase 3 may sweep this per cell. // Standard is the spike default; Phase 3 may sweep this per cell.
CampType string CampType string
// HarvestRollsPerDay overrides the harness's default combat-interrupt
// roll count for the daytime phase. Zero means "use the package
// default" (harnessHarvestRollsPerDay). Phase 2's cadence
// calibration sweep sets this per cell; everyday callers leave it
// zero.
HarvestRollsPerDay int
} }
// expeditionTrialResult is the outcome of one simulated expedition. // expeditionTrialResult is the outcome of one simulated expedition.
@@ -244,7 +251,7 @@ func (h *expeditionHarness) advanceExpeditionOneDay() expeditionTrialResult {
} }
// 4. Daytime combat-interrupt rolls. // 4. Daytime combat-interrupt rolls.
for i := 0; i < harnessHarvestRollsPerDay; i++ { for i := 0; i < h.rollsPerDay; i++ {
kind, _ := resolveCombatInterrupt( kind, _ := 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,
) )
@@ -366,7 +373,20 @@ func (h *expeditionHarness) runHarnessFight(zone ZoneDefinition, elite bool) Com
player.Stats.StartHP = h.char.HPCurrent player.Stats.StartHP = h.char.HPCurrent
} }
enemy := buildHarnessZoneEnemy(monster, int(zone.Tier)) enemy := buildHarnessZoneEnemy(monster, int(zone.Tier))
return simulateCombatWithRNG(player, enemy, dungeonCombatPhases, h.rng.r) hpBeforeFight := h.char.HPCurrent
result := simulateCombatWithRNG(player, enemy, dungeonCombatPhases, h.rng.r)
if h.traceFight != nil {
outcome := "WON"
if !result.PlayerWon {
outcome = "LOST"
}
h.traceFight(fmt.Sprintf(
"fight day=%d zone=%s tier=%d elite=%v monster=%s hp_max=%d nick=%d hp_pre=%d hp_post=%d enemy_ac=%d enemy_atk=%d → %s",
h.exp.CurrentDay, h.exp.ZoneID, int(zone.Tier), elite,
monster.Name, h.char.HPMax, nick, hpBeforeFight, result.PlayerEndHP,
enemy.Stats.AC, enemy.Stats.AttackBonus, outcome))
}
return result
} }
// pickHarnessZoneEnemy is the harness's RNG-driven analogue of // pickHarnessZoneEnemy is the harness's RNG-driven analogue of
@@ -447,10 +467,17 @@ func buildHarnessZoneEnemy(monster DnDMonsterTemplate, tier int) Combatant {
// expeditionHarness threads per-trial mutable state (RNG, expedition, // expeditionHarness threads per-trial mutable state (RNG, expedition,
// character HP carryover, encounter count) through the day loop. // character HP carryover, encounter count) through the day loop.
type expeditionHarness struct { type expeditionHarness struct {
exp *Expedition exp *Expedition
char *DnDCharacter char *DnDCharacter
rng *harnessRNG rng *harnessRNG
encounters int encounters int
rollsPerDay int // resolved from profile + default; never zero
// 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
// monster, or the combat fold itself is driving deaths. Nil in
// production runs — has zero cost when unused.
traceFight func(line string)
} }
// harnessRNG is a thin wrapper around math/rand/v2 so the d20 helper // harnessRNG is a thin wrapper around math/rand/v2 so the d20 helper
@@ -484,10 +511,15 @@ func runExpeditionBalanceTrial(p expeditionBalanceProfile, seed uint64) expediti
Subclass: p.Subclass, Subclass: p.Subclass,
Level: p.Level, Level: p.Level,
}) })
rolls := p.HarvestRollsPerDay
if rolls <= 0 {
rolls = harnessHarvestRollsPerDay
}
h := &expeditionHarness{ h := &expeditionHarness{
exp: exp, exp: exp,
char: char, char: char,
rng: newHarnessRNG(seed), rng: newHarnessRNG(seed),
rollsPerDay: rolls,
} }
for { for {
res := h.advanceExpeditionOneDay() res := h.advanceExpeditionOneDay()

View File

@@ -95,23 +95,31 @@ func TestExpeditionBalance_Phase0_SeedSpread(t *testing.T) {
} }
} }
// phase1TierCenterline maps each tier to its centerline player level // phase1TierCenterline maps each tier to its centerline player level.
// the median of the design-doc player-level range for the tier: // Originally the design doc's median per tier; bumped where the
// gear-ladder boundaries (gearTier in dnd_class_balance.go: 5/9/13/17)
// pushed the median into a gear bracket *below* the zone's tier. The
// harness's classLoadout is shared with the class-balance pass, and
// retuning its boundaries would shift class-balance numbers too, so the
// adjustment lands here instead: pick the lowest level inside each
// tier's design-doc level range that resolves to gearTier == tier.
// //
// T1 (L1-4) → 3 // T1 (L1-4) → 3 gearTier 1 = mundane ✓
// T2 (L3-7) → 5 // T2 (L3-7) → 5 gearTier 2 = +1 weapon/armor ✓
// T3 (L5-10) → 8 // T3 (L5-10) 9 gearTier 3 = +2 (was L8 → +1)
// T4 (L7-15) → 11 // T4 (L7-15) → 13 gearTier 4 = +3 (was L11 → +2)
// T5 (L10-20) → 15 // T5 (L10-20) → 17 gearTier 5 = +3 (was L15 → +3, but bumped
// for consistency with the rule, no stat impact)
// //
// One level per tier keeps the matrix at zones × 1, not zones × range. // All centerlines stay inside their design-doc range. One level per
// Phase 4 may widen this if a tier band looks level-sensitive. // tier keeps the matrix at zones × 1, not zones × range. Phase 4 may
// widen this if a tier band looks level-sensitive.
var phase1TierCenterline = map[ZoneTier]int{ var phase1TierCenterline = map[ZoneTier]int{
ZoneTierBeginner: 3, ZoneTierBeginner: 3,
ZoneTierApprentice: 5, ZoneTierApprentice: 5,
ZoneTierJourneyman: 8, ZoneTierJourneyman: 9,
ZoneTierVeteran: 11, ZoneTierVeteran: 13,
ZoneTierLegendary: 15, ZoneTierLegendary: 17,
} }
// TestExpeditionBalance_Phase1_FullMatrix is the Phase 1 baseline- // TestExpeditionBalance_Phase1_FullMatrix is the Phase 1 baseline-
@@ -254,6 +262,172 @@ func TestExpeditionBalance_Phase1_FullMatrix(t *testing.T) {
} }
} }
// TestExpeditionBalance_Phase2_CadenceCalibration sweeps the harness's
// daytime combat-interrupt cadence across {1,2,3,4} rolls/day and logs
// the full matrix per cadence. Diagnostic-only — no gates beyond the
// Phase 1 wiring pathologies.
//
// Why this exists: Phase 1's baseline came back uniformly 0% / 100%
// death at every cell, and the commit message named cadence as the
// suspected lever. We can't compare against a live trace
// (prod is too low-traffic; the corpus is one in-flight expedition
// with zero interrupt rows), so the cadence constant is itself a
// tunable. This test surfaces the curve so Phase 2's global-lever
// pass starts from a cadence where the matrix has signal — i.e. where
// T1 is roughly in the 7090% band without flooring T5 to 100%.
//
// Cell shape mirrors Phase 1: every registered zone × its tier-
// centerline level, Fighter, 200 trials. The only thing that varies
// across runs is HarvestRollsPerDay.
func TestExpeditionBalance_Phase2_CadenceCalibration(t *testing.T) {
if testing.Short() {
t.Skip("phase 2 cadence sweep is heavy; -short skips it")
}
const trialsPerCell = 200
const baseSeed uint64 = 0xCAFEC1DE
cadences := []int{1, 2, 3, 4}
t.Logf("phase2 cadence calibration — %d zones × %d cadences × %d trials, Fighter @ tier-centerline level",
len(zoneOrder), len(cadences), trialsPerCell)
for _, rolls := range cadences {
// Per-tier aggregation for the headline view.
type tierStat struct {
cells int
sumC float64
lo float64
hi float64
}
tierStats := map[ZoneTier]*tierStat{}
t.Logf("─── HarvestRollsPerDay=%d ───", rolls)
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,
HarvestRollsPerDay: rolls,
}
// Seed schedule: same base + cell offset as Phase 1, plus a
// cadence-dependent salt so different cadences don't sample
// the same correlated streams.
seed := baseSeed + uint64(i)*1_000_003 + uint64(rolls)*7919
r := runExpeditionBalanceCell(profile, trialsPerCell, seed)
c := r.CompletionRate() * 100
t.Logf("CELL rolls=%d %-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%%",
rolls, 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 rolls=%d T%d n=%d mean_comp=%5.1f%% spread=%5.1f pp",
rolls, tier, ts.cells, mean, ts.hi-ts.lo)
}
}
}
// TestExpeditionBalance_Phase2_LethalityProbe runs a handful of trials
// at the cleanest cell (T1 goblin_warrens L3 Fighter, gear-aligned so
// gear mismatch isn't a factor) and logs per-fight details: monster,
// max HP, surprise nick, HP pre/post fight, enemy AC/atk, outcome.
//
// The cadence sweep (TestExpeditionBalance_Phase2_CadenceCalibration)
// proved cadence isn't the dominant lever — even at rolls=1 the T1
// cell sits at 2% completion with bimodal hp_left (100% for survivors,
// 0% for the dead). This probe is the next diagnostic step: see
// whether the lethality comes from the pre-combat nick, the picked
// monster's tier-floored stats, or the combat fold itself.
//
// Diagnostic-only — no assertions beyond "produced trace lines."
// Next session reads the output and picks Phase 2's real first lever.
func TestExpeditionBalance_Phase2_LethalityProbe(t *testing.T) {
if testing.Short() {
t.Skip("phase 2 lethality probe writes verbose log; -short skips it")
}
const trials = 5
const baseSeed uint64 = 0x1E7A11
profile := expeditionBalanceProfile{
ZoneID: ZoneGoblinWarrens,
Class: ClassFighter,
Level: 3,
Supplies: makeSupplies(ZoneTierBeginner, SupplyPurchase{StandardPacks: 3}),
CampType: CampTypeStandard,
HarvestRollsPerDay: 1, // sparse cadence so each fight is legible
}
t.Logf("phase2 lethality probe — %d trials at %s L%d %s (rolls=1)",
trials, profile.ZoneID, profile.Level, profile.Class)
for trial := 0; trial < trials; trial++ {
exp := newHarnessExpedition(profile)
char := buildHarnessCharacter(classBalanceProfile{
Class: profile.Class,
Level: profile.Level,
})
t.Logf("─── trial %d: hp_max=%d ───", trial, char.HPMax)
h := &expeditionHarness{
exp: exp,
char: char,
rng: newHarnessRNG(baseSeed + uint64(trial)),
rollsPerDay: profile.HarvestRollsPerDay,
traceFight: func(line string) {
t.Logf(" %s", line)
},
}
for {
res := h.advanceExpeditionOneDay()
if res.EndedReason != "" {
t.Logf("END trial %d: reason=%s days=%d threat=%d encs=%d hp_left_pct=%.1f%%",
trial, res.EndedReason, res.DaysElapsed, res.ThreatAtEnd,
res.CombatEncounters, res.HPRemainingPct*100)
break
}
}
}
}
// 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.