diff --git a/README.md b/README.md index 97d1b0a..974ed17 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 922ce23..c221e5d 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -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.""" diff --git a/tests/test_creature_sizes.py b/tests/test_creature_sizes.py index 453bdc1..3027e8e 100644 --- a/tests/test_creature_sizes.py +++ b/tests/test_creature_sizes.py @@ -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) diff --git a/tests/test_maneuvers.py b/tests/test_maneuvers.py index 881c7b5..3f8684f 100644 --- a/tests/test_maneuvers.py +++ b/tests/test_maneuvers.py @@ -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).