244 lines
8.7 KiB
C#
244 lines
8.7 KiB
C#
using System.Net.WebSockets;
|
|
using System.Text.Json;
|
|
using HSchool.Protocol;
|
|
|
|
namespace HSchool.AppHost.Tests;
|
|
|
|
/// <summary>
|
|
/// Talks to the running server exactly the way the browser client does: binary frames over
|
|
/// a WebSocket, plus the HTTP endpoints the dashboard and probes use.
|
|
/// </summary>
|
|
[Collection(AppHostCollection.Name)]
|
|
public class GameServerIntegrationTests(AppHostFixture fixture)
|
|
{
|
|
private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(30);
|
|
|
|
private DistributedApplication App => fixture.App;
|
|
|
|
[Fact]
|
|
public async Task HealthEndpoint_ReportsHealthy()
|
|
{
|
|
using var client = App.CreateHttpClient("server");
|
|
|
|
using var response = await client.GetAsync("/health", TestContext.Current.CancellationToken);
|
|
|
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task StatusEndpoint_ReportsARunningLoop()
|
|
{
|
|
using var client = App.CreateHttpClient("server");
|
|
|
|
using var response = await client.GetAsync("/api/status", TestContext.Current.CancellationToken);
|
|
response.EnsureSuccessStatusCode();
|
|
|
|
var status = JsonSerializer.Deserialize<StatusResponse>(
|
|
await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken),
|
|
JsonSerializerOptions.Web);
|
|
|
|
Assert.NotNull(status);
|
|
Assert.Equal(20, status.TickRate);
|
|
Assert.True(status.WorldWidth > 0);
|
|
|
|
// The loop runs on its own thread; give it a moment to produce a tick.
|
|
await WaitUntilAsync(
|
|
async () => (await GetStatusAsync(client)).Tick > 0,
|
|
TimeSpan.FromSeconds(5));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Handshake_AnswersWithAWelcomeFrame()
|
|
{
|
|
using var socket = await ConnectAsync();
|
|
|
|
var welcome = await ReceiveWelcomeAsync(socket);
|
|
|
|
Assert.Equal(ProtocolConstants.Version, welcome.ProtocolVersion);
|
|
Assert.Equal(20, welcome.TickRate);
|
|
Assert.True(welcome.PlayerEntityId > 0);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Snapshots_ArriveAndIncludeTheJoinedPlayer()
|
|
{
|
|
using var socket = await ConnectAsync();
|
|
var welcome = await ReceiveWelcomeAsync(socket);
|
|
|
|
var entities = await ReceiveSnapshotWithAsync(socket, welcome.PlayerEntityId);
|
|
|
|
Assert.Contains(entities, entity => entity.Kind == EntityKind.Obstacle);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Input_MovesThePlayerOnTheServer()
|
|
{
|
|
using var socket = await ConnectAsync();
|
|
var welcome = await ReceiveWelcomeAsync(socket);
|
|
|
|
var first = await ReceiveSnapshotWithAsync(socket, welcome.PlayerEntityId);
|
|
var startX = first.Single(entity => entity.Id == welcome.PlayerEntityId).X;
|
|
|
|
// Hold "right" for a few ticks, draining snapshots so the socket never backs up.
|
|
var sequence = 0u;
|
|
var lastX = startX;
|
|
for (var i = 0; i < 20; i++)
|
|
{
|
|
await SendAsync(socket, buffer =>
|
|
ProtocolCodec.WriteInput(buffer, new ClientInputMessage(++sequence, InputButtons.Right)));
|
|
|
|
var entities = await ReceiveSnapshotWithAsync(socket, welcome.PlayerEntityId);
|
|
lastX = entities.Single(entity => entity.Id == welcome.PlayerEntityId).X;
|
|
}
|
|
|
|
Assert.True(lastX > startX, $"Player did not move right: {startX} -> {lastX}.");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Ping_IsAnsweredWithTheSameTimestamp()
|
|
{
|
|
using var socket = await ConnectAsync();
|
|
await ReceiveWelcomeAsync(socket);
|
|
|
|
const long ClientTime = 1_700_000_000_123;
|
|
await SendAsync(socket, buffer =>
|
|
ProtocolCodec.WritePing(buffer, new ClientPingMessage(ClientTime)));
|
|
|
|
var pong = await ReceiveUntilAsync(socket, MessageType.ServerPong);
|
|
|
|
Assert.Equal(ClientTime, ProtocolCodec.ReadPong(pong).ClientTimeMs);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task VersionMismatch_IsRejected()
|
|
{
|
|
using var socket = await ConnectRawAsync();
|
|
|
|
await SendAsync(socket, buffer => ProtocolCodec.WriteHello(
|
|
buffer,
|
|
new ClientHelloMessage((byte)(ProtocolConstants.Version + 1), "stale-client")));
|
|
|
|
var buffer = new byte[ProtocolConstants.MaxMessageSize];
|
|
var result = await socket.ReceiveAsync(buffer, TestContext.Current.CancellationToken);
|
|
|
|
Assert.Equal(WebSocketMessageType.Close, result.MessageType);
|
|
Assert.Equal(WebSocketCloseStatus.ProtocolError, socket.CloseStatus);
|
|
}
|
|
|
|
private async Task<ClientWebSocket> ConnectAsync(string playerName = "integration-test")
|
|
{
|
|
var socket = await ConnectRawAsync();
|
|
await SendAsync(socket, buffer =>
|
|
ProtocolCodec.WriteHello(buffer, new ClientHelloMessage(ProtocolConstants.Version, playerName)));
|
|
return socket;
|
|
}
|
|
|
|
private async Task<ClientWebSocket> ConnectRawAsync()
|
|
{
|
|
var http = App.GetEndpoint("server", "http");
|
|
var uri = new UriBuilder(http) { Scheme = "ws", Path = "/ws/game" }.Uri;
|
|
|
|
var socket = new ClientWebSocket();
|
|
await socket.ConnectAsync(uri, TestContext.Current.CancellationToken).WaitAsync(DefaultTimeout);
|
|
return socket;
|
|
}
|
|
|
|
private static async Task SendAsync(WebSocket socket, Func<byte[], int> write)
|
|
{
|
|
var buffer = new byte[64];
|
|
var length = write(buffer);
|
|
|
|
await socket.SendAsync(
|
|
buffer.AsMemory(0, length),
|
|
WebSocketMessageType.Binary,
|
|
endOfMessage: true,
|
|
TestContext.Current.CancellationToken);
|
|
}
|
|
|
|
private static async Task<ServerWelcomeMessage> ReceiveWelcomeAsync(WebSocket socket) =>
|
|
ProtocolCodec.ReadWelcome(await ReceiveUntilAsync(socket, MessageType.ServerWelcome));
|
|
|
|
private static async Task<EntitySnapshot[]> ReceiveSnapshotAsync(WebSocket socket)
|
|
{
|
|
var frame = await ReceiveUntilAsync(socket, MessageType.ServerSnapshot);
|
|
|
|
var entities = new EntitySnapshot[ushort.MaxValue];
|
|
var count = ProtocolCodec.ReadSnapshot(frame, entities, out _);
|
|
|
|
return entities[..count];
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reads snapshots until the given entity shows up. The very first snapshot after a join can
|
|
/// still describe the tick before the spawn was applied.
|
|
/// </summary>
|
|
private static async Task<EntitySnapshot[]> ReceiveSnapshotWithAsync(WebSocket socket, uint entityId)
|
|
{
|
|
for (var attempt = 0; attempt < 10; attempt++)
|
|
{
|
|
var entities = await ReceiveSnapshotAsync(socket);
|
|
if (Array.Exists(entities, entity => entity.Id == entityId))
|
|
{
|
|
return entities;
|
|
}
|
|
}
|
|
|
|
throw new InvalidOperationException($"Entity {entityId} never appeared in a snapshot.");
|
|
}
|
|
|
|
/// <summary>Reads frames until one of <paramref name="expected"/> shows up.</summary>
|
|
private static async Task<byte[]> ReceiveUntilAsync(WebSocket socket, MessageType expected)
|
|
{
|
|
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
|
|
timeout.CancelAfter(DefaultTimeout);
|
|
|
|
var buffer = new byte[ProtocolConstants.MaxMessageSize];
|
|
|
|
while (true)
|
|
{
|
|
var result = await socket.ReceiveAsync(buffer, timeout.Token);
|
|
if (result.MessageType == WebSocketMessageType.Close)
|
|
{
|
|
throw new InvalidOperationException($"Socket closed while waiting for {expected}: {socket.CloseStatus}.");
|
|
}
|
|
|
|
var frame = buffer[..result.Count];
|
|
if (ProtocolCodec.PeekMessageType(frame) == expected)
|
|
{
|
|
return frame;
|
|
}
|
|
}
|
|
}
|
|
|
|
private static async Task<StatusResponse> GetStatusAsync(HttpClient client)
|
|
{
|
|
var json = await client.GetStringAsync("/api/status", TestContext.Current.CancellationToken);
|
|
return JsonSerializer.Deserialize<StatusResponse>(json, JsonSerializerOptions.Web)!;
|
|
}
|
|
|
|
private static async Task WaitUntilAsync(Func<Task<bool>> condition, TimeSpan timeout)
|
|
{
|
|
var deadline = DateTime.UtcNow + timeout;
|
|
|
|
while (DateTime.UtcNow < deadline)
|
|
{
|
|
if (await condition())
|
|
{
|
|
return;
|
|
}
|
|
|
|
await Task.Delay(100, TestContext.Current.CancellationToken);
|
|
}
|
|
|
|
Assert.Fail($"Condition was not met within {timeout}.");
|
|
}
|
|
|
|
private sealed record StatusResponse(
|
|
uint Tick,
|
|
int TickRate,
|
|
int Players,
|
|
int Connections,
|
|
float WorldWidth,
|
|
float WorldHeight);
|
|
}
|