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