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
+5 -2
View File
@@ -1317,7 +1317,10 @@ Règles modélisées :
(tentative refusée,
journalisée `rejected (target too large)`, aucun jet, pas d'AoO provoquée) ;
les autres manœuvres (disarm, sunder, grapple…) n'ont pas de limite de taille.
trip → `prone` ; disarm → `disarmed` (−4 attaque) ; bull_rush → pousse la
trip → `prone` ; disarm → l'arme tenue est **au sol** (SRD « Disarm » :
« your target drops one item it is carrying » — `weapon_dropped`, exclue
de `usable_attacks()` jusqu'à un `retrieve_weapon` ; un échec de 10 ou
plus fait tomber **l'arme de l'attaquant**) ; bull_rush → pousse la
cible (direction sélectionnable via `Action.direction`) ; conformance L6
(SRD « Bull Rush ») : la poussée vaut 5 ft plus « For every 5 by which
your attack exceeds your opponent's CMD you can push the target back an
@@ -1473,7 +1476,7 @@ Règles modélisées :
state) -> tuple[Action, ...]`). Schéma enrichi (E1-E5) :
- Par combattant : id, side, pos, occupied_cells, hp/hp_max, active/dead,
dying/stabilized, conditions (noms + rounds_left), resources_left (+ `spell_slot_{level}`),
weapon_broken/weapon_stolen, grappled_with, moved_this_turn, power_attack,
weapon_broken/weapon_stolen/weapon_dropped, grappled_with, moved_this_turn, power_attack,
name, level, size, natural_reach_ft (S4c : allonge naturelle du gabarit
en pieds — 0 pour Tiny/Diminutive/Fine, 5 pour Small/Medium, 10 pour
Large… ; l'allonge effective d'une attaque reste `max(allonge naturelle,
+52 -18
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 (disarmed)"
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 ({weapon.name} dropped)"
)
def _maneuver_bull_rush(
+56
View File
@@ -284,6 +284,62 @@ def test_unrestricted_maneuver_ignores_size_limit() -> None:
assert not engine._rng._queue
# --- SRD Disarm: the target DROPS the weapon (no synthetic -4 penalty) --------
def test_disarm_success_drops_target_weapon() -> None:
# SRD Disarm: "If your attack is successful, your target drops one item
# it is carrying." The dropped weapon is unusable until retrieved; the
# old synthetic "disarmed" -4 attack condition is gone.
a = make_state(make_combatant("a", attack_bonus=2), "players", (1, 1))
b = make_state(make_combatant("b"), "monsters", (1, 2))
engine = make_engine([1, 12], [a, b])
engine._execute_maneuver(a, b, "disarm")
assert "b-w" in b.weapon_dropped
assert [w.id for w in b.usable_attacks()] == []
assert engine.weapon_for(b, a) is None
assert not any(c.condition.name == "disarmed" for c in b.active_conditions)
assert "SUCCESS (sword dropped)" in engine._transcript[-1]
def test_retrieve_weapon_picks_dropped_weapon_back_up() -> None:
# Picking the dropped weapon back up is a move action (retrieve_weapon);
# once restored, the weapon is usable again.
a = make_state(make_combatant("a", attack_bonus=2), "players", (1, 1))
b = make_state(make_combatant("b"), "monsters", (1, 2))
engine = make_engine([1, 12], [a, b])
engine._execute_maneuver(a, b, "disarm")
assert "b-w" in b.weapon_dropped
engine._retrieve_weapon(b, "b-w")
assert "b-w" not in b.weapon_dropped
assert [w.id for w in b.usable_attacks()] == ["b-w"]
assert "retrieve b-w (restored)" in engine._transcript[-1]
def test_retrieve_weapon_still_restores_stolen() -> None:
# The steal path keeps working: retrieve removes the weapon from
# weapon_stolen.
b = make_state(make_combatant("b"), "monsters", (1, 2))
b.weapon_stolen.add("b-w")
engine = make_engine([], [b])
engine._retrieve_weapon(b, "b-w")
assert not b.weapon_stolen
assert [w.id for w in b.usable_attacks()] == ["b-w"]
def test_disarm_fail_by_10_drops_attacker_weapon() -> None:
# SRD Disarm: "If your attack fails by 10 or more, you drop the weapon
# that you were using to attempt the disarm." a total 2+2=4 vs CMD 14
# fails by 10 -> a drops its sword; b's weapon is untouched.
a = make_state(make_combatant("a", attack_bonus=2), "players", (1, 1))
b = make_state(make_combatant("b", attack_bonus=20), "monsters", (1, 2))
engine = make_engine([1, 2], [a, b])
engine._execute_maneuver(a, b, "disarm")
assert "a-w" in a.weapon_dropped
assert not b.weapon_dropped
assert any("disarm backfire -> drops sword" in line for line in engine._transcript)
def test_overrun_knocks_prone_and_moves_through() -> None:
# d20=15+2=17 vs CMD 12: margin 5 -> prone AND moved through (CRB
# "Overrun": prone only when the attack exceeds CMD by 5 or more).