diff --git a/README.md b/README.md index f4b32d7..f8b15f5 100644 --- a/README.md +++ b/README.md @@ -1691,7 +1691,15 @@ Règles modélisées : Large… ; l'allonge effective d'une attaque reste `max(allonge naturelle, 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, - 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), bab (E1), initiative_mod (E2) et initiative_total (U2 : total du jet 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, 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 - 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 (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 — @@ -1844,27 +1854,35 @@ plutôt que bricolées) : 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 inactive ne bloquent pas et ne coûtent pas double). -- **Échec d'incantation pour un personnage sourd hors périmètre** (SRD - « Conditions — Deafened » : « a 20% chance of spell failure when casting - spells with verbal components ») : le moteur ne porte pas la composante - verbale des sorts (`SpellSpec` n'a pas de champ `components` — il - faudrait l'encoder sur les 108 sorts du registre) et ne modélise pas non - plus le jet de Perception DD 20 basé sur le son ni l'échec automatique - associé ; la condition `deafened` reste donc un porteur de modificateurs - (−4 d'initiative) sans la faillibilité d'incantation. Le soin des +- **Composantes des sorts et échecs d'incantation associés — moteur en + place, encodage des données en cours** (SRD « Cast a Spell — 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 » ; + SRD « Arcane Spells and Armor » : « If you cast an arcane spell with a + somatic component while wearing armor, you incur a chance of arcane spell + failure ») : `SpellSpec` porte un champ `components` (liste de tokens + 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é : SRD « Cure Light Wounds » — « this spell deals damage to them instead of 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 — Dismiss a Spell » : « Dismissing an active spell is a standard action 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, sauvegarde, DC de base, exigence de contact à distance `requires_ranged_touch` (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`, `HealEffect`, `BuffEffect`, `AreaEffect` — burst/cône/ligne, S1a, `OneShotEffect` — bonus compétence à usage unique sur un jet d'attaque, de diff --git a/data/monsters/lich.json b/data/monsters/lich.json index 30bf9b5..85b6f1e 100644 --- a/data/monsters/lich.json +++ b/data/monsters/lich.json @@ -27,6 +27,8 @@ "will": 11 }, "casting_ability": "int", + "arcane_caster": true, + "armor_spell_failure": 0, "spell_slots": { "1": 4, "2": 4, diff --git a/data/pcs/oni.json b/data/pcs/oni.json index 0757a2d..6f4b1b0 100644 --- a/data/pcs/oni.json +++ b/data/pcs/oni.json @@ -26,6 +26,8 @@ "will": 9 }, "casting_ability": "wis", + "arcane_caster": false, + "armor_spell_failure": 0, "spell_slots": { "1": 4, "2": 3 diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 826bc3c..5f609b1 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -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 _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 +# 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 # with an attack — only these weapon damage types ride the DR path. _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), "aware": state.combatant.aware, "alignment": state.combatant.alignment, + "armor_spell_failure": state.combatant.armor_spell_failure, + "arcane_caster": state.combatant.arcane_caster, "creature_type": state.combatant.creature_type, "bab": state.combatant.bab, "initiative_mod": state.combatant.initiative_mod, @@ -6536,6 +6542,7 @@ class CombatEngine: "harmless": False, "area": None, "cast_time": "standard", + "components": (), } half_on_save = _spell_half_on_save(spell) return { @@ -6551,6 +6558,7 @@ class CombatEngine: "harmless": spell.harmless, "area": _spell_area_dict(spell), "cast_time": spell.cast_time, + "components": tuple(sorted(spell.components)), } def _cast_spell( @@ -6624,9 +6632,16 @@ class CombatEngine: caster.held_charge = None # PF1e (CRB, Concentration, B24): grappled/defensive/threatened casts # 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 - ): + ) + if interrupted or self._spell_component_failure(caster, spell, spell_name): return None if spell.cast_time == "one_round": # PF1e (SRD "Casting Time"): "A spell that takes 1 round to cast @@ -6734,6 +6749,49 @@ class CombatEngine: return True 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( self, caster: CombatantState, diff --git a/src/pf1e_simulator/loaders/combatant.py b/src/pf1e_simulator/loaders/combatant.py index 8dec731..4d85a8f 100644 --- a/src/pf1e_simulator/loaders/combatant.py +++ b/src/pf1e_simulator/loaders/combatant.py @@ -239,6 +239,9 @@ def load_combatant(path: Path) -> Combatant: ``Combatant`` schema (default ``true``), so it parses directly here. The ``alignment`` code (SRD "Alignment") is a plain string of the schema (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: raw = path.read_text(encoding="utf-8") diff --git a/src/pf1e_simulator/models.py b/src/pf1e_simulator/models.py index 6291b7d..03ab21f 100644 --- a/src/pf1e_simulator/models.py +++ b/src/pf1e_simulator/models.py @@ -322,6 +322,16 @@ class Combatant(_FrozenModel): metamagic_feats: list[str] = Field(default_factory=list) # e.g. Empower/Maximize/Extend Spell spells: list[str] = Field(default_factory=list) 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) immunities: tuple[str, ...] = () # PF1e (SRD "Surprise"): "If some but not all of the combatants are aware diff --git a/src/pf1e_simulator/spells.py b/src/pf1e_simulator/spells.py index 7a3497e..2438643 100644 --- a/src/pf1e_simulator/spells.py +++ b/src/pf1e_simulator/spells.py @@ -462,6 +462,14 @@ class SpellSpec: # 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 + # 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, ...] = () description: str = "" @@ -572,6 +580,38 @@ def _is_cast_time(value: str) -> TypeGuard[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. _CAST_TIME_ALIASES: dict[str, CastTime] = { "1 round": "one_round", @@ -983,6 +1023,10 @@ def load_spell(path: str | Path) -> SpellSpec: msg = f"unknown save type: {save_raw!r}" raise ValueError(msg) 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( name=_as_str(data["name"]), level=_as_int(data["level"]), @@ -994,6 +1038,7 @@ def load_spell(path: str | Path) -> SpellSpec: cast_time=cast_time, spell_resistance=bool(data.get("spell_resistance", True)), harmless=bool(data.get("harmless", False)), + components=components, effects=effects, description=_as_str(data.get("description", "")), ) diff --git a/tests/test_observation_enriched.py b/tests/test_observation_enriched.py index f0d3313..8d54dfb 100644 --- a/tests/test_observation_enriched.py +++ b/tests/test_observation_enriched.py @@ -534,6 +534,7 @@ def test_observation_enriched_spells_unknown_graceful() -> None: "harmless": False, "area": None, "cast_time": "standard", + "components": (), }, ) diff --git a/tests/test_spell_area_snapshot.py b/tests/test_spell_area_snapshot.py index 76f9461..4801ed4 100644 --- a/tests/test_spell_area_snapshot.py +++ b/tests/test_spell_area_snapshot.py @@ -143,6 +143,7 @@ def test_area_snapshot_existing_keys_unchanged() -> None: assert s["save_negates"] is False assert s["harmless"] is False assert s["cast_time"] == "standard" + assert s["components"] == () assert set(s.keys()) == { "name", "level", @@ -156,6 +157,7 @@ def test_area_snapshot_existing_keys_unchanged() -> None: "harmless", "area", "cast_time", + "components", } diff --git a/tests/test_spell_failure.py b/tests/test_spell_failure.py new file mode 100644 index 0000000..73d886e --- /dev/null +++ b/tests/test_spell_failure.py @@ -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())