diff --git a/README.md b/README.md index 3c202e1..7fcf65c 100644 --- a/README.md +++ b/README.md @@ -476,9 +476,9 @@ Règles modélisées : (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 (`_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, - 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` (Grasp, Saving Finale) et 4 `long` (Make Whole, Masterwork Transformation, 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 - **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, conditions effectives (`conditions.py`), manœuvres de combat, dons (55 noms résolus, `abilities.py` + chargeur) et capacités de classe (channel energy, diff --git a/data/spells/mage_armor.json b/data/spells/mage_armor.json new file mode 100644 index 0000000..74c3ed8 --- /dev/null +++ b/data/spells/mage_armor.json @@ -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)." +} diff --git a/src/pf1e_simulator/effects.py b/src/pf1e_simulator/effects.py index c28fd57..0990a25 100644 --- a/src/pf1e_simulator/effects.py +++ b/src/pf1e_simulator/effects.py @@ -3,8 +3,8 @@ PF1e bonus types determine whether multiple bonuses to the same stat stack: - Stacking types (dodge, racial, trait, untyped/"") all sum together. - Non-stacking types (morale, sacred, profane, competence, insight, luck, - enhancement, deflection, natural, shield) keep only the highest value per - type. + enhancement, deflection, natural, shield, armor) keep only the highest + value per type. - Penalties follow the same rules: same-type penalties don't stack (the least severe — i.e. the max value — is kept). @@ -38,6 +38,7 @@ BonusType = Literal[ "deflection", # no stack "natural", # no stack "shield", # no stack + "armor", # no stack (SRD mage armor: "+4 armor bonus to AC") "circumstance", # no stack (simulator can't distinguish circumstances) ] diff --git a/tests/test_mage_armor.py b/tests/test_mage_armor.py new file mode 100644 index 0000000..03b9021 --- /dev/null +++ b/tests/test_mage_armor.py @@ -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 diff --git a/tests/test_runner_spells.py b/tests/test_runner_spells.py index e7ae480..f24fe1e 100644 --- a/tests/test_runner_spells.py +++ b/tests/test_runner_spells.py @@ -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 diff --git a/tests/test_spell_registry_batch3.py b/tests/test_spell_registry_batch3.py index 00d10a6..a5bfd64 100644 --- a/tests/test_spell_registry_batch3.py +++ b/tests/test_spell_registry_batch3.py @@ -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: diff --git a/tests/test_spells.py b/tests/test_spells.py index 84e4986..74f7354 100644 --- a/tests/test_spells.py +++ b/tests/test_spells.py @@ -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")