mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Per gogobee_class_balance.md §5 Phase 0: harness skeleton, equipment and spell-selection policies, Fighter-vs-Mage plausibility run before Phase 1 generalizes to the full 10 × 30 matrix. Bypassed in Phase 0 (per doc §2): DB-touching layers (magic items, armed abilities, pending-cast persistence), subclass passives (none below L5), and race passives beyond Human +1-all. Everything else flows through the production combat path. Initial numbers (400 trials/cell, dungeon T1..T5): fighter L1 .998 .805 .165 .020 .000 fighter L3 1.000 .998 .795 .235 .035 mage L1 .880 .190 .003 .000 .000 mage L3 1.000 .950 .158 .003 .000 Both classes win at T1 (spell policy is firing — Magic Missile lands each Mage fight); both collapse by their off-tier — monster scaling works. The Mage's L1 T2 gap (-60pp vs Fighter) is real data, not a broken harness. Phase 1 picks up the full matrix from here.
87 lines
3.2 KiB
Go
87 lines
3.2 KiB
Go
package plugin
|
||
|
||
import (
|
||
"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)
|
||
}
|
||
}
|