"""Dying & stabilization, and the Improved Critical feat (Package P3).""" from __future__ import annotations from pf1e_simulator.abilities import AbilitySpec from pf1e_simulator.combat import CombatantState, CombatEngine from pf1e_simulator.dice import parse_dice from pf1e_simulator.models import ( AbilityScores, ACProfile, AttackSpec, Combatant, DamageComponent, Saves, ) from pf1e_simulator.rng import ScriptedRng from pf1e_simulator.spells import HealEffect from tests.test_observation_enriched import _snapshot, make_grid, make_state def _melee( name: str, attack_bonus: int = 5, *, crit_range: int = 20, crit_mult: int = 2, ) -> AttackSpec: return AttackSpec( id=f"{name}-sword", name="short sword", kind="melee", attack_bonus=attack_bonus, damage=[DamageComponent(formula=parse_dice("1d6"), types=["slashing"])], crit_range=crit_range, crit_mult=crit_mult, ) def _combatant( id_: str, *, con_score: int = 10, hp_max: int = 100, ac: int = 10, attacks: tuple[AttackSpec, ...] = (), features: tuple[str, ...] = (), ) -> Combatant: return Combatant( id=id_, name=id_, level=1, abilities=AbilityScores( str_score=10, dex_score=10, con_score=con_score, int_score=10, wis_score=10, cha_score=10, ), ac=ACProfile(total=ac, touch=ac, flat_footed=ac), bab=1, initiative_mod=0, speed_land_ft=30, hp_max=hp_max, saves=Saves(fort=0, ref=0, will=0), attacks=list(attacks) or [_melee(id_)], features=[AbilitySpec(name=f, category="feat") for f in features], ) def _engine( combatants: list[Combatant], positions: list[tuple[int, int]], sides: list[str], rng: list[int], ) -> CombatEngine: states = [ make_state(c, side, pos) for c, side, pos in zip(combatants, sides, positions, strict=True) ] return CombatEngine(ScriptedRng(rng), make_grid(), states) def _solo(con_score: int = 10) -> tuple[CombatEngine, CombatantState]: """Engine with one combatant; the state is returned for direct manipulation.""" eng = _engine([_combatant("subj", con_score=con_score)], [(0, 0)], ["players"], []) return eng, eng._states[0] # ── Rule A: dying bleed, stabilization, natural 20, resume, reset, death ───── def test_dying_failed_con_check_bleeds_one_hp() -> None: eng, s = _solo(con_score=8) # CON modifier -1 s.hp = -3 eng._current_round = 2 eng._rng = ScriptedRng([7]) # 7 + (-1) - 3 = 3 < 10 -> fail eng._process_dying(s) assert s.hp == -4 assert s.stabilized_hp is None line = eng._transcript[-1] assert "dying — Constitution check d20(7) + CON(-1) - 3 = 3 vs DC 10 failed" in line assert "loses 1 hp (hp -3 -> -4)" in line def test_dying_passed_con_check_stabilizes_and_stops_bleeding() -> None: eng, s = _solo(con_score=8) s.hp = -3 eng._rng = ScriptedRng([14]) # 14 + (-1) - 3 = 10 >= 10 -> stabilize eng._process_dying(s) assert s.hp == -3 assert s.stabilized_hp == -3 assert "stabilized" in eng._transcript[-1] # Later rounds: no roll consumed, no further hp loss while stable. eng._rng = ScriptedRng([]) eng._process_dying(s) eng._process_dying(s) assert s.hp == -3 assert s.stabilized_hp == -3 def test_dying_natural_twenty_auto_stabilizes() -> None: eng, s = _solo(con_score=26) # CON modifier +8, death threshold -26 s.hp = -20 eng._rng = ScriptedRng([20]) # 20 + 8 - 20 = 8 < 10 — only nat 20 saves eng._process_dying(s) assert s.hp == -20 assert s.stabilized_hp == -20 assert "stabilized" in eng._transcript[-1] assert "natural 20" in eng._transcript[-1] def test_stabilized_new_damage_resumes_dying() -> None: eng, s = _solo(con_score=8) s.hp = -3 eng._rng = ScriptedRng([14]) # stabilizes at -3 eng._process_dying(s) assert s.stabilized_hp == -3 s.hp = -5 # new damage below the stabilizing hp eng._rng = ScriptedRng([2]) # 2 + (-1) - 5 = -4 < 10 -> fail, bleeds again eng._process_dying(s) assert s.hp == -6 assert s.stabilized_hp is None assert "dying — Constitution check" in eng._transcript[-1] def test_stabilized_then_healed_resets_stabilization() -> None: eng, s = _solo(con_score=8) s.hp = -3 eng._rng = ScriptedRng([14]) # stabilizes at -3 eng._process_dying(s) assert s.stabilized_hp == -3 s.hp = 5 # healed back to consciousness eng._rng = ScriptedRng([]) # reset must not consume a roll eng._process_dying(s) assert s.stabilized_hp is None s.hp = -2 # downed again — dying from scratch, bleeds on a failed check eng._rng = ScriptedRng([3]) # 3 + (-1) - 2 = 0 < 10 -> fail eng._process_dying(s) assert s.hp == -3 assert s.stabilized_hp is None def test_dying_bleeds_to_death_threshold() -> None: # PF1e (B12 conformance): death when negative hp reaches the CON score # (hp <= -CON = -10 here); there is no -10 floor. eng, s = _solo(con_score=10) s.hp = -9 eng._rng = ScriptedRng([1]) # failed check: -9 -> -10 reaches the threshold eng._process_dying(s) assert s.hp == -10 assert s.dead # Dead creatures never roll again (empty queue would raise if they did). eng._rng = ScriptedRng([]) eng._process_dying(s) 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_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. combatants = [ _combatant("p1"), _combatant("p2"), _combatant("m1", con_score=8), _combatant("m2"), ] positions = [(0, 0), (0, 4), (0, 1), (0, 5)] sides = ["players", "players", "monsters", "monsters"] states = [ make_state(c, side, pos) for c, side, pos in zip(combatants, sides, positions, strict=True) ] # initiative p1..m2, then two dying-check rolls (both fail: -3 -> -4 -> -5) rng = ScriptedRng([1, 2, 3, 4, 5, 6]) eng = CombatEngine( rng, make_grid(), states, round_cap=2, policy=lambda _e, _s: () ) eng._states[2].hp = -3 # m1 downed result = eng.run() m1 = eng._states[2] assert m1.hp == -5 # bled on its slot in each of the two rounds assert result.winner is None # both sides still have active members lines = eng._transcript dying1 = [i for i, ln in enumerate(lines) if "round 1 m1: dying" in ln] assert len(dying1) == 1 m2_wait = next(i for i, ln in enumerate(lines) if "round 1 m2: wait" in ln) p2_wait = next(i for i, ln in enumerate(lines) if "round 1 p2: wait" in ln) assert m2_wait < dying1[0] < p2_wait # initiative slot respected assert any("round 2 m1: dying" in ln for ln in lines) # ── Snapshot keys ───────────────────────────────────────────────────────────── def test_snapshot_exposes_dying_and_stabilized() -> None: eng = _engine( [ _combatant("ok"), _combatant("dying"), _combatant("stab"), _combatant("dead"), ], [(0, 0), (0, 1), (2, 0), (2, 1)], ["players", "monsters", "players", "monsters"], [], ) _ok, dying, stab, dead = eng._states dying.hp = -2 stab.hp = -3 stab.stabilized_hp = -3 dead.hp = -11 # CON 10 -> death threshold -10 -> dead, no longer dying obs = _snapshot(eng, eng._states[0]) by_id = {c["id"]: c for c in obs["combatants"]} assert by_id["ok"]["dying"] is False assert by_id["ok"]["stabilized"] is False assert by_id["dying"]["dying"] is True assert by_id["dying"]["stabilized"] is False assert by_id["stab"]["dying"] is True assert by_id["stab"]["stabilized"] is True assert by_id["dead"]["dead"] is True assert by_id["dead"]["dying"] is False assert by_id["dead"]["stabilized"] is False # ── Rule B: Improved Critical ───────────────────────────────────────────────── def _crit_setup(*, feat: bool, crit_range: int) -> CombatEngine: features = ("Improved Critical",) if feat else () return _engine( [ _combatant( "atk", attacks=(_melee("atk", crit_range=crit_range),), features=features, ), _combatant("tgt"), ], [(0, 0), (0, 1)], ["players", "monsters"], [], ) def test_improved_critical_threats_on_19_with_20_range() -> None: eng = _crit_setup(feat=True, crit_range=20) a, t = eng._states # attack 19 (threatens), confirm 19 (hits AC 10), damage die 6 -> x2 = 12. eng._rng = ScriptedRng([19, 19, 6]) result = eng.resolve_attack(a, t, a.combatant.attacks[0]) assert result.hit is True assert result.crit is True assert result.damage == 12 assert t.hp == 88 def test_no_feat_does_not_threat_on_19_with_20_range() -> None: eng = _crit_setup(feat=False, crit_range=20) a, t = eng._states # Only two rolls scripted: a spurious confirmation roll would exhaust the # queue and raise, proving no confirm was attempted on the natural 19. eng._rng = ScriptedRng([19, 6]) result = eng.resolve_attack(a, t, a.combatant.attacks[0]) assert result.hit is True assert result.crit is False assert result.damage == 6 assert t.hp == 94 def test_improved_critical_threats_on_17_with_19_range() -> None: eng = _crit_setup(feat=True, crit_range=19) a, t = eng._states # 21 - 2 * (21 - 19) = 17: the natural 17 threatens. eng._rng = ScriptedRng([17, 17, 6]) result = eng.resolve_attack(a, t, a.combatant.attacks[0]) assert result.crit is True assert result.damage == 12 def test_improved_critical_does_not_affect_confirmation() -> None: eng = _crit_setup(feat=True, crit_range=20) a, t = eng._states # Threatens on 19 but the confirmation roll 2 fails vs AC 10 (2+5=7). eng._rng = ScriptedRng([19, 2, 6]) result = eng.resolve_attack(a, t, a.combatant.attacks[0]) assert result.crit is False assert result.damage == 6