From 1b5cac234efb849de692000d4f09a8df6e6222d5 Mon Sep 17 00:00:00 2001 From: ctan Date: Fri, 11 Sep 2026 21:06:10 +0200 Subject: [PATCH] fix(rules): tick bleed and auto-fail stabilization for a dying creature PF1e (CRB, Stable Characters and Recovery): characters taking continuous damage such as bleed automatically fail all Constitution checks made to stabilize, losing 1 hp per round in addition to the continuous damage. _process_dying never applied bleed (only _turn_gate, reached by active creatures did), so a bleeding dying creature lost 0 hp per round. It now ticks bleed_damage at the start of the dying turn and auto-fails the check (no d20 consumed), then evaluates death. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- README.md | 7 +++++- src/pf1e_simulator/combat.py | 21 +++++++++++++++++- tests/test_dying_and_crits.py | 42 +++++++++++++++++++++++++++++++++++ 3 files changed, 68 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index e07efdd..b34bd6e 100644 --- a/README.md +++ b/README.md @@ -319,7 +319,12 @@ Règles modélisées : ramenant à `hp ≥ 0` réinitialise la stabilisation pour toute chute future. La mort survient quand les pv négatifs atteignent le score de CON (`hp ≤ -CON`) — conformance P2 : la règle du −10 de D&D 3.5 n'existe pas - en PF1e et n'est plus modélisée. + en PF1e et n'est plus modélisée. Une créature mourante qui subit des dégâts + continus (saignement) perd ses dégâts de saignement au début de son tour, + puis **échoue automatiquement** son jet de stabilisation (aucun d20 jeté) et + perd donc 1 pv de plus par tour en plus des dégâts continus (CRB « Stable + Characters and Recovery ») — elle meurt ainsi plus vite qu'une créature + mourante non saignante. - Mort simplifiée des monstres (option `--simple-monster-death`, défaut désactivé) : quand l'option est présente dans la commande, un **monstre** meurt dès `hp ≤ 0` — ni bande d'invalidité à 0 pv, ni bande d'agonie entre diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 7476441..52e6f83 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -1816,7 +1816,9 @@ class CombatEngine: (d20 + Con modifier - negative hp total); a natural 20 auto-succeeds. Failure bleeds 1 more hp; success stabilizes (no further loss while stable). New damage below the stabilizing hp resumes dying; healing to - hp >= 0 resets stabilization for any future downing. + hp >= 0 resets stabilization for any future downing. A creature taking + continuous damage (bleed) instead ticks the bleed and AUTOMATICALLY + FAILS the check (CRB, Stable Characters and Recovery). """ if state.dead: return @@ -1827,6 +1829,23 @@ class CombatEngine: if state.hp >= state.stabilized_hp: return state.stabilized_hp = None + # PF1e (CRB, Stable Characters and Recovery): "Characters taking + # continuous damage, such as from an acid arrow or a bleed effect, + # automatically fail all Constitution checks made to stabilize. Such + # characters lose 1 hit point per round in addition to the continuous + # damage." This is the inactive creature's once-per-round slot (the + # active path already ticked bleed in _turn_gate), so the bleed damage + # is applied here and the d20 check is skipped (auto-fail). + if state.bleed_damage > 0: + self._apply_bleed_damage(state) + hp_before = state.hp + state.hp -= 1 + self._log( + f"round {self._current_round} {state.combatant.id}: dying — continuous " + f"damage auto-fails the Constitution check, loses 1 hp " + f"(hp {hp_before} -> {state.hp})" + ) + return con_mod = (state.combatant.abilities.con_score - 10) // 2 penalty = -state.hp roll = self._rng.d20() diff --git a/tests/test_dying_and_crits.py b/tests/test_dying_and_crits.py index 35bcb2c..1cb4452 100644 --- a/tests/test_dying_and_crits.py +++ b/tests/test_dying_and_crits.py @@ -170,6 +170,48 @@ def test_dying_bleeds_to_death_threshold() -> None: assert s.hp == -10 +def test_dying_with_bleed_ticks_damage_and_auto_fails_stabilization() -> None: + # PF1e (CRB, Stable Characters and Recovery): a creature taking continuous + # damage (bleed) automatically fails the stabilization check and loses 1 hp + # per round IN ADDITION to the bleed damage. No d20 is consumed (an empty + # queue would raise), and it dies faster than the 1-hp-per-round baseline. + eng, s = _solo(con_score=8) # death threshold -8 + s.hp = -3 + s.bleed_damage = 2 + eng._current_round = 2 + eng._rng = ScriptedRng([]) # no roll: the check is auto-failed + eng._process_dying(s) + # bleed 2 (hp -3 -> -5), then auto-fail loses 1 more (hp -5 -> -6). + assert s.hp == -6 + assert s.stabilized_hp is None + lines = "\n".join(eng._transcript) + assert "bleed 2 (hp -3->-5)" in lines + assert "continuous damage auto-fails the Constitution check" in lines + assert "loses 1 hp (hp -5 -> -6)" in lines + + +def test_dying_with_bleed_reaches_death_faster_than_without() -> None: + # Same start; the bleeding creature hits -CON in fewer rounds than the + # non-bleeding one (which only loses 1 hp per failed check). + eng_b, bleed = _solo(con_score=8) # death threshold -8 + bleed.hp = -3 + bleed.bleed_damage = 2 + eng_b._rng = ScriptedRng([]) # auto-fail, no roll + eng_b._process_dying(bleed) # -3 -2 bleed -1 = -6 + assert bleed.hp == -6 + assert not bleed.dead + eng_b._process_dying(bleed) # -6 -2 bleed -1 = -9 <= -8 -> dead + assert bleed.dead + + eng_n, plain = _solo(con_score=8) + plain.hp = -3 + eng_n._rng = ScriptedRng([1, 1]) # two failed CON checks: -3 -> -4 -> -5 + eng_n._process_dying(plain) + eng_n._process_dying(plain) + assert plain.hp == -5 # still alive after two rounds + assert not plain.dead + + 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.