fix(rules): scale reposition and drag distance with the margin

PF1e (APG Reposition / Drag): "For every 5 by which your attack exceeds
your opponent's CMD, you can move/drag the target an additional 5 feet."
Both handlers now walk 1 + margin//5 cells along the chosen (or auto-picked)
direction, stopping at the first obstacle or occupied square, mirroring the
bull rush scaling. 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 11:43:12 +02:00
parent f9420c7b09
commit 8043dd393c
3 changed files with 102 additions and 37 deletions
+8 -4
View File
@@ -1073,10 +1073,14 @@ Règles modélisées :
held in the hands (such as wielded weapons or wands) also cannot be taken 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 »), with the steal maneuver—you must use the disarm combat maneuver instead »),
une réussite ne vole rien (journalisé `nothing stealable — wielded weapons une réussite ne vole rien (journalisé `nothing stealable — wielded weapons
require disarm`) ; reposition → déplacement de 5 ft de la require disarm`) ; reposition → déplacement de la cible de 5 ft plus
cible vers le premier voisin libre (direction sélectionnable via « For every 5 by which your attack exceeds your opponent's CMD, you can
`Action.direction`) ; drag → traction de 5 ft de la cible, l'attaquant move the target an additional 5 feet » (APG « Reposition ») — `1 +
occupant sa case d'origine. `default_policy` ne 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 choisit pas ces manœuvres (utilisables via `Action(kind="maneuver")` ou une
politique personnalisée). politique personnalisée).
- Combat Reflexes : 1 AoO par combattant par round par défaut ; avec le don Combat - Combat Reflexes : 1 AoO par combattant par round par défaut ; avec le don Combat
+45 -33
View File
@@ -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 _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 _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 _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 _TRIP_RETALIATION_MARGIN = 10 # PF1e: trip failing by 10+ knocks the attacker prone
_MANEUVER_MAX_SIZE_DIFF = 2 # PF1e: 2+ size categories larger = unmaneuverable _MANEUVER_MAX_SIZE_DIFF = 2 # PF1e: 2+ size categories larger = unmaneuverable
_SAVE_DESCRIPTOR_CONDITIONS = frozenset({ _SAVE_DESCRIPTOR_CONDITIONS = frozenset({
@@ -4496,30 +4498,30 @@ class CombatEngine:
if not m.success: if not m.success:
self._maneuver_fail(state, target, "reposition", m) self._maneuver_fail(state, target, "reposition", m)
return return
new_pos: Pos | None = None # PF1e (APG Reposition): "you may move your target 5 feet to a new
if direction is not None: # location. For every 5 by which your attack exceeds your opponent's
pos = (target.pos[0] + direction[0], target.pos[1] + direction[1]) # CMD, you can move the target an additional 5 feet" — the walk stops
if ( # at the first obstacle ("You cannot move a creature into a square
0 <= pos[0] < self._grid.height # that is occupied by a solid object or obstacle").
and 0 <= pos[1] < self._grid.width distance = 1 + max(0, m.total - m.target_cmd) // _REPOSITION_EXTRA_PER_MARGIN
and self._cells_free(self._footprint_at(pos, target), target) step = direction
): if step is None:
new_pos = pos # First free neighbor (the attacker's own square is occupied, so
else: # it can never be picked here).
for dr, dc in _NEIGHBOR_OFFSETS: for dr, dc in _NEIGHBOR_OFFSETS:
pos = (target.pos[0] + dr, target.pos[1] + dc) cand = (target.pos[0] + dr, target.pos[1] + dc)
if pos == state.pos: if self._cells_free(self._footprint_at(cand, target), target):
continue step = (dr, dc)
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
break break
if new_pos is not None: moved = False
target.pos = new_pos if step is not None:
outcome = f"SUCCESS (moved to {new_pos})" for _ in range(distance):
else: pos = (target.pos[0] + step[0], target.pos[1] + step[1])
outcome = "SUCCESS (no space)" 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( self._log(
f"round {self._current_round} {state.combatant.id}: reposition vs " f"round {self._current_round} {state.combatant.id}: reposition vs "
f"{target.combatant.id} d20={m.roll}+{m.attacker_cmb}={m.total} " f"{target.combatant.id} d20={m.roll}+{m.attacker_cmb}={m.total} "
@@ -4537,20 +4539,30 @@ class CombatEngine:
if not m.success: if not m.success:
self._maneuver_fail(state, target, "drag", m) self._maneuver_fail(state, target, "drag", m)
return return
ar, ac = state.pos # PF1e (APG Drag): "both you and your target are moved 5 feet back,
tr, tc = target.pos # with your opponent occupying your original space and you in the
adjacent = max(abs(tr - ar), abs(tc - ac)) == 1 # space behind that in a straight line. For every 5 by which your
if direction is not None: # attack exceeds your opponent's CMD, you can drag the target back an
beyond = (tr + direction[0], tc + direction[1]) # additional 5 feet" — the line stops at the first obstacle.
else: distance = 1 + max(0, m.total - m.target_cmd) // _DRAG_EXTRA_PER_MARGIN
beyond = self._beyond_cell(state.pos, target.pos) dragged = 0
if adjacent and self._cells_free(self._footprint_at(beyond, target), target): 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_old = target.pos
target.pos = beyond target.pos = beyond
state.pos = target_old state.pos = target_old
outcome = "SUCCESS (dragged)" dragged += 1
else: outcome = "SUCCESS (dragged)" if dragged else "SUCCESS (no space)"
outcome = "SUCCESS (no space)"
self._log( self._log(
f"round {self._current_round} {state.combatant.id}: drag vs " f"round {self._current_round} {state.combatant.id}: drag vs "
f"{target.combatant.id} d20={m.roll}+{m.attacker_cmb}={m.total} " f"{target.combatant.id} d20={m.roll}+{m.attacker_cmb}={m.total} "
+49
View File
@@ -382,6 +382,55 @@ def test_steal_nothing_to_steal() -> None:
assert "nothing stealable" in engine._transcript[-1] 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: def test_reposition_moves_to_first_free_neighbor() -> None:
a_spec = make_combatant("a", attack_bonus=2) a_spec = make_combatant("a", attack_bonus=2)
b_spec = make_combatant("b") b_spec = make_combatant("b")