"""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