"""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" _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", ) 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 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 (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(ac.condition.name == "frightened" 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, 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") 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)) 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)) 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)