Refactor project structure and update documentation. Replace PixiJS with plain DOM for UI rendering, enhance README with game features, and revise protocol documentation for HTTP API. Remove unused files and streamline client code for better maintainability.
ci / server (push) Failing after 3m31s
ci / client (push) Successful in 17s

This commit is contained in:
Leonid Pershin
2026-08-18 12:27:30 +03:00
parent e6739e7912
commit b9ddc018d3
73 changed files with 4387 additions and 2930 deletions
+111
View File
@@ -0,0 +1,111 @@
using HSchool.Server.Game;
using HSchool.Simulation;
namespace HSchool.Server.Api;
/// <summary>
/// The main menu talks to these: list, create, delete. Everything that mutates state is handed to
/// the loop thread as a command and awaited, so schools stay single-threaded.
/// </summary>
internal static class SchoolEndpoints
{
/// <summary>How long a request waits for the loop thread before giving up.</summary>
private static readonly TimeSpan CommandTimeout = TimeSpan.FromSeconds(5);
public static void MapSchoolEndpoints(this IEndpointRouteBuilder builder)
{
var schools = builder.MapGroup("/api/schools");
schools.MapGet("/", (GameLoopService loop) =>
{
var state = loop.SchoolsState;
var options = loop.Options;
return new SchoolsResponse(
state.MaxSchools,
options.DefaultStartDate,
options.GameMinutesPerRealSecond,
[.. state.Schools.Select(SchoolResponse.From)]);
})
.WithName("GetSchools");
schools.MapGet("/random-name", async (GameCommandQueue commands, CancellationToken cancellationToken) =>
{
var command = new GameCommand.SuggestName(NewCompletion<string>());
commands.Enqueue(command);
var name = await command.Result.Task.WaitAsync(CommandTimeout, cancellationToken);
return new RandomNameResponse(name);
})
.WithName("GetRandomSchoolName");
schools.MapPost("/", async (
CreateSchoolRequest request,
GameCommandQueue commands,
CancellationToken cancellationToken) =>
{
var command = new GameCommand.CreateSchool(
request.Name ?? string.Empty,
DateTime.SpecifyKind(request.StartDate, DateTimeKind.Utc),
NewCompletion<SchoolCreationOutcome>());
commands.Enqueue(command);
var outcome = await command.Result.Task.WaitAsync(CommandTimeout, cancellationToken);
return outcome.Error switch
{
SchoolCreationError.None =>
Results.Created($"/api/schools/{outcome.School!.Id}", SchoolResponse.From(outcome.School)),
SchoolCreationError.LimitReached =>
Problem(StatusCodes.Status409Conflict, "school-limit-reached", "The school limit is already reached."),
SchoolCreationError.InvalidName =>
Problem(StatusCodes.Status400BadRequest, "invalid-name", $"A name must be 1 to {School.MaxNameLength} characters."),
SchoolCreationError.InvalidStartDate =>
Problem(StatusCodes.Status400BadRequest, "invalid-start-date", "The start date is outside the supported range."),
_ => Results.Problem("Unknown error."),
};
})
.WithName("CreateSchool");
schools.MapDelete("/{id:int}", async (
int id,
GameCommandQueue commands,
CancellationToken cancellationToken) =>
{
var command = new GameCommand.DeleteSchool(id, NewCompletion<bool>());
commands.Enqueue(command);
var deleted = await command.Result.Task.WaitAsync(CommandTimeout, cancellationToken);
return deleted ? Results.NoContent() : Results.NotFound();
})
.WithName("DeleteSchool");
}
/// <summary>The loop thread must never be blocked by a continuation of a waiting request.</summary>
private static TaskCompletionSource<T> NewCompletion<T>() =>
new(TaskCreationOptions.RunContinuationsAsynchronously);
private static IResult Problem(int statusCode, string code, string detail) =>
Results.Problem(detail: detail, statusCode: statusCode, title: code, extensions: new Dictionary<string, object?>
{
["code"] = code,
});
}
/// <summary>Body of <c>POST /api/schools</c>. The start date is a game calendar date, not a real one.</summary>
internal sealed record CreateSchoolRequest(string? Name, DateTime StartDate);
internal sealed record SchoolResponse(int Id, string Name, DateTime GameTime, bool Running, byte SpeedIndex)
{
public static SchoolResponse From(SchoolState school) =>
new(school.Id, school.Name, school.GameTime, school.Running, school.SpeedIndex);
}
/// <summary>Everything the main menu needs in one request.</summary>
internal sealed record SchoolsResponse(
int MaxSchools,
DateTime DefaultStartDate,
double GameMinutesPerRealSecond,
IReadOnlyList<SchoolResponse> Schools);
internal sealed record RandomNameResponse(string Name);
+30 -20
View File
@@ -1,20 +1,30 @@
using HSchool.Protocol;
namespace HSchool.Server.Game;
/// <summary>
/// Work item handed from a connection thread to the loop thread. The simulation is
/// single-threaded, so every mutation arrives as one of these.
/// </summary>
internal abstract record GameCommand
{
/// <summary>
/// Spawns an avatar for the connection. The loop completes <see cref="EntityId"/>
/// with the replication id so the handler can send a Welcome frame.
/// </summary>
internal sealed record Join(uint PlayerId, TaskCompletionSource<uint> EntityId) : GameCommand;
internal sealed record Leave(uint PlayerId) : GameCommand;
internal sealed record Input(uint PlayerId, InputButtons Buttons, uint Sequence) : GameCommand;
}
namespace HSchool.Server.Game;
/// <summary>
/// Work item handed from a request or connection thread to the loop thread. Schools are
/// single-threaded, so every mutation and every read of live state arrives as one of these.
/// </summary>
internal abstract record GameCommand
{
internal sealed record CreateSchool(
string Name,
DateTime StartDate,
TaskCompletionSource<SchoolCreationOutcome> Result) : GameCommand;
internal sealed record DeleteSchool(int SchoolId, TaskCompletionSource<bool> Result) : GameCommand;
internal sealed record SuggestName(TaskCompletionSource<string> Result) : GameCommand;
/// <summary>A connection starts watching a school; its calendar starts running.</summary>
internal sealed record OpenSchool(uint PlayerId, int SchoolId) : GameCommand;
/// <summary>
/// Stops watching. The school id travels with the command because the connection may already
/// be gone from the registry by the time the loop thread gets here.
/// </summary>
internal sealed record CloseSchool(uint PlayerId, int SchoolId) : GameCommand;
internal sealed record SetRunning(uint PlayerId, bool Running) : GameCommand;
internal sealed record SetSpeed(uint PlayerId, byte SpeedIndex) : GameCommand;
}
+287 -153
View File
@@ -1,153 +1,287 @@
using System.Diagnostics;
using HSchool.Protocol;
using HSchool.Server.Net;
using HSchool.Simulation;
using Microsoft.Extensions.Options;
namespace HSchool.Server.Game;
/// <summary>
/// Owns the authoritative <see cref="GameWorld"/> and drives it at a fixed rate:
/// drain commands, step the simulation, broadcast a full snapshot.
/// The world is touched from this thread only.
/// </summary>
internal sealed class GameLoopService(
IOptions<SimulationOptions> options,
GameCommandQueue commands,
ClientRegistry clients,
GameMetrics metrics,
ILogger<GameLoopService> logger) : BackgroundService
{
/// <summary>Upper bound on steps simulated in one wake-up; the rest of the backlog is dropped.</summary>
private const int MaxCatchUpSteps = 5;
private readonly SimulationOptions _options = options.Value;
private readonly List<EntitySnapshot> _snapshotBuffer = [];
private readonly GameWorld _world = new(options.Value);
private uint _currentTick;
private int _playerCount;
public uint CurrentTick => Volatile.Read(ref _currentTick);
public int PlayerCount => Volatile.Read(ref _playerCount);
public SimulationOptions Options => _options;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
logger.LogInformation(
"Game loop starting at {TickRate} Hz on a {Width}x{Height} field.",
_options.TickRate,
_options.WorldWidth,
_options.WorldHeight);
using var timer = new PeriodicTimer(_options.TickInterval);
var fixedDelta = _options.FixedDeltaTime;
var lastTimestamp = Stopwatch.GetTimestamp();
var accumulator = 0d;
try
{
while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false))
{
var now = Stopwatch.GetTimestamp();
accumulator += Stopwatch.GetElapsedTime(lastTimestamp, now).TotalSeconds;
lastTimestamp = now;
DrainCommands();
var steps = 0;
while (accumulator >= fixedDelta && steps < MaxCatchUpSteps)
{
var stepStarted = Stopwatch.GetTimestamp();
_world.Tick();
metrics.RecordTick(Stopwatch.GetElapsedTime(stepStarted, Stopwatch.GetTimestamp()).TotalMilliseconds);
accumulator -= fixedDelta;
steps++;
}
if (steps == MaxCatchUpSteps && accumulator >= fixedDelta)
{
logger.LogWarning("Game loop is behind by {Backlog:F0} ms; dropping the backlog.", accumulator * 1000);
accumulator = 0d;
}
if (steps > 0)
{
Volatile.Write(ref _currentTick, _world.CurrentTick);
BroadcastSnapshot();
}
}
}
catch (OperationCanceledException)
{
// Normal shutdown.
}
finally
{
_world.Dispose();
logger.LogInformation("Game loop stopped at tick {Tick}.", _world.CurrentTick);
}
}
private void DrainCommands()
{
while (commands.TryDequeue(out var command))
{
switch (command)
{
case GameCommand.Join join:
HandleJoin(join);
break;
case GameCommand.Leave leave:
_world.DespawnPlayer(leave.PlayerId);
Volatile.Write(ref _playerCount, _world.PlayerCount);
metrics.PlayerLeft();
logger.LogInformation("Player {PlayerId} left; {PlayerCount} remaining.", leave.PlayerId, _world.PlayerCount);
break;
case GameCommand.Input input:
_world.ApplyInput(input.PlayerId, input.Buttons, input.Sequence);
break;
}
}
}
private void HandleJoin(GameCommand.Join join)
{
try
{
var entityId = _world.SpawnPlayer(join.PlayerId);
Volatile.Write(ref _playerCount, _world.PlayerCount);
metrics.PlayerJoined();
join.EntityId.TrySetResult(entityId);
logger.LogInformation(
"Player {PlayerId} joined as entity {EntityId}; {PlayerCount} connected.",
join.PlayerId,
entityId,
_world.PlayerCount);
}
catch (Exception ex)
{
join.EntityId.TrySetException(ex);
}
}
private void BroadcastSnapshot()
{
_world.CaptureSnapshot(_snapshotBuffer);
// One immutable buffer is shared by every recipient, so nothing has to be copied per client.
var frame = new byte[ProtocolCodec.SnapshotSize(_snapshotBuffer.Count)];
var written = ProtocolCodec.WriteSnapshot(frame, _world.CurrentTick, System.Runtime.InteropServices.CollectionsMarshal.AsSpan(_snapshotBuffer));
var recipients = clients.Broadcast(frame.AsMemory(0, written));
if (recipients > 0)
{
metrics.SnapshotSent(written, recipients);
}
}
}
using System.Diagnostics;
using HSchool.Protocol;
using HSchool.Server.Net;
using HSchool.Simulation;
using Microsoft.Extensions.Options;
namespace HSchool.Server.Game;
/// <summary>
/// Owns every <see cref="School"/> and drives them at a fixed rate: drain commands, advance the
/// running calendars, push a clock frame to each connection that has a school open.
/// Schools are touched from this thread only.
/// </summary>
internal sealed class GameLoopService(
IOptions<SimulationOptions> options,
GameCommandQueue commands,
ClientRegistry clients,
GameMetrics metrics,
ILogger<GameLoopService> logger) : BackgroundService
{
/// <summary>Upper bound on steps simulated in one wake-up; the rest of the backlog is dropped.</summary>
private const int MaxCatchUpSteps = 5;
private readonly SimulationOptions _options = options.Value;
private readonly SchoolRegistry _schools = new(options.Value);
private uint _currentTick;
private SchoolsState _publishedState = new(options.Value.MaxSchools, []);
public uint CurrentTick => Volatile.Read(ref _currentTick);
public SimulationOptions Options => _options;
/// <summary>
/// Last state published by the loop thread. Menu requests read this instead of blocking on a
/// command; it is at most one tick (50 ms) behind.
/// </summary>
public SchoolsState SchoolsState => Volatile.Read(ref _publishedState);
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
logger.LogInformation(
"Game loop starting at {TickRate} Hz, up to {MaxSchools} schools, {GameMinutes} game minutes per second.",
_options.TickRate,
_options.MaxSchools,
_options.GameMinutesPerRealSecond);
using var timer = new PeriodicTimer(_options.TickInterval);
var fixedDelta = _options.FixedDeltaTime;
var lastTimestamp = Stopwatch.GetTimestamp();
var accumulator = 0d;
try
{
while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false))
{
var now = Stopwatch.GetTimestamp();
accumulator += Stopwatch.GetElapsedTime(lastTimestamp, now).TotalSeconds;
lastTimestamp = now;
DrainCommands();
var steps = 0;
while (accumulator >= fixedDelta && steps < MaxCatchUpSteps)
{
var stepStarted = Stopwatch.GetTimestamp();
_schools.Tick();
Volatile.Write(ref _currentTick, _currentTick + 1);
metrics.RecordTick(Stopwatch.GetElapsedTime(stepStarted, Stopwatch.GetTimestamp()).TotalMilliseconds);
accumulator -= fixedDelta;
steps++;
}
if (steps == MaxCatchUpSteps && accumulator >= fixedDelta)
{
logger.LogWarning("Game loop is behind by {Backlog:F0} ms; dropping the backlog.", accumulator * 1000);
accumulator = 0d;
}
if (steps > 0)
{
PublishState();
BroadcastClocks();
}
}
}
catch (OperationCanceledException)
{
// Normal shutdown.
}
finally
{
_schools.Dispose();
logger.LogInformation("Game loop stopped at tick {Tick}.", _currentTick);
}
}
private void DrainCommands()
{
while (commands.TryDequeue(out var command))
{
switch (command)
{
case GameCommand.CreateSchool create:
HandleCreate(create);
break;
case GameCommand.DeleteSchool delete:
HandleDelete(delete);
break;
case GameCommand.SuggestName suggest:
Complete(suggest.Result, _schools.SuggestName);
break;
case GameCommand.OpenSchool open:
HandleOpen(open);
break;
case GameCommand.CloseSchool close:
StopWatching(close.PlayerId, close.SchoolId);
break;
case GameCommand.SetRunning setRunning:
WithOpenSchool(setRunning.PlayerId, school => school.Clock.IsRunning = setRunning.Running);
break;
case GameCommand.SetSpeed setSpeed:
WithOpenSchool(setSpeed.PlayerId, school => school.Clock.SpeedIndex = setSpeed.SpeedIndex);
break;
}
}
}
private void HandleCreate(GameCommand.CreateSchool command)
{
Complete(command.Result, () =>
{
var result = _schools.Create(command.Name, command.StartDate);
if (!result.Succeeded)
{
return new SchoolCreationOutcome(null, result.Error);
}
logger.LogInformation("School {SchoolId} \"{Name}\" created.", result.School!.Id, result.School.Name);
PublishState();
return new SchoolCreationOutcome(Capture(result.School), SchoolCreationError.None);
});
}
private void HandleDelete(GameCommand.DeleteSchool command)
{
Complete(command.Result, () =>
{
var deleted = _schools.Delete(command.SchoolId);
if (!deleted)
{
return false;
}
// Anyone watching it now stares at a school that no longer exists.
foreach (var client in clients.All)
{
if (client.OpenSchoolId == command.SchoolId)
{
client.OpenSchoolId = null;
SendSchoolGone(client, command.SchoolId);
}
}
logger.LogInformation("School {SchoolId} deleted.", command.SchoolId);
PublishState();
return true;
});
}
private void HandleOpen(GameCommand.OpenSchool command)
{
var client = clients.Find(command.PlayerId);
if (client is null)
{
return;
}
var school = _schools.Find(command.SchoolId);
if (school is null)
{
SendSchoolGone(client, command.SchoolId);
return;
}
client.OpenSchoolId = school.Id;
logger.LogInformation("Client {PlayerId} opened school {SchoolId}.", command.PlayerId, school.Id);
}
/// <summary>
/// The connection stops receiving clock frames for that school. The calendar keeps running —
/// schools live whether or not somebody is looking at them.
/// </summary>
private void StopWatching(uint playerId, int schoolId)
{
var client = clients.Find(playerId);
if (client?.OpenSchoolId == schoolId)
{
client.OpenSchoolId = null;
}
}
/// <summary>Applies a change to the school a connection has open, if it still has one.</summary>
private void WithOpenSchool(uint playerId, Action<School> change)
{
var client = clients.Find(playerId);
if (client?.OpenSchoolId is not { } schoolId)
{
return;
}
var school = _schools.Find(schoolId);
if (school is not null)
{
change(school);
}
}
private void BroadcastClocks()
{
foreach (var client in clients.All)
{
if (!client.IsReady || client.OpenSchoolId is not { } schoolId)
{
continue;
}
var school = _schools.Find(schoolId);
if (school is null)
{
continue;
}
var frame = new byte[ProtocolCodec.MaxFrameSize];
var length = ProtocolCodec.WriteClock(frame, new ServerClockMessage(
school.Id,
new DateTimeOffset(school.Clock.Time).ToUnixTimeMilliseconds(),
school.Clock.IsRunning,
(byte)school.Clock.SpeedIndex));
client.TrySend(frame.AsMemory(0, length));
}
}
private void SendSchoolGone(GameClient client, int schoolId)
{
var frame = new byte[ProtocolCodec.MaxFrameSize];
var length = ProtocolCodec.WriteSchoolGone(frame, new ServerSchoolGoneMessage(schoolId));
client.TrySend(frame.AsMemory(0, length));
}
private void PublishState()
{
var snapshot = new SchoolsState(
_schools.MaxSchools,
_schools.Schools.Select(Capture).ToArray());
Volatile.Write(ref _publishedState, snapshot);
metrics.SchoolsChanged(snapshot.Schools.Count);
}
private static SchoolState Capture(School school) =>
new(school.Id, school.Name, school.Clock.Time, school.Clock.IsRunning, (byte)school.Clock.SpeedIndex);
/// <summary>Runs work for a waiting request thread without letting an exception kill the loop.</summary>
private static void Complete<T>(TaskCompletionSource<T> completion, Func<T> work)
{
try
{
completion.TrySetResult(work());
}
catch (Exception ex)
{
completion.TrySetException(ex);
}
}
}
+8 -7
View File
@@ -10,16 +10,17 @@ internal sealed class GameMetrics : IDisposable
private readonly Meter _meter;
private readonly Counter<long> _ticks;
private readonly Histogram<double> _tickDuration;
private readonly UpDownCounter<long> _connectedPlayers;
private readonly Counter<long> _snapshotBytes;
private readonly UpDownCounter<long> _connections;
private int _schools;
public GameMetrics(IMeterFactory meterFactory)
{
_meter = meterFactory.Create(MeterName);
_ticks = _meter.CreateCounter<long>("hschool.game.ticks", "{tick}", "Simulation steps executed.");
_tickDuration = _meter.CreateHistogram<double>("hschool.game.tick.duration", "ms", "Wall time of one simulation step.");
_connectedPlayers = _meter.CreateUpDownCounter<long>("hschool.game.players", "{player}", "Currently connected players.");
_snapshotBytes = _meter.CreateCounter<long>("hschool.game.snapshot.bytes", "By", "Snapshot bytes pushed to clients.");
_connections = _meter.CreateUpDownCounter<long>("hschool.game.connections", "{connection}", "Open WebSocket connections.");
_meter.CreateObservableGauge("hschool.game.schools", () => Volatile.Read(ref _schools), "{school}", "Schools that currently exist.");
}
public void RecordTick(double durationMs)
@@ -28,11 +29,11 @@ internal sealed class GameMetrics : IDisposable
_tickDuration.Record(durationMs);
}
public void PlayerJoined() => _connectedPlayers.Add(1);
public void ClientConnected() => _connections.Add(1);
public void PlayerLeft() => _connectedPlayers.Add(-1);
public void ClientDisconnected() => _connections.Add(-1);
public void SnapshotSent(int bytes, int recipients) => _snapshotBytes.Add((long)bytes * recipients);
public void SchoolsChanged(int count) => Volatile.Write(ref _schools, count);
public void Dispose() => _meter.Dispose();
}
@@ -0,0 +1,9 @@
using HSchool.Simulation;
namespace HSchool.Server.Game;
/// <summary>What the loop thread reports back after trying to create a school.</summary>
internal readonly record struct SchoolCreationOutcome(SchoolState? School, SchoolCreationError Error)
{
public bool Succeeded => Error == SchoolCreationError.None && School is not null;
}
+10
View File
@@ -0,0 +1,10 @@
namespace HSchool.Server.Game;
/// <summary>
/// Immutable copy of a school, safe to hand to request threads. The live <c>School</c> object
/// never leaves the loop thread.
/// </summary>
internal sealed record SchoolState(int Id, string Name, DateTime GameTime, bool Running, byte SpeedIndex);
/// <summary>Everything the main menu needs in one read.</summary>
internal sealed record SchoolsState(int MaxSchools, IReadOnlyList<SchoolState> Schools);
+17 -16
View File
@@ -1,16 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<RootNamespace>HSchool.Server</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\HSchool.ServiceDefaults\HSchool.ServiceDefaults.csproj" />
<ProjectReference Include="..\HSchool.Simulation\HSchool.Simulation.csproj" />
</ItemGroup>
</Project>
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<RootNamespace>HSchool.Server</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\HSchool.Protocol\HSchool.Protocol.csproj" />
<ProjectReference Include="..\HSchool.ServiceDefaults\HSchool.ServiceDefaults.csproj" />
<ProjectReference Include="..\HSchool.Simulation\HSchool.Simulation.csproj" />
</ItemGroup>
</Project>
+28 -42
View File
@@ -1,42 +1,28 @@
using System.Collections.Concurrent;
using System.Net.WebSockets;
namespace HSchool.Server.Net;
/// <summary>Tracks live connections and hands out player ids.</summary>
internal sealed class ClientRegistry
{
private readonly ConcurrentDictionary<uint, GameClient> _clients = new();
private uint _nextPlayerId;
public int Count => _clients.Count;
public GameClient Add(WebSocket socket)
{
var playerId = Interlocked.Increment(ref _nextPlayerId);
var client = new GameClient(playerId, socket);
_clients[playerId] = client;
return client;
}
public void Remove(uint playerId) => _clients.TryRemove(playerId, out _);
/// <summary>
/// Queues the same frame for every client that finished its handshake; the buffer must not
/// be reused afterwards.
/// </summary>
public int Broadcast(ReadOnlyMemory<byte> frame)
{
var recipients = 0;
foreach (var client in _clients.Values)
{
if (client.IsReady && client.TrySend(frame))
{
recipients++;
}
}
return recipients;
}
}
using System.Collections.Concurrent;
using System.Net.WebSockets;
namespace HSchool.Server.Net;
/// <summary>Tracks live connections and hands out client ids.</summary>
internal sealed class ClientRegistry
{
private readonly ConcurrentDictionary<uint, GameClient> _clients = new();
private uint _nextPlayerId;
public int Count => _clients.Count;
/// <summary>Snapshot-free enumeration; safe because the dictionary is concurrent.</summary>
public IEnumerable<GameClient> All => _clients.Values;
public GameClient Add(WebSocket socket)
{
var playerId = Interlocked.Increment(ref _nextPlayerId);
var client = new GameClient(playerId, socket);
_clients[playerId] = client;
return client;
}
public GameClient? Find(uint playerId) => _clients.GetValueOrDefault(playerId);
public void Remove(uint playerId) => _clients.TryRemove(playerId, out _);
}
+21 -7
View File
@@ -4,9 +4,9 @@ using System.Threading.Channels;
namespace HSchool.Server.Net;
/// <summary>
/// One connected browser. Frames are queued instead of written inline so a slow client
/// can never stall the game loop; when the outbox overflows the oldest snapshot is dropped,
/// which is exactly what you want for state that is resent 20 times a second.
/// One connected browser. Frames are queued instead of written inline so a slow client can never
/// stall the game loop; when the outbox overflows the oldest frame is dropped, which is right for
/// a clock that is resent 20 times a second.
/// </summary>
internal sealed class GameClient(uint playerId, WebSocket socket)
{
@@ -21,19 +21,33 @@ internal sealed class GameClient(uint playerId, WebSocket socket)
});
private bool _ready;
private int _openSchoolId;
public uint PlayerId { get; } = playerId;
public WebSocket Socket { get; } = socket;
public string Name { get; set; } = $"player-{playerId}";
/// <summary>
/// Set once the welcome frame is out. Snapshots are only queued for ready clients, so a
/// connection never sees world state before it knows its own entity id.
/// Set once the welcome frame is out. Clock frames are only queued for ready clients, so a
/// connection never sees game state before the handshake finished.
/// </summary>
public bool IsReady => Volatile.Read(ref _ready);
/// <summary>
/// School this connection is watching, or <c>null</c> in the menu. Written by the loop thread,
/// read by the connection thread on disconnect.
/// </summary>
public int? OpenSchoolId
{
get
{
// School ids start at 1, so 0 stands for "this client is in the menu".
var id = Volatile.Read(ref _openSchoolId);
return id == 0 ? null : id;
}
set => Volatile.Write(ref _openSchoolId, value ?? 0);
}
public void MarkReady() => Volatile.Write(ref _ready, true);
/// <summary>Queues a frame. Returns false once the connection is shutting down.</summary>
+40 -58
View File
@@ -1,20 +1,20 @@
using System.Buffers;
using System.Net.WebSockets;
using System.Text;
using HSchool.Protocol;
using HSchool.Server.Game;
namespace HSchool.Server.Net;
/// <summary>
/// Drives one WebSocket connection: handshake, join, then the receive loop.
/// Everything it learns from the wire is untrusted, so frames are validated before
/// they reach the simulation.
/// Drives one WebSocket connection: version handshake, then the receive loop that turns frames
/// into commands. Everything it reads from the wire is untrusted, so frames are validated before
/// anything reaches the loop thread.
/// </summary>
internal sealed class GameSocketHandler(
ClientRegistry clients,
GameCommandQueue commands,
GameLoopService loop,
GameMetrics metrics,
ILogger<GameSocketHandler> logger)
{
private static readonly TimeSpan HandshakeTimeout = TimeSpan.FromSeconds(5);
@@ -26,7 +26,7 @@ internal sealed class GameSocketHandler(
using var connectionCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
Task? sendLoop = null;
var joined = false;
metrics.ClientConnected();
try
{
@@ -56,19 +56,7 @@ internal sealed class GameSocketHandler(
return;
}
client.Name = SanitizeName(hello.PlayerName, client.PlayerId);
var join = new GameCommand.Join(
client.PlayerId,
new TaskCompletionSource<uint>(TaskCreationOptions.RunContinuationsAsynchronously));
commands.Enqueue(join);
var entityId = await join.EntityId.Task
.WaitAsync(HandshakeTimeout, connectionCts.Token)
.ConfigureAwait(false);
joined = true;
await SendWelcomeAsync(socket, entityId, connectionCts.Token).ConfigureAwait(false);
await SendWelcomeAsync(socket, connectionCts.Token).ConfigureAwait(false);
client.MarkReady();
// From here on every outbound frame goes through the outbox, so there is
@@ -96,10 +84,13 @@ internal sealed class GameSocketHandler(
ArrayPool<byte>.Shared.Return(buffer);
clients.Remove(client.PlayerId);
client.CompleteOutbox();
metrics.ClientDisconnected();
if (joined)
// Read after the removal above: an open that lands later finds no client and is
// dropped, so this is the last chance to see the school this connection was watching.
if (client.OpenSchoolId is { } watchedSchoolId)
{
commands.Enqueue(new GameCommand.Leave(client.PlayerId));
commands.Enqueue(new GameCommand.CloseSchool(client.PlayerId, watchedSchoolId));
}
if (sendLoop is not null)
@@ -131,14 +122,31 @@ internal sealed class GameSocketHandler(
var frame = buffer.AsSpan(0, length);
switch (ProtocolCodec.PeekMessageType(frame))
{
case MessageType.ClientInput:
var input = ProtocolCodec.ReadInput(frame);
commands.Enqueue(new GameCommand.Input(client.PlayerId, input.Buttons, input.Sequence));
case MessageType.ClientPing:
SendPong(client, ProtocolCodec.ReadPing(frame).ClientTimeMs);
break;
case MessageType.ClientPing:
var ping = ProtocolCodec.ReadPing(frame);
SendPong(client, ping.ClientTimeMs);
case MessageType.ClientOpenSchool:
var open = ProtocolCodec.ReadOpenSchool(frame);
commands.Enqueue(new GameCommand.OpenSchool(client.PlayerId, open.SchoolId));
break;
case MessageType.ClientCloseSchool:
if (client.OpenSchoolId is { } openSchoolId)
{
commands.Enqueue(new GameCommand.CloseSchool(client.PlayerId, openSchoolId));
}
break;
case MessageType.ClientSetRunning:
var setRunning = ProtocolCodec.ReadSetRunning(frame);
commands.Enqueue(new GameCommand.SetRunning(client.PlayerId, setRunning.Running));
break;
case MessageType.ClientSetSpeed:
var setSpeed = ProtocolCodec.ReadSetSpeed(frame);
commands.Enqueue(new GameCommand.SetSpeed(client.PlayerId, setSpeed.SpeedIndex));
break;
default:
@@ -190,18 +198,13 @@ internal sealed class GameSocketHandler(
}
}
private async Task SendWelcomeAsync(WebSocket socket, uint entityId, CancellationToken cancellationToken)
private async Task SendWelcomeAsync(WebSocket socket, CancellationToken cancellationToken)
{
var options = loop.Options;
var welcome = new ServerWelcomeMessage(
ProtocolConstants.Version,
entityId,
(byte)options.TickRate,
options.WorldWidth,
options.WorldHeight);
var frame = new byte[32];
var length = ProtocolCodec.WriteWelcome(frame, welcome);
var frame = new byte[ProtocolCodec.MaxFrameSize];
var length = ProtocolCodec.WriteWelcome(
frame,
new ServerWelcomeMessage(ProtocolConstants.Version, (byte)options.TickRate, (byte)options.MaxSchools));
await socket
.SendAsync(frame.AsMemory(0, length), WebSocketMessageType.Binary, endOfMessage: true, cancellationToken)
@@ -210,7 +213,7 @@ internal sealed class GameSocketHandler(
private void SendPong(GameClient client, long clientTimeMs)
{
var frame = new byte[16];
var frame = new byte[ProtocolCodec.MaxFrameSize];
var length = ProtocolCodec.WritePong(frame, new ServerPongMessage(clientTimeMs, loop.CurrentTick));
client.TrySend(frame.AsMemory(0, length));
}
@@ -233,25 +236,4 @@ internal sealed class GameSocketHandler(
}
}
}
/// <summary>Names come from the wire: strip control characters and clamp the length.</summary>
private static string SanitizeName(string name, uint playerId)
{
var trimmed = name.Trim();
if (trimmed.Length == 0)
{
return $"player-{playerId}";
}
var builder = new StringBuilder(trimmed.Length);
foreach (var character in trimmed)
{
builder.Append(char.IsControl(character) ? ' ' : character);
}
var sanitized = builder.ToString();
return sanitized.Length <= ProtocolConstants.MaxPlayerNameBytes
? sanitized
: sanitized[..ProtocolConstants.MaxPlayerNameBytes];
}
}
+10 -19
View File
@@ -1,4 +1,5 @@
using System.Net.WebSockets;
using HSchool.Server.Api;
using HSchool.Server.Game;
using HSchool.Server.Net;
using HSchool.Simulation;
@@ -13,7 +14,9 @@ builder.Services
.AddOptions<SimulationOptions>()
.Bind(builder.Configuration.GetSection(SimulationOptions.SectionName))
.Validate(options => options.TickRate is > 0 and <= 120, "Simulation:TickRate must be between 1 and 120.")
.Validate(options => options.WorldWidth > 0 && options.WorldHeight > 0, "World size must be positive.")
.Validate(options => options.MaxSchools is > 0 and <= 255, "Simulation:MaxSchools must be between 1 and 255.")
.Validate(options => options.GameMinutesPerRealSecond > 0, "Simulation:GameMinutesPerRealSecond must be positive.")
.Validate(options => GameClock.IsValidStartDate(options.DefaultStartDate), "Simulation:DefaultStartDate is out of range.")
.ValidateOnStart();
builder.Services.AddSingleton<GameCommandQueue>();
@@ -39,18 +42,12 @@ app.UseWebSockets(new WebSocketOptions
KeepAliveInterval = TimeSpan.FromSeconds(30),
});
var api = app.MapGroup("/api");
app.MapSchoolEndpoints();
api.MapGet("/status", (GameLoopService loop, ClientRegistry clients) =>
app.MapGet("/api/status", (GameLoopService loop, ClientRegistry clients) =>
{
var options = loop.Options;
return new GameStatusResponse(
loop.CurrentTick,
options.TickRate,
loop.PlayerCount,
clients.Count,
options.WorldWidth,
options.WorldHeight);
var state = loop.SchoolsState;
return new GameStatusResponse(loop.CurrentTick, loop.Options.TickRate, state.Schools.Count, state.MaxSchools, clients.Count);
})
.WithName("GetGameStatus");
@@ -75,14 +72,8 @@ app.UseFileServer();
app.Run();
/// <summary>Snapshot of loop health for dashboards and integration tests.</summary>
internal sealed record GameStatusResponse(
uint Tick,
int TickRate,
int Players,
int Connections,
float WorldWidth,
float WorldHeight);
/// <summary>Loop health for dashboards and integration tests.</summary>
internal sealed record GameStatusResponse(uint Tick, int TickRate, int Schools, int MaxSchools, int Connections);
/// <summary>Exposed so <c>WebApplicationFactory</c>-style tests can reference the entry point.</summary>
public partial class Program;
+15 -16
View File
@@ -1,16 +1,15 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"Simulation": {
"TickRate": 20,
"WorldWidth": 1600,
"WorldHeight": 900,
"PlayerSpeed": 260,
"PlayerRadius": 18
}
}
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"Simulation": {
"TickRate": 20,
"MaxSchools": 6,
"GameMinutesPerRealSecond": 5,
"DefaultStartDate": "2012-04-03T06:00:00"
}
}