2f00d76ecc
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>
422 lines
16 KiB
Python
422 lines
16 KiB
Python
"""Explicit movement destinations (Package U5c).
|
|
|
|
``move`` and ``5foot_step`` accept ``Action.destination`` — a ``(row, col)``
|
|
cell. With a destination the combatant walks to THAT cell (validated against
|
|
the Dijkstra cost field under its speed budget, with the usual occupied-cell
|
|
blocking) instead of auto-pathing toward ``target_id``; the step-by-step walk
|
|
reuses the same AoO machinery as the auto-pathed move. A ``5foot_step``
|
|
destination must be exactly one cell away and never provokes. Rejections
|
|
(out of bounds, impassable, occupied, beyond speed, non-adjacent) report
|
|
``unreachable_destination``. Without a destination both kinds behave exactly
|
|
as before, and the U1 ``missing_target`` rule still applies when neither a
|
|
target nor a destination is given.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import Callable
|
|
|
|
from pf1e_simulator.combat import (
|
|
ACTION_RESULT_REASONS,
|
|
Action,
|
|
ActionResult,
|
|
CombatantState,
|
|
CombatEngine,
|
|
)
|
|
from pf1e_simulator.dice import parse_dice
|
|
from pf1e_simulator.grid import Grid
|
|
from pf1e_simulator.map import MapSpec, TerrainType
|
|
from pf1e_simulator.models import (
|
|
AbilityScores,
|
|
ACProfile,
|
|
AttackSpec,
|
|
Combatant,
|
|
DamageComponent,
|
|
Saves,
|
|
)
|
|
from pf1e_simulator.rng import ScriptedRng
|
|
from tests.test_observation_enriched import make_grid, make_state
|
|
|
|
PolicyFn = Callable[[CombatEngine, CombatantState], tuple[Action, ...]]
|
|
|
|
_WAIT: tuple[Action, ...] = (Action(kind="wait"),)
|
|
|
|
|
|
def _combatant(id_: str, *, initiative_mod: int = 0) -> Combatant:
|
|
return Combatant(
|
|
id=id_,
|
|
name=id_,
|
|
level=1,
|
|
abilities=AbilityScores(
|
|
str_score=10, dex_score=10, con_score=10,
|
|
int_score=10, wis_score=10, cha_score=10,
|
|
),
|
|
ac=ACProfile(total=10, touch=10, flat_footed=10),
|
|
bab=1,
|
|
initiative_mod=initiative_mod,
|
|
speed_land_ft=30,
|
|
hp_max=100,
|
|
saves=Saves(fort=0, ref=0, will=0),
|
|
attacks=[
|
|
AttackSpec(
|
|
id=f"{id_}-sword",
|
|
name="short sword",
|
|
kind="melee",
|
|
attack_bonus=5,
|
|
damage=[
|
|
DamageComponent(formula=parse_dice("1d6"), types=["slashing"])
|
|
],
|
|
)
|
|
],
|
|
)
|
|
|
|
|
|
def _wall_grid() -> Grid:
|
|
"""Open 8x8 floor with a wall column segment at (0, 2)."""
|
|
rows = [".." + "#" + "." * 5] + ["." * 8] * 7
|
|
legend = {
|
|
".": TerrainType(type="floor", move_cost=1),
|
|
"#": TerrainType(type="wall", move_cost=None, blocks_los=True),
|
|
}
|
|
spec = MapSpec(name="walls", terrain=tuple(rows), legend=legend)
|
|
return Grid.from_spec(spec)
|
|
|
|
|
|
def _hero_vs_foe(
|
|
policy: PolicyFn, rng: list[int], round_cap: int, *, foe_pos: tuple[int, int],
|
|
grid: Grid | None = None,
|
|
) -> CombatEngine:
|
|
"""hero (acts first) at (0, 0); foe at ``foe_pos``; scripted rolls."""
|
|
hero = make_state(_combatant("hero", initiative_mod=2), "players", (0, 0))
|
|
foe = make_state(_combatant("foe"), "monsters", foe_pos)
|
|
return CombatEngine(
|
|
ScriptedRng(rng), grid or make_grid(), [hero, foe],
|
|
policy=policy, round_cap=round_cap,
|
|
)
|
|
|
|
|
|
# ── (a) move to a specific free cell off the auto-path ────────────────────────
|
|
|
|
|
|
def test_move_to_destination_off_the_enemy_path() -> None:
|
|
# Foe at (0, 4): the auto-path would walk hero along row 0 toward (0, 3).
|
|
# The destination (3, 0) is NOT on that path — hero must go there instead.
|
|
def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
|
|
if state.combatant.id == "hero":
|
|
return (Action(kind="move", destination=(3, 0)),)
|
|
return _WAIT
|
|
|
|
eng = _hero_vs_foe(policy, [10, 5], round_cap=1, foe_pos=(0, 4))
|
|
t = eng.run().transcript
|
|
hero = eng._states[0]
|
|
assert hero.pos == (3, 0)
|
|
assert "round 1 hero: move -> (3,0)" in t
|
|
assert not any("move (0,0)->" in line for line in t)
|
|
assert hero.last_action_results == [
|
|
ActionResult(kind="move", ok=True, reason="ok")
|
|
]
|
|
|
|
|
|
def test_move_destination_walk_uses_aoo_machinery() -> None:
|
|
# Foe at (1, 1) threatens (0, 0): the first step away provokes an AoO
|
|
# (d20 1 + 5 = 6 vs AC 10 -> MISS) resolved before leaving the square.
|
|
def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
|
|
if state.combatant.id == "hero":
|
|
return (Action(kind="move", destination=(3, 0)),)
|
|
return _WAIT
|
|
|
|
eng = _hero_vs_foe(policy, [10, 5, 1], round_cap=1, foe_pos=(1, 1))
|
|
t = eng.run().transcript
|
|
assert eng._states[0].pos == (3, 0)
|
|
assert "round 1 foe: AoO vs hero d20=1+5=6 AC 10 -> MISS" in t
|
|
lines = "\n".join(t)
|
|
assert lines.index("AoO vs hero") < lines.index("round 1 hero: move -> (3,0)")
|
|
assert eng._aoo_used == {"foe": 1}
|
|
|
|
|
|
# ── (b) out-of-bounds / impassable / occupied destinations ────────────────────
|
|
|
|
|
|
def test_out_of_bounds_destination_is_unreachable() -> None:
|
|
def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
|
|
if state.combatant.id == "hero":
|
|
return (Action(kind="move", destination=(99, 99)),)
|
|
return _WAIT
|
|
|
|
eng = _hero_vs_foe(policy, [10, 5], round_cap=1, foe_pos=(0, 4))
|
|
t = eng.run().transcript
|
|
assert "round 1 hero: move rejected (unreachable_destination)" in t
|
|
assert eng._states[0].pos == (0, 0)
|
|
assert eng._states[0].last_action_results == [
|
|
ActionResult(kind="move", ok=False, reason="unreachable_destination")
|
|
]
|
|
assert "unreachable_destination" in ACTION_RESULT_REASONS
|
|
|
|
|
|
def test_impassable_destination_is_unreachable() -> None:
|
|
def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
|
|
if state.combatant.id == "hero":
|
|
return (Action(kind="move", destination=(0, 2)),)
|
|
return _WAIT
|
|
|
|
eng = _hero_vs_foe(
|
|
policy, [10, 5], round_cap=1, foe_pos=(0, 4), grid=_wall_grid()
|
|
)
|
|
t = eng.run().transcript
|
|
assert "round 1 hero: move rejected (unreachable_destination)" in t
|
|
assert eng._states[0].pos == (0, 0)
|
|
|
|
|
|
def test_occupied_destination_is_unreachable() -> None:
|
|
def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
|
|
if state.combatant.id == "hero":
|
|
return (Action(kind="move", destination=(0, 4)),)
|
|
return _WAIT
|
|
|
|
eng = _hero_vs_foe(policy, [10, 5], round_cap=1, foe_pos=(0, 4))
|
|
t = eng.run().transcript
|
|
assert "round 1 hero: move rejected (unreachable_destination)" in t
|
|
assert eng._states[0].pos == (0, 0)
|
|
|
|
|
|
# ── (c) destination beyond the speed budget ───────────────────────────────────
|
|
|
|
|
|
def test_destination_beyond_speed_budget_is_unreachable() -> None:
|
|
# speed 30 ft = 6 cells; (0, 7) is 7 cells away — out of reach this turn.
|
|
def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
|
|
if state.combatant.id == "hero":
|
|
return (Action(kind="move", destination=(0, 7)),)
|
|
return _WAIT
|
|
|
|
eng = _hero_vs_foe(policy, [10, 5], round_cap=1, foe_pos=(7, 7))
|
|
t = eng.run().transcript
|
|
assert "round 1 hero: move rejected (unreachable_destination)" in t
|
|
assert eng._states[0].pos == (0, 0)
|
|
assert eng._states[0].last_action_results == [
|
|
ActionResult(kind="move", ok=False, reason="unreachable_destination")
|
|
]
|
|
|
|
|
|
# ── (d) 5-foot step to an adjacent cell; non-adjacent rejected ────────────────
|
|
|
|
|
|
def test_step5_to_adjacent_destination_does_not_provoke() -> None:
|
|
# Foe at (0, 1) threatens the starting square: the 5-ft step must NOT
|
|
# provoke (no extra scripted rolls beyond initiative).
|
|
def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
|
|
if state.combatant.id == "hero":
|
|
return (Action(kind="5foot_step", destination=(1, 0)),)
|
|
return _WAIT
|
|
|
|
eng = _hero_vs_foe(policy, [10, 5], round_cap=1, foe_pos=(0, 1))
|
|
t = eng.run().transcript
|
|
hero = eng._states[0]
|
|
assert hero.pos == (1, 0)
|
|
assert "round 1 hero: 5ft step (0,0)->(1,0)" in t
|
|
assert not any("AoO" in line for line in t)
|
|
assert hero.last_action_results == [
|
|
ActionResult(kind="5foot_step", ok=True, reason="ok")
|
|
]
|
|
|
|
|
|
def test_step5_to_non_adjacent_destination_is_rejected() -> None:
|
|
def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
|
|
if state.combatant.id == "hero":
|
|
return (Action(kind="5foot_step", destination=(0, 2)),)
|
|
return _WAIT
|
|
|
|
eng = _hero_vs_foe(policy, [10, 5], round_cap=1, foe_pos=(0, 4))
|
|
t = eng.run().transcript
|
|
assert "round 1 hero: 5foot_step rejected (unreachable_destination)" in t
|
|
assert eng._states[0].pos == (0, 0)
|
|
assert eng._states[0].last_action_results == [
|
|
ActionResult(kind="5foot_step", ok=False, reason="unreachable_destination")
|
|
]
|
|
|
|
|
|
def test_step5_to_occupied_destination_is_rejected() -> None:
|
|
def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
|
|
if state.combatant.id == "hero":
|
|
return (Action(kind="5foot_step", destination=(0, 1)),)
|
|
return _WAIT
|
|
|
|
eng = _hero_vs_foe(policy, [10, 5], round_cap=1, foe_pos=(0, 1))
|
|
t = eng.run().transcript
|
|
assert "round 1 hero: 5foot_step rejected (unreachable_destination)" in t
|
|
assert eng._states[0].pos == (0, 0)
|
|
|
|
|
|
def _corner_grid() -> Grid:
|
|
"""Open 8x8 floor with a wall at (1, 0) — a corner for the (0,0)->(1,1) step."""
|
|
rows = ["." * 8, "#" + "." * 7] + ["." * 8] * 6
|
|
legend = {
|
|
".": TerrainType(type="floor", move_cost=1),
|
|
"#": TerrainType(type="wall", move_cost=None, blocks_los=True),
|
|
}
|
|
spec = MapSpec(name="corner", terrain=tuple(rows), legend=legend)
|
|
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:
|
|
# 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.
|
|
def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
|
|
if state.combatant.id == "hero":
|
|
return (Action(kind="5foot_step", destination=(1, 1)),)
|
|
return _WAIT
|
|
|
|
eng = _hero_vs_foe(policy, [10, 5], round_cap=1, foe_pos=(7, 7), grid=_corner_grid())
|
|
t = eng.run().transcript
|
|
assert "round 1 hero: 5foot_step rejected (unreachable_destination)" in t
|
|
assert eng._states[0].pos == (0, 0)
|
|
|
|
|
|
def test_step5_diagonal_with_clear_flanks_is_allowed() -> None:
|
|
# The same diagonal step on an open grid (both flanks passable) is legal.
|
|
def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
|
|
if state.combatant.id == "hero":
|
|
return (Action(kind="5foot_step", destination=(1, 1)),)
|
|
return _WAIT
|
|
|
|
eng = _hero_vs_foe(policy, [10, 5], round_cap=1, foe_pos=(7, 7))
|
|
t = eng.run().transcript
|
|
assert "round 1 hero: 5ft step (0,0)->(1,1)" in t
|
|
assert eng._states[0].pos == (1, 1)
|
|
|
|
|
|
# ── (e) U1 preserved: neither target nor destination → missing_target ─────────
|
|
|
|
|
|
def test_move_without_target_or_destination_is_missing_target() -> None:
|
|
def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
|
|
if state.combatant.id == "hero":
|
|
return (Action(kind="move"), Action(kind="5foot_step"))
|
|
return _WAIT
|
|
|
|
eng = _hero_vs_foe(policy, [10, 5], round_cap=1, foe_pos=(0, 4))
|
|
t = eng.run().transcript
|
|
assert "round 1 hero: move rejected (missing_target)" in t
|
|
assert "round 1 hero: 5foot_step rejected (missing_target)" in t
|
|
assert eng._states[0].last_action_results == [
|
|
ActionResult(kind="move", ok=False, reason="missing_target"),
|
|
ActionResult(kind="5foot_step", ok=False, reason="missing_target"),
|
|
]
|
|
|
|
|
|
# ── (f) target-only move still auto-paths byte-identically ────────────────────
|
|
|
|
|
|
def test_move_with_target_auto_paths_as_before() -> None:
|
|
def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
|
|
if state.combatant.id == "hero":
|
|
return (Action(kind="move", target_id="foe"),)
|
|
return _WAIT
|
|
|
|
eng = _hero_vs_foe(policy, [10, 5], round_cap=1, foe_pos=(0, 5))
|
|
result = eng.run()
|
|
assert result.transcript == (
|
|
"initiative: hero d20=10+2=12",
|
|
"initiative: foe d20=5+0=5",
|
|
"round 1 hero: move (0,0)->(0,1)->(0,2)->(0,3)->(0,4)",
|
|
"round 1 foe: wait",
|
|
"battle over: draw after 1 rounds",
|
|
)
|
|
assert eng._states[0].pos == (0, 4)
|
|
assert eng._states[0].last_action_results == [
|
|
ActionResult(kind="move", ok=True, reason="ok")
|
|
]
|