From a4095cb9bf1ae5b1f70494c567e3cc18485a5dad Mon Sep 17 00:00:00 2001 From: VinPropane Date: Fri, 17 Apr 2026 20:06:46 -0400 Subject: [PATCH] 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). --- .../rules/godot-client-script-organization.md | 6 +- client/README.md | 45 +++---- client/scenes/main.tscn | 4 - client/scripts/ground_pick.gd | 126 ------------------ client/scripts/main.gd | 53 +++----- client/scripts/position_authority_client.gd | 72 +--------- client/test/ground_pick_test.gd | 69 ---------- client/test/position_authority_client_test.gd | 24 ++-- docs/plans/NEO-22-implementation-plan.md | 4 + server/README.md | 4 +- 10 files changed, 62 insertions(+), 345 deletions(-) delete mode 100644 client/scripts/ground_pick.gd delete mode 100644 client/test/ground_pick_test.gd diff --git a/.cursor/rules/godot-client-script-organization.md b/.cursor/rules/godot-client-script-organization.md index 2f78b5a..168a2d6 100644 --- a/.cursor/rules/godot-client-script-organization.md +++ b/.cursor/rules/godot-client-script-organization.md @@ -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 ``` diff --git a/client/README.md b/client/README.md index db6f804..66ad04e 100644 --- a/client/README.md +++ b/client/README.md @@ -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 pre–NEO-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 pre–NEO-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 violet’s **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 violet’s **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 server’s 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 Jolt’s 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 server’s 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 Jolt’s 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 server’s 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 repo’s 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 **viewport’s 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. diff --git a/client/scenes/main.tscn b/client/scenes/main.tscn index ffe3f6d..cd0fc52 100644 --- a/client/scenes/main.tscn +++ b/client/scenes/main.tscn @@ -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") diff --git a/client/scripts/ground_pick.gd b/client/scripts/ground_pick.gd deleted file mode 100644 index 4ae757b..0000000 --- a/client/scripts/ground_pick.gd +++ /dev/null @@ -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 diff --git a/client/scripts/main.gd b/client/scripts/main.gd index 38c9652..e69ff44 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -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 diff --git a/client/scripts/position_authority_client.gd b/client/scripts/position_authority_client.gd index 5dd1cb2..f655dc5 100644 --- a/client/scripts/position_authority_client.gd +++ b/client/scripts/position_authority_client.gd @@ -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() diff --git a/client/test/ground_pick_test.gd b/client/test/ground_pick_test.gd deleted file mode 100644 index 1c4b916..0000000 --- a/client/test/ground_pick_test.gd +++ /dev/null @@ -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() diff --git a/client/test/position_authority_client_test.gd b/client/test/position_authority_client_test.gd index 5896b0e..40eaac2 100644 --- a/client/test/position_authority_client_test.gd +++ b/client/test/position_authority_client_test.gd @@ -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 ) diff --git a/docs/plans/NEO-22-implementation-plan.md b/docs/plans/NEO-22-implementation-plan.md index 3879093..caea3e6 100644 --- a/docs/plans/NEO-22-implementation-plan.md +++ b/docs/plans/NEO-22-implementation-plan.md @@ -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. diff --git a/server/README.md b/server/README.md index 0e406e5..4447ffb 100644 --- a/server/README.md +++ b/server/README.md @@ -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 (~**8–14 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 client’s **`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 (~**8–14 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):