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
+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
_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} "