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
+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")