feat(rules): nonlethal sources - unarmed strikes deal nonlethal by default (SRD Unarmed Attacks), Minimum Damage floor lands 1 nonlethal (CRB Damage), transcript + AttackResult expose the track

This commit is contained in:
2026-09-20 18:54:22 +02:00
parent cba7bbf3c9
commit 83c6e541ac
4 changed files with 257 additions and 38 deletions
+103 -25
View File
@@ -224,6 +224,20 @@ _DEAFENED_VERBAL_FAILURE_PCT = 20
# PF1e (CRB Damage Reduction): DR does not negate energy damage dealt along
# with an attack — only these weapon damage types ride the DR path.
_PHYSICAL_DAMAGE_TYPES = frozenset({"bludgeoning", "piercing", "slashing"})
# PF1e (SRD undead/construct traits, KB rules/advanced-race-guide/
# undead-16-rp.md:15 / construct-20-rp.md:16): "not subject to ... nonlethal
# damage" - the same living-creature split death_threshold already draws.
_NONLETHAL_IMMUNE_TYPES: frozenset[str] = frozenset({"undead", "construct"})
# PF1e (SRD "Attack - Unarmed Attacks", KB rules/prpg-core-rulebook/
# attack.md:23): "All damage from unarmed strikes is nonlethal damage." The
# data model has no damage-mode field, so the authored weapon name is the
# marker: a melee attack named exactly "Unarmed Strike" (case-insensitive,
# as transcribed in data/pcs/*.json) routes its damage to the nonlethal
# track. Natural weapons (bite/claw) and manufactured weapons deal lethal
# damage and are NOT matched; the monk/brawler lethal-by-default exception
# and the declared -4 conversions (lethal weapon at nonlethal, unarmed
# strike at lethal) are out of scope (documented in README).
_UNARMED_STRIKE_NAME = "unarmed strike"
# PF1e confused d% table: 01-25 act normally, 26-50 babble, 51-75 hit self,
# 76-100 attack the nearest creature
_CONFUSED_ACT_NORMAL_MAX = 25
@@ -654,6 +668,48 @@ def _effective_reach_ft(state: CombatantState, weapon: AttackSpec) -> int:
return max(natural_ft, weapon.reach_ft)
def _deals_nonlethal_by_default(weapon: AttackSpec) -> bool:
"""True when the weapon's damage goes to the nonlethal track with no
declaration (SRD "Unarmed Attacks": "All damage from unarmed strikes is
nonlethal damage", KB attack.md:23). Detection is the authored name
heuristic documented at _UNARMED_STRIKE_NAME."""
return weapon.kind == "melee" and weapon.name.strip().lower() == (
_UNARMED_STRIKE_NAME
)
def _split_landed_damage(weapon: AttackSpec, final: int) -> tuple[int, int]:
"""Split a landed hit's final damage into ``(lethal, nonlethal)``.
PF1e (SRD "Unarmed Attacks"): an unarmed strike's damage is entirely
nonlethal. PF1e (CRB "Damage" - Minimum Damage): "If penalties reduce the
damage result to less than 1, a hit still deals 1 point of nonlethal
damage" - a lethal hit cut below 1 by DR/resistance becomes 1 nonlethal
point, and a nonlethal hit floors at 1 nonlethal point too.
"""
if _deals_nonlethal_by_default(weapon):
return 0, max(_MINIMUM_HIT_DAMAGE, final)
if final < _MINIMUM_HIT_DAMAGE:
return 0, _MINIMUM_HIT_DAMAGE
return final, 0
def _hit_damage_segment(result: AttackResult, hp_before: int, hp_after: int) -> str:
"""Damage part of a HIT transcript line: lethal, nonlethal, or both.
SRD "Nonlethal Damage": the nonlethal total is logged beside the lethal
one because it never shows up in the hp delta.
"""
parts: list[str] = []
if result.damage:
parts.append(f"{result.damage} damage")
if result.nonlethal:
parts.append(f"{result.nonlethal} nonlethal")
if not parts:
parts.append("0 damage")
return f" {' + '.join(parts)} ({hp_before}->{hp_after})"
def _melee_reach_zone_cells(state: CombatantState, weapon: AttackSpec) -> tuple[int, int]:
"""Inclusive grid-cell reach zone ``(inner, outer)`` of one melee attack.
@@ -1016,7 +1072,7 @@ class CombatantState:
"""
if amount <= 0:
return 0
if self.combatant.creature_type in ("undead", "construct"):
if self.combatant.creature_type in _NONLETHAL_IMMUNE_TYPES:
return 0
if self.nonlethal >= self.combatant.hp_max:
return amount
@@ -1101,6 +1157,10 @@ class AttackResult:
high_ground_bonus: int = 0
base_bonus: int = 0
melee_penalty: int = 0
# SRD "Nonlethal Damage": damage this attack routed to the target's
# separate nonlethal track (NOT included in ``damage``; ``damage`` only
# grows by the max-hp overflow the track could not hold).
nonlethal: int = 0
@dataclass(frozen=True)
@@ -2443,15 +2503,23 @@ 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
def _apply_nonlethal(self, state: CombatantState, amount: int) -> tuple[int, int]:
"""Route ``amount`` of nonlethal damage through the state's track (SRD
"Nonlethal Damage"); returns ``(lethal_part, tracked_nonlethal)``. The
lethal part (max-hp overflow) is already applied to hp and booked to
the victim's damage_taken; the nonlethal part is NOT real damage and
never touches the hp/damage counters - the attrition stats stay
lethal-only). Logs threshold crossings (staggered / unconscious)."""
lethal-only. Immune targets (undead/construct) return (0, 0): the
damage is discarded. Logs threshold crossings (staggered /
unconscious)."""
if amount <= 0:
return 0
return 0, 0
if state.combatant.creature_type in _NONLETHAL_IMMUNE_TYPES:
self._log(
f"round {self._current_round} {state.combatant.id}: nonlethal "
f"{amount} ignored (immune)"
)
return 0, 0
was_staggered = state.nonlethal_staggered
was_unconscious = state.nonlethal_unconscious
lethal_part = state.take_nonlethal(amount)
@@ -2480,7 +2548,7 @@ class CombatEngine:
f"round {self._current_round} {state.combatant.id}: "
f"staggered (nonlethal {state.nonlethal} = hp {state.hp})"
)
return lethal_part
return lethal_part, amount - lethal_part
def _apply_bleed_damage(self, state: CombatantState) -> None:
"""PF1e bleeding: take ``bleed_damage`` at the beginning of the turn (CRB)."""
@@ -3316,10 +3384,11 @@ class CombatEngine:
f"{weapon.name} MISFIRE (broken)"
)
damage = 0
nonlethal = 0
concealment_miss = False
crit = False
if hit:
crit, concealment_miss, damage = self._resolve_hit_damage(
crit, concealment_miss, damage, nonlethal = self._resolve_hit_damage(
attacker, target, weapon, roll=roll, ac=ac, base_bonus=base_bonus,
penalty=penalty, flank=flank, high_ground=high_ground,
attack_mod=attack_mod, pa_amt=pa_amt, fim_penalty=fim_penalty,
@@ -3330,11 +3399,19 @@ class CombatEngine:
damage = self._consume_up_close_and_deadly(
attacker, target, weapon, dist_ft, damage, hit=hit
)
if nonlethal:
# SRD "Nonlethal Damage": the nonlethal part goes to the
# separate track; only the max-hp overflow (if any) comes
# back as lethal damage, folded into the reported total so
# the callers' hp accounting stays exact.
lethal_part, tracked = self._apply_nonlethal(target, nonlethal)
damage += lethal_part
nonlethal = tracked
attacker.aid_attack_bonus = 0
return AttackResult(
hit=hit, crit=crit, damage=damage, roll=roll, total=total, ac=ac,
penalty=penalty, flank_bonus=flank, high_ground_bonus=high_ground,
base_bonus=base_bonus, melee_penalty=fim_penalty,
base_bonus=base_bonus, melee_penalty=fim_penalty, nonlethal=nonlethal,
)
def _attack_ac(
@@ -3400,15 +3477,19 @@ class CombatEngine:
dist_ft: int,
denies_dex: bool,
broken: bool = False,
) -> tuple[bool, bool, int]:
) -> tuple[bool, bool, int, int]:
"""Resolve a successful attack roll: crit confirm, concealment miss
chance, sneak attack dice, then damage (PF1e order).
Returns ``(crit, concealment_miss, damage)``. Sneak precision dice are
added after the crit multiplier and before DR / energy resistance.
Improved Critical doubles the threat range of every weapon (engine
simplification: no per-weapon-type feat selection); it never affects
the confirmation roll.
Returns ``(crit, concealment_miss, lethal, nonlethal)``. Sneak
precision dice are added after the crit multiplier and before DR /
energy resistance. Improved Critical doubles the threat range of
every weapon (engine simplification: no per-weapon-type feat
selection); it never affects the confirmation roll. A melee attack
named "Unarmed Strike" (_deals_nonlethal_by_default) routes its whole
final damage to the nonlethal track, and the CRB "Damage" Minimum
Damage floor of a lethal hit reduced below 1 lands as 1 nonlethal
point (SRD "Nonlethal Damage").
"""
crit = False
# CRB "Broken": a broken weapon "only score[s] a critical hit on a
@@ -3452,7 +3533,7 @@ class CombatEngine:
f"— attack {'misses' if missed else 'lands'}"
)
if missed:
return False, True, 0
return False, True, 0, 0
sneak_total = 0
sneak_dice = self._sneak_attack_dice(
attacker, target, weapon, dist_ft=dist_ft,
@@ -3519,12 +3600,9 @@ class CombatEngine:
final = self._apply_energy_resistance_per_component(
damage, weapon, rolls, target=target
)
# PF1e (CRB "Damage" — Minimum Damage): "If penalties reduce the
# damage result to less than 1, a hit still deals 1 point of
# nonlethal damage." The engine has no nonlethal damage track
# (documented approximation): the floor is applied as 1 point of
# normal damage on a landed hit.
return crit, False, max(_MINIMUM_HIT_DAMAGE, final)
# PF1e (CRB "Damage" - Minimum Damage) and SRD "Unarmed Attacks":
# the lethal/nonlethal split of the landed hit (floor included).
return crit, False, *_split_landed_damage(weapon, final)
def _attack_cover_bonus(
self, attacker: CombatantState, target: CombatantState, *, ranged: bool
@@ -8550,7 +8628,7 @@ class CombatEngine:
f"AC {result.ac} -> {outcome}"
)
if result.hit:
line += f" {result.damage} damage ({hp_before}->{target.hp})"
line += _hit_damage_segment(result, hp_before, target.hp)
if self._is_paralyzing_touch(attacker, weapon):
duration = 1 + self._rng.roll_die(4)
if not self._has_condition_named(target, "paralyzed"):