fix(rules): stabilize is a StabilizeEffect that auto-stabilizes a dying creature

This commit is contained in:
2026-09-20 10:48:08 +02:00
parent a8a65b18cd
commit f7886135bd
5 changed files with 338 additions and 16 deletions
+2
View File
@@ -27,6 +27,7 @@ from pf1e_simulator.spells import (
PenaltyEffect,
SpellEffect,
SpellSpec,
StabilizeEffect,
SummonEffect,
TempHpEffect,
load_spell,
@@ -84,6 +85,7 @@ _EFFECT_TYPES = (
PenaltyEffect,
AreaEffect,
SummonEffect,
StabilizeEffect,
TempHpEffect,
)
+256
View File
@@ -0,0 +1,256 @@
"""Tests for the Stabilize cantrip (SRD spells/stabilize.md).
Given: the Stabilize cantrip encoding an automatic stabilization of a dying
creature as a StabilizeEffect
When: casting on a dying ally / a conscious creature / a dead creature / a
hostile target / the default policy choosing a spell
Then: a dying same-side target is stabilized without a save roll (harmless,
willing target) and stops losing hp until new damage below the stabilized hp
resumes dying; conscious and dead targets are unaffected; a hostile target
rolls its Will save (success negates); and default_policy never proposes the
cantrip (no spell slot, level-0 skipped).
"""
from __future__ import annotations
from pathlib import Path
from pf1e_simulator.combat import (
CombatantState,
CombatEngine,
_choose_spell_action,
)
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 StabilizeEffect, load_spell_registry
_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)
class TestStabilizeLoading:
"""Given: data/spells/stabilize.json
When: load_spell_registry is called
Then: Stabilize is a StabilizeEffect cantrip (conjuration, close, Will
negates (harmless))."""
def test_stabilize_is_a_stabilize_effect(self) -> None:
reg = load_spell_registry(_SPELLS_DIR)
spell = reg["Stabilize"]
assert len(spell.effects) == 1
effect = spell.effects[0]
assert isinstance(effect, StabilizeEffect)
assert spell.level == 0
assert spell.school == "conjuration"
assert spell.range == "close"
assert spell.save == "will"
assert spell.harmless is True
class TestStabilizeCasting:
"""Given: a caster casting Stabilize
When: the target is dying / conscious / dead / hostile
Then: a dying same-side target is stabilized without a save roll
(harmless, willing target) and stops losing hp; conscious and dead
targets are unaffected; a hostile target rolls its Will save."""
def test_stabilizes_dying_ally_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
t.hp = -5
engine._cast_spell(c, t, "Stabilize")
assert t.stabilized_hp == -5
# Saving Throw 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_stabilized_creature_stops_losing_hp(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
t.hp = -5
engine._cast_spell(c, t, "Stabilize")
engine._process_dying(t)
# Stabilized: no Constitution check, no further hp loss.
assert t.hp == -5
assert t.stabilized_hp == -5
assert len(engine._rng._queue) == 1
def test_new_damage_below_stabilized_hp_resumes_dying(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
t.hp = -5
engine._cast_spell(c, t, "Stabilize")
t.hp = -6
engine._process_dying(t)
# New damage below the stabilized hp resumes dying: the marker is
# cleared and the Constitution check is rolled (d20=3 + CON(0) - 6
# = -3 < DC 10 -> fails, loses 1 hp).
assert t.stabilized_hp is None
assert t.hp == -7
assert len(engine._rng._queue) == 0
def test_no_effect_on_conscious_creature(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
t.hp = 5
engine._cast_spell(c, t, "Stabilize")
assert t.stabilized_hp is None
assert t.hp == 5
def test_no_effect_on_dead_creature(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
t.hp = -12 # below -CON (con 10): dead
engine._cast_spell(c, t, "Stabilize")
assert t.stabilized_hp is None
def test_hostile_target_save_failure_stabilizes(self) -> None:
caster = _make_combatant("caster", casting=True)
target = _make_combatant("target")
engine = _make_engine(
[3],
sides={"caster": "players", "target": "monsters"},
positions={"caster": (0, 0), "target": (1, 0)},
combatants=[caster, target],
)
c, t = engine.states
t.hp = -5
engine._cast_spell(c, t, "Stabilize")
# d20=3 + will 2 = 5 < DC 14 (10 + level 0 + int 4): save fails, the
# dying target stabilizes.
assert t.stabilized_hp == -5
assert len(engine._rng._queue) == 0
def test_hostile_target_save_success_negates(self) -> None:
caster = _make_combatant("caster", casting=True)
target = _make_combatant("target")
engine = _make_engine(
[20],
sides={"caster": "players", "target": "monsters"},
positions={"caster": (0, 0), "target": (1, 0)},
combatants=[caster, target],
)
c, t = engine.states
t.hp = -5
engine._cast_spell(c, t, "Stabilize")
# d20=20 + will 2 = 22 >= DC 14: save succeeds, the spell is negated.
assert t.stabilized_hp is None
assert len(engine._rng._queue) == 0
class TestStabilizePolicy:
"""Given: the default spell selection
When: a caster knows Stabilize (cantrip)
Then: _choose_spell_action never proposes it (no spell slot, level-0
skipped, and StabilizeEffect is neither a heal nor a buff)."""
def test_choose_spell_action_never_proposes_stabilize(self) -> None:
caster = _make_combatant("caster", casting=True, spells=["Stabilize"])
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