Files
pf1e-simulator/tests/test_dying_and_crits.py
T
ctan cad35e2e13 fix(rules): cure spells damage undead instead of healing
SRD Cure Light Wounds: 'Since undead are powered by negative energy,
this spell deals damage to them instead of curing their wounds. An
undead creature can apply spell resistance, and can attempt a Will save
to take half damage.'

- _apply_heal_effect takes the rolled save; an undead target takes the
  rolled healing as damage (halved on a successful save; SR already
  resolved upstream, DR never applies to spell energy, no bleeding stop
  or stabilization). Living targets are bit-identical.
- cure_light/cure_moderate descriptions fixed: the SRD save line is
  'Will half (harmless)', not 'negates'.
- Deafened 20% verbal spell failure DEFERRED to the README « Limites
  hors perimetre »: SpellSpec carries no components data (would need a
  verbal flag on all 108 spells) and sound-based Perception is not
  modeled either.
2026-09-16 15:48:36 +02:00

394 lines
14 KiB
Python

"""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, caster_level=1,
empower=False, maximize=False, saved=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, caster_level=1,
empower=False, maximize=False, saved=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