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