mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Phase 5-B's exit named two follow-ups: T3 below band (manor 39 /
underforge 47 vs 55-75) and the T4 spread (feywild 59 vs underdark
88, 30pp asymmetry). Phase 5-C re-runs the Phase 4-A trace on the
four affected zones under the shipped Phase 5-B cell and applies
roster knobs only (IsElite + SpawnWeight; no bestiary stat-block
touches).
TestExpeditionBalance_Phase5C_OutlierDiagnostic names the killer
per zone:
manor_blackspire (53%): Vampire Spawn (72% win, 42 kills) +
Revenant (16% win, 31 kills at SW=1, can't go lower). First
attempt trimmed VS SW 3 -> 2 and backfired by concentrating
elite share on Revenant (kills jumped to 48); reverted.
underforge (49.5%): Fire Elemental (57 kills, 61hp/win) +
Salamander (33 kills) carry the lethality; Helmed Horror only
100%-win elite but SW=1, no dilution effect.
feywild_crossing (54%): only 2 elites in the pool; Fomorian
(50% win, SW=1) still pulls 25% of elite picks and owns 65
kills.
underdark (86%): Drow at SW=7 fills nearly half of standard
rolls at 100% win / 1.1hp loss -- free-HP filler.
Roster changes:
manor Banshee promoted to elite SW=2 (was standard SW=3,
99.6% win). Soft 4th elite slot dilutes Revenant share
~14% -> ~11%. Standards collapse to Shadow+Poltergeist
(>=99% win).
underforge Helmed Horror SW 1 -> 3. Three-way elite pool drops
Fire Elemental's share from ~44% to ~33%.
feywild Green Hag promoted to elite SW=2 (was standard SW=4).
Adds soft 3rd elite + removes a 16hp/win standard.
Standards become Redcap+Will-o-Wisp+Quickling.
underdark Drow SW 7 -> 5 (light trim per the user's "lift
trailers, don't nerf leaders" stance). Standards
shift toward Hook Horror / Drow Mage.
Phase 1 matrix after Phase 5-C (200 trials, Fighter @ centerline):
T1 88.5% spread 1.0 (in band 70-90)
T2 74.5% spread 15.0 (in band 62-82)
T3 56.7% spread 0.5 (in band 55-75) +13.5pp
T4 77.0% spread 13.0 (over band 45-65, matches 5-B target;
spread halved from 25.5)
T5 58.0% spread 40.0 (abyss_portal 38% is the residual)
T3 mean lifted +13.5pp; both zones inside band with 0.5pp residual
spread. T4 spread halved (25.5 -> 13.0pp) by lifting feywild
+11.5pp and trimming underdark -4.5pp. T1/T2/T5 untouched.
No test debt -- no production tests pin SpawnWeight or IsElite on
the changed entries (TestMonsterKillTags_GatesKnownMonsters checks
vampire_spawn tags only, unaffected). -short suite green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1631 lines
57 KiB
Go
1631 lines
57 KiB
Go
package plugin
|
||
|
||
import (
|
||
"fmt"
|
||
"math"
|
||
"sort"
|
||
"testing"
|
||
)
|
||
|
||
// TestExpeditionBalance_Phase0_Spike is the sanity check the
|
||
// expedition-difficulty plan doc's Phase 0 calls for: one cell, 100
|
||
// trials, sensible numbers, fully deterministic from the seed.
|
||
//
|
||
// The cell — T2 zone (Crypt Valdris) × L5 Fighter — was chosen
|
||
// because it's the smallest cell that actually exercises every part
|
||
// of the seam: tier scaling raises monster AC/atk, supply burn lands
|
||
// at 1.5×, the player is built off the class-balance loadout ladder
|
||
// at the T2/+1 step, and the night phase has roster entries to draw
|
||
// from. If the seam works here, Phase 1's full-matrix expansion is
|
||
// mostly typing.
|
||
//
|
||
// Pass criteria are intentionally loose — we are not asserting a
|
||
// target band yet (Phase 2 does that), only that nothing is wired up
|
||
// so badly the result is degenerate (0%/100%/NaN/zero days). Anything
|
||
// in (0..100%) completion at this cell is a green Phase 0.
|
||
func TestExpeditionBalance_Phase0_Spike(t *testing.T) {
|
||
const trials = 100
|
||
profile := expeditionBalanceProfile{
|
||
ZoneID: ZoneCryptValdris,
|
||
Class: ClassFighter,
|
||
Level: 5,
|
||
Supplies: makeSupplies(ZoneTierApprentice, SupplyPurchase{
|
||
StandardPacks: 3,
|
||
DeluxePacks: 0,
|
||
}),
|
||
CampType: CampTypeStandard,
|
||
}
|
||
|
||
res := runExpeditionBalanceCell(profile, trials, 0xC0FFEE)
|
||
|
||
t.Logf("cell: %s L%d %s — completions=%d/%d (%.1f%%), deaths=%d, starved=%d, "+
|
||
"median_days=%d, median_threat=%d, avg_encounters=%.1f, avg_hp_remaining=%.1f%%",
|
||
profile.ZoneID, profile.Level, profile.Class,
|
||
res.Completions, res.Trials, res.CompletionRate()*100,
|
||
res.Deaths, res.StarvedOuts,
|
||
res.MedianDays, res.MedianThreatEnd,
|
||
res.AvgEncounters, res.AvgHPRemainingPct*100,
|
||
)
|
||
|
||
// Degenerate-outcome gates. These are harness-broken sentinels,
|
||
// not difficulty assertions — Phase 2 layers the real band on
|
||
// top of this same test.
|
||
if res.Trials != trials {
|
||
t.Fatalf("trial count mismatch: got %d, want %d", res.Trials, trials)
|
||
}
|
||
if res.Completions == 0 {
|
||
t.Errorf("zero completions in %d trials at T2/L5 Fighter — the spike cell should not be unwinnable", trials)
|
||
}
|
||
// Phase 5-B player floor lifted the at-tier completion rate
|
||
// substantially; 100%% completion at the spike cell is now the
|
||
// expected "fairly breezy" outcome, not a harness-broken signal.
|
||
// (Cells that 0% out remain a harness-broken signal — checked
|
||
// above.)
|
||
_ = trials
|
||
if res.MedianDays == 0 {
|
||
t.Fatalf("median days == 0; day loop never advanced")
|
||
}
|
||
if res.MedianDays > harnessMaxDays {
|
||
t.Fatalf("median days %d > cap %d; termination wiring broken", res.MedianDays, harnessMaxDays)
|
||
}
|
||
}
|
||
|
||
// TestExpeditionBalance_Phase0_SeedSpread confirms the RNG seam is
|
||
// actually wired — different seeds produce different trial outcomes
|
||
// across a small sample. Full byte-for-byte reproducibility under
|
||
// the same seed is *not* asserted at Phase 0: a couple of production
|
||
// helpers we lean on (surpriseRoundNick, pickWanderingMonster) draw
|
||
// from the package-global math/rand/v2, same as the class-balance
|
||
// harness. Phase 1 lifts those to seeded variants if matrix
|
||
// reproducibility becomes a real requirement; until then, "seeds
|
||
// differentiate" is the contract we can honestly hold.
|
||
func TestExpeditionBalance_Phase0_SeedSpread(t *testing.T) {
|
||
profile := expeditionBalanceProfile{
|
||
ZoneID: ZoneCryptValdris,
|
||
Class: ClassFighter,
|
||
Level: 5,
|
||
Supplies: makeSupplies(ZoneTierApprentice, SupplyPurchase{
|
||
StandardPacks: 3,
|
||
}),
|
||
CampType: CampTypeStandard,
|
||
}
|
||
const seedA uint64 = 0xDEADBEEF
|
||
const seedB uint64 = 0xFEEDFACE
|
||
a := runExpeditionBalanceTrial(profile, seedA)
|
||
b := runExpeditionBalanceTrial(profile, seedB)
|
||
if a == b {
|
||
t.Fatalf("two distinct seeds produced byte-identical trials — RNG seam may not be wired:\n a = %+v\n b = %+v", a, b)
|
||
}
|
||
}
|
||
|
||
// 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 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)
|
||
//
|
||
// 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: 9,
|
||
ZoneTierVeteran: 13,
|
||
ZoneTierLegendary: 17,
|
||
}
|
||
|
||
// TestExpeditionBalance_Phase1_FullMatrix is the Phase 1 baseline-
|
||
// measurement test: every registered zone × its tier centerline level,
|
||
// 200 trials/cell, class fixed to Fighter so the cell-to-cell delta
|
||
// isolates zone difficulty (class parity is the class-balance pass's
|
||
// job, not this one).
|
||
//
|
||
// Per the plan doc, Phase 1's gate is *pathology-only*: no zone at T1
|
||
// reads 0% completion, no zone at T5 reads 100% completion, no NaN /
|
||
// zero-day cells. The real target-band assertion lands in Phase 2 after
|
||
// the global levers have been tuned to centerline. Until then the
|
||
// matrix's job is to *log numbers we can read*, so the per-cell line
|
||
// and per-tier mean+spread go through t.Log for the commit diff.
|
||
func TestExpeditionBalance_Phase1_FullMatrix(t *testing.T) {
|
||
if testing.Short() {
|
||
t.Skip("phase 1 matrix is heavy; -short skips it")
|
||
}
|
||
|
||
const trialsPerCell = 200
|
||
const baseSeed uint64 = 0xE0FFEE1
|
||
|
||
// Walk the zone registry in declared order (matches zoneOrder so
|
||
// the log table is stable across runs / commits).
|
||
type cellRow struct {
|
||
zone ZoneDefinition
|
||
result expeditionBalanceResult
|
||
}
|
||
rows := make([]cellRow, 0, len(zoneOrder))
|
||
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,
|
||
}
|
||
// Per-cell seed offset keeps cells independent — same seed
|
||
// across cells would correlate their RNG streams.
|
||
res := runExpeditionBalanceCell(profile, trialsPerCell, baseSeed+uint64(i)*1_000_003)
|
||
rows = append(rows, cellRow{zone: zone, result: res})
|
||
}
|
||
|
||
// Per-cell log. The format is grep-able: leading "CELL" tag, fixed
|
||
// columns. Future me will diff this between commits to see what
|
||
// Phase 2's lever moves actually did.
|
||
t.Logf("phase1 matrix — %d zones × %d trials, Fighter @ tier-centerline level", len(rows), trialsPerCell)
|
||
t.Logf("CELL %-18s %-3s %-3s %-12s %-12s %-13s %-11s %-13s %-9s %-12s",
|
||
"zone", "tier", "lvl", "comp%", "death%", "starve%", "med_days", "med_threat", "encs", "hp_left%")
|
||
for _, row := range rows {
|
||
r := row.result
|
||
t.Logf("CELL %-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%%",
|
||
row.zone.ID, row.zone.Tier, r.Profile.Level,
|
||
r.CompletionRate()*100,
|
||
r.DeathRate()*100,
|
||
float64(r.StarvedOuts)/float64(r.Trials)*100,
|
||
r.MedianDays, r.MedianThreatEnd,
|
||
r.AvgEncounters, r.AvgHPRemainingPct*100,
|
||
)
|
||
}
|
||
|
||
// Per-tier diagnostic: mean completion% across the zones in the
|
||
// tier, and the spread (max − min). Mirrors the class-balance
|
||
// per-(level,tier) spread diagnostic. A tight spread means the
|
||
// global lever pass alone can drag the tier onto target; a wide
|
||
// spread is a signal that Phase 3 per-zone outlier work is
|
||
// unavoidable.
|
||
tierRows := map[ZoneTier][]cellRow{}
|
||
for _, row := range rows {
|
||
tierRows[row.zone.Tier] = append(tierRows[row.zone.Tier], row)
|
||
}
|
||
tiers := []ZoneTier{
|
||
ZoneTierBeginner, ZoneTierApprentice, ZoneTierJourneyman,
|
||
ZoneTierVeteran, ZoneTierLegendary,
|
||
}
|
||
for _, tier := range tiers {
|
||
group := tierRows[tier]
|
||
if len(group) == 0 {
|
||
continue
|
||
}
|
||
var sum, lo, hi float64
|
||
lo = math.Inf(1)
|
||
hi = math.Inf(-1)
|
||
zoneNames := make([]string, 0, len(group))
|
||
for _, row := range group {
|
||
c := row.result.CompletionRate() * 100
|
||
sum += c
|
||
if c < lo {
|
||
lo = c
|
||
}
|
||
if c > hi {
|
||
hi = c
|
||
}
|
||
zoneNames = append(zoneNames, fmt.Sprintf("%s=%.1f%%", row.zone.ID, c))
|
||
}
|
||
sort.Strings(zoneNames)
|
||
mean := sum / float64(len(group))
|
||
t.Logf("TIER T%d n=%d mean_comp=%5.1f%% spread=%5.1f pp [%s]",
|
||
tier, len(group), mean, hi-lo, joinZones(zoneNames))
|
||
}
|
||
|
||
// Gates split into two buckets:
|
||
//
|
||
// *Wiring* pathologies (zero-day loop, days > cap) — fatal. These
|
||
// would mean the harness itself is broken and the matrix numbers
|
||
// above are noise.
|
||
//
|
||
// *Difficulty* sentinels (0% at T1, 100% at T5) — logged as WARN,
|
||
// not fatal. Phase 1's contract is "log a baseline"; the band
|
||
// assertion is Phase 2's job once the global levers have been
|
||
// tuned to land within ±10pp of target. Promoting these to
|
||
// t.Errorf here would turn Phase 1 into a tuning gate before
|
||
// Phase 2 has a chance to move the levers, which the plan doc
|
||
// explicitly defers.
|
||
for _, row := range rows {
|
||
r := row.result
|
||
if r.MedianDays == 0 {
|
||
t.Errorf("%s: median days == 0; day loop never advanced", row.zone.ID)
|
||
}
|
||
if r.MedianDays > harnessMaxDays {
|
||
t.Errorf("%s: median days %d > cap %d; termination wiring broken",
|
||
row.zone.ID, r.MedianDays, harnessMaxDays)
|
||
}
|
||
if row.zone.Tier == ZoneTierBeginner && r.Completions == 0 {
|
||
t.Logf("WARN %s (T1): 0%% completions in %d trials — Phase 2 should lift this",
|
||
row.zone.ID, r.Trials)
|
||
}
|
||
if row.zone.Tier == ZoneTierLegendary && r.Completions == r.Trials {
|
||
t.Logf("WARN %s (T5): 100%% completions in %d trials — Phase 2 should pressure this",
|
||
row.zone.ID, r.Trials)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestExpeditionBalance_Phase2_TierLethality is the tier-walking
|
||
// companion to the T1-only lethality probe. Phase 1's matrix shows
|
||
// uniform 0% across every tier at the default rolls=4 cadence, which
|
||
// the T1/rolls=1 probe alone can't explain (its bimodal-warchief
|
||
// finding wouldn't show at T5 vs an L17 fighter). This probe traces
|
||
// every fight at the matrix cadence across one zone per tier so we
|
||
// can read whether the deaths are elite-driven, standard-fight
|
||
// chained, or something the harness combat fold itself is doing.
|
||
//
|
||
// Diagnostic-only — no assertions.
|
||
func TestExpeditionBalance_Phase2_TierLethality(t *testing.T) {
|
||
if testing.Short() {
|
||
t.Skip("phase 2 tier-lethality probe writes verbose log; -short skips it")
|
||
}
|
||
|
||
const trialsPerTier = 3
|
||
const baseSeed uint64 = 0x7E11A1
|
||
cells := []struct {
|
||
zone ZoneID
|
||
tier ZoneTier
|
||
}{
|
||
{ZoneGoblinWarrens, ZoneTierBeginner},
|
||
{ZoneForestShadows, ZoneTierApprentice},
|
||
{ZoneUnderforge, ZoneTierJourneyman},
|
||
{ZoneUnderdark, ZoneTierVeteran},
|
||
{ZoneDragonsLair, ZoneTierLegendary},
|
||
}
|
||
for _, cell := range cells {
|
||
level := phase1TierCenterline[cell.tier]
|
||
profile := expeditionBalanceProfile{
|
||
ZoneID: cell.zone,
|
||
Class: ClassFighter,
|
||
Level: level,
|
||
Supplies: makeSupplies(cell.tier, SupplyPurchase{StandardPacks: 3}),
|
||
CampType: CampTypeStandard,
|
||
HarvestRollsPerDay: 4, // matrix default, not the sparse probe
|
||
}
|
||
t.Logf("═══ %s T%d L%d Fighter rolls=4 ═══", cell.zone, cell.tier, level)
|
||
for trial := 0; trial < trialsPerTier; trial++ {
|
||
exp := newHarnessExpedition(profile)
|
||
char := buildHarnessCharacter(classBalanceProfile{
|
||
Class: profile.Class,
|
||
Level: profile.Level,
|
||
})
|
||
t.Logf("─── %s trial %d: hp_max=%d ───", cell.zone, trial, char.HPMax)
|
||
h := &expeditionHarness{
|
||
exp: exp,
|
||
char: char,
|
||
rng: newHarnessRNG(baseSeed + uint64(trial) + uint64(cell.tier)*101),
|
||
rollsPerDay: profile.HarvestRollsPerDay,
|
||
traceFight: func(line string) {
|
||
t.Logf(" %s", line)
|
||
},
|
||
}
|
||
for {
|
||
res := h.advanceExpeditionOneDay()
|
||
if res.EndedReason != "" {
|
||
t.Logf("END %s trial %d: reason=%s days=%d threat=%d encs=%d hp_left_pct=%.1f%%",
|
||
cell.zone, trial, res.EndedReason, res.DaysElapsed, res.ThreatAtEnd,
|
||
res.CombatEncounters, res.HPRemainingPct*100)
|
||
break
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestExpeditionBalance_Phase3_GlobalLeverSweep is the global-tuning
|
||
// sweep the plan doc's "Phase 3 — global lever tuning" step calls for.
|
||
// Phase 2c (roster gate) lifted T1 goblin_warrens off the floor (~3%)
|
||
// but every other tier still reads 0% in the post-2c Phase 1 matrix.
|
||
// The Phase 2 lever sweep proved the wounded-cascade knobs are inert
|
||
// once the clamp is in place — deaths are now fresh-entry elite
|
||
// one-shots and multi-day AC/init creep, not chained nicks.
|
||
//
|
||
// This sweep walks two of the global knobs called out in the plan-doc
|
||
// "Phase 3 — global lever tuning" section:
|
||
//
|
||
// eliteInterruptThreshold (live=19, total roll cutoff for Elite
|
||
// bracket) — directly controls how often fresh-entry elite fights
|
||
// trigger during the daytime harvest pipeline. Sweep {17, 19, 23}:
|
||
// 17 = more elites (slope check below live), 19 = live baseline,
|
||
// 23 = elites only when roll+mod ≥ 23 (rare even at T5).
|
||
//
|
||
// threatDriftBase (live=3, daily threat clock drift before mood-mod)
|
||
// — slows the multi-day AC/init/supply-burn creep that compounds
|
||
// over a 14-day expedition. Sweep {1, 3, 5}: 1 = nearly flat, 3 =
|
||
// live, 5 = harsher.
|
||
//
|
||
// 3×3 = 9 combos × 10 zones × 200 trials/cell. Diagnostic-only — no
|
||
// gates beyond the Phase 1 wiring sanity. -short skips.
|
||
func TestExpeditionBalance_Phase3_GlobalLeverSweep(t *testing.T) {
|
||
if testing.Short() {
|
||
t.Skip("phase 3 global-lever sweep is heavy; -short skips it")
|
||
}
|
||
|
||
const trialsPerCell = 200
|
||
const baseSeed uint64 = 0x9101E5
|
||
|
||
eliteThresholds := []int{17, 19, 23}
|
||
driftBases := []int{1, 3, 5}
|
||
|
||
t.Logf("phase3 global-lever sweep — %d zones × %d elite-thresholds × %d drift-bases × %d trials, Fighter @ tier centerline (rolls=4)",
|
||
len(zoneOrder), len(eliteThresholds), len(driftBases), trialsPerCell)
|
||
|
||
type tierStat struct {
|
||
cells int
|
||
sumC float64
|
||
lo float64
|
||
hi float64
|
||
}
|
||
|
||
for _, elite := range eliteThresholds {
|
||
for _, drift := range driftBases {
|
||
tierStats := map[ZoneTier]*tierStat{}
|
||
t.Logf("─── eliteInterruptThreshold=%d threatDriftBase=%d ───", elite, drift)
|
||
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,
|
||
EliteInterruptThresholdOverride: elite,
|
||
ThreatDriftBaseOverride: drift,
|
||
}
|
||
seed := baseSeed + uint64(i)*1_000_003 + uint64(elite)*101 + uint64(drift)*7919
|
||
r := runExpeditionBalanceCell(profile, trialsPerCell, seed)
|
||
|
||
c := r.CompletionRate() * 100
|
||
t.Logf("CELL e=%-2d d=%-1d %-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%%",
|
||
elite, drift, 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 e=%-2d d=%-1d T%d n=%d mean_comp=%5.1f%% spread=%5.1f pp",
|
||
elite, drift, tier, ts.cells, mean, ts.hi-ts.lo)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestExpeditionBalance_Phase3B_NickSupplySweep is the second
|
||
// global-tuning sweep from gogobee_expedition_difficulty.md. Phase 3-A
|
||
// surfaced the elite-bracket threshold as the dominant T1–T3 lever
|
||
// (e=23/d=1 lifted T1 from 3% → 24%) but exposed a tail-side
|
||
// fingerprint shift: T4/T5 dragons_lair death dropped 60% → 24% while
|
||
// starvation climbed to 75% — the fighter now survives elites long
|
||
// enough to run out of food. Phase 3-A picked the best cell but it
|
||
// still leaves every tier well below target band (T1 70-90%, T2
|
||
// 62-82%, …, T5 ~40%).
|
||
//
|
||
// Phase 3-B holds the Phase 3-A best cell (eliteInterruptThreshold=23,
|
||
// threatDriftBase=1) and walks two more global knobs:
|
||
//
|
||
// surpriseNickFloor (live=tier, i.e. 1..5 by zone tier) — the raw
|
||
// surprise-round nick on a fresh-HP entry. Lower floor = less
|
||
// per-fight chip on T4/T5 standard fights, where the live tier=4-5
|
||
// floor is the biggest single contributor to the wear-down curve
|
||
// that funnels survivors into starvation. Sweep {-1 (disable, =0),
|
||
// 1 (flat), 0-sentinel (live tier)}.
|
||
//
|
||
// supplyBurnRatePct (live=100, %) — daily supply burn scalar. Lower
|
||
// = more days of margin. T4 and T5 burns are 3×/4× the T1 baseline;
|
||
// halving them ought to convert the starvation outs we saw in
|
||
// Phase 3-A into actual completions if survivability is the only
|
||
// remaining blocker, or leave them dying in combat if it isn't.
|
||
// Sweep {100 (live), 75, 50}.
|
||
//
|
||
// 3×3 = 9 combos × 10 zones × 200 trials/cell, all on top of the
|
||
// Phase 3-A best cell. Diagnostic-only — no gates beyond Phase 1
|
||
// wiring sanity. -short skips.
|
||
func TestExpeditionBalance_Phase3B_NickSupplySweep(t *testing.T) {
|
||
if testing.Short() {
|
||
t.Skip("phase 3-B nick/supply sweep is heavy; -short skips it")
|
||
}
|
||
|
||
const trialsPerCell = 200
|
||
const baseSeed uint64 = 0xB1C5E2
|
||
// Phase 3-A best cell — held constant across this sweep.
|
||
const eliteThreshold = 23
|
||
const driftBase = 1
|
||
|
||
nickFloors := []int{-1, 1, 0} // -1 disables floor; 1 flat-1; 0 = use live tier
|
||
burnPcts := []int{50, 75, 100}
|
||
|
||
t.Logf("phase3-B nick/supply sweep — %d zones × %d nick-floors × %d burn-pcts × %d trials, Fighter @ tier centerline (rolls=4); base e=%d d=%d",
|
||
len(zoneOrder), len(nickFloors), len(burnPcts), trialsPerCell, eliteThreshold, driftBase)
|
||
|
||
type tierStat struct {
|
||
cells int
|
||
sumC float64
|
||
lo float64
|
||
hi float64
|
||
}
|
||
|
||
nickLabel := func(f int) string {
|
||
switch {
|
||
case f == 0:
|
||
return "tier"
|
||
case f < 0:
|
||
return "0"
|
||
default:
|
||
return fmt.Sprintf("%d", f)
|
||
}
|
||
}
|
||
|
||
for _, floor := range nickFloors {
|
||
for _, burn := range burnPcts {
|
||
tierStats := map[ZoneTier]*tierStat{}
|
||
t.Logf("─── surpriseNickFloor=%s supplyBurnPct=%d ───", nickLabel(floor), burn)
|
||
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,
|
||
EliteInterruptThresholdOverride: eliteThreshold,
|
||
ThreatDriftBaseOverride: driftBase,
|
||
SurpriseNickFloorOverride: floor,
|
||
SupplyBurnRatePctOverride: burn,
|
||
}
|
||
seed := baseSeed + uint64(i)*1_000_003 +
|
||
uint64(floor+2)*1009 + uint64(burn)*7919
|
||
r := runExpeditionBalanceCell(profile, trialsPerCell, seed)
|
||
|
||
c := r.CompletionRate() * 100
|
||
t.Logf("CELL f=%-4s b=%-3d %-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%%",
|
||
nickLabel(floor), burn, 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 f=%-4s b=%-3d T%d n=%d mean_comp=%5.1f%% spread=%5.1f pp",
|
||
nickLabel(floor), burn, tier, ts.cells, mean, ts.hi-ts.lo)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestExpeditionBalance_Phase4A_OutlierDiagnostic is the per-zone
|
||
// diagnostic the plan doc's Phase 4 calls for. Phase 3-B's global
|
||
// sweep wrung out the matrix-wide knobs and named four outlier zones
|
||
// that still read ~0% completion at every (elite-threshold, drift,
|
||
// nick-floor, supply-burn) combo: crypt_valdris T1, forest_shadows
|
||
// T2, manor_blackspire T3, abyss_portal T5. Each has a healthy
|
||
// sibling at the same tier (goblin_warrens, sunken_temple,
|
||
// underforge, dragons_lair) that completes at the matrix mean.
|
||
//
|
||
// This test holds the Phase 3-A/3-B best cell (e=23, d=1, burn=50,
|
||
// nick-floor=tier — recall nick-floor was inert) and pairs each
|
||
// outlier with its sibling. For every (zone, trial) it captures the
|
||
// structured fight trace via traceFightStruct and aggregates:
|
||
//
|
||
// - Per-monster: appearances, win-rate, avg HP-loss-on-win,
|
||
// attribution of the final losing fight ("kills"). Surfaces the
|
||
// "one monster owns the lethality budget" pattern that single-
|
||
// boss-pool elite gates already mitigated for the live healthy
|
||
// zones (Phase 2c).
|
||
// - Day-of-end histogram (1..14): clusters early-day deaths
|
||
// (chained-interrupt collapse) vs late-day starve-outs vs
|
||
// survives-to-cap.
|
||
// - Elite vs Standard fight mix: confirms whether the outlier is
|
||
// drawing disproportionate elites (roster weight problem) or
|
||
// hitting normal cadence and losing baseline fights (stat-floor
|
||
// problem).
|
||
//
|
||
// Constraint per the plan doc: do not touch monster stat blocks
|
||
// (those are bestiary, shared across systems). The output of this
|
||
// test names the per-zone tool to reach for — SpawnWeight rebalance,
|
||
// elite-flag toggle, supply-DC pull, or boss tuning (boss is
|
||
// zone-scoped, not bestiary-scoped, so it's in-bounds).
|
||
//
|
||
// Diagnostic-only — no gates. -short skips.
|
||
func TestExpeditionBalance_Phase4A_OutlierDiagnostic(t *testing.T) {
|
||
if testing.Short() {
|
||
t.Skip("phase 4-A diagnostic walks 8 zones × 200 trials; -short skips it")
|
||
}
|
||
|
||
const trialsPerZone = 200
|
||
const baseSeed uint64 = 0xF40A4D
|
||
// Phase 3-A/3-B best cell — same as the Phase 3-B nick=tier b=50 row.
|
||
const eliteThreshold = 23
|
||
const driftBase = 1
|
||
const supplyBurnPct = 50
|
||
|
||
type pair struct {
|
||
outlier ZoneID
|
||
sibling ZoneID
|
||
tier ZoneTier
|
||
}
|
||
pairs := []pair{
|
||
{ZoneCryptValdris, ZoneGoblinWarrens, ZoneTierBeginner},
|
||
{ZoneForestShadows, ZoneSunkenTemple, ZoneTierApprentice},
|
||
{ZoneManorBlackspire, ZoneUnderforge, ZoneTierJourneyman},
|
||
{ZoneAbyssPortal, ZoneDragonsLair, ZoneTierLegendary},
|
||
}
|
||
|
||
type monsterStat struct {
|
||
appearances int
|
||
standardCount int
|
||
eliteCount int
|
||
wins int
|
||
hpLossOnWin int // sum, divided by wins at report time
|
||
killAttributed int // last fight of a died_combat trial
|
||
}
|
||
type zoneAgg struct {
|
||
profile expeditionBalanceProfile
|
||
completions int
|
||
deaths int
|
||
starves int
|
||
monsters map[string]*monsterStat
|
||
dayHist [16]int // 1..14 + overflow bucket; index 0 unused
|
||
eliteFights int
|
||
stdFights int
|
||
// for the trial currently in flight (mutated during traceFightStruct)
|
||
lastFight harnessFightTrace
|
||
}
|
||
|
||
report := func(label string, agg *zoneAgg) {
|
||
total := trialsPerZone
|
||
compPct := float64(agg.completions) / float64(total) * 100
|
||
deathPct := float64(agg.deaths) / float64(total) * 100
|
||
starvePct := float64(agg.starves) / float64(total) * 100
|
||
t.Logf("─── %-7s %-18s L%-2d comp=%5.1f%% death=%5.1f%% starve=%5.1f%% elite_fights=%d std_fights=%d ───",
|
||
label, agg.profile.ZoneID,
|
||
agg.profile.Level,
|
||
compPct, deathPct, starvePct,
|
||
agg.eliteFights, agg.stdFights)
|
||
|
||
// Per-monster — sort by appearances desc for readability.
|
||
names := make([]string, 0, len(agg.monsters))
|
||
for n := range agg.monsters {
|
||
names = append(names, n)
|
||
}
|
||
sort.Slice(names, func(i, j int) bool {
|
||
return agg.monsters[names[i]].appearances > agg.monsters[names[j]].appearances
|
||
})
|
||
for _, n := range names {
|
||
m := agg.monsters[n]
|
||
winPct := 0.0
|
||
if m.appearances > 0 {
|
||
winPct = float64(m.wins) / float64(m.appearances) * 100
|
||
}
|
||
avgLoss := 0.0
|
||
if m.wins > 0 {
|
||
avgLoss = float64(m.hpLossOnWin) / float64(m.wins)
|
||
}
|
||
t.Logf(" MON %-22s appear=%4d (std=%-3d elite=%-3d) win=%5.1f%% avg_hp_loss_on_win=%5.1f kills=%d",
|
||
n, m.appearances, m.standardCount, m.eliteCount,
|
||
winPct, avgLoss, m.killAttributed)
|
||
}
|
||
|
||
// Day-of-end histogram — squashes 14 into "14+" since the cap
|
||
// terminates a trial at exactly day 14.
|
||
bins := make([]string, 0, 14)
|
||
for d := 1; d <= 14; d++ {
|
||
if agg.dayHist[d] > 0 {
|
||
bins = append(bins, fmt.Sprintf("d%d=%d", d, agg.dayHist[d]))
|
||
}
|
||
}
|
||
t.Logf(" END-DAYS %s", joinZones(bins))
|
||
}
|
||
|
||
for _, p := range pairs {
|
||
level := phase1TierCenterline[p.tier]
|
||
t.Logf("═══ T%d outlier=%s sibling=%s (L%d Fighter, e=%d d=%d burn=%d, %d trials each) ═══",
|
||
p.tier, p.outlier, p.sibling, level,
|
||
eliteThreshold, driftBase, supplyBurnPct, trialsPerZone)
|
||
|
||
for _, zid := range []ZoneID{p.outlier, p.sibling} {
|
||
profile := expeditionBalanceProfile{
|
||
ZoneID: zid,
|
||
Class: ClassFighter,
|
||
Level: level,
|
||
Supplies: makeSupplies(p.tier, SupplyPurchase{StandardPacks: 3}),
|
||
CampType: CampTypeStandard,
|
||
EliteInterruptThresholdOverride: eliteThreshold,
|
||
ThreatDriftBaseOverride: driftBase,
|
||
SupplyBurnRatePctOverride: supplyBurnPct,
|
||
}
|
||
agg := &zoneAgg{
|
||
profile: profile,
|
||
monsters: map[string]*monsterStat{},
|
||
}
|
||
|
||
for trial := 0; trial < trialsPerZone; trial++ {
|
||
exp := newHarnessExpedition(profile)
|
||
char := buildHarnessCharacter(classBalanceProfile{
|
||
Class: profile.Class,
|
||
Level: profile.Level,
|
||
})
|
||
h := &expeditionHarness{
|
||
exp: exp,
|
||
char: char,
|
||
rng: newHarnessRNG(baseSeed + uint64(trial) + uint64(p.tier)*131 + zoneSeedSalt(zid)),
|
||
rollsPerDay: harnessHarvestRollsPerDay,
|
||
eliteInterruptThresholdOverride: eliteThreshold,
|
||
threatDriftBaseOverride: driftBase,
|
||
supplyBurnRatePctOverride: supplyBurnPct,
|
||
traceFightStruct: func(ft harnessFightTrace) {
|
||
m, ok := agg.monsters[ft.MonsterName]
|
||
if !ok {
|
||
m = &monsterStat{}
|
||
agg.monsters[ft.MonsterName] = m
|
||
}
|
||
m.appearances++
|
||
if ft.Elite {
|
||
m.eliteCount++
|
||
agg.eliteFights++
|
||
} else {
|
||
m.standardCount++
|
||
agg.stdFights++
|
||
}
|
||
if ft.Won {
|
||
m.wins++
|
||
loss := ft.HPPre - ft.HPPost
|
||
if loss < 0 {
|
||
loss = 0
|
||
}
|
||
m.hpLossOnWin += loss
|
||
}
|
||
agg.lastFight = ft
|
||
},
|
||
}
|
||
var trialEnd expeditionTrialResult
|
||
for {
|
||
res := h.advanceExpeditionOneDay()
|
||
if res.EndedReason != "" {
|
||
trialEnd = res
|
||
break
|
||
}
|
||
}
|
||
switch {
|
||
case trialEnd.Completed:
|
||
agg.completions++
|
||
case trialEnd.Died:
|
||
agg.deaths++
|
||
// Attribute the kill to the last fight's monster
|
||
// (the one whose !PlayerWon return drove the
|
||
// terminate call). Sound because the day loop
|
||
// terminates immediately on combat loss.
|
||
if m, ok := agg.monsters[agg.lastFight.MonsterName]; ok && !agg.lastFight.Won {
|
||
m.killAttributed++
|
||
}
|
||
case trialEnd.StarvedOut:
|
||
agg.starves++
|
||
}
|
||
d := trialEnd.DaysElapsed
|
||
if d < 1 {
|
||
d = 1
|
||
}
|
||
if d > 14 {
|
||
d = 14
|
||
}
|
||
agg.dayHist[d]++
|
||
}
|
||
|
||
label := "OUTLIER"
|
||
if zid == p.sibling {
|
||
label = "SIBLING"
|
||
}
|
||
report(label, agg)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestExpeditionBalance_Phase5A_TierWideSensitivity is the tier-wide
|
||
// counterpart to Phase 4-A. Phase 4-B closed the four named per-zone
|
||
// outliers, but the b=50 sweep still left T2/T3/T5 sibling *pairs*
|
||
// below the target band as a group (T2 7-13% vs 62-82%; T3 3-14% vs
|
||
// 54-74%; T5 25-57% vs 36-56%). Both zones at each problem tier
|
||
// underperform together, so no further per-zone tool will close it —
|
||
// the lever has to be tier-wide. The plan doc names three candidate
|
||
// moves:
|
||
//
|
||
// 1. Gear-tier centerline remap (phase1TierCenterline) → axis L
|
||
// 2. Per-tier elite-bracket threshold (vs global) → axis E
|
||
// 3. Ship burn=75 globally (Phase 3-B's high-burn cell) → axis B
|
||
//
|
||
// This diagnostic does *not* pick the lever. It runs a one-axis-at-a-
|
||
// time sensitivity sweep on the six under-band zones and lets the
|
||
// numbers name the lever. Each axis holds the other two at Phase 3-B
|
||
// best (e=23, d=1, burn=50) and walks the named axis through three
|
||
// values. Three points per axis is enough to read the slope: flat-line
|
||
// means the lever is inert for that tier, monotone climb means it's
|
||
// the lever, non-monotone means a confound.
|
||
//
|
||
// L: player level = {centerline-2, centerline, centerline+2}
|
||
// E: elite threshold = {18, 23, 28}
|
||
// B: supply burn pct = {40, 50, 60}
|
||
//
|
||
// Per cell we log comp%/death%/starve% plus the elite-vs-standard
|
||
// fight share (so axis E's slope can be cross-checked against the
|
||
// fight-mix shift it actually produced). 200 trials/cell × 6 zones ×
|
||
// 9 cells = 10.8k trials — heavier than Phase 4-A (1.6k) but bounded
|
||
// and skipped under -short.
|
||
//
|
||
// Diagnostic-only — no gates. Output names the lever Phase 5-B
|
||
// should pull.
|
||
func TestExpeditionBalance_Phase5A_TierWideSensitivity(t *testing.T) {
|
||
if testing.Short() {
|
||
t.Skip("phase 5-A sensitivity sweep walks 6 zones × 9 cells × 200 trials; -short skips it")
|
||
}
|
||
|
||
const trialsPerCell = 200
|
||
const baseSeed uint64 = 0xF50A5E
|
||
|
||
// Phase 3-B best cell (held constant on the two non-axis levers).
|
||
const eliteBaseline = 23
|
||
const driftBase = 1
|
||
const burnBaseline = 50
|
||
|
||
type tierGroup struct {
|
||
tier ZoneTier
|
||
zones []ZoneID
|
||
}
|
||
groups := []tierGroup{
|
||
{ZoneTierApprentice, []ZoneID{ZoneForestShadows, ZoneSunkenTemple}},
|
||
{ZoneTierJourneyman, []ZoneID{ZoneManorBlackspire, ZoneUnderforge}},
|
||
{ZoneTierLegendary, []ZoneID{ZoneAbyssPortal, ZoneDragonsLair}},
|
||
}
|
||
|
||
// runCell is the inner-loop closure: one (zone, lever-triple) cell.
|
||
// Returns comp/death/starve counts plus elite/standard fight share
|
||
// so the caller can attribute the slope across axis values.
|
||
type cellOut struct {
|
||
comp, death, starve int
|
||
eliteFights, stdFights int
|
||
}
|
||
runCell := func(zid ZoneID, tier ZoneTier, level, elite, burn int) cellOut {
|
||
out := cellOut{}
|
||
profile := expeditionBalanceProfile{
|
||
ZoneID: zid,
|
||
Class: ClassFighter,
|
||
Level: level,
|
||
Supplies: makeSupplies(tier, SupplyPurchase{StandardPacks: 3}),
|
||
CampType: CampTypeStandard,
|
||
EliteInterruptThresholdOverride: elite,
|
||
ThreatDriftBaseOverride: driftBase,
|
||
SupplyBurnRatePctOverride: burn,
|
||
}
|
||
for trial := 0; trial < trialsPerCell; trial++ {
|
||
exp := newHarnessExpedition(profile)
|
||
char := buildHarnessCharacter(classBalanceProfile{
|
||
Class: profile.Class,
|
||
Level: profile.Level,
|
||
})
|
||
// Seed mixes trial, tier, zone, and the *axis values* so
|
||
// cells across the sweep draw distinct RNG streams. Without
|
||
// the lever mix-in, e.g. L=centerline cells would
|
||
// byte-match across axes and the slope reading would be
|
||
// degenerate on the shared row.
|
||
seed := baseSeed + uint64(trial) +
|
||
uint64(tier)*131 + zoneSeedSalt(zid) +
|
||
uint64(level)*1_000_003 +
|
||
uint64(elite)*7_919 +
|
||
uint64(burn)*17
|
||
h := &expeditionHarness{
|
||
exp: exp,
|
||
char: char,
|
||
rng: newHarnessRNG(seed),
|
||
rollsPerDay: harnessHarvestRollsPerDay,
|
||
eliteInterruptThresholdOverride: elite,
|
||
threatDriftBaseOverride: driftBase,
|
||
supplyBurnRatePctOverride: burn,
|
||
traceFightStruct: func(ft harnessFightTrace) {
|
||
if ft.Elite {
|
||
out.eliteFights++
|
||
} else {
|
||
out.stdFights++
|
||
}
|
||
},
|
||
}
|
||
var trialEnd expeditionTrialResult
|
||
for {
|
||
res := h.advanceExpeditionOneDay()
|
||
if res.EndedReason != "" {
|
||
trialEnd = res
|
||
break
|
||
}
|
||
}
|
||
switch {
|
||
case trialEnd.Completed:
|
||
out.comp++
|
||
case trialEnd.Died:
|
||
out.death++
|
||
case trialEnd.StarvedOut:
|
||
out.starve++
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
logRow := func(axis, label string, zid ZoneID, val int, c cellOut) {
|
||
total := trialsPerCell
|
||
t.Logf(" %s %-18s %s=%-3d comp=%5.1f%% death=%5.1f%% starve=%5.1f%% elite_fights=%-4d std_fights=%-4d",
|
||
axis, zid, label, val,
|
||
float64(c.comp)/float64(total)*100,
|
||
float64(c.death)/float64(total)*100,
|
||
float64(c.starve)/float64(total)*100,
|
||
c.eliteFights, c.stdFights)
|
||
}
|
||
|
||
for _, g := range groups {
|
||
center := phase1TierCenterline[g.tier]
|
||
t.Logf("═══ T%d zones=%v centerline=L%d (Fighter, %d trials/cell, baselines e=%d d=%d burn=%d) ═══",
|
||
g.tier, g.zones, center, trialsPerCell,
|
||
eliteBaseline, driftBase, burnBaseline)
|
||
|
||
// Axis L — player level. Centerline ± 2 keeps the move inside
|
||
// each tier's design-doc level range for T2/T3/T5 (T2: L3-7,
|
||
// centerline 5 → 3,5,7; T3: L5-10, centerline 9 → 7,9,11; T5:
|
||
// L10-20, centerline 17 → 15,17,19). No gear-tier boundary is
|
||
// crossed within ±2 for any of these (boundaries 5/9/13/17),
|
||
// so axis L isolates the "level inside same gear bracket"
|
||
// sensitivity. Phase 5-B may widen this if the slope is flat
|
||
// here but the boundary is the real lever.
|
||
t.Logf(" AXIS-L player level ±2 (gear-tier mapping sensitivity)")
|
||
for _, zid := range g.zones {
|
||
for _, dl := range []int{-2, 0, +2} {
|
||
lvl := center + dl
|
||
out := runCell(zid, g.tier, lvl, eliteBaseline, burnBaseline)
|
||
logRow("L", "lvl", zid, lvl, out)
|
||
}
|
||
}
|
||
|
||
// Axis E — elite threshold. 23 is the Phase 3-B baseline; 18
|
||
// is "more elites" (lower bar → more interrupts roll elite);
|
||
// 28 is "fewer elites". Slope here disambiguates whether the
|
||
// tier is gated by elite mix vs baseline fights.
|
||
t.Logf(" AXIS-E elite threshold ±5 (monster-side gate)")
|
||
for _, zid := range g.zones {
|
||
for _, eth := range []int{18, 23, 28} {
|
||
out := runCell(zid, g.tier, center, eth, burnBaseline)
|
||
logRow("E", "eth", zid, eth, out)
|
||
}
|
||
}
|
||
|
||
// Axis B — supply burn pct. Phase 3-B's negative result on
|
||
// burn=75 was tier-mean; reading this axis per under-band
|
||
// tier may surface a tier where burn is *the* lever (e.g. T5
|
||
// borderline 25-57% may close on burn alone). 40/50/60 brackets
|
||
// the Phase 3-B baseline and the candidate burn=75 direction.
|
||
t.Logf(" AXIS-B supply burn ±10pp (resource pressure)")
|
||
for _, zid := range g.zones {
|
||
for _, b := range []int{40, 50, 60} {
|
||
out := runCell(zid, g.tier, center, eliteBaseline, b)
|
||
logRow("B", "burn", zid, b, out)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestExpeditionBalance_Phase5B_GearBonusSweep walks the full matrix
|
||
// at gear magic-bonus delta ∈ {0, +1, +2} on top of the Phase 3-B
|
||
// best cell (e=23, d=1, burn=50, nick-floor=tier). Phase 5-A named
|
||
// player level as the dominant lever at T2/T3 but showed the
|
||
// within-bracket slope can't close the band — the live magic-bonus
|
||
// ladder (0/1/2/3/3) tops the player-power knob, so the question is
|
||
// what flat delta on top of the ladder lands T1-T5 in band.
|
||
//
|
||
// Bands (gogobee_expedition_difficulty.md):
|
||
// T1 80% (70-90%) T2 72% (62-82%) T3 65% (55-75%)
|
||
// T4 55% (45-65%) T5 45% (35-55%)
|
||
//
|
||
// 10 zones × 3 deltas × 200 trials = 6k trials; runs in ~1s.
|
||
// Diagnostic-only — picks the delta Phase 5-B ships in
|
||
// magicBonusForTier.
|
||
func TestExpeditionBalance_Phase5B_GearBonusSweep(t *testing.T) {
|
||
if testing.Short() {
|
||
t.Skip("phase 5-B gear sweep walks 10 zones × 3 deltas × 200 trials; -short skips it")
|
||
}
|
||
|
||
const trialsPerCell = 200
|
||
const baseSeed uint64 = 0xF50B1E
|
||
// Phase 3-B best cell, held constant.
|
||
const eliteThreshold = 23
|
||
const driftBase = 1
|
||
const supplyBurnPct = 50
|
||
// Two-axis grid. Gear delta is the to-hit/AC/damage lever (Phase
|
||
// 5-B's first read showed it dominates at T1/T2/T4); HP multiplier
|
||
// is the durability lever needed to close T3/T5 where gear alone
|
||
// stalled at ~5pp/delta. Smallest combination that lands all
|
||
// tiers in band gets shipped.
|
||
gearDeltas := []int{2, 3, 4}
|
||
hpMults := []float64{1.0, 1.25, 1.5}
|
||
|
||
type cellOut struct {
|
||
comp, death, starve int
|
||
}
|
||
runCell := func(zid ZoneID, tier ZoneTier, level, delta int, hpMult float64) cellOut {
|
||
profile := expeditionBalanceProfile{
|
||
ZoneID: zid,
|
||
Class: ClassFighter,
|
||
Level: level,
|
||
Supplies: makeSupplies(tier, SupplyPurchase{StandardPacks: 3}),
|
||
CampType: CampTypeStandard,
|
||
EliteInterruptThresholdOverride: eliteThreshold,
|
||
ThreatDriftBaseOverride: driftBase,
|
||
SupplyBurnRatePctOverride: supplyBurnPct,
|
||
GearMagicBonusOverride: delta,
|
||
PlayerHPMultOverride: hpMult,
|
||
}
|
||
out := cellOut{}
|
||
for trial := 0; trial < trialsPerCell; trial++ {
|
||
seed := baseSeed + uint64(trial) +
|
||
uint64(tier)*131 + zoneSeedSalt(zid) +
|
||
uint64(delta)*7_919 +
|
||
uint64(hpMult*1000)*23
|
||
res := runExpeditionBalanceTrial(profile, seed)
|
||
switch {
|
||
case res.Completed:
|
||
out.comp++
|
||
case res.Died:
|
||
out.death++
|
||
case res.StarvedOut:
|
||
out.starve++
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
t.Logf("phase5-B gear×hp sweep — %d zones × %d gear × %d hp × %d trials, Fighter @ tier-centerline, baselines e=%d d=%d burn=%d",
|
||
len(zoneOrder), len(gearDeltas), len(hpMults), trialsPerCell,
|
||
eliteThreshold, driftBase, supplyBurnPct)
|
||
|
||
type comboKey struct {
|
||
gear int
|
||
hp float64
|
||
}
|
||
type tierAgg struct{ sum, count float64 }
|
||
perCombo := map[comboKey]map[ZoneTier]*tierAgg{}
|
||
|
||
for _, delta := range gearDeltas {
|
||
for _, hp := range hpMults {
|
||
key := comboKey{delta, hp}
|
||
perCombo[key] = map[ZoneTier]*tierAgg{}
|
||
t.Logf("═══ gear=+%d hp×%.2f ═══", delta, hp)
|
||
for _, zid := range zoneOrder {
|
||
zone, ok := getZone(zid)
|
||
if !ok {
|
||
continue
|
||
}
|
||
level := phase1TierCenterline[zone.Tier]
|
||
out := runCell(zid, zone.Tier, level, delta, hp)
|
||
compPct := float64(out.comp) / float64(trialsPerCell) * 100
|
||
t.Logf(" CELL g=+%d h=%.2f %-18s T%d L%-2d comp=%5.1f%% death=%5.1f%% starve=%5.1f%%",
|
||
delta, hp, zid, zone.Tier, level, compPct,
|
||
float64(out.death)/float64(trialsPerCell)*100,
|
||
float64(out.starve)/float64(trialsPerCell)*100)
|
||
ag, ok := perCombo[key][zone.Tier]
|
||
if !ok {
|
||
ag = &tierAgg{}
|
||
perCombo[key][zone.Tier] = ag
|
||
}
|
||
ag.sum += compPct
|
||
ag.count++
|
||
}
|
||
}
|
||
}
|
||
|
||
// Combo × tier headline. Bands per gogobee_expedition_difficulty.md:
|
||
// the "fairly breezy" target the user picked for Phase 5 means we
|
||
// can land at or above band-center across all tiers.
|
||
t.Logf("─── tier means by (gear, hp) — bands: T1 70-90, T2 62-82, T3 55-75, T4 45-65, T5 35-55 ───")
|
||
tiers := []ZoneTier{
|
||
ZoneTierBeginner, ZoneTierApprentice, ZoneTierJourneyman,
|
||
ZoneTierVeteran, ZoneTierLegendary,
|
||
}
|
||
for _, delta := range gearDeltas {
|
||
for _, hp := range hpMults {
|
||
key := comboKey{delta, hp}
|
||
parts := make([]string, 0, len(tiers))
|
||
for _, ti := range tiers {
|
||
ag := perCombo[key][ti]
|
||
if ag == nil || ag.count == 0 {
|
||
continue
|
||
}
|
||
parts = append(parts, fmt.Sprintf("T%d=%.1f%%", ti, ag.sum/ag.count))
|
||
}
|
||
t.Logf(" TIER-MEANS g=+%d h=%.2f %s", delta, hp, joinZones(parts))
|
||
}
|
||
}
|
||
}
|
||
|
||
// zoneSeedSalt produces a stable per-zone seed offset so each
|
||
// (outlier, sibling) draws from a distinct RNG stream without
|
||
// hand-numbering. fnv-like fold over the bytes; small footprint, no
|
||
// import needed.
|
||
func zoneSeedSalt(id ZoneID) uint64 {
|
||
var s uint64 = 1469598103934665603
|
||
for i := 0; i < len(id); i++ {
|
||
s ^= uint64(id[i])
|
||
s *= 1099511628211
|
||
}
|
||
return s
|
||
}
|
||
|
||
// TestExpeditionBalance_Phase5C_OutlierDiagnostic is the per-zone
|
||
// trace pass for Phase 5-C — the second roster-polish round. Phase
|
||
// 5-B's shipped baseline (HP×1.5, +3 player floor, e=23, d=1, burn=50)
|
||
// landed T1/T2/T4/T5 at or above band, but left T3 at 43% mean
|
||
// (manor 39 / underforge 47 vs band 55-75) and produced a 30pp
|
||
// T4 spread (feywild 59 vs underdark 88) with feywild trailing.
|
||
//
|
||
// This test re-runs the Phase 4-A trace on the four affected zones
|
||
// (manor + underforge as both-trail, feywild as lift-target, underdark
|
||
// as healthy reference / candidate for one mild elite demotion). Same
|
||
// monster-attribution, day-of-end, and elite/std-mix aggregates as
|
||
// Phase 4-A; the difference is the candidate zone set and that all
|
||
// four are reported side-by-side rather than as outlier+sibling pairs.
|
||
//
|
||
// Constraint per the plan doc and Phase 4-B's commit: do not touch
|
||
// monster stat blocks. Tools allowed are IsElite toggle, SpawnWeight
|
||
// rebalance, and zone-scoped boss tuning.
|
||
//
|
||
// Diagnostic-only — no gates. -short skips.
|
||
func TestExpeditionBalance_Phase5C_OutlierDiagnostic(t *testing.T) {
|
||
if testing.Short() {
|
||
t.Skip("phase 5-C diagnostic walks 4 zones × 200 trials; -short skips it")
|
||
}
|
||
|
||
const trialsPerZone = 200
|
||
const baseSeed uint64 = 0xF50C5D
|
||
// Phase 5-B shipped cell. HP×1.5 and the +3 combat floor are
|
||
// already wired into the harness via the live constants
|
||
// (phase5BHPMult, applyPhase5BPlayerFloor); only the three
|
||
// expedition-side overrides need restating.
|
||
const eliteThreshold = 23
|
||
const driftBase = 1
|
||
const supplyBurnPct = 50
|
||
|
||
zones := []struct {
|
||
id ZoneID
|
||
tier ZoneTier
|
||
role string
|
||
}{
|
||
{ZoneManorBlackspire, ZoneTierJourneyman, "TRAIL-T3"},
|
||
{ZoneUnderforge, ZoneTierJourneyman, "TRAIL-T3"},
|
||
{ZoneFeywildCrossing, ZoneTierVeteran, "TRAIL-T4"},
|
||
{ZoneUnderdark, ZoneTierVeteran, "HEALTHY-T4"},
|
||
}
|
||
|
||
type monsterStat struct {
|
||
appearances int
|
||
standardCount int
|
||
eliteCount int
|
||
wins int
|
||
hpLossOnWin int
|
||
killAttributed int
|
||
}
|
||
type zoneAgg struct {
|
||
profile expeditionBalanceProfile
|
||
completions int
|
||
deaths int
|
||
starves int
|
||
monsters map[string]*monsterStat
|
||
dayHist [16]int
|
||
eliteFights int
|
||
stdFights int
|
||
lastFight harnessFightTrace
|
||
}
|
||
|
||
report := func(label string, agg *zoneAgg) {
|
||
total := trialsPerZone
|
||
compPct := float64(agg.completions) / float64(total) * 100
|
||
deathPct := float64(agg.deaths) / float64(total) * 100
|
||
starvePct := float64(agg.starves) / float64(total) * 100
|
||
t.Logf("─── %-11s %-18s L%-2d comp=%5.1f%% death=%5.1f%% starve=%5.1f%% elite_fights=%d std_fights=%d ───",
|
||
label, agg.profile.ZoneID,
|
||
agg.profile.Level,
|
||
compPct, deathPct, starvePct,
|
||
agg.eliteFights, agg.stdFights)
|
||
names := make([]string, 0, len(agg.monsters))
|
||
for n := range agg.monsters {
|
||
names = append(names, n)
|
||
}
|
||
sort.Slice(names, func(i, j int) bool {
|
||
return agg.monsters[names[i]].appearances > agg.monsters[names[j]].appearances
|
||
})
|
||
for _, n := range names {
|
||
m := agg.monsters[n]
|
||
winPct := 0.0
|
||
if m.appearances > 0 {
|
||
winPct = float64(m.wins) / float64(m.appearances) * 100
|
||
}
|
||
avgLoss := 0.0
|
||
if m.wins > 0 {
|
||
avgLoss = float64(m.hpLossOnWin) / float64(m.wins)
|
||
}
|
||
t.Logf(" MON %-22s appear=%4d (std=%-3d elite=%-3d) win=%5.1f%% avg_hp_loss_on_win=%5.1f kills=%d",
|
||
n, m.appearances, m.standardCount, m.eliteCount,
|
||
winPct, avgLoss, m.killAttributed)
|
||
}
|
||
bins := make([]string, 0, 14)
|
||
for d := 1; d <= 14; d++ {
|
||
if agg.dayHist[d] > 0 {
|
||
bins = append(bins, fmt.Sprintf("d%d=%d", d, agg.dayHist[d]))
|
||
}
|
||
}
|
||
t.Logf(" END-DAYS %s", joinZones(bins))
|
||
}
|
||
|
||
for _, z := range zones {
|
||
level := phase1TierCenterline[z.tier]
|
||
t.Logf("═══ %-11s %s (L%d Fighter, e=%d d=%d burn=%d, %d trials) ═══",
|
||
z.role, z.id, level,
|
||
eliteThreshold, driftBase, supplyBurnPct, trialsPerZone)
|
||
|
||
profile := expeditionBalanceProfile{
|
||
ZoneID: z.id,
|
||
Class: ClassFighter,
|
||
Level: level,
|
||
Supplies: makeSupplies(z.tier, SupplyPurchase{StandardPacks: 3}),
|
||
CampType: CampTypeStandard,
|
||
EliteInterruptThresholdOverride: eliteThreshold,
|
||
ThreatDriftBaseOverride: driftBase,
|
||
SupplyBurnRatePctOverride: supplyBurnPct,
|
||
}
|
||
agg := &zoneAgg{
|
||
profile: profile,
|
||
monsters: map[string]*monsterStat{},
|
||
}
|
||
|
||
for trial := 0; trial < trialsPerZone; trial++ {
|
||
exp := newHarnessExpedition(profile)
|
||
char := buildHarnessCharacter(classBalanceProfile{
|
||
Class: profile.Class,
|
||
Level: profile.Level,
|
||
})
|
||
h := &expeditionHarness{
|
||
exp: exp,
|
||
char: char,
|
||
rng: newHarnessRNG(baseSeed + uint64(trial) + uint64(z.tier)*131 + zoneSeedSalt(z.id)),
|
||
rollsPerDay: harnessHarvestRollsPerDay,
|
||
eliteInterruptThresholdOverride: eliteThreshold,
|
||
threatDriftBaseOverride: driftBase,
|
||
supplyBurnRatePctOverride: supplyBurnPct,
|
||
traceFightStruct: func(ft harnessFightTrace) {
|
||
m, ok := agg.monsters[ft.MonsterName]
|
||
if !ok {
|
||
m = &monsterStat{}
|
||
agg.monsters[ft.MonsterName] = m
|
||
}
|
||
m.appearances++
|
||
if ft.Elite {
|
||
m.eliteCount++
|
||
agg.eliteFights++
|
||
} else {
|
||
m.standardCount++
|
||
agg.stdFights++
|
||
}
|
||
if ft.Won {
|
||
m.wins++
|
||
loss := ft.HPPre - ft.HPPost
|
||
if loss < 0 {
|
||
loss = 0
|
||
}
|
||
m.hpLossOnWin += loss
|
||
}
|
||
agg.lastFight = ft
|
||
},
|
||
}
|
||
var trialEnd expeditionTrialResult
|
||
for {
|
||
res := h.advanceExpeditionOneDay()
|
||
if res.EndedReason != "" {
|
||
trialEnd = res
|
||
break
|
||
}
|
||
}
|
||
switch {
|
||
case trialEnd.Completed:
|
||
agg.completions++
|
||
case trialEnd.Died:
|
||
agg.deaths++
|
||
if m, ok := agg.monsters[agg.lastFight.MonsterName]; ok && !agg.lastFight.Won {
|
||
m.killAttributed++
|
||
}
|
||
case trialEnd.StarvedOut:
|
||
agg.starves++
|
||
}
|
||
d := trialEnd.DaysElapsed
|
||
if d < 1 {
|
||
d = 1
|
||
}
|
||
if d > 14 {
|
||
d = 14
|
||
}
|
||
agg.dayHist[d]++
|
||
}
|
||
|
||
report(z.role, agg)
|
||
}
|
||
}
|
||
|
||
// 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.
|
||
func joinZones(parts []string) string {
|
||
out := ""
|
||
for i, p := range parts {
|
||
if i > 0 {
|
||
out += ", "
|
||
}
|
||
out += p
|
||
}
|
||
return out
|
||
}
|