mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
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:
@@ -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
|
||||||
@@ -451,6 +471,13 @@ type expeditionHarness struct {
|
|||||||
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()
|
||||||
|
|||||||
@@ -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 70–90% 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.
|
||||||
|
|||||||
Reference in New Issue
Block a user