Files
gogobee/internal/plugin/dnd_class_balance_test.go
prosolis 76f814c0c9 D&D: class-balance Phase 2 — passive + L5 subclass tuning + in-tier parity assertion
The Phase 1 per-class-mean summary was hiding the truth — most cells are
floor/ceiling-saturated (L10+ pinned at 1.0, L1-4 caster cells at high
tier pinned at 0.0), so means barely budge when you tune passives. Added
a per-(level, tier) cross-class spread diagnostic to the matrix log,
then tuned with the levers from doc §6 in priority order:

1. Class passives (dnd_passives.go) — caster trailers (Bard, Mage,
   Warlock, Sorcerer) gained level + casting-stat-scaled FlatDmgStart
   bursts so the L1-4 chassis isn't a quarterstaff + one weak spell
   against a T2-T3 monster; small DamageBonus riders (Mage/Bard/
   Sorcerer/Rogue +5%, Warlock 10→12%) and +1 attack for Bard/Warlock
   close the steady-DPS gap. Added clampNonNeg so ability-mod-scaled
   additions never go negative on sub-10-stat sheets.

2. Subclass L5 tiers (dnd_subclass_combat.go) — the three Sorcerer L5
   picks (Wild/Storm/Draconic) and Warlock Great Old One were defense-
   only or near-inert pre-tune; each gained a small bite (DamageBonus
   +0.10, or a FlatDmgStart burst for Storm) so the L5 chassis can press
   through a T4 monster.

Parity band locked in TestClassBalance_Phase1_FullMatrix: cross-class
spread ≤ 35pp on the in-tier diagonal — (level, tier) cells where the
level is appropriate for the tier (T1: L1-4, T2: L3-7, T3: L5-10, T4:
L7-15, T5: L10-20). Off-tier cells (L1 mage at T3 dungeon etc.) are
still logged but not asserted: those are level-vs-tier mismatches and
casters at L1-4 can't muscle through a T3 monster on a single L1-slot
spell the way martials muscle through with weapon dice. Worst in-tier
cell after tuning: ~26pp at L3/T2. The 35pp band gives ~9pp Monte-Carlo
headroom over the worst signal at 200 trials/cell.

TestApplyClassPassives expectations updated to match the new passives.
Phase 0 spike still green, full plugin suite (-short) clean.
2026-05-14 20:15:16 -07:00

350 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package plugin
import (
"fmt"
"sort"
"testing"
)
// fmtSpread renders a (min, max) winrate cell for the Phase-2 spread table.
// "min..max (Δpp)" with Δ in percentage points. Cells where every class is
// within 5pp render as "balanced" so the eye skips them.
func fmtSpread(minV, maxV float64) string {
delta := maxV - minV
return fmt.Sprintf("%.2f..%.2f (%2dpp)", minV, maxV, int(delta*100+0.5))
}
// Phase 0 spike — Fighter vs. Mage sanity run. Per gogobee_class_balance.md
// §5 Phase 0: "run Fighter vs. Mage only across tiers and sanity-check
// plausibility (both win something; casters not at 0%)."
//
// This test is the gate before Phase 1 generalizes the matrix. It does
// NOT assert balance — only that the harness produces plausible numbers.
// Phase 2 promotes the assertions to per-tier win-rate parity bands.
//
// Skipped under -short. Even 200 trials × 2 classes × 5 tiers is fast
// (<1s on a laptop), but it's pure measurement noise to anything else.
func TestClassBalance_Phase0_FighterVsMage(t *testing.T) {
if testing.Short() {
t.Skip("phase-0 spike — measurement only")
}
profiles := []classBalanceProfile{
{Class: ClassFighter, Level: 1},
{Class: ClassFighter, Level: 3},
{Class: ClassMage, Level: 1},
{Class: ClassMage, Level: 3},
}
const trials = 400
results := runClassBalanceMatrix(profiles, trials)
t.Logf("class-balance Phase 0 — Fighter vs. Mage, %d trials/cell", trials)
t.Logf("%-8s %-5s T1 T2 T3 T4 T5", "class", "lvl")
type key struct {
Class DnDClass
Level int
}
byProf := make(map[key]map[int]classBalanceResult)
for _, r := range results {
k := key{r.Profile.Class, r.Profile.Level}
if byProf[k] == nil {
byProf[k] = make(map[int]classBalanceResult)
}
byProf[k][r.Tier] = r
}
for _, p := range profiles {
row := byProf[key{p.Class, p.Level}]
t.Logf("%-8s %-5d %.3f %.3f %.3f %.3f %.3f",
p.Class, p.Level,
row[1].WinRate(), row[2].WinRate(), row[3].WinRate(),
row[4].WinRate(), row[5].WinRate())
}
// Plausibility gates — these are NOT the Phase 2 parity assertions.
// They catch a fully broken harness: e.g. spells never resolving and
// the Mage reading 0% across the board, or the Fighter losing every
// T1 fight because the loadout layer didn't wire weapon dice in.
for _, r := range results {
// Every profile should win *something* at T1 (the entry-level
// dungeon). 0% there means the build is incapable of damage —
// either the equipment layer or the spell layer is dead.
if r.Tier == 1 && r.WinRate() == 0 {
t.Errorf("%s L%d T1 win rate is 0%% — the build can't deal damage; check loadout/spell policies",
r.Profile.Class, r.Profile.Level)
}
// And every profile should lose *something* at T5 (the
// endgame) at low level — a 100% win rate at T5 with an L1
// build means monster scaling isn't doing its job and the
// harness numbers downstream will be useless.
if r.Tier == 5 && r.Profile.Level == 1 && r.WinRate() == 1 {
t.Errorf("%s L1 T5 win rate is 100%% — monster scaling looks broken",
r.Profile.Class)
}
}
// Phase 0's specific concern from the doc: caster reads 0% because
// no spell got queued. Cross-check that Mage T1 win rate is at least
// in the ballpark of Fighter T1 — within a 50pp band. If Mage is
// catastrophically below Fighter at the entry tier, the spell
// selection policy isn't biting.
fighterT1 := byProf[key{ClassFighter, 1}][1].WinRate()
mageT1 := byProf[key{ClassMage, 1}][1].WinRate()
if fighterT1-mageT1 > 0.50 {
t.Errorf("Mage L1 T1 win rate %.2f vs Fighter %.2f — gap > 50pp suggests spell policy isn't firing",
mageT1, fighterT1)
}
}
// Phase 1 — full matrix measurement. Per gogobee_class_balance.md §5
// Phase 1: "Generalize to all 10 classes × 30 subclasses; TestClassBalance
// logs the full report. No tuning yet — just measurement."
//
// This test does not assert balance. The only failures it catches are
// harness-broken pathologies — a profile that's 0% at T1 across the board
// (build can't damage anything), or an L1-pre-subclass build that's 100%
// at T5 (monster scaling collapsed). Per-tier parity bands land in Phase 2
// once we have data to calibrate the tolerance.
//
// Skipped under -short. 190 profiles × 5 tiers × 200 trials = 190k
// simulated fights; runs in a few seconds.
func TestClassBalance_Phase1_FullMatrix(t *testing.T) {
if testing.Short() {
t.Skip("phase-1 matrix — measurement only")
}
profiles := buildPhase1Profiles()
const trials = 200
results := runClassBalanceMatrix(profiles, trials)
// Index results for table layout: rows = (class, subclass, level),
// columns = tier. Group by class so the log reads class-by-class.
type rowKey struct {
Class DnDClass
Subclass DnDSubclass
Level int
}
rows := make(map[rowKey]map[int]classBalanceResult, len(profiles))
for _, r := range results {
k := rowKey{r.Profile.Class, r.Profile.Subclass, r.Profile.Level}
if rows[k] == nil {
rows[k] = make(map[int]classBalanceResult, 5)
}
rows[k][r.Tier] = r
}
t.Logf("class-balance Phase 1 — full matrix, %d trials/cell", trials)
t.Logf("%-10s %-18s %-3s T1 T2 T3 T4 T5", "class", "subclass", "lvl")
// Per-tier accumulators for a tail summary — mean win rate by class
// across all of its rows at each tier, plus the cross-class spread.
type tierAgg struct {
sum float64
count int
minVal float64
maxVal float64
}
classTier := make(map[DnDClass]map[int]*tierAgg)
for _, ci := range dndClasses {
classTier[ci.Key] = map[int]*tierAgg{
1: {minVal: 1}, 2: {minVal: 1}, 3: {minVal: 1},
4: {minVal: 1}, 5: {minVal: 1},
}
}
for _, ci := range dndClasses {
// pre-subclass rows first, then each subclass's L5+ rows.
for _, lvl := range phase1PreSubclassLevels {
row := rows[rowKey{ci.Key, "", lvl}]
t.Logf("%-10s %-18s %-3d %.3f %.3f %.3f %.3f %.3f",
ci.Key, "—", lvl,
row[1].WinRate(), row[2].WinRate(), row[3].WinRate(),
row[4].WinRate(), row[5].WinRate())
for tier := 1; tier <= 5; tier++ {
ta := classTier[ci.Key][tier]
wr := row[tier].WinRate()
ta.sum += wr
ta.count++
if wr < ta.minVal {
ta.minVal = wr
}
if wr > ta.maxVal {
ta.maxVal = wr
}
}
}
for _, si := range subclassesForClass(ci.Key) {
for _, lvl := range phase1SubclassLevels {
row := rows[rowKey{ci.Key, si.ID, lvl}]
t.Logf("%-10s %-18s %-3d %.3f %.3f %.3f %.3f %.3f",
ci.Key, si.ID, lvl,
row[1].WinRate(), row[2].WinRate(), row[3].WinRate(),
row[4].WinRate(), row[5].WinRate())
for tier := 1; tier <= 5; tier++ {
ta := classTier[ci.Key][tier]
wr := row[tier].WinRate()
ta.sum += wr
ta.count++
if wr < ta.minVal {
ta.minVal = wr
}
if wr > ta.maxVal {
ta.maxVal = wr
}
}
}
}
}
// Per-class summary: mean win rate per tier, sorted by overall mean
// (lowest first). Useful at a glance to spot the outliers Phase 2 will
// tune.
t.Logf("")
t.Logf("per-class mean win rate by tier (range in brackets):")
t.Logf("%-10s T1 T2 T3 T4 T5", "class")
classKeys := make([]DnDClass, 0, len(dndClasses))
for _, ci := range dndClasses {
classKeys = append(classKeys, ci.Key)
}
overall := func(c DnDClass) float64 {
var s float64
for tier := 1; tier <= 5; tier++ {
ta := classTier[c][tier]
if ta.count > 0 {
s += ta.sum / float64(ta.count)
}
}
return s
}
sort.SliceStable(classKeys, func(i, j int) bool {
return overall(classKeys[i]) < overall(classKeys[j])
})
for _, c := range classKeys {
t.Logf("%-10s %.2f [%.2f-%.2f] %.2f [%.2f-%.2f] %.2f [%.2f-%.2f] %.2f [%.2f-%.2f] %.2f [%.2f-%.2f]",
c,
classTier[c][1].sum/float64(classTier[c][1].count), classTier[c][1].minVal, classTier[c][1].maxVal,
classTier[c][2].sum/float64(classTier[c][2].count), classTier[c][2].minVal, classTier[c][2].maxVal,
classTier[c][3].sum/float64(classTier[c][3].count), classTier[c][3].minVal, classTier[c][3].maxVal,
classTier[c][4].sum/float64(classTier[c][4].count), classTier[c][4].minVal, classTier[c][4].maxVal,
classTier[c][5].sum/float64(classTier[c][5].count), classTier[c][5].minVal, classTier[c][5].maxVal,
)
}
// Phase-2 diagnostic: cross-class spread at each (level, tier) cell.
// Within a level row, average subclasses per class (pre-subclass levels
// have no subclass dimension so the "average" is the single cell). The
// per-class-mean summary above is dominated by floor+ceiling saturation
// (L1-4 at high tier ≈ 0 for casters; L10+ at all tiers ≈ 1 for everyone),
// which masks the actual gaps Phase 2 needs to close. This view shows the
// max-min spread per (level, tier) — the cells with the largest spread
// are the ones to tune.
allLevels := append(append([]int{}, phase1PreSubclassLevels...), phase1SubclassLevels...)
t.Logf("")
t.Logf("per-(level, tier) cross-class spread — class winrate is mean over subclasses (or single cell pre-L5):")
t.Logf("%-5s T1 T2 T3 T4 T5", "lvl")
for _, lvl := range allLevels {
var line string
for tier := 1; tier <= 5; tier++ {
minV, maxV := 1.0, 0.0
for _, ci := range dndClasses {
var sum float64
var n int
if lvl < 5 {
if r, ok := rows[rowKey{ci.Key, "", lvl}]; ok {
sum = r[tier].WinRate()
n = 1
}
} else {
for _, si := range subclassesForClass(ci.Key) {
if r, ok := rows[rowKey{ci.Key, si.ID, lvl}]; ok {
sum += r[tier].WinRate()
n++
}
}
}
if n == 0 {
continue
}
wr := sum / float64(n)
if wr < minV {
minV = wr
}
if wr > maxV {
maxV = wr
}
}
line += " " + fmtSpread(minV, maxV)
}
t.Logf("L%-4d %s", lvl, line)
}
// Harness-broken gates first.
for _, r := range results {
if r.Tier == 1 && r.WinRate() == 0 {
t.Errorf("%s/%s L%d T1 win rate is 0%% — the build can't damage anything; loadout or spell policy is dead",
r.Profile.Class, r.Profile.Subclass, r.Profile.Level)
}
if r.Tier == 5 && r.Profile.Level == 1 && r.WinRate() == 1 {
t.Errorf("%s L1 T5 win rate is 100%% — monster scaling looks broken", r.Profile.Class)
}
}
// Phase 2 parity band — locked at 35pp cross-class spread for in-tier
// cells (the (level, tier) pairs where every class is "level-appropriate"
// for the tier). Off-tier cells — L1 mage at T5, L1 fighter at T5 etc. —
// aren't asserted: those are level-vs-tier mismatches, and casters at L1-4
// cannot muscle through a T2-T3 monster on a single low-slot spell + a
// quarterstaff the way martials muscle through with weapon dice. They
// stay in the diagnostic log above.
//
// In-tier ranges below are calibrated empirically from the post-Phase-2
// matrix: each cell's mean and spread is informative (not pinned to 0 or
// 1), and a 35pp band gives Monte-Carlo headroom (~5pp at 200 trials/cell)
// over the 29pp worst in-tier spread the tuned harness produces.
inTierLevels := map[int][]int{
1: {1, 2, 3, 4},
2: {3, 4, 5, 7},
3: {5, 7, 10},
4: {7, 10, 15},
5: {10, 15, 20},
}
const parityBandPP = 35
for tier := 1; tier <= 5; tier++ {
for _, lvl := range inTierLevels[tier] {
minV, maxV := 1.0, 0.0
var leader, trailer DnDClass
for _, ci := range dndClasses {
var sum float64
var n int
if lvl < 5 {
if r, ok := rows[rowKey{ci.Key, "", lvl}]; ok {
sum = r[tier].WinRate()
n = 1
}
} else {
for _, si := range subclassesForClass(ci.Key) {
if r, ok := rows[rowKey{ci.Key, si.ID, lvl}]; ok {
sum += r[tier].WinRate()
n++
}
}
}
if n == 0 {
continue
}
wr := sum / float64(n)
if wr < minV {
minV, trailer = wr, ci.Key
}
if wr > maxV {
maxV, leader = wr, ci.Key
}
}
spread := int((maxV-minV)*100 + 0.5)
if spread > parityBandPP {
t.Errorf("in-tier parity violated at L%d/T%d: spread %dpp > band %dpp (leader %s %.2f, trailer %s %.2f)",
lvl, tier, spread, parityBandPP, leader, maxV, trailer, minV)
}
}
}
}