"""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_lich_is_arcane_oni_is_not() -> None: """Smoke test on the shipped data: the lich casts arcane spells with no armor (ASF 0); Oni is a divine caster and never incurs ASF.""" lich = load_combatant(DATA_DIR / "monsters" / "lich.json") oni = load_combatant(DATA_DIR / "pcs" / "oni.json") assert lich.arcane_caster is True assert lich.armor_spell_failure == 0 assert oni.arcane_caster is False assert oni.armor_spell_failure == 0 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"})