fix(rules): scale the bull rush push distance with the margin

PF1e (SRD Bull Rush): "your target is pushed back 5 feet. For every 5 by which your attack exceeds your opponent's CMD you can push the target back an additional 5 feet." _bull_rush_push (and the explicit-direction branch) now walk 1 + margin//5 cells, stopping at the first obstacle; a natural-20 auto-success below CMD floors the margin at 0 (base push only). README updated.

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-12 09:27:03 +02:00
parent 2f00d76ecc
commit 346e300eab
4 changed files with 67 additions and 13 deletions
+5 -1
View File
@@ -1008,7 +1008,11 @@ Règles modélisées :
creature does not add its Dexterity bonus to its CMD » — la pénalité de
Dextérité, qui n'est pas un bonus, reste appliquée).
trip → `prone` ; disarm → `disarmed` (−4 attaque) ; bull_rush → pousse la
cible (direction sélectionnable via `Action.direction`) ;
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
additional 5 feet » — `1 + marge//5` cases, stoppées au premier obstacle
(un 20 naturel sous le CMD ne donne que la poussée de base) ;
grapple → `grappled`/`pinned` ; escape → **une** sauvegarde (bonus statique de la
compétence `escape_artist` ou CMB, + un d20) contre CMD — une seule tentative,
sans boucle multi-tour. Conformance L4 (SRD « Combat Maneuvers ») : le 20
+26 -8
View File
@@ -260,6 +260,7 @@ _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
_BULL_RUSH_EXTRA_PER_MARGIN = 5 # PF1e: +5 ft push per 5 points above CMD
_SAVE_DESCRIPTOR_CONDITIONS = frozenset({
"fear", "cold", "electricity", "fey", "divination", "poison", "disease",
"mind_affecting",
@@ -4165,13 +4166,21 @@ class CombatEngine:
if not m.success:
self._maneuver_fail(state, target, "bull_rush", m)
return
# PF1e (SRD Bull Rush): "your target is pushed back 5 feet. For every
# 5 by which your attack exceeds your opponent's CMD you can push the
# target back an additional 5 feet." A natural-20 auto-success below
# CMD still gets only the base 5 ft, hence the margin floor of 0.
distance = 1 + max(0, m.total - m.target_cmd) // _BULL_RUSH_EXTRA_PER_MARGIN
if direction is not None:
pushed = False
for _ in range(distance):
new_pos = (target.pos[0] + direction[0], target.pos[1] + direction[1])
pushed = self._cells_free(self._footprint_at(new_pos, target), target)
if pushed:
if not self._cells_free(self._footprint_at(new_pos, target), target):
break
target.pos = new_pos
pushed = True
else:
pushed = self._bull_rush_push(state, target)
pushed = self._bull_rush_push(state, target, distance)
outcome = "SUCCESS (pushed)" if pushed else "SUCCESS (no space)"
self._log(
f"round {self._current_round} {state.combatant.id}: bull rush vs "
@@ -6154,8 +6163,14 @@ class CombatEngine:
"""Size modifier for CMB/CMD: Medium=0, Small=-1, Large=1, etc."""
return _SIZE_MODIFIERS.get(state.combatant.size.lower(), 0)
def _bull_rush_push(self, attacker: CombatantState, target: CombatantState) -> bool:
"""Push target 5ft away from attacker. Returns True if push succeeded."""
def _bull_rush_push(
self, attacker: CombatantState, target: CombatantState, distance: int
) -> bool:
"""Push target up to ``distance`` cells away from attacker.
The push stops at the first obstacle; returns True if the target
moved at least one cell.
"""
# Calculate direction away from attacker
ar, ac = attacker.pos
tr, tc = target.pos
@@ -6166,11 +6181,14 @@ class CombatEngine:
dr = 1 if dr > 0 else -1
if dc != 0:
dc = 1 if dc > 0 else -1
new_pos = (tr + dr, tc + dc)
moved = False
for _ in range(distance):
new_pos = (target.pos[0] + dr, target.pos[1] + dc)
if not self._cells_free(self._footprint_at(new_pos, target), target):
return False
break
target.pos = new_pos
return True
moved = True
return moved
def _beyond_cell(self, attacker_pos: Pos, target_pos: Pos) -> Pos:
"""The 1-cell position on the far side of the target from the attacker."""
+1 -1
View File
@@ -534,7 +534,7 @@ class TestBullRushLarge:
t_state = make_state(target, "e", (3, 3))
engine = make_engine([a_state, t_state])
# Push direction: away from attacker (3,1) -> (3,3) means push right
result = engine._bull_rush_push(a_state, t_state)
result = engine._bull_rush_push(a_state, t_state, 1)
if result:
# Target moved 1 cell right
assert t_state.pos == (3, 4)
+32
View File
@@ -162,6 +162,38 @@ def test_escape_natural_1_still_fails_above_cmd() -> None:
assert any(c.condition.name == "grappled" for c in a.active_conditions)
def test_bull_rush_push_scales_with_margin() -> None:
# SRD Bull Rush: "For every 5 by which your attack exceeds your
# opponent's CMD you can push the target back an additional 5 feet."
# d20=20+2=22 vs CMD 12: margin 10 -> push 1 + 10//5 = 3 cells.
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, 20], [a, b])
engine._execute_maneuver(a, b, "bull_rush")
assert b.pos == (1, 5)
def test_bull_rush_push_stops_at_obstacle() -> None:
# Same margin 10 (3 cells) but a creature blocks (1, 4): the push moves
# b two cells and stops.
a = make_state(make_combatant("a", attack_bonus=2), "players", (1, 1))
b = make_state(make_combatant("b"), "monsters", (1, 2))
c = make_state(make_combatant("c"), "monsters", (1, 4))
engine = make_engine([1, 20], [a, b, c])
engine._execute_maneuver(a, b, "bull_rush")
assert b.pos == (1, 3)
def test_bull_rush_natural_20_below_cmd_pushes_one_cell() -> None:
# Natural 20 auto-succeeds even below CMD (bab 20 foe: CMD 30, total 22):
# the margin floors at 0, so only the base 5 ft push applies.
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, 20], [a, b])
engine._execute_maneuver(a, b, "bull_rush")
assert b.pos == (1, 3)
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).