From 5568e0f8af33aa8ebca8d5726363f30285156f9b Mon Sep 17 00:00:00 2001 From: ctan Date: Fri, 11 Sep 2026 22:22:57 +0200 Subject: [PATCH] fix(rules): knock prone only on a 5-point overrun margin PF1e CRB 'Overrun': 'If your maneuver is successful, you move through the target's space. If your attack exceeds your opponent's CMD by 5 or more, ... the target is knocked prone.' The engine knocked the target prone on ANY success. A plain success (margin 0-4) now only moves the attacker through; prone requires total - CMD >= 5 (_OVERRUN_PRONE_MARGIN). Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- README.md | 8 ++++++-- src/pf1e_simulator/combat.py | 30 ++++++++++++++++++++---------- tests/test_maneuvers.py | 23 +++++++++++++++++++++-- 3 files changed, 47 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 4f01a85..1cf4e75 100644 --- a/README.md +++ b/README.md @@ -955,8 +955,12 @@ Règles modélisées : sans boucle multi-tour. Un sunder endommage l'**arme**, pas les PV : `str_mod + 2` de dégâts à l'arme, qui se brise à 10 d'usure cumulée (`_WEAPON_DURABILITY`) → `disarmed` ; une arme brisée est exclue de `usable_attacks()` et réparable via - `Action(kind="repair_weapon")`. overrun → - `prone` 1 round + passage au travers si la case au-delà est libre ; dirty trick → + `Action(kind="repair_weapon")`. overrun → + passage au travers si la case au-delà est libre, et `prone` 1 round + **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 → 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 ced4cb0..b9a42c8 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -246,6 +246,7 @@ _PRECISION_IMMUNE_TYPES: frozenset[str] = frozenset({"undead", "construct"}) _AID_ANOTHER_DC = 10 # PF1e: aid another is an attack roll against AC 10 _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 _SAVE_DESCRIPTOR_CONDITIONS = frozenset({ "fear", "cold", "electricity", "fey", "divination", "poison", "disease", "mind_affecting", @@ -4077,19 +4078,28 @@ class CombatEngine: if not m.success: self._maneuver_fail(state, target, "overrun", m) return - target.active_conditions.append( - ActiveCondition( - condition=CONDITIONS_BY_NAME["prone"], - rounds_left=1, - source="maneuver: overrun", + # PF1e (CRB "Overrun"): a successful maneuver moves you through the + # target's square; the target is knocked prone only if the attack + # exceeds its CMD by 5 or more. + knocked_prone = m.total - m.target_cmd >= _OVERRUN_PRONE_MARGIN + if knocked_prone: + target.active_conditions.append( + ActiveCondition( + condition=CONDITIONS_BY_NAME["prone"], + rounds_left=1, + source="maneuver: overrun", + ) ) - ) beyond = self._beyond_cell(state.pos, target.pos) - if self._cells_free(self._footprint_at(beyond, state), state): + moved_through = self._cells_free(self._footprint_at(beyond, state), state) + if moved_through: state.pos = beyond - outcome = "SUCCESS (prone, moved through)" - else: - outcome = "SUCCESS (prone)" + achieved = ", ".join( + label + for label, flag in (("prone", knocked_prone), ("moved through", moved_through)) + if flag + ) + outcome = f"SUCCESS ({achieved})" if achieved else "SUCCESS" self._log( f"round {self._current_round} {state.combatant.id}: overrun vs " f"{target.combatant.id} d20={m.roll}+{m.attacker_cmb}={m.total} " diff --git a/tests/test_maneuvers.py b/tests/test_maneuvers.py index d00768d..bac1298 100644 --- a/tests/test_maneuvers.py +++ b/tests/test_maneuvers.py @@ -123,11 +123,13 @@ def test_escape_from_grapple_consumes_single_d20() -> None: 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). a_spec = make_combatant("a", attack_bonus=2) b_spec = make_combatant("b") a = make_state(a_spec, "players", (1, 1)) b = make_state(b_spec, "monsters", (1, 2)) - engine = make_engine([1, 12], [a, b]) + engine = make_engine([1, 15], [a, b]) engine._execute_maneuver(a, b, "overrun") prone = next(c for c in b.active_conditions if c.condition.name == "prone") assert prone.rounds_left == 1 @@ -135,14 +137,31 @@ def test_overrun_knocks_prone_and_moves_through() -> None: assert a.pos == (1, 3) +def test_overrun_small_margin_moves_through_without_prone() -> None: + # d20=14+2=16 vs CMD 12: margin 4 -> plain success: moved through, + # NOT prone (CRB "Overrun"). + a_spec = make_combatant("a", attack_bonus=2) + b_spec = make_combatant("b") + a = make_state(a_spec, "players", (1, 1)) + b = make_state(b_spec, "monsters", (1, 2)) + engine = make_engine([1, 14], [a, b]) + engine._execute_maneuver(a, b, "overrun") + assert not any(c.condition.name == "prone" for c in b.active_conditions) + assert a.pos == (1, 3) + assert "SUCCESS (moved through)" in engine._transcript[-1] + assert "prone" not in engine._transcript[-1] + + def test_overrun_prone_but_no_space() -> None: + # d20=15+2=17 vs CMD 12: margin 5 -> prone; the beyond cell is occupied, + # so no move-through. a_spec = make_combatant("a", attack_bonus=2) b_spec = make_combatant("b") c_spec = make_combatant("c") a = make_state(a_spec, "players", (1, 1)) b = make_state(b_spec, "monsters", (1, 2)) c = make_state(c_spec, "monsters", (1, 3)) - engine = make_engine([1, 12], [a, b, c]) + engine = make_engine([1, 15], [a, b, c]) engine._execute_maneuver(a, b, "overrun") assert any(cond.condition.name == "prone" for cond in b.active_conditions) assert a.pos == (1, 1)