Hover marker + optimal path line over the KNOWN tile store only — the character plans through what they know; fog is unpathable (info boundary at the planning layer; the follower additionally revalidates every remaining tile per step). Pure static 8-dir A*: uniform cost 1 incl. diagonals (D-248 time-optimal, no sqrt2), no corner-cutting, terrain-cost provider seam for Phase-4 terrain. Execution streams ordinary Move* steps through the existing throttle — zero protocol change, server validates every step. RMB vocabulary (live-session spec): click = walk there at current stance; double-click = sprint there (ToggleStanceUp burst — server toggle handler verified cooldown-free so bursts climb deterministically — with net-zero restore on arrival; a double upgrades the active follow in place); long-press >=400ms = go there then Crouch on arrival, no restore (input- vocabulary prototype; real cover mechanics are future combat design). Cancellation: WASD override (stance kept), invalidation, teleport, suppression. Two additive InputMapper seams (queue_move_step, queue_stance_toggle); mouse unproject shared with the facing provider (ground_hit_local). 44 new gdUnit tests across finder + follower ladders. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
174 lines
7.2 KiB
GDScript
174 lines
7.2 KiB
GDScript
## PathFinder A* (T-1088 Live-session feedback item 2): 8-directional search over
|
|
## the greybox KNOWN-tile store, exercised on synthetic floor sets. Covers the
|
|
## uniform cardinal/diagonal cost (no sqrt(2), D-248), no-corner-cutting, the
|
|
## information boundary (unknown tiles impassable), the terrain-cost seam,
|
|
## unreachable-returns-empty, and deterministic tie-breaking.
|
|
##
|
|
## Pure static: no scene, autoload, or GameState — floor knowledge is injected as
|
|
## a Callable, so the whole search is headless (design §10.4).
|
|
class_name TestPathFinder
|
|
extends GdUnitTestSuite
|
|
|
|
|
|
## A floor lookup over an explicit set of known-floor tiles — everything else is
|
|
## impassable (unknown/void/wall), mirroring GreyboxWorld.Store.kind_of.
|
|
func _lookup(floors: Array) -> Callable:
|
|
var known := {}
|
|
for t: Vector3i in floors:
|
|
known[t] = true
|
|
return func(tile: Vector3i) -> bool: return known.has(tile)
|
|
|
|
|
|
## Every floor tile in an inclusive rectangle (z=0).
|
|
func _rect(x0: int, x1: int, y0: int, y1: int) -> Array:
|
|
var out: Array = []
|
|
for x in range(x0, x1 + 1):
|
|
for y in range(y0, y1 + 1):
|
|
out.append(Vector3i(x, y, 0))
|
|
return out
|
|
|
|
|
|
## True if every consecutive pair in the path differs by a king-move (adjacent
|
|
## incl. diagonal) — a well-formed contiguous path.
|
|
func _is_contiguous(path: Array[Vector3i]) -> bool:
|
|
for i in range(1, path.size()):
|
|
var d: Vector3i = path[i] - path[i - 1]
|
|
if absi(d.x) > 1 or absi(d.y) > 1 or d.z != 0 or d == Vector3i.ZERO:
|
|
return false
|
|
return true
|
|
|
|
|
|
# -- shortest path: straight (cardinal) ---------------------------------------------
|
|
|
|
|
|
func test_straight_cardinal_shortest() -> void:
|
|
var lookup := _lookup(_rect(0, 5, 0, 0)) # one row, y=0
|
|
var path := PathFinder.find_path(Vector3i(0, 0, 0), Vector3i(5, 0, 0), lookup)
|
|
# 5 steps east -> 6 tiles, endpoints inclusive, contiguous.
|
|
assert_int(path.size()).is_equal(6)
|
|
assert_object(path[0]).is_equal(Vector3i(0, 0, 0))
|
|
assert_object(path[path.size() - 1]).is_equal(Vector3i(5, 0, 0))
|
|
assert_bool(_is_contiguous(path)).is_true()
|
|
|
|
|
|
# -- shortest path: diagonal (uniform cost — no sqrt(2), D-248) -----------------------
|
|
|
|
|
|
func test_diagonal_shortest_beats_cardinal() -> void:
|
|
var lookup := _lookup(_rect(0, 3, 0, 3))
|
|
var path := PathFinder.find_path(Vector3i(0, 0, 0), Vector3i(3, 3, 0), lookup)
|
|
# Chebyshev distance 3 -> 4 tiles all-diagonal; a diagonal costs the same as a
|
|
# cardinal, so 3 diagonal steps (cost 3) beat 6 cardinal steps (cost 6).
|
|
assert_int(path.size()).is_equal(4)
|
|
assert_bool(_is_contiguous(path)).is_true()
|
|
# Every leg is a true diagonal.
|
|
for i in range(1, path.size()):
|
|
var d: Vector3i = path[i] - path[i - 1]
|
|
assert_int(absi(d.x)).is_equal(1)
|
|
assert_int(absi(d.y)).is_equal(1)
|
|
|
|
|
|
# -- no corner-cutting ---------------------------------------------------------------
|
|
|
|
|
|
func test_no_corner_cut_blocks_bare_diagonal() -> void:
|
|
# Only the two diagonal-opposite tiles are floor; both shared cardinals are
|
|
# missing, so the diagonal is illegal and there is no other route.
|
|
var lookup := _lookup([Vector3i(0, 0, 0), Vector3i(1, 1, 0)])
|
|
var path := PathFinder.find_path(Vector3i(0, 0, 0), Vector3i(1, 1, 0), lookup)
|
|
assert_array(path).is_empty()
|
|
|
|
|
|
func test_no_corner_cut_takes_legal_l_route() -> void:
|
|
# One shared cardinal present -> the bare diagonal is still illegal (needs
|
|
# BOTH), but an L via the open cardinal is legal: 2 cardinal steps.
|
|
var lookup := _lookup([Vector3i(0, 0, 0), Vector3i(1, 0, 0), Vector3i(1, 1, 0)])
|
|
var path := PathFinder.find_path(Vector3i(0, 0, 0), Vector3i(1, 1, 0), lookup)
|
|
assert_array(path).is_equal(
|
|
[Vector3i(0, 0, 0), Vector3i(1, 0, 0), Vector3i(1, 1, 0)] as Array[Vector3i]
|
|
)
|
|
|
|
|
|
func test_diagonal_allowed_when_both_cardinals_open() -> void:
|
|
# Full 2x2 -> the diagonal is legal and cheaper than the L, so it wins.
|
|
var lookup := _lookup(_rect(0, 1, 0, 1))
|
|
var path := PathFinder.find_path(Vector3i(0, 0, 0), Vector3i(1, 1, 0), lookup)
|
|
assert_array(path).is_equal([Vector3i(0, 0, 0), Vector3i(1, 1, 0)] as Array[Vector3i])
|
|
|
|
|
|
# -- information boundary: unknown tiles impassable (fog is unpathable) ----------------
|
|
|
|
|
|
func test_unknown_tile_blocks_route() -> void:
|
|
# A gap in the row (tile (2,0) never observed) severs the only path.
|
|
var lookup := _lookup([Vector3i(0, 0, 0), Vector3i(1, 0, 0), Vector3i(3, 0, 0)])
|
|
var path := PathFinder.find_path(Vector3i(0, 0, 0), Vector3i(3, 0, 0), lookup)
|
|
assert_array(path).is_empty()
|
|
|
|
|
|
func test_goal_not_floor_returns_empty() -> void:
|
|
# Hovering an unknown/void/wall tile: no path, ever.
|
|
var lookup := _lookup(_rect(0, 3, 0, 0))
|
|
var path := PathFinder.find_path(Vector3i(0, 0, 0), Vector3i(9, 9, 0), lookup)
|
|
assert_array(path).is_empty()
|
|
|
|
|
|
func test_start_equals_goal_is_single_tile() -> void:
|
|
var lookup := _lookup([Vector3i(5, 5, 0)])
|
|
var path := PathFinder.find_path(Vector3i(5, 5, 0), Vector3i(5, 5, 0), lookup)
|
|
assert_array(path).is_equal([Vector3i(5, 5, 0)] as Array[Vector3i])
|
|
|
|
|
|
# -- terrain-cost seam ----------------------------------------------------------------
|
|
|
|
|
|
func test_terrain_cost_forces_detour() -> void:
|
|
# 3x3 open. Straight (0,1)->(1,1)->(2,1) is 2 steps, but (1,1) costs 100, so
|
|
# A* detours through the cheap corner (1,0) instead — proving the seam steers
|
|
# the search without touching passability.
|
|
var lookup := _lookup(_rect(0, 2, 0, 2))
|
|
var costly := Vector3i(1, 1, 0)
|
|
var terrain := func(tile: Vector3i) -> float: return 100.0 if tile == costly else 1.0
|
|
var path := PathFinder.find_path(Vector3i(0, 1, 0), Vector3i(2, 1, 0), lookup, terrain)
|
|
assert_bool(path.has(costly)).is_false()
|
|
assert_int(path.size()).is_equal(3)
|
|
assert_bool(_is_contiguous(path)).is_true()
|
|
|
|
|
|
func test_uniform_default_takes_straight_line() -> void:
|
|
# No terrain provider -> uniform cost 1: the same query goes straight through
|
|
# the middle (the contrast case for the detour above).
|
|
var lookup := _lookup(_rect(0, 2, 0, 2))
|
|
var path := PathFinder.find_path(Vector3i(0, 1, 0), Vector3i(2, 1, 0), lookup)
|
|
assert_array(path).is_equal(
|
|
[Vector3i(0, 1, 0), Vector3i(1, 1, 0), Vector3i(2, 1, 0)] as Array[Vector3i]
|
|
)
|
|
|
|
|
|
# -- unreachable ----------------------------------------------------------------------
|
|
|
|
|
|
func test_unreachable_returns_empty() -> void:
|
|
# Goal is known-floor but on a disconnected island — the open set drains.
|
|
var lookup := _lookup([Vector3i(0, 0, 0), Vector3i(1, 0, 0), Vector3i(9, 9, 0)])
|
|
var path := PathFinder.find_path(Vector3i(0, 0, 0), Vector3i(9, 9, 0), lookup)
|
|
assert_array(path).is_empty()
|
|
|
|
|
|
# -- determinism ----------------------------------------------------------------------
|
|
|
|
|
|
func test_tie_break_is_deterministic() -> void:
|
|
# 3x3 open, (0,0)->(2,0): the straight cardinal route and the via-(1,1) route
|
|
# both cost 2 (uniform diagonal). The fixed neighbour order + (f, h, seq)
|
|
# tiebreak must return the SAME path every call, never Dictionary hash order.
|
|
var lookup := _lookup(_rect(0, 2, 0, 2))
|
|
var a := PathFinder.find_path(Vector3i(0, 0, 0), Vector3i(2, 0, 0), lookup)
|
|
var b := PathFinder.find_path(Vector3i(0, 0, 0), Vector3i(2, 0, 0), lookup)
|
|
assert_array(a).is_equal(b)
|
|
# And it is a valid shortest path (3 tiles, contiguous, correct endpoints).
|
|
assert_int(a.size()).is_equal(3)
|
|
assert_object(a[0]).is_equal(Vector3i(0, 0, 0))
|
|
assert_object(a[a.size() - 1]).is_equal(Vector3i(2, 0, 0))
|
|
assert_bool(_is_contiguous(a)).is_true()
|