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
+9 -2
View File
@@ -13,8 +13,15 @@
"formula": "0", "formula": "0",
"types": ["force"], "types": ["force"],
"half_on_save": true, "half_on_save": true,
"scaling": { "dice": "1d8", "per_levels": 2, "max_dice": 5 } "scaling": { "dice": "1d8", "per_levels": 2, "max_dice": 5 },
"alignment_gate": {
"axis": "law_chaos",
"immune": "chaotic",
"full": "lawful",
"full_condition": "staggered",
"full_condition_rounds": 1
}
} }
], ],
"description": "SRD spells/shard-of-chaos.md: evocation [chaotic], level 2 (cleric/oracle/warpriest), range close, dart-shaped projectile of chaotic energy delivered as a ranged touch attack, Will partial, spell resistance yes. A lawful creature struck takes 1d8 points of damage per two caster levels (maximum 5d8); a successful Will save halves the damage. Simplified: the engine has no alignment track, so the SRD alignment gate (no effect on chaotic creatures, half damage to neutrals, the lawful-outsider 1d6/level + slowed variant) cannot be represented - the base 1d8-per-two-levels damage (engine floor: caster_level // 2 dice) is modeled against every target as force damage (no energy type models chaotic energy), Will half." "description": "SRD spells/shard-of-chaos.md: evocation [chaotic], level 2 (cleric/oracle/warpriest), range close, dart-shaped projectile of chaotic energy delivered as a ranged touch attack, Will partial, spell resistance yes. A lawful creature struck takes 1d8 points of damage per two caster levels (maximum 5d8) and, on a failed Will save, is slowed for 1 round; a successful Will save halves the damage and negates the slow. The bolt has no effect on chaotic creatures (alignment_gate immune) and deals only half damage to creatures neither lawful nor chaotic, without the slow (alignment_gate neutral halving). Simplifications: the SRD's slowed condition is mapped to the engine's staggered condition (the SRD slow entry states a slowed creature is staggered; the -1 penalty and half-speed parts of slow are not modeled); the lawful-outsider variant (1d6 per caster level, maximum 10d6) is not modeled (the engine has no outsider subtype); force damage stands in for chaotic energy (no energy type models it)."
} }
+9 -2
View File
@@ -13,8 +13,15 @@
"formula": "0", "formula": "0",
"types": ["force"], "types": ["force"],
"half_on_save": true, "half_on_save": true,
"scaling": { "dice": "1d8", "per_levels": 2, "max_dice": 5 } "scaling": { "dice": "1d8", "per_levels": 2, "max_dice": 5 },
"alignment_gate": {
"axis": "good_evil",
"immune": "good",
"full": "evil",
"full_condition": "blinded",
"full_condition_rounds": 1
}
} }
], ],
"description": "SRD spells/spear-of-purity.md: evocation [good], level 2 (cleric/oracle/warpriest), range close, spear of good energy delivered as a ranged touch attack, Will partial, spell resistance yes. An evil creature struck takes 1d8 points of damage per two caster levels (maximum 5d8); a successful Will save halves the damage. Simplified: the engine has no alignment track, so the SRD alignment gate (no effect on good creatures, half damage to neutrals, the evil-outsider 1d6/level + blinded variant) cannot be represented - the base 1d8-per-two-levels damage (engine floor: caster_level // 2 dice) is modeled against every target as force damage (no energy type models good energy), Will half." "description": "SRD spells/spear-of-purity.md: evocation [good], level 2 (cleric/oracle/warpriest), range close, spear of good energy delivered as a ranged touch attack, Will partial, spell resistance yes. An evil creature struck takes 1d8 points of damage per two caster levels (maximum 5d8) and, on a failed Will save, is blinded for 1 round; a successful Will save halves the damage and negates the blinded effect. The spear has no effect on good creatures (alignment_gate immune) and deals only half damage to creatures neither evil nor good, without the blindness (alignment_gate neutral halving). Simplifications: the evil-outsider variant (1d6 per caster level, maximum 10d6) is not modeled (the engine has no outsider subtype); force damage stands in for good energy (no energy type models it)."
} }
+59
View File
@@ -138,6 +138,7 @@ from pf1e_simulator.effects import (
from pf1e_simulator.loaders.combatant import CombatantLoadError, load_combatant from pf1e_simulator.loaders.combatant import CombatantLoadError, load_combatant
from pf1e_simulator.models import PREDEFINED_SKILLS from pf1e_simulator.models import PREDEFINED_SKILLS
from pf1e_simulator.spells import ( from pf1e_simulator.spells import (
AlignmentGate,
AreaEffect, AreaEffect,
AreaShape, AreaShape,
BuffEffect, BuffEffect,
@@ -7296,6 +7297,31 @@ class CombatEngine:
target.temp_hp_rounds_left = effect.duration_rounds target.temp_hp_rounds_left = effect.duration_rounds
return f"temp hp +{effect.amount} ({effect.duration_rounds}r)" 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( def _apply_damage_effect(
self, self,
effect: DamageEffect, effect: DamageEffect,
@@ -7316,6 +7342,13 @@ class CombatEngine:
# against the caster's level at cast time; the scaled formula then # against the caster's level at cast time; the scaled formula then
# flows through crits, energy resistance and metamagic unchanged. # flows through crits, energy resistance and metamagic unchanged.
formula = effect.effective_formula(caster.combatant.level) 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 # PF1e multi-projectile spells (SRD Magic Missile: "one missile, plus
# one additional missile per two caster levels beyond 1st [...] # one additional missile per two caster levels beyond 1st [...]
# maximum of five"; Scorching Ray: "one ray, plus one additional ray # maximum of five"; Scorching Ray: "one ray, plus one additional ray
@@ -7332,6 +7365,7 @@ class CombatEngine:
effect, formula, target, caster, effect, formula, target, caster,
saved=saved, empower=empower, maximize=maximize, saved=saved, empower=empower, maximize=maximize,
crit=crit, ranged_touch=ranged_touch, attack_penalty=attack_penalty, crit=crit, ranged_touch=ranged_touch, attack_penalty=attack_penalty,
gate_neutral=gate_class == "neutral",
) )
if dmg is None: if dmg is None:
continue # this ray missed — no damage, no stat/reaction 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). # while the target is still standing (a dead target has no turns).
if effect.recurring is not None and target.active: if effect.recurring is not None and target.active:
self._attach_recurring_damage(effect.recurring, target, caster, spell_name) 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" return f"{total} damage"
def _resolve_one_projectile( def _resolve_one_projectile(
@@ -7367,6 +7419,7 @@ class CombatEngine:
crit: bool, crit: bool,
ranged_touch: bool, ranged_touch: bool,
attack_penalty: int, attack_penalty: int,
gate_neutral: bool = False,
) -> int | None: ) -> int | None:
"""Resolve one projectile's hit + damage; None when the ray misses. """Resolve one projectile's hit + damage; None when the ray misses.
@@ -7398,6 +7451,12 @@ class CombatEngine:
dmg = 0 dmg = 0
else: else:
dmg = raw 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: if empower:
dmg = int(dmg * 1.5) dmg = int(dmg * 1.5)
dmg = self._apply_energy_resistance(dmg, frozenset(effect.types), target) 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) 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) @dataclass(frozen=True)
class DamageEffect: class DamageEffect:
formula: DiceExpr formula: DiceExpr
@@ -149,6 +176,10 @@ class DamageEffect:
# initial hit the target takes this damage again at the start of each of # 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. # its turns for `rounds_for(caster_level)` rounds. Absent = no DoT.
recurring: RecurringDamageSpec | None = None 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: def effective_formula(self, caster_level: int) -> DiceExpr:
"""The damage formula resolved for a caster of ``caster_level``. """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) 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: def _parse_damage_effect(effect: dict[str, Json]) -> DamageEffect:
formula = parse_dice(_as_str(effect["formula"])) formula = parse_dice(_as_str(effect["formula"]))
scaling_raw = effect.get("scaling") 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) projectiles = None if proj_raw is None else _parse_projectiles(proj_raw)
rec_raw = effect.get("recurring") rec_raw = effect.get("recurring")
recurring = None if rec_raw is None else _parse_recurring(rec_raw) 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( return DamageEffect(
formula=formula, formula=formula,
types=tuple(_as_str(t) for t in _as_list(effect["types"])), 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, scaling_flat=scaling_flat,
projectiles=projectiles, projectiles=projectiles,
recurring=recurring, recurring=recurring,
alignment_gate=alignment_gate,
) )
+365
View File
@@ -0,0 +1,365 @@
"""Alignment-gated spell damage (SRD Shard of Chaos / Spear of Purity).
A DamageEffect may declare an ``"alignment_gate"``: the spell has NO effect
on the immune side (chaotic for Shard of Chaos, good for Spear of Purity),
deals half damage to creatures neutral on that axis (and never the gate
condition), and deals full damage to the full side (lawful / evil) which on
a failed save also suffers the gate condition (slowed -> staggered for
Shard, blinded for Spear; SRD spells/shard-of-chaos.md,
spells/spear-of-purity.md).
Loader tests pin the JSON parsing and validation; cast tests pin the three
gate classes, the save interaction and the bit-identical behaviour of
non-gated damage spells.
"""
from __future__ import annotations
from pathlib import Path
from typing import TYPE_CHECKING
import pytest
from pf1e_simulator.combat import CombatantState, CombatEngine
from pf1e_simulator.dice import parse_dice
from pf1e_simulator.rng import ScriptedRng
from pf1e_simulator.spells import (
AlignmentGate,
DamageEffect,
SpellSpec,
load_spell,
)
from tests.test_observation_enriched import make_combatant, make_grid, make_state
if TYPE_CHECKING:
from pf1e_simulator.models import Combatant
DATA_SPELLS = Path(__file__).resolve().parent.parent / "data" / "spells"
def _loaded(name: str) -> SpellSpec:
return load_spell(DATA_SPELLS / f"{name}.json")
def _caster(level: int, spell: SpellSpec) -> Combatant:
base = make_combatant("caster", spells=[spell.name], spell_slots={2: 1})
return base.model_copy(update={"level": level})
def _target(alignment: str) -> Combatant:
return make_combatant("foe", hp=100).model_copy(
update={"alignment": alignment}
)
def _cast(
caster: Combatant,
target: Combatant,
spell: SpellSpec,
rolls: list[int],
) -> tuple[CombatEngine, CombatantState]:
grid = make_grid()
c = make_state(caster, "players", (0, 0))
t = make_state(target, "monsters", (2, 0))
engine = CombatEngine(
ScriptedRng(rolls), grid, [c, t], spell_registry={spell.name: spell}
)
engine._cast_spell(c, t, spell.name)
return engine, t
def _condition_names(state: CombatantState) -> list[str]:
return [ac.condition.name for ac in state.active_conditions]
# ---------------------------------------------------------------------------
# loader: the shipped JSON carries its gate
# ---------------------------------------------------------------------------
def test_shard_of_chaos_loads_its_gate() -> None:
"""Given: the shipped shard_of_chaos.json
When: load_spell parses it
Then: the DamageEffect carries the law-chaos gate (immune chaotic, full
lawful, staggered 1 round - the engine mapping of the SRD's slowed)."""
spell = _loaded("shard_of_chaos")
(effect,) = spell.effects
assert isinstance(effect, DamageEffect)
assert effect.alignment_gate == AlignmentGate(
axis="law_chaos",
immune="chaotic",
full="lawful",
full_condition="staggered",
full_condition_rounds=1,
)
def test_spear_of_purity_loads_its_gate() -> None:
"""Given: the shipped spear_of_purity.json
When: load_spell parses it
Then: the DamageEffect carries the good-evil gate (immune good, full
evil, blinded 1 round)."""
spell = _loaded("spear_of_purity")
(effect,) = spell.effects
assert isinstance(effect, DamageEffect)
assert effect.alignment_gate == AlignmentGate(
axis="good_evil",
immune="good",
full="evil",
full_condition="blinded",
full_condition_rounds=1,
)
def _gate_json(gate: str) -> str:
return (
'{"name": "Gated", "level": 2, "school": "evocation",'
' "range": "close", "save": "will", "effects": ['
' {"type": "damage", "formula": "1d6", "types": ["force"],'
' "half_on_save": true, "alignment_gate": '
) + gate + "}]}"
def test_loader_rejects_unknown_axis(tmp_path: Path) -> None:
"""Given: a gate whose axis is neither law_chaos nor good_evil
When: load_spell parses it
Then: a clear ValueError names the allowed axes."""
spell_file = tmp_path / "bad_axis.json"
spell_file.write_text(
_gate_json('{"axis": "order_freedom", "immune": "chaotic",'
' "full": "lawful", "full_condition": null,'
' "full_condition_rounds": 0}')
)
with pytest.raises(ValueError, match="axis must be"):
load_spell(spell_file)
def test_loader_rejects_sides_mismatched_to_the_axis(tmp_path: Path) -> None:
"""Given: a good_evil axis paired with the law-chaos sides
When: load_spell parses it
Then: a clear ValueError rejects the pairing."""
spell_file = tmp_path / "bad_sides.json"
spell_file.write_text(
_gate_json('{"axis": "good_evil", "immune": "chaotic",'
' "full": "lawful", "full_condition": null,'
' "full_condition_rounds": 0}')
)
with pytest.raises(ValueError, match="pairs immune="):
load_spell(spell_file)
def test_loader_rejects_rounds_without_condition(tmp_path: Path) -> None:
"""Given: full_condition_rounds > 0 with no full_condition
When: load_spell parses it
Then: a clear ValueError rejects the orphaned duration."""
spell_file = tmp_path / "bad_rounds.json"
spell_file.write_text(
_gate_json('{"axis": "law_chaos", "immune": "chaotic",'
' "full": "lawful", "full_condition": null,'
' "full_condition_rounds": 1}')
)
with pytest.raises(ValueError, match="without full_condition"):
load_spell(spell_file)
def test_loader_rejects_condition_without_rounds(tmp_path: Path) -> None:
"""Given: a full_condition with full_condition_rounds < 1
When: load_spell parses it
Then: a clear ValueError rejects the zero duration."""
spell_file = tmp_path / "bad_cond.json"
spell_file.write_text(
_gate_json('{"axis": "law_chaos", "immune": "chaotic",'
' "full": "lawful", "full_condition": "staggered",'
' "full_condition_rounds": 0}')
)
with pytest.raises(ValueError, match="must be >= 1"):
load_spell(spell_file)
def test_loader_accepts_conditionless_gate(tmp_path: Path) -> None:
"""Given: a gate with full_condition null and rounds 0
When: load_spell parses it
Then: the gate loads with no condition (the damage gate alone is legal)."""
spell_file = tmp_path / "plain_gate.json"
spell_file.write_text(
_gate_json('{"axis": "law_chaos", "immune": "chaotic",'
' "full": "lawful", "full_condition": null,'
' "full_condition_rounds": 0}')
)
spell = load_spell(spell_file)
(effect,) = spell.effects
assert isinstance(effect, DamageEffect)
assert effect.alignment_gate == AlignmentGate(
axis="law_chaos",
immune="chaotic",
full="lawful",
full_condition=None,
full_condition_rounds=0,
)
# ---------------------------------------------------------------------------
# casts: Shard of Chaos (law-chaos axis, lawful -> staggered)
# ---------------------------------------------------------------------------
#
# Roll queues: [ranged touch d20, Will d20, damage d8, d8]. The caster is
# level 4 (2d8, BAB +5 vs touch AC 15: d20=10 hits); Will natural 1 always
# fails and natural 20 always succeeds, so the DC is outcome-irrelevant.
# Immune tests queue ONLY the two d20s: any damage die would raise
# RngExhaustedError, proving the gate consumes no damage dice.
_SHARD = _loaded("shard_of_chaos")
_SPEAR = _loaded("spear_of_purity")
def test_shard_is_no_effect_on_chaotic() -> None:
"""Given: a chaotic (CN) target of Shard of Chaos
When: the spell is cast (touch hits, save fails)
Then: no damage, no condition, and no damage dice are rolled (the queue
holds only the two d20s) - SRD "The bolt has no effect on chaotic
creatures"."""
engine, t = _cast(_caster(4, _SHARD), _target("CN"), _SHARD, [10, 1])
assert t.hp == 100
assert _condition_names(t) == []
assert any("no effect (alignment immune)" in line for line in engine._transcript)
def test_shard_lawful_failing_save_takes_full_and_staggered() -> None:
"""Given: a lawful (LG) target failing its Will save
When: Shard of Chaos lands 2d8 = 10
Then: full damage and staggered 1 round (the engine mapping of slowed)."""
engine, t = _cast(_caster(4, _SHARD), _target("LG"), _SHARD, [10, 1, 5, 5])
assert t.hp == 90
assert _condition_names(t) == ["staggered"]
assert any("10 damage, staggered (1r)" in line for line in engine._transcript)
def test_shard_lawful_succeeding_save_takes_half_and_no_condition() -> None:
"""Given: a lawful (LG) target succeeding its Will save
When: Shard of Chaos lands 2d8 = 10
Then: half damage (5) and NO staggered - SRD "A successful Will save
reduces the damage to half and negates the slow effect"."""
_, t = _cast(_caster(4, _SHARD), _target("LG"), _SHARD, [10, 20, 5, 5])
assert t.hp == 95
assert _condition_names(t) == []
def test_shard_neutral_takes_half_no_condition_on_failed_save() -> None:
"""Given: a neutral (N) target failing its Will save
When: Shard of Chaos lands 2d8 = 10
Then: the gate halves to 5 and no condition - SRD "deals only half
damage to creatures that are neither lawful nor chaotic, and they are
not slowed"."""
_, t = _cast(_caster(4, _SHARD), _target("N"), _SHARD, [10, 1, 5, 5])
assert t.hp == 95
assert _condition_names(t) == []
def test_shard_neutral_gate_halving_stacks_with_save_halving() -> None:
"""Given: a neutral (N) target succeeding its Will save
When: Shard of Chaos lands 2d8 = 10
Then: save half (5) then gate half (2) - the two halvings stack."""
_, t = _cast(_caster(4, _SHARD), _target("N"), _SHARD, [10, 20, 5, 5])
assert t.hp == 98
assert _condition_names(t) == []
def test_shard_lawful_neutral_component_still_full() -> None:
"""Given: a lawful-neutral (LN) target (lawful, not good/evil)
When: Shard of Chaos lands on a failed save
Then: the law-chaos axis alone decides: full side, staggered imposed."""
_, t = _cast(_caster(4, _SHARD), _target("LN"), _SHARD, [10, 1, 5, 5])
assert t.hp == 90
assert _condition_names(t) == ["staggered"]
# ---------------------------------------------------------------------------
# casts: Spear of Purity (good-evil axis, evil -> blinded)
# ---------------------------------------------------------------------------
def test_spear_is_no_effect_on_good() -> None:
"""Given: a good (NG) target of Spear of Purity
When: the spell is cast (touch hits, save fails)
Then: no damage, no condition, no damage dice rolled - SRD "The spear
has no effect on good creatures"."""
engine, t = _cast(_caster(4, _SPEAR), _target("NG"), _SPEAR, [10, 1])
assert t.hp == 100
assert _condition_names(t) == []
assert any("no effect (alignment immune)" in line for line in engine._transcript)
def test_spear_evil_failing_save_takes_full_and_blinded() -> None:
"""Given: an evil (NE) target failing its Will save
When: Spear of Purity lands 2d8 = 10
Then: full damage and blinded 1 round."""
engine, t = _cast(_caster(4, _SPEAR), _target("NE"), _SPEAR, [10, 1, 5, 5])
assert t.hp == 90
assert _condition_names(t) == ["blinded"]
assert any("10 damage, blinded (1r)" in line for line in engine._transcript)
def test_spear_evil_succeeding_save_takes_half_and_no_blindness() -> None:
"""Given: an evil (NE) target succeeding its Will save
When: Spear of Purity lands 2d8 = 10
Then: half damage (5) and no blinded."""
_, t = _cast(_caster(4, _SPEAR), _target("NE"), _SPEAR, [10, 20, 5, 5])
assert t.hp == 95
assert _condition_names(t) == []
def test_spear_neutral_takes_half_no_condition() -> None:
"""Given: a neutral (N) target failing its Will save
When: Spear of Purity lands 2d8 = 10
Then: the gate halves to 5 and no blinded - SRD "deals only half damage
to creatures that are neither evil nor good, and they are not
blinded"."""
_, t = _cast(_caster(4, _SPEAR), _target("N"), _SPEAR, [10, 1, 5, 5])
assert t.hp == 95
assert _condition_names(t) == []
def test_spear_lawful_evil_is_on_the_full_side() -> None:
"""Given: an LE target (evil on the good-evil axis, lawful on the other)
When: Spear of Purity lands on a failed save
Then: only the declared axis matters: full damage + blinded."""
_, t = _cast(_caster(4, _SPEAR), _target("LE"), _SPEAR, [10, 1, 5, 5])
assert t.hp == 90
assert _condition_names(t) == ["blinded"]
# ---------------------------------------------------------------------------
# non-gated spells are untouched
# ---------------------------------------------------------------------------
_PLAIN_BOLT = SpellSpec(
name="Plain Bolt",
level=2,
school="evocation",
range="close",
save="will",
requires_ranged_touch=True,
effects=(
DamageEffect(formula=parse_dice("2d6"), types=("force",), half_on_save=True),
),
)
def test_ungated_damage_spell_is_unchanged_on_any_alignment() -> None:
"""Given: a damage spell WITHOUT an alignment_gate
When: cast on a lawful target that fails its save
Then: full damage and no condition - the gate fields default to None and
the pre-gate code path is bit-identical."""
engine, t = _cast(_caster(4, _PLAIN_BOLT), _target("LG"), _PLAIN_BOLT, [10, 1, 3, 4])
assert t.hp == 100 - 7
assert _condition_names(t) == []
assert any("7 damage" in line for line in engine._transcript)
def test_ungated_damage_spell_save_still_negates_via_half() -> None:
"""Given: the same gate-less spell, target succeeds its save
When: 2d6 = 7 lands
Then: half_on_save applies exactly as before (3), no gate interaction."""
_, t = _cast(_caster(4, _PLAIN_BOLT), _target("CE"), _PLAIN_BOLT, [10, 20, 3, 4])
assert t.hp == 100 - 3
assert _condition_names(t) == []