fix(rules): disarm drops the wielded weapon (weapon_dropped) instead of a synthetic -4 penalty
This commit is contained in:
@@ -1317,7 +1317,10 @@ Règles modélisées :
|
|||||||
(tentative refusée,
|
(tentative refusée,
|
||||||
journalisée `rejected (target too large)`, aucun jet, pas d'AoO provoqué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.
|
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
|
cible (direction sélectionnable via `Action.direction`) ; conformance L6
|
||||||
(SRD « Bull Rush ») : la poussée vaut 5 ft plus « For every 5 by which
|
(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
|
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) :
|
state) -> tuple[Action, ...]`). Schéma enrichi (E1-E5) :
|
||||||
- Par combattant : id, side, pos, occupied_cells, hp/hp_max, active/dead,
|
- Par combattant : id, side, pos, occupied_cells, hp/hp_max, active/dead,
|
||||||
dying/stabilized, conditions (noms + rounds_left), resources_left (+ `spell_slot_{level}`),
|
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
|
name, level, size, natural_reach_ft (S4c : allonge naturelle du gabarit
|
||||||
en pieds — 0 pour Tiny/Diminutive/Fine, 5 pour Small/Medium, 10 pour
|
en pieds — 0 pour Tiny/Diminutive/Fine, 5 pour Small/Medium, 10 pour
|
||||||
Large… ; l'allonge effective d'une attaque reste `max(allonge naturelle,
|
Large… ; l'allonge effective d'une attaque reste `max(allonge naturelle,
|
||||||
|
|||||||
@@ -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)
|
_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
|
_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
|
_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
|
# PF1e: Point-Blank Shot grants +1/+1 within 30 ft with ranged weapons
|
||||||
_POINT_BLANK_RANGE_FT = 30
|
_POINT_BLANK_RANGE_FT = 30
|
||||||
_FIRING_MELEE_PENALTY = -4 # PF1e: ranged attack into melee
|
_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
|
# Runtime weapon theft (not in the frozen data model): weapon ids taken by
|
||||||
# steal maneuvers.
|
# steal maneuvers.
|
||||||
weapon_stolen: set[str] = field(default_factory=set)
|
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
|
# Dying & stabilization (not in the frozen data model): the hp at which
|
||||||
# this combatant stabilized while dying; None = not stabilized.
|
# this combatant stabilized while dying; None = not stabilized.
|
||||||
stabilized_hp: int | None = None
|
stabilized_hp: int | None = None
|
||||||
@@ -752,8 +758,11 @@ class CombatantState:
|
|||||||
|
|
||||||
def usable_attacks(self) -> list[AttackSpec]:
|
def usable_attacks(self) -> list[AttackSpec]:
|
||||||
return [
|
return [
|
||||||
w for w in self.combatant.attacks
|
w
|
||||||
if w.id not in self.weapon_broken and w.id not in self.weapon_stolen
|
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]:
|
def occupied_cells(self) -> frozenset[Pos]:
|
||||||
@@ -1422,6 +1431,7 @@ class CombatEngine:
|
|||||||
"resources_left": resources,
|
"resources_left": resources,
|
||||||
"weapon_broken": tuple(sorted(state.weapon_broken)),
|
"weapon_broken": tuple(sorted(state.weapon_broken)),
|
||||||
"weapon_stolen": tuple(sorted(state.weapon_stolen)),
|
"weapon_stolen": tuple(sorted(state.weapon_stolen)),
|
||||||
|
"weapon_dropped": tuple(sorted(state.weapon_dropped)),
|
||||||
"grappled_with": state.grappled_with,
|
"grappled_with": state.grappled_with,
|
||||||
"moved_this_turn": state.moved_this_turn,
|
"moved_this_turn": state.moved_this_turn,
|
||||||
"power_attack": state.power_attack,
|
"power_attack": state.power_attack,
|
||||||
@@ -4674,10 +4684,14 @@ class CombatEngine:
|
|||||||
self._resolve_aoo(target, state)
|
self._resolve_aoo(target, state)
|
||||||
|
|
||||||
def _retrieve_weapon(self, state: CombatantState, weapon_id: str | None) -> None:
|
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
|
Removes ``weapon_id`` from ``weapon_stolen`` (steal maneuvers) or
|
||||||
stolen. Once restored, the weapon is usable again via ``usable_attacks()``.
|
``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:
|
if weapon_id is None:
|
||||||
self._log(
|
self._log(
|
||||||
@@ -4685,13 +4699,16 @@ class CombatEngine:
|
|||||||
f"retrieve: nothing to retrieve"
|
f"retrieve: nothing to retrieve"
|
||||||
)
|
)
|
||||||
return
|
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(
|
self._log(
|
||||||
f"round {self._current_round} {state.combatant.id}: "
|
f"round {self._current_round} {state.combatant.id}: "
|
||||||
f"retrieve {weapon_id}: nothing to retrieve"
|
f"retrieve {weapon_id}: nothing to retrieve"
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
state.weapon_stolen.remove(weapon_id)
|
|
||||||
self._log(
|
self._log(
|
||||||
f"round {self._current_round} {state.combatant.id}: "
|
f"round {self._current_round} {state.combatant.id}: "
|
||||||
f"retrieve {weapon_id} (restored)"
|
f"retrieve {weapon_id} (restored)"
|
||||||
@@ -4822,21 +4839,38 @@ class CombatEngine:
|
|||||||
) -> None:
|
) -> None:
|
||||||
if not m.success:
|
if not m.success:
|
||||||
self._maneuver_fail(state, target, "disarm", m)
|
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
|
return
|
||||||
target.active_conditions.append(
|
# PF1e (SRD Disarm): "If your attack is successful, your target drops
|
||||||
ActiveCondition(
|
# one item it is carrying of your choice" — the weapon lands on the
|
||||||
condition=Condition(
|
# ground (unusable until a retrieve_weapon move action picks it back
|
||||||
name="disarmed",
|
# up), NOT a synthetic -4 attack penalty. The engine chooses the
|
||||||
modifiers=(StatModifier(target="attack", value=-4),),
|
# target's first usable melee weapon (the wielded weapon, same choice
|
||||||
),
|
# as the AoO machinery).
|
||||||
rounds_left=1,
|
weapon = next((w for w in target.usable_attacks() if w.kind == "melee"), None)
|
||||||
source="maneuver: disarm",
|
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(
|
self._log(
|
||||||
f"round {self._current_round} {state.combatant.id}: disarm vs "
|
f"round {self._current_round} {state.combatant.id}: disarm vs "
|
||||||
f"{target.combatant.id} d20={m.roll}+{m.attacker_cmb}={m.total} "
|
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(
|
def _maneuver_bull_rush(
|
||||||
|
|||||||
@@ -284,6 +284,62 @@ def test_unrestricted_maneuver_ignores_size_limit() -> None:
|
|||||||
assert not engine._rng._queue
|
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:
|
def test_overrun_knocks_prone_and_moves_through() -> None:
|
||||||
# d20=15+2=17 vs CMD 12: margin 5 -> prone AND moved through (CRB
|
# 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).
|
# "Overrun": prone only when the attack exceeds CMD by 5 or more).
|
||||||
|
|||||||
Reference in New Issue
Block a user