fix(combat): apply save negates to targeted spell damage effects
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user