NEO-22: Remove click-to-move client and legacy move POST/verify

Delete ground_pick and GroundPick wiring; PositionAuthorityClient now only
boot GET and move-stream POST. main.gd snaps on authoritative position only.
Update client/server READMEs, cursor rule example, NEO-22 plan note, and
GdUnit authority tests (stream 400 + boot resync).
pull/42/head
VinPropane 2026-04-17 20:06:46 -04:00
parent 76ff2cddac
commit a4095cb9bf
10 changed files with 62 additions and 345 deletions

View File

@ -12,7 +12,7 @@ Avoid a single **monolithic `main.gd`**. Treat the main scene root as a **compos
- **`main.gd` (scene root):** Bootstrapping only—attach children, connect signals, optional high-level toggles. If a block grows past ~50 lines or a clear concern appears, **extract** it.
- **Avatar / props:** Keep behavior on the node that owns it (e.g. `player.gd` on `CharacterBody3D`).
- **Cross-cutting features:** Prefer a **child `Node`** (or `Node3D`) with its own script, e.g. ground picking → `ground_pick.gd`, server HTTP sync → `position_authority_client.gd`. Use **signals** or small public methods so `main` does not call into deep implementation details.
- **Cross-cutting features:** Prefer a **child `Node`** (or `Node3D`) with its own script, e.g. server HTTP sync → `position_authority_client.gd`, interaction POST → `interaction_request_client.gd`. Use **signals** or small public methods so `main` does not call into deep implementation details.
## `class_name` vs headless / CI
@ -30,8 +30,8 @@ Use **sparingly** (e.g. shared config many scenes need). Default to **scene-loca
## Examples
```text
✅ main.gd: _ready() → get_node, connect ground_pick.target_selected to _on_target
ground_pick.gd: raycast + walkable check → emit target_selected(Vector3)
✅ main.gd: _ready() → get_node, connect authority.authoritative_position_received to _on_snap
position_authority_client.gd: HTTP + emit authoritative_position_received
❌ main.gd: 300 lines of HTTP + ray math + player tuning in one file
```

View File

@ -13,7 +13,7 @@ Do not grow an all-in-one **`main.gd`**. The main scene root should **compose**
## Follow camera (NEO-15)
The main scene uses **`World/IsometricFollowCamera`** (`scripts/isometric_follow_camera.gd`): client-local **isometric follow** for **`Player`** (`NodePath` **`../Player`**), with damped eye motion (`follow_smoothness`, exponential lerp) and **teleport snap** when the eye is farther than **`snap_distance`** from the desired pose (e.g. after **`snap_to_server`**). Framing exports **`follow_distance`**, **`pitch_elevation_deg`**, and **`presentation_yaw_deg`** match the preNEO-15 static camera at spawn. **Orbit is off in the prototype:** **`allow_yaw`** is false, **`CameraState.yaw`** stays **0**, and no rotate input is read—enable **`allow_yaw`** later and drive **`_orbit_yaw_rad`** from input on the rig (see **`docs/decomposition/modules/E1_M2_IsometricCameraController.md`**). **`CameraState`** (`scripts/camera_state.gd`) is refreshed every **`_physics_process`** tick on the rig (**`process_physics_priority`** after **`Player`**) so framing tracks **`move_and_slide`** without display-vs-physics jitter. **`ground_pick`** uses the viewport camera; **`main.gd`** sets **`fallback_camera`** to **`World/IsometricFollowCamera/Camera3D`**.
The main scene uses **`World/IsometricFollowCamera`** (`scripts/isometric_follow_camera.gd`): client-local **isometric follow** for **`Player`** (`NodePath` **`../Player`**), with damped eye motion (`follow_smoothness`, exponential lerp) and **teleport snap** when the eye is farther than **`snap_distance`** from the desired pose (e.g. after **`snap_to_server`**). Framing exports **`follow_distance`**, **`pitch_elevation_deg`**, and **`presentation_yaw_deg`** match the preNEO-15 static camera at spawn. **Orbit is off in the prototype:** **`allow_yaw`** is false, **`CameraState.yaw`** stays **0**, and no rotate input is read—enable **`allow_yaw`** later and drive **`_orbit_yaw_rad`** from input on the rig (see **`docs/decomposition/modules/E1_M2_IsometricCameraController.md`**). **`CameraState`** (`scripts/camera_state.gd`) is refreshed every **`_physics_process`** tick on the rig (**`process_physics_priority`** after **`Player`**) so framing tracks **`move_and_slide`** without display-vs-physics jitter.
## Prototype district (NEON-29)
@ -29,7 +29,7 @@ Elevated walkables use **distinct albedo tints** in `scenes/main.tscn` so screen
| `PrototypeTerminal` | `walkable` | Slate blue | 0.9 × 0.4 | (0, 0) | Interaction target |
| `MoveRejectPedestal` | `walkable` | Orange | 1.5 × 1.5 | (7.5, 6.5) | NEON-7 vertical reject |
| `MoveRejectFarPad` | `walkable` | Blue-gray | 2.5 × 2.5 | (9, 9) | Near-floor walkable pad; no longer a reject target (horizontal limit removed NEON-29) |
| `Obstacle` | `occluder` | Default gray | 2 × 2 | (6, 5) | Original occlusion / click-through target |
| `Obstacle` | `occluder` | Default gray | 2 × 2 | (6, 5) | Occlusion policy demo |
| `ObstacleB` | `occluder` | Default gray | 3 × 3 | (10, 5) | Second occluder (wider angle from spawn) |
| `ObstacleC` | `occluder` | Default gray | 2 × 4 | (5, 12) | Tall occluder, south quadrant |
| `ObstacleD` | `occluder` | Default gray | 3 × 2 | (8, 16) | Far-south occluder |
@ -40,30 +40,30 @@ Elevated walkables use **distinct albedo tints** in `scenes/main.tscn` so screen
### Height variation notes
- **Single-step terraces (A, C):** 0.3 m rise — within `agent_max_climb = 0.35`, so the nav mesh routes directly from floor onto the pad; one click suffices. **`TerracePlatformA`** (teal) sits **12 mm** above the main floor slab and has **cardinal walkable treads** (`TerracePlatformA_Approach`) so movement does not rely on a bare vertical box lip; nav rebakes at startup.
- **Two-level terrace B:** floor → `TerraceStepB` (**gold**, 0.3 m) → `TerracePlatformB` (**violet**, +0.3 m). **`agent_max_climb = 0.35`** applies to the nav mesh **and** to **walk step assist**: you cannot cheese the violet deck from the floor in one move — click the **gold step** (or the platform after you are already on the step), not the violet top from below. **Approach treads** on gold (**`TSB_*`**) and on violets **north / east / west** (**`TPB_*`**, six risers for 0.6 m) match the teal convention.
- **Single-step terraces (A, C):** 0.3 m rise — within `agent_max_climb = 0.35`, so the nav mesh routes directly from floor onto the pad; one steady WASD approach suffices. **`TerracePlatformA`** (teal) sits **12 mm** above the main floor slab and has **cardinal walkable treads** (`TerracePlatformA_Approach`) so movement does not rely on a bare vertical box lip; nav rebakes at startup.
- **Two-level terrace B:** floor → `TerraceStepB` (**gold**, 0.3 m) → `TerracePlatformB` (**violet**, +0.3 m). **`agent_max_climb = 0.35`** applies to the nav mesh **and** to **walk step assist**: you cannot cheese the violet deck from the floor in one move — use the **gold step** (or the platform after you are already on the step), not the violet top from below. **Approach treads** on gold (**`TSB_*`**) and on violets **north / east / west** (**`TPB_*`**, six risers for 0.6 m) match the teal convention.
- **QA / thin props:** **`PrototypeTerminal`**, **`MoveRejectPedestal`**, **`MoveRejectFarPad`** are unchanged (terminal kiosk, vertical reject demo, near-floor pad). Add ramps or treads later if they need full walk-up treatment.
- **Occluders** are plain `StaticBody3D` with no `walkable` tag; the nav bake excludes their top surfaces, so they remain true obstacles for routing.
### Designer / QA limits
- Floor bounds: **±22.5 m** in X and Z from world origin. Clicks outside the `NavigationRegion3D` boundary (beyond the floor edge) will be rejected by `NavigationAgent3D` path queries.
- Floor bounds: **±22.5 m** in X and Z from world origin. Nav queries outside the `NavigationRegion3D` boundary (beyond the floor edge) fail as expected.
- The `MoveRejectPedestal` (orange box) top is ~2.5 m above the floor and will be rejected by the server's `MaxVerticalStep` (**2.2 m** by default) — the only remaining move-rejection criterion with default server config. Horizontal distance is never a rejection reason when `HorizontalStepEnabled` is false.
- New obstacles are tagged `"occluder"` so the NEON-27 occlusion policy and NEON-30 click-through both apply automatically.
- New obstacles are tagged `"occluder"` so the NEON-27 occlusion policy applies automatically.
## Authoritative movement (NEO-7, NEO-11, NEO-22)
With the game server running ([`server/README.md`](../server/README.md)), **WASD** (input actions **`move_*`** in `project.godot`) drives **camera-relative XZ** wish direction on the **`Player`**, and **`main.gd`** periodically **`POST`**s **`/game/players/{id}/move-stream`** with the **latest** body-centre sample while you are moving (no stream while standing still, so idle anchor does not fight HTTP). **`PositionAuthorityClient`** coalesces to **one target per request** so a slow round-trip cannot apply a stale multi-step chain after the capsule has moved. The server validates each leg with the same rules as a single **`MoveCommand`**, applies **all targets in order** (reject = **no** partial apply), and returns **`PositionStateResponse`**. A successful **`move-stream`** response is treated as an **ack only** (no client teleport): the echoed position typically trails **`move_and_slide`** by roughly one RTT, so snapping it caused rubber-banding. **Boot** `GET` and **debug** click-move **verify** `GET` still emit **`authoritative_position_received`** so **`main.gd`** can **`snap_to_server`** once or set a **nav walk goal**. **`POST /game/players/{id}/move`** remains for **debug** click-to-move when running a **debug** Godot build (`ground_pick.gd` only listens in **`OS.is_debug_build()`**): it still does **POST** + **`GET`** verify and sets a **nav walk goal** on the player.
With the game server running ([`server/README.md`](../server/README.md)), **WASD** (input actions **`move_*`** in `project.godot`) drives **camera-relative XZ** wish direction on the **`Player`**, and **`main.gd`** periodically **`POST`**s **`/game/players/{id}/move-stream`** with the **latest** body-centre sample while you are moving (no stream while standing still, so idle anchor does not fight HTTP). **`PositionAuthorityClient`** coalesces to **one target per request** so a slow round-trip cannot apply a stale multi-step chain after the capsule has moved. The server validates each leg with the same rules as a single **`MoveCommand`**, applies **all targets in order** (reject = **no** partial apply), and returns **`PositionStateResponse`**. A successful **`move-stream`** response is treated as an **ack only** (no client teleport): the echoed position typically trails **`move_and_slide`** by roughly one RTT, so snapping it caused rubber-banding. **`authoritative_position_received`** is emitted only from **boot** `GET` so **`main.gd`** can **`snap_to_server`**. Left-click **ground pick / `POST /move`** has been removed from the client.
**Boot** `sync_from_server()` **snaps** once so spawn matches the server.
**Tradeoff (prototype):** Horizontal motion is a **direct XZ bee-line** toward the servers verified target. The baked **`NavigationMesh`** is still used when **vertical routing** is latched **and** the capsule is inside the **nav column** hysteresis band (`NAV_COLUMN_STEER_ENTER_DIST` / `EXIT`) **and** **`is_on_floor()`** — terraces / steps then steer along **`get_current_navigation_path()`** using the first waypoint at least **`NAV_PATH_STEER_MIN_LOOKAHEAD`** (~22 cm) away in XZ (unlike a **5 cm** scan or raw **`get_next_path_position()`**, which both sat inside Jolts per-tick slide and could flip velocity **180°** every tick). While **airborne**, column path steering is **off** so horizontal motion stays a bee-line toward the click (avoids fall-time XZ oscillation). **No descend bypass** for gravity while on an upper deck (same as before). While walking, **gravity** still runs when **`is_on_floor()`** is false; if **snap / lip contacts** keep **`is_on_floor()`** true over open space (e.g. gold deck → gray floor), **`player.gd`** casts **several short down rays** under the **foot disk** (center + offsets along move direction so the **leading** edge off a deck is tested) and applies **gravity** when **none** hit upward-facing ground within **`WALK_SUPPORT_PROBE_DEPTH`**. In that case **`floor_snap_length`** is set to **0** for the tick so **moving snap** does not cancel **`velocity.y`** on every physics step. **Automatic routing around tall obstacles on one click is still not guaranteed** — use **several clicks** to go around e.g. the gray **`Obstacle`** when needed.
**Tradeoff (prototype):** Horizontal motion is a **direct XZ bee-line** toward the servers verified target. The baked **`NavigationMesh`** is still used when **vertical routing** is latched **and** the capsule is inside the **nav column** hysteresis band (`NAV_COLUMN_STEER_ENTER_DIST` / `EXIT`) **and** **`is_on_floor()`** — terraces / steps then steer along **`get_current_navigation_path()`** using the first waypoint at least **`NAV_PATH_STEER_MIN_LOOKAHEAD`** (~22 cm) away in XZ (unlike a **5 cm** scan or raw **`get_next_path_position()`**, which both sat inside Jolts per-tick slide and could flip velocity **180°** every tick). While **airborne**, column path steering is **off** so horizontal motion stays a bee-line toward the movement goal (avoids fall-time XZ oscillation). **No descend bypass** for gravity while on an upper deck (same as before). While walking, **gravity** still runs when **`is_on_floor()`** is false; if **snap / lip contacts** keep **`is_on_floor()`** true over open space (e.g. gold deck → gray floor), **`player.gd`** casts **several short down rays** under the **foot disk** (center + offsets along move direction so the **leading** edge off a deck is tested) and applies **gravity** when **none** hit upward-facing ground within **`WALK_SUPPORT_PROBE_DEPTH`**. In that case **`floor_snap_length`** is set to **0** for the tick so **moving snap** does not cancel **`velocity.y`** on every physics step. **Automatic routing around tall obstacles in one straight push is still not guaranteed** — steer around e.g. the gray **`Obstacle`** with WASD as needed.
**NEO-10 / movement QA bumps:** On **run**, **`spawn_short_random_bumps`** adds **two** green cylinders, each on its own **`StaticBody3D`** **sibling** of **`Floor`** under **`NavigationRegion3D`** (**`walkable`** on bump roots — avoids compound **internal-edge** jitter vs floor+cylinder on one body). Bump meshes use Godot group **`random_floor_bump_mesh`**. **Collision radius** = mesh **+ `COLLISION_RADIUS_EXTRA`** (see **`scripts/random_floor_bump_collision_constants.gd`**, capped by **`COLLISION_RADIUS_MAX`**). **`bake_navigation_mesh(false)`** after spawn.
**Idle stability (NEO-14 / NEON-16):** **Jolt Physics**; **TPS** **120**. **`physics/common/physics_interpolation`** is **off** — with **on**, small physics **position** changes on bump **edges** were **blended** across render frames and looked like **ghosting / extra jitter**. **`snap_to_server()`** still calls **`reset_physics_interpolation()`** for compatibility if you turn interpolation on later. **`floor_max_angle`** **~50°** walking / **~35°** idle; **loose** angle **~0.8 s** after walk stops. **Walk step assist**. **Idle rim / straddle:** **moving** `floor_max_angle` when floor normal is **shallow** or slide hits mix **floor + wall**. One idle **`move_and_slide()`**, rim **settle**, **`random_floor_bump_mesh`** **lip / rim / vertical-wall** escape (**`IDLE_BUMP_ESCAPE_STEP`**, **`PLAYER_CAPSULE_RADIUS`**, collider fudge from **`random_floor_bump_collision_constants.gd`**). **Flat idle hardening:** when support is already effectively level (floor normal almost **`Vector3.UP`** and no post-arrival loose-floor window), **`player.gd`** now skips the corrective idle slide / escape loop instead of nudging the capsule anyway, even if physics reports incidental extra contacts on an otherwise flat support. In that stable grounded state the player also keeps an **idle x/z anchor**, so any residual horizontal creep is clamped away until movement resumes or support stops looking stable. **Uniform shallow ramps** can latch stable idle; **nav-column path steering** is gated to flatter floors so ramp ribbons do not flip velocity each tick. Goal arrival and descend-bypass checks both use the capsule **feet height** rather than the body origin, so flat floor clicks clear and route normally instead of lingering in a straight-line obstacle push. **`Player`:** interp **Off**, **`avoidance_enabled`** **false**, **`floor_block_on_wall`** **true** — do **not** rewrite **`global_transform`** in **`_process`** (can **ghost** **`CharacterBody3D`**). **`_snap_capsule_upright()`** now short-circuits when the basis is already identity. Idle **`FLOOR_SNAP_IDLE`** ~**11 cm**; walking **`FLOOR_SNAP_MOVING`** ~**0.32**. **Rendering:** **`Mat_player_capsule`**, **`cast_shadow = 0`**, capsule mesh **+Y ~3.4 cm** (visual-only vs **`CapsuleShape3D`** — less **z-fight** vs floor/bump tops), **`light_specular = 0`**, **`msaa_3d = 2`**. **`safe_margin`** **0.055**.
**Idle stability (NEO-14 / NEON-16):** **Jolt Physics**; **TPS** **120**. **`physics/common/physics_interpolation`** is **off** — with **on**, small physics **position** changes on bump **edges** were **blended** across render frames and looked like **ghosting / extra jitter**. **`snap_to_server()`** still calls **`reset_physics_interpolation()`** for compatibility if you turn interpolation on later. **`floor_max_angle`** **~50°** walking / **~35°** idle; **loose** angle **~0.8 s** after walk stops. **Walk step assist**. **Idle rim / straddle:** **moving** `floor_max_angle` when floor normal is **shallow** or slide hits mix **floor + wall**. One idle **`move_and_slide()`**, rim **settle**, **`random_floor_bump_mesh`** **lip / rim / vertical-wall** escape (**`IDLE_BUMP_ESCAPE_STEP`**, **`PLAYER_CAPSULE_RADIUS`**, collider fudge from **`random_floor_bump_collision_constants.gd`**). **Flat idle hardening:** when support is already effectively level (floor normal almost **`Vector3.UP`** and no post-arrival loose-floor window), **`player.gd`** now skips the corrective idle slide / escape loop instead of nudging the capsule anyway, even if physics reports incidental extra contacts on an otherwise flat support. In that stable grounded state the player also keeps an **idle x/z anchor**, so any residual horizontal creep is clamped away until movement resumes or support stops looking stable. **Uniform shallow ramps** can latch stable idle; **nav-column path steering** is gated to flatter floors so ramp ribbons do not flip velocity each tick. Goal arrival and descend-bypass checks both use the capsule **feet height** rather than the body origin, so flat-floor arrivals clear and route normally instead of lingering in a straight-line obstacle push. **`Player`:** interp **Off**, **`avoidance_enabled`** **false**, **`floor_block_on_wall`** **true** — do **not** rewrite **`global_transform`** in **`_process`** (can **ghost** **`CharacterBody3D`**). **`_snap_capsule_upright()`** now short-circuits when the basis is already identity. Idle **`FLOOR_SNAP_IDLE`** ~**11 cm**; walking **`FLOOR_SNAP_MOVING`** ~**0.32**. **Rendering:** **`Mat_player_capsule`**, **`cast_shadow = 0`**, capsule mesh **+Y ~3.4 cm** (visual-only vs **`CapsuleShape3D`** — less **z-fight** vs floor/bump tops), **`light_specular = 0`**, **`msaa_3d = 2`**. **`safe_margin`** **0.055**.
- **Scripts:** `scripts/ground_pick.gd` (walkable pick + `target_chosen`; occluder bodies tagged `"occluder"` are passed through unconditionally so clicks reach the ground behind them — NEO-20), `scripts/position_authority_client.gd` (`PositionAuthorityClient`: POST move, GET verify; second signal arg = boot snap vs nav goal), `scripts/player.gd` (path-follow), `scripts/isometric_follow_camera.gd` + `scripts/camera_state.gd` (NEO-15 follow), thin `scripts/main.gd` (nav bake on first frame, then wiring).
- **Scripts:** `scripts/position_authority_client.gd` (`PositionAuthorityClient`: boot `GET`, `move-stream` `POST`), `scripts/player.gd` (WASD locomotion + nav assist when a goal exists), `scripts/isometric_follow_camera.gd` + `scripts/camera_state.gd` (NEO-15 follow), thin `scripts/main.gd` (nav bake on first frame, then wiring).
- **Scene:** `scenes/main.tscn` — walkable **`StaticBody3D`** geometry lives under **`World/NavigationRegion3D`**. `main.gd` waits one **`process_frame`**, spawns **random test bumps** on **`Floor`**, then **`bake_navigation_mesh(false)`** (main-thread bake), then waits two **`physics_frame`**s so **`NavigationServer3D`** has a map before agents query paths. The baked **`NavigationMesh`** asset in the scene file is **stale** until you run or re-bake in the editor.
- **Inspector:** select **`PositionAuthorityClient`** on the main scene to set **`base_url`** (default `http://127.0.0.1:5253`) and **`dev_player_id`** (default `dev-local-1`, must match server `Game:DevPlayerId`).
@ -73,11 +73,10 @@ With the game server running ([`server/README.md`](../server/README.md)), **WASD
2. If the port differs, set **`base_url`** on **`PositionAuthorityClient`** accordingly (e.g. `http://127.0.0.1:5253`).
3. Open the client in Godot and run the main scene (**F5**). The player should **snap** to the servers default position (e.g. **(-5, 0.9, -5)** per `Game:DefaultPosition` / NEO-9 walk demo).
4. **WASD:** walk on the floor; the HUD position label should update and the server should accept **`move-stream`** steps (watch server logs or use a proxy if needed). **Release keys** and confirm the capsule settles to **idle** without runaway drift.
5. **Debug build only — click-to-move:** **Left-click** the floor: the client **POST**s **`/move`**, then **GET**s position; the capsule **walks** toward that verified target with **nav** assist where the old prototype applies. Server-rejected clicks show the reject label.
6. Walk to the **orange reject pedestal** and try to climb it with WASD / jumps as applicable; **`move-stream`** should return **`vertical_step_exceeded`** when a requested step exceeds **`MaxVerticalStep`**. In a **debug** build, click the **pedestal top** to confirm the same **`vertical_step_exceeded`** path via **`/move`**. From the **physics ramp test block** top (**2 m**), clicking the floor should **succeed**. The `MoveRejectFarPad` (blue pad at (9, 9)) remains a normal walkable surface.
7. After movement, **stand idle** for **10+ seconds** and confirm the capsule does **not** visibly vibrate or drift in **x/z** on flat floor, then repeat on the **random green bumps** (positions change each run).
5. Walk to the **orange reject pedestal** and try to climb it with WASD; **`move-stream`** should return **`vertical_step_exceeded`** when a requested step exceeds **`MaxVerticalStep`** (watch the **`MoveRejectLabel`** or server logs). The `MoveRejectFarPad` (blue pad at (9, 9)) remains a normal walkable surface.
6. After movement, **stand idle** for **10+ seconds** and confirm the capsule does **not** visibly vibrate or drift in **x/z** on flat floor, then repeat on the **random green bumps** (positions change each run).
If the server is **down**, boot **`GET`** fails silently (check Output for warnings); clicks while a request is in flight are ignored until it finishes.
If the server is **down**, boot **`GET`** fails silently (check Output for warnings). **`move-stream`** submissions are skipped while the HTTP client is busy, then resume.
## Interaction + range preview (NEO-9)
@ -89,32 +88,20 @@ The main scene includes a **prototype terminal** at the map center (same world *
### Manual check (NEO-9)
1. Run the game server and client as in NEO-7.
2. **F5** in Godot: default spawn is out of range of the terminal; markers should stay **dim**. **Click-move** toward the center until markers **brighten** (within **3** m on the floor plane).
2. **F5** in Godot: default spawn is out of range of the terminal; markers should stay **dim**. **WASD** toward the center until markers **brighten** (within **3** m on the floor plane).
3. Press **E** (input action **`interact`** in `project.godot`): Output should show **`allowed=true`** when markers glow, **`allowed=false`** with **`reasonCode=out_of_range`** when dim (if you walk back out). Interaction uses **`_input`**, not `_unhandled_input`, so keys register reliably in the embedded **Game** dock; click the game view if the editor had focus elsewhere.
### Manual check (NEO-20 — occluder click-through)
1. Run the main scene (**F5**) with the server running.
2. **While `Obstacle` is opaque** (player not behind it): left-click the ground directly behind `World/NavigationRegion3D/Obstacle` (roughly `(6, 0, 5)` + a few units further). The player should receive a valid move target and walk toward that position — not stop or ignore the click.
3. **While `Obstacle` is faded** (player positioned so the occlusion policy is actively fading the obstacle): repeat the same click. Behavior should be identical — the occluder fade state must not affect pick success.
4. **Regression — normal floor:** click a clear area of the floor; confirm the player walks there as before.
5. **Regression — far pad / pedestal:** confirm click-rejection behavior on `MoveRejectPedestal` and `MoveRejectFarPad` is unchanged.
## Movement prototype (NEO-5 → NEO-11)
**`player.gd`** steers **XZ** toward the authoritative goal and uses **`NavigationAgent3D`**s **current path** only for the **vertical-routing** case above (on sufficiently flat floor); **`move_and_slide()`** does the motion. **`snap_to_server()`** remains for **boot** (and would apply for any future hard reconcile).
- The avatar is on **physics layer 2** with **collision_mask** **3** (scans layers **1** and **2** so all walkables/occluders pair reliably). Environment **StaticBody3D** nodes stay on **collision_layer** **1** with **collision_mask** **3** as well. The pick ray uses **mask 1** only, so clicks pass through the avatar and hit the floor.
- The avatar is on **physics layer 2** with **collision_mask** **3** (scans layers **1** and **2** so all walkables/occluders pair reliably). Environment **StaticBody3D** nodes stay on **collision_layer** **1** with **collision_mask** **3** as well.
### Manual check (remnants)
1. Run the main scene (**F5**) **with server running** for movement behavior above.
2. Without the server, startup sync does not apply authoritative position; behavior is undefined for this repos intended demo—**run the server** for the demo path.
### Clicks still ignored?
In the **Game** dock, **Input** must be active (not the **2D** / **3D** scene-picking tools) so events go to the game. If Input is on and clicks still do nothing, pick rays must use the **viewports current camera** and **mouse position** (the script does this so the embedded Game view matches the ray).
## First run
1. Install [Godot 4.x](https://godotengine.org/download).
@ -153,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`**, **`ground_pick.gd`** (walkable collider check + occluder ancestry check), **`isometric_follow_camera.gd`** (eye math + effective zoom distance), **`camera_state.gd`**, and **`zoom_band_config.gd`**. **`main.gd`**, full **`_input` / ray pick** flows, and scene wiring are **not** automated here—use manual checks above.
**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.
**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

@ -2,7 +2,6 @@
[ext_resource type="Script" uid="uid://bh04b3iify0hd" path="res://scripts/main.gd" id="1_main"]
[ext_resource type="Script" uid="uid://1jimgt3d4bjj" path="res://scripts/player.gd" id="2_player"]
[ext_resource type="Script" uid="uid://v0xpntk6uumr" path="res://scripts/ground_pick.gd" id="3_ground"]
[ext_resource type="Script" uid="uid://ds5fkbscljkxi" path="res://scripts/position_authority_client.gd" id="4_auth"]
[ext_resource type="Script" uid="uid://bv0xprp660hib" path="res://scripts/interaction_request_client.gd" id="5_ix"]
[ext_resource type="Script" uid="uid://n3p4f3fky3nv" path="res://scripts/interaction_radius_indicators.gd" id="6_rad"]
@ -1106,9 +1105,6 @@ cast_shadow = 0
mesh = SubResource("CapsuleMesh_player")
surface_material_override/0 = SubResource("Mat_player_capsule")
[node name="GroundPick" type="Node3D" parent="." unique_id=2500001]
script = ExtResource("3_ground")
[node name="PositionAuthorityClient" type="Node" parent="." unique_id=2500002]
script = ExtResource("4_auth")

View File

@ -1,126 +0,0 @@
extends Node3D
## NS-16: walkable ground ray pick. Emits world target for MoveCommand; camera wired from main.
## Flat tops on `occluder` bodies (horizontal normal) are valid destinations; vertical occluder
## faces still punch through so ground behind can be picked.
## NS-19: reject vertical faces (pedestal). Stepped ray: steep walkable hits (bump slopes) advance
## along the ray so the next hit can be flat floor beyond — fixes “stuck” when leaving plateau.
## (No `class_name` so headless/CI can load the project before `.godot` import exists.)
signal target_chosen(world: Vector3)
## Minimum `hit_normal.dot(Vector3.UP)` to accept a pick (0 = vertical wall, 1 = flat floor).
const MIN_WALKABLE_NORMAL_DOT_UP: float = 0.64
## Nudge past a steep walkable triangle so the ray can hit ground behind (meters along view ray).
const STEEP_PICK_THROUGH: float = 0.14
## Nudge past an occluder body so the ray reaches walkable ground behind it (meters along view ray).
const OCCLUDER_PICK_THROUGH: float = 0.14
## Max ray segments (steep walkable → advance) before giving up.
const MAX_PICK_SEGMENTS: int = 24
## Below this dot, treat as wall — do not step through (pedestal sides stay non-pickable).
const WALL_NORMAL_DOT_CUTOFF: float = 0.09
## Fallback when `Viewport.get_camera_3d()` is null (e.g. some editor setups). Set from `main.gd`
## in `_ready`.
var fallback_camera: Camera3D
func _ready() -> void:
# NEO-22: click-to-move is dev-only; WASD is default locomotion.
if OS.is_debug_build():
set_process_input(true)
func _input(event: InputEvent) -> void:
if event is InputEventMouseButton:
var mb := event as InputEventMouseButton
if mb.button_index == MOUSE_BUTTON_LEFT and mb.pressed:
_try_pick(get_viewport().get_mouse_position())
func _try_pick(screen_pos: Vector2) -> void:
var cam: Camera3D = get_viewport().get_camera_3d()
if cam == null:
cam = fallback_camera
if cam == null:
return
var ray_dir: Vector3 = cam.project_ray_normal(screen_pos)
if ray_dir.length_squared() < 1e-12:
return
ray_dir = ray_dir.normalized()
var from: Vector3 = cam.project_ray_origin(screen_pos)
var space: PhysicsDirectSpaceState3D = get_world_3d().direct_space_state
const RAY_LEN: float = 2000.0
var chosen: Vector3 = Vector3.ZERO
var have_chosen: bool = false
for _i in range(MAX_PICK_SEGMENTS):
var to: Vector3 = from + ray_dir * RAY_LEN
var query := PhysicsRayQueryParameters3D.create(from, to)
query.collision_mask = 1
var hit: Dictionary = space.intersect_ray(query)
if hit.is_empty():
break
var hit_collider: Variant = hit.get("collider")
if _collider_is_occluder(hit_collider):
var hit_normal_o: Variant = hit.get("normal", Vector3.ZERO)
if hit_normal_o is not Vector3:
break
var nrm_o: Vector3 = (hit_normal_o as Vector3).normalized()
var ndot_o: float = nrm_o.dot(Vector3.UP)
if ndot_o >= MIN_WALKABLE_NORMAL_DOT_UP:
var top_pos: Variant = hit.get("position")
if top_pos is Vector3:
chosen = top_pos as Vector3
have_chosen = true
break
var occluder_pos: Variant = hit.get("position")
if occluder_pos is not Vector3:
break
from = (occluder_pos as Vector3) + ray_dir * OCCLUDER_PICK_THROUGH
continue
if not _collider_is_walkable(hit_collider):
break
var hit_normal: Variant = hit.get("normal", Vector3.ZERO)
if hit_normal is not Vector3:
break
var nrm: Vector3 = (hit_normal as Vector3).normalized()
var ndot: float = nrm.dot(Vector3.UP)
if ndot >= MIN_WALKABLE_NORMAL_DOT_UP:
var pos_var: Variant = hit.get("position")
if pos_var is Vector3:
chosen = pos_var as Vector3
have_chosen = true
break
if ndot <= WALL_NORMAL_DOT_CUTOFF:
break
var hp_var: Variant = hit.get("position")
if hp_var is not Vector3:
break
from = (hp_var as Vector3) + ray_dir * STEEP_PICK_THROUGH
if have_chosen:
target_chosen.emit(chosen)
func _collider_is_walkable(collider: Variant) -> bool:
var n: Node = collider as Node
while n:
if n.is_in_group("walkable"):
return true
n = n.get_parent()
return false
func _collider_is_occluder(collider: Variant) -> bool:
var n: Node = collider as Node
while n:
if n.is_in_group("occluder"):
return true
n = n.get_parent()
return false

View File

@ -1,9 +1,9 @@
extends Node3D
## NS-16: composes ground pick + server authority; see `ground_pick.gd` /
## `position_authority_client.gd`. NS-18: interaction POST + radius glow preview (see child nodes).
## NS-16: server authority; see `position_authority_client.gd`. NS-18: interaction POST + radius
## glow preview (see child nodes).
## NEO-22: WASD locomotion + `move-stream` samples (see `player.gd` / `position_authority_client.gd`).
## NS-23: bakes `NavigationRegion3D` on startup; boot snap vs nav goal from authority signal.
## NS-23: bakes `NavigationRegion3D` on startup; boot snap from authority `GET`.
## NEO-15: follow camera is `World/IsometricFollowCamera/Camera3D`
## (see `isometric_follow_camera.gd`).
## Prototype: two random short bumps (sibling StaticBody3D under NavigationRegion3D; see
@ -56,7 +56,6 @@ var _dev_obstacle_smoke: Node3D
@onready var _world: Node3D = $World
@onready var _nav_region: NavigationRegion3D = $World/NavigationRegion3D
@onready var _player: CharacterBody3D = $World/Player
@onready var _ground_pick: Node3D = $GroundPick
@onready var _authority: Node = $PositionAuthorityClient
@onready var _radius_preview: Node3D = $World/InteractionMarkers
@onready var _move_reject_label: Label = $UICanvas/MoveRejectLabel
@ -76,8 +75,6 @@ func _ready() -> void:
_nav_region.bake_navigation_mesh(false)
await get_tree().physics_frame
await get_tree().physics_frame
_ground_pick.set("fallback_camera", _camera)
_ground_pick.connect("target_chosen", Callable(self, "_on_target_chosen"))
_authority.connect(
"authoritative_position_received", Callable(self, "_on_authoritative_position")
)
@ -127,37 +124,29 @@ func _locomotion_wish_world_xz_from_camera() -> Vector3:
return w.normalized()
func _on_target_chosen(world: Vector3) -> void:
_authority.call("submit_move_target", world)
func _on_authoritative_position(world: Vector3, apply_as_snap: bool) -> void:
if apply_as_snap:
var force: bool = _authority_force_snap_next
_authority_force_snap_next = false
var p: Vector3 = _player.global_position
var dh: float = Vector2(world.x - p.x, world.z - p.z).length()
var dy: float = absf(world.y - p.y)
if _allow_authority_soft_snap_skip and not force:
var wish_len2: float = _player._locomotion_wish_world_xz.length_squared()
var vel_hz_sq: float = Vector2(_player.velocity.x, _player.velocity.z).length_squared()
var locomoting: bool = (
wish_len2 > 1e-10 or vel_hz_sq > AUTH_SNAP_LOCOMOTION_VEL_HZ_SQ
)
if locomoting and dh < AUTH_SNAP_LOCOMOTION_SKIP_MAX_DH:
return
if dh < AUTH_SNAP_MIN_HORIZONTAL and dy < AUTH_SNAP_MIN_VERTICAL:
return
_allow_authority_soft_snap_skip = true
_player.snap_to_server(world)
else:
_player.set_authoritative_nav_goal(world)
if not apply_as_snap:
return
var force: bool = _authority_force_snap_next
_authority_force_snap_next = false
var p: Vector3 = _player.global_position
var dh: float = Vector2(world.x - p.x, world.z - p.z).length()
var dy: float = absf(world.y - p.y)
if _allow_authority_soft_snap_skip and not force:
var wish_len2: float = _player._locomotion_wish_world_xz.length_squared()
var vel_hz_sq: float = Vector2(_player.velocity.x, _player.velocity.z).length_squared()
var locomoting: bool = wish_len2 > 1e-10 or vel_hz_sq > AUTH_SNAP_LOCOMOTION_VEL_HZ_SQ
if locomoting and dh < AUTH_SNAP_LOCOMOTION_SKIP_MAX_DH:
return
if dh < AUTH_SNAP_MIN_HORIZONTAL and dy < AUTH_SNAP_MIN_VERTICAL:
return
_allow_authority_soft_snap_skip = true
_player.snap_to_server(world)
func _on_move_rejected(reason_code: String) -> void:
_authority_force_snap_next = true
# Rejected POST never reached VERIFY_GET, so no new nav target was applied; do not clear an
# in-flight path from a prior successful move.
# Rejected stream: server state may differ; next snap is forced.
push_warning("Move rejected: %s" % reason_code)
_move_reject_msg_token += 1
var token: int = _move_reject_msg_token

View File

@ -1,10 +1,9 @@
extends Node
## NS-16: POST MoveCommand then GET PositionState; emits authoritative world position for the
## avatar. NS-19: failed validation (HTTP 400 + rejection body) emits `move_rejected`.
## NS-23: second signal arg — `true` = boot snap, `false` = post-move (nav goal, no teleport).
## NEO-22: `submit_stream_targets` POSTs `move-stream` (batched small moves); no VERIFY GET. A 200
## response does not emit `authoritative_position_received` (ack-only; avoids RTT snap-back).
## `GET` position for boot snap; `submit_stream_targets` POSTs `move-stream` (WASD locomotion).
## NS-19: failed validation (HTTP 400 + rejection body) emits `move_rejected`.
## NS-23: second signal arg on `authoritative_position_received` — always `true` (boot snap only).
## A successful `move-stream` 200 is ack-only (no position signal; avoids RTT rubber-band).
## (No `class_name` so headless/CI can load the project before `.godot` import exists.)
signal authoritative_position_received(world: Vector3, apply_as_snap: bool)
@ -12,7 +11,7 @@ signal move_rejected(reason_code: String)
const MOVE_STREAM_MAX_PER_REQUEST: int = 12
enum Phase { BOOT_GET, POST_MOVE, VERIFY_GET, STREAM_POST }
enum Phase { BOOT_GET, STREAM_POST }
@export var base_url: String = "http://127.0.0.1:5253"
@export var dev_player_id: String = "dev-local-1"
@ -22,9 +21,6 @@ var _http: Node
var _busy: bool = false
var _phase: Phase = Phase.BOOT_GET
## Latest click target while [_busy] (POST or VERIFY in flight); coalesced so clicks are not lost.
var _pending_move_target: Variant = null
## Queued world samples for NEO-22 move-stream (FIFO).
var _stream_queue: Array[Vector3] = []
var _stream_in_flight_count: int = 0
@ -51,14 +47,6 @@ func sync_from_server() -> void:
_request_get()
func submit_move_target(world: Vector3) -> void:
if _busy:
_pending_move_target = world
return
_pending_move_target = null
_start_move_post(world)
## NEO-22: append samples; each POST sends **only the latest** queued position so slow HTTP cannot
## apply a stale multi-step chain after the capsule has already moved (rubber-band / bounce).
func submit_stream_targets(targets: Array) -> void:
@ -81,28 +69,6 @@ func _try_flush_pending() -> void:
var latest: Vector3 = _stream_queue[_stream_queue.size() - 1]
_stream_queue.clear()
_start_stream_post([latest])
return
if _pending_move_target is Vector3:
var w: Vector3 = _pending_move_target as Vector3
_pending_move_target = null
_start_move_post(w)
func _start_move_post(world: Vector3) -> void:
_busy = true
_phase = Phase.POST_MOVE
var url := "%s/game/players/%s/move" % [_base_root(), _player_path_segment()]
var payload := {
"schemaVersion": 1,
"target": {"x": world.x, "y": world.y, "z": world.z},
}
var body := JSON.stringify(payload)
var headers := PackedStringArray(["Content-Type: application/json"])
var err: Error = _http.request(url, headers, HTTPClient.METHOD_POST, body)
if err != OK:
push_warning("PositionAuthorityClient: POST failed to start (%s)" % err)
_busy = false
_try_flush_pending()
func _start_stream_post(batch: Array) -> void:
@ -164,30 +130,6 @@ func _on_request_completed(
if response_code == 200:
_emit_position_from_response(text, true)
_try_flush_pending()
Phase.POST_MOVE:
if response_code == 400:
_emit_move_rejection_if_present(text)
_busy = false
_try_flush_pending()
return
if response_code != 200:
push_warning(
"PositionAuthorityClient: move POST unexpected code %s" % response_code
)
_busy = false
_try_flush_pending()
return
_phase = Phase.VERIFY_GET
_request_get()
Phase.VERIFY_GET:
_busy = false
if response_code == 200:
_emit_position_from_response(text, false)
else:
push_warning(
"PositionAuthorityClient: VERIFY GET unexpected code %s" % response_code
)
_try_flush_pending()
Phase.STREAM_POST:
if response_code == 400:
_emit_move_rejection_if_present(text)
@ -204,14 +146,12 @@ func _on_request_completed(
_busy = false
_try_flush_pending()
return
# Queue was cleared when the POST started; nothing to drop.
_stream_in_flight_count = 0
_busy = false
if response_code == 200:
# Do not emit `authoritative_position_received` here. The body echoes server position
# after applying the batch, which typically lags the client's integrated capsule by
# roughly one RTT — snapping caused visible rubber-banding during WASD. Boot GET and
# click-move VERIFY still drive snaps / nav goals.
# roughly one RTT — snapping caused visible rubber-banding during WASD.
pass
_try_flush_pending()

View File

@ -1,69 +0,0 @@
# Tests walkable collider ancestry for res://scripts/ground_pick.gd.
# Full ray / viewport pick flow stays manual (see README).
extends GdUnitTestSuite
const GroundPickScript := preload("res://scripts/ground_pick.gd")
func test_collider_is_walkable_true_when_ancestor_in_walkable_group() -> void:
var gp := Node3D.new()
gp.set_script(GroundPickScript)
auto_free(gp)
add_child(gp)
var floor_root := StaticBody3D.new()
floor_root.add_to_group("walkable")
var mesh_child := MeshInstance3D.new()
floor_root.add_child(mesh_child)
gp.add_child(floor_root)
var walkable := bool(gp.call("_collider_is_walkable", mesh_child))
assert_that(walkable).is_true()
func test_collider_is_walkable_false_without_walkable_group() -> void:
var gp := Node3D.new()
gp.set_script(GroundPickScript)
auto_free(gp)
add_child(gp)
var body := StaticBody3D.new()
gp.add_child(body)
var ok := bool(gp.call("_collider_is_walkable", body))
assert_that(ok).is_false()
func test_collider_is_occluder_true_when_ancestor_in_occluder_group() -> void:
var gp := Node3D.new()
gp.set_script(GroundPickScript)
auto_free(gp)
add_child(gp)
var obstacle := StaticBody3D.new()
obstacle.add_to_group("occluder")
var mesh_child := MeshInstance3D.new()
obstacle.add_child(mesh_child)
gp.add_child(obstacle)
var is_occluder := bool(gp.call("_collider_is_occluder", mesh_child))
assert_that(is_occluder).is_true()
func test_collider_is_occluder_false_without_occluder_group() -> void:
var gp := Node3D.new()
gp.set_script(GroundPickScript)
auto_free(gp)
add_child(gp)
var body := StaticBody3D.new()
gp.add_child(body)
var is_occluder := bool(gp.call("_collider_is_occluder", body))
assert_that(is_occluder).is_false()
func test_collider_is_occluder_false_for_walkable_only_ancestor() -> void:
var gp := Node3D.new()
gp.set_script(GroundPickScript)
auto_free(gp)
add_child(gp)
var floor_root := StaticBody3D.new()
floor_root.add_to_group("walkable")
var mesh_child := MeshInstance3D.new()
floor_root.add_child(mesh_child)
gp.add_child(floor_root)
var is_occluder := bool(gp.call("_collider_is_occluder", mesh_child))
assert_that(is_occluder).is_false()

View File

@ -75,33 +75,29 @@ func test_sync_boot_get_200_malformed_position_does_not_emit() -> void:
await assert_signal(c).wait_until(400).is_not_emitted("authoritative_position_received")
func test_move_post_400_emits_move_rejected() -> void:
func test_move_stream_post_400_emits_move_rejected_then_boot_get() -> void:
var mock := MockHttpTransport.new()
mock.enqueue(HTTPRequest.RESULT_SUCCESS, 400, '{"reasonCode":"vertical_step_exceeded"}')
mock.enqueue(HTTPRequest.RESULT_SUCCESS, 200, '{"position":{"x":0,"y":0.9,"z":0}}')
var c := _make_client(mock)
monitor_signals(c)
c.submit_move_target(Vector3(1.0, 2.0, 3.0))
c.submit_stream_targets([Vector3(1.0, 2.0, 3.0)])
await assert_signal(c).is_emitted("move_rejected", "vertical_step_exceeded")
await assert_signal(c).is_emitted(
"authoritative_position_received", Vector3(0.0, 0.9, 0.0), true
)
func test_move_post_400_without_reason_emits_unknown() -> void:
func test_move_stream_post_400_without_reason_emits_unknown() -> void:
var mock := MockHttpTransport.new()
mock.enqueue(HTTPRequest.RESULT_SUCCESS, 400, "{}")
mock.enqueue(HTTPRequest.RESULT_SUCCESS, 200, '{"position":{"x":1,"y":0.9,"z":1}}')
var c := _make_client(mock)
monitor_signals(c)
c.submit_move_target(Vector3.ZERO)
c.submit_stream_targets([Vector3.ZERO])
await assert_signal(c).is_emitted("move_rejected", "unknown")
func test_move_post_200_then_verify_get_emits_nav_goal() -> void:
var mock := MockHttpTransport.new()
mock.enqueue(HTTPRequest.RESULT_SUCCESS, 200, "")
mock.enqueue(HTTPRequest.RESULT_SUCCESS, 200, '{"position":{"x":2,"y":0.5,"z":-1}}')
var c := _make_client(mock)
monitor_signals(c)
c.submit_move_target(Vector3(9.0, 0.0, 9.0))
await assert_signal(c).is_emitted(
"authoritative_position_received", Vector3(2.0, 0.5, -1.0), false
"authoritative_position_received", Vector3(1.0, 0.9, 1.0), true
)

View File

@ -99,3 +99,7 @@
- **Interaction vs pick:** Confirm **E** / interaction does not rely on the same mouse button in a way that breaks when pick is disabled.
- **Gamepad:** Out of scope unless trivial (`Input.get_vector`); note for follow-up story.
- **Jira naming:** If the team uses **NEON-*** keys elsewhere, align Linear/Git subject lines per [linear-git-naming](../../.cursor/rules/jira-git-naming.md).
## Client follow-up (done)
- **Click-to-move removed:** Deleted `client/scripts/ground_pick.gd` and `client/test/ground_pick_test.gd`, removed **`GroundPick`** from `client/scenes/main.tscn`, dropped **`PositionAuthorityClient.submit_move_target`** and **`POST_MOVE` / `VERIFY_GET`** phases. **`main.gd`** no longer wires pick → move. Boot **`GET`** still emits **`authoritative_position_received(..., true)`** for **`snap_to_server`**; the `apply_as_snap == false` branch is unused until a future server-driven nav goal exists.

View File

@ -65,7 +65,7 @@ Unknown player ids return **404**. Full zone sync / replication is still out of
**`POST /game/players/{id}/move-stream` (NEO-22):** JSON body **`MoveStreamRequest`** (`schemaVersion` **1**, **`targets`**: ordered array of world positions, max **24** per request). The server **validates the entire chain** against **`Game:MovementValidation`** (same rules as a single move) **before** applying anything, then applies each target in order; **`sequence`** increases by **one per applied target**. Response body matches **`PositionStateResponse`**. On the first failing leg the handler returns **400** with **`MoveCommandRejectedResponse`** and **does not** change stored position.
**Client navigation (NEO-11):** The Godot prototype uses a **baked navigation mesh** only for **presentation**; the client may follow waypoints when active, but **automatic obstacle detours on a single click are not guaranteed** (tradeoff for smooth movement on stepped geometry). The server does **not** simulate navmesh; it validates **straight-line** step limits (NEO-10) from the **last authoritative position** to the **requested target**. Cheating or divergent paths are out of scope for this slice.
**Client navigation (NEO-11):** The Godot prototype uses a **baked navigation mesh** for **local** vertical routing / step assist when applicable; **WASD** sends **`move-stream`** samples and the server validates **straight-line** legs (NEO-10) from the **last authoritative position** to each **requested** position. The server does **not** simulate navmesh. Cheating or divergent paths are out of scope for this slice.
**NEO-10 validation (reject-only):** Before applying, the server checks the move against **`Game:MovementValidation`**. Limits use **horizontal** distance on **X/Z** only and **|ΔY|** separately (see `MoveCommandValidation`). Unknown players return **404** before validation.
@ -76,7 +76,7 @@ Unknown player ids return **404**. Full zone sync / replication is still out of
| `Game:MovementValidation:DistrictBoundsEnabled` | Axis-aligned box on **target** (inclusive min/max) | `false` |
| `Game:MovementValidation:DistrictMinX``DistrictMaxZ` | District extents when enabled | ±12 / Y 2…24 |
**Manual QA (Godot `main.tscn`):** **Green bumps** are **two random short cylinders** each run (~**814 cm** rise) and stay under default `MaxVerticalStep` from the dev spawn; **orange reject pedestal** top is ~**2.5 m** above floor so a floor-level click still yields **`vertical_step_exceeded`** (default `MaxVerticalStep` is **2.2 m**); the **physics ramp test block** top is **2 m** so clicking the floor from it is **accepted**. **Far pad** is beyond default horizontal reach for **`horizontal_step_exceeded`** when horizontal limits are enabled. The clients **`ground_pick.gd`** only accepts ray hits whose surface **normal** is mostly **upward** (`MIN_WALKABLE_NORMAL_DOT_UP`), so **vertical faces** on the pedestal do not emit move targets—this complements server per-step limits and prevents “stair-stepping” up walls.
**Manual QA (Godot `main.tscn`):** **Green bumps** are **two random short cylinders** each run (~**814 cm** rise) and stay under default `MaxVerticalStep` from the dev spawn; **orange reject pedestal** top is ~**2.5 m** above floor so **`move-stream`** from the floor into the pedestal still yields **`vertical_step_exceeded`** (default `MaxVerticalStep` is **2.2 m**); the **physics ramp test block** top is **2 m** so streaming from it toward the floor is **accepted**. **Far pad** is beyond default horizontal reach for **`horizontal_step_exceeded`** when horizontal limits are enabled.
Request body (example):