diff --git a/data/spells/stabilize.json b/data/spells/stabilize.json index f9c0f16..e7060ca 100644 --- a/data/spells/stabilize.json +++ b/data/spells/stabilize.json @@ -7,6 +7,10 @@ "harmless": true, "base_dc": 10, "cast_time": "standard", - "effects": [], - "description": "SRD spells/stabilize.md: a cantrip - you target one living creature with -1 or fewer hit points and it is automatically stabilized, taking no further hp loss until it takes damage again. Will negates (harmless). Deferred: the dying/stabilization track (stabilized_hp, set only by _process_dying and healing) is not exposed to the spell-effect system, and the pre-cast gate rejects inactive (hp < 0) targets, so no spell can currently reach a dying creature; a zero-heal HealEffect hack would additionally stop bleeding, which the RAW stabilize does not do. No combat effect encoded." + "effects": [ + { + "type": "stabilize" + } + ], + "description": "SRD spells/stabilize.md: a cantrip - you target one living creature with -1 or fewer hit points and it is automatically stabilized, taking no further hp loss until it takes damage again. Will negates (harmless)." } diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 465dee5..d7ebe11 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -150,6 +150,7 @@ from pf1e_simulator.spells import ( RecurringDamageSpec, SaveType, SpellEffect, + StabilizeEffect, SummonEffect, TempHpEffect, close_range_ft, @@ -469,10 +470,22 @@ def _damage_effect_summary(effect: DamageEffect | PenaltyEffect, tags: list[str] return " ".join(parts) +def _simple_effect_summary( + effect: OneShotEffect | TempHpEffect | StabilizeEffect, +) -> str: + if isinstance(effect, OneShotEffect): + return ( + f"one-shot +{effect.value} {effect.bonus_type} on {effect.track}" + ) + if isinstance(effect, TempHpEffect): + return f"temp hp +{effect.amount} ({effect.duration_rounds}r)" + return "stabilize" + + def _non_damage_effect_summary( effect: ( ConditionEffect | HealEffect | BuffEffect | AreaEffect | SummonEffect - | OneShotEffect | TempHpEffect + | OneShotEffect | TempHpEffect | StabilizeEffect ) ) -> str: if isinstance(effect, ConditionEffect): @@ -483,12 +496,8 @@ def _non_damage_effect_summary( return f"area {effect.radius_ft}ft" if isinstance(effect, SummonEffect): return f"summon {effect.creature}" - if isinstance(effect, (OneShotEffect, TempHpEffect)): - return ( - f"one-shot +{effect.value} {effect.bonus_type} on {effect.track}" - if isinstance(effect, OneShotEffect) - else f"temp hp +{effect.amount} ({effect.duration_rounds}r)" - ) + if isinstance(effect, (OneShotEffect, TempHpEffect, StabilizeEffect)): + return _simple_effect_summary(effect) return f"buff {effect.duration_rounds}r" @@ -6904,7 +6913,12 @@ class CombatEngine: caster, spell, spell_name, center_pos, has_area=has_area ) return not point_ok - if not target.active: + if not target.active and not any( + isinstance(e, StabilizeEffect) for e in spell.effects + ): + # Stabilize (SRD spells/stabilize.md) targets a living creature + # at -1 or fewer hit points — an inactive state every other spell + # cannot reach, so it alone bypasses the active-target gate. return True dist_ft = self.creature_distance(caster, target) * _SQUARE_FT if dist_ft > spell_range_ft(spell, caster.combatant.level): @@ -7155,7 +7169,7 @@ class CombatEngine: self, effect: ( DamageEffect | ConditionEffect | HealEffect | BuffEffect - | PenaltyEffect | OneShotEffect | TempHpEffect + | PenaltyEffect | OneShotEffect | TempHpEffect | StabilizeEffect ), target: CombatantState, caster_live: _LiveStats, @@ -7193,8 +7207,8 @@ class CombatEngine: effect, target, caster=caster, saved=saved, empower=empower, maximize=maximize, spell_name=spell_name, ) - if isinstance(effect, (OneShotEffect, TempHpEffect)): - return self._apply_grant_effect( + if isinstance(effect, (OneShotEffect, TempHpEffect, StabilizeEffect)): + return self._apply_simple_effect( effect, target, saved=saved, spell_name=spell_name, ) return self._apply_buff_effect( @@ -7202,9 +7216,9 @@ class CombatEngine: caster_level=caster.combatant.level, ) - def _apply_grant_effect( + def _apply_simple_effect( self, - effect: OneShotEffect | TempHpEffect, + effect: OneShotEffect | TempHpEffect | StabilizeEffect, target: CombatantState, *, saved: bool, @@ -7214,7 +7228,28 @@ class CombatEngine: return self._apply_one_shot_effect( effect, target, saved=saved, spell_name=spell_name, ) - return self._apply_temp_hp_effect(effect, target, saved=saved) + if isinstance(effect, TempHpEffect): + return self._apply_temp_hp_effect(effect, target, saved=saved) + return self._apply_stabilize_effect(target, saved=saved) + + def _apply_stabilize_effect( + self, target: CombatantState, *, saved: bool + ) -> str: + """Stabilize a dying creature (SRD spells/stabilize.md). + + The spell targets a living creature at -1 or fewer hit points and + automatically stabilizes it: stabilized_hp is set to the target's + current hp, the same marker a successful Constitution check writes in + _process_dying, so the per-round hp loss stops. New damage below that + hp clears the marker and the creature resumes dying normally; a + creature at hp >= 0 or dead is unaffected. + """ + if saved: + return "stabilize negated" + if target.dead or target.hp >= 0: + return "stabilize no effect" + target.stabilized_hp = target.hp + return f"stabilized (hp {target.hp})" def _apply_one_shot_effect( self, effect: OneShotEffect, target: CombatantState, *, saved: bool, spell_name: str diff --git a/src/pf1e_simulator/spells.py b/src/pf1e_simulator/spells.py index e9a3ed0..37b0afd 100644 --- a/src/pf1e_simulator/spells.py +++ b/src/pf1e_simulator/spells.py @@ -303,6 +303,22 @@ class TempHpEffect: duration_rounds: int +@dataclass(frozen=True) +class StabilizeEffect: + """PF1e stabilize (SRD spells/stabilize.md: "you target a living creature + that has -1 or fewer hit points. That creature is automatically stabilized + and does not lose any further hit points"). + + The engine's dying track already carries the stabilization marker + (``CombatantState.stabilized_hp``, written by a successful Constitution + check in ``_process_dying``); the spell writes the same marker at the + target's current hp. The marker stops the per-round hp loss while + ``hp >= stabilized_hp`` and is cleared by new damage below it, so the + creature "continues dying normally" (SRD) after being hurt again. A + creature at hp >= 0 or dead is unaffected. + """ + + @dataclass(frozen=True) class PenaltyEffect: """PF1e rolled ability penalty (SRD Ray of Enfeeblement). @@ -385,6 +401,7 @@ class SummonEffect: SpellEffect = ( DamageEffect | ConditionEffect | HealEffect | BuffEffect | PenaltyEffect | AreaEffect | SummonEffect | OneShotEffect | TempHpEffect + | StabilizeEffect ) @@ -801,6 +818,13 @@ def _parse_temp_hp_effect(effect: dict[str, Json]) -> TempHpEffect: return TempHpEffect(amount=amount, duration_rounds=duration) +def _parse_stabilize_effect(effect: dict[str, Json]) -> StabilizeEffect: + if effect.keys() - {"type"}: + msg = f"stabilize effect takes no fields, got {sorted(effect.keys() - {'type'})}" + raise ValueError(msg) + return StabilizeEffect() + + def _parse_penalty_effect(effect: dict[str, Json]) -> PenaltyEffect: flat_raw = effect.get("scaling_flat") scaling_flat = None if flat_raw is None else _parse_flat_scaling(flat_raw) @@ -855,6 +879,7 @@ _EFFECT_PARSERS: dict[str, Callable[[dict[str, Json]], SpellEffect]] = { "buff": _parse_buff_effect, "one_shot": _parse_one_shot_effect, "temp_hp": _parse_temp_hp_effect, + "stabilize": _parse_stabilize_effect, "penalty": _parse_penalty_effect, "area": _parse_area_effect, "summon": _parse_summon_effect, diff --git a/tests/test_spell_registry_batch3.py b/tests/test_spell_registry_batch3.py index bcfe1d4..c3e5323 100644 --- a/tests/test_spell_registry_batch3.py +++ b/tests/test_spell_registry_batch3.py @@ -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, ) diff --git a/tests/test_stabilize.py b/tests/test_stabilize.py new file mode 100644 index 0000000..00bcb4d --- /dev/null +++ b/tests/test_stabilize.py @@ -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