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