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
+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).