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.
This commit is contained in:
prosolis
2026-05-28 08:14:13 -07:00
parent 63ad423b79
commit d7ced471a1
3 changed files with 117 additions and 16 deletions

View File

@@ -408,3 +408,45 @@ func TestClassBalance_Phase1_FullMatrix(t *testing.T) {
}
}
}
// D8-c: concentration damage spells re-tick each round; the picker has to
// score them above one-shots of similar level or it'll never pick them.
func TestSpellExpectedDamage_ConcentrationMultiplier(t *testing.T) {
const concentrationFactor = 3.0
// 2d8 base: avg = 9.
const baseAvg = 9.0
cases := []struct {
name string
sp SpellDefinition
want float64
}{
{"oneshot save (shatter-like)",
SpellDefinition{Level: 2, Effect: EffectDamageSave, DamageDice: "2d8"},
baseAvg},
{"oneshot attack",
SpellDefinition{Level: 2, Effect: EffectDamageAttack, DamageDice: "2d8"},
baseAvg},
{"concentration save (spirit_guardians-like)",
SpellDefinition{Level: 2, Effect: EffectDamageSave, Concentration: true, DamageDice: "2d8"},
baseAvg * concentrationFactor},
{"concentration auto (spike_growth-like)",
SpellDefinition{Level: 2, Effect: EffectDamageAuto, Concentration: true, DamageDice: "2d8"},
baseAvg * concentrationFactor},
{"concentration attack stays unmultiplied",
SpellDefinition{Level: 2, Effect: EffectDamageAttack, Concentration: true, DamageDice: "2d8"},
baseAvg},
}
for _, tc := range cases {
got := spellExpectedDamage(tc.sp, tc.sp.Level, 10)
if got != tc.want {
t.Errorf("%s: expDmg=%.2f want %.2f", tc.name, got, tc.want)
}
}
// Upcast still applies before the multiplier: 2d8 at slot 3 = 3d8 = 13.5, ×3 = 40.5.
sp := SpellDefinition{Level: 2, Effect: EffectDamageSave, Concentration: true, DamageDice: "2d8"}
if got := spellExpectedDamage(sp, 3, 10); got != 13.5*concentrationFactor {
t.Errorf("upcast + concentration: got %.2f want %.2f", got, 13.5*concentrationFactor)
}
}