using Microsoft.Extensions.Options; namespace NeonSprawl.Server.Game.PositionState; /// 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) { app.MapGet( "/game/players/{id}/position", (string id, IPositionStateStore store) => { if (!store.TryGetPosition(id, out var snap)) { return Results.NotFound(); } var body = new PositionStateResponse { SchemaVersion = PositionStateResponse.CurrentSchemaVersion, PlayerId = id, Position = new PositionVector { X = snap.X, Y = snap.Y, Z = snap.Z }, Sequence = snap.Sequence, }; return Results.Json(body); }); app.MapPost( "/game/players/{id}/move", (string id, MoveCommandRequest? body, IPositionStateStore store, IOptions gameOptions) => { if (body is null || body.SchemaVersion != MoveCommandRequest.CurrentSchemaVersion) { return Results.BadRequest(); } var target = body.Target; if (target is null) { 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(); } var response = new PositionStateResponse { SchemaVersion = PositionStateResponse.CurrentSchemaVersion, PlayerId = id, Position = new PositionVector { X = snap.X, Y = snap.Y, Z = snap.Z }, Sequence = snap.Sequence, }; return Results.Json(response); }); app.MapPost( "/game/players/{id}/move-stream", (string id, MoveStreamRequest? body, IPositionStateStore store, IOptions gameOptions) => { if (body is null || body.SchemaVersion != MoveStreamRequest.CurrentSchemaVersion) { return Results.BadRequest(); } var targets = body.Targets; if (targets is null || targets.Count == 0 || targets.Count > MoveStreamRequest.MaxTargets) { return Results.BadRequest(); } if (!store.TryGetPosition(id, out var current)) { return Results.NotFound(); } var rules = gameOptions.Value.MovementValidation; // Validate the full chain before applying so a mid-chain reject does not leave a partial apply. var probe = current; foreach (var target in targets) { if (!MoveCommandValidation.TryValidate(probe, target, rules, out var reasonCode)) { var rejected = new MoveCommandRejectedResponse { SchemaVersion = MoveCommandRejectedResponse.CurrentSchemaVersion, ReasonCode = reasonCode, }; return Results.Json(rejected, statusCode: StatusCodes.Status400BadRequest); } probe = new PositionSnapshot(target.X, target.Y, target.Z, probe.Sequence); } PositionSnapshot lastSnap = current; foreach (var target in targets) { if (!store.TryApplyMoveTarget(id, target.X, target.Y, target.Z, out var snap)) { return Results.NotFound(); } lastSnap = snap; } var response = new PositionStateResponse { SchemaVersion = PositionStateResponse.CurrentSchemaVersion, PlayerId = id, Position = new PositionVector { X = lastSnap.X, Y = lastSnap.Y, Z = lastSnap.Z }, Sequence = lastSnap.Sequence, }; return Results.Json(response); }); return app; } }