diff --git a/tanks-backend/TanksServer/Endpoints.cs b/tanks-backend/TanksServer/Endpoints.cs index 8685f63..a002cdd 100644 --- a/tanks-backend/TanksServer/Endpoints.cs +++ b/tanks-backend/TanksServer/Endpoints.cs @@ -47,7 +47,7 @@ internal static class Endpoints return Results.BadRequest(); using var ws = await context.WebSockets.AcceptWebSocketAsync(); - await clientScreenServer.HandleClient(ws, player); + await clientScreenServer.HandleClientAsync(ws, player); return Results.Empty; }); @@ -60,7 +60,7 @@ internal static class Endpoints return Results.NotFound(); using var ws = await context.WebSockets.AcceptWebSocketAsync(); - await controlsServer.HandleClient(ws, player); + await controlsServer.HandleClientAsync(ws, player); return Results.Empty; }); diff --git a/tanks-backend/TanksServer/Interactivity/ClientScreenServer.cs b/tanks-backend/TanksServer/Interactivity/ClientScreenServer.cs index e3861be..e52fcec 100644 --- a/tanks-backend/TanksServer/Interactivity/ClientScreenServer.cs +++ b/tanks-backend/TanksServer/Interactivity/ClientScreenServer.cs @@ -1,7 +1,5 @@ -using System.Diagnostics; using System.Net.WebSockets; using DisplayCommands; -using Microsoft.Extensions.Hosting; using TanksServer.Graphics; namespace TanksServer.Interactivity; @@ -10,54 +8,18 @@ internal sealed class ClientScreenServer( ILogger logger, ILoggerFactory loggerFactory, IOptions hostConfig -) : IHostedLifecycleService, IFrameConsumer +) : WebsocketServer(logger), IFrameConsumer { - private readonly ConcurrentDictionary _connections = new(); private readonly TimeSpan _minFrameTime = TimeSpan.FromMilliseconds(hostConfig.Value.ClientDisplayMinFrameTimeMs); - private bool _closing; - public Task StoppingAsync(CancellationToken cancellationToken) - { - logger.LogInformation("closing connections"); - _closing = true; - return Task.WhenAll(_connections.Keys.Select(c => c.CloseAsync())); - } - - public Task HandleClient(WebSocket socket, Guid? playerGuid) - { - if (_closing) - { - logger.LogWarning("ignoring request because connections are closing"); - return Task.CompletedTask; - } - - logger.LogDebug("HandleClient"); - var connection = new ClientScreenServerConnection( + public Task HandleClientAsync(WebSocket socket, Guid? playerGuid) + => base.HandleClientAsync(new( socket, loggerFactory.CreateLogger(), - this, _minFrameTime, - playerGuid); - var added = _connections.TryAdd(connection, 0); - Debug.Assert(added); - return connection.Done; - } - - public void Remove(ClientScreenServerConnection connection) => _connections.TryRemove(connection, out _); - - public IEnumerable GetConnections() => _connections.Keys; - + playerGuid + )); public Task OnFrameDoneAsync(GamePixelGrid gamePixelGrid, PixelGrid observerPixels) - { - var tasks = _connections.Keys - .Select(c => c.SendAsync(observerPixels, gamePixelGrid)); - return Task.WhenAll(tasks); - } - - public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask; - public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; - public Task StartedAsync(CancellationToken cancellationToken) => Task.CompletedTask; - public Task StartingAsync(CancellationToken cancellationToken) => Task.CompletedTask; - public Task StoppedAsync(CancellationToken cancellationToken) => Task.CompletedTask; + => ParallelForEachConnectionAsync(c => c.SendAsync(observerPixels, gamePixelGrid)); } diff --git a/tanks-backend/TanksServer/Interactivity/ClientScreenServerConnection.cs b/tanks-backend/TanksServer/Interactivity/ClientScreenServerConnection.cs index 75e97d7..1294934 100644 --- a/tanks-backend/TanksServer/Interactivity/ClientScreenServerConnection.cs +++ b/tanks-backend/TanksServer/Interactivity/ClientScreenServerConnection.cs @@ -5,11 +5,10 @@ using TanksServer.Graphics; namespace TanksServer.Interactivity; -internal sealed class ClientScreenServerConnection : IDisposable +internal sealed class ClientScreenServerConnection : IWebsocketServerConnection, IDisposable { private readonly ByteChannelWebSocket _channel; private readonly ILogger _logger; - private readonly ClientScreenServer _server; private readonly SemaphoreSlim _wantedFrames = new(1); private readonly Guid? _playerGuid; private readonly PlayerScreenData? _playerScreenData; @@ -20,12 +19,10 @@ internal sealed class ClientScreenServerConnection : IDisposable public ClientScreenServerConnection( WebSocket webSocket, ILogger logger, - ClientScreenServer server, TimeSpan minFrameTime, Guid? playerGuid = null ) { - _server = server; _logger = logger; _minFrameTime = minFrameTime; @@ -91,9 +88,7 @@ internal sealed class ClientScreenServerConnection : IDisposable { await foreach (var _ in _channel.ReadAllAsync()) _wantedFrames.Release(); - _logger.LogTrace("done receiving"); - _server.Remove(this); } public Task CloseAsync() diff --git a/tanks-backend/TanksServer/Interactivity/ControlsServer.cs b/tanks-backend/TanksServer/Interactivity/ControlsServer.cs index 1d081ad..aa1fc9b 100644 --- a/tanks-backend/TanksServer/Interactivity/ControlsServer.cs +++ b/tanks-backend/TanksServer/Interactivity/ControlsServer.cs @@ -1,115 +1,19 @@ using System.Net.WebSockets; -using Microsoft.Extensions.Hosting; namespace TanksServer.Interactivity; -internal sealed class ControlsServer(ILogger logger, ILoggerFactory loggerFactory) - : IHostedLifecycleService +internal sealed class ControlsServer( + ILogger logger, + ILoggerFactory loggerFactory +) : WebsocketServer(logger) { - private readonly List _connections = []; - - public Task StoppingAsync(CancellationToken cancellationToken) - { - return Task.WhenAll(_connections.Select(c => c.CloseAsync())); - } - - public Task HandleClient(WebSocket ws, Player player) + public async Task HandleClientAsync(WebSocket ws, Player player) { logger.LogDebug("control client connected {}", player.Id); var clientLogger = loggerFactory.CreateLogger(); - var sock = new ControlsServerConnection(ws, clientLogger, this, player); - _connections.Add(sock); - return sock.Done; - } - - private void Remove(ControlsServerConnection connection) => _connections.Remove(connection); - - public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask; - public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; - public Task StartedAsync(CancellationToken cancellationToken) => Task.CompletedTask; - public Task StartingAsync(CancellationToken cancellationToken) => Task.CompletedTask; - public Task StoppedAsync(CancellationToken cancellationToken) => Task.CompletedTask; - - private sealed class ControlsServerConnection - { - private readonly ByteChannelWebSocket _binaryWebSocket; - private readonly ILogger _logger; - private readonly Player _player; - private readonly ControlsServer _server; - - public ControlsServerConnection(WebSocket socket, ILogger logger, - ControlsServer server, Player player) - { - _logger = logger; - _server = server; - _player = player; - _binaryWebSocket = new ByteChannelWebSocket(socket, logger, 2); - Done = ReceiveAsync(); - } - - public Task Done { get; } - - private async Task ReceiveAsync() - { - await foreach (var buffer in _binaryWebSocket.ReadAllAsync()) - { - var type = (MessageType)buffer.Span[0]; - var control = (InputType)buffer.Span[1]; - - _logger.LogTrace("player input {} {} {}", _player.Id, type, control); - - var isEnable = type switch - { - MessageType.Enable => true, - MessageType.Disable => false, - _ => throw new ArgumentException("invalid message type") - }; - - _player.LastInput = DateTime.Now; - - switch (control) - { - case InputType.Forward: - _player.Controls.Forward = isEnable; - break; - case InputType.Backward: - _player.Controls.Backward = isEnable; - break; - case InputType.Left: - _player.Controls.TurnLeft = isEnable; - break; - case InputType.Right: - _player.Controls.TurnRight = isEnable; - break; - case InputType.Shoot: - _player.Controls.Shoot = isEnable; - break; - default: - throw new ArgumentException("invalid control type"); - } - } - - _server.Remove(this); - } - - public Task CloseAsync() - { - return _binaryWebSocket.CloseAsync(); - } - - private enum MessageType : byte - { - Enable = 0x01, - Disable = 0x02 - } - - private enum InputType : byte - { - Forward = 0x01, - Backward = 0x02, - Left = 0x03, - Right = 0x04, - Shoot = 0x05 - } + var sock = new ControlsServerConnection(ws, clientLogger, player); + await AddConnection(sock); + await sock.Done; + await RemoveConnection(sock); } } diff --git a/tanks-backend/TanksServer/Interactivity/ControlsServerConnection.cs b/tanks-backend/TanksServer/Interactivity/ControlsServerConnection.cs new file mode 100644 index 0000000..e30095a --- /dev/null +++ b/tanks-backend/TanksServer/Interactivity/ControlsServerConnection.cs @@ -0,0 +1,78 @@ +using System.Net.WebSockets; + +namespace TanksServer.Interactivity; + +internal sealed class ControlsServerConnection : IWebsocketServerConnection +{ + private readonly ByteChannelWebSocket _binaryWebSocket; + private readonly ILogger _logger; + private readonly Player _player; + + public ControlsServerConnection(WebSocket socket, ILogger logger, Player player) + { + _logger = logger; + _player = player; + _binaryWebSocket = new ByteChannelWebSocket(socket, logger, 2); + Done = ReceiveAsync(); + } + + public Task Done { get; } + + private async Task ReceiveAsync() + { + await foreach (var buffer in _binaryWebSocket.ReadAllAsync()) + { + var type = (MessageType)buffer.Span[0]; + var control = (InputType)buffer.Span[1]; + + _logger.LogTrace("player input {} {} {}", _player.Id, type, control); + + var isEnable = type switch + { + MessageType.Enable => true, + MessageType.Disable => false, + _ => throw new ArgumentException("invalid message type") + }; + + _player.LastInput = DateTime.Now; + + switch (control) + { + case InputType.Forward: + _player.Controls.Forward = isEnable; + break; + case InputType.Backward: + _player.Controls.Backward = isEnable; + break; + case InputType.Left: + _player.Controls.TurnLeft = isEnable; + break; + case InputType.Right: + _player.Controls.TurnRight = isEnable; + break; + case InputType.Shoot: + _player.Controls.Shoot = isEnable; + break; + default: + throw new ArgumentException("invalid control type"); + } + } + } + + public Task CloseAsync() => _binaryWebSocket.CloseAsync(); + + private enum MessageType : byte + { + Enable = 0x01, + Disable = 0x02 + } + + private enum InputType : byte + { + Forward = 0x01, + Backward = 0x02, + Left = 0x03, + Right = 0x04, + Shoot = 0x05 + } +} diff --git a/tanks-backend/TanksServer/Interactivity/IWebsocketServerConnection.cs b/tanks-backend/TanksServer/Interactivity/IWebsocketServerConnection.cs new file mode 100644 index 0000000..10210cb --- /dev/null +++ b/tanks-backend/TanksServer/Interactivity/IWebsocketServerConnection.cs @@ -0,0 +1,8 @@ +namespace TanksServer.Interactivity; + +internal interface IWebsocketServerConnection +{ + Task CloseAsync(); + + Task Done { get; } +} diff --git a/tanks-backend/TanksServer/Interactivity/PlayerServer.cs b/tanks-backend/TanksServer/Interactivity/PlayerServer.cs index a78d4a9..e1d7c80 100644 --- a/tanks-backend/TanksServer/Interactivity/PlayerServer.cs +++ b/tanks-backend/TanksServer/Interactivity/PlayerServer.cs @@ -3,7 +3,10 @@ using TanksServer.GameLogic; namespace TanksServer.Interactivity; -internal sealed class PlayerServer(ILogger logger, TankSpawnQueue tankSpawnQueue) +internal sealed class PlayerServer( + ILogger logger, + TankSpawnQueue tankSpawnQueue +) { private readonly ConcurrentDictionary _players = new(); diff --git a/tanks-backend/TanksServer/Interactivity/WebsocketServer.cs b/tanks-backend/TanksServer/Interactivity/WebsocketServer.cs new file mode 100644 index 0000000..a4bd0eb --- /dev/null +++ b/tanks-backend/TanksServer/Interactivity/WebsocketServer.cs @@ -0,0 +1,87 @@ +using Microsoft.Extensions.Hosting; + +namespace TanksServer.Interactivity; + +internal class WebsocketServer( + ILogger logger +) : IHostedLifecycleService, IDisposable + where T : IWebsocketServerConnection +{ + private readonly SemaphoreSlim _mutex = new(1, 1); + private bool _closing; + private readonly HashSet _connections = []; + + public async Task StoppingAsync(CancellationToken cancellationToken) + { + logger.LogInformation("closing connections"); + await Locked(async () => + { + _closing = true; + await Task.WhenAll(_connections.Select(c => c.CloseAsync())); + }, cancellationToken); + logger.LogInformation("closed connections"); + } + + protected Task ParallelForEachConnectionAsync(Func body) + { + _mutex.Wait(); + try + { + return Task.WhenAll(_connections.Select(body)); + } + finally + { + _mutex.Release(); + } + } + + protected Task AddConnection(T connection) => Locked(() => + { + if (_closing) + { + logger.LogWarning("refusing connection because server is shutting down"); + return connection.CloseAsync(); + } + + _connections.Add(connection); + return Task.CompletedTask; + }, CancellationToken.None); + + protected Task RemoveConnection(T connection) => Locked(() => + { + _connections.Remove(connection); + return Task.CompletedTask; + }, CancellationToken.None); + + protected async Task HandleClientAsync(T connection) + { + await AddConnection(connection); + await connection.Done; + await RemoveConnection(connection); + } + + private async Task Locked(Func action, CancellationToken cancellationToken) + { + await _mutex.WaitAsync(cancellationToken); + try + { + await action(); + } + finally + { + _mutex.Release(); + } + } + + public void Dispose() => _mutex.Dispose(); + + public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + public Task StartedAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + public Task StartingAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + public Task StoppedAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} diff --git a/tanks-backend/TanksServer/Models/Scores.cs b/tanks-backend/TanksServer/Models/Scores.cs index 9c651f3..afa730d 100644 --- a/tanks-backend/TanksServer/Models/Scores.cs +++ b/tanks-backend/TanksServer/Models/Scores.cs @@ -1,10 +1,10 @@ namespace TanksServer.Models; -internal sealed record class Scores(int Kills = 0, int Deaths = 0) +internal sealed record class Scores { - public int Kills { get; set; } = Kills; + public int Kills { get; set; } - public int Deaths { get; set; } = Deaths; + public int Deaths { get; set; } public double Ratio { @@ -14,7 +14,7 @@ internal sealed record class Scores(int Kills = 0, int Deaths = 0) return 0; if (Deaths == 0) return Kills; - return Kills / (double)Deaths; + return Math.Round(Kills / (double)Deaths, 3); } }