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,20 @@
|
||||
using HSchool.Protocol;
|
||||
|
||||
namespace HSchool.Server.Game;
|
||||
|
||||
/// <summary>
|
||||
/// Work item handed from a connection thread to the loop thread. The simulation is
|
||||
/// single-threaded, so every mutation arrives as one of these.
|
||||
/// </summary>
|
||||
internal abstract record GameCommand
|
||||
{
|
||||
/// <summary>
|
||||
/// Spawns an avatar for the connection. The loop completes <see cref="EntityId"/>
|
||||
/// with the replication id so the handler can send a Welcome frame.
|
||||
/// </summary>
|
||||
internal sealed record Join(uint PlayerId, TaskCompletionSource<uint> EntityId) : GameCommand;
|
||||
|
||||
internal sealed record Leave(uint PlayerId) : GameCommand;
|
||||
|
||||
internal sealed record Input(uint PlayerId, InputButtons Buttons, uint Sequence) : GameCommand;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace HSchool.Server.Game;
|
||||
|
||||
/// <summary>Multi-producer, single-consumer inbox drained at the start of every tick.</summary>
|
||||
internal sealed class GameCommandQueue
|
||||
{
|
||||
private readonly ConcurrentQueue<GameCommand> _commands = new();
|
||||
|
||||
public void Enqueue(GameCommand command) => _commands.Enqueue(command);
|
||||
|
||||
public bool TryDequeue(out GameCommand command) => _commands.TryDequeue(out command!);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
using System.Diagnostics;
|
||||
using HSchool.Protocol;
|
||||
using HSchool.Server.Net;
|
||||
using HSchool.Simulation;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace HSchool.Server.Game;
|
||||
|
||||
/// <summary>
|
||||
/// Owns the authoritative <see cref="GameWorld"/> and drives it at a fixed rate:
|
||||
/// drain commands, step the simulation, broadcast a full snapshot.
|
||||
/// The world is touched from this thread only.
|
||||
/// </summary>
|
||||
internal sealed class GameLoopService(
|
||||
IOptions<SimulationOptions> options,
|
||||
GameCommandQueue commands,
|
||||
ClientRegistry clients,
|
||||
GameMetrics metrics,
|
||||
ILogger<GameLoopService> logger) : BackgroundService
|
||||
{
|
||||
/// <summary>Upper bound on steps simulated in one wake-up; the rest of the backlog is dropped.</summary>
|
||||
private const int MaxCatchUpSteps = 5;
|
||||
|
||||
private readonly SimulationOptions _options = options.Value;
|
||||
private readonly List<EntitySnapshot> _snapshotBuffer = [];
|
||||
private readonly GameWorld _world = new(options.Value);
|
||||
|
||||
private uint _currentTick;
|
||||
private int _playerCount;
|
||||
|
||||
public uint CurrentTick => Volatile.Read(ref _currentTick);
|
||||
|
||||
public int PlayerCount => Volatile.Read(ref _playerCount);
|
||||
|
||||
public SimulationOptions Options => _options;
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Game loop starting at {TickRate} Hz on a {Width}x{Height} field.",
|
||||
_options.TickRate,
|
||||
_options.WorldWidth,
|
||||
_options.WorldHeight);
|
||||
|
||||
using var timer = new PeriodicTimer(_options.TickInterval);
|
||||
var fixedDelta = _options.FixedDeltaTime;
|
||||
var lastTimestamp = Stopwatch.GetTimestamp();
|
||||
var accumulator = 0d;
|
||||
|
||||
try
|
||||
{
|
||||
while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false))
|
||||
{
|
||||
var now = Stopwatch.GetTimestamp();
|
||||
accumulator += Stopwatch.GetElapsedTime(lastTimestamp, now).TotalSeconds;
|
||||
lastTimestamp = now;
|
||||
|
||||
DrainCommands();
|
||||
|
||||
var steps = 0;
|
||||
while (accumulator >= fixedDelta && steps < MaxCatchUpSteps)
|
||||
{
|
||||
var stepStarted = Stopwatch.GetTimestamp();
|
||||
_world.Tick();
|
||||
metrics.RecordTick(Stopwatch.GetElapsedTime(stepStarted, Stopwatch.GetTimestamp()).TotalMilliseconds);
|
||||
|
||||
accumulator -= fixedDelta;
|
||||
steps++;
|
||||
}
|
||||
|
||||
if (steps == MaxCatchUpSteps && accumulator >= fixedDelta)
|
||||
{
|
||||
logger.LogWarning("Game loop is behind by {Backlog:F0} ms; dropping the backlog.", accumulator * 1000);
|
||||
accumulator = 0d;
|
||||
}
|
||||
|
||||
if (steps > 0)
|
||||
{
|
||||
Volatile.Write(ref _currentTick, _world.CurrentTick);
|
||||
BroadcastSnapshot();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Normal shutdown.
|
||||
}
|
||||
finally
|
||||
{
|
||||
_world.Dispose();
|
||||
logger.LogInformation("Game loop stopped at tick {Tick}.", _world.CurrentTick);
|
||||
}
|
||||
}
|
||||
|
||||
private void DrainCommands()
|
||||
{
|
||||
while (commands.TryDequeue(out var command))
|
||||
{
|
||||
switch (command)
|
||||
{
|
||||
case GameCommand.Join join:
|
||||
HandleJoin(join);
|
||||
break;
|
||||
|
||||
case GameCommand.Leave leave:
|
||||
_world.DespawnPlayer(leave.PlayerId);
|
||||
Volatile.Write(ref _playerCount, _world.PlayerCount);
|
||||
metrics.PlayerLeft();
|
||||
logger.LogInformation("Player {PlayerId} left; {PlayerCount} remaining.", leave.PlayerId, _world.PlayerCount);
|
||||
break;
|
||||
|
||||
case GameCommand.Input input:
|
||||
_world.ApplyInput(input.PlayerId, input.Buttons, input.Sequence);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleJoin(GameCommand.Join join)
|
||||
{
|
||||
try
|
||||
{
|
||||
var entityId = _world.SpawnPlayer(join.PlayerId);
|
||||
Volatile.Write(ref _playerCount, _world.PlayerCount);
|
||||
metrics.PlayerJoined();
|
||||
join.EntityId.TrySetResult(entityId);
|
||||
logger.LogInformation(
|
||||
"Player {PlayerId} joined as entity {EntityId}; {PlayerCount} connected.",
|
||||
join.PlayerId,
|
||||
entityId,
|
||||
_world.PlayerCount);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
join.EntityId.TrySetException(ex);
|
||||
}
|
||||
}
|
||||
|
||||
private void BroadcastSnapshot()
|
||||
{
|
||||
_world.CaptureSnapshot(_snapshotBuffer);
|
||||
|
||||
// One immutable buffer is shared by every recipient, so nothing has to be copied per client.
|
||||
var frame = new byte[ProtocolCodec.SnapshotSize(_snapshotBuffer.Count)];
|
||||
var written = ProtocolCodec.WriteSnapshot(frame, _world.CurrentTick, System.Runtime.InteropServices.CollectionsMarshal.AsSpan(_snapshotBuffer));
|
||||
|
||||
var recipients = clients.Broadcast(frame.AsMemory(0, written));
|
||||
if (recipients > 0)
|
||||
{
|
||||
metrics.SnapshotSent(written, recipients);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using System.Diagnostics.Metrics;
|
||||
|
||||
namespace HSchool.Server.Game;
|
||||
|
||||
/// <summary>Game-loop counters surfaced in the Aspire dashboard.</summary>
|
||||
internal sealed class GameMetrics : IDisposable
|
||||
{
|
||||
public const string MeterName = "HSchool.Server.Game";
|
||||
|
||||
private readonly Meter _meter;
|
||||
private readonly Counter<long> _ticks;
|
||||
private readonly Histogram<double> _tickDuration;
|
||||
private readonly UpDownCounter<long> _connectedPlayers;
|
||||
private readonly Counter<long> _snapshotBytes;
|
||||
|
||||
public GameMetrics(IMeterFactory meterFactory)
|
||||
{
|
||||
_meter = meterFactory.Create(MeterName);
|
||||
_ticks = _meter.CreateCounter<long>("hschool.game.ticks", "{tick}", "Simulation steps executed.");
|
||||
_tickDuration = _meter.CreateHistogram<double>("hschool.game.tick.duration", "ms", "Wall time of one simulation step.");
|
||||
_connectedPlayers = _meter.CreateUpDownCounter<long>("hschool.game.players", "{player}", "Currently connected players.");
|
||||
_snapshotBytes = _meter.CreateCounter<long>("hschool.game.snapshot.bytes", "By", "Snapshot bytes pushed to clients.");
|
||||
}
|
||||
|
||||
public void RecordTick(double durationMs)
|
||||
{
|
||||
_ticks.Add(1);
|
||||
_tickDuration.Record(durationMs);
|
||||
}
|
||||
|
||||
public void PlayerJoined() => _connectedPlayers.Add(1);
|
||||
|
||||
public void PlayerLeft() => _connectedPlayers.Add(-1);
|
||||
|
||||
public void SnapshotSent(int bytes, int recipients) => _snapshotBytes.Add((long)bytes * recipients);
|
||||
|
||||
public void Dispose() => _meter.Dispose();
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<RootNamespace>HSchool.Server</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\HSchool.ServiceDefaults\HSchool.ServiceDefaults.csproj" />
|
||||
<ProjectReference Include="..\HSchool.Simulation\HSchool.Simulation.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
using System.Net.WebSockets;
|
||||
using HSchool.Server.Game;
|
||||
using HSchool.Server.Net;
|
||||
using HSchool.Simulation;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.AddServiceDefaults();
|
||||
builder.Services.AddProblemDetails();
|
||||
builder.Services.AddOpenApi();
|
||||
|
||||
builder.Services
|
||||
.AddOptions<SimulationOptions>()
|
||||
.Bind(builder.Configuration.GetSection(SimulationOptions.SectionName))
|
||||
.Validate(options => options.TickRate is > 0 and <= 120, "Simulation:TickRate must be between 1 and 120.")
|
||||
.Validate(options => options.WorldWidth > 0 && options.WorldHeight > 0, "World size must be positive.")
|
||||
.ValidateOnStart();
|
||||
|
||||
builder.Services.AddSingleton<GameCommandQueue>();
|
||||
builder.Services.AddSingleton<ClientRegistry>();
|
||||
builder.Services.AddSingleton<GameMetrics>();
|
||||
builder.Services.AddSingleton<GameSocketHandler>();
|
||||
builder.Services.AddSingleton<GameLoopService>();
|
||||
builder.Services.AddHostedService(sp => sp.GetRequiredService<GameLoopService>());
|
||||
|
||||
builder.Services.AddOpenTelemetry().WithMetrics(metrics => metrics.AddMeter(GameMetrics.MeterName));
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.UseExceptionHandler();
|
||||
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.MapOpenApi();
|
||||
}
|
||||
|
||||
app.UseWebSockets(new WebSocketOptions
|
||||
{
|
||||
KeepAliveInterval = TimeSpan.FromSeconds(30),
|
||||
});
|
||||
|
||||
var api = app.MapGroup("/api");
|
||||
|
||||
api.MapGet("/status", (GameLoopService loop, ClientRegistry clients) =>
|
||||
{
|
||||
var options = loop.Options;
|
||||
return new GameStatusResponse(
|
||||
loop.CurrentTick,
|
||||
options.TickRate,
|
||||
loop.PlayerCount,
|
||||
clients.Count,
|
||||
options.WorldWidth,
|
||||
options.WorldHeight);
|
||||
})
|
||||
.WithName("GetGameStatus");
|
||||
|
||||
// The realtime channel: one binary frame per protocol message, see docs/protocol.md.
|
||||
app.Map("/ws/game", async (HttpContext context, GameSocketHandler handler) =>
|
||||
{
|
||||
if (!context.WebSockets.IsWebSocketRequest)
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status400BadRequest;
|
||||
await context.Response.WriteAsync("This endpoint expects a WebSocket upgrade.");
|
||||
return;
|
||||
}
|
||||
|
||||
using WebSocket socket = await context.WebSockets.AcceptWebSocketAsync();
|
||||
await handler.HandleAsync(socket, context.RequestAborted);
|
||||
});
|
||||
|
||||
app.MapDefaultEndpoints();
|
||||
|
||||
// In a published container the built client lands in wwwroot next to the server.
|
||||
app.UseFileServer();
|
||||
|
||||
app.Run();
|
||||
|
||||
/// <summary>Snapshot of loop health for dashboards and integration tests.</summary>
|
||||
internal sealed record GameStatusResponse(
|
||||
uint Tick,
|
||||
int TickRate,
|
||||
int Players,
|
||||
int Connections,
|
||||
float WorldWidth,
|
||||
float WorldHeight);
|
||||
|
||||
/// <summary>Exposed so <c>WebApplicationFactory</c>-style tests can reference the entry point.</summary>
|
||||
public partial class Program;
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"applicationUrl": "http://localhost:5180",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"applicationUrl": "https://localhost:7180;http://localhost:5180",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"HSchool.Server.Game": "Information"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"Simulation": {
|
||||
"TickRate": 20,
|
||||
"WorldWidth": 1600,
|
||||
"WorldHeight": 900,
|
||||
"PlayerSpeed": 260,
|
||||
"PlayerRadius": 18
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user