diff --git a/client/README.md b/client/README.md index e8f576f..6b1f6a6 100644 --- a/client/README.md +++ b/client/README.md @@ -9,20 +9,21 @@ Open this **`client/`** directory as a project in **Godot 4.6** (4.x compatible) Do not grow an all-in-one **`main.gd`**. The main scene root should **compose** child nodes and scripts; put picking, server sync, and similar features in **dedicated scripts** (typically under `scripts/`) and connect via **signals** or small APIs. Use **Autoloads** only when several scenes truly need the same global. Full guidance for contributors and tooling: [`.cursor/rules/godot-client-script-organization.md`](../.cursor/rules/godot-client-script-organization.md). -## Authoritative movement (NS-16) +## Authoritative movement (NS-16, NS-23) -With the game server running ([`server/README.md`](../server/README.md)), the client sends a **`MoveCommand`** over HTTP and **snaps** the avatar to the server’s **`PositionState`** after a follow-up **`GET`**. +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 (NS-16/19); the client **walks** along a **baked navigation mesh** (`NavigationRegion3D` + **`NavigationAgent3D`** on the player) toward that verified position instead of teleporting on the **`GET`** (NS-23). **Boot** `sync_from_server()` **snaps** once so spawn matches the server. -- **Scripts:** `scripts/ground_pick.gd` (walkable pick + `target_chosen`), `scripts/position_authority_client.gd` (`PositionAuthorityClient`: POST move, GET verify), thin `scripts/main.gd` wiring. +- **Scripts:** `scripts/ground_pick.gd` (walkable pick + `target_chosen`), `scripts/position_authority_client.gd` (`PositionAuthorityClient`: POST move, GET verify; second signal arg = boot snap vs nav goal), `scripts/player.gd` (path-follow), thin `scripts/main.gd` (nav bake on first frame, then wiring). +- **Scene:** `scenes/main.tscn` — walkable **`StaticBody3D`** geometry lives under **`World/NavigationRegion3D`** so **`bake_navigation_mesh()`** (called from `main.gd` after one **`process_frame`**) picks up floor, NS-19 props, and the **`Obstacle`** cube. After moving floor or obstacles, open the scene in the editor and **Bake NavigationMesh** on the region if you need to refresh cached mesh data. - **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`). -- On startup, **`sync_from_server()`** runs once so the capsule matches the server before you click. -### Manual check (NS-16) +### Manual check (NS-16 + NS-23) 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 jump to the server’s default position (e.g. **(-5, 0.9, -5)** per `Game:DefaultPosition` / NS-18 walk demo). -4. **Left-click** the floor: the client POSTs the target, then GETs position; the avatar **snaps** to the authoritative coordinates (no client-side walk to the click). +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` / NS-18 walk demo). +4. **Left-click** the floor: the client **POST**s the target, then **GET**s position; the capsule **walks** around **`Obstacle`** toward the authoritative target. NS-19 reject clicks still show the reject label and do **not** start a path. +5. Click the **far pad** or **pedestal top** (from spawn) to confirm **`horizontal_step_exceeded`** / **`vertical_step_exceeded`** behavior is unchanged. 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. @@ -39,16 +40,16 @@ The main scene includes a **prototype terminal** at the map center (same world * 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). 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. -## Movement prototype (NS-14, legacy local steering) +## Movement prototype (NS-14 → NS-23) -The **`CharacterBody3D`** still uses horizontal steering + **`move_and_slide()`** in **`player.gd`**, but **NS-16** drives placement via **`snap_to_server()`** after each successful sync, so you will mostly see **snapping**, not walking to the click. Obstacle sliding from NS-14 applies only if local goals diverge from server snaps in future work. +**`player.gd`** uses **`NavigationAgent3D.get_next_path_position()`** + **`move_and_slide()`** for horizontal motion (NS-23). **`snap_to_server()`** remains for **boot** (and would apply for any future hard reconcile). - The avatar is on **physics layer 2** with **mask 1** (floor + obstacle on layer 1); the pick ray uses **mask 1** so clicks pass through the avatar and hit the floor. -### Manual check (NS-14 remnants) +### Manual check (remnants) -1. Run the main scene (**F5**) **with server running** for NS-16 behavior above. -2. Without the server, startup sync does not apply authoritative position; behavior is undefined for this repo’s intended demo—**run the server** for NS-16. +1. Run the main scene (**F5**) **with server running** for movement behavior above. +2. Without the server, startup sync does not apply authoritative position; behavior is undefined for this repo’s intended demo—**run the server** for the demo path. ### Clicks still ignored? diff --git a/client/scenes/main.tscn b/client/scenes/main.tscn index 6d31397..b62c246 100644 --- a/client/scenes/main.tscn +++ b/client/scenes/main.tscn @@ -72,6 +72,17 @@ albedo_color = Color(0.92, 0.42, 0.12, 1) [sub_resource type="StandardMaterial3D" id="Mat_ns19_farpad"] albedo_color = Color(0.32, 0.38, 0.55, 1) +[sub_resource type="NavigationMesh" id="NavigationMesh_district"] +geometry_parsed_geometry_type = 2 +geometry_collision_mask = 4294967295 +geometry_source_geometry_mode = 0 +cell_size = 0.15 +cell_height = 0.15 +agent_radius = 0.4 +agent_height = 1.0 +agent_max_climb = 0.35 +border_size = 0.15 + [node name="Main" type="Node3D" unique_id=1358372723] script = ExtResource("1_main") @@ -86,21 +97,24 @@ transform = Transform3D(0.819152, -0.40558, 0.40558, 0, 0.707107, 0.707107, -0.5 current = true fov = 50.0 -[node name="Floor" type="StaticBody3D" parent="World" unique_id=1800743112 groups=["walkable"]] +[node name="NavigationRegion3D" type="NavigationRegion3D" parent="World" unique_id=8880001] +navigation_mesh = SubResource("NavigationMesh_district") + +[node name="Floor" type="StaticBody3D" parent="World/NavigationRegion3D" unique_id=1800743112 groups=["walkable"]] collision_layer = 1 -[node name="MeshInstance3D" type="MeshInstance3D" parent="World/Floor" unique_id=152652175] +[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/Floor" unique_id=152652175] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.1, 0) mesh = SubResource("BoxMesh_floor") -[node name="CollisionShape3D" type="CollisionShape3D" parent="World/Floor" unique_id=409532142] +[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/Floor" unique_id=409532142] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.1, 0) shape = SubResource("BoxShape3D_floor") -[node name="PrototypeTerminal" type="StaticBody3D" parent="World" unique_id=1700001] +[node name="PrototypeTerminal" type="StaticBody3D" parent="World/NavigationRegion3D" unique_id=1700001] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.5, 0) -[node name="MeshInstance3D" type="MeshInstance3D" parent="World/PrototypeTerminal" unique_id=1700002] +[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/PrototypeTerminal" unique_id=1700002] mesh = SubResource("BoxMesh_terminal") surface_material_override/0 = SubResource("Mat_terminal") @@ -117,61 +131,61 @@ transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.95, 0.22, -1.05) mesh = SubResource("BoxMesh_marker") surface_material_override/0 = SubResource("Mat_marker_base") -[node name="NS19BumpA" type="StaticBody3D" parent="World" unique_id=1900001 groups=["walkable"]] +[node name="NS19BumpA" type="StaticBody3D" parent="World/NavigationRegion3D" unique_id=1900001 groups=["walkable"]] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -3.4, 0.075, -2.6) collision_layer = 1 -[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NS19BumpA" unique_id=1900002] +[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/NS19BumpA" unique_id=1900002] mesh = SubResource("BoxMesh_ns19_bump_a") surface_material_override/0 = SubResource("Mat_ns19_bump") -[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NS19BumpA" unique_id=1900003] +[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/NS19BumpA" unique_id=1900003] shape = SubResource("BoxShape3D_ns19_bump_a") -[node name="NS19BumpB" type="StaticBody3D" parent="World" unique_id=1900011 groups=["walkable"]] +[node name="NS19BumpB" type="StaticBody3D" parent="World/NavigationRegion3D" unique_id=1900011 groups=["walkable"]] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.85, 0.06, -4.1) collision_layer = 1 -[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NS19BumpB" unique_id=1900012] +[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/NS19BumpB" unique_id=1900012] mesh = SubResource("BoxMesh_ns19_bump_b") surface_material_override/0 = SubResource("Mat_ns19_bump") -[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NS19BumpB" unique_id=1900013] +[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/NS19BumpB" unique_id=1900013] shape = SubResource("BoxShape3D_ns19_bump_b") -[node name="NS19RejectPedestal" type="StaticBody3D" parent="World" unique_id=1900021 groups=["walkable"]] +[node name="NS19RejectPedestal" type="StaticBody3D" parent="World/NavigationRegion3D" unique_id=1900021 groups=["walkable"]] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 7.5, 0, -6.5) collision_layer = 1 -[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NS19RejectPedestal" unique_id=1900022] +[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/NS19RejectPedestal" unique_id=1900022] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.25, 0) mesh = SubResource("BoxMesh_ns19_pedestal") surface_material_override/0 = SubResource("Mat_ns19_pedestal") -[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NS19RejectPedestal" unique_id=1900023] +[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/NS19RejectPedestal" unique_id=1900023] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.25, 0) shape = SubResource("BoxShape3D_ns19_pedestal") -[node name="NS19FarPad" type="StaticBody3D" parent="World" unique_id=1900031 groups=["walkable"]] +[node name="NS19FarPad" type="StaticBody3D" parent="World/NavigationRegion3D" unique_id=1900031 groups=["walkable"]] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 9, 0.06, 9) collision_layer = 1 -[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NS19FarPad" unique_id=1900032] +[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/NS19FarPad" unique_id=1900032] mesh = SubResource("BoxMesh_ns19_farpad") surface_material_override/0 = SubResource("Mat_ns19_farpad") -[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NS19FarPad" unique_id=1900033] +[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/NS19FarPad" unique_id=1900033] shape = SubResource("BoxShape3D_ns19_farpad") -[node name="Obstacle" type="StaticBody3D" parent="World" unique_id=1638845763] +[node name="Obstacle" type="StaticBody3D" parent="World/NavigationRegion3D" unique_id=1638845763] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 6, 0, 5) collision_layer = 1 -[node name="MeshInstance3D" type="MeshInstance3D" parent="World/Obstacle" unique_id=750473628] +[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/Obstacle" unique_id=750473628] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 0) mesh = SubResource("BoxMesh_obstacle") -[node name="CollisionShape3D" type="CollisionShape3D" parent="World/Obstacle" unique_id=1344302688] +[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/Obstacle" unique_id=1344302688] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 0) shape = SubResource("BoxShape3D_obstacle") @@ -184,6 +198,14 @@ script = ExtResource("2_player") [node name="CollisionShape3D" type="CollisionShape3D" parent="Player" unique_id=1695755590] shape = SubResource("CapsuleShape3D_player") +[node name="NavigationAgent3D" type="NavigationAgent3D" parent="Player" unique_id=8880002] +avoidance_enabled = false +path_desired_distance = 0.35 +target_desired_distance = 0.35 +path_height_offset = 0.0 +radius = 0.4 +height = 1.0 + [node name="MeshInstance3D" type="MeshInstance3D" parent="Player" unique_id=2027034386] mesh = SubResource("CapsuleMesh_player") diff --git a/client/scripts/main.gd b/client/scripts/main.gd index d218071..92537e9 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). +## NS-23: bakes `NavigationRegion3D` on startup; boot snap vs nav goal from authority signal. const MOVE_REJECT_MSG_SECONDS: float = 4.0 @@ -9,6 +10,7 @@ const MOVE_REJECT_MSG_SECONDS: float = 4.0 var _move_reject_msg_token: int = 0 @onready var _camera: Camera3D = $World/Camera3D +@onready var _nav_region: NavigationRegion3D = $World/NavigationRegion3D @onready var _player: CharacterBody3D = $Player @onready var _ground_pick: Node3D = $GroundPick @onready var _authority: Node = $PositionAuthorityClient @@ -17,6 +19,8 @@ var _move_reject_msg_token: int = 0 func _ready() -> void: + await get_tree().process_frame + _nav_region.bake_navigation_mesh() _ground_pick.set("fallback_camera", _camera) _ground_pick.connect("target_chosen", Callable(self, "_on_target_chosen")) _authority.connect( @@ -32,11 +36,16 @@ func _on_target_chosen(world: Vector3) -> void: _authority.call("submit_move_target", world) -func _on_authoritative_position(world: Vector3) -> void: - _player.snap_to_server(world) +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) 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. push_warning("Move rejected: %s" % reason_code) _move_reject_msg_token += 1 var token: int = _move_reject_msg_token diff --git a/client/scripts/player.gd b/client/scripts/player.gd index 48edee7..f2b5e2d 100644 --- a/client/scripts/player.gd +++ b/client/scripts/player.gd @@ -1,37 +1,47 @@ extends CharacterBody3D -## NS-14: click-to-move using horizontal steering + move_and_slide (not NavigationAgent3D). -## Obstacles are handled by physics sliding, not pathfinding — see client README. +## NS-23: `NavigationAgent3D` path-follow for click-to-move visuals. Server owns `PositionState` / +## `MoveCommand` (NS-16, NS-19); boot snap vs post-verify nav goal from `main.gd`. const MOVE_SPEED: float = 5.0 -const ARRIVE_EPS: float = 0.35 -var _goal: Vector3 +@onready var _nav_agent: NavigationAgent3D = $NavigationAgent3D func _ready() -> void: - _goal = global_position + _nav_agent.target_position = global_position -func set_move_goal(world_pos: Vector3) -> void: - _goal = world_pos - _goal.y = global_position.y +## After successful move POST + GET: walk to server target without teleporting. +func set_authoritative_nav_goal(world_pos: Vector3) -> void: + _nav_agent.target_position = world_pos -## NS-16: snap to authoritative server position; clears velocity so `move_and_slide` does not fight -## the sync. +## NS-19 rejection or cancel pending path without moving the body. +func clear_nav_goal() -> void: + velocity = Vector3.ZERO + _nav_agent.target_position = global_position + + +## Boot (and any hard reconcile): teleport to server position and reset agent. func snap_to_server(world_pos: Vector3) -> void: global_position = world_pos velocity = Vector3.ZERO - _goal = world_pos + _nav_agent.target_position = world_pos func _physics_process(_delta: float) -> void: - var to_goal: Vector3 = _goal - global_position - to_goal.y = 0.0 - if to_goal.length() <= ARRIVE_EPS: + if _nav_agent.is_navigation_finished(): + velocity = Vector3.ZERO + move_and_slide() + return + + var next_path_position: Vector3 = _nav_agent.get_next_path_position() + var to_next: Vector3 = next_path_position - global_position + to_next.y = 0.0 + if to_next.length_squared() < 0.0001: velocity = Vector3.ZERO else: - velocity = to_goal.normalized() * MOVE_SPEED + velocity = to_next.normalized() * MOVE_SPEED velocity.y = 0.0 move_and_slide() diff --git a/client/scripts/position_authority_client.gd b/client/scripts/position_authority_client.gd index 3f0df7a..a8b832a 100644 --- a/client/scripts/position_authority_client.gd +++ b/client/scripts/position_authority_client.gd @@ -2,9 +2,10 @@ 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). ## (No `class_name` so headless/CI can load the project before `.godot` import exists.) -signal authoritative_position_received(world: Vector3) +signal authoritative_position_received(world: Vector3, apply_as_snap: bool) signal move_rejected(reason_code: String) enum Phase { BOOT_GET, POST_MOVE, VERIFY_GET } @@ -79,7 +80,7 @@ func _on_request_completed( Phase.BOOT_GET: _busy = false if response_code == 200: - _emit_position_from_response(text) + _emit_position_from_response(text, true) Phase.POST_MOVE: if response_code == 400: _emit_move_rejection_if_present(text) @@ -96,7 +97,7 @@ func _on_request_completed( Phase.VERIFY_GET: _busy = false if response_code == 200: - _emit_position_from_response(text) + _emit_position_from_response(text, false) func _emit_move_rejection_if_present(json_text: String) -> void: @@ -110,7 +111,7 @@ func _emit_move_rejection_if_present(json_text: String) -> void: move_rejected.emit("unknown") -func _emit_position_from_response(json_text: String) -> void: +func _emit_position_from_response(json_text: String, apply_as_snap: bool) -> void: var parsed: Variant = JSON.parse_string(json_text) if parsed == null: return @@ -124,4 +125,4 @@ func _emit_position_from_response(json_text: String) -> void: var x: float = float(pos.get("x", 0.0)) var y: float = float(pos.get("y", 0.0)) var z: float = float(pos.get("z", 0.0)) - authoritative_position_received.emit(Vector3(x, y, z)) + authoritative_position_received.emit(Vector3(x, y, z), apply_as_snap) diff --git a/docs/decomposition/modules/E1_M1_InputAndMovementRuntime.md b/docs/decomposition/modules/E1_M1_InputAndMovementRuntime.md index 1286d74..0bd2c11 100644 --- a/docs/decomposition/modules/E1_M1_InputAndMovementRuntime.md +++ b/docs/decomposition/modules/E1_M1_InputAndMovementRuntime.md @@ -33,8 +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); **NS-19** server-side step + optional district bounds validation with **`reasonCode`** rejections ([NS-19](../../plans/NS-19-implementation-plan.md), `MoveCommandValidation`, [server README — Move command](../../../server/README.md#move-command-ns-16-ns-19)); default **in-memory** store; optional **PostgreSQL** persistence when `ConnectionStrings:NeonSprawl` is set ([NS-17](../../plans/NS-17-implementation-plan.md)); Godot client submits move and snaps to server after GET ([NS-15](../../plans/NS-15-implementation-plan.md), [NS-16](../../plans/NS-16-implementation-plan.md), `server/NeonSprawl.Server/Game/PositionState/`, `client/scripts/`). **`InteractionRequest`** + server-side horizontal range check ([NS-18](../../plans/NS-18-implementation-plan.md); `Game/Interaction/`, `Game/World/HorizontalReach.cs`, [server README — Interaction](../../../server/README.md#interaction-ns-18)). See [server README — Position persistence](../../../server/README.md#position-persistence-ns-17). -- **In progress:** **NS-23** — client **`NavigationRegion3D` / `NavigationAgent3D`** path-follow baseline while keeping **`MoveCommand`** / **`GET`** authority ([NS-23](../../plans/NS-23-implementation-plan.md)). +- **Done (prototype):** Server-side authoritative **`PositionState`** read + **`MoveCommand`** apply (HTTP JSON v1, snap-to-target, `sequence` increments); **NS-19** server-side step + optional district bounds validation with **`reasonCode`** rejections ([NS-19](../../plans/NS-19-implementation-plan.md), `MoveCommandValidation`, [server README — Move command](../../../server/README.md#move-command-ns-16-ns-19)); default **in-memory** store; optional **PostgreSQL** persistence when `ConnectionStrings:NeonSprawl` is set ([NS-17](../../plans/NS-17-implementation-plan.md)); Godot client **`POST`/`GET`** move flow ([NS-15](../../plans/NS-15-implementation-plan.md), [NS-16](../../plans/NS-16-implementation-plan.md), `server/NeonSprawl.Server/Game/PositionState/`, `client/scripts/`). **NS-23** — client **`NavigationRegion3D` / `NavigationAgent3D`** path-follow for click-to-move visuals while server authority unchanged ([NS-23](../../plans/NS-23-implementation-plan.md); [client README](../../../client/README.md#authoritative-movement-ns-16-ns-23)). **`InteractionRequest`** + server-side horizontal range check ([NS-18](../../plans/NS-18-implementation-plan.md); `Game/Interaction/`, `Game/World/HorizontalReach.cs`, [server README — Interaction](../../../server/README.md#interaction-ns-18)). See [server README — Position persistence](../../../server/README.md#position-persistence-ns-17). - **Not yet:** Prediction/reconciliation, full Epic 1 Slice 1 movement loop and telemetry. - **Alignment:** [Documentation and implementation alignment](documentation_and_implementation_alignment.md). diff --git a/docs/plans/NS-16-implementation-plan.md b/docs/plans/NS-16-implementation-plan.md index 8327234..dabe1e5 100644 --- a/docs/plans/NS-16-implementation-plan.md +++ b/docs/plans/NS-16-implementation-plan.md @@ -54,7 +54,7 @@ 5. **Godot client** — **Split scripts by concern** (required repo policy: [godot-client-script-organization.md](../../.cursor/rules/godot-client-script-organization.md)); do **not** grow a monolithic `main.gd` with pick + HTTP + wiring. - **`ground_pick.gd`** on a new **`Node3D`** child (e.g. `GroundPick`): owns walkable **ray pick** (logic moved out of NS-14 `main.gd`). Exposes **`target_chosen(Vector3)`** when the user clicks valid ground. **`main.gd`** sets **`fallback_camera`** (or equivalent) in `_ready`. - - **`position_authority_client.gd`** on a new **`Node`** child: `@export` **base URL** (default `http://127.0.0.1:5253`, match `launchSettings.json`) and **dev player id** (`dev-local-1`). Creates **`HTTPRequest`** in **`_ready`** via **`add_child`** (not as a node hand-placed in `main.tscn`). Flow: **`POST`** `MoveCommand`, then **`GET`** position (tests must show **GET** reflects apply). Emits **`authoritative_position_received(Vector3)`** so **`main`** only snaps the player. + - **`position_authority_client.gd`** on a new **`Node`** child: `@export` **base URL** (default `http://127.0.0.1:5253`, match `launchSettings.json`) and **dev player id** (`dev-local-1`). Creates **`HTTPRequest`** in **`_ready`** via **`add_child`** (not as a node hand-placed in `main.tscn`). Flow: **`POST`** `MoveCommand`, then **`GET`** position (tests must show **GET** reflects apply). Emits **`authoritative_position_received(Vector3, bool)`** (NS-23: second arg = boot snap vs nav goal) so **`main`** snaps on boot and path-follows after move verify. - **`main.gd`:** Thin composer only—connect signals, call **`sync_from_server()`** on run, delegate pick and HTTP to the two child scripts. - Align **client README** with NS-14 note: server authority path replaces pure local steering for this demo. diff --git a/docs/plans/NS-23-implementation-plan.md b/docs/plans/NS-23-implementation-plan.md index 447141b..c057803 100644 --- a/docs/plans/NS-23-implementation-plan.md +++ b/docs/plans/NS-23-implementation-plan.md @@ -40,9 +40,9 @@ ## Acceptance criteria checklist -- [ ] Clicking a valid floor target produces **continuous motion** along a plausible path around static obstacles in the prototype scene (not sliding through walls). -- [ ] Authoritative position still **converges** with the server (`MoveCommand` + `GET`); no regression on NS-19 rejection UX (`reasonCode` / label timeout). -- [ ] Short note in **`server/README.md`** and/or **client script header**: client nav vs server authority for this slice. +- [x] Clicking a valid floor target produces **continuous motion** along a plausible path around static obstacles in the prototype scene (not sliding through walls). +- [x] Authoritative position still **converges** with the server (`MoveCommand` + `GET`); no regression on NS-19 rejection UX (`reasonCode` / label timeout). +- [x] Short note in **`server/README.md`** and/or **client script header**: client nav vs server authority for this slice. ## Technical approach diff --git a/server/README.md b/server/README.md index d62b873..a1da7a0 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. +**Client navigation (NS-23):** The Godot prototype uses a **baked navigation mesh** only for **presentation**—path-follow around static geometry. The server does **not** simulate navmesh; it validates **straight-line** step limits (NS-19) from the **last authoritative position** to the **requested target**. Cheating or divergent paths are out of scope for this slice. + **NS-19 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. | Config key | Meaning | Default (see `appsettings.json`) |