feat(rules): SRD Mage Armor spell + non-stacking armor bonus type

SRD spells/mage-armor.md: 'An invisible but tangible field of force
surrounds the subject of a mage armor spell, providing a +4 armor bonus
to AC' - 'School conjuration (creation) [force]', level 1, 'Casting Time
1 standard action', 'Range touch', 'Duration 1 hour/level (D)',
'Spell Resistance no'.

- data/spells/mage_armor.json: buff +4 armor to AC, 600 rounds (1
  hour/level modeled fixed, the shield.json convention). Description
  documents the known simplification: the engine bakes worn armor into
  the AC total and cannot see it, so the armor bonus stacks additively
  with that baked value (two mage armors still do not stack).
- effects.py: 'armor' added to BonusType; absent from _STACKING_TYPES it
  behaves as a keep-the-highest type (verified by resolve_modifiers
  tests).
- Spell-count pins 107 -> 108 (test_runner_spells, batch3, test_spells)
  and the README references.
- Golden transcript untouched (buff branch only fires when no offensive
  spell is selected; the lich still opens with Magic Missile).
This commit is contained in:
2026-09-16 15:36:43 +02:00
parent 881f2ef9b2
commit 3e19d2974e
7 changed files with 161 additions and 14 deletions
+122
View File
@@ -0,0 +1,122 @@
"""SRD Mage Armor and the "armor" bonus type (Task 10 conformance).
SRD "Mage Armor" (spells/mage-armor.md): "School conjuration (creation)
[force]", "Casting Time 1 standard action", "Range touch", "Duration 1
hour/level (D)", "Spell Resistance no", and "An invisible but tangible field
of force surrounds the subject of a mage armor spell, providing a +4 armor
bonus to AC." The armor bonus type is non-stacking (PF1e stacking rules:
bonuses of the same type do not stack), and the engine bakes worn armor into
the AC total of the JSON, so the buff adds on top of that baked value
(documented simplification).
"""
from __future__ import annotations
from pathlib import Path
from pf1e_simulator.effects import StatModifier, resolve_modifiers
from pf1e_simulator.spells import BuffEffect, SpellSpec, load_spell
from tests.test_spells import _make_caster, _make_engine, _make_target
_SPELLS_DIR = Path(__file__).resolve().parent.parent / "data" / "spells"
def _mage_armor() -> SpellSpec:
return load_spell(_SPELLS_DIR / "mage_armor.json")
class TestMageArmorLoading:
"""Given: data/spells/mage_armor.json
When: load_spell parses it
Then: every field matches the SRD card."""
def test_srd_fields(self) -> None:
"""Given: the mage armor JSON
When: loaded
Then: level 1, conjuration, touch, save none, standard, SR no."""
spell = _mage_armor()
assert spell.name == "Mage Armor"
assert spell.level == 1
assert spell.school == "conjuration"
assert spell.range == "touch"
assert spell.save == "none"
assert spell.cast_time == "standard"
assert spell.spell_resistance is False
def test_buff_is_a_plus_four_armor_bonus(self) -> None:
"""Given: the loaded spell
When: inspecting its effect
Then: one buff with a +4 armor bonus to AC, 600 rounds (1 hour/level,
modeled fixed per the shield.json convention)."""
(effect,) = _mage_armor().effects
assert isinstance(effect, BuffEffect)
assert effect.duration_rounds == 600
(mod,) = effect.modifiers
assert mod.target == "ac"
assert mod.value == 4
assert mod.bonus_type == "armor"
class TestArmorBonusStacking:
"""Given: the armor bonus type
When: resolve_modifiers applies the stacking rules
Then: armor does not stack with itself but stacks with other types."""
def test_two_armor_bonuses_do_not_stack(self) -> None:
"""Given: a +4 and a +6 armor bonus to AC
When: stacking rules apply
Then: only the highest armor bonus counts."""
mods = [
StatModifier(target="ac", value=4, bonus_type="armor"),
StatModifier(target="ac", value=6, bonus_type="armor"),
]
assert resolve_modifiers(mods) == 6
def test_armor_stacks_with_other_types(self) -> None:
"""Given: armor +4 with shield +4, dodge +4 and an untyped +2
When: stacking rules apply
Then: all four sources sum (different types stack)."""
mods = [
StatModifier(target="ac", value=4, bonus_type="armor"),
StatModifier(target="ac", value=4, bonus_type="shield"),
StatModifier(target="ac", value=4, bonus_type="dodge"),
StatModifier(target="ac", value=2, bonus_type=""),
]
assert resolve_modifiers(mods) == 14
class TestMageArmorInEngine:
"""Given: a caster who knows Mage Armor
When: it self-buffs and an enemy attacks
Then: the attack resolves against AC + 4 (additive on the baked-in armor,
documented simplification)."""
def test_mage_armor_raises_ac_by_four(self) -> None:
"""Given: a caster with AC 12 and a hostile attacker
When: Mage Armor is cast on self, then the enemy swings
Then: the swing resolves against AC 16 instead of 12."""
caster = _make_caster().model_copy(
update={"spells": ["Mage Armor"], "spell_slots": {1: 1}}
)
attacker = _make_target()
engine, c, t = _make_engine(caster, attacker, [1, 1])
before = engine.resolve_attack(t, c, t.combatant.attacks[0])
assert before.ac == 12
engine._cast_spell(c, c, "Mage Armor")
assert any(b.source.startswith("spell:") for b in c.active_buffs)
after = engine.resolve_attack(t, c, t.combatant.attacks[0])
assert after.ac == 16
def test_two_casts_do_not_double_the_bonus(self) -> None:
"""Given: the same caster with two level-1 slots
When: Mage Armor is cast twice on self
Then: the armor bonus stays +4 (non-stacking type)."""
caster = _make_caster().model_copy(
update={"spells": ["Mage Armor"], "spell_slots": {1: 2}}
)
attacker = _make_target()
engine, c, t = _make_engine(caster, attacker, [1])
engine._cast_spell(c, c, "Mage Armor")
engine._cast_spell(c, c, "Mage Armor")
result = engine.resolve_attack(t, c, t.combatant.attacks[0])
assert result.ac == 16
+2 -2
View File
@@ -24,11 +24,11 @@ MAP = ROOT / "data" / "maps" / "sample_arena.yaml"
ONI = ROOT / "data" / "pcs" / "oni.json"
GOBLIN = ROOT / "data" / "monsters" / "goblin.json"
_NUM_SPELLS = 107
_NUM_SPELLS = 108
def test_runner_loads_realtime_spell_registry() -> None:
"""The runner's registry source holds all 107 real spells from data/spells/."""
"""The runner's registry source holds all 108 real spells from data/spells/."""
registry = load_spell_registry(SPELLS_DIR)
assert len(registry) == _NUM_SPELLS
+6 -3
View File
@@ -3,7 +3,8 @@
Given: the registry directory with the 11 original spells plus batches 1-2 (75)
When: load_spell_registry / load_spell is called
Then: every batch-3 spell loads with the expected level/school/range/save,
the registry reaches exactly 107 (11 + 96), every effect validates against the
the registry reaches exactly 108 (11 + 96 + the SRD-conformance mage armor),
every effect validates against the
schema (parsing raises on any unknown field value or condition), and the
registry covers EVERY spell name referenced by data/pcs/*.json (no missing
spell remains).
@@ -113,9 +114,11 @@ class TestBatch3Loading:
assert spell.base_dc == 10
assert spell.description
def test_registry_reaches_107(self) -> None:
def test_registry_reaches_108(self) -> None:
reg = load_spell_registry(_SPELLS_DIR)
assert len(reg) == _BATCH2_COUNT + 32 == 107
# +1: mage_armor.json, added by the SRD conformance pass (not a
# batch-3 PC-sheet spell).
assert len(reg) == _BATCH2_COUNT + 32 + 1 == 108
assert set(_BATCH3) <= set(reg)
def test_every_effect_validates(self) -> None:
+5 -4
View File
@@ -129,13 +129,13 @@ def _make_engine(
class TestSpellLoading:
"""Given: data/spells/ directory with 107 JSON spell files
"""Given: data/spells/ directory with 108 JSON spell files
When: load_spell_registry is called
Then: all 107 spells are loaded with correct fields."""
Then: all 108 spells are loaded with correct fields."""
def test_loads_all_10_spells(self) -> None:
reg = load_spell_registry(_SPELLS_DIR)
assert len(reg) == 107
assert len(reg) == 108
def test_spell_names_present(self) -> None:
reg = load_spell_registry(_SPELLS_DIR)
@@ -144,9 +144,10 @@ class TestSpellLoading:
"Cure Light Wounds", "Cure Moderate Wounds", "Hold Person",
"Fear", "Bull's Strength", "Acid Arrow",
"Summon Monster II",
"Mage Armor",
}
assert expected <= set(reg)
assert len(reg) == 107
assert len(reg) == 108
def test_fireball_fields(self) -> None:
spell = load_spell(_SPELLS_DIR / "fireball.json")