"""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, 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, 0) 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 # ── 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