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.
pull/42/head
VinPropane 2026-04-17 20:16:25 -04:00
parent 90a17bc43d
commit c79c58c298
5 changed files with 193 additions and 37 deletions

View File

@ -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. **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. **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.

View File

@ -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_PROBE_Y_LERP: float = 0.42
const LOCOMOTION_SCRAPE_RAY_FEET_MEDIAN_SPAN: float = 0.055 const LOCOMOTION_SCRAPE_RAY_FEET_MEDIAN_SPAN: float = 0.055
const LOCOMOTION_SCRAPE_WISH_LINE_MICRO_PERP_M: float = 0.032 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 ## Must match [code]player.gd[/code] walk continuation probe (same ray math as
## [method _walk_ray_hit_up_floor]). ## [method _walk_ray_hit_up_floor]).
const _WALK_SUPPORT_PROBE_DEPTH: float = 0.32 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 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( func _init(
player: CharacterBody3D, player: CharacterBody3D,
cap_half: float, 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: 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 p2: Vector2 = Vector2(_player.global_position.x, _player.global_position.z)
var along: float = (p2 - anchor_xz).dot(w2n) var new_xz: Vector2 = xz_after_micro_slip_projection(
var p2_on: Vector2 = anchor_xz + w2n * along anchor_xz, p2, wish_xz, LOCOMOTION_SCRAPE_WISH_LINE_MICRO_PERP_M
var perp_len: float = (p2 - p2_on).length() )
if perp_len > LOCOMOTION_SCRAPE_WISH_LINE_MICRO_PERP_M or perp_len <= 1e-7: if new_xz.is_equal_approx(p2):
return return
_player.global_position.x = p2_on.x _player.global_position.x = new_xz.x
_player.global_position.z = p2_on.y _player.global_position.z = new_xz.y
func _align_velocity_xz_to_wish() -> void: func _align_velocity_xz_to_wish() -> void:
var wish: Vector3 = _player._locomotion_wish_world_xz var wish: Vector3 = _player._locomotion_wish_world_xz
var w2 := Vector2(wish.x, wish.z) 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 v2 := Vector2(_player.velocity.x, _player.velocity.z)
var along: float = v2.dot(w2) var out: Vector2 = horizontal_velocity_aligned_to_wish(w2, v2, _move_speed)
if along < 0.0: _player.velocity.x = out.x
along = 0.0 _player.velocity.z = out.y
along = minf(_move_speed, along)
_player.velocity.x = w2.x * along
_player.velocity.z = w2.y * along
func _floor_ray_feet_y( func _floor_ray_feet_y(
@ -115,14 +162,12 @@ func _floor_ray_feet_y(
q.exclude = [_player.get_rid()] q.exclude = [_player.get_rid()]
var res: Dictionary = space.intersect_ray(q) var res: Dictionary = space.intersect_ray(q)
if res.is_empty() or not res.has("normal") or not res.has("position"): 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 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"] var n: Vector3 = res["normal"]
if n.dot(Vector3.UP) <= _WALK_SUPPORT_PROBE_MIN_UP_DOT: return evaluate_floor_ray_hit_y(
return _FLOOR_RAY_FEET_INVALID feet_y, hit_y, n, _WALK_CONTINUATION_MAX_BELOW_FEET, _WALK_SUPPORT_PROBE_MIN_UP_DOT
return hit_y )
func _floor_ray_feet_y_median( func _floor_ray_feet_y_median(
@ -138,14 +183,9 @@ func _floor_ray_feet_y_median(
var y_vals: Array[float] = [] var y_vals: Array[float] = []
for k: int in range(offs.size()): for k: int in range(offs.size()):
var y_one: float = _floor_ray_feet_y(space, anchor_feet_xz, offs[k]) 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) y_vals.append(y_one)
if y_vals.is_empty(): return median_feet_y_from_samples(y_vals)
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]
func run_wasd(delta: float) -> void: 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( var probe_feet_y: float = _floor_ray_feet_y_median(
w3d.direct_space_state, scrape_ray_anchor_feet_xz, wish_probe 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 raw_absorb_dy: float = probe_feet_y - feet_after
var absorb_dy: float = raw_absorb_dy var absorb_dy: float = raw_absorb_dy
if absf(raw_absorb_dy) > LOCOMOTION_SCRAPE_RAY_FEET_ABSORB_MAX: if absf(raw_absorb_dy) > LOCOMOTION_SCRAPE_RAY_FEET_ABSORB_MAX:

View File

@ -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))

View File

@ -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. - [ ] **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. - [ ] **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. - [ ] **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 ## Technical approach
@ -89,6 +89,7 @@
| File | Coverage | | 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_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. | | `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. | | `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. | | **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. |

View File

@ -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. 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. 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.