From 4b4e6f2726e61215a1f2756c51c9c292616badac Mon Sep 17 00:00:00 2001 From: ctan Date: Sat, 12 Sep 2026 10:35:44 +0200 Subject: [PATCH] fix(rules): stabilize a dying creature when healed PF1e (CRB, Stable Characters and Recovery): any healing that cures a dying creature of even 1 point makes it stable. _apply_heal_effect and _channel_heal now set stabilized_hp when the healed hp stays negative, so the creature stops losing 1 hp per round. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/pf1e_simulator/combat.py | 10 ++++++++ tests/test_dying_and_crits.py | 47 +++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 1122edc..83a7c5c 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -4622,6 +4622,11 @@ class CombatEngine: ) -> None: old_hp = affected_state.hp affected_state.hp = min(affected_state.hp + dmg, affected_state.combatant.hp_max) + # PF1e (CRB, Stable Characters and Recovery): "If any sort of healing + # cures the dying character of even 1 point of damage, he becomes + # stable and stops losing hit points." + if affected_state.hp < 0: + affected_state.stabilized_hp = affected_state.hp actual = affected_state.hp - old_hp self._stats[state.combatant.id].damage_dealt += actual self._stop_bleeding(affected_state) @@ -5503,6 +5508,11 @@ class CombatEngine: heal = int(heal * 1.5) old_hp = target.hp target.hp = min(target.hp + heal, target.combatant.hp_max) + # PF1e (CRB, Stable Characters and Recovery): "If any sort of healing + # cures the dying character of even 1 point of damage, he becomes + # stable and stops losing hit points." + if target.hp < 0: + target.stabilized_hp = target.hp actual = target.hp - old_hp self._stop_bleeding(target) return f"+{actual} HP" diff --git a/tests/test_dying_and_crits.py b/tests/test_dying_and_crits.py index 1cb4452..2b1bb6a 100644 --- a/tests/test_dying_and_crits.py +++ b/tests/test_dying_and_crits.py @@ -13,6 +13,7 @@ from pf1e_simulator.models import ( Saves, ) from pf1e_simulator.rng import ScriptedRng +from pf1e_simulator.spells import HealEffect from tests.test_observation_enriched import _snapshot, make_grid, make_state @@ -212,6 +213,52 @@ def test_dying_with_bleed_reaches_death_faster_than_without() -> None: assert not plain.dead +def test_heal_spell_stabilizes_dying_creature() -> None: + # PF1e (CRB, Stable Characters and Recovery): healing a dying creature for + # even 1 point makes it stable — it stops losing 1 hp per round. + eng, s = _solo(con_score=10) + s.hp = -3 + eng._rng = ScriptedRng([1]) # 1d8 -> 1: only 1 point cured + result = eng._apply_heal_effect( + HealEffect(formula=parse_dice("1d8")), s, empower=False, maximize=False + ) + assert result == "+1 HP" + assert s.hp == -2 + assert s.stabilized_hp == -2 + # Stable: the dying slot consumes no roll and loses no hp. + eng._rng = ScriptedRng([]) + eng._process_dying(s) + assert s.hp == -2 + assert s.stabilized_hp == -2 + + +def test_channel_heal_stabilizes_dying_creature() -> None: + eng, s = _solo(con_score=10) + channeler = eng._states[0] + s.hp = -3 + eng._channel_heal(channeler, s, 1) # exactly 1 point cured + assert s.hp == -2 + assert s.stabilized_hp == -2 + eng._rng = ScriptedRng([]) + eng._process_dying(s) + assert s.hp == -2 + + +def test_heal_to_positive_hp_does_not_stabilize() -> None: + # Healed back to consciousness there is no dying state to stabilize; the + # next drop starts dying from scratch (stabilized_hp stays None at hp >= 0). + eng, s = _solo(con_score=10) + s.hp = -3 + eng._rng = ScriptedRng([8]) + eng._apply_heal_effect( + HealEffect(formula=parse_dice("1d8")), s, empower=False, maximize=False + ) + assert s.hp == 5 + assert s.stabilized_hp is None + eng._process_dying(s) + assert s.stabilized_hp is None + + def test_dying_tick_runs_on_initiative_slot_during_round() -> None: # m1 is downed before the battle starts; both sides still have active # members so the battle continues and m1's slot processes the dying check.