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 <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-09-12 10:35:44 +02:00
parent e8cca954d0
commit 4b4e6f2726
2 changed files with 57 additions and 0 deletions
+10
View File
@@ -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"
+47
View File
@@ -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.