Files
pf1e-simulator/tests/test_alignment_gate.py
T
ctan 6a18d456f6 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.
2026-09-20 12:32:14 +02:00

366 lines
14 KiB
Python

"""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) == []