mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
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=="".
This commit is contained in:
@@ -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;
|
- **Phase 0 — spike.** Harness skeleton; equipment + spell-selection policies;
|
||||||
run *Fighter vs. Mage only* across tiers and sanity-check plausibility
|
run *Fighter vs. Mage only* across tiers and sanity-check plausibility
|
||||||
(both win something; casters not at 0%). If the numbers are implausible, fix
|
(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;
|
- **Phase 1 — harness + matrix.** Generalize to all 10 classes × 30 subclasses;
|
||||||
`TestClassBalance` logs the full report. No tuning yet — just measurement.
|
`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
|
- **Phase 2 — tuning pass.** Adjust the levers (class passives → subclass tiers
|
||||||
→ spell dice → AC floor → attack bonus, in that order) until the parity band
|
→ spell dice → AC floor → attack bonus, in that order) until the parity band
|
||||||
holds. Lock the band into the test assertion.
|
holds. Lock the band into the test assertion.
|
||||||
|
|||||||
@@ -5,7 +5,10 @@ import (
|
|||||||
"sort"
|
"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
|
// 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.
|
// 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
|
// - DB-touching layers: applyMagicItemEffects, applyArmedAbility, and
|
||||||
// the SaveDnDCharacter inside applyPendingCast. The harness is pure
|
// the SaveDnDCharacter inside applyPendingCast. The harness is pure
|
||||||
// Go; tests run without a sqlite instance.
|
// 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.
|
// - Race passives beyond Human (+1 all): neutral baseline, again per §2.
|
||||||
// - Inventory consumables: empty.
|
// - Inventory consumables: empty.
|
||||||
//
|
//
|
||||||
@@ -43,8 +44,9 @@ import (
|
|||||||
// shipped in the race-balance pass) so class numbers aren't skewed by
|
// shipped in the race-balance pass) so class numbers aren't skewed by
|
||||||
// racial mods.
|
// racial mods.
|
||||||
type classBalanceProfile struct {
|
type classBalanceProfile struct {
|
||||||
Class DnDClass
|
Class DnDClass
|
||||||
Level int
|
Subclass DnDSubclass // empty below L5, per doc §2
|
||||||
|
Level int
|
||||||
}
|
}
|
||||||
|
|
||||||
// classBalanceResult is the empirical performance of one profile against
|
// classBalanceResult is the empirical performance of one profile against
|
||||||
@@ -316,10 +318,11 @@ func buildHarnessCharacter(p classBalanceProfile) *DnDCharacter {
|
|||||||
scores := classStatPriority(p.Class)
|
scores := classStatPriority(p.Class)
|
||||||
scores = applyRaceMods(RaceHuman, scores)
|
scores = applyRaceMods(RaceHuman, scores)
|
||||||
c := &DnDCharacter{
|
c := &DnDCharacter{
|
||||||
Race: RaceHuman,
|
Race: RaceHuman,
|
||||||
Class: p.Class,
|
Class: p.Class,
|
||||||
Level: p.Level,
|
Subclass: p.Subclass,
|
||||||
STR: scores[0], DEX: scores[1], CON: scores[2],
|
Level: p.Level,
|
||||||
|
STR: scores[0], DEX: scores[1], CON: scores[2],
|
||||||
INT: scores[3], WIS: scores[4], CHA: scores[5],
|
INT: scores[3], WIS: scores[4], CHA: scores[5],
|
||||||
}
|
}
|
||||||
conMod := abilityModifier(c.CON)
|
conMod := abilityModifier(c.CON)
|
||||||
@@ -361,9 +364,13 @@ func buildHarnessPlayer(c *DnDCharacter) Combatant {
|
|||||||
stats.AC = computeArmorAC(armor, shield, abilityModifier(c.DEX))
|
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)
|
applyClassPassives(&stats, &mods, c)
|
||||||
applyRacePassives(&stats, &mods, c)
|
applyRacePassives(&stats, &mods, c)
|
||||||
|
applySubclassPassives(&stats, &mods, c)
|
||||||
|
|
||||||
return Combatant{
|
return Combatant{
|
||||||
Name: string(c.Class),
|
Name: string(c.Class),
|
||||||
@@ -462,6 +469,43 @@ func runClassBalanceMatrix(profiles []classBalanceProfile, trials int) []classBa
|
|||||||
return out
|
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
|
// _ 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
|
// file want to draw directly (e.g. for harness-level RNG control). Today
|
||||||
// every randomized step is inside production helpers.
|
// every randomized step is inside production helpers.
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package plugin
|
package plugin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"sort"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -84,3 +85,149 @@ func TestClassBalance_Phase0_FighterVsMage(t *testing.T) {
|
|||||||
mageT1, fighterT1)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user