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
+68 -10
View File
@@ -317,6 +317,18 @@ _SAVE_DESCRIPTOR_CONDITIONS = frozenset({
"mind_affecting",
})
# SRD "Alignment" axis predicates for the attacker-alignment modifier
# conditions (protection from evil/law/good/chaos: "+2 deflection bonus to AC
# and a +2 resistance bonus on saves against attacks or effects created by
# evil creatures"). Evaluated against the optional ``attacker`` context;
# without a source in scope the condition is False.
_ATTACKER_ALIGNMENT_CONDITIONS: dict[str, Callable[[Combatant], bool]] = {
"attacker_is_evil": lambda c: c.is_evil,
"attacker_is_lawful": lambda c: c.is_lawful,
"attacker_is_good": lambda c: c.is_good,
"attacker_is_chaotic": lambda c: c.is_chaotic,
}
_SIZE_MODIFIERS: dict[str, int] = {
"fine": -8, "diminutive": -4, "tiny": -2, "small": -1,
@@ -2216,13 +2228,20 @@ class CombatEngine:
weapon: AttackSpec | None = None,
dist_ft: int | None = None,
save_descriptors: frozenset[str] = frozenset(),
attacker: CombatantState | None = None,
) -> bool:
"""Evaluate whether a StatModifier's runtime condition holds.
Unknown/unresolvable conditions return False (modifier excluded).
Supported: "target_within_30ft_ranged" (ranged-style weapon, <= 30 ft),
"axe" (weapon name contains "axe"), and save-descriptor names (fear,
cold, ...) that are present in the save's descriptor set.
"axe" (weapon name contains "axe"), save-descriptor names (fear,
cold, ...) that are present in the save's descriptor set, and the
attacker-alignment names "attacker_is_evil" / "attacker_is_lawful" /
"attacker_is_good" / "attacker_is_chaotic" (SRD protection from evil/
law/good/chaos: "+2 deflection bonus to AC and a +2 resistance bonus
on saves against attacks or effects created by evil creatures" - the
bonus checks the ALIGNMENT of the attacker/effect source, passed as
``attacker``; without a source in scope the condition is False).
"""
if condition_name == "target_within_30ft_ranged":
return (
@@ -2235,6 +2254,9 @@ class CombatEngine:
return weapon is not None and "axe" in weapon.name.lower()
if condition_name in _SAVE_DESCRIPTOR_CONDITIONS:
return condition_name in save_descriptors
alignment_holds = _ATTACKER_ALIGNMENT_CONDITIONS.get(condition_name)
if alignment_holds is not None:
return attacker is not None and alignment_holds(attacker.combatant)
return False
def _get_speed_mult(self, state: CombatantState) -> float:
@@ -2834,6 +2856,7 @@ class CombatEngine:
weapon: AttackSpec | None = None,
dist_ft: int | None = None,
save_descriptors: frozenset[str] = frozenset(),
attacker: CombatantState | None = None,
) -> list[StatModifier]:
"""Collect all StatModifiers for ``target`` from all sources.
@@ -2846,8 +2869,11 @@ class CombatEngine:
Modifiers whose ``condition`` is set are only included when the named
runtime condition holds for ``state`` (evaluated via
``_modifier_condition_holds``, which consumes the weapon/range/save
context threaded from the action being resolved). Unsupported
condition names are treated as NOT holding (modifier excluded).
context threaded from the action being resolved). ``attacker`` is the
source of the attack/effect being resolved (the alignment context for
the "attacker_is_*" conditions); None when no source is in scope.
Unsupported condition names are treated as NOT holding (modifier
excluded).
"""
mods: list[StatModifier] = []
# Old effects field (backwards compatibility)
@@ -2885,6 +2911,7 @@ class CombatEngine:
weapon=weapon,
dist_ft=dist_ft,
save_descriptors=save_descriptors,
attacker=attacker,
)
]
@@ -2977,7 +3004,11 @@ class CombatEngine:
if dist_ft > self._FEAR_AURA_RANGE_FT:
continue
save = self.resolve_save(
enemy, "will", self._FEAR_AURA_DC, save_descriptors=frozenset({"fear"})
enemy,
"will",
self._FEAR_AURA_DC,
save_descriptors=frozenset({"fear"}),
attacker=state,
)
if save.success:
self._log(
@@ -3021,10 +3052,19 @@ class CombatEngine:
return max(1, attacker.combatant.bab // 4 + 1)
def _attack_ac_mods(
self, target: CombatantState, weapon: AttackSpec
self,
target: CombatantState,
weapon: AttackSpec,
*,
attacker: CombatantState | None = None,
) -> list[StatModifier]:
"""AC modifiers for one attack — the weapon-kind-dependent ones included.
``attacker`` is the source of the attack, threaded to
``_stat_modifiers`` so alignment-gated AC modifiers (SRD protection
from evil/law/good/chaos: "+2 deflection bonus to AC ... against
attacks made ... by evil creatures") can evaluate their condition.
PF1e (CRB): a prone defender takes -4 AC against melee attacks but
gains +4 AC against ranged attacks; a helpless defender (paralyzed,
asleep, unconscious, helpless) has an effective Dexterity score of 0,
@@ -3033,7 +3073,7 @@ class CombatEngine:
so the mod is exactly -5 there; on the touch base the stored Dex
modifier is stripped at the base (see resolve_attack) before this
-5 is added."""
mods = self._stat_modifiers(target, "ac")
mods = self._stat_modifiers(target, "ac", attacker=attacker)
if self._has_condition_named(target, "prone"):
mods.append(
StatModifier(
@@ -3140,7 +3180,7 @@ class CombatEngine:
base_ac = target.combatant.ac.touch
else:
base_ac = full_ac_base
ac_mods = self._attack_ac_mods(target, weapon)
ac_mods = self._attack_ac_mods(target, weapon, attacker=attacker)
ac = base_ac + resolve_modifiers(ac_mods)
ac += target.defensive_ac_bonus
ac += target.aid_ac_bonus
@@ -3617,6 +3657,7 @@ class CombatEngine:
*,
save_descriptors: frozenset[str] = frozenset(),
flat_bonus: int = 0,
attacker: CombatantState | None = None,
) -> SaveResult:
"""Roll a saving throw (fort/ref/will) against ``dc``.
@@ -3628,11 +3669,16 @@ class CombatEngine:
``flat_bonus`` is an untyped situational bonus added to the total
(used for the cover bonus on Reflex saves, CRB "Cover and Reflex
Saves"); natural 1/20 still override the roll regardless of it.
``attacker`` is the creature that created the effect being saved
against (the alignment context for the "attacker_is_*" conditions,
SRD protection from evil: "+2 resistance bonus on saves ... against
... effects created by evil creatures"); None when no source is in
scope.
"""
roll = self._rng.d20()
base = getattr(state.combatant.saves, save_type)
save_mods = self._stat_modifiers(
state, save_type, save_descriptors=save_descriptors
state, save_type, save_descriptors=save_descriptors, attacker=attacker
)
# PF1e one-shot save bonus (SRD spells/guidance.md, save track): the
# first save of the bearer discharges it (stacked by bonus type).
@@ -7024,7 +7070,11 @@ class CombatEngine:
dc = self._spell_dc(caster, spell, slot_level=slot_level, metamagic=metamagic)
save_result = (
self.resolve_save(
target, spell.save, dc, save_descriptors=spell_save_descriptors(spell)
target,
spell.save,
dc,
save_descriptors=spell_save_descriptors(spell),
attacker=caster,
)
if spell.save != "none" and target is not None
and not _spell_centered_on_caster(spell)
@@ -7791,6 +7841,7 @@ class CombatEngine:
affected_state, None if effect.centered_on_caster else target,
effect, saved=saved, dc=dc,
save_type=save_type, origin_cells=origin_cells,
attacker=caster,
)
for sub_effect in effect.effects:
target_live = self._stats[affected_state.combatant.id]
@@ -7912,6 +7963,7 @@ class CombatEngine:
dc: int = 15,
save_type: SaveType = "none",
origin_cells: frozenset[Pos] = frozenset(),
attacker: CombatantState | None = None,
) -> bool:
"""Save result for one creature in an area effect.
@@ -7923,6 +7975,11 @@ class CombatEngine:
other creature in the area - and every creature of a point-targeted
burst (no primary target, U7b) - rolls its own.
``attacker`` is the caster who created the area effect, threaded to
``resolve_save`` so alignment-gated save bonuses (SRD protection from
evil: "+2 resistance bonus on saves ... against ... effects created by
evil creatures") can evaluate their condition.
PF1e (CRB "Cover and Reflex Saves"): cover grants a Reflex bonus
against effects that burst from a point on the other side of the
cover - half the AC value (+1 partial / +2 standard / +4 improved);
@@ -7949,6 +8006,7 @@ class CombatEngine:
dc,
save_descriptors=save_descriptors_for_effects(effect.effects),
flat_bonus=flat_bonus,
attacker=attacker,
)
return save_result.success