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:
+164
-81
@@ -190,7 +190,8 @@ _FLANK_BONUS = 2 # PF1e: +2 melee attack when ally threatens opposite side
|
||||
_HIGH_GROUND_BONUS = 1 # PF1e: +1 melee attack when attacker stands higher than target
|
||||
_ITERATIVE_PENALTY = 5 # PF1e: each BAB iterative is at -5 from the previous
|
||||
_MAX_ITERATIVES = 4 # PF1e: BAB +16 gives 4 attacks (at +16, +11, +6, +1)
|
||||
_WEAPON_DURABILITY = 10 # Accumulated sunder damage that destroys a weapon.
|
||||
_BROKEN_WEAPON_PENALTY = -2 # PF1e (CRB "Broken"): -2 attack and damage rolls
|
||||
_BROKEN_CRIT_MULT = 2 # PF1e (CRB "Broken"): broken weapons deal only x2 on a crit
|
||||
_CHARGE_ATTACK_BONUS = 2 # PF1e: +2 attack roll on a charge
|
||||
_CHARGE_AC_PENALTY = 2 # PF1e: -2 AC until start of next turn after charging
|
||||
_CHARGE_MIN_CELLS = 2 # PF1e: charge must move at least 10 ft (2 squares)
|
||||
@@ -840,10 +841,16 @@ class CombatantState:
|
||||
swift_used_this_turn: bool = False
|
||||
summon_duration: int = 0
|
||||
summon_caster_id: str | None = None
|
||||
# Runtime weapon durability (not in the frozen data model): accumulated
|
||||
# sunder damage per weapon id, and the set of weapon ids rendered unusable.
|
||||
# Runtime weapon durability (not in the frozen data model): damage dealt
|
||||
# PAST the object's hardness per weapon id (CRB "Breaking and Entering":
|
||||
# "Only damage in excess of its hardness is deducted from the object's
|
||||
# hit points"), the set of weapon ids with the broken condition (usable
|
||||
# at -2 with restricted crits — CRB "Broken"), and the set of weapon ids
|
||||
# destroyed by a sunder (hp <= 0: "When an object's hit points reach 0,
|
||||
# it's ruined" — unusable).
|
||||
weapon_damage: dict[str, int] = field(default_factory=dict)
|
||||
weapon_broken: set[str] = field(default_factory=set)
|
||||
weapon_destroyed: set[str] = field(default_factory=set)
|
||||
# Runtime weapon theft (not in the frozen data model): weapon ids taken by
|
||||
# steal maneuvers.
|
||||
weapon_stolen: set[str] = field(default_factory=set)
|
||||
@@ -924,12 +931,23 @@ class CombatantState:
|
||||
held_charge: HeldCharge | None = None
|
||||
|
||||
def usable_attacks(self) -> list[AttackSpec]:
|
||||
# PF1e (CRB "Breaking Items" / "Conditions - Broken"): a broken
|
||||
# weapon is still USABLE — it just suffers -2 on attack and damage
|
||||
# rolls with crits restricted to natural 20 at x2 (applied in
|
||||
# resolve_attack). Destroyed weapons (hp <= 0) are unusable.
|
||||
# FIREARM EXCEPTION: a broken firearm (misfire, Ultimate Combat
|
||||
# "Firearm Rules - Misfires") is kept unusable until Quick Clear or
|
||||
# a repair — the SRD would let it fire at -2 with its misfire value
|
||||
# raised by 4, but the misfire escalation and the exploding-early-
|
||||
# firearm rules are not modeled, so re-firing a broken gun is
|
||||
# represented as not possible (documented simplification).
|
||||
return [
|
||||
w
|
||||
for w in self.combatant.attacks
|
||||
if w.id not in self.weapon_broken
|
||||
if w.id not in self.weapon_destroyed
|
||||
and w.id not in self.weapon_stolen
|
||||
and w.id not in self.weapon_dropped
|
||||
and not (w.id in self.weapon_broken and w.firearm)
|
||||
]
|
||||
|
||||
def occupied_cells(self) -> frozenset[Pos]:
|
||||
@@ -1634,6 +1652,7 @@ class CombatEngine:
|
||||
"conditions": conditions,
|
||||
"resources_left": resources,
|
||||
"weapon_broken": tuple(sorted(state.weapon_broken)),
|
||||
"weapon_destroyed": tuple(sorted(state.weapon_destroyed)),
|
||||
"weapon_stolen": tuple(sorted(state.weapon_stolen)),
|
||||
"weapon_dropped": tuple(sorted(state.weapon_dropped)),
|
||||
"grappled_with": state.grappled_with,
|
||||
@@ -1761,6 +1780,8 @@ class CombatEngine:
|
||||
"range_increment_ft": attack.range_increment_ft,
|
||||
"count": attack.count,
|
||||
"firearm": attack.firearm,
|
||||
"hardness": attack.object_stats()[0],
|
||||
"hit_points": attack.object_stats()[1],
|
||||
}
|
||||
for attack in state.combatant.attacks
|
||||
),
|
||||
@@ -3112,6 +3133,11 @@ class CombatEngine:
|
||||
else 0
|
||||
)
|
||||
base_bonus = bonus_override if bonus_override is not None else weapon.attack_bonus
|
||||
# PF1e (CRB "Conditions - Broken"): "If the item is a weapon, any
|
||||
# attacks made with the item suffer a -2 penalty on attack and
|
||||
# damage rolls. Such weapons only score a critical hit on a natural
|
||||
# 20 and only deal x2 damage on a confirmed critical hit."
|
||||
broken = weapon.id in attacker.weapon_broken
|
||||
fim_penalty = (
|
||||
self._firing_into_melee_penalty(attacker, target)
|
||||
if self._is_ranged_attack(weapon)
|
||||
@@ -3149,6 +3175,7 @@ class CombatEngine:
|
||||
total = (
|
||||
roll + base_bonus + penalty + flank + high_ground + attack_roll_mod - pa_amt
|
||||
+ fim_penalty + fd_penalty + attacker.aid_attack_bonus
|
||||
+ (_BROKEN_WEAPON_PENALTY if broken else 0)
|
||||
)
|
||||
# Check if target denies DEX to AC (flat-footed); a running target
|
||||
# loses its DEX bonus too (U5e), reusing the same AC path — unless it
|
||||
@@ -3160,6 +3187,55 @@ class CombatEngine:
|
||||
or self._running_denies_dex(target)
|
||||
or self._has_condition_named(attacker, "invisible")
|
||||
)
|
||||
ac = self._attack_ac(attacker, target, weapon, dist_ft, denies_dex=denies_dex)
|
||||
hit = roll == _NATURAL_TWENTY or (roll != _NATURAL_ONE and total >= ac)
|
||||
# PF1e (Ultimate Combat, Firearm Rules — Misfires): "If the natural
|
||||
# result of your attack roll falls within a firearm's misfire value,
|
||||
# that shot misses [...] When a firearm misfires, it gains the broken
|
||||
# condition." The model uses the minimum misfire value (1): a natural
|
||||
# 1 with a firearm (``firearm: true``) breaks the weapon — it joins
|
||||
# ``weapon_broken`` and is unusable until Quick Clear or a repair.
|
||||
# Bounded to firearms; the exploding-early-firearm and increased
|
||||
# misfire-value rules are not modeled (a broken gun cannot be fired
|
||||
# again here, so it cannot misfire twice).
|
||||
if weapon.firearm and roll == _NATURAL_ONE:
|
||||
attacker.weapon_broken.add(weapon.id)
|
||||
self._log(
|
||||
f"round {self._current_round} {attacker.combatant.id}: "
|
||||
f"{weapon.name} MISFIRE (broken)"
|
||||
)
|
||||
damage = 0
|
||||
concealment_miss = False
|
||||
crit = False
|
||||
if hit:
|
||||
crit, concealment_miss, damage = self._resolve_hit_damage(
|
||||
attacker, target, weapon, roll=roll, ac=ac, base_bonus=base_bonus,
|
||||
penalty=penalty, flank=flank, high_ground=high_ground,
|
||||
attack_mod=attack_mod, pa_amt=pa_amt, fim_penalty=fim_penalty,
|
||||
dist_ft=dist_ft, denies_dex=denies_dex, broken=broken,
|
||||
)
|
||||
if not concealment_miss:
|
||||
self._lose_hp(target, damage)
|
||||
damage = self._consume_up_close_and_deadly(
|
||||
attacker, target, weapon, dist_ft, damage, hit=hit
|
||||
)
|
||||
attacker.aid_attack_bonus = 0
|
||||
return AttackResult(
|
||||
hit=hit, crit=crit, damage=damage, roll=roll, total=total, ac=ac,
|
||||
penalty=penalty, flank_bonus=flank, high_ground_bonus=high_ground,
|
||||
base_bonus=base_bonus, melee_penalty=fim_penalty,
|
||||
)
|
||||
|
||||
def _attack_ac(
|
||||
self,
|
||||
attacker: CombatantState,
|
||||
target: CombatantState,
|
||||
weapon: AttackSpec,
|
||||
dist_ft: int,
|
||||
*,
|
||||
denies_dex: bool,
|
||||
) -> int:
|
||||
"""Effective AC of ``target`` against this attack (modifiers + cover)."""
|
||||
full_ac_base = (
|
||||
target.combatant.ac.flat_footed
|
||||
if denies_dex
|
||||
@@ -3193,43 +3269,7 @@ class CombatEngine:
|
||||
ac += self._attack_cover_bonus(
|
||||
attacker, target, ranged=self._is_ranged_attack(weapon)
|
||||
)
|
||||
hit = roll == _NATURAL_TWENTY or (roll != _NATURAL_ONE and total >= ac)
|
||||
# PF1e (Ultimate Combat, Firearm Rules — Misfires): "If the natural
|
||||
# result of your attack roll falls within a firearm's misfire value,
|
||||
# that shot misses [...] When a firearm misfires, it gains the broken
|
||||
# condition." The model uses the minimum misfire value (1): a natural
|
||||
# 1 with a firearm (``firearm: true``) breaks the weapon — it joins
|
||||
# ``weapon_broken`` and is unusable until Quick Clear or a repair.
|
||||
# Bounded to firearms; the exploding-early-firearm and increased
|
||||
# misfire-value rules are not modeled (a broken gun cannot be fired
|
||||
# again here, so it cannot misfire twice).
|
||||
if weapon.firearm and roll == _NATURAL_ONE:
|
||||
attacker.weapon_broken.add(weapon.id)
|
||||
self._log(
|
||||
f"round {self._current_round} {attacker.combatant.id}: "
|
||||
f"{weapon.name} MISFIRE (broken)"
|
||||
)
|
||||
damage = 0
|
||||
concealment_miss = False
|
||||
crit = False
|
||||
if hit:
|
||||
crit, concealment_miss, damage = self._resolve_hit_damage(
|
||||
attacker, target, weapon, roll=roll, ac=ac, base_bonus=base_bonus,
|
||||
penalty=penalty, flank=flank, high_ground=high_ground,
|
||||
attack_mod=attack_mod, pa_amt=pa_amt, fim_penalty=fim_penalty,
|
||||
dist_ft=dist_ft, denies_dex=denies_dex,
|
||||
)
|
||||
if not concealment_miss:
|
||||
self._lose_hp(target, damage)
|
||||
damage = self._consume_up_close_and_deadly(
|
||||
attacker, target, weapon, dist_ft, damage, hit=hit
|
||||
)
|
||||
attacker.aid_attack_bonus = 0
|
||||
return AttackResult(
|
||||
hit=hit, crit=crit, damage=damage, roll=roll, total=total, ac=ac,
|
||||
penalty=penalty, flank_bonus=flank, high_ground_bonus=high_ground,
|
||||
base_bonus=base_bonus, melee_penalty=fim_penalty,
|
||||
)
|
||||
return ac
|
||||
|
||||
def _resolve_hit_damage(
|
||||
self,
|
||||
@@ -3248,6 +3288,7 @@ class CombatEngine:
|
||||
fim_penalty: int,
|
||||
dist_ft: int,
|
||||
denies_dex: bool,
|
||||
broken: bool = False,
|
||||
) -> tuple[bool, bool, int]:
|
||||
"""Resolve a successful attack roll: crit confirm, concealment miss
|
||||
chance, sneak attack dice, then damage (PF1e order).
|
||||
@@ -3259,8 +3300,15 @@ class CombatEngine:
|
||||
the confirmation roll.
|
||||
"""
|
||||
crit = False
|
||||
threat_range = weapon.crit_range
|
||||
if self._has_feat(attacker, _IMPROVED_CRITICAL_FEATURE):
|
||||
# CRB "Broken": a broken weapon "only score[s] a critical hit on a
|
||||
# natural 20" — the threat range collapses to 20 and Improved
|
||||
# Critical cannot widen it (a broken weapon is not being wielded to
|
||||
# its full effect).
|
||||
threat_range = _NATURAL_TWENTY if broken else weapon.crit_range
|
||||
if (
|
||||
not broken
|
||||
and self._has_feat(attacker, _IMPROVED_CRITICAL_FEATURE)
|
||||
):
|
||||
threat_range = _CRIT_RANGE_BASE - 2 * (_CRIT_RANGE_BASE - weapon.crit_range)
|
||||
if roll != _NATURAL_ONE and roll >= threat_range:
|
||||
confirm = self._rng.d20()
|
||||
@@ -3268,6 +3316,7 @@ class CombatEngine:
|
||||
confirm + base_bonus + penalty + flank + high_ground
|
||||
+ attack_mod - pa_amt + fim_penalty + attacker.aid_attack_bonus
|
||||
+ (_FIGHT_DEFENSIVELY_ATTACK_PENALTY if attacker.fight_defensively else 0)
|
||||
+ (_BROKEN_WEAPON_PENALTY if broken else 0)
|
||||
)
|
||||
# PF1e (CRB): the confirmation roll is an attack roll — a natural
|
||||
# 20 on it is an automatic hit (auto-confirm), a natural 1 miss.
|
||||
@@ -3322,6 +3371,10 @@ class CombatEngine:
|
||||
energy_dice = sum(rolls) - physical_dice
|
||||
damage = physical_dice + weapon.damage_bonus
|
||||
damage += resolve_modifiers(dmg_mods)
|
||||
# CRB "Broken": the -2 rides the damage roll too (applied before the
|
||||
# multiplier, like every other flat damage modifier).
|
||||
if broken:
|
||||
damage += _BROKEN_WEAPON_PENALTY
|
||||
# PF1e (CRB Power Attack): +2X damage with a melee weapon, +3X (the
|
||||
# bonus increased by half) with a two-handed weapon, a one-handed
|
||||
# weapon used in two hands, or a primary natural with 1.5x Strength
|
||||
@@ -3334,7 +3387,9 @@ class CombatEngine:
|
||||
if weapon.kind == "melee":
|
||||
damage += pa_amt * (3 if weapon.two_handed else 2)
|
||||
if crit:
|
||||
damage *= weapon.crit_mult
|
||||
# CRB "Broken": a broken weapon "only deal[s] x2 damage on a
|
||||
# confirmed critical hit" even if its multiplier is higher.
|
||||
damage *= _BROKEN_CRIT_MULT if broken else weapon.crit_mult
|
||||
damage += sneak_total + energy_dice
|
||||
# PF1e (CRB Damage Reduction): "Damage reduction does not negate ...
|
||||
# energy damage dealt along with an attack" — DR reduces only the
|
||||
@@ -5417,9 +5472,8 @@ class CombatEngine:
|
||||
"""Repair a broken weapon: remove it from ``weapon_broken`` and reset damage.
|
||||
|
||||
No-op (with a log line) when ``weapon_id`` is None or the weapon was never
|
||||
damaged. Also removes the transient ``disarmed`` condition originating
|
||||
from the sunder that broke the weapon, so the repaired weapon is fully
|
||||
usable again.
|
||||
damaged. A DESTROYED weapon (hp <= 0, "ruined" per CRB "Smashing an
|
||||
Object") cannot be repaired by this action.
|
||||
"""
|
||||
if weapon_id is None:
|
||||
self._log(
|
||||
@@ -5427,6 +5481,12 @@ class CombatEngine:
|
||||
f"repair: nothing to repair"
|
||||
)
|
||||
return
|
||||
if weapon_id in state.weapon_destroyed:
|
||||
self._log(
|
||||
f"round {self._current_round} {state.combatant.id}: "
|
||||
f"repair {weapon_id}: weapon destroyed"
|
||||
)
|
||||
return
|
||||
if weapon_id not in state.weapon_damage:
|
||||
self._log(
|
||||
f"round {self._current_round} {state.combatant.id}: "
|
||||
@@ -5435,10 +5495,6 @@ class CombatEngine:
|
||||
return
|
||||
state.weapon_damage.pop(weapon_id, None)
|
||||
state.weapon_broken.discard(weapon_id)
|
||||
state.active_conditions = [
|
||||
c for c in state.active_conditions
|
||||
if not (c.condition.name == "disarmed" and c.source == "maneuver: sunder")
|
||||
]
|
||||
self._log(
|
||||
f"round {self._current_round} {state.combatant.id}: "
|
||||
f"repair {weapon_id} (restored)"
|
||||
@@ -5623,39 +5679,66 @@ class CombatEngine:
|
||||
if not m.success:
|
||||
self._maneuver_fail(state, target, "sunder", m)
|
||||
return
|
||||
weapon = next((w for w in target.usable_attacks() if w.kind == "melee"), None)
|
||||
if weapon is None:
|
||||
self._log(
|
||||
f"round {self._current_round} {state.combatant.id}: sunder vs "
|
||||
f"{target.combatant.id} d20={m.roll}+{m.attacker_cmb}={m.total} "
|
||||
f"CMD {m.target_cmd} -> SUCCESS (no weapon to damage)"
|
||||
)
|
||||
return
|
||||
dmg = state.combatant.abilities.str_mod + 2
|
||||
target.weapon_damage[weapon.id] = target.weapon_damage.get(weapon.id, 0) + dmg
|
||||
if target.weapon_damage[weapon.id] >= _WEAPON_DURABILITY:
|
||||
target.weapon_broken.add(weapon.id)
|
||||
target.active_conditions.append(
|
||||
ActiveCondition(
|
||||
condition=Condition(
|
||||
name="disarmed",
|
||||
modifiers=(StatModifier(target="attack", value=-4),),
|
||||
),
|
||||
rounds_left=1,
|
||||
source="maneuver: sunder",
|
||||
)
|
||||
)
|
||||
self._log(
|
||||
f"round {self._current_round} {state.combatant.id}: sunder vs "
|
||||
f"{target.combatant.id} d20={m.roll}+{m.attacker_cmb}={m.total} "
|
||||
f"CMD {m.target_cmd} -> SUCCESS (weapon destroyed)"
|
||||
)
|
||||
return
|
||||
self._log(
|
||||
# PF1e (SRD Sunder): "you can attempt to sunder an item held or worn
|
||||
# by your opponent" — a natural weapon is not an item, so only
|
||||
# manufactured melee weapons can be targeted.
|
||||
weapon = next(
|
||||
(
|
||||
w
|
||||
for w in target.usable_attacks()
|
||||
if w.kind == "melee" and not w.natural
|
||||
),
|
||||
None,
|
||||
)
|
||||
prefix = (
|
||||
f"round {self._current_round} {state.combatant.id}: sunder vs "
|
||||
f"{target.combatant.id} d20={m.roll}+{m.attacker_cmb}={m.total} "
|
||||
f"CMD {m.target_cmd} -> SUCCESS ({dmg} damage to weapon)"
|
||||
f"CMD {m.target_cmd}"
|
||||
)
|
||||
if weapon is None:
|
||||
self._log(f"{prefix} -> SUCCESS (no weapon to damage)")
|
||||
return
|
||||
# PF1e (SRD Sunder): "If your attack is successful, you deal damage
|
||||
# to the item normally" — the attacker's melee weapon damage (physical
|
||||
# components only; objects are immune to energy per CRB "Smashing an
|
||||
# Object" energy rules, which are out of scope). A broken attacker
|
||||
# weapon deals -2 (CRB "Broken").
|
||||
striker = next((w for w in state.usable_attacks() if w.kind == "melee"), None)
|
||||
raw = 0
|
||||
if striker is not None:
|
||||
raw = sum(
|
||||
comp.formula.roll(self._rng)
|
||||
for comp in striker.damage
|
||||
if _PHYSICAL_DAMAGE_TYPES.issuperset(comp.types)
|
||||
) + striker.damage_bonus
|
||||
if striker.id in state.weapon_broken:
|
||||
raw += _BROKEN_WEAPON_PENALTY
|
||||
# PF1e (CRB "Smashing an Object"): "subtract its hardness from the
|
||||
# damage. Only damage in excess of its hardness is deducted from the
|
||||
# object's hit points."
|
||||
hardness, max_hp = weapon.object_stats()
|
||||
dealt = max(0, raw - hardness)
|
||||
accumulated = target.weapon_damage.get(weapon.id, 0) + dealt
|
||||
target.weapon_damage[weapon.id] = accumulated
|
||||
hp_left = max_hp - accumulated
|
||||
if hp_left <= 0:
|
||||
# SRD Sunder: "If the damage you deal would reduce the object to
|
||||
# less than 0 hit points, you can choose to destroy it" — the
|
||||
# engine always takes the destructive option (no choice payload
|
||||
# on Action; documented simplification).
|
||||
target.weapon_broken.discard(weapon.id)
|
||||
target.weapon_destroyed.add(weapon.id)
|
||||
self._log(f"{prefix} -> SUCCESS (weapon destroyed)")
|
||||
return
|
||||
if hp_left * 2 <= max_hp:
|
||||
# SRD Sunder: "If an object has equal to or less than half its
|
||||
# total hit points remaining, it gains the broken condition."
|
||||
target.weapon_broken.add(weapon.id)
|
||||
self._log(
|
||||
f"{prefix} -> SUCCESS ({dealt} damage to weapon, broken)"
|
||||
)
|
||||
return
|
||||
self._log(f"{prefix} -> SUCCESS ({dealt} damage to weapon)")
|
||||
|
||||
def _nearest_anchor_adjacent_to(
|
||||
self, grappler: CombatantState, target: CombatantState
|
||||
|
||||
Reference in New Issue
Block a user