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)
+75
View File
@@ -125,6 +125,33 @@ class RecurringDamageSpec:
return min(self.max_rounds, self.base_rounds + caster_level // self.per_levels)
# PF1e alignment axes (SRD "Alignment"): the two gating axes a spell may
# discriminate on. See ``AlignmentGate``.
AlignmentAxis = Literal["law_chaos", "good_evil"]
@dataclass(frozen=True)
class AlignmentGate:
"""Alignment-gated spell damage (SRD spells/shard-of-chaos.md,
spells/spear-of-purity.md).
The spell has NO effect on creatures of the ``immune`` side ("chaotic"
for Shard of Chaos, "good" for Spear of Purity), deals HALF damage to
creatures neutral on that axis (and no condition), and deals FULL damage
to creatures of the ``full`` side ("lawful" / "evil") - which on a
FAILED save additionally suffer ``full_condition`` for
``full_condition_rounds`` rounds (SRD Shard of Chaos: a lawful creature
is slowed; the engine maps slowed to the staggered condition. SRD Spear
of Purity: an evil creature is blinded for 1 round).
"""
axis: AlignmentAxis
immune: str
full: str
full_condition: str | None
full_condition_rounds: int
@dataclass(frozen=True)
class DamageEffect:
formula: DiceExpr
@@ -149,6 +176,10 @@ class DamageEffect:
# initial hit the target takes this damage again at the start of each of
# its turns for `rounds_for(caster_level)` rounds. Absent = no DoT.
recurring: RecurringDamageSpec | None = None
# PF1e alignment gating (Shard of Chaos, Spear of Purity): the spell's
# effect depends on the target's alignment axis. Absent = alignment is
# irrelevant (every pre-gate spell keeps this None, bit-identical).
alignment_gate: AlignmentGate | None = None
def effective_formula(self, caster_level: int) -> DiceExpr:
"""The damage formula resolved for a caster of ``caster_level``.
@@ -705,6 +736,47 @@ def _parse_images(value: Json) -> ImageCountScaling:
return ImageCountScaling(dice=dice, per_levels=per_levels, max_images=max_images)
def _parse_alignment_gate(value: Json) -> AlignmentGate:
data = _as_dict(value)
axis_raw = _as_str(data["axis"])
if axis_raw == "law_chaos":
axis: AlignmentAxis = "law_chaos"
expected_immune, expected_full = "chaotic", "lawful"
elif axis_raw == "good_evil":
axis = "good_evil"
expected_immune, expected_full = "good", "evil"
else:
msg = (
"alignment_gate axis must be 'law_chaos' or 'good_evil', "
f"got {axis_raw!r}"
)
raise ValueError(msg)
immune = _as_str(data["immune"])
full = _as_str(data["full"])
if immune != expected_immune or full != expected_full:
msg = (
f"alignment_gate axis {axis!r} pairs immune={expected_immune!r} "
f"with full={expected_full!r}, got immune={immune!r}, full={full!r}"
)
raise ValueError(msg)
condition_raw = data.get("full_condition")
full_condition = None if condition_raw is None else _as_str(condition_raw)
rounds = _as_int(data.get("full_condition_rounds", 0))
if full_condition is None and rounds != 0:
msg = f"full_condition_rounds without full_condition must be 0, got {rounds}"
raise ValueError(msg)
if full_condition is not None and rounds < 1:
msg = f"full_condition_rounds must be >= 1 with a full_condition, got {rounds}"
raise ValueError(msg)
return AlignmentGate(
axis=axis,
immune=immune,
full=full,
full_condition=full_condition,
full_condition_rounds=rounds,
)
def _parse_damage_effect(effect: dict[str, Json]) -> DamageEffect:
formula = parse_dice(_as_str(effect["formula"]))
scaling_raw = effect.get("scaling")
@@ -725,6 +797,8 @@ def _parse_damage_effect(effect: dict[str, Json]) -> DamageEffect:
projectiles = None if proj_raw is None else _parse_projectiles(proj_raw)
rec_raw = effect.get("recurring")
recurring = None if rec_raw is None else _parse_recurring(rec_raw)
gate_raw = effect.get("alignment_gate")
alignment_gate = None if gate_raw is None else _parse_alignment_gate(gate_raw)
return DamageEffect(
formula=formula,
types=tuple(_as_str(t) for t in _as_list(effect["types"])),
@@ -734,6 +808,7 @@ def _parse_damage_effect(effect: dict[str, Json]) -> DamageEffect:
scaling_flat=scaling_flat,
projectiles=projectiles,
recurring=recurring,
alignment_gate=alignment_gate,
)