Update .gitignore to exclude TypeScript build info and add dist directory. Expand README with project overview, technology stack, prerequisites, and instructions for running and testing the application.
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using System.Net.WebSockets;
|
||||
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.
|
||||
/// </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 bool _ready;
|
||||
|
||||
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.
|
||||
/// </summary>
|
||||
public bool IsReady => Volatile.Read(ref _ready);
|
||||
|
||||
public void MarkReady() => Volatile.Write(ref _ready, true);
|
||||
|
||||
/// <summary>Queues a 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>
|
||||
public async Task RunSendLoopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await foreach (var frame in _outbox.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
if (Socket.State != WebSocketState.Open)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
await Socket.SendAsync(frame, WebSocketMessageType.Binary, endOfMessage: true, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
public void CompleteOutbox() => _outbox.Writer.TryComplete();
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
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.
|
||||
/// </summary>
|
||||
internal sealed class GameSocketHandler(
|
||||
ClientRegistry clients,
|
||||
GameCommandQueue commands,
|
||||
GameLoopService loop,
|
||||
ILogger<GameSocketHandler> logger)
|
||||
{
|
||||
private static readonly TimeSpan HandshakeTimeout = TimeSpan.FromSeconds(5);
|
||||
|
||||
public async Task HandleAsync(WebSocket socket, CancellationToken cancellationToken)
|
||||
{
|
||||
var client = clients.Add(socket);
|
||||
var buffer = ArrayPool<byte>.Shared.Rent(ProtocolConstants.MaxMessageSize);
|
||||
using var connectionCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
|
||||
Task? sendLoop = null;
|
||||
var joined = false;
|
||||
|
||||
try
|
||||
{
|
||||
using var handshakeCts = CancellationTokenSource.CreateLinkedTokenSource(connectionCts.Token);
|
||||
handshakeCts.CancelAfter(HandshakeTimeout);
|
||||
|
||||
var helloLength = await ReceiveFrameAsync(socket, buffer, handshakeCts.Token).ConfigureAwait(false);
|
||||
if (helloLength <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var hello = ProtocolCodec.ReadHello(buffer.AsSpan(0, helloLength));
|
||||
if (hello.ProtocolVersion != ProtocolConstants.Version)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Rejecting client {PlayerId}: protocol v{ClientVersion}, server speaks v{ServerVersion}.",
|
||||
client.PlayerId,
|
||||
hello.ProtocolVersion,
|
||||
ProtocolConstants.Version);
|
||||
|
||||
await CloseAsync(
|
||||
socket,
|
||||
WebSocketCloseStatus.ProtocolError,
|
||||
$"Protocol v{ProtocolConstants.Version} required.",
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
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);
|
||||
client.MarkReady();
|
||||
|
||||
// From here on every outbound frame goes through the outbox, so there is
|
||||
// exactly one writer on the socket.
|
||||
sendLoop = client.RunSendLoopAsync(connectionCts.Token);
|
||||
|
||||
await ReceiveLoopAsync(client, buffer, connectionCts.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Client went away or the host is shutting down.
|
||||
}
|
||||
catch (ProtocolException ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Malformed frame from client {PlayerId}.", client.PlayerId);
|
||||
await CloseAsync(socket, WebSocketCloseStatus.InvalidPayloadData, "Malformed frame.", CancellationToken.None)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (WebSocketException ex)
|
||||
{
|
||||
logger.LogDebug(ex, "Connection {PlayerId} dropped.", client.PlayerId);
|
||||
}
|
||||
finally
|
||||
{
|
||||
ArrayPool<byte>.Shared.Return(buffer);
|
||||
clients.Remove(client.PlayerId);
|
||||
client.CompleteOutbox();
|
||||
|
||||
if (joined)
|
||||
{
|
||||
commands.Enqueue(new GameCommand.Leave(client.PlayerId));
|
||||
}
|
||||
|
||||
if (sendLoop is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await sendLoop.ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (ex is OperationCanceledException or WebSocketException)
|
||||
{
|
||||
// Expected while tearing the connection down.
|
||||
}
|
||||
}
|
||||
|
||||
await connectionCts.CancelAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ReceiveLoopAsync(GameClient client, byte[] buffer, CancellationToken cancellationToken)
|
||||
{
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
var length = await ReceiveFrameAsync(client.Socket, buffer, cancellationToken).ConfigureAwait(false);
|
||||
if (length <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
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));
|
||||
break;
|
||||
|
||||
case MessageType.ClientPing:
|
||||
var ping = ProtocolCodec.ReadPing(frame);
|
||||
SendPong(client, ping.ClientTimeMs);
|
||||
break;
|
||||
|
||||
default:
|
||||
logger.LogDebug(
|
||||
"Ignoring unexpected frame 0x{MessageType:X2} from client {PlayerId}.",
|
||||
frame[0],
|
||||
client.PlayerId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Reads one whole message. Returns 0 on close, -1 on an oversized or non-binary frame.</summary>
|
||||
private async Task<int> ReceiveFrameAsync(WebSocket socket, byte[] buffer, CancellationToken cancellationToken)
|
||||
{
|
||||
var offset = 0;
|
||||
|
||||
while (true)
|
||||
{
|
||||
var result = await socket
|
||||
.ReceiveAsync(new ArraySegment<byte>(buffer, offset, buffer.Length - offset), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (result.MessageType == WebSocketMessageType.Close)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (result.MessageType != WebSocketMessageType.Binary)
|
||||
{
|
||||
logger.LogDebug("Dropping non-binary frame.");
|
||||
return -1;
|
||||
}
|
||||
|
||||
offset += result.Count;
|
||||
|
||||
if (result.EndOfMessage)
|
||||
{
|
||||
return offset;
|
||||
}
|
||||
|
||||
if (offset >= buffer.Length)
|
||||
{
|
||||
logger.LogWarning("Frame exceeds {Limit} bytes; closing.", ProtocolConstants.MaxMessageSize);
|
||||
await CloseAsync(socket, WebSocketCloseStatus.MessageTooBig, "Frame too large.", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SendWelcomeAsync(WebSocket socket, uint entityId, 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);
|
||||
|
||||
await socket
|
||||
.SendAsync(frame.AsMemory(0, length), WebSocketMessageType.Binary, endOfMessage: true, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private void SendPong(GameClient client, long clientTimeMs)
|
||||
{
|
||||
var frame = new byte[16];
|
||||
var length = ProtocolCodec.WritePong(frame, new ServerPongMessage(clientTimeMs, loop.CurrentTick));
|
||||
client.TrySend(frame.AsMemory(0, length));
|
||||
}
|
||||
|
||||
private static async Task CloseAsync(
|
||||
WebSocket socket,
|
||||
WebSocketCloseStatus status,
|
||||
string description,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (socket.State is WebSocketState.Open or WebSocketState.CloseReceived)
|
||||
{
|
||||
try
|
||||
{
|
||||
await socket.CloseAsync(status, description, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (WebSocketException)
|
||||
{
|
||||
// The peer may already be gone; nothing left to do.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <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];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user