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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user