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.
ci / server (push) Failing after 4m10s
ci / client (push) Successful in 17s

This commit is contained in:
Leonid Pershin
2026-08-18 11:11:48 +03:00
parent 84aafb0b69
commit e6739e7912
84 changed files with 5698 additions and 2 deletions
@@ -0,0 +1,51 @@
using Microsoft.Extensions.Logging;
namespace HSchool.AppHost.Tests;
/// <summary>
/// Boots the AppHost once for the whole suite — starting it per test costs about ten
/// seconds each. The client resource is skipped (<c>HSchool:Headless</c>), so the tests
/// need no Node install.
/// </summary>
public sealed class AppHostFixture : IAsyncLifetime
{
private static readonly TimeSpan StartupTimeout = TimeSpan.FromSeconds(120);
private DistributedApplication? _app;
public DistributedApplication App =>
_app ?? throw new InvalidOperationException("The AppHost has not been started.");
public async ValueTask InitializeAsync()
{
var appHost = await DistributedApplicationTestingBuilder
.CreateAsync<Projects.HSchool_AppHost>(["--HSchool:Headless=true"], CancellationToken.None);
appHost.Services.AddLogging(logging =>
{
logging.SetMinimumLevel(LogLevel.Warning);
logging.AddFilter("Aspire.", LogLevel.Warning);
});
_app = await appHost.BuildAsync().WaitAsync(StartupTimeout);
await _app.StartAsync().WaitAsync(StartupTimeout);
await _app.ResourceNotifications
.WaitForResourceHealthyAsync("server", CancellationToken.None)
.WaitAsync(StartupTimeout);
}
public async ValueTask DisposeAsync()
{
if (_app is not null)
{
await _app.DisposeAsync();
}
}
}
/// <summary>Groups every integration test around the single AppHost instance.</summary>
[CollectionDefinition(Name)]
public sealed class AppHostCollection : ICollectionFixture<AppHostFixture>
{
public const string Name = "apphost";
}
@@ -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);
}
@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RootNamespace>HSchool.AppHost.Tests</RootNamespace>
<IsTestProject>true</IsTestProject>
<OutputType>Exe</OutputType>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Aspire.Hosting.Testing" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit.v3" />
<PackageReference Include="xunit.runner.visualstudio" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\HSchool.AppHost\HSchool.AppHost.csproj" />
<ProjectReference Include="..\..\src\HSchool.Protocol\HSchool.Protocol.csproj" />
</ItemGroup>
<ItemGroup>
<Using Include="System.Net" />
<Using Include="Aspire.Hosting" />
<Using Include="Aspire.Hosting.Testing" />
<Using Include="Microsoft.Extensions.DependencyInjection" />
<Using Include="Xunit" />
</ItemGroup>
</Project>
@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RootNamespace>HSchool.Protocol.Tests</RootNamespace>
<IsTestProject>true</IsTestProject>
<OutputType>Exe</OutputType>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit.v3" />
<PackageReference Include="xunit.runner.visualstudio" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\HSchool.Protocol\HSchool.Protocol.csproj" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
</Project>
@@ -0,0 +1,157 @@
namespace HSchool.Protocol.Tests;
/// <summary>
/// The wire format is a contract with the browser client. Round-trips prove the C# side is
/// self-consistent; the explicit byte-layout tests are what keeps
/// <c>src/HSchool.Client/src/net/protocol.ts</c> honest.
/// </summary>
public class ProtocolCodecTests
{
[Fact]
public void Hello_RoundTrips()
{
var message = new ClientHelloMessage(ProtocolConstants.Version, "ada");
Span<byte> buffer = stackalloc byte[64];
var length = ProtocolCodec.WriteHello(buffer, message);
Assert.Equal(message, ProtocolCodec.ReadHello(buffer[..length]));
}
[Fact]
public void Input_RoundTrips()
{
var message = new ClientInputMessage(0x01020304, InputButtons.Up | InputButtons.Right);
Span<byte> buffer = stackalloc byte[16];
var length = ProtocolCodec.WriteInput(buffer, message);
Assert.Equal(6, length);
Assert.Equal(message, ProtocolCodec.ReadInput(buffer[..length]));
}
[Fact]
public void Ping_RoundTrips()
{
var message = new ClientPingMessage(1_700_000_000_123);
Span<byte> buffer = stackalloc byte[16];
var length = ProtocolCodec.WritePing(buffer, message);
Assert.Equal(9, length);
Assert.Equal(message, ProtocolCodec.ReadPing(buffer[..length]));
}
[Fact]
public void Welcome_RoundTripsAndIsFifteenBytes()
{
var message = new ServerWelcomeMessage(ProtocolConstants.Version, 42, 20, 1600f, 900f);
Span<byte> buffer = stackalloc byte[32];
var length = ProtocolCodec.WriteWelcome(buffer, message);
Assert.Equal(15, length);
Assert.Equal(message, ProtocolCodec.ReadWelcome(buffer[..length]));
}
[Fact]
public void Pong_RoundTrips()
{
var message = new ServerPongMessage(5, 99);
Span<byte> buffer = stackalloc byte[32];
var length = ProtocolCodec.WritePong(buffer, message);
Assert.Equal(13, length);
Assert.Equal(message, ProtocolCodec.ReadPong(buffer[..length]));
}
[Fact]
public void Snapshot_RoundTripsEveryEntityField()
{
ReadOnlySpan<EntitySnapshot> entities =
[
new EntitySnapshot(7, EntityKind.Player, 100f, 200f, 18f, 0x4CC9F0),
new EntitySnapshot(8, EntityKind.Obstacle, 800f, 450f, 70f, 0x3A4553),
];
var buffer = new byte[ProtocolCodec.SnapshotSize(entities.Length)];
var length = ProtocolCodec.WriteSnapshot(buffer, 1234, entities);
Assert.Equal(buffer.Length, length);
var decoded = new EntitySnapshot[entities.Length];
var count = ProtocolCodec.ReadSnapshot(buffer, decoded, out var tick);
Assert.Equal(entities.Length, count);
Assert.Equal(1234u, tick);
Assert.Equal(entities[0], decoded[0]);
Assert.Equal(entities[1], decoded[1]);
}
[Fact]
public void SnapshotSize_MatchesTheLayoutTheClientAssumes()
{
// 1 type + 4 tick + 2 count, then 21 bytes per entity.
Assert.Equal(7, ProtocolCodec.SnapshotSize(0));
Assert.Equal(7 + 21, ProtocolCodec.SnapshotSize(1));
Assert.Equal(21, ProtocolConstants.EntitySnapshotSize);
}
[Fact]
public void Numbers_AreLittleEndian()
{
Span<byte> buffer = stackalloc byte[16];
var length = ProtocolCodec.WriteInput(buffer, new ClientInputMessage(0x01020304, InputButtons.None));
Assert.Equal((byte)MessageType.ClientInput, buffer[0]);
Assert.Equal(new byte[] { 0x04, 0x03, 0x02, 0x01 }, buffer[1..5].ToArray());
Assert.Equal(6, length);
}
[Fact]
public void PeekMessageType_ReadsTheFirstByte()
{
Span<byte> buffer = stackalloc byte[16];
ProtocolCodec.WritePing(buffer, new ClientPingMessage(1));
Assert.Equal(MessageType.ClientPing, ProtocolCodec.PeekMessageType(buffer));
Assert.Equal(MessageType.None, ProtocolCodec.PeekMessageType([]));
}
[Fact]
public void TruncatedFrame_Throws()
{
byte[] frame = [(byte)MessageType.ServerWelcome, ProtocolConstants.Version];
Assert.Throws<ProtocolException>(() => ProtocolCodec.ReadWelcome(frame));
}
[Fact]
public void WrongMessageId_Throws()
{
Span<byte> buffer = stackalloc byte[16];
var length = ProtocolCodec.WritePing(buffer, new ClientPingMessage(1));
var frame = buffer[..length].ToArray();
Assert.Throws<ProtocolException>(() => ProtocolCodec.ReadInput(frame));
}
[Fact]
public void OversizedName_Throws()
{
var message = new ClientHelloMessage(ProtocolConstants.Version, new string('x', 100));
var buffer = new byte[256];
Assert.Throws<ProtocolException>(() => ProtocolCodec.WriteHello(buffer, message));
}
[Fact]
public void UndersizedBuffer_Throws()
{
var message = new ServerWelcomeMessage(ProtocolConstants.Version, 1, 20, 1f, 1f);
var buffer = new byte[4];
Assert.Throws<ProtocolException>(() => ProtocolCodec.WriteWelcome(buffer, message));
}
}
@@ -0,0 +1,224 @@
using HSchool.Protocol;
namespace HSchool.Simulation.Tests;
public class GameWorldTests
{
private static SimulationOptions Options() => new()
{
TickRate = 20,
WorldWidth = 1000f,
WorldHeight = 1000f,
PlayerSpeed = 200f,
PlayerRadius = 10f,
};
private static EntitySnapshot Entity(GameWorld world, uint networkId)
{
var buffer = new List<EntitySnapshot>();
world.CaptureSnapshot(buffer);
return buffer.Single(entity => entity.Id == networkId);
}
[Fact]
public void Tick_AdvancesTheTickCounter()
{
using var world = new GameWorld(Options());
world.Tick();
world.Tick();
Assert.Equal(2u, world.CurrentTick);
}
[Fact]
public void SpawnPlayer_AddsAPlayerEntityToSnapshots()
{
using var world = new GameWorld(Options());
var networkId = world.SpawnPlayer(playerId: 1);
Assert.Equal(1, world.PlayerCount);
Assert.Equal(EntityKind.Player, Entity(world, networkId).Kind);
}
[Fact]
public void SpawnPlayer_Twice_Throws()
{
using var world = new GameWorld(Options());
world.SpawnPlayer(playerId: 1);
Assert.Throws<InvalidOperationException>(() => world.SpawnPlayer(playerId: 1));
}
[Fact]
public void Input_MovesThePlayerAtExactlySpeedTimesDelta()
{
var options = Options();
using var world = new GameWorld(options);
var networkId = world.SpawnPlayer(playerId: 1);
var startX = Entity(world, networkId).X;
world.ApplyInput(playerId: 1, InputButtons.Right, sequence: 1);
world.Tick();
var expected = startX + (options.PlayerSpeed * options.FixedDeltaTime);
Assert.Equal(expected, Entity(world, networkId).X, tolerance: 0.001f);
}
[Fact]
public void DiagonalInput_IsNotFasterThanCardinal()
{
var options = Options();
using var world = new GameWorld(options);
var straight = world.SpawnPlayer(playerId: 1);
var diagonal = world.SpawnPlayer(playerId: 2);
world.ApplyInput(playerId: 1, InputButtons.Right, sequence: 1);
world.ApplyInput(playerId: 2, InputButtons.Right | InputButtons.Down, sequence: 1);
world.Tick();
var straightBefore = Entity(world, straight);
var diagonalBefore = Entity(world, diagonal);
world.Tick();
var straightStep = Distance(straightBefore, Entity(world, straight));
var diagonalStep = Distance(diagonalBefore, Entity(world, diagonal));
Assert.Equal(straightStep, diagonalStep, tolerance: 0.01f);
}
[Fact]
public void Player_StopsAtTheWorldBounds()
{
var options = Options();
using var world = new GameWorld(options);
var networkId = world.SpawnPlayer(playerId: 1);
world.ApplyInput(playerId: 1, InputButtons.Left, sequence: 1);
for (var i = 0; i < 200; i++)
{
world.Tick();
}
Assert.Equal(options.PlayerRadius, Entity(world, networkId).X, tolerance: 0.001f);
}
[Fact]
public void StaleInput_IsIgnored()
{
using var world = new GameWorld(Options());
var networkId = world.SpawnPlayer(playerId: 1);
var startX = Entity(world, networkId).X;
world.ApplyInput(playerId: 1, InputButtons.None, sequence: 10);
world.ApplyInput(playerId: 1, InputButtons.Right, sequence: 2);
world.Tick();
Assert.Equal(startX, Entity(world, networkId).X, tolerance: 0.001f);
}
[Fact]
public void InputForAnUnknownPlayer_IsIgnored()
{
using var world = new GameWorld(Options());
world.ApplyInput(playerId: 999, InputButtons.Right, sequence: 1);
world.Tick();
Assert.Equal(0, world.PlayerCount);
}
[Fact]
public void DespawnPlayer_RemovesItFromSnapshots()
{
using var world = new GameWorld(Options());
var networkId = world.SpawnPlayer(playerId: 1);
world.DespawnPlayer(playerId: 1);
var buffer = new List<EntitySnapshot>();
world.CaptureSnapshot(buffer);
Assert.Equal(0, world.PlayerCount);
Assert.DoesNotContain(buffer, entity => entity.Id == networkId);
Assert.Null(world.GetNetworkId(playerId: 1));
}
[Fact]
public void DespawnPlayer_Twice_IsHarmless()
{
using var world = new GameWorld(Options());
world.SpawnPlayer(playerId: 1);
world.DespawnPlayer(playerId: 1);
world.DespawnPlayer(playerId: 1);
Assert.Equal(0, world.PlayerCount);
}
[Fact]
public void NetworkIds_AreNotRecycledAfterDespawn()
{
using var world = new GameWorld(Options());
var first = world.SpawnPlayer(playerId: 1);
world.DespawnPlayer(playerId: 1);
var second = world.SpawnPlayer(playerId: 1);
Assert.NotEqual(first, second);
}
[Fact]
public void EmptyWorld_StillContainsTheStaticObstacles()
{
using var world = new GameWorld(Options());
var buffer = new List<EntitySnapshot>();
world.CaptureSnapshot(buffer);
Assert.NotEmpty(buffer);
Assert.All(buffer, entity => Assert.Equal(EntityKind.Obstacle, entity.Kind));
}
[Fact]
public void Simulation_IsDeterministicForTheSameInputs()
{
var first = Run();
var second = Run();
Assert.Equal(first, second);
static (float X, float Y) Run()
{
using var world = new GameWorld(Options());
var networkId = world.SpawnPlayer(playerId: 3);
for (var i = 0; i < 25; i++)
{
world.ApplyInput(playerId: 3, i % 2 == 0 ? InputButtons.Right : InputButtons.Down, (uint)i + 1);
world.Tick();
}
var entity = Entity(world, networkId);
return (entity.X, entity.Y);
}
}
[Fact]
public void UsingADisposedWorld_Throws()
{
var world = new GameWorld(Options());
world.Dispose();
Assert.Throws<ObjectDisposedException>(world.Tick);
}
private static float Distance(EntitySnapshot from, EntitySnapshot to)
{
var dx = to.X - from.X;
var dy = to.Y - from.Y;
return MathF.Sqrt((dx * dx) + (dy * dy));
}
}
@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RootNamespace>HSchool.Simulation.Tests</RootNamespace>
<IsTestProject>true</IsTestProject>
<OutputType>Exe</OutputType>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit.v3" />
<PackageReference Include="xunit.runner.visualstudio" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\HSchool.Simulation\HSchool.Simulation.csproj" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
</Project>