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