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:
+11
-6
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
+136
-2
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user