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
import (
"fmt"
"math/rand/v2"
"sort"
)
@@ -90,6 +91,12 @@ type expeditionBalanceProfile struct {
// CampType is the camp the player establishes each night.
// Standard is the spike default; Phase 3 may sweep this per cell.
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.
@@ -244,7 +251,7 @@ func (h *expeditionHarness) advanceExpeditionOneDay() expeditionTrialResult {
}
// 4. Daytime combat-interrupt rolls.
for i := 0; i < harnessHarvestRollsPerDay; i++ {
for i := 0; i < h.rollsPerDay; i++ {
kind, _ := resolveCombatInterrupt(
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
}
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
@@ -447,10 +467,17 @@ func buildHarnessZoneEnemy(monster DnDMonsterTemplate, tier int) Combatant {
// expeditionHarness threads per-trial mutable state (RNG, expedition,
// character HP carryover, encounter count) through the day loop.
type expeditionHarness struct {
exp *Expedition
char *DnDCharacter
rng *harnessRNG
encounters int
exp *Expedition
char *DnDCharacter
rng *harnessRNG
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
@@ -484,10 +511,15 @@ func runExpeditionBalanceTrial(p expeditionBalanceProfile, seed uint64) expediti
Subclass: p.Subclass,
Level: p.Level,
})
rolls := p.HarvestRollsPerDay
if rolls <= 0 {
rolls = harnessHarvestRollsPerDay
}
h := &expeditionHarness{
exp: exp,
char: char,
rng: newHarnessRNG(seed),
exp: exp,
char: char,
rng: newHarnessRNG(seed),
rollsPerDay: rolls,
}
for {
res := h.advanceExpeditionOneDay()