NS-19: server move validation, client rejection UX, scene QA props

pull/21/head
VinPropane 2026-04-04 23:39:40 -04:00
parent e4ebf97e28
commit 32d2153acb
17 changed files with 430 additions and 23 deletions

View File

@ -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

View File

@ -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

View File

@ -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:

View File

@ -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).

View File

@ -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

View File

@ -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;
/// <summary>
/// NS-16: integration tests for <c>POST /game/players/{{id}}/move</c> on <see cref="NeonSprawl.Server.Game.PositionState.PositionStateApi"/>.
/// NS-16 / NS-19: integration tests for <c>POST /game/players/{{id}}/move</c> on <see cref="NeonSprawl.Server.Game.PositionState.PositionStateApi"/>.
/// Isolated host per test (fresh in-memory store).
/// </summary>
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<GamePositionOptions>(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<MoveCommandRejectedResponse>();
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<GamePositionOptions>(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<MoveCommandRejectedResponse>();
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);
}
}

View File

@ -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 _));
}
}

View File

@ -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);
}

View File

@ -12,6 +12,9 @@ public sealed class GamePositionOptions
/// <summary>World position for the dev player at process start.</summary>
public DefaultPositionOptions DefaultPosition { get; set; } = new();
/// <summary>NS-19: per-command step limits and optional district bounds.</summary>
public MovementValidationOptions MovementValidation { get; set; } = new();
}
/// <summary>Default spawn coordinates for the configured dev player.</summary>

View File

@ -0,0 +1,9 @@
namespace NeonSprawl.Server.Game.PositionState;
/// <summary>Stable <c>reasonCode</c> strings for <see cref="MoveCommandRejectedResponse"/> (telemetry / client logging).</summary>
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";
}

View File

@ -0,0 +1,15 @@
namespace NeonSprawl.Server.Game.PositionState;
/// <summary>HTTP JSON body when <c>POST …/move</c> is rejected by movement validation (NS-19).</summary>
/// <remarks>
/// <para><b><c>reasonCode</c> (v1):</b> <see cref="MoveCommandReasonCodes"/>.</para>
/// </remarks>
public sealed class MoveCommandRejectedResponse
{
public const int CurrentSchemaVersion = 1;
public int SchemaVersion { get; init; }
/// <summary>Non-empty stable code; see <see cref="MoveCommandReasonCodes"/>.</summary>
public string ReasonCode { get; init; } = "";
}

View File

@ -4,7 +4,7 @@ namespace NeonSprawl.Server.Game.PositionState;
/// HTTP JSON body for <c>POST /game/players/{{id}}/move</c>. Wire contract v1 (JSON); migrate to protobuf later per project contracts docs.
/// </summary>
/// <remarks>
/// <para><b>Server rule (v1):</b> authoritative position is set to <see cref="Target"/> immediately (snap). <see cref="PositionStateResponse.Sequence"/> increments by one on each successful apply.</para>
/// <para><b>Server rule (v1):</b> after NS-19 validation, authoritative position is set to <see cref="Target"/> (snap). <see cref="PositionStateResponse.Sequence"/> increments by one on each successful apply. Oversized steps return HTTP 400 with <see cref="MoveCommandRejectedResponse"/>.</para>
/// <para>Example v1 payload:</para>
/// <code>
/// {"schemaVersion":1,"target":{"x":1.5,"y":0,"z":-2}}

View File

@ -0,0 +1,48 @@
namespace NeonSprawl.Server.Game.PositionState;
/// <summary>Pure movement validation for <see cref="MoveCommandRequest"/> targets (NS-19).</summary>
/// <remarks>
/// <para><b>Precedence</b> when multiple rules fail: <see cref="MoveCommandReasonCodes.HorizontalStepExceeded"/>,
/// then <see cref="MoveCommandReasonCodes.VerticalStepExceeded"/>, then <see cref="MoveCommandReasonCodes.OutOfBounds"/>.</para>
/// </remarks>
public static class MoveCommandValidation
{
/// <summary>Returns false and sets <paramref name="reasonCode"/> when the move must be rejected.</summary>
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;
}
}

View File

@ -0,0 +1,21 @@
namespace NeonSprawl.Server.Game.PositionState;
/// <summary>Per-command movement sanity limits (NS-19). Bound from <c>Game:MovementValidation</c>.</summary>
public sealed class MovementValidationOptions
{
/// <summary>Max horizontal displacement on X/Z in one move (meters). Inclusive at the limit.</summary>
public double MaxHorizontalStep { get; set; } = 18.0;
/// <summary>Max absolute delta on Y in one move (meters). Inclusive at the limit.</summary>
public double MaxVerticalStep { get; set; } = 1.25;
/// <summary>When true, target must lie inside the axis-aligned district box (inclusive min/max).</summary>
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;
}

View File

@ -1,6 +1,8 @@
using Microsoft.Extensions.Options;
namespace NeonSprawl.Server.Game.PositionState;
/// <summary>Maps HTTP routes for authoritative position read and move (NS-15 / NS-16).</summary>
/// <summary>Maps HTTP routes for authoritative position read and move (NS-15 / NS-16 / NS-19).</summary>
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<GamePositionOptions> 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();

View File

@ -12,6 +12,11 @@
"X": -5,
"Y": 0.9,
"Z": -5
},
"MovementValidation": {
"MaxHorizontalStep": 18,
"MaxVerticalStep": 1.25,
"DistrictBoundsEnabled": false
}
}
}

View File

@ -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)