feat(rules): alignment-gated spell damage for Shard of Chaos and Spear of Purity

DamageEffect gains an optional AlignmentGate (axis law_chaos/good_evil):
the immune side (chaotic/good) is wholly unaffected (no damage dice rolled),
neutral-on-axis targets take half damage with no condition (gate halving
stacks after the save halving), and the full side (lawful/evil) takes full
damage plus the gate condition on a failed save. Shard of Chaos maps the
SRD slowed to the engine staggered condition (the -1 penalty and half-speed
of slow are not modeled); the outsider-only 1d6/level variants are not
modeled (no outsider subtype). Non-gated damage spells are bit-identical.
Loader validates axis/side pairing with clear ValueErrors.
This commit is contained in:
2026-09-20 12:32:14 +02:00
parent f9e51e377a
commit 6a18d456f6
5 changed files with 517 additions and 4 deletions
+59
View File
@@ -138,6 +138,7 @@ from pf1e_simulator.effects import (
from pf1e_simulator.loaders.combatant import CombatantLoadError, load_combatant
from pf1e_simulator.models import PREDEFINED_SKILLS
from pf1e_simulator.spells import (
AlignmentGate,
AreaEffect,
AreaShape,
BuffEffect,
@@ -7296,6 +7297,31 @@ class CombatEngine:
target.temp_hp_rounds_left = effect.duration_rounds
return f"temp hp +{effect.amount} ({effect.duration_rounds}r)"
def _alignment_gate_class(
self, gate: AlignmentGate, target: CombatantState
) -> Literal["immune", "neutral", "full"]:
"""Classify a target against a spell's alignment gate.
PF1e (SRD Shard of Chaos / Spear of Purity): the immune side
(chaotic / good) is unaffected, the full side (lawful / evil) takes
full damage plus the gate condition on a failed save, and a creature
neutral on that axis takes half damage and no condition. The loader
guarantees immune/full match the declared axis, so the axis alone
selects the flags.
"""
combatant = target.combatant
if gate.axis == "law_chaos":
if combatant.is_chaotic:
return "immune"
if combatant.is_lawful:
return "full"
else:
if combatant.is_good:
return "immune"
if combatant.is_evil:
return "full"
return "neutral"
def _apply_damage_effect(
self,
effect: DamageEffect,
@@ -7316,6 +7342,13 @@ class CombatEngine:
# against the caster's level at cast time; the scaled formula then
# flows through crits, energy resistance and metamagic unchanged.
formula = effect.effective_formula(caster.combatant.level)
# PF1e alignment gate (SRD Shard of Chaos / Spear of Purity): the
# target's alignment decides how the damage lands. An immune-side
# creature is unaffected - no dice are rolled at all.
gate = effect.alignment_gate
gate_class = None if gate is None else self._alignment_gate_class(gate, target)
if gate_class == "immune":
return "no effect (alignment immune)"
# PF1e multi-projectile spells (SRD Magic Missile: "one missile, plus
# one additional missile per two caster levels beyond 1st [...]
# maximum of five"; Scorching Ray: "one ray, plus one additional ray
@@ -7332,6 +7365,7 @@ class CombatEngine:
effect, formula, target, caster,
saved=saved, empower=empower, maximize=maximize,
crit=crit, ranged_touch=ranged_touch, attack_penalty=attack_penalty,
gate_neutral=gate_class == "neutral",
)
if dmg is None:
continue # this ray missed — no damage, no stat/reaction
@@ -7352,6 +7386,24 @@ class CombatEngine:
# while the target is still standing (a dead target has no turns).
if effect.recurring is not None and target.active:
self._attach_recurring_damage(effect.recurring, target, caster, spell_name)
# PF1e alignment gate (SRD Shard of Chaos: "A lawful outsider [...] is
# slowed (as slow) for 1 round. A successful Will save reduces the
# damage to half and negates the slow effect"; Spear of Purity: blinded
# for 1 round): the full-side creature that FAILED its save and
# actually took damage suffers the gate condition.
if (
gate is not None
and gate_class == "full"
and gate.full_condition is not None
and not saved
and total > 0
and target.active
):
imposed = self._impose_condition(
target, gate.full_condition, gate.full_condition_rounds, spell_name
)
if imposed:
return f"{total} damage, {imposed}"
return f"{total} damage"
def _resolve_one_projectile(
@@ -7367,6 +7419,7 @@ class CombatEngine:
crit: bool,
ranged_touch: bool,
attack_penalty: int,
gate_neutral: bool = False,
) -> int | None:
"""Resolve one projectile's hit + damage; None when the ray misses.
@@ -7398,6 +7451,12 @@ class CombatEngine:
dmg = 0
else:
dmg = raw
if gate_neutral:
# PF1e alignment gate (SRD Shard of Chaos: "This spell deals only
# half damage to creatures that are neither lawful nor chaotic"):
# the gate halving stacks with the save halving (integer order
# immaterial), and the neutral side never suffers the condition.
dmg //= 2
if empower:
dmg = int(dmg * 1.5)
dmg = self._apply_energy_resistance(dmg, frozenset(effect.types), target)