diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index d59c495..6c71621 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -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 the true shortest path (Dijkstra cost field from the target, 5-10-5 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 - obstacles or difficult terrain is NOT modeled (grid costs still apply). + The speed budget is the best of land and fly speed (V4). A creature whose + 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 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 @@ -774,15 +779,35 @@ def _melee_reach_zone_cells(state: CombatantState, weapon: AttackSpec) -> tuple[ def _speed_ft(state: CombatantState) -> int: """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; - flying OVER obstacles or difficult terrain is NOT modeled — the grid's - passability and terrain costs still apply to the whole budget. No - fly maneuverability, no 3D flight. Swim/climb/burrow speeds do not exist - in the model (fly only). + Simplification (V4): a creature that has a fly speed is assumed to use it + when its fly speed is at least its land speed (see ``_is_airborne``). The + budget itself is land-or-fly; an airborne creature additionally moves over + ground terrain at cost 1 (``Grid.airborne_view``). No fly maneuverability, + 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) +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]: """Integer Bresenham line from start to end (exclusive of start, inclusive of end).""" r0, c0 = start @@ -1598,6 +1623,7 @@ class CombatEngine: raise ValueError(msg) self._rng = rng self._grid = grid + self._airborne_grid = grid.airborne_view() self._states = states self._round_cap = round_cap if llm_policy is not None: @@ -3022,11 +3048,25 @@ class CombatEngine: """Height of the creature's anchor cell (0 when no elevation layer).""" 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: """True if every cell is in bounds, passable, and unoccupied by others.""" blocked = self._blocked(mover) + grid = self._movement_grid(mover) 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: @@ -3037,8 +3077,9 @@ class CombatEngine: though the movement may not END there (``_cells_free`` gates that). """ hostile = self._blocked_hostile(mover) + grid = self._movement_grid(mover) 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]: @@ -4420,16 +4461,18 @@ class CombatEngine: (API publique, lecture pour politique LLM.) Same cost model as ``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) + grid = self._movement_grid(state) if budget is None: - field = self._grid.reachable(state.pos, None, blocked) + field = grid.reachable(state.pos, None, blocked) else: budget_cells = budget // _SQUARE_FT if budget_cells < 1: 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()} 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 active states' cells blocked), then the cheapest field value over ``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) - 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) if cost_cells is None: return None @@ -4840,12 +4884,12 @@ class CombatEngine: return ChargeResult(weapon=weapon, distance_ft=distance_ft, reason=None) return ChargeResult(weapon=None, distance_ft=0, reason="no_charge_path") - def _path_cost_5105(self, path: list[Pos]) -> int: - """Total 5-10-5 movement cost of a path (diagonal parity threaded).""" + def _path_cost_5105(self, path: list[Pos], grid: Grid) -> int: + """Total 5-10-5 movement cost of a path on ``grid`` (parity threaded).""" total = 0 diagonals_used = 0 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]: diagonals_used += 1 return total @@ -4869,8 +4913,11 @@ class CombatEngine: Ties break toward lower distance from state. """ target_cells = target.occupied_cells() + grid = self._movement_grid(state) 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) if not self._cells_free(footprint, state): continue @@ -4888,7 +4935,7 @@ class CombatEngine: continue # The charge budget is a movement budget: count it in 5-10-5 # 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 candidates.append((len(path), end)) if not candidates: @@ -4902,8 +4949,13 @@ class CombatEngine: reach_cells: int, blocked: frozenset[Pos], start: Pos, + grid: Grid, ) -> 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() for tr, tc in target.occupied_cells(): for dr in range(-reach_cells, reach_cells + 1): @@ -4915,7 +4967,7 @@ class CombatEngine: end = (tr + dr, tc + dc) if end == start: 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 if end in blocked: continue @@ -4939,19 +4991,24 @@ class CombatEngine: below. Difficult terrain is tested over the WHOLE footprint, not just the anchor cell (SRD "Movement, Position and Distance": "You 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 for pos in path: footprint = self._footprint_at(pos, state) if not self._cells_free(footprint, state): return False - if self._footprint_touches_difficult(footprint): + if self._footprint_touches_difficult(state, footprint): return False if ( prev[0] != pos[0] and prev[1] != pos[1] - and not self._grid.diagonal_allowed(prev, pos) + and not grid.diagonal_allowed(prev, pos) ): return False prev = pos @@ -9349,12 +9406,15 @@ class CombatEngine: # 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 # 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)") return path = self._move_path(state, target) 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)") return state.pos = path[0] @@ -9376,16 +9436,19 @@ class CombatEngine: 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) + grid = self._movement_grid(state) # PF1e (SRD Measuring Distance): "You can't move diagonally past a # corner (even by taking a 5-foot step)." 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 # 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 ( - self._footprint_touches_difficult(self._footprint_at(state.pos, state)) - or self._footprint_touches_difficult(self._footprint_at(destination, state)) + self._footprint_touches_difficult(state, self._footprint_at(state.pos, 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: self._log( @@ -9497,22 +9560,23 @@ class CombatEngine: if not self._cells_free(self._footprint_at(destination, state), state): return False if avoid_difficult and self._footprint_touches_difficult( - self._footprint_at(destination, state) + state, self._footprint_at(destination, state) ): return False blocked = self._blocked_hostile(state) if friendly_passable else self._blocked(state) + grid = self._movement_grid(state) if avoid_difficult: - blocked = blocked | self._difficult_cells() - field = self._grid.reachable(state.pos, speed_cells, blocked) + blocked = blocked | self._difficult_cells(grid) + field = grid.reachable(state.pos, speed_cells, blocked) return destination in field - def _difficult_cells(self) -> frozenset[Pos]: - """Every passable cell on the grid whose terrain costs more than one.""" + def _difficult_cells(self, grid: Grid) -> frozenset[Pos]: + """Every passable cell of ``grid`` whose terrain costs more than one.""" return frozenset( (row, col) - for row in range(self._grid.height) - for col in range(self._grid.width) - if self._is_difficult_cell((row, col)) + for row in range(grid.height) + for col in range(grid.width) + if self._is_difficult_cell(grid, (row, col)) ) def _walk_steps( @@ -9595,7 +9659,7 @@ class CombatEngine: if speed_cells <= 0: return [] 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: return [] path: list[Pos] = [] @@ -9640,9 +9704,13 @@ class CombatEngine: so an auto-directed run ends adjacent to the target — except one the mover may pass through (three size categories apart, SRD "Moving Through a Square"), which is crossed with an entry AoO and never - ended on. Friendly squares - are still crossed (SRD Movement, conformance L5) but never ended on — - the tail is trimmed like ``_descend_path``. + ended on. Friendly squares + are still crossed (SRD Movement, conformance L5) but never ended on — + 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) budget = int( @@ -9650,6 +9718,7 @@ class CombatEngine: ) if budget <= 0: return [] + grid = self._movement_grid(state) path: list[Pos] = [] current = state.pos diagonals_used = 0 @@ -9661,15 +9730,15 @@ class CombatEngine: if ( current[0] != nxt[0] and current[1] != nxt[1] - and not self._grid.diagonal_allowed(current, nxt) + and not grid.diagonal_allowed(current, nxt) ): break footprint = self._footprint_at(nxt, state) - if self._footprint_touches_difficult(footprint): + if self._footprint_touches_difficult(state, footprint): break if not self._cells_walkable(footprint, state): break - step = self._grid.step_cost(current, nxt, diagonals_used) + step = grid.step_cost(current, nxt, diagonals_used) if step > budget: break path.append(nxt) @@ -9698,6 +9767,7 @@ class CombatEngine: return None best: tuple[int, int, Pos] | None = None row, col = current + grid = self._movement_grid(state) fits = self._cells_walkable if friendly_passable else self._cells_free for d_row, d_col in _STEP_DELTAS: 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 # explicit step (``_step5_to``) and flee (``_best_flee_step``) # paths already enforce. - if not self._grid.diagonal_allowed(current, nxt): + if not grid.diagonal_allowed(current, nxt): continue footprint = self._footprint_at(nxt, state) if not fits(footprint, state): continue - if avoid_difficult and self._footprint_touches_difficult(footprint): + if avoid_difficult and self._footprint_touches_difficult(state, footprint): continue nxt_cost = self._footprint_cost(footprint, to_target) if nxt_cost is None or nxt_cost >= current_cost: @@ -9766,9 +9836,17 @@ class CombatEngine: frontier.append((nxt, cost)) return False - def _footprint_touches_difficult(self, footprint: frozenset[Pos]) -> bool: - """True if any footprint cell is difficult terrain (move cost > 1).""" - return any(self._is_difficult_cell(cell) for cell in footprint) + def _footprint_touches_difficult( + self, state: CombatantState, footprint: frozenset[Pos] + ) -> 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( 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 1x1 creature the footprint is the anchor cell, so this is exactly ``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) hardest = 1 for cell in footprint: - if not self._grid.in_bounds(cell): + if not grid.in_bounds(cell): continue - cost = self._grid.terrain_at(cell).move_cost + cost = grid.terrain_at(cell).move_cost if cost is not None and cost > hardest: 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: - """True for a passable cell whose terrain costs more than one square.""" - if not self._grid.in_bounds(pos): + def _is_difficult_cell(self, grid: Grid, pos: Pos) -> bool: + """True for a passable cell of ``grid`` whose terrain costs more than one.""" + if not grid.in_bounds(pos): 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 def _flee_path(self, state: CombatantState, threat: CombatantState) -> list[Pos]: @@ -9819,7 +9901,7 @@ class CombatEngine: if max_cells <= 0: return [] 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: return [] path: list[Pos] = [] @@ -9854,12 +9936,13 @@ class CombatEngine: return None best: tuple[int, int, Pos] | None = None row, col = current + grid = self._movement_grid(state) for d_row, d_col in _STEP_DELTAS: nxt = (row + d_row, col + d_col) footprint = self._footprint_at(nxt, state) if not self._cells_free(footprint, state): continue - if not self._grid.diagonal_allowed(current, nxt): + if not grid.diagonal_allowed(current, nxt): continue nxt_cost = self._footprint_cost(footprint, from_threat) if nxt_cost is None or nxt_cost <= current_cost: diff --git a/src/pf1e_simulator/grid.py b/src/pf1e_simulator/grid.py index 7cfb055..a01a31a 100644 --- a/src/pf1e_simulator/grid.py +++ b/src/pf1e_simulator/grid.py @@ -18,6 +18,7 @@ if TYPE_CHECKING: from pf1e_simulator.map import MapSpec, Pos, TerrainType _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, ...] = ( (-1, -1), @@ -48,6 +49,41 @@ class Grid: self._width = len(cells[0]) if cells else 0 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 def from_spec(cls, spec: MapSpec) -> Grid: cells = tuple(tuple(spec.legend[ch] for ch in row) for row in spec.terrain) diff --git a/tests/test_fly_speed.py b/tests/test_fly_speed.py index 9f48ba5..eba614d 100644 --- a/tests/test_fly_speed.py +++ b/tests/test_fly_speed.py @@ -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, 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 -fly speed counts as 0, so a land-only creature is unchanged). Simplification: -a creature that has a fly speed is assumed to use it, but flying OVER -obstacles or difficult terrain is NOT modeled — the grid's passability and -terrain costs still apply to the whole budget. The observation snapshot adds -an additive ``speed_effective_ft`` key; ``speed_land_ft``/``speed_fly_ft`` are -kept as-is. +fly speed counts as 0, so a land-only creature is unchanged). A creature +whose budget comes from its fly speed (fly >= land) is AIRBORNE +(``_is_airborne``): it moves on ``Grid.airborne_view``, where ground difficult +terrain costs 1 and cells impassable to walkers (pits, water, rubble) are +crossable — 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". 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 @@ -77,6 +81,45 @@ def _corridor(cols: int) -> Grid: 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( *, land: int, fly: int | None, destination: tuple[int, int], cols: int, ) -> CombatantState: @@ -220,3 +263,236 @@ def test_fly_lower_than_land_uses_land() -> None: assert mover.pos == (0, 6) mover = _run_move(land=30, fly=10, destination=(0, 7), cols=12) 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