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
+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];
}
}