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
|
||||
|
||||
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
|
||||
@@ -451,6 +471,13 @@ type expeditionHarness struct {
|
||||
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),
|
||||
rollsPerDay: rolls,
|
||||
}
|
||||
for {
|
||||
res := h.advanceExpeditionOneDay()
|
||||
|
||||
@@ -95,23 +95,31 @@ func TestExpeditionBalance_Phase0_SeedSpread(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// phase1TierCenterline maps each tier to its centerline player level —
|
||||
// the median of the design-doc player-level range for the tier:
|
||||
// phase1TierCenterline maps each tier to its centerline player level.
|
||||
// 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
|
||||
// T2 (L3-7) → 5
|
||||
// T3 (L5-10) → 8
|
||||
// T4 (L7-15) → 11
|
||||
// T5 (L10-20) → 15
|
||||
// T1 (L1-4) → 3 gearTier 1 = mundane ✓
|
||||
// T2 (L3-7) → 5 gearTier 2 = +1 weapon/armor ✓
|
||||
// T3 (L5-10) → 9 gearTier 3 = +2 (was L8 → +1)
|
||||
// T4 (L7-15) → 13 gearTier 4 = +3 (was L11 → +2)
|
||||
// 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.
|
||||
// Phase 4 may widen this if a tier band looks level-sensitive.
|
||||
// All centerlines stay inside their design-doc range. One level per
|
||||
// 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{
|
||||
ZoneTierBeginner: 3,
|
||||
ZoneTierApprentice: 5,
|
||||
ZoneTierJourneyman: 8,
|
||||
ZoneTierVeteran: 11,
|
||||
ZoneTierLegendary: 15,
|
||||
ZoneTierJourneyman: 9,
|
||||
ZoneTierVeteran: 13,
|
||||
ZoneTierLegendary: 17,
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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