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
|
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).
|
Dextérité, qui n'est pas un bonus, reste appliquée).
|
||||||
trip → `prone` ; disarm → `disarmed` (−4 attaque) ; bull_rush → pousse la
|
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
|
grapple → `grappled`/`pinned` ; escape → **une** sauvegarde (bonus statique de la
|
||||||
compétence `escape_artist` ou CMB, + un d20) contre CMD — une seule tentative,
|
compétence `escape_artist` ou CMB, + un d20) contre CMD — une seule tentative,
|
||||||
sans boucle multi-tour. Conformance L4 (SRD « Combat Maneuvers ») : le 20
|
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)
|
_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
|
_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({
|
_SAVE_DESCRIPTOR_CONDITIONS = frozenset({
|
||||||
"fear", "cold", "electricity", "fey", "divination", "poison", "disease",
|
"fear", "cold", "electricity", "fey", "divination", "poison", "disease",
|
||||||
"mind_affecting",
|
"mind_affecting",
|
||||||
@@ -4165,13 +4166,21 @@ class CombatEngine:
|
|||||||
if not m.success:
|
if not m.success:
|
||||||
self._maneuver_fail(state, target, "bull_rush", m)
|
self._maneuver_fail(state, target, "bull_rush", m)
|
||||||
return
|
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:
|
if direction is not None:
|
||||||
|
pushed = False
|
||||||
|
for _ in range(distance):
|
||||||
new_pos = (target.pos[0] + direction[0], target.pos[1] + direction[1])
|
new_pos = (target.pos[0] + direction[0], target.pos[1] + direction[1])
|
||||||
pushed = self._cells_free(self._footprint_at(new_pos, target), target)
|
if not self._cells_free(self._footprint_at(new_pos, target), target):
|
||||||
if pushed:
|
break
|
||||||
target.pos = new_pos
|
target.pos = new_pos
|
||||||
|
pushed = True
|
||||||
else:
|
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)"
|
outcome = "SUCCESS (pushed)" if pushed else "SUCCESS (no space)"
|
||||||
self._log(
|
self._log(
|
||||||
f"round {self._current_round} {state.combatant.id}: bull rush vs "
|
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."""
|
"""Size modifier for CMB/CMD: Medium=0, Small=-1, Large=1, etc."""
|
||||||
return _SIZE_MODIFIERS.get(state.combatant.size.lower(), 0)
|
return _SIZE_MODIFIERS.get(state.combatant.size.lower(), 0)
|
||||||
|
|
||||||
def _bull_rush_push(self, attacker: CombatantState, target: CombatantState) -> bool:
|
def _bull_rush_push(
|
||||||
"""Push target 5ft away from attacker. Returns True if push succeeded."""
|
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
|
# Calculate direction away from attacker
|
||||||
ar, ac = attacker.pos
|
ar, ac = attacker.pos
|
||||||
tr, tc = target.pos
|
tr, tc = target.pos
|
||||||
@@ -6166,11 +6181,14 @@ class CombatEngine:
|
|||||||
dr = 1 if dr > 0 else -1
|
dr = 1 if dr > 0 else -1
|
||||||
if dc != 0:
|
if dc != 0:
|
||||||
dc = 1 if dc > 0 else -1
|
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):
|
if not self._cells_free(self._footprint_at(new_pos, target), target):
|
||||||
return False
|
break
|
||||||
target.pos = new_pos
|
target.pos = new_pos
|
||||||
return True
|
moved = True
|
||||||
|
return moved
|
||||||
|
|
||||||
def _beyond_cell(self, attacker_pos: Pos, target_pos: Pos) -> Pos:
|
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."""
|
"""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))
|
t_state = make_state(target, "e", (3, 3))
|
||||||
engine = make_engine([a_state, t_state])
|
engine = make_engine([a_state, t_state])
|
||||||
# Push direction: away from attacker (3,1) -> (3,3) means push right
|
# 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:
|
if result:
|
||||||
# Target moved 1 cell right
|
# Target moved 1 cell right
|
||||||
assert t_state.pos == (3, 4)
|
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)
|
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:
|
def test_overrun_knocks_prone_and_moves_through() -> None:
|
||||||
# d20=15+2=17 vs CMD 12: margin 5 -> prone AND moved through (CRB
|
# 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).
|
# "Overrun": prone only when the attack exceeds CMD by 5 or more).
|
||||||
|
|||||||
Reference in New Issue
Block a user