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:
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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", "")),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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": (),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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