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
+45
View File
@@ -426,6 +426,51 @@ Règles modélisées :
si l'action elle-même soigne la créature au-dessus de 0 pv, ce coût n'est pas
appliqué.
- `hp < 0` = inactive (mourante ou morte) ; `hp = 0` = active mais invalide.
- Dégâts non-létaux (SRD « Nonlethal Damage ») : piste séparée `nonlethal`
du runtime (`CombatantState.nonlethal`), **jamais** déduite des pv (« Do not
deduct the nonlethal damage number from your current hit points. It is not
"real" damage »). Les seuils sont **dérivés** de (nonlethal, pv courants),
sans objet de condition : `nonlethal == hp` (et > 0) = **staggered** (une
action de mouvement OU standard, via le filtre `_is_staggered_limited` déjà
en place) ; `nonlethal > hp` = **inconsciente et inerme** (inactif via
`CombatantState.active` — ne peut rien faire, ne menace aucune case, compte
comme tombé pour la victoire de camp — et `helpless` via `_is_helpless` :
Dex effective 0, +4 contre elle en mêlée). Un soin qui remonte les pv
au-dessus du total non-létal (ou qui réduit ce total) restaure la
conscience automatiquement, la règle étant une fonction pure des deux
nombres. **Règle du plafond de pv maximaux** (SRD) : « 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 » —
au-delà de `hp_max` accumulé, les dégâts non-létaux suivants deviennent
réels et peuvent tuer (l'exception des créatures à régénération n'est pas
modélisée : aucune créature du moteur n'a la régénération). **Immunité** :
morts-vivants et constructeurs ne subissent **pas** de dégâts non-létaux
(traits SRD « Undead » / « Construct », KB `rules/advanced-race-guide/
undead-16-rp.md` et `construct-20-rp.md` : « not subject to ... nonlethal
damage ») — les dégâts sont annulés. **Soins** : « When a spell or ability
cures hit point damage, it also removes an equal amount of nonlethal
damage » — un sort de soin ou `channel energy` qui rend N pv retire aussi N
points de non-létal. **Sources de dégâts non-létaux** : (1) l'attaque sans
arme — les coups sans arme infligent des dégâts non-létaux par défaut (SRD
« Unarmed Attacks » : « An unarmed strike deals nonlethal damage ») ; le
moteur reconnaît une attaque dont le nom d'arme est exactement
« Unarmed Strike » (insensible à la casse, comme transcrit dans
`data/pcs/*.json`) et route la totalité de son dégât (après RD et
résistances) vers la piste non-létale ; heuristique documentée : le modèle
de données n'a pas de champ « mode de dégâts », le nom est le marqueur.
(2) La règle du **dégât minimal** (CRB « Damage — Minimum Damage ») : « If
penalties reduce the damage result to less than 1, a hit still deals 1
point of nonlethal damage » — une touche réduite sous 1 par la RD ou une
résistance énergétique inflige désormais 1 point **non-létal** (approximation
« 1 point normal » de ae037c9 corrigée). Hors périmètre (documenté) : la
conversion déclarée avant le jet — arme létale frappant en non-létal à −4,
attaque sans arme frappant en léthal à −4 (choix du moine sans pénalité non
modélisé faute de marqueur de classe) ; la récupération hors combat
(1 pv de non-létal par heure et par niveau) ; les dégâts non-létaux ne sont
**jamais** comptés dans l'attrition (`damage_dealt`/`damage_taken` restent
strictement létaux) ; le snapshot d'observation expose la clé additive
`nonlethal` (int) par combattant — la politique dérive staggered/inconscient
en la comparant à `hp`.
- Initiative : triée sur (total, modificateur, ordre de liste), sans re-jet.
- Round de surprise (SRD « Surprise ») : le champ booléen `aware` du JSON de
combattant (défaut `true`) encode la conscience des adversaires au début du
+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)
+305
View File
@@ -0,0 +1,305 @@
"""Nonlethal damage track (SRD "Nonlethal Damage", KB
rules/prpg-core-rulebook/nonlethal-damage.md): a separate running total never
deducted from hp, derived staggered/unconscious thresholds, the max-hp
overflow-to-lethal rule, undead/construct immunity, and healing that removes
an equal amount of nonlethal damage.
This file covers the TRACK; the damage SOURCES (unarmed strikes, the CRB
Minimum Damage floor) are locked in the "sources" section at the bottom,
which exercises the routing through resolve_attack.
"""
from __future__ import annotations
from pf1e_simulator.combat import Action, CombatantState, CombatEngine
from pf1e_simulator.dice import parse_dice
from pf1e_simulator.grid import Grid
from pf1e_simulator.map import MapSpec, TerrainType
from pf1e_simulator.models import (
AbilityScores,
ACProfile,
AttackSpec,
Combatant,
DamageComponent,
DamageReduction,
Saves,
)
from pf1e_simulator.rng import ScriptedRng
from pf1e_simulator.spells import HealEffect
def _combatant(
cid: str,
*,
hp: int = 6,
attack_bonus: int = 2,
weapon_name: str = "short sword",
damage: str = "1d4",
dr: DamageReduction | None = None,
creature_type: str = "living",
) -> Combatant:
attack = AttackSpec(
id=f"{cid}-w",
name=weapon_name,
kind="melee",
attack_bonus=attack_bonus,
damage=[DamageComponent(formula=parse_dice(damage), types=["bludgeoning"])],
)
return Combatant(
id=cid,
name=cid,
level=1,
size="Medium",
abilities=AbilityScores(
str_score=10, dex_score=10, con_score=10,
int_score=10, wis_score=10, cha_score=10,
),
hp_max=hp,
ac=ACProfile(total=13, touch=13, flat_footed=13),
bab=attack_bonus,
initiative_mod=0,
speed_land_ft=30,
saves=Saves(fort=0, ref=0, will=0),
attacks=[attack],
dr=dr,
creature_type=creature_type,
)
def _state(
combatant: Combatant, side: str, pos: tuple[int, int], hp: int | None = None
) -> CombatantState:
return CombatantState(
combatant=combatant, side=side, pos=pos,
hp=combatant.hp_max if hp is None else hp,
)
def _grid() -> Grid:
legend = {".": TerrainType(type="floor", move_cost=1)}
spec = MapSpec(name="test", terrain=tuple(["." * 8] * 8), legend=legend)
return Grid.from_spec(spec)
def _engine(
states: list[CombatantState], rolls: list[int] | None = None
) -> CombatEngine:
return CombatEngine(ScriptedRng(rolls or [20] * 200), _grid(), states)
# ── Track accumulation and separation from hp ────────────────────────────────
def test_take_nonlethal_accumulates_without_touching_hp() -> None:
# SRD: "keep a running total ... Do not deduct the nonlethal damage
# number from your current hit points."
foe = _state(_combatant("foe", hp=10), "monsters", (0, 1))
assert foe.take_nonlethal(4) == 0
assert foe.nonlethal == 4
assert foe.hp == 10
foe.take_nonlethal(3)
assert foe.nonlethal == 7
assert foe.hp == 10
def test_nonlethal_below_hp_is_no_state() -> None:
foe = _state(_combatant("foe", hp=10), "monsters", (0, 1))
foe.take_nonlethal(9)
assert not foe.nonlethal_staggered
assert not foe.nonlethal_unconscious
assert foe.active is True
# ── Derived thresholds: staggered at == hp, unconscious at > hp ──────────────
def test_staggered_when_nonlethal_equals_hp() -> None:
# SRD: "when your nonlethal damage equals your current hit points,
# you're staggered."
foe = _state(_combatant("foe", hp=10), "monsters", (0, 1))
foe.take_nonlethal(10)
assert foe.nonlethal_staggered is True
assert foe.nonlethal_unconscious is False
assert foe.active is True # staggered still acts (limited)
def test_unconscious_when_nonlethal_exceeds_hp() -> None:
# SRD: "when it exceeds your current hit points, you fall unconscious.
# While unconscious, you are helpless."
foe = _state(_combatant("foe", hp=10), "monsters", (0, 1))
foe.take_nonlethal(11)
assert foe.nonlethal_unconscious is True
assert foe.nonlethal_staggered is False # subsumed by unconscious
assert foe.active is False # cannot act, threatens nothing, counts downed
assert foe.hp == 10 # unconsciousness never costs hp
def test_engine_treats_nonlethal_unconscious_as_helpless_and_staggered_as_limited() -> (
None
):
# _is_helpless drives the Dex-0 / +4-melee package; _is_staggered_limited
# drives the one-move-or-standard action filter.
engine = _engine(
[
_state(_combatant("a"), "players", (0, 0)),
_state(_combatant("b", hp=20), "monsters", (0, 1), hp=10),
]
)
staggered = engine._states[1]
staggered.take_nonlethal(10)
assert engine._is_staggered_limited(staggered) is True
assert engine._is_helpless(staggered) is False
staggered.take_nonlethal(1)
# Unconscious is NOT staggered-limited: it is total inaction (active is
# False, so the turn never reaches the action filter) plus helpless.
assert engine._is_staggered_limited(staggered) is False
assert staggered.active is False
assert engine._is_helpless(staggered) is True
def test_staggered_from_nonlethal_downgrades_full_attack() -> None:
# The derived staggered state feeds the existing action-economy filter:
# a full attack collapses to a single standard attack.
engine = _engine(
[
_state(_combatant("a"), "players", (0, 0)),
_state(_combatant("b", hp=10), "monsters", (0, 1)),
]
)
b = engine._states[1]
b.nonlethal = 10 # == hp -> staggered
kept = engine._filter_turn_actions(
b, (Action(kind="full_attack", target_id="a"),), []
)
assert [a.kind for a in kept] == ["attack"]
# ── Nonlethal never kills below the max-hp ceiling ───────────────────────────
def test_further_nonlethal_just_keeps_the_target_unconscious() -> None:
# SRD: nonlethal below total-max-hp is never hp damage; a pummeled
# unconscious creature simply accrues more nonlethal.
foe = _state(_combatant("foe", hp=30), "monsters", (0, 1), hp=10)
for _ in range(5):
assert foe.take_nonlethal(4) == 0
assert foe.nonlethal == 20 # far above hp...
assert foe.hp == 10 # ...but no hp was ever lost
assert foe.dead is False
def test_overflow_at_max_hp_converts_to_lethal() -> None:
# SRD: "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."
foe = _state(_combatant("foe", hp=10), "monsters", (0, 1))
assert foe.take_nonlethal(10) == 0 # reaches hp_max exactly: still nonlethal
assert foe.nonlethal == 10
assert foe.take_nonlethal(3) == 3 # at the ceiling: this instance is lethal
assert foe.nonlethal == 10 # the track stops growing
def test_engine_apply_nonlethal_logs_thresholds_and_books_overflow() -> None:
engine = _engine(
[
_state(_combatant("a"), "players", (0, 0)),
_state(_combatant("b", hp=4), "monsters", (0, 1)),
]
)
b = engine._states[1]
assert engine._apply_nonlethal(b, 5) == 0
assert "b: unconscious (nonlethal 5 > hp 4)" in engine._transcript[-1]
# b.nonlethal (5) >= hp_max (4): further nonlethal is lethal damage.
assert engine._apply_nonlethal(b, 2) == 2
assert b.hp == 2
assert engine._stats["b"].damage_taken == 2 # only the lethal part is booked
# ── Undead / construct immunity ──────────────────────────────────────────────
def test_undead_are_not_subject_to_nonlethal() -> None:
# SRD undead traits (KB undead-16-rp.md:15): "not subject to ... nonlethal
# damage".
lich = _state(
_combatant("lich", hp=10, creature_type="undead"), "monsters", (0, 1)
)
assert lich.take_nonlethal(8) == 0
assert lich.nonlethal == 0
assert lich.hp == 10
def test_construct_is_not_subject_to_nonlethal() -> None:
# SRD construct traits (KB construct-20-rp.md:16): "not subject to ...
# nonlethal damage".
robot = _state(
_combatant("robot", hp=10, creature_type="construct"), "monsters", (0, 1)
)
assert robot.take_nonlethal(8) == 0
assert robot.nonlethal == 0
# ── Healing restores consciousness ───────────────────────────────────────────
def test_healing_above_the_nonlethal_total_restores_consciousness() -> None:
# Derived thresholds: raising hp back above the nonlethal total ends the
# unconscious state with no bookkeeping ("You cease being staggered when
# your current hit points once again exceed your nonlethal damage" - the
# same pure function governs both thresholds).
foe = _state(_combatant("foe", hp=10), "monsters", (0, 1), hp=4)
foe.take_nonlethal(6) # 6 > hp 4 -> unconscious
assert foe.nonlethal_unconscious is True
assert foe.active is False
foe.hp = 7 # healed above the nonlethal total
assert foe.nonlethal_unconscious is False
assert foe.nonlethal_staggered is False
assert foe.active is True
def test_spell_healing_removes_equal_nonlethal() -> None:
# SRD: "When a spell or ability cures hit point damage, it also removes
# an equal amount of nonlethal damage."
engine = _engine(
[
_state(_combatant("a"), "players", (0, 0)),
_state(_combatant("b", hp=10), "monsters", (0, 1), hp=3),
]
)
b = engine._states[1]
b.nonlethal = 8 # unconscious (8 > 3)
effect = HealEffect(formula=parse_dice("6"))
out = engine._apply_heal_effect(
effect, b, caster_level=1, empower=False, maximize=False, saved=False
)
assert out == "+6 HP"
assert b.hp == 9
assert b.nonlethal == 2 # 8 - 6 equal removal
assert b.active is True
def test_channel_heal_removes_equal_nonlethal() -> None:
engine = _engine(
[
_state(_combatant("a"), "players", (0, 0)),
_state(_combatant("b", hp=10), "monsters", (0, 1), hp=2),
]
)
healer, b = engine._states[0], engine._states[1]
b.nonlethal = 5 # unconscious (5 > 2)
engine._channel_heal(healer, b, 4)
assert b.hp == 6
assert b.nonlethal == 1 # 5 - 4
assert b.nonlethal_staggered is False
assert b.active is True
# ── Observation snapshot exposure ────────────────────────────────────────────
def test_snapshot_exposes_nonlethal() -> None:
engine = _engine(
[
_state(_combatant("a"), "players", (0, 0)),
_state(_combatant("b", hp=10), "monsters", (0, 1)),
]
)
b = engine._states[1]
b.nonlethal = 7
snapshot = engine._combatant_snapshot(b)
assert snapshot["nonlethal"] == 7
assert snapshot["hp"] == 10