feat(rules): alignment-gated protection from evil/law wards via attacker context

Add attacker-alignment modifier conditions (attacker_is_evil/lawful/good/
chaotic) evaluated against an optional attacker context threaded through
_stat_modifiers/_modifier_condition_holds, _attack_ac_mods (resolve_attack
AC path), resolve_save and _area_save (spell save paths), plus the fear
aura. Without a source in scope the condition is False, so existing rolls
are untouched (golden transcripts bit-identical).

Give the four protection_from_evil/law(+communal) JSONs their SRD buff:
+2 deflection to AC and +2 resistance on fort/ref/will, gated on the
attacker/caster alignment (SRD: bonuses apply 'against attacks or effects
created by evil creatures'); duration 1 min/level approximated as 10
rounds (engine convention). Descriptions document the unmodeled mental-
control suppression and communal duration-split clauses. Level/range/
save/SR verified against the KB.
This commit is contained in:
2026-09-20 12:57:29 +02:00
parent 6a18d456f6
commit 3be22b9c55
8 changed files with 452 additions and 22 deletions
+129 -1
View File
@@ -39,7 +39,7 @@ from pf1e_simulator.abilities import (
)
from pf1e_simulator.combat import CombatantState, CombatEngine, SaveResult
from pf1e_simulator.dice import parse_dice
from pf1e_simulator.effects import StatModifier
from pf1e_simulator.effects import ActiveBuff, StatModifier
from pf1e_simulator.grid import Grid
from pf1e_simulator.map import MapSpec, TerrainType
from pf1e_simulator.models import (
@@ -508,3 +508,131 @@ def test_spell_save_descriptors_derivation(spell_name: str, expected: frozenset[
"""Damage types, fear conditions and school tags map to save descriptors."""
reg = load_spell_registry(_SPELLS_DIR)
assert spell_save_descriptors(reg[spell_name]) == expected
# ── Attacker-alignment conditions (SRD protection from X) ─────────────────────
def _aligned(cid: str, alignment: str) -> CombatantState:
hero = make_combatant(cid)
combatant = hero.model_copy(update={"alignment": alignment})
return make_state(combatant, "monsters", (5, 5))
@pytest.mark.parametrize(
("condition", "alignment"),
[
("attacker_is_evil", "CE"),
("attacker_is_evil", "LE"),
("attacker_is_evil", "NE"),
("attacker_is_lawful", "LG"),
("attacker_is_lawful", "LN"),
("attacker_is_lawful", "LE"),
("attacker_is_good", "LG"),
("attacker_is_good", "NG"),
("attacker_is_good", "CG"),
("attacker_is_chaotic", "CG"),
("attacker_is_chaotic", "CN"),
("attacker_is_chaotic", "CE"),
],
)
def test_attacker_alignment_condition_vocabulary_holds(
condition: str, alignment: str
) -> None:
"""The attacker_is_* names hold when the attacker's axis matches."""
hero = make_combatant("hero")
h = make_state(hero, "players", (0, 0))
engine = make_engine([1], [h])
attacker = _aligned("attacker", alignment)
assert engine._modifier_condition_holds(h, condition, attacker=attacker) is True
@pytest.mark.parametrize(
("condition", "alignment"),
[
("attacker_is_evil", "LG"),
("attacker_is_evil", "N"),
("attacker_is_lawful", "CE"),
("attacker_is_lawful", "N"),
("attacker_is_good", "NE"),
("attacker_is_good", "N"),
("attacker_is_chaotic", "LN"),
("attacker_is_chaotic", "N"),
],
)
def test_attacker_alignment_condition_vocabulary_fails(
condition: str, alignment: str
) -> None:
"""The attacker_is_* names fail when the attacker's axis does not match."""
hero = make_combatant("hero")
h = make_state(hero, "players", (0, 0))
engine = make_engine([1], [h])
attacker = _aligned("attacker", alignment)
assert engine._modifier_condition_holds(h, condition, attacker=attacker) is False
def test_attacker_alignment_condition_false_without_attacker() -> None:
"""No attacker in scope -> the alignment condition is False (modifier excluded)."""
hero = make_combatant("hero")
h = make_state(hero, "players", (0, 0))
engine = make_engine([1], [h])
for condition in (
"attacker_is_evil",
"attacker_is_lawful",
"attacker_is_good",
"attacker_is_chaotic",
):
assert engine._modifier_condition_holds(h, condition) is False
def test_attacker_alignment_condition_on_attack_ac() -> None:
"""End-to-end: an attacker_is_evil AC modifier applies only vs an evil attacker."""
hero = make_combatant("hero", ac_total=16)
h = make_state(hero, "players", (1, 1))
h.active_buffs.append(
ActiveBuff(
modifiers=(
StatModifier(target="ac", value=2, bonus_type="deflection",
condition="attacker_is_evil"),
),
rounds_left=10,
source="spell: probe",
)
)
evil = make_combatant("evil", hp=40)
e = evil.model_copy(update={"alignment": "CE"})
es = make_state(e, "monsters", (1, 2))
neutral = make_combatant("neutral", hp=40)
ns = make_state(neutral, "monsters", (1, 2))
engine = make_engine([2, 2], [h, es, ns])
evil_result = engine.resolve_attack(es, h, evil.attacks[0])
neutral_result = engine.resolve_attack(ns, h, neutral.attacks[0])
assert evil_result.ac == 18 # 16 + 2 deflection vs the evil attacker
assert neutral_result.ac == 16 # no bonus against a neutral attacker
def test_attacker_alignment_condition_on_save() -> None:
"""End-to-end: an attacker_is_lawful save modifier applies only vs a lawful source."""
hero = make_combatant("hero")
h = make_state(hero, "players", (0, 0))
h.active_buffs.append(
ActiveBuff(
modifiers=(
StatModifier(target="will", value=2, bonus_type="resistance",
condition="attacker_is_lawful"),
),
rounds_left=10,
source="spell: probe",
)
)
lawful = make_combatant("lawful")
ls = make_state(lawful.model_copy(update={"alignment": "LG"}), "monsters", (5, 5))
chaotic = make_combatant("chaotic")
cs = make_state(chaotic.model_copy(update={"alignment": "CE"}), "monsters", (5, 5))
engine = make_engine([10, 10, 10], [h])
vs_lawful = engine.resolve_save(h, "will", 25, attacker=ls)
vs_chaotic = engine.resolve_save(h, "will", 25, attacker=cs)
no_source = engine.resolve_save(h, "will", 25)
assert vs_lawful.total == 12 # 10 + 2 resistance vs the lawful source
assert vs_chaotic.total == 10 # CE is not lawful
assert no_source.total == 10 # no attacker in scope -> condition False