"""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) def test_auto_move_does_not_cut_wall_corner() -> None: # SRD Measuring Distance: "You can't move diagonally past a corner." # Wall at (1, 0) flanks the (0,0)->(1,1) diagonal: the auto-directed # move must walk around it (via (0, 1)), never through the corner. 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=(2, 2), grid=_corner_grid()) t = eng.run().transcript move_line = next(ln for ln in t if "hero: move" in ln) assert "(0,0)->(1,1)" not in move_line # no corner cut assert "(0,0)->(0,1)" in move_line # went around the wall def test_auto_step5_does_not_cut_wall_corner() -> None: # The auto 5-foot step takes its single step from the same greedy walk, # so it too must refuse the corner-cutting diagonal toward the foe. def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]: if state.combatant.id == "hero": return (Action(kind="5foot_step", target_id="foe"),) return _WAIT eng = _hero_vs_foe(policy, [10, 5], round_cap=1, foe_pos=(2, 2), grid=_corner_grid()) t = eng.run().transcript assert "round 1 hero: 5ft step (0,0)->(1,1)" not in t assert "round 1 hero: 5ft step (0,0)->(0,1)" in t assert eng._states[0].pos == (0, 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") ]