Files
pf1e-simulator/tests/test_resistance.py
T
ctan 1238c40e43 fix(rules): resistance is a +1 resistance bonus on fort/ref/will saves for 1 minute - SRD spells/resistance.md grants the subject a +1 resistance bonus on saving throws for 1 minute (Will negates, harmless); the resistance bonus type already exists in the stacking model (effects.py BonusType), so the buff is expressible as a BuffEffect and was only deferred because _find_buff_spell returns the FIRST BuffEffect in known-spell order and cantrips cost no slots - encoding it would have made default_policy cast Resistance every round (for Tammara it would preempt Divine Favor and never kill anything); resistance.json now carries a BuffEffect (+1 resistance on fort/ref/will, 10 rounds = 1 minute) and _find_buff_spell skips level-0 spells (cantrips 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 Resistance cannot 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); the resistance bonus type does not stack with itself (resolve_modifiers keeps the higher value); +9 tests (test_resistance.py: buff shape, cast on a willing ally consumes no d20 and applies without a save roll, the bonus rides save rolls, no self-stacking, expiry at 10 rounds, _find_buff_spell skips the slot-less cantrip / returns None without a leveled buff, _choose_spell_action proposes Divine Favor and never Resistance); golden untouched; 1714 tests green
2026-09-20 10:10:32 +02:00

250 lines
9.3 KiB
Python

"""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