mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 00:32:40 +00:00
Phase 4a (diag): per-zone outlier attribution
The Phase 3-B sweep wrung out the global lever surface and named four
zones that read ~0% completion under 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 44%, sunken_temple 13.5%, underforge
2.5%, dragons_lair 57.5%) so the gap is per-zone, not tier-wide.
Adds a structured per-fight trace hook (traceFightStruct + the
harnessFightTrace struct that mirrors the existing string trace) so
diagnostics can aggregate without parsing the formatted log line.
Mirror-format with traceFight; if a field is added, update both
paths.
TestExpeditionBalance_Phase4A_OutlierDiagnostic walks the four
outlier-vs-sibling pairs at 200 trials each on the Phase 3-A/3-B best
cell (e=23 d=1 burn=50) and reports per-monster appearances /
win-rate / avg HP loss / kill attribution + day-of-end histogram +
elite-vs-standard fight mix.
Findings:
- crypt_valdris: dual-killer elite pool (Wight 99 kills, Flameskull
68). Phase 2c left this zone untouched ("already dual-elite") but
both elites are over-tier for T1.
- forest_shadows: standard pool too lethal — Displacer Beast (53
kills, 38% win standard) + Bandit Captain (48 kills, 57% win).
- manor_blackspire: Wraith on the standard slot is dragging the
floor (85 kills, 45hp loss per win). Vampire Spawn + Revenant
elite pair is also rough.
- abyss_portal: Nalfeshnee mis-classified standard (86 kills, 2.8%
win at L17). Vrock at 79% win is borderline.
Phase 4-B applies per-zone roster tweaks (IsElite re-flag, drop a
deadly entry, soften a SpawnWeight) — no monster stat-block changes.
T3 may need a follow-up tier-wide pass since the sibling underforge
also sits at 2.5%; out of scope here.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -504,6 +504,21 @@ func (h *expeditionHarness) runHarnessFight(zone ZoneDefinition, elite bool) Com
|
||||
monster.Name, h.char.HPMax, nick, hpBeforeFight, result.PlayerEndHP,
|
||||
enemy.Stats.AC, enemy.Stats.AttackBonus, outcome))
|
||||
}
|
||||
if h.traceFightStruct != nil {
|
||||
h.traceFightStruct(harnessFightTrace{
|
||||
Day: h.exp.CurrentDay,
|
||||
Tier: int(zone.Tier),
|
||||
Elite: elite,
|
||||
MonsterName: monster.Name,
|
||||
HPMax: h.char.HPMax,
|
||||
Nick: nick,
|
||||
HPPre: hpBeforeFight,
|
||||
HPPost: result.PlayerEndHP,
|
||||
EnemyAC: enemy.Stats.AC,
|
||||
EnemyAtk: enemy.Stats.AttackBonus,
|
||||
Won: result.PlayerWon,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -608,6 +623,30 @@ type expeditionHarness struct {
|
||||
// monster, or the combat fold itself is driving deaths. Nil in
|
||||
// production runs — has zero cost when unused.
|
||||
traceFight func(line string)
|
||||
// traceFightStruct, if non-nil, fires alongside traceFight with the
|
||||
// same fight's data as a parsed struct. Lets aggregate diagnostics
|
||||
// (e.g. Phase 4-A's per-monster attribution) skip the fragile parse
|
||||
// of the formatted line. Retreat events are not surfaced here — the
|
||||
// retreat trace is one-line only via traceFight. Nil in production.
|
||||
traceFightStruct func(harnessFightTrace)
|
||||
}
|
||||
|
||||
// harnessFightTrace is the structured per-fight record exposed via
|
||||
// traceFightStruct. Mirrors the fields the formatted traceFight line
|
||||
// already emits; if you add a field, update both paths so the human
|
||||
// log and the structured aggregate stay in sync.
|
||||
type harnessFightTrace struct {
|
||||
Day int
|
||||
Tier int
|
||||
Elite bool
|
||||
MonsterName string
|
||||
HPMax int
|
||||
Nick int
|
||||
HPPre int
|
||||
HPPost int
|
||||
EnemyAC int
|
||||
EnemyAtk int
|
||||
Won bool
|
||||
}
|
||||
|
||||
// harnessRNG is a thin wrapper around math/rand/v2 so the d20 helper
|
||||
|
||||
@@ -855,6 +855,246 @@ func TestExpeditionBalance_Phase3B_NickSupplySweep(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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