Enhance school creation and map management features by updating the API to support mod packs and map layouts. Introduce a new map snapshot protocol for efficient data handling during school sessions. Revise documentation to reflect these changes, including updates to the protocol and architecture documents. Improve UI components for mod selection and map editing, ensuring a better user experience. Update tests to validate new functionalities and ensure robustness.
ci / server (push) Failing after 3m31s
ci / client (push) Successful in 14s

This commit is contained in:
Leonid Pershin
2026-08-18 15:15:49 +03:00
parent 1bc75244e8
commit 30cc937069
36 changed files with 1876 additions and 171 deletions
+103 -11
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 a school worker; when the outbox overflows the oldest frame is dropped, which is right
/// for a clock that is resent 20 times a second.
/// One connected browser. Clock frames go through a 32-slot outbox that drops the oldest under
/// pressure — a stale clock is worthless. One-shot frames (the map snapshot) use a separate
/// reliable channel so they cannot be crowded out by ticks.
/// </summary>
internal sealed class GameClient(uint playerId, WebSocket socket)
{
@@ -20,8 +20,16 @@ internal sealed class GameClient(uint playerId, WebSocket socket)
SingleWriter = false,
});
private readonly Channel<ReadOnlyMemory<byte>> _reliable =
Channel.CreateUnbounded<ReadOnlyMemory<byte>>(new UnboundedChannelOptions
{
SingleReader = true,
SingleWriter = false,
});
private bool _ready;
private int _openSchoolId;
private int _locale;
public uint PlayerId { get; } = playerId;
@@ -33,6 +41,16 @@ internal sealed class GameClient(uint playerId, WebSocket socket)
/// </summary>
public bool IsReady => Volatile.Read(ref _ready);
/// <summary>
/// Hello locale byte. Workers read this when labelling a map snapshot; unknown values are
/// treated as Russian by <see cref="HSchool.Protocol.ProtocolConstants.CatalogLocale"/>.
/// </summary>
public byte Locale
{
get => (byte)Volatile.Read(ref _locale);
set => Volatile.Write(ref _locale, value);
}
/// <summary>
/// School this connection is watching, or <c>null</c> in the menu. Written by the supervisor
/// on open/close, read by the connection thread on disconnect.
@@ -50,23 +68,97 @@ internal sealed class GameClient(uint playerId, WebSocket socket)
public void MarkReady() => Volatile.Write(ref _ready, true);
/// <summary>Queues a frame. Returns false once the connection is shutting down.</summary>
/// <summary>Queues a clock frame. Returns false once the connection is shutting down.</summary>
public bool TrySend(ReadOnlyMemory<byte> frame) => _outbox.Writer.TryWrite(frame);
/// <summary>Pumps queued frames to the socket until cancelled or the outbox completes.</summary>
/// <summary>Queues a frame that must arrive; never dropped for a newer clock.</summary>
public bool TrySendReliable(ReadOnlyMemory<byte> frame) => _reliable.Writer.TryWrite(frame);
/// <summary>Pumps queued frames to the socket until cancelled or both channels complete.</summary>
public async Task RunSendLoopAsync(CancellationToken cancellationToken)
{
await foreach (var frame in _outbox.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false))
var reliable = _reliable.Reader;
var outbox = _outbox.Reader;
while (Socket.State == WebSocketState.Open && !cancellationToken.IsCancellationRequested)
{
if (Socket.State != WebSocketState.Open)
if (reliable.TryRead(out var reliableFrame))
{
break;
if (!await SendAsync(reliableFrame, cancellationToken).ConfigureAwait(false))
{
return;
}
continue;
}
await Socket.SendAsync(frame, WebSocketMessageType.Binary, endOfMessage: true, cancellationToken)
.ConfigureAwait(false);
if (outbox.TryRead(out var clockFrame))
{
if (!await SendAsync(clockFrame, cancellationToken).ConfigureAwait(false))
{
return;
}
continue;
}
var waitReliable = reliable.WaitToReadAsync(cancellationToken).AsTask();
var waitOutbox = outbox.WaitToReadAsync(cancellationToken).AsTask();
Task<bool> finished;
try
{
finished = await Task.WhenAny(waitReliable, waitOutbox).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
return;
}
bool hasData;
try
{
hasData = await finished.ConfigureAwait(false);
}
catch (OperationCanceledException)
{
return;
}
if (hasData)
{
continue;
}
var other = ReferenceEquals(finished, waitReliable) ? waitOutbox : waitReliable;
try
{
if (!await other.ConfigureAwait(false))
{
return;
}
}
catch (OperationCanceledException)
{
return;
}
}
}
public void CompleteOutbox() => _outbox.Writer.TryComplete();
public void CompleteOutbox()
{
_reliable.Writer.TryComplete();
_outbox.Writer.TryComplete();
}
private async Task<bool> SendAsync(ReadOnlyMemory<byte> frame, CancellationToken cancellationToken)
{
if (Socket.State != WebSocketState.Open)
{
return false;
}
await Socket.SendAsync(frame, WebSocketMessageType.Binary, endOfMessage: true, cancellationToken)
.ConfigureAwait(false);
return true;
}
}