feat(combat): allow explicit movement destinations
This commit is contained in:
@@ -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
@@ -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] = []
|
||||||
|
|||||||
@@ -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")
|
||||||
|
]
|
||||||
Reference in New Issue
Block a user