feat(combat): add dying stabilization and Improved Critical
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user