mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-16 08:52:42 +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,
|
monster.Name, h.char.HPMax, nick, hpBeforeFight, result.PlayerEndHP,
|
||||||
enemy.Stats.AC, enemy.Stats.AttackBonus, outcome))
|
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
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -608,6 +623,30 @@ type expeditionHarness struct {
|
|||||||
// monster, or the combat fold itself is driving deaths. Nil in
|
// monster, or the combat fold itself is driving deaths. Nil in
|
||||||
// production runs — has zero cost when unused.
|
// production runs — has zero cost when unused.
|
||||||
traceFight func(line string)
|
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
|
// 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
|
// 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