fix(rules): allow movement through friendly squares
PF1e (SRD Movement, Position and Distance): "You can move through a square occupied by a friendly character, unless you are charging." The move and run descents now route their Dijkstra cost field and greedy steps over hostile-only blocking (_blocked_hostile), so allies' squares are passable; a friendly square may only be entered when a free cell descends from it and the path tail is trimmed, so movement never ENDS on an occupied cell. Charge, withdraw, the auto 5-foot step and the Observation cost fields keep full blocking (documented). Corridor tests added; the Combat Reflexes multi-AoO transcript is unchanged. 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:
@@ -588,7 +588,16 @@ Règles modélisées :
|
|||||||
vitesse du combattant en cases le long du plus court chemin réel (champ de
|
vitesse du combattant en cases le long du plus court chemin réel (champ de
|
||||||
coût Dijkstra depuis la cible, diagonales 5-10-5), en s'arrêtant adjacent à
|
coût Dijkstra depuis la cible, diagonales 5-10-5), en s'arrêtant adjacent à
|
||||||
l'ennemi le plus proche — les combattants contournent les murs au lieu
|
l'ennemi le plus proche — les combattants contournent les murs au lieu
|
||||||
d'osciller contre eux.
|
d'osciller contre eux. Conformance L5 (SRD « Movement, Position and
|
||||||
|
Distance ») : « You can move through a square occupied by a friendly
|
||||||
|
character, unless you are charging » — `move` et `run` **traversent** les
|
||||||
|
cases des alliés (les ennemis bloquent toujours le chemin), mais ne
|
||||||
|
peuvent jamais **finir** sur une case occupée : entrer sur une case alliée
|
||||||
|
exige un prolongement libre (règle du « pass-through »), et la queue du
|
||||||
|
chemin est tronquée si le budget s'épuise sur une case alliée. La charge
|
||||||
|
reste bloquée par les créatures (ligne droite dégagée), comme la retraite
|
||||||
|
(limite documentée : le passage à travers les alliés n'est pas modélisé
|
||||||
|
pour `withdraw` et le pas de placement auto-dirigé).
|
||||||
Vitesse de déplacement (V4) : le budget de tout mouvement (déplacement,
|
Vitesse de déplacement (V4) : le budget de tout mouvement (déplacement,
|
||||||
charge 2×, retraite 2×, course 4×) est le **meilleur entre la vitesse au
|
charge 2×, retraite 2×, course 4×) est le **meilleur entre la vitesse au
|
||||||
sol (`speed_land_ft`) et la vitesse de vol (`speed_fly_ft`)** — une créature
|
sol (`speed_land_ft`) et la vitesse de vol (`speed_fly_ft`)** — une créature
|
||||||
|
|||||||
@@ -1953,6 +1953,20 @@ class CombatEngine:
|
|||||||
for cell in s.occupied_cells()
|
for cell in s.occupied_cells()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _blocked_hostile(self, mover: CombatantState) -> frozenset[Pos]:
|
||||||
|
"""Occupied cells of active creatures hostile to ``mover``.
|
||||||
|
|
||||||
|
Same-side allies are excluded: PF1e (SRD Movement, Position and
|
||||||
|
Distance) — "You can move through a square occupied by a friendly
|
||||||
|
character, unless you are charging."
|
||||||
|
"""
|
||||||
|
return frozenset(
|
||||||
|
cell
|
||||||
|
for s in self._states
|
||||||
|
if s.active and s is not mover and s.side != mover.side
|
||||||
|
for cell in s.occupied_cells()
|
||||||
|
)
|
||||||
|
|
||||||
def creature_distance(self, a: CombatantState, b: CombatantState) -> int:
|
def creature_distance(self, a: CombatantState, b: CombatantState) -> int:
|
||||||
"""Minimum grid distance between any occupied cell of ``a`` and ``b``."""
|
"""Minimum grid distance between any occupied cell of ``a`` and ``b``."""
|
||||||
cells_b = b.occupied_cells()
|
cells_b = b.occupied_cells()
|
||||||
@@ -1973,6 +1987,18 @@ class CombatEngine:
|
|||||||
self._grid.passable(cell) and cell not in blocked for cell in cells
|
self._grid.passable(cell) and cell not in blocked for cell in cells
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _cells_walkable(self, cells: Iterable[Pos], mover: CombatantState) -> bool:
|
||||||
|
"""True if a move/run path may enter these cells (allies allowed).
|
||||||
|
|
||||||
|
Inbounds, passable, and not occupied by a hostile creature; a
|
||||||
|
friendly creature's square may be crossed through (SRD Movement),
|
||||||
|
though the movement may not END there (``_cells_free`` gates that).
|
||||||
|
"""
|
||||||
|
hostile = self._blocked_hostile(mover)
|
||||||
|
return all(
|
||||||
|
self._grid.passable(cell) and cell not in hostile for cell in cells
|
||||||
|
)
|
||||||
|
|
||||||
def _footprint_at(self, anchor: Pos, state: CombatantState) -> frozenset[Pos]:
|
def _footprint_at(self, anchor: Pos, state: CombatantState) -> frozenset[Pos]:
|
||||||
"""The cells ``state`` would occupy if its anchor were at ``anchor``."""
|
"""The cells ``state`` would occupy if its anchor were at ``anchor``."""
|
||||||
n = SIZE_CELLS.get(state.combatant.size, 1)
|
n = SIZE_CELLS.get(state.combatant.size, 1)
|
||||||
@@ -3605,7 +3631,8 @@ class CombatEngine:
|
|||||||
return ActionResult(kind=kind, ok=True, reason=_REASON_OK)
|
return ActionResult(kind=kind, ok=True, reason=_REASON_OK)
|
||||||
if action.destination is not None:
|
if action.destination is not None:
|
||||||
reachable = self._destination_reachable(
|
reachable = self._destination_reachable(
|
||||||
state, action.destination, speed_factor=_RUN_SPEED_FACTOR
|
state, action.destination, speed_factor=_RUN_SPEED_FACTOR,
|
||||||
|
friendly_passable=True,
|
||||||
)
|
)
|
||||||
if not reachable:
|
if not reachable:
|
||||||
self._log(
|
self._log(
|
||||||
@@ -3620,12 +3647,14 @@ class CombatEngine:
|
|||||||
action.destination,
|
action.destination,
|
||||||
speed_factor=_RUN_SPEED_FACTOR,
|
speed_factor=_RUN_SPEED_FACTOR,
|
||||||
avoid_difficult=True,
|
avoid_difficult=True,
|
||||||
|
friendly_passable=True,
|
||||||
)
|
)
|
||||||
self._walk_steps(state, path)
|
self._walk_steps(state, path)
|
||||||
state.moved_this_turn = True
|
state.moved_this_turn = True
|
||||||
elif target is not None:
|
elif target is not None:
|
||||||
path = self._descend_path(
|
path = self._descend_path(
|
||||||
state, target.pos, speed_factor=_RUN_SPEED_FACTOR, avoid_difficult=True
|
state, target.pos, speed_factor=_RUN_SPEED_FACTOR, avoid_difficult=True,
|
||||||
|
friendly_passable=True,
|
||||||
)
|
)
|
||||||
if self._walk_steps(state, path):
|
if self._walk_steps(state, path):
|
||||||
state.moved_this_turn = True
|
state.moved_this_turn = True
|
||||||
@@ -5808,7 +5837,7 @@ class CombatEngine:
|
|||||||
if self.has_condition_flag(state, "cant_move"):
|
if self.has_condition_flag(state, "cant_move"):
|
||||||
self._log(f"round {self._current_round} {state.combatant.id}: wait (cannot move)")
|
self._log(f"round {self._current_round} {state.combatant.id}: wait (cannot move)")
|
||||||
return
|
return
|
||||||
path = self._move_path(state, target)
|
path = self._move_path(state, target, friendly_passable=True)
|
||||||
start = state.pos
|
start = state.pos
|
||||||
actual_path = self._walk_steps(state, path)
|
actual_path = self._walk_steps(state, path)
|
||||||
if actual_path:
|
if actual_path:
|
||||||
@@ -5829,7 +5858,7 @@ class CombatEngine:
|
|||||||
if self.has_condition_flag(state, "cant_move"):
|
if self.has_condition_flag(state, "cant_move"):
|
||||||
self._log(f"round {self._current_round} {state.combatant.id}: wait (cannot move)")
|
self._log(f"round {self._current_round} {state.combatant.id}: wait (cannot move)")
|
||||||
return ActionResult(kind=kind, ok=True, reason=_REASON_OK)
|
return ActionResult(kind=kind, ok=True, reason=_REASON_OK)
|
||||||
if not self._destination_reachable(state, destination):
|
if not self._destination_reachable(state, destination, friendly_passable=True):
|
||||||
self._log(
|
self._log(
|
||||||
f"round {self._current_round} {state.combatant.id}: "
|
f"round {self._current_round} {state.combatant.id}: "
|
||||||
f"{kind} rejected ({_REASON_UNREACHABLE_DESTINATION})"
|
f"{kind} rejected ({_REASON_UNREACHABLE_DESTINATION})"
|
||||||
@@ -5837,7 +5866,7 @@ class CombatEngine:
|
|||||||
return ActionResult(
|
return ActionResult(
|
||||||
kind=kind, ok=False, reason=_REASON_UNREACHABLE_DESTINATION
|
kind=kind, ok=False, reason=_REASON_UNREACHABLE_DESTINATION
|
||||||
)
|
)
|
||||||
path = self._descend_path(state, destination)
|
path = self._descend_path(state, destination, friendly_passable=True)
|
||||||
self._walk_steps(state, path)
|
self._walk_steps(state, path)
|
||||||
state.moved_this_turn = True
|
state.moved_this_turn = True
|
||||||
self._log(
|
self._log(
|
||||||
@@ -5852,10 +5881,14 @@ class CombatEngine:
|
|||||||
destination: Pos,
|
destination: Pos,
|
||||||
*,
|
*,
|
||||||
speed_factor: int = 1,
|
speed_factor: int = 1,
|
||||||
|
friendly_passable: bool = False,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""True when destination is passable, unoccupied and within this speed.
|
"""True when destination is passable, unoccupied and within this speed.
|
||||||
|
|
||||||
``speed_factor`` widens the budget (run: 4x speed, U5e).
|
``speed_factor`` widens the budget (run: 4x speed, U5e).
|
||||||
|
``friendly_passable`` routes the cost field around allies' squares
|
||||||
|
(move/run may cross them, SRD Movement); the destination itself must
|
||||||
|
still be completely free.
|
||||||
"""
|
"""
|
||||||
speed_cells = int(
|
speed_cells = int(
|
||||||
_speed_ft(state)
|
_speed_ft(state)
|
||||||
@@ -5867,7 +5900,8 @@ class CombatEngine:
|
|||||||
return destination == state.pos
|
return destination == state.pos
|
||||||
if not self._cells_free(self._footprint_at(destination, state), state):
|
if not self._cells_free(self._footprint_at(destination, state), state):
|
||||||
return False
|
return False
|
||||||
field = self._grid.reachable(state.pos, speed_cells, self._blocked(state))
|
blocked = self._blocked_hostile(state) if friendly_passable else self._blocked(state)
|
||||||
|
field = self._grid.reachable(state.pos, speed_cells, blocked)
|
||||||
return destination in field
|
return destination in field
|
||||||
|
|
||||||
def _walk_steps(
|
def _walk_steps(
|
||||||
@@ -5887,13 +5921,15 @@ class CombatEngine:
|
|||||||
break
|
break
|
||||||
return actual_path
|
return actual_path
|
||||||
|
|
||||||
def _move_path(self, state: CombatantState, target: CombatantState) -> list[Pos]:
|
def _move_path(
|
||||||
|
self, state: CombatantState, target: CombatantState, *, friendly_passable: bool = False
|
||||||
|
) -> list[Pos]:
|
||||||
"""Full movement path toward the target, up to the combatant's speed.
|
"""Full movement path toward the target, up to the combatant's speed.
|
||||||
|
|
||||||
Greedy walk of the anchor over the Dijkstra cost field from the target,
|
Greedy walk of the anchor over the Dijkstra cost field from the target,
|
||||||
requiring the whole footprint to fit at each step.
|
requiring the whole footprint to fit at each step.
|
||||||
"""
|
"""
|
||||||
return self._descend_path(state, target.pos)
|
return self._descend_path(state, target.pos, friendly_passable=friendly_passable)
|
||||||
|
|
||||||
def _descend_path(
|
def _descend_path(
|
||||||
self,
|
self,
|
||||||
@@ -5902,13 +5938,16 @@ class CombatEngine:
|
|||||||
*,
|
*,
|
||||||
speed_factor: int = 1,
|
speed_factor: int = 1,
|
||||||
avoid_difficult: bool = False,
|
avoid_difficult: bool = False,
|
||||||
|
friendly_passable: bool = False,
|
||||||
) -> list[Pos]:
|
) -> list[Pos]:
|
||||||
"""Greedy walk of the anchor over the cost field from ``anchor``.
|
"""Greedy walk of the anchor over the cost field from ``anchor``.
|
||||||
|
|
||||||
``speed_factor`` widens the movement budget (run: 4x speed, U5e).
|
``speed_factor`` widens the movement budget (run: 4x speed, U5e).
|
||||||
``avoid_difficult`` excludes difficult-terrain cells from the walk
|
``avoid_difficult`` excludes difficult-terrain cells from the walk
|
||||||
(PF1e: a run cannot cross difficult terrain), so the walk stops in
|
(PF1e: a run cannot cross difficult terrain), so the walk stops in
|
||||||
front of the obstacle.
|
front of the obstacle. ``friendly_passable`` lets the walk CROSS
|
||||||
|
same-side creatures' squares (move/run, PF1e SRD Movement) while the
|
||||||
|
tail is trimmed so the movement never ENDS on an occupied cell.
|
||||||
"""
|
"""
|
||||||
speed_mult = self._get_speed_mult(state)
|
speed_mult = self._get_speed_mult(state)
|
||||||
speed_cells = int(
|
speed_cells = int(
|
||||||
@@ -5919,7 +5958,7 @@ class CombatEngine:
|
|||||||
)
|
)
|
||||||
if speed_cells <= 0:
|
if speed_cells <= 0:
|
||||||
return []
|
return []
|
||||||
blocked = self._blocked(state)
|
blocked = self._blocked_hostile(state) if friendly_passable else self._blocked(state)
|
||||||
to_target = self._grid.reachable(anchor, None, blocked)
|
to_target = self._grid.reachable(anchor, None, blocked)
|
||||||
if self._footprint_cost(state.occupied_cells(), to_target) is None:
|
if self._footprint_cost(state.occupied_cells(), to_target) is None:
|
||||||
return []
|
return []
|
||||||
@@ -5928,7 +5967,8 @@ class CombatEngine:
|
|||||||
budget = speed_cells
|
budget = speed_cells
|
||||||
while budget > 0:
|
while budget > 0:
|
||||||
best = self._best_move_step(
|
best = self._best_move_step(
|
||||||
state, current, to_target, budget, avoid_difficult=avoid_difficult
|
state, current, to_target, budget,
|
||||||
|
avoid_difficult=avoid_difficult, friendly_passable=friendly_passable,
|
||||||
)
|
)
|
||||||
if best is None:
|
if best is None:
|
||||||
break
|
break
|
||||||
@@ -5936,6 +5976,9 @@ class CombatEngine:
|
|||||||
current = nxt
|
current = nxt
|
||||||
path.append(current)
|
path.append(current)
|
||||||
budget -= step_cost
|
budget -= step_cost
|
||||||
|
if friendly_passable:
|
||||||
|
while path and not self._cells_free(self._footprint_at(path[-1], state), state):
|
||||||
|
path.pop()
|
||||||
return path
|
return path
|
||||||
|
|
||||||
def _best_move_step(
|
def _best_move_step(
|
||||||
@@ -5946,6 +5989,7 @@ class CombatEngine:
|
|||||||
budget: int,
|
budget: int,
|
||||||
*,
|
*,
|
||||||
avoid_difficult: bool = False,
|
avoid_difficult: bool = False,
|
||||||
|
friendly_passable: bool = False,
|
||||||
) -> tuple[int, int, Pos] | None:
|
) -> tuple[int, int, Pos] | None:
|
||||||
"""Pick the adjacent anchor that best descends the cost field toward the target."""
|
"""Pick the adjacent anchor that best descends the cost field toward the target."""
|
||||||
current_cost = self._footprint_cost(self._footprint_at(current, state), to_target)
|
current_cost = self._footprint_cost(self._footprint_at(current, state), to_target)
|
||||||
@@ -5953,16 +5997,26 @@ class CombatEngine:
|
|||||||
return None
|
return None
|
||||||
best: tuple[int, int, Pos] | None = None
|
best: tuple[int, int, Pos] | None = None
|
||||||
row, col = current
|
row, col = current
|
||||||
|
fits = self._cells_walkable if friendly_passable else self._cells_free
|
||||||
for d_row, d_col in _STEP_DELTAS:
|
for d_row, d_col in _STEP_DELTAS:
|
||||||
nxt = (row + d_row, col + d_col)
|
nxt = (row + d_row, col + d_col)
|
||||||
footprint = self._footprint_at(nxt, state)
|
footprint = self._footprint_at(nxt, state)
|
||||||
if not self._cells_free(footprint, state):
|
if not fits(footprint, state):
|
||||||
continue
|
continue
|
||||||
if avoid_difficult and self._footprint_touches_difficult(footprint):
|
if avoid_difficult and self._footprint_touches_difficult(footprint):
|
||||||
continue
|
continue
|
||||||
nxt_cost = self._footprint_cost(footprint, to_target)
|
nxt_cost = self._footprint_cost(footprint, to_target)
|
||||||
if nxt_cost is None or nxt_cost >= current_cost:
|
if nxt_cost is None or nxt_cost >= current_cost:
|
||||||
continue
|
continue
|
||||||
|
# Friendly square: enter it only to cross it — a free cell must
|
||||||
|
# descend from it, so the walk never dead-ends on an ally
|
||||||
|
# (movement may not END on an occupied cell).
|
||||||
|
if (
|
||||||
|
friendly_passable
|
||||||
|
and not self._cells_free(footprint, state)
|
||||||
|
and not self._has_free_descent(state, nxt, to_target, nxt_cost)
|
||||||
|
):
|
||||||
|
continue
|
||||||
step = self._grid.step_cost(current, nxt, 0)
|
step = self._grid.step_cost(current, nxt, 0)
|
||||||
if step > budget:
|
if step > budget:
|
||||||
continue
|
continue
|
||||||
@@ -5971,6 +6025,24 @@ class CombatEngine:
|
|||||||
best = (score, step, nxt)
|
best = (score, step, nxt)
|
||||||
return best
|
return best
|
||||||
|
|
||||||
|
def _has_free_descent(
|
||||||
|
self,
|
||||||
|
state: CombatantState,
|
||||||
|
pos: Pos,
|
||||||
|
to_target: dict[Pos, int],
|
||||||
|
pos_cost: int,
|
||||||
|
) -> bool:
|
||||||
|
"""True if some adjacent anchor is fully free and descends the field."""
|
||||||
|
row, col = pos
|
||||||
|
for d_row, d_col in _STEP_DELTAS:
|
||||||
|
footprint = self._footprint_at((row + d_row, col + d_col), state)
|
||||||
|
if not self._cells_free(footprint, state):
|
||||||
|
continue
|
||||||
|
cost = self._footprint_cost(footprint, to_target)
|
||||||
|
if cost is not None and cost < pos_cost:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
def _footprint_touches_difficult(self, footprint: frozenset[Pos]) -> bool:
|
def _footprint_touches_difficult(self, footprint: frozenset[Pos]) -> bool:
|
||||||
"""True if any footprint cell is difficult terrain (move cost > 1)."""
|
"""True if any footprint cell is difficult terrain (move cost > 1)."""
|
||||||
return any(self._is_difficult_cell(cell) for cell in footprint)
|
return any(self._is_difficult_cell(cell) for cell in footprint)
|
||||||
|
|||||||
@@ -257,6 +257,100 @@ def _corner_grid() -> Grid:
|
|||||||
return Grid.from_spec(spec)
|
return Grid.from_spec(spec)
|
||||||
|
|
||||||
|
|
||||||
|
def _corridor_grid() -> Grid:
|
||||||
|
"""A one-cell corridor along row 0 (rest walls) — no way around an ally."""
|
||||||
|
rows = ["." * 8] + ["#" * 8] * 7
|
||||||
|
legend = {
|
||||||
|
".": TerrainType(type="floor", move_cost=1),
|
||||||
|
"#": TerrainType(type="wall", move_cost=None, blocks_los=True),
|
||||||
|
}
|
||||||
|
spec = MapSpec(name="corridor", terrain=tuple(rows), legend=legend)
|
||||||
|
return Grid.from_spec(spec)
|
||||||
|
|
||||||
|
|
||||||
|
def _hero_ally_foe(
|
||||||
|
policy: PolicyFn, rng: list[int], *, ally_pos: tuple[int, int], foe_pos: tuple[int, int],
|
||||||
|
) -> CombatEngine:
|
||||||
|
"""hero+ally (players) and foe (monsters) on the corridor; hero acts first."""
|
||||||
|
hero = make_state(_combatant("hero", initiative_mod=2), "players", (0, 0))
|
||||||
|
ally = make_state(_combatant("ally"), "players", ally_pos)
|
||||||
|
foe = make_state(_combatant("foe"), "monsters", foe_pos)
|
||||||
|
return CombatEngine(
|
||||||
|
ScriptedRng(rng), _corridor_grid(), [hero, ally, foe],
|
||||||
|
policy=policy, round_cap=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_move_passes_through_friendly_square() -> None:
|
||||||
|
# SRD Movement: "You can move through a square occupied by a friendly
|
||||||
|
# character, unless you are charging." Ally at (0, 1) is the only route
|
||||||
|
# to the foe at (0, 3) — the move crosses it and ends free at (0, 2).
|
||||||
|
def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
|
||||||
|
if state.combatant.id == "hero":
|
||||||
|
return (Action(kind="move", target_id="foe"),)
|
||||||
|
return _WAIT
|
||||||
|
|
||||||
|
eng = _hero_ally_foe(policy, [10, 5, 5], ally_pos=(0, 1), foe_pos=(0, 3))
|
||||||
|
t = eng.run().transcript
|
||||||
|
assert "round 1 hero: move (0,0)->(0,1)->(0,2)" in t
|
||||||
|
assert eng._states[0].pos == (0, 2)
|
||||||
|
|
||||||
|
|
||||||
|
def test_move_cannot_end_on_friendly_square() -> None:
|
||||||
|
# Ally at (0, 2) directly in front of the foe: the walk crosses it but
|
||||||
|
# must not END there — the path is trimmed to the last free cell (0, 1).
|
||||||
|
def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
|
||||||
|
if state.combatant.id == "hero":
|
||||||
|
return (Action(kind="move", target_id="foe"),)
|
||||||
|
return _WAIT
|
||||||
|
|
||||||
|
eng = _hero_ally_foe(policy, [10, 5, 5], ally_pos=(0, 2), foe_pos=(0, 3))
|
||||||
|
t = eng.run().transcript
|
||||||
|
assert "round 1 hero: move (0,0)->(0,1)" in t
|
||||||
|
assert eng._states[0].pos == (0, 1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_passes_through_friendly_square() -> None:
|
||||||
|
# Run reuses the move machinery, so allies are passable there too.
|
||||||
|
def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
|
||||||
|
if state.combatant.id == "hero":
|
||||||
|
return (Action(kind="run", target_id="foe"),)
|
||||||
|
return _WAIT
|
||||||
|
|
||||||
|
eng = _hero_ally_foe(policy, [10, 5, 5], ally_pos=(0, 1), foe_pos=(0, 4))
|
||||||
|
t = eng.run().transcript
|
||||||
|
assert eng._states[0].pos == (0, 3)
|
||||||
|
assert "round 1 hero: run" in t
|
||||||
|
|
||||||
|
|
||||||
|
def test_move_to_destination_behind_friendly_square() -> None:
|
||||||
|
# Explicit destination (0, 2) past the ally at (0, 1): reachable now that
|
||||||
|
# the cost field lets the path cross friendly squares.
|
||||||
|
def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
|
||||||
|
if state.combatant.id == "hero":
|
||||||
|
return (Action(kind="move", destination=(0, 2)),)
|
||||||
|
return _WAIT
|
||||||
|
|
||||||
|
eng = _hero_ally_foe(policy, [10, 5, 5], ally_pos=(0, 1), foe_pos=(0, 7))
|
||||||
|
t = eng.run().transcript
|
||||||
|
assert "round 1 hero: move -> (0,2)" in t
|
||||||
|
assert eng._states[0].pos == (0, 2)
|
||||||
|
|
||||||
|
|
||||||
|
def test_charge_still_blocked_by_friendly_square() -> None:
|
||||||
|
# The charge exception: "unless you are charging" — the ally at (0, 1)
|
||||||
|
# on the straight line keeps the charge impossible; the hero stays put.
|
||||||
|
def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
|
||||||
|
if state.combatant.id == "hero":
|
||||||
|
return (Action(kind="charge", target_id="foe"),)
|
||||||
|
return _WAIT
|
||||||
|
|
||||||
|
eng = _hero_ally_foe(policy, [10, 5, 5], ally_pos=(0, 1), foe_pos=(0, 3))
|
||||||
|
t = eng.run().transcript
|
||||||
|
assert eng._states[0].pos == (0, 0)
|
||||||
|
assert not any("hero: charge" in line for line in t)
|
||||||
|
|
||||||
|
|
||||||
def test_step5_diagonal_past_corner_is_rejected() -> None:
|
def test_step5_diagonal_past_corner_is_rejected() -> None:
|
||||||
# SRD Measuring Distance: "You can't move diagonally past a corner
|
# SRD Measuring Distance: "You can't move diagonally past a corner
|
||||||
# (even by taking a 5-foot step)." Wall at (1, 0) blocks the flank.
|
# (even by taking a 5-foot step)." Wall at (1, 0) blocks the flank.
|
||||||
|
|||||||
Reference in New Issue
Block a user