diff --git a/gogobee_class_balance.md b/gogobee_class_balance.md index ab038f4..5d0b053 100644 --- a/gogobee_class_balance.md +++ b/gogobee_class_balance.md @@ -86,9 +86,16 @@ HP-remaining, and near-death-rate are logged as diagnostics, not asserted. - **Phase 0 — spike.** Harness skeleton; equipment + spell-selection policies; run *Fighter vs. Mage only* across tiers and sanity-check plausibility (both win something; casters not at 0%). If the numbers are implausible, fix - the policies before trusting anything. **← current** + the policies before trusting anything. **Done — commit 0878b4e.** - **Phase 1 — harness + matrix.** Generalize to all 10 classes × 30 subclasses; `TestClassBalance` logs the full report. No tuning yet — just measurement. + **Done.** Subclass field plumbed through the harness, `applySubclassPassives` + wired in to match live combat order, `buildPhase1Profiles` produces 190 rows + (10 × 4 pre-subclass + 10 × 3 × 5 post-subclass), `TestClassBalance_Phase1_FullMatrix` + logs the cells plus per-class tier means and ranges. Phase-2 calibration baseline: + 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. **← current** - **Phase 2 — tuning pass.** Adjust the levers (class passives → subclass tiers → spell dice → AC floor → attack bonus, in that order) until the parity band holds. Lock the band into the test assertion. diff --git a/internal/plugin/dnd_class_balance.go b/internal/plugin/dnd_class_balance.go index 500fa12..02ba912 100644 --- a/internal/plugin/dnd_class_balance.go +++ b/internal/plugin/dnd_class_balance.go @@ -5,7 +5,10 @@ import ( "sort" ) -// Phase 0 spike for the class-balance pass (gogobee_class_balance.md). +// Measurement harness for the class-balance pass (gogobee_class_balance.md). +// Phase 0 introduced this for a Fighter-vs-Mage spike; Phase 1 extended it +// to drive the full 10-class × 30-subclass matrix (subclass=="" at L1–L4, +// each of a class's three subclasses at the L5/L7/L10/L15/L20 checkpoints). // // Sibling to dnd_race_balance.go — same spirit, different method. Races // don't fight, so race balance had to use a hand-weighted scoring proxy. @@ -25,8 +28,6 @@ import ( // - DB-touching layers: applyMagicItemEffects, applyArmedAbility, and // the SaveDnDCharacter inside applyPendingCast. The harness is pure // Go; tests run without a sqlite instance. -// - Subclass passives: doc §2 specifies subclass = none below L5, and -// we only need L1–L4 to sanity-check Phase 0. // - Race passives beyond Human (+1 all): neutral baseline, again per §2. // - Inventory consumables: empty. // @@ -43,8 +44,9 @@ import ( // shipped in the race-balance pass) so class numbers aren't skewed by // racial mods. type classBalanceProfile struct { - Class DnDClass - Level int + Class DnDClass + Subclass DnDSubclass // empty below L5, per doc §2 + Level int } // classBalanceResult is the empirical performance of one profile against @@ -316,10 +318,11 @@ func buildHarnessCharacter(p classBalanceProfile) *DnDCharacter { scores := classStatPriority(p.Class) scores = applyRaceMods(RaceHuman, scores) c := &DnDCharacter{ - Race: RaceHuman, - Class: p.Class, - Level: p.Level, - STR: scores[0], DEX: scores[1], CON: scores[2], + Race: RaceHuman, + Class: p.Class, + Subclass: p.Subclass, + Level: p.Level, + STR: scores[0], DEX: scores[1], CON: scores[2], INT: scores[3], WIS: scores[4], CHA: scores[5], } conMod := abilityModifier(c.CON) @@ -361,9 +364,13 @@ func buildHarnessPlayer(c *DnDCharacter) Combatant { stats.AC = computeArmorAC(armor, shield, abilityModifier(c.DEX)) } - // 3. Passives (no subclass in Phase 0). + // 3. Passives. Live order is class → race → subclass (see + // combat_bridge.go and combat_session_build.go). Subclass passives are + // a no-op when c.Subclass == "" — the harness uses that for the L1–L4 + // pre-unlock rows. applyClassPassives(&stats, &mods, c) applyRacePassives(&stats, &mods, c) + applySubclassPassives(&stats, &mods, c) return Combatant{ Name: string(c.Class), @@ -462,6 +469,43 @@ func runClassBalanceMatrix(profiles []classBalanceProfile, trials int) []classBa return out } +// ── Phase 1 matrix builder ─────────────────────────────────────────────────── + +// phase1SubclassLevels is the post-unlock checkpoint ladder from doc §2. +// L5/L7/L10/L15/L20 line up with the subclass tier-unlock structure in +// dnd_subclass_combat.go — each row reads a class's behaviour at one more +// unlocked tier than the row above it. +var phase1SubclassLevels = []int{5, 7, 10, 15, 20} + +// phase1PreSubclassLevels is the L1–L4 ladder run with Subclass=="". Doc §2 +// notes that subclasses aren't selected until L5, so these rows measure the +// raw class chassis. +var phase1PreSubclassLevels = []int{1, 2, 3, 4} + +// buildPhase1Profiles assembles the full Phase 1 build matrix: every class +// at L1–L4 (no subclass), then each of that class's three subclasses at +// each of the five tier-unlock checkpoints. 10 × 4 + 10 × 3 × 5 = 190 rows. +// Order is registry order (dndClasses, then subclassesForClass) so the +// matrix log reads the same way as the design doc and the !class help. +func buildPhase1Profiles() []classBalanceProfile { + out := make([]classBalanceProfile, 0, 10*4+10*3*5) + for _, ci := range dndClasses { + for _, lvl := range phase1PreSubclassLevels { + out = append(out, classBalanceProfile{Class: ci.Key, Level: lvl}) + } + for _, si := range subclassesForClass(ci.Key) { + for _, lvl := range phase1SubclassLevels { + out = append(out, classBalanceProfile{ + Class: ci.Key, + Subclass: si.ID, + Level: lvl, + }) + } + } + } + return out +} + // _ keeps the math/rand/v2 import live in case future iterations of this // file want to draw directly (e.g. for harness-level RNG control). Today // every randomized step is inside production helpers. diff --git a/internal/plugin/dnd_class_balance_test.go b/internal/plugin/dnd_class_balance_test.go index 4f4a738..8319b9e 100644 --- a/internal/plugin/dnd_class_balance_test.go +++ b/internal/plugin/dnd_class_balance_test.go @@ -1,6 +1,7 @@ package plugin import ( + "sort" "testing" ) @@ -84,3 +85,149 @@ func TestClassBalance_Phase0_FighterVsMage(t *testing.T) { 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) + } + } +}