diff --git a/README.md b/README.md index 6787776..44b7177 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index e1d8b40..2b0dea0 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -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) diff --git a/tests/test_nonlethal.py b/tests/test_nonlethal.py new file mode 100644 index 0000000..3fdb476 --- /dev/null +++ b/tests/test_nonlethal.py @@ -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