Files
pf1e-simulator/tests/test_spells.py
T
ctan 5b64fbee48 fix(spells): correct the fear condition
PF1e (SRD spells/fear.md): creatures become panicked unless they succeed on a Will save - the JSON applied frightened instead. Condition fixed to panicked. The shaken-for-1-round on a successful save needs partial-save support for conditions and the 30-ft cone needs a cone template, neither of which the engine has; both documented in README as out of scope rather than built.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-12 13:05:49 +02:00

737 lines
29 KiB
Python

"""Tests for spell system: loading, range, casting (damage, save, condition, heal, buff)."""
from __future__ import annotations
from pathlib import Path
import pytest
from pf1e_simulator.combat import Action, CombatantState, CombatEngine, _choose_spell_action
from pf1e_simulator.dice import parse_dice
from pf1e_simulator.grid import Grid
from pf1e_simulator.map import MapSpec, TerrainType
from pf1e_simulator.models import (
AbilityScores,
ACProfile,
AttackSpec,
Combatant,
DamageComponent,
DamageReduction,
Saves,
)
from pf1e_simulator.rng import ScriptedRng
from pf1e_simulator.spells import (
AreaEffect,
BuffEffect,
ConditionEffect,
DamageEffect,
HealEffect,
SpellSpec,
load_spell,
load_spell_registry,
spell_range_ft,
)
_SPELLS_DIR = Path(__file__).resolve().parent.parent / "data" / "spells"
_SAVE_NEGATES_BOLT = SpellSpec(
name="Save Negates Bolt",
level=1,
school="evocation",
range="medium",
save="ref",
effects=(
DamageEffect(
formula=parse_dice("2d4"),
types=("force",),
half_on_save=False,
),
),
)
def _make_caster(cid: str = "caster", *, level: int = 5) -> Combatant:
attack = AttackSpec(
id=f"{cid}-w",
name="dagger",
kind="melee",
attack_bonus=2,
damage=[DamageComponent(formula=parse_dice("1d4"), types=["piercing"])],
)
return Combatant(
id=cid,
name=cid,
level=level,
size="Medium",
abilities=AbilityScores(
str_score=10, dex_score=14, con_score=12,
int_score=18, wis_score=12, cha_score=10,
),
hp_max=20,
ac=ACProfile(total=12, touch=12, flat_footed=10),
bab=2,
initiative_mod=2,
speed_land_ft=30,
saves=Saves(fort=3, ref=4, will=4),
attacks=[attack],
casting_ability="int",
# B4 conformance: _cast_spell now refuses without a slot; test
# casters carry a generous pool so fixtures stay about spells.
spell_slots={0: 99, 1: 99, 2: 99, 3: 99, 4: 99, 5: 99, 6: 99},
)
def _make_target(cid: str = "target", *, hp_max: int = 30) -> Combatant:
attack = AttackSpec(
id=f"{cid}-w",
name="club",
kind="melee",
attack_bonus=3,
damage=[DamageComponent(formula=parse_dice("1d6"), types=["bludgeoning"])],
)
return Combatant(
id=cid,
name=cid,
level=1,
size="Medium",
abilities=AbilityScores(
str_score=10, dex_score=10, con_score=10,
int_score=10, wis_score=10, cha_score=10,
),
hp_max=hp_max,
ac=ACProfile(total=10, touch=10, flat_footed=10),
bab=1,
initiative_mod=0,
speed_land_ft=30,
saves=Saves(fort=2, ref=2, will=2),
attacks=[attack],
)
def _make_engine(
caster: Combatant,
target: Combatant,
queue: list[int],
*,
caster_pos: tuple[int, int] = (0, 0),
target_pos: tuple[int, int] = (3, 0),
spell_registry: dict[str, SpellSpec] | None = None,
target_side: str = "monsters",
) -> tuple[CombatEngine, CombatantState, CombatantState]:
c = CombatantState(combatant=caster, side="players", pos=caster_pos, hp=caster.hp_max)
t = CombatantState(combatant=target, side=target_side, pos=target_pos, hp=target.hp_max)
legend = {".": TerrainType(type="floor", move_cost=1)}
spec = MapSpec(name="test", terrain=tuple(["." * 12] * 12), legend=legend)
grid = Grid.from_spec(spec)
reg = spell_registry if spell_registry is not None else load_spell_registry(_SPELLS_DIR)
engine = CombatEngine(ScriptedRng(queue), grid, [c, t], spell_registry=reg)
return engine, c, t
class TestSpellLoading:
"""Given: data/spells/ directory with 107 JSON spell files
When: load_spell_registry is called
Then: all 107 spells are loaded with correct fields."""
def test_loads_all_10_spells(self) -> None:
reg = load_spell_registry(_SPELLS_DIR)
assert len(reg) == 107
def test_spell_names_present(self) -> None:
reg = load_spell_registry(_SPELLS_DIR)
expected = {
"Magic Missile", "Burning Hands", "Fireball", "Lightning Bolt",
"Cure Light Wounds", "Cure Moderate Wounds", "Hold Person",
"Fear", "Bull's Strength", "Acid Arrow",
"Summon Monster II",
}
assert expected <= set(reg)
assert len(reg) == 107
def test_fireball_fields(self) -> None:
spell = load_spell(_SPELLS_DIR / "fireball.json")
assert spell.name == "Fireball"
assert spell.level == 3
assert spell.school == "evocation"
assert spell.range == "long"
assert spell.save == "ref"
assert spell.base_dc == 10
assert isinstance(spell.effects[0], AreaEffect)
area_eff = spell.effects[0]
assert isinstance(area_eff, AreaEffect)
assert area_eff.radius_ft == 20
assert area_eff.save_for_half is True
assert len(area_eff.effects) == 1
inner = area_eff.effects[0]
assert isinstance(inner, DamageEffect)
assert inner.half_on_save is True
def test_hold_person_has_condition_effect(self) -> None:
spell = load_spell(_SPELLS_DIR / "hold_person.json")
eff = spell.effects[0]
assert isinstance(eff, ConditionEffect)
assert eff.condition_name == "paralyzed"
assert eff.save_negates is True
def test_cure_light_has_heal_effect(self) -> None:
spell = load_spell(_SPELLS_DIR / "cure_light_wounds.json")
assert isinstance(spell.effects[0], HealEffect)
def test_bulls_strength_has_buff_effect(self) -> None:
spell = load_spell(_SPELLS_DIR / "bulls_strength.json")
eff = spell.effects[0]
assert isinstance(eff, BuffEffect)
assert len(eff.modifiers) == 2
def test_summon_monster_casts_for_one_round(self) -> None:
# PF1e: Summon Monster I/II have a 1-round casting time.
for name in ("summon_monster_i", "summon_monster_ii"):
assert load_spell(_SPELLS_DIR / f"{name}.json").cast_time == "one_round"
class TestSpellCastTime:
"""Given: SpellSpec and the JSON loader casting-time field
When: specs are built or spell JSON parsed
Then: the default is "standard", RAW phrasings map onto budget codes,
and unknown values are rejected."""
def test_spec_defaults_to_standard(self) -> None:
spell = SpellSpec(name="X", level=1, school="evocation")
assert spell.cast_time == "standard"
def _load(self, tmp_path: Path, cast_time: str) -> SpellSpec:
spell_file = tmp_path / "test_spell.json"
spell_file.write_text(
'{"name": "Test Bolt", "level": 1, "school": "evocation",'
f' "range": "medium", "save": "ref", "cast_time": "{cast_time}",'
' "effects": []}'
)
return load_spell(spell_file)
def test_loader_parses_raw_aliases(self, tmp_path: Path) -> None:
cases = {
"1 round": "one_round",
"1 minute": "long",
"3 rounds": "long",
"full-round": "full_round",
"swift action": "swift",
}
for raw, code in cases.items():
assert self._load(tmp_path, raw).cast_time == code, raw
def test_loader_accepts_code_values(self, tmp_path: Path) -> None:
for code in ("standard", "swift", "immediate", "free", "full_round",
"one_round", "long"):
assert self._load(tmp_path, code).cast_time == code, code
def test_loader_rejects_unknown(self, tmp_path: Path) -> None:
with pytest.raises(ValueError, match="unknown cast time"):
self._load(tmp_path, "2 hours")
def test_imported_spells_declare_their_cast_time(self) -> None:
"""Given: the imported spell data
When: the registry is loaded
Then: exactly the KB-verified spells carry a non-standard cast_time."""
expected = {
"Enhance Water": "one_round",
"Silence": "one_round",
"Sleep": "one_round",
"Summon Instrument": "one_round",
"Summon Monster I": "one_round",
"Summon Monster II": "one_round",
"Grasp": "immediate",
"Saving Finale": "immediate",
"Make Whole": "long",
"Masterwork Transformation": "long",
"Mending": "long",
"Restoration, Lesser": "long",
}
registry = load_spell_registry(_SPELLS_DIR)
non_standard = {n for n, s in registry.items() if s.cast_time != "standard"}
assert non_standard == set(expected)
for name, code in expected.items():
assert registry[name].cast_time == code, name
class TestSpellRangeFt:
"""Given: a spell with a range category
When: spell_range_ft is called
Then: returns the correct range in feet."""
def test_personal_is_zero(self) -> None:
spell = SpellSpec(name="t", level=1, school="x", range="personal")
assert spell_range_ft(spell, 10) == 0
def test_touch_is_five(self) -> None:
spell = SpellSpec(name="t", level=1, school="x", range="touch")
assert spell_range_ft(spell, 10) == 5
def test_close_scales_with_level(self) -> None:
spell = SpellSpec(name="t", level=1, school="x", range="close")
assert spell_range_ft(spell, 2) == 25 + 5 * 1
assert spell_range_ft(spell, 4) == 25 + 5 * 2
def test_medium_scales_with_level(self) -> None:
spell = SpellSpec(name="t", level=1, school="x", range="medium")
assert spell_range_ft(spell, 3) == 100 + 30
def test_long_scales_with_level(self) -> None:
spell = SpellSpec(name="t", level=1, school="x", range="long")
assert spell_range_ft(spell, 2) == 400 + 80
class TestMagicMissile:
"""Given: a caster casting Magic Missile (no save, force damage)
When: _cast_spell is called
Then: target takes full damage with no save."""
def test_deals_damage_no_save(self) -> None:
caster = _make_caster()
target = _make_target(hp_max=100)
engine, c, t = _make_engine(caster, target, [3])
hp_before = t.hp
engine._cast_spell(c, t, "Magic Missile")
assert t.hp < hp_before
assert t.hp == hp_before - (3 + 1)
def test_transcript_shows_no_save(self) -> None:
caster = _make_caster()
target = _make_target()
engine, c, t = _make_engine(caster, target, [3])
engine._cast_spell(c, t, "Magic Missile")
assert any("NO SAVE" in line for line in engine._transcript)
class TestFireballSave:
"""Given: a caster casting Fireball (ref save for half)
When: target saves / fails
Then: damage is halved / full."""
def test_failed_save_full_damage(self) -> None:
caster = _make_caster()
target = _make_target(hp_max=200)
# 2 save rolls (2 targets in area) + 6 damage dice
engine, c, t = _make_engine(caster, target, [2, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3])
hp_before = t.hp
engine._cast_spell(c, t, "Fireball")
assert t.hp == hp_before - (3 + 3 + 3 + 3 + 3 + 3)
def test_successful_save_half_damage(self) -> None:
caster = _make_caster()
target = _make_target(hp_max=200)
# 2 save rolls (2 targets in area) + 6 damage dice
engine, c, t = _make_engine(caster, target, [20, 20, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3])
hp_before = t.hp
engine._cast_spell(c, t, "Fireball")
full = 3 + 3 + 3 + 3 + 3 + 3
assert t.hp == hp_before - (full // 2)
def test_transcript_shows_saved(self) -> None:
caster = _make_caster()
target = _make_target()
# 2 save rolls (2 targets in area) + 6 damage dice
engine, c, t = _make_engine(caster, target, [20, 20, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3])
engine._cast_spell(c, t, "Fireball")
assert any("SAVED" in line for line in engine._transcript)
class TestAreaSavesPerSpellSaveType:
"""B1 (PF1e conformance): every creature in an area rolls the save the
SPELL lists (Will/Fort/Ref) — "save negates" areas are no longer
save-free, and secondary creatures do not always roll Reflex."""
def test_sleep_secondary_creature_rolls_will_and_negates(self) -> None:
# Sleep (Will negates, save_for_half=False): primary target saves on
# natural 20 (first roll), the caster — second creature in the area —
# rolls its own Will and fails on natural 1 (second roll).
caster = _make_caster()
target = _make_target(hp_max=100)
engine, c, t = _make_engine(
caster, target, [20, 1], target_pos=(2, 0)
)
engine._cast_spell(c, t, "Sleep")
assert not any(ac.condition.name == "asleep" for ac in t.active_conditions)
assert any(ac.condition.name == "asleep" for ac in c.active_conditions)
def test_color_spray_primary_save_is_not_discarded(self) -> None:
# Color Spray (Will negates): the primary's pre-rolled save decides —
# natural 1 fails (stunned), the caster's own natural 20 succeeds.
caster = _make_caster()
target = _make_target(hp_max=100)
engine, c, t = _make_engine(
caster, target, [1, 20], target_pos=(2, 0)
)
engine._cast_spell(c, t, "Color Spray")
assert any(ac.condition.name == "stunned" for ac in t.active_conditions)
assert not any(ac.condition.name == "stunned" for ac in c.active_conditions)
def test_web_secondary_creature_rolls_reflex(self) -> None:
# Web (Reflex negates): secondary creature rolls its own Reflex.
caster = _make_caster()
target = _make_target(hp_max=100)
engine, c, t = _make_engine(
caster, target, [20, 1], target_pos=(2, 0)
)
engine._cast_spell(c, t, "Web")
assert not any(ac.condition.name == "grappled" for ac in t.active_conditions)
assert any(ac.condition.name == "grappled" for ac in c.active_conditions)
def test_sound_burst_secondary_rolls_fortitude_not_reflex(self) -> None:
# Sound Burst (Fortitude partial): caster has ref +4, fort +3 and the
# DC is 16 — d20 12 passes Reflex but fails Fortitude. The caster
# being stunned proves the FORT save was rolled (B1's hardcoded ref
# would have "saved" and left the caster unstunned).
caster = _make_caster()
target = _make_target(hp_max=200)
# rolls: primary (target) fort 20 = save; caster fort 12 = fail;
# then the 1d8 damage dice for each creature in state order.
engine, c, t = _make_engine(
caster, target, [20, 12, 3, 3], target_pos=(2, 0)
)
engine._cast_spell(c, t, "Sound Burst")
assert any(ac.condition.name == "stunned" for ac in c.active_conditions)
# SRD spells/sound-burst.md: the save only avoids the stun — the 1d8
# sonic damage is full regardless (save_negates: false on the damage).
assert t.hp == 200 - 3 # saved: full damage, no stun
assert not any(ac.condition.name == "stunned" for ac in t.active_conditions)
class TestBleedCantrip:
"""Given: the Bleed cantrip (SRD spells/bleed.md: Will negates; the target
takes 1 point of bleed damage per round)
When: the target fails / succeeds its Will save
Then: failed — bleeding condition, 1 hp lost at the start of its turn;
saved — no condition, no bleed damage."""
def test_failed_save_deals_one_bleed_damage_per_round(self) -> None:
caster = _make_caster()
target = _make_target(hp_max=30)
# Will save d20=2 (+2) vs DC 14 -> fail.
engine, c, t = _make_engine(caster, target, [2])
engine._cast_spell(c, t, "Bleed")
assert any(ac.condition.name == "bleeding" for ac in t.active_conditions)
assert t.bleed_damage == 1
# The target's next turn start: the 1-round condition expires and its
# final bleed tick lands (1 hp).
engine._decrement_durations(t)
assert t.hp == 30 - 1
assert t.bleed_damage == 0
def test_successful_save_does_not_bleed(self) -> None:
caster = _make_caster()
target = _make_target(hp_max=30)
# Will save natural 20 -> auto-success (SRD: Will negates).
engine, c, t = _make_engine(caster, target, [20])
engine._cast_spell(c, t, "Bleed")
assert not any(ac.condition.name == "bleeding" for ac in t.active_conditions)
assert t.bleed_damage == 0
assert t.hp == 30
class TestTargetedSpellSaveNegates:
"""Given: a targeted spell with a save (half_on_save=False)
When: the target succeeds / fails its save
Then: full damage on failure, ZERO damage on success (save negates)."""
def test_successful_save_deals_no_damage(self) -> None:
caster = _make_caster()
target = _make_target(hp_max=100)
# 1 save roll (natural 20 = success) + 2 damage dice
engine, c, t = _make_engine(
caster, target, [20, 3, 3], spell_registry={"Save Negates Bolt": _SAVE_NEGATES_BOLT}
)
hp_before = t.hp
engine._cast_spell(c, t, "Save Negates Bolt")
assert t.hp == hp_before
assert any("0 damage" in line for line in engine._transcript)
def test_failed_save_deals_full_damage(self) -> None:
caster = _make_caster()
target = _make_target(hp_max=100)
# 1 save roll (natural 1 = failure) + 2 damage dice
engine, c, t = _make_engine(
caster, target, [1, 3, 3], spell_registry={"Save Negates Bolt": _SAVE_NEGATES_BOLT}
)
hp_before = t.hp
engine._cast_spell(c, t, "Save Negates Bolt")
assert t.hp == hp_before - 6
class TestLightningBoltSave:
"""Given: Lightning Bolt (line, ref save for half, half_on_save=True)
When: the target succeeds its save
Then: damage is still halved (half-on-save is declared for line effects)."""
def test_successful_save_still_halves(self) -> None:
caster = _make_caster()
target = _make_target(hp_max=200)
# 1 save roll (natural 20 = success) + 6 damage dice
engine, c, t = _make_engine(caster, target, [20, 3, 3, 3, 3, 3, 3])
hp_before = t.hp
engine._cast_spell(c, t, "Lightning Bolt")
full = 3 + 3 + 3 + 3 + 3 + 3
assert t.hp == hp_before - (full // 2)
class TestDamageEffectHalfOnSaveDefault:
"""Given: a DamageEffect constructed without half_on_save
When: the field is read
Then: it defaults to False (save negates for targeted effects)."""
def test_defaults_false(self) -> None:
effect = DamageEffect(formula=parse_dice("1d4"), types=("fire",))
assert effect.half_on_save is False
def test_loader_defaults_false(self, tmp_path) -> None:
spell_file = tmp_path / "test_spell.json"
spell_file.write_text(
'{"name": "Test Bolt", "level": 1, "school": "evocation",'
' "range": "medium", "save": "ref", "effects": ['
'{"type": "damage", "formula": "1d4", "types": ["fire"]}]}'
)
spell = load_spell(spell_file)
assert isinstance(spell.effects[0], DamageEffect)
assert spell.effects[0].half_on_save is False
class TestHoldPerson:
"""Given: a caster casting Hold Person (will negates, paralyzed)
When: target fails save
Then: paralyzed condition is applied."""
def test_failed_save_applies_condition(self) -> None:
caster = _make_caster()
target = _make_target()
engine, c, t = _make_engine(caster, target, [2])
engine._cast_spell(c, t, "Hold Person")
assert any(ac.condition.name == "paralyzed" for ac in t.active_conditions)
def test_successful_save_no_condition(self) -> None:
caster = _make_caster()
target = _make_target()
engine, c, t = _make_engine(caster, target, [20])
engine._cast_spell(c, t, "Hold Person")
assert not any(ac.condition.name == "paralyzed" for ac in t.active_conditions)
def test_transcript_shows_negated_on_save(self) -> None:
caster = _make_caster()
target = _make_target()
engine, c, t = _make_engine(caster, target, [20])
engine._cast_spell(c, t, "Hold Person")
assert any("negated" in line for line in engine._transcript)
class TestFear:
"""Given: a caster casting Fear (SRD spells/fear.md: panicked unless the
Will save succeeds)
When: target fails save
Then: panicked condition is applied."""
def test_failed_save_applies_panicked(self) -> None:
caster = _make_caster()
target = _make_target()
engine, c, t = _make_engine(caster, target, [2])
engine._cast_spell(c, t, "Fear")
assert any(ac.condition.name == "panicked" for ac in t.active_conditions)
class TestCureLightWounds:
"""Given: a caster casting Cure Light Wounds on a wounded target
When: _cast_spell is called
Then: HP is restored, capped at hp_max."""
def test_heals_damage(self) -> None:
caster = _make_caster()
target = _make_target(hp_max=30)
engine, c, t = _make_engine(caster, target, [3, 5], target_pos=(1, 0),
target_side="players")
t.hp = 10
engine._cast_spell(c, t, "Cure Light Wounds")
assert t.hp == 10 + (5 + 1)
def test_heal_capped_at_max(self) -> None:
caster = _make_caster()
target = _make_target(hp_max=30)
engine, c, t = _make_engine(caster, target, [3, 3, 6], target_pos=(1, 0),
target_side="players")
t.hp = 28
engine._cast_spell(c, t, "Cure Light Wounds")
assert t.hp == 30
class TestBullsStrength:
"""Given: a caster casting Bull's Strength on a target
When: _cast_spell is called
Then: buff modifiers are added to target's effects."""
def test_applies_buff_effects(self) -> None:
caster = _make_caster()
target = _make_target()
engine, c, t = _make_engine(caster, target, [3, 3], target_pos=(1, 0),
target_side="players")
engine._cast_spell(c, t, "Bull's Strength")
buff_mods = [m for b in t.active_buffs for m in b.modifiers]
atk_mods = [e for e in buff_mods if e.target == "attack"]
dmg_mods = [e for e in buff_mods if e.target == "damage"]
assert len(atk_mods) == 1
assert len(dmg_mods) == 1
assert atk_mods[0].value == 1
assert dmg_mods[0].value == 1
def test_transcript_shows_buff(self) -> None:
caster = _make_caster()
target = _make_target()
engine, c, t = _make_engine(caster, target, [3, 3], target_pos=(1, 0),
target_side="players")
engine._cast_spell(c, t, "Bull's Strength")
assert any("buff" in line for line in engine._transcript)
class TestOutOfRange:
"""Given: a target beyond the spell's maximum range
When: _cast_spell is called
Then: no damage is dealt and transcript shows OUT OF RANGE."""
def test_out_of_range_no_damage(self) -> None:
caster = _make_caster()
target = _make_target(hp_max=100)
engine, c, t = _make_engine(
caster, target, [], caster_pos=(0, 0), target_pos=(11, 11)
)
hp_before = t.hp
engine._cast_spell(c, t, "Burning Hands")
assert t.hp == hp_before
assert any("OUT OF RANGE" in line for line in engine._transcript)
class TestUnknownSpell:
"""Given: a spell name not in the registry
When: _cast_spell is called
Then: transcript shows UNKNOWN SPELL and no damage."""
def test_unknown_spell_logged(self) -> None:
caster = _make_caster()
target = _make_target(hp_max=100)
engine, c, t = _make_engine(caster, target, [])
hp_before = t.hp
engine._cast_spell(c, t, "Nonexistent Spell")
assert t.hp == hp_before
assert any("UNKNOWN SPELL" in line for line in engine._transcript)
class TestSpellDispatchViaExecute:
"""Given: an Action(kind='cast_spell') with a spell_name
When: _execute processes it
Then: the spell is cast via _cast_spell."""
def test_execute_cast_spell(self) -> None:
caster = _make_caster()
target = _make_target(hp_max=100)
engine, c, t = _make_engine(caster, target, [4])
hp_before = t.hp
engine._execute(
c, Action(kind="cast_spell", target_id="target", spell_name="Magic Missile")
)
assert t.hp < hp_before
class TestSpellDcScalesWithLevel:
"""Given: a caster with level N and casting ability mod M
When: a spell of level L is cast
Then: the DC is 10 + L + M."""
def test_dc_is_ten_plus_spell_level_plus_ability_mod(self) -> None:
caster = _make_caster(level=5) # int 18 -> mod +4
target = _make_target()
# 2 save rolls (2 targets in area) + 6 damage dice
engine, c, t = _make_engine(caster, target, [20, 20, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3])
engine._cast_spell(c, t, "Fireball") # fireball is level 3
# DC = 10 + spell_level(3) + int_mod(4) = 17
assert any("DC17" in line for line in engine._transcript)
class TestSpellDamageWithDr:
"""Given: a target with DR 5/- (bypassed by nothing)
When: a spell deals damage
Then: DR does NOT reduce spell damage (PF1e: spells/energy bypass DR)."""
def test_dr_does_not_reduce_spell_damage(self) -> None:
caster = _make_caster()
target = _make_target(hp_max=200)
# DR 5/- does NOT apply to spell damage
target_with_dr = target.model_copy(update={
"dr": DamageReduction(amount=5, bypass=frozenset()),
})
# 2 save rolls (2 targets in area) + 6 damage dice
queue = [2, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3]
engine, c, t = _make_engine(caster, target_with_dr, queue)
hp_before = t.hp
engine._cast_spell(c, t, "Fireball")
# Save fails (2), 6d6 rolls all 3 = 18 damage, DR ignored -> 18 damage
assert t.hp == hp_before - 18
class TestSummonMonster:
"""Given: a caster casting Summon Monster II
When: _cast_spell is called
Then: a celestial wolf is summoned adjacent to the caster."""
def test_summon_creature(self) -> None:
caster = _make_caster()
target = _make_target(hp_max=100)
engine, c, t = _make_engine(caster, target, [3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3])
hp_before = len(engine._states)
engine._cast_spell(c, t, "Summon Monster II")
# Caster + target + summoned = 3 combatants
assert len(engine._states) == hp_before + 1
# Find the summoned creature
summoned = [s for s in engine._states if s.combatant.id == "celestial_wolf"]
assert len(summoned) == 1
assert summoned[0].side == "players"
# Check it has correct HP
assert summoned[0].hp == 15
# Check transcript mentions summon
assert any("summon" in line.lower() for line in engine._transcript)
class TestSpellSlots:
"""Given: a caster with per-level spell_slots
When: a spell of a level is cast
Then: that level's slot count decrements and exhausted levels block further casts."""
def test_slot_depense(self) -> None:
caster = _make_caster().model_copy(update={"spell_slots": {1: 2, 2: 1}})
target = _make_target(hp_max=100)
engine, c, t = _make_engine(caster, target, [3])
engine._cast_spell(c, t, "Magic Missile") # level 1
assert c.spell_slots_left[1] == 1
assert c.spell_slots_left[2] == 1
def test_slots_epuises(self) -> None:
caster = _make_caster().model_copy(
update={"spell_slots": {1: 1}, "spells": ["Magic Missile"]}
)
target = _make_target(hp_max=100)
engine, c, t = _make_engine(caster, target, [3])
assert _choose_spell_action(engine, c, t) is not None
engine._cast_spell(c, t, "Magic Missile") # spends the only level-1 slot
assert c.spell_slots_left[1] == 0
assert _choose_spell_action(engine, c, t) is None
class TestActiveIsSingleSource:
"""Given: active_* is the single source of truth for conditions and buffs
When: _cast_spell applies a condition and a buff
Then: the legacy conditions/effects lists stay empty while the active_* lists are populated."""
def test_legacy_lists_stay_empty_active_lists_populated(self) -> None:
caster = _make_caster()
target = _make_target()
engine, c, t = _make_engine(caster, target, [2, 3, 3], target_pos=(1, 0),
target_side="players")
engine._cast_spell(c, t, "Hold Person")
engine._cast_spell(c, t, "Bull's Strength")
assert t.conditions == []
assert t.effects == []
assert any(ac.condition.name == "paralyzed" for ac in t.active_conditions)
assert any(b.source.startswith("spell:") for b in t.active_buffs)