Update .gitignore to exclude TypeScript build info and add dist directory. Expand README with project overview, technology stack, prerequisites, and instructions for running and testing the application.
This commit is contained in:
@@ -0,0 +1,243 @@
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user