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