From 49c89949433eab3488030ba02eeb8c68801c3955 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Mon, 6 Jul 2026 19:52:03 +0200 Subject: [PATCH] feat(client): move-here path preview + RMB gesture vocabulary (T-1088, Q-084) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- client/scripts/autoloads/input_mapper.gd | 44 ++++ client/scripts/sandbox/locomotion_sandbox.gd | 110 +++++++++ client/scripts/sandbox/mouse_aim_provider.gd | 34 ++- client/scripts/sandbox/path_finder.gd | 200 ++++++++++++++++ client/scripts/sandbox/path_follower.gd | 236 +++++++++++++++++++ client/scripts/sandbox/path_preview.gd | 182 ++++++++++++++ client/tests/unit/test_path_finder.gd | 173 ++++++++++++++ client/tests/unit/test_path_follower.gd | 65 +++++ 8 files changed, 1034 insertions(+), 10 deletions(-) create mode 100644 client/scripts/sandbox/path_finder.gd create mode 100644 client/scripts/sandbox/path_follower.gd create mode 100644 client/scripts/sandbox/path_preview.gd create mode 100644 client/tests/unit/test_path_finder.gd create mode 100644 client/tests/unit/test_path_follower.gd diff --git a/client/scripts/autoloads/input_mapper.gd b/client/scripts/autoloads/input_mapper.gd index 9efcb3ab1..47d92bc86 100644 --- a/client/scripts/autoloads/input_mapper.gd +++ b/client/scripts/autoloads/input_mapper.gd @@ -175,6 +175,50 @@ func flush_queue() -> Array[Dictionary]: return queue +# T-1088 click-to-move seam (design §9 / D-248): queue ONE 8-direction step from a +# sim tile delta (e.g. Vector2i(1, 0) = East, Vector2i(1, 1) = Southeast), gated by +# the SAME per-stance throttle held-WASD uses (_last_move_msec + MOVE_INTERVAL_MS). +# The sandbox path follower calls this once per frame while walking a client-planned +# path; the shared throttle paces it to the exact stance cadence (no duplicated +# interval table) and interleaves cleanly with WASD — a held key and a queued step +# can never both fire inside one window. Returns true when the step was queued, +# false when throttled or input-suppressed. Ordinary Move* actions only: zero +# protocol change, no client prediction, the server validates every step (D-010). +func queue_move_step(tile_delta: Vector2i) -> bool: + if GameState.dialogue_active or GameState.free_camera_mode: + return false + if tile_delta == Vector2i.ZERO: + return false + var now := Time.get_ticks_msec() + var interval: int = MOVE_INTERVAL_MS.get(GameState.player_stance, 200) + if now - _last_move_msec < interval: + return false + _last_move_msec = now + input_queue.append( + { + "action": _dir_to_action(tile_delta), + "timestamp_msec": now, + } + ) + return true + + +# T-1088 double-RMB sprint-there seam: queue ONE ordinary stance toggle (up = toward +# Sprint, down = toward Crouch) through the same input_queue movement rides. Discrete, +# so no throttle — the server's handle_toggle_stance has no cooldown, so the sandbox +# follower can burst the exact ladder distance and trust it (server validates). No-op +# while input is suppressed (dialogue / free camera), matching movement. +func queue_stance_toggle(up: bool) -> void: + if GameState.dialogue_active or GameState.free_camera_mode: + return + input_queue.append( + { + "action": Action.TOGGLE_STANCE_UP if up else Action.TOGGLE_STANCE_DOWN, + "timestamp_msec": Time.get_ticks_msec(), + } + ) + + ## Reset facing state to default (North). Use in tests per D-030 testability. func reset_facing_state() -> void: facing_angle = -PI / 2.0 diff --git a/client/scripts/sandbox/locomotion_sandbox.gd b/client/scripts/sandbox/locomotion_sandbox.gd index d91ad4114..eff350388 100644 --- a/client/scripts/sandbox/locomotion_sandbox.gd +++ b/client/scripts/sandbox/locomotion_sandbox.gd @@ -36,6 +36,14 @@ var _anim: LocomotionAnim = null ## Mouse-aim facing provider (design §9) — the installed Callable keeps it alive; ## referenced here too so the seam's owner is greppable. Cleared in _exit_tree(). var _aim_provider: SandboxMouseAimProvider = null +## T-1088 click-to-move (Live-session feedback item 2): the mouse-over path +## preview (Node3D under WorldRoot) and the path follower (RefCounted, ticked +## per frame). Installed in _ready(); the RMB gesture handling is in _unhandled_input. +var _path_preview: PathPreview = null +var _path_follower: PathFollower = null +## Time (ms) a non-double RMB press began, -1 = none pending. Resolves click vs +## long-press on RELEASE (RMB_LONG_PRESS_MS threshold). +var _rmb_press_msec: int = -1 var _first_snapshot_seen: bool = false var _gameplay_paused: bool = false # D-170: implant fullscreen occludes gameplay var _autopilot_spec: String = "" # design §10.2: raw SR_AUTOPILOT string (kept for debugging) @@ -92,6 +100,10 @@ func _ready() -> void: # (4) InputMapper facing provider (design §9). _install_facing_provider() + # (4b) T-1088 click-to-move (Live-session feedback item 2): hover path preview + # + follower. See _install_click_to_move. + _install_click_to_move() + # (5) D-170: the occlusion *mechanism* is CanvasItem-only, the signal is not — # a 3D scene connects it directly to a pause flag. HudGroups.gameplay_occluded.connect(_on_gameplay_occluded) @@ -245,6 +257,12 @@ func _input_suppressed() -> bool: # 0.0-blend reset rides its own connection (LocomotionAnim.setup). func _on_rig_teleported(_pos_m: Vector3) -> void: camera_rig.snap_to_target() + # T-1088: a teleport invalidates any in-flight click-to-move and stale preview + # (design item 2 cancel condition — the planned route no longer applies). + if _path_follower != null: + _path_follower.cancel() + if _path_preview != null: + _path_preview.clear() # --------------------------------------------------------------------------- @@ -263,6 +281,84 @@ func _install_facing_provider() -> void: InputMapper.facing_angle_provider = Callable(_aim_provider, "get_facing_angle") +## Click-to-move seam (T-1088 Live-session feedback item 2): the mouse-over path +## preview — a Node3D under WorldRoot so it inherits the 45° map rotation for free — +## and the path follower (RefCounted, ticked from _per_frame_update). Both plan over +## the greybox KNOWN store via _is_floor and honour the information boundary (fog is +## unpathable). The RMB commit is handled in _unhandled_input; nothing crosses a new +## wire — the follower streams ordinary Move* steps through InputMapper's throttle. +func _install_click_to_move() -> void: + _path_preview = PathPreview.new() + _path_preview.name = "PathPreview" + world_root.add_child(_path_preview) + _path_preview.setup(camera_rig.get_camera(), world_root) + _path_follower = PathFollower.new() + + +## RMB gesture vocabulary for click-to-move (design item 2 + sprint/crouch additions). +## RMB, not LMB: no mouse button is bound in the input map (movement is WASD, interact +## is E), so both are free — RMB is the tactical-genre move-here idiom and leaves LMB +## open for the future click-to-interact/select verb. Read raw here (no project.godot +## action) so the binding stays sandbox-local and the shared input map is untouched. +## Single click (press, quick release) → walk the path at the current stance. +## Double click (double_click press) → sprint-there: UPGRADE the in-flight +## single-click follow in place (Godot delivers the double's first press as an +## ordinary click that already started it), or start a fresh sprint follow. +## Long press (held ≥ RMB_LONG_PRESS_MS, committed on RELEASE) → go there, then +## Crouch on arrival ("take cover" input-vocabulary prototype only — real cover +## mechanics are future combat design). +## Click vs long-press can only be told apart on release, so the single click commits +## on RELEASE (a few ms later — imperceptible); the double still commits on its press. +## The path used is always the preview's current path (the hover tile at that instant). +func _unhandled_input(event: InputEvent) -> void: + if not (event is InputEventMouseButton): + return + var mb := event as InputEventMouseButton + if mb.button_index != MOUSE_BUTTON_RIGHT: + return + # Any state where a commit is invalid drops the pending press — a gesture + # interrupted by pause / dialogue / free-camera is abandoned. + var can_commit := ( + _first_snapshot_seen + and not _gameplay_paused + and not _input_suppressed() + and _path_preview != null + and _path_follower != null + ) + if not can_commit: + _rmb_press_msec = -1 + return + + if mb.pressed: + if mb.double_click: + _rmb_press_msec = -1 # this gesture resolved as a double — no click/long-press + if _path_follower.is_active(): + _path_follower.upgrade_to_sprint(GameState.player_stance) + else: + _path_follower.start_sprint( + _path_preview.get_current_path(), GameState.player_stance + ) + else: + _rmb_press_msec = Time.get_ticks_msec() # defer: decide on release + else: + if _rmb_press_msec < 0: + return # release of a double's press (or nothing pending) — ignore + var held := Time.get_ticks_msec() - _rmb_press_msec + _rmb_press_msec = -1 + if held >= SandboxConstants.RMB_LONG_PRESS_MS: + _path_follower.start_crouch(_path_preview.get_current_path()) + else: + _path_follower.start(_path_preview.get_current_path()) + get_viewport().set_input_as_handled() + + +## Greybox known-floor lookup (the PathFinder / follower / preview substrate): +## true only for tiles the store KNOWS to be floor (doors collapse to FLOOR on the +## wire). Unknown / never-seen / wall are impassable — fog is unpathable (D-010). +func _is_floor(tile: Vector3i) -> bool: + return greybox.store.has_tile(tile) and greybox.store.kind_of(tile) == GreyboxWorld.Store.Kind.FLOOR + + ## Per-frame cross-component work owned by the root, gated on the first snapshot ## and the D-170 pause flag: the cutaway anchor (design §8 — the rig's INTERPOLATED ## world XZ, so the cut zone glides and spatial smoothstep becomes temporal @@ -280,6 +376,20 @@ func _per_frame_update(delta: float) -> void: greybox.set_cutaway_char_pos(player_rig.global_position, reach) _anim.update(delta) + # T-1088 click-to-move (design item 2): hover preview + path follower. Both plan + # from the server-confirmed character tile so a plan starts from ground truth, + # not an optimistic guess. Under input suppression (dialogue / free camera) the + # preview hides and any walk is cancelled — the same condition that freezes idle + # facing. Recompute inside the preview is edge-triggered, so this is cheap. + var char_tile := SandboxSpace.wire_to_tile(GameState.player_position.x, GameState.player_position.y) + var is_floor := Callable(self, "_is_floor") + if _input_suppressed(): + _path_preview.clear() + _path_follower.cancel() + else: + _path_preview.update(char_tile, is_floor, greybox.store.size()) + _path_follower.tick(char_tile, is_floor) + ## Hard camera-pivot snap (design §7) — first-snapshot latch + teleport fan-out. func _snap_camera_to_player() -> void: diff --git a/client/scripts/sandbox/mouse_aim_provider.gd b/client/scripts/sandbox/mouse_aim_provider.gd index dbbb4da4b..a3dc479d0 100644 --- a/client/scripts/sandbox/mouse_aim_provider.gd +++ b/client/scripts/sandbox/mouse_aim_provider.gd @@ -79,20 +79,34 @@ static func compute_facing_angle( rig_local_pos: Vector3, deadzone_m: float ) -> float: - # Ray -> y=0 ground plane, world space. - if absf(ray_dir.y) < _RAY_PARALLEL_EPS: + var hit_local: Variant = ground_hit_local(ray_origin, ray_dir, world_root_transform) + if hit_local == null: return NAN - var t := -ray_origin.y / ray_dir.y - if t < 0.0: - return NAN - var hit_world := ray_origin + ray_dir * t - # Undo the 45° map rotation: WorldRoot-local axes are sim axes (SandboxSpace §2: - # local +X = sim East, local +Z = sim South). - var hit_local := world_root_transform.affine_inverse() * hit_world - var delta := hit_local - rig_local_pos + var delta: Vector3 = (hit_local as Vector3) - rig_local_pos var planar := Vector2(delta.x, delta.z) if planar.length_squared() < deadzone_m * deadzone_m: return NAN # atan2(z, x) IS the sim convention (0 = East, +PI/2 = South) because local # +Z = sim +y (South, Y-down radians — server vision_cone.rs). return atan2(delta.z, delta.x) + + +## Pure ground-plane pick (headless-testable): a world-space mouse ray -> its y=0 +## ground-plane intersection expressed in WorldRoot-LOCAL metres (undoing the 45° +## map rotation, so the result is sim-aligned space per SandboxSpace §2). Shared by +## the facing provider above and the T-1088 hover-tile picker (path_preview.gd), +## which feeds the returned point straight into SandboxSpace.world_to_tile — one +## unproject, one place. Returns null (not a Vector3) when the ray is parallel to +## the ground or the plane sits behind the ray origin. +static func ground_hit_local( + ray_origin: Vector3, ray_dir: Vector3, world_root_transform: Transform3D +) -> Variant: + if absf(ray_dir.y) < _RAY_PARALLEL_EPS: + return null + var t := -ray_origin.y / ray_dir.y + if t < 0.0: + return null + var hit_world := ray_origin + ray_dir * t + # Undo the 45° map rotation: WorldRoot-local axes are sim axes (SandboxSpace §2: + # local +X = sim East, local +Z = sim South). + return world_root_transform.affine_inverse() * hit_world diff --git a/client/scripts/sandbox/path_finder.gd b/client/scripts/sandbox/path_finder.gd new file mode 100644 index 000000000..33eebb4c5 --- /dev/null +++ b/client/scripts/sandbox/path_finder.gd @@ -0,0 +1,200 @@ +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 diff --git a/client/scripts/sandbox/path_follower.gd b/client/scripts/sandbox/path_follower.gd new file mode 100644 index 000000000..8a4c9001a --- /dev/null +++ b/client/scripts/sandbox/path_follower.gd @@ -0,0 +1,236 @@ +class_name PathFollower +extends RefCounted +## T-1088 (Live-session feedback item 2 + the RMB gesture vocabulary) — click-to-move +## follower. Walks a client-planned path (PathFinder) by emitting ONE ordinary Move* +## step per throttle window through InputMapper.queue_move_step — the server validates +## and drives every step, exactly as if the player were holding WASD. No client +## prediction, no protocol change (D-010/D-248). +## +## RMB gesture vocabulary (resolved by the sandbox root; this class is the executor): +## NORMAL (single click) — walk the path at the current stance. +## SPRINT (double click) — raise stance to Sprint, walk, RESTORE the pre-sprint +## stance on ARRIVAL. +## CROUCH (long press) — walk at the current stance, then lower stance to Crouch +## on ARRIVAL. NO restore — crouching was the point. NB: +## real "take cover" mechanics (wall adjacency, directional +## protection) are FUTURE combat design; this gesture is the +## input-vocabulary prototype only. +## +## Stance is server-authoritative and a ladder (Crouch < Careful < Walk < Sprint — +## server/src/bridge/types.rs step_up/step_down). We move along it by bursting the +## exact number of ordinary ToggleStanceUp/Down actions through the same queue path as +## movement (InputMapper.queue_stance_toggle): the server's handle_toggle_stance has NO +## cooldown and drops nothing (server/src/simulation/stance.rs), so N queued toggles +## deterministically climb/drop N rungs and the ends saturate (an over-toggle is a +## no-op). SPRINT restore bursts the SAME count it raised, so the round-trip is +## NET-ZERO regardless of snapshot RTT — no observe-and-gate loop, no overshoot. +## +## Stance changes fire ONLY on the follow's terminal event (arrival). Every +## CANCELLATION — WASD override (spec: the player took over deliberately, leaving +## them in Sprint is correct), path invalidation, teleport, input suppression — +## leaves the stance as-is: an interruption must not trail a stance-toggle burst +## (and under suppression the queue is blocked anyway). +## +## RefCounted, not a Node: it owns no visuals and needs no tree presence — the +## sandbox root ticks it once per frame from _per_frame_update, passing the +## server-confirmed player tile so every decision is made from ground truth, never +## from an optimistic guess. A blocked/dropped step re-emits next window +## (bump-to-turn, Q-020) instead of desyncing the path. + +## Stance ladder, fastest (top) → slowest. Index = rung; Sprint is rung 0, Crouch 3. +## Order mirrors the server MovementStance ladder (step_up/step_down). +const STANCE_LADDER: Array[String] = ["Sprint", "Walk", "Careful", "Crouch"] + +## Terminal stance behaviour of a follow. +enum Mode { NORMAL, SPRINT, CROUCH } + +## Remaining planned path INCLUDING the current tile at some index; the goal is the +## last element. Empty while inactive. +var _path: Array[Vector3i] = [] +var _goal: Vector3i = Vector3i.ZERO +var _active: bool = false + +var _mode: int = Mode.NORMAL +## SPRINT only: the pre-sprint stance (kept for readability/debug) and the exact +## number of ToggleStanceUp bursted — the same ToggleStanceDown count restores it. +var _saved_stance: String = "Walk" +var _raise_count: int = 0 + + +## True while walking a path. +func is_active() -> bool: + return _active + + +## True if this follow was raised to sprint-there. +func is_sprinting() -> bool: + return _mode == Mode.SPRINT + + +## Begin a NORMAL follow at the current stance (single RMB). A path shorter than two +## tiles is nothing to walk (clicking the current tile / an unpathable tile). +func start(path: Array[Vector3i]) -> void: + if not _adopt_path(path): + return + _mode = Mode.NORMAL + _raise_count = 0 + + +## Begin a SPRINT-there follow (double RMB with no active follow to upgrade): adopt +## the path, remember the pre-sprint stance, and burst the raise toggles. +func start_sprint(path: Array[Vector3i], current_stance: String) -> void: + if not _adopt_path(path): + return + _begin_sprint(current_stance) + + +## Begin a CROUCH-on-arrival follow (long-press RMB): walk at the current stance, +## drop to Crouch on arrival. No raise at start, no restore. +func start_crouch(path: Array[Vector3i]) -> void: + if not _adopt_path(path): + return + _mode = Mode.CROUCH + _raise_count = 0 + + +## Upgrade an ALREADY-ACTIVE NORMAL follow to sprint-there — the double-click's +## second press, after the single-click commit already started walking. Keeps the +## path and progress (an UPGRADE, not a restart); just raises the stance. No-op if +## no follow is active or the follow is already sprint/crouch. +func upgrade_to_sprint(current_stance: String) -> void: + if not _active or _mode != Mode.NORMAL: + return + _begin_sprint(current_stance) + + +## Stop following with NO stance change — every interruption that is not a clean +## arrival (WASD override / path invalidation / teleport / suppression). +func cancel() -> void: + _active = false + _path = [] + _mode = Mode.NORMAL + _raise_count = 0 + + +## One frame of following, called by the sandbox root while active. +## current_tile: the server-confirmed player tile (SandboxSpace.wire_to_tile of +## GameState.player_position) — the single source of truth. +## is_floor: the greybox known-floor lookup (Vector3i) -> bool. +## Emits at most one throttled step; may finish (arrival stance change) or cancel. +func tick(current_tile: Vector3i, is_floor: Callable) -> void: + if not _active: + return + # Cancel (no stance change): input suppression — dialogue / free camera. + if GameState.dialogue_active or GameState.free_camera_mode: + cancel() + return + # Cancel (no stance change): any WASD held — the player is steering manually. + if _wasd_held(): + cancel() + return + # Arrival — the sole terminal event: apply the mode's arrival stance change, stop. + if current_tile == _goal: + _finish() + return + # Revalidate: replan if the player drifted off the path (desync) or a still-ahead + # tile became impassable (a revealed wall). A dead route cancels (no stance change). + var idx := _path.find(current_tile) + if idx == -1 or not _remaining_passable(idx, is_floor): + var replan := PathFinder.find_path(current_tile, _goal, is_floor) + if replan.size() < 2: + cancel() + return + _path = replan + idx = 0 + # Step toward the next planned tile; the shared stance throttle paces it (for a + # sprint follow, as the confirmed stance climbs to Sprint queue_move_step picks up + # the 200 ms cadence automatically — the character accelerates into the sprint). + var next_tile: Vector3i = _path[idx + 1] + var delta := Vector2i(next_tile.x - current_tile.x, next_tile.y - current_tile.y) + InputMapper.queue_move_step(delta) + + +# --------------------------------------------------------------------------- +# Pure ladder math (headless-testable — test_path_follower.gd) +# --------------------------------------------------------------------------- + + +## Rung of a stance on the ladder (Sprint 0 .. Crouch 3). Unknown → Walk's rung, +## the GameState default. +static func stance_rung(stance: String) -> int: + var i := STANCE_LADDER.find(stance) + return i if i >= 0 else STANCE_LADDER.find("Walk") + + +## ToggleStanceUp actions to climb from `stance` to Sprint = its rung. Also the exact +## ToggleStanceDown count that restores back afterwards (net-zero). +static func toggles_to_sprint(stance: String) -> int: + return stance_rung(stance) + + +## ToggleStanceDown actions to descend from `stance` to Crouch = Crouch's rung minus +## the stance's rung (0 when already crouched). +static func toggles_to_crouch(stance: String) -> int: + return STANCE_LADDER.find("Crouch") - stance_rung(stance) + + +# --------------------------------------------------------------------------- +# Internals +# --------------------------------------------------------------------------- + + +## Adopt a path (shared by start / start_sprint / start_crouch); false if too short. +func _adopt_path(path: Array[Vector3i]) -> bool: + if path.size() < 2: + cancel() + return false + _path = path.duplicate() + _goal = _path[_path.size() - 1] + _active = true + return true + + +## Enter sprint-there: remember the pre-sprint stance and burst the raise toggles. +func _begin_sprint(current_stance: String) -> void: + _mode = Mode.SPRINT + _saved_stance = current_stance + _raise_count = toggles_to_sprint(current_stance) + for _i in _raise_count: + InputMapper.queue_stance_toggle(true) # burst up toward Sprint + + +## Arrival stance change per mode, then stop: +## SPRINT — burst the inverse toggles to restore the pre-sprint stance (net-zero). +## CROUCH — burst stance_down from the observed arrival stance to Crouch (no restore). +## NORMAL — nothing. +func _finish() -> void: + match _mode: + Mode.SPRINT: + for _i in _raise_count: + InputMapper.queue_stance_toggle(false) + Mode.CROUCH: + # Observed stance at arrival (no toggles were emitted mid-walk, so this is + # the true current stance) → exact ToggleStanceDown count to reach Crouch. + var n := toggles_to_crouch(GameState.player_stance) + for _i in n: + InputMapper.queue_stance_toggle(false) + cancel() + + +## Every remaining path tile (from idx onward) is still known-floor. A revealed wall +## on the route triggers a replan. +func _remaining_passable(idx: int, is_floor: Callable) -> bool: + for i in range(idx, _path.size()): + if not bool(is_floor.call(_path[i])): + return false + return true + + +## Any of the four movement actions currently held. +func _wasd_held() -> bool: + return ( + Input.is_action_pressed("move_north") + or Input.is_action_pressed("move_south") + or Input.is_action_pressed("move_east") + or Input.is_action_pressed("move_west") + ) diff --git a/client/scripts/sandbox/path_preview.gd b/client/scripts/sandbox/path_preview.gd new file mode 100644 index 000000000..19f6b1ab4 --- /dev/null +++ b/client/scripts/sandbox/path_preview.gd @@ -0,0 +1,182 @@ +class_name PathPreview +extends Node3D +## T-1088 (Live-session feedback item 2) — mouse-over move-here marker + optimal +## path line. A Node3D placed UNDER WorldRoot by the sandbox root, so it inherits +## the single D-148 45° map rotation for free: its children live in the same +## WorldRoot-local (sim-aligned) metric space the greybox tiles do, and +## SandboxSpace.tile_to_world positions them directly. +## +## Two visuals, both children built in setup(): +## - marker: a flat PlaneMesh quad on the hovered destination subtile, lifted a +## hair above the floor, in a distinct accent colour. +## - line: an ImmediateMesh line strip through the tile centers of the planned +## path, lifted slightly higher so it reads on top of the marker. +## +## The A* runs over the greybox KNOWN-tile store via the injected is_floor +## Callable (PathFinder honours the information boundary — fog is unpathable). +## Recompute is edge-triggered — on hovered-tile change, character-tile change, or +## knowledge growth — NEVER per frame (A* every frame over ~14k tiles is waste). +## Hidden whenever there is no valid path: hovering unknown/void/wall, or a +## known-floor tile with no known route. +## +## Note on knowledge_version: the root passes store.size() (monotonic — the store +## never evicts). A revealed wall REPLACING a known floor (a rekind, no size +## change) can leave the drawn line momentarily stale until the next hover/step, +## but the EXECUTED route is always safe — path_follower.gd revalidates every +## remaining tile per step and replans. Preview is advisory; the follower is +## authoritative. + +## Hover-pick ray sources, injected in setup(). +var _camera: Camera3D = null +var _world_root: Node3D = null + +var _marker: MeshInstance3D = null +var _line: MeshInstance3D = null +var _line_mesh: ImmediateMesh = null + +## Latest computed path (character tile -> hovered tile, inclusive). Empty when +## the hovered tile is unpathable. Read by the root's click handler. +var _current_path: Array[Vector3i] = [] + +## Edge-trigger memory — recompute only when one of these changes. +var _last_hover: Vector3i = Vector3i(2147483647, 0, 0) # sentinel: forces first pass +var _last_char_tile: Vector3i = Vector3i(2147483647, 0, 0) +var _last_version: int = -1 + + +## Inject the ray-pick nodes and build the two child visuals. Called by the +## sandbox root right after the preview is added under WorldRoot. +func setup(camera: Camera3D, world_root: Node3D) -> void: + _camera = camera + _world_root = world_root + + # Move-here marker — PlaneMesh faces +Y natively (like the floor tiles); a + # double-sided unshaded material keeps it readable from every camera preset. + var quad := PlaneMesh.new() + quad.size = Vector2(SandboxConstants.SUBTILE_M, SandboxConstants.SUBTILE_M) + _marker = MeshInstance3D.new() + _marker.name = "MoveHereMarker" + _marker.mesh = quad + _marker.material_override = _flat_material(SandboxConstants.PATH_MARKER_COLOR) + _marker.visible = false + add_child(_marker) + + # Path line — an ImmediateMesh rebuilt on recompute. + _line_mesh = ImmediateMesh.new() + _line = MeshInstance3D.new() + _line.name = "PathLine" + _line.mesh = _line_mesh + _line.material_override = _flat_material(SandboxConstants.PATH_LINE_COLOR) + _line.visible = false + add_child(_line) + + +## One frame of preview maintenance (called by the root while gameplay is live). +## char_tile: the server-confirmed character tile (path origin). +## is_floor: greybox known-floor lookup (Vector3i) -> bool. +## knowledge_version: store.size() — bumps when new tiles are learned. +func update(char_tile: Vector3i, is_floor: Callable, knowledge_version: int) -> void: + var hover: Variant = _hovered_tile() + if hover == null: + # Mouse off the ground plane (pointing at sky / degenerate ray). + _clear_visuals() + _last_hover = Vector3i(2147483647, 0, 0) + return + var hover_tile: Vector3i = hover + if ( + hover_tile == _last_hover + and char_tile == _last_char_tile + and knowledge_version == _last_version + ): + return # nothing that affects the path changed — leave visuals as-is + _last_hover = hover_tile + _last_char_tile = char_tile + _last_version = knowledge_version + + _current_path = PathFinder.find_path(char_tile, hover_tile, is_floor) + _redraw() + + +## The planned path (character -> hovered), inclusive of both ends; empty when the +## hovered tile is unpathable. The root hands this to the follower on click. +func get_current_path() -> Array[Vector3i]: + return _current_path + + +## Hide both visuals and forget the current path (root calls on teleport / when +## gameplay is suppressed). +func clear() -> void: + _current_path = [] + _clear_visuals() + _last_hover = Vector3i(2147483647, 0, 0) + + +# --------------------------------------------------------------------------- +# Internals +# --------------------------------------------------------------------------- + + +## The tile currently under the mouse, or null if the ray misses the ground. Reuses +## the facing provider's ground-plane unproject (one unproject, one place), then +## SandboxSpace.world_to_tile — WorldRoot-local metres are sim-aligned space. +func _hovered_tile() -> Variant: + if not is_instance_valid(_camera) or not is_instance_valid(_world_root): + return null + if not _camera.is_inside_tree(): + return null + var viewport := _camera.get_viewport() + if viewport == null: + return null + var mouse := viewport.get_mouse_position() + var hit_local: Variant = SandboxMouseAimProvider.ground_hit_local( + _camera.project_ray_origin(mouse), + _camera.project_ray_normal(mouse), + _world_root.global_transform + ) + if hit_local == null: + return null + return SandboxSpace.world_to_tile(hit_local as Vector3) + + +## Repaint marker + line from _current_path. Marker on the destination when a path +## exists; line only for a real (>= 2 tile) move. +func _redraw() -> void: + if _current_path.is_empty(): + _clear_visuals() + return + var dest: Vector3i = _current_path[_current_path.size() - 1] + var dest_world := SandboxSpace.tile_to_world(dest) + _marker.position = Vector3(dest_world.x, SandboxConstants.PATH_MARKER_Y, dest_world.z) + _marker.visible = true + + _line_mesh.clear_surfaces() + if _current_path.size() >= 2: + _line_mesh.surface_begin(Mesh.PRIMITIVE_LINE_STRIP) + for tile in _current_path: + var c := SandboxSpace.tile_to_world(tile) + _line_mesh.surface_add_vertex(Vector3(c.x, SandboxConstants.PATH_LINE_Y, c.z)) + _line_mesh.surface_end() + _line.visible = true + else: + _line.visible = false + + +func _clear_visuals() -> void: + if _marker != null: + _marker.visible = false + if _line != null: + _line.visible = false + if _line_mesh != null: + _line_mesh.clear_surfaces() + + +## Unshaded, double-sided, alpha-blended material — a flat overlay swatch that +## ignores scene lighting so the accent colour stays constant at any camera pitch. +func _flat_material(color: Color) -> StandardMaterial3D: + var mat := StandardMaterial3D.new() + mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA + mat.cull_mode = BaseMaterial3D.CULL_DISABLED + mat.albedo_color = color + mat.vertex_color_use_as_albedo = false + return mat diff --git a/client/tests/unit/test_path_finder.gd b/client/tests/unit/test_path_finder.gd new file mode 100644 index 000000000..814005ec1 --- /dev/null +++ b/client/tests/unit/test_path_finder.gd @@ -0,0 +1,173 @@ +## 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() diff --git a/client/tests/unit/test_path_follower.gd b/client/tests/unit/test_path_follower.gd new file mode 100644 index 000000000..fc7fd73fd --- /dev/null +++ b/client/tests/unit/test_path_follower.gd @@ -0,0 +1,65 @@ +## PathFollower stance-ladder math (T-1088 double-RMB sprint-there): the number of +## ToggleStanceUp actions to reach Sprint from each stance, and the net-zero restore +## property. Pure static — the ladder helpers touch no autoloads, so they run +## headless (the instance follow/step behaviour is live-only, verified in the smoke). +class_name TestPathFollower +extends GdUnitTestSuite + + +func test_toggles_to_sprint_from_each_stance() -> void: + # Ladder: Crouch < Careful < Walk < Sprint (server MovementStance step_up). + assert_int(PathFollower.toggles_to_sprint("Sprint")).is_equal(0) + assert_int(PathFollower.toggles_to_sprint("Walk")).is_equal(1) + assert_int(PathFollower.toggles_to_sprint("Careful")).is_equal(2) + assert_int(PathFollower.toggles_to_sprint("Crouch")).is_equal(3) + + +func test_stance_rung_matches_ladder() -> void: + assert_int(PathFollower.stance_rung("Sprint")).is_equal(0) + assert_int(PathFollower.stance_rung("Walk")).is_equal(1) + assert_int(PathFollower.stance_rung("Careful")).is_equal(2) + assert_int(PathFollower.stance_rung("Crouch")).is_equal(3) + + +func test_unknown_stance_defaults_to_walk() -> void: + # Defensive: an unrecognized / empty stance is treated as Walk (GameState default). + assert_int(PathFollower.stance_rung("Bogus")).is_equal(1) + assert_int(PathFollower.toggles_to_sprint("")).is_equal(1) + + +func test_restore_count_equals_raise_count_net_zero() -> void: + # Restore bursts the SAME number of ToggleStanceDown as the raise bursted + # ToggleStanceUp. Since Sprint is rung 0, raising from a stance to Sprint climbs + # `rung` steps and restoring drops `rung` steps → back to the exact start rung, + # regardless of snapshot RTT. The single source of truth is toggles_to_sprint. + for stance: String in ["Sprint", "Walk", "Careful", "Crouch"]: + var raise_toggles := PathFollower.toggles_to_sprint(stance) + var restore_toggles := PathFollower.toggles_to_sprint(stance) + assert_int(restore_toggles).is_equal(raise_toggles) + # Net-zero: start rung, up to Sprint (0), then down `raise_toggles` == start. + assert_int(0 + restore_toggles).is_equal(PathFollower.stance_rung(stance)) + + +# -- long-press crouch-on-arrival ------------------------------------------------------ + + +func test_toggles_to_crouch_from_each_stance() -> void: + # Long-press lowers to Crouch on arrival: ToggleStanceDown from the arrival stance + # down to Crouch (rung 3). No restore. + assert_int(PathFollower.toggles_to_crouch("Sprint")).is_equal(3) + assert_int(PathFollower.toggles_to_crouch("Walk")).is_equal(2) + assert_int(PathFollower.toggles_to_crouch("Careful")).is_equal(1) + assert_int(PathFollower.toggles_to_crouch("Crouch")).is_equal(0) + + +func test_toggles_to_crouch_unknown_defaults_to_walk() -> void: + # Unknown/empty stance is treated as Walk (rung 1) → 2 down-toggles to Crouch. + assert_int(PathFollower.toggles_to_crouch("Bogus")).is_equal(2) + assert_int(PathFollower.toggles_to_crouch("")).is_equal(2) + + +func test_crouch_lands_on_bottom_rung() -> void: + # After the crouch burst the character sits exactly on Crouch's rung from any start. + for stance: String in ["Sprint", "Walk", "Careful", "Crouch"]: + var down := PathFollower.toggles_to_crouch(stance) + assert_int(PathFollower.stance_rung(stance) + down).is_equal(PathFollower.stance_rung("Crouch"))