diff --git a/tanks-backend/TanksServer/Endpoints.cs b/tanks-backend/TanksServer/Endpoints.cs index 6815fc0..d6242d7 100644 --- a/tanks-backend/TanksServer/Endpoints.cs +++ b/tanks-backend/TanksServer/Endpoints.cs @@ -18,7 +18,7 @@ internal sealed class Endpoints( { app.MapPost("/player", PostPlayer); app.MapGet("/player", GetPlayerAsync); - app.MapGet("/scores", () => playerService.GetAll() as IEnumerable); + app.MapGet("/scores", () => playerService.Players); app.Map("/screen", ConnectScreenAsync); app.Map("/controls", ConnectControlsAsync); app.MapGet("/map", () => mapService.MapNames); diff --git a/tanks-backend/TanksServer/Graphics/DrawMapStep.cs b/tanks-backend/TanksServer/Graphics/DrawMapStep.cs index 67d41ea..cf23bf0 100644 --- a/tanks-backend/TanksServer/Graphics/DrawMapStep.cs +++ b/tanks-backend/TanksServer/Graphics/DrawMapStep.cs @@ -9,8 +9,7 @@ internal sealed class DrawMapStep(MapService map) : IDrawStep for (ushort y = 0; y < MapService.PixelsPerColumn; y++) for (ushort x = 0; x < MapService.PixelsPerRow; x++) { - var pixel = new PixelPosition(x, y); - if (!map.Current.IsWall(pixel)) + if (!map.Current.IsWall(x, y)) continue; pixels[x, y].EntityType = GamePixelEntityType.Wall; diff --git a/tanks-backend/TanksServer/Graphics/GeneratePixelsTickStep.cs b/tanks-backend/TanksServer/Graphics/GeneratePixelsTickStep.cs index eba8ebf..7edf1a0 100644 --- a/tanks-backend/TanksServer/Graphics/GeneratePixelsTickStep.cs +++ b/tanks-backend/TanksServer/Graphics/GeneratePixelsTickStep.cs @@ -1,5 +1,6 @@ using DisplayCommands; using TanksServer.GameLogic; +using TanksServer.Interactivity; namespace TanksServer.Graphics; @@ -22,7 +23,8 @@ internal sealed class GeneratePixelsTickStep( if (_observerPixelGrid.Data.Span.SequenceEqual(_lastObserverPixelGrid.Data.Span)) return; - await Task.WhenAll(_consumers.Select(c => c.OnFrameDoneAsync(_gamePixelGrid, _observerPixelGrid))); + await _consumers.Select(c => c.OnFrameDoneAsync(_gamePixelGrid, _observerPixelGrid)) + .WhenAll(); (_lastGamePixelGrid, _gamePixelGrid) = (_gamePixelGrid, _lastGamePixelGrid); (_lastObserverPixelGrid, _observerPixelGrid) = (_observerPixelGrid, _lastObserverPixelGrid); diff --git a/tanks-backend/TanksServer/Interactivity/BufferPool.cs b/tanks-backend/TanksServer/Interactivity/BufferPool.cs new file mode 100644 index 0000000..8e7ad5c --- /dev/null +++ b/tanks-backend/TanksServer/Interactivity/BufferPool.cs @@ -0,0 +1,25 @@ +using System.Buffers; + +namespace TanksServer.Interactivity; + +internal sealed class BufferPool: MemoryPool +{ + private readonly MemoryPool _actualPool = Shared; + + public override int MaxBufferSize => int.MaxValue; + + protected override void Dispose(bool disposing) {} + + public override IMemoryOwner Rent(int minBufferSize = -1) + { + ArgumentOutOfRangeException.ThrowIfLessThan(minBufferSize, 1); + return new BufferPoolMemoryOwner(_actualPool.Rent(minBufferSize), minBufferSize); + } + + private sealed class BufferPoolMemoryOwner(IMemoryOwner actualOwner, int wantedSize): IMemoryOwner + { + public Memory Memory { get; } = actualOwner.Memory[..wantedSize]; + + public void Dispose() => actualOwner.Dispose(); + } +} diff --git a/tanks-backend/TanksServer/Interactivity/ClientScreenServer.cs b/tanks-backend/TanksServer/Interactivity/ClientScreenServer.cs index 2f82b3a..6955c4d 100644 --- a/tanks-backend/TanksServer/Interactivity/ClientScreenServer.cs +++ b/tanks-backend/TanksServer/Interactivity/ClientScreenServer.cs @@ -6,17 +6,23 @@ namespace TanksServer.Interactivity; internal sealed class ClientScreenServer( ILogger logger, - ILoggerFactory loggerFactory + ILoggerFactory loggerFactory, + BufferPool bufferPool ) : WebsocketServer(logger), IFrameConsumer { public Task HandleClientAsync(WebSocket socket, Player? player) - => base.HandleClientAsync(new ClientScreenServerConnection( + { + var connection = new ClientScreenServerConnection( socket, loggerFactory.CreateLogger(), - player - )); + player, + bufferPool + ); + return base.HandleClientAsync(connection); + } - public async Task OnFrameDoneAsync(GamePixelGrid gamePixelGrid, PixelGrid observerPixels) - => await ParallelForEachConnectionAsync(c => c.OnGameTickAsync(observerPixels, gamePixelGrid).AsTask()); + public Task OnFrameDoneAsync(GamePixelGrid gamePixelGrid, PixelGrid observerPixels) + => Connections.Select(c => c.OnGameTickAsync(observerPixels, gamePixelGrid)) + .WhenAll(); } diff --git a/tanks-backend/TanksServer/Interactivity/ClientScreenServerConnection.cs b/tanks-backend/TanksServer/Interactivity/ClientScreenServerConnection.cs index d76eda5..4cda7bb 100644 --- a/tanks-backend/TanksServer/Interactivity/ClientScreenServerConnection.cs +++ b/tanks-backend/TanksServer/Interactivity/ClientScreenServerConnection.cs @@ -7,24 +7,23 @@ namespace TanksServer.Interactivity; internal sealed class ClientScreenServerConnection : WebsocketServerConnection { - private sealed record class Package( - IMemoryOwner PixelsOwner, - Memory Pixels, - IMemoryOwner? PlayerDataOwner, - Memory? PlayerData - ); + private sealed record class Package(IMemoryOwner Pixels, IMemoryOwner? PlayerData); - private readonly MemoryPool _memoryPool = MemoryPool.Shared; + private readonly BufferPool _bufferPool; private readonly PlayerScreenData? _playerDataBuilder; private readonly Player? _player; private int _wantsFrameOnTick = 1; private Package? _next; - public ClientScreenServerConnection(WebSocket webSocket, + public ClientScreenServerConnection( + WebSocket webSocket, ILogger logger, - Player? player) : base(logger, new ByteChannelWebSocket(webSocket, logger, 0)) + Player? player, + BufferPool bufferPool + ) : base(logger, new ByteChannelWebSocket(webSocket, logger, 0)) { _player = player; + _bufferPool = bufferPool; _player?.IncrementConnectionCount(); _playerDataBuilder = player == null ? null @@ -46,25 +45,22 @@ internal sealed class ClientScreenServerConnection : WebsocketServerConnection return ValueTask.CompletedTask; } - public async ValueTask OnGameTickAsync(PixelGrid pixels, GamePixelGrid gamePixelGrid) + public async Task OnGameTickAsync(PixelGrid pixels, GamePixelGrid gamePixelGrid) { await Task.Yield(); - var nextPixelsOwner = _memoryPool.Rent(pixels.Data.Length); - var nextPixels = nextPixelsOwner.Memory[..pixels.Data.Length]; - pixels.Data.CopyTo(nextPixels); + var nextPixels = _bufferPool.Rent(pixels.Data.Length); + pixels.Data.CopyTo(nextPixels.Memory); - IMemoryOwner? nextPlayerDataOwner = null; - Memory? nextPlayerData = null; + IMemoryOwner? nextPlayerData = null; if (_playerDataBuilder != null) { var data = _playerDataBuilder.Build(gamePixelGrid); - nextPlayerDataOwner = _memoryPool.Rent(data.Length); - nextPlayerData = nextPlayerDataOwner.Memory[..data.Length]; - data.CopyTo(nextPlayerData.Value); + nextPlayerData = _bufferPool.Rent(data.Length); + data.CopyTo(nextPlayerData.Memory); } - var next = new Package(nextPixelsOwner, nextPixels, nextPlayerDataOwner, nextPlayerData); + var next = new Package(nextPixels, nextPlayerData); if (Interlocked.Exchange(ref _wantsFrameOnTick, 0) != 0) { await SendAndDisposeAsync(next); @@ -72,8 +68,8 @@ internal sealed class ClientScreenServerConnection : WebsocketServerConnection } var oldNext = Interlocked.Exchange(ref _next, next); - oldNext?.PixelsOwner.Dispose(); - oldNext?.PlayerDataOwner?.Dispose(); + oldNext?.Pixels.Dispose(); + oldNext?.PlayerData?.Dispose(); } public override ValueTask RemovedAsync() @@ -86,9 +82,9 @@ internal sealed class ClientScreenServerConnection : WebsocketServerConnection { try { - await Socket.SendBinaryAsync(package.Pixels, package.PlayerData == null); + await Socket.SendBinaryAsync(package.Pixels.Memory, package.PlayerData == null); if (package.PlayerData != null) - await Socket.SendBinaryAsync(package.PlayerData.Value); + await Socket.SendBinaryAsync(package.PlayerData.Memory); } catch (WebSocketException ex) { @@ -96,8 +92,8 @@ internal sealed class ClientScreenServerConnection : WebsocketServerConnection } finally { - package.PixelsOwner.Dispose(); - package.PlayerDataOwner?.Dispose(); + package.Pixels.Dispose(); + package.PlayerData?.Dispose(); } } } diff --git a/tanks-backend/TanksServer/Interactivity/PlayerInfoConnection.cs b/tanks-backend/TanksServer/Interactivity/PlayerInfoConnection.cs index bc20954..cd4d461 100644 --- a/tanks-backend/TanksServer/Interactivity/PlayerInfoConnection.cs +++ b/tanks-backend/TanksServer/Interactivity/PlayerInfoConnection.cs @@ -6,25 +6,30 @@ using TanksServer.GameLogic; namespace TanksServer.Interactivity; +// MemoryStream is IDisposable but does not need to be disposed +#pragma warning disable CA1001 internal sealed class PlayerInfoConnection : WebsocketServerConnection +#pragma warning restore CA1001 { private readonly Player _player; private readonly MapEntityManager _entityManager; + private readonly BufferPool _bufferPool; private readonly MemoryStream _tempStream = new(); - private readonly MemoryPool _memoryPool = MemoryPool.Shared; private int _wantsInfoOnTick = 1; - private Package? _lastMessage = null; - private Package? _nextMessage = null; + private IMemoryOwner? _lastMessage = null; + private IMemoryOwner? _nextMessage = null; - private sealed record class Package(IMemoryOwner Owner, Memory Memory); - - public PlayerInfoConnection(Player player, + public PlayerInfoConnection( + Player player, ILogger logger, WebSocket rawSocket, - MapEntityManager entityManager) : base(logger, new ByteChannelWebSocket(rawSocket, logger, 0)) + MapEntityManager entityManager, + BufferPool bufferPool + ) : base(logger, new ByteChannelWebSocket(rawSocket, logger, 0)) { _player = player; _entityManager = entityManager; + _bufferPool = bufferPool; _player.IncrementConnectionCount(); } @@ -38,7 +43,7 @@ internal sealed class PlayerInfoConnection : WebsocketServerConnection return ValueTask.CompletedTask; } - public async ValueTask OnGameTickAsync() + public async Task OnGameTickAsync() { await Task.Yield(); @@ -60,7 +65,7 @@ internal sealed class PlayerInfoConnection : WebsocketServerConnection return ValueTask.CompletedTask; } - private async ValueTask GenerateMessageAsync() + private async ValueTask> GenerateMessageAsync() { var tank = _entityManager.GetCurrentTankOfPlayer(_player); @@ -82,17 +87,16 @@ internal sealed class PlayerInfoConnection : WebsocketServerConnection await JsonSerializer.SerializeAsync(_tempStream, info, AppSerializerContext.Default.PlayerInfo); var messageLength = (int)_tempStream.Position; - var owner = _memoryPool.Rent(messageLength); - var package = new Package(owner, owner.Memory[..messageLength]); + var owner = _bufferPool.Rent(messageLength); _tempStream.Position = 0; - await _tempStream.ReadExactlyAsync(package.Memory); - return package; + await _tempStream.ReadExactlyAsync(owner.Memory); + return owner; } - private async ValueTask SendAndDisposeAsync(Package data) + private async ValueTask SendAndDisposeAsync(IMemoryOwner data) { await Socket.SendTextAsync(data.Memory); - Interlocked.Exchange(ref _lastMessage, data)?.Owner.Dispose(); + Interlocked.Exchange(ref _lastMessage, data)?.Dispose(); } } diff --git a/tanks-backend/TanksServer/Interactivity/PlayerServer.cs b/tanks-backend/TanksServer/Interactivity/PlayerServer.cs index 200c77e..a3b1b2f 100644 --- a/tanks-backend/TanksServer/Interactivity/PlayerServer.cs +++ b/tanks-backend/TanksServer/Interactivity/PlayerServer.cs @@ -8,65 +8,34 @@ internal sealed class PlayerServer( ILogger logger, ILogger connectionLogger, TankSpawnQueue tankSpawnQueue, - MapEntityManager entityManager + MapEntityManager entityManager, + BufferPool bufferPool ) : WebsocketServer(logger), ITickStep { - private readonly Dictionary _players = []; - private readonly SemaphoreSlim _mutex = new(1, 1); + private readonly ConcurrentDictionary _players = []; - public Player GetOrAdd(string name) - { - _mutex.Wait(); - try - { - if (_players.TryGetValue(name, out var existingPlayer)) - { - logger.LogInformation("player {} rejoined", existingPlayer.Name); - return existingPlayer; - } - - var newPlayer = new Player { Name = name }; - logger.LogInformation("player {} joined", newPlayer.Name); - _players.Add(name, newPlayer); - tankSpawnQueue.EnqueueForImmediateSpawn(newPlayer); - return newPlayer; - } - finally - { - _mutex.Release(); - } - } + public Player GetOrAdd(string name) => _players.GetOrAdd(name, Add); public bool TryGet(string name, [MaybeNullWhen(false)] out Player foundPlayer) - { - _mutex.Wait(); - try - { - foundPlayer = _players.Values.FirstOrDefault(player => player.Name == name); - return foundPlayer != null; - } - finally - { - _mutex.Release(); - } - } + => _players.TryGetValue(name, out foundPlayer); - public List GetAll() + public IEnumerable Players => _players.Values; + + private Player Add(string name) { - _mutex.Wait(); - try - { - return _players.Values.ToList(); - } - finally - { - _mutex.Release(); - } + var newPlayer = new Player { Name = name }; + logger.LogInformation("player {} joined", newPlayer.Name); + tankSpawnQueue.EnqueueForImmediateSpawn(newPlayer); + return newPlayer; } public Task HandleClientAsync(WebSocket webSocket, Player player) - => HandleClientAsync(new PlayerInfoConnection(player, connectionLogger, webSocket, entityManager)); + { + var connection = new PlayerInfoConnection(player, connectionLogger, webSocket, entityManager, bufferPool); + return HandleClientAsync(connection); + } - public ValueTask TickAsync(TimeSpan delta) - => ParallelForEachConnectionAsync(connection => connection.OnGameTickAsync().AsTask()); + public async ValueTask TickAsync(TimeSpan delta) + => await Connections.Select(connection => connection.OnGameTickAsync()) + .WhenAll(); } diff --git a/tanks-backend/TanksServer/Interactivity/SendToServicePointDisplay.cs b/tanks-backend/TanksServer/Interactivity/SendToServicePointDisplay.cs index 7a9d5a5..5625566 100644 --- a/tanks-backend/TanksServer/Interactivity/SendToServicePointDisplay.cs +++ b/tanks-backend/TanksServer/Interactivity/SendToServicePointDisplay.cs @@ -75,7 +75,7 @@ internal sealed class SendToServicePointDisplay : IFrameConsumer private void RefreshScores() { - var playersToDisplay = _players.GetAll() + var playersToDisplay = _players.Players .OrderByDescending(p => p.Scores.Kills) .Take(ScoresPlayerRows); diff --git a/tanks-backend/TanksServer/Interactivity/TaskExtensions.cs b/tanks-backend/TanksServer/Interactivity/TaskExtensions.cs new file mode 100644 index 0000000..8d32203 --- /dev/null +++ b/tanks-backend/TanksServer/Interactivity/TaskExtensions.cs @@ -0,0 +1,9 @@ +using System.Runtime.CompilerServices; + +namespace TanksServer.Interactivity; + +public static class TaskExtensions +{ + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static Task WhenAll(this IEnumerable tasks) => Task.WhenAll(tasks); +} diff --git a/tanks-backend/TanksServer/Interactivity/WebsocketServer.cs b/tanks-backend/TanksServer/Interactivity/WebsocketServer.cs index 8912b35..19f7e7a 100644 --- a/tanks-backend/TanksServer/Interactivity/WebsocketServer.cs +++ b/tanks-backend/TanksServer/Interactivity/WebsocketServer.cs @@ -1,70 +1,45 @@ +using System.Diagnostics; using Microsoft.Extensions.Hosting; namespace TanksServer.Interactivity; internal abstract class WebsocketServer( ILogger logger -) : IHostedLifecycleService, IDisposable +) : IHostedLifecycleService where T : WebsocketServerConnection { - private readonly SemaphoreSlim _mutex = new(1, 1); private bool _closing; - private readonly HashSet _connections = []; + private readonly ConcurrentDictionary _connections = []; public async Task StoppingAsync(CancellationToken cancellationToken) { + _closing = true; logger.LogInformation("closing connections"); - await LockedAsync(async () => - { - _closing = true; - await Task.WhenAll(_connections.Select(c => c.CloseAsync())); - }, cancellationToken); + await _connections.Keys.Select(c => c.CloseAsync()) + .WhenAll(); logger.LogInformation("closed connections"); } - protected ValueTask ParallelForEachConnectionAsync(Func body) => - LockedAsync(async () => await Task.WhenAll(_connections.Select(body)), CancellationToken.None); + protected IEnumerable Connections => _connections.Keys; - private ValueTask AddConnectionAsync(T connection) => LockedAsync(async () => + protected async Task HandleClientAsync(T connection) { if (_closing) { logger.LogWarning("refusing connection because server is shutting down"); await connection.CloseAsync(); + return; } - _connections.Add(connection); - }, CancellationToken.None); + var added = _connections.TryAdd(connection, 0); + Debug.Assert(added); - private ValueTask RemoveConnectionAsync(T connection) => LockedAsync(() => - { - _connections.Remove(connection); - return ValueTask.CompletedTask; - }, CancellationToken.None); - - protected async Task HandleClientAsync(T connection) - { - await AddConnectionAsync(connection); await connection.ReceiveAsync(); - await RemoveConnectionAsync(connection); + + _ = _connections.TryRemove(connection, out _); await connection.RemovedAsync(); } - private async ValueTask LockedAsync(Func action, CancellationToken cancellationToken) - { - await _mutex.WaitAsync(cancellationToken); - try - { - await action(); - } - finally - { - _mutex.Release(); - } - } - - public virtual void Dispose() => _mutex.Dispose(); - public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask; public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; diff --git a/tanks-backend/TanksServer/Interactivity/WebsocketServerConnection.cs b/tanks-backend/TanksServer/Interactivity/WebsocketServerConnection.cs index d501c1d..6be215c 100644 --- a/tanks-backend/TanksServer/Interactivity/WebsocketServerConnection.cs +++ b/tanks-backend/TanksServer/Interactivity/WebsocketServerConnection.cs @@ -3,9 +3,8 @@ namespace TanksServer.Interactivity; internal abstract class WebsocketServerConnection( ILogger logger, ByteChannelWebSocket socket -) : IDisposable +) { - private readonly SemaphoreSlim _mutex = new(1); protected readonly ByteChannelWebSocket Socket = socket; protected readonly ILogger Logger = logger; @@ -25,6 +24,4 @@ internal abstract class WebsocketServerConnection( public abstract ValueTask RemovedAsync(); protected abstract ValueTask HandleMessageAsync(Memory buffer); - - public virtual void Dispose() => _mutex.Dispose(); } diff --git a/tanks-backend/TanksServer/Program.cs b/tanks-backend/TanksServer/Program.cs index cce672b..36126dc 100644 --- a/tanks-backend/TanksServer/Program.cs +++ b/tanks-backend/TanksServer/Program.cs @@ -63,6 +63,7 @@ public static class Program builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddHostedService(); builder.Services.AddHostedService(sp => sp.GetRequiredService());