diff --git a/README.md b/README.md index 8dd1af6..088be1a 100644 --- a/README.md +++ b/README.md @@ -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, diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index b2922e2..adc5a0c 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -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( diff --git a/tests/test_maneuvers.py b/tests/test_maneuvers.py index 50baaf1..f4ed9fa 100644 --- a/tests/test_maneuvers.py +++ b/tests/test_maneuvers.py @@ -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).