fix(rules): harmless spells on willing targets forgo the save roll - SpellSpec gains a data-driven 'harmless' flag set on the 29 SRD '(harmless)' spells (verified one by one against the KB); a same-side/self target consumes NO save die (SRD saving-throw.md: '(harmless) ... can attempt a saving throw if it desires' + 'Voluntarily Giving up a Saving Throw'), while hostile targets and undead receiving a cure (harmful to them, Will half for undead damage) keep the roll; snapshot spell summaries expose 'harmless'; golden transcript unchanged (no harmless-save cast in it); +8 tests, 3 fixtures re-pinned for the removed die

This commit is contained in:
2026-09-19 22:27:46 +02:00
parent ea835293a0
commit 4665a1c245
37 changed files with 266 additions and 12 deletions
+10 -6
View File
@@ -2,10 +2,11 @@
SRD "Cure Light Wounds" (spells/cure-light-wounds.md): "Since undead are
powered by negative energy, this spell deals damage to them instead of curing
their wounds. An undead creature can apply spell resistance, and can attempt
a Will save to take half damage." The engine resolves SR upstream and the
their wounds. An undead creature can apply spell resistance, and can attempt a
Will save to take half damage." The engine resolves SR upstream and the
rolled Will save halves the damage here; living targets keep healing exactly
as before.
as before — and as a willing same-side target of a (harmless) spell a living
target forgoes the Will roll entirely (no die consumed).
"""
from __future__ import annotations
@@ -70,9 +71,12 @@ class TestCureDamagesUndead:
def test_living_target_still_heals(self) -> None:
"""Given: a living ally at 5/20 hp
When: cured with a die roll of 8
Then: it heals 13 hp (behavior unchanged for the living)."""
Then: it heals 13 hp (behavior unchanged for the living). The ally is
a willing target of a (harmless) spell, so NO Will die is consumed
(SRD saving-throw.md: "(harmless)" + "Voluntarily Giving up a Saving
Throw") — the queue holds only the heal die."""
engine, c, t = _make_engine(
_caster(), _make_target(), [1, 8], target_pos=(1, 0), target_side="players"
_caster(), _make_target(), [8], target_pos=(1, 0), target_side="players"
)
t.hp = 5
engine._cast_spell(c, t, "Cure Light Wounds")
@@ -96,7 +100,7 @@ class TestCureUndeadAttrition:
def test_living_heal_records_no_damage(self) -> None:
engine, c, t = _make_engine(
_caster(), _make_target(), [1, 8], target_pos=(1, 0), target_side="players"
_caster(), _make_target(), [8], target_pos=(1, 0), target_side="players"
)
t.hp = 5
engine._cast_spell(c, t, "Cure Light Wounds")