Support websocket based pushing.
This commit is contained in:
@@ -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!);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user