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:
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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", "")),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user