feat(combat): allow explicit movement destinations

This commit is contained in:
2026-09-10 13:21:54 +02:00
parent 468c22ff57
commit b91e1ac215
3 changed files with 446 additions and 24 deletions
+12
View File
@@ -385,6 +385,18 @@ Règles modélisées :
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.
Destination explicite : `Action(kind="move", destination=(r, c))` et
`Action(kind="5foot_step", destination=(r, c))` déplacent le combattant vers
la case `(r, c)` désignée (monter au couvert, quitter un défilé, se
désengager) au lieu de auto-diriger vers `target_id`. Pour `move`, la case
doit être praticable, libre et atteignable dans le budget de vitesse (sinon
rejet `unreachable_destination`) ; le trajet réutilise la mécanique pas à pas
avec provocation d'AoO. Pour `5foot_step`, la case doit être exactement
adjacente (sinon `unreachable_destination`) et le pas ne provoque jamais
d'AoO. Sans `destination`, le comportement auto-dirigé est inchangé ; avec
`target_id` et `destination` absents, rejet `missing_target` (U1).
`default_policy` ne choisit pas de destination (disponible via une politique
personnalisée).
- Tailles et occupation multi-cases : une créature occupe un footprint carré à - Tailles et occupation multi-cases : une créature occupe un footprint carré à
son ancre (`pos`) selon sa taille — Small/Medium 1×1, Large 2×2, Huge 3×3, son ancre (`pos`) selon sa taille — Small/Medium 1×1, Large 2×2, Huge 3×3,
Gargantuan 4×4, Colossal 6×6 (taille inconnue → 1×1). Distance, ligne Gargantuan 4×4, Colossal 6×6 (taille inconnue → 1×1). Distance, ligne
+145 -24
View File
@@ -540,7 +540,10 @@ class Action:
``fight_defensively`` also takes -4 on its attacks. ``stand_up`` is a move ``fight_defensively`` also takes -4 on its attacks. ``stand_up`` is a move
action ending the prone condition (it provokes an AoO from each enemy action ending the prone condition (it provokes an AoO from each enemy
threatening the combatant); ``drop_prone`` is a free action going prone threatening the combatant); ``drop_prone`` is a free action going prone
until the combatant stands up. until the combatant stands up. ``move`` and ``5foot_step`` accept an
explicit ``destination`` cell ``(row, col)``: with one they walk to that
cell instead of auto-pathing toward ``target_id`` (the 5-foot step's
destination must be exactly one cell away).
""" """
kind: Literal[ kind: Literal[
@@ -563,6 +566,7 @@ class Action:
trick_condition: TrickCondition | None = None # dirty trick condition choice trick_condition: TrickCondition | None = None # dirty trick condition choice
weapon_id: str | None = None # weapon id for retrieve_weapon / repair_weapon weapon_id: str | None = None # weapon id for retrieve_weapon / repair_weapon
ready_trigger: ReadyTrigger | None = None # ready action trigger event ready_trigger: ReadyTrigger | None = None # ready action trigger event
destination: tuple[int, int] | None = None # explicit (row, col) cell for move / 5foot_step
# Normalized action-result reason vocabulary. U1 emits ok, unsupported_kind, # Normalized action-result reason vocabulary. U1 emits ok, unsupported_kind,
@@ -570,13 +574,14 @@ class Action:
# no_line_of_effect, no_slot and insufficient_resource are declared here for # no_line_of_effect, no_slot and insufficient_resource are declared here for
# the deeper execution-feedback package (U6b). U3 adds delayed / # the deeper execution-feedback package (U6b). U3 adds delayed /
# already_delayed for the delay action. U4 adds ready for the ready action. # already_delayed for the delay action. U4 adds ready for the ready action.
# U5b adds not_prone / already_prone for the prone actions. # U5b adds not_prone / already_prone for the prone actions. U5c adds
# unreachable_destination for move / 5foot_step with an explicit destination.
ACTION_RESULT_REASONS: frozenset[str] = frozenset({ ACTION_RESULT_REASONS: frozenset[str] = frozenset({
"ok", "unsupported_kind", "missing_target", "no_weapon", "out_of_range", "ok", "unsupported_kind", "missing_target", "no_weapon", "out_of_range",
"no_line_of_effect", "no_slot", "insufficient_resource", "no_line_of_effect", "no_slot", "insufficient_resource",
"downgraded:staggered", "downgraded:nauseated", "downgraded:staggered", "downgraded:nauseated",
"delayed", "already_delayed", "ready", "delayed", "already_delayed", "ready",
"not_prone", "already_prone", "not_prone", "already_prone", "unreachable_destination",
}) })
_REASON_OK = "ok" _REASON_OK = "ok"
_REASON_UNSUPPORTED_KIND = "unsupported_kind" _REASON_UNSUPPORTED_KIND = "unsupported_kind"
@@ -589,12 +594,14 @@ _REASON_ALREADY_DELAYED = "already_delayed"
_REASON_READY = "ready" _REASON_READY = "ready"
_REASON_NOT_PRONE = "not_prone" _REASON_NOT_PRONE = "not_prone"
_REASON_ALREADY_PRONE = "already_prone" _REASON_ALREADY_PRONE = "already_prone"
_REASON_UNREACHABLE_DESTINATION = "unreachable_destination"
# Action kinds that are declared but have no engine dispatch (U1 surfaces # Action kinds that are declared but have no engine dispatch (U1 surfaces
# them as explicit rejections instead of silent no-ops). # them as explicit rejections instead of silent no-ops).
_UNSUPPORTED_ACTION_KINDS: frozenset[str] = frozenset({"swift", "free", "full_round"}) _UNSUPPORTED_ACTION_KINDS: frozenset[str] = frozenset({"swift", "free", "full_round"})
# Action kinds whose helper is a silent no-op (or worse) without a resolved # Action kinds whose helper is a silent no-op (or worse) without a resolved
# target; _execute rejects them up front with missing_target instead. # target; _execute rejects them up front with missing_target instead.
# move / 5foot_step are exempt when an explicit destination is provided (U5c).
_TARGET_REQUIRED_ACTION_KINDS: frozenset[str] = frozenset({ _TARGET_REQUIRED_ACTION_KINDS: frozenset[str] = frozenset({
"attack", "full_attack", "charge", "withdraw", "move", "5foot_step", "attack", "full_attack", "charge", "withdraw", "move", "5foot_step",
"cast_spell", "maneuver", "cast_spell", "maneuver",
@@ -2430,20 +2437,37 @@ class CombatEngine:
kind=kind, ok=False, reason=_REASON_UNSUPPORTED_KIND kind=kind, ok=False, reason=_REASON_UNSUPPORTED_KIND
) )
target = next((s for s in self._states if s.combatant.id == action.target_id), None) target = next((s for s in self._states if s.combatant.id == action.target_id), None)
if target is None and kind in _TARGET_REQUIRED_ACTION_KINDS: rejection = self._target_rejection(state, kind, target, action)
self._log( if rejection is not None:
f"round {self._current_round} {state.combatant.id}: " return rejection
f"{kind} rejected ({_REASON_MISSING_TARGET})"
)
return ActionResult(kind=kind, ok=False, reason=_REASON_MISSING_TARGET)
if kind == "delay": if kind == "delay":
return self._execute_delay(state, target, action) return self._execute_delay(state, target, action)
if kind == "ready": if kind == "ready":
return self._execute_ready(state, action) return self._execute_ready(state, action)
if kind in ("total_defense", "fight_defensively", "stand_up", "drop_prone"): if kind in ("total_defense", "fight_defensively", "stand_up", "drop_prone"):
return self._execute_self_action(state, kind) return self._execute_self_action(state, kind)
self._dispatch_action(state, target, action) override = self._dispatch_action(state, target, action)
return ActionResult(kind=kind, ok=True, reason=_REASON_OK) return override if override is not None else ActionResult(
kind=kind, ok=True, reason=_REASON_OK
)
def _target_rejection(
self,
state: CombatantState,
kind: str,
target: CombatantState | None,
action: Action,
) -> ActionResult | None:
"""U1 missing-target gate; move/5foot_step accept a destination instead (U5c)."""
if target is not None or kind not in _TARGET_REQUIRED_ACTION_KINDS:
return None
if kind in ("move", "5foot_step") and action.destination is not None:
return None
self._log(
f"round {self._current_round} {state.combatant.id}: "
f"{kind} rejected ({_REASON_MISSING_TARGET})"
)
return ActionResult(kind=kind, ok=False, reason=_REASON_MISSING_TARGET)
def _execute_delay( def _execute_delay(
self, state: CombatantState, target: CombatantState | None, action: Action self, state: CombatantState, target: CombatantState | None, action: Action
@@ -2597,8 +2621,13 @@ class CombatEngine:
def _dispatch_action( def _dispatch_action(
self, state: CombatantState, target: CombatantState | None, action: Action self, state: CombatantState, target: CombatantState | None, action: Action
) -> None: ) -> ActionResult | None:
"""Route a validated action to its execution helper.""" """Route a validated action to its execution helper.
Returns an explicit rejection when a destination-bearing movement
cannot be honoured (U5c); None means the helper ran and the caller
reports ok.
"""
kind = action.kind kind = action.kind
if kind == "cast_spell": if kind == "cast_spell":
self._execute_cast_spell(state, target, action.spell_name) self._execute_cast_spell(state, target, action.spell_name)
@@ -2622,7 +2651,8 @@ class CombatEngine:
elif kind == "immediate": elif kind == "immediate":
self._execute_immediate(state, target, action) self._execute_immediate(state, target, action)
else: else:
self._execute_movement(state, target, kind) return self._execute_movement(state, target, action)
return None
def _execute_class_ability( def _execute_class_ability(
self, state: CombatantState, target: CombatantState | None, action: Action self, state: CombatantState, target: CombatantState | None, action: Action
@@ -2643,21 +2673,32 @@ class CombatEngine:
self._execute_inspire_courage(state, target) self._execute_inspire_courage(state, target)
def _execute_movement( def _execute_movement(
self, state: CombatantState, target: CombatantState | None, kind: str self, state: CombatantState, target: CombatantState | None, action: Action
) -> None: ) -> ActionResult | None:
"""Execute a movement-style action (5foot_step, move, wait).""" """Execute a movement-style action (5foot_step, move, wait).
With an explicit destination, move/5foot_step walk to that cell and
report unreachable_destination when it cannot be honoured (U5c);
without one they auto-path toward the target as before.
"""
kind = action.kind
if kind == "5foot_step": if kind == "5foot_step":
if action.destination is not None:
return self._step5_to(state, action.destination)
if target is None: if target is None:
return return None
self._step5(state, target) self._step5(state, target)
elif kind == "move": elif kind == "move":
if action.destination is not None:
return self._move_to(state, action.destination)
if target is None: if target is None:
return return None
self._move(state, target) self._move(state, target)
elif kind == "wait": elif kind == "wait":
self._log(f"round {self._current_round} {state.combatant.id}: wait") self._log(f"round {self._current_round} {state.combatant.id}: wait")
elif kind == "activate_power_attack": elif kind == "activate_power_attack":
self._execute_activate_power_attack(state) self._execute_activate_power_attack(state)
return None
def _execute_immediate( def _execute_immediate(
self, state: CombatantState, target: CombatantState | None, action: Action self, state: CombatantState, target: CombatantState | None, action: Action
@@ -4145,6 +4186,33 @@ class CombatEngine:
f"5ft step ({start[0]},{start[1]})->({path[0][0]},{path[0][1]})" f"5ft step ({start[0]},{start[1]})->({path[0][0]},{path[0][1]})"
) )
def _step5_to(self, state: CombatantState, destination: Pos) -> ActionResult:
"""5-foot step to an explicit adjacent cell — never provokes (U5c)."""
kind = "5foot_step"
if state.moved_this_turn:
return ActionResult(kind=kind, ok=True, reason=_REASON_OK)
if self.has_condition_flag(state, "cant_move"):
self._log(f"round {self._current_round} {state.combatant.id}: wait (cannot move)")
return ActionResult(kind=kind, ok=True, reason=_REASON_OK)
adjacent = self._grid.distance(state.pos, destination) == 1
free = self._cells_free(self._footprint_at(destination, state), state)
if not adjacent or not free:
self._log(
f"round {self._current_round} {state.combatant.id}: "
f"{kind} rejected ({_REASON_UNREACHABLE_DESTINATION})"
)
return ActionResult(
kind=kind, ok=False, reason=_REASON_UNREACHABLE_DESTINATION
)
start = state.pos
state.pos = destination
state.moved_this_turn = True
self._log(
f"round {self._current_round} {state.combatant.id}: "
f"5ft step ({start[0]},{start[1]})->({destination[0]},{destination[1]})"
)
return ActionResult(kind=kind, ok=True, reason=_REASON_OK)
def _move(self, state: CombatantState, target: CombatantState) -> None: def _move(self, state: CombatantState, target: CombatantState) -> None:
if state.moved_this_turn: if state.moved_this_turn:
return return
@@ -4153,6 +4221,58 @@ class CombatEngine:
return return
path = self._move_path(state, target) path = self._move_path(state, target)
start = state.pos start = state.pos
actual_path = self._walk_steps(state, path)
if actual_path:
state.moved_this_turn = True
coords = "->".join(f"({p[0]},{p[1]})" for p in (start, *actual_path))
self._log(f"round {self._current_round} {state.combatant.id}: move {coords}")
def _move_to(self, state: CombatantState, destination: Pos) -> ActionResult:
"""Move action to an explicit cell within speed (U5c).
Validated against the Dijkstra cost field from the combatant's position
under its speed budget; walked step-by-step with the same AoO
machinery as the auto-pathed move.
"""
kind = "move"
if state.moved_this_turn:
return ActionResult(kind=kind, ok=True, reason=_REASON_OK)
if self.has_condition_flag(state, "cant_move"):
self._log(f"round {self._current_round} {state.combatant.id}: wait (cannot move)")
return ActionResult(kind=kind, ok=True, reason=_REASON_OK)
if not self._destination_reachable(state, destination):
self._log(
f"round {self._current_round} {state.combatant.id}: "
f"{kind} rejected ({_REASON_UNREACHABLE_DESTINATION})"
)
return ActionResult(
kind=kind, ok=False, reason=_REASON_UNREACHABLE_DESTINATION
)
path = self._descend_path(state, destination)
self._walk_steps(state, path)
state.moved_this_turn = True
self._log(
f"round {self._current_round} {state.combatant.id}: "
f"move -> ({destination[0]},{destination[1]})"
)
return ActionResult(kind=kind, ok=True, reason=_REASON_OK)
def _destination_reachable(self, state: CombatantState, destination: Pos) -> bool:
"""True when destination is passable, unoccupied and within this speed."""
speed_cells = int(
state.combatant.speed_land_ft // _SQUARE_FT * self._get_speed_mult(state)
)
if speed_cells <= 0:
return destination == state.pos
if not self._cells_free(self._footprint_at(destination, state), state):
return False
field = self._grid.reachable(state.pos, speed_cells, self._blocked(state))
return destination in field
def _walk_steps(
self, state: CombatantState, path: list[Pos]
) -> list[Pos]:
"""Walk a precomputed path cell by cell, resolving AoOs and reactions."""
actual_path: list[Pos] = [] actual_path: list[Pos] = []
for step in path: for step in path:
if self._check_move_aoo(state): if self._check_move_aoo(state):
@@ -4164,10 +4284,7 @@ class CombatEngine:
self._check_reactions(ReactionEvent(kind="movement_step", actor=state)) self._check_reactions(ReactionEvent(kind="movement_step", actor=state))
if not state.active: if not state.active:
break break
if actual_path: return actual_path
state.moved_this_turn = True
coords = "->".join(f"({p[0]},{p[1]})" for p in (start, *actual_path))
self._log(f"round {self._current_round} {state.combatant.id}: move {coords}")
def _move_path(self, state: CombatantState, target: CombatantState) -> list[Pos]: def _move_path(self, state: CombatantState, target: CombatantState) -> 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.
@@ -4175,12 +4292,16 @@ class CombatEngine:
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)
def _descend_path(self, state: CombatantState, anchor: Pos) -> list[Pos]:
"""Greedy walk of the anchor over the cost field from ``anchor``."""
speed_mult = self._get_speed_mult(state) speed_mult = self._get_speed_mult(state)
speed_cells = int(state.combatant.speed_land_ft // _SQUARE_FT * speed_mult) speed_cells = int(state.combatant.speed_land_ft // _SQUARE_FT * speed_mult)
if speed_cells <= 0: if speed_cells <= 0:
return [] return []
blocked = self._blocked(state) blocked = self._blocked(state)
to_target = self._grid.reachable(target.pos, 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 []
path: list[Pos] = [] path: list[Pos] = []
+289
View File
@@ -0,0 +1,289 @@
"""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)
# ── (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")
]