440 lines
16 KiB
Python
440 lines
16 KiB
Python
"""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
|