using System.Net;
using System.Net.Http.Json;
using System.Text;
using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Npc;
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 = PrototypeNpcRegistry.PrototypeNpcMeleeId,
});
// 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(PrototypeNpcRegistry.PrototypeNpcMeleeId, 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_ShouldDenyLegacyAlphaDummyId_WithUnknownTarget()
{
// 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.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 = PrototypeNpcRegistry.PrototypeNpcRangedId,
});
// 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 = PrototypeNpcRegistry.PrototypeNpcRangedId,
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(PrototypeNpcRegistry.PrototypeNpcRangedId, 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 = PrototypeNpcRegistry.PrototypeNpcRangedId,
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 = PrototypeNpcRegistry.PrototypeNpcRangedId,
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 = PrototypeNpcRegistry.PrototypeNpcMeleeId,
});
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 = PrototypeNpcRegistry.PrototypeNpcMeleeId,
});
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(PrototypeNpcRegistry.PrototypeNpcMeleeId, 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 = PrototypeNpcRegistry.PrototypeNpcMeleeId,
});
// 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 = PrototypeNpcRegistry.PrototypeNpcMeleeId,
});
// 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_NPC_MELEE ",
});
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync();
Assert.NotNull(body);
Assert.True(body!.SelectionApplied);
Assert.Equal(PrototypeNpcRegistry.PrototypeNpcMeleeId, 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 = PrototypeNpcRegistry.PrototypeNpcMeleeId,
};
// 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 = PrototypeNpcRegistry.PrototypeNpcMeleeId,
});
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 = PrototypeNpcRegistry.PrototypeNpcMeleeId,
});
// 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(PrototypeNpcRegistry.PrototypeNpcMeleeId, body.TargetState.LockedTargetId);
Assert.Equal(TargetValidity.OutOfRange, body.TargetState.Validity);
}
}