get_unit_range
Retrieve a unit's full threat zone, including reachable movement tiles and attack tiles from any position. Use to plan positioning or evaluate enemy threat coverage for a specific unit.
Instructions
Read-only. Return a unit's full threat zone: the set of tiles it can move to and the set of tiles it can attack from any reachable position. Works for any alive unit, own or enemy. unit_id is the string identifier from get_state (e.g. 'red_cavalry_2'). Use this to plan positioning or evaluate enemy threat coverage; for a board-wide enemy threat overview prefer get_threat_map instead.
Input Schema
| Name | Required | Description | Default |
|---|---|---|---|
| connection_id | Yes | ||
| unit_id | Yes |
Implementation Reference
- The main handler function for get_unit_range. Computes move_tiles (BFS reachable positions) and attack_tiles (tiles attackable from any reachable position). Handles fog-of-war checks so enemy units hidden by fog are not queryable.
def get_unit_range(session: Session, viewer: Team, unit_id: str) -> dict: """Return the full threat zone for a unit: tiles it can move to (BFS reachable set) AND tiles it can attack from any reachable position (the outer threat ring). Read-only, works for ANY alive unit (own or enemy), no turn-ownership check. Units with status DONE return empty sets (they can't act this turn -- nothing to show). """ from ..engine.board import reachable_tiles, tiles_in_attack_range state = session.state u = state.units.get(unit_id) if u is None or not u.alive: raise ToolError(f"unit {unit_id} not found (dead, nonexistent, or hidden by fog)") # Fog check: enemy units hidden by fog must not be queryable. if u.owner != viewer: visible_enemies = _visible_enemies(session, viewer) if u not in visible_enemies: raise ToolError(f"unit {unit_id} not found (dead, nonexistent, or hidden by fog)") # Always show full hypothetical range from the unit's current # tile, regardless of status (ready/moved/done). This is a # visualization aid — "what could this unit reach?" — not tied # to whether it can actually act this turn. reach = reachable_tiles(state, u) move_set = set(reach.keys()) move_tiles = [{"x": p.x, "y": p.y} for p in sorted(move_set, key=lambda p: (p.y, p.x))] # Attack range -- expand each reachable tile by the unit's # attack range, subtract the move set itself. This is the # "outer ring" of threat: tiles the unit can hit if it moves # optimally first. Current position is included in reach so # standing attacks are covered. attack_set: set[Pos] = set() for p in move_set: for t in tiles_in_attack_range(p, u.stats, state.board): if t not in move_set: attack_set.add(t) attack_tiles = [{"x": p.x, "y": p.y} for p in sorted(attack_set, key=lambda p: (p.y, p.x))] return { "unit_id": unit_id, "move_tiles": move_tiles, "attack_tiles": attack_tiles, } - src/silicon_pantheon/server/tools/__init__.py:63-76 (registration)Registration of get_unit_range in TOOL_REGISTRY with description and input_schema (requires unit_id string).
"get_unit_range": { "fn": get_unit_range, "description": ( "Full threat zone for a unit: tiles it can move to (BFS " "reachable) + tiles it can attack from any reachable " "position (the outer threat ring). Works for any alive " "unit, own or enemy. Units with status=done return empty." ), "input_schema": { "type": "object", "properties": {"unit_id": {"type": "string"}}, "required": ["unit_id"], }, }, - src/silicon_pantheon/server/tools/__init__.py:27-33 (registration)Import of get_unit_range from read_only module into the tools package for re-export.
get_unit_range, get_legal_actions, simulate_attack, get_threat_map, get_tactical_summary, get_history, ) - reachable_tiles helper – performs Dijkstra/BFS to find all tiles a unit can reach within its move budget, respecting terrain, enemy blocking, and ally blocking.
def reachable_tiles(state: GameState, unit: Unit) -> dict[Pos, int]: """Return {destination: cost} for every tile reachable within `unit.stats.move`. Rules: - Cannot cross tiles occupied by enemy units. - Can pass through tiles occupied by allied units but cannot end there. - Cannot enter terrain the unit's class forbids. - Unit's current tile is included with cost 0. """ board = state.board stats = unit.stats start = unit.pos # Dijkstra: each tile's cost is paid on entry (so starting tile is 0). dist: dict[Pos, int] = {start: 0} pq: list[tuple[int, int, int]] = [(0, start.x, start.y)] while pq: d, x, y = heapq.heappop(pq) p = Pos(x, y) if d > dist[p]: continue for n in p.neighbors4(): if not board.in_bounds(n): continue tile = board.tile(n) if not can_enter(stats, tile, unit.class_): continue occupant = state.unit_at(n) if occupant is not None and occupant.owner is not unit.owner: # cannot pass through enemies continue step = tile.move_cost(unit.class_) nd = d + step if nd > stats.move: continue if nd < dist.get(n, 10**9): dist[n] = nd heapq.heappush(pq, (nd, n.x, n.y)) # Filter out tiles blocked by allies (cannot end there), keep starting tile. result: dict[Pos, int] = {} for p, d in dist.items(): if p == start: result[p] = d continue occupant = state.unit_at(p) if occupant is not None: continue # ally or enemy — can't end here result[p] = d return result - tiles_in_attack_range helper – returns all in-bounds tiles at Manhattan distance within the unit's attack range (rng_min to rng_max) from a given position.
def tiles_in_attack_range(pos: Pos, stats: UnitStats, board: Board) -> list[Pos]: """All in-bounds tiles at Manhattan distance within the unit's attack range.""" out: list[Pos] = [] for dx in range(-stats.rng_max, stats.rng_max + 1): for dy in range(-stats.rng_max, stats.rng_max + 1): d = abs(dx) + abs(dy) if d < stats.rng_min or d > stats.rng_max: continue p = Pos(pos.x + dx, pos.y + dy) if board.in_bounds(p): out.append(p) return out