diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 2b0dea0..7ae4d58 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -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"): diff --git a/tests/test_combat.py b/tests/test_combat.py index 9b27bb5..8d7f84c 100644 --- a/tests/test_combat.py +++ b/tests/test_combat.py @@ -203,7 +203,8 @@ def test_dr_reduces_damage() -> None: def test_dr_floors_hit_at_one_minimum_damage() -> None: # PF1e (CRB "Damage" - Minimum Damage): "If penalties reduce the damage # result to less than 1, a hit still deals 1 point of nonlethal damage" - # (no nonlethal track: floored to 1 point of normal damage). + # (nonlethal track implemented: the floor lands on the separate track, + # never on hp). a_spec = make_combatant("a", attack_bonus=2) b_spec = make_combatant("b", dr=DamageReduction(amount=5)) a = make_state(a_spec, "players", (1, 1)) @@ -211,11 +212,13 @@ def test_dr_floors_hit_at_one_minimum_damage() -> None: engine = make_engine([12, 3], [a, b]) result = engine.resolve_attack(a, b, a_spec.attacks[0]) assert result.hit is True - assert result.damage == 1 - assert b.hp == 5 + assert result.damage == 0 + assert result.nonlethal == 1 + assert b.hp == 6 + assert b.nonlethal == 1 -def test_energy_resistance_floors_hit_at_one() -> None: +def test_energy_resistance_floors_hit_at_one_nonlethal() -> None: a_spec = make_combatant("a", attack_bonus=2) b_spec = make_combatant("b").model_copy(update={"resistances": {"fire": 10}}) fire = AttackSpec( @@ -230,8 +233,10 @@ def test_energy_resistance_floors_hit_at_one() -> None: engine = make_engine([12, 5], [a, b]) result = engine.resolve_attack(a, b, fire) assert result.hit is True - assert result.damage == 1 - assert b.hp == 5 + assert result.damage == 0 + assert result.nonlethal == 1 + assert b.hp == 6 + assert b.nonlethal == 1 def test_dr_bypass_ignores_reduction() -> None: diff --git a/tests/test_conformance_energy.py b/tests/test_conformance_energy.py index 3cdda81..354ae19 100644 --- a/tests/test_conformance_energy.py +++ b/tests/test_conformance_energy.py @@ -251,14 +251,16 @@ def test_dr_spares_the_energy_component_of_a_mixed_attack() -> None: def test_physical_only_damage_vs_dr_unchanged() -> None: """(h) a physical-only weapon keeps the exact old DR arithmetic (regression - lock for the split): 1d8 = 3 vs DR 5 reduces below 1 — the CRB "Damage" - Minimum Damage floor then leaves a landed hit dealing 1 (nonlethal is not - modeled: 1 point of normal damage).""" + lock for the split): 1d8 = 3 vs DR 5 reduces below 1 - the CRB "Damage" + Minimum Damage floor then leaves a landed hit dealing 1 point of + NONLETHAL damage (SRD "Nonlethal Damage": the floor never touches hp).""" weapon = _attack("warhammer", (("1d8", ("bludgeoning",)),)) hero = _combatant("hero", attacks=(weapon,)) foe = _combatant("foe").model_copy(update={"dr": DamageReduction(amount=5)}) eng = _engine([hero, foe], [(0, 0), (0, 1)], ["players", "monsters"], [15, 3]) result = eng.resolve_attack(eng._states[0], eng._states[1], weapon) - assert result.damage == 1 - assert eng._states[1].hp == 99 # the Minimum Damage floor lands 1 point + assert result.damage == 0 + assert result.nonlethal == 1 + assert eng._states[1].hp == 100 # the Minimum Damage floor is nonlethal + assert eng._states[1].nonlethal == 1 diff --git a/tests/test_nonlethal.py b/tests/test_nonlethal.py index 3fdb476..9a734e2 100644 --- a/tests/test_nonlethal.py +++ b/tests/test_nonlethal.py @@ -204,10 +204,10 @@ def test_engine_apply_nonlethal_logs_thresholds_and_books_overflow() -> None: ] ) b = engine._states[1] - assert engine._apply_nonlethal(b, 5) == 0 + assert engine._apply_nonlethal(b, 5) == (0, 5) 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 engine._apply_nonlethal(b, 2) == (2, 0) assert b.hp == 2 assert engine._stats["b"].damage_taken == 2 # only the lethal part is booked @@ -303,3 +303,137 @@ def test_snapshot_exposes_nonlethal() -> None: snapshot = engine._combatant_snapshot(b) assert snapshot["nonlethal"] == 7 assert snapshot["hp"] == 10 + + +# ── Sources: routing through resolve_attack ────────────────────────────────── + +def _attack_pair( + rolls: list[int], + *, + weapon_name: str = "short sword", + damage: str = "1d4", + target_dr: DamageReduction | None = None, + target_hp: int = 6, + creature_type: str = "living", +) -> tuple[CombatEngine, CombatantState, CombatantState, AttackSpec]: + attacker = _combatant("a", attack_bonus=2, weapon_name=weapon_name, damage=damage) + target = _combatant("b", hp=target_hp, dr=target_dr, creature_type=creature_type) + a = _state(attacker, "players", (1, 1)) + b = _state(target, "monsters", (1, 2)) + engine = CombatEngine(ScriptedRng(rolls), _grid(), [a, b]) + return engine, a, b, attacker.attacks[0] + + +def test_unarmed_strike_deals_nonlethal_by_default() -> None: + # SRD "Unarmed Attacks" (KB attack.md:23): "All damage from unarmed + # strikes is nonlethal damage." + engine, a, b, weapon = _attack_pair([12, 3], weapon_name="Unarmed Strike") + result = engine.resolve_attack(a, b, weapon) + assert result.hit is True + assert result.damage == 0 + assert result.nonlethal == 3 # the 1d4 roll went to the separate track + assert b.hp == 6 # hp untouched + assert b.nonlethal == 3 + + +def test_minimum_damage_floor_is_one_nonlethal() -> None: + # CRB "Damage - Minimum Damage": a hit reduced below 1 by DR still deals + # 1 point of NONLETHAL damage (replaces the 1-normal approximation of + # ae037c9). + engine, a, b, weapon = _attack_pair([12, 3], target_dr=DamageReduction(amount=5)) + result = engine.resolve_attack(a, b, weapon) + assert result.hit is True + assert result.damage == 0 + assert result.nonlethal == 1 + assert b.hp == 6 + assert b.nonlethal == 1 + + +def test_lethal_damage_is_unaffected() -> None: + engine, a, b, weapon = _attack_pair([12, 3]) + result = engine.resolve_attack(a, b, weapon) + assert result.damage == 3 + assert result.nonlethal == 0 + assert b.hp == 3 + assert b.nonlethal == 0 + + +def test_unarmed_strike_on_undead_is_ignored() -> None: + # Immunity beats the routing: the lich takes neither hp nor nonlethal. + engine, a, b, weapon = _attack_pair( + [12, 3], weapon_name="Unarmed Strike", creature_type="undead" + ) + result = engine.resolve_attack(a, b, weapon) + assert result.hit is True + assert result.damage == 0 + assert result.nonlethal == 0 + assert b.hp == 6 + assert b.nonlethal == 0 + assert any("ignored (immune)" in line for line in engine._transcript) + + +def test_unarmed_strike_staggers_then_unconscious_via_track() -> None: + # End-to-end: repeated unarmed strikes pile up to the hp thresholds. The + # target's hp_max (30) stays above the growing total, so nothing converts + # to lethal and hp never moves. + engine, a, b, weapon = _attack_pair( + [12, 3, 12, 3, 12, 3], weapon_name="Unarmed Strike", target_hp=30 + ) + b.hp = 6 # wounded: the thresholds compare against CURRENT hp + engine.resolve_attack(a, b, weapon) # nl 3 < hp 6: nothing + assert b.active is True + assert not b.nonlethal_staggered + engine.resolve_attack(a, b, weapon) # nl 6 == hp 6 -> staggered + assert b.nonlethal_staggered is True + assert b.active is True + engine.resolve_attack(a, b, weapon) # nl 9 > hp 6 -> unconscious + assert b.nonlethal_unconscious is True + assert b.active is False + assert b.hp == 6 # never any hp loss + + +def test_heuristic_is_the_exact_name_not_a_substring() -> None: + # "Unarmed Strike of Doom" (an authored weapon) and natural weapons stay + # lethal: the marker is the exact name, case-insensitive. + engine, a, b, weapon = _attack_pair([12, 3], weapon_name="Unarmed Strike of Doom") + result = engine.resolve_attack(a, b, weapon) + assert result.damage == 3 + assert result.nonlethal == 0 + assert b.hp == 3 + engine, a, b, weapon = _attack_pair([12, 3], weapon_name="unarmed strike") + result = engine.resolve_attack(a, b, weapon) + assert result.damage == 0 # lowercase still matches + assert result.nonlethal == 3 + + +def test_resolve_swing_logs_nonlethal_and_stagger() -> None: + engine = _engine( + [ + _state(_combatant("a", attack_bonus=2), "players", (1, 1)), + _state(_combatant("b", hp=6), "monsters", (1, 2)), + ], + rolls=[12, 6], + ) + a, b = engine._states + weapon = a.combatant.attacks[0].model_copy(update={"name": "Unarmed Strike"}) + # d20=12 hits (12+2 >= 13); the damage roll 6 lands as nl 6 == hp 6. + dropped = engine._resolve_swing(a, b, weapon, label="Unarmed Strike") + assert dropped is False + swing = next(line for line in engine._transcript if "-> HIT" in line) + assert "6 nonlethal (6->6)" in swing + assert any("staggered (nonlethal 6 = hp 6)" in line for line in engine._transcript) + assert b.hp == 6 + + +def test_nonlethal_overflow_via_attack_wounds_hp() -> None: + # A creature already at the max-hp nonlethal ceiling takes the next + # unarmed hit as real damage (SRD overflow rule), end to end. + engine, a, b, weapon = _attack_pair( + [12, 2, 12, 2], weapon_name="Unarmed Strike", target_hp=4 + ) + b.nonlethal = 4 # == hp_max: the track is at the ceiling + result = engine.resolve_attack(a, b, weapon) + assert result.damage == 2 # the whole instance converted to lethal + assert result.nonlethal == 0 + assert b.hp == 2 + assert b.nonlethal == 4 # the frozen track does not grow