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