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); }