fix(combat): apply save negates to targeted spell damage effects

This commit is contained in:
2026-09-05 12:52:58 +02:00
parent cd75a4f2bf
commit 7cb3653750
4 changed files with 100 additions and 10 deletions
+80
View File
@@ -32,6 +32,21 @@ from pf1e_simulator.spells import (
_SPELLS_DIR = Path(__file__).resolve().parent.parent / "data" / "spells"
_SAVE_NEGATES_BOLT = SpellSpec(
name="Save Negates Bolt",
level=1,
school="evocation",
range="medium",
save="ref",
effects=(
DamageEffect(
formula=parse_dice("2d4"),
types=("force",),
half_on_save=False,
),
),
)
def _make_caster(cid: str = "caster", *, level: int = 5) -> Combatant:
attack = AttackSpec(
@@ -244,6 +259,71 @@ class TestFireballSave:
assert any("SAVED" in line for line in engine._transcript)
class TestTargetedSpellSaveNegates:
"""Given: a targeted spell with a save (half_on_save=False)
When: the target succeeds / fails its save
Then: full damage on failure, ZERO damage on success (save negates)."""
def test_successful_save_deals_no_damage(self) -> None:
caster = _make_caster()
target = _make_target(hp_max=100)
# 1 save roll (natural 20 = success) + 2 damage dice
engine, c, t = _make_engine(
caster, target, [20, 3, 3], spell_registry={"Save Negates Bolt": _SAVE_NEGATES_BOLT}
)
hp_before = t.hp
engine._cast_spell(c, t, "Save Negates Bolt")
assert t.hp == hp_before
assert any("0 damage" in line for line in engine._transcript)
def test_failed_save_deals_full_damage(self) -> None:
caster = _make_caster()
target = _make_target(hp_max=100)
# 1 save roll (natural 1 = failure) + 2 damage dice
engine, c, t = _make_engine(
caster, target, [1, 3, 3], spell_registry={"Save Negates Bolt": _SAVE_NEGATES_BOLT}
)
hp_before = t.hp
engine._cast_spell(c, t, "Save Negates Bolt")
assert t.hp == hp_before - 6
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)."""
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])
hp_before = t.hp
engine._cast_spell(c, t, "Lightning Bolt")
full = 3 + 3 + 3 + 3 + 3 + 3
assert t.hp == hp_before - (full // 2)
class TestDamageEffectHalfOnSaveDefault:
"""Given: a DamageEffect constructed without half_on_save
When: the field is read
Then: it defaults to False (save negates for targeted effects)."""
def test_defaults_false(self) -> None:
effect = DamageEffect(formula=parse_dice("1d4"), types=("fire",))
assert effect.half_on_save is False
def test_loader_defaults_false(self, tmp_path) -> None:
spell_file = tmp_path / "test_spell.json"
spell_file.write_text(
'{"name": "Test Bolt", "level": 1, "school": "evocation",'
' "range": "medium", "save": "ref", "effects": ['
'{"type": "damage", "formula": "1d4", "types": ["fire"]}]}'
)
spell = load_spell(spell_file)
assert isinstance(spell.effects[0], DamageEffect)
assert spell.effects[0].half_on_save is False
class TestHoldPerson:
"""Given: a caster casting Hold Person (will negates, paralyzed)