From 32d2153acb57126d2bf0376c549c5516595be2ab Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sat, 4 Apr 2026 23:39:40 -0400 Subject: [PATCH] NS-19: server move validation, client rejection UX, scene QA props --- client/scenes/main.tscn | 94 +++++++++++++++++++ client/scripts/main.gd | 7 ++ client/scripts/position_authority_client.gd | 21 ++++- .../modules/E1_M1_InputAndMovementRuntime.md | 2 +- docs/plans/NS-19-implementation-plan.md | 10 +- .../Game/PositionState/MoveCommandApiTests.cs | 73 +++++++++++++- .../MoveCommandValidationTests.cs | 90 ++++++++++++++++++ .../PostgresPositionStateIntegrationTests.cs | 12 +-- .../Game/PositionState/GamePositionOptions.cs | 3 + .../PositionState/MoveCommandReasonCodes.cs | 9 ++ .../MoveCommandRejectedResponse.cs | 15 +++ .../Game/PositionState/MoveCommandRequest.cs | 2 +- .../PositionState/MoveCommandValidation.cs | 48 ++++++++++ .../MovementValidationOptions.cs | 21 +++++ .../Game/PositionState/PositionStateApi.cs | 22 ++++- server/NeonSprawl.Server/appsettings.json | 5 + server/README.md | 19 +++- 17 files changed, 430 insertions(+), 23 deletions(-) create mode 100644 server/NeonSprawl.Server.Tests/Game/PositionState/MoveCommandValidationTests.cs create mode 100644 server/NeonSprawl.Server/Game/PositionState/MoveCommandReasonCodes.cs create mode 100644 server/NeonSprawl.Server/Game/PositionState/MoveCommandRejectedResponse.cs create mode 100644 server/NeonSprawl.Server/Game/PositionState/MoveCommandValidation.cs create mode 100644 server/NeonSprawl.Server/Game/PositionState/MovementValidationOptions.cs diff --git a/client/scenes/main.tscn b/client/scenes/main.tscn index fbec954..6d31397 100644 --- a/client/scenes/main.tscn +++ b/client/scenes/main.tscn @@ -39,6 +39,39 @@ size = Vector3(0.22, 0.35, 0.22) [sub_resource type="StandardMaterial3D" id="Mat_marker_base"] albedo_color = Color(0.15, 0.4, 1, 1) +[sub_resource type="BoxMesh" id="BoxMesh_ns19_bump_a"] +size = Vector3(1, 0.15, 1) + +[sub_resource type="BoxShape3D" id="BoxShape3D_ns19_bump_a"] +size = Vector3(1, 0.15, 1) + +[sub_resource type="BoxMesh" id="BoxMesh_ns19_bump_b"] +size = Vector3(0.85, 0.12, 0.85) + +[sub_resource type="BoxShape3D" id="BoxShape3D_ns19_bump_b"] +size = Vector3(0.85, 0.12, 0.85) + +[sub_resource type="BoxMesh" id="BoxMesh_ns19_pedestal"] +size = Vector3(1.5, 2.5, 1.5) + +[sub_resource type="BoxShape3D" id="BoxShape3D_ns19_pedestal"] +size = Vector3(1.5, 2.5, 1.5) + +[sub_resource type="BoxMesh" id="BoxMesh_ns19_farpad"] +size = Vector3(2.5, 0.12, 2.5) + +[sub_resource type="BoxShape3D" id="BoxShape3D_ns19_farpad"] +size = Vector3(2.5, 0.12, 2.5) + +[sub_resource type="StandardMaterial3D" id="Mat_ns19_bump"] +albedo_color = Color(0.22, 0.52, 0.32, 1) + +[sub_resource type="StandardMaterial3D" id="Mat_ns19_pedestal"] +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) + [node name="Main" type="Node3D" unique_id=1358372723] script = ExtResource("1_main") @@ -84,6 +117,52 @@ 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"]] +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] +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] +shape = SubResource("BoxShape3D_ns19_bump_a") + +[node name="NS19BumpB" type="StaticBody3D" parent="World" 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] +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] +shape = SubResource("BoxShape3D_ns19_bump_b") + +[node name="NS19RejectPedestal" type="StaticBody3D" parent="World" 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] +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] +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"]] +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] +mesh = SubResource("BoxMesh_ns19_farpad") +surface_material_override/0 = SubResource("Mat_ns19_farpad") + +[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NS19FarPad" unique_id=1900033] +shape = SubResource("BoxShape3D_ns19_farpad") + [node name="Obstacle" type="StaticBody3D" parent="World" unique_id=1638845763] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 6, 0, 5) collision_layer = 1 @@ -116,3 +195,18 @@ script = ExtResource("4_auth") [node name="InteractionRequestClient" type="Node" parent="." unique_id=2500003] script = ExtResource("5_ix") + +[node name="UICanvas" type="CanvasLayer" parent="." unique_id=9000001] + +[node name="MoveRejectLabel" type="Label" parent="UICanvas" unique_id=9000002] +anchors_preset = 12 +anchor_left = 0.0 +anchor_top = 1.0 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_left = 12.0 +offset_top = -44.0 +offset_right = -12.0 +offset_bottom = -8.0 +grow_horizontal = 2 +autowrap_mode = 3 diff --git a/client/scripts/main.gd b/client/scripts/main.gd index f488d6d..c466e42 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -8,6 +8,7 @@ extends Node3D @onready var _ground_pick: Node3D = $GroundPick @onready var _authority: Node = $PositionAuthorityClient @onready var _radius_preview: Node3D = $World/InteractionMarkers +@onready var _move_reject_label: Label = $UICanvas/MoveRejectLabel func _ready() -> void: @@ -16,6 +17,7 @@ func _ready() -> void: _authority.connect( "authoritative_position_received", Callable(self, "_on_authoritative_position") ) + _authority.connect("move_rejected", Callable(self, "_on_move_rejected")) _authority.call("sync_from_server") if _radius_preview.has_method("setup_player"): _radius_preview.call("setup_player", _player) @@ -27,3 +29,8 @@ func _on_target_chosen(world: Vector3) -> void: func _on_authoritative_position(world: Vector3) -> void: _player.snap_to_server(world) + + +func _on_move_rejected(reason_code: String) -> void: + push_warning("Move rejected: %s" % reason_code) + _move_reject_label.text = "Move rejected: %s" % reason_code diff --git a/client/scripts/position_authority_client.gd b/client/scripts/position_authority_client.gd index 049e8e3..3f0df7a 100644 --- a/client/scripts/position_authority_client.gd +++ b/client/scripts/position_authority_client.gd @@ -1,10 +1,11 @@ extends Node ## NS-16: POST MoveCommand then GET PositionState; emits authoritative world position for the -## avatar. +## avatar. NS-19: failed validation (HTTP 400 + rejection body) emits `move_rejected`. ## (No `class_name` so headless/CI can load the project before `.godot` import exists.) signal authoritative_position_received(world: Vector3) +signal move_rejected(reason_code: String) enum Phase { BOOT_GET, POST_MOVE, VERIFY_GET } @@ -80,7 +81,14 @@ func _on_request_completed( if response_code == 200: _emit_position_from_response(text) Phase.POST_MOVE: + if response_code == 400: + _emit_move_rejection_if_present(text) + _busy = false + return if response_code != 200: + push_warning( + "PositionAuthorityClient: move POST unexpected code %s" % response_code + ) _busy = false return _phase = Phase.VERIFY_GET @@ -91,6 +99,17 @@ func _on_request_completed( _emit_position_from_response(text) +func _emit_move_rejection_if_present(json_text: String) -> void: + var parsed: Variant = JSON.parse_string(json_text) + if parsed != null and parsed is Dictionary: + var data: Dictionary = parsed + var code_variant: Variant = data.get("reasonCode", "") + if code_variant is String: + move_rejected.emit(code_variant as String) + return + move_rejected.emit("unknown") + + func _emit_position_from_response(json_text: String) -> void: var parsed: Variant = JSON.parse_string(json_text) if parsed == null: diff --git a/docs/decomposition/modules/E1_M1_InputAndMovementRuntime.md b/docs/decomposition/modules/E1_M1_InputAndMovementRuntime.md index c40aa63..5fd2062 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); 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). +- **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). - **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-19-implementation-plan.md b/docs/plans/NS-19-implementation-plan.md index 9f3012f..9aa2dae 100644 --- a/docs/plans/NS-19-implementation-plan.md +++ b/docs/plans/NS-19-implementation-plan.md @@ -42,11 +42,11 @@ Invalid moves are **rejected**: return the error payload; **do not** change auth ## Acceptance criteria checklist -- [ ] **Oversized** move (horizontal and/or vertical per configured rules) is **rejected**; client can observe failure (existing logging and/or minimal UI). -- [ ] Rules implemented in **testable** pure logic (e.g. static `MoveCommandValidation` or small service) with **unit tests** on the math and edge cases (at limit, just inside, just outside; floating-point safe). -- [ ] **Constants / thresholds** live in **one configuration surface** (options pattern bound to `Game:`), not scattered magic numbers. -- [ ] **Integration tests** on `PositionStateApi` / HTTP: valid move still **200** + `PositionStateResponse`; invalid move returns the agreed status + **`reasonCode`**; unknown player still **404**. -- [ ] **Godot scene:** **`walkable`** bumps (small ΔY) and tall **`walkable`** pedestal (and optional far pad) per §5b; manual pass confirms bump **accepts** vs pedestal **rejects** with expected **`reasonCode`**. +- [x] **Oversized** move (horizontal and/or vertical per configured rules) is **rejected**; client can observe failure (existing logging and/or minimal UI). +- [x] Rules implemented in **testable** pure logic (e.g. static `MoveCommandValidation` or small service) with **unit tests** on the math and edge cases (at limit, just inside, just outside; floating-point safe). +- [x] **Constants / thresholds** live in **one configuration surface** (options pattern bound to `Game:`), not scattered magic numbers. +- [x] **Integration tests** on `PositionStateApi` / HTTP: valid move still **200** + `PositionStateResponse`; invalid move returns the agreed status + **`reasonCode`**; unknown player still **404**. +- [x] **Godot scene:** **`walkable`** bumps (small ΔY) and tall **`walkable`** pedestal (and optional far pad) per §5b; manual pass confirms bump **accepts** vs pedestal **rejects** with expected **`reasonCode`**. ## Technical approach diff --git a/server/NeonSprawl.Server.Tests/Game/PositionState/MoveCommandApiTests.cs b/server/NeonSprawl.Server.Tests/Game/PositionState/MoveCommandApiTests.cs index 4c772c4..4f4ab9a 100644 --- a/server/NeonSprawl.Server.Tests/Game/PositionState/MoveCommandApiTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/PositionState/MoveCommandApiTests.cs @@ -1,13 +1,15 @@ 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; /// -/// NS-16: integration tests for POST /game/players/{{id}}/move on . +/// NS-16 / NS-19: integration tests for POST /game/players/{{id}}/move on . /// Isolated host per test (fresh in-memory store). /// public class MoveCommandApiTests @@ -21,7 +23,7 @@ public class MoveCommandApiTests var cmd = new MoveCommandRequest { SchemaVersion = MoveCommandRequest.CurrentSchemaVersion, - Target = new PositionVector { X = 1.5, Y = -0.5, Z = 2.25 }, + Target = new PositionVector { X = 1.5, Y = 0.0, Z = 2.25 }, }; // Act @@ -40,7 +42,7 @@ public class MoveCommandApiTests Assert.Equal("dev-local-1", postBody.PlayerId); Assert.Equal(seq0 + 1, postBody.Sequence); Assert.Equal(1.5, postBody.Position.X); - Assert.Equal(-0.5, postBody.Position.Y); + Assert.Equal(0.0, postBody.Position.Y); Assert.Equal(2.25, postBody.Position.Z); Assert.NotNull(afterMove); @@ -105,4 +107,69 @@ public class MoveCommandApiTests // Assert Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); } + + [Fact] + public async Task PostMove_ShouldReturn400WithReason_WhenHorizontalStepTooLarge() + { + await using var factory = new InMemoryWebApplicationFactory().WithWebHostBuilder(b => + { + b.ConfigureTestServices(services => + { + services.PostConfigure(o => o.MovementValidation.MaxHorizontalStep = 1.0); + }); + }); + var client = factory.CreateClient(); + var cmd = new MoveCommandRequest + { + SchemaVersion = MoveCommandRequest.CurrentSchemaVersion, + Target = new PositionVector { X = 0, Y = 0.9, Z = 0 }, + }; + + var response = await client.PostAsJsonAsync("/game/players/dev-local-1/move", cmd); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(body); + Assert.Equal(MoveCommandRejectedResponse.CurrentSchemaVersion, body!.SchemaVersion); + Assert.Equal(MoveCommandReasonCodes.HorizontalStepExceeded, body.ReasonCode); + } + + [Fact] + public async Task PostMove_ShouldReturn400WithReason_WhenVerticalStepTooLarge() + { + await using var factory = new InMemoryWebApplicationFactory().WithWebHostBuilder(b => + { + b.ConfigureTestServices(services => + { + services.PostConfigure(o => o.MovementValidation.MaxVerticalStep = 0.5); + }); + }); + var client = factory.CreateClient(); + var cmd = new MoveCommandRequest + { + SchemaVersion = MoveCommandRequest.CurrentSchemaVersion, + Target = new PositionVector { X = -5, Y = 2.0, Z = -5 }, + }; + + var response = await client.PostAsJsonAsync("/game/players/dev-local-1/move", cmd); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(body); + Assert.Equal(MoveCommandReasonCodes.VerticalStepExceeded, body!.ReasonCode); + } + + [Fact] + public async Task PostMove_ShouldReturn400WithoutReasonBody_WhenSchemaInvalid() + { + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + var response = await client.PostAsync( + "/game/players/dev-local-1/move", + new StringContent("{\"schemaVersion\":999,\"target\":{\"x\":0,\"y\":0,\"z\":0}}", Encoding.UTF8, "application/json")); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var text = await response.Content.ReadAsStringAsync(); + Assert.DoesNotContain("reasonCode", text, StringComparison.OrdinalIgnoreCase); + } } diff --git a/server/NeonSprawl.Server.Tests/Game/PositionState/MoveCommandValidationTests.cs b/server/NeonSprawl.Server.Tests/Game/PositionState/MoveCommandValidationTests.cs new file mode 100644 index 0000000..7614c42 --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/PositionState/MoveCommandValidationTests.cs @@ -0,0 +1,90 @@ +using NeonSprawl.Server.Game.PositionState; +using Xunit; + +namespace NeonSprawl.Server.Tests.Game.PositionState; + +public class MoveCommandValidationTests +{ + private static MovementValidationOptions Rules(double maxH, double maxV, bool bounds = false) => + new() + { + MaxHorizontalStep = maxH, + MaxVerticalStep = maxV, + DistrictBoundsEnabled = bounds, + DistrictMinX = 0, + DistrictMaxX = 10, + DistrictMinY = 0, + DistrictMaxY = 10, + DistrictMinZ = 0, + DistrictMaxZ = 10, + }; + + [Fact] + public void TryValidate_ShouldAllow_WhenHorizontalExactlyAtMax() + { + var from = new PositionSnapshot(0, 0, 0, 0); + var to = new PositionVector { X = 5, Y = 0, Z = 0 }; + Assert.True(MoveCommandValidation.TryValidate(from, to, Rules(5, 10), out var code)); + Assert.Equal("", code); + } + + [Fact] + public void TryValidate_ShouldRejectHorizontal_WhenJustOutsideMax() + { + var from = new PositionSnapshot(0, 0, 0, 0); + var to = new PositionVector { X = 5 + 1e-6, Y = 0, Z = 0 }; + Assert.False(MoveCommandValidation.TryValidate(from, to, Rules(5, 10), out var code)); + Assert.Equal(MoveCommandReasonCodes.HorizontalStepExceeded, code); + } + + [Fact] + public void TryValidate_ShouldAllow_WhenVerticalExactlyAtMax() + { + var from = new PositionSnapshot(0, 1, 0, 0); + var to = new PositionVector { X = 0, Y = 2.5, Z = 0 }; + Assert.True(MoveCommandValidation.TryValidate(from, to, Rules(10, 1.5), out _)); + } + + [Fact] + public void TryValidate_ShouldRejectVertical_WhenJustOutsideMax() + { + var from = new PositionSnapshot(0, 1, 0, 0); + var to = new PositionVector { X = 0, Y = 2.5 + 1e-9, Z = 0 }; + Assert.False(MoveCommandValidation.TryValidate(from, to, Rules(10, 1.5), out var code)); + Assert.Equal(MoveCommandReasonCodes.VerticalStepExceeded, code); + } + + [Fact] + public void TryValidate_ShouldPreferHorizontalReason_WhenBothWouldFail() + { + var from = new PositionSnapshot(0, 0, 0, 0); + var to = new PositionVector { X = 100, Y = 100, Z = 0 }; + Assert.False(MoveCommandValidation.TryValidate(from, to, Rules(1, 1), out var code)); + Assert.Equal(MoveCommandReasonCodes.HorizontalStepExceeded, code); + } + + [Fact] + public void TryValidate_ShouldIgnoreBounds_WhenDistrictDisabled() + { + var from = new PositionSnapshot(0, 0, 0, 0); + var to = new PositionVector { X = -50, Y = 0, Z = 0 }; + Assert.True(MoveCommandValidation.TryValidate(from, to, Rules(100, 100, bounds: false), out _)); + } + + [Fact] + public void TryValidate_ShouldRejectOutOfBounds_WhenDistrictEnabled() + { + var from = new PositionSnapshot(5, 5, 5, 0); + var to = new PositionVector { X = 5, Y = 5, Z = 11 }; + Assert.False(MoveCommandValidation.TryValidate(from, to, Rules(20, 20, bounds: true), out var code)); + Assert.Equal(MoveCommandReasonCodes.OutOfBounds, code); + } + + [Fact] + public void TryValidate_ShouldAllowOnBoundsSurface_WhenDistrictEnabled() + { + var from = new PositionSnapshot(5, 5, 5, 0); + var to = new PositionVector { X = 10, Y = 10, Z = 10 }; + Assert.True(MoveCommandValidation.TryValidate(from, to, Rules(20, 20, bounds: true), out _)); + } +} diff --git a/server/NeonSprawl.Server.Tests/Game/PositionState/PostgresPositionStateIntegrationTests.cs b/server/NeonSprawl.Server.Tests/Game/PositionState/PostgresPositionStateIntegrationTests.cs index 8b2555a..5878722 100644 --- a/server/NeonSprawl.Server.Tests/Game/PositionState/PostgresPositionStateIntegrationTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/PositionState/PostgresPositionStateIntegrationTests.cs @@ -22,7 +22,7 @@ public sealed class PostgresPositionStateIntegrationTests(PostgresIntegrationHar var cmd = new MoveCommandRequest { SchemaVersion = MoveCommandRequest.CurrentSchemaVersion, - Target = new PositionVector { X = 2, Y = -1, Z = 3.5 }, + Target = new PositionVector { X = 2, Y = 0.5, Z = 3.5 }, }; // Act @@ -39,7 +39,7 @@ public sealed class PostgresPositionStateIntegrationTests(PostgresIntegrationHar Assert.Equal(PositionStateResponse.CurrentSchemaVersion, postBody!.SchemaVersion); Assert.Equal(before!.Sequence + 1, postBody.Sequence); Assert.Equal(2, postBody.Position.X); - Assert.Equal(-1, postBody.Position.Y); + Assert.Equal(0.5, postBody.Position.Y); Assert.Equal(3.5, postBody.Position.Z); Assert.Equal(postBody.Sequence, after!.Sequence); Assert.Equal(postBody.Position.X, after.Position.X); @@ -55,7 +55,7 @@ public sealed class PostgresPositionStateIntegrationTests(PostgresIntegrationHar var cmd = new MoveCommandRequest { SchemaVersion = MoveCommandRequest.CurrentSchemaVersion, - Target = new PositionVector { X = 10, Y = 20, Z = 30 }, + Target = new PositionVector { X = 7, Y = 0.8, Z = 7 }, }; HttpStatusCode postStatus = default; @@ -77,9 +77,9 @@ public sealed class PostgresPositionStateIntegrationTests(PostgresIntegrationHar Assert.Equal(HttpStatusCode.OK, postStatus); Assert.NotNull(persisted); Assert.NotNull(after); - Assert.Equal(10, after!.Position.X); - Assert.Equal(20, after.Position.Y); - Assert.Equal(30, after.Position.Z); + Assert.Equal(7, after!.Position.X); + Assert.Equal(0.8, after.Position.Y); + Assert.Equal(7, after.Position.Z); Assert.Equal(1ul, after.Sequence); } diff --git a/server/NeonSprawl.Server/Game/PositionState/GamePositionOptions.cs b/server/NeonSprawl.Server/Game/PositionState/GamePositionOptions.cs index a4642cc..aacc85b 100644 --- a/server/NeonSprawl.Server/Game/PositionState/GamePositionOptions.cs +++ b/server/NeonSprawl.Server/Game/PositionState/GamePositionOptions.cs @@ -12,6 +12,9 @@ public sealed class GamePositionOptions /// World position for the dev player at process start. public DefaultPositionOptions DefaultPosition { get; set; } = new(); + + /// NS-19: per-command step limits and optional district bounds. + public MovementValidationOptions MovementValidation { get; set; } = new(); } /// Default spawn coordinates for the configured dev player. diff --git a/server/NeonSprawl.Server/Game/PositionState/MoveCommandReasonCodes.cs b/server/NeonSprawl.Server/Game/PositionState/MoveCommandReasonCodes.cs new file mode 100644 index 0000000..4cb4eb4 --- /dev/null +++ b/server/NeonSprawl.Server/Game/PositionState/MoveCommandReasonCodes.cs @@ -0,0 +1,9 @@ +namespace NeonSprawl.Server.Game.PositionState; + +/// Stable reasonCode strings for (telemetry / client logging). +public static class MoveCommandReasonCodes +{ + public const string HorizontalStepExceeded = "horizontal_step_exceeded"; + public const string VerticalStepExceeded = "vertical_step_exceeded"; + public const string OutOfBounds = "out_of_bounds"; +} diff --git a/server/NeonSprawl.Server/Game/PositionState/MoveCommandRejectedResponse.cs b/server/NeonSprawl.Server/Game/PositionState/MoveCommandRejectedResponse.cs new file mode 100644 index 0000000..2957a32 --- /dev/null +++ b/server/NeonSprawl.Server/Game/PositionState/MoveCommandRejectedResponse.cs @@ -0,0 +1,15 @@ +namespace NeonSprawl.Server.Game.PositionState; + +/// HTTP JSON body when POST …/move is rejected by movement validation (NS-19). +/// +/// reasonCode (v1): . +/// +public sealed class MoveCommandRejectedResponse +{ + public const int CurrentSchemaVersion = 1; + + public int SchemaVersion { get; init; } + + /// Non-empty stable code; see . + public string ReasonCode { get; init; } = ""; +} diff --git a/server/NeonSprawl.Server/Game/PositionState/MoveCommandRequest.cs b/server/NeonSprawl.Server/Game/PositionState/MoveCommandRequest.cs index bc2f8b3..697a264 100644 --- a/server/NeonSprawl.Server/Game/PositionState/MoveCommandRequest.cs +++ b/server/NeonSprawl.Server/Game/PositionState/MoveCommandRequest.cs @@ -4,7 +4,7 @@ namespace NeonSprawl.Server.Game.PositionState; /// HTTP JSON body for POST /game/players/{{id}}/move. Wire contract v1 (JSON); migrate to protobuf later per project contracts docs. /// /// -/// Server rule (v1): authoritative position is set to immediately (snap). increments by one on each successful apply. +/// Server rule (v1): after NS-19 validation, authoritative position is set to (snap). increments by one on each successful apply. Oversized steps return HTTP 400 with . /// Example v1 payload: /// /// {"schemaVersion":1,"target":{"x":1.5,"y":0,"z":-2}} diff --git a/server/NeonSprawl.Server/Game/PositionState/MoveCommandValidation.cs b/server/NeonSprawl.Server/Game/PositionState/MoveCommandValidation.cs new file mode 100644 index 0000000..0dc1c86 --- /dev/null +++ b/server/NeonSprawl.Server/Game/PositionState/MoveCommandValidation.cs @@ -0,0 +1,48 @@ +namespace NeonSprawl.Server.Game.PositionState; + +/// Pure movement validation for targets (NS-19). +/// +/// Precedence when multiple rules fail: , +/// then , then . +/// +public static class MoveCommandValidation +{ + /// Returns false and sets when the move must be rejected. + public static bool TryValidate( + in PositionSnapshot from, + PositionVector to, + MovementValidationOptions rules, + out string reasonCode) + { + reasonCode = ""; + + var dx = to.X - from.X; + var dz = to.Z - from.Z; + var horizontal = Math.Sqrt(dx * dx + dz * dz); + if (horizontal > rules.MaxHorizontalStep) + { + reasonCode = MoveCommandReasonCodes.HorizontalStepExceeded; + return false; + } + + var dy = Math.Abs(to.Y - from.Y); + if (dy > rules.MaxVerticalStep) + { + reasonCode = MoveCommandReasonCodes.VerticalStepExceeded; + return false; + } + + if (rules.DistrictBoundsEnabled) + { + if (to.X < rules.DistrictMinX || to.X > rules.DistrictMaxX || + to.Y < rules.DistrictMinY || to.Y > rules.DistrictMaxY || + to.Z < rules.DistrictMinZ || to.Z > rules.DistrictMaxZ) + { + reasonCode = MoveCommandReasonCodes.OutOfBounds; + return false; + } + } + + return true; + } +} diff --git a/server/NeonSprawl.Server/Game/PositionState/MovementValidationOptions.cs b/server/NeonSprawl.Server/Game/PositionState/MovementValidationOptions.cs new file mode 100644 index 0000000..51111e1 --- /dev/null +++ b/server/NeonSprawl.Server/Game/PositionState/MovementValidationOptions.cs @@ -0,0 +1,21 @@ +namespace NeonSprawl.Server.Game.PositionState; + +/// Per-command movement sanity limits (NS-19). Bound from Game:MovementValidation. +public sealed class MovementValidationOptions +{ + /// Max horizontal displacement on X/Z in one move (meters). Inclusive at the limit. + public double MaxHorizontalStep { get; set; } = 18.0; + + /// Max absolute delta on Y in one move (meters). Inclusive at the limit. + public double MaxVerticalStep { get; set; } = 1.25; + + /// When true, target must lie inside the axis-aligned district box (inclusive min/max). + public bool DistrictBoundsEnabled { get; set; } + + public double DistrictMinX { get; set; } = -12.0; + public double DistrictMaxX { get; set; } = 12.0; + public double DistrictMinY { get; set; } = -2.0; + public double DistrictMaxY { get; set; } = 24.0; + public double DistrictMinZ { get; set; } = -12.0; + public double DistrictMaxZ { get; set; } = 12.0; +} diff --git a/server/NeonSprawl.Server/Game/PositionState/PositionStateApi.cs b/server/NeonSprawl.Server/Game/PositionState/PositionStateApi.cs index 7a3c72e..ce3ca25 100644 --- a/server/NeonSprawl.Server/Game/PositionState/PositionStateApi.cs +++ b/server/NeonSprawl.Server/Game/PositionState/PositionStateApi.cs @@ -1,6 +1,8 @@ +using Microsoft.Extensions.Options; + namespace NeonSprawl.Server.Game.PositionState; -/// Maps HTTP routes for authoritative position read and move (NS-15 / NS-16). +/// Maps HTTP routes for authoritative position read and move (NS-15 / NS-16 / NS-19). public static class PositionStateApi { public static WebApplication MapPositionStateApi(this WebApplication app) @@ -26,7 +28,7 @@ public static class PositionStateApi app.MapPost( "/game/players/{id}/move", - (string id, MoveCommandRequest? body, IPositionStateStore store) => + (string id, MoveCommandRequest? body, IPositionStateStore store, IOptions gameOptions) => { if (body is null || body.SchemaVersion != MoveCommandRequest.CurrentSchemaVersion) { @@ -39,6 +41,22 @@ public static class PositionStateApi return Results.BadRequest(); } + if (!store.TryGetPosition(id, out var current)) + { + return Results.NotFound(); + } + + var rules = gameOptions.Value.MovementValidation; + if (!MoveCommandValidation.TryValidate(current, target, rules, out var reasonCode)) + { + var rejected = new MoveCommandRejectedResponse + { + SchemaVersion = MoveCommandRejectedResponse.CurrentSchemaVersion, + ReasonCode = reasonCode, + }; + return Results.Json(rejected, statusCode: StatusCodes.Status400BadRequest); + } + if (!store.TryApplyMoveTarget(id, target.X, target.Y, target.Z, out var snap)) { return Results.NotFound(); diff --git a/server/NeonSprawl.Server/appsettings.json b/server/NeonSprawl.Server/appsettings.json index a65a4d8..e09c7f9 100644 --- a/server/NeonSprawl.Server/appsettings.json +++ b/server/NeonSprawl.Server/appsettings.json @@ -12,6 +12,11 @@ "X": -5, "Y": 0.9, "Z": -5 + }, + "MovementValidation": { + "MaxHorizontalStep": 18, + "MaxVerticalStep": 1.25, + "DistrictBoundsEnabled": false } } } diff --git a/server/README.md b/server/README.md index 9ca7918..e728f22 100644 --- a/server/README.md +++ b/server/README.md @@ -59,10 +59,21 @@ Sample response (default spawn matches Godot capsule and NS-18 walk-in range dem Unknown player ids return **404**. Full zone sync / replication is still out of scope for these spikes. -## Move command (NS-16) +## Move command (NS-16, NS-19) **`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. +**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`) | +|------------|---------|-----------------------------------| +| `Game:MovementValidation:MaxHorizontalStep` | Max XZ displacement per command (m); inclusive at limit | `18` | +| `Game:MovementValidation:MaxVerticalStep` | Max absolute Y delta per command (m); inclusive | `1.25` | +| `Game:MovementValidation:DistrictBoundsEnabled` | Axis-aligned box on **target** (inclusive min/max) | `false` | +| `Game:MovementValidation:DistrictMinX` … `DistrictMaxZ` | District extents when enabled | ±12 / Y −2…24 | + +**Manual QA (Godot `main.tscn`):** **Green bumps** (~0.15 m rise) stay under default `MaxVerticalStep` from the dev spawn; **orange reject pedestal** top is ~2.5 m above floor so a floor-level click yields **`vertical_step_exceeded`**; **far pad** is beyond default horizontal reach for **`horizontal_step_exceeded`**. + Request body (example): ```bash @@ -72,12 +83,12 @@ curl -s -X POST http://localhost:5253/game/players/dev-local-1/move \ ``` - **200** — body matches **`PositionStateResponse`** (same shape as `GET` … `/position`). -- **400** — missing/invalid body or `schemaVersion` ≠ `1`. +- **400** — malformed command (missing/invalid body or wrong `schemaVersion`) **or** move rejected by NS-19 rules. Rejection responses are JSON **`MoveCommandRejectedResponse`**: `schemaVersion` (`1`) + **`reasonCode`** (`horizontal_step_exceeded`, `vertical_step_exceeded`, `out_of_bounds`). Malformed requests return **400** with **no** rejection body. - **404** — unknown player id. -See XML on `MoveCommandRequest` and `PositionStateResponse` in the server project. +See XML on `MoveCommandRequest`, `PositionStateResponse`, and `MoveCommandRejectedResponse` in the server project. -For a **PR/Jira-ready** contract blurb (formatted JSON + field table), see the [NS-15 implementation plan — Pull request description](../../docs/plans/NS-15-implementation-plan.md#pull-request-description-jira-ac) (GET shape) and [NS-16 implementation plan](../../docs/plans/NS-16-implementation-plan.md#pull-request-description-jira-ac--draft-for-merge) (MoveCommand + sequence semantics). +For a **PR/Jira-ready** contract blurb (formatted JSON + field table), see the [NS-15 implementation plan — Pull request description](../../docs/plans/NS-15-implementation-plan.md#pull-request-description-jira-ac) (GET shape) and [NS-16 implementation plan](../../docs/plans/NS-16-implementation-plan.md#pull-request-description-jira-ac--draft-for-merge) (MoveCommand + sequence semantics). NS-19: [NS-19 implementation plan](../../docs/plans/NS-19-implementation-plan.md). ## Interaction (NS-18)