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:
@@ -1035,11 +1035,20 @@ Règles modélisées :
|
||||
- Modificateurs conditionnés : un `StatModifier` avec `condition=` n'est appliqué
|
||||
que si la condition tient au moment de la résolution (`_modifier_condition_holds`) :
|
||||
`target_within_30ft_ranged` (arme à distance à ≤ 30 ft), `axe` (arme dont le nom
|
||||
contient « axe »), et les descripteurs de sauvegarde (`fear`, `cold`,
|
||||
contient « axe »), les descripteurs de sauvegarde (`fear`, `cold`,
|
||||
`electricity`, `fey`, `divination`, `poison`, `disease`, `mind_affecting`) — les
|
||||
descripteurs sont portés par l'aura de terreur, les sorts (types de dégâts,
|
||||
conditions de peur, écoles enchantement/divination) et les effets de zone. Toute
|
||||
condition inconnue ou non supportée reste exclue (False).
|
||||
conditions de peur, écoles enchantement/divination) et les effets de zone — et
|
||||
les conditions d'alignement de la source (`attacker_is_evil`, `attacker_is_lawful`,
|
||||
`attacker_is_good`, `attacker_is_chaotic`) évaluées contre l'attaquant / la
|
||||
créature qui crée l'effet, passé en contexte `attacker` par la résolution
|
||||
d'attaque (CA), les sauvegardes de sorts (chemin ciblé et zone) et l'aura de
|
||||
terreur ; sans source en contexte, la condition est Fausse. C'est ce qui porte
|
||||
les protections alignement-dépendantes du SRD (protection from evil/law :
|
||||
« +2 deflection bonus to AC and a +2 resistance bonus on saves against attacks
|
||||
or effects created by evil creatures » — jamais contre une source qui n'est pas
|
||||
du côté aligné en face). Toute condition inconnue ou non supportée reste exclue
|
||||
(False).
|
||||
- Incantation : chaque `CombatantState` porte `spell_slots_left` (initialisé depuis
|
||||
le champ `spell_slots` du JSON, ex. `{"1": 4, ...}`) — convention de données :
|
||||
`spell_slots` est une valeur **finale** par rencontre et le moteur ne calcule
|
||||
|
||||
@@ -8,6 +8,17 @@
|
||||
"base_dc": 10,
|
||||
"cast_time": "standard",
|
||||
"spell_resistance": false,
|
||||
"effects": [],
|
||||
"description": "A barrier wards the subject from evil for 1 min/level: +2 deflection bonus to AC and +2 resistance bonus on saves against attacks and effects created by evil creatures, renewed saves and suppression of mental control, and lawful summoned evil creatures cannot touch it. Will negates (harmless). The engine has no alignment system, so alignment-restricted bonuses cannot be modeled and the spell has no mechanical effects here."
|
||||
"effects": [
|
||||
{
|
||||
"type": "buff",
|
||||
"modifiers": [
|
||||
{ "target": "ac", "value": 2, "bonus_type": "deflection", "condition": "attacker_is_evil" },
|
||||
{ "target": "fort", "value": 2, "bonus_type": "resistance", "condition": "attacker_is_evil" },
|
||||
{ "target": "ref", "value": 2, "bonus_type": "resistance", "condition": "attacker_is_evil" },
|
||||
{ "target": "will", "value": 2, "bonus_type": "resistance", "condition": "attacker_is_evil" }
|
||||
],
|
||||
"duration_rounds": 10
|
||||
}
|
||||
],
|
||||
"description": "SRD spells/protection-from-evil.md: a barrier wards the subject from evil for 1 min./level. The subject gains a +2 deflection bonus to AC and a +2 resistance bonus on saves - the exact SRD wording: \"+2 deflection bonus to AC and a +2 resistance bonus on saves against attacks or effects created by evil creatures\" - so both bonuses are gated on the attacker/caster alignment (condition attacker_is_evil) and never apply against a creature that is not evil. Will negates (harmless), spell resistance no. Duration 1 min/level approximated as 10 rounds (engine convention for 1-minute durations). Not modeled: the SRD's second effect (immediate renewed saving throw with a +2 morale bonus against mental-control spells and effects, suppression for the duration, immunity to new attempts) - the engine has no mental-control tracking to suppress; and the third effect (evil summoned creatures cannot touch the warded creature) - the engine has no summoned-subtype contact restriction."
|
||||
}
|
||||
|
||||
@@ -8,6 +8,17 @@
|
||||
"base_dc": 10,
|
||||
"cast_time": "standard",
|
||||
"spell_resistance": false,
|
||||
"effects": [],
|
||||
"description": "Functions like protection from evil (+2 deflection to AC and +2 resistance on saves vs evil creatures, protection from mental control), except the 1 min/level duration is divided in 1-minute intervals among the touched creatures. Will negates (harmless). Alignment-restricted bonuses and duration-sharing are not modeled, so the spell has no mechanical effects here."
|
||||
"effects": [
|
||||
{
|
||||
"type": "buff",
|
||||
"modifiers": [
|
||||
{ "target": "ac", "value": 2, "bonus_type": "deflection", "condition": "attacker_is_evil" },
|
||||
{ "target": "fort", "value": 2, "bonus_type": "resistance", "condition": "attacker_is_evil" },
|
||||
{ "target": "ref", "value": 2, "bonus_type": "resistance", "condition": "attacker_is_evil" },
|
||||
{ "target": "will", "value": 2, "bonus_type": "resistance", "condition": "attacker_is_evil" }
|
||||
],
|
||||
"duration_rounds": 10
|
||||
}
|
||||
],
|
||||
"description": "SRD spells/protection-from-evil-communal.md: functions like protection from evil - each touched creature gains the +2 deflection bonus to AC and +2 resistance bonus on saves against attacks or effects created by evil creatures (gated on the attacker/caster alignment via condition attacker_is_evil, never applying against a non-evil source). Will negates (harmless), spell resistance no. Not modeled: the SRD duration split - \"you divide the duration in 1-minute intervals among the creatures touched\" - the engine gives every touched creature the same fixed duration as protection from evil (1 min/level approximated as 10 rounds each) instead of draining one shared pool; and the mental-control suppression clause of the base spell (see protection_from_evil.json)."
|
||||
}
|
||||
|
||||
@@ -8,6 +8,17 @@
|
||||
"base_dc": 10,
|
||||
"cast_time": "standard",
|
||||
"spell_resistance": false,
|
||||
"effects": [],
|
||||
"description": "A barrier wards the subject from lawful creatures for 1 min/level: +2 deflection bonus to AC and +2 resistance bonus on saves against attacks and effects created by lawful creatures, plus renewed saves against mental control by lawful creatures. Will negates (harmless). The engine has no alignment system, so alignment-restricted bonuses cannot be modeled and the spell has no mechanical effects here."
|
||||
"effects": [
|
||||
{
|
||||
"type": "buff",
|
||||
"modifiers": [
|
||||
{ "target": "ac", "value": 2, "bonus_type": "deflection", "condition": "attacker_is_lawful" },
|
||||
{ "target": "fort", "value": 2, "bonus_type": "resistance", "condition": "attacker_is_lawful" },
|
||||
{ "target": "ref", "value": 2, "bonus_type": "resistance", "condition": "attacker_is_lawful" },
|
||||
{ "target": "will", "value": 2, "bonus_type": "resistance", "condition": "attacker_is_lawful" }
|
||||
],
|
||||
"duration_rounds": 10
|
||||
}
|
||||
],
|
||||
"description": "SRD spells/protection-from-law.md: functions like protection from evil, except the deflection and resistance bonuses apply against lawful creatures - \"+2 deflection bonus to AC and a +2 resistance bonus on saves against attacks or effects created by lawful creatures\" - so both bonuses are gated on the attacker/caster alignment (condition attacker_is_lawful) and never apply against a creature that is not lawful. Will negates (harmless), spell resistance no. Duration 1 min/level approximated as 10 rounds (engine convention for 1-minute durations). Not modeled: the renewed saving throw against mental control by lawful creatures and the lawful summoned creature contact restriction (same gaps as protection from evil)."
|
||||
}
|
||||
|
||||
@@ -8,6 +8,17 @@
|
||||
"base_dc": 10,
|
||||
"cast_time": "standard",
|
||||
"spell_resistance": false,
|
||||
"effects": [],
|
||||
"description": "Functions like protection from law (+2 deflection to AC and +2 resistance on saves vs lawful creatures, protection from mental control), except the 1 min/level duration is divided in 1-minute intervals among the touched creatures. Will negates (harmless). Alignment-restricted bonuses and duration-sharing are not modeled, so the spell has no mechanical effects here."
|
||||
"effects": [
|
||||
{
|
||||
"type": "buff",
|
||||
"modifiers": [
|
||||
{ "target": "ac", "value": 2, "bonus_type": "deflection", "condition": "attacker_is_lawful" },
|
||||
{ "target": "fort", "value": 2, "bonus_type": "resistance", "condition": "attacker_is_lawful" },
|
||||
{ "target": "ref", "value": 2, "bonus_type": "resistance", "condition": "attacker_is_lawful" },
|
||||
{ "target": "will", "value": 2, "bonus_type": "resistance", "condition": "attacker_is_lawful" }
|
||||
],
|
||||
"duration_rounds": 10
|
||||
}
|
||||
],
|
||||
"description": "SRD spells/protection-from-law-communal.md: functions like protection from law - each touched creature gains the +2 deflection bonus to AC and +2 resistance bonus on saves against attacks or effects created by lawful creatures (gated on the attacker/caster alignment via condition attacker_is_lawful, never applying against a non-lawful source). Will negates (harmless), spell resistance no. Not modeled: the SRD duration split - \"you divide the duration in 1-minute intervals among the creatures touched\" - the engine gives every touched creature the same fixed duration as protection from law (1 min/level approximated as 10 rounds each) instead of draining one shared pool; and the mental-control suppression clause of the base spell (see protection_from_law.json)."
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
"""Protection from Evil / Law (and Communal variants): alignment-gated wards.
|
||||
|
||||
SRD spells/protection-from-evil.md: "+2 deflection bonus to AC and a +2
|
||||
resistance bonus on saves against attacks or effects created by evil
|
||||
creatures"; spells/protection-from-law.md: same against lawful creatures.
|
||||
The bonuses are carried as condition-gated StatModifiers (attacker_is_evil /
|
||||
attacker_is_lawful) and only apply when the attack/effect source's alignment
|
||||
matches; a neutral (or opposing-axis) source sees no bonus at all.
|
||||
|
||||
Casting tests pin the buff landing on a willing ally (harmless, no save),
|
||||
the AC gate through resolve_attack, the save gate through resolve_save, and
|
||||
the communal variants sharing the exact same effect. Not modeled (documented
|
||||
in the JSON descriptions): the mental-control suppression clause, the
|
||||
summoned-creature contact restriction, and the communal 1-minute-interval
|
||||
duration split.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
|
||||
from pf1e_simulator.combat import CombatEngine
|
||||
from pf1e_simulator.rng import ScriptedRng
|
||||
from pf1e_simulator.spells import BuffEffect, load_spell
|
||||
from tests.test_observation_enriched import make_combatant, make_grid, make_state
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pf1e_simulator.combat import CombatantState
|
||||
from pf1e_simulator.models import Combatant
|
||||
from pf1e_simulator.spells import SpellSpec
|
||||
|
||||
DATA_SPELLS = Path(__file__).resolve().parent.parent / "data" / "spells"
|
||||
|
||||
|
||||
def _loaded(stem: str) -> SpellSpec:
|
||||
return load_spell(DATA_SPELLS / f"{stem}.json")
|
||||
|
||||
|
||||
def _aligned(cid: str, alignment: str, *, hp: int = 40) -> Combatant:
|
||||
return make_combatant(cid, hp=hp).model_copy(update={"alignment": alignment})
|
||||
|
||||
|
||||
def _cast(stem: str, slot_level: int) -> tuple[CombatEngine, CombatantState]:
|
||||
"""Cast a harmless protection spell on a willing ally.
|
||||
|
||||
Returns the engine and the warded ally's state. The cast consumes no
|
||||
rolls (same-side harmless -> save forgone; spell_resistance false); the
|
||||
scripted 2s cover the deliberate-miss attack rolls and the saves below
|
||||
(attack bonus 5: 2+5=7 misses AC 15/17; saves base 0: total 2 or 2+2).
|
||||
"""
|
||||
spell = _loaded(stem)
|
||||
caster_cb = make_combatant(
|
||||
"caster", spells=[spell.name], spell_slots={slot_level: 1}
|
||||
)
|
||||
ward_cb = make_combatant("ward", hp=40, ac=15)
|
||||
c = make_state(caster_cb, "players", (0, 0))
|
||||
w = make_state(ward_cb, "players", (1, 0))
|
||||
engine = CombatEngine(
|
||||
ScriptedRng([2] * 24),
|
||||
make_grid(),
|
||||
[c, w],
|
||||
spell_registry={spell.name: spell},
|
||||
)
|
||||
engine._cast_spell(c, w, spell.name)
|
||||
return engine, w
|
||||
|
||||
|
||||
def _ac_vs(engine: CombatEngine, ward: CombatantState, alignment: str) -> int:
|
||||
"""Resolve a deliberate-miss attack from an ``alignment`` foe; return AC."""
|
||||
foe_cb = _aligned("foe", alignment)
|
||||
f = make_state(foe_cb, "monsters", (2, 0))
|
||||
result = engine.resolve_attack(f, ward, foe_cb.attacks[0])
|
||||
assert result.hit is False
|
||||
return result.ac
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("stem", "slot_level"),
|
||||
[
|
||||
("protection_from_evil", 1),
|
||||
("protection_from_evil_communal", 2),
|
||||
("protection_from_law", 1),
|
||||
("protection_from_law_communal", 2),
|
||||
],
|
||||
)
|
||||
def test_spell_carries_the_gated_buff(stem: str, slot_level: int) -> None:
|
||||
"""Given: the shipped protection JSON
|
||||
When: load_spell parses it
|
||||
Then: one buff carries +2 deflection AC and +2 resistance on all three
|
||||
saves, all gated on the attacker's alignment, 10 rounds (1 min/level)."""
|
||||
spell = _loaded(stem)
|
||||
assert spell.level == slot_level
|
||||
(effect,) = spell.effects
|
||||
assert isinstance(effect, BuffEffect)
|
||||
assert effect.duration_rounds == 10
|
||||
condition = "attacker_is_lawful" if "law" in stem else "attacker_is_evil"
|
||||
by_target = {m.target: m for m in effect.modifiers}
|
||||
assert set(by_target) == {"ac", "fort", "ref", "will"}
|
||||
assert by_target["ac"].value == 2
|
||||
assert by_target["ac"].bonus_type == "deflection"
|
||||
for save in ("fort", "ref", "will"):
|
||||
assert by_target[save].value == 2
|
||||
assert by_target[save].bonus_type == "resistance"
|
||||
for mod in effect.modifiers:
|
||||
assert mod.condition == condition
|
||||
|
||||
|
||||
def test_cast_lands_buff_on_willing_ally() -> None:
|
||||
"""Casting PFE on a same-side ally applies the buff with no save roll."""
|
||||
_, w = _cast("protection_from_evil", 1)
|
||||
(buff,) = w.active_buffs
|
||||
assert buff.source.startswith("spell:")
|
||||
assert buff.rounds_left == 10
|
||||
assert len(buff.modifiers) == 4
|
||||
|
||||
|
||||
def test_pfe_no_bonus_vs_neutral_attacker() -> None:
|
||||
"""Neutral source: the ward is inert - AC stays at base."""
|
||||
engine, w = _cast("protection_from_evil", 1)
|
||||
assert _ac_vs(engine, w, "N") == 15
|
||||
|
||||
|
||||
def test_pfe_ac_bonus_vs_evil_attacker() -> None:
|
||||
"""Evil attacker: +2 deflection to AC (SRD: the bonus applies against
|
||||
attacks made by evil creatures)."""
|
||||
engine, w = _cast("protection_from_evil", 1)
|
||||
assert _ac_vs(engine, w, "CE") == 17
|
||||
assert _ac_vs(engine, w, "LE") == 17
|
||||
|
||||
|
||||
def test_pfe_no_ac_bonus_vs_lawful_good_attacker() -> None:
|
||||
"""LG is not evil: no AC bonus from protection from evil."""
|
||||
engine, w = _cast("protection_from_evil", 1)
|
||||
assert _ac_vs(engine, w, "LG") == 15
|
||||
|
||||
|
||||
def test_pfe_save_bonus_vs_evil_attacker_only() -> None:
|
||||
"""+2 resistance on fort/ref/will vs an evil source; nothing vs a
|
||||
good source; nothing when no source is in scope."""
|
||||
engine, w = _cast("protection_from_evil", 1)
|
||||
evil = make_state(_aligned("evil", "NE"), "monsters", (5, 5))
|
||||
good = make_state(_aligned("good", "CG"), "monsters", (5, 5))
|
||||
for save in ("fort", "ref", "will"):
|
||||
vs_evil = engine.resolve_save(w, save, 30, attacker=evil)
|
||||
vs_good = engine.resolve_save(w, save, 30, attacker=good)
|
||||
no_source = engine.resolve_save(w, save, 30)
|
||||
assert vs_evil.total == 4 # scripted d20 2 + base 0 + 2 resistance
|
||||
assert vs_good.total == 2
|
||||
assert no_source.total == 2
|
||||
|
||||
|
||||
def test_pfl_vice_versus() -> None:
|
||||
"""Protection from Law: +2 AC and +2 saves vs lawful sources only;
|
||||
protection from evil stays inert against the same lawful-but-not-evil
|
||||
attacker (the gates are exact opposites on the good-evil axis)."""
|
||||
engine, w = _cast("protection_from_law", 1)
|
||||
assert _ac_vs(engine, w, "LG") == 17
|
||||
assert _ac_vs(engine, w, "LN") == 17
|
||||
assert _ac_vs(engine, w, "CE") == 15
|
||||
assert _ac_vs(engine, w, "N") == 15
|
||||
lawful = make_state(_aligned("lawful", "LN"), "monsters", (5, 5))
|
||||
chaotic = make_state(_aligned("chaotic", "CN"), "monsters", (5, 5))
|
||||
will_vs_lawful = engine.resolve_save(w, "will", 30, attacker=lawful)
|
||||
will_vs_chaotic = engine.resolve_save(w, "will", 30, attacker=chaotic)
|
||||
assert will_vs_lawful.total == 4
|
||||
assert will_vs_chaotic.total == 2
|
||||
|
||||
|
||||
def test_lawful_evil_attacker_triggers_both_wards() -> None:
|
||||
"""LE is on both gates: an LE attacker meets evil AND lawful."""
|
||||
engine_pfe, w_pfe = _cast("protection_from_evil", 1)
|
||||
engine_pfl, w_pfl = _cast("protection_from_law", 1)
|
||||
assert _ac_vs(engine_pfe, w_pfe, "LE") == 17
|
||||
assert _ac_vs(engine_pfl, w_pfl, "LE") == 17
|
||||
|
||||
|
||||
def test_communal_variants_share_the_effect() -> None:
|
||||
"""The communal spells carry the same gated bonuses as their base spell
|
||||
(the 1-minute-interval duration split is documented as not modeled)."""
|
||||
engine, w = _cast("protection_from_evil_communal", 2)
|
||||
assert _ac_vs(engine, w, "CE") == 17
|
||||
assert _ac_vs(engine, w, "N") == 15
|
||||
evil = make_state(_aligned("evil", "CE"), "monsters", (5, 5))
|
||||
assert engine.resolve_save(w, "will", 30, attacker=evil).total == 4
|
||||
|
||||
engine_law, w_law = _cast("protection_from_law_communal", 2)
|
||||
assert _ac_vs(engine_law, w_law, "LG") == 17
|
||||
assert _ac_vs(engine_law, w_law, "CE") == 15
|
||||
Reference in New Issue
Block a user