feat(combat): add dying stabilization and Improved Critical

This commit is contained in:
2026-09-10 10:43:02 +02:00
parent 97f4429c2b
commit 2eed752f7f
4 changed files with 395 additions and 3 deletions
+55 -1
View File
@@ -143,6 +143,9 @@ _FIRING_MELEE_REDUCED_PENALTY = -2 # PF1e: -2 when the melee is 10+ ft away
_FIRING_MELEE_SAFE_FT = 10 # PF1e: 2 cells (10 ft) from attacker = reduced penalty
_SNEAK_ATTACK_FEATURE = "Sneak Attack" # PF1e: rogue class feature (precision dice)
_PRECISE_SHOT_FEATURE = "Precise Shot" # PF1e: feat negating firing-into-melee penalty
_IMPROVED_CRITICAL_FEATURE = "Improved Critical" # PF1e: feat doubling the threat range
_CRIT_RANGE_BASE = 21 # threat range top is 21 - min natural die (20 -> 20-20)
_DYING_DC = 10 # PF1e: DC of the per-round stabilization Constitution check
_SAVE_DESCRIPTOR_CONDITIONS = frozenset({
"fear", "cold", "electricity", "fey", "divination", "poison", "disease",
"mind_affecting",
@@ -335,6 +338,9 @@ class CombatantState:
# Runtime weapon theft (not in the frozen data model): weapon ids taken by
# steal maneuvers.
weapon_stolen: set[str] = field(default_factory=set)
# Dying & stabilization (not in the frozen data model): the hp at which
# this combatant stabilized while dying; None = not stabilized.
stabilized_hp: int | None = None
def usable_attacks(self) -> list[AttackSpec]:
return [
@@ -741,6 +747,8 @@ class CombatEngine:
"hp_max": state.combatant.hp_max,
"active": state.active,
"dead": state.dead,
"dying": state.hp < 0 and not state.dead,
"stabilized": state.stabilized_hp is not None,
"conditions": conditions,
"resources_left": resources,
"weapon_broken": tuple(sorted(state.weapon_broken)),
@@ -1024,6 +1032,7 @@ class CombatEngine:
order = self._build_round_order()
for state in order:
if not state.active:
self._process_dying(state)
continue
if self._take_turn(state):
return True
@@ -1044,6 +1053,45 @@ class CombatEngine:
return True
return False
def _process_dying(self, state: CombatantState) -> None:
"""Advance the dying condition on the creature's own initiative slot.
PF1e dying: a combatant below 0 hp but not dead is unconscious and, on
its turn each round, makes a DC 10 Constitution check
(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.
"""
if state.dead:
return
if state.hp >= 0:
state.stabilized_hp = None
return
if state.stabilized_hp is not None:
if state.hp >= state.stabilized_hp:
return
state.stabilized_hp = None
con_mod = (state.combatant.abilities.con_score - 10) // 2
penalty = -state.hp
roll = self._rng.d20()
total = roll + con_mod - penalty
check = f"d20({roll}) + CON({con_mod}) - {penalty} = {total} vs DC {_DYING_DC}"
if roll == _NATURAL_TWENTY or total >= _DYING_DC:
state.stabilized_hp = state.hp
nat20 = " — natural 20" if roll == _NATURAL_TWENTY else ""
self._log(
f"round {self._current_round} {state.combatant.id}: "
f"stabilized ({check}{nat20})"
)
else:
hp_before = state.hp
state.hp -= 1
self._log(
f"round {self._current_round} {state.combatant.id}: dying — Constitution "
f"check {check} failed, loses 1 hp (hp {hp_before} -> {state.hp})"
)
def _blocked(self, *exclude: CombatantState) -> frozenset[Pos]:
"""Union of occupied cells of all active states except those in ``exclude``."""
return frozenset(
@@ -1423,9 +1471,15 @@ class CombatEngine:
Returns ``(crit, concealment_miss, damage)``. Sneak precision dice are
added after the crit multiplier and before DR / energy resistance.
Improved Critical doubles the threat range of every weapon (engine
simplification: no per-weapon-type feat selection); it never affects
the confirmation roll.
"""
crit = False
if roll != _NATURAL_ONE and roll >= weapon.crit_range:
threat_range = weapon.crit_range
if self._has_feat(attacker, _IMPROVED_CRITICAL_FEATURE):
threat_range = _CRIT_RANGE_BASE - 2 * (_CRIT_RANGE_BASE - weapon.crit_range)
if roll != _NATURAL_ONE and roll >= threat_range:
confirm = self._rng.d20()
confirm_total = (
confirm + base_bonus + penalty + flank + high_ground