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:
@@ -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)."
|
||||
}
|
||||
|
||||
@@ -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)."
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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) == []
|
||||
Reference in New Issue
Block a user