feat(rules): sunder deals real weapon damage vs object hardness/hp; broken weapons usable at -2 with restricted crits

SRD Sunder + CRB 'Smashing an Object' / 'Conditions - Broken': weapons
carry Table 7-12 object stats (AttackSpec.hardness/hit_points/enhancement,
defaults by weapon class); a successful sunder deals the attacker's actual
physical weapon damage minus the target weapon's hardness, subtracted from
its hp. At <= half hp the weapon gains the broken condition - still usable
at -2 attack/damage, crits only on a natural 20 at x2. At <= 0 it is
destroyed (weapon_destroyed, unusable). The old flat str_mod+2 / durability
10 / synthetic 'disarmed' condition is gone. Firearm misfire carve-out kept
(broken firearms stay unusable until Quick Clear - misfire escalation not
modeled, documented).
This commit is contained in:
2026-09-20 15:43:45 +02:00
parent 544d870688
commit 74f5d64c43
6 changed files with 482 additions and 159 deletions
+27 -9
View File
@@ -2,10 +2,12 @@
``attack`` and ``full_attack`` accept ``Action.weapon_id`` — the id of one of
the attacker's weapons. With it the engine strikes with THAT weapon instead of
the ``weapon_for`` auto-selection; a choice that is unknown, broken, stolen,
or unable to strike the target (out of reach / no line of effect) is rejected
with ``weapon_unavailable`` and no attack is made. Without it, the
auto-selection and the full-attack mechanics are byte-identical to before.
the ``weapon_for`` auto-selection; a choice that is unknown, destroyed,
stolen, dropped, or unable to strike the target (out of reach / no line of
effect) is rejected with ``weapon_unavailable`` and no attack is made (a
BROKEN melee weapon stays selectable — CRB "Broken" makes it usable at -2).
Without it, the auto-selection and the full-attack mechanics are
byte-identical to before.
"""
from __future__ import annotations
@@ -133,15 +135,31 @@ def test_full_attack_without_choice_still_swings_all_melee_weapons() -> None:
assert "shortsword vs foe d20=10+2=12" in joined
# ── (b) broken weapon → weapon_unavailable ───────────────────────────────────
# ── (b) broken / destroyed weapon selection ──────────────────────────────────
def test_attack_with_broken_weapon_is_rejected() -> None:
eng, hero, foe = _duel([]) # empty script: any roll would raise
def test_attack_with_broken_weapon_is_usable() -> None:
# CRB "Broken": a broken weapon is still usable — at -2 on attack and
# damage. The explicit choice is accepted: d20 10 + 2 - 2 = 10 vs AC 10
# -> HIT for 3 - 2 = 1 damage.
eng, hero, foe = _duel([10, 3])
hero.weapon_broken.add("hero-short")
result = eng._execute(
hero, Action(kind="attack", target_id="foe", weapon_id="hero-short")
)
assert result == ActionResult(kind="attack", ok=True, reason="ok")
assert "shortsword vs foe d20=10+2=10 AC 10 -> HIT 1 damage" in eng._transcript[-1]
assert foe.hp == 99
def test_attack_with_destroyed_weapon_is_rejected() -> None:
# A destroyed weapon (hp <= 0, CRB "Smashing an Object": "ruined") is
# unusable: the explicit choice is rejected, no roll consumed.
eng, hero, foe = _duel([]) # empty script: any roll would raise
hero.weapon_destroyed.add("hero-short")
result = eng._execute(
hero, Action(kind="attack", target_id="foe", weapon_id="hero-short")
)
assert result == ActionResult(
kind="attack", ok=False, reason="weapon_unavailable"
)
@@ -149,9 +167,9 @@ def test_attack_with_broken_weapon_is_rejected() -> None:
assert foe.hp == 100
def test_full_attack_with_broken_weapon_is_rejected() -> None:
def test_full_attack_with_destroyed_weapon_is_rejected() -> None:
eng, hero, foe = _duel([])
hero.weapon_broken.add("hero-short")
hero.weapon_destroyed.add("hero-short")
result = eng._execute(
hero, Action(kind="full_attack", target_id="foe", weapon_id="hero-short")
)