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:
@@ -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
|
||||||
|
|||||||
@@ -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} "
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
Reference in New Issue
Block a user