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.
pull/42/head
VinPropane 2026-04-17 19:21:33 -04:00
parent 7ffeb320c4
commit a2c6c9f93e
14 changed files with 422 additions and 19 deletions

View File

@ -51,9 +51,11 @@ Elevated walkables use **distinct albedo tints** in `scenes/main.tscn` so screen
- 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. - 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 and NEON-30 click-through both apply 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 a batch of **absolute** world positions (NEO-22 **option C**). 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`**; the client **snaps** to that position. **`POST /game/players/{id}/move`** remains for **debug** click-to-move when running a **debug** Godot build (`ground_pick.gd` only listens in **`OS.is_debug_build()`**): it still does **POST** + **`GET`** verify and sets a **nav walk goal** on the player.
**Boot** `sync_from_server()` **snaps** once so spawn matches the server.
**Tradeoff (prototype):** Horizontal motion is a **direct XZ bee-line** toward the servers verified target. The baked **`NavigationMesh`** is still used when **vertical routing** is latched **and** the capsule is inside the **nav column** hysteresis band (`NAV_COLUMN_STEER_ENTER_DIST` / `EXIT`) **and** **`is_on_floor()`** — terraces / steps then steer along **`get_current_navigation_path()`** using the first waypoint at least **`NAV_PATH_STEER_MIN_LOOKAHEAD`** (~22 cm) away in XZ (unlike a **5 cm** scan or raw **`get_next_path_position()`**, which both sat inside Jolts per-tick slide and could flip velocity **180°** every tick). While **airborne**, column path steering is **off** so horizontal motion stays a bee-line toward the click (avoids fall-time XZ oscillation). **No descend bypass** for gravity while on an upper deck (same as before). While walking, **gravity** still runs when **`is_on_floor()`** is false; if **snap / lip contacts** keep **`is_on_floor()`** true over open space (e.g. gold deck → gray floor), **`player.gd`** casts **several short down rays** under the **foot disk** (center + offsets along move direction so the **leading** edge off a deck is tested) and applies **gravity** when **none** hit upward-facing ground within **`WALK_SUPPORT_PROBE_DEPTH`**. In that case **`floor_snap_length`** is set to **0** for the tick so **moving snap** does not cancel **`velocity.y`** on every physics step. **Automatic routing around tall obstacles on one click is still not guaranteed** — use **several clicks** to go around e.g. the gray **`Obstacle`** when needed. **Tradeoff (prototype):** Horizontal motion is a **direct XZ bee-line** toward the servers verified target. The baked **`NavigationMesh`** is still used when **vertical routing** is latched **and** the capsule is inside the **nav column** hysteresis band (`NAV_COLUMN_STEER_ENTER_DIST` / `EXIT`) **and** **`is_on_floor()`** — terraces / steps then steer along **`get_current_navigation_path()`** using the first waypoint at least **`NAV_PATH_STEER_MIN_LOOKAHEAD`** (~22 cm) away in XZ (unlike a **5 cm** scan or raw **`get_next_path_position()`**, which both sat inside Jolts per-tick slide and could flip velocity **180°** every tick). While **airborne**, column path steering is **off** so horizontal motion stays a bee-line toward the 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.
@ -65,14 +67,15 @@ With the game server running ([`server/README.md`](../server/README.md)), each v
- **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. - **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`). - **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`). 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`). 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). 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. 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. 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. 5. **Debug build only — click-to-move:** **Left-click** the floor: the client **POST**s **`/move`**, then **GET**s position; the capsule **walks** toward that verified target with **nav** assist where the old prototype applies. Server-rejected clicks show the reject label.
6. 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). 6. Walk to the **orange reject pedestal** and try to climb it with WASD / jumps as applicable; **`move-stream`** should return **`vertical_step_exceeded`** when a requested step exceeds **`MaxVerticalStep`**. In a **debug** build, click the **pedestal top** to confirm the same **`vertical_step_exceeded`** path via **`/move`**. From the **physics ramp test block** top (**2 m**), clicking the floor should **succeed**. The `MoveRejectFarPad` (blue pad at (9, 9)) remains a normal walkable surface.
7. After movement, **stand idle** for **10+ seconds** and confirm the capsule does **not** visibly vibrate or drift in **x/z** on flat floor, then repeat on the **random green bumps** (positions change each run).
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); clicks while a request is in flight are ignored until it finishes.

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) "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] [physics]

View File

@ -30,7 +30,9 @@ var fallback_camera: Camera3D
func _ready() -> void: func _ready() -> void:
set_process_input(true) # NEO-22: click-to-move is dev-only; WASD is default locomotion.
if OS.is_debug_build():
set_process_input(true)
func _input(event: InputEvent) -> void: func _input(event: InputEvent) -> void:

View File

@ -2,6 +2,7 @@ extends Node3D
## NS-16: composes ground pick + server authority; see `ground_pick.gd` / ## 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). ## `position_authority_client.gd`. NS-18: interaction POST + radius glow preview (see child nodes).
## NEO-22: WASD locomotion + `move-stream` samples (see `player.gd` / `position_authority_client.gd`).
## NS-23: bakes `NavigationRegion3D` on startup; boot snap vs nav goal from authority signal. ## NS-23: bakes `NavigationRegion3D` on startup; boot snap vs nav goal from authority signal.
## NEO-15: follow camera is `World/IsometricFollowCamera/Camera3D` ## NEO-15: follow camera is `World/IsometricFollowCamera/Camera3D`
## (see `isometric_follow_camera.gd`). ## (see `isometric_follow_camera.gd`).
@ -19,8 +20,12 @@ extends Node3D
const MOVE_REJECT_MSG_SECONDS: float = 4.0 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
## Bump on each rejection so older one-shot timers do not clear a newer message. ## Bump on each rejection so older one-shot timers do not clear a newer message.
var _move_reject_msg_token: int = 0 var _move_reject_msg_token: int = 0
var _stream_physics_counter: int = 0
## [InputMap] [code]dev_toggle_occluder_obstacle[/code] (default **Ctrl+Shift+K**): ## [InputMap] [code]dev_toggle_occluder_obstacle[/code] (default **Ctrl+Shift+K**):
## Restored when the obstacle is shown again. ## Restored when the obstacle is shown again.
@ -68,10 +73,38 @@ func _ready() -> void:
func _physics_process(_delta: float) -> void: func _physics_process(_delta: float) -> void:
if not is_instance_valid(_player) or not is_instance_valid(_player_pos_label): if not is_instance_valid(_player) or not is_instance_valid(_player_pos_label):
return return
var wish: Vector3 = _locomotion_wish_world_xz_from_camera()
_player.call("set_locomotion_wish_world_xz", wish)
_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)
var p: Vector3 = _player.global_position var p: Vector3 = _player.global_position
_player_pos_label.text = "x: %.3f\ny: %.3f\nz: %.3f" % [p.x, p.y, p.z] _player_pos_label.text = "x: %.3f\ny: %.3f\nz: %.3f" % [p.x, p.y, p.z]
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_target_chosen(world: Vector3) -> void: func _on_target_chosen(world: Vector3) -> void:
_authority.call("submit_move_target", world) _authority.call("submit_move_target", world)

View File

@ -1,6 +1,7 @@
extends CharacterBody3D 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. ## Summary: Jolt, no interp; dual floor_max_angle; rim/straddle, bump lip escape.
## Snap upright after motion. Do not set global_transform in _process. ## Snap upright after motion. Do not set global_transform in _process.
@ -273,6 +274,9 @@ var _walk_col_seam_prev_hz: Vector2 = Vector2.ZERO
var _walk_col_seam_suppress_ticks: int = 0 var _walk_col_seam_suppress_ticks: int = 0
@onready var _nav_agent: NavigationAgent3D = $NavigationAgent3D @onready var _nav_agent: NavigationAgent3D = $NavigationAgent3D
## Normalized world XZ direction from WASD (Y ignored). Zero when idle.
var _locomotion_wish_world_xz: Vector3 = Vector3.ZERO
func _ready() -> void: func _ready() -> void:
physics_interpolation_mode = Node.PHYSICS_INTERPOLATION_MODE_OFF physics_interpolation_mode = Node.PHYSICS_INTERPOLATION_MODE_OFF
@ -344,6 +348,14 @@ func sync_navigation_agent_after_map_rebuild(nav_region: NavigationRegion3D) ->
_nav_agent.set_target_position(global_position) _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
else:
_locomotion_wish_world_xz = h.normalized()
func snap_to_server(world_pos: Vector3) -> void: 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. # 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 # Clamping to the minimum standing height (total half = 0.9 m) prevents the player from
@ -373,6 +385,27 @@ func snap_to_server(world_pos: Vector3) -> void:
_walk_col_seam_suppress_ticks = 0 _walk_col_seam_suppress_ticks = 0
_nav_agent.set_target_position(settled) _nav_agent.set_target_position(settled)
reset_physics_interpolation() reset_physics_interpolation()
_locomotion_wish_world_xz = Vector3.ZERO
func _physics_process_locomotion_wasd(delta: float) -> void:
if _has_walk_goal:
clear_nav_goal()
floor_block_on_wall = true
var feet_y: float = capsule_feet_y(
global_position.y, CAPSULE_HALF_HEIGHT + PLAYER_CAPSULE_RADIUS
)
velocity.x = _locomotion_wish_world_xz.x * MOVE_SPEED
velocity.z = _locomotion_wish_world_xz.z * MOVE_SPEED
if is_on_floor():
velocity.y = 0.0
else:
_apply_walk_air_gravity(delta, feet_y, Vector2.ZERO)
_walk_clip_horizontal_velocity_against_vertical_contacts(_locomotion_wish_world_xz)
floor_snap_length = FLOOR_SNAP_MOVING
move_and_slide()
_snap_capsule_upright()
_debug_trace_transform("physics")
## Returns true when the capsule has something to stand on **or** is pressing against a step face ## Returns true when the capsule has something to stand on **or** is pressing against a step face
@ -1359,6 +1392,12 @@ func _physics_process(delta: float) -> void:
floor_max_angle = deg_to_rad( floor_max_angle = deg_to_rad(
FLOOR_MAX_ANGLE_MOVING_DEG if use_loose_floor_angle else FLOOR_MAX_ANGLE_IDLE_DEG 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)
_physics_process_locomotion_wasd(delta)
return
if not _has_walk_goal: if not _has_walk_goal:
_physics_process_no_walk_goal(delta) _physics_process_no_walk_goal(delta)
return return

View File

@ -3,12 +3,15 @@ extends Node
## NS-16: POST MoveCommand then GET PositionState; emits authoritative world position for the ## 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`. ## 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). ## NS-23: second signal arg — `true` = boot snap, `false` = post-move (nav goal, no teleport).
## NEO-22: `submit_stream_targets` POSTs `move-stream` (batched small moves); no VERIFY GET.
## (No `class_name` so headless/CI can load the project before `.godot` import exists.) ## (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 authoritative_position_received(world: Vector3, apply_as_snap: bool)
signal move_rejected(reason_code: String) signal move_rejected(reason_code: String)
enum Phase { BOOT_GET, POST_MOVE, VERIFY_GET } const MOVE_STREAM_MAX_PER_REQUEST: int = 12
enum Phase { BOOT_GET, POST_MOVE, VERIFY_GET, STREAM_POST }
@export var base_url: String = "http://127.0.0.1:5253" @export var base_url: String = "http://127.0.0.1:5253"
@export var dev_player_id: String = "dev-local-1" @export var dev_player_id: String = "dev-local-1"
@ -21,6 +24,10 @@ var _phase: Phase = Phase.BOOT_GET
## Latest click target while [_busy] (POST or VERIFY in flight); coalesced so clicks are not lost. ## Latest click target while [_busy] (POST or VERIFY in flight); coalesced so clicks are not lost.
var _pending_move_target: Variant = null 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: func _create_http_request() -> Node:
return HTTPRequest.new() return HTTPRequest.new()
@ -51,9 +58,26 @@ func submit_move_target(world: Vector3) -> void:
_start_move_post(world) _start_move_post(world)
func _try_flush_pending_move() -> void: ## NEO-22: append samples; sends up to [constant MOVE_STREAM_MAX_PER_REQUEST] per POST when idle.
func submit_stream_targets(targets: Array) -> void:
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() -> void:
if _busy: if _busy:
return return
if not _stream_queue.is_empty():
var n: int = mini(_stream_queue.size(), MOVE_STREAM_MAX_PER_REQUEST)
var batch: Array = []
for i in range(n):
batch.append(_stream_queue[i])
_start_stream_post(batch)
return
if _pending_move_target is Vector3: if _pending_move_target is Vector3:
var w: Vector3 = _pending_move_target as Vector3 var w: Vector3 = _pending_move_target as Vector3
_pending_move_target = null _pending_move_target = null
@ -74,7 +98,30 @@ func _start_move_post(world: Vector3) -> void:
if err != OK: if err != OK:
push_warning("PositionAuthorityClient: POST failed to start (%s)" % err) push_warning("PositionAuthorityClient: POST failed to start (%s)" % err)
_busy = false _busy = false
_try_flush_pending_move() _try_flush_pending()
func _start_stream_post(batch: Array) -> void:
if batch.is_empty():
return
_busy = true
_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: move-stream POST failed to start (%s)" % err)
_busy = false
_stream_in_flight_count = 0
_try_flush_pending()
func _base_root() -> String: func _base_root() -> String:
@ -92,7 +139,7 @@ func _request_get() -> void:
if err != OK: if err != OK:
push_warning("PositionAuthorityClient: GET failed to start (%s)" % err) push_warning("PositionAuthorityClient: GET failed to start (%s)" % err)
_busy = false _busy = false
_try_flush_pending_move() _try_flush_pending()
func _on_request_completed( func _on_request_completed(
@ -101,7 +148,8 @@ func _on_request_completed(
if _result != HTTPRequest.RESULT_SUCCESS: if _result != HTTPRequest.RESULT_SUCCESS:
push_warning("PositionAuthorityClient: HTTP failed (result=%s); releasing busy." % _result) push_warning("PositionAuthorityClient: HTTP failed (result=%s); releasing busy." % _result)
_busy = false _busy = false
_try_flush_pending_move() _stream_in_flight_count = 0
_try_flush_pending()
return return
var text := body.get_string_from_utf8() var text := body.get_string_from_utf8()
@ -111,19 +159,19 @@ func _on_request_completed(
_busy = false _busy = false
if response_code == 200: if response_code == 200:
_emit_position_from_response(text, true) _emit_position_from_response(text, true)
_try_flush_pending_move() _try_flush_pending()
Phase.POST_MOVE: Phase.POST_MOVE:
if response_code == 400: if response_code == 400:
_emit_move_rejection_if_present(text) _emit_move_rejection_if_present(text)
_busy = false _busy = false
_try_flush_pending_move() _try_flush_pending()
return return
if response_code != 200: if response_code != 200:
push_warning( push_warning(
"PositionAuthorityClient: move POST unexpected code %s" % response_code "PositionAuthorityClient: move POST unexpected code %s" % response_code
) )
_busy = false _busy = false
_try_flush_pending_move() _try_flush_pending()
return return
_phase = Phase.VERIFY_GET _phase = Phase.VERIFY_GET
_request_get() _request_get()
@ -135,7 +183,32 @@ func _on_request_completed(
push_warning( push_warning(
"PositionAuthorityClient: VERIFY GET unexpected code %s" % response_code "PositionAuthorityClient: VERIFY GET unexpected code %s" % response_code
) )
_try_flush_pending_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
sync_from_server()
return
if response_code != 200:
push_warning(
"PositionAuthorityClient: move-stream unexpected code %s" % response_code
)
_stream_in_flight_count = 0
_busy = false
_try_flush_pending()
return
# Drop the batch we sent (validated + applied as a unit on the server).
var drop: int = mini(_stream_in_flight_count, _stream_queue.size())
if drop > 0:
_stream_queue = _stream_queue.slice(drop)
_stream_in_flight_count = 0
_busy = false
if response_code == 200:
_emit_position_from_response(text, true)
_try_flush_pending()
func _emit_move_rejection_if_present(json_text: String) -> void: func _emit_move_rejection_if_present(json_text: String) -> void:

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) 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: func test_clear_nav_goal_clears_velocity_and_nav_target() -> void:
var p := _make_player() var p := _make_player()
p.velocity = Vector3(1.0, 0.0, 0.0) p.velocity = Vector3(1.0, 0.0, 0.0)

View File

@ -111,3 +111,21 @@ func test_second_sync_while_busy_is_ignored() -> void:
c.sync_from_server() c.sync_from_server()
c.sync_from_server() c.sync_from_server()
assert_that(mock.request_count).is_equal(1) assert_that(mock.request_count).is_equal(1)
func test_move_stream_post_200_emits_snap_from_body() -> void:
var mock := MockHttpTransport.new()
mock.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(mock)
monitor_signals(c)
c.submit_stream_targets([Vector3(-4.9, 0.9, -5.0)])
await assert_signal(c).is_emitted(
"authoritative_position_received", Vector3(-4.9, 0.9, -5.0), true
)

View File

@ -33,7 +33,7 @@ Contract readiness is tracked in the [module dependency register](module_depende
## Implementation snapshot ## 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). - **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). - **Alignment:** [Documentation and implementation alignment](documentation_and_implementation_alignment.md).

View File

@ -54,7 +54,7 @@
|------|--------|-----------| |------|--------|-----------|
| **Issue id** | **NEO-22** | Created in Linear 2026-04-16; plan filename matches this key. | | **Issue id** | **NEO-22** | Created in Linear 2026-04-16; plan filename matches this key. |
| **Kickoff** | **2026-04-17** | Branch `NEO-22-retire-click-to-move-wasd-locomotion`; status In Progress in Linear. | | **Kickoff** | **2026-04-17** | Branch `NEO-22-retire-click-to-move-wasd-locomotion`; status In Progress in Linear. |
| **Camera vs world steering** | **TBD in kickoff** — default recommendation: **camera-relative XZ** | Matches isometric follow rig; document if world-axis is chosen instead. | | **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. | | **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.” | | **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.” |

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,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); 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; return app;
} }
} }

View File

@ -63,6 +63,8 @@ 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. **`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.
**`POST /game/players/{id}/move-stream` (NEO-22):** JSON body **`MoveStreamRequest`** (`schemaVersion` **1**, **`targets`**: ordered array of world positions, max **24** per request). The server **validates the entire chain** against **`Game:MovementValidation`** (same rules as a single move) **before** applying anything, then applies each target in order; **`sequence`** increases by **one per applied target**. Response body matches **`PositionStateResponse`**. On the first failing leg the handler returns **400** with **`MoveCommandRejectedResponse`** and **does not** change stored position.
**Client navigation (NEO-11):** The Godot prototype uses a **baked navigation mesh** only for **presentation**; the client may follow waypoints when active, but **automatic obstacle detours on a single click are not guaranteed** (tradeoff for smooth movement on stepped geometry). The server does **not** simulate navmesh; it validates **straight-line** step limits (NEO-10) from the **last authoritative position** to the **requested target**. Cheating or divergent paths are out of scope for this slice. **Client navigation (NEO-11):** The Godot prototype uses a **baked navigation mesh** 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.
**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. **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.