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,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>
|
||||
Reference in New Issue
Block a user