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:
+103
-25
@@ -224,6 +224,20 @@ _DEAFENED_VERBAL_FAILURE_PCT = 20
|
|||||||
# PF1e (CRB Damage Reduction): DR does not negate energy damage dealt along
|
# PF1e (CRB Damage Reduction): DR does not negate energy damage dealt along
|
||||||
# with an attack — only these weapon damage types ride the DR path.
|
# with an attack — only these weapon damage types ride the DR path.
|
||||||
_PHYSICAL_DAMAGE_TYPES = frozenset({"bludgeoning", "piercing", "slashing"})
|
_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,
|
# PF1e confused d% table: 01-25 act normally, 26-50 babble, 51-75 hit self,
|
||||||
# 76-100 attack the nearest creature
|
# 76-100 attack the nearest creature
|
||||||
_CONFUSED_ACT_NORMAL_MAX = 25
|
_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)
|
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]:
|
def _melee_reach_zone_cells(state: CombatantState, weapon: AttackSpec) -> tuple[int, int]:
|
||||||
"""Inclusive grid-cell reach zone ``(inner, outer)`` of one melee attack.
|
"""Inclusive grid-cell reach zone ``(inner, outer)`` of one melee attack.
|
||||||
|
|
||||||
@@ -1016,7 +1072,7 @@ class CombatantState:
|
|||||||
"""
|
"""
|
||||||
if amount <= 0:
|
if amount <= 0:
|
||||||
return 0
|
return 0
|
||||||
if self.combatant.creature_type in ("undead", "construct"):
|
if self.combatant.creature_type in _NONLETHAL_IMMUNE_TYPES:
|
||||||
return 0
|
return 0
|
||||||
if self.nonlethal >= self.combatant.hp_max:
|
if self.nonlethal >= self.combatant.hp_max:
|
||||||
return amount
|
return amount
|
||||||
@@ -1101,6 +1157,10 @@ class AttackResult:
|
|||||||
high_ground_bonus: int = 0
|
high_ground_bonus: int = 0
|
||||||
base_bonus: int = 0
|
base_bonus: int = 0
|
||||||
melee_penalty: 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)
|
@dataclass(frozen=True)
|
||||||
@@ -2443,15 +2503,23 @@ class CombatEngine:
|
|||||||
state.temp_hp -= absorbed
|
state.temp_hp -= absorbed
|
||||||
state.hp -= amount - absorbed
|
state.hp -= amount - absorbed
|
||||||
|
|
||||||
def _apply_nonlethal(self, state: CombatantState, amount: int) -> int:
|
def _apply_nonlethal(self, state: CombatantState, amount: int) -> tuple[int, int]:
|
||||||
"""Route ``amount`` of nonlethal damage to the separate track (SRD
|
"""Route ``amount`` of nonlethal damage through the state's track (SRD
|
||||||
"Nonlethal Damage"); returns the portion the max-hp overflow rule
|
"Nonlethal Damage"); returns ``(lethal_part, tracked_nonlethal)``. The
|
||||||
converted to lethal damage, already applied to hp and booked to the
|
lethal part (max-hp overflow) is already applied to hp and booked to
|
||||||
victim's damage_taken (the nonlethal portion is NOT real damage and
|
the victim's damage_taken; the nonlethal part is NOT real damage and
|
||||||
never touches the hp/damage counters - the attrition stats stay
|
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:
|
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_staggered = state.nonlethal_staggered
|
||||||
was_unconscious = state.nonlethal_unconscious
|
was_unconscious = state.nonlethal_unconscious
|
||||||
lethal_part = state.take_nonlethal(amount)
|
lethal_part = state.take_nonlethal(amount)
|
||||||
@@ -2480,7 +2548,7 @@ class CombatEngine:
|
|||||||
f"round {self._current_round} {state.combatant.id}: "
|
f"round {self._current_round} {state.combatant.id}: "
|
||||||
f"staggered (nonlethal {state.nonlethal} = hp {state.hp})"
|
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:
|
def _apply_bleed_damage(self, state: CombatantState) -> None:
|
||||||
"""PF1e bleeding: take ``bleed_damage`` at the beginning of the turn (CRB)."""
|
"""PF1e bleeding: take ``bleed_damage`` at the beginning of the turn (CRB)."""
|
||||||
@@ -3316,10 +3384,11 @@ class CombatEngine:
|
|||||||
f"{weapon.name} MISFIRE (broken)"
|
f"{weapon.name} MISFIRE (broken)"
|
||||||
)
|
)
|
||||||
damage = 0
|
damage = 0
|
||||||
|
nonlethal = 0
|
||||||
concealment_miss = False
|
concealment_miss = False
|
||||||
crit = False
|
crit = False
|
||||||
if hit:
|
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,
|
attacker, target, weapon, roll=roll, ac=ac, base_bonus=base_bonus,
|
||||||
penalty=penalty, flank=flank, high_ground=high_ground,
|
penalty=penalty, flank=flank, high_ground=high_ground,
|
||||||
attack_mod=attack_mod, pa_amt=pa_amt, fim_penalty=fim_penalty,
|
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(
|
damage = self._consume_up_close_and_deadly(
|
||||||
attacker, target, weapon, dist_ft, damage, hit=hit
|
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
|
attacker.aid_attack_bonus = 0
|
||||||
return AttackResult(
|
return AttackResult(
|
||||||
hit=hit, crit=crit, damage=damage, roll=roll, total=total, ac=ac,
|
hit=hit, crit=crit, damage=damage, roll=roll, total=total, ac=ac,
|
||||||
penalty=penalty, flank_bonus=flank, high_ground_bonus=high_ground,
|
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(
|
def _attack_ac(
|
||||||
@@ -3400,15 +3477,19 @@ class CombatEngine:
|
|||||||
dist_ft: int,
|
dist_ft: int,
|
||||||
denies_dex: bool,
|
denies_dex: bool,
|
||||||
broken: bool = False,
|
broken: bool = False,
|
||||||
) -> tuple[bool, bool, int]:
|
) -> tuple[bool, bool, int, int]:
|
||||||
"""Resolve a successful attack roll: crit confirm, concealment miss
|
"""Resolve a successful attack roll: crit confirm, concealment miss
|
||||||
chance, sneak attack dice, then damage (PF1e order).
|
chance, sneak attack dice, then damage (PF1e order).
|
||||||
|
|
||||||
Returns ``(crit, concealment_miss, damage)``. Sneak precision dice are
|
Returns ``(crit, concealment_miss, lethal, nonlethal)``. Sneak
|
||||||
added after the crit multiplier and before DR / energy resistance.
|
precision dice are added after the crit multiplier and before DR /
|
||||||
Improved Critical doubles the threat range of every weapon (engine
|
energy resistance. Improved Critical doubles the threat range of
|
||||||
simplification: no per-weapon-type feat selection); it never affects
|
every weapon (engine simplification: no per-weapon-type feat
|
||||||
the confirmation roll.
|
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
|
crit = False
|
||||||
# CRB "Broken": a broken weapon "only score[s] a critical hit on a
|
# 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'}"
|
f"— attack {'misses' if missed else 'lands'}"
|
||||||
)
|
)
|
||||||
if missed:
|
if missed:
|
||||||
return False, True, 0
|
return False, True, 0, 0
|
||||||
sneak_total = 0
|
sneak_total = 0
|
||||||
sneak_dice = self._sneak_attack_dice(
|
sneak_dice = self._sneak_attack_dice(
|
||||||
attacker, target, weapon, dist_ft=dist_ft,
|
attacker, target, weapon, dist_ft=dist_ft,
|
||||||
@@ -3519,12 +3600,9 @@ class CombatEngine:
|
|||||||
final = self._apply_energy_resistance_per_component(
|
final = self._apply_energy_resistance_per_component(
|
||||||
damage, weapon, rolls, target=target
|
damage, weapon, rolls, target=target
|
||||||
)
|
)
|
||||||
# PF1e (CRB "Damage" — Minimum Damage): "If penalties reduce the
|
# PF1e (CRB "Damage" - Minimum Damage) and SRD "Unarmed Attacks":
|
||||||
# damage result to less than 1, a hit still deals 1 point of
|
# the lethal/nonlethal split of the landed hit (floor included).
|
||||||
# nonlethal damage." The engine has no nonlethal damage track
|
return crit, False, *_split_landed_damage(weapon, final)
|
||||||
# (documented approximation): the floor is applied as 1 point of
|
|
||||||
# normal damage on a landed hit.
|
|
||||||
return crit, False, max(_MINIMUM_HIT_DAMAGE, final)
|
|
||||||
|
|
||||||
def _attack_cover_bonus(
|
def _attack_cover_bonus(
|
||||||
self, attacker: CombatantState, target: CombatantState, *, ranged: bool
|
self, attacker: CombatantState, target: CombatantState, *, ranged: bool
|
||||||
@@ -8550,7 +8628,7 @@ class CombatEngine:
|
|||||||
f"AC {result.ac} -> {outcome}"
|
f"AC {result.ac} -> {outcome}"
|
||||||
)
|
)
|
||||||
if result.hit:
|
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):
|
if self._is_paralyzing_touch(attacker, weapon):
|
||||||
duration = 1 + self._rng.roll_die(4)
|
duration = 1 + self._rng.roll_die(4)
|
||||||
if not self._has_condition_named(target, "paralyzed"):
|
if not self._has_condition_named(target, "paralyzed"):
|
||||||
|
|||||||
+11
-6
@@ -203,7 +203,8 @@ def test_dr_reduces_damage() -> None:
|
|||||||
def test_dr_floors_hit_at_one_minimum_damage() -> None:
|
def test_dr_floors_hit_at_one_minimum_damage() -> None:
|
||||||
# PF1e (CRB "Damage" - Minimum Damage): "If penalties reduce the damage
|
# PF1e (CRB "Damage" - Minimum Damage): "If penalties reduce the damage
|
||||||
# result to less than 1, a hit still deals 1 point of nonlethal 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)
|
a_spec = make_combatant("a", attack_bonus=2)
|
||||||
b_spec = make_combatant("b", dr=DamageReduction(amount=5))
|
b_spec = make_combatant("b", dr=DamageReduction(amount=5))
|
||||||
a = make_state(a_spec, "players", (1, 1))
|
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])
|
engine = make_engine([12, 3], [a, b])
|
||||||
result = engine.resolve_attack(a, b, a_spec.attacks[0])
|
result = engine.resolve_attack(a, b, a_spec.attacks[0])
|
||||||
assert result.hit is True
|
assert result.hit is True
|
||||||
assert result.damage == 1
|
assert result.damage == 0
|
||||||
assert b.hp == 5
|
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)
|
a_spec = make_combatant("a", attack_bonus=2)
|
||||||
b_spec = make_combatant("b").model_copy(update={"resistances": {"fire": 10}})
|
b_spec = make_combatant("b").model_copy(update={"resistances": {"fire": 10}})
|
||||||
fire = AttackSpec(
|
fire = AttackSpec(
|
||||||
@@ -230,8 +233,10 @@ def test_energy_resistance_floors_hit_at_one() -> None:
|
|||||||
engine = make_engine([12, 5], [a, b])
|
engine = make_engine([12, 5], [a, b])
|
||||||
result = engine.resolve_attack(a, b, fire)
|
result = engine.resolve_attack(a, b, fire)
|
||||||
assert result.hit is True
|
assert result.hit is True
|
||||||
assert result.damage == 1
|
assert result.damage == 0
|
||||||
assert b.hp == 5
|
assert result.nonlethal == 1
|
||||||
|
assert b.hp == 6
|
||||||
|
assert b.nonlethal == 1
|
||||||
|
|
||||||
|
|
||||||
def test_dr_bypass_ignores_reduction() -> None:
|
def test_dr_bypass_ignores_reduction() -> None:
|
||||||
|
|||||||
@@ -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:
|
def test_physical_only_damage_vs_dr_unchanged() -> None:
|
||||||
"""(h) a physical-only weapon keeps the exact old DR arithmetic (regression
|
"""(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"
|
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
|
Minimum Damage floor then leaves a landed hit dealing 1 point of
|
||||||
modeled: 1 point of normal damage)."""
|
NONLETHAL damage (SRD "Nonlethal Damage": the floor never touches hp)."""
|
||||||
weapon = _attack("warhammer", (("1d8", ("bludgeoning",)),))
|
weapon = _attack("warhammer", (("1d8", ("bludgeoning",)),))
|
||||||
hero = _combatant("hero", attacks=(weapon,))
|
hero = _combatant("hero", attacks=(weapon,))
|
||||||
foe = _combatant("foe").model_copy(update={"dr": DamageReduction(amount=5)})
|
foe = _combatant("foe").model_copy(update={"dr": DamageReduction(amount=5)})
|
||||||
eng = _engine([hero, foe], [(0, 0), (0, 1)], ["players", "monsters"],
|
eng = _engine([hero, foe], [(0, 0), (0, 1)], ["players", "monsters"],
|
||||||
[15, 3])
|
[15, 3])
|
||||||
result = eng.resolve_attack(eng._states[0], eng._states[1], weapon)
|
result = eng.resolve_attack(eng._states[0], eng._states[1], weapon)
|
||||||
assert result.damage == 1
|
assert result.damage == 0
|
||||||
assert eng._states[1].hp == 99 # the Minimum Damage floor lands 1 point
|
assert result.nonlethal == 1
|
||||||
|
assert eng._states[1].hp == 100 # the Minimum Damage floor is nonlethal
|
||||||
|
assert eng._states[1].nonlethal == 1
|
||||||
|
|||||||
+136
-2
@@ -204,10 +204,10 @@ def test_engine_apply_nonlethal_logs_thresholds_and_books_overflow() -> None:
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
b = engine._states[1]
|
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]
|
assert "b: unconscious (nonlethal 5 > hp 4)" in engine._transcript[-1]
|
||||||
# b.nonlethal (5) >= hp_max (4): further nonlethal is lethal damage.
|
# 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 b.hp == 2
|
||||||
assert engine._stats["b"].damage_taken == 2 # only the lethal part is booked
|
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)
|
snapshot = engine._combatant_snapshot(b)
|
||||||
assert snapshot["nonlethal"] == 7
|
assert snapshot["nonlethal"] == 7
|
||||||
assert snapshot["hp"] == 10
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user