459 lines
17 KiB
C#
459 lines
17 KiB
C#
using NeonSprawl.Server.Game.PositionState;
|
|
using NeonSprawl.Server.Game.Targeting;
|
|
|
|
namespace NeonSprawl.Server.Tests.Game.Targeting;
|
|
|
|
/// <summary>Integration tests for <see cref="TargetingApi"/> (NEO-23).</summary>
|
|
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<PlayerTargetStateResponse>();
|
|
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<TargetSelectResponse>();
|
|
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<TargetSelectResponse>();
|
|
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<TargetSelectResponse>();
|
|
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<TargetSelectResponse>();
|
|
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<TargetSelectResponse>();
|
|
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<TargetSelectResponse>();
|
|
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<PositionStateResponse>("/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<PositionStateResponse>("/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<TargetSelectResponse>();
|
|
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<PlayerTargetStateResponse>();
|
|
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<TargetSelectResponse>();
|
|
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<TargetSelectResponse>();
|
|
var b2 = await second.Content.ReadFromJsonAsync<TargetSelectResponse>();
|
|
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<TargetSelectResponse>();
|
|
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);
|
|
}
|
|
}
|