Files
gogobee/internal/plugin/dnd_class_balance_test.go
prosolis ddfa89e7a7 D&D: class-balance Phase 1 — full 10×30 measurement matrix
Generalizes the Phase 0 spike harness to the full build matrix the
class-balance doc plans for. No tuning yet — just measurement.

- classBalanceProfile gains Subclass; buildHarnessCharacter sets it on
  the synthetic DnDCharacter; buildHarnessPlayer now calls
  applySubclassPassives after class+race passives, matching live order
  (combat_bridge.go, combat_session_build.go). Subclass="" is a no-op,
  so L1–L4 pre-unlock rows are unaffected.
- buildPhase1Profiles yields 190 rows: 10 classes × 4 pre-subclass
  levels (L1–L4) + 10 classes × 3 subclasses × 5 post-unlock checkpoints
  (L5/7/10/15/20). Order is registry order so output reads like the
  design doc / !class help.
- TestClassBalance_Phase1_FullMatrix runs the matrix at 200 trials/cell
  (~5.5s) and logs every cell plus a per-class tier-mean summary with
  min/max range. Only harness-broken pathologies fail the test (0% at
  T1 anywhere, or 100% at T5 for an L1 build); per-tier parity bands
  land in Phase 2 once we have data to calibrate the tolerance.

Phase-2 baseline from this run: at T4 the cross-class spread of mean
win rate runs Bard 0.62 → Fighter 0.80 (~18pp); at T5 0.48 → 0.64
(~16pp); casters trail martials at the post-unlock tier (T3) by ~20pp.

Phase 0 test (TestClassBalance_Phase0_FighterVsMage) still green with
identical numbers — the additional applySubclassPassives call is a
no-op for Subclass=="".
2026-05-14 20:00:00 -07:00

234 lines
8.3 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 (
"sort"
"testing"
)
// 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,
)
}
// Harness-broken gates only. Tuned-balance assertions land in Phase 2.
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)
}
}
}