"""Tests for spell system: loading, range, casting (damage, save, condition, heal, buff).""" from __future__ import annotations from pathlib import Path 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" 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", ) 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, ) -> tuple[CombatEngine, CombatantState, CombatantState]: c = CombatantState(combatant=caster, side="players", pos=caster_pos, hp=caster.hp_max) t = CombatantState(combatant=target, side="monsters", 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 11 JSON spell files When: load_spell_registry is called Then: all 11 spells are loaded with correct fields.""" def test_loads_all_10_spells(self) -> None: reg = load_spell_registry(_SPELLS_DIR) assert len(reg) == 11 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 set(reg) == expected 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 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 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(cond.name == "paralyzed" for cond in t.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(cond.name == "paralyzed" for cond in t.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 (will negates, frightened) When: target fails save Then: frightened condition is applied.""" def test_failed_save_applies_frightened(self) -> None: caster = _make_caster() target = _make_target() engine, c, t = _make_engine(caster, target, [2]) engine._cast_spell(c, t, "Fear") assert any(cond.name == "frightened" for cond in t.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, 3, 5], target_pos=(1, 0)) 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)) 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)) engine._cast_spell(c, t, "Bull's Strength") atk_mods = [e for e in t.effects if e.target == "attack"] dmg_mods = [e for e in t.effects 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)) 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 vs the spell's damage type When: a spell deals damage Then: DR is applied after save halving.""" def test_dr_reduces_spell_damage(self) -> None: caster = _make_caster() target = _make_target(hp_max=200) # DR 5/- (bypassed by nothing) applies to all damage including fire 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 5/- -> 13 damage assert t.hp == hp_before - 13 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