"""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 assert eff.save_partial is None def test_fear_condition_has_save_partial(self) -> None: # PF1e (SRD spells/fear.md): a successful Will save imposes shaken 1 # round instead of panicked — the partial-save spec. spell = load_spell(_SPELLS_DIR / "fear.json") area = spell.effects[0] assert isinstance(area, AreaEffect) eff = area.effects[0] assert isinstance(eff, ConditionEffect) assert eff.condition_name == "panicked" assert eff.save_partial == ("shaken", 1) 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; the level-5 caster fires the SRD three missiles (1 + one per two levels beyond 1st), each 1d4+1.""" def test_deals_damage_no_save(self) -> None: caster = _make_caster() target = _make_target(hp_max=100) # 3 missiles (level 5), each rolls 1d4+1 -> three scripted d4s of 3 engine, c, t = _make_engine(caster, target, [3, 3, 3]) hp_before = t.hp engine._cast_spell(c, t, "Magic Missile") assert t.hp < hp_before assert t.hp == hp_before - 3 * (3 + 1) def test_transcript_shows_no_save(self) -> None: caster = _make_caster() target = _make_target(hp_max=100) engine, c, t = _make_engine(caster, target, [3, 3, 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) + 5 damage dice (1d6/level, lvl 5) engine, c, t = _make_engine(caster, target, [2, 2, 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) def test_successful_save_half_damage(self) -> None: caster = _make_caster() target = _make_target(hp_max=200) # 2 save rolls (2 targets in area) + 5 damage dice (1d6/level, lvl 5) engine, c, t = _make_engine(caster, target, [20, 20, 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 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) + 5 damage dice (1d6/level, lvl 5) engine, c, t = _make_engine(caster, target, [20, 20, 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 (SRD spells/lightning-bolt.md: a 120-ft. line, 1d6 per caster level max 10d6, ref save for half) cast by the level-5 fixture at a target straight ahead When: the primary target succeeds its save Then: damage is halved — and the caster is NOT caught by their own line (CRB, Magic — Aiming a Spell: the line "starts from any corner of your square", so the caster's own square is outside the template).""" def test_successful_save_still_halves(self) -> None: caster = _make_caster() target = _make_target(hp_max=200) # primary save (target, SAVED on a natural 20) + 5 damage dice # (1d6/level, level 5); the caster is no longer in the line so no # secondary save and no caster damage dice are rolled. engine, c, t = _make_engine( caster, target, [20] + [3] * 5 ) hp_before = t.hp engine._cast_spell(c, t, "Lightning Bolt") full = 3 + 3 + 3 + 3 + 3 assert t.hp == hp_before - (full // 2) assert c.hp == 20 # the caster's own square is outside the line 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; a successful save makes the creature shaken 1 round) When: target fails / succeeds the save Then: panicked is applied / shaken (partial save) is applied instead.""" 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) assert not any(ac.condition.name == "shaken" for ac in t.active_conditions) def test_successful_save_applies_shaken(self) -> None: """Given: the target succeeds its Will save (natural 20) When: Fear resolves Then: it is shaken for 1 round (partial save), not panicked.""" caster = _make_caster() target = _make_target() engine, c, t = _make_engine(caster, target, [20]) engine._cast_spell(c, t, "Fear") shaken = [ac for ac in t.active_conditions if ac.condition.name == "shaken"] assert len(shaken) == 1 assert shaken[0].rounds_left == 1 assert not 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) # level-5 caster fires 3 missiles (1d4+1 each) engine, c, t = _make_engine(caster, target, [4, 4, 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) + 5 damage dice each (level 5) queue = [2, 2, 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), 5d6 rolls all 3 = 15 damage, DR ignored -> 15 damage assert t.hp == hp_before - 15 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) # level-5 caster fires 3 missiles (1d4+1 each) engine, c, t = _make_engine(caster, target, [3, 3, 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) # level-5 caster fires 3 missiles (1d4+1 each) engine, c, t = _make_engine(caster, target, [3, 3, 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)