From 1b60cd049b6f4f7711b27abc6c40ec1fd3b2ee2a Mon Sep 17 00:00:00 2001 From: ctan Date: Sat, 12 Sep 2026 09:34:57 +0200 Subject: [PATCH] fix(rules): apply maneuver failure consequences PF1e (SRD Trip): "If your attack fails by 10 or more, you are knocked prone instead" - a trip whose total lands at CMD-10 or below now knocks the attacker prone (1 round). PF1e (SRD Overrun): "If your overrun attempt fails, you stop in the space directly in front of the opponent, or the nearest open space in front of the creature" - a failed overrun from beyond adjacency walks the attacker to the free approach cell nearest the target; an already-adjacent attacker stays put. README updated. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- README.md | 8 +++++- src/pf1e_simulator/combat.py | 45 ++++++++++++++++++++++++++++++ tests/test_maneuvers.py | 54 ++++++++++++++++++++++++++++++++++++ 3 files changed, 106 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 974ed17..750679d 100644 --- a/README.md +++ b/README.md @@ -1027,7 +1027,13 @@ Règles modélisées : **seulement si le jet dépasse le CMD de 5 ou plus** (conformance R3 — CRB « Overrun » : « If your attack exceeds your opponent's CMD by 5 or more, you move through the target's space and the target is knocked prone ») ; - un succès par 0–4 points ne fait que traverser, sans renverser ; dirty trick → + un succès par 0–4 points ne fait que traverser, sans renverser ; conformance + L7 (SRD « Overrun ») : une tentative **échouée** arrête l'attaquant « in the + space directly in front of the opponent, or the nearest open space in front + of the creature » — l'attaquant non adjacent est déplacé sur la case libre + la plus proche de la cible sur la ligne d'approche (adjacent : immobile) ; + conformance L7 (SRD « Trip ») : un trip qui « fails by 10 or more » renverse + l'**attaquant** (`prone` 1 round, `total <= CMD - 10`) ; dirty trick → condition choisie par `Action.trick_condition` (défaut `dazzled` 1 round ; `blinded`/`deafened`/`entangled`/`nauseated` possibles) ; steal → l'arme de mêlée de la cible est volée (exclue de `usable_attacks()`, récupérable via diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index c221e5d..de621e6 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -261,6 +261,7 @@ _AID_ANOTHER_BONUS = 2 # PF1e: a successful aid grants +2 (next attack or AC) _RUN_SPEED_FACTOR = 4 # PF1e: running is a full-round action at 4x speed _OVERRUN_PRONE_MARGIN = 5 # PF1e: overrun knocks prone only on a 5+ CMB margin _BULL_RUSH_EXTRA_PER_MARGIN = 5 # PF1e: +5 ft push per 5 points above CMD +_TRIP_RETALIATION_MARGIN = 10 # PF1e: trip failing by 10+ knocks the attacker prone _SAVE_DESCRIPTOR_CONDITIONS = frozenset({ "fear", "cold", "electricity", "fey", "divination", "poison", "disease", "mind_affecting", @@ -4119,6 +4120,20 @@ class CombatEngine: ) -> None: if not m.success: self._maneuver_fail(state, target, "trip", m) + # PF1e (SRD Trip): "If your attack fails by 10 or more, you are + # knocked prone instead." + if m.total <= m.target_cmd - _TRIP_RETALIATION_MARGIN: + state.active_conditions.append( + ActiveCondition( + condition=CONDITIONS_BY_NAME["prone"], + rounds_left=1, + source="maneuver: trip retaliation", + ) + ) + self._log( + f"round {self._current_round} {state.combatant.id}: " + f"trip backfire -> attacker prone" + ) return target.active_conditions.append( ActiveCondition( @@ -4264,6 +4279,7 @@ class CombatEngine: ) -> None: if not m.success: self._maneuver_fail(state, target, "overrun", m) + self._overrun_fail_stop(state, target) return # PF1e (CRB "Overrun"): a successful maneuver moves you through the # target's square; the target is knocked prone only if the attack @@ -4293,6 +4309,35 @@ class CombatEngine: f"CMD {m.target_cmd} -> {outcome}" ) + def _overrun_fail_stop( + self, state: CombatantState, target: CombatantState + ) -> None: + """Park a failed overrun on the open approach cell in front of the target. + + PF1e (SRD Overrun): "If your overrun attempt fails, you stop in the + space directly in front of the opponent, or the nearest open space + in front of the creature if there are other creatures occupying that + space." An already-adjacent attacker is in front: no move, no log. + """ + ar, ac = state.pos + tr, tc = target.pos + steps = max(abs(tr - ar), abs(tc - ac)) + if steps <= 1: + return + dr = 1 if tr - ar > 0 else (-1 if tr - ar < 0 else 0) + dc = 1 if tc - ac > 0 else (-1 if tc - ac < 0 else 0) + for i in range(steps - 1, 0, -1): + cell = (ar + dr * i, ac + dc * i) + if self._cells_free(self._footprint_at(cell, state), state): + start = state.pos + state.pos = cell + self._log( + f"round {self._current_round} {state.combatant.id}: " + f"overrun blocked, stopped ({start[0]},{start[1]})->" + f"({cell[0]},{cell[1]})" + ) + return + def _maneuver_dirty_trick( self, state: CombatantState, diff --git a/tests/test_maneuvers.py b/tests/test_maneuvers.py index 3f8684f..c03a74f 100644 --- a/tests/test_maneuvers.py +++ b/tests/test_maneuvers.py @@ -194,6 +194,60 @@ def test_bull_rush_natural_20_below_cmd_pushes_one_cell() -> None: assert b.pos == (1, 3) +def test_trip_fails_by_10_knocks_attacker_prone() -> None: + # SRD Trip: "If your attack fails by 10 or more, you are knocked prone + # instead." b CMD 30 (bab 20); a total 10+2=12 fails by 18. + 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, 10], [a, b]) + engine._execute_maneuver(a, b, "trip") + assert not any(c.condition.name == "prone" for c in b.active_conditions) + prone = next(c for c in a.active_conditions if c.condition.name == "prone") + assert prone.source == "maneuver: trip retaliation" + + +def test_trip_fails_by_less_than_10_attacker_stands() -> None: + # a total 19+2=21 vs CMD 30 fails by 9 — no retaliation. + 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, 19], [a, b]) + engine._execute_maneuver(a, b, "trip") + assert not any(c.condition.name == "prone" for c in a.active_conditions) + + +def test_overrun_fail_stops_in_front_of_target() -> None: + # SRD Overrun: "If your overrun attempt fails, you stop in the space + # directly in front of the opponent." a at (1,0), b at (1,3): the failed + # attempt parks a on (1,2), the cell in front of b. a is not threatened + # from (1,0), so no AoO roll is consumed: the single d20 is the maneuver. + a = make_state(make_combatant("a", attack_bonus=2), "players", (1, 0)) + b = make_state(make_combatant("b"), "monsters", (1, 3)) + engine = make_engine([5], [a, b]) + engine._execute_maneuver(a, b, "overrun") + assert a.pos == (1, 2) + + +def test_overrun_fail_nearest_open_when_front_occupied() -> None: + # "...or the nearest open space in front of the creature if there are + # other creatures occupying that space." Blocker on (1,2): a stops (1,1). + a = make_state(make_combatant("a", attack_bonus=2), "players", (1, 0)) + b = make_state(make_combatant("b"), "monsters", (1, 3)) + c = make_state(make_combatant("c"), "monsters", (1, 2)) + engine = make_engine([1, 5], [a, b, c]) + engine._execute_maneuver(a, b, "overrun") + assert a.pos == (1, 1) + + +def test_overrun_fail_adjacent_attacker_does_not_move() -> None: + # An already-adjacent attacker is in front of the target: it stays put. + 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, 5], [a, b]) + engine._execute_maneuver(a, b, "overrun") + assert a.pos == (1, 1) + assert not any("overrun blocked" 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).