diff --git a/internal/plugin/dnd_class_balance.go b/internal/plugin/dnd_class_balance.go new file mode 100644 index 0000000..500fa12 --- /dev/null +++ b/internal/plugin/dnd_class_balance.go @@ -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 diff --git a/internal/plugin/dnd_class_balance_test.go b/internal/plugin/dnd_class_balance_test.go new file mode 100644 index 0000000..4f4a738 --- /dev/null +++ b/internal/plugin/dnd_class_balance_test.go @@ -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) + } +}