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
|
si l'action elle-même soigne la créature au-dessus de 0 pv, ce coût n'est pas
|
||||||
appliqué.
|
appliqué.
|
||||||
- `hp < 0` = inactive (mourante ou morte) ; `hp = 0` = active mais invalide.
|
- `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.
|
- 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
|
- 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
|
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
|
# Bleeding (V5b, not in the frozen data model): damage taken at the start
|
||||||
# of each turn; any effect that restores hit points clears it.
|
# of each turn; any effect that restores hit points clears it.
|
||||||
bleed_damage: int = 0
|
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
|
# 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
|
# 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`
|
# 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
|
self.spell_slots_left[level] = current - 1
|
||||||
return True
|
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
|
@property
|
||||||
def active(self) -> bool:
|
def active(self) -> bool:
|
||||||
"""PF1e: hp > 0 acts normally; hp == 0 is DISABLED (CRB, Effects of
|
"""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
|
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.
|
or standard action), so it remains active. hp < 0 is dying/dead.
|
||||||
Under the simple monster-death rule (``dies_at_zero``) hp <= 0 is
|
Under the simple monster-death rule (``dies_at_zero``) hp <= 0 is
|
||||||
dead, so the disabled band never applies."""
|
dead, so the disabled band never applies. A creature knocked
|
||||||
return self.hp >= 0 and not self.dead
|
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
|
@property
|
||||||
def dead(self) -> bool:
|
def dead(self) -> bool:
|
||||||
@@ -1652,6 +1707,10 @@ class CombatEngine:
|
|||||||
"occupied_cells": tuple(sorted(state.occupied_cells())),
|
"occupied_cells": tuple(sorted(state.occupied_cells())),
|
||||||
"hp": state.hp,
|
"hp": state.hp,
|
||||||
"hp_max": state.combatant.hp_max,
|
"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,
|
"active": state.active,
|
||||||
"dead": state.dead,
|
"dead": state.dead,
|
||||||
"dying": state.hp < 0 and not state.dead,
|
"dying": state.hp < 0 and not state.dead,
|
||||||
@@ -2238,19 +2297,24 @@ class CombatEngine:
|
|||||||
def _is_staggered_limited(self, state: CombatantState) -> bool:
|
def _is_staggered_limited(self, state: CombatantState) -> bool:
|
||||||
"""True when the creature is limited to one move OR one standard
|
"""True when the creature is limited to one move OR one standard
|
||||||
action per round (CRB "Conditions — Staggered"; a disabled creature
|
action per round (CRB "Conditions — Staggered"; a disabled creature
|
||||||
at 0 hp is staggered). Such a creature cannot take full-round actions
|
at 0 hp is staggered; nonlethal damage equal to current hp staggers,
|
||||||
— except Restricted Withdraw (SRD "Full-Round Actions"), which it may
|
SRD "Nonlethal Damage"). Such a creature cannot take full-round
|
||||||
take as a standard action moving up to its speed."""
|
actions — except Restricted Withdraw (SRD "Full-Round Actions"),
|
||||||
return self._is_disabled(state) or self._has_condition_named(
|
which it may take as a standard action moving up to its speed."""
|
||||||
state, "staggered"
|
return (
|
||||||
|
self._is_disabled(state)
|
||||||
|
or self._has_condition_named(state, "staggered")
|
||||||
|
or state.nonlethal_staggered
|
||||||
)
|
)
|
||||||
|
|
||||||
def _is_helpless(self, state: CombatantState) -> bool:
|
def _is_helpless(self, state: CombatantState) -> bool:
|
||||||
"""PF1e (CRB): helpless = paralyzed, held, bound, sleeping, unconscious.
|
"""PF1e (CRB): helpless = paralyzed, held, bound, sleeping, unconscious.
|
||||||
|
|
||||||
Stunned is NOT helpless. A helpless creature has effective Dexterity 0
|
Stunned is NOT helpless. A helpless creature has effective Dexterity 0
|
||||||
and melee attackers gain +4 (resolve_attack)."""
|
and melee attackers gain +4 (resolve_attack). A creature whose
|
||||||
return any(
|
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)
|
self._has_condition_named(state, name)
|
||||||
for name in _HELPLESS_CONDITION_NAMES
|
for name in _HELPLESS_CONDITION_NAMES
|
||||||
)
|
)
|
||||||
@@ -2379,6 +2443,45 @@ class CombatEngine:
|
|||||||
state.temp_hp -= absorbed
|
state.temp_hp -= absorbed
|
||||||
state.hp -= amount - 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:
|
def _apply_bleed_damage(self, state: CombatantState) -> None:
|
||||||
"""PF1e bleeding: take ``bleed_damage`` at the beginning of the turn (CRB)."""
|
"""PF1e bleeding: take ``bleed_damage`` at the beginning of the turn (CRB)."""
|
||||||
if state.bleed_damage <= 0:
|
if state.bleed_damage <= 0:
|
||||||
@@ -6222,6 +6325,10 @@ class CombatEngine:
|
|||||||
if affected_state.hp < 0:
|
if affected_state.hp < 0:
|
||||||
affected_state.stabilized_hp = affected_state.hp
|
affected_state.stabilized_hp = affected_state.hp
|
||||||
actual = affected_state.hp - old_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
|
# Healing is NOT damage dealt: the damage counters stay damage-only
|
||||||
# (README "degats infligés/subis"); restored hp are booked to the
|
# (README "degats infligés/subis"); restored hp are booked to the
|
||||||
# healer's separate healing_done counter (mirrors spell HealEffect).
|
# healer's separate healing_done counter (mirrors spell HealEffect).
|
||||||
@@ -7876,6 +7983,12 @@ class CombatEngine:
|
|||||||
if target.hp < 0:
|
if target.hp < 0:
|
||||||
target.stabilized_hp = target.hp
|
target.stabilized_hp = target.hp
|
||||||
actual = target.hp - old_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:
|
if caster_live is not None:
|
||||||
caster_live.healing_done += actual
|
caster_live.healing_done += actual
|
||||||
self._stop_bleeding(target)
|
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