Support websocket based pushing.

This commit is contained in:
AnduinXue
2023-11-27 17:11:02 +00:00
parent 436d271c99
commit bf3121e47a
7 changed files with 202 additions and 58 deletions
@@ -1,14 +1,24 @@
using Aiursoft.ChessServer.Data; using Aiursoft.ChessServer.Data;
using Aiursoft.ChessServer.Models;
using Aiursoft.ChessServer.Services;
using Aiursoft.CSTools.Services;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
namespace Aiursoft.ChessServer.Controllers; namespace Aiursoft.ChessServer.Controllers;
public class GamesController : Controller public class GamesController : Controller
{ {
private readonly Counter _counter;
private readonly WebSocketPusher _pusher;
private readonly InMemoryDatabase _database; private readonly InMemoryDatabase _database;
public GamesController(InMemoryDatabase database) public GamesController(
Counter counter,
WebSocketPusher pusher,
InMemoryDatabase database)
{ {
_counter = counter;
_pusher = pusher;
_database = database; _database = database;
} }
@@ -38,10 +48,44 @@ public class GamesController : Controller
{ "fen", $"games/{id}/fen"}, { "fen", $"games/{id}/fen"},
{ "pgn", $"games/{id}/pgn"}, { "pgn", $"games/{id}/pgn"},
{ "html", $"games/{id}/html"}, { "html", $"games/{id}/html"},
{"websocket", $"games/{id}/websocket"},
{ "move-post", $"games/{id}/move/{{player}}/{{move_algebraic_notation}}"} { "move-post", $"games/{id}/move/{{player}}/{{move_algebraic_notation}}"}
} }
}); });
} }
[Route("games/{id}/ws")]
public async Task<IActionResult> 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")] [Route("games/{id}/ascii")]
public IActionResult GetAscii([FromRoute] int id) 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) if (game.IsValidMove(move) && !game.IsEndGame && game.Turn.AsChar.ToString() == player)
{ {
game.Move(move); 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(); return BadRequest();
} }
@@ -1,6 +1,7 @@
using Aiursoft.Scanner.Abstractions; using Aiursoft.Scanner.Abstractions;
using Chess; using Chess;
using System.Collections.Concurrent; using System.Collections.Concurrent;
using Aiursoft.ChessServer.Models;
namespace Aiursoft.ChessServer.Data; namespace Aiursoft.ChessServer.Data;
@@ -12,7 +13,8 @@ public class Game
public class InMemoryDatabase : ISingletonDependency public class InMemoryDatabase : ISingletonDependency
{ {
public ConcurrentDictionary<int, ChessBoard> Boards { get; private set; } = new ConcurrentDictionary<int, ChessBoard>(); public ConcurrentDictionary<int, ChessBoard> Boards { get; } = new();
public ConcurrentDictionary<int, Channel> Channels { get; } = new();
public Game[] GetActiveGames() public Game[] GetActiveGames()
{ {
@@ -27,4 +29,12 @@ public class InMemoryDatabase : ISingletonDependency
{ {
return Boards.GetOrAdd(id, _ => new ChessBoard()); 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);
}
} }
@@ -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<Message> Messages { get; } = new();
public ConcurrentBag<SemaphoreSlim> HasNewMessageBlocker { get; } = new();
public IEnumerable<Message> 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!);
}
}
+2
View File
@@ -19,6 +19,7 @@ public class Startup : IWebStartup
public void ConfigureServices(IConfiguration configuration, IWebHostEnvironment environment, IServiceCollection services) public void ConfigureServices(IConfiguration configuration, IWebHostEnvironment environment, IServiceCollection services)
{ {
services.AddLibraryDependencies(); services.AddLibraryDependencies();
services services
.AddControllersWithViews() .AddControllersWithViews()
.AddApplicationPart(Assembly.GetExecutingAssembly()); .AddApplicationPart(Assembly.GetExecutingAssembly());
@@ -30,5 +31,6 @@ public class Startup : IWebStartup
app.UseStaticFiles(); app.UseStaticFiles();
app.UseRouting(); app.UseRouting();
app.MapDefaultControllerRoute(); app.MapDefaultControllerRoute();
app.UseWebSockets();
} }
} }
@@ -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<byte>(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;
}
}
@@ -11,10 +11,8 @@
</head> </head>
<body> <body>
<div id="board" style="width: 400px"></div> <div id="board" style="width: 400px"></div>
<button id="refresh" style="margin-top: 5px">Refresh</button>
<p id="status"></p> <p id="status"></p>
<p id="fen"></p> <p id="fen"></p>
<p id="pgn"></p>
<script type="module" src="~/chess.js/dist/esm/chess.js"></script> <script type="module" src="~/chess.js/dist/esm/chess.js"></script>
<script src="~/jquery/dist/jquery.min.js"></script> <script src="~/jquery/dist/jquery.min.js"></script>
<script src="~/chessboardjs/js/chessboard-1.0.0.min.js"></script> <script src="~/chessboardjs/js/chessboard-1.0.0.min.js"></script>
+38 -53
View File
@@ -1,19 +1,12 @@
import {Chess} from "/chess.js/dist/esm/chess.js"; import {Chess} from "/chess.js/dist/esm/chess.js";
const statusControl = $('#status');
const fenControl = $('#fen');
const initGameBoard = function (player, gameId) { const initGameBoard = function (player, gameId) {
$.get("/games/" + gameId + "/fen", function (fen) { $.get("/games/" + gameId + "/fen", function (fen) {
let board = null let board = null;
let game = new Chess(fen); let game = null;
const refreshButton = $('#refresh');
const statusControl = $('#status');
const fenControl = $('#fen');
const pgnControl = $('#pgn');
function refresh(newFen) {
game = new Chess(newFen);
board.position(newFen);
updateStatus();
}
// Happens when a player picks up a piece. // Happens when a player picks up a piece.
function onDragStart(source, piece, position, _) { 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) { function onDrop(source, target) {
try { try {
// see if the move is legal
const move = game.move({ const move = game.move({
from: source, from: source,
to: target, to: target,
promotion: 'q' // NOTE: always promote to a queen for example simplicity promotion: 'q'
}) })
if (move === null) { if (move === null) {
return 'snapback' return 'snapback'
} }
// Get the last move and send it to server.
const lastMove = game.history({verbose: true}).pop().san; const lastMove = game.history({verbose: true}).pop().san;
$.post("/games/" + gameId + "/move/" + player + "/" + lastMove, function (fen) { $.post("/games/" + gameId + "/move/" + player + "/" + lastMove);
refresh(fen);
});
} catch (e) { } catch (e) {
return 'snapback' return 'snapback';
} }
} }
@@ -61,36 +47,23 @@ const initGameBoard = function (player, gameId) {
board.position(game.fen()) board.position(game.fen())
} }
function updateStatus() { function updateStatusText() {
let status; let status;
let moveColor = 'White'; let moveColor = 'White';
if (game.turn() === 'b') { if (game.turn() === 'b') {
moveColor = 'Black' moveColor = 'Black';
} } if (game.isCheckmate()) {
status = 'Game over, ' + moveColor + ' is in checkmate, and winner is ' + (game.turn() === 'w' ? 'Black' : 'White');
// checkmate? } else if (game.isDraw()) {
if (game.isCheckmate()) { status = 'Game over, drawn position';
status = 'Game over, ' + moveColor + ' is in checkmate.' } else {
} status = moveColor + ' to move';
// draw?
else if (game.isDraw()) {
status = 'Game over, drawn position'
}
// game still on
else {
status = moveColor + ' to move'
// check?
if (game.isCheck()) { if (game.isCheck()) {
status += ', ' + moveColor + ' is in check' status += ', ' + moveColor + ' is in check';
} }
} }
statusControl.html(status);
statusControl.html(status) fenControl.html(game.fen());
fenControl.html(game.fen())
pgnControl.html(game.pgn())
} }
const config = { const config = {
@@ -104,14 +77,26 @@ const initGameBoard = function (player, gameId) {
}; };
board = ChessBoard('board', config); board = ChessBoard('board', config);
// Bind Refresh button. function refresh(newFen) {
refreshButton.click(function () { game = new Chess(newFen);
$.get("/games/" + gameId + "/fen", function (fen) { board.position(newFen);
refresh(fen); 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);
};
}); });
}; };