- arcane_caster true: misty (Magus/Bard sheet class), nairda (Witch/
Sorcerer), thrune_agent (Human magus 8, KB bestiary/thrune-agent.md);
lich already true. False: oni (Cleric), tammara (Gunslinger/
Warpriest - divine), esha (Cavalier/Rogue, spell-like only), the
companions harvie/ierlieth/jeanne/esha_mount and the remaining
monsters.
- armor_spell_failure stays 0 everywhere, as the effective PF1e value:
nairda and the lich wear no armor (sheet note "Pas d'armure", KB lich
block); misty's mithral shirt (RAW ASF 10%, magic-items/mithral-shirt
.md) and the thrune agent's +1 studded leather (15%) are light armor
and every spell they cast is on their own class list - magus and bard
cast their own spells in light armor with no arcane spell failure
(classes/magus.md, classes/bard.md).
- tests/test_spell_failure.py: the lich/oni smoke test becomes a
full-data table pinning all 15 combatants' flags with per-entry
sources. No shipped combatant incurs a non-zero ASF; the failure roll
itself stays covered by the synthetic armored-caster tests.
- 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.
Adds the PF1e two-axis alignment code (LG/NG/CG/LN/N/CN/LE/NE/CE,
default N) to the Combatant model with a strict validator, derived
is_lawful/is_chaotic/is_good/is_evil properties, exposure in the
observation snapshot, and alignment values for all monsters and PCs
sourced from the bestiary KB and the Foundry sheets.
Verified against the KB (bestiary/lich.md, Bestiary Lich CR 12):
- bab 8 -> 5 ('Base Atk +5')
- Paralyzing Touch attack_bonus 10 -> 5 ('Melee touch +5 (1d8+5 plus
paralyzing touch)') and marked natural: true (template: 'A lich has a
touch attack that it can use once per round as a natural weapon [...]
treated as a primary natural weapon' - no BAB iteratives per e5691bd)
- damage_bonus 5 -> 0: the +5 (1 point per 2 HD) is already in the
1d8+5 formula and _resolve_swing adds damage_bonus on top (double
counting: the old data dealt 1d8+10)
- ac 25/14/23 -> 23/14/21 ('AC 23, touch 14, flat-footed 21'; touch was
already correct)
- saves 10/10/14 -> 6/7/11 ('Fort +6, Ref +7, Will +11')
- con_score 14 -> 10: 'Being undead, a lich has no Constitution score'
(Con '—'); the int-only schema encodes 'no score' as the neutral 10
(modifier +0), inert anyway since undead are destroyed at hp <= 0.
Left as intentional engine tuning (residual divergences, documented in
the final report): hp 100 vs 111, Str 12 vs 10, Int 20 vs 22,
initiative +6 vs +2, fear aura 30 ft/DC 22 vs 60 ft/DC 18.
Golden transcript untouched (the lich wins via Magic Missile before any
weapon swing; MM DC depends on Int, unchanged). Pinned a new
test_json_matches_srd_stat_block.
PF1e (CRB "Natural Attacks"):
- Natural weapons (natural=true) get exactly one swing per full attack,
regardless of BAB (no BAB iteratives for natural attacks).
- Secondary natural attacks (secondary=true) attack at BAB -5; the
Multiattack feat reduces this to -2.
- Manifested in combat.py (_full_attack_weapon, _resolve_swing),
models.py (AttackSpec.natural/secondary), 6 data JSONs, 4 new tests,
and README documentation.
1461 passed, ruff clean, basedpyright 0 errors.