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:
@@ -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
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"school": "transmutation",
|
||||
"range": "touch",
|
||||
"save": "will",
|
||||
"harmless": true,
|
||||
"base_dc": 10,
|
||||
"cast_time": "standard",
|
||||
"effects": [
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"school": "transmutation",
|
||||
"range": "touch",
|
||||
"save": "will",
|
||||
"harmless": true,
|
||||
"base_dc": 10,
|
||||
"cast_time": "standard",
|
||||
"effects": [
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"school": "conjuration",
|
||||
"range": "touch",
|
||||
"save": "will",
|
||||
"harmless": true,
|
||||
"base_dc": 10,
|
||||
"cast_time": "standard",
|
||||
"effects": [
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"school": "conjuration",
|
||||
"range": "touch",
|
||||
"save": "will",
|
||||
"harmless": true,
|
||||
"base_dc": 10,
|
||||
"cast_time": "standard",
|
||||
"effects": [
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"school": "conjuration",
|
||||
"range": "touch",
|
||||
"save": "fort",
|
||||
"harmless": true,
|
||||
"base_dc": 10,
|
||||
"cast_time": "standard",
|
||||
"effects": [],
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"school": "conjuration",
|
||||
"range": "touch",
|
||||
"save": "fort",
|
||||
"harmless": true,
|
||||
"base_dc": 10,
|
||||
"cast_time": "standard",
|
||||
"effects": [],
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"school": "transmutation",
|
||||
"range": "touch",
|
||||
"save": "fort",
|
||||
"harmless": true,
|
||||
"base_dc": 10,
|
||||
"cast_time": "one_round",
|
||||
"effects": [],
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"school": "divination",
|
||||
"range": "touch",
|
||||
"save": "will",
|
||||
"harmless": true,
|
||||
"base_dc": 10,
|
||||
"cast_time": "standard",
|
||||
"effects": [],
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"school": "transmutation",
|
||||
"range": "touch",
|
||||
"save": "fort",
|
||||
"harmless": true,
|
||||
"base_dc": 10,
|
||||
"cast_time": "standard",
|
||||
"effects": [],
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"school": "divination",
|
||||
"range": "touch",
|
||||
"save": "will",
|
||||
"harmless": true,
|
||||
"base_dc": 10,
|
||||
"cast_time": "standard",
|
||||
"effects": [],
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"school": "enchantment",
|
||||
"range": "touch",
|
||||
"save": "will",
|
||||
"harmless": true,
|
||||
"base_dc": 10,
|
||||
"cast_time": "standard",
|
||||
"effects": [
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"school": "abjuration",
|
||||
"range": "touch",
|
||||
"save": "will",
|
||||
"harmless": true,
|
||||
"base_dc": 10,
|
||||
"cast_time": "standard",
|
||||
"effects": [],
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"school": "transmutation",
|
||||
"range": "close",
|
||||
"save": "will",
|
||||
"harmless": true,
|
||||
"base_dc": 10,
|
||||
"cast_time": "long",
|
||||
"effects": [],
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"school": "transmutation",
|
||||
"range": "close",
|
||||
"save": "will",
|
||||
"harmless": true,
|
||||
"base_dc": 10,
|
||||
"cast_time": "long",
|
||||
"effects": [],
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"school": "transmutation",
|
||||
"range": "touch",
|
||||
"save": "will",
|
||||
"harmless": true,
|
||||
"base_dc": 10,
|
||||
"cast_time": "standard",
|
||||
"effects": [
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"school": "abjuration",
|
||||
"range": "touch",
|
||||
"save": "will",
|
||||
"harmless": true,
|
||||
"base_dc": 10,
|
||||
"cast_time": "standard",
|
||||
"spell_resistance": false,
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"school": "abjuration",
|
||||
"range": "touch",
|
||||
"save": "will",
|
||||
"harmless": true,
|
||||
"base_dc": 10,
|
||||
"cast_time": "standard",
|
||||
"spell_resistance": false,
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"school": "abjuration",
|
||||
"range": "touch",
|
||||
"save": "will",
|
||||
"harmless": true,
|
||||
"base_dc": 10,
|
||||
"cast_time": "standard",
|
||||
"spell_resistance": false,
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"school": "abjuration",
|
||||
"range": "touch",
|
||||
"save": "will",
|
||||
"harmless": true,
|
||||
"base_dc": 10,
|
||||
"cast_time": "standard",
|
||||
"spell_resistance": false,
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"school": "abjuration",
|
||||
"range": "touch",
|
||||
"save": "will",
|
||||
"harmless": true,
|
||||
"base_dc": 10,
|
||||
"cast_time": "standard",
|
||||
"effects": [],
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"school": "conjuration",
|
||||
"range": "touch",
|
||||
"save": "will",
|
||||
"harmless": true,
|
||||
"base_dc": 10,
|
||||
"cast_time": "long",
|
||||
"effects": [],
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"school": "evocation",
|
||||
"range": "close",
|
||||
"save": "will",
|
||||
"harmless": true,
|
||||
"base_dc": 10,
|
||||
"cast_time": "immediate",
|
||||
"effects": [],
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"school": "abjuration",
|
||||
"range": "touch",
|
||||
"save": "will",
|
||||
"harmless": true,
|
||||
"base_dc": 10,
|
||||
"cast_time": "standard",
|
||||
"effects": [
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"school": "conjuration",
|
||||
"range": "close",
|
||||
"save": "will",
|
||||
"harmless": true,
|
||||
"base_dc": 10,
|
||||
"cast_time": "standard",
|
||||
"effects": [],
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"school": "divination",
|
||||
"range": "touch",
|
||||
"save": "will",
|
||||
"harmless": true,
|
||||
"base_dc": 10,
|
||||
"cast_time": "standard",
|
||||
"effects": [],
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"school": "abjuration",
|
||||
"range": "touch",
|
||||
"save": "will",
|
||||
"harmless": true,
|
||||
"base_dc": 10,
|
||||
"cast_time": "standard",
|
||||
"effects": [],
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"school": "abjuration",
|
||||
"range": "close",
|
||||
"save": "will",
|
||||
"harmless": true,
|
||||
"base_dc": 10,
|
||||
"cast_time": "standard",
|
||||
"effects": [],
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"school": "illusion",
|
||||
"range": "touch",
|
||||
"save": "will",
|
||||
"harmless": true,
|
||||
"base_dc": 10,
|
||||
"cast_time": "standard",
|
||||
"effects": [
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"school": "transmutation",
|
||||
"range": "touch",
|
||||
"save": "will",
|
||||
"harmless": true,
|
||||
"base_dc": 10,
|
||||
"cast_time": "standard",
|
||||
"effects": [],
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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", "")),
|
||||
)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user