fix(rules): disarm drops the wielded weapon (weapon_dropped) instead of a synthetic -4 penalty

This commit is contained in:
2026-09-16 20:08:10 +02:00
parent fdc9127483
commit 2b5920c85c
3 changed files with 112 additions and 19 deletions
+51 -17
View File
@@ -178,6 +178,7 @@ _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)
_UP_CLOSE_RANGE_FT = 30 # PF1e: Up Close and Deadly works within 30 ft
_GRAPPLED_CMB_PENALTY = 2 # PF1e: grappled/pinned -2 on non-grapple/escape CMB
_DISARM_RETALIATION_MARGIN = 10 # PF1e SRD: disarm fails by 10 -> attacker drops
# PF1e: Point-Blank Shot grants +1/+1 within 30 ft with ranged weapons
_POINT_BLANK_RANGE_FT = 30
_FIRING_MELEE_PENALTY = -4 # PF1e: ranged attack into melee
@@ -701,6 +702,11 @@ class CombatantState:
# Runtime weapon theft (not in the frozen data model): weapon ids taken by
# steal maneuvers.
weapon_stolen: set[str] = field(default_factory=set)
# Runtime disarmed weapons (not in the frozen data model): weapon ids the
# combatant DROPPED on a failed disarm maneuver (SRD Disarm: "your
# opponent drops the weapon"). A retrieve_weapon move action picks the
# weapon back up.
weapon_dropped: set[str] = field(default_factory=set)
# Dying & stabilization (not in the frozen data model): the hp at which
# this combatant stabilized while dying; None = not stabilized.
stabilized_hp: int | None = None
@@ -752,8 +758,11 @@ class CombatantState:
def usable_attacks(self) -> list[AttackSpec]:
return [
w for w in self.combatant.attacks
if w.id not in self.weapon_broken and w.id not in self.weapon_stolen
w
for w in self.combatant.attacks
if w.id not in self.weapon_broken
and w.id not in self.weapon_stolen
and w.id not in self.weapon_dropped
]
def occupied_cells(self) -> frozenset[Pos]:
@@ -1422,6 +1431,7 @@ class CombatEngine:
"resources_left": resources,
"weapon_broken": tuple(sorted(state.weapon_broken)),
"weapon_stolen": tuple(sorted(state.weapon_stolen)),
"weapon_dropped": tuple(sorted(state.weapon_dropped)),
"grappled_with": state.grappled_with,
"moved_this_turn": state.moved_this_turn,
"power_attack": state.power_attack,
@@ -4674,10 +4684,14 @@ class CombatEngine:
self._resolve_aoo(target, state)
def _retrieve_weapon(self, state: CombatantState, weapon_id: str | None) -> None:
"""Restore a stolen weapon to its owner (remove it from ``weapon_stolen``).
"""Pick a stolen or dropped weapon back up.
No-op (with a log line) when ``weapon_id`` is None or the id was never
stolen. Once restored, the weapon is usable again via ``usable_attacks()``.
Removes ``weapon_id`` from ``weapon_stolen`` (steal maneuvers) or
``weapon_dropped`` (disarm maneuvers — SRD Disarm: the disarmed
creature "drops one item it is carrying"; retrieving a dropped item
is a move action). No-op (with a log line) when ``weapon_id`` is
None or the id was never stolen or dropped. Once restored, the
weapon is usable again via ``usable_attacks()``.
"""
if weapon_id is None:
self._log(
@@ -4685,13 +4699,16 @@ class CombatEngine:
f"retrieve: nothing to retrieve"
)
return
if weapon_id not in state.weapon_stolen:
if weapon_id in state.weapon_stolen:
state.weapon_stolen.remove(weapon_id)
elif weapon_id in state.weapon_dropped:
state.weapon_dropped.remove(weapon_id)
else:
self._log(
f"round {self._current_round} {state.combatant.id}: "
f"retrieve {weapon_id}: nothing to retrieve"
)
return
state.weapon_stolen.remove(weapon_id)
self._log(
f"round {self._current_round} {state.combatant.id}: "
f"retrieve {weapon_id} (restored)"
@@ -4822,21 +4839,38 @@ class CombatEngine:
) -> None:
if not m.success:
self._maneuver_fail(state, target, "disarm", m)
# PF1e (SRD Disarm): "If your attack fails by 10 or more, you
# drop the weapon that you were using to attempt the disarm."
if m.total <= m.target_cmd - _DISARM_RETALIATION_MARGIN:
own = next(
(w for w in state.usable_attacks() if w.kind == "melee"), None
)
if own is not None:
state.weapon_dropped.add(own.id)
self._log(
f"round {self._current_round} {state.combatant.id}: "
f"disarm backfire -> drops {own.name}"
)
return
target.active_conditions.append(
ActiveCondition(
condition=Condition(
name="disarmed",
modifiers=(StatModifier(target="attack", value=-4),),
),
rounds_left=1,
source="maneuver: disarm",
# PF1e (SRD Disarm): "If your attack is successful, your target drops
# one item it is carrying of your choice" — the weapon lands on the
# ground (unusable until a retrieve_weapon move action picks it back
# up), NOT a synthetic -4 attack penalty. The engine chooses the
# target's first usable melee weapon (the wielded weapon, same choice
# as the AoO machinery).
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}: disarm vs "
f"{target.combatant.id} d20={m.roll}+{m.attacker_cmb}={m.total} "
f"CMD {m.target_cmd} -> SUCCESS (no weapon to drop)"
)
)
return
target.weapon_dropped.add(weapon.id)
self._log(
f"round {self._current_round} {state.combatant.id}: disarm vs "
f"{target.combatant.id} d20={m.roll}+{m.attacker_cmb}={m.total} "
f"CMD {m.target_cmd} -> SUCCESS (disarmed)"
f"CMD {m.target_cmd} -> SUCCESS ({weapon.name} dropped)"
)
def _maneuver_bull_rush(