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
+29 -11
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:
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:
pushed = False
for _ in range(distance):
new_pos = (target.pos[0] + direction[0], target.pos[1] + direction[1])
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)
if not self._cells_free(self._footprint_at(new_pos, target), target):
return False
target.pos = new_pos
return True
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):
break
target.pos = new_pos
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."""