Compare commits

...

27 Commits

Author SHA1 Message Date
VinPropane d4bfd59251
Merge pull request #45 from ViPro-Technologies/chore/story-kickoff-clarify-questions
chore: story kickoff — ask user when unclear or decision needed
2026-04-17 21:21:16 -04:00
VinPropane aed600abd2 chore: story kickoff — ask user when unclear or decision needed 2026-04-17 21:19:40 -04:00
VinPropane 114bebc8da
Merge pull request #44 from ViPro-Technologies/NEO-23-e1m3-targetstate-selection-api
NEO-23 e1m3 targetstate selection api
2026-04-17 21:18:17 -04:00
VinPropane 17f87aa747 NEO-23: code review follow-up (E1.M3 docs, GET 404 test, radius bounds) 2026-04-17 21:14:43 -04:00
VinPropane 7e18ecedb6 NEO-23: Add code review for targeting API branch 2026-04-17 21:12:46 -04:00
VinPropane 465c4b14eb NEO-23: add targeting HTTP API (TargetState v1, select intent) 2026-04-17 21:06:59 -04:00
VinPropane 8c9db789d0 NEO-23: lock JSON shape, GET envelope, selectionApplied, soft lock in plan 2026-04-17 21:04:01 -04:00
VinPropane bd0286cb1c NEO-23: add implementation plan for TargetState v1 and selection API 2026-04-17 20:57:13 -04:00
VinPropane 1233801227
Merge pull request #43 from ViPro-Technologies/docs/e1m3-interaction-targeting-layer
Docs/e1m3 interaction targeting layer
2026-04-17 20:52:43 -04:00
VinPropane abb6e6b81b NEO-22: Add Godot .uid sidecars for WASD locomotion scripts 2026-04-17 20:51:16 -04:00
VinPropane 7f9fc8e429 chore: address E1.M3 docs review (Slice 3 anchor, Linear pointers) 2026-04-17 20:48:53 -04:00
VinPropane 5bb70efd6c chore: docs review for E1.M3 documentation pass 2026-04-17 20:47:47 -04:00
VinPropane 189c1c9810
Merge pull request #42 from ViPro-Technologies/NEO-22-retire-click-to-move-wasd-locomotion
Neo 22 retire click to move wasd locomotion
2026-04-17 20:27:54 -04:00
VinPropane 735f569496 NEO-22: Fix locomotion test static calls for GDScript + LSP
Type preload as GDScript so static helpers resolve on the script
resource; pass Array[float] literals into median_feet_y_from_samples to
match typed parameters. Document FLOOR_RAY_FEET_INVALID sync in tests.
2026-04-17 20:24:16 -04:00
VinPropane 866c973af7 NEO-22: Cache scrape contact in WASD tick; close review items
run_wasd: one _has_scrape_vertical_contact() before move_and_slide and one
after; fold repeated predicates into wish_active_scrape_vertical_pre and
post_slide_wish_floor_scrape.

Review doc: mark suggestions #2/#4 done; suggestion #1, nits, verification
ledge bullet, and partial doc rows reviewed/deferred with strikethrough notes.
2026-04-17 20:21:13 -04:00
VinPropane c79c58c298 NEO-22: Unit-test WASD locomotion pure helpers
Extract static helpers (floor-ray continuation, median feet Y, micro-slip
XZ projection, wish-aligned horizontal velocity) from the tick path so
GdUnit can cover seam math without physics doubles. Add
player_locomotion_wasd_test.gd, document in README, and update NEO-22
plan/review.
2026-04-17 20:16:25 -04:00
VinPropane 90a17bc43d NEO-22: Extract WASD locomotion for gdlint max-file-lines
Move the WASD physics tick into player_locomotion_wasd.gd so player.gd
stays under the 1600-line CI cap. Fix gdlint class order (enum before
consts), load-constant naming, and wrap long doc lines. Update NEO-22
review and plan; refresh gdlintrc comment.
2026-04-17 20:13:36 -04:00
VinPropane 05f703322d NEO-22: Add code review for WASD locomotion wall-scrape diff 2026-04-17 20:08:26 -04:00
VinPropane a7e6a5af59 NEO-22: Fix GdUnit warnings in position_authority_client_test
Rename mock variables to avoid shadowing GdUnitTestSuite.mock, drop
redundant await on synchronous assert_signal chains, and ignore
unused request_completed on HangingHttpTransport (API parity only).
2026-04-17 20:07:27 -04:00
VinPropane a4095cb9bf 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).
2026-04-17 20:06:46 -04:00
VinPropane 76ff2cddac NEO-22: clear idle anchor during WASD so stop does not snap back
Stable idle latches _idle_anchor_active and _hold_idle_anchor clamps XZ each
tick. Locomotion bypasses that branch, so the anchor stayed at the pre-walk
position while the capsule moved; releasing keys ran idle again and
_hold_idle_anchor teleported to the stale anchor.
2026-04-17 19:37:58 -04:00
VinPropane 6cc2ac60d4 NEO-22: stop applying move-stream HTTP body as client teleport
Successful move-stream responses echoed server position roughly one RTT behind
integrated move_and_slide, so apply_as_snap still fired whenever dh exceeded
the locomotion skip threshold. Treat stream 200 as ack-only: do not emit
authoritative_position_received. Boot GET and click-move verify unchanged.
Update README + GdUnit expectation.
2026-04-17 19:34:45 -04:00
VinPropane 39a41ac62c NEO-22: suppress stream snap-back while locomoting
Stream HTTP responses can lag integrated move_and_slide by roughly one RTT,
so apply_as_snap was pulling the capsule backward each tick while moving.
Skip small XZ corrections when wish or horizontal speed indicates locomotion,
with a cap so large desync still snaps. move_rejected sets a one-shot flag so
resync after move-stream 400 still applies the authoritative pose.
2026-04-17 19:29:56 -04:00
VinPropane 878b1f4aee NEO-22: fix stream rubber-banding (coalesce, idle gate, soft snap)
Queue only latest move-stream target per POST; clear pending while busy.
Skip stream submits when idle; skip tiny authoritative snaps after boot.
2026-04-17 19:27:10 -04:00
VinPropane a2c6c9f93e NEO-22: add move-stream API and WASD client locomotion
Introduce POST /game/players/{id}/move-stream with ordered targets, full-chain
validation before apply, and PositionStateResponse. Godot client uses camera-
relative WASD, queues samples, and snaps to stream responses; debug builds keep
click-to-move via ground_pick. Update READMEs, E1.M1 snapshot, and tests.
2026-04-17 19:21:33 -04:00
VinPropane 7ffeb320c4 NEO-22: record option C stream authority in implementation plan 2026-04-17 19:17:18 -04:00
VinPropane 3f781ef358 NEO-22: record kickoff branch and Linear status in plan 2026-04-17 19:11:05 -04:00
42 changed files with 2158 additions and 361 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

@ -7,6 +7,11 @@ alwaysApply: true
When the user starts work on a **Linear issue** (e.g. issue key `NEO-5`, phrases like “begin work on story”, “implement NEO-…”), run this sequence **before** writing or changing application code.
## Clarifications (ask the user)
- During kickoff and while drafting the plan, **ask the user** about anything **unclear**, **underspecified**, or that **needs a decision** (scope, behavior, acceptance criteria, priorities, trade-offs, or what is out of scope). Do not guess or pick silent defaults when the issue, Linear notes, or repo context do not settle it.
- Use **concrete questions**, often with short options when helpful; group related questions so the user can answer in one pass.
## 1. Load Linear context
- Fetch the issue when possible (Linear MCP `get_issue` / `list_issues`, server **`plugin-linear-linear`**), or use a URL / pasted description the user provides.

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,41 +40,43 @@ 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)
## Authoritative movement (NEO-7, NEO-11, NEO-22)
With the game server running ([`server/README.md`](../server/README.md)), each valid floor click sends a **`MoveCommand`** (**`POST`**) and a follow-up **`GET`** for **`PositionState`**. The server still **snaps** authority to the target (NEO-7); the client **moves** toward that verified position using **`NavigationAgent3D`** + a **baked mesh** instead of teleporting on the **`GET`** (NEO-11). **Boot** `sync_from_server()` **snaps** once so spawn matches the server.
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.
**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.
**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 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`).
### Manual check (NEO-7 + NEO-11)
### Manual check (NEO-7 + NEO-11 + NEO-22)
1. From repo root: `cd server/NeonSprawl.Server && dotnet run` (note the URL/port, usually `http://localhost:5253`).
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. **Left-click** the floor: the client **POST**s the target, then **GET**s position; the capsule **walks** in **XZ** straight toward that target (nav path only for **vertical** routing when you are already near the goal column on **flatter** floor). Server-rejected clicks show the reject label and do **not** start a path.
5. Click the **pedestal top** (orange box at ~(7.5, 0, 6.5)) to confirm **`vertical_step_exceeded`** rejection — the top is ~2.5 m above floor, which exceeds default **`MaxVerticalStep` (2.2 m)**. From the **physics ramp test block** top (**2 m**), clicking the floor should **succeed**. The `MoveRejectFarPad` (blue pad at (9, 9)) is a normal walkable surface; clicking it from any distance should succeed.
6. After a move (or at spawn), **stand idle** with no click goal 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).
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. 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)
@ -86,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).
@ -150,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`**, **`player_locomotion_wasd.gd`** (pure WASD seam helpers), **`position_authority_client.gd`**, **`isometric_follow_camera.gd`** (eye math + effective zoom distance), **`camera_state.gd`**, and **`zoom_band_config.gd`**. **`main.gd`** and scene wiring are **not** automated here—use manual checks above.
**Camera zoom:** Input actions **`camera_zoom_in`** / **`camera_zoom_out`** (mouse wheel, **`=`** / **`-`**, keypad **+** / ****) are defined in **`project.godot`**. On layouts where **`+`** requires **Shift**, remap or add a binding under **Project → Project Settings → Input Map** if needed.

View File

@ -63,6 +63,26 @@ dev_toggle_occluder_obstacle={
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":true,"ctrl_pressed":true,"meta_pressed":false,"pressed":false,"keycode":75,"physical_keycode":75,"key_label":0,"unicode":107,"location":0,"echo":false,"script":null)
]
}
move_left={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":65,"physical_keycode":65,"key_label":0,"unicode":97,"location":0,"echo":false,"script":null)
]
}
move_right={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":68,"physical_keycode":68,"key_label":0,"unicode":100,"location":0,"echo":false,"script":null)
]
}
move_forward={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":87,"physical_keycode":87,"key_label":0,"unicode":119,"location":0,"echo":false,"script":null)
]
}
move_back={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":83,"physical_keycode":83,"key_label":0,"unicode":115,"location":0,"echo":false,"script":null)
]
}
[physics]

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,124 +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:
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,8 +1,10 @@
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-23: bakes `NavigationRegion3D` on startup; boot snap vs nav goal from authority signal.
## 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 (`player.gd`,
## `position_authority_client.gd`).
## 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
@ -19,8 +21,30 @@ extends Node3D
const MOVE_REJECT_MSG_SECONDS: float = 4.0
## Throttle `move-stream` POSTs (~20 Hz at 120 physics ticks/sec).
const STREAM_PHYSICS_INTERVAL: int = 6
## Skip `move-stream` while standing still so idle + anchor are not fighting HTTP snaps.
const STREAM_IDLE_VEL_HZ_SQ: float = 0.0004
## Ignore authority snaps smaller than this (m) to avoid micro-flicker from float / echo noise.
const AUTH_SNAP_MIN_HORIZONTAL: float = 0.08
const AUTH_SNAP_MIN_VERTICAL: float = 0.04
## While moving, ignore stream snaps smaller than this (XZ m): responses are often one RTT behind
## integrated `move_and_slide`, so snapping causes rubber-band / bounciness.
const AUTH_SNAP_LOCOMOTION_SKIP_MAX_DH: float = 0.55
## Horizontal speed² above this (m²/s²) counts as “moving” for snap suppression (~0.35 m/s).
const AUTH_SNAP_LOCOMOTION_VEL_HZ_SQ: float = 0.12
## Bump on each rejection so older one-shot timers do not clear a newer message.
var _move_reject_msg_token: int = 0
var _stream_physics_counter: int = 0
## After the first `apply_as_snap` authority update, micro-snaps may be skipped
## (boot always applies).
var _allow_authority_soft_snap_skip: bool = false
## Next `apply_as_snap` must not be suppressed (e.g. `move-stream` 400 → `sync_from_server`).
var _authority_force_snap_next: bool = false
## [InputMap] [code]dev_toggle_occluder_obstacle[/code] (default **Ctrl+Shift+K**):
## Restored when the obstacle is shown again.
@ -34,7 +58,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
@ -54,8 +77,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")
)
@ -68,24 +89,66 @@ func _ready() -> void:
func _physics_process(_delta: float) -> void:
if not is_instance_valid(_player) or not is_instance_valid(_player_pos_label):
return
var wish: Vector3 = _locomotion_wish_world_xz_from_camera()
_player.call("set_locomotion_wish_world_xz", wish)
var vel_hz_sq: float = Vector2(_player.velocity.x, _player.velocity.z).length_squared()
var moving: bool = wish.length_squared() > 1e-10 or vel_hz_sq > STREAM_IDLE_VEL_HZ_SQ
if moving:
_stream_physics_counter += 1
if _stream_physics_counter >= STREAM_PHYSICS_INTERVAL:
_stream_physics_counter = 0
var batch: Array = [_player.global_position]
_authority.call("submit_stream_targets", batch)
else:
_stream_physics_counter = 0
var p: Vector3 = _player.global_position
_player_pos_label.text = "x: %.3f\ny: %.3f\nz: %.3f" % [p.x, p.y, p.z]
func _on_target_chosen(world: Vector3) -> void:
_authority.call("submit_move_target", world)
func _locomotion_wish_world_xz_from_camera() -> Vector3:
var v: Vector2 = Input.get_vector("move_left", "move_right", "move_forward", "move_back")
if v.length_squared() < 1e-8:
return Vector3.ZERO
if not is_instance_valid(_camera):
return Vector3.ZERO
var forward: Vector3 = -_camera.global_transform.basis.z
forward.y = 0.0
if forward.length_squared() < 1e-10:
return Vector3.ZERO
forward = forward.normalized()
var right: Vector3 = forward.cross(Vector3.UP)
if right.length_squared() < 1e-10:
return Vector3.ZERO
right = right.normalized()
var w: Vector3 = right * v.x + forward * (-v.y)
if w.length_squared() < 1e-10:
return Vector3.ZERO
return w.normalized()
func _on_authoritative_position(world: Vector3, apply_as_snap: bool) -> void:
if apply_as_snap:
_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:
# 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.
_authority_force_snap_next = true
# 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,6 +1,7 @@
extends CharacterBody3D
## NS-23 path-follow; NS-24 idle. Details in `client/README.md` (movement, idle).
## NS-23 path-follow; NS-24 idle. NEO-22: WASD locomotion via [method set_locomotion_wish_world_xz].
## Details in `client/README.md` (movement, idle).
##
## Summary: Jolt, no interp; dual floor_max_angle; rim/straddle, bump lip escape.
## Snap upright after motion. Do not set global_transform in _process.
@ -220,6 +221,7 @@ const CAPSULE_HALF_HEIGHT: float = 0.5
const BUMP_COLLISION_CONSTS_SCRIPT: Script = preload(
"res://scripts/random_floor_bump_collision_constants.gd"
)
const PLAYER_LOCOMOTION_WASD_SCRIPT := preload("res://scripts/player_locomotion_wasd.gd")
## Idle radial push per tick off bump lip / wall (meters; 120 Hz physics).
const IDLE_BUMP_ESCAPE_STEP: float = 0.014
## Player capsule radius; wall touch can happen past axis distance **`col_r`**.
@ -271,6 +273,9 @@ var _walk_column_stuck_origin: Vector3 = Vector3.ZERO
## && ncol[/code].
var _walk_col_seam_prev_hz: Vector2 = Vector2.ZERO
var _walk_col_seam_suppress_ticks: int = 0
## Normalized world XZ direction from WASD (Y ignored). Zero when idle.
var _locomotion_wish_world_xz: Vector3 = Vector3.ZERO
var _wasd_locomotion: RefCounted
@onready var _nav_agent: NavigationAgent3D = $NavigationAgent3D
@ -279,6 +284,9 @@ func _ready() -> void:
_nav_agent.avoidance_enabled = false
_nav_agent.set_target_position(global_position)
floor_block_on_wall = true
_wasd_locomotion = PLAYER_LOCOMOTION_WASD_SCRIPT.new(
self, CAPSULE_HALF_HEIGHT, PLAYER_CAPSULE_RADIUS, MOVE_SPEED, FLOOR_SNAP_MOVING
)
if debug_idle_trace:
set_notify_transform(true)
@ -324,6 +332,7 @@ func clear_nav_goal() -> void:
_walk_column_stuck_frame = 0
_walk_col_seam_prev_hz = Vector2.ZERO
_walk_col_seam_suppress_ticks = 0
_wasd_locomotion.reset_for_nav_or_snap()
_nav_agent.set_target_position(global_position)
@ -344,6 +353,15 @@ func sync_navigation_agent_after_map_rebuild(nav_region: NavigationRegion3D) ->
_nav_agent.set_target_position(global_position)
func set_locomotion_wish_world_xz(dir: Vector3) -> void:
var h := Vector3(dir.x, 0.0, dir.z)
if h.length_squared() < 1e-10:
_locomotion_wish_world_xz = Vector3.ZERO
_wasd_locomotion.reset_scrape_feet_lock()
else:
_locomotion_wish_world_xz = h.normalized()
func snap_to_server(world_pos: Vector3) -> void:
# The server stores the surface Y of the last move target, not the capsule body-centre Y.
# Clamping to the minimum standing height (total half = 0.9 m) prevents the player from
@ -371,8 +389,10 @@ func snap_to_server(world_pos: Vector3) -> void:
_walk_nav_path_steer_hz = Vector3.ZERO
_walk_col_seam_prev_hz = Vector2.ZERO
_walk_col_seam_suppress_ticks = 0
_wasd_locomotion.reset_for_nav_or_snap()
_nav_agent.set_target_position(settled)
reset_physics_interpolation()
_locomotion_wish_world_xz = Vector3.ZERO
## Returns true when the capsule has something to stand on **or** is pressing against a step face
@ -1359,6 +1379,12 @@ func _physics_process(delta: float) -> void:
floor_max_angle = deg_to_rad(
FLOOR_MAX_ANGLE_MOVING_DEG if use_loose_floor_angle else FLOOR_MAX_ANGLE_IDLE_DEG
)
var wish_active: bool = _locomotion_wish_world_xz.length_squared() > 1e-10
if wish_active:
use_loose_floor_angle = true
floor_max_angle = deg_to_rad(FLOOR_MAX_ANGLE_MOVING_DEG)
_wasd_locomotion.run_wasd(delta)
return
if not _has_walk_goal:
_physics_process_no_walk_goal(delta)
return

View File

@ -0,0 +1,306 @@
extends RefCounted
## NEO-22 WASD wall scrape / floor seam tick (extracted from [code]player.gd[/code] for
## [kbd]gdlint[/kbd] [code]max-file-lines[/code]). Owns coyote + scrape feet state; calls back into
## the [CharacterBody3D] player for physics and walk helpers.
const LOCOMOTION_FLOOR_COYOTE_TICKS: int = 8
const LOCOMOTION_WALL_SCRAPE_PROBE_HOLD_VY_MAX: float = -1.05
const LOCOMOTION_WALL_SCRAPE_SNAP_FALLBACK: float = 0.12
const LOCOMOTION_SCRAPE_FEET_LOCK_LERP: float = 0.2
const LOCOMOTION_SCRAPE_FEET_CORRECT_MAX: float = 0.008
const LOCOMOTION_SCRAPE_FEET_ERR_GATE: float = 0.075
const LOCOMOTION_SCRAPE_RAY_FEET_ABSORB_MAX: float = 0.1
const LOCOMOTION_SCRAPE_RAY_PROBE_Y_LERP: float = 0.42
const LOCOMOTION_SCRAPE_RAY_FEET_MEDIAN_SPAN: float = 0.055
const LOCOMOTION_SCRAPE_WISH_LINE_MICRO_PERP_M: float = 0.032
## Sentinel when no walkable floor hit (tests compare to this).
const FLOOR_RAY_FEET_INVALID: float = -1.0e9
## Must match [code]player.gd[/code] walk continuation probe (same ray math as
## [method _walk_ray_hit_up_floor]).
const _WALK_SUPPORT_PROBE_DEPTH: float = 0.32
const _WALK_SUPPORT_PROBE_MIN_UP_DOT: float = 0.42
const _WALK_CONTINUATION_MAX_BELOW_FEET: float = 0.108
var _player: CharacterBody3D
var _cap_half: float
var _cap_radius: float
var _move_speed: float
var _floor_snap_moving: float
var _floor_coyote: int = 0
var _scrape_feet_lock_valid: bool = false
var _scrape_feet_y_lock: float = 0.0
var _scrape_probe_y_ema_valid: bool = false
var _scrape_probe_y_ema: float = 0.0
## Pure: walk continuation rules after a downward feet probe hit (same thresholds as
## [method _floor_ray_feet_y]).
static func evaluate_floor_ray_hit_y(
feet_y: float,
hit_y: float,
normal: Vector3,
continuation_max_below_feet: float,
min_floor_up_dot: float
) -> float:
if hit_y < feet_y - continuation_max_below_feet:
return FLOOR_RAY_FEET_INVALID
if normal.dot(Vector3.UP) <= min_floor_up_dot:
return FLOOR_RAY_FEET_INVALID
return hit_y
## Pure: median of one or more feet Y samples (sorted); empty → [constant FLOOR_RAY_FEET_INVALID].
static func median_feet_y_from_samples(samples: Array[float]) -> float:
if samples.is_empty():
return FLOOR_RAY_FEET_INVALID
var y_vals: Array[float] = samples.duplicate()
y_vals.sort()
var mid: int = (y_vals.size() - 1) >> 1
return y_vals[mid]
## Pure: XZ after micro-slip projection onto the wish line (or unchanged if out of band).
static func xz_after_micro_slip_projection(
anchor_xz: Vector2, player_xz: Vector2, wish_xz: Vector2, perp_limit_m: float
) -> Vector2:
var w2s: float = wish_xz.length_squared()
if w2s < 1e-12:
return player_xz
var w2n: Vector2 = wish_xz / sqrt(w2s)
var along: float = (player_xz - anchor_xz).dot(w2n)
var p2_on: Vector2 = anchor_xz + w2n * along
var perp_len: float = (player_xz - p2_on).length()
if perp_len > perp_limit_m or perp_len <= 1e-7:
return player_xz
return Vector2(p2_on.x, p2_on.y)
## Pure: non-negative wish-aligned horizontal speed, capped at [param move_speed].
static func horizontal_velocity_aligned_to_wish(
wish_xz: Vector2, vel_xz: Vector2, move_speed: float
) -> Vector2:
if wish_xz.length_squared() < 1e-10:
return vel_xz
var w2: Vector2 = wish_xz.normalized()
var along: float = vel_xz.dot(w2)
if along < 0.0:
along = 0.0
along = minf(move_speed, along)
return Vector2(w2.x * along, w2.y * along)
func _init(
player: CharacterBody3D,
cap_half: float,
cap_radius: float,
move_speed: float,
floor_snap_moving: float
) -> void:
_player = player
_cap_half = cap_half
_cap_radius = cap_radius
_move_speed = move_speed
_floor_snap_moving = floor_snap_moving
func reset_scrape_feet_lock() -> void:
_scrape_feet_lock_valid = false
_scrape_probe_y_ema_valid = false
func reset_for_nav_or_snap() -> void:
_floor_coyote = 0
reset_scrape_feet_lock()
func _feet_y() -> float:
return _player.global_position.y - _cap_half - _cap_radius
func _has_scrape_vertical_contact() -> bool:
if _player.is_on_wall():
return true
for i: int in _player.get_slide_collision_count():
var n: Vector3 = _player.get_slide_collision(i).get_normal()
if n.y < 0.48 and n.y > -0.22:
return true
return false
func _project_global_xz_on_wish_line_if_micro_slip(anchor_xz: Vector2, wish_xz: Vector2) -> void:
var p2: Vector2 = Vector2(_player.global_position.x, _player.global_position.z)
var new_xz: Vector2 = xz_after_micro_slip_projection(
anchor_xz, p2, wish_xz, LOCOMOTION_SCRAPE_WISH_LINE_MICRO_PERP_M
)
if new_xz.is_equal_approx(p2):
return
_player.global_position.x = new_xz.x
_player.global_position.z = new_xz.y
func _align_velocity_xz_to_wish() -> void:
var wish: Vector3 = _player._locomotion_wish_world_xz
var w2 := Vector2(wish.x, wish.z)
var v2 := Vector2(_player.velocity.x, _player.velocity.z)
var out: Vector2 = horizontal_velocity_aligned_to_wish(w2, v2, _move_speed)
_player.velocity.x = out.x
_player.velocity.z = out.y
func _floor_ray_feet_y(
space: PhysicsDirectSpaceState3D, anchor_feet_xz: Vector2, off_xz: Vector2
) -> float:
var feet_y: float = _player.global_position.y - _cap_half - _cap_radius
var feet_x: float = anchor_feet_xz.x + off_xz.x
var feet_z: float = anchor_feet_xz.y + off_xz.y
var probe_from := Vector3(feet_x, feet_y + 0.04, feet_z)
var probe_end := Vector3(feet_x, feet_y - _WALK_SUPPORT_PROBE_DEPTH, feet_z)
var q := PhysicsRayQueryParameters3D.create(probe_from, probe_end)
q.collision_mask = _player.collision_mask
q.exclude = [_player.get_rid()]
var res: Dictionary = space.intersect_ray(q)
if res.is_empty() or not res.has("normal") or not res.has("position"):
return FLOOR_RAY_FEET_INVALID
var hit_y: float = (res["position"] as Vector3).y
var n: Vector3 = res["normal"]
return evaluate_floor_ray_hit_y(
feet_y, hit_y, n, _WALK_CONTINUATION_MAX_BELOW_FEET, _WALK_SUPPORT_PROBE_MIN_UP_DOT
)
func _floor_ray_feet_y_median(
space: PhysicsDirectSpaceState3D, anchor_feet_xz: Vector2, wish_probe: Vector2
) -> float:
var offs: Array[Vector2] = [Vector2.ZERO]
var ws2: float = wish_probe.length_squared()
if ws2 > 1e-10:
var wp: Vector2 = wish_probe / sqrt(ws2)
var perp: Vector2 = Vector2(-wp.y, wp.x) * LOCOMOTION_SCRAPE_RAY_FEET_MEDIAN_SPAN
offs.append(perp)
offs.append(-perp)
var y_vals: Array[float] = []
for k: int in range(offs.size()):
var y_one: float = _floor_ray_feet_y(space, anchor_feet_xz, offs[k])
if y_one != FLOOR_RAY_FEET_INVALID:
y_vals.append(y_one)
return median_feet_y_from_samples(y_vals)
func run_wasd(delta: float) -> void:
if _player._has_walk_goal:
_player.clear_nav_goal()
_player._idle_anchor_active = false
_player.floor_block_on_wall = true
var scrape_ray_anchor_feet_xz := Vector2(_player.global_position.x, _player.global_position.z)
var feet_y: float = _feet_y()
var wish: Vector3 = _player._locomotion_wish_world_xz
_player.velocity.x = wish.x * _move_speed
_player.velocity.z = wish.z * _move_speed
var wish_probe: Vector2 = Vector2(wish.x, wish.z)
var wish_probe_active: bool = wish_probe.length_squared() > 1e-10
var locomotion_probe_floor: bool = (
wish_probe_active and _player._walk_has_close_floor_probe_below(wish_probe)
)
# Slide normals reflect the **previous** tick until [method CharacterBody3D.move_and_slide] runs.
var scrape_vertical_pre_slide: bool = _has_scrape_vertical_contact()
var scrape_probe_hold: bool = (
locomotion_probe_floor
and scrape_vertical_pre_slide
and _player.velocity.y >= LOCOMOTION_WALL_SCRAPE_PROBE_HOLD_VY_MAX
)
if _player.is_on_floor():
_floor_coyote = LOCOMOTION_FLOOR_COYOTE_TICKS
elif scrape_probe_hold:
_floor_coyote = LOCOMOTION_FLOOR_COYOTE_TICKS
else:
_floor_coyote = maxi(0, _floor_coyote - 1)
var locomotion_grounded_y: bool = _player.is_on_floor() or _floor_coyote > 0
var wish_active_scrape_vertical_pre: bool = wish_probe_active and scrape_vertical_pre_slide
var scrape_micro_slip_xz: bool = locomotion_grounded_y and wish_active_scrape_vertical_pre
if locomotion_grounded_y:
_player.velocity.y = 0.0
else:
_player._apply_walk_air_gravity(delta, feet_y, Vector2.ZERO)
_player._walk_clip_horizontal_velocity_against_vertical_contacts(wish)
_player.floor_snap_length = _floor_snap_moving
if locomotion_grounded_y and wish_active_scrape_vertical_pre:
if locomotion_probe_floor:
_player.floor_snap_length = 0.0
else:
_player.floor_snap_length = minf(
_floor_snap_moving, LOCOMOTION_WALL_SCRAPE_SNAP_FALLBACK
)
_player.move_and_slide()
if scrape_micro_slip_xz:
_project_global_xz_on_wish_line_if_micro_slip(scrape_ray_anchor_feet_xz, wish_probe)
var feet_after: float = _feet_y()
var scrape_vertical_post_slide: bool = _has_scrape_vertical_contact()
var on_floor_post_slide: bool = _player.is_on_floor()
var post_slide_wish_floor_scrape: bool = (
wish_probe_active and on_floor_post_slide and scrape_vertical_post_slide
)
if post_slide_wish_floor_scrape:
var ray_absorbed: bool = false
if locomotion_probe_floor:
var w3d := _player.get_world_3d()
if w3d != null:
var probe_feet_y: float = _floor_ray_feet_y_median(
w3d.direct_space_state, scrape_ray_anchor_feet_xz, wish_probe
)
if probe_feet_y != FLOOR_RAY_FEET_INVALID:
var raw_absorb_dy: float = probe_feet_y - feet_after
var absorb_dy: float = raw_absorb_dy
if absf(raw_absorb_dy) > LOCOMOTION_SCRAPE_RAY_FEET_ABSORB_MAX:
_scrape_probe_y_ema_valid = false
else:
if _scrape_probe_y_ema_valid:
_scrape_probe_y_ema = lerpf(
_scrape_probe_y_ema,
probe_feet_y,
LOCOMOTION_SCRAPE_RAY_PROBE_Y_LERP
)
else:
_scrape_probe_y_ema = probe_feet_y
_scrape_probe_y_ema_valid = true
absorb_dy = _scrape_probe_y_ema - feet_after
if (
absf(absorb_dy) <= LOCOMOTION_SCRAPE_RAY_FEET_ABSORB_MAX
and absf(absorb_dy) > 1e-5
):
_player.global_position.y += absorb_dy
feet_after = _feet_y()
_scrape_feet_y_lock = feet_after
_scrape_feet_lock_valid = true
ray_absorbed = true
if not ray_absorbed:
if not _scrape_feet_lock_valid:
_scrape_feet_y_lock = feet_after
_scrape_feet_lock_valid = true
else:
if absf(_scrape_feet_y_lock - feet_after) > LOCOMOTION_SCRAPE_FEET_ERR_GATE:
_scrape_feet_y_lock = feet_after
else:
_scrape_feet_y_lock = lerpf(
_scrape_feet_y_lock, feet_after, LOCOMOTION_SCRAPE_FEET_LOCK_LERP
)
var pull_feet: float = _scrape_feet_y_lock - feet_after
var step_y: float = clampf(
pull_feet,
-LOCOMOTION_SCRAPE_FEET_CORRECT_MAX,
LOCOMOTION_SCRAPE_FEET_CORRECT_MAX
)
if absf(step_y) > 1e-6:
_player.global_position.y += step_y
if scrape_micro_slip_xz:
_project_global_xz_on_wish_line_if_micro_slip(scrape_ray_anchor_feet_xz, wish_probe)
else:
reset_scrape_feet_lock()
if post_slide_wish_floor_scrape:
if locomotion_probe_floor:
_align_velocity_xz_to_wish()
if locomotion_grounded_y:
_player.velocity.y = 0.0
_player._snap_capsule_upright()
_player._debug_trace_transform("physics")

View File

@ -0,0 +1 @@
uid://buchf00nvx3y7

View File

@ -1,14 +1,17 @@
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).
## `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)
signal move_rejected(reason_code: String)
enum Phase { BOOT_GET, POST_MOVE, VERIFY_GET }
enum Phase { BOOT_GET, STREAM_POST }
const MOVE_STREAM_MAX_PER_REQUEST: int = 12
@export var base_url: String = "http://127.0.0.1:5253"
@export var dev_player_id: String = "dev-local-1"
@ -18,8 +21,9 @@ 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
func _create_http_request() -> Node:
@ -43,38 +47,51 @@ func sync_from_server() -> void:
_request_get()
func submit_move_target(world: Vector3) -> void:
## 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:
if _busy:
_pending_move_target = world
return
_pending_move_target = null
_start_move_post(world)
# In-flight POST already encodes an older chain; drop queued crumbs so we do not replay
# past intent after this response lands.
_stream_queue.clear()
for t: Variant in targets:
if t is Vector3:
_stream_queue.append(t as Vector3)
while _stream_queue.size() > 48:
_stream_queue.pop_front()
_try_flush_pending()
func _try_flush_pending_move() -> void:
func _try_flush_pending() -> void:
if _busy:
return
if _pending_move_target is Vector3:
var w: Vector3 = _pending_move_target as Vector3
_pending_move_target = null
_start_move_post(w)
if not _stream_queue.is_empty():
var latest: Vector3 = _stream_queue[_stream_queue.size() - 1]
_stream_queue.clear()
_start_stream_post([latest])
func _start_move_post(world: Vector3) -> void:
func _start_stream_post(batch: Array) -> void:
if batch.is_empty():
return
_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},
}
_stream_in_flight_count = batch.size()
_phase = Phase.STREAM_POST
var targets_json: Array = []
for t: Variant in batch:
if t is Vector3:
var v: Vector3 = t as Vector3
targets_json.append({"x": v.x, "y": v.y, "z": v.z})
var payload := {"schemaVersion": 1, "targets": targets_json}
var body := JSON.stringify(payload)
var headers := PackedStringArray(["Content-Type: application/json"])
var url := "%s/game/players/%s/move-stream" % [_base_root(), _player_path_segment()]
var err: Error = _http.request(url, headers, HTTPClient.METHOD_POST, body)
if err != OK:
push_warning("PositionAuthorityClient: POST failed to start (%s)" % err)
push_warning("PositionAuthorityClient: move-stream POST failed to start (%s)" % err)
_busy = false
_try_flush_pending_move()
_stream_in_flight_count = 0
_try_flush_pending()
func _base_root() -> String:
@ -92,7 +109,7 @@ func _request_get() -> void:
if err != OK:
push_warning("PositionAuthorityClient: GET failed to start (%s)" % err)
_busy = false
_try_flush_pending_move()
_try_flush_pending()
func _on_request_completed(
@ -101,7 +118,8 @@ func _on_request_completed(
if _result != HTTPRequest.RESULT_SUCCESS:
push_warning("PositionAuthorityClient: HTTP failed (result=%s); releasing busy." % _result)
_busy = false
_try_flush_pending_move()
_stream_in_flight_count = 0
_try_flush_pending()
return
var text := body.get_string_from_utf8()
@ -111,31 +129,31 @@ func _on_request_completed(
_busy = false
if response_code == 200:
_emit_position_from_response(text, true)
_try_flush_pending_move()
Phase.POST_MOVE:
_try_flush_pending()
Phase.STREAM_POST:
if response_code == 400:
_emit_move_rejection_if_present(text)
_stream_queue.clear()
_stream_in_flight_count = 0
_busy = false
_try_flush_pending_move()
sync_from_server()
return
if response_code != 200:
push_warning(
"PositionAuthorityClient: move POST unexpected code %s" % response_code
"PositionAuthorityClient: move-stream unexpected code %s" % response_code
)
_stream_in_flight_count = 0
_busy = false
_try_flush_pending_move()
_try_flush_pending()
return
_phase = Phase.VERIFY_GET
_request_get()
Phase.VERIFY_GET:
_stream_in_flight_count = 0
_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_move()
# 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.
pass
_try_flush_pending()
func _emit_move_rejection_if_present(json_text: String) -> void:

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

@ -0,0 +1,114 @@
# Unit tests for static helpers on `res://scripts/player_locomotion_wasd.gd` (NEO-22 seam math).
extends GdUnitTestSuite
const LocomotionWasdScript: GDScript = preload("res://scripts/player_locomotion_wasd.gd")
## Must match [constant FLOOR_RAY_FEET_INVALID] in [code]player_locomotion_wasd.gd[/code].
const FLOOR_RAY_FEET_INVALID: float = -1.0e9
func test_evaluate_floor_ray_hit_y_rejects_hit_too_far_below_feet() -> void:
var feet_y := 0.0
var hit_y := feet_y - 0.2
var n := Vector3.UP
var out: float = LocomotionWasdScript.evaluate_floor_ray_hit_y(feet_y, hit_y, n, 0.108, 0.42)
assert_that(out).is_equal(FLOOR_RAY_FEET_INVALID)
func test_evaluate_floor_ray_hit_y_rejects_shallow_floor_normal() -> void:
var feet_y := 0.0
var hit_y := 0.0
# Up component ~0.37 so dot(Vector3.UP, n) is below min_floor_up_dot (0.42).
var n := Vector3(0.85, 0.35, 0.0).normalized()
var out: float = LocomotionWasdScript.evaluate_floor_ray_hit_y(feet_y, hit_y, n, 0.108, 0.42)
assert_that(out).is_equal(FLOOR_RAY_FEET_INVALID)
func test_evaluate_floor_ray_hit_y_accepts_valid_hit() -> void:
var feet_y := 0.0
var hit_y := -0.04
var n := Vector3.UP
var out: float = LocomotionWasdScript.evaluate_floor_ray_hit_y(feet_y, hit_y, n, 0.108, 0.42)
assert_that(out).is_equal(hit_y)
func test_median_feet_y_from_samples_empty_is_invalid() -> void:
var empty: Array[float] = []
var out: float = LocomotionWasdScript.median_feet_y_from_samples(empty)
assert_that(out).is_equal(FLOOR_RAY_FEET_INVALID)
func test_median_feet_y_from_samples_single() -> void:
var samples: Array[float] = [0.12]
var out: float = LocomotionWasdScript.median_feet_y_from_samples(samples)
assert_that(out).is_equal(0.12)
func test_median_feet_y_from_samples_three_sorted_middle() -> void:
var samples: Array[float] = [0.3, 0.1, 0.2]
var out: float = LocomotionWasdScript.median_feet_y_from_samples(samples)
assert_that(out).is_equal(0.2)
func test_median_feet_y_from_samples_two_uses_lower_index() -> void:
# Matches runtime: mid index (n-1)>>1 picks the smaller of two sorted values.
var samples: Array[float] = [0.5, 0.1]
var out: float = LocomotionWasdScript.median_feet_y_from_samples(samples)
assert_that(out).is_equal(0.1)
func test_xz_after_micro_slip_projection_zero_wish_unchanged() -> void:
var anchor := Vector2.ZERO
var player_xz := Vector2(0.02, 0.03)
var out: Vector2 = LocomotionWasdScript.xz_after_micro_slip_projection(
anchor, player_xz, Vector2.ZERO, 0.032
)
assert_that(out).is_equal(player_xz)
func test_xz_after_micro_slip_projection_large_perp_unchanged() -> void:
var anchor := Vector2.ZERO
var player_xz := Vector2(0.0, 0.2)
var wish := Vector2(1.0, 0.0)
var out: Vector2 = LocomotionWasdScript.xz_after_micro_slip_projection(
anchor, player_xz, wish, 0.032
)
assert_that(out).is_equal(player_xz)
func test_xz_after_micro_slip_projection_nudges_small_perp() -> void:
var anchor := Vector2.ZERO
var player_xz := Vector2(0.0, 0.01)
var wish := Vector2(1.0, 0.0)
var out: Vector2 = LocomotionWasdScript.xz_after_micro_slip_projection(
anchor, player_xz, wish, 0.032
)
assert_that(out.x).is_equal(0.0)
assert_that(out.y).is_equal(0.0)
func test_horizontal_velocity_aligned_to_wish_zero_wish_returns_velocity() -> void:
var out: Vector2 = LocomotionWasdScript.horizontal_velocity_aligned_to_wish(
Vector2.ZERO, Vector2(3.0, 4.0), 5.0
)
assert_that(out).is_equal(Vector2(3.0, 4.0))
func test_horizontal_velocity_aligned_to_wish_clamps_negative_along_to_zero() -> void:
var out: Vector2 = LocomotionWasdScript.horizontal_velocity_aligned_to_wish(
Vector2(1.0, 0.0), Vector2(-2.0, 0.0), 5.0
)
assert_that(out).is_equal(Vector2.ZERO)
func test_horizontal_velocity_aligned_to_wish_caps_speed() -> void:
var out: Vector2 = LocomotionWasdScript.horizontal_velocity_aligned_to_wish(
Vector2(1.0, 0.0), Vector2(10.0, 0.0), 5.0
)
assert_that(out).is_equal(Vector2(5.0, 0.0))
func test_horizontal_velocity_aligned_to_wish_preserves_wish_direction() -> void:
var out: Vector2 = LocomotionWasdScript.horizontal_velocity_aligned_to_wish(
Vector2(0.0, 1.0), Vector2(0.0, 2.0), 5.0
)
assert_that(out).is_equal(Vector2(0.0, 2.0))

View File

@ -0,0 +1 @@
uid://b6yac8odp5ud

View File

@ -36,6 +36,20 @@ func test_set_authoritative_nav_goal_sets_goal_and_target() -> void:
assert_that(nav.target_position).is_equal(goal)
func test_set_locomotion_wish_world_xz_normalizes_horizontal() -> void:
var p := _make_player()
p.call("set_locomotion_wish_world_xz", Vector3(3.0, 9.0, 4.0))
var w: Vector3 = p.get("_locomotion_wish_world_xz") as Vector3
assert_that(w.y).is_equal(0.0)
assert_that(absf(w.length_squared() - 1.0)).is_less(0.0001)
func test_set_locomotion_wish_world_xz_zero_for_near_zero() -> void:
var p := _make_player()
p.call("set_locomotion_wish_world_xz", Vector3(0.0, 1.0, 0.0))
assert_that(p.get("_locomotion_wish_world_xz") as Vector3).is_equal(Vector3.ZERO)
func test_clear_nav_goal_clears_velocity_and_nav_target() -> void:
var p := _make_player()
p.velocity = Vector3(1.0, 0.0, 0.0)

View File

@ -31,6 +31,7 @@ class MockHttpTransport:
# Holds _busy without completing (never emits request_completed).
class HangingHttpTransport:
extends Node
@warning_ignore("unused_signal")
signal request_completed(
result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray
)
@ -46,68 +47,74 @@ class HangingHttpTransport:
return OK
func _make_client(mock: Node) -> Node:
func _make_client(http_transport: Node) -> Node:
var c: Node = PositionAuthorityTestDouble.new()
c.set("injected_http", mock)
auto_free(mock)
c.set("injected_http", http_transport)
auto_free(http_transport)
auto_free(c)
add_child(c)
return c
func test_sync_boot_get_200_emits_snap() -> void:
var mock := MockHttpTransport.new()
mock.enqueue(HTTPRequest.RESULT_SUCCESS, 200, '{"position":{"x":1,"y":0.9,"z":-5}}')
var c := _make_client(mock)
var transport := MockHttpTransport.new()
transport.enqueue(HTTPRequest.RESULT_SUCCESS, 200, '{"position":{"x":1,"y":0.9,"z":-5}}')
var c := _make_client(transport)
monitor_signals(c)
c.sync_from_server()
await assert_signal(c).is_emitted(
"authoritative_position_received", Vector3(1.0, 0.9, -5.0), true
)
assert_signal(c).is_emitted("authoritative_position_received", Vector3(1.0, 0.9, -5.0), true)
func test_sync_boot_get_200_malformed_position_does_not_emit() -> void:
var mock := MockHttpTransport.new()
mock.enqueue(HTTPRequest.RESULT_SUCCESS, 200, '{"position":"invalid"}')
var c := _make_client(mock)
var transport := MockHttpTransport.new()
transport.enqueue(HTTPRequest.RESULT_SUCCESS, 200, '{"position":"invalid"}')
var c := _make_client(transport)
monitor_signals(c)
c.sync_from_server()
await assert_signal(c).wait_until(400).is_not_emitted("authoritative_position_received")
assert_signal(c).wait_until(400).is_not_emitted("authoritative_position_received")
func test_move_post_400_emits_move_rejected() -> void:
var mock := MockHttpTransport.new()
mock.enqueue(HTTPRequest.RESULT_SUCCESS, 400, '{"reasonCode":"vertical_step_exceeded"}')
var c := _make_client(mock)
func test_move_stream_post_400_emits_move_rejected_then_boot_get() -> void:
var transport := MockHttpTransport.new()
transport.enqueue(HTTPRequest.RESULT_SUCCESS, 400, '{"reasonCode":"vertical_step_exceeded"}')
transport.enqueue(HTTPRequest.RESULT_SUCCESS, 200, '{"position":{"x":0,"y":0.9,"z":0}}')
var c := _make_client(transport)
monitor_signals(c)
c.submit_move_target(Vector3(1.0, 2.0, 3.0))
await assert_signal(c).is_emitted("move_rejected", "vertical_step_exceeded")
c.submit_stream_targets([Vector3(1.0, 2.0, 3.0)])
assert_signal(c).is_emitted("move_rejected", "vertical_step_exceeded")
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:
var mock := MockHttpTransport.new()
mock.enqueue(HTTPRequest.RESULT_SUCCESS, 400, "{}")
var c := _make_client(mock)
func test_move_stream_post_400_without_reason_emits_unknown() -> void:
var transport := MockHttpTransport.new()
transport.enqueue(HTTPRequest.RESULT_SUCCESS, 400, "{}")
transport.enqueue(HTTPRequest.RESULT_SUCCESS, 200, '{"position":{"x":1,"y":0.9,"z":1}}')
var c := _make_client(transport)
monitor_signals(c)
c.submit_move_target(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
)
c.submit_stream_targets([Vector3.ZERO])
assert_signal(c).is_emitted("move_rejected", "unknown")
assert_signal(c).is_emitted("authoritative_position_received", Vector3(1.0, 0.9, 1.0), true)
func test_second_sync_while_busy_is_ignored() -> void:
var mock := HangingHttpTransport.new()
var c := _make_client(mock)
var transport := HangingHttpTransport.new()
var c := _make_client(transport)
c.sync_from_server()
c.sync_from_server()
assert_that(mock.request_count).is_equal(1)
assert_that(transport.request_count).is_equal(1)
func test_move_stream_post_200_does_not_emit_position() -> void:
var transport := MockHttpTransport.new()
transport.enqueue(
HTTPRequest.RESULT_SUCCESS,
200,
(
'{"schemaVersion":1,"playerId":"dev-local-1","position":{"x":-4.9,"y":0.9,"z":-5},'
+ '"sequence":3}'
)
)
var c := _make_client(transport)
monitor_signals(c)
c.submit_stream_targets([Vector3(-4.9, 0.9, -5.0)])
assert_signal(c).wait_until(400).is_not_emitted("authoritative_position_received")

View File

@ -64,6 +64,8 @@ Deliver the foundational client runtime: movement, locked isometric camera, worl
- **Orbit not exposed in UX** for players in prototype (`allow_yaw` default off); zoom stays within configured bands; player remains readable during motion; occlusion policy active; `CameraState` contract documented for dependents.
- Telemetry hooks: `camera_zoom_changed` (optional throttled), occluder stress markers if needed for perf.
<a id="epic-1-slice-3"></a>
### Slice 3 - Interaction, targeting, and ability input wiring
- Scope: E1.M3 + E1.M4 with stub or live combat consumer for `AbilityCastRequest`.

View File

@ -33,7 +33,7 @@ Contract readiness is tracked in the [module dependency register](module_depende
## Implementation snapshot
- **Done (prototype):** Server-side authoritative **`PositionState`** read + **`MoveCommand`** apply (HTTP JSON v1, snap-to-target, `sequence` increments); **NEO-10** server-side step + optional district bounds validation with **`reasonCode`** rejections ([NEO-10](../../plans/NEO-10-implementation-plan.md), `MoveCommandValidation`, [server README — Move command](../../../server/README.md#move-command-neo-7-neo-10)); default **in-memory** store; optional **PostgreSQL** persistence when `ConnectionStrings:NeonSprawl` is set ([NEO-8](../../plans/NEO-8-implementation-plan.md)); shared **NpgsqlDataSource** disposed on application shutdown ([NEO-13](../../plans/NEO-13-implementation-plan.md)); Godot client **`POST`/`GET`** move flow ([NEO-6](../../plans/NEO-6-implementation-plan.md), [NEO-7](../../plans/NEO-7-implementation-plan.md), `server/NeonSprawl.Server/Game/PositionState/`, `client/scripts/`). **NEO-11** — client **`NavigationRegion3D` / `NavigationAgent3D`** for click-to-move visuals while server authority unchanged; **single-click obstacle detours not guaranteed** (see plan tradeoff) ([NEO-11](../../plans/NEO-11-implementation-plan.md); [client README](../../../client/README.md#authoritative-movement-neon-4-neon-8)). **`InteractionRequest`** + server-side horizontal range check ([NEO-9](../../plans/NEO-9-implementation-plan.md); `Game/Interaction/`, `Game/World/HorizontalReach.cs`, [server README — Interaction](../../../server/README.md#interaction-neon-6)). See [server README — Position persistence](../../../server/README.md#position-persistence-neon-5). Jira **[E1.M1](https://linear.app/neon-sprawl/project/e1m1-inputandmovementruntime-20eb28dd3db4)** (E1.M1 Feature) is **Done** for this prototype scope.
- **Done (prototype):** Server-side authoritative **`PositionState`** read + **`MoveCommand`** apply (HTTP JSON v1, snap-to-target, `sequence` increments); **`POST …/move-stream`** (NEO-22) for ordered multi-target applies with the same validation rules; **NEO-10** server-side step + optional district bounds validation with **`reasonCode`** rejections ([NEO-10](../../plans/NEO-10-implementation-plan.md), `MoveCommandValidation`, [server README — Move command](../../../server/README.md#move-command-neo-7-neo-10)); default **in-memory** store; optional **PostgreSQL** persistence when `ConnectionStrings:NeonSprawl` is set ([NEO-8](../../plans/NEO-8-implementation-plan.md)); shared **NpgsqlDataSource** disposed on application shutdown ([NEO-13](../../plans/NEO-13-implementation-plan.md)); Godot client **`move-stream`** + **WASD** default locomotion and legacy **`POST`/`GET`** move for **debug** click-to-move ([NEO-22](../../plans/NEO-22-implementation-plan.md), [NEO-6](../../plans/NEO-6-implementation-plan.md), [NEO-7](../../plans/NEO-7-implementation-plan.md), `server/NeonSprawl.Server/Game/PositionState/`, `client/scripts/`). **NEO-11** — client **`NavigationRegion3D` / `NavigationAgent3D`** for **debug** click-to-move visuals while server authority unchanged; **single-click obstacle detours not guaranteed** (see plan tradeoff) ([NEO-11](../../plans/NEO-11-implementation-plan.md); [client README](../../../client/README.md#authoritative-movement-neo-7-neo-11-neo-22)). **`InteractionRequest`** + server-side horizontal range check ([NEO-9](../../plans/NEO-9-implementation-plan.md); `Game/Interaction/`, `Game/World/HorizontalReach.cs`, [server README — Interaction](../../../server/README.md#interaction-neon-6)). See [server README — Position persistence](../../../server/README.md#position-persistence-neon-5). Jira **[E1.M1](https://linear.app/neon-sprawl/project/e1m1-inputandmovementruntime-20eb28dd3db4)** (E1.M1 Feature) is **Done** for this prototype scope.
- **Follow-on:** Client prediction/reconciliation; Epic 1 Slice 1 telemetry and movement-loop polish; optional **Protobuf** wire promotion for `MoveCommand` / `PositionState` per [contracts.md](contracts.md).
- **Alignment:** [Documentation and implementation alignment](documentation_and_implementation_alignment.md).

View File

@ -7,41 +7,129 @@
| **Module ID** | E1.M3 |
| **Epic** | [Epic 1 — Core Player Runtime](../epics/epic_01_core_player_runtime.md) |
| **Stage target** | Prototype |
| **Status** | Planned (see [dependency register](module_dependency_register.md)) |
| **Status** | In Progress (see [dependency register](module_dependency_register.md)) |
| **Linear** | [NEO-23](https://linear.app/neon-sprawl/issue/NEO-23) through [NEO-27](https://linear.app/neon-sprawl/issue/NEO-27) ([backlog](../../plans/E1M3-prototype-backlog.md)); label **`E1.M3`** per [decomposition README — Linear alignment](../README.md#linear-alignment) |
## Purpose
World object selection, target lock, focus swap rules, and hover/highlight feedback. Bridges movement/position ([E1.M1](E1_M1_InputAndMovementRuntime.md)) into combat, gathering, and ability activation ([E1.M4](E1_M4_AbilityInputScaffold.md), [E5.M1](E5_M1_CombatRulesEngine.md)).
**Authority:** [Client vs server — E1.M3](client_server_authority.md#e1m3-interactionandtargetinglayer) — the server decides valid **targets** and **interactable** eligibility (range, faction, phase, alive/dead, etc.); the client sends selection or use **intent** and may show hover/soft focus until the server confirms.
Bridges authoritative **position** and reach policy from [E1.M1 — InputAndMovementRuntime](E1_M1_InputAndMovementRuntime.md) into combat, gathering, abilities, and social flows: **who** is locked for gameplay, **what** in the world is selectable, and **when** selection changes—without treating camera pose as gameplay truth (see [E1.M2 — world-anchored semantics](E1_M2_IsometricCameraController.md#mid-project-rotation-policy-practical-compromise)).
## Boundary: E1.M1 vs E1.M3
| Topic | E1.M1 (today) | E1.M3 (this module) |
|--------|----------------|---------------------|
| **Primary job** | Locomotion + authoritative **“use interactable *now*”** attempt via `InteractionRequest` | **Selection model**: lock, swap rules, descriptors, and **change events** for UI and downstream systems |
| **Wire / HTTP** | `POST …/interact` + `InteractionResponse` with `reasonCode` ([NEO-9](../../plans/NEO-9-implementation-plan.md)) | Future **selection/target** intents and authoritative **`TargetState`** sync (wire shape TBD; see [contracts.md](contracts.md)) |
| **Registry / catalog** | Static prototype interactable table for range checks | **`InteractableDescriptor`** (and combat target projection) as the **stable** description surface for clients and validators |
| **Client UX** | Minimal **E** key + proximity **preview** glow (authoritative outcome still from server) | Tab-target, focus cycling, sticky lock, rich hover, prompts—**prototype scope** grows here |
**Shared policy (not owned exclusively by either module):** horizontal reach on **X/Z only**, **Y ignored** for threshold distance, inclusive radius boundary—same rule as [NEO-9](../../plans/NEO-9-implementation-plan.md). Both E1.M1 interact validation and E1.M3 eligibility checks **reuse** the same math and content anchors so client preview and server denial stay aligned.
## Responsibilities
- Resolve `TargetState` for tab-target and interaction flows.
- Describe interactables via `InteractableDescriptor` and emit `SelectionEvent` for UI and systems.
- **Combat target lock:** maintain and expose **`TargetState`** for tab-target flow; integrate with [E1.M4 — AbilityInputScaffold](E1_M4_AbilityInputScaffold.md) and [E5.M1 — CombatRulesEngine](E5_M1_CombatRulesEngine.md) for cast validity (server re-validates; client is a view + intent).
- **World interactables:** discovery, selection, and use flow beyond the NEO-9 smoke path—multiple kinds, prompts, and data-driven defs when scoped; still grounded in **`InteractableDescriptor`** projections.
- **Focus swap rules:** **Prototype:** explicit input to cycle targets / interactables in a deterministic order (e.g. tab through eligible list); **Deferred:** nearest-in-cone heuristics, soft-target stickiness under latency—decide with first multiplayer combat vertical slice ([authority doc — lag compensation](client_server_authority.md#what-this-doc-does-not-fix-yet)).
- **Presentation:** highlights, outlines, and optional **wrong** local preview until server ack; reconcile on snapshot or denial ([authority](client_server_authority.md#e1m3-interactionandtargetinglayer)).
- **E1.M2 adjacency:** targeting and line-of-interest for **gameplay** use **world geometry + server-known positions**, not `CameraState` or client-only aim ([E1.M2](E1_M2_IsometricCameraController.md)).
## Key contracts
| Contract | Role |
|----------|------|
| `TargetState` | Current locked target, validity, and swap rules. |
| `InteractableDescriptor` | What can be selected or used in range. |
| `SelectionEvent` | Selection changes for feedback and downstream consumers. |
| `TargetState` | Authoritative (server) view: locked combat/interaction target, validity, swap/clear reasons. |
| `InteractableDescriptor` | Stable description of a selectable world entity (id, kind, anchor, radius / flags). |
| `SelectionEvent` | Selection changes for UI and consumers (who/when/cause). |
### Contract notes ([contracts.md](contracts.md))
- **Kind:** these are **wire** contracts once the game channel exists (Protobuf preferred per [tech stack](../../architecture/tech_stack.md)); until then, versioned **JSON** spikes must be explicitly throwaway and tracked for migration—same discipline as early `MoveCommand` / `InteractionRequest`.
- **Denials:** reuse the **stable `reasonCode` string** pattern from `InteractionResponse` (NEO-9): whenever the server rejects a selection or use intent, return a **non-empty** machine-oriented code; document new codes alongside E5.M1 / E3.M1 consumers.
**Logical fields (illustrative, not a frozen schema):**
| Contract | Illustrative fields |
|----------|---------------------|
| `TargetState` | Target entity id (or empty); validity (`ok` / `out_of_range` / `invalid_target` / …); optional server tick or sequence for reconciliation |
| `InteractableDescriptor` | `interactableId`; category/kind; world position or entity ref; `interactionRadius`; flags (e.g. usable-in-combat) |
| `SelectionEvent` | Previous id, new id, cause (`tab`, `clear`, `target_died`, `zone_change`, `server_correction`, …) |
### Intent vs state (reference)
```mermaid
flowchart LR
subgraph clientLayer [Client]
Hover[HoverHighlight]
Intent[SelectionIntent]
end
subgraph serverLayer [Server]
Pos[PositionState_E1M1]
Val[EligibilityValidation]
TS[TargetState_truth]
end
Pos --> Val
Intent --> Val
Val --> TS
TS --> clientLayer
Hover -.->|"preview_only"| clientLayer
```
## Module dependencies
- **E1.M1** — InputAndMovementRuntime: range and position source of truth.
- **E1.M1** — InputAndMovementRuntime: authoritative **`PositionState`** and horizontal reach policy; existing **`InteractionRequest`** path for “use now” attempts.
**Logical (not in register “Depends On” column):** [E5.M1](E5_M1_CombatRulesEngine.md) resolves hostile actions and may **deny** casts for reasons beyond selection; [E6.M1](E6_M1_PvPEligibilityAndFlagState.md) gates player targets ([pvp_combat_integration.md](pvp_combat_integration.md)). E1.M3 does **not** list E5.M1 as a hard module dependency in the register so that targeting can ship and test against stubs per [Epic 1 Slice 3](../epics/epic_01_core_player_runtime.md#epic-1-slice-3).
## Dependents (by design)
- **E1.M4** — AbilityInputScaffold.
- **E3.M1** — ResourceNodeAndGatherLoop.
- **E5.M1** — CombatRulesEngine.
- **E8.M1** — PartyAndMatchAssembly (player/party context hooks per epic).
- **E1.M4** — AbilityInputScaffold: reads **`TargetState`** (and related validity) to build **`AbilityCastRequest`**.
- **E3.M1** — ResourceNodeAndGatherLoop: gather **interact** flows use range + selection aligned with **`InteractableDescriptor`**.
- **E5.M1** — CombatRulesEngine: tab-target resolution, valid actions against locked target; re-validates client intent.
- **E8.M1** — PartyAndMatchAssembly: party-scoped selection / invite context hooks anchored on player identity from targeting layer.
## Related implementation slices
Epic 1 **Slice 3** — interaction, targeting, and ability input wiring with [E5.M1](E5_M1_CombatRulesEngine.md).
[Epic 1 — Slice 3: Interaction, targeting, and ability input wiring](../epics/epic_01_core_player_runtime.md#epic-1-slice-3)
- **Scope:** E1.M3 + E1.M4 with stub or live combat consumer for `AbilityCastRequest`.
- **Dependencies:** E1.M2, E5.M1 (minimum combat hook).
- **Acceptance criteria:** Players can select targets/interactables and fire hotbar-bound abilities when the combat module accepts casts.
- **Telemetry hooks:** `target_changed`, `ability_cast_requested`, `ability_cast_denied` with reason codes (align with [E9.M1](E9_M1_TelemetryEventSchema.md) when catalog exists).
## Implementation snapshot
- **`TargetState` (prototype JSON v1 — NEO-23):** server **`GET /game/players/{id}/target`** and **`POST /game/players/{id}/target/select`** with versioned **`PlayerTargetStateResponse`** / **`TargetSelectRequest`** / **`TargetSelectResponse`**; stub registry + in-memory lock under `server/NeonSprawl.Server/Game/Targeting/`; horizontal reach + soft lock semantics — [NEO-23](../../plans/NEO-23-implementation-plan.md), [server README — Targeting](../../../server/README.md#targeting-neo-23). **Still open on later issues:** **`InteractableDescriptor`**, **`SelectionEvent`**, Godot tab-target ([NEO-24](https://linear.app/neon-sprawl/issue/NEO-24)). **Story backlog:** [E1M3 prototype backlog](../../plans/E1M3-prototype-backlog.md).
- **Precursor under E1.M1 (NEO-9):** server **`POST /game/players/{id}/interact`** with **`InteractionRequest`** / **`InteractionResponse`**; **`HorizontalReach`** (`server/NeonSprawl.Server/Game/World/HorizontalReach.cs`); prototype registry and API under `server/NeonSprawl.Server/Game/Interaction/`; client **`interaction_request_client.gd`**, **`interaction_radius_indicators.gd`**, **`prototype_interaction_constants.gd`** — see [NEO-9](../../plans/NEO-9-implementation-plan.md) and [server README — Interaction](../../../server/README.md#interaction-neo-9).
- **Alignment:** [Documentation and implementation alignment](documentation_and_implementation_alignment.md).
## Risks and telemetry
- **Soft vs authoritative target:** client may highlight a target the server will deny; mitigate with visible deny reasons, fast reconciliation, and logging material mismatch ([authority — debugging](client_server_authority.md#telemetry-and-debugging)).
- **Occluders vs truth:** camera occlusion and pick-through ([NEO-20](https://linear.app/neon-sprawl/issue/NEO-20)) affect **presentation** and click-to-move; **server** eligibility must not depend on “what the camera showed.”
- **Lag compensation** for tab-target: deferred per [client vs server authority](client_server_authority.md#what-this-doc-does-not-fix-yet); decide with combat design when E5.M1 networking MVP is scoped.
**Telemetry (Slice 3):** `target_changed`, `ability_cast_requested`, `ability_cast_denied`**TODO(E9.M1)** until event schema exists.
## Linear backlog
Parent epic: [Epic 1 — Core Player Runtime](https://linear.app/neon-sprawl/project/epic-1-core-player-runtime-client-controls-character-loop-66bd590cd016).
**Stories:** full acceptance checklists in **[`docs/plans/E1M3-prototype-backlog.md`](../../plans/E1M3-prototype-backlog.md)**.
| Slug | Linear |
|------|--------|
| **E1M3-01** | [NEO-23](https://linear.app/neon-sprawl/issue/NEO-23) — TargetState v1 + server selection intent |
| **E1M3-02** | [NEO-24](https://linear.app/neon-sprawl/issue/NEO-24) — Client tab-target + lock UI synced to server |
| **E1M3-03** | [NEO-25](https://linear.app/neon-sprawl/issue/NEO-25) — InteractableDescriptor list / projection |
| **E1M3-04** | [NEO-26](https://linear.app/neon-sprawl/issue/NEO-26) — SelectionEvent surface + consumer |
| **E1M3-05** | [NEO-27](https://linear.app/neon-sprawl/issue/NEO-27) — Slice 3 telemetry hook sites |
Issues should carry label **`E1.M3`** per [Linear alignment](../README.md#linear-alignment).
## Source anchors
- Master plan: [`neon_sprawl_vision.plan.md`](../../../neon_sprawl_vision.plan.md) — Epic 1.
- Master plan: [`neon_sprawl_vision.plan.md`](../../../neon_sprawl_vision.plan.md) — Epic 1, E1.M3 module line.
- [Module dependency register](module_dependency_register.md)
- [PvP and the combat engine](pvp_combat_integration.md) — E5.M1 + E6.M1 gating for player targets

View File

@ -48,6 +48,7 @@ Rows appear when work starts; default for unlisted modules is **Planned** / not
|--------|-----------------|----------|-------------------|
| E1.M1 | Ready | Prototype milestone **Done** ([E1.M1](https://linear.app/neon-sprawl/project/e1m1-inputandmovementruntime-20eb28dd3db4)). Authoritative `PositionState` + **MoveCommand** over HTTP (JSON v1 snap); **in-memory** store by default, **Postgres** when configured ([NEO-8](../../plans/NEO-8-implementation-plan.md)); shared **NpgsqlDataSource** disposed on host shutdown ([NEO-13](../../plans/NEO-13-implementation-plan.md)); Godot sync + path-follow ([NEO-7](../../plans/NEO-7-implementation-plan.md), [NEO-11](../../plans/NEO-11-implementation-plan.md)); **InteractionRequest** + horizontal range ([NEO-9](../../plans/NEO-9-implementation-plan.md)). Follow-on: prediction/reconciliation, Slice 1 telemetry, Protobuf wire per [contracts.md](contracts.md). | [NEO-6](../../plans/NEO-6-implementation-plan.md), [NEO-7](../../plans/NEO-7-implementation-plan.md), [NEO-8](../../plans/NEO-8-implementation-plan.md), [NEO-9](../../plans/NEO-9-implementation-plan.md), [NEO-10](../../plans/NEO-10-implementation-plan.md), [NEO-11](../../plans/NEO-11-implementation-plan.md), [NEO-13](../../plans/NEO-13-implementation-plan.md); `server/NeonSprawl.Server/Game/PositionState/`, `Game/Interaction/`; [server README](../../../server/README.md) |
| E1.M2 | Ready | **Slice 2 prototype slice closed:** follow + `CameraState` ([NEO-15](https://linear.app/neon-sprawl/issue/NEO-15)); zoom bands ([NEO-16](https://linear.app/neon-sprawl/issue/NEO-16)); occlusion ([NEO-17](https://linear.app/neon-sprawl/issue/NEO-17)); occluder pick-through ([NEO-20](https://linear.app/neon-sprawl/issue/NEO-20)); contracts + hardening ([NEO-18](https://linear.app/neon-sprawl/issue/NEO-18)). Client-local; no server use of camera pose. | [NEO-15](../../plans/NEO-15-implementation-plan.md), [NEO-16](../../plans/NEO-16-implementation-plan.md), [NEO-17](../../plans/NEO-17-implementation-plan.md), [NEO-18](../../plans/NEO-18-implementation-plan.md), [NEO-20](../../plans/NEO-20-implementation-plan.md); `client/scripts/isometric_follow_camera.gd`, `camera_state.gd`, `zoom_band_config.gd`, `occlusion_policy.gd`, `ground_pick.gd`; [E1_M2_IsometricCameraController.md](E1_M2_IsometricCameraController.md) |
| E1.M3 | In Progress | **NEO-23 landed:** JSON v1 targeting read + select (`GET`/`POST …/target`, `Game/Targeting/`, [NEO-23](../../plans/NEO-23-implementation-plan.md)) — `PlayerTargetStateResponse` / soft lock / `reasonCode` denials; wire name differs from illustrative **`TargetState`** in module doc (called out in plan + README). **`InteractableDescriptor`**, **`SelectionEvent`**, and client tab-target ([NEO-24](https://linear.app/neon-sprawl/issue/NEO-24)) still open. **Precursor (E1.M1):** [NEO-9](../../plans/NEO-9-implementation-plan.md) `POST …/interact`. | Linear [NEO-24](https://linear.app/neon-sprawl/issue/NEO-24)[NEO-27](https://linear.app/neon-sprawl/issue/NEO-27); [E1_M3_InteractionAndTargetingLayer.md](E1_M3_InteractionAndTargetingLayer.md); [E1M3 prototype backlog](../../plans/E1M3-prototype-backlog.md); [server README — Targeting](../../../server/README.md#targeting-neo-23), [Interaction](../../../server/README.md#interaction-neo-9) |
---

View File

@ -14,11 +14,13 @@ Fleshed-out scope, contracts, and integration notes live in **per-module documen
|---|---|---|---|---|---|
| E1.M1 | InputAndMovementRuntime | None | MoveCommand, PositionState, InteractionRequest | Prototype | Ready |
| E1.M2 | IsometricCameraController | E1.M1 | CameraState, ZoomBandConfig, OcclusionPolicy | Prototype | Ready |
| E1.M3 | InteractionAndTargetingLayer | E1.M1 | TargetState, InteractableDescriptor, SelectionEvent | Prototype | Planned |
| E1.M3 | InteractionAndTargetingLayer | E1.M1 | TargetState, InteractableDescriptor, SelectionEvent | Prototype | In Progress |
| E1.M4 | AbilityInputScaffold | E1.M3, E5.M1 | AbilityCastRequest, HotbarLoadout, CooldownSnapshot | Prototype | Planned |
**E1.M2 note:** Prototype slice **Ready**: `client/scripts/isometric_follow_camera.gd` + child `Camera3D` on **`World/IsometricFollowCamera`** in `client/scenes/main.tscn`; per-tick **`CameraState`** (`client/scripts/camera_state.gd`); **`ZoomBandConfig`** ([NEO-16](https://linear.app/neon-sprawl/issue/NEO-16)); **`OcclusionPolicy`** ([NEO-17](https://linear.app/neon-sprawl/issue/NEO-17)); pick-through **`"occluder"`** convention ([NEO-20](https://linear.app/neon-sprawl/issue/NEO-20)); consumer contract + occluder lifecycle hardening ([NEO-18](https://linear.app/neon-sprawl/issue/NEO-18)). See [E1_M2_IsometricCameraController.md](E1_M2_IsometricCameraController.md).
**E1.M3 note:** Decomposition expanded in [E1_M3_InteractionAndTargetingLayer.md](E1_M3_InteractionAndTargetingLayer.md) (E1.M1 vs E1.M3 boundary, contract sketches, Slice 3 alignment). **Partial (NEO-23):** versioned JSON v1 **`PlayerTargetStateResponse`** + **`POST …/target/select`** under `server/NeonSprawl.Server/Game/Targeting/` ([NEO-23](../../plans/NEO-23-implementation-plan.md); [server README — Targeting](../../../server/README.md#targeting-neo-23)) — throwaway HTTP spike per [contracts.md](contracts.md). **`InteractableDescriptor`** / **`SelectionEvent`** and a Godot consumer for targeting remain **in progress** on later Linear issues. **Precursor (E1.M1):** **`InteractionRequest`** + horizontal reach ([NEO-9](../../plans/NEO-9-implementation-plan.md); `Game/Interaction/`, `Game/World/HorizontalReach.cs`).
### Epic 2 — Skills and Progression Framework
| Module ID | Module Name | Depends On | Contract Needed | Phase Required | Status |

View File

@ -0,0 +1,153 @@
# E1.M3 — Prototype story backlog (InteractionAndTargetingLayer)
Working backlog for **Epic 1 — Slice 3** ([interaction, targeting, ability input](../decomposition/epics/epic_01_core_player_runtime.md#epic-1-slice-3)). Decomposition and contracts: [E1.M3 — InteractionAndTargetingLayer](../decomposition/modules/E1_M3_InteractionAndTargetingLayer.md).
**Labels (Linear):** every issue **`E1.M3`**; add **`Epic 1`** / Slice 3 as your team prefers.
**Precursor (do not re-scope):** authoritative **`InteractionRequest`** + horizontal reach = **E1.M1** / [NEO-9](NEO-9-implementation-plan.md). These stories **build on** that policy; they do not replace NEO-9 acceptance.
**Linear issues (created):** attach **`docs/plans/NEO-*-implementation-plan.md`** on the same branch as code when each story starts (same pattern as [NEO-18](NEO-18-implementation-plan.md)).
| Slug | Linear |
|------|--------|
| E1M3-01 | [NEO-23](https://linear.app/neon-sprawl/issue/NEO-23) |
| E1M3-02 | [NEO-24](https://linear.app/neon-sprawl/issue/NEO-24) |
| E1M3-03 | [NEO-25](https://linear.app/neon-sprawl/issue/NEO-25) |
| E1M3-04 | [NEO-26](https://linear.app/neon-sprawl/issue/NEO-26) |
| E1M3-05 | [NEO-27](https://linear.app/neon-sprawl/issue/NEO-27) |
**Dependency graph in Linear:** NEO-24, NEO-25 → blocked by **NEO-23**. NEO-26 → blocked by **NEO-23**, **NEO-24**. NEO-27 → blocked by **NEO-24**.
---
## Story order (recommended)
| Order | Slug | Linear | Depends on |
|-------|------|--------|------------|
| 1 | **E1M3-01** | [NEO-23](https://linear.app/neon-sprawl/issue/NEO-23) | E1.M1 (PositionState, HorizontalReach), NEO-9 patterns |
| 2 | **E1M3-02** | [NEO-24](https://linear.app/neon-sprawl/issue/NEO-24) | E1M3-01, E1.M2 (presentation only) |
| 3 | **E1M3-03** | [NEO-25](https://linear.app/neon-sprawl/issue/NEO-25) | E1M3-01 or parallel after NEO-9 registry read |
| 4 | **E1M3-04** | [NEO-26](https://linear.app/neon-sprawl/issue/NEO-26) | E1M3-01, E1M3-02 |
| 5 | **E1M3-05** | [NEO-27](https://linear.app/neon-sprawl/issue/NEO-27) | E1M3-02; [E9.M1](../decomposition/modules/E9_M1_TelemetryEventSchema.md) when schema exists |
---
### E1M3-01 — `TargetState` v1 + server selection intent
**Goal:** Publish a **versioned** server-owned **`TargetState`** and a client→server **selection intent** (name TBD: e.g. `TargetSelectRequest`) over the **existing HTTP JSON** prototype channel, with **`reasonCode`** denials consistent with [NEO-9](NEO-9-implementation-plan.md) / `InteractionResponse`.
**In scope**
- Server: validate intent using authoritative **`PositionState`** + shared **horizontal XZ** reach rules; stub **eligible target** set (static ids or scene-registered entities) until sim entities exist.
- Response: current **`TargetState`** (locked id or empty, validity, optional sequence).
- Unit + integration tests; [server README](../../server/README.md) section for the new route(s).
**Out of scope**
- Protobuf wire; full entity sim; PvP gating ([E6.M1](../decomposition/modules/E6_M1_PvPEligibilityAndFlagState.md)) beyond a single **stub** deny path if needed for API shape.
- Replacing **`POST …/interact`** — remain separate surface ([E1.M3 boundary](../decomposition/modules/E1_M3_InteractionAndTargetingLayer.md#boundary-e1m1-vs-e1m3)).
**Acceptance criteria**
- [ ] Client cannot commit a locked target the server would reject: **server** is source of truth for `TargetState`.
- [ ] Denied selection returns **200** (or agreed status) + body with **required** `reasonCode` when selection is rejected (match project conventions from NEO-9).
- [ ] Moving the player with the existing move APIs changes **in range / out of range** outcomes for target lock where range applies.
- [ ] Documented field list matches the **illustrative** table in [E1_M3 — Key contracts](../decomposition/modules/E1_M3_InteractionAndTargetingLayer.md#key-contracts) (refine names in the plan PR, not silently in code only).
---
### E1M3-02 — Client tab-target + lock UI synced to server
**Goal:** **Tab** (or agreed binding) cycles **server-eligible** targets in deterministic order; **visual** lock state matches last acknowledged **`TargetState`**.
**In scope**
- Godot: input → emit selection intent → apply server response; optional **optimistic** highlight **must** reconcile on deny ([authority](../decomposition/modules/client_server_authority.md#e1m3-interactionandtargetinglayer)).
- Clear target (e.g. Esc) if product agrees; document binding in `client/project.godot` or module README.
**Out of scope**
- Nearest-in-cone auto-target; sticky soft-target under latency ([deferred in E1.M3](../decomposition/modules/E1_M3_InteractionAndTargetingLayer.md#responsibilities)).
- Hotbar / `AbilityCastRequest` — that is [E1.M4](../decomposition/modules/E1_M4_AbilityInputScaffold.md) once E1M3-01/02 are in place.
**Acceptance criteria**
- [ ] With **≥2** stub targets, tab cycles order **defined in module or plan** (e.g. ascending id) and wraps.
- [ ] UI or debug overlay shows **server** lock id (or “none”) after each successful round-trip.
- [ ] Server denial updates UI to **previous** or **cleared** state per documented policy.
---
### E1M3-03 — `InteractableDescriptor` list / projection (multi-entity)
**Goal:** Server exposes a **small, versioned** list (or per-zone snapshot) of **`InteractableDescriptor`** records so the client can tab/hover **interactables** beyond the single NEO-9 row **without** duplicating registry truth in GDScript long-term.
**In scope**
- Extend or complement `PrototypeInteractableRegistry` (or successor) so **multiple** entries are addressable; single JSON **GET** or embedded list on an existing player/session endpoint — pick one and document.
- Client: consume list for proximity preview (optional) or selection id set; **constants in GDScript** must stay **documented** as duplicate-until-codegen ([NEO-9](NEO-9-implementation-plan.md) precedent) or generated from server in this story if you add a trivial export path.
**Out of scope**
- Full data-driven content pipeline ([contracts.md](../decomposition/modules/contracts.md) content kind).
- Gather loop rewards ([E3.M1](../decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md)).
**Acceptance criteria**
- [ ] **≥2** interactables in server registry; client can issue **`InteractionRequest`** against each id when in range (NEO-9 path still works).
- [ ] Descriptor payload includes at least: **id**, **world anchor**, **`interactionRadius`**, **kind** string or enum for future branching.
---
### E1M3-04 — `SelectionEvent` surface + consumer
**Goal:** Any **change** to effective target or primary interactable selection is observable as a **`SelectionEvent`** (wire or client-local mirror of server events for prototype).
**In scope**
- Define minimal event shape: **previous**, **next**, **cause** (`tab`, `clear`, `server_correction`, …).
- One **consumer**: debug panel, `print`, or thin HUD widget listing last N events.
**Out of scope**
- Full UI prompt system; party flows ([E8.M1](../decomposition/modules/E8_M1_PartyAndMatchAssembly.md)).
**Acceptance criteria**
- [ ] Tab, clear, and forced server correction (if testable) each emit distinguishable **cause** values.
- [ ] [E1.M4](../decomposition/modules/E1_M4_AbilityInputScaffold.md) can subscribe to the same bus in a follow-up without reshaping the event (document subscription point).
---
### E1M3-05 — Slice 3 telemetry hook sites
**Goal:** Align with Epic 1 Slice 3 hooks: **`target_changed`**, and reserve call sites for **`ability_cast_requested`** / **`ability_cast_denied`** (latter two may no-op until E1.M4 + E5.M1).
**In scope**
- Document function names or signals in client/server where events **will** emit; **`TODO(E9.M1)`** until [telemetry schema](../decomposition/modules/E9_M1_TelemetryEventSchema.md) exists.
- Optional: dev-only logging when flag enabled.
**Out of scope**
- Production ingest, dashboards, OpenTelemetry wiring.
**Acceptance criteria**
- [ ] Code or doc comment references **event names** matching [epic_01 Slice 3](../decomposition/epics/epic_01_core_player_runtime.md#epic-1-slice-3).
- [ ] [documentation_and_implementation_alignment.md](../decomposition/modules/documentation_and_implementation_alignment.md) updated when **NEO-23** merges (status **In Progress** / contract partial per team rule).
---
## After this backlog
- **E1.M4** stories: hotbar + **`AbilityCastRequest`** to [E5.M1](../decomposition/modules/E5_M1_CombatRulesEngine.md) stub or live consumer (separate plans).
- **Register:** when E1M3-01 merges exported **`TargetState`** (+ intent), update [module_dependency_register.md](../decomposition/modules/module_dependency_register.md) **E1.M3** row / note and E1.M3 **Status** per [documentation_and_implementation_alignment.md](../decomposition/modules/documentation_and_implementation_alignment.md).
---
## Related docs
- [E1_M3_InteractionAndTargetingLayer.md](../decomposition/modules/E1_M3_InteractionAndTargetingLayer.md)
- [NEO-9 — InteractionRequest](NEO-9-implementation-plan.md)
- [client_server_authority.md — E1.M3](../decomposition/modules/client_server_authority.md#e1m3-interactionandtargetinglayer)

View File

@ -19,7 +19,7 @@
- **Input:** `project.godot` actions (or code) for **W/A/S/D**; read them in a thin place (`main.gd` or a small `locomotion_input.gd`) and drive the players **horizontal** intent each physics tick.
- **Locomotion model:** `CharacterBody3D` + `move_and_slide()` with velocity derived from **wish direction × speed**, camera-relative or world-relative (decide in [Decisions](#decisions)); **no `NavigationAgent3D` path goal** for routine walking unless a later slice reintroduces hybrid “click to mark + auto-run” explicitly.
- **Remove / disable** left-click ground pick as the default move trigger (`ground_pick.gd` → `main.gd``PositionAuthorityClient.submit_move_target`). Either delete the flow for this slice or gate it behind a dev flag documented as off by default.
- **Server / authority:** Resolve how WASD maps to **NEO-7 `MoveCommand`** and **authoritative position** (see [Open questions](#open-questions--risks)). Minimum acceptable outcomes are spelled in acceptance criteria — pick one path and document it in `client/README.md` + decomposition.
- **Server / authority:** **Option C** — server accepts a **stream** of small validated moves (see [Decisions](#decisions)); document wire shape and cadence in `client/README.md` + `server/README.md` + decomposition. **NEO-7** `POST …/move` may remain for click/debug targets or be narrowed once the stream path owns routine locomotion (decide before merge).
- **Cleanup:** Delete or drastically narrow **NEO-11 / NEO-14 / NEO-16** workaround blocks in `player.gd` (column seam damp, nav column steering, idle trace scaffolding tied to click goals) **only** after WASD path is stable; prefer one commit that switches input then a follow-up that deletes dead code.
- **Docs:** Update `client/README.md`, `docs/decomposition/modules/E1_M1_InputAndMovementRuntime.md`, and this plans checklist when shipped.
@ -34,19 +34,15 @@
- [ ] **WASD** moves the avatar on the default **`main.tscn`** floor with **no** left-click requirement; speed and direction are stable on flat ground and on **stepped** QA props without the prior ±`MOVE_SPEED` seam oscillation at column/stair corners (subjective + optional `debug_idle_trace` spot-check).
- [ ] **Left-click** no longer starts a walk by default (or is clearly dev-only and off in normal play, documented).
- [ ] **Authoritative position** still converges with the server: document whether **continuous** sync, **throttled** `MoveCommand`, or **temporary client-only** prototype applies — and add the minimal client/server change so CI + manual steps do not lie.
- [ ] **Authoritative position** still converges with the server via the **stream / small-step** contract (option **C**); README + tests describe batching or per-tick cadence so CI + manual steps do not lie.
- [ ] **Cold boot `snap_to_server`** still works; no spawn underground / stale goal regressions.
- [ ] **NEO-10** rejection UX: if clicks are removed, another path still exercises reject payloads in dev (automated test or documented console-only command) **or** clicks remain only for “set goal” debug, not default locomotion.
- [ ] **GdUnit** (or existing harness) covers at least one pure helper for wish-dir / speed clamp if logic is non-trivial.
- [x] **GdUnit** (or existing harness) covers pure helpers on `player_locomotion_wasd.gd` (floor-ray continuation, median feet Y, micro-slip XZ projection, wish-aligned horizontal velocity).
## Technical approach
1. **Linear + branch:** Issue **NEO-22** exists in Backlog; on kickoff set **In Progress** and branch `neo-22-retire-click-to-move-implement-wasd-locomotion-godot-client` (or team naming).
2. **Authority model (blocking):** Decide with product/server owner:
- **A)** Client integrates WASD locally only for prototype; server receives **rate-limited** position or move deltas (may need new endpoint — likely **out of this slice** unless already planned), or
- **B)** Keep **discrete `MoveCommand`** but drive target from **keyboard-sampled “stick to last intent”** (awkward), or
- **C)** Server accepts a **stream** of small moves (largest change — schedule separately).
Document chosen option in README before merging locomotion feel work.
1. **Linear + branch:** **2026-04-17 kickoff** — Linear **In Progress**; story branch **`NEO-22-retire-click-to-move-wasd-locomotion`** (issue id first per [linear-git-naming](../../.cursor/rules/linear-git-naming.md)).
2. **Authority model:** **Option C** — introduce a **continuous-locomotion** server path (small validated steps or **batched** steps per HTTP call in this slice; **WebSocket / tick pipe** deferred unless explicitly pulled in). Reuse or mirror **NEO-10**-style limits (max step, ΔY) where applicable; assign **sequence** / ordering rules so out-of-order repeats are safe. **NEO-7** `POST …/move` stays available for debug or one-shot targets until stream-only is intentional. Document the contract in `client/README.md` and `server/README.md` before merge.
3. **Input layer:** Map WASD to `Vector2` in screen/camera space → world XZ tangent to `Camera3D` basis (same pattern as many third-person controllers); normalize; zero when no keys.
4. **`player.gd` refactor:** Replace `_has_walk_goal` / `_auth_walk_goal` steering path with **wish velocity** from parent or injected each `_physics_process`; keep gravity, floor snap policy, and `snap_to_server` entry points. Strip `NavigationAgent3D` dependency for default walk if decision says so (agent node can stay in scene unused until a later hybrid story).
5. **Remove click wiring:** Disconnect `target_chosen``submit_move_target` in `main.gd` for default build; optionally keep `ground_pick.gd` for debug “teleport goal” behind `OS.is_debug_build()` flag.
@ -57,14 +53,19 @@
| Topic | Choice | Rationale |
|------|--------|-----------|
| **Issue id** | **NEO-22** | Created in Linear 2026-04-16; plan filename matches this key. |
| **Camera vs world steering** | **TBD in kickoff** — default recommendation: **camera-relative XZ** | Matches isometric follow rig; document if world-axis is chosen instead. |
| **Kickoff** | **2026-04-17** | Branch `NEO-22-retire-click-to-move-wasd-locomotion`; status In Progress in Linear. |
| **Camera vs world steering** | **Camera-relative XZ** | Implemented in `main.gd` via `_camera` basis and `Input.get_vector` on **`move_*`** actions. |
| **NavAgent** | Default **off** for routine WASD in this slice | Removes nav-ribbon + column latch class of bugs; re-add only with explicit hybrid design. |
| **Authority (WASD → server)** | **Option C — stream of small moves** | Aligns wire semantics with continuous input; leaves room for batched HTTP now and a tick/WebSocket transport later without rebranding “every WASD sample as a click destination.” |
## Files to add
| Path | Purpose |
|------|---------|
| None required. | Prefer extending existing scripts unless `locomotion_input.gd` (or similar) is needed to keep `main.gd` thin per `godot-client-script-organization`. If extracted, add here and list in “Files to modify” as N/A for that line. |
| `server/NeonSprawl.Server/Game/PositionState/MoveStreamRequest.cs` (name at implementer discretion) | Versioned JSON body for **one or more** small authoritative steps (or equivalent “locomotion frame”) applied in order; pairs with new `MapPost` route. |
| `server/NeonSprawl.Server.Tests/Game/PositionState/MoveStreamApiTests.cs` (matching test file) | Happy path, rejection, unknown player, and ordering/sequence behavior for the new endpoint. |
| `client/scripts/locomotion_input.gd` (optional) | Thin WASD → wish vector if `main.gd` would otherwise grow; omit if logic stays trivially inline. |
| `client/scripts/player_locomotion_wasd.gd` | WASD wall-scrape / seam tick extracted from `player.gd` so **`gdlint` `max-file-lines`** (1600) stays green without raising the cap. |
## Files to modify
@ -73,8 +74,11 @@
| `client/project.godot` | Declare **`move_forward` / `move_back` / `move_left` / `move_right`** (or reuse Godot defaults) bound to WASD. |
| `client/scripts/main.gd` | Stop wiring click pick to `submit_move_target` by default; optionally forward WASD intent to player/authority per chosen model. |
| `client/scripts/ground_pick.gd` | Disable default `_input` pick **or** gate behind export/debug; update header comments (NS-16 lineage). |
| `client/scripts/player.gd` | Replace goal/nav horizontal steering with WASD-driven velocity; delete obsolete seam/nav state once stable. |
| `client/scripts/position_authority_client.gd` | Align with authority model (throttle, stream, or document client-only prototype). |
| `client/scripts/player.gd` | Replace goal/nav horizontal steering with WASD-driven velocity (delegates active WASD tick to `player_locomotion_wasd.gd`); delete obsolete seam/nav state once stable. |
| `client/scripts/position_authority_client.gd` | Add **stream submit** path (batch or frequent small-step POSTs), backoff/coalesce while in flight, and alignment with server **sequence** after locomotion frames. |
| `server/NeonSprawl.Server/Game/PositionState/PositionStateApi.cs` | Register **option C** route; delegate to store / validation (may loop `TryApplyMoveTarget` or new store helper for multi-step). |
| `server/NeonSprawl.Server/Game/PositionState/MoveCommandValidation.cs` (and/or new validator) | Reuse or factor shared rules so stream steps and legacy `MoveCommand` stay consistent where intended. |
| `server/README.md` | Document new movement stream endpoint, schema version, and relationship to NEO-7 `POST …/move`. |
| `client/scenes/main.tscn` | Only if `NavigationAgent3D` or nodes are removed/changed or input singleton paths change. |
| `client/README.md` | Replace “click-move” manual checks with WASD + server steps; note deprecation of click-to-move prototype. |
| `docs/decomposition/modules/E1_M1_InputAndMovementRuntime.md` | Snapshot: click-to-move retired in favor of WASD for prototype; link this plan. |
@ -85,12 +89,19 @@
| File | Coverage |
|------|----------|
| `client/test/player_test.gd` | **Change:** remove or replace tests that assumed `_has_walk_goal` / nav steering; add tests for any extracted **wish-direction** or speed clamp helper. |
| `client/test/position_authority_client_test.gd` | **Change:** align with new submit/sync cadence if `PositionAuthorityClient` changes. |
| `client/test/player_locomotion_wasd_test.gd` | **Add:** unit tests for static seam/locomotion math (`evaluate_floor_ray_hit_y`, median samples, micro-slip projection, horizontal velocity alignment). |
| `client/test/position_authority_client_test.gd` | **Change:** align with **stream** submit cadence, pending queue, and sequence handling. |
| `server/NeonSprawl.Server.Tests/Game/PositionState/MoveCommandApiTests.cs` | **Change only if** legacy `POST …/move` behavior or shared validation changes; otherwise leave as regression guard for one-shot moves. |
| **Manual (required)** | README path: boot with server, WASD on flat + stepped geometry, `snap_to_server`, and whichever NEO-10 rejection path remains after click removal. |
## Open questions / risks
- **Server contract:** Todays **`MoveCommand`** is **discrete target**-shaped ([NEO-7](NEO-7-implementation-plan.md)). WASD needs **continuous** intent — either extend the protocol, rate-limit many small commands, or temporarily diverge prototype authority. **This is the main schedule risk**; do not merge feel-only client changes without an explicit written decision in README + this plan.
- **Wire shape for C:** Single-step POST at high cadence vs **batched** array per POST vs early **WebSocket** — pick for NEO-22 to control server load and client complexity; document in README.
- **Legacy `MoveCommand`:** Whether **NEO-7** remains for debug/teleport only or is unified with stream handler — avoid two conflicting sources of truth without documenting precedence.
- **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

@ -0,0 +1,99 @@
# NEO-23 — Implementation plan
## Story reference
| Field | Value |
|--------|--------|
| **Key** | NEO-23 |
| **Title** | E1.M3: TargetState v1 + server selection intent |
| **Linear** | [NEO-23](https://linear.app/neon-sprawl/issue/NEO-23/e1m3-targetstate-v1-server-selection-intent) |
| **Git branch** | `NEO-23-e1m3-targetstate-selection-api` |
| **Parent context** | [Epic 1 — Core Player Runtime](https://linear.app/neon-sprawl/project/epic-1-core-player-runtime-client-controls-character-loop-66bd590cd016) · [E1.M3 prototype backlog](E1M3-prototype-backlog.md) — **E1M3-01** |
| **Decomposition** | [E1.M3 — InteractionAndTargetingLayer](../decomposition/modules/E1_M3_InteractionAndTargetingLayer.md) (key contracts, boundary vs [NEO-9](NEO-9-implementation-plan.md)) |
## Goal, scope, and out-of-scope
**Goal:** Ship **versioned JSON** over the **existing HTTP** prototype channel: **authoritative `TargetState`** per player plus a **client→server selection intent** (working name **`TargetSelectRequest`**) such that the **server** is the only source of truth for who is locked, with **`reasonCode`** denials aligned with [NEO-9](NEO-9-implementation-plan.md) / `InteractionResponse` conventions (handled attempt → **200** + structured body; **400** / **404** only where the interaction route already does).
**In scope**
- **Stub eligible combat targets:** static registry (≥ **2** distinct ids with world anchors + horizontal **lock radius**) so [NEO-24](https://linear.app/neon-sprawl/issue/NEO-24) can assume deterministic cycling without expanding scope here beyond “exists and validates.”
- **Selection intent:** `POST` with v1 body; **clear lock** when `targetId` is **omitted or JSON `null`** (explicit rule in XML + README). **Trim** non-null ids; **whitespace-only** after trim → **400** (same spirit as empty `interactableId` on NEO-9).
- **Authoritative read model:** `TargetState` includes **`lockedTargetId`** (**always** serialized; JSON **`null`** when no lock — never omit the key), **`validity`** derived from **current** `PositionState` vs stub anchor + radius using **`HorizontalReach`** (X/Z only, inclusive boundary — same policy as NEO-9), and **`sequence`** bumped only when the **persisted lock id** changes (aligns with “optional sequence” in the modules illustrative table).
- **`GET`** current `TargetState` for the same player id normalization rules as position/interact APIs.
- **Unit + integration tests** mirroring `InteractionApiTests` patterns; **server README** subsection documenting routes, curl, and `reasonCode` table.
**Out of scope (per Linear / backlog)**
- Protobuf wire; full entity sim; replacing **`POST …/interact`** (remains separate).
- PvP / faction gating beyond a single optional **`invalid_target`** (or reserved code) if we need a stub deny path for API shape — **no** real [E6.M1](../decomposition/modules/E6_M1_PvPEligibilityAndFlagState.md) logic.
- Godot tab-target UI, HUD, optimistic highlight — **[NEO-24](https://linear.app/neon-sprawl/issue/NEO-24)**.
## Acceptance criteria checklist
- [x] **Server source of truth:** Client cannot persist a lock the server rejects; every **`POST`** response includes the **authoritative** `targetState` after evaluation (success or denial).
- [x] **Denials:** **`selectionApplied`: `false`** ⇒ **required non-empty `reasonCode`**; **HTTP 200** for logical denials; **400** / **404** rules match NEO-9 spirit (unknown player → **404** before body; malformed v1 → **400**).
- [x] **Movement coupling:** After **`POST /move`** or **`POST /move-stream`**, the same **`GET`**/ **`POST`** response reflects updated **`validity`** (e.g. walk out of stub radius ⇒ `out_of_range` on read without requiring a new select).
- [x] **Contract names** in README + XML match the **illustrative** E1.M3 table ([Key contracts](../decomposition/modules/E1_M3_InteractionAndTargetingLayer.md#key-contracts)); any renames are visible in this plan and in PR text, not only in code.
## Technical approach
1. **New feature folder** `NeonSprawl.Server/Game/Targeting/` (keeps E1.M3 surface distinct from `Game/Interaction/` NEO-9 “use now” interactable path).
2. **Prototype stub registry** (e.g. `PrototypeTargetRegistry`): lowercase keys → anchor **X/Z** (Y stored but ignored for reach) + **`lockRadius`**. At least **two** rows at distinct horizontal positions so lock + range tests and NEO-24 ordering are unblocked. Constants duplicated in Godot **only when** NEO-24 adds preview; **not** required for NEO-23 if server-only.
3. **Mutable per-player lock** (e.g. `IPlayerTargetLockStore` + `InMemoryPlayerTargetLockStore`): stores **nullable locked target id** + **monotonic `sequence`** incremented **only** when the lock id changes (clear or swap). Thread-safe in-memory singleton for prototype (same lifetime pattern as `InMemoryPositionStateStore`).
4. **`TargetState` fields (v1 JSON, shared by GET body and nested POST echo):** `schemaVersion`, `playerId` (echo), **`lockedTargetId`** (string or JSON **`null`** — key **always present**), `validity` (`ok`, `out_of_range`, `invalid_target`, `none`), `sequence` (int). **Validity** is **computed** on each read: no lock ⇒ `validity: none` and `lockedTargetId: null`; unknown id in store (should not happen) ⇒ `invalid_target`; else horizontal distance vs **`lockRadius`**.
5. **`TargetSelectRequest` / `TargetSelectResponse` v1:** Request: `schemaVersion`, optional `targetId`. Response: `schemaVersion`, **`selectionApplied`** (bool), **`targetState`** (object matching the GET field set above — always present), **`reasonCode`** (required when `selectionApplied` is `false`, omitted when `true` — same spirit as `InteractionResponse`). Successful **clear**`selectionApplied: true`, `targetState` shows `lockedTargetId: null`, `validity: none`.
6. **Routes** (exact paths in README; suggested):
- `GET /game/players/{id}/target`**envelope** aligned with **`PositionStateResponse`**: top-level `schemaVersion`, `playerId`, plus lock fields (`lockedTargetId`, `validity`, `sequence`) so read and move APIs share the same “version + player echo + payload” pattern.
- `POST /game/players/{id}/target/select` → body `TargetSelectRequest`, response `TargetSelectResponse`.
7. **`Program.cs`:** register store + `app.MapTargetingApi()` (static extension parallel to `MapInteractionApi`).
8. **README:** new subsection **Targeting (NEO-23)** with curl, status table, `reasonCode` list (`unknown_target`, `out_of_range`, optional `invalid_target`, …).
**Decisions (locked — stakeholder 2026-04-18)**
| Topic | Choice |
|--------|--------|
| **`lockedTargetId` when no lock** | Property **always present**; value JSON **`null`** (never omit the key). |
| **GET body shape** | **Envelope** like `PositionStateResponse`: `schemaVersion`, `playerId`, then lock fields (easier evolution and parity with position GET). |
| **POST success flag** | **`selectionApplied`** (distinct from interacts `allowed` — avoids “permission” ambiguity). |
| **Out of range vs stored lock** | **Soft lock:** persisted lock **id** unchanged until clear/swap; **`validity`** becomes `out_of_range` when the player moves away until they clear or select again. **No auto-clear** on read or timer in NEO-23. |
## Files to add
| Path | Purpose |
|------|---------|
| `server/NeonSprawl.Server/Game/Targeting/TargetingApi.cs` | Maps GET/POST routes; normalizes player id; composes store + position + registry. |
| `server/NeonSprawl.Server/Game/Targeting/TargetStateDtos.cs` | JSON v1 DTOs: `TargetStateResponse` / `TargetStateBody` (split if needed), `TargetSelectRequest`, `TargetSelectResponse`. |
| `server/NeonSprawl.Server/Game/Targeting/PrototypeTargetRegistry.cs` | Static stub targets (≥2 ids) with anchors + `lockRadius`. |
| `server/NeonSprawl.Server/Game/Targeting/IPlayerTargetLockStore.cs` | Abstraction for lock + sequence mutations. |
| `server/NeonSprawl.Server/Game/Targeting/InMemoryPlayerTargetLockStore.cs` | Prototype in-memory implementation. |
| `server/NeonSprawl.Server/Game/Targeting/PlayerTargetStateReader.cs` (optional single file) | Pure helper: given lock id + `PositionSnapshot` + registry → `validity` (keeps handler thin). |
| `server/NeonSprawl.Server.Tests/Game/Targeting/TargetingApiTests.cs` | Integration tests: select, deny unknown, deny out of range, clear, move then re-read validity, unknown player 404, bad schema 400. |
## Files to modify
| Path | Rationale |
|------|-----------|
| `server/NeonSprawl.Server/Program.cs` | Register `IPlayerTargetLockStore` and call `MapTargetingApi()`. |
| `server/README.md` | Document NEO-23 endpoints, request/response fields, and `reasonCode` table next to Interaction (NEO-9). |
## Tests
| Test file | What to cover |
|-----------|----------------|
| `server/NeonSprawl.Server.Tests/Game/Targeting/TargetingApiTests.cs` | **Apply lock** when in range; **deny** unknown id with `reasonCode` + `selectionApplied` false + unchanged authoritative state; **deny** out of range when in range for another stub only if requesting the far one (or walk seed position); **clear** lock via POST without `targetId`; **GET** after **POST /move** updates `validity` without a new select; **404** unknown player; **400** bad `schemaVersion` / whitespace `targetId`; **case-insensitive** target id lookup. |
| *(optional)* `server/NeonSprawl.Server.Tests/Game/Targeting/PlayerTargetStateReaderTests.cs` | **Only if** validation is extracted: unit tests for validity transitions at inclusive radius boundary (same spirit as `HorizontalReachTests`). |
No automated Godot harness for NEO-23; manual verification is optional HTTP client / curl per README.
## Open questions / risks
- **NEO-24 UX:** document that UI may show **out of range** while **`lockedTargetId`** stays set until an explicit clear or a successful swap (soft lock).
- **Postgres vs in-memory target store:** prototype lock store is in-memory only for NEO-23; multi-instance deployment would need shared storage — out of scope until infra story exists.

View File

@ -0,0 +1,50 @@
# Code review — NEO-22 WASD locomotion (wall scrape / floor seam)
**Date:** 2026-04-17
**Scope:** Branch `NEO-22-retire-click-to-move-wasd-locomotion`; working-tree diff for `client/scripts/player.gd` only (unstaged vs `HEAD`). No PR URL supplied.
**Base:** `origin/main` @ merge-base `aee3ca80d8451952c62fff7e8e72899e0d62bf68` (inferred).
## Verdict
**Approve** (blocking CI line-count issue resolved; remaining bullets are non-blocking suggestions.)
## Summary
The WASD tick implements floor coyote time, wall-scrape detection (including slide normals when `is_on_floor` / `is_on_wall` disagree with Jolt), wish-aligned velocity cleanup when a floor continuation probe says walkable floor exists ahead, micro XZ projection onto the wish line to damp seam lip slip, median floor-ray feet Y sampling, and post`move_and_slide` feet Y smoothing / one-frame absorption (in `player_locomotion_wasd.gd` + callbacks on `player.gd`). Intent matches NEO-22 acceptance language (stable WASD on stepped geometry; NEO-14-class seam mitigation). **Repository CI:** `player.gd` is split so `gdlint` `max-file-lines` should pass. Secondary risks are behavioral (global coyote zeros vertical velocity for several ticks after *any* floor loss) and maintainability (hot-path branching split across two files).
## Documentation checked
| Path | Assessment |
|------|------------|
| `docs/plans/NEO-22-implementation-plan.md` | ~~**Partially matches** — WASD seam stability and `player.gd` locomotion work align with goal and acceptance bullets; remaining plan checklist / cleanup bullets not re-verified line-by-line in this review pass.~~ *Reviewed; defer remaining checklist + doc sync to story close-out.* |
| `docs/plans/NEO-14-implementation-plan.md` | **N/A** for literal checklist (not re-read end-to-end); comments in code cite NEO-14 seam behavior — **matches** spirit of prior seam/jitter work applied to WASD. |
| `docs/decomposition/modules/module_dependency_register.md` | **Matches** — E1.M1 remains the owning module; no new contract surfaces in this diff. |
| `docs/decomposition/modules/E1_M1_InputAndMovementRuntime.md` | ~~**Partially matches** — implementation snapshot describes WASD + move-stream; **Purpose** still reads “click-to-move or path-follow baseline,” legacy until a doc pass.~~ *Reviewed; Purpose wording update deferred to doc-only work.* |
| `docs/decomposition/modules/client_server_authority.md` | **N/A** — no server or wire changes; client-side `CharacterBody3D` nudges remain in local prediction territory. |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **N/A** — no register status row change required for this slice alone. |
## Blocking issues
1. ~~**`gdlint` `max-file-lines` will fail after merge.** Repo `gdlintrc` sets `max-file-lines: 1600` with an explicit note that `player.gd` is a large controller. `origin/main`s `client/scripts/player.gd` is **1483** lines; the working tree is **~1781** lines, i.e. **over the 1600 limit**. CI (`gdlint` on `client/scripts/`) should fail until lines are trimmed, config is temporarily raised with a tracked follow-up to split the script, or locomotion helpers move to another file under the NEO-22 refactor plan.~~ Done. WASD locomotion tick moved to `client/scripts/player_locomotion_wasd.gd`; `player.gd` is back under the 1600 line cap (`gdlint` / `gdformat` clean).
## Suggestions
1. ~~**Coyote vs ledge fall:** Floor coyote refreshes on `is_on_floor()` or `scrape_probe_hold`, otherwise decrements. While grounded-by-coyote is true, `velocity.y` is forced to `0.0` before `move_and_slide()`. That means **several physics ticks with no gravity** after leaving walkable support even on an open ledge (no wall scrape), which may feel like a short hover. If coyote is only meant to paper over Jolt floor flicker during wall scrape, consider narrowing when `velocity.y` is zeroed (e.g. require scrape vertical contact or probe hold), and keep normal air gravity otherwise.~~ *Reviewed; not implementing in this pass — revisit only if open-ledge playtest shows unacceptable hover.*
2. ~~**Hot-path calls:** Scrape vertical contact checks can scan all slide collisions and are invoked multiple times in one WASD tick. Caching the result once per tick would reduce work and keep behavior deterministic if slide ordering ever changes.~~ Done. `run_wasd` caches `_has_scrape_vertical_contact()` once **before** `move_and_slide()` (`scrape_vertical_pre_slide`) and once **after** (`scrape_vertical_post_slide`); pre vs post split preserves correct floor/slide state.
3. ~~**NEO-22 test AC:** The plan asks for GdUnit coverage when wish/scrape logic is non-trivial. Floor-ray feet Y, micro-slip projection, and wish alignment helpers in `player_locomotion_wasd.gd` are good candidates for small pure tests with a stubbed `PhysicsDirectSpaceState3D` or extracted math-only helpers.~~ Done. `client/test/player_locomotion_wasd_test.gd` covers the extracted static helpers; full `run_wasd` + `move_and_slide` remains manual / integration territory.
4. ~~**Readability:** The post`move_and_slide` block repeats the same scrape-contact predicates; folding into a single boolean or early section may reduce mistake surface when tuning.~~ Done. `wish_active_scrape_vertical_pre`, `post_slide_wish_floor_scrape`, and related locals in `player_locomotion_wasd.gd`.
## Nits
- ~~**Nit:** In floor-ray median sampling, `var y_vals: Array = []` could use a typed array (`Array[float]`) for consistency with typed GDScript elsewhere.~~ *Reviewed; source already uses `Array[float]` in `_floor_ray_feet_y_median`.*
- ~~**Nit:** `wish_probe` duplicates direction already in `_locomotion_wish_world_xz`; low cost, but if refactors continue, a single source avoids drift.~~ *Reviewed; not deduplicating in this pass.*
## Verification
- Install tooling per `gdscript-style` / CI: `pip install "gdtoolkit==4.5.0"`, then `gdlint client/scripts client/test` and `gdformat --check client/scripts client/test`.
- Godot manual: WASD along stepped props and column-like seams from NEO-14 QA; confirm no regression on flat idle (NEO-14 idle path unchanged in diff but worth a smoke check).
- ~~Walk off an open ledge with no wall: confirm fall start timing matches design (see coyote suggestion).~~ *Reviewed; optional QA tied to suggestion #1 (not pursuing coyote change here).*

View File

@ -0,0 +1,48 @@
# Code review — NEO-23 (targeting API)
**Date:** 2026-04-17
**Scope:** Branch `NEO-23-e1m3-targetstate-selection-api`; `git` range `main...HEAD` (working tree clean at review time). Linear **NEO-23**.
**Base:** `main` (local `origin/main` implied).
## Verdict
**Approve with nits**
## Summary
The branch adds the NEO-23 targeting surface under `server/NeonSprawl.Server/Game/Targeting/`: prototype registry, in-memory per-player lock + sequence, horizontal reachbased validity (soft lock), `GET /game/players/{id}/target` and `POST …/target/select`, DTOs aligned with the plan (`selectionApplied`, optional `reasonCode`, always-present `lockedTargetId`), integration tests in the same spirit as interaction APIs, and server README coverage. Behavior matches the written implementation plan for denials (200 + `reasonCode` when not applied), 400/404 boundaries, clear via omitted/null `targetId`, and movement-driven `validity` without auto-clear. `dotnet test NeonSprawl.sln` passes (55 tests).
## Documentation checked
| Path | Result |
|------|--------|
| `docs/plans/NEO-23-implementation-plan.md` (branch) | **Matches** — routes, DTO names as documented (`PlayerTargetStateResponse`, `TargetSelectRequest`/`Response`), soft lock, sequence rules, README scope, and acceptance bullets align with the code under review. |
| `docs/decomposition/modules/E1_M3_InteractionAndTargetingLayer.md` | **Matches** (postfollow-up) — Summary **Status**, **Implementation snapshot**, and NEO-23 `Game/Targeting/` + README pointers reflect the JSON v1 spike; later contracts remain explicitly open. |
| `docs/decomposition/modules/module_dependency_register.md` | **Matches** (postfollow-up) — E1.M3 **In Progress**; note text covers NEO-23 partial slice vs remaining contracts. |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** (postfollow-up) — E1.M3 row register + snapshot + plans cell aligned with NEO-23 merge and follow-on Linear issues. |
| `docs/decomposition/modules/contracts.md` (versioned JSON spike discipline) | **Matches** — explicit `schemaVersion`, throwaway JSON over HTTP, consistent with other prototype routes. |
**Register / tracking table:** ~~After merge, **should** update `module_dependency_register.md` and `documentation_and_implementation_alignment.md` (and optionally the E1.M3 module snapshot) so status and inventory match the new `Game/Targeting/` + README section.~~ **Done** on branch (same PR window as NEO-23).
## Blocking issues
(none)
## Suggestions
1. ~~**Doc alignment in merge train:** Update `module_dependency_register.md`, `documentation_and_implementation_alignment.md`, and the **Implementation snapshot** in `E1_M3_InteractionAndTargetingLayer.md` so E1.M3 is not described as having zero implementation once NEO-23 lands (per this repos own alignment policy). A follow-up doc-only PR is acceptable if called out explicitly for the merge.~~ **Done.**
2. ~~**Test gap — `GET` 404:** `PostSelect_ShouldReturnNotFound_WhenPlayerUnknown` covers unknown player on POST. Adding a parallel test for `GET /game/players/{id}/target` would lock the same contract as position/interact read paths.~~ **Done.**
3. ~~**`PlayerTargetStateReaderTests` depth:** The plans optional table called out inclusive-radius boundary cases (similar to `HorizontalReachTests`). Current unit tests cover `null` lock and unknown registry id only; adding one boundary assertion would better match the plans optional coverage intent.~~ **Done.**
## Nits
- **Nit:** Wire type name `PlayerTargetStateResponse` vs illustrative doc name `TargetState` is already explained in the NEO-23 plan and README; no change required if PR text points readers there once.
## Verification
- `dotnet test NeonSprawl.sln` (author ran: all passed).
- Manual: `curl` examples from `server/README.md` **Targeting (NEO-23)** against a running server (optional smoke).

View File

@ -0,0 +1,49 @@
# Documentation review — E1.M3 documentation pass
**Date:** 2026-04-18
**Scope:** E1.M3 decomposition expansion, prototype backlog, register/alignment touchpoints, and Linear story linkage (NEO-23NEO-27).
**Base:** `main` / repo as of review (no full `docs/game-design/**` pass). **Follow-up:** Suggestions in this file are **Done.** (strikethrough under **Suggestions**); epic Slice 3 anchor + alignment pointers landed in the same doc pass.
## Verdict
**Approve with suggestions** — Content is coherent, implementation-ready, and consistent with authority + NEO-9 boundary. ~~Address anchor fragility on Epic 1 Slice 3 and optionally tighten alignment-table wording vs Linear issues.~~ **Done.** (2026-04-18 follow-up commit.)
## Summary
The E1.M3 module doc, `E1M3-prototype-backlog.md`, and supporting register/alignment rows form a clear stack: E1.M1/NEO-9 precursor vs E1.M3 contracts, Slice 3 alignment, and ticketed work in Linear. Cross-links to decomposition siblings, `client_server_authority.md`, and plans resolve. Remaining gaps are **mechanical** (GitHub/Cursor heading slug stability for Slice 3; optional explicit mention of NEO-2327 in the alignment tracking row).
## Documentation checked
| Path | Result |
|------|--------|
| `docs/decomposition/modules/E1_M3_InteractionAndTargetingLayer.md` | **Matches** — Purpose, boundary table, contracts, deps/dependents, snapshot, risks, Linear table align with register and NEO-9 out-of-scope. |
| `docs/plans/E1M3-prototype-backlog.md` | **Matches** — Story order, AC blocks, dependency graph vs Linear blockers; precursor warning preserved. |
| `docs/decomposition/modules/module_dependency_register.md` (E1.M3 note) | **Matches** — Note matches module doc and Planned status. |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` (E1.M3 row) | **Matches** (postfollow-up) — Plans cell now cites Linear NEO-23NEO-27 + backlog; register **Planned** rule unchanged. |
| `docs/decomposition/epics/epic_01_core_player_runtime.md` (Slice 3) | **Matches** (postfollow-up) — Stable `#epic-1-slice-3` anchor added. |
| `docs/decomposition/modules/client_server_authority.md` (E1.M3, deferred networking) | **Matches** — Cited behavior matches E1_M3 responsibilities. |
| `docs/plans/NEO-9-implementation-plan.md` | **Matches** — Boundary “do not re-scope NEO-9” is respected in E1.M3 + backlog. |
| `docs/decomposition/modules/contracts.md` | **N/A** (spot-check) — E1.M3 references kinds/versioning at high level; no conflict. |
| `docs/game-design/gathering.md` | **N/A** — Not modified; still points at E1.M3 module; no conflict expected from this pass. |
## Blocking issues
None — no broken relative paths were found for files targeted by this change set; `docs/architecture/tech_stack.md`, `E9_M1_TelemetryEventSchema.md`, and `server/README.md` targets exist.
## Suggestions
1. ~~**Stable anchor for Epic 1 Slice 3** — `epic_01_core_player_runtime.md` is deep-linked as `#slice-3---interaction-targeting-and-ability-input-wiring` from `E1_M3_InteractionAndTargetingLayer.md` and `E1M3-prototype-backlog.md`. Slug behavior varies by renderer; **`epic_03_crafting_economy.md` already uses explicit `<a id="epic-3-slice-…">`** (see `docs/reviews/2026-04-03-game-design-branch-docs-review.md`). Add a similar **`id="epic-1-slice-3"`** (or equivalent) before the Slice 3 heading in `epic_01_core_player_runtime.md` and point links at it to avoid anchor drift.~~ **Done.**`<a id="epic-1-slice-3"></a>` in `epic_01_core_player_runtime.md`; links updated to `#epic-1-slice-3` in `E1_M3_InteractionAndTargetingLayer.md` and `E1M3-prototype-backlog.md`.
2. ~~**Alignment table vs Linear** — The E1.M3 row says register stays **Planned** until first implementation **merges**; that is correct per `documentation_and_implementation_alignment.md` rules. Consider one clause in the **Plans / pointers** cell: e.g. “Linear **NEO-23NEO-27** (Todo); register unchanged until code merges” so the table reflects ticketed work without bumping status early.~~ **Done.** — E1.M3 row Plans cell updated.
3. ~~**E1M3-05 acceptance line** — In `E1M3-prototype-backlog.md`, the checkbox “update … when E1M3-01 merges” could say **NEO-23** for parity with Linear keys used elsewhere in the same file.~~ **Done.**
## Nits
- ~~Summary table Linear cell uses an en-dash between NEO-23 and NEO-27 links; fine for humans; optional to spell out “through” for clarity in prose contexts outside the table.~~ **Done.**`E1_M3_InteractionAndTargetingLayer.md` Summary **Linear** row now says “through”.
## Verification
- After any anchor fix: open `epic_01_core_player_runtime.md` from links in `E1_M3_InteractionAndTargetingLayer.md` and `E1M3-prototype-backlog.md` and confirm the viewport lands on Slice 3.
- Grep `E1.M3` / `NEO-2[3-7]` in `docs/` after edits to ensure no stale “TBD” story rows remain.
- When NEO-23 merges: update alignment row + register per the backlog “After this backlog” section (already stated).

View File

@ -32,7 +32,7 @@ function-preload-variable-name: ([A-Z][a-z0-9]*)+
function-variable-name: '[a-z][a-z0-9]*(_[a-z0-9]+)*'
load-constant-name: (([A-Z][a-z0-9]*)+|_?[A-Z][A-Z0-9]*(_[A-Z0-9]+)*)
loop-variable-name: _?[a-z][a-z0-9]*(_[a-z0-9]+)*
# `player.gd` is a single CharacterBody3D controller (~1.4k lines); keep headroom until it splits.
# `player.gd` is the CharacterBody3D controller; WASD tick lives in `player_locomotion_wasd.gd`.
max-file-lines: 1600
max-line-length: 100
max-public-methods: 20

View File

@ -0,0 +1,120 @@
using System.Net;
using System.Net.Http.Json;
using System.Text;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.PositionState;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.PositionState;
public sealed class MoveStreamApiTests
{
[Fact]
public async Task PostMoveStream_ShouldApplyChainAndIncrementSequence_WhenTwoSmallSteps()
{
await using var factory = new InMemoryWebApplicationFactory().WithWebHostBuilder(b =>
{
b.ConfigureTestServices(services =>
{
services.PostConfigure<GamePositionOptions>(o =>
{
o.MovementValidation.HorizontalStepEnabled = true;
o.MovementValidation.MaxHorizontalStep = 1.0;
});
});
});
var client = factory.CreateClient();
var before = await client.GetFromJsonAsync<PositionStateResponse>("/game/players/dev-local-1/position");
Assert.NotNull(before);
var seq0 = before!.Sequence;
var body = new MoveStreamRequest
{
SchemaVersion = MoveStreamRequest.CurrentSchemaVersion,
Targets =
[
new PositionVector { X = -4.9, Y = 0.9, Z = -5.0 },
new PositionVector { X = -4.8, Y = 0.9, Z = -5.0 },
],
};
var postResponse = await client.PostAsJsonAsync("/game/players/dev-local-1/move-stream", body);
var postBody = await postResponse.Content.ReadFromJsonAsync<PositionStateResponse>();
Assert.Equal(HttpStatusCode.OK, postResponse.StatusCode);
Assert.NotNull(postBody);
Assert.Equal(seq0 + 2, postBody!.Sequence);
Assert.Equal(-4.8, postBody.Position.X);
Assert.Equal(0.9, postBody.Position.Y);
Assert.Equal(-5.0, postBody.Position.Z);
}
[Fact]
public async Task PostMoveStream_ShouldReturnNotFound_WhenPlayerIsUnknown()
{
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
var body = new MoveStreamRequest
{
SchemaVersion = MoveStreamRequest.CurrentSchemaVersion,
Targets = [new PositionVector { X = 0, Y = 0.9, Z = 0 }],
};
var response = await client.PostAsJsonAsync("/game/players/unknown-player/move-stream", body);
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
public async Task PostMoveStream_ShouldReturn400_WhenTargetsEmpty()
{
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
var content = new StringContent(
"{\"schemaVersion\":1,\"targets\":[]}",
Encoding.UTF8,
"application/json");
var response = await client.PostAsync("/game/players/dev-local-1/move-stream", content);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task PostMoveStream_ShouldReturn400WithReason_WhenSecondStepExceedsHorizontalLimit()
{
await using var factory = new InMemoryWebApplicationFactory().WithWebHostBuilder(b =>
{
b.ConfigureTestServices(services =>
{
services.PostConfigure<GamePositionOptions>(o =>
{
o.MovementValidation.HorizontalStepEnabled = true;
o.MovementValidation.MaxHorizontalStep = 0.2;
});
});
});
var client = factory.CreateClient();
var body = new MoveStreamRequest
{
SchemaVersion = MoveStreamRequest.CurrentSchemaVersion,
Targets =
[
new PositionVector { X = -4.95, Y = 0.9, Z = -5.0 },
new PositionVector { X = -4.0, Y = 0.9, Z = -5.0 },
],
};
var response = await client.PostAsJsonAsync("/game/players/dev-local-1/move-stream", body);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
var rej = await response.Content.ReadFromJsonAsync<MoveCommandRejectedResponse>();
Assert.NotNull(rej);
Assert.Equal(MoveCommandReasonCodes.HorizontalStepExceeded, rej!.ReasonCode);
var after = await client.GetFromJsonAsync<PositionStateResponse>("/game/players/dev-local-1/position");
Assert.NotNull(after);
Assert.Equal(-5.0, after!.Position.X);
}
}

View File

@ -0,0 +1,42 @@
using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Targeting;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Targeting;
public class PlayerTargetStateReaderTests
{
[Fact]
public void ComputeValidity_ShouldReturnInvalidTarget_WhenLockIdNotInRegistry()
{
var snap = new PositionSnapshot(0, 0, 0, 0);
Assert.Equal(TargetValidity.InvalidTarget, PlayerTargetStateReader.ComputeValidity("not_in_registry", in snap));
}
[Fact]
public void ComputeValidity_ShouldReturnNone_WhenLockNull()
{
var snap = new PositionSnapshot(0, 0, 0, 0);
Assert.Equal(TargetValidity.None, PlayerTargetStateReader.ComputeValidity(null, in snap));
}
[Fact]
public void ComputeValidity_AtExactLockRadius_ReturnsOk()
{
// Alpha anchor XZ (-5, -5), lockRadius 8 → horizontal distance 8 from (3, -5) is on boundary (inclusive).
var snap = new PositionSnapshot(3, 0, -5, 0);
Assert.Equal(
TargetValidity.Ok,
PlayerTargetStateReader.ComputeValidity(PrototypeTargetRegistry.PrototypeTargetAlphaId, in snap));
}
[Fact]
public void ComputeValidity_JustOutsideLockRadius_ReturnsOutOfRange()
{
const double eps = 1e-6;
var snap = new PositionSnapshot(3 + eps, 0, -5, 0);
Assert.Equal(
TargetValidity.OutOfRange,
PlayerTargetStateReader.ComputeValidity(PrototypeTargetRegistry.PrototypeTargetAlphaId, in snap));
}
}

View File

@ -0,0 +1,300 @@
using System.Net;
using System.Net.Http.Json;
using System.Text;
using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Targeting;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Targeting;
/// <summary>Integration tests for <see cref="TargetingApi"/> (NEO-23).</summary>
public class TargetingApiTests
{
[Fact]
public async Task GetTarget_ShouldReturnNone_WhenNoLock()
{
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
var response = await client.GetAsync("/game/players/dev-local-1/target");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<PlayerTargetStateResponse>();
Assert.NotNull(body);
Assert.Equal(PlayerTargetStateResponse.CurrentSchemaVersion, body!.SchemaVersion);
Assert.Equal("dev-local-1", body.PlayerId);
Assert.Null(body.LockedTargetId);
Assert.Equal(TargetValidity.None, body.Validity);
Assert.Equal(0, body.Sequence);
}
[Fact]
public async Task PostSelect_ShouldApplyLock_WhenInRange()
{
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
var response = await client.PostAsJsonAsync(
"/game/players/dev-local-1/target/select",
new TargetSelectRequest
{
SchemaVersion = TargetSelectRequest.CurrentSchemaVersion,
TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId,
});
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<TargetSelectResponse>();
Assert.NotNull(body);
Assert.True(body!.SelectionApplied);
Assert.Null(body.ReasonCode);
Assert.Equal(PrototypeTargetRegistry.PrototypeTargetAlphaId, body.TargetState.LockedTargetId);
Assert.Equal(TargetValidity.Ok, body.TargetState.Validity);
Assert.Equal(1, body.TargetState.Sequence);
}
[Fact]
public async Task PostSelect_ShouldDenyUnknownTarget_WithReasonCode()
{
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
var response = await client.PostAsJsonAsync(
"/game/players/dev-local-1/target/select",
new TargetSelectRequest
{
SchemaVersion = TargetSelectRequest.CurrentSchemaVersion,
TargetId = "not_a_real_target",
});
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<TargetSelectResponse>();
Assert.NotNull(body);
Assert.False(body!.SelectionApplied);
Assert.Equal(TargetingApi.ReasonUnknownTarget, body.ReasonCode);
Assert.Null(body.TargetState.LockedTargetId);
Assert.Equal(TargetValidity.None, body.TargetState.Validity);
}
[Fact]
public async Task PostSelect_ShouldDenyOutOfRange_WhenBetaFarFromSpawn()
{
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
var response = await client.PostAsJsonAsync(
"/game/players/dev-local-1/target/select",
new TargetSelectRequest
{
SchemaVersion = TargetSelectRequest.CurrentSchemaVersion,
TargetId = PrototypeTargetRegistry.PrototypeTargetBetaId,
});
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<TargetSelectResponse>();
Assert.NotNull(body);
Assert.False(body!.SelectionApplied);
Assert.Equal(TargetingApi.ReasonOutOfRange, body.ReasonCode);
Assert.Null(body.TargetState.LockedTargetId);
}
[Fact]
public async Task PostSelect_ShouldClearLock_WhenTargetIdOmitted()
{
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
Assert.Equal(
HttpStatusCode.OK,
(await client.PostAsJsonAsync(
"/game/players/dev-local-1/target/select",
new TargetSelectRequest
{
SchemaVersion = TargetSelectRequest.CurrentSchemaVersion,
TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId,
})).StatusCode);
var clear = await client.PostAsync(
"/game/players/dev-local-1/target/select",
new StringContent(
$"{{\"schemaVersion\":{TargetSelectRequest.CurrentSchemaVersion}}}",
Encoding.UTF8,
"application/json"));
Assert.Equal(HttpStatusCode.OK, clear.StatusCode);
var body = await clear.Content.ReadFromJsonAsync<TargetSelectResponse>();
Assert.NotNull(body);
Assert.True(body!.SelectionApplied);
Assert.Null(body.TargetState.LockedTargetId);
Assert.Equal(TargetValidity.None, body.TargetState.Validity);
}
[Fact]
public async Task GetTarget_ShouldShowOutOfRange_AfterMoveWithoutSelect()
{
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
Assert.Equal(
HttpStatusCode.OK,
(await client.PostAsJsonAsync(
"/game/players/dev-local-1/target/select",
new TargetSelectRequest
{
SchemaVersion = TargetSelectRequest.CurrentSchemaVersion,
TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId,
})).StatusCode);
var move = new MoveCommandRequest
{
SchemaVersion = MoveCommandRequest.CurrentSchemaVersion,
Target = new PositionVector { X = 50, Y = 0, Z = 50 },
};
Assert.Equal(HttpStatusCode.OK, (await client.PostAsJsonAsync("/game/players/dev-local-1/move", move)).StatusCode);
var get = await client.GetAsync("/game/players/dev-local-1/target");
Assert.Equal(HttpStatusCode.OK, get.StatusCode);
var body = await get.Content.ReadFromJsonAsync<PlayerTargetStateResponse>();
Assert.NotNull(body);
Assert.Equal(PrototypeTargetRegistry.PrototypeTargetAlphaId, body!.LockedTargetId);
Assert.Equal(TargetValidity.OutOfRange, body.Validity);
}
[Fact]
public async Task GetTarget_ShouldReturnNotFound_WhenPlayerUnknown()
{
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
var response = await client.GetAsync("/game/players/missing-player/target");
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
public async Task PostSelect_ShouldReturnNotFound_WhenPlayerUnknown()
{
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
var response = await client.PostAsJsonAsync(
"/game/players/missing-player/target/select",
new TargetSelectRequest
{
SchemaVersion = TargetSelectRequest.CurrentSchemaVersion,
TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId,
});
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
public async Task PostSelect_ShouldReturnBadRequest_WhenSchemaVersionWrong()
{
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
var response = await client.PostAsJsonAsync(
"/game/players/dev-local-1/target/select",
new TargetSelectRequest
{
SchemaVersion = 999,
TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId,
});
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task PostSelect_ShouldReturnBadRequest_WhenTargetIdWhitespaceOnly()
{
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
var response = await client.PostAsJsonAsync(
"/game/players/dev-local-1/target/select",
new TargetSelectRequest
{
SchemaVersion = TargetSelectRequest.CurrentSchemaVersion,
TargetId = " \t ",
});
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task PostSelect_ShouldBeCaseInsensitive_ForTargetId()
{
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
var response = await client.PostAsJsonAsync(
"/game/players/dev-local-1/target/select",
new TargetSelectRequest
{
SchemaVersion = TargetSelectRequest.CurrentSchemaVersion,
TargetId = " PROTOTYPE_TARGET_ALPHA ",
});
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<TargetSelectResponse>();
Assert.NotNull(body);
Assert.True(body!.SelectionApplied);
Assert.Equal(PrototypeTargetRegistry.PrototypeTargetAlphaId, body.TargetState.LockedTargetId);
}
[Fact]
public async Task PostSelect_ShouldStayIdempotent_WhenSameTargetReselect()
{
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
var req = new TargetSelectRequest
{
SchemaVersion = TargetSelectRequest.CurrentSchemaVersion,
TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId,
};
var first = await client.PostAsJsonAsync("/game/players/dev-local-1/target/select", req);
var second = await client.PostAsJsonAsync("/game/players/dev-local-1/target/select", req);
Assert.Equal(HttpStatusCode.OK, first.StatusCode);
Assert.Equal(HttpStatusCode.OK, second.StatusCode);
var b1 = await first.Content.ReadFromJsonAsync<TargetSelectResponse>();
var b2 = await second.Content.ReadFromJsonAsync<TargetSelectResponse>();
Assert.Equal(b1!.TargetState.Sequence, b2!.TargetState.Sequence);
}
[Fact]
public async Task PostSelect_ShouldDenyOutOfRange_WhenReselectingSameLockWhileOutOfRange()
{
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
await client.PostAsJsonAsync(
"/game/players/dev-local-1/target/select",
new TargetSelectRequest
{
SchemaVersion = TargetSelectRequest.CurrentSchemaVersion,
TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId,
});
var move = new MoveCommandRequest
{
SchemaVersion = MoveCommandRequest.CurrentSchemaVersion,
Target = new PositionVector { X = 50, Y = 0, Z = 50 },
};
await client.PostAsJsonAsync("/game/players/dev-local-1/move", move);
var response = await client.PostAsJsonAsync(
"/game/players/dev-local-1/target/select",
new TargetSelectRequest
{
SchemaVersion = TargetSelectRequest.CurrentSchemaVersion,
TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId,
});
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<TargetSelectResponse>();
Assert.NotNull(body);
Assert.False(body!.SelectionApplied);
Assert.Equal(TargetingApi.ReasonOutOfRange, body.ReasonCode);
Assert.Equal(PrototypeTargetRegistry.PrototypeTargetAlphaId, body.TargetState.LockedTargetId);
Assert.Equal(TargetValidity.OutOfRange, body.TargetState.Validity);
}
}

View File

@ -0,0 +1,20 @@
namespace NeonSprawl.Server.Game.PositionState;
/// <summary>
/// HTTP JSON body for <c>POST /game/players/{{id}}/move-stream</c> (NEO-22): ordered absolute
/// targets applied in one request; each leg uses the same rules as <see cref="MoveCommandRequest"/>.
/// </summary>
public sealed class MoveStreamRequest
{
/// <summary>Maximum number of targets accepted per request (inclusive).</summary>
public const int MaxTargets = 24;
/// <summary>Schema version for this contract; must match <see cref="CurrentSchemaVersion"/>.</summary>
public const int CurrentSchemaVersion = 1;
/// <summary>Contract version; must equal <see cref="CurrentSchemaVersion"/>.</summary>
public int SchemaVersion { get; init; }
/// <summary>World targets applied in order; each must pass validation from the position after the previous apply.</summary>
public IReadOnlyList<PositionVector>? Targets { get; init; }
}

View File

@ -72,6 +72,65 @@ public static class PositionStateApi
return Results.Json(response);
});
app.MapPost(
"/game/players/{id}/move-stream",
(string id, MoveStreamRequest? body, IPositionStateStore store, IOptions<GamePositionOptions> gameOptions) =>
{
if (body is null || body.SchemaVersion != MoveStreamRequest.CurrentSchemaVersion)
{
return Results.BadRequest();
}
var targets = body.Targets;
if (targets is null || targets.Count == 0 || targets.Count > MoveStreamRequest.MaxTargets)
{
return Results.BadRequest();
}
if (!store.TryGetPosition(id, out var current))
{
return Results.NotFound();
}
var rules = gameOptions.Value.MovementValidation;
// Validate the full chain before applying so a mid-chain reject does not leave a partial apply.
var probe = current;
foreach (var target in targets)
{
if (!MoveCommandValidation.TryValidate(probe, target, rules, out var reasonCode))
{
var rejected = new MoveCommandRejectedResponse
{
SchemaVersion = MoveCommandRejectedResponse.CurrentSchemaVersion,
ReasonCode = reasonCode,
};
return Results.Json(rejected, statusCode: StatusCodes.Status400BadRequest);
}
probe = new PositionSnapshot(target.X, target.Y, target.Z, probe.Sequence);
}
PositionSnapshot lastSnap = current;
foreach (var target in targets)
{
if (!store.TryApplyMoveTarget(id, target.X, target.Y, target.Z, out var snap))
{
return Results.NotFound();
}
lastSnap = snap;
}
var response = new PositionStateResponse
{
SchemaVersion = PositionStateResponse.CurrentSchemaVersion,
PlayerId = id,
Position = new PositionVector { X = lastSnap.X, Y = lastSnap.Y, Z = lastSnap.Z },
Sequence = lastSnap.Sequence,
};
return Results.Json(response);
});
return app;
}
}

View File

@ -0,0 +1,14 @@
namespace NeonSprawl.Server.Game.Targeting;
/// <summary>In-memory per-player combat target lock for prototype HTTP (NEO-23).</summary>
public interface IPlayerTargetLockStore
{
/// <summary>Current lock id (lowercase registry key) and sequence; missing player ⇒ <c>(null, 0)</c>.</summary>
(string? LockIdLowercase, int Sequence) GetLockState(string playerId);
/// <summary>Clears lock; bumps sequence only if a lock was present.</summary>
(string? LockIdLowercase, int Sequence) ApplyClear(string playerId);
/// <summary>Sets lock to <paramref name="targetIdLowercase"/>; no sequence bump if already equal (idempotent).</summary>
(string? LockIdLowercase, int Sequence) ApplySet(string playerId, string targetIdLowercase);
}

View File

@ -0,0 +1,73 @@
namespace NeonSprawl.Server.Game.Targeting;
/// <summary>Thread-safe prototype lock store; player keys are trimmed with ordinal case-insensitive lookup (same spirit as position APIs).</summary>
public sealed class InMemoryPlayerTargetLockStore : IPlayerTargetLockStore
{
private readonly object mutex = new();
private readonly Dictionary<string, PlayerLockRow> rows = new(StringComparer.OrdinalIgnoreCase);
private readonly record struct PlayerLockRow(string? LockIdLowercase, int Sequence);
public (string? LockIdLowercase, int Sequence) GetLockState(string playerId)
{
var key = playerId.Trim();
if (key.Length == 0)
{
return (null, 0);
}
lock (mutex)
{
return rows.TryGetValue(key, out var row) ? (row.LockIdLowercase, row.Sequence) : (null, 0);
}
}
public (string? LockIdLowercase, int Sequence) ApplyClear(string playerId)
{
var key = playerId.Trim();
if (key.Length == 0)
{
return (null, 0);
}
lock (mutex)
{
if (!rows.TryGetValue(key, out var prev))
{
return (null, 0);
}
if (prev.LockIdLowercase is null)
{
return (null, prev.Sequence);
}
var next = new PlayerLockRow(null, prev.Sequence + 1);
rows[key] = next;
return (null, next.Sequence);
}
}
public (string? LockIdLowercase, int Sequence) ApplySet(string playerId, string targetIdLowercase)
{
var key = playerId.Trim();
if (key.Length == 0)
{
return (null, 0);
}
lock (mutex)
{
var prev = rows.TryGetValue(key, out var r) ? r : new PlayerLockRow(null, 0);
if (string.Equals(prev.LockIdLowercase, targetIdLowercase, StringComparison.Ordinal))
{
return (prev.LockIdLowercase, prev.Sequence);
}
var next = new PlayerLockRow(targetIdLowercase, prev.Sequence + 1);
rows[key] = next;
return (next.LockIdLowercase, next.Sequence);
}
}
}

View File

@ -0,0 +1,38 @@
using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.World;
namespace NeonSprawl.Server.Game.Targeting;
/// <summary>Computes <see cref="PlayerTargetStateResponse.Validity"/> from authoritative position + persisted lock (NEO-23).</summary>
public static class PlayerTargetStateReader
{
public static string ComputeValidity(string? lockIdLowercase, in PositionSnapshot snap)
{
if (string.IsNullOrEmpty(lockIdLowercase))
{
return TargetValidity.None;
}
if (!PrototypeTargetRegistry.TryGet(lockIdLowercase, out var entry))
{
return TargetValidity.InvalidTarget;
}
if (!HorizontalReach.IsWithinHorizontalRadius(snap.X, snap.Z, entry.X, entry.Z, entry.LockRadius))
{
return TargetValidity.OutOfRange;
}
return TargetValidity.Ok;
}
public static PlayerTargetStateResponse Build(string routePlayerId, string? lockIdLowercase, int sequence, in PositionSnapshot snap) =>
new()
{
SchemaVersion = PlayerTargetStateResponse.CurrentSchemaVersion,
PlayerId = routePlayerId,
LockedTargetId = lockIdLowercase,
Validity = ComputeValidity(lockIdLowercase, in snap),
Sequence = sequence,
};
}

View File

@ -0,0 +1,28 @@
namespace NeonSprawl.Server.Game.Targeting;
/// <summary>Prototype combat target ids → world anchor + horizontal lock radius (NEO-23). Keys are lowercase.</summary>
public static class PrototypeTargetRegistry
{
/// <summary>Near default dev spawn <c>(-5, -5)</c> on XZ.</summary>
public const string PrototypeTargetAlphaId = "prototype_target_alpha";
/// <summary>Farther stub for out-of-range tests; ascending id order for NEO-24 tab cycle.</summary>
public const string PrototypeTargetBetaId = "prototype_target_beta";
/// <summary>Alpha anchor aligned with spawn horizontal cell; <see cref="PrototypeTargetEntry.LockRadius"/> on XZ only.</summary>
public static readonly PrototypeTargetEntry PrototypeTargetAlpha = new(-5, 0.5, -5, 8.0);
public static readonly PrototypeTargetEntry PrototypeTargetBeta = new(8, 0, 8, 4.0);
private static readonly Dictionary<string, PrototypeTargetEntry> ById = new(StringComparer.Ordinal)
{
[PrototypeTargetAlphaId] = PrototypeTargetAlpha,
[PrototypeTargetBetaId] = PrototypeTargetBeta,
};
public static bool TryGet(string targetIdLowercase, out PrototypeTargetEntry entry) =>
ById.TryGetValue(targetIdLowercase, out entry);
}
/// <param name="LockRadius">Horizontal reach on X/Z; inclusive boundary (same as NEO-9).</param>
public readonly record struct PrototypeTargetEntry(double X, double Y, double Z, double LockRadius);

View File

@ -0,0 +1,63 @@
using System.Text.Json.Serialization;
namespace NeonSprawl.Server.Game.Targeting;
/// <summary>JSON body for <c>GET /game/players/{{id}}/target</c> and nested <c>targetState</c> on select (NEO-23).</summary>
public sealed class PlayerTargetStateResponse
{
public const int CurrentSchemaVersion = 1;
[JsonPropertyName("schemaVersion")]
public int SchemaVersion { get; init; } = CurrentSchemaVersion;
[JsonPropertyName("playerId")]
public required string PlayerId { get; init; }
/// <summary>Lowercase registry id when locked; JSON <c>null</c> when no lock (key always serialized).</summary>
[JsonPropertyName("lockedTargetId")]
[JsonIgnore(Condition = JsonIgnoreCondition.Never)]
public string? LockedTargetId { get; init; }
/// <summary>One of <see cref="TargetValidity"/> string constants.</summary>
[JsonPropertyName("validity")]
public required string Validity { get; init; }
[JsonPropertyName("sequence")]
public int Sequence { get; init; }
}
/// <summary>POST body for <c>POST /game/players/{{id}}/target/select</c> (NEO-23).</summary>
/// <remarks>
/// Omit <c>targetId</c> or send JSON <c>null</c> to clear the lock. Whitespace-only after trim → HTTP 400.
/// </remarks>
public sealed class TargetSelectRequest
{
public const int CurrentSchemaVersion = 1;
[JsonPropertyName("schemaVersion")]
public int SchemaVersion { get; init; }
/// <summary>Registry key after trim + case-insensitive lookup; <c>null</c> or omitted ⇒ clear intent.</summary>
[JsonPropertyName("targetId")]
public string? TargetId { get; init; }
}
/// <summary>POST response for target selection (NEO-23).</summary>
public sealed class TargetSelectResponse
{
public const int CurrentSchemaVersion = 1;
[JsonPropertyName("schemaVersion")]
public int SchemaVersion { get; init; } = CurrentSchemaVersion;
[JsonPropertyName("selectionApplied")]
public bool SelectionApplied { get; init; }
[JsonPropertyName("targetState")]
public required PlayerTargetStateResponse TargetState { get; init; }
/// <summary>Required when <see cref="SelectionApplied"/> is <c>false</c>; omitted on success.</summary>
[JsonPropertyName("reasonCode")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? ReasonCode { get; init; }
}

View File

@ -0,0 +1,10 @@
namespace NeonSprawl.Server.Game.Targeting;
/// <summary>Serialized <c>validity</c> strings for <see cref="PlayerTargetStateResponse"/> (NEO-23).</summary>
public static class TargetValidity
{
public const string None = "none";
public const string Ok = "ok";
public const string OutOfRange = "out_of_range";
public const string InvalidTarget = "invalid_target";
}

View File

@ -0,0 +1,105 @@
using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.World;
namespace NeonSprawl.Server.Game.Targeting;
/// <summary>Maps <c>GET/POST …/target</c> selection APIs (NEO-23).</summary>
public static class TargetingApi
{
public const string ReasonUnknownTarget = "unknown_target";
public const string ReasonOutOfRange = "out_of_range";
public static WebApplication MapTargetingApi(this WebApplication app)
{
app.MapGet(
"/game/players/{id}/target",
(string id, IPositionStateStore positions, IPlayerTargetLockStore locks) =>
{
if (!positions.TryGetPosition(id, out var snap))
{
return Results.NotFound();
}
var (lockId, seq) = locks.GetLockState(id);
var body = PlayerTargetStateReader.Build(id, lockId, seq, in snap);
return Results.Json(body);
});
app.MapPost(
"/game/players/{id}/target/select",
(string id, TargetSelectRequest? body, IPositionStateStore positions, IPlayerTargetLockStore locks) =>
{
if (body is null || body.SchemaVersion != TargetSelectRequest.CurrentSchemaVersion)
{
return Results.BadRequest();
}
if (!positions.TryGetPosition(id, out var snap))
{
return Results.NotFound();
}
// Clear intent: property omitted or JSON null.
if (body.TargetId is null)
{
var cleared = locks.ApplyClear(id);
var stateAfter = PlayerTargetStateReader.Build(id, cleared.LockIdLowercase, cleared.Sequence, in snap);
return Results.Json(
new TargetSelectResponse
{
SchemaVersion = TargetSelectResponse.CurrentSchemaVersion,
SelectionApplied = true,
TargetState = stateAfter,
});
}
var raw = body.TargetId;
var trimmed = raw.Trim();
if (trimmed.Length == 0)
{
return Results.BadRequest();
}
var lookupKey = trimmed.ToLowerInvariant();
if (!PrototypeTargetRegistry.TryGet(lookupKey, out var entry))
{
var (lockUnknown, seqUnknown) = locks.GetLockState(id);
var stateUnknown = PlayerTargetStateReader.Build(id, lockUnknown, seqUnknown, in snap);
return Results.Json(
new TargetSelectResponse
{
SchemaVersion = TargetSelectResponse.CurrentSchemaVersion,
SelectionApplied = false,
TargetState = stateUnknown,
ReasonCode = ReasonUnknownTarget,
});
}
if (!HorizontalReach.IsWithinHorizontalRadius(snap.X, snap.Z, entry.X, entry.Z, entry.LockRadius))
{
var (lockFar, seqFar) = locks.GetLockState(id);
var stateFar = PlayerTargetStateReader.Build(id, lockFar, seqFar, in snap);
return Results.Json(
new TargetSelectResponse
{
SchemaVersion = TargetSelectResponse.CurrentSchemaVersion,
SelectionApplied = false,
TargetState = stateFar,
ReasonCode = ReasonOutOfRange,
});
}
var applied = locks.ApplySet(id, lookupKey);
var stateOk = PlayerTargetStateReader.Build(id, applied.LockIdLowercase, applied.Sequence, in snap);
return Results.Json(
new TargetSelectResponse
{
SchemaVersion = TargetSelectResponse.CurrentSchemaVersion,
SelectionApplied = true,
TargetState = stateOk,
});
});
return app;
}
}

View File

@ -1,8 +1,10 @@
using NeonSprawl.Server.Game.Interaction;
using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Targeting;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddPositionStateStore(builder.Configuration);
builder.Services.AddSingleton<IPlayerTargetLockStore, InMemoryPlayerTargetLockStore>();
var app = builder.Build();
@ -18,5 +20,6 @@ app.MapGet("/health", () => Results.Json(new
app.MapPositionStateApi();
app.MapInteractionApi();
app.MapTargetingApi();
app.Run();

View File

@ -63,7 +63,9 @@ Unknown player ids return **404**. Full zone sync / replication is still out of
**`POST /game/players/{id}/move`** with JSON body applies a v1 **MoveCommand**: authoritative position **snaps** to `target` immediately; `sequence` in responses increases by one per successful apply.
**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.
**`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** 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.
@ -74,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):
@ -123,6 +125,55 @@ When **`allowed` is `true`**, **`reasonCode` is omitted** (clients must not requ
Contract details and PR blurb: [NEO-9 implementation plan](../../docs/plans/NEO-9-implementation-plan.md).
## Targeting (NEO-23)
Authoritative **combat target lock** (prototype): **`GET /game/players/{id}/target`** returns **`PlayerTargetStateResponse`** v1; **`POST /game/players/{id}/target/select`** accepts **`TargetSelectRequest`** v1 and returns **`TargetSelectResponse`** v1. Player id rules match position/interact (trim + ordinal case-insensitive lookup in the in-memory store). **Horizontal lock range** uses **`HorizontalReach`** on **X/Z only** (inclusive radius), same floor-plane policy as NEO-9.
**Soft lock:** the server keeps the persisted **`lockedTargetId`** until clear or a successful swap. If the player moves out of stub range, **`validity`** becomes **`out_of_range`** on **`GET`** and on **`POST`** echoes until they move back in range, clear, or select another valid target (see [NEO-23 implementation plan](../../docs/plans/NEO-23-implementation-plan.md)).
Stub registry: `PrototypeTargetRegistry` in `NeonSprawl.Server/Game/Targeting/` — ids **`prototype_target_alpha`**, **`prototype_target_beta`** (ascending id order for future tab cycle).
**`GET /game/players/{id}/target`**
| Field | Meaning |
|--------|--------|
| `schemaVersion` | `1` |
| `playerId` | Echo of route `{id}` |
| `lockedTargetId` | Lowercase stub id when locked; JSON **`null`** when no lock (**key always present**) |
| `validity` | `none` (no lock), `ok`, `out_of_range`, or `invalid_target` (unknown id in store — should not occur in normal use) |
| `sequence` | Increments when the persisted lock **id** changes (clear or swap); unchanged on idempotent re-select of the same id |
**`POST /game/players/{id}/target/select`** — body `schemaVersion` (`1`), optional **`targetId`**. Omit **`targetId`** or send JSON **`null`** to **clear** the lock. Non-null **`targetId`** is trimmed; **whitespace-only** after trim → **400**.
Examples:
```bash
curl -s http://localhost:5253/game/players/dev-local-1/target
```
```bash
curl -s -X POST http://localhost:5253/game/players/dev-local-1/target/select \
-H "Content-Type: application/json" \
-d '{"schemaVersion":1,"targetId":"prototype_target_alpha"}'
```
**HTTP**
| Status | Meaning |
|--------|---------|
| **200** | **GET:** `PlayerTargetStateResponse` v1. **POST:** `TargetSelectResponse` v1 with **`targetState`** always set; **`selectionApplied`** `true` or `false`; when `false`, required **`reasonCode`**. |
| **400** | Invalid v1 body (wrong `schemaVersion`, malformed JSON, or **`targetId`** whitespace-only when provided). |
| **404** | Unknown **player** only (same as interact). |
**`reasonCode` (POST v1, when `selectionApplied` is `false`)**
| Code | When |
|------|------|
| `unknown_target` | Id not in the prototype target registry (after trim + case-insensitive lookup). |
| `out_of_range` | Horizontal distance on X/Z is **greater than** the targets `lockRadius` (on the radius is **allowed** — inclusive `<=`). |
When **`selectionApplied` is `true`**, **`reasonCode` is omitted** (clients must not require it on success).
## Solution
From repo root: `dotnet build NeonSprawl.sln` and `dotnet test NeonSprawl.sln`.