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