From a2c6c9f93e2605aa9a2100f020012d1d835e6cf9 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Fri, 17 Apr 2026 19:21:33 -0400 Subject: [PATCH] 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. --- client/README.md | 15 ++- client/project.godot | 20 +++ client/scripts/ground_pick.gd | 4 +- client/scripts/main.gd | 33 +++++ client/scripts/player.gd | 41 +++++- client/scripts/position_authority_client.gd | 91 +++++++++++-- client/test/player_test.gd | 14 ++ client/test/position_authority_client_test.gd | 18 +++ .../modules/E1_M1_InputAndMovementRuntime.md | 2 +- docs/plans/NEO-22-implementation-plan.md | 2 +- .../Game/PositionState/MoveStreamApiTests.cs | 120 ++++++++++++++++++ .../Game/PositionState/MoveStreamRequest.cs | 20 +++ .../Game/PositionState/PositionStateApi.cs | 59 +++++++++ server/README.md | 2 + 14 files changed, 422 insertions(+), 19 deletions(-) create mode 100644 server/NeonSprawl.Server.Tests/Game/PositionState/MoveStreamApiTests.cs create mode 100644 server/NeonSprawl.Server/Game/PositionState/MoveStreamRequest.cs diff --git a/client/README.md b/client/README.md index abfd7a0..3c0a3b8 100644 --- a/client/README.md +++ b/client/README.md @@ -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. - 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 server’s verified target. The baked **`NavigationMesh`** is still used when **vertical routing** is latched **and** the capsule is inside the **nav column** hysteresis band (`NAV_COLUMN_STEER_ENTER_DIST` / `EXIT`) **and** **`is_on_floor()`** — terraces / steps then steer along **`get_current_navigation_path()`** using the first waypoint at least **`NAV_PATH_STEER_MIN_LOOKAHEAD`** (~22 cm) away in XZ (unlike a **5 cm** scan or raw **`get_next_path_position()`**, which both sat inside Jolt’s per-tick slide and could flip velocity **180°** every tick). While **airborne**, column path steering is **off** so horizontal motion stays a bee-line toward the click (avoids fall-time XZ oscillation). **No descend bypass** for gravity while on an upper deck (same as before). While walking, **gravity** still runs when **`is_on_floor()`** is false; if **snap / lip contacts** keep **`is_on_floor()`** true over open space (e.g. gold deck → gray floor), **`player.gd`** casts **several short down rays** under the **foot disk** (center + offsets along move direction so the **leading** edge off a deck is tested) and applies **gravity** when **none** hit upward-facing ground within **`WALK_SUPPORT_PROBE_DEPTH`**. In that case **`floor_snap_length`** is set to **0** for the tick so **moving snap** does not cancel **`velocity.y`** on every physics step. **Automatic routing around tall obstacles on one click is still not guaranteed** — use **several clicks** to go around e.g. the gray **`Obstacle`** when needed. @@ -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. - **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 server’s 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. **Debug build only — click-to-move:** **Left-click** the floor: the client **POST**s **`/move`**, then **GET**s position; the capsule **walks** toward that verified target with **nav** assist where the old prototype applies. Server-rejected clicks show the reject label. +6. Walk to the **orange reject pedestal** and try to climb it with WASD / jumps as applicable; **`move-stream`** should return **`vertical_step_exceeded`** when a requested step exceeds **`MaxVerticalStep`**. In a **debug** build, click the **pedestal top** to confirm the same **`vertical_step_exceeded`** path via **`/move`**. From the **physics ramp test block** top (**2 m**), clicking the floor should **succeed**. The `MoveRejectFarPad` (blue pad at (9, 9)) remains a normal walkable surface. +7. After movement, **stand idle** for **10+ seconds** and confirm the capsule does **not** visibly vibrate or drift in **x/z** on flat floor, then repeat on the **random green bumps** (positions change each run). 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. diff --git a/client/project.godot b/client/project.godot index 5ab0b4b..05e57ce 100644 --- a/client/project.godot +++ b/client/project.godot @@ -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] diff --git a/client/scripts/ground_pick.gd b/client/scripts/ground_pick.gd index d52b798..4ae757b 100644 --- a/client/scripts/ground_pick.gd +++ b/client/scripts/ground_pick.gd @@ -30,7 +30,9 @@ var fallback_camera: Camera3D 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: diff --git a/client/scripts/main.gd b/client/scripts/main.gd index ce4946e..ca0cff8 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -2,6 +2,7 @@ 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). +## 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. ## NEO-15: follow camera is `World/IsometricFollowCamera/Camera3D` ## (see `isometric_follow_camera.gd`). @@ -19,8 +20,12 @@ 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 + ## 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 ## [InputMap] [code]dev_toggle_occluder_obstacle[/code] (default **Ctrl+Shift+K**): ## Restored when the obstacle is shown again. @@ -68,10 +73,38 @@ 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) + _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 _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: _authority.call("submit_move_target", world) diff --git a/client/scripts/player.gd b/client/scripts/player.gd index f112ff9..e120ea0 100644 --- a/client/scripts/player.gd +++ b/client/scripts/player.gd @@ -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. @@ -273,6 +274,9 @@ var _walk_col_seam_prev_hz: Vector2 = Vector2.ZERO var _walk_col_seam_suppress_ticks: int = 0 @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: 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) +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: # 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 @@ -373,6 +385,27 @@ func snap_to_server(world_pos: Vector3) -> void: _walk_col_seam_suppress_ticks = 0 _nav_agent.set_target_position(settled) 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 @@ -1359,6 +1392,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) + _physics_process_locomotion_wasd(delta) + return if not _has_walk_goal: _physics_process_no_walk_goal(delta) return diff --git a/client/scripts/position_authority_client.gd b/client/scripts/position_authority_client.gd index 3166a57..06ebc49 100644 --- a/client/scripts/position_authority_client.gd +++ b/client/scripts/position_authority_client.gd @@ -3,12 +3,15 @@ extends Node ## NS-16: POST MoveCommand then GET PositionState; emits authoritative world position for the ## avatar. NS-19: failed validation (HTTP 400 + rejection body) emits `move_rejected`. ## NS-23: second signal arg — `true` = boot snap, `false` = post-move (nav goal, no teleport). +## NEO-22: `submit_stream_targets` POSTs `move-stream` (batched small moves); no VERIFY GET. ## (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 } +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 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. 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: return HTTPRequest.new() @@ -51,9 +58,26 @@ func submit_move_target(world: Vector3) -> void: _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: 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: var w: Vector3 = _pending_move_target as Vector3 _pending_move_target = null @@ -74,7 +98,30 @@ func _start_move_post(world: Vector3) -> void: if err != OK: push_warning("PositionAuthorityClient: POST failed to start (%s)" % err) _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: @@ -92,7 +139,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 +148,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,19 +159,19 @@ func _on_request_completed( _busy = false if response_code == 200: _emit_position_from_response(text, true) - _try_flush_pending_move() + _try_flush_pending() Phase.POST_MOVE: if response_code == 400: _emit_move_rejection_if_present(text) _busy = false - _try_flush_pending_move() + _try_flush_pending() return if response_code != 200: push_warning( "PositionAuthorityClient: move POST unexpected code %s" % response_code ) _busy = false - _try_flush_pending_move() + _try_flush_pending() return _phase = Phase.VERIFY_GET _request_get() @@ -135,7 +183,32 @@ func _on_request_completed( push_warning( "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: diff --git a/client/test/player_test.gd b/client/test/player_test.gd index 9fe91ad..f87d5c5 100644 --- a/client/test/player_test.gd +++ b/client/test/player_test.gd @@ -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) diff --git a/client/test/position_authority_client_test.gd b/client/test/position_authority_client_test.gd index bc59ada..5591b90 100644 --- a/client/test/position_authority_client_test.gd +++ b/client/test/position_authority_client_test.gd @@ -111,3 +111,21 @@ func test_second_sync_while_busy_is_ignored() -> void: c.sync_from_server() c.sync_from_server() 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 + ) diff --git a/docs/decomposition/modules/E1_M1_InputAndMovementRuntime.md b/docs/decomposition/modules/E1_M1_InputAndMovementRuntime.md index d6bef71..c749fdf 100644 --- a/docs/decomposition/modules/E1_M1_InputAndMovementRuntime.md +++ b/docs/decomposition/modules/E1_M1_InputAndMovementRuntime.md @@ -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). diff --git a/docs/plans/NEO-22-implementation-plan.md b/docs/plans/NEO-22-implementation-plan.md index 3887d7f..3879093 100644 --- a/docs/plans/NEO-22-implementation-plan.md +++ b/docs/plans/NEO-22-implementation-plan.md @@ -54,7 +54,7 @@ |------|--------|-----------| | **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. | -| **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. | | **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.” | diff --git a/server/NeonSprawl.Server.Tests/Game/PositionState/MoveStreamApiTests.cs b/server/NeonSprawl.Server.Tests/Game/PositionState/MoveStreamApiTests.cs new file mode 100644 index 0000000..186c88a --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/PositionState/MoveStreamApiTests.cs @@ -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(o => + { + o.MovementValidation.HorizontalStepEnabled = true; + o.MovementValidation.MaxHorizontalStep = 1.0; + }); + }); + }); + var client = factory.CreateClient(); + var before = await client.GetFromJsonAsync("/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(); + + 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(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(); + Assert.NotNull(rej); + Assert.Equal(MoveCommandReasonCodes.HorizontalStepExceeded, rej!.ReasonCode); + + var after = await client.GetFromJsonAsync("/game/players/dev-local-1/position"); + Assert.NotNull(after); + Assert.Equal(-5.0, after!.Position.X); + } +} diff --git a/server/NeonSprawl.Server/Game/PositionState/MoveStreamRequest.cs b/server/NeonSprawl.Server/Game/PositionState/MoveStreamRequest.cs new file mode 100644 index 0000000..a754a14 --- /dev/null +++ b/server/NeonSprawl.Server/Game/PositionState/MoveStreamRequest.cs @@ -0,0 +1,20 @@ +namespace NeonSprawl.Server.Game.PositionState; + +/// +/// HTTP JSON body for POST /game/players/{{id}}/move-stream (NEO-22): ordered absolute +/// targets applied in one request; each leg uses the same rules as . +/// +public sealed class MoveStreamRequest +{ + /// Maximum number of targets accepted per request (inclusive). + public const int MaxTargets = 24; + + /// Schema version for this contract; must match . + public const int CurrentSchemaVersion = 1; + + /// Contract version; must equal . + public int SchemaVersion { get; init; } + + /// World targets applied in order; each must pass validation from the position after the previous apply. + public IReadOnlyList? Targets { get; init; } +} diff --git a/server/NeonSprawl.Server/Game/PositionState/PositionStateApi.cs b/server/NeonSprawl.Server/Game/PositionState/PositionStateApi.cs index ce3ca25..e27a3a9 100644 --- a/server/NeonSprawl.Server/Game/PositionState/PositionStateApi.cs +++ b/server/NeonSprawl.Server/Game/PositionState/PositionStateApi.cs @@ -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 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; } } diff --git a/server/README.md b/server/README.md index 63f378d..0e406e5 100644 --- a/server/README.md +++ b/server/README.md @@ -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-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. **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.