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
+3 -3
View File
@@ -476,9 +476,9 @@ Règles modélisées :
(seule `default_policy` s'en préserve : `_choose_spell_action` ne sélectionne (seule `default_policy` s'en préserve : `_choose_spell_action` ne sélectionne
jamais un sort à temps long). Le moteur résout le code via sa registry jamais un sort à temps long). Le moteur résout le code via sa registry
(`_cast_time_of`, repli `standard` pour un nom inconnu) et le passe aux (`_cast_time_of`, repli `standard` pour un nom inconnu) et le passe aux
filtres purs sous forme de résolveur `CastTimeResolver`. Les 107 sorts filtres purs sous forme de résolveur `CastTimeResolver`. Les 108 sorts
importés de `data/spells/` déclarent tous leur `cast_time` (codes normalisés, importés de `data/spells/` déclarent tous leur `cast_time` (codes normalisés,
backfillés depuis le SRD) : 95 `standard`, 6 `one_round` (Enhance Water, backfillés depuis le SRD) : 96 `standard`, 6 `one_round` (Enhance Water,
Silence, Sleep, Summon Instrument, Summon Monster I/II), 2 `immediate` Silence, Sleep, Summon Instrument, Summon Monster I/II), 2 `immediate`
(Grasp, Saving Finale) et 4 `long` (Make Whole, Masterwork Transformation, (Grasp, Saving Finale) et 4 `long` (Make Whole, Masterwork Transformation,
Mending, Restoration Lesser). Hors du périmètre (hook futur, commenté dans Mending, Restoration Lesser). Hors du périmètre (hook futur, commenté dans
@@ -1724,7 +1724,7 @@ uv run basedpyright src # mode strict
## Feuille de route ## Feuille de route
- **Phase 1** — magie et états : largement réalisée. Sorts par emplacements - **Phase 1** — magie et états : largement réalisée. Sorts par emplacements
(`spells.py` + base `data/spells/`, 107 sorts), DD et sauvegardes, RD non (`spells.py` + base `data/spells/`, 108 sorts), DD et sauvegardes, RD non
appliquée aux dégâts de sorts, concentration par situation, invocation, appliquée aux dégâts de sorts, concentration par situation, invocation,
conditions effectives (`conditions.py`), manœuvres de combat, dons (55 noms conditions effectives (`conditions.py`), manœuvres de combat, dons (55 noms
résolus, `abilities.py` + chargeur) et capacités de classe (channel energy, résolus, `abilities.py` + chargeur) et capacités de classe (channel energy,
+20
View File
@@ -0,0 +1,20 @@
{
"name": "Mage Armor",
"level": 1,
"school": "conjuration",
"range": "touch",
"save": "none",
"base_dc": 10,
"cast_time": "standard",
"spell_resistance": false,
"effects": [
{
"type": "buff",
"modifiers": [
{ "target": "ac", "value": 4, "bonus_type": "armor" }
],
"duration_rounds": 600
}
],
"description": "An invisible but tangible field of force surrounds the subject, providing a +4 armor bonus to AC (SRD mage armor: school conjuration (creation) [force], range touch, duration 1 hour/level (modeled as a fixed 600 rounds), spell resistance: no; Will negates (harmless) is not modeled - the engine only self/target-buffs willing recipients). Known simplification: the engine bakes worn armor into the AC total of the JSON and cannot see it, so this armor bonus stacks additively with that baked-in armor value instead of replacing it; two mage armors still do not stack with each other (armor is a non-stacking bonus type)."
}
+3 -2
View File
@@ -3,8 +3,8 @@
PF1e bonus types determine whether multiple bonuses to the same stat stack: PF1e bonus types determine whether multiple bonuses to the same stat stack:
- Stacking types (dodge, racial, trait, untyped/"") all sum together. - Stacking types (dodge, racial, trait, untyped/"") all sum together.
- Non-stacking types (morale, sacred, profane, competence, insight, luck, - Non-stacking types (morale, sacred, profane, competence, insight, luck,
enhancement, deflection, natural, shield) keep only the highest value per enhancement, deflection, natural, shield, armor) keep only the highest
type. value per type.
- Penalties follow the same rules: same-type penalties don't stack (the - Penalties follow the same rules: same-type penalties don't stack (the
least severe — i.e. the max value — is kept). least severe — i.e. the max value — is kept).
@@ -38,6 +38,7 @@ BonusType = Literal[
"deflection", # no stack "deflection", # no stack
"natural", # no stack "natural", # no stack
"shield", # no stack "shield", # no stack
"armor", # no stack (SRD mage armor: "+4 armor bonus to AC")
"circumstance", # no stack (simulator can't distinguish circumstances) "circumstance", # no stack (simulator can't distinguish circumstances)
] ]
+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" ONI = ROOT / "data" / "pcs" / "oni.json"
GOBLIN = ROOT / "data" / "monsters" / "goblin.json" GOBLIN = ROOT / "data" / "monsters" / "goblin.json"
_NUM_SPELLS = 107 _NUM_SPELLS = 108
def test_runner_loads_realtime_spell_registry() -> None: 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) registry = load_spell_registry(SPELLS_DIR)
assert len(registry) == _NUM_SPELLS 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) Given: the registry directory with the 11 original spells plus batches 1-2 (75)
When: load_spell_registry / load_spell is called When: load_spell_registry / load_spell is called
Then: every batch-3 spell loads with the expected level/school/range/save, 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 schema (parsing raises on any unknown field value or condition), and the
registry covers EVERY spell name referenced by data/pcs/*.json (no missing registry covers EVERY spell name referenced by data/pcs/*.json (no missing
spell remains). spell remains).
@@ -113,9 +114,11 @@ class TestBatch3Loading:
assert spell.base_dc == 10 assert spell.base_dc == 10
assert spell.description assert spell.description
def test_registry_reaches_107(self) -> None: def test_registry_reaches_108(self) -> None:
reg = load_spell_registry(_SPELLS_DIR) 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) assert set(_BATCH3) <= set(reg)
def test_every_effect_validates(self) -> None: def test_every_effect_validates(self) -> None:
+5 -4
View File
@@ -129,13 +129,13 @@ def _make_engine(
class TestSpellLoading: 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 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: def test_loads_all_10_spells(self) -> None:
reg = load_spell_registry(_SPELLS_DIR) reg = load_spell_registry(_SPELLS_DIR)
assert len(reg) == 107 assert len(reg) == 108
def test_spell_names_present(self) -> None: def test_spell_names_present(self) -> None:
reg = load_spell_registry(_SPELLS_DIR) reg = load_spell_registry(_SPELLS_DIR)
@@ -144,9 +144,10 @@ class TestSpellLoading:
"Cure Light Wounds", "Cure Moderate Wounds", "Hold Person", "Cure Light Wounds", "Cure Moderate Wounds", "Hold Person",
"Fear", "Bull's Strength", "Acid Arrow", "Fear", "Bull's Strength", "Acid Arrow",
"Summon Monster II", "Summon Monster II",
"Mage Armor",
} }
assert expected <= set(reg) assert expected <= set(reg)
assert len(reg) == 107 assert len(reg) == 108
def test_fireball_fields(self) -> None: def test_fireball_fields(self) -> None:
spell = load_spell(_SPELLS_DIR / "fireball.json") spell = load_spell(_SPELLS_DIR / "fireball.json")