diff --git a/README.md b/README.md index a065e76..9921b10 100644 --- a/README.md +++ b/README.md @@ -385,6 +385,18 @@ Règles modélisées : 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 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é à 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 diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 6fdf029..fa3098a 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -540,7 +540,10 @@ class Action: ``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 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[ @@ -563,6 +566,7 @@ class Action: trick_condition: TrickCondition | None = None # dirty trick condition choice weapon_id: str | None = None # weapon id for retrieve_weapon / repair_weapon 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, @@ -570,13 +574,14 @@ class Action: # no_line_of_effect, no_slot and insufficient_resource are declared here for # the deeper execution-feedback package (U6b). U3 adds delayed / # 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({ "ok", "unsupported_kind", "missing_target", "no_weapon", "out_of_range", "no_line_of_effect", "no_slot", "insufficient_resource", "downgraded:staggered", "downgraded:nauseated", "delayed", "already_delayed", "ready", - "not_prone", "already_prone", + "not_prone", "already_prone", "unreachable_destination", }) _REASON_OK = "ok" _REASON_UNSUPPORTED_KIND = "unsupported_kind" @@ -589,12 +594,14 @@ _REASON_ALREADY_DELAYED = "already_delayed" _REASON_READY = "ready" _REASON_NOT_PRONE = "not_prone" _REASON_ALREADY_PRONE = "already_prone" +_REASON_UNREACHABLE_DESTINATION = "unreachable_destination" # Action kinds that are declared but have no engine dispatch (U1 surfaces # them as explicit rejections instead of silent no-ops). _UNSUPPORTED_ACTION_KINDS: frozenset[str] = frozenset({"swift", "free", "full_round"}) # Action kinds whose helper is a silent no-op (or worse) without a resolved # 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({ "attack", "full_attack", "charge", "withdraw", "move", "5foot_step", "cast_spell", "maneuver", @@ -2430,20 +2437,37 @@ class CombatEngine: kind=kind, ok=False, reason=_REASON_UNSUPPORTED_KIND ) 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: - 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) + rejection = self._target_rejection(state, kind, target, action) + if rejection is not None: + return rejection if kind == "delay": return self._execute_delay(state, target, action) if kind == "ready": return self._execute_ready(state, action) if kind in ("total_defense", "fight_defensively", "stand_up", "drop_prone"): return self._execute_self_action(state, kind) - self._dispatch_action(state, target, action) - return ActionResult(kind=kind, ok=True, reason=_REASON_OK) + override = self._dispatch_action(state, target, action) + 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( self, state: CombatantState, target: CombatantState | None, action: Action @@ -2597,8 +2621,13 @@ class CombatEngine: def _dispatch_action( self, state: CombatantState, target: CombatantState | None, action: Action - ) -> None: - """Route a validated action to its execution helper.""" + ) -> ActionResult | None: + """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 if kind == "cast_spell": self._execute_cast_spell(state, target, action.spell_name) @@ -2622,7 +2651,8 @@ class CombatEngine: elif kind == "immediate": self._execute_immediate(state, target, action) else: - self._execute_movement(state, target, kind) + return self._execute_movement(state, target, action) + return None def _execute_class_ability( self, state: CombatantState, target: CombatantState | None, action: Action @@ -2643,21 +2673,32 @@ class CombatEngine: self._execute_inspire_courage(state, target) def _execute_movement( - self, state: CombatantState, target: CombatantState | None, kind: str - ) -> None: - """Execute a movement-style action (5foot_step, move, wait).""" + self, state: CombatantState, target: CombatantState | None, action: Action + ) -> ActionResult | None: + """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 action.destination is not None: + return self._step5_to(state, action.destination) if target is None: - return + return None self._step5(state, target) elif kind == "move": + if action.destination is not None: + return self._move_to(state, action.destination) if target is None: - return + return None self._move(state, target) elif kind == "wait": self._log(f"round {self._current_round} {state.combatant.id}: wait") elif kind == "activate_power_attack": self._execute_activate_power_attack(state) + return None def _execute_immediate( 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]})" ) + 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: if state.moved_this_turn: return @@ -4153,6 +4221,58 @@ class CombatEngine: return path = self._move_path(state, target) 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] = [] for step in path: if self._check_move_aoo(state): @@ -4164,10 +4284,7 @@ class CombatEngine: self._check_reactions(ReactionEvent(kind="movement_step", actor=state)) if not state.active: break - 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}") + return actual_path def _move_path(self, state: CombatantState, target: CombatantState) -> list[Pos]: """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, 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_cells = int(state.combatant.speed_land_ft // _SQUARE_FT * speed_mult) if speed_cells <= 0: return [] 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: return [] path: list[Pos] = [] diff --git a/tests/test_move_destination.py b/tests/test_move_destination.py new file mode 100644 index 0000000..821c001 --- /dev/null +++ b/tests/test_move_destination.py @@ -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") + ]