From c79c58c298611fa5f33dbb42939b1bd38d87433d Mon Sep 17 00:00:00 2001 From: VinPropane Date: Fri, 17 Apr 2026 20:16:25 -0400 Subject: [PATCH] NEO-22: Unit-test WASD locomotion pure helpers Extract static helpers (floor-ray continuation, median feet Y, micro-slip XZ projection, wish-aligned horizontal velocity) from the tick path so GdUnit can cover seam math without physics doubles. Add player_locomotion_wasd_test.gd, document in README, and update NEO-22 plan/review. --- client/README.md | 2 +- client/scripts/player_locomotion_wasd.gd | 108 +++++++++++++------ client/test/player_locomotion_wasd_test.gd | 115 +++++++++++++++++++++ docs/plans/NEO-22-implementation-plan.md | 3 +- docs/reviews/2026-04-17-NEO-22.md | 2 +- 5 files changed, 193 insertions(+), 37 deletions(-) create mode 100644 client/test/player_locomotion_wasd_test.gd diff --git a/client/README.md b/client/README.md index 66ad04e..e0bf5ae 100644 --- a/client/README.md +++ b/client/README.md @@ -140,7 +140,7 @@ On **Linux** (including GitHub Actions), the path must be **`gdUnit4`** with a c **CI:** The **GDScript** workflow fails the job if Godot prints **`SCRIPT ERROR:`** or **`ERROR: Failed to load script`** while running GdUnit. GdUnit alone can still exit **0** when a test file fails to parse (that suite is skipped); the workflow guards against that. -**Scope:** Unit tests cover **`player.gd`**, **`position_authority_client.gd`**, **`isometric_follow_camera.gd`** (eye math + effective zoom distance), **`camera_state.gd`**, and **`zoom_band_config.gd`**. **`main.gd`** and scene wiring are **not** automated here—use manual checks above. +**Scope:** Unit tests cover **`player.gd`**, **`player_locomotion_wasd.gd`** (pure WASD seam helpers), **`position_authority_client.gd`**, **`isometric_follow_camera.gd`** (eye math + effective zoom distance), **`camera_state.gd`**, and **`zoom_band_config.gd`**. **`main.gd`** and scene wiring are **not** automated here—use manual checks above. **Camera zoom:** Input actions **`camera_zoom_in`** / **`camera_zoom_out`** (mouse wheel, **`=`** / **`-`**, keypad **+** / **−**) are defined in **`project.godot`**. On layouts where **`+`** requires **Shift**, remap or add a binding under **Project → Project Settings → Input Map** if needed. diff --git a/client/scripts/player_locomotion_wasd.gd b/client/scripts/player_locomotion_wasd.gd index 838451b..2dcc94e 100644 --- a/client/scripts/player_locomotion_wasd.gd +++ b/client/scripts/player_locomotion_wasd.gd @@ -14,7 +14,8 @@ const LOCOMOTION_SCRAPE_RAY_FEET_ABSORB_MAX: float = 0.1 const LOCOMOTION_SCRAPE_RAY_PROBE_Y_LERP: float = 0.42 const LOCOMOTION_SCRAPE_RAY_FEET_MEDIAN_SPAN: float = 0.055 const LOCOMOTION_SCRAPE_WISH_LINE_MICRO_PERP_M: float = 0.032 -const _FLOOR_RAY_FEET_INVALID: float = -1.0e9 +## Sentinel when no walkable floor hit (tests compare to this). +const FLOOR_RAY_FEET_INVALID: float = -1.0e9 ## Must match [code]player.gd[/code] walk continuation probe (same ray math as ## [method _walk_ray_hit_up_floor]). const _WALK_SUPPORT_PROBE_DEPTH: float = 0.32 @@ -34,6 +35,62 @@ var _scrape_probe_y_ema_valid: bool = false var _scrape_probe_y_ema: float = 0.0 +## Pure: walk continuation rules after a downward feet probe hit (same thresholds as +## [method _floor_ray_feet_y]). +static func evaluate_floor_ray_hit_y( + feet_y: float, + hit_y: float, + normal: Vector3, + continuation_max_below_feet: float, + min_floor_up_dot: float +) -> float: + if hit_y < feet_y - continuation_max_below_feet: + return FLOOR_RAY_FEET_INVALID + if normal.dot(Vector3.UP) <= min_floor_up_dot: + return FLOOR_RAY_FEET_INVALID + return hit_y + + +## Pure: median of one or more feet Y samples (sorted); empty → [constant FLOOR_RAY_FEET_INVALID]. +static func median_feet_y_from_samples(samples: Array[float]) -> float: + if samples.is_empty(): + return FLOOR_RAY_FEET_INVALID + var y_vals: Array[float] = samples.duplicate() + y_vals.sort() + var mid: int = (y_vals.size() - 1) >> 1 + return y_vals[mid] + + +## Pure: XZ after micro-slip projection onto the wish line (or unchanged if out of band). +static func xz_after_micro_slip_projection( + anchor_xz: Vector2, player_xz: Vector2, wish_xz: Vector2, perp_limit_m: float +) -> Vector2: + var w2s: float = wish_xz.length_squared() + if w2s < 1e-12: + return player_xz + var w2n: Vector2 = wish_xz / sqrt(w2s) + var along: float = (player_xz - anchor_xz).dot(w2n) + var p2_on: Vector2 = anchor_xz + w2n * along + var perp_len: float = (player_xz - p2_on).length() + if perp_len > perp_limit_m or perp_len <= 1e-7: + return player_xz + return Vector2(p2_on.x, p2_on.y) + + +## Pure: non-negative wish-aligned horizontal speed, capped at [param move_speed]. +static func horizontal_velocity_aligned_to_wish( + wish_xz: Vector2, vel_xz: Vector2, move_speed: float +) -> Vector2: + if wish_xz.length_squared() < 1e-10: + return vel_xz + var w2: Vector2 = wish_xz.normalized() + var along: float = vel_xz.dot(w2) + if along < 0.0: + along = 0.0 + along = minf(move_speed, along) + return Vector2(w2.x * along, w2.y * along) + + func _init( player: CharacterBody3D, cap_half: float, @@ -73,33 +130,23 @@ func _has_scrape_vertical_contact() -> bool: func _project_global_xz_on_wish_line_if_micro_slip(anchor_xz: Vector2, wish_xz: Vector2) -> void: - var w2s: float = wish_xz.length_squared() - if w2s < 1e-12: - return - var w2n: Vector2 = wish_xz / sqrt(w2s) var p2: Vector2 = Vector2(_player.global_position.x, _player.global_position.z) - var along: float = (p2 - anchor_xz).dot(w2n) - var p2_on: Vector2 = anchor_xz + w2n * along - var perp_len: float = (p2 - p2_on).length() - if perp_len > LOCOMOTION_SCRAPE_WISH_LINE_MICRO_PERP_M or perp_len <= 1e-7: + var new_xz: Vector2 = xz_after_micro_slip_projection( + anchor_xz, p2, wish_xz, LOCOMOTION_SCRAPE_WISH_LINE_MICRO_PERP_M + ) + if new_xz.is_equal_approx(p2): return - _player.global_position.x = p2_on.x - _player.global_position.z = p2_on.y + _player.global_position.x = new_xz.x + _player.global_position.z = new_xz.y func _align_velocity_xz_to_wish() -> void: var wish: Vector3 = _player._locomotion_wish_world_xz var w2 := Vector2(wish.x, wish.z) - if w2.length_squared() < 1e-10: - return - w2 = w2.normalized() var v2 := Vector2(_player.velocity.x, _player.velocity.z) - var along: float = v2.dot(w2) - if along < 0.0: - along = 0.0 - along = minf(_move_speed, along) - _player.velocity.x = w2.x * along - _player.velocity.z = w2.y * along + var out: Vector2 = horizontal_velocity_aligned_to_wish(w2, v2, _move_speed) + _player.velocity.x = out.x + _player.velocity.z = out.y func _floor_ray_feet_y( @@ -115,14 +162,12 @@ func _floor_ray_feet_y( q.exclude = [_player.get_rid()] var res: Dictionary = space.intersect_ray(q) if res.is_empty() or not res.has("normal") or not res.has("position"): - return _FLOOR_RAY_FEET_INVALID + return FLOOR_RAY_FEET_INVALID var hit_y: float = (res["position"] as Vector3).y - if hit_y < feet_y - _WALK_CONTINUATION_MAX_BELOW_FEET: - return _FLOOR_RAY_FEET_INVALID var n: Vector3 = res["normal"] - if n.dot(Vector3.UP) <= _WALK_SUPPORT_PROBE_MIN_UP_DOT: - return _FLOOR_RAY_FEET_INVALID - return hit_y + return evaluate_floor_ray_hit_y( + feet_y, hit_y, n, _WALK_CONTINUATION_MAX_BELOW_FEET, _WALK_SUPPORT_PROBE_MIN_UP_DOT + ) func _floor_ray_feet_y_median( @@ -138,14 +183,9 @@ func _floor_ray_feet_y_median( var y_vals: Array[float] = [] for k: int in range(offs.size()): var y_one: float = _floor_ray_feet_y(space, anchor_feet_xz, offs[k]) - if y_one != _FLOOR_RAY_FEET_INVALID: + if y_one != FLOOR_RAY_FEET_INVALID: y_vals.append(y_one) - if y_vals.is_empty(): - return _FLOOR_RAY_FEET_INVALID - y_vals.sort() - var n_hit: int = y_vals.size() - var mid: int = (n_hit - 1) >> 1 - return y_vals[mid] + return median_feet_y_from_samples(y_vals) func run_wasd(delta: float) -> void: @@ -203,7 +243,7 @@ func run_wasd(delta: float) -> void: var probe_feet_y: float = _floor_ray_feet_y_median( w3d.direct_space_state, scrape_ray_anchor_feet_xz, wish_probe ) - if probe_feet_y != _FLOOR_RAY_FEET_INVALID: + if probe_feet_y != FLOOR_RAY_FEET_INVALID: var raw_absorb_dy: float = probe_feet_y - feet_after var absorb_dy: float = raw_absorb_dy if absf(raw_absorb_dy) > LOCOMOTION_SCRAPE_RAY_FEET_ABSORB_MAX: diff --git a/client/test/player_locomotion_wasd_test.gd b/client/test/player_locomotion_wasd_test.gd new file mode 100644 index 0000000..1fe16dc --- /dev/null +++ b/client/test/player_locomotion_wasd_test.gd @@ -0,0 +1,115 @@ +# Pure helpers on res://scripts/player_locomotion_wasd.gd (NEO-22 WASD seam math). +extends GdUnitTestSuite + +const PlayerLocomotionWasdScript := preload("res://scripts/player_locomotion_wasd.gd") + + +func test_evaluate_floor_ray_hit_y_rejects_hit_too_far_below_feet() -> void: + var feet_y := 0.0 + var hit_y := feet_y - 0.2 + var n := Vector3.UP + var out: float = PlayerLocomotionWasdScript.evaluate_floor_ray_hit_y( + feet_y, hit_y, n, 0.108, 0.42 + ) + assert_that(out).is_equal(PlayerLocomotionWasdScript.FLOOR_RAY_FEET_INVALID) + + +func test_evaluate_floor_ray_hit_y_rejects_shallow_floor_normal() -> void: + var feet_y := 0.0 + var hit_y := 0.0 + # Up component ~0.37 so dot(Vector3.UP, n) is below min_floor_up_dot (0.42). + var n := Vector3(0.85, 0.35, 0.0).normalized() + var out: float = PlayerLocomotionWasdScript.evaluate_floor_ray_hit_y( + feet_y, hit_y, n, 0.108, 0.42 + ) + assert_that(out).is_equal(PlayerLocomotionWasdScript.FLOOR_RAY_FEET_INVALID) + + +func test_evaluate_floor_ray_hit_y_accepts_valid_hit() -> void: + var feet_y := 0.0 + var hit_y := -0.04 + var n := Vector3.UP + var out: float = PlayerLocomotionWasdScript.evaluate_floor_ray_hit_y( + feet_y, hit_y, n, 0.108, 0.42 + ) + assert_that(out).is_equal(hit_y) + + +func test_median_feet_y_from_samples_empty_is_invalid() -> void: + var empty: Array[float] = [] + var out: float = PlayerLocomotionWasdScript.median_feet_y_from_samples(empty) + assert_that(out).is_equal(PlayerLocomotionWasdScript.FLOOR_RAY_FEET_INVALID) + + +func test_median_feet_y_from_samples_single() -> void: + var out: float = PlayerLocomotionWasdScript.median_feet_y_from_samples([0.12]) + assert_that(out).is_equal(0.12) + + +func test_median_feet_y_from_samples_three_sorted_middle() -> void: + var out: float = PlayerLocomotionWasdScript.median_feet_y_from_samples([0.3, 0.1, 0.2]) + assert_that(out).is_equal(0.2) + + +func test_median_feet_y_from_samples_two_uses_lower_index() -> void: + # Matches runtime: mid index (n-1)>>1 picks the smaller of two sorted values. + var out: float = PlayerLocomotionWasdScript.median_feet_y_from_samples([0.5, 0.1]) + assert_that(out).is_equal(0.1) + + +func test_xz_after_micro_slip_projection_zero_wish_unchanged() -> void: + var anchor := Vector2.ZERO + var player_xz := Vector2(0.02, 0.03) + var out: Vector2 = PlayerLocomotionWasdScript.xz_after_micro_slip_projection( + anchor, player_xz, Vector2.ZERO, 0.032 + ) + assert_that(out).is_equal(player_xz) + + +func test_xz_after_micro_slip_projection_large_perp_unchanged() -> void: + var anchor := Vector2.ZERO + var player_xz := Vector2(0.0, 0.2) + var wish := Vector2(1.0, 0.0) + var out: Vector2 = PlayerLocomotionWasdScript.xz_after_micro_slip_projection( + anchor, player_xz, wish, 0.032 + ) + assert_that(out).is_equal(player_xz) + + +func test_xz_after_micro_slip_projection_nudges_small_perp() -> void: + var anchor := Vector2.ZERO + var player_xz := Vector2(0.0, 0.01) + var wish := Vector2(1.0, 0.0) + var out: Vector2 = PlayerLocomotionWasdScript.xz_after_micro_slip_projection( + anchor, player_xz, wish, 0.032 + ) + assert_that(out.x).is_equal(0.0) + assert_that(out.y).is_equal(0.0) + + +func test_horizontal_velocity_aligned_to_wish_zero_wish_returns_velocity() -> void: + var out: Vector2 = PlayerLocomotionWasdScript.horizontal_velocity_aligned_to_wish( + Vector2.ZERO, Vector2(3.0, 4.0), 5.0 + ) + assert_that(out).is_equal(Vector2(3.0, 4.0)) + + +func test_horizontal_velocity_aligned_to_wish_clamps_negative_along_to_zero() -> void: + var out: Vector2 = PlayerLocomotionWasdScript.horizontal_velocity_aligned_to_wish( + Vector2(1.0, 0.0), Vector2(-2.0, 0.0), 5.0 + ) + assert_that(out).is_equal(Vector2.ZERO) + + +func test_horizontal_velocity_aligned_to_wish_caps_speed() -> void: + var out: Vector2 = PlayerLocomotionWasdScript.horizontal_velocity_aligned_to_wish( + Vector2(1.0, 0.0), Vector2(10.0, 0.0), 5.0 + ) + assert_that(out).is_equal(Vector2(5.0, 0.0)) + + +func test_horizontal_velocity_aligned_to_wish_preserves_wish_direction() -> void: + var out: Vector2 = PlayerLocomotionWasdScript.horizontal_velocity_aligned_to_wish( + Vector2(0.0, 1.0), Vector2(0.0, 2.0), 5.0 + ) + assert_that(out).is_equal(Vector2(0.0, 2.0)) diff --git a/docs/plans/NEO-22-implementation-plan.md b/docs/plans/NEO-22-implementation-plan.md index de330fa..23afe5a 100644 --- a/docs/plans/NEO-22-implementation-plan.md +++ b/docs/plans/NEO-22-implementation-plan.md @@ -37,7 +37,7 @@ - [ ] **Authoritative position** still converges with the server via the **stream / small-step** contract (option **C**); README + tests describe batching or per-tick cadence so CI + manual steps do not lie. - [ ] **Cold boot `snap_to_server`** still works; no spawn underground / stale goal regressions. - [ ] **NEO-10** rejection UX: if clicks are removed, another path still exercises reject payloads in dev (automated test or documented console-only command) **or** clicks remain only for “set goal” debug, not default locomotion. -- [ ] **GdUnit** (or existing harness) covers at least one pure helper for wish-dir / speed clamp if logic is non-trivial. +- [x] **GdUnit** (or existing harness) covers pure helpers on `player_locomotion_wasd.gd` (floor-ray continuation, median feet Y, micro-slip XZ projection, wish-aligned horizontal velocity). ## Technical approach @@ -89,6 +89,7 @@ | File | Coverage | |------|----------| | `client/test/player_test.gd` | **Change:** remove or replace tests that assumed `_has_walk_goal` / nav steering; add tests for any extracted **wish-direction** or speed clamp helper. | +| `client/test/player_locomotion_wasd_test.gd` | **Add:** unit tests for static seam/locomotion math (`evaluate_floor_ray_hit_y`, median samples, micro-slip projection, horizontal velocity alignment). | | `client/test/position_authority_client_test.gd` | **Change:** align with **stream** submit cadence, pending queue, and sequence handling. | | `server/NeonSprawl.Server.Tests/Game/PositionState/MoveCommandApiTests.cs` | **Change only if** legacy `POST …/move` behavior or shared validation changes; otherwise leave as regression guard for one-shot moves. | | **Manual (required)** | README path: boot with server, WASD on flat + stepped geometry, `snap_to_server`, and whichever NEO-10 rejection path remains after click removal. | diff --git a/docs/reviews/2026-04-17-NEO-22.md b/docs/reviews/2026-04-17-NEO-22.md index 0503805..063247c 100644 --- a/docs/reviews/2026-04-17-NEO-22.md +++ b/docs/reviews/2026-04-17-NEO-22.md @@ -33,7 +33,7 @@ The WASD tick implements floor coyote time, wall-scrape detection (including sli 2. **Hot-path calls:** Scrape vertical contact checks can scan all slide collisions and are invoked multiple times in one WASD tick. Caching the result once per tick would reduce work and keep behavior deterministic if slide ordering ever changes. -3. **NEO-22 test AC:** The plan asks for GdUnit coverage when wish/scrape logic is non-trivial. Floor-ray feet Y, micro-slip projection, and wish alignment helpers in `player_locomotion_wasd.gd` are good candidates for small pure tests with a stubbed `PhysicsDirectSpaceState3D` or extracted math-only helpers. +3. ~~**NEO-22 test AC:** The plan asks for GdUnit coverage when wish/scrape logic is non-trivial. Floor-ray feet Y, micro-slip projection, and wish alignment helpers in `player_locomotion_wasd.gd` are good candidates for small pure tests with a stubbed `PhysicsDirectSpaceState3D` or extracted math-only helpers.~~ Done. `client/test/player_locomotion_wasd_test.gd` covers the extracted static helpers; full `run_wasd` + `move_and_slide` remains manual / integration territory. 4. **Readability:** The post–`move_and_slide` block repeats the same scrape-contact predicates; folding into a single boolean or early section may reduce mistake surface when tuning.