class_name PathFinder extends RefCounted ## T-1088 (Live-session feedback item 2; D-248/D-053) — pure static 8-directional ## A* over the greybox KNOWN-tile store, feeding the mouse-over path preview ## (path_preview.gd) and the click-to-move follower (path_follower.gd). ## ## Headless-testable by construction: no scene, autoload, or GameState access — ## the caller injects tile knowledge as Callables, so the whole search is covered ## in test_path_finder.gd with synthetic stores. ## ## INFORMATION BOUNDARY (D-010): the character plans only through tiles it KNOWS ## to be walkable. `is_floor(tile)` returns true ONLY for known floor/door tiles; ## unknown/void/wall are impassable. Fog is unpathable — you cannot route the ## character through space it has never observed. This is honest to the sim's ## asymmetric-information model, not a UI convenience: the same never-evict store ## that dims remembered tiles (§3) is the pathing substrate. ## ## COST MODEL (D-248/D-053): uniform step cost for cardinal AND diagonal — the ## wire charges a diagonal the same movement cadence as a cardinal (no sqrt(2)), ## so the time-optimal path is the one with the FEWEST STEPS and diagonals are ## "free" length. Chebyshev distance (max(|dx|, |dy|)) is therefore the exact ## obstacle-free cost and an admissible + consistent A* heuristic. Per-tile entry ## cost is scaled by `terrain_cost(tile)` (default uniform 1.0) — the Phase-4 seam ## for mud/rubble/slope. terrain_cost is expected >= 1.0 (a difficulty multiplier); ## values below 1.0 still return a valid path but can defeat heuristic optimality. ## ## NO CORNER-CUTTING: a diagonal step is legal only when BOTH shared-edge cardinal ## neighbours are also passable — the character never slips through a wall corner. ## Neighbour offsets in a fixed order (cardinals first, then diagonals). The order ## is load-bearing for determinism: with the tie-break in _entry_less it fixes ## which of several equal-cost paths is returned. const _DIRS: Array[Vector2i] = [ Vector2i(1, 0), # East Vector2i(-1, 0), # West Vector2i(0, 1), # South (sim +y is South, Y-down — SandboxSpace §2) Vector2i(0, -1), # North Vector2i(1, 1), # Southeast Vector2i(1, -1), # Northeast Vector2i(-1, 1), # Southwest Vector2i(-1, -1), # Northwest ] ## Find the time-optimal path from `start` to `goal` over the known-tile substrate. ## Returns an Array[Vector3i] of tile coords INCLUDING both endpoints, or an empty ## array when: goal (or start) is not known-floor, or the goal is unreachable ## through known tiles. start == goal returns a one-element path (already there — ## the follower treats a path shorter than 2 as "nothing to walk"). ## ## is_floor: (Vector3i) -> bool — true only for known floor/door tiles. ## terrain_cost: (Vector3i) -> float — per-tile entry cost (default uniform 1.0). static func find_path( start: Vector3i, goal: Vector3i, is_floor: Callable, terrain_cost: Callable = Callable() ) -> Array[Vector3i]: var result: Array[Vector3i] = [] if not _passable(start, is_floor) or not _passable(goal, is_floor): return result if start == goal: result.append(start) return result var open_heap: Array[Dictionary] = [] var g_score: Dictionary = {} # Vector3i -> float (best known cost from start) var came_from: Dictionary = {} # Vector3i -> Vector3i var closed: Dictionary = {} # Vector3i -> true (expanded — never re-opened) var seq := 0 # monotonic insertion counter — the final determinism tiebreak g_score[start] = 0.0 var h0 := _heuristic(start, goal) _heap_push(open_heap, {"pos": start, "f": h0, "h": h0, "seq": seq}) seq += 1 while not open_heap.is_empty(): var current := _heap_pop(open_heap) var cpos: Vector3i = current["pos"] if cpos == goal: return _reconstruct(came_from, goal, start) if closed.has(cpos): continue # stale heap entry (lazy deletion) — already expanded closed[cpos] = true var cg: float = g_score[cpos] for dir in _DIRS: var npos := Vector3i(cpos.x + dir.x, cpos.y + dir.y, cpos.z) if closed.has(npos): continue if not _passable(npos, is_floor): continue # No corner-cutting: a diagonal needs BOTH shared cardinals passable. if dir.x != 0 and dir.y != 0: if not _passable(Vector3i(cpos.x + dir.x, cpos.y, cpos.z), is_floor): continue if not _passable(Vector3i(cpos.x, cpos.y + dir.y, cpos.z), is_floor): continue var tentative_g: float = cg + _cost(npos, terrain_cost) if tentative_g < float(g_score.get(npos, INF)): came_from[npos] = cpos g_score[npos] = tentative_g var h := _heuristic(npos, goal) _heap_push(open_heap, {"pos": npos, "f": tentative_g + h, "h": h, "seq": seq}) seq += 1 return result # open set drained — goal unreachable through known tiles # --------------------------------------------------------------------------- # Pure helpers # --------------------------------------------------------------------------- ## Passable iff the lookup is installed AND reports the tile as known-floor. ## An unset/invalid Callable makes everything impassable (fail-closed — never ## route through space with no knowledge source). static func _passable(tile: Vector3i, is_floor: Callable) -> bool: if not is_floor.is_valid(): return false return bool(is_floor.call(tile)) ## Per-tile entry cost. Default 1.0 (uniform, D-248); the terrain provider raises ## it for difficult terrain in Phase 4. Non-positive returns fall back to 1.0 so a ## misbehaving provider can never zero out or invert step cost (Dijkstra safety). static func _cost(tile: Vector3i, terrain_cost: Callable) -> float: if terrain_cost.is_valid(): var c := float(terrain_cost.call(tile)) if c > 0.0: return c return 1.0 ## Chebyshev distance — exact obstacle-free cost under the uniform 8-dir step cost ## (diagonal == cardinal, D-248); admissible + consistent when terrain_cost >= 1. static func _heuristic(a: Vector3i, b: Vector3i) -> float: return float(maxi(absi(a.x - b.x), absi(a.y - b.y))) ## Walk came_from back from goal to start and reverse — path includes both ends. static func _reconstruct(came_from: Dictionary, goal: Vector3i, start: Vector3i) -> Array[Vector3i]: var path: Array[Vector3i] = [goal] var cur := goal while cur != start: cur = came_from[cur] path.append(cur) path.reverse() return path # --------------------------------------------------------------------------- # Binary min-heap (open set). GDScript has no priority queue; this keeps A* # expansion O(log n) and, with _entry_less, fully deterministic. # --------------------------------------------------------------------------- ## Total order on frontier entries: lowest f first; ties broken toward the goal ## (lower h — the standard A* tiebreak that also speeds convergence); remaining ## ties broken by insertion order (seq) so the result never depends on Dictionary ## hash iteration order. static func _entry_less(a: Dictionary, b: Dictionary) -> bool: if a["f"] != b["f"]: return a["f"] < b["f"] if a["h"] != b["h"]: return a["h"] < b["h"] return a["seq"] < b["seq"] static func _heap_push(heap: Array[Dictionary], entry: Dictionary) -> void: heap.append(entry) var i := heap.size() - 1 while i > 0: var parent := (i - 1) >> 1 if not _entry_less(heap[i], heap[parent]): break var tmp: Dictionary = heap[parent] heap[parent] = heap[i] heap[i] = tmp i = parent static func _heap_pop(heap: Array[Dictionary]) -> Dictionary: var top: Dictionary = heap[0] var last: Dictionary = heap.pop_back() if heap.is_empty(): return top heap[0] = last var n := heap.size() var i := 0 while true: var smallest := i var l := 2 * i + 1 var r := 2 * i + 2 if l < n and _entry_less(heap[l], heap[smallest]): smallest = l if r < n and _entry_less(heap[r], heap[smallest]): smallest = r if smallest == i: break var tmp: Dictionary = heap[i] heap[i] = heap[smallest] heap[smallest] = tmp i = smallest return top