Files
pf1e-simulator/tests/test_spell_failure.py
T
ctan f6292f8bdc feat(rules): encode arcane_caster and armor_spell_failure from the sheets
- arcane_caster true: misty (Magus/Bard sheet class), nairda (Witch/
  Sorcerer), thrune_agent (Human magus 8, KB bestiary/thrune-agent.md);
  lich already true. False: oni (Cleric), tammara (Gunslinger/
  Warpriest - divine), esha (Cavalier/Rogue, spell-like only), the
  companions harvie/ierlieth/jeanne/esha_mount and the remaining
  monsters.
- armor_spell_failure stays 0 everywhere, as the effective PF1e value:
  nairda and the lich wear no armor (sheet note "Pas d'armure", KB lich
  block); misty's mithral shirt (RAW ASF 10%, magic-items/mithral-shirt
  .md) and the thrune agent's +1 studded leather (15%) are light armor
  and every spell they cast is on their own class list - magus and bard
  cast their own spells in light armor with no arcane spell failure
  (classes/magus.md, classes/bard.md).
- tests/test_spell_failure.py: the lich/oni smoke test becomes a
  full-data table pinning all 15 combatants' flags with per-entry
  sources. No shipped combatant incurs a non-zero ASF; the failure roll
  itself stays covered by the synthetic armored-caster tests.
2026-09-20 14:30:57 +02:00

380 lines
14 KiB
Python

"""Spell components and the component-gated casting failures.
SRD sources:
- rules/prpg-core-rulebook/cast-a-spell.md (components V/S/M/F/DF):
"A spellcaster who has been deafened has a 20% chance to spoil any spell
he tries to cast if that spell has a verbal component."
- rules/prpg-core-rulebook/arcane-spells-and-armor.md:
"Armor restricts the complicated gestures required while casting any
spell that has a somatic component." - arcane casters only.
The rolls are conditional: a non-deafened caster never rolls the deafened
d100, a divine caster (arcane_caster false) or asf=0 never rolls the ASF
d100, and a spell without the component never triggers its roll - so the
RNG stream stays bit-identical for everyone else. Failure burns the slot
(mirrors the concentration-fizzle path). The roll convention mirrors the
concealment d100: failure on d100 <= the percent.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import TYPE_CHECKING
import pytest
from pf1e_simulator.combat import CombatantState, CombatEngine
from pf1e_simulator.conditions import DEAFENED
from pf1e_simulator.loaders.combatant import CombatantLoadError, load_combatant
from pf1e_simulator.rng import ScriptedRng
from pf1e_simulator.spells import (
ConditionEffect,
SpellSpec,
load_spell,
load_spell_registry,
)
from tests.test_observation_enriched import make_combatant, make_grid, make_state
if TYPE_CHECKING:
from pf1e_simulator.models import Combatant
DATA_DIR = Path(__file__).resolve().parent.parent / "data"
def _zap(name: str, components: frozenset[str]) -> SpellSpec:
"""A save-less condition spell: applying it consumes no dice, so the
test queues pin exactly the failure rolls."""
return SpellSpec(
name=name,
level=1,
school="transmutation",
range="close",
components=components,
effects=(ConditionEffect(condition_name="shaken", duration_rounds=1),),
)
def _caster(
spell: SpellSpec,
*,
arcane_caster: bool = False,
armor_spell_failure: int = 0,
) -> Combatant:
base = make_combatant("caster", spells=[spell.name], spell_slots={1: 1})
return base.model_copy(
update={
"arcane_caster": arcane_caster,
"armor_spell_failure": armor_spell_failure,
}
)
def _cast(
caster: Combatant,
spell: SpellSpec,
rolls: list[int],
*,
deafened: bool = False,
) -> tuple[CombatEngine, CombatantState, CombatantState, ScriptedRng]:
grid = make_grid()
c = make_state(caster, "players", (0, 0))
t = make_state(make_combatant("foe"), "monsters", (2, 0))
if deafened:
c.conditions.append(DEAFENED)
rng = ScriptedRng(rolls)
engine = CombatEngine(rng, grid, [c, t], spell_registry={spell.name: spell})
engine._cast_spell(c, t, spell.name)
return engine, c, t, rng
def _shaken(state: CombatantState) -> bool:
return any(ac.condition.name == "shaken" for ac in state.active_conditions)
def _transcript(engine: CombatEngine) -> str:
return "\n".join(engine._transcript)
def _goblin_payload() -> dict[str, object]:
path = DATA_DIR / "monsters" / "goblin.json"
data: dict[str, object] = json.loads(path.read_text(encoding="utf-8"))
return data
# ---------------------------------------------------------------------------
# loader: the components key
# ---------------------------------------------------------------------------
def _write_spell(tmp_path: Path, payload: dict[str, object]) -> SpellSpec:
path = tmp_path / "spell.json"
path.write_text(json.dumps(payload), encoding="utf-8")
return load_spell(path)
def test_components_string_srd_notation(tmp_path: Path) -> None:
"""Given: the RAW stat-block notation "V, S, M/DF"
When: load_spell parses it
Then: the tokens normalize to {V, S, M, DF} (slash = alternative)."""
spell = _write_spell(
tmp_path,
{"name": "X", "level": 1, "school": "x", "components": "V, S, M/DF"},
)
assert spell.components == frozenset({"V", "S", "M", "DF"})
def test_components_list_of_tokens(tmp_path: Path) -> None:
spell = _write_spell(
tmp_path,
{"name": "X", "level": 1, "school": "x", "components": ["v", "S"]},
)
assert spell.components == frozenset({"V", "S"})
def test_components_default_empty(tmp_path: Path) -> None:
spell = _write_spell(tmp_path, {"name": "X", "level": 1, "school": "x"})
assert spell.components == frozenset()
def test_components_unknown_token_rejected(tmp_path: Path) -> None:
with pytest.raises(ValueError, match="unknown spell component"):
_write_spell(
tmp_path,
{"name": "X", "level": 1, "school": "x", "components": "V, Q"},
)
def test_components_bad_type_rejected(tmp_path: Path) -> None:
with pytest.raises(ValueError, match="expected list"):
_write_spell(
tmp_path,
{"name": "X", "level": 1, "school": "x", "components": 3},
)
def test_combatant_caster_fields_load_from_json(tmp_path: Path) -> None:
"""Given: a combatant JSON with armor_spell_failure and arcane_caster
When: load_combatant parses it
Then: both fields land on the model."""
payload = _goblin_payload()
payload["armor_spell_failure"] = 25
payload["arcane_caster"] = True
path = tmp_path / "armored.json"
path.write_text(json.dumps(payload), encoding="utf-8")
loaded = load_combatant(path)
assert loaded.armor_spell_failure == 25
assert loaded.arcane_caster is True
def test_armor_spell_failure_out_of_bounds_rejected(tmp_path: Path) -> None:
payload = _goblin_payload()
payload["armor_spell_failure"] = 101
path = tmp_path / "bad.json"
path.write_text(json.dumps(payload), encoding="utf-8")
with pytest.raises(CombatantLoadError, match="armor_spell_failure"):
load_combatant(path)
# ---------------------------------------------------------------------------
# deafened + verbal component: 20% spoil chance
# ---------------------------------------------------------------------------
def test_deafened_verbal_fails_at_threshold() -> None:
"""d100 = 20 <= 20: the spell is spoiled, the slot stays spent, no
effect lands."""
spell = _zap("Buzz", frozenset({"V"}))
caster = _caster(spell)
engine, c, t, rng = _cast(caster, spell, [20], deafened=True)
assert _shaken(t) is False
assert c.spell_slots_left[1] == 0
assert "casting failed (deafened)" in _transcript(engine)
assert len(rng._queue) == 0
def test_deafened_verbal_succeeds_above_threshold() -> None:
spell = _zap("Buzz", frozenset({"V"}))
caster = _caster(spell)
engine, c, t, rng = _cast(caster, spell, [21], deafened=True)
assert _shaken(t) is True
assert c.spell_slots_left[1] == 0
assert "casting failed (deafened)" not in _transcript(engine)
assert len(rng._queue) == 0
def test_deafened_without_verbal_never_rolls() -> None:
"""A spell with no V component: the deafened caster casts it with no
roll - the RNG stream is untouched."""
spell = _zap("Quiet", frozenset({"S"}))
caster = _caster(spell)
_engine, _c, t, rng = _cast(caster, spell, [], deafened=True)
assert _shaken(t) is True
assert len(rng._queue) == 0
def test_not_deafened_with_verbal_never_rolls() -> None:
spell = _zap("Buzz", frozenset({"V"}))
caster = _caster(spell)
_engine, _c, t, rng = _cast(caster, spell, [], deafened=False)
assert _shaken(t) is True
assert len(rng._queue) == 0
# ---------------------------------------------------------------------------
# arcane + somatic + armor: ASF percent
# ---------------------------------------------------------------------------
def test_arcane_somatic_fails_at_asf_threshold() -> None:
"""d100 = 25 <= ASF 25: the spell is spoiled, slot spent, no effect."""
spell = _zap("Gesticulate", frozenset({"S"}))
caster = _caster(spell, arcane_caster=True, armor_spell_failure=25)
engine, c, t, rng = _cast(caster, spell, [25])
assert _shaken(t) is False
assert c.spell_slots_left[1] == 0
assert "arcane spell failure" in _transcript(engine)
assert len(rng._queue) == 0
def test_arcane_somatic_succeeds_above_asf() -> None:
spell = _zap("Gesticulate", frozenset({"S"}))
caster = _caster(spell, arcane_caster=True, armor_spell_failure=25)
engine, _c, t, rng = _cast(caster, spell, [26])
assert _shaken(t) is True
assert "arcane spell failure" not in _transcript(engine)
assert len(rng._queue) == 0
def test_divine_caster_never_rolls_asf() -> None:
"""arcane_caster false with armor_spell_failure 35: no roll, ever."""
spell = _zap("Gesticulate", frozenset({"S"}))
caster = _caster(spell, arcane_caster=False, armor_spell_failure=35)
_engine, _c, t, rng = _cast(caster, spell, [])
assert _shaken(t) is True
assert len(rng._queue) == 0
def test_zero_asf_never_rolls() -> None:
spell = _zap("Gesticulate", frozenset({"S"}))
caster = _caster(spell, arcane_caster=True, armor_spell_failure=0)
_engine, _c, t, rng = _cast(caster, spell, [])
assert _shaken(t) is True
assert len(rng._queue) == 0
def test_somatic_free_spell_never_rolls_asf() -> None:
"""An arcane caster in heavy armor casts a V-only spell with no ASF
roll (SRD: "If a spell doesn't have a somatic component, an arcane
spellcaster can cast it with no arcane spell failure chance")."""
spell = _zap("Chant", frozenset({"V"}))
caster = _caster(spell, arcane_caster=True, armor_spell_failure=45)
_engine, _c, t, rng = _cast(caster, spell, [])
assert _shaken(t) is True
assert len(rng._queue) == 0
def test_deafened_arcane_both_rolls_in_order() -> None:
"""V+S spell, deafened arcane caster in armor: the deafened d100 is
rolled first and passes, then the ASF d100 spoils the spell."""
spell = _zap("Incant", frozenset({"V", "S"}))
caster = _caster(spell, arcane_caster=True, armor_spell_failure=30)
engine, c, t, rng = _cast(caster, spell, [50, 10], deafened=True)
assert _shaken(t) is False
assert c.spell_slots_left[1] == 0
transcript = _transcript(engine)
assert "arcane spell failure: d100(10) <= 30%" in transcript
assert "casting failed (deafened)" not in transcript
assert len(rng._queue) == 0
def test_deafened_failure_short_circuits_asf() -> None:
"""The deafened roll spoils the spell before the ASF die is touched:
exactly one d100 consumed."""
spell = _zap("Incant", frozenset({"V", "S"}))
caster = _caster(spell, arcane_caster=True, armor_spell_failure=30)
_engine, _c, t, rng = _cast(caster, spell, [5], deafened=True)
assert _shaken(t) is False
assert len(rng._queue) == 0
# ---------------------------------------------------------------------------
# observation: the new keys
# ---------------------------------------------------------------------------
def test_observation_exposes_components_and_caster_fields() -> None:
spell = _zap("Incant", frozenset({"V", "S"}))
caster = _caster(spell, arcane_caster=True, armor_spell_failure=15)
c = make_state(caster, "players", (0, 0))
t = make_state(make_combatant("foe"), "monsters", (2, 0))
engine = CombatEngine(
ScriptedRng([]), make_grid(), [c, t], spell_registry={spell.name: spell}
)
obs = engine._build_observation(c).to_dict()
hero = next(x for x in obs["combatants"] if x["id"] == "caster")
assert hero["arcane_caster"] is True
assert hero["armor_spell_failure"] == 15
(snap,) = hero["spells"]
assert snap["components"] == ("S", "V")
json.dumps(obs)
def test_combatant_caster_flags_and_asf_match_sheets() -> None:
"""Smoke test on the shipped data, one entry per combatant JSON.
arcane_caster follows the sheet class (fiches_personnages identity.classes
/ bestiary KB): magus/bard/witch/sorcerer/wizard true, divine and
non-casters false. armor_spell_failure is the EFFECTIVE percent:
- lich: no armor (KB bestiary/lich.json, unarmored skeletal body).
- misty: mithral shirt (sheet combat.ac note "+6 mithral shirt",
light armor, RAW ASF 10%) but every spell she knows is a bard or
magus spell and both classes cast their own-list spells in light
armor with no ASF (classes/bard.md, classes/magus.md) -> 0.
- nairda: no armor (sheet combat.ac note "Pas d'armure") -> 0.
- thrune_agent: +1 studded leather (KB bestiary/thrune-agent.md gear,
light armor) but all its spells are magus spells and a magus casts
magus spells in light armor with no ASF (classes/magus.md) -> 0.
"""
expectations: dict[str, tuple[bool, int]] = {
"pcs/misty.json": (True, 0),
"pcs/nairda.json": (True, 0),
"pcs/oni.json": (False, 0),
"pcs/tammara.json": (False, 0),
"pcs/esha.json": (False, 0),
"pcs/esha_mount.json": (False, 0),
"pcs/harvie.json": (False, 0),
"pcs/ierlieth.json": (False, 0),
"pcs/jeanne.json": (False, 0),
"monsters/lich.json": (True, 0),
"monsters/thrune_agent.json": (True, 0),
"monsters/goblin.json": (False, 0),
"monsters/orc.json": (False, 0),
"monsters/celestial_wolf.json": (False, 0),
"monsters/giant_belostomatid.json": (False, 0),
}
for rel, (arcane, asf) in expectations.items():
loaded = load_combatant(DATA_DIR / rel)
assert loaded.arcane_caster is arcane, rel
assert loaded.armor_spell_failure == asf, rel
def test_registry_spells_declare_kb_components() -> None:
"""Every shipped spell encodes its KB Components line (V/S/M/F/DF
tokens, material/focus descriptions dropped) except Create Alcool,
whose SRD entry is not in the KB yet."""
registry = load_spell_registry(DATA_DIR / "spells")
assert registry
for name, spell in registry.items():
if name == "Create Alcool":
assert spell.components == frozenset()
continue
assert spell.components, name
# spot-checks against the SRD stat blocks
assert registry["Acid Arrow"].components == frozenset({"V", "S", "M", "F"})
assert registry["Bless"].components == frozenset({"V", "S", "DF"})
assert registry["Bladed Dash"].components == frozenset({"V"})
# slash alternatives (M/DF, F/DF) expand to both tokens
assert registry["Alarm"].components == frozenset({"V", "S", "F", "DF"})
assert registry["Bear's Endurance"].components == frozenset({"V", "S", "M", "DF"})