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
This commit is contained in:
@@ -7,6 +7,16 @@
|
|||||||
"harmless": true,
|
"harmless": true,
|
||||||
"base_dc": 10,
|
"base_dc": 10,
|
||||||
"cast_time": "standard",
|
"cast_time": "standard",
|
||||||
"effects": [],
|
"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."
|
{
|
||||||
|
"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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8837,11 +8837,26 @@ def _find_offensive_spell(
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _find_buff_spell(engine: CombatEngine, spells_known: list[str]) -> str | None:
|
def _find_buff_spell(
|
||||||
"""Find a 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:
|
for name in spells_known:
|
||||||
spell = engine.spell_registry.get(name)
|
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 name
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user