feat(rules): scale spell damage with caster level
DamageEffect gains optional scaling (1dX per N levels, capped) and scaling_flat (+X per N levels, capped) specs; effective_formula resolves them against the caster's level at cast time, so scaled dice flow through crits, energy resistance and metamagic like the base formula (CRB Magic - Caster Level). Loader rejects mismatched die types. Data: Fireball/Lightning Bolt 1d6/lvl max 10d6, Burning Hands 1d4/lvl max 5d4, Shocking Grasp 1d6/lvl max 5d6, Inflict Light Wounds +1/lvl max +5 (SRD-verified). Cure Light Wounds stays flat: its golden transcript pins 1d8+1 and HealEffect scaling is out of scope. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
+19
-18
@@ -310,27 +310,27 @@ class TestFireballSave:
|
||||
def test_failed_save_full_damage(self) -> None:
|
||||
caster = _make_caster()
|
||||
target = _make_target(hp_max=200)
|
||||
# 2 save rolls (2 targets in area) + 6 damage dice
|
||||
engine, c, t = _make_engine(caster, target, [2, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3])
|
||||
# 2 save rolls (2 targets in area) + 5 damage dice (1d6/level, lvl 5)
|
||||
engine, c, t = _make_engine(caster, target, [2, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3])
|
||||
hp_before = t.hp
|
||||
engine._cast_spell(c, t, "Fireball")
|
||||
assert t.hp == hp_before - (3 + 3 + 3 + 3 + 3 + 3)
|
||||
assert t.hp == hp_before - (3 + 3 + 3 + 3 + 3)
|
||||
|
||||
def test_successful_save_half_damage(self) -> None:
|
||||
caster = _make_caster()
|
||||
target = _make_target(hp_max=200)
|
||||
# 2 save rolls (2 targets in area) + 6 damage dice
|
||||
engine, c, t = _make_engine(caster, target, [20, 20, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3])
|
||||
# 2 save rolls (2 targets in area) + 5 damage dice (1d6/level, lvl 5)
|
||||
engine, c, t = _make_engine(caster, target, [20, 20, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3])
|
||||
hp_before = t.hp
|
||||
engine._cast_spell(c, t, "Fireball")
|
||||
full = 3 + 3 + 3 + 3 + 3 + 3
|
||||
full = 3 + 3 + 3 + 3 + 3
|
||||
assert t.hp == hp_before - (full // 2)
|
||||
|
||||
def test_transcript_shows_saved(self) -> None:
|
||||
caster = _make_caster()
|
||||
target = _make_target()
|
||||
# 2 save rolls (2 targets in area) + 6 damage dice
|
||||
engine, c, t = _make_engine(caster, target, [20, 20, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3])
|
||||
# 2 save rolls (2 targets in area) + 5 damage dice (1d6/level, lvl 5)
|
||||
engine, c, t = _make_engine(caster, target, [20, 20, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3])
|
||||
engine._cast_spell(c, t, "Fireball")
|
||||
assert any("SAVED" in line for line in engine._transcript)
|
||||
|
||||
@@ -458,18 +458,19 @@ class TestTargetedSpellSaveNegates:
|
||||
|
||||
|
||||
class TestLightningBoltSave:
|
||||
"""Given: Lightning Bolt (line, ref save for half, half_on_save=True)
|
||||
When: the target succeeds its save
|
||||
Then: damage is still halved (half-on-save is declared for line effects)."""
|
||||
"""Given: Lightning Bolt (ref save for half, half_on_save=True, 1d6 per
|
||||
caster level max 10d6 — SRD spells/lightning-bolt.md)
|
||||
When: the level-5 target succeeds its save
|
||||
Then: damage is halved from the SCALED 5d6."""
|
||||
|
||||
def test_successful_save_still_halves(self) -> None:
|
||||
caster = _make_caster()
|
||||
target = _make_target(hp_max=200)
|
||||
# 1 save roll (natural 20 = success) + 6 damage dice
|
||||
engine, c, t = _make_engine(caster, target, [20, 3, 3, 3, 3, 3, 3])
|
||||
# 1 save roll (natural 20 = success) + 5 damage dice (caster level 5)
|
||||
engine, c, t = _make_engine(caster, target, [20, 3, 3, 3, 3, 3])
|
||||
hp_before = t.hp
|
||||
engine._cast_spell(c, t, "Lightning Bolt")
|
||||
full = 3 + 3 + 3 + 3 + 3 + 3
|
||||
full = 3 + 3 + 3 + 3 + 3
|
||||
assert t.hp == hp_before - (full // 2)
|
||||
|
||||
|
||||
@@ -661,13 +662,13 @@ class TestSpellDamageWithDr:
|
||||
target_with_dr = target.model_copy(update={
|
||||
"dr": DamageReduction(amount=5, bypass=frozenset()),
|
||||
})
|
||||
# 2 save rolls (2 targets in area) + 6 damage dice
|
||||
queue = [2, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3]
|
||||
# 2 save rolls (2 targets in area) + 5 damage dice each (level 5)
|
||||
queue = [2, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3]
|
||||
engine, c, t = _make_engine(caster, target_with_dr, queue)
|
||||
hp_before = t.hp
|
||||
engine._cast_spell(c, t, "Fireball")
|
||||
# Save fails (2), 6d6 rolls all 3 = 18 damage, DR ignored -> 18 damage
|
||||
assert t.hp == hp_before - 18
|
||||
# Save fails (2), 5d6 rolls all 3 = 15 damage, DR ignored -> 15 damage
|
||||
assert t.hp == hp_before - 15
|
||||
|
||||
|
||||
class TestSummonMonster:
|
||||
|
||||
Reference in New Issue
Block a user