diff --git a/data/spells/shard_of_chaos.json b/data/spells/shard_of_chaos.json index 617aadf..5a72414 100644 --- a/data/spells/shard_of_chaos.json +++ b/data/spells/shard_of_chaos.json @@ -13,8 +13,15 @@ "formula": "0", "types": ["force"], "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)." } diff --git a/data/spells/spear_of_purity.json b/data/spells/spear_of_purity.json index 7cdd570..5cad789 100644 --- a/data/spells/spear_of_purity.json +++ b/data/spells/spear_of_purity.json @@ -13,8 +13,15 @@ "formula": "0", "types": ["force"], "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)." } diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 219bd63..9baf6b8 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -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) diff --git a/src/pf1e_simulator/spells.py b/src/pf1e_simulator/spells.py index 37b0afd..7a3497e 100644 --- a/src/pf1e_simulator/spells.py +++ b/src/pf1e_simulator/spells.py @@ -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, ) diff --git a/tests/test_alignment_gate.py b/tests/test_alignment_gate.py new file mode 100644 index 0000000..0827232 --- /dev/null +++ b/tests/test_alignment_gate.py @@ -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) == []