Files
gogobee/internal/plugin/dnd_class_balance.go
prosolis d7ced471a1 J3 D8-c: concentration ×3 multiplier in spellExpectedDamage
`spellExpectedDamage` (`dnd_class_balance.go`) now multiplies expected
damage by 3 when `sp.Concentration && sp.Effect ∈ {EffectDamageSave,
EffectDamageAuto}`. Attack-roll concentration excluded by design —
hex-style cases ride per-hit mods, not the score. Closes the picker
gap that walked past heat_metal / spike_growth / flaming_sphere /
cloud_of_daggers in favour of one-shot blasts of similar slot level.

Measured on n=5000 d8c corpus vs d8prereq baseline (sim_results,
gitignored): per-class means all within ±2.4pp (1σ ≈ 2.2pp); macro
leaderboard unchanged. Real picker swap lands at T3 manor_blackspire
— bard +11pp, mage +10pp, sorcerer +9pp, druid +5pp. T4/T5 caster
wall unchanged → confirms HP+AC is the binding constraint past T3
(D8-d-fix territory), not picker quality.

Side discovery: cleric flat / warlock −6pp manor — spirit_guardians-
style AOE-save concentration spells appear to resolve once in
SimulateCombat / turn-engine instead of re-ticking per round. The
multiplier is correct in expectation; the engine under-delivers.
Filed as separate follow-up; D8-c stays.

Plan §8 D8-c marked SHIPPED.
2026-05-28 08:14:13 -07:00

529 lines
20 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package plugin
import (
"math/rand/v2"
"sort"
)
// 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 L1L4,
// 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.
// 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.
// - 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
Subclass DnDSubclass // empty below L5, per doc §2
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.
//
// L1L4 → T1 mundane kit (no magic bonus)
// L5L8 → T2, +1 weapon and armor
// L9L12 → T3, +2
// L13L16 → T4, +3
// L17L20 → 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 = &copy
}
if armorID != "" {
if a := armorByID(armorID); a != nil {
copy := *a
copy.MagicBonus = mb
armor = &copy
}
}
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 = &copy
}
}
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)
// Concentration damage spells (heat_metal, spirit_guardians,
// flaming_sphere, call_lightning, spike_growth, cloud_of_daggers, …)
// re-tick each round while concentration holds. Without this factor
// the picker scores them as one-shots and they lose to higher-tier
// blasts on every comparison. Conservative ×3 = roughly the median
// fight length the picker can hope to keep concentration up; tier-
// scaled would be more correct but adds noise here.
// EffectDamageAttack is excluded — single-target attack-roll spells
// aren't generally concentration; the rare ones (hex-style) get
// their lift from mods, not this score.
if s.Concentration && (s.Effect == EffectDamageSave || s.Effect == EffectDamageAuto) {
avg *= 3
}
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,
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)
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))
}
// Phase 5-B player power floor. applyDnDEquipmentLayer applies this
// at the same point in the live combat path — keep the harness's
// measurement aligned with what live players experience by calling
// the same helper here, before passives stack on top.
applyPhase5BPlayerFloor(&stats)
// 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 L1L4
// pre-unlock rows.
applyClassPassives(&stats, &mods, c)
applyRacePassives(&stats, &mods, c)
applySubclassPassives(&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
}
// ── 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 L1L4 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 L1L4 (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.
var _ = rand.IntN