using System.Net.WebSockets;
using System.Text.Json;
using HSchool.Protocol;
namespace HSchool.AppHost.Tests;
///
/// 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.
///
[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(
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 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 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 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 ReceiveWelcomeAsync(WebSocket socket) =>
ProtocolCodec.ReadWelcome(await ReceiveUntilAsync(socket, MessageType.ServerWelcome));
private static async Task 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];
}
///
/// 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.
///
private static async Task 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.");
}
/// Reads frames until one of shows up.
private static async Task 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 GetStatusAsync(HttpClient client)
{
var json = await client.GetStringAsync("/api/status", TestContext.Current.CancellationToken);
return JsonSerializer.Deserialize(json, JsonSerializerOptions.Web)!;
}
private static async Task WaitUntilAsync(Func> 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);
}