feat(rules): 3D flight over ground terrain - airborne creatures (fly >= land, V4 budget from fly speed) move on Grid.airborne_view: ground difficult terrain costs 1 and cells impassable to walkers (pits, water, rubble, low obstacles) are crossable at cost 1, EXCEPT blocks_los cells (solid barriers, assumed full height) which still stop flight; run/charge/5-foot-step gates and diagonal corner rule follow the same view (flying past a pit is legal, past a wall corner is not); elevation climb cost still applies (step heights, not wall heights - documented); line of effect, cover and high ground use the unchanged real grid so combat geometry is unaffected by flight (SRD Movement, Position and Distance: flying creatures not hampered by difficult terrain, able to avoid most obstacles), 11 tests
This commit is contained in:
+142
-59
@@ -55,8 +55,13 @@ Phase 0 documented deviations from PF1e (conventions):
|
|||||||
- Movement: a move action travels up to the combatant's speed in cells along
|
- Movement: a move action travels up to the combatant's speed in cells along
|
||||||
the true shortest path (Dijkstra cost field from the target, 5-10-5
|
the true shortest path (Dijkstra cost field from the target, 5-10-5
|
||||||
diagonals), stopping adjacent to the nearest enemy; ties keep delta order.
|
diagonals), stopping adjacent to the nearest enemy; ties keep delta order.
|
||||||
The speed budget is the best of land and fly speed (V4); flying over
|
The speed budget is the best of land and fly speed (V4). A creature whose
|
||||||
obstacles or difficult terrain is NOT modeled (grid costs still apply).
|
budget comes from its fly speed (fly >= land) is AIRBORNE: it ignores
|
||||||
|
difficult-terrain costs and crosses ground cells impassable to walkers
|
||||||
|
(pits, rubble, water), except cells that block line of effect (solid
|
||||||
|
barriers, assumed full height) — SRD "Movement, Position, and Distance":
|
||||||
|
"Flying and incorporeal creatures are not hampered by difficult terrain" /
|
||||||
|
"are able to avoid most obstacles". No fly maneuverability, no 3D flight.
|
||||||
- Line of effect gates all attacks: a target fully behind blocking terrain
|
- Line of effect gates all attacks: a target fully behind blocking terrain
|
||||||
cannot be attacked, and the policy moves to gain sight instead.
|
cannot be attacked, and the policy moves to gain sight instead.
|
||||||
- Cover (corner rule) grants +4 AC on hit and crit-confirm rolls; melee and
|
- Cover (corner rule) grants +4 AC on hit and crit-confirm rolls; melee and
|
||||||
@@ -774,15 +779,35 @@ def _melee_reach_zone_cells(state: CombatantState, weapon: AttackSpec) -> tuple[
|
|||||||
def _speed_ft(state: CombatantState) -> int:
|
def _speed_ft(state: CombatantState) -> int:
|
||||||
"""Movement budget in feet: the best of the combatant's land and fly speed.
|
"""Movement budget in feet: the best of the combatant's land and fly speed.
|
||||||
|
|
||||||
Simplification (V4): a creature that has a fly speed is assumed to use it;
|
Simplification (V4): a creature that has a fly speed is assumed to use it
|
||||||
flying OVER obstacles or difficult terrain is NOT modeled — the grid's
|
when its fly speed is at least its land speed (see ``_is_airborne``). The
|
||||||
passability and terrain costs still apply to the whole budget. No
|
budget itself is land-or-fly; an airborne creature additionally moves over
|
||||||
fly maneuverability, no 3D flight. Swim/climb/burrow speeds do not exist
|
ground terrain at cost 1 (``Grid.airborne_view``). No fly maneuverability,
|
||||||
in the model (fly only).
|
no 3D flight. Swim/climb/burrow speeds do not exist in the model (fly only).
|
||||||
"""
|
"""
|
||||||
return max(state.combatant.speed_land_ft, state.combatant.speed_fly_ft or 0)
|
return max(state.combatant.speed_land_ft, state.combatant.speed_fly_ft or 0)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_airborne(state: CombatantState) -> bool:
|
||||||
|
"""True when the creature's movement budget comes from its fly speed.
|
||||||
|
|
||||||
|
The engine's V4 assumption is that a creature with a fly speed uses it
|
||||||
|
(budget = max(land, fly)); the creature is AIRBORNE — flying over the
|
||||||
|
ground, so difficult terrain does not hamper it and it avoids the
|
||||||
|
obstacles that merely block walkers (SRD "Movement, Position, and
|
||||||
|
Distance": "Flying and incorporeal creatures are not hampered by difficult
|
||||||
|
terrain"; "Flying and incorporeal creatures are able to avoid most
|
||||||
|
obstacles") — exactly when the fly speed is the source of that budget,
|
||||||
|
i.e. ``speed_fly_ft >= speed_land_ft``. A creature whose fly speed is
|
||||||
|
slower than its land speed walks (the budget is the land speed) and pays
|
||||||
|
ground terrain costs normally. There is no explicit altitude flag: the
|
||||||
|
model has no 3D position, so "airborne" is derived from the speeds alone
|
||||||
|
(documented decision).
|
||||||
|
"""
|
||||||
|
fly = state.combatant.speed_fly_ft
|
||||||
|
return fly is not None and fly >= state.combatant.speed_land_ft
|
||||||
|
|
||||||
|
|
||||||
def _bresenham_line(start: Pos, end: Pos) -> list[Pos]:
|
def _bresenham_line(start: Pos, end: Pos) -> list[Pos]:
|
||||||
"""Integer Bresenham line from start to end (exclusive of start, inclusive of end)."""
|
"""Integer Bresenham line from start to end (exclusive of start, inclusive of end)."""
|
||||||
r0, c0 = start
|
r0, c0 = start
|
||||||
@@ -1598,6 +1623,7 @@ class CombatEngine:
|
|||||||
raise ValueError(msg)
|
raise ValueError(msg)
|
||||||
self._rng = rng
|
self._rng = rng
|
||||||
self._grid = grid
|
self._grid = grid
|
||||||
|
self._airborne_grid = grid.airborne_view()
|
||||||
self._states = states
|
self._states = states
|
||||||
self._round_cap = round_cap
|
self._round_cap = round_cap
|
||||||
if llm_policy is not None:
|
if llm_policy is not None:
|
||||||
@@ -3022,11 +3048,25 @@ class CombatEngine:
|
|||||||
"""Height of the creature's anchor cell (0 when no elevation layer)."""
|
"""Height of the creature's anchor cell (0 when no elevation layer)."""
|
||||||
return self._grid.elevation_at(state.pos)
|
return self._grid.elevation_at(state.pos)
|
||||||
|
|
||||||
|
def _movement_grid(self, state: CombatantState) -> Grid:
|
||||||
|
"""The terrain grid the mover walks on: the air view when airborne.
|
||||||
|
|
||||||
|
An airborne creature prices and gates every movement action (move,
|
||||||
|
run, charge, withdraw, 5-foot step) on ``Grid.airborne_view``: ground
|
||||||
|
difficult terrain costs 1 and impassable non-barrier cells (pits,
|
||||||
|
water, rubble) are crossable; ``blocks_los`` cells (solid barriers,
|
||||||
|
assumed full height) stay impassable. Line of effect, cover and
|
||||||
|
high ground always use the real ``_grid`` — the air view preserves
|
||||||
|
their terrain flags, so combat geometry is unaffected by flight.
|
||||||
|
"""
|
||||||
|
return self._airborne_grid if _is_airborne(state) else self._grid
|
||||||
|
|
||||||
def _cells_free(self, cells: Iterable[Pos], mover: CombatantState) -> bool:
|
def _cells_free(self, cells: Iterable[Pos], mover: CombatantState) -> bool:
|
||||||
"""True if every cell is in bounds, passable, and unoccupied by others."""
|
"""True if every cell is in bounds, passable, and unoccupied by others."""
|
||||||
blocked = self._blocked(mover)
|
blocked = self._blocked(mover)
|
||||||
|
grid = self._movement_grid(mover)
|
||||||
return all(
|
return all(
|
||||||
self._grid.passable(cell) and cell not in blocked for cell in cells
|
grid.passable(cell) and cell not in blocked for cell in cells
|
||||||
)
|
)
|
||||||
|
|
||||||
def _cells_walkable(self, cells: Iterable[Pos], mover: CombatantState) -> bool:
|
def _cells_walkable(self, cells: Iterable[Pos], mover: CombatantState) -> bool:
|
||||||
@@ -3037,8 +3077,9 @@ class CombatEngine:
|
|||||||
though the movement may not END there (``_cells_free`` gates that).
|
though the movement may not END there (``_cells_free`` gates that).
|
||||||
"""
|
"""
|
||||||
hostile = self._blocked_hostile(mover)
|
hostile = self._blocked_hostile(mover)
|
||||||
|
grid = self._movement_grid(mover)
|
||||||
return all(
|
return all(
|
||||||
self._grid.passable(cell) and cell not in hostile for cell in cells
|
grid.passable(cell) and cell not in hostile for cell in cells
|
||||||
)
|
)
|
||||||
|
|
||||||
def _footprint_at(self, anchor: Pos, state: CombatantState) -> frozenset[Pos]:
|
def _footprint_at(self, anchor: Pos, state: CombatantState) -> frozenset[Pos]:
|
||||||
@@ -4420,16 +4461,18 @@ class CombatEngine:
|
|||||||
|
|
||||||
(API publique, lecture pour politique LLM.) Same cost model as
|
(API publique, lecture pour politique LLM.) Same cost model as
|
||||||
``Grid.reachable``: 5-10-5 diagonals, terrain, elevation, blocked
|
``Grid.reachable``: 5-10-5 diagonals, terrain, elevation, blocked
|
||||||
creatures excluded. ``budget`` is in feet; None = full field.
|
creatures excluded. An airborne mover prices the field on the air
|
||||||
|
view (``_movement_grid``). ``budget`` is in feet; None = full field.
|
||||||
"""
|
"""
|
||||||
blocked = self._blocked(state)
|
blocked = self._blocked(state)
|
||||||
|
grid = self._movement_grid(state)
|
||||||
if budget is None:
|
if budget is None:
|
||||||
field = self._grid.reachable(state.pos, None, blocked)
|
field = grid.reachable(state.pos, None, blocked)
|
||||||
else:
|
else:
|
||||||
budget_cells = budget // _SQUARE_FT
|
budget_cells = budget // _SQUARE_FT
|
||||||
if budget_cells < 1:
|
if budget_cells < 1:
|
||||||
return {}
|
return {}
|
||||||
field = self._grid.reachable(state.pos, budget_cells, blocked)
|
field = grid.reachable(state.pos, budget_cells, blocked)
|
||||||
return {pos: cost * _SQUARE_FT for pos, cost in field.items()}
|
return {pos: cost * _SQUARE_FT for pos, cost in field.items()}
|
||||||
|
|
||||||
def movement_cost_ft(self, state: CombatantState, target: CombatantState) -> int | None:
|
def movement_cost_ft(self, state: CombatantState, target: CombatantState) -> int | None:
|
||||||
@@ -4440,10 +4483,11 @@ class CombatEngine:
|
|||||||
``_move_path``: a full Dijkstra field from the target's anchor (other
|
``_move_path``: a full Dijkstra field from the target's anchor (other
|
||||||
active states' cells blocked), then the cheapest field value over
|
active states' cells blocked), then the cheapest field value over
|
||||||
``state``'s footprint, compared against the combatant's effective
|
``state``'s footprint, compared against the combatant's effective
|
||||||
(land-or-fly) speed. Read-only: never mutates state nor consumes RNG.
|
(land-or-fly) speed. An airborne ``state`` prices the field on the air
|
||||||
|
view. Read-only: never mutates state nor consumes RNG.
|
||||||
"""
|
"""
|
||||||
blocked = self._blocked(state, target)
|
blocked = self._blocked(state, target)
|
||||||
field = self._grid.reachable(target.pos, None, blocked)
|
field = self._movement_grid(state).reachable(target.pos, None, blocked)
|
||||||
cost_cells = self._footprint_cost(state.occupied_cells(), field)
|
cost_cells = self._footprint_cost(state.occupied_cells(), field)
|
||||||
if cost_cells is None:
|
if cost_cells is None:
|
||||||
return None
|
return None
|
||||||
@@ -4840,12 +4884,12 @@ class CombatEngine:
|
|||||||
return ChargeResult(weapon=weapon, distance_ft=distance_ft, reason=None)
|
return ChargeResult(weapon=weapon, distance_ft=distance_ft, reason=None)
|
||||||
return ChargeResult(weapon=None, distance_ft=0, reason="no_charge_path")
|
return ChargeResult(weapon=None, distance_ft=0, reason="no_charge_path")
|
||||||
|
|
||||||
def _path_cost_5105(self, path: list[Pos]) -> int:
|
def _path_cost_5105(self, path: list[Pos], grid: Grid) -> int:
|
||||||
"""Total 5-10-5 movement cost of a path (diagonal parity threaded)."""
|
"""Total 5-10-5 movement cost of a path on ``grid`` (parity threaded)."""
|
||||||
total = 0
|
total = 0
|
||||||
diagonals_used = 0
|
diagonals_used = 0
|
||||||
for frm, to in pairwise(path):
|
for frm, to in pairwise(path):
|
||||||
total += self._grid.step_cost(frm, to, diagonals_used)
|
total += grid.step_cost(frm, to, diagonals_used)
|
||||||
if frm[0] != to[0] and frm[1] != to[1]:
|
if frm[0] != to[0] and frm[1] != to[1]:
|
||||||
diagonals_used += 1
|
diagonals_used += 1
|
||||||
return total
|
return total
|
||||||
@@ -4869,8 +4913,11 @@ class CombatEngine:
|
|||||||
Ties break toward lower distance from state.
|
Ties break toward lower distance from state.
|
||||||
"""
|
"""
|
||||||
target_cells = target.occupied_cells()
|
target_cells = target.occupied_cells()
|
||||||
|
grid = self._movement_grid(state)
|
||||||
candidates: list[tuple[int, Pos]] = []
|
candidates: list[tuple[int, Pos]] = []
|
||||||
for end in self._charge_candidates(target, reach_cells, blocked, state.pos):
|
for end in self._charge_candidates(
|
||||||
|
target, reach_cells, blocked, state.pos, grid
|
||||||
|
):
|
||||||
footprint = self._footprint_at(end, state)
|
footprint = self._footprint_at(end, state)
|
||||||
if not self._cells_free(footprint, state):
|
if not self._cells_free(footprint, state):
|
||||||
continue
|
continue
|
||||||
@@ -4888,7 +4935,7 @@ class CombatEngine:
|
|||||||
continue
|
continue
|
||||||
# The charge budget is a movement budget: count it in 5-10-5
|
# The charge budget is a movement budget: count it in 5-10-5
|
||||||
# squares (diagonal parity threaded), not raw cells.
|
# squares (diagonal parity threaded), not raw cells.
|
||||||
if self._path_cost_5105(path) > max_cells:
|
if self._path_cost_5105(path, grid) > max_cells:
|
||||||
continue
|
continue
|
||||||
candidates.append((len(path), end))
|
candidates.append((len(path), end))
|
||||||
if not candidates:
|
if not candidates:
|
||||||
@@ -4902,8 +4949,13 @@ class CombatEngine:
|
|||||||
reach_cells: int,
|
reach_cells: int,
|
||||||
blocked: frozenset[Pos],
|
blocked: frozenset[Pos],
|
||||||
start: Pos,
|
start: Pos,
|
||||||
|
grid: Grid,
|
||||||
) -> list[Pos]:
|
) -> list[Pos]:
|
||||||
"""Anchors within reach of some target cell, passable, unoccupied, not the start."""
|
"""Anchors within reach of some target cell, passable, unoccupied, not the start.
|
||||||
|
|
||||||
|
``grid`` is the charger's movement grid (the air view when airborne:
|
||||||
|
an airborne charger ends its charge over cells walkers cannot enter).
|
||||||
|
"""
|
||||||
result: set[Pos] = set()
|
result: set[Pos] = set()
|
||||||
for tr, tc in target.occupied_cells():
|
for tr, tc in target.occupied_cells():
|
||||||
for dr in range(-reach_cells, reach_cells + 1):
|
for dr in range(-reach_cells, reach_cells + 1):
|
||||||
@@ -4915,7 +4967,7 @@ class CombatEngine:
|
|||||||
end = (tr + dr, tc + dc)
|
end = (tr + dr, tc + dc)
|
||||||
if end == start:
|
if end == start:
|
||||||
continue
|
continue
|
||||||
if not self._grid.in_bounds(end) or not self._grid.passable(end):
|
if not grid.in_bounds(end) or not grid.passable(end):
|
||||||
continue
|
continue
|
||||||
if end in blocked:
|
if end in blocked:
|
||||||
continue
|
continue
|
||||||
@@ -4939,19 +4991,24 @@ class CombatEngine:
|
|||||||
below. Difficult terrain is tested over the WHOLE footprint, not
|
below. Difficult terrain is tested over the WHOLE footprint, not
|
||||||
just the anchor cell (SRD "Movement, Position and Distance": "You
|
just the anchor cell (SRD "Movement, Position and Distance": "You
|
||||||
can't run or charge across difficult terrain" - a 2x2 creature with
|
can't run or charge across difficult terrain" - a 2x2 creature with
|
||||||
one trailing square in rubble cannot charge).
|
one trailing square in rubble cannot charge). An AIRBORNE charger is
|
||||||
|
not hampered by difficult terrain and avoids ground obstacles, so it
|
||||||
|
walks these gates on the air view (SRD "Movement, Position, and
|
||||||
|
Distance": "Flying and incorporeal creatures are not hampered by
|
||||||
|
difficult terrain"); solid barriers still stop the charge.
|
||||||
"""
|
"""
|
||||||
|
grid = self._movement_grid(state)
|
||||||
prev = state.pos
|
prev = state.pos
|
||||||
for pos in path:
|
for pos in path:
|
||||||
footprint = self._footprint_at(pos, state)
|
footprint = self._footprint_at(pos, state)
|
||||||
if not self._cells_free(footprint, state):
|
if not self._cells_free(footprint, state):
|
||||||
return False
|
return False
|
||||||
if self._footprint_touches_difficult(footprint):
|
if self._footprint_touches_difficult(state, footprint):
|
||||||
return False
|
return False
|
||||||
if (
|
if (
|
||||||
prev[0] != pos[0]
|
prev[0] != pos[0]
|
||||||
and prev[1] != pos[1]
|
and prev[1] != pos[1]
|
||||||
and not self._grid.diagonal_allowed(prev, pos)
|
and not grid.diagonal_allowed(prev, pos)
|
||||||
):
|
):
|
||||||
return False
|
return False
|
||||||
prev = pos
|
prev = pos
|
||||||
@@ -9349,12 +9406,15 @@ class CombatEngine:
|
|||||||
# SRD "Take 5-Foot Step": "You can only take a 5-foot-step if your
|
# SRD "Take 5-Foot Step": "You can only take a 5-foot-step if your
|
||||||
# movement isn't hampered by difficult terrain or darkness." — a step
|
# movement isn't hampered by difficult terrain or darkness." — a step
|
||||||
# out of OR into a difficult cell is blocked (darkness not modeled).
|
# out of OR into a difficult cell is blocked (darkness not modeled).
|
||||||
if self._footprint_touches_difficult(self._footprint_at(state.pos, state)):
|
# An airborne creature's movement is not hampered by difficult
|
||||||
|
# terrain (SRD "Movement, Position, and Distance"), so the gate
|
||||||
|
# passes for it.
|
||||||
|
if self._footprint_touches_difficult(state, self._footprint_at(state.pos, state)):
|
||||||
self._log(f"round {self._current_round} {state.combatant.id}: wait (cannot step)")
|
self._log(f"round {self._current_round} {state.combatant.id}: wait (cannot step)")
|
||||||
return
|
return
|
||||||
path = self._move_path(state, target)
|
path = self._move_path(state, target)
|
||||||
start = state.pos
|
start = state.pos
|
||||||
if self._footprint_touches_difficult(self._footprint_at(path[0], state)):
|
if self._footprint_touches_difficult(state, self._footprint_at(path[0], state)):
|
||||||
self._log(f"round {self._current_round} {state.combatant.id}: wait (cannot step)")
|
self._log(f"round {self._current_round} {state.combatant.id}: wait (cannot step)")
|
||||||
return
|
return
|
||||||
state.pos = path[0]
|
state.pos = path[0]
|
||||||
@@ -9376,16 +9436,19 @@ class CombatEngine:
|
|||||||
return ActionResult(kind=kind, ok=True, reason=_REASON_OK)
|
return ActionResult(kind=kind, ok=True, reason=_REASON_OK)
|
||||||
adjacent = self._grid.distance(state.pos, destination) == 1
|
adjacent = self._grid.distance(state.pos, destination) == 1
|
||||||
free = self._cells_free(self._footprint_at(destination, state), state)
|
free = self._cells_free(self._footprint_at(destination, state), state)
|
||||||
|
grid = self._movement_grid(state)
|
||||||
# PF1e (SRD Measuring Distance): "You can't move diagonally past a
|
# PF1e (SRD Measuring Distance): "You can't move diagonally past a
|
||||||
# corner (even by taking a 5-foot step)."
|
# corner (even by taking a 5-foot step)."
|
||||||
diagonal = state.pos[0] != destination[0] and state.pos[1] != destination[1]
|
diagonal = state.pos[0] != destination[0] and state.pos[1] != destination[1]
|
||||||
corner_clear = not diagonal or self._grid.diagonal_allowed(state.pos, destination)
|
corner_clear = not diagonal or grid.diagonal_allowed(state.pos, destination)
|
||||||
# SRD "Take 5-Foot Step": "You can only take a 5-foot-step if your
|
# SRD "Take 5-Foot Step": "You can only take a 5-foot-step if your
|
||||||
# movement isn't hampered by difficult terrain or darkness." — a step
|
# movement isn't hampered by difficult terrain or darkness." — a step
|
||||||
# out of OR into a difficult cell is blocked (darkness not modeled).
|
# out of OR into a difficult cell is blocked (darkness not modeled);
|
||||||
|
# flight is not hampered by ground terrain, so the gate passes for an
|
||||||
|
# airborne creature.
|
||||||
step_clear = not (
|
step_clear = not (
|
||||||
self._footprint_touches_difficult(self._footprint_at(state.pos, state))
|
self._footprint_touches_difficult(state, self._footprint_at(state.pos, state))
|
||||||
or self._footprint_touches_difficult(self._footprint_at(destination, state))
|
or self._footprint_touches_difficult(state, self._footprint_at(destination, state))
|
||||||
)
|
)
|
||||||
if not adjacent or not free or not corner_clear or not step_clear:
|
if not adjacent or not free or not corner_clear or not step_clear:
|
||||||
self._log(
|
self._log(
|
||||||
@@ -9497,22 +9560,23 @@ class CombatEngine:
|
|||||||
if not self._cells_free(self._footprint_at(destination, state), state):
|
if not self._cells_free(self._footprint_at(destination, state), state):
|
||||||
return False
|
return False
|
||||||
if avoid_difficult and self._footprint_touches_difficult(
|
if avoid_difficult and self._footprint_touches_difficult(
|
||||||
self._footprint_at(destination, state)
|
state, self._footprint_at(destination, state)
|
||||||
):
|
):
|
||||||
return False
|
return False
|
||||||
blocked = self._blocked_hostile(state) if friendly_passable else self._blocked(state)
|
blocked = self._blocked_hostile(state) if friendly_passable else self._blocked(state)
|
||||||
|
grid = self._movement_grid(state)
|
||||||
if avoid_difficult:
|
if avoid_difficult:
|
||||||
blocked = blocked | self._difficult_cells()
|
blocked = blocked | self._difficult_cells(grid)
|
||||||
field = self._grid.reachable(state.pos, speed_cells, blocked)
|
field = grid.reachable(state.pos, speed_cells, blocked)
|
||||||
return destination in field
|
return destination in field
|
||||||
|
|
||||||
def _difficult_cells(self) -> frozenset[Pos]:
|
def _difficult_cells(self, grid: Grid) -> frozenset[Pos]:
|
||||||
"""Every passable cell on the grid whose terrain costs more than one."""
|
"""Every passable cell of ``grid`` whose terrain costs more than one."""
|
||||||
return frozenset(
|
return frozenset(
|
||||||
(row, col)
|
(row, col)
|
||||||
for row in range(self._grid.height)
|
for row in range(grid.height)
|
||||||
for col in range(self._grid.width)
|
for col in range(grid.width)
|
||||||
if self._is_difficult_cell((row, col))
|
if self._is_difficult_cell(grid, (row, col))
|
||||||
)
|
)
|
||||||
|
|
||||||
def _walk_steps(
|
def _walk_steps(
|
||||||
@@ -9595,7 +9659,7 @@ class CombatEngine:
|
|||||||
if speed_cells <= 0:
|
if speed_cells <= 0:
|
||||||
return []
|
return []
|
||||||
blocked = self._blocked_hostile(state) if friendly_passable else self._blocked(state)
|
blocked = self._blocked_hostile(state) if friendly_passable else self._blocked(state)
|
||||||
to_target = self._grid.reachable(anchor, None, blocked)
|
to_target = self._movement_grid(state).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] = []
|
||||||
@@ -9640,9 +9704,13 @@ class CombatEngine:
|
|||||||
so an auto-directed run ends adjacent to the target — except one the
|
so an auto-directed run ends adjacent to the target — except one the
|
||||||
mover may pass through (three size categories apart, SRD "Moving
|
mover may pass through (three size categories apart, SRD "Moving
|
||||||
Through a Square"), which is crossed with an entry AoO and never
|
Through a Square"), which is crossed with an entry AoO and never
|
||||||
ended on. Friendly squares
|
ended on. Friendly squares
|
||||||
are still crossed (SRD Movement, conformance L5) but never ended on —
|
are still crossed (SRD Movement, conformance L5) but never ended on —
|
||||||
the tail is trimmed like ``_descend_path``.
|
the tail is trimmed like ``_descend_path``. An AIRBORNE runner prices
|
||||||
|
the line on the air view: ground difficult terrain and impassable
|
||||||
|
non-barrier cells do not stop it (SRD "Movement, Position, and
|
||||||
|
Distance": "Flying and incorporeal creatures are not hampered by
|
||||||
|
difficult terrain").
|
||||||
"""
|
"""
|
||||||
speed_mult = self._get_speed_mult(state)
|
speed_mult = self._get_speed_mult(state)
|
||||||
budget = int(
|
budget = int(
|
||||||
@@ -9650,6 +9718,7 @@ class CombatEngine:
|
|||||||
)
|
)
|
||||||
if budget <= 0:
|
if budget <= 0:
|
||||||
return []
|
return []
|
||||||
|
grid = self._movement_grid(state)
|
||||||
path: list[Pos] = []
|
path: list[Pos] = []
|
||||||
current = state.pos
|
current = state.pos
|
||||||
diagonals_used = 0
|
diagonals_used = 0
|
||||||
@@ -9661,15 +9730,15 @@ class CombatEngine:
|
|||||||
if (
|
if (
|
||||||
current[0] != nxt[0]
|
current[0] != nxt[0]
|
||||||
and current[1] != nxt[1]
|
and current[1] != nxt[1]
|
||||||
and not self._grid.diagonal_allowed(current, nxt)
|
and not grid.diagonal_allowed(current, nxt)
|
||||||
):
|
):
|
||||||
break
|
break
|
||||||
footprint = self._footprint_at(nxt, state)
|
footprint = self._footprint_at(nxt, state)
|
||||||
if self._footprint_touches_difficult(footprint):
|
if self._footprint_touches_difficult(state, footprint):
|
||||||
break
|
break
|
||||||
if not self._cells_walkable(footprint, state):
|
if not self._cells_walkable(footprint, state):
|
||||||
break
|
break
|
||||||
step = self._grid.step_cost(current, nxt, diagonals_used)
|
step = grid.step_cost(current, nxt, diagonals_used)
|
||||||
if step > budget:
|
if step > budget:
|
||||||
break
|
break
|
||||||
path.append(nxt)
|
path.append(nxt)
|
||||||
@@ -9698,6 +9767,7 @@ class CombatEngine:
|
|||||||
return None
|
return None
|
||||||
best: tuple[int, int, Pos] | None = None
|
best: tuple[int, int, Pos] | None = None
|
||||||
row, col = current
|
row, col = current
|
||||||
|
grid = self._movement_grid(state)
|
||||||
fits = self._cells_walkable if friendly_passable else self._cells_free
|
fits = self._cells_walkable if friendly_passable else self._cells_free
|
||||||
for d_row, d_col in _STEP_DELTAS:
|
for d_row, d_col in _STEP_DELTAS:
|
||||||
nxt = (row + d_row, col + d_col)
|
nxt = (row + d_row, col + d_col)
|
||||||
@@ -9705,12 +9775,12 @@ class CombatEngine:
|
|||||||
# a corner (even by taking a 5-foot step)" — same corner rule the
|
# a corner (even by taking a 5-foot step)" — same corner rule the
|
||||||
# explicit step (``_step5_to``) and flee (``_best_flee_step``)
|
# explicit step (``_step5_to``) and flee (``_best_flee_step``)
|
||||||
# paths already enforce.
|
# paths already enforce.
|
||||||
if not self._grid.diagonal_allowed(current, nxt):
|
if not grid.diagonal_allowed(current, nxt):
|
||||||
continue
|
continue
|
||||||
footprint = self._footprint_at(nxt, state)
|
footprint = self._footprint_at(nxt, state)
|
||||||
if not fits(footprint, state):
|
if not fits(footprint, state):
|
||||||
continue
|
continue
|
||||||
if avoid_difficult and self._footprint_touches_difficult(footprint):
|
if avoid_difficult and self._footprint_touches_difficult(state, footprint):
|
||||||
continue
|
continue
|
||||||
nxt_cost = self._footprint_cost(footprint, to_target)
|
nxt_cost = self._footprint_cost(footprint, to_target)
|
||||||
if nxt_cost is None or nxt_cost >= current_cost:
|
if nxt_cost is None or nxt_cost >= current_cost:
|
||||||
@@ -9766,9 +9836,17 @@ class CombatEngine:
|
|||||||
frontier.append((nxt, cost))
|
frontier.append((nxt, cost))
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def _footprint_touches_difficult(self, footprint: frozenset[Pos]) -> bool:
|
def _footprint_touches_difficult(
|
||||||
"""True if any footprint cell is difficult terrain (move cost > 1)."""
|
self, state: CombatantState, footprint: frozenset[Pos]
|
||||||
return any(self._is_difficult_cell(cell) for cell in footprint)
|
) -> bool:
|
||||||
|
"""True if any footprint cell is difficult terrain (move cost > 1).
|
||||||
|
|
||||||
|
Priced on the mover's movement grid: an airborne creature is not
|
||||||
|
hampered by difficult terrain (SRD "Movement, Position, and
|
||||||
|
Distance"), so no cell is difficult for it.
|
||||||
|
"""
|
||||||
|
grid = self._movement_grid(state)
|
||||||
|
return any(self._is_difficult_cell(grid, cell) for cell in footprint)
|
||||||
|
|
||||||
def _footprint_step_cost(
|
def _footprint_step_cost(
|
||||||
self, state: CombatantState, frm: Pos, to: Pos, diagonals_used: int
|
self, state: CombatantState, frm: Pos, to: Pos, diagonals_used: int
|
||||||
@@ -9783,22 +9861,26 @@ class CombatEngine:
|
|||||||
at ``to`` (the 5-10-5 + elevation formula stays in ``grid.py``). For a
|
at ``to`` (the 5-10-5 + elevation formula stays in ``grid.py``). For a
|
||||||
1x1 creature the footprint is the anchor cell, so this is exactly
|
1x1 creature the footprint is the anchor cell, so this is exactly
|
||||||
``grid.step_cost`` - bit-identical to the old anchor-only pricing.
|
``grid.step_cost`` - bit-identical to the old anchor-only pricing.
|
||||||
|
The pricing grid is the mover's movement grid: every cell costs 1 to
|
||||||
|
an airborne creature (SRD "Movement, Position, and Distance": flying
|
||||||
|
creatures are not hampered by difficult terrain).
|
||||||
"""
|
"""
|
||||||
|
grid = self._movement_grid(state)
|
||||||
footprint = self._footprint_at(to, state)
|
footprint = self._footprint_at(to, state)
|
||||||
hardest = 1
|
hardest = 1
|
||||||
for cell in footprint:
|
for cell in footprint:
|
||||||
if not self._grid.in_bounds(cell):
|
if not grid.in_bounds(cell):
|
||||||
continue
|
continue
|
||||||
cost = self._grid.terrain_at(cell).move_cost
|
cost = grid.terrain_at(cell).move_cost
|
||||||
if cost is not None and cost > hardest:
|
if cost is not None and cost > hardest:
|
||||||
hardest = cost
|
hardest = cost
|
||||||
return self._grid.step_cost_with_terrain(frm, to, diagonals_used, hardest)
|
return grid.step_cost_with_terrain(frm, to, diagonals_used, hardest)
|
||||||
|
|
||||||
def _is_difficult_cell(self, pos: Pos) -> bool:
|
def _is_difficult_cell(self, grid: Grid, pos: Pos) -> bool:
|
||||||
"""True for a passable cell whose terrain costs more than one square."""
|
"""True for a passable cell of ``grid`` whose terrain costs more than one."""
|
||||||
if not self._grid.in_bounds(pos):
|
if not grid.in_bounds(pos):
|
||||||
return False
|
return False
|
||||||
terrain = self._grid.terrain_at(pos)
|
terrain = grid.terrain_at(pos)
|
||||||
return terrain.move_cost is not None and terrain.move_cost > 1
|
return terrain.move_cost is not None and terrain.move_cost > 1
|
||||||
|
|
||||||
def _flee_path(self, state: CombatantState, threat: CombatantState) -> list[Pos]:
|
def _flee_path(self, state: CombatantState, threat: CombatantState) -> list[Pos]:
|
||||||
@@ -9819,7 +9901,7 @@ class CombatEngine:
|
|||||||
if max_cells <= 0:
|
if max_cells <= 0:
|
||||||
return []
|
return []
|
||||||
blocked = self._blocked(state)
|
blocked = self._blocked(state)
|
||||||
from_threat = self._grid.reachable(threat.pos, None, blocked)
|
from_threat = self._movement_grid(state).reachable(threat.pos, None, blocked)
|
||||||
if self._footprint_cost(state.occupied_cells(), from_threat) is None:
|
if self._footprint_cost(state.occupied_cells(), from_threat) is None:
|
||||||
return []
|
return []
|
||||||
path: list[Pos] = []
|
path: list[Pos] = []
|
||||||
@@ -9854,12 +9936,13 @@ class CombatEngine:
|
|||||||
return None
|
return None
|
||||||
best: tuple[int, int, Pos] | None = None
|
best: tuple[int, int, Pos] | None = None
|
||||||
row, col = current
|
row, col = current
|
||||||
|
grid = self._movement_grid(state)
|
||||||
for d_row, d_col in _STEP_DELTAS:
|
for d_row, d_col in _STEP_DELTAS:
|
||||||
nxt = (row + d_row, col + d_col)
|
nxt = (row + d_row, col + d_col)
|
||||||
footprint = self._footprint_at(nxt, state)
|
footprint = self._footprint_at(nxt, state)
|
||||||
if not self._cells_free(footprint, state):
|
if not self._cells_free(footprint, state):
|
||||||
continue
|
continue
|
||||||
if not self._grid.diagonal_allowed(current, nxt):
|
if not grid.diagonal_allowed(current, nxt):
|
||||||
continue
|
continue
|
||||||
nxt_cost = self._footprint_cost(footprint, from_threat)
|
nxt_cost = self._footprint_cost(footprint, from_threat)
|
||||||
if nxt_cost is None or nxt_cost <= current_cost:
|
if nxt_cost is None or nxt_cost <= current_cost:
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ if TYPE_CHECKING:
|
|||||||
from pf1e_simulator.map import MapSpec, Pos, TerrainType
|
from pf1e_simulator.map import MapSpec, Pos, TerrainType
|
||||||
|
|
||||||
_DIFFICULT_TERRAIN_COST = 2 # rubble, water, etc. count as 2 squares (PF1e)
|
_DIFFICULT_TERRAIN_COST = 2 # rubble, water, etc. count as 2 squares (PF1e)
|
||||||
|
_AIRBORNE_TERRAIN_COST = 1 # flying is not hampered by ground terrain (PF1e)
|
||||||
|
|
||||||
_DIRECTIONS: tuple[Pos, ...] = (
|
_DIRECTIONS: tuple[Pos, ...] = (
|
||||||
(-1, -1),
|
(-1, -1),
|
||||||
@@ -48,6 +49,41 @@ class Grid:
|
|||||||
self._width = len(cells[0]) if cells else 0
|
self._width = len(cells[0]) if cells else 0
|
||||||
self._elevation = elevation
|
self._elevation = elevation
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _airborne_terrain(terrain: TerrainType) -> TerrainType:
|
||||||
|
"""The same square seen from the air (PF1e flight over ground terrain).
|
||||||
|
|
||||||
|
SRD "Movement, Position, and Distance" (Terrain and Obstacles):
|
||||||
|
"Flying and incorporeal creatures are not hampered by difficult
|
||||||
|
terrain" and "Flying and incorporeal creatures are able to avoid most
|
||||||
|
obstacles" — so a cell that only slows walking (move_cost > 1) or
|
||||||
|
stops it (pits, rubble, water: move_cost None) becomes a plain
|
||||||
|
cost-1 cell in the air view. The exception is a SOLID BARRIER
|
||||||
|
(`blocks_los`, the engine's encoding of CRB "Aiming a Spell — Line of
|
||||||
|
Effect": "A line of effect is canceled by a solid barrier"), which is
|
||||||
|
assumed full height and still stops flight. Cover flags and the
|
||||||
|
terrain name are preserved; only the walking cost is normalized.
|
||||||
|
"""
|
||||||
|
if terrain.blocks_los:
|
||||||
|
return terrain
|
||||||
|
cost = terrain.move_cost
|
||||||
|
if cost is None or cost > _AIRBORNE_TERRAIN_COST:
|
||||||
|
return terrain.model_copy(update={"move_cost": _AIRBORNE_TERRAIN_COST})
|
||||||
|
return terrain
|
||||||
|
|
||||||
|
def airborne_view(self) -> Grid:
|
||||||
|
"""A terrain view for airborne creatures (see ``_airborne_terrain``).
|
||||||
|
|
||||||
|
Elevation is preserved: the layer stores STEP heights of the ground,
|
||||||
|
not wall or cliff heights, so the climbing cost still applies to
|
||||||
|
flight (documented simplification — true 3D flight is not modeled).
|
||||||
|
"""
|
||||||
|
cells = tuple(
|
||||||
|
tuple(self._airborne_terrain(terrain) for terrain in row)
|
||||||
|
for row in self._cells
|
||||||
|
)
|
||||||
|
return Grid(cells, elevation=self._elevation)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_spec(cls, spec: MapSpec) -> Grid:
|
def from_spec(cls, spec: MapSpec) -> Grid:
|
||||||
cells = tuple(tuple(spec.legend[ch] for ch in row) for row in spec.terrain)
|
cells = tuple(tuple(spec.legend[ch] for ch in row) for row in spec.terrain)
|
||||||
|
|||||||
+283
-7
@@ -1,14 +1,18 @@
|
|||||||
"""V4: movement uses the best of land and fly speed.
|
"""V4: movement uses the best of land and fly speed + 3D flight over terrain.
|
||||||
|
|
||||||
The engine budgets every movement action (move, 5-foot step toward a target,
|
The engine budgets every movement action (move, 5-foot step toward a target,
|
||||||
charge at 2x, withdraw at 2x, run at 4x) from ``_speed_ft(state)`` — the
|
charge at 2x, withdraw at 2x, run at 4x) from ``_speed_ft(state)`` — the
|
||||||
maximum of the creature's ``speed_land_ft`` and ``speed_fly_ft`` (a missing
|
maximum of the creature's ``speed_land_ft`` and ``speed_fly_ft`` (a missing
|
||||||
fly speed counts as 0, so a land-only creature is unchanged). Simplification:
|
fly speed counts as 0, so a land-only creature is unchanged). A creature
|
||||||
a creature that has a fly speed is assumed to use it, but flying OVER
|
whose budget comes from its fly speed (fly >= land) is AIRBORNE
|
||||||
obstacles or difficult terrain is NOT modeled — the grid's passability and
|
(``_is_airborne``): it moves on ``Grid.airborne_view``, where ground difficult
|
||||||
terrain costs still apply to the whole budget. The observation snapshot adds
|
terrain costs 1 and cells impassable to walkers (pits, water, rubble) are
|
||||||
an additive ``speed_effective_ft`` key; ``speed_land_ft``/``speed_fly_ft`` are
|
crossable — SRD "Movement, Position, and Distance": "Flying and incorporeal
|
||||||
kept as-is.
|
creatures are not hampered by difficult terrain"; "Flying and incorporeal
|
||||||
|
creatures are able to avoid most obstacles". Cells that block line of effect
|
||||||
|
(``blocks_los`` — solid barriers, assumed full height) still stop flight and
|
||||||
|
still block attacks. The observation snapshot adds an additive
|
||||||
|
``speed_effective_ft`` key; ``speed_land_ft``/``speed_fly_ft`` are kept as-is.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -77,6 +81,45 @@ def _corridor(cols: int) -> Grid:
|
|||||||
return Grid.from_spec(spec)
|
return Grid.from_spec(spec)
|
||||||
|
|
||||||
|
|
||||||
|
_LEGEND = {
|
||||||
|
".": TerrainType(type="floor", move_cost=1),
|
||||||
|
"T": TerrainType(type="rubble", move_cost=2),
|
||||||
|
"P": TerrainType(type="pit", move_cost=None),
|
||||||
|
"#": TerrainType(type="wall", move_cost=None, blocks_los=True),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _terrain_grid(rows: tuple[str, ...]) -> Grid:
|
||||||
|
"""A grid from ASCII rows over the shared ``_LEGEND`` terrain types."""
|
||||||
|
spec = MapSpec(name="terrain", terrain=rows, legend=_LEGEND)
|
||||||
|
return Grid.from_spec(spec)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_move_on(
|
||||||
|
*, land: int, fly: int | None, destination: tuple[int, int], grid: Grid,
|
||||||
|
) -> CombatantState:
|
||||||
|
"""Hero acts first and takes one ``move`` to ``destination`` on ``grid``.
|
||||||
|
|
||||||
|
The foe idles far to the right (never threatens). Returns the hero state.
|
||||||
|
"""
|
||||||
|
cols = grid.width
|
||||||
|
hero = make_state(
|
||||||
|
_combatant("hero", land=land, fly=fly, initiative_mod=2), "players", (0, 0),
|
||||||
|
)
|
||||||
|
foe = make_state(_combatant("foe", land=30), "monsters", (0, cols - 1))
|
||||||
|
|
||||||
|
def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
|
||||||
|
if state.combatant.id == "hero":
|
||||||
|
return (Action(kind="move", destination=destination),)
|
||||||
|
return _WAIT
|
||||||
|
|
||||||
|
eng = CombatEngine(
|
||||||
|
ScriptedRng([10, 5]), grid, [hero, foe], policy=policy, round_cap=1,
|
||||||
|
)
|
||||||
|
eng.run()
|
||||||
|
return eng._states[0]
|
||||||
|
|
||||||
|
|
||||||
def _run_move(
|
def _run_move(
|
||||||
*, land: int, fly: int | None, destination: tuple[int, int], cols: int,
|
*, land: int, fly: int | None, destination: tuple[int, int], cols: int,
|
||||||
) -> CombatantState:
|
) -> CombatantState:
|
||||||
@@ -220,3 +263,236 @@ def test_fly_lower_than_land_uses_land() -> None:
|
|||||||
assert mover.pos == (0, 6)
|
assert mover.pos == (0, 6)
|
||||||
mover = _run_move(land=30, fly=10, destination=(0, 7), cols=12)
|
mover = _run_move(land=30, fly=10, destination=(0, 7), cols=12)
|
||||||
assert mover.pos == (0, 0)
|
assert mover.pos == (0, 0)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 3D flight over ground terrain (airborne creatures) ────────────────────────
|
||||||
|
#
|
||||||
|
# SRD "Movement, Position, and Distance" (Terrain and Obstacles): "Flying and
|
||||||
|
# incorporeal creatures are not hampered by difficult terrain" and "Flying and
|
||||||
|
# incorporeal creatures are able to avoid most obstacles." An airborne creature
|
||||||
|
# (fly >= land, ``_is_airborne``) moves on ``Grid.airborne_view``: ground
|
||||||
|
# difficult terrain costs 1 and cells impassable to walkers (pits) are
|
||||||
|
# crossable, EXCEPT cells that block line of effect (solid barriers, assumed
|
||||||
|
# full height) which still stop flight. Walkers are bit-identical. The foe sits
|
||||||
|
# two rows below the hero's lane so it never threatens the row being crossed.
|
||||||
|
|
||||||
|
_LEGEND = {
|
||||||
|
".": TerrainType(type="floor", move_cost=1),
|
||||||
|
"T": TerrainType(type="rubble", move_cost=2),
|
||||||
|
"P": TerrainType(type="pit", move_cost=None),
|
||||||
|
"#": TerrainType(type="wall", move_cost=None, blocks_los=True),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _lane_grid(lane: str) -> Grid:
|
||||||
|
"""A 3-row grid: the terrain ``lane`` on row 0, floor rows below."""
|
||||||
|
width = len(lane)
|
||||||
|
rows = (lane, "." * width, "." * width)
|
||||||
|
spec = MapSpec(name="lane", terrain=rows, legend=_LEGEND)
|
||||||
|
return Grid.from_spec(spec)
|
||||||
|
|
||||||
|
|
||||||
|
def _move_on_lane(*, land: int, fly: int | None, lane: str,
|
||||||
|
destination: tuple[int, int]) -> tuple[int, int]:
|
||||||
|
"""Hero (row 0, col 0) acts first and takes one ``move`` to ``destination``.
|
||||||
|
|
||||||
|
The foe idles at (2, 0), two rows below and never threatening. Returns the
|
||||||
|
hero's final position.
|
||||||
|
"""
|
||||||
|
hero = make_state(
|
||||||
|
_combatant("hero", land=land, fly=fly, initiative_mod=2), "players", (0, 0),
|
||||||
|
)
|
||||||
|
foe = make_state(_combatant("foe", land=30), "monsters", (2, 0))
|
||||||
|
|
||||||
|
def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
|
||||||
|
if state.combatant.id == "hero":
|
||||||
|
return (Action(kind="move", destination=destination),)
|
||||||
|
return _WAIT
|
||||||
|
|
||||||
|
eng = CombatEngine(
|
||||||
|
ScriptedRng([10, 5]), _lane_grid(lane), [hero, foe],
|
||||||
|
policy=policy, round_cap=1,
|
||||||
|
)
|
||||||
|
eng.run()
|
||||||
|
return eng._states[0].pos
|
||||||
|
|
||||||
|
|
||||||
|
def test_flyer_crosses_rubble_at_normal_cost() -> None:
|
||||||
|
# Rubble at cols 4-5: the flyer prices every cell at 1, so (0, 9) costs
|
||||||
|
# 9 cells and is within the 16-cell (fly 80) budget.
|
||||||
|
assert _move_on_lane(land=30, fly=80, lane="....TT....", destination=(0, 9)) == (0, 9)
|
||||||
|
|
||||||
|
|
||||||
|
def test_walker_still_pays_for_rubble() -> None:
|
||||||
|
# Same lane, same 10-cell span: a land-30 walker pays 3 + 2*2 + 4 = 11
|
||||||
|
# cells for (0, 9), beyond its 6-cell budget, so it is rejected in place.
|
||||||
|
assert _move_on_lane(land=30, fly=None, lane="....TT....", destination=(0, 9)) == (0, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_flyer_crosses_pit_impassable_to_walkers() -> None:
|
||||||
|
# A pit (move_cost None, no blocks_los) at col 3: the flyer avoids the
|
||||||
|
# obstacle and reaches (0, 7); the walker cannot enter the pit cell.
|
||||||
|
assert _move_on_lane(land=30, fly=80, lane="...P....", destination=(0, 7)) == (0, 7)
|
||||||
|
assert _move_on_lane(land=30, fly=None, lane="...P....", destination=(0, 7)) == (0, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_flyer_still_stopped_by_wall() -> None:
|
||||||
|
# A wall column (blocks_los) is a solid barrier assumed full height: it
|
||||||
|
# stops flight just like walking — no crossing and no detour.
|
||||||
|
hero = make_state(
|
||||||
|
_combatant("hero", land=30, fly=80, initiative_mod=2), "players", (0, 0),
|
||||||
|
)
|
||||||
|
foe = make_state(_combatant("foe", land=30), "monsters", (2, 7))
|
||||||
|
|
||||||
|
def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
|
||||||
|
if state.combatant.id == "hero":
|
||||||
|
return (Action(kind="move", destination=(0, 7)),)
|
||||||
|
return _WAIT
|
||||||
|
|
||||||
|
spec = MapSpec(
|
||||||
|
name="wall", terrain=("...#....", "...#....", "...#...."), legend=_LEGEND,
|
||||||
|
)
|
||||||
|
eng = CombatEngine(
|
||||||
|
ScriptedRng([10, 5]), Grid.from_spec(spec), [hero, foe],
|
||||||
|
policy=policy, round_cap=1,
|
||||||
|
)
|
||||||
|
eng.run()
|
||||||
|
assert eng._states[0].pos == (0, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_flyer_runs_over_rubble_walker_run_stops() -> None:
|
||||||
|
# Run is a straight line: an airborne runner is not hampered by the
|
||||||
|
# rubble; a walker's run stops in front of it (CRB "Difficult Terrain":
|
||||||
|
# "You can't run or charge across difficult terrain").
|
||||||
|
def _run(land: int, fly: int | None) -> tuple[int, int]:
|
||||||
|
hero = make_state(
|
||||||
|
_combatant("hero", land=land, fly=fly, initiative_mod=2), "players", (0, 0),
|
||||||
|
)
|
||||||
|
foe = make_state(_combatant("foe", land=30), "monsters", (2, 0))
|
||||||
|
|
||||||
|
def policy(_e: CombatEngine, s: CombatantState) -> tuple[Action, ...]:
|
||||||
|
if s.combatant.id == "hero":
|
||||||
|
return (Action(kind="run", destination=(0, 9)),)
|
||||||
|
return _WAIT
|
||||||
|
|
||||||
|
eng = CombatEngine(
|
||||||
|
ScriptedRng([10, 5]), _lane_grid("....TT...."),
|
||||||
|
[hero, foe], policy=policy, round_cap=1,
|
||||||
|
)
|
||||||
|
eng.run()
|
||||||
|
return eng._states[0].pos
|
||||||
|
|
||||||
|
assert _run(30, 80) == (0, 9)
|
||||||
|
# The walker's run destination lies beyond difficult terrain: the
|
||||||
|
# reachability verdict and the walked line agree, so the run is rejected
|
||||||
|
# outright (unreachable_destination) and the walker never starts.
|
||||||
|
assert _run(30, None) == (0, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_flyer_5foot_step_into_rubble() -> None:
|
||||||
|
# SRD "Take 5-Foot Step": the step is legal only if movement isn't
|
||||||
|
# hampered by difficult terrain — a flyer's isn't, a walker's is.
|
||||||
|
def _step(land: int, fly: int | None) -> tuple[int, int]:
|
||||||
|
hero = make_state(
|
||||||
|
_combatant("hero", land=land, fly=fly, initiative_mod=2), "players", (0, 3),
|
||||||
|
)
|
||||||
|
foe = make_state(_combatant("foe", land=30), "monsters", (2, 0))
|
||||||
|
|
||||||
|
def policy(_e: CombatEngine, s: CombatantState) -> tuple[Action, ...]:
|
||||||
|
if s.combatant.id == "hero":
|
||||||
|
return (Action(kind="5foot_step", destination=(0, 4)),)
|
||||||
|
return _WAIT
|
||||||
|
|
||||||
|
eng = CombatEngine(
|
||||||
|
ScriptedRng([10, 5]), _lane_grid("...T...."),
|
||||||
|
[hero, foe], policy=policy, round_cap=1,
|
||||||
|
)
|
||||||
|
eng.run()
|
||||||
|
return eng._states[0].pos
|
||||||
|
|
||||||
|
assert _step(30, 80) == (0, 4)
|
||||||
|
assert _step(30, None) == (0, 3)
|
||||||
|
|
||||||
|
|
||||||
|
def test_movement_cost_ft_uses_the_air_view() -> None:
|
||||||
|
# Cost from the hero's cell to the target's anchor: rubble prices at 1 for
|
||||||
|
# the flyer (8 cells = 40 ft) and at 2 for the walker (13 cells = 65 ft,
|
||||||
|
# beyond its 30 ft speed so None).
|
||||||
|
grid = _lane_grid("..TTTTT..")
|
||||||
|
flyer = make_state(_combatant("flyer", land=30, fly=80), "players", (0, 0))
|
||||||
|
walker = make_state(_combatant("walker", land=30), "players", (0, 0))
|
||||||
|
target_foe = make_state(_combatant("target", land=30), "monsters", (0, 8))
|
||||||
|
eng = CombatEngine(ScriptedRng([]), grid, [flyer, target_foe])
|
||||||
|
assert eng.movement_cost_ft(flyer, target_foe) == 40
|
||||||
|
target_walker = make_state(_combatant("target", land=30), "monsters", (0, 8))
|
||||||
|
eng2 = CombatEngine(ScriptedRng([]), grid, [walker, target_walker])
|
||||||
|
assert eng2.movement_cost_ft(walker, target_walker) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_fly_equal_to_land_is_airborne() -> None:
|
||||||
|
# fly >= land: the budget comes from the fly speed, so the creature flies
|
||||||
|
# (6-cell budget, rubble priced at 1, (0, 6) reachable).
|
||||||
|
assert _move_on_lane(land=30, fly=30, lane="....TT....", destination=(0, 6)) == (0, 6)
|
||||||
|
|
||||||
|
|
||||||
|
def test_fly_slower_than_land_walks_and_pays() -> None:
|
||||||
|
# fly < land: the creature walks (budget = land) and pays the rubble cost
|
||||||
|
# like any walker — (0, 9) costs 11 cells > the 6-cell budget.
|
||||||
|
assert _move_on_lane(land=30, fly=10, lane="....TT....", destination=(0, 9)) == (0, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_flyer_still_pays_elevation_climb() -> None:
|
||||||
|
# The elevation layer stores STEP heights, not wall heights, so the
|
||||||
|
# climbing cost still applies to flight (documented simplification): with
|
||||||
|
# a 2-cell budget (fly 10 = land 10) the step up onto the higher ledge
|
||||||
|
# costs 2 and only the flat neighbour is reachable.
|
||||||
|
legend = {".": TerrainType(type="floor", move_cost=1)}
|
||||||
|
spec = MapSpec(
|
||||||
|
name="ledge", terrain=("....", "...."), legend=legend,
|
||||||
|
elevation=("..1.", "...."),
|
||||||
|
)
|
||||||
|
grid = Grid.from_spec(spec)
|
||||||
|
|
||||||
|
def _go(destination: tuple[int, int]) -> tuple[int, int]:
|
||||||
|
hero = make_state(
|
||||||
|
_combatant("hero", land=10, fly=10, initiative_mod=2), "players", (0, 0),
|
||||||
|
)
|
||||||
|
foe = make_state(_combatant("foe", land=30), "monsters", (1, 3))
|
||||||
|
|
||||||
|
def policy(_e: CombatEngine, s: CombatantState) -> tuple[Action, ...]:
|
||||||
|
if s.combatant.id == "hero":
|
||||||
|
return (Action(kind="move", destination=destination),)
|
||||||
|
return _WAIT
|
||||||
|
|
||||||
|
eng = CombatEngine(
|
||||||
|
ScriptedRng([10, 5]), grid, [hero, foe], policy=policy, round_cap=1,
|
||||||
|
)
|
||||||
|
eng.run()
|
||||||
|
return eng._states[0].pos
|
||||||
|
|
||||||
|
assert _go((0, 2)) == (0, 0)
|
||||||
|
assert _go((0, 1)) == (0, 1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_airborne_view_preserves_combat_flags() -> None:
|
||||||
|
# The air view normalizes only the walking cost: solid barriers stay
|
||||||
|
# impassable (so LoE/cover geometry is untouched by flight), cover flags
|
||||||
|
# and the elevation layer are preserved.
|
||||||
|
legend = dict(_LEGEND)
|
||||||
|
legend["C"] = TerrainType(type="pillar", move_cost=None, cover=True)
|
||||||
|
spec = MapSpec(
|
||||||
|
name="flags", terrain=(".TP#C",), legend=legend, elevation=(".1...",),
|
||||||
|
)
|
||||||
|
grid = Grid.from_spec(spec)
|
||||||
|
view = grid.airborne_view()
|
||||||
|
wall = view.terrain_at((0, 3))
|
||||||
|
assert wall.blocks_los
|
||||||
|
assert wall.move_cost is None
|
||||||
|
pit = view.terrain_at((0, 2))
|
||||||
|
assert pit.move_cost == 1
|
||||||
|
assert not pit.blocks_los
|
||||||
|
assert view.terrain_at((0, 1)).move_cost == 1
|
||||||
|
pillar = view.terrain_at((0, 4))
|
||||||
|
assert pillar.move_cost == 1
|
||||||
|
assert pillar.cover
|
||||||
|
assert view.elevation_at((0, 1)) == grid.elevation_at((0, 1)) == 1
|
||||||
|
|||||||
Reference in New Issue
Block a user