172 lines
6.0 KiB
C#
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;
|
|
}
|
|
}
|