diff --git a/README.md b/README.md index 7fcf65c..e4a041e 100644 --- a/README.md +++ b/README.md @@ -1583,6 +1583,17 @@ plutôt que bricolées) : en revanche deux créatures de même taille (Small et plus) ne partagent jamais une case, et la co-occupation de plusieurs Tiny sur une même case n'est pas modélisée. +- **Échec d'incantation pour un personnage sourd hors périmètre** (SRD + « Conditions — Deafened » : « a 20% chance of spell failure when casting + spells with verbal components ») : le moteur ne porte pas la composante + verbale des sorts (`SpellSpec` n'a pas de champ `components` — il + faudrait l'encoder sur les 108 sorts du registre) et ne modélise pas non + plus le jet de Perception DD 20 basé sur le son ni l'échec automatique + associé ; la condition `deafened` reste donc un porteur de modificateurs + (−4 d'initiative) sans la faillibilité d'incantation. Le soin des + morts-vivants par les sorts de soin (`cure`) est en revanche modélisé : + SRD « Cure Light Wounds » — « this spell deals damage to them instead of + curing their wounds [...] can attempt a Will save to take half damage ». Limites de sorts (conformance D6-D9, documentées plutôt que bricolées) : diff --git a/data/spells/cure_light_wounds.json b/data/spells/cure_light_wounds.json index 9ca3c61..22e733a 100644 --- a/data/spells/cure_light_wounds.json +++ b/data/spells/cure_light_wounds.json @@ -13,5 +13,5 @@ "scaling_flat": { "amount": 1, "per_levels": 1, "max": 5 } } ], - "description": "Cures 1d8 points of damage +1 point per caster level (maximum +5). Will save negates (harmless)." + "description": "Cures 1d8 points of damage +1 point per caster level (maximum +5). Will half (harmless); since undead are powered by negative energy, the spell deals damage to them instead (Will half, SRD cure light wounds)." } diff --git a/data/spells/cure_moderate_wounds.json b/data/spells/cure_moderate_wounds.json index 898201e..3be2a17 100644 --- a/data/spells/cure_moderate_wounds.json +++ b/data/spells/cure_moderate_wounds.json @@ -13,5 +13,5 @@ "scaling_flat": { "amount": 1, "per_levels": 1, "max": 10 } } ], - "description": "Cures 2d8 points of damage +1 point per caster level (maximum +10). Will save negates (harmless)." + "description": "Cures 2d8 points of damage +1 point per caster level (maximum +10). Will half (harmless); since undead are powered by negative energy, the spell deals damage to them instead (Will half, SRD cure moderate wounds)." } diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index f4578a1..25176e7 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -6287,7 +6287,7 @@ class CombatEngine: if isinstance(effect, HealEffect): return self._apply_heal_effect( effect, target, caster_level=caster.combatant.level, - empower=empower, maximize=maximize, + empower=empower, maximize=maximize, saved=saved, ) return self._apply_buff_effect(effect, target, spell_name=spell_name) @@ -6475,6 +6475,7 @@ class CombatEngine: caster_level: int, empower: bool, maximize: bool, + saved: bool, ) -> str: formula = effect.effective_formula(caster_level) heal = formula.roll(self._rng) @@ -6482,6 +6483,17 @@ class CombatEngine: heal = formula.max_value() if empower: heal = int(heal * 1.5) + if target.combatant.creature_type == "undead": + # PF1e (SRD "Cure Light Wounds"): "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." + # SR is resolved upstream (_spell_delivery_blocked); the rolled + # save halves here. Cure damage is spell energy: never reduced by + # DR, and it does not stop bleeding or stabilize. + dmg = heal // 2 if saved else heal + target.hp -= dmg + return f"{dmg} damage (undead)" old_hp = target.hp target.hp = min(target.hp + heal, target.combatant.hp_max) # PF1e (CRB, Stable Characters and Recovery): "If any sort of healing diff --git a/tests/test_bleeding_confused.py b/tests/test_bleeding_confused.py index 59cd06e..c15860f 100644 --- a/tests/test_bleeding_confused.py +++ b/tests/test_bleeding_confused.py @@ -103,7 +103,7 @@ def test_heal_spell_clears_bleed() -> None: a.conditions.append(BLEEDING) result = eng._apply_heal_effect( HealEffect(formula=parse_dice("1d8")), a, caster_level=1, - empower=False, maximize=False, + empower=False, maximize=False, saved=False, ) assert result == "+5 HP" assert a.hp == 55 diff --git a/tests/test_cure_undead.py b/tests/test_cure_undead.py new file mode 100644 index 0000000..5fa2f70 --- /dev/null +++ b/tests/test_cure_undead.py @@ -0,0 +1,80 @@ +"""Cure spells damage undead instead of healing (Task 11 conformance). + +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 +rolled Will save halves the damage here; living targets keep healing exactly +as before. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from tests.test_spells import _make_caster, _make_engine, _make_target + +if TYPE_CHECKING: + from pf1e_simulator.models import Combatant + + +def _undead(cid: str = "skeleton", *, hp_max: int = 20) -> Combatant: + return _make_target(cid=cid, hp_max=hp_max).model_copy( + update={"creature_type": "undead"} + ) + + +def _caster() -> Combatant: + return _make_caster().model_copy(update={"spells": ["Cure Light Wounds"]}) + + +class TestCureDamagesUndead: + """Given: a level-5 caster (Cure Light Wounds = 1d8+5) and an undead target + + When: the cure spell is delivered + Then: the undead takes damage instead of healing. + """ + + def test_failed_save_deals_full_damage(self) -> None: + """Given: an undead at 20 hp + When: cured and it fails the Will save (d20=1), the die rolls 8 + Then: it takes the full 13 damage (8+5) instead of healing.""" + engine, c, t = _make_engine( + _caster(), _undead(), [1, 8], target_pos=(1, 0), target_side="players" + ) + engine._cast_spell(c, t, "Cure Light Wounds") + assert t.hp == 7 + assert any("13 damage (undead)" in line for line in engine._transcript) + + def test_successful_save_halves_the_damage(self) -> None: + """Given: an undead at 20 hp + When: cured and it succeeds the Will save (natural 20), the die rolls 8 + Then: it takes half damage (13 // 2 = 6).""" + engine, c, t = _make_engine( + _caster(), _undead(), [20, 8], target_pos=(1, 0), target_side="players" + ) + engine._cast_spell(c, t, "Cure Light Wounds") + assert t.hp == 14 + assert any("6 damage (undead)" in line for line in engine._transcript) + + def test_undead_destroyed_by_cure_damage(self) -> None: + """Given: an undead at 10 hp + When: a failed-save cure deals 13 damage + Then: the undead is destroyed (undead die at hp <= 0).""" + engine, c, t = _make_engine( + _caster(), _undead(hp_max=10), [1, 8], target_pos=(1, 0), target_side="players" + ) + engine._cast_spell(c, t, "Cure Light Wounds") + assert t.dead is True + + 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).""" + engine, c, t = _make_engine( + _caster(), _make_target(), [1, 8], target_pos=(1, 0), target_side="players" + ) + t.hp = 5 + engine._cast_spell(c, t, "Cure Light Wounds") + assert t.hp == 18 + assert any("+13 HP" in line for line in engine._transcript) diff --git a/tests/test_dying_and_crits.py b/tests/test_dying_and_crits.py index bcadce4..355a0d6 100644 --- a/tests/test_dying_and_crits.py +++ b/tests/test_dying_and_crits.py @@ -221,7 +221,7 @@ def test_heal_spell_stabilizes_dying_creature() -> None: eng._rng = ScriptedRng([1]) # 1d8 -> 1: only 1 point cured result = eng._apply_heal_effect( HealEffect(formula=parse_dice("1d8")), s, caster_level=1, - empower=False, maximize=False, + empower=False, maximize=False, saved=False, ) assert result == "+1 HP" assert s.hp == -2 @@ -253,7 +253,7 @@ def test_heal_to_positive_hp_does_not_stabilize() -> None: eng._rng = ScriptedRng([8]) eng._apply_heal_effect( HealEffect(formula=parse_dice("1d8")), s, caster_level=1, - empower=False, maximize=False, + empower=False, maximize=False, saved=False, ) assert s.hp == 5 assert s.stabilized_hp is None