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,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