A queue manager for seeking Lichess board games. It validates an OAuth token, opens an account event stream, posts a board seek, listens for gameStart events, signals found games, and manages lifecycle and cancellation.
#nullable enable annotations
using LichessNET.API;
using LichessNET.Entities.Board;
using LichessNET.Entities.Enumerations;
using LichessNET.Entities.OAuth;
namespace LichessNET.Gameplay;
public enum LichessQueueState
{
Idle,
ValidatingToken,
Seeking,
GameFound,
Stopping,
Faulted
}
public sealed class LichessGameFoundEventArgs : EventArgs
{
public LichessGameFoundEventArgs(string gameId, string color, BoardGameInfo game)
{
GameId = gameId;
Color = color;
Game = game;
}
public string GameId { get; }
public string Color { get; }
public BoardGameInfo Game { get; }
}
public sealed class LichessQueue : IAsyncDisposable
{
private readonly ILichessBoardClient _client;
private CancellationTokenSource? _cancellation;
private ILichessBoardEventStream? _accountStream;
private Task _seekTask = Task.CompletedTask;
private bool _accountStreamErrorObserved;
private LichessQueueState _state;
public LichessQueue(LichessApiClient api)
: this((ILichessBoardClient)api)
{
}
public LichessQueue(ILichessBoardClient client)
{
_client = client ?? throw new ArgumentNullException(nameof(client));
}
/// <summary>
/// The concrete client supplied to the legacy constructor.
/// </summary>
public LichessApiClient Api => _client as LichessApiClient
?? throw new InvalidOperationException("This queue uses a custom ILichessBoardClient.");
public ILichessBoardClient BoardClient => _client;
public ILichessBoardClient Client => _client;
public LichessQueueState State => _state;
public bool IsSeeking => _state == LichessQueueState.Seeking;
public event Action<LichessQueue, LichessGameFoundEventArgs>? OnGameFound;
public event Action<LichessQueue, Exception>? OnError;
public event Action<LichessQueue, LichessQueueState>? OnStateChanged;
public bool StopWhenGameFound { get; set; } = true;
/// <summary>
/// Validates the current in-memory token and returns its public metadata.
/// </summary>
public async Task<TokenInfo> ValidatePlayTokenAsync(CancellationToken cancellationToken = default)
{
var token = _client.GetToken();
if (string.IsNullOrWhiteSpace(token))
throw new UnauthorizedAccessException("A Lichess OAuth token with board:play is required.");
var result = await _client.TestTokensAsync(new List<string> { token }, cancellationToken);
cancellationToken.ThrowIfCancellationRequested();
if (!result.TryGetValue(token, out var info) || info == null ||
info.Permissions == null || !info.Permissions.Contains(TokenPermission.PlayGames))
{
throw new UnauthorizedAccessException("The Lichess OAuth token must include board:play.");
}
return info;
}
public async Task StartSeekAsync(BoardSeekOptions options, CancellationToken cancellationToken = default)
{
if (options == null)
throw new ArgumentNullException(nameof(options));
options.ToFormData();
await StopAsync();
SetState(LichessQueueState.ValidatingToken);
try
{
await ValidatePlayTokenAsync(cancellationToken);
cancellationToken.ThrowIfCancellationRequested();
_cancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
_accountStreamErrorObserved = false;
_accountStream = await _client.CreateBoardAccountEventStreamAsync(_cancellation.Token);
_accountStream.LineReceived += HandleAccountLine;
_accountStream.ErrorReceived += HandleStreamError;
_accountStream.Completed += HandleStreamCompleted;
// A deterministic fake may deliver the first event synchronously.
SetState(LichessQueueState.Seeking);
_accountStream.Start();
_seekTask = RunSeekAsync(options, _cancellation.Token);
}
catch (OperationCanceledException)
{
await ReleaseResourcesAsync();
SetState(LichessQueueState.Idle);
throw new OperationCanceledException(cancellationToken);
}
catch (Exception ex)
{
await ReleaseResourcesAsync();
SetState(LichessQueueState.Faulted);
#pragma warning disable CA2200 // ExceptionDispatchInfo is not whitelisted by s&box.
throw ex;
#pragma warning restore CA2200
}
}
public async Task StopAsync()
{
if (_accountStream == null && _cancellation == null && _seekTask.IsCompleted)
{
SetState(LichessQueueState.Idle);
return;
}
SetState(LichessQueueState.Stopping);
await ReleaseResourcesAsync();
SetState(LichessQueueState.Idle);
}
public async ValueTask DisposeAsync()
{
await StopAsync();
}
private async Task RunSeekAsync(BoardSeekOptions options, CancellationToken cancellationToken)
{
try
{
await _client.CreateBoardSeekAsync(options, cancellationToken);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
}
catch (Exception ex)
{
OnError?.Invoke(this, ex);
SetState(LichessQueueState.Faulted);
if (_cancellation != null && !_cancellation.IsCancellationRequested)
_cancellation.Cancel();
}
}
private async Task ReleaseResourcesAsync()
{
var cancellation = _cancellation;
var stream = _accountStream;
var seekTask = _seekTask;
_cancellation = null;
_accountStream = null;
_seekTask = Task.CompletedTask;
if (cancellation != null && !cancellation.IsCancellationRequested)
cancellation.Cancel();
if (stream != null)
{
Unsubscribe(stream);
await stream.DisposeAsync();
}
try
{
await seekTask;
}
catch (OperationCanceledException)
{
}
cancellation?.Dispose();
}
private void HandleAccountLine(ILichessBoardEventStream stream, JsonElement data)
{
if (!ReferenceEquals(stream, _accountStream))
return;
try
{
if (!BoardEventParser.GetEventType(data).Equals("gameStart", StringComparison.OrdinalIgnoreCase))
return;
var gameStart = BoardEventParser.ParseGameStart(data);
var game = gameStart?.Game;
var gameId = game?.BestGameId;
if (string.IsNullOrWhiteSpace(gameId))
return;
SetState(LichessQueueState.GameFound);
OnGameFound?.Invoke(this, new LichessGameFoundEventArgs(gameId, game?.Color, game));
if (StopWhenGameFound && _cancellation != null && !_cancellation.IsCancellationRequested)
_cancellation.Cancel();
}
catch (Exception ex)
{
OnError?.Invoke(this, ex);
SetState(LichessQueueState.Faulted);
}
}
private void HandleStreamError(ILichessBoardEventStream stream, Exception exception)
{
if (!ReferenceEquals(stream, _accountStream))
return;
_accountStreamErrorObserved = true;
OnError?.Invoke(this, exception);
SetState(LichessQueueState.Faulted);
if (_cancellation != null && !_cancellation.IsCancellationRequested)
_cancellation.Cancel();
}
private void HandleStreamCompleted(ILichessBoardEventStream stream)
{
if (!ReferenceEquals(stream, _accountStream) ||
_state == LichessQueueState.Stopping || _cancellation == null)
{
return;
}
if (_state == LichessQueueState.GameFound)
{
_ = FinishCompletedStreamAsync(LichessQueueState.GameFound);
return;
}
if (_cancellation.IsCancellationRequested)
{
var finalState = _state == LichessQueueState.Faulted
? LichessQueueState.Faulted
: LichessQueueState.Idle;
_ = FinishCompletedStreamAsync(finalState);
return;
}
if (!_accountStreamErrorObserved)
OnError?.Invoke(this, new IOException("The Lichess account event stream ended unexpectedly."));
_cancellation.Cancel();
_ = FinishCompletedStreamAsync(LichessQueueState.Faulted);
}
private async Task FinishCompletedStreamAsync(LichessQueueState finalState)
{
try
{
await ReleaseResourcesAsync();
SetState(finalState);
}
catch (Exception ex)
{
OnError?.Invoke(this, ex);
SetState(LichessQueueState.Faulted);
}
}
private void Unsubscribe(ILichessBoardEventStream stream)
{
stream.LineReceived -= HandleAccountLine;
stream.ErrorReceived -= HandleStreamError;
stream.Completed -= HandleStreamCompleted;
}
private void SetState(LichessQueueState state)
{
if (_state == state)
return;
_state = state;
OnStateChanged?.Invoke(this, state);
}
}