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 <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-09-11 22:22:57 +02:00
parent b453b19be2
commit 5568e0f8af
3 changed files with 47 additions and 14 deletions
+5 -1
View File
@@ -956,7 +956,11 @@ Règles modélisées :
de dégâts à l'arme, qui se brise à 10 d'usure cumulée (`_WEAPON_DURABILITY`) → 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 `disarmed` ; une arme brisée est exclue de `usable_attacks()` et réparable via
`Action(kind="repair_weapon")`. overrun → `Action(kind="repair_weapon")`. overrun →
`prone` 1 round + passage au travers si la case au-delà est libre ; dirty trick → 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 ; condition choisie par `Action.trick_condition` (défaut `dazzled` 1 round ;
`blinded`/`deafened`/`entangled`/`nauseated` possibles) ; steal → l'arme de `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 mêlée de la cible est volée (exclue de `usable_attacks()`, récupérable via
+14 -4
View File
@@ -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_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) _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 _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({ _SAVE_DESCRIPTOR_CONDITIONS = frozenset({
"fear", "cold", "electricity", "fey", "divination", "poison", "disease", "fear", "cold", "electricity", "fey", "divination", "poison", "disease",
"mind_affecting", "mind_affecting",
@@ -4077,6 +4078,11 @@ class CombatEngine:
if not m.success: if not m.success:
self._maneuver_fail(state, target, "overrun", m) self._maneuver_fail(state, target, "overrun", m)
return return
# 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( target.active_conditions.append(
ActiveCondition( ActiveCondition(
condition=CONDITIONS_BY_NAME["prone"], condition=CONDITIONS_BY_NAME["prone"],
@@ -4085,11 +4091,15 @@ class CombatEngine:
) )
) )
beyond = self._beyond_cell(state.pos, target.pos) 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 state.pos = beyond
outcome = "SUCCESS (prone, moved through)" achieved = ", ".join(
else: label
outcome = "SUCCESS (prone)" for label, flag in (("prone", knocked_prone), ("moved through", moved_through))
if flag
)
outcome = f"SUCCESS ({achieved})" if achieved else "SUCCESS"
self._log( self._log(
f"round {self._current_round} {state.combatant.id}: overrun vs " f"round {self._current_round} {state.combatant.id}: overrun 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} "
+21 -2
View File
@@ -123,11 +123,13 @@ def test_escape_from_grapple_consumes_single_d20() -> None:
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
# "Overrun": prone only when the attack exceeds CMD by 5 or more).
a_spec = make_combatant("a", attack_bonus=2) a_spec = make_combatant("a", attack_bonus=2)
b_spec = make_combatant("b") b_spec = make_combatant("b")
a = make_state(a_spec, "players", (1, 1)) a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2)) 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") engine._execute_maneuver(a, b, "overrun")
prone = next(c for c in b.active_conditions if c.condition.name == "prone") prone = next(c for c in b.active_conditions if c.condition.name == "prone")
assert prone.rounds_left == 1 assert prone.rounds_left == 1
@@ -135,14 +137,31 @@ def test_overrun_knocks_prone_and_moves_through() -> None:
assert a.pos == (1, 3) 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: 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) a_spec = make_combatant("a", attack_bonus=2)
b_spec = make_combatant("b") b_spec = make_combatant("b")
c_spec = make_combatant("c") c_spec = make_combatant("c")
a = make_state(a_spec, "players", (1, 1)) a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2)) b = make_state(b_spec, "monsters", (1, 2))
c = make_state(c_spec, "monsters", (1, 3)) 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") engine._execute_maneuver(a, b, "overrun")
assert any(cond.condition.name == "prone" for cond in b.active_conditions) assert any(cond.condition.name == "prone" for cond in b.active_conditions)
assert a.pos == (1, 1) assert a.pos == (1, 1)