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:
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user