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