mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
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:
@@ -272,9 +272,19 @@ func spellExpectedDamage(s SpellDefinition, slot, charLevel int) float64 {
|
||||
}
|
||||
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.
|
||||
// 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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user