using System.Net; using System.Net.Http.Json; using System.Text; using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Game.Targeting; using Xunit; namespace NeonSprawl.Server.Tests.Game.Targeting; /// Integration tests for (NEO-23). public class TargetingApiTests { [Fact] public async Task GetTarget_ShouldReturnNone_WhenNoLock() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var client = factory.CreateClient(); // Act var response = await client.GetAsync("/game/players/dev-local-1/target"); // Assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); var body = await response.Content.ReadFromJsonAsync(); Assert.NotNull(body); Assert.Equal(PlayerTargetStateResponse.CurrentSchemaVersion, body!.SchemaVersion); Assert.Equal("dev-local-1", body.PlayerId); Assert.Null(body.LockedTargetId); Assert.Equal(TargetValidity.None, body.Validity); Assert.Equal(0, body.Sequence); } [Fact] public async Task PostSelect_ShouldApplyLock_WhenInRange() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var client = factory.CreateClient(); // Act var response = await client.PostAsJsonAsync( "/game/players/dev-local-1/target/select", new TargetSelectRequest { SchemaVersion = TargetSelectRequest.CurrentSchemaVersion, TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId, }); // Assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); var body = await response.Content.ReadFromJsonAsync(); Assert.NotNull(body); Assert.True(body!.SelectionApplied); Assert.Null(body.ReasonCode); Assert.Equal(PrototypeTargetRegistry.PrototypeTargetAlphaId, body.TargetState.LockedTargetId); Assert.Equal(TargetValidity.Ok, body.TargetState.Validity); Assert.Equal(1, body.TargetState.Sequence); } [Fact] public async Task PostSelect_ShouldDenyUnknownTarget_WithReasonCode() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var client = factory.CreateClient(); // Act var response = await client.PostAsJsonAsync( "/game/players/dev-local-1/target/select", new TargetSelectRequest { SchemaVersion = TargetSelectRequest.CurrentSchemaVersion, TargetId = "not_a_real_target", }); // Assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); var body = await response.Content.ReadFromJsonAsync(); Assert.NotNull(body); Assert.False(body!.SelectionApplied); Assert.Equal(TargetingApi.ReasonUnknownTarget, body.ReasonCode); Assert.Null(body.TargetState.LockedTargetId); Assert.Equal(TargetValidity.None, body.TargetState.Validity); } [Fact] public async Task PostSelect_ShouldDenyOutOfRange_WhenBetaFarFromSpawn() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var client = factory.CreateClient(); // Act var response = await client.PostAsJsonAsync( "/game/players/dev-local-1/target/select", new TargetSelectRequest { SchemaVersion = TargetSelectRequest.CurrentSchemaVersion, TargetId = PrototypeTargetRegistry.PrototypeTargetBetaId, }); // Assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); var body = await response.Content.ReadFromJsonAsync(); Assert.NotNull(body); Assert.False(body!.SelectionApplied); Assert.Equal(TargetingApi.ReasonOutOfRange, body.ReasonCode); Assert.Null(body.TargetState.LockedTargetId); } [Fact] public async Task PostSelect_ShouldUsePositionHint_WhenStoredSnapIsStale() { // NEO-24 follow-up #5: without the hint, spawn's stored position (-5, -5) denies beta (r=6, // anchor (3, 3)) — ~11.3 m out. With an advisory `positionHint` pinning the capsule inside // beta's ring, the server must accept. This is exactly the race-free path the client uses. // Arrange await using var factory = new InMemoryWebApplicationFactory(); var client = factory.CreateClient(); // Act var response = await client.PostAsJsonAsync( "/game/players/dev-local-1/target/select", new TargetSelectRequest { SchemaVersion = TargetSelectRequest.CurrentSchemaVersion, TargetId = PrototypeTargetRegistry.PrototypeTargetBetaId, PositionHint = new PositionVector { X = 3.0, Y = 0.5, Z = 5.0 }, }); // Assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); var body = await response.Content.ReadFromJsonAsync(); Assert.NotNull(body); Assert.True(body!.SelectionApplied); Assert.Null(body.ReasonCode); Assert.Equal(PrototypeTargetRegistry.PrototypeTargetBetaId, body.TargetState.LockedTargetId); } [Fact] public async Task PostSelect_ShouldDenyOutOfRange_WhenHintIsFar() { // Hint that places the capsule outside beta's ring must deny — hint is advisory only; // it does not bypass the radius check. // Arrange await using var factory = new InMemoryWebApplicationFactory(); var client = factory.CreateClient(); // Act var response = await client.PostAsJsonAsync( "/game/players/dev-local-1/target/select", new TargetSelectRequest { SchemaVersion = TargetSelectRequest.CurrentSchemaVersion, TargetId = PrototypeTargetRegistry.PrototypeTargetBetaId, PositionHint = new PositionVector { X = 50.0, Y = 0.0, Z = 50.0 }, }); // Assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); var body = await response.Content.ReadFromJsonAsync(); Assert.NotNull(body); Assert.False(body!.SelectionApplied); Assert.Equal(TargetingApi.ReasonOutOfRange, body.ReasonCode); } [Fact] public async Task PostSelect_PositionHint_ShouldNotWriteToPositionStore() { // Hint must be purely advisory for the range check. Confirm the stored `PositionState` // is untouched by a select-with-hint by reading it back through `GET /position`. // Arrange await using var factory = new InMemoryWebApplicationFactory(); var client = factory.CreateClient(); var before = await client.GetFromJsonAsync("/game/players/dev-local-1/position"); // Act var selectResponse = await client.PostAsJsonAsync( "/game/players/dev-local-1/target/select", new TargetSelectRequest { SchemaVersion = TargetSelectRequest.CurrentSchemaVersion, TargetId = PrototypeTargetRegistry.PrototypeTargetBetaId, PositionHint = new PositionVector { X = 3.0, Y = 0.5, Z = 5.0 }, }); // Assert Assert.NotNull(before); Assert.Equal(HttpStatusCode.OK, selectResponse.StatusCode); var after = await client.GetFromJsonAsync("/game/players/dev-local-1/position"); Assert.NotNull(after); Assert.Equal(before!.Position.X, after!.Position.X); Assert.Equal(before.Position.Y, after.Position.Y); Assert.Equal(before.Position.Z, after.Position.Z); Assert.Equal(before.Sequence, after.Sequence); } [Fact] public async Task PostSelect_ShouldClearLock_WhenTargetIdOmitted() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var client = factory.CreateClient(); // Act var setResponse = await client.PostAsJsonAsync( "/game/players/dev-local-1/target/select", new TargetSelectRequest { SchemaVersion = TargetSelectRequest.CurrentSchemaVersion, TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId, }); var clear = await client.PostAsync( "/game/players/dev-local-1/target/select", new StringContent( $"{{\"schemaVersion\":{TargetSelectRequest.CurrentSchemaVersion}}}", Encoding.UTF8, "application/json")); // Assert var body = await clear.Content.ReadFromJsonAsync(); Assert.Equal(HttpStatusCode.OK, setResponse.StatusCode); Assert.Equal(HttpStatusCode.OK, clear.StatusCode); Assert.NotNull(body); Assert.True(body!.SelectionApplied); Assert.Null(body.TargetState.LockedTargetId); Assert.Equal(TargetValidity.None, body.TargetState.Validity); } [Fact] public async Task GetTarget_ShouldShowOutOfRange_AfterMoveWithoutSelect() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var client = factory.CreateClient(); // Act var selectResponse = await client.PostAsJsonAsync( "/game/players/dev-local-1/target/select", new TargetSelectRequest { SchemaVersion = TargetSelectRequest.CurrentSchemaVersion, TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId, }); var move = new MoveCommandRequest { SchemaVersion = MoveCommandRequest.CurrentSchemaVersion, Target = new PositionVector { X = 50, Y = 0, Z = 50 }, }; var moveResponse = await client.PostAsJsonAsync("/game/players/dev-local-1/move", move); var get = await client.GetAsync("/game/players/dev-local-1/target"); // Assert var body = await get.Content.ReadFromJsonAsync(); Assert.Equal(HttpStatusCode.OK, selectResponse.StatusCode); Assert.Equal(HttpStatusCode.OK, moveResponse.StatusCode); Assert.Equal(HttpStatusCode.OK, get.StatusCode); Assert.NotNull(body); Assert.Equal(PrototypeTargetRegistry.PrototypeTargetAlphaId, body!.LockedTargetId); Assert.Equal(TargetValidity.OutOfRange, body.Validity); } [Fact] public async Task GetTarget_ShouldReturnNotFound_WhenPlayerUnknown() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var client = factory.CreateClient(); // Act var response = await client.GetAsync("/game/players/missing-player/target"); // Assert Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); } [Fact] public async Task PostSelect_ShouldReturnNotFound_WhenPlayerUnknown() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var client = factory.CreateClient(); // Act var response = await client.PostAsJsonAsync( "/game/players/missing-player/target/select", new TargetSelectRequest { SchemaVersion = TargetSelectRequest.CurrentSchemaVersion, TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId, }); // Assert Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); } [Fact] public async Task PostSelect_ShouldReturnBadRequest_WhenSchemaVersionWrong() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var client = factory.CreateClient(); // Act var response = await client.PostAsJsonAsync( "/game/players/dev-local-1/target/select", new TargetSelectRequest { SchemaVersion = 999, TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId, }); // Assert Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); } [Fact] public async Task PostSelect_ShouldReturnBadRequest_WhenTargetIdWhitespaceOnly() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var client = factory.CreateClient(); // Act var response = await client.PostAsJsonAsync( "/game/players/dev-local-1/target/select", new TargetSelectRequest { SchemaVersion = TargetSelectRequest.CurrentSchemaVersion, TargetId = " \t ", }); // Assert Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); } [Fact] public async Task PostSelect_ShouldBeCaseInsensitive_ForTargetId() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var client = factory.CreateClient(); // Act var response = await client.PostAsJsonAsync( "/game/players/dev-local-1/target/select", new TargetSelectRequest { SchemaVersion = TargetSelectRequest.CurrentSchemaVersion, TargetId = " PROTOTYPE_TARGET_ALPHA ", }); // Assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); var body = await response.Content.ReadFromJsonAsync(); Assert.NotNull(body); Assert.True(body!.SelectionApplied); Assert.Equal(PrototypeTargetRegistry.PrototypeTargetAlphaId, body.TargetState.LockedTargetId); } [Fact] public async Task PostSelect_ShouldStayIdempotent_WhenSameTargetReselect() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var client = factory.CreateClient(); var req = new TargetSelectRequest { SchemaVersion = TargetSelectRequest.CurrentSchemaVersion, TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId, }; // Act var first = await client.PostAsJsonAsync("/game/players/dev-local-1/target/select", req); var second = await client.PostAsJsonAsync("/game/players/dev-local-1/target/select", req); // Assert Assert.Equal(HttpStatusCode.OK, first.StatusCode); Assert.Equal(HttpStatusCode.OK, second.StatusCode); var b1 = await first.Content.ReadFromJsonAsync(); var b2 = await second.Content.ReadFromJsonAsync(); Assert.Equal(b1!.TargetState.Sequence, b2!.TargetState.Sequence); } [Fact] public async Task PostSelect_ShouldDenyOutOfRange_WhenReselectingSameLockWhileOutOfRange() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var client = factory.CreateClient(); await client.PostAsJsonAsync( "/game/players/dev-local-1/target/select", new TargetSelectRequest { SchemaVersion = TargetSelectRequest.CurrentSchemaVersion, TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId, }); var move = new MoveCommandRequest { SchemaVersion = MoveCommandRequest.CurrentSchemaVersion, Target = new PositionVector { X = 50, Y = 0, Z = 50 }, }; await client.PostAsJsonAsync("/game/players/dev-local-1/move", move); // Act var response = await client.PostAsJsonAsync( "/game/players/dev-local-1/target/select", new TargetSelectRequest { SchemaVersion = TargetSelectRequest.CurrentSchemaVersion, TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId, }); // Assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); var body = await response.Content.ReadFromJsonAsync(); Assert.NotNull(body); Assert.False(body!.SelectionApplied); Assert.Equal(TargetingApi.ReasonOutOfRange, body.ReasonCode); Assert.Equal(PrototypeTargetRegistry.PrototypeTargetAlphaId, body.TargetState.LockedTargetId); Assert.Equal(TargetValidity.OutOfRange, body.TargetState.Validity); } }