"""Spell resistance and energy resistance/immunity (Package P1).""" from __future__ import annotations from pf1e_simulator.combat import CombatEngine from pf1e_simulator.dice import parse_dice from pf1e_simulator.models import ( AbilityScores, ACProfile, AttackSpec, Combatant, DamageComponent, Saves, ) from pf1e_simulator.rng import ScriptedRng from pf1e_simulator.spells import AreaEffect, DamageEffect, SpellSpec from tests.test_observation_enriched import _snapshot, make_grid, make_state def _make_combatant( id_: str, *, hp_max: int = 100, attacks: tuple[AttackSpec, ...] = (), spell_resistance: int = 0, resistances: dict[str, int] | None = None, immunities: tuple[str, ...] = (), ) -> Combatant: return Combatant( id=id_, name=id_, level=1, abilities=AbilityScores( str_score=10, dex_score=10, con_score=10, int_score=10, wis_score=10, cha_score=10, ), ac=ACProfile(total=10, touch=10, flat_footed=10), bab=1, initiative_mod=0, speed_land_ft=30, hp_max=hp_max, saves=Saves(fort=0, ref=0, will=0), attacks=list(attacks), spell_resistance=spell_resistance, resistances=resistances or {}, immunities=immunities, ) def _engine( combatants: list[Combatant], positions: list[tuple[int, int]], sides: list[str], rng: list[int], spell_registry: dict[str, SpellSpec] | None = None, ) -> CombatEngine: states = [ make_state(c, side, pos) for c, side, pos in zip(combatants, sides, positions, strict=True) ] return CombatEngine( ScriptedRng(rng), make_grid(), states, spell_registry=spell_registry, ) _MAGIC_MISSILE = SpellSpec( name="Magic Missile", level=1, school="evocation", range="medium", save="none", base_dc=10, effects=( DamageEffect( formula=parse_dice("1d4+1"), types=("force",), ), ), ) _FIREBALL = SpellSpec( name="Fireball", level=3, school="evocation", range="long", save="ref", base_dc=10, effects=( AreaEffect( radius_ft=20, effects=( DamageEffect( formula=parse_dice("8d6"), types=("fire",), half_on_save=True, ), ), save_for_half=True, ), ), ) def test_sr_blocks_all_effects() -> None: caster = _make_combatant("caster", spell_resistance=0) target = _make_combatant("target", spell_resistance=15) eng = _engine( [caster, target], [(0, 0), (0, 2)], ["players", "monsters"], [8], spell_registry={"Magic Missile": _MAGIC_MISSILE}, ) cs = eng._states[0] ts = eng._states[1] eng._cast_spell(cs, ts, "Magic Missile") assert ts.hp == 100 def test_sr_passes_when_cl_suffices() -> None: caster = _make_combatant("caster", spell_resistance=0) target = _make_combatant("target", spell_resistance=10) eng = _engine( [caster, target], [(0, 0), (0, 2)], ["players", "monsters"], [10, 3], spell_registry={"Magic Missile": _MAGIC_MISSILE}, ) cs = eng._states[0] ts = eng._states[1] eng._cast_spell(cs, ts, "Magic Missile") assert ts.hp < 100 def test_per_target_sr_in_area_spell() -> None: caster = _make_combatant("caster", spell_resistance=0) t1 = _make_combatant("t1", spell_resistance=15) t2 = _make_combatant("t2", spell_resistance=0) eng = _engine( [caster, t1, t2], [(0, 0), (0, 3), (0, 5)], ["players", "monsters", "monsters"], [20, 3, 4, 4, 4, 4, 4, 4, 4, 4], spell_registry={"Fireball": _FIREBALL}, ) cs = eng._states[0] ts1 = eng._states[1] ts2 = eng._states[2] eng._cast_spell(cs, ts2, "Fireball") assert ts1.hp == 100 assert ts2.hp < 100 def test_no_sr_check_when_sr_zero() -> None: caster = _make_combatant("caster", spell_resistance=0) target = _make_combatant("target", spell_resistance=0) eng = _engine( [caster, target], [(0, 0), (0, 2)], ["players", "monsters"], [3], spell_registry={"Magic Missile": _MAGIC_MISSILE}, ) cs = eng._states[0] ts = eng._states[1] eng._cast_spell(cs, ts, "Magic Missile") assert ts.hp == 96 assert not any("spell resistance" in line for line in eng._transcript) def test_fire_resistance_reduces_spell_damage() -> None: caster = _make_combatant("caster", spell_resistance=0) target = _make_combatant( "target", spell_resistance=0, resistances={"fire": 5}, ) eng = _engine( [caster, target], [(0, 0), (0, 5)], ["players", "monsters"], [1, 3, 3, 3, 3, 3, 3, 3, 3], spell_registry={"Fireball": _FIREBALL}, ) cs = eng._states[0] ts = eng._states[1] eng._cast_spell(cs, ts, "Fireball") assert ts.hp < 100 def test_fire_immunity_negates_damage() -> None: caster = _make_combatant("caster", spell_resistance=0) target = _make_combatant( "target", spell_resistance=0, immunities=("fire",), ) eng = _engine( [caster, target], [(0, 0), (0, 5)], ["players", "monsters"], [1, 3, 3, 3, 3, 3, 3, 3, 3], spell_registry={"Fireball": _FIREBALL}, ) cs = eng._states[0] ts = eng._states[1] eng._cast_spell(cs, ts, "Fireball") assert ts.hp == 100 def test_weapon_fire_damage_reduced_by_resistance() -> None: fire_weapon = AttackSpec( id="flaming-sword", name="flaming sword", kind="melee", attack_bonus=20, damage=( DamageComponent(formula=parse_dice("1d8"), types=("slashing",)), DamageComponent(formula=parse_dice("1d6"), types=("fire",)), ), damage_bonus=0, crit_range=20, crit_mult=2, ) hero = _make_combatant("hero", attacks=(fire_weapon,)) foe = _make_combatant( "foe", resistances={"fire": 10}, ) eng = _engine( [hero, foe], [(0, 0), (0, 1)], ["players", "monsters"], [15, 7, 6], ) hs = eng._states[0] fs = eng._states[1] eng._single_attack(hs, fs, fire_weapon) assert fs.hp < 100 def test_snapshot_exposes_new_keys() -> None: hero = _make_combatant( "hero", spell_resistance=15, resistances={"fire": 10, "cold": 5}, immunities=("electricity",), ) foe = _make_combatant("foe") eng = _engine( [hero, foe], [(0, 0), (0, 3)], ["players", "monsters"], [], ) hs = eng._states[0] obs = _snapshot(eng, hs) c = obs["combatants"][0] assert c["spell_resistance"] == 15 assert c["resistances"] == {"fire": 10, "cold": 5} assert c["immunities"] == ("electricity",)