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,
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