feat(rules): spell components + deafened verbal failure and arcane spell failure rolls
- SpellSpec.components (frozenset of V/S/M/F/DF, JSON key accepting the RAW string notation "V, S, M/DF" or a token list; slash = alternative; unknown token is a data error). Default empty = not encoded yet -> no failure. - Combatant.armor_spell_failure (0-100 percent, authored on the JSON - the model has no equipment) and arcane_caster (divine casters never incur ASF); both load directly through load_combatant. - _cast_spell: after the start-of-casting gates and before delivery, two conditional d100 rolls (convention mirrors the concealment roll, <=): deafened + V component spoils the spell on <=20 (SRD cast-a-spell.md); arcane_caster + S component + armor_spell_failure > 0 fails the spell on <= the ASF percent (SRD arcane-spells-and-armor.md). The slot stays spent (mirrors the concentration fizzle). No condition -> no roll: the RNG stream is bit-identical for every current combat (registry encodes no components yet - full encoding is a follow-up task). - Observation: spell summary gains "components" (sorted tuple), combatant snapshot gains "armor_spell_failure"/"arcane_caster"; README documents the keys and the two former out-of-scope bullets now describe the modeled rule with its data-encoding follow-up. - Data smoke test: lich arcane_caster true / ASF 0, oni arcane_caster false. - tests/test_spell_failure.py: 21 tests (parsing, thresholds <=20 / <=ASF, divine/no-ASF/no-component never roll, roll order, slot stays spent, observation keys). Golden transcripts unchanged.
This commit is contained in:
@@ -0,0 +1,339 @@
|
||||
"""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_have_no_components_yet() -> None:
|
||||
"""The full component encoding is a follow-up: no shipped spell
|
||||
declares components, so no golden combat can roll a failure."""
|
||||
registry = load_spell_registry(DATA_DIR / "spells")
|
||||
assert registry
|
||||
assert all(spell.components == frozenset() for spell in registry.values())
|
||||
Reference in New Issue
Block a user