diff --git a/README.md b/README.md index 563dfd3..f0eb3c2 100644 --- a/README.md +++ b/README.md @@ -1073,10 +1073,14 @@ Règles modélisées : held in the hands (such as wielded weapons or wands) also cannot be taken with the steal maneuver—you must use the disarm combat maneuver instead »), une réussite ne vole rien (journalisé `nothing stealable — wielded weapons - require disarm`) ; reposition → déplacement de 5 ft de la - cible vers le premier voisin libre (direction sélectionnable via - `Action.direction`) ; drag → traction de 5 ft de la cible, l'attaquant - occupant sa case d'origine. `default_policy` ne + require disarm`) ; reposition → déplacement de la cible de 5 ft plus + « For every 5 by which your attack exceeds your opponent's CMD, you can + move the target an additional 5 feet » (APG « Reposition ») — `1 + + marge//5` cases le long de la direction choisie (premier voisin libre par + défaut, direction sélectionnable via `Action.direction`), stoppées au + premier obstacle ; drag → traction de la cible de 5 ft plus 5 ft + supplémentaires par 5 points d'excès sur le CMD (APG « Drag »), l'attaquant + occupant sa case d'origine, stoppée au premier obstacle. `default_policy` ne choisit pas ces manœuvres (utilisables via `Action(kind="maneuver")` ou une politique personnalisée). - Combat Reflexes : 1 AoO par combattant par round par défaut ; avec le don Combat diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 30ee2e3..e932961 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -264,6 +264,8 @@ _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 _DIRTY_TRICK_EXTRA_ROUND_MARGIN = 5 # PF1e APG: +1 round per 5 points above CMD +_REPOSITION_EXTRA_PER_MARGIN = 5 # PF1e APG: +5 ft move per 5 points above CMD +_DRAG_EXTRA_PER_MARGIN = 5 # PF1e APG: +5 ft drag per 5 points above CMD _TRIP_RETALIATION_MARGIN = 10 # PF1e: trip failing by 10+ knocks the attacker prone _MANEUVER_MAX_SIZE_DIFF = 2 # PF1e: 2+ size categories larger = unmaneuverable _SAVE_DESCRIPTOR_CONDITIONS = frozenset({ @@ -4496,30 +4498,30 @@ class CombatEngine: if not m.success: self._maneuver_fail(state, target, "reposition", m) return - new_pos: Pos | None = None - if direction is not None: - pos = (target.pos[0] + direction[0], target.pos[1] + direction[1]) - if ( - 0 <= pos[0] < self._grid.height - and 0 <= pos[1] < self._grid.width - and self._cells_free(self._footprint_at(pos, target), target) - ): - new_pos = pos - else: + # PF1e (APG Reposition): "you may move your target 5 feet to a new + # location. For every 5 by which your attack exceeds your opponent's + # CMD, you can move the target an additional 5 feet" — the walk stops + # at the first obstacle ("You cannot move a creature into a square + # that is occupied by a solid object or obstacle"). + distance = 1 + max(0, m.total - m.target_cmd) // _REPOSITION_EXTRA_PER_MARGIN + step = direction + if step is None: + # First free neighbor (the attacker's own square is occupied, so + # it can never be picked here). for dr, dc in _NEIGHBOR_OFFSETS: - pos = (target.pos[0] + dr, target.pos[1] + dc) - if pos == state.pos: - continue - if not (0 <= pos[0] < self._grid.height and 0 <= pos[1] < self._grid.width): - continue - if self._cells_free(self._footprint_at(pos, target), target): - new_pos = pos + cand = (target.pos[0] + dr, target.pos[1] + dc) + if self._cells_free(self._footprint_at(cand, target), target): + step = (dr, dc) break - if new_pos is not None: - target.pos = new_pos - outcome = f"SUCCESS (moved to {new_pos})" - else: - outcome = "SUCCESS (no space)" + moved = False + if step is not None: + for _ in range(distance): + pos = (target.pos[0] + step[0], target.pos[1] + step[1]) + if not self._cells_free(self._footprint_at(pos, target), target): + break + target.pos = pos + moved = True + outcome = f"SUCCESS (moved to {target.pos})" if moved else "SUCCESS (no space)" self._log( f"round {self._current_round} {state.combatant.id}: reposition vs " f"{target.combatant.id} d20={m.roll}+{m.attacker_cmb}={m.total} " @@ -4537,20 +4539,30 @@ class CombatEngine: if not m.success: self._maneuver_fail(state, target, "drag", m) return - ar, ac = state.pos - tr, tc = target.pos - adjacent = max(abs(tr - ar), abs(tc - ac)) == 1 - if direction is not None: - beyond = (tr + direction[0], tc + direction[1]) - else: - beyond = self._beyond_cell(state.pos, target.pos) - if adjacent and self._cells_free(self._footprint_at(beyond, target), target): + # PF1e (APG Drag): "both you and your target are moved 5 feet back, + # with your opponent occupying your original space and you in the + # space behind that in a straight line. For every 5 by which your + # attack exceeds your opponent's CMD, you can drag the target back an + # additional 5 feet" — the line stops at the first obstacle. + distance = 1 + max(0, m.total - m.target_cmd) // _DRAG_EXTRA_PER_MARGIN + dragged = 0 + for _ in range(distance): + ar, ac = state.pos + tr, tc = target.pos + adjacent = max(abs(tr - ar), abs(tc - ac)) == 1 + if not adjacent: + break + if direction is not None: + beyond = (tr + direction[0], tc + direction[1]) + else: + beyond = self._beyond_cell(state.pos, target.pos) + if not self._cells_free(self._footprint_at(beyond, target), target): + break target_old = target.pos target.pos = beyond state.pos = target_old - outcome = "SUCCESS (dragged)" - else: - outcome = "SUCCESS (no space)" + dragged += 1 + outcome = "SUCCESS (dragged)" if dragged else "SUCCESS (no space)" self._log( f"round {self._current_round} {state.combatant.id}: drag vs " f"{target.combatant.id} d20={m.roll}+{m.attacker_cmb}={m.total} " diff --git a/tests/test_maneuvers.py b/tests/test_maneuvers.py index f1a24e5..0e10eec 100644 --- a/tests/test_maneuvers.py +++ b/tests/test_maneuvers.py @@ -382,6 +382,55 @@ def test_steal_nothing_to_steal() -> None: assert "nothing stealable" in engine._transcript[-1] +def test_reposition_distance_scales_with_margin() -> None: + # APG Reposition: "For every 5 by which your attack exceeds your + # opponent's CMD, you can move the target an additional 5 feet." + # a CMB 2 vs b CMD 12: d20 17 -> total 19, margin 7 -> 2 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, 17], [a, b]) # leading 1: AoO auto-miss + engine._execute_maneuver(a, b, "reposition", direction=(0, 1)) + assert b.pos == (1, 4) + + +def test_reposition_stops_at_obstacle() -> None: + # The extra cell is blocked by c: the walk stops on (1, 3), the last + # free square ("You cannot move a creature into a square that is + # occupied by a solid object or obstacle"). + 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, 17], [a, b, c]) + engine._execute_maneuver(a, b, "reposition", direction=(0, 1)) + assert b.pos == (1, 3) + assert c.pos == (1, 4) + + +def test_drag_distance_scales_with_margin() -> None: + # APG Drag: "For every 5 by which your attack exceeds your opponent's + # CMD, you can drag the target back an additional 5 feet." Margin 7 -> + # two 5-ft swaps along the straight line: b (1,2)->(1,4), a (1,1)->(1,3). + 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, 17], [a, b]) + engine._execute_maneuver(a, b, "drag") + assert b.pos == (1, 4) + assert a.pos == (1, 3) + + +def test_drag_stops_at_obstacle() -> None: + # Second drag cell (1, 4) is occupied: the drag ends after the first + # 5 ft ("If there is another creature in the way of your movement, the + # drag ends adjacent to that creature"). + 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, 17], [a, b, c]) + engine._execute_maneuver(a, b, "drag") + assert b.pos == (1, 3) + assert a.pos == (1, 2) + + def test_reposition_moves_to_first_free_neighbor() -> None: a_spec = make_combatant("a", attack_bonus=2) b_spec = make_combatant("b")