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