Files
h-school/src/HSchool.Server/Net/GameClient.cs
T

172 lines
6.0 KiB
C#

using System.Net.WebSockets;
using System.Threading.Channels;
namespace HSchool.Server.Net;
/// <summary>
/// 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)
{
private const int OutboxCapacity = 32;
private readonly Channel<ReadOnlyMemory<byte>> _outbox =
Channel.CreateBounded<ReadOnlyMemory<byte>>(new BoundedChannelOptions(OutboxCapacity)
{
FullMode = BoundedChannelFullMode.DropOldest,
SingleReader = true,
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;
public WebSocket Socket { get; } = socket;
/// <summary>
/// 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>
/// 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.
/// </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 clock frame. Returns false once the connection is shutting down.</summary>
public bool TrySend(ReadOnlyMemory<byte> frame) => _outbox.Writer.TryWrite(frame);
/// <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)
{
var reliable = _reliable.Reader;
var outbox = _outbox.Reader;
// The two waits live across iterations on purpose. Creating a fresh pair every idle pass
// left the loser of Task.WhenAny queued on its channel forever — and because clock frames
// arrive twenty times a second, the reliable channel collected one dead waiter per tick,
// each holding a cancellation registration, for as long as the player stayed in a school.
Task<bool>? waitReliable = null;
Task<bool>? waitOutbox = null;
while (Socket.State == WebSocketState.Open && !cancellationToken.IsCancellationRequested)
{
if (reliable.TryRead(out var reliableFrame))
{
if (!await SendAsync(reliableFrame, cancellationToken).ConfigureAwait(false))
{
return;
}
continue;
}
if (outbox.TryRead(out var clockFrame))
{
if (!await SendAsync(clockFrame, cancellationToken).ConfigureAwait(false))
{
return;
}
continue;
}
// A completed channel is empty for good; waiting on it again would spin.
if (!reliable.Completion.IsCompleted)
{
waitReliable ??= reliable.WaitToReadAsync(cancellationToken).AsTask();
}
if (!outbox.Completion.IsCompleted)
{
waitOutbox ??= outbox.WaitToReadAsync(cancellationToken).AsTask();
}
if (waitReliable is null && waitOutbox is null)
{
return;
}
Task<bool> finished;
try
{
finished = waitReliable is null ? waitOutbox!
: waitOutbox is null ? waitReliable
: await Task.WhenAny(waitReliable, waitOutbox).ConfigureAwait(false);
await finished.ConfigureAwait(false);
}
catch (OperationCanceledException)
{
return;
}
// Only the wait that finished is dropped; the other one stays queued on its channel.
if (ReferenceEquals(finished, waitReliable))
{
waitReliable = null;
}
else
{
waitOutbox = null;
}
}
}
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;
}
}