mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 16:42:41 +00:00
D&D: class-balance Phase 0 — Fighter-vs-Mage Monte Carlo spike
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.
This commit is contained in:
468
internal/plugin/dnd_class_balance.go
Normal file
468
internal/plugin/dnd_class_balance.go
Normal file
@@ -0,0 +1,468 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"math/rand/v2"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// Phase 0 spike for the class-balance pass (gogobee_class_balance.md).
|
||||
//
|
||||
// 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.
|
||||
// Classes do fight: combat collapses to a single seedable call into the
|
||||
// engine, so class balance is *measured*, not modeled. This file is the
|
||||
// measurement harness.
|
||||
//
|
||||
// Scope here is Phase 0 only: build a synthetic Fighter and Mage at a
|
||||
// handful of levels, layer equipment and a queued spell the same way live
|
||||
// combat does, run N fights per dungeon tier, read the win rates. The
|
||||
// goal is to sanity-check the two policies the doc flags in §3 — the
|
||||
// equipment loadout and the spell-selection heuristic — before Phase 1
|
||||
// generalizes to all 10 classes × 30 subclasses.
|
||||
//
|
||||
// Bypassed deliberately (Phase 0 simplifying constraints, doc §2):
|
||||
//
|
||||
// - 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.
|
||||
//
|
||||
// Everything else flows through the production code paths
|
||||
// (applyDnDPlayerLayer, applyClassPassives, applyRacePassives, the
|
||||
// equipment-derived AC/weapon-dice resolution inside SimulateCombat) so
|
||||
// numbers from this harness are directly comparable to live fights at
|
||||
// the same character level.
|
||||
|
||||
// ── Build profile ────────────────────────────────────────────────────────────
|
||||
|
||||
// classBalanceProfile is one row of the matrix: a single class build at a
|
||||
// single level. Race is fixed to Human (the +1-to-all neutral baseline
|
||||
// shipped in the race-balance pass) so class numbers aren't skewed by
|
||||
// racial mods.
|
||||
type classBalanceProfile struct {
|
||||
Class DnDClass
|
||||
Level int
|
||||
}
|
||||
|
||||
// classBalanceResult is the empirical performance of one profile against
|
||||
// one dungeon tier. WinRate is the headline number; the rest are
|
||||
// diagnostics logged but not asserted on (per doc §4).
|
||||
type classBalanceResult struct {
|
||||
Profile classBalanceProfile
|
||||
Tier int
|
||||
Trials int
|
||||
Wins int
|
||||
AvgHPRemainingPct float64 // mean of endHP/MaxHP across won trials; 0 if no wins
|
||||
NearDeathRate float64 // fraction of trials flagged NearDeath
|
||||
}
|
||||
|
||||
// WinRate is the cell value the doc's parity rule asserts on once we get
|
||||
// to Phase 2 tuning.
|
||||
func (r classBalanceResult) WinRate() float64 {
|
||||
if r.Trials == 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(r.Wins) / float64(r.Trials)
|
||||
}
|
||||
|
||||
// ── Equipment loadout policy (doc §3.1) ──────────────────────────────────────
|
||||
//
|
||||
// One of the two policies Phase 0 exists to de-risk. The kit must be
|
||||
// standardized fairly across classes — otherwise downstream win rates
|
||||
// reflect the kit, not the class. The mapping below treats character
|
||||
// level as a proxy for "what tier of gear would a player at this level
|
||||
// realistically be holding," using the dungeon tier MinLevel ladder
|
||||
// (advDungeons in adventure_activities.go) as the reference.
|
||||
//
|
||||
// L1–L4 → T1 mundane kit (no magic bonus)
|
||||
// L5–L8 → T2, +1 weapon and armor
|
||||
// L9–L12 → T3, +2
|
||||
// L13–L16 → T4, +3
|
||||
// L17–L20 → T5, +3 (cap — the appendix doesn't go higher)
|
||||
//
|
||||
// Per-class kit choice tracks the character's primary attack stat: STR
|
||||
// martials wear heavy armor and swing martial-melee; DEX skirmishers
|
||||
// take light armor and a finesse weapon; casters keep a quarterstaff
|
||||
// and rely on Mage Armor / class AC floors instead of armor proficiency.
|
||||
|
||||
// gearTier maps a character level to a 1..5 magic/quality tier for the
|
||||
// loadout policy. Kept private and tunable in one place.
|
||||
func gearTier(level int) int {
|
||||
switch {
|
||||
case level >= 17:
|
||||
return 5
|
||||
case level >= 13:
|
||||
return 4
|
||||
case level >= 9:
|
||||
return 3
|
||||
case level >= 5:
|
||||
return 2
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
// magicBonusForTier is the +X enchantment we hand the player at this tier.
|
||||
// T1 is mundane; the +1/+2/+3 ladder mirrors gogobee_equipment_appendix.md
|
||||
// §7 magic-weapon tiers.
|
||||
func magicBonusForTier(tier int) int {
|
||||
switch tier {
|
||||
case 1:
|
||||
return 0
|
||||
case 2:
|
||||
return 1
|
||||
case 3:
|
||||
return 2
|
||||
default:
|
||||
return 3
|
||||
}
|
||||
}
|
||||
|
||||
// classLoadout is the standardized weapon + armor + shield kit a class
|
||||
// fights with at this level. Returning copies so the caller can mutate
|
||||
// MagicBonus without poisoning the registry. armor or shield may be nil.
|
||||
func classLoadout(class DnDClass, level int) (weapon *WeaponProfile, armor *ArmorProfile, shield *ArmorProfile) {
|
||||
tier := gearTier(level)
|
||||
mb := magicBonusForTier(tier)
|
||||
|
||||
weaponID, armorID, useShield := classLoadoutIDs(class)
|
||||
if w := weaponByID(weaponID); w != nil {
|
||||
copy := *w
|
||||
copy.MagicBonus = mb
|
||||
weapon = ©
|
||||
}
|
||||
if armorID != "" {
|
||||
if a := armorByID(armorID); a != nil {
|
||||
copy := *a
|
||||
copy.MagicBonus = mb
|
||||
armor = ©
|
||||
}
|
||||
}
|
||||
if useShield {
|
||||
if s := armorByID("arm_shield"); s != nil {
|
||||
copy := *s
|
||||
// Shields don't get the weapon-tier enchantment in this kit;
|
||||
// keeping them mundane avoids double-counting the +X.
|
||||
shield = ©
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// classLoadoutIDs picks the canonical weapon / armor / shield set per
|
||||
// class. Choices follow the class's PrimaryA stat and 5e proficiency
|
||||
// expectations — Fighter swings martial melee in heavy armor; Mage stays
|
||||
// behind a quarterstaff and lets Mage Armor / DEX carry AC.
|
||||
func classLoadoutIDs(class DnDClass) (weapon, armor string, shield bool) {
|
||||
switch class {
|
||||
case ClassFighter, ClassPaladin:
|
||||
return "wpn_longsword", "arm_chain_mail", true
|
||||
case ClassRanger:
|
||||
// DEX skirmisher with a finesse-friendly bow; light armor, no
|
||||
// shield (two-handed bow occupies the off-hand anyway).
|
||||
return "wpn_longbow", "arm_studded", false
|
||||
case ClassRogue:
|
||||
return "wpn_shortsword", "arm_studded", false
|
||||
case ClassCleric:
|
||||
return "wpn_mace", "arm_chain_shirt", true
|
||||
case ClassDruid:
|
||||
// Druids canonically eschew metal armor; hide is the SRD default.
|
||||
return "wpn_scimitar", "arm_hide", false
|
||||
case ClassBard:
|
||||
return "wpn_rapier", "arm_leather", false
|
||||
case ClassMage, ClassSorcerer, ClassWarlock:
|
||||
// No armor proficiency. The Mage's class AC floor + DEX + a future
|
||||
// queued Mage Armor cast carry survival. Quarterstaff is the
|
||||
// canonical caster sidearm.
|
||||
return "wpn_quarterstaff", "", false
|
||||
}
|
||||
return "wpn_club", "", false
|
||||
}
|
||||
|
||||
// ── Spell-selection policy (doc §3.2) ────────────────────────────────────────
|
||||
//
|
||||
// The other Phase 0 policy. Without a "what would a caster cast here"
|
||||
// heuristic, the 8 caster classes fight as naked weapon-users — a
|
||||
// measurement artifact, not real imbalance.
|
||||
//
|
||||
// Phase 0 simplification: pick the single best damage spell from the
|
||||
// class's available spells (level ≤ highest slot the build owns). "Best"
|
||||
// is the expected damage of one cast under generous assumptions — avg
|
||||
// dice × cantrip/upcast scaling — ignoring hit chance and save-half
|
||||
// (those would require knowing the target's AC/save, which we don't have
|
||||
// at selection time). This gets the Mage casting Magic Missile / Fireball
|
||||
// instead of Fire Bolt's weaker auto-damage, which is the whole point.
|
||||
//
|
||||
// Phase 1 will refine this: per-fight slot bookkeeping for multi-round
|
||||
// fights, fight-context selection (control vs. damage), buff pre-casts.
|
||||
|
||||
// pickBestDamageSpell returns the spell a caster of this class+level
|
||||
// would queue for one fight, plus the slot level to upcast at. Returns
|
||||
// (zero, 0, false) for non-casters and classes with no damage spells.
|
||||
func pickBestDamageSpell(c *DnDCharacter) (SpellDefinition, int, bool) {
|
||||
if !classIsCaster(c.Class) {
|
||||
return SpellDefinition{}, 0, false
|
||||
}
|
||||
slots := slotsForClassLevel(c.Class, c.Level)
|
||||
maxSlot := 0
|
||||
for lvl := range slots {
|
||||
if slots[lvl] > 0 && lvl > maxSlot {
|
||||
maxSlot = lvl
|
||||
}
|
||||
}
|
||||
candidates := spellsForClass(c.Class, maxSlot)
|
||||
var best SpellDefinition
|
||||
var bestSlot int
|
||||
bestScore := -1.0
|
||||
for _, s := range candidates {
|
||||
switch s.Effect {
|
||||
case EffectDamageAttack, EffectDamageSave, EffectDamageAuto:
|
||||
default:
|
||||
continue
|
||||
}
|
||||
// Cantrip → always castable, no slot cost.
|
||||
// Leveled → upcast to maxSlot when we own a slot ≥ spell level.
|
||||
slot := s.Level
|
||||
if s.Level == 0 {
|
||||
slot = 0
|
||||
} else if slots[s.Level] == 0 && s.Level > 0 {
|
||||
continue
|
||||
} else if maxSlot > s.Level {
|
||||
slot = maxSlot
|
||||
}
|
||||
score := spellExpectedDamage(s, slot, c.Level)
|
||||
if score > bestScore {
|
||||
bestScore = score
|
||||
best = s
|
||||
bestSlot = slot
|
||||
}
|
||||
}
|
||||
if bestScore < 0 {
|
||||
return SpellDefinition{}, 0, false
|
||||
}
|
||||
return best, bestSlot, true
|
||||
}
|
||||
|
||||
// spellExpectedDamage estimates the average raw damage of one cast — dice
|
||||
// count × avg-face + flat, with cantrip/upcast scaling identical to
|
||||
// rollSpellDamageDice. No hit-chance or save-half weighting (see policy
|
||||
// note above). Magic Missile's auto-damage path gets a small explicit
|
||||
// bonus to reflect that it never misses; the auto-damage flag alone
|
||||
// already steers picks correctly in practice.
|
||||
func spellExpectedDamage(s SpellDefinition, slot, charLevel int) float64 {
|
||||
dice, faces, flat := parseDamageDice(s.DamageDice)
|
||||
if dice == 0 || faces == 0 {
|
||||
return 0
|
||||
}
|
||||
if s.Level == 0 {
|
||||
switch {
|
||||
case charLevel >= 17:
|
||||
dice *= 4
|
||||
case charLevel >= 11:
|
||||
dice *= 3
|
||||
case charLevel >= 5:
|
||||
dice *= 2
|
||||
}
|
||||
} else if extra := slot - s.Level; extra > 0 {
|
||||
dice += extra
|
||||
}
|
||||
avgFace := (float64(faces) + 1) / 2
|
||||
avg := float64(dice)*avgFace + float64(flat)
|
||||
// Auto-damage (Magic Missile) doesn't roll to hit — count its
|
||||
// expected-on-table value at face. Attack/save spells roll, and the
|
||||
// engine will resolve hit chance at cast time.
|
||||
return avg
|
||||
}
|
||||
|
||||
// applyHarnessSpellCast is the DB-free version of applyPendingCast: same
|
||||
// damage resolution, no SaveDnDCharacter. Mirrors the live path's choice
|
||||
// of attack/save/auto handlers so the Mage's contribution to a fight is
|
||||
// the same shape as it would be in production.
|
||||
func applyHarnessSpellCast(
|
||||
c *DnDCharacter,
|
||||
spell SpellDefinition,
|
||||
slot int,
|
||||
playerStats *CombatStats,
|
||||
playerMods *CombatModifiers,
|
||||
enemyStats *CombatStats,
|
||||
) {
|
||||
dc := spellSaveDC(c)
|
||||
atk := spellAttackBonus(c)
|
||||
preDmgBefore := playerMods.SpellPreDamage
|
||||
switch spell.Effect {
|
||||
case EffectDamageAttack:
|
||||
applySpellDamageAttack(spell, atk, playerMods, enemyStats, slot, c.Level)
|
||||
case EffectDamageSave:
|
||||
applySpellDamageSave(spell, dc, c, playerMods, enemyStats, slot)
|
||||
case EffectDamageAuto:
|
||||
applySpellDamageAuto(spell, playerMods, slot, c.Level)
|
||||
}
|
||||
if playerMods.SpellPreDamage > preDmgBefore && c.Class == ClassMage {
|
||||
// Mage evocation/necromancy hooks live in the live spell-combat
|
||||
// path; we never set a subclass in Phase 0, so the call is a
|
||||
// no-op today but keeps shape parity for Phase 1.
|
||||
applyMageSubclassSpellHooks(c, spell, slot, playerMods)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Synthesizing the build ───────────────────────────────────────────────────
|
||||
|
||||
// buildHarnessCharacter constructs the DnDCharacter for one profile. Uses
|
||||
// the class's stat priority + Human's +1-to-all racial mods, then derives
|
||||
// HP and the baseline AC from the class.
|
||||
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],
|
||||
INT: scores[3], WIS: scores[4], CHA: scores[5],
|
||||
}
|
||||
conMod := abilityModifier(c.CON)
|
||||
dexMod := abilityModifier(c.DEX)
|
||||
c.HPMax = computeMaxHP(c.Class, conMod, c.Level)
|
||||
c.HPCurrent = c.HPMax
|
||||
c.ArmorClass = computeAC(c.Class, dexMod)
|
||||
return c
|
||||
}
|
||||
|
||||
// buildHarnessPlayer assembles the Combatant the engine will fight with.
|
||||
// Layers the same calls runDungeonCombat makes, in order, minus the
|
||||
// DB-touching ones (per the file header). Returns the Combatant; the
|
||||
// caller decides whether to queue a spell on top.
|
||||
func buildHarnessPlayer(c *DnDCharacter) Combatant {
|
||||
stats := CombatStats{}
|
||||
mods := CombatModifiers{DamageReduct: 1.0}
|
||||
|
||||
// 1. Player layer (HP/AC/AttackBonus from the sheet).
|
||||
applyDnDPlayerLayer(&stats, c)
|
||||
|
||||
// 2. Equipment layer — inlined from applyDnDEquipmentLayer to avoid
|
||||
// the AdvEquipment synthesis chain. Same net effect on stats.
|
||||
weapon, armor, shield := classLoadout(c.Class, c.Level)
|
||||
if weapon != nil {
|
||||
stats.Weapon = weapon
|
||||
stats.AbilityModForDamage = pickWeaponAbilityMod(weapon, c)
|
||||
stats.WeaponProficient = dndClassWeaponProficiency(c.Class, weapon)
|
||||
stats.AttackBonus += weapon.MagicBonus
|
||||
if weapon.HasProperty(PropTwoHanded) || (weapon.HasProperty(PropVersatile) && shield == nil) {
|
||||
stats.TwoHandedMode = true
|
||||
}
|
||||
}
|
||||
// Two-handed weapons forbid shields (appendix §5.4).
|
||||
if weapon != nil && weapon.HasProperty(PropTwoHanded) {
|
||||
shield = nil
|
||||
}
|
||||
if armor != nil || shield != nil {
|
||||
stats.AC = computeArmorAC(armor, shield, abilityModifier(c.DEX))
|
||||
}
|
||||
|
||||
// 3. Passives (no subclass in Phase 0).
|
||||
applyClassPassives(&stats, &mods, c)
|
||||
applyRacePassives(&stats, &mods, c)
|
||||
|
||||
return Combatant{
|
||||
Name: string(c.Class),
|
||||
Stats: stats,
|
||||
Mods: mods,
|
||||
IsPlayer: true,
|
||||
}
|
||||
}
|
||||
|
||||
// buildHarnessEnemy mirrors runDungeonCombat's enemy assembly: the
|
||||
// per-tier stat curve from DeriveDungeonMonsterStats, then the d20
|
||||
// AC/AttackBonus overlay from applyDnDDungeonMonsterLayer. No
|
||||
// MonsterAbility — Phase 0 measures the base case.
|
||||
func buildHarnessEnemy(tier int) Combatant {
|
||||
loc := dungeonLocForTier(tier)
|
||||
stats, mods := DeriveDungeonMonsterStats(loc)
|
||||
applyDnDDungeonMonsterLayer(&stats, tier)
|
||||
return Combatant{Name: loc.Denizens, Stats: stats, Mods: mods}
|
||||
}
|
||||
|
||||
// dungeonLocForTier returns the canonical advDungeons row for a tier.
|
||||
// Falls back to T1 for out-of-range input.
|
||||
func dungeonLocForTier(tier int) *AdvLocation {
|
||||
for i := range advDungeons {
|
||||
if advDungeons[i].Tier == tier {
|
||||
return &advDungeons[i]
|
||||
}
|
||||
}
|
||||
return &advDungeons[0]
|
||||
}
|
||||
|
||||
// ── Monte Carlo runner ───────────────────────────────────────────────────────
|
||||
|
||||
// runClassBalanceTrial runs one fight: build player + enemy fresh, queue
|
||||
// the caster's best spell if applicable, simulate, return the result.
|
||||
// Each trial constructs fresh combatants so the per-fight RNG (rand.IntN
|
||||
// in spell rolls + the engine's package-global rand) drives variance.
|
||||
func runClassBalanceTrial(p classBalanceProfile, tier int) CombatResult {
|
||||
c := buildHarnessCharacter(p)
|
||||
player := buildHarnessPlayer(c)
|
||||
enemy := buildHarnessEnemy(tier)
|
||||
if spell, slot, ok := pickBestDamageSpell(c); ok {
|
||||
applyHarnessSpellCast(c, spell, slot, &player.Stats, &player.Mods, &enemy.Stats)
|
||||
}
|
||||
return SimulateCombat(player, enemy, dungeonCombatPhases)
|
||||
}
|
||||
|
||||
// runClassBalanceCell is one cell of the matrix: N trials of (profile,
|
||||
// tier). Returns aggregated win rate + diagnostics.
|
||||
func runClassBalanceCell(p classBalanceProfile, tier, trials int) classBalanceResult {
|
||||
r := classBalanceResult{Profile: p, Tier: tier, Trials: trials}
|
||||
var hpSum float64
|
||||
var hpWonTrials int
|
||||
for i := 0; i < trials; i++ {
|
||||
res := runClassBalanceTrial(p, tier)
|
||||
if res.PlayerWon {
|
||||
r.Wins++
|
||||
if res.PlayerStartHP > 0 {
|
||||
hpSum += float64(res.PlayerEndHP) / float64(res.PlayerStartHP)
|
||||
hpWonTrials++
|
||||
}
|
||||
}
|
||||
if res.NearDeath {
|
||||
r.NearDeathRate++
|
||||
}
|
||||
}
|
||||
if hpWonTrials > 0 {
|
||||
r.AvgHPRemainingPct = hpSum / float64(hpWonTrials)
|
||||
}
|
||||
if trials > 0 {
|
||||
r.NearDeathRate /= float64(trials)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// runClassBalanceMatrix sweeps a list of profiles across the full T1..T5
|
||||
// dungeon ladder. Returns results sorted by (Tier asc, Class, Level) for
|
||||
// deterministic test output.
|
||||
func runClassBalanceMatrix(profiles []classBalanceProfile, trials int) []classBalanceResult {
|
||||
tiers := []int{1, 2, 3, 4, 5}
|
||||
out := make([]classBalanceResult, 0, len(profiles)*len(tiers))
|
||||
for _, p := range profiles {
|
||||
for _, t := range tiers {
|
||||
out = append(out, runClassBalanceCell(p, t, trials))
|
||||
}
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
if out[i].Tier != out[j].Tier {
|
||||
return out[i].Tier < out[j].Tier
|
||||
}
|
||||
if out[i].Profile.Class != out[j].Profile.Class {
|
||||
return out[i].Profile.Class < out[j].Profile.Class
|
||||
}
|
||||
return out[i].Profile.Level < out[j].Profile.Level
|
||||
})
|
||||
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.
|
||||
var _ = rand.IntN
|
||||
86
internal/plugin/dnd_class_balance_test.go
Normal file
86
internal/plugin/dnd_class_balance_test.go
Normal file
@@ -0,0 +1,86 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user