From 4665a1c2454507e19209e0c364ab2f3d26295265 Mon Sep 17 00:00:00 2001 From: ctan Date: Sat, 19 Sep 2026 22:27:46 +0200 Subject: [PATCH] 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 --- README.md | 25 ++- data/spells/bears_endurance.json | 1 + data/spells/bulls_strength.json | 1 + data/spells/cure_light_wounds.json | 1 + data/spells/cure_moderate_wounds.json | 1 + data/spells/delay_disease.json | 1 + data/spells/delay_poison.json | 1 + data/spells/enhance_water.json | 1 + data/spells/enhanced_diplomacy.json | 1 + data/spells/face_of_the_devourer.json | 1 + data/spells/guidance.json | 1 + data/spells/heroism.json | 1 + data/spells/hide_from_undead.json | 1 + data/spells/make_whole.json | 1 + data/spells/mending.json | 1 + data/spells/owls_wisdom.json | 1 + data/spells/protection_from_evil.json | 1 + .../spells/protection_from_evil_communal.json | 1 + data/spells/protection_from_law.json | 1 + data/spells/protection_from_law_communal.json | 1 + data/spells/resistance.json | 1 + data/spells/restoration_lesser.json | 1 + data/spells/saving_finale.json | 1 + data/spells/shield_of_faith.json | 1 + data/spells/stabilize.json | 1 + data/spells/status.json | 1 + data/spells/stave_off_corruption.json | 1 + .../suppress_charms_and_compulsions.json | 1 + data/spells/vanish.json | 1 + data/spells/weapon_of_awe.json | 1 + src/pf1e_simulator/combat.py | 32 ++++ src/pf1e_simulator/spells.py | 9 + tests/test_cure_undead.py | 16 +- tests/test_harmless_spells.py | 154 ++++++++++++++++++ tests/test_observation_enriched.py | 1 + tests/test_spell_area_snapshot.py | 5 +- tests/test_spells.py | 7 +- 37 files changed, 266 insertions(+), 12 deletions(-) create mode 100644 tests/test_harmless_spells.py diff --git a/README.md b/README.md index f06cd04..d9a4f1a 100644 --- a/README.md +++ b/README.md @@ -1137,7 +1137,24 @@ Règles modélisées : sur un échec et **aucun dégât sur un succès** (la sauvegarde annule) ; le demi-dégât sur sauvegarde réussie ne s'applique que là où l'effet le déclare (`half_on_save: true` pour les rayons/lignes/cônes, `AreaEffect.save_for_half` - pour les explosions). Ciblage par point (U7b) : `Action(kind="cast_spell", + pour les explosions). **Sorts « harmless » et cible consentante** (SRD + « Spell Descriptions — Saving Throw » : « (harmless) : The spell is usually + beneficial, not harmful, but a targeted creature can attempt a saving throw + if it desires » ; « Voluntarily Giving up a Saving Throw : A creature can + voluntarily forego a saving throw and willingly accept a spell's result ») : + `SpellSpec` porte un booléen `harmless` (JSON `"harmless"`, défaut `false`) + posés sur les 29 sorts dont la fiche SRD marque la sauvegarde « (harmless) » + (vérifiés un à un contre le KB — les sorts SRD « save: none » comme Aid et + Bless n'ont pas la marque et n'en ont pas besoin). Jeté sur une cible + **consentante** (soi-même ou un même camp), le jet de sauvegarde primaire + est **totalement sauté** — aucun dé consommé, journalisé `NO SAVE` + (`_save_forgone`). Deux exceptions fidèles conservent le jet : une cible + **hostile** n'est pas consentante, et un sort de soin sur un **mort-vivant** + lui est **nuisible** (SRD cure-light-wounds.md : il « deals damage to them + instead » avec « Will half » pour demi-dégâts). Simplification documentée : + la « volonté » est approximée par le camp (le moteur n'a pas de concept + d'intention de cible) ; un sort harmless jeté sur un ennemi garde donc son + jet. Ciblage par point (U7b) : `Action(kind="cast_spell", target_pos=(r, c))` centre l'`AreaEffect` de niveau supérieur du sort sur la case `(r, c)` désignée au lieu de la position de la cible créature — poser un Fireball au sol, pas seulement sur une tête. Le point doit être dans la carte @@ -1653,7 +1670,8 @@ Règles modélisées : feature_details (C4 : tuple de {name, category, source_detail, effects: tuple de {target, value, bonus_type, condition, weapon_filter}}), spells (résumés enrichis du registre : name, level, school, range_ft, - save, dc, summary, half_on_save, save_negates (E4), area {shape, + save, dc, summary, half_on_save, save_negates (E4), harmless (sort « + (harmless) » du SRD — sauvegarde sautée sur cible consentante), area {shape, radius_ft} | None (U7a : premier AreaEffect de niveau supérieur du sort, gabarit de zone structuré, None si le sort n'a pas de zone), cast_time (code de temps d'incantation, « standard » pour un sort inconnu du @@ -1908,7 +1926,8 @@ Limites de sorts (conformance D6-D9, documentées plutôt que bricolées) : `AbilitySpec` réels ou des « no-op » tracés. - `spells.py` — sorts : `SpellSpec` (dataclass avec niveau, école, portée, sauvegarde, DC de base, exigence de contact à distance `requires_ranged_touch` - (rayons), temps d'incantation `cast_time`) et types d'effets + (rayons), marque `harmless` (SRD « (harmless) » — sauvegarde sautée sur + cible consentante), temps d'incantation `cast_time`) et types d'effets (`DamageEffect`, `ConditionEffect`, `HealEffect`, `BuffEffect`, `AreaEffect` — burst/cône/ligne, S1a). Un `DamageEffect` (et un `HealEffect`, S5b) porte des specs de scalage diff --git a/data/spells/bears_endurance.json b/data/spells/bears_endurance.json index 15d48aa..a030d4a 100644 --- a/data/spells/bears_endurance.json +++ b/data/spells/bears_endurance.json @@ -4,6 +4,7 @@ "school": "transmutation", "range": "touch", "save": "will", + "harmless": true, "base_dc": 10, "cast_time": "standard", "effects": [ diff --git a/data/spells/bulls_strength.json b/data/spells/bulls_strength.json index 2e25086..a4fd42e 100644 --- a/data/spells/bulls_strength.json +++ b/data/spells/bulls_strength.json @@ -4,6 +4,7 @@ "school": "transmutation", "range": "touch", "save": "will", + "harmless": true, "base_dc": 10, "cast_time": "standard", "effects": [ diff --git a/data/spells/cure_light_wounds.json b/data/spells/cure_light_wounds.json index 22e733a..b8bff8a 100644 --- a/data/spells/cure_light_wounds.json +++ b/data/spells/cure_light_wounds.json @@ -4,6 +4,7 @@ "school": "conjuration", "range": "touch", "save": "will", + "harmless": true, "base_dc": 10, "cast_time": "standard", "effects": [ diff --git a/data/spells/cure_moderate_wounds.json b/data/spells/cure_moderate_wounds.json index 3be2a17..4969336 100644 --- a/data/spells/cure_moderate_wounds.json +++ b/data/spells/cure_moderate_wounds.json @@ -4,6 +4,7 @@ "school": "conjuration", "range": "touch", "save": "will", + "harmless": true, "base_dc": 10, "cast_time": "standard", "effects": [ diff --git a/data/spells/delay_disease.json b/data/spells/delay_disease.json index 55595d5..d80e568 100644 --- a/data/spells/delay_disease.json +++ b/data/spells/delay_disease.json @@ -4,6 +4,7 @@ "school": "conjuration", "range": "touch", "save": "fort", + "harmless": true, "base_dc": 10, "cast_time": "standard", "effects": [], diff --git a/data/spells/delay_poison.json b/data/spells/delay_poison.json index 8d541db..b5da981 100644 --- a/data/spells/delay_poison.json +++ b/data/spells/delay_poison.json @@ -4,6 +4,7 @@ "school": "conjuration", "range": "touch", "save": "fort", + "harmless": true, "base_dc": 10, "cast_time": "standard", "effects": [], diff --git a/data/spells/enhance_water.json b/data/spells/enhance_water.json index eb44740..f0b1a59 100644 --- a/data/spells/enhance_water.json +++ b/data/spells/enhance_water.json @@ -4,6 +4,7 @@ "school": "transmutation", "range": "touch", "save": "fort", + "harmless": true, "base_dc": 10, "cast_time": "one_round", "effects": [], diff --git a/data/spells/enhanced_diplomacy.json b/data/spells/enhanced_diplomacy.json index 49bca72..b6eff89 100644 --- a/data/spells/enhanced_diplomacy.json +++ b/data/spells/enhanced_diplomacy.json @@ -4,6 +4,7 @@ "school": "divination", "range": "touch", "save": "will", + "harmless": true, "base_dc": 10, "cast_time": "standard", "effects": [], diff --git a/data/spells/face_of_the_devourer.json b/data/spells/face_of_the_devourer.json index b7b23a1..b267ec3 100644 --- a/data/spells/face_of_the_devourer.json +++ b/data/spells/face_of_the_devourer.json @@ -4,6 +4,7 @@ "school": "transmutation", "range": "touch", "save": "fort", + "harmless": true, "base_dc": 10, "cast_time": "standard", "effects": [], diff --git a/data/spells/guidance.json b/data/spells/guidance.json index d9796ae..70d5618 100644 --- a/data/spells/guidance.json +++ b/data/spells/guidance.json @@ -4,6 +4,7 @@ "school": "divination", "range": "touch", "save": "will", + "harmless": true, "base_dc": 10, "cast_time": "standard", "effects": [], diff --git a/data/spells/heroism.json b/data/spells/heroism.json index 9eff129..e4ebd41 100644 --- a/data/spells/heroism.json +++ b/data/spells/heroism.json @@ -4,6 +4,7 @@ "school": "enchantment", "range": "touch", "save": "will", + "harmless": true, "base_dc": 10, "cast_time": "standard", "effects": [ diff --git a/data/spells/hide_from_undead.json b/data/spells/hide_from_undead.json index 943edd6..d06afef 100644 --- a/data/spells/hide_from_undead.json +++ b/data/spells/hide_from_undead.json @@ -4,6 +4,7 @@ "school": "abjuration", "range": "touch", "save": "will", + "harmless": true, "base_dc": 10, "cast_time": "standard", "effects": [], diff --git a/data/spells/make_whole.json b/data/spells/make_whole.json index d53b070..b156b99 100644 --- a/data/spells/make_whole.json +++ b/data/spells/make_whole.json @@ -4,6 +4,7 @@ "school": "transmutation", "range": "close", "save": "will", + "harmless": true, "base_dc": 10, "cast_time": "long", "effects": [], diff --git a/data/spells/mending.json b/data/spells/mending.json index 75ceab6..ab3aa97 100644 --- a/data/spells/mending.json +++ b/data/spells/mending.json @@ -4,6 +4,7 @@ "school": "transmutation", "range": "close", "save": "will", + "harmless": true, "base_dc": 10, "cast_time": "long", "effects": [], diff --git a/data/spells/owls_wisdom.json b/data/spells/owls_wisdom.json index 8803e3b..dd73ae2 100644 --- a/data/spells/owls_wisdom.json +++ b/data/spells/owls_wisdom.json @@ -4,6 +4,7 @@ "school": "transmutation", "range": "touch", "save": "will", + "harmless": true, "base_dc": 10, "cast_time": "standard", "effects": [ diff --git a/data/spells/protection_from_evil.json b/data/spells/protection_from_evil.json index 6d2d6bf..559eb06 100644 --- a/data/spells/protection_from_evil.json +++ b/data/spells/protection_from_evil.json @@ -4,6 +4,7 @@ "school": "abjuration", "range": "touch", "save": "will", + "harmless": true, "base_dc": 10, "cast_time": "standard", "spell_resistance": false, diff --git a/data/spells/protection_from_evil_communal.json b/data/spells/protection_from_evil_communal.json index bb3cfc9..ab5d33b 100644 --- a/data/spells/protection_from_evil_communal.json +++ b/data/spells/protection_from_evil_communal.json @@ -4,6 +4,7 @@ "school": "abjuration", "range": "touch", "save": "will", + "harmless": true, "base_dc": 10, "cast_time": "standard", "spell_resistance": false, diff --git a/data/spells/protection_from_law.json b/data/spells/protection_from_law.json index 5c48dc1..16184d9 100644 --- a/data/spells/protection_from_law.json +++ b/data/spells/protection_from_law.json @@ -4,6 +4,7 @@ "school": "abjuration", "range": "touch", "save": "will", + "harmless": true, "base_dc": 10, "cast_time": "standard", "spell_resistance": false, diff --git a/data/spells/protection_from_law_communal.json b/data/spells/protection_from_law_communal.json index 3ca6ffc..fa471dc 100644 --- a/data/spells/protection_from_law_communal.json +++ b/data/spells/protection_from_law_communal.json @@ -4,6 +4,7 @@ "school": "abjuration", "range": "touch", "save": "will", + "harmless": true, "base_dc": 10, "cast_time": "standard", "spell_resistance": false, diff --git a/data/spells/resistance.json b/data/spells/resistance.json index 0d4fa88..0ce1430 100644 --- a/data/spells/resistance.json +++ b/data/spells/resistance.json @@ -4,6 +4,7 @@ "school": "abjuration", "range": "touch", "save": "will", + "harmless": true, "base_dc": 10, "cast_time": "standard", "effects": [], diff --git a/data/spells/restoration_lesser.json b/data/spells/restoration_lesser.json index d94a561..cca2cc0 100644 --- a/data/spells/restoration_lesser.json +++ b/data/spells/restoration_lesser.json @@ -4,6 +4,7 @@ "school": "conjuration", "range": "touch", "save": "will", + "harmless": true, "base_dc": 10, "cast_time": "long", "effects": [], diff --git a/data/spells/saving_finale.json b/data/spells/saving_finale.json index bd7a56b..aa9fb70 100644 --- a/data/spells/saving_finale.json +++ b/data/spells/saving_finale.json @@ -4,6 +4,7 @@ "school": "evocation", "range": "close", "save": "will", + "harmless": true, "base_dc": 10, "cast_time": "immediate", "effects": [], diff --git a/data/spells/shield_of_faith.json b/data/spells/shield_of_faith.json index 3e6196d..a6313ce 100644 --- a/data/spells/shield_of_faith.json +++ b/data/spells/shield_of_faith.json @@ -4,6 +4,7 @@ "school": "abjuration", "range": "touch", "save": "will", + "harmless": true, "base_dc": 10, "cast_time": "standard", "effects": [ diff --git a/data/spells/stabilize.json b/data/spells/stabilize.json index f1d7f3a..f9c0f16 100644 --- a/data/spells/stabilize.json +++ b/data/spells/stabilize.json @@ -4,6 +4,7 @@ "school": "conjuration", "range": "close", "save": "will", + "harmless": true, "base_dc": 10, "cast_time": "standard", "effects": [], diff --git a/data/spells/status.json b/data/spells/status.json index 625de1b..3f0485b 100644 --- a/data/spells/status.json +++ b/data/spells/status.json @@ -4,6 +4,7 @@ "school": "divination", "range": "touch", "save": "will", + "harmless": true, "base_dc": 10, "cast_time": "standard", "effects": [], diff --git a/data/spells/stave_off_corruption.json b/data/spells/stave_off_corruption.json index 3de0f0e..0ca5557 100644 --- a/data/spells/stave_off_corruption.json +++ b/data/spells/stave_off_corruption.json @@ -4,6 +4,7 @@ "school": "abjuration", "range": "touch", "save": "will", + "harmless": true, "base_dc": 10, "cast_time": "standard", "effects": [], diff --git a/data/spells/suppress_charms_and_compulsions.json b/data/spells/suppress_charms_and_compulsions.json index ee9528d..7e4aebd 100644 --- a/data/spells/suppress_charms_and_compulsions.json +++ b/data/spells/suppress_charms_and_compulsions.json @@ -4,6 +4,7 @@ "school": "abjuration", "range": "close", "save": "will", + "harmless": true, "base_dc": 10, "cast_time": "standard", "effects": [], diff --git a/data/spells/vanish.json b/data/spells/vanish.json index ffa1517..a7d6cfd 100644 --- a/data/spells/vanish.json +++ b/data/spells/vanish.json @@ -4,6 +4,7 @@ "school": "illusion", "range": "touch", "save": "will", + "harmless": true, "base_dc": 10, "cast_time": "standard", "effects": [ diff --git a/data/spells/weapon_of_awe.json b/data/spells/weapon_of_awe.json index d870e09..2e5f6df 100644 --- a/data/spells/weapon_of_awe.json +++ b/data/spells/weapon_of_awe.json @@ -4,6 +4,7 @@ "school": "transmutation", "range": "touch", "save": "will", + "harmless": true, "base_dc": 10, "cast_time": "standard", "effects": [], diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index e42acbc..1a089bc 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -6240,6 +6240,7 @@ class CombatEngine: "summary": "", "half_on_save": False, "save_negates": False, + "harmless": False, "area": None, "cast_time": "standard", } @@ -6254,6 +6255,7 @@ class CombatEngine: "summary": _spell_summary(spell), "half_on_save": half_on_save, "save_negates": spell.save != "none" and not half_on_save, + "harmless": spell.harmless, "area": _spell_area_dict(spell), "cast_time": spell.cast_time, } @@ -6710,6 +6712,35 @@ class CombatEngine: return False return True + def _save_forgone( + self, caster: CombatantState, target: CombatantState, spell: SpellSpec + ) -> bool: + """True when a willing target forgoes the save of a harmless spell. + + PF1e (SRD "Spell Descriptions — Saving Throw", + rules/prpg-core-rulebook/saving-throw.md): "(harmless): The spell is + usually beneficial, not harmful, but a targeted creature can attempt + a saving throw if it desires" and "Voluntarily Giving up a Saving + Throw: A creature can voluntarily forego a saving throw and willingly + accept a spell's result". The engine's willingness proxy is the side: + self and same-side creatures are willing, so a spell flagged + ``harmless`` rolled on them consumes NO save die. Two faithful + exceptions keep the roll: a hostile target is not willing (it may + attempt the save), and a cure spell is HARMFUL to an undead target + (SRD cure-light-wounds.md: it "deals damage to them instead of + curing their wounds [...] can attempt a Will save to take half + damage"), so an undead never forgoes that save. + """ + if not spell.harmless: + return False + if target is not caster and target.side != caster.side: + return False + return not any( + isinstance(effect, HealEffect) + and target.combatant.creature_type == "undead" + for effect in spell.effects + ) + def _apply_and_log_cast( self, caster: CombatantState, @@ -6744,6 +6775,7 @@ class CombatEngine: target, spell.save, dc, save_descriptors=spell_save_descriptors(spell) ) if spell.save != "none" and target is not None + and not self._save_forgone(caster, target, spell) else None ) caster_live = self._stats[caster.combatant.id] diff --git a/src/pf1e_simulator/spells.py b/src/pf1e_simulator/spells.py index d023f8a..5b5f840 100644 --- a/src/pf1e_simulator/spells.py +++ b/src/pf1e_simulator/spells.py @@ -364,6 +364,14 @@ class SpellSpec: # no" (Acid Arrow, Flurry of Snowballs, ...). False = skip the SR check # entirely (targeted and per-creature in areas). Default True. spell_resistance: bool = True + # PF1e (SRD, Magic — Spell Descriptions, saving throw notation): + # "(harmless): The spell is usually beneficial, not harmful, but a + # targeted creature can attempt a saving throw if it desires" + # (rules/prpg-core-rulebook/saving-throw.md). A willing target forgoes + # the save ("Voluntarily Giving up a Saving Throw: A creature can + # voluntarily forego a saving throw and willingly accept a spell's + # result"), so the engine skips the save roll on same-side targets. + harmless: bool = False effects: tuple[SpellEffect, ...] = () description: str = "" @@ -803,6 +811,7 @@ def load_spell(path: str | Path) -> SpellSpec: requires_ranged_touch=bool(data.get("requires_ranged_touch", False)), cast_time=cast_time, spell_resistance=bool(data.get("spell_resistance", True)), + harmless=bool(data.get("harmless", False)), effects=effects, description=_as_str(data.get("description", "")), ) diff --git a/tests/test_cure_undead.py b/tests/test_cure_undead.py index 28f3086..1ff5f96 100644 --- a/tests/test_cure_undead.py +++ b/tests/test_cure_undead.py @@ -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") diff --git a/tests/test_harmless_spells.py b/tests/test_harmless_spells.py new file mode 100644 index 0000000..d1178ef --- /dev/null +++ b/tests/test_harmless_spells.py @@ -0,0 +1,154 @@ +"""Harmless spells on willing targets forgo the saving throw roll. + +PF1e (SRD "Spell Descriptions — Saving Throw", +rules/prpg-core-rulebook/saving-throw.md): "(harmless): The spell is usually +beneficial, not harmful, but a targeted creature can attempt a saving throw +if it desires" and "Voluntarily Giving up a Saving Throw: A creature can +voluntarily forego a saving throw and willingly accept a spell's result". +The engine models willingness by side: self and same-side creatures are +willing, so a spell carrying the data flag ``harmless`` (set on the SRD +"(harmless)" spells) consumes NO save die on them. Hostile targets and undead +receiving a cure (harmful to them, SRD cure-light-wounds.md "deals damage to +them instead") keep the roll. Non-harmless spells keep the roll everywhere. +""" + +from __future__ import annotations + +import pytest + +from pf1e_simulator.rng import RngExhaustedError +from pf1e_simulator.spells import SPELLS_DIR, load_spell_registry +from tests.test_spells import _make_caster, _make_engine, _make_target + +# The 29 registry spells whose SRD stat block marks the save "(harmless)" +# (verified one by one against the KB, 2026-09 conformance round). +SRD_HARMLESS_SPELLS: frozenset[str] = frozenset({ + "Bear's Endurance", "Bull's Strength", "Cure Light Wounds", + "Cure Moderate Wounds", "Delay Disease", "Delay Poison", "Enhance Water", + "Enhanced Diplomacy", "Face of the Devourer", "Guidance", "Heroism", + "Hide from Undead", "Make Whole", "Mending", "Owl's Wisdom", + "Protection from Evil", "Protection from Evil, Communal", + "Protection from Law", "Protection from Law, Communal", "Resistance", + "Restoration, Lesser", "Saving Finale", "Shield of Faith", "Stabilize", + "Status", "Stave Off Corruption", "Suppress Charms and Compulsions", + "Vanish", "Weapon of Awe", +}) + + +def test_registry_harmless_flag_matches_srd_audit() -> None: + """Given: the spell registry loaded from data/spells/ + When: every spell's harmless flag is collected + Then: it matches exactly the SRD "(harmless)" audit — no more, no less.""" + registry = load_spell_registry(SPELLS_DIR) + flagged = {name for name, spell in registry.items() if spell.harmless} + assert flagged == SRD_HARMLESS_SPELLS + + +def test_harmless_spell_data_defaults_to_false() -> None: + registry = load_spell_registry(SPELLS_DIR) + assert registry["Cure Light Wounds"].harmless is True + assert registry["Fireball"].harmless is False + assert registry["Inflict Light Wounds"].harmless is False + + +def test_cure_on_willing_ally_consumes_no_save_die() -> None: + """Given: a wounded living ally + When: Cure Light Wounds (Will half (harmless)) is cast on it + Then: the heal lands with zero dice consumed for the save — the queue + holds only the heal die, and the transcript logs NO SAVE.""" + caster = _make_caster().model_copy(update={"spells": ["Cure Light Wounds"]}) + engine, c, t = _make_engine( + caster, _make_target(hp_max=30), [8], target_pos=(1, 0), + target_side="players", + ) + t.hp = 10 + engine._cast_spell(c, t, "Cure Light Wounds") + assert t.hp == 10 + (8 + 5) # 1d8=8 + caster-level 5 flat bonus + cast_lines = [line for line in engine._transcript if "cast Cure" in line] + assert any("NO SAVE" in line for line in cast_lines) + + +def test_harmless_buff_on_self_consumes_no_dice_at_all() -> None: + """Given: a caster shielding itself (Shield of Faith, Will negates + (harmless), range personal) + When: the spell is cast with an EMPTY dice queue + Then: no roll is attempted (a save die would raise RngExhaustedError) and + the buff is applied.""" + caster = _make_caster().model_copy(update={"spells": ["Shield of Faith"]}) + engine, c, _t = _make_engine( + caster, _make_target(), [], target_pos=(1, 0), target_side="players" + ) + engine._cast_spell(c, c, "Shield of Faith") + assert any(b.source == "spell: Shield of Faith" for b in c.active_buffs) + + +def test_undead_ally_does_not_forgo_the_cure_save() -> None: + """Given: an undead on the caster's own side + When: cured (the spell is HARMFUL to it: negative-energy damage) + Then: the Will roll still happens — d20=1 fails, full 13 damage (the + queue [1, 8] is consumed save-then-damage; a skipped save would feed the + 1 to the damage die for 6 hp, not 7).""" + caster = _make_caster().model_copy(update={"spells": ["Cure Light Wounds"]}) + undead = _make_target(hp_max=20).model_copy(update={"creature_type": "undead"}) + 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 + + +def test_hostile_target_of_harmless_spell_still_rolls() -> None: + """Given: Suppress Charms and Compulsions (close range, Will negates + (harmless)) aimed at an enemy — not a willing target + When: the spell is delivered (no touch attack: the spell is not touch) + Then: the Will die is still consumed — a d20=1 scripted as a failed save + proves the roll happened (FAILED label).""" + caster = _make_caster().model_copy( + update={"spells": ["Suppress Charms and Compulsions"]} + ) + engine, c, t = _make_engine( + caster, _make_target(), [1], target_pos=(5, 0), target_side="monsters" + ) + # (5,0) is 25 ft: inside close range, outside melee - no casting AoO. + engine._cast_spell(c, t, "Suppress Charms and Compulsions") + cast_lines = [ + line for line in engine._transcript if "cast Suppress" in line + ] + assert any("FAILED" in line for line in cast_lines) + + +def test_non_harmless_spell_on_ally_still_rolls() -> None: + """Given: Inflict Light Wounds (Will half, NOT harmless) aimed at a + living ally + When: delivered + Then: the save die is consumed — d20=1 fails (1+2 < DC 15) and the full + damage lands; had the roll been skipped the queue's 1 would have fed the + damage die instead (1d4=1+5 vs the scripted 4+5).""" + caster = _make_caster().model_copy(update={"spells": ["Inflict Light Wounds"]}) + engine, c, t = _make_engine( + caster, _make_target(hp_max=30), [1, 4], target_pos=(1, 0), + target_side="players", + ) + t.hp = 30 + engine._cast_spell(c, t, "Inflict Light Wounds") + assert t.hp == 30 - (4 + 5) + + +def test_forgone_save_never_breaks_when_queue_empty() -> None: + """Sanity: the willing-target path with an EMPTY queue applies the buff; + the hostile path with the same queue raises (the save die is genuinely + gone). Suppress Charms is close range, so the hostile leg reaches the + save without a touch-attack die.""" + caster = _make_caster().model_copy(update={"spells": ["Bull's Strength"]}) + engine, c, _t = _make_engine( + caster, _make_target(), [], target_pos=(1, 0), target_side="players" + ) + engine._cast_spell(c, c, "Bull's Strength") + suppress = _make_caster().model_copy( + update={"spells": ["Suppress Charms and Compulsions"]} + ) + hostile, c2, t2 = _make_engine( + suppress, _make_target(), [], target_pos=(1, 0), target_side="monsters" + ) + with pytest.raises(RngExhaustedError): + hostile._cast_spell(c2, t2, "Suppress Charms and Compulsions") diff --git a/tests/test_observation_enriched.py b/tests/test_observation_enriched.py index 5a52ae2..f0d3313 100644 --- a/tests/test_observation_enriched.py +++ b/tests/test_observation_enriched.py @@ -531,6 +531,7 @@ def test_observation_enriched_spells_unknown_graceful() -> None: "summary": "", "half_on_save": False, "save_negates": False, + "harmless": False, "area": None, "cast_time": "standard", }, diff --git a/tests/test_spell_area_snapshot.py b/tests/test_spell_area_snapshot.py index 7a2d9b6..c659aaa 100644 --- a/tests/test_spell_area_snapshot.py +++ b/tests/test_spell_area_snapshot.py @@ -127,7 +127,8 @@ def test_area_snapshot_existing_keys_unchanged() -> None: """Given: a known Fireball snapshot dict When: all keys and values are checked Then: the nine pre-U7a keys are present with unchanged values, and the - exact key set is the old nine plus the additive "area" and "cast_time".""" + exact key set is the old nine plus the additive "area", "cast_time" and + "harmless".""" s = _spell_dict_for(_FIREBALL, "Fireball") assert s["name"] == "Fireball" assert s["level"] == 3 @@ -140,6 +141,7 @@ def test_area_snapshot_existing_keys_unchanged() -> None: assert s["summary"] == "area 20ft" assert s["half_on_save"] is True assert s["save_negates"] is False + assert s["harmless"] is False assert s["cast_time"] == "standard" assert set(s.keys()) == { "name", @@ -151,6 +153,7 @@ def test_area_snapshot_existing_keys_unchanged() -> None: "summary", "half_on_save", "save_negates", + "harmless", "area", "cast_time", } diff --git a/tests/test_spells.py b/tests/test_spells.py index b47c516..0209602 100644 --- a/tests/test_spells.py +++ b/tests/test_spells.py @@ -665,7 +665,10 @@ class TestCureLightWounds: def test_heals_damage(self) -> None: caster = _make_caster() target = _make_target(hp_max=30) - engine, c, t = _make_engine(caster, target, [3, 5], target_pos=(1, 0), + # Willing same-side target of a (harmless) spell: 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, target, [5], target_pos=(1, 0), target_side="players") t.hp = 10 engine._cast_spell(c, t, "Cure Light Wounds") @@ -676,7 +679,7 @@ class TestCureLightWounds: def test_heal_capped_at_max(self) -> None: caster = _make_caster() target = _make_target(hp_max=30) - engine, c, t = _make_engine(caster, target, [3, 3, 6], target_pos=(1, 0), + engine, c, t = _make_engine(caster, target, [3], target_pos=(1, 0), target_side="players") t.hp = 28 engine._cast_spell(c, t, "Cure Light Wounds")