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)