diff --git a/data/spells/resistance.json b/data/spells/resistance.json index 0ce1430..52a223d 100644 --- a/data/spells/resistance.json +++ b/data/spells/resistance.json @@ -7,6 +7,16 @@ "harmless": true, "base_dc": 10, "cast_time": "standard", - "effects": [], - "description": "SRD spells/resistance.md: a cantrip - the subject gains a +1 resistance bonus on saving throws for 1 minute. Will negates (harmless). Deferred: the resistance bonus type exists in the stacking model, so the buff is expressible - but _find_buff_spell returns the FIRST BuffEffect spell in known-spell order and cantrips cost no slots, so encoding it makes default_policy cast Resistance every round instead of attacking or leveling buffs (for Tammara it would preempt Divine Favor and never kill anything). A buff-priority policy is a redesign; no mechanical effect is applied." + "effects": [ + { + "type": "buff", + "modifiers": [ + { "target": "fort", "value": 1, "bonus_type": "resistance" }, + { "target": "ref", "value": 1, "bonus_type": "resistance" }, + { "target": "will", "value": 1, "bonus_type": "resistance" } + ], + "duration_rounds": 10 + } + ], + "description": "SRD spells/resistance.md: a cantrip - the subject gains a +1 resistance bonus on saving throws for 1 minute. Will negates (harmless). Modeled as a BuffEffect granting +1 resistance to fort/ref/will for 10 rounds (1 minute); the resistance bonus type does not stack with itself, so a second Resistance (or a cloak of resistance) keeps the higher value. default_policy never casts it: cantrips have no spell slot, and _find_buff_spell skips spells the caster has no slot for, so Resistance cannot preempt a leveled buff (Divine Favor) in the known-spell order." } diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index a23ffa5..897f518 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -8837,11 +8837,26 @@ def _find_offensive_spell( return None -def _find_buff_spell(engine: CombatEngine, spells_known: list[str]) -> str | None: - """Find a buff spell.""" +def _find_buff_spell( + engine: CombatEngine, spells_known: list[str] +) -> str | None: + """Find a buff spell the policy will actually cast. + + PF1e cantrips (level 0) have no spell slot in the engine's budget + (``spell_slots_left`` has no level-0 key) and the policy deliberately + never auto-casts them (a cantrip buff would be cast every round, degrading + the policy), so a cantrip buff such as Resistance must not preempt a + leveled buff (Divine Favor) in the known-spell order. Leveled buffs keep + the historical first-match behavior: if the first one's slots are + exhausted the buff branch gives up (the golden pins that). + """ for name in spells_known: spell = engine.spell_registry.get(name) - if spell and any(isinstance(e, BuffEffect) for e in spell.effects): + if ( + spell + and spell.level > 0 + and any(isinstance(e, BuffEffect) for e in spell.effects) + ): return name return None diff --git a/tests/test_resistance.py b/tests/test_resistance.py new file mode 100644 index 0000000..c8fa778 --- /dev/null +++ b/tests/test_resistance.py @@ -0,0 +1,249 @@ +"""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