"""Tests for the Resistance cantrip buff (SRD spells/resistance.md). Given: the Resistance cantrip encoding a +1 resistance bonus on fort/ref/will saves for 1 minute (10 rounds) as a BuffEffect When: casting on a willing ally / resolving saves / ticking durations / the default policy choosing a buff spell Then: the buff is applied without a save roll on a willing target, the resistance bonus type does not stack with itself, the bonus actually rides save rolls, and default_policy never proposes the cantrip (no spell slot) and never lets it preempt a leveled buff (Divine Favor) in the known-spell order. """ from __future__ import annotations from pathlib import Path from typing import TYPE_CHECKING from pf1e_simulator.combat import ( CombatantState, CombatEngine, _choose_spell_action, _find_buff_spell, ) 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, Saves, ) from pf1e_simulator.rng import ScriptedRng from pf1e_simulator.spells import BuffEffect, load_spell_registry if TYPE_CHECKING: from pf1e_simulator.effects import StatModifier _SPELLS_DIR = Path(__file__).resolve().parent.parent / "data" / "spells" def _make_combatant( cid: str, *, casting: bool = False, spells: list[str] | None = None ) -> 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=4, size="Medium", abilities=AbilityScores( str_score=10, dex_score=10, con_score=10, int_score=18, wis_score=10, cha_score=10, ), hp_max=30, ac=ACProfile(total=12, touch=12, flat_footed=12), bab=2, initiative_mod=0, speed_land_ft=30, saves=Saves(fort=2, ref=2, will=2), attacks=[attack], casting_ability="int" if casting else "wis", spell_slots={1: 3} if casting else {}, spells=spells or [], ) def _make_engine( queue: list[int], *, sides: dict[str, str] | None = None, positions: dict[str, tuple[int, int]] | None = None, combatants: list[Combatant] | None = None, ) -> CombatEngine: roster = combatants or [_make_combatant("hero")] side_map = sides or {c.id: "players" for c in roster} pos_map = positions or {c.id: (i, 0) for i, c in enumerate(roster)} states = [ CombatantState( combatant=c, side=side_map[c.id], pos=pos_map[c.id], hp=c.hp_max ) for c in roster ] legend = {".": TerrainType(type="floor", move_cost=1)} spec = MapSpec(name="test", terrain=tuple(["." * 12] * 12), legend=legend) grid = Grid.from_spec(spec) reg = load_spell_registry(_SPELLS_DIR) return CombatEngine(ScriptedRng(queue), grid, states, spell_registry=reg) def _resistance_mods(state: CombatantState) -> list[StatModifier]: return [m for b in state.active_buffs for m in b.modifiers] class TestResistanceLoading: """Given: data/spells/resistance.json When: load_spell_registry is called Then: Resistance is a BuffEffect granting +1 resistance to fort/ref/will for 10 rounds (1 minute).""" def test_resistance_is_a_buff_effect(self) -> None: reg = load_spell_registry(_SPELLS_DIR) spell = reg["Resistance"] assert len(spell.effects) == 1 effect = spell.effects[0] assert isinstance(effect, BuffEffect) assert effect.duration_rounds == 10 assert len(effect.modifiers) == 3 targets = {m.target for m in effect.modifiers} assert targets == {"fort", "ref", "will"} assert all(m.value == 1 for m in effect.modifiers) assert all(m.bonus_type == "resistance" for m in effect.modifiers) class TestResistanceCasting: """Given: a caster casting Resistance on a willing ally When: _cast_spell is called Then: the buff is applied without a save roll (harmless, willing target) and the modifiers ride save rolls.""" def test_applies_buff_without_save_roll(self) -> None: caster = _make_combatant("caster", casting=True) target = _make_combatant("target") engine = _make_engine( [3], sides={"caster": "players", "target": "players"}, positions={"caster": (0, 0), "target": (1, 0)}, combatants=[caster, target], ) c, t = engine.states engine._cast_spell(c, t, "Resistance") mods = _resistance_mods(t) assert len(mods) == 3 assert {m.target for m in mods} == {"fort", "ref", "will"} assert all(m.value == 1 and m.bonus_type == "resistance" for m in mods) # Will negates (harmless): a willing same-side target forgoes the save, # so the cast consumes no d20 from the queue. assert len(engine._rng._queue) == 1 def test_bonus_rides_save_rolls(self) -> None: caster = _make_combatant("caster", casting=True) target = _make_combatant("target") engine = _make_engine( [10], sides={"caster": "players", "target": "players"}, positions={"caster": (0, 0), "target": (1, 0)}, combatants=[caster, target], ) c, t = engine.states engine._cast_spell(c, t, "Resistance") # d20=10 + base will 2 + resistance 1 = 13 vs DC 12 (success). result = engine.resolve_save(t, "will", 12) assert result.success assert result.total == 13 def test_resistance_does_not_stack_with_itself(self) -> None: caster = _make_combatant("caster", casting=True) target = _make_combatant("target") engine = _make_engine( [10], sides={"caster": "players", "target": "players"}, positions={"caster": (0, 0), "target": (1, 0)}, combatants=[caster, target], ) c, t = engine.states engine._cast_spell(c, t, "Resistance") engine._cast_spell(c, t, "Resistance") # Two castings are two ActiveBuff entries, but the resistance bonus # type does not stack: resolve_modifiers keeps the higher value (+1). assert len(t.active_buffs) == 2 will_mods = [m for m in _resistance_mods(t) if m.target == "will"] assert len(will_mods) == 2 result = engine.resolve_save(t, "will", 12) assert result.total == 2 + 10 + 1 # base + d20 + single +1 def test_buff_expires_after_ten_rounds(self) -> None: caster = _make_combatant("caster", casting=True) target = _make_combatant("target") engine = _make_engine( [3], sides={"caster": "players", "target": "players"}, positions={"caster": (0, 0), "target": (1, 0)}, combatants=[caster, target], ) c, t = engine.states engine._cast_spell(c, t, "Resistance") assert len(t.active_buffs) == 1 for _ in range(10): engine._decrement_durations(t) assert t.active_buffs == [] class TestResistancePolicy: """Given: the default buff-spell selection When: a caster knows both Resistance (cantrip) and Divine Favor (level 1) Then: _find_buff_spell skips the slot-less cantrip and returns the leveled buff, and _choose_spell_action proposes Divine Favor, never Resistance.""" def test_find_buff_spell_skips_slotless_cantrip(self) -> None: caster = _make_combatant( "caster", casting=True, spells=["Resistance", "Divine Favor"] ) engine = _make_engine([3], combatants=[caster]) c = engine.states[0] assert _find_buff_spell(engine, c.combatant.spells) == "Divine Favor" def test_find_buff_spell_returns_none_without_leveled_buff(self) -> None: caster = _make_combatant("caster", casting=True, spells=["Resistance"]) engine = _make_engine([3], combatants=[caster]) c = engine.states[0] assert _find_buff_spell(engine, c.combatant.spells) is None def test_choose_spell_action_proposes_divine_favor(self) -> None: caster = _make_combatant( "caster", casting=True, spells=["Resistance", "Divine Favor"] ) target = _make_combatant("target") engine = _make_engine( [3], sides={"caster": "players", "target": "monsters"}, positions={"caster": (0, 0), "target": (5, 0)}, combatants=[caster, target], ) c, t = engine.states actions = _choose_spell_action(engine, c, t) assert actions is not None assert actions[0].kind == "cast_spell" assert actions[0].spell_name == "Divine Favor" assert actions[0].target_id == c.combatant.id def test_choose_spell_action_never_proposes_resistance(self) -> None: caster = _make_combatant("caster", casting=True, spells=["Resistance"]) target = _make_combatant("target") engine = _make_engine( [3], sides={"caster": "players", "target": "monsters"}, positions={"caster": (0, 0), "target": (5, 0)}, combatants=[caster, target], ) c, t = engine.states assert _choose_spell_action(engine, c, t) is None