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:
2026-09-20 14:02:10 +02:00
parent a483070e13
commit 1f8f068dd1
10 changed files with 505 additions and 23 deletions
+41 -21
View File
@@ -1691,7 +1691,15 @@ Règles modélisées :
Large… ; l'allonge effective d'une attaque reste `max(allonge naturelle, Large… ; l'allonge effective d'une attaque reste `max(allonge naturelle,
reach_ft de l'arme)`), aware (SRD « Surprise » : agit au round de surprise), reach_ft de l'arme)`), aware (SRD « Surprise » : agit au round de surprise),
alignment (SRD « Alignment » : code à deux axes parmi LG/NG/CG/LN/N/CN/LE/NE/CE, alignment (SRD « Alignment » : code à deux axes parmi LG/NG/CG/LN/N/CN/LE/NE/CE,
défaut "N"), défaut "N"), armor_spell_failure (SRD « Arcane Spells and Armor » :
pourcentage 0-100 d'échec d'incantation arcanique dû à l'armure, authored
sur le JSON — le modèle n'a pas d'équipement) et arcane_caster (bool :
vrai = incanteur arcanse exposé à l'ASF, faux = incanteur divin jamais
exposé), armor_spell_failure (int 0-100 — pourcentage d'échec
d'incantation arcanique de l'armure, SRD « Arcane Spells and Armor »,
authored sur le JSON faute de modèle d'équipement) et arcane_caster
(bool — vrai pour un incanteur arcanse exposé à l'ASF, faux pour un
incanteur divin),
dr {amount, bypass} ou None (C1), creature_type (C3), dr {amount, bypass} ou None (C1), creature_type (C3),
bab (E1), initiative_mod (E2) et initiative_total (U2 : total du jet bab (E1), initiative_mod (E2) et initiative_total (U2 : total du jet
d'initiative courant = d20 + mod, 0 si pas encore de clé — invocation d'initiative courant = d20 + mod, 0 si pas encore de clé — invocation
@@ -1716,7 +1724,9 @@ Règles modélisées :
radius_ft, centered_on_caster} | None (U7a : premier AreaEffect de niveau supérieur du sort, radius_ft, centered_on_caster} | 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 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 (code de temps d'incantation, « standard » pour un sort inconnu du
registre)), registre), components (tuple trié des tokens de composantes V/S/M/F/DF,
SRD « Cast a Spell — Spell Components » ; vide = composantes non
encodées — aucun échec de composante possible pour ce sort)),
spell_resistance (int), resistances {type: montant}, immunities spell_resistance (int), resistances {type: montant}, immunities
(tuple triée des types immunisés), sneak_dice (dés de précision disponibles : (tuple triée des types immunisés), sneak_dice (dés de précision disponibles :
(niveau + 1) // 2 avec « Sneak Attack », sinon 0), engaged_in_melee (bool — (niveau + 1) // 2 avec « Sneak Attack », sinon 0), engaged_in_melee (bool —
@@ -1844,27 +1854,35 @@ plutôt que bricolées) :
large ones, may present an obstacle even when helpless. In such cases, each large ones, may present an obstacle even when helpless. In such cases, each
square you move through counts as 2 squares » — les cases d'une créature square you move through counts as 2 squares » — les cases d'une créature
inactive ne bloquent pas et ne coûtent pas double). inactive ne bloquent pas et ne coûtent pas double).
- **Échec d'incantation pour un personnage sourd hors périmètre** (SRD - **Composantes des sorts et échecs d'incantation associés — moteur en
« Conditions — Deafened » : « a 20% chance of spell failure when casting place, encodage des données en cours** (SRD « Cast a Spell — Spell
spells with verbal components ») : le moteur ne porte pas la composante Components » : « A spellcaster who has been deafened has a 20% chance to
verbale des sorts (`SpellSpec` n'a pas de champ `components` — il spoil any spell he tries to cast if that spell has a verbal component » ;
faudrait l'encoder sur les 108 sorts du registre) et ne modélise pas non SRD « Arcane Spells and Armor » : « If you cast an arcane spell with a
plus le jet de Perception DD 20 basé sur le son ni l'échec automatique somatic component while wearing armor, you incur a chance of arcane spell
associé ; la condition `deafened` reste donc un porteur de modificateurs failure ») : `SpellSpec` porte un champ `components` (liste de tokens
(−4 d'initiative) sans la faillibilité d'incantation. Le soin des parmi V/S/M/F/DF, notation RAW acceptée — `"V, S, M/DF"` où la barre
oblique sépare les alternatives) et `Combatant` porte
`arcane_caster` (vrai pour les incanteurs arcanses — le liche ; faux
pour les incanteurs divins — Oni) et `armor_spell_failure` (pourcentage
0-100 authored sur le JSON, le modèle n'ayant pas d'équipement — la CA
des JSON est une valeur finale). `_cast_spell` joue les deux jets après
les gardes de début d'incantation (emplacement déjà dépensé — un échec
brûle l'emplacement, comme le fumble de concentration) : d100 ≤ 20 si le
lanceur est `deafened` et le sort a la composante V
(journalisé `casting failed (deafened)`) ; d100 ≤ l'ASF si le lanceur
est arcanse, porte la condition `S` et a un ASF > 0 (journalisé
`arcane spell failure`) ; convention `<=` miroir du jet de
dissimulation. Chaque jet n'est joué que si sa condition tient : un
lanceur non-sourd et dépourvu d'ASF garde un flux RNG bit-identique.
Reste hors périmètre (tâche de suivi) : l'encodage des composantes sur
les 108 sorts du registre `data/spells/` (aucun sort ne déclare encore
de composante, donc aucun échec ne peut partir dans les combats
dorés) ; le jet de Perception DD 20 basé sur le son du sourd et son
échec automatique associé ne sont pas modélisés. Le soin des
morts-vivants par les sorts de soin (`cure`) est en revanche modélisé : morts-vivants par les sorts de soin (`cure`) est en revanche modélisé :
SRD « Cure Light Wounds » — « this spell deals damage to them instead of SRD « Cure Light Wounds » — « this spell deals damage to them instead of
curing their wounds [...] can attempt a Will save to take half damage ». curing their wounds [...] can attempt a Will save to take half damage ».
- **Échec d'incantation arcanique due à l'armure (ASF) hors périmètre** (SRD
« Arcane Spells and Armor » : « Armor restricts the complicated gestures
required while casting any spell that has a somatic component. The armor
and shield descriptions list the arcane spell failure chance for different
armors and shields ») : le moteur ne modélise pas l'équipement — le
`Combatant` n'a aucun champ armure/bouclier (la CA des JSON est une valeur
finale, voir « Taille et attaques/CA ») et `SpellSpec` n'a aucun champ
`components` (même constat que la limite « personnage sourd » ci-dessus) ;
la probabilité d'échec d'incantation n'a donc ni source (montant d'ASF de
l'armure) ni filtre (sorts à composante somatique) et n'est pas appliquée.
- **Congé des sorts à durée « (D) » hors périmètre** (SRD « Cast a Spell — - **Congé des sorts à durée « (D) » hors périmètre** (SRD « Cast a Spell —
Dismiss a Spell » : « Dismissing an active spell is a standard action Dismiss a Spell » : « Dismissing an active spell is a standard action
that doesn't provoke attacks of opportunity » ; marque « (D) » de la that doesn't provoke attacks of opportunity » ; marque « (D) » de la
@@ -1969,7 +1987,9 @@ Limites de sorts (conformance D6-D9, documentées plutôt que bricolées) :
- `spells.py` — sorts : `SpellSpec` (dataclass avec niveau, école, portée, - `spells.py` — sorts : `SpellSpec` (dataclass avec niveau, école, portée,
sauvegarde, DC de base, exigence de contact à distance `requires_ranged_touch` sauvegarde, DC de base, exigence de contact à distance `requires_ranged_touch`
(rayons), marque `harmless` (SRD « (harmless) » — sauvegarde sautée sur (rayons), marque `harmless` (SRD « (harmless) » — sauvegarde sautée sur
cible consentante), temps d'incantation `cast_time`) et types d'effets cible consentante), temps d'incantation `cast_time`, composantes
`components` (frozenset de tokens V/S/M/F/DF, SRD « Cast a Spell — Spell
Components »)) et types d'effets
(`DamageEffect`, `ConditionEffect`, (`DamageEffect`, `ConditionEffect`,
`HealEffect`, `BuffEffect`, `AreaEffect` — burst/cône/ligne, S1a, `HealEffect`, `BuffEffect`, `AreaEffect` — burst/cône/ligne, S1a,
`OneShotEffect` — bonus compétence à usage unique sur un jet d'attaque, de `OneShotEffect` — bonus compétence à usage unique sur un jet d'attaque, de
+2
View File
@@ -27,6 +27,8 @@
"will": 11 "will": 11
}, },
"casting_ability": "int", "casting_ability": "int",
"arcane_caster": true,
"armor_spell_failure": 0,
"spell_slots": { "spell_slots": {
"1": 4, "1": 4,
"2": 4, "2": 4,
+2
View File
@@ -26,6 +26,8 @@
"will": 9 "will": 9
}, },
"casting_ability": "wis", "casting_ability": "wis",
"arcane_caster": false,
"armor_spell_failure": 0,
"spell_slots": { "spell_slots": {
"1": 4, "1": 4,
"2": 3 "2": 3
+60 -2
View File
@@ -216,6 +216,10 @@ _FIGHT_DEFENSIVELY_AC_BONUS = 2 # PF1e: fighting defensively grants +2 dodge AC
_FIGHT_DEFENSIVELY_ATTACK_PENALTY = -4 # PF1e: and -4 on attacks / crit confirms _FIGHT_DEFENSIVELY_ATTACK_PENALTY = -4 # PF1e: and -4 on attacks / crit confirms
_SPELL_COMBAT_ATTACK_PENALTY = -2 # PF1e: magus Spell Combat melee attacks at -2 _SPELL_COMBAT_ATTACK_PENALTY = -2 # PF1e: magus Spell Combat melee attacks at -2
_TOTAL_CONCEALMENT_PCT = 50 # PF1e: a blinded attacker's targets have total concealment _TOTAL_CONCEALMENT_PCT = 50 # PF1e: a blinded attacker's targets have total concealment
# PF1e (SRD Cast a Spell - Components): "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/cast-a-spell.md).
_DEAFENED_VERBAL_FAILURE_PCT = 20
# PF1e (CRB Damage Reduction): DR does not negate energy damage dealt along # PF1e (CRB Damage Reduction): DR does not negate energy damage dealt along
# with an attack — only these weapon damage types ride the DR path. # with an attack — only these weapon damage types ride the DR path.
_PHYSICAL_DAMAGE_TYPES = frozenset({"bludgeoning", "piercing", "slashing"}) _PHYSICAL_DAMAGE_TYPES = frozenset({"bludgeoning", "piercing", "slashing"})
@@ -1661,6 +1665,8 @@ class CombatEngine:
"natural_reach_ft": _SIZE_NATURAL_REACH_FT.get(state.combatant.size, 5), "natural_reach_ft": _SIZE_NATURAL_REACH_FT.get(state.combatant.size, 5),
"aware": state.combatant.aware, "aware": state.combatant.aware,
"alignment": state.combatant.alignment, "alignment": state.combatant.alignment,
"armor_spell_failure": state.combatant.armor_spell_failure,
"arcane_caster": state.combatant.arcane_caster,
"creature_type": state.combatant.creature_type, "creature_type": state.combatant.creature_type,
"bab": state.combatant.bab, "bab": state.combatant.bab,
"initiative_mod": state.combatant.initiative_mod, "initiative_mod": state.combatant.initiative_mod,
@@ -6536,6 +6542,7 @@ class CombatEngine:
"harmless": False, "harmless": False,
"area": None, "area": None,
"cast_time": "standard", "cast_time": "standard",
"components": (),
} }
half_on_save = _spell_half_on_save(spell) half_on_save = _spell_half_on_save(spell)
return { return {
@@ -6551,6 +6558,7 @@ class CombatEngine:
"harmless": spell.harmless, "harmless": spell.harmless,
"area": _spell_area_dict(spell), "area": _spell_area_dict(spell),
"cast_time": spell.cast_time, "cast_time": spell.cast_time,
"components": tuple(sorted(spell.components)),
} }
def _cast_spell( def _cast_spell(
@@ -6624,9 +6632,16 @@ class CombatEngine:
caster.held_charge = None caster.held_charge = None
# PF1e (CRB, Concentration, B24): grappled/defensive/threatened casts # PF1e (CRB, Concentration, B24): grappled/defensive/threatened casts
# must clear their interruption gate before the spell is delivered. # must clear their interruption gate before the spell is delivered.
if self._spell_cast_interrupted( # PF1e (SRD Cast a Spell - Components): then the component failure
# rolls (deafened + verbal, arcane + somatic + armor), made during
# the cast itself - the slot is already spent, mirroring the
# concentration-fizzle path. Each d100 is rolled ONLY when its
# condition applies, so a non-deafened or unarmored caster keeps a
# bit-identical RNG stream.
interrupted = self._spell_cast_interrupted(
caster, used_level, cast_defensively=cast_defensively caster, used_level, cast_defensively=cast_defensively
): )
if interrupted or self._spell_component_failure(caster, spell, spell_name):
return None return None
if spell.cast_time == "one_round": if spell.cast_time == "one_round":
# PF1e (SRD "Casting Time"): "A spell that takes 1 round to cast # PF1e (SRD "Casting Time"): "A spell that takes 1 round to cast
@@ -6734,6 +6749,49 @@ class CombatEngine:
return True return True
return self._check_casting_aoo(caster, spell_level) return self._check_casting_aoo(caster, spell_level)
def _spell_component_failure(
self, caster: CombatantState, spell: SpellSpec, spell_name: str
) -> bool:
"""Component failure rolls, made during the cast (SRD "Cast a Spell -
Spell Components").
- Deafened + verbal: "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/cast-a-spell.md).
- Arcane + somatic + armor: "Armor restricts the complicated
gestures required while casting any spell that has a somatic
component" (rules/prpg-core-rulebook/arcane-spells-and-armor.md) -
divine casters (``arcane_caster`` false) never roll.
Each d100 is rolled ONLY when its condition applies, so a
non-deafened, unarmored or divine caster keeps a bit-identical RNG
stream. Convention mirrors the concealment roll: failure on
d100 <= the percent. True = the spell is spoiled (logged; the slot
stays spent), False = the cast survives every applicable roll.
"""
if "V" in spell.components and self._has_condition_named(
caster, "deafened"
):
pct = self._rng.d100()
if pct <= _DEAFENED_VERBAL_FAILURE_PCT:
self._log(
f"round {self._current_round} {caster.combatant.id}: "
f"casting failed (deafened): d100({pct}) <= "
f"{_DEAFENED_VERBAL_FAILURE_PCT}% - {spell_name} lost"
)
return True
asf = caster.combatant.armor_spell_failure
if "S" in spell.components and caster.combatant.arcane_caster and asf > 0:
pct = self._rng.d100()
if pct <= asf:
self._log(
f"round {self._current_round} {caster.combatant.id}: "
f"arcane spell failure: d100({pct}) <= {asf}% - "
f"{spell_name} lost"
)
return True
return False
def _spell_delivery_blocked( def _spell_delivery_blocked(
self, self,
caster: CombatantState, caster: CombatantState,
+3
View File
@@ -239,6 +239,9 @@ def load_combatant(path: Path) -> Combatant:
``Combatant`` schema (default ``true``), so it parses directly here. ``Combatant`` schema (default ``true``), so it parses directly here.
The ``alignment`` code (SRD "Alignment") is a plain string of the schema The ``alignment`` code (SRD "Alignment") is a plain string of the schema
(default ``"N"``), so a JSON ``alignment`` key loads directly too. (default ``"N"``), so a JSON ``alignment`` key loads directly too.
The ``armor_spell_failure`` percent and the ``arcane_caster`` flag
(SRD "Arcane Spells and Armor") are plain fields of the schema (defaults
``0`` and ``false``), so their JSON keys load directly as well.
""" """
try: try:
raw = path.read_text(encoding="utf-8") raw = path.read_text(encoding="utf-8")
+10
View File
@@ -322,6 +322,16 @@ class Combatant(_FrozenModel):
metamagic_feats: list[str] = Field(default_factory=list) # e.g. Empower/Maximize/Extend Spell metamagic_feats: list[str] = Field(default_factory=list) # e.g. Empower/Maximize/Extend Spell
spells: list[str] = Field(default_factory=list) spells: list[str] = Field(default_factory=list)
spell_resistance: int = 0 spell_resistance: int = 0
# PF1e (SRD, Magic - Arcane Spells and Armor): the armor/shield worn
# imposes an arcane spell failure chance (percent, 0-100) on arcane
# spells with a somatic component. The model has no equipment, so the
# chance is authored directly on the combatant JSON.
# rules/prpg-core-rulebook/arcane-spells-and-armor.md
armor_spell_failure: int = Field(default=0, ge=0, le=100)
# True when the combatant's spellcasting is arcane (sorcerer, wizard,
# magus...) - only arcane casters incur armor spell failure; divine
# casters (cleric, druid...) wear any armor freely.
arcane_caster: bool = False
resistances: dict[str, int] = Field(default_factory=dict) resistances: dict[str, int] = Field(default_factory=dict)
immunities: tuple[str, ...] = () immunities: tuple[str, ...] = ()
# PF1e (SRD "Surprise"): "If some but not all of the combatants are aware # PF1e (SRD "Surprise"): "If some but not all of the combatants are aware
+45
View File
@@ -462,6 +462,14 @@ class SpellSpec:
# voluntarily forego a saving throw and willingly accept a spell's # voluntarily forego a saving throw and willingly accept a spell's
# result"), so the engine skips the save roll on same-side targets. # result"), so the engine skips the save roll on same-side targets.
harmless: bool = False harmless: bool = False
# PF1e (SRD, Magic - Cast a Spell - Spell Components): the components a
# spell requires, as normalized uppercase tokens among V (verbal),
# S (somatic), M (material), F (focus), DF (divine focus)
# (rules/prpg-core-rulebook/cast-a-spell.md). Empty = no components
# encoded yet (the registry is not fully transcribed), which makes both
# component-gated failure rolls (deafened casting, arcane spell failure)
# impossible for that spell.
components: frozenset[str] = frozenset()
effects: tuple[SpellEffect, ...] = () effects: tuple[SpellEffect, ...] = ()
description: str = "" description: str = ""
@@ -572,6 +580,38 @@ def _is_cast_time(value: str) -> TypeGuard[CastTime]:
return value in get_args(CastTime) return value in get_args(CastTime)
# PF1e spell components (SRD, Magic - Cast a Spell - Spell Components):
# verbal, somatic, material, focus, divine focus.
_SPELL_COMPONENTS: frozenset[str] = frozenset({"V", "S", "M", "F", "DF"})
def _parse_components(value: Json) -> frozenset[str]:
"""Spell components from a RAW string ("V, S, M/DF") or a token list.
The SRD stat-block notation separates components with commas and
alternatives (material OR divine focus) with a slash; both delimiters
split into tokens, normalized to uppercase and validated against
{V, S, M, F, DF}. An unknown token is a data error.
"""
if isinstance(value, str):
raw_tokens = value.replace("/", ",").split(",")
else:
raw_tokens = [_as_str(item) for item in _as_list(value)]
tokens: set[str] = set()
for raw in raw_tokens:
token = raw.strip().upper()
if not token:
continue
if token not in _SPELL_COMPONENTS:
msg = (
f"unknown spell component: {raw!r} "
f"(expected one of {sorted(_SPELL_COMPONENTS)})"
)
raise ValueError(msg)
tokens.add(token)
return frozenset(tokens)
# RAW phrasings accepted by the JSON loader, mapped onto the budget codes. # RAW phrasings accepted by the JSON loader, mapped onto the budget codes.
_CAST_TIME_ALIASES: dict[str, CastTime] = { _CAST_TIME_ALIASES: dict[str, CastTime] = {
"1 round": "one_round", "1 round": "one_round",
@@ -983,6 +1023,10 @@ def load_spell(path: str | Path) -> SpellSpec:
msg = f"unknown save type: {save_raw!r}" msg = f"unknown save type: {save_raw!r}"
raise ValueError(msg) raise ValueError(msg)
cast_time = _parse_cast_time(_as_str(data.get("cast_time", "standard"))) cast_time = _parse_cast_time(_as_str(data.get("cast_time", "standard")))
components_raw = data.get("components")
components = (
frozenset[str]() if components_raw is None else _parse_components(components_raw)
)
return SpellSpec( return SpellSpec(
name=_as_str(data["name"]), name=_as_str(data["name"]),
level=_as_int(data["level"]), level=_as_int(data["level"]),
@@ -994,6 +1038,7 @@ def load_spell(path: str | Path) -> SpellSpec:
cast_time=cast_time, cast_time=cast_time,
spell_resistance=bool(data.get("spell_resistance", True)), spell_resistance=bool(data.get("spell_resistance", True)),
harmless=bool(data.get("harmless", False)), harmless=bool(data.get("harmless", False)),
components=components,
effects=effects, effects=effects,
description=_as_str(data.get("description", "")), description=_as_str(data.get("description", "")),
) )
+1
View File
@@ -534,6 +534,7 @@ def test_observation_enriched_spells_unknown_graceful() -> None:
"harmless": False, "harmless": False,
"area": None, "area": None,
"cast_time": "standard", "cast_time": "standard",
"components": (),
}, },
) )
+2
View File
@@ -143,6 +143,7 @@ def test_area_snapshot_existing_keys_unchanged() -> None:
assert s["save_negates"] is False assert s["save_negates"] is False
assert s["harmless"] is False assert s["harmless"] is False
assert s["cast_time"] == "standard" assert s["cast_time"] == "standard"
assert s["components"] == ()
assert set(s.keys()) == { assert set(s.keys()) == {
"name", "name",
"level", "level",
@@ -156,6 +157,7 @@ def test_area_snapshot_existing_keys_unchanged() -> None:
"harmless", "harmless",
"area", "area",
"cast_time", "cast_time",
"components",
} }
+339
View File
@@ -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())