Files
pf1e-simulator/tests/test_mage_armor.py
T
ctan 3e19d2974e 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).
2026-09-16 15:36:43 +02:00

123 lines
4.9 KiB
Python

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