6a18d456f6
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.
366 lines
14 KiB
Python
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) == []
|