feat(rules): nonlethal damage track - separate total, derived staggered/unconscious, max-hp overflow, undead/construct immunity, healing removal (SRD Nonlethal Damage)

This commit is contained in:
2026-09-20 18:41:53 +02:00
parent 3d0fd0c101
commit cba7bbf3c9
3 changed files with 472 additions and 9 deletions
+122 -9
View File
@@ -919,6 +919,17 @@ class CombatantState:
# Bleeding (V5b, not in the frozen data model): damage taken at the start
# of each turn; any effect that restores hit points clears it.
bleed_damage: int = 0
# PF1e nonlethal damage (SRD "Nonlethal Damage", not in the frozen data
# model): a running total that is NEVER deducted from hp ("Do not deduct
# the nonlethal damage number from your current hit points. It is not
# 'real' damage"). The staggered/unconscious thresholds are DERIVED from
# this total against current hp (``nonlethal_staggered`` /
# ``nonlethal_unconscious``) rather than stored as condition objects -
# the SRD state is a pure function of the two numbers ("You cease being
# staggered when your current hit points once again exceed your nonlethal
# damage"), so a derived flag can never desync, double up with an
# injected condition, or fight the duration-tick machinery.
nonlethal: int = 0
# PF1e recurring spell damage (S2c, not in the frozen data model): each
# entry is one damage-over-time source (Acid Arrow's lingering acid) that
# deals its dice at the start of this combatant's turn for `rounds_left`
@@ -989,14 +1000,58 @@ class CombatantState:
self.spell_slots_left[level] = current - 1
return True
def take_nonlethal(self, amount: int) -> int:
"""Accumulate nonlethal damage; return the portion treated as LETHAL.
PF1e (SRD "Nonlethal Damage"): nonlethal damage is a running total
never deducted from hp, so the track simply grows - EXCEPT the max-hp
overflow rule: "If a creature's nonlethal damage is equal to his total
maximum hit points (not his current hit points), all further nonlethal
damage is treated as lethal damage" (the regeneration exception is not
modeled: no combatant has regeneration). Undead and constructs are
NOT subject to nonlethal damage (SRD undead/construct traits, KB
rules/advanced-race-guide/undead-16-rp.md:15, construct-20-rp.md:16)
- the damage is discarded. Returns the amount the caller must apply
as real hp damage (0 in every other case).
"""
if amount <= 0:
return 0
if self.combatant.creature_type in ("undead", "construct"):
return 0
if self.nonlethal >= self.combatant.hp_max:
return amount
self.nonlethal += amount
return 0
@property
def nonlethal_unconscious(self) -> bool:
"""PF1e (SRD "Nonlethal Damage"): "when it [nonlethal damage] exceeds
your current hit points, you fall unconscious" - and "While
unconscious, you are helpless". Derived, so healing hp back above the
nonlethal total (or removing nonlethal) restores consciousness with no
bookkeeping; below 0 hp the dying rules already govern the creature."""
return self.hp >= 0 and self.nonlethal > self.hp and self.nonlethal > 0
@property
def nonlethal_staggered(self) -> bool:
"""PF1e (SRD "Nonlethal Damage"): "when your nonlethal damage equals
your current hit points, you're staggered ... You cease being
staggered when your current hit points once again exceed your nonlethal
damage." Strictly equal: exceeding the total is the unconscious case
(``nonlethal_unconscious``), which subsumes the staggered restriction."""
return self.hp >= 0 and self.nonlethal > 0 and self.nonlethal == self.hp
@property
def active(self) -> bool:
"""PF1e: hp > 0 acts normally; hp == 0 is DISABLED (CRB, Effects of
Death: a living creature at exactly 0 hp can still take a single move
or standard action), so it remains active. hp < 0 is dying/dead.
Under the simple monster-death rule (``dies_at_zero``) hp <= 0 is
dead, so the disabled band never applies."""
return self.hp >= 0 and not self.dead
dead, so the disabled band never applies. A creature knocked
unconscious by nonlethal damage (SRD "Nonlethal Damage") is inactive
exactly like a dying one: it cannot act, threatens no square, and
counts as downed for the winner check - but its hp is untouched."""
return self.hp >= 0 and not self.dead and not self.nonlethal_unconscious
@property
def dead(self) -> bool:
@@ -1652,6 +1707,10 @@ class CombatEngine:
"occupied_cells": tuple(sorted(state.occupied_cells())),
"hp": state.hp,
"hp_max": state.combatant.hp_max,
# SRD "Nonlethal Damage": separate running total; staggered when
# it equals hp, unconscious (helpless) when it exceeds hp - the
# policy derives the thresholds from (nonlethal, hp) itself.
"nonlethal": state.nonlethal,
"active": state.active,
"dead": state.dead,
"dying": state.hp < 0 and not state.dead,
@@ -2238,19 +2297,24 @@ class CombatEngine:
def _is_staggered_limited(self, state: CombatantState) -> bool:
"""True when the creature is limited to one move OR one standard
action per round (CRB "Conditions — Staggered"; a disabled creature
at 0 hp is staggered). Such a creature cannot take full-round actions
— except Restricted Withdraw (SRD "Full-Round Actions"), which it may
take as a standard action moving up to its speed."""
return self._is_disabled(state) or self._has_condition_named(
state, "staggered"
at 0 hp is staggered; nonlethal damage equal to current hp staggers,
SRD "Nonlethal Damage"). Such a creature cannot take full-round
actions — except Restricted Withdraw (SRD "Full-Round Actions"),
which it may take as a standard action moving up to its speed."""
return (
self._is_disabled(state)
or self._has_condition_named(state, "staggered")
or state.nonlethal_staggered
)
def _is_helpless(self, state: CombatantState) -> bool:
"""PF1e (CRB): helpless = paralyzed, held, bound, sleeping, unconscious.
Stunned is NOT helpless. A helpless creature has effective Dexterity 0
and melee attackers gain +4 (resolve_attack)."""
return any(
and melee attackers gain +4 (resolve_attack). A creature whose
nonlethal damage EXCEEDS its current hp is unconscious, hence helpless
(SRD "Nonlethal Damage": "While unconscious, you are helpless")."""
return state.nonlethal_unconscious or any(
self._has_condition_named(state, name)
for name in _HELPLESS_CONDITION_NAMES
)
@@ -2379,6 +2443,45 @@ class CombatEngine:
state.temp_hp -= absorbed
state.hp -= amount - absorbed
def _apply_nonlethal(self, state: CombatantState, amount: int) -> int:
"""Route ``amount`` of nonlethal damage to the separate track (SRD
"Nonlethal Damage"); returns the portion the max-hp overflow rule
converted to lethal damage, already applied to hp and booked to the
victim's damage_taken (the nonlethal portion is NOT real damage and
never touches the hp/damage counters - the attrition stats stay
lethal-only). Logs threshold crossings (staggered / unconscious)."""
if amount <= 0:
return 0
was_staggered = state.nonlethal_staggered
was_unconscious = state.nonlethal_unconscious
lethal_part = state.take_nonlethal(amount)
if lethal_part:
old_hp = state.hp
self._lose_hp(state, lethal_part)
self._stats[state.combatant.id].damage_taken += lethal_part
self._log(
f"round {self._current_round} {state.combatant.id}: nonlethal "
f"overflow at max hp - {lethal_part} treated as lethal "
f"(hp {old_hp}->{state.hp})"
)
else:
self._log(
f"round {self._current_round} {state.combatant.id}: "
f"nonlethal {amount} (nl {state.nonlethal - amount}->{state.nonlethal}, "
f"hp {state.hp})"
)
if not was_unconscious and state.nonlethal_unconscious:
self._log(
f"round {self._current_round} {state.combatant.id}: "
f"unconscious (nonlethal {state.nonlethal} > hp {state.hp})"
)
elif not was_staggered and state.nonlethal_staggered:
self._log(
f"round {self._current_round} {state.combatant.id}: "
f"staggered (nonlethal {state.nonlethal} = hp {state.hp})"
)
return lethal_part
def _apply_bleed_damage(self, state: CombatantState) -> None:
"""PF1e bleeding: take ``bleed_damage`` at the beginning of the turn (CRB)."""
if state.bleed_damage <= 0:
@@ -6222,6 +6325,10 @@ class CombatEngine:
if affected_state.hp < 0:
affected_state.stabilized_hp = affected_state.hp
actual = affected_state.hp - old_hp
# SRD "Nonlethal Damage": curing hit point damage removes an equal
# amount of nonlethal damage (see _apply_heal_effect).
if actual > 0:
affected_state.nonlethal = max(0, affected_state.nonlethal - actual)
# Healing is NOT damage dealt: the damage counters stay damage-only
# (README "degats infligés/subis"); restored hp are booked to the
# healer's separate healing_done counter (mirrors spell HealEffect).
@@ -7876,6 +7983,12 @@ class CombatEngine:
if target.hp < 0:
target.stabilized_hp = target.hp
actual = target.hp - old_hp
# PF1e (SRD "Nonlethal Damage" - Healing Nonlethal Damage): "When a
# spell or ability cures hit point damage, it also removes an equal
# amount of nonlethal damage." (The out-of-combat recovery of 1 hp per
# hour per level is out of combat scope - documented, not modeled.)
if actual > 0:
target.nonlethal = max(0, target.nonlethal - actual)
if caster_live is not None:
caster_live.healing_done += actual
self._stop_bleeding(target)