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
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
+38 -26
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
# 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 adjacent and self._cells_free(self._footprint_at(beyond, target), target):
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} "
+49
View File
@@ -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")