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:
@@ -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
|
||||
Reference in New Issue
Block a user