diff --git a/src/Aiursoft.ChessServer/Controllers/GamesController.cs b/src/Aiursoft.ChessServer/Controllers/GamesController.cs index b540242..8954c92 100644 --- a/src/Aiursoft.ChessServer/Controllers/GamesController.cs +++ b/src/Aiursoft.ChessServer/Controllers/GamesController.cs @@ -1,14 +1,24 @@ using Aiursoft.ChessServer.Data; +using Aiursoft.ChessServer.Models; +using Aiursoft.ChessServer.Services; +using Aiursoft.CSTools.Services; using Microsoft.AspNetCore.Mvc; namespace Aiursoft.ChessServer.Controllers; public class GamesController : Controller { + private readonly Counter _counter; + private readonly WebSocketPusher _pusher; private readonly InMemoryDatabase _database; - public GamesController(InMemoryDatabase database) + public GamesController( + Counter counter, + WebSocketPusher pusher, + InMemoryDatabase database) { + _counter = counter; + _pusher = pusher; _database = database; } @@ -38,10 +48,44 @@ public class GamesController : Controller { "fen", $"games/{id}/fen"}, { "pgn", $"games/{id}/pgn"}, { "html", $"games/{id}/html"}, + {"websocket", $"games/{id}/websocket"}, { "move-post", $"games/{id}/move/{{player}}/{{move_algebraic_notation}}"} } }); } + + [Route("games/{id}/ws")] + public async Task GetWebSocket([FromRoute] int id) + { + var lastReadId = _counter.GetCurrent; + var (channel, blocker) = _database.ListenChannel(id); + + await _pusher.Accept(HttpContext); + try + { + await Task.Factory.StartNew(_pusher.PendingClose); + while (_pusher.Connected) + { + await blocker.WaitAsync(); + var nextMessages = channel + .GetMessagesFrom(lastReadId) + .ToList(); + var messageToPush = nextMessages.MinBy(t => t.Id); + await _pusher.SendMessage(messageToPush!.Content); + lastReadId = messageToPush.Id; + } + } + finally + { + await _pusher.Close(); + if (!channel.UnRegister(out blocker)) + { + throw new InvalidOperationException("Failed to unregister blocker!"); + } + } + + return NoContent(); + } [Route("games/{id}/ascii")] public IActionResult GetAscii([FromRoute] int id) @@ -80,7 +124,15 @@ public class GamesController : Controller if (game.IsValidMove(move) && !game.IsEndGame && game.Turn.AsChar.ToString() == player) { game.Move(move); - return Ok(game.ToFen()); + var fen = game.ToFen(); + if (_database.Channels.TryGetValue(id, out var channel)) + { + channel.Push(new Message(fen) + { + Id = _counter.GetUniqueNo(), + }); + } + return Ok(fen); } return BadRequest(); } diff --git a/src/Aiursoft.ChessServer/Data/InMemoryDatabase.cs b/src/Aiursoft.ChessServer/Data/InMemoryDatabase.cs index eaa5dbc..841843e 100644 --- a/src/Aiursoft.ChessServer/Data/InMemoryDatabase.cs +++ b/src/Aiursoft.ChessServer/Data/InMemoryDatabase.cs @@ -1,6 +1,7 @@ using Aiursoft.Scanner.Abstractions; using Chess; using System.Collections.Concurrent; +using Aiursoft.ChessServer.Models; namespace Aiursoft.ChessServer.Data; @@ -12,7 +13,8 @@ public class Game public class InMemoryDatabase : ISingletonDependency { - public ConcurrentDictionary Boards { get; private set; } = new ConcurrentDictionary(); + public ConcurrentDictionary Boards { get; } = new(); + public ConcurrentDictionary Channels { get; } = new(); public Game[] GetActiveGames() { @@ -27,4 +29,12 @@ public class InMemoryDatabase : ISingletonDependency { return Boards.GetOrAdd(id, _ => new ChessBoard()); } + + public (Channel, SemaphoreSlim) ListenChannel(int id) + { + var channel = Channels.GetOrAdd(id, _ => new Channel()); + var blocker = new SemaphoreSlim(0); + channel.HasNewMessageBlocker.Add(blocker); + return (channel, blocker); + } } diff --git a/src/Aiursoft.ChessServer/Models/Channel.cs b/src/Aiursoft.ChessServer/Models/Channel.cs new file mode 100644 index 0000000..e15c6b5 --- /dev/null +++ b/src/Aiursoft.ChessServer/Models/Channel.cs @@ -0,0 +1,40 @@ +using System.Collections.Concurrent; + +namespace Aiursoft.ChessServer.Models; + +public class Message +{ + public Message(string content) + { + Content = content; + } + + public int Id { get; init; } + public string Content { get; set; } +} + +public class Channel +{ + private ConcurrentBag Messages { get; } = new(); + + public ConcurrentBag HasNewMessageBlocker { get; } = new(); + + public IEnumerable GetMessagesFrom(int lastReadId) + { + return Messages.Where(t => t.Id > lastReadId); + } + + public void Push(Message message) + { + Messages.Add(message); + foreach (var blocker in HasNewMessageBlocker) + { + blocker.Release(); + } + } + + public bool UnRegister(out SemaphoreSlim blocker) + { + return HasNewMessageBlocker.TryTake(out blocker!); + } +} \ No newline at end of file diff --git a/src/Aiursoft.ChessServer/Program.cs b/src/Aiursoft.ChessServer/Program.cs index 2b8525d..a8788bc 100644 --- a/src/Aiursoft.ChessServer/Program.cs +++ b/src/Aiursoft.ChessServer/Program.cs @@ -19,6 +19,7 @@ public class Startup : IWebStartup public void ConfigureServices(IConfiguration configuration, IWebHostEnvironment environment, IServiceCollection services) { services.AddLibraryDependencies(); + services .AddControllersWithViews() .AddApplicationPart(Assembly.GetExecutingAssembly()); @@ -30,5 +31,6 @@ public class Startup : IWebStartup app.UseStaticFiles(); app.UseRouting(); app.MapDefaultControllerRoute(); + app.UseWebSockets(); } } diff --git a/src/Aiursoft.ChessServer/Services/WebSocketPusher.cs b/src/Aiursoft.ChessServer/Services/WebSocketPusher.cs new file mode 100644 index 0000000..8af3cdd --- /dev/null +++ b/src/Aiursoft.ChessServer/Services/WebSocketPusher.cs @@ -0,0 +1,57 @@ +using System.Net.WebSockets; +using Aiursoft.CSTools.Tools; +using Aiursoft.Scanner.Abstractions; + +namespace Aiursoft.ChessServer.Services; + +public class WebSocketPusher : IScopedDependency +{ + private bool _dropped; + private WebSocket? _ws; + + public bool Connected => !_dropped && _ws?.State == WebSocketState.Open; + + public async Task Accept(HttpContext context) + { + _ws = await context.WebSockets.AcceptWebSocketAsync(); + } + + public async Task SendMessage(string message) + { + await (_ws?.SendMessage(message) ?? throw new InvalidOperationException("WebSocket is not connected!")); + } + + public async Task PendingClose() + { + try + { + var buffer = new ArraySegment(new byte[4096 * 20]); + while (true) + { + await (_ws?.ReceiveAsync(buffer, CancellationToken.None) ?? throw new InvalidOperationException("WebSocket is not connected!")); + if (_ws.State == WebSocketState.Open) + { + continue; + } + + _dropped = true; + return; + } + } + catch (Exception e) when (!e.Message.StartsWith("The remote party closed the WebSocket connection")) + { + _dropped = true; + } + } + + public Task Close() + { + if (_ws?.State == WebSocketState.Open) + { + return _ws.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, "Close because of error.", + CancellationToken.None); + } + + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/src/Aiursoft.ChessServer/Views/Games/GetHtml.cshtml b/src/Aiursoft.ChessServer/Views/Games/GetHtml.cshtml index f57ad51..424ba6b 100644 --- a/src/Aiursoft.ChessServer/Views/Games/GetHtml.cshtml +++ b/src/Aiursoft.ChessServer/Views/Games/GetHtml.cshtml @@ -11,10 +11,8 @@
-

-

diff --git a/src/Aiursoft.ChessServer/wwwroot/site.js b/src/Aiursoft.ChessServer/wwwroot/site.js index 08c2d7f..2b319c0 100644 --- a/src/Aiursoft.ChessServer/wwwroot/site.js +++ b/src/Aiursoft.ChessServer/wwwroot/site.js @@ -1,19 +1,12 @@ import {Chess} from "/chess.js/dist/esm/chess.js"; +const statusControl = $('#status'); +const fenControl = $('#fen'); + const initGameBoard = function (player, gameId) { $.get("/games/" + gameId + "/fen", function (fen) { - let board = null - let game = new Chess(fen); - const refreshButton = $('#refresh'); - const statusControl = $('#status'); - const fenControl = $('#fen'); - const pgnControl = $('#pgn'); - - function refresh(newFen) { - game = new Chess(newFen); - board.position(newFen); - updateStatus(); - } + let board = null; + let game = null; // Happens when a player picks up a piece. function onDragStart(source, piece, position, _) { @@ -32,27 +25,20 @@ const initGameBoard = function (player, gameId) { } } - // Happens when a player drops a piece. function onDrop(source, target) { try { - // see if the move is legal const move = game.move({ from: source, to: target, - promotion: 'q' // NOTE: always promote to a queen for example simplicity + promotion: 'q' }) - if (move === null) { return 'snapback' } - - // Get the last move and send it to server. const lastMove = game.history({verbose: true}).pop().san; - $.post("/games/" + gameId + "/move/" + player + "/" + lastMove, function (fen) { - refresh(fen); - }); + $.post("/games/" + gameId + "/move/" + player + "/" + lastMove); } catch (e) { - return 'snapback' + return 'snapback'; } } @@ -61,36 +47,23 @@ const initGameBoard = function (player, gameId) { board.position(game.fen()) } - function updateStatus() { + function updateStatusText() { let status; let moveColor = 'White'; if (game.turn() === 'b') { - moveColor = 'Black' - } - - // checkmate? - if (game.isCheckmate()) { - status = 'Game over, ' + moveColor + ' is in checkmate.' - } - - // draw? - else if (game.isDraw()) { - status = 'Game over, drawn position' - } - - // game still on - else { - status = moveColor + ' to move' - - // check? + moveColor = 'Black'; + } if (game.isCheckmate()) { + status = 'Game over, ' + moveColor + ' is in checkmate, and winner is ' + (game.turn() === 'w' ? 'Black' : 'White'); + } else if (game.isDraw()) { + status = 'Game over, drawn position'; + } else { + status = moveColor + ' to move'; if (game.isCheck()) { - status += ', ' + moveColor + ' is in check' + status += ', ' + moveColor + ' is in check'; } } - - statusControl.html(status) - fenControl.html(game.fen()) - pgnControl.html(game.pgn()) + statusControl.html(status); + fenControl.html(game.fen()); } const config = { @@ -104,14 +77,26 @@ const initGameBoard = function (player, gameId) { }; board = ChessBoard('board', config); - // Bind Refresh button. - refreshButton.click(function () { - $.get("/games/" + gameId + "/fen", function (fen) { - refresh(fen); - }); - }); + function refresh(newFen) { + game = new Chess(newFen); + board.position(newFen); + updateStatusText(); + } - updateStatus(); + refresh(fen); + + const wsScheme = window.location.protocol === "https:" ? "wss://" : "ws://"; + const socket = new WebSocket(wsScheme + window.location.host + "/games/" + gameId + "/ws"); + socket.onmessage = function (event) { + refresh(event.data); + }; + + // Auto reconnect. + socket.onclose = function (event) { + setTimeout(function () { + initGameBoard(player, gameId); + }, 1000); + }; }); };