"""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 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 ( 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], ) 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] = (1, 1), 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 10 JSON spell files When: load_spell_registry is called Then: all 10 spells are loaded with correct fields.""" def test_loads_all_10_spells(self) -> None: reg = load_spell_registry(_SPELLS_DIR) assert len(reg) == 10 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", } 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 len(spell.effects) == 1 assert isinstance(spell.effects[0], DamageEffect) assert spell.effects[0].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) engine, c, t = _make_engine(caster, target, [2, 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) engine, c, t = _make_engine(caster, target, [20, 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() engine, c, t = _make_engine(caster, target, [20, 1, 1, 1, 1, 1, 1]) 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, [20, 5]) 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, [20, 6]) 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, [20]) 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, [20]) 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 When: a spell with base_dc 10 is cast Then: the DC is 10 + N.""" def test_dc_is_base_plus_level(self) -> None: caster = _make_caster(level=5) target = _make_target() engine, c, t = _make_engine(caster, target, [20, 1, 1, 1, 1, 1, 1]) engine._cast_spell(c, t, "Fireball") assert any("DC15" 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) target_with_dr = target.model_copy(update={ "dr": DamageReduction(amount=5, bypass=frozenset({"bludgeoning"})), }) engine, c, t = _make_engine(caster, target_with_dr, [2, 6, 6, 6, 6, 6, 6]) hp_before = t.hp engine._cast_spell(c, t, "Fireball") full = 6 * 6 assert t.hp == hp_before - max(0, full - 5)