NS-19: server move validation, client rejection UX, scene QA props
parent
e4ebf97e28
commit
32d2153acb
|
|
@ -39,6 +39,39 @@ size = Vector3(0.22, 0.35, 0.22)
|
||||||
[sub_resource type="StandardMaterial3D" id="Mat_marker_base"]
|
[sub_resource type="StandardMaterial3D" id="Mat_marker_base"]
|
||||||
albedo_color = Color(0.15, 0.4, 1, 1)
|
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]
|
[node name="Main" type="Node3D" unique_id=1358372723]
|
||||||
script = ExtResource("1_main")
|
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")
|
mesh = SubResource("BoxMesh_marker")
|
||||||
surface_material_override/0 = SubResource("Mat_marker_base")
|
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]
|
[node name="Obstacle" type="StaticBody3D" parent="World" unique_id=1638845763]
|
||||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 6, 0, 5)
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 6, 0, 5)
|
||||||
collision_layer = 1
|
collision_layer = 1
|
||||||
|
|
@ -116,3 +195,18 @@ script = ExtResource("4_auth")
|
||||||
|
|
||||||
[node name="InteractionRequestClient" type="Node" parent="." unique_id=2500003]
|
[node name="InteractionRequestClient" type="Node" parent="." unique_id=2500003]
|
||||||
script = ExtResource("5_ix")
|
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
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ extends Node3D
|
||||||
@onready var _ground_pick: Node3D = $GroundPick
|
@onready var _ground_pick: Node3D = $GroundPick
|
||||||
@onready var _authority: Node = $PositionAuthorityClient
|
@onready var _authority: Node = $PositionAuthorityClient
|
||||||
@onready var _radius_preview: Node3D = $World/InteractionMarkers
|
@onready var _radius_preview: Node3D = $World/InteractionMarkers
|
||||||
|
@onready var _move_reject_label: Label = $UICanvas/MoveRejectLabel
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
|
|
@ -16,6 +17,7 @@ func _ready() -> void:
|
||||||
_authority.connect(
|
_authority.connect(
|
||||||
"authoritative_position_received", Callable(self, "_on_authoritative_position")
|
"authoritative_position_received", Callable(self, "_on_authoritative_position")
|
||||||
)
|
)
|
||||||
|
_authority.connect("move_rejected", Callable(self, "_on_move_rejected"))
|
||||||
_authority.call("sync_from_server")
|
_authority.call("sync_from_server")
|
||||||
if _radius_preview.has_method("setup_player"):
|
if _radius_preview.has_method("setup_player"):
|
||||||
_radius_preview.call("setup_player", _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:
|
func _on_authoritative_position(world: Vector3) -> void:
|
||||||
_player.snap_to_server(world)
|
_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
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
extends Node
|
extends Node
|
||||||
|
|
||||||
## NS-16: POST MoveCommand then GET PositionState; emits authoritative world position for the
|
## 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.)
|
## (No `class_name` so headless/CI can load the project before `.godot` import exists.)
|
||||||
|
|
||||||
signal authoritative_position_received(world: Vector3)
|
signal authoritative_position_received(world: Vector3)
|
||||||
|
signal move_rejected(reason_code: String)
|
||||||
|
|
||||||
enum Phase { BOOT_GET, POST_MOVE, VERIFY_GET }
|
enum Phase { BOOT_GET, POST_MOVE, VERIFY_GET }
|
||||||
|
|
||||||
|
|
@ -80,7 +81,14 @@ func _on_request_completed(
|
||||||
if response_code == 200:
|
if response_code == 200:
|
||||||
_emit_position_from_response(text)
|
_emit_position_from_response(text)
|
||||||
Phase.POST_MOVE:
|
Phase.POST_MOVE:
|
||||||
|
if response_code == 400:
|
||||||
|
_emit_move_rejection_if_present(text)
|
||||||
|
_busy = false
|
||||||
|
return
|
||||||
if response_code != 200:
|
if response_code != 200:
|
||||||
|
push_warning(
|
||||||
|
"PositionAuthorityClient: move POST unexpected code %s" % response_code
|
||||||
|
)
|
||||||
_busy = false
|
_busy = false
|
||||||
return
|
return
|
||||||
_phase = Phase.VERIFY_GET
|
_phase = Phase.VERIFY_GET
|
||||||
|
|
@ -91,6 +99,17 @@ func _on_request_completed(
|
||||||
_emit_position_from_response(text)
|
_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:
|
func _emit_position_from_response(json_text: String) -> void:
|
||||||
var parsed: Variant = JSON.parse_string(json_text)
|
var parsed: Variant = JSON.parse_string(json_text)
|
||||||
if parsed == null:
|
if parsed == null:
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ Contract readiness is tracked in the [module dependency register](module_depende
|
||||||
|
|
||||||
## Implementation snapshot
|
## 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.
|
- **Not yet:** Prediction/reconciliation, full Epic 1 Slice 1 movement loop and telemetry.
|
||||||
- **Alignment:** [Documentation and implementation alignment](documentation_and_implementation_alignment.md).
|
- **Alignment:** [Documentation and implementation alignment](documentation_and_implementation_alignment.md).
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -42,11 +42,11 @@ Invalid moves are **rejected**: return the error payload; **do not** change auth
|
||||||
|
|
||||||
## Acceptance criteria checklist
|
## Acceptance criteria checklist
|
||||||
|
|
||||||
- [ ] **Oversized** move (horizontal and/or vertical per configured rules) is **rejected**; client can observe failure (existing logging and/or minimal UI).
|
- [x] **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).
|
- [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).
|
||||||
- [ ] **Constants / thresholds** live in **one configuration surface** (options pattern bound to `Game:`), not scattered magic numbers.
|
- [x] **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**.
|
- [x] **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] **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
|
## Technical approach
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,15 @@
|
||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
using Microsoft.AspNetCore.TestHost;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using NeonSprawl.Server.Game.PositionState;
|
using NeonSprawl.Server.Game.PositionState;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace NeonSprawl.Server.Tests.Game.PositionState;
|
namespace NeonSprawl.Server.Tests.Game.PositionState;
|
||||||
|
|
||||||
/// <summary>
|
/// <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).
|
/// Isolated host per test (fresh in-memory store).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class MoveCommandApiTests
|
public class MoveCommandApiTests
|
||||||
|
|
@ -21,7 +23,7 @@ public class MoveCommandApiTests
|
||||||
var cmd = new MoveCommandRequest
|
var cmd = new MoveCommandRequest
|
||||||
{
|
{
|
||||||
SchemaVersion = MoveCommandRequest.CurrentSchemaVersion,
|
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
|
// Act
|
||||||
|
|
@ -40,7 +42,7 @@ public class MoveCommandApiTests
|
||||||
Assert.Equal("dev-local-1", postBody.PlayerId);
|
Assert.Equal("dev-local-1", postBody.PlayerId);
|
||||||
Assert.Equal(seq0 + 1, postBody.Sequence);
|
Assert.Equal(seq0 + 1, postBody.Sequence);
|
||||||
Assert.Equal(1.5, postBody.Position.X);
|
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.Equal(2.25, postBody.Position.Z);
|
||||||
|
|
||||||
Assert.NotNull(afterMove);
|
Assert.NotNull(afterMove);
|
||||||
|
|
@ -105,4 +107,69 @@ public class MoveCommandApiTests
|
||||||
// Assert
|
// Assert
|
||||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 _));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -22,7 +22,7 @@ public sealed class PostgresPositionStateIntegrationTests(PostgresIntegrationHar
|
||||||
var cmd = new MoveCommandRequest
|
var cmd = new MoveCommandRequest
|
||||||
{
|
{
|
||||||
SchemaVersion = MoveCommandRequest.CurrentSchemaVersion,
|
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
|
// Act
|
||||||
|
|
@ -39,7 +39,7 @@ public sealed class PostgresPositionStateIntegrationTests(PostgresIntegrationHar
|
||||||
Assert.Equal(PositionStateResponse.CurrentSchemaVersion, postBody!.SchemaVersion);
|
Assert.Equal(PositionStateResponse.CurrentSchemaVersion, postBody!.SchemaVersion);
|
||||||
Assert.Equal(before!.Sequence + 1, postBody.Sequence);
|
Assert.Equal(before!.Sequence + 1, postBody.Sequence);
|
||||||
Assert.Equal(2, postBody.Position.X);
|
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(3.5, postBody.Position.Z);
|
||||||
Assert.Equal(postBody.Sequence, after!.Sequence);
|
Assert.Equal(postBody.Sequence, after!.Sequence);
|
||||||
Assert.Equal(postBody.Position.X, after.Position.X);
|
Assert.Equal(postBody.Position.X, after.Position.X);
|
||||||
|
|
@ -55,7 +55,7 @@ public sealed class PostgresPositionStateIntegrationTests(PostgresIntegrationHar
|
||||||
var cmd = new MoveCommandRequest
|
var cmd = new MoveCommandRequest
|
||||||
{
|
{
|
||||||
SchemaVersion = MoveCommandRequest.CurrentSchemaVersion,
|
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;
|
HttpStatusCode postStatus = default;
|
||||||
|
|
@ -77,9 +77,9 @@ public sealed class PostgresPositionStateIntegrationTests(PostgresIntegrationHar
|
||||||
Assert.Equal(HttpStatusCode.OK, postStatus);
|
Assert.Equal(HttpStatusCode.OK, postStatus);
|
||||||
Assert.NotNull(persisted);
|
Assert.NotNull(persisted);
|
||||||
Assert.NotNull(after);
|
Assert.NotNull(after);
|
||||||
Assert.Equal(10, after!.Position.X);
|
Assert.Equal(7, after!.Position.X);
|
||||||
Assert.Equal(20, after.Position.Y);
|
Assert.Equal(0.8, after.Position.Y);
|
||||||
Assert.Equal(30, after.Position.Z);
|
Assert.Equal(7, after.Position.Z);
|
||||||
Assert.Equal(1ul, after.Sequence);
|
Assert.Equal(1ul, after.Sequence);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,9 @@ public sealed class GamePositionOptions
|
||||||
|
|
||||||
/// <summary>World position for the dev player at process start.</summary>
|
/// <summary>World position for the dev player at process start.</summary>
|
||||||
public DefaultPositionOptions DefaultPosition { get; set; } = new();
|
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>
|
/// <summary>Default spawn coordinates for the configured dev player.</summary>
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
}
|
||||||
|
|
@ -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; } = "";
|
||||||
|
}
|
||||||
|
|
@ -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.
|
/// HTTP JSON body for <c>POST /game/players/{{id}}/move</c>. Wire contract v1 (JSON); migrate to protobuf later per project contracts docs.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <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>
|
/// <para>Example v1 payload:</para>
|
||||||
/// <code>
|
/// <code>
|
||||||
/// {"schemaVersion":1,"target":{"x":1.5,"y":0,"z":-2}}
|
/// {"schemaVersion":1,"target":{"x":1.5,"y":0,"z":-2}}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
namespace NeonSprawl.Server.Game.PositionState;
|
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 class PositionStateApi
|
||||||
{
|
{
|
||||||
public static WebApplication MapPositionStateApi(this WebApplication app)
|
public static WebApplication MapPositionStateApi(this WebApplication app)
|
||||||
|
|
@ -26,7 +28,7 @@ public static class PositionStateApi
|
||||||
|
|
||||||
app.MapPost(
|
app.MapPost(
|
||||||
"/game/players/{id}/move",
|
"/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)
|
if (body is null || body.SchemaVersion != MoveCommandRequest.CurrentSchemaVersion)
|
||||||
{
|
{
|
||||||
|
|
@ -39,6 +41,22 @@ public static class PositionStateApi
|
||||||
return Results.BadRequest();
|
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))
|
if (!store.TryApplyMoveTarget(id, target.X, target.Y, target.Z, out var snap))
|
||||||
{
|
{
|
||||||
return Results.NotFound();
|
return Results.NotFound();
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,11 @@
|
||||||
"X": -5,
|
"X": -5,
|
||||||
"Y": 0.9,
|
"Y": 0.9,
|
||||||
"Z": -5
|
"Z": -5
|
||||||
|
},
|
||||||
|
"MovementValidation": {
|
||||||
|
"MaxHorizontalStep": 18,
|
||||||
|
"MaxVerticalStep": 1.25,
|
||||||
|
"DistrictBoundsEnabled": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.
|
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.
|
**`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):
|
Request body (example):
|
||||||
|
|
||||||
```bash
|
```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`).
|
- **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.
|
- **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)
|
## Interaction (NS-18)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue