Gameplay/LichessGameSession.cs

Manages a live Lichess board session, including opening and maintaining an event stream, applying server moves to a local adapter, submitting local moves through the API client, handling reconnect logic, and raising events for moves, chat, clock updates and state changes.

Networking
#nullable enable annotations

using LichessNET.API;
using LichessNET.Entities.Board;

namespace LichessNET.Gameplay;

public sealed class LichessGameSession : IAsyncDisposable
{
    private readonly ILichessBoardClient _client;
    private readonly IChessBoardAdapter _adapter;
    private readonly LichessGameSessionOptions _options;
    private readonly List<string> _moveHistory = new();
    private readonly SemaphoreSlim _connectionGate = new(1, 1);
    private readonly string _initialAdapterState;

    private CancellationTokenSource? _lifetimeCancellation;
    private ILichessBoardEventStream? _stream;
    private Task _reconnectTask = Task.CompletedTask;
    private bool _stopping;
    private bool _disposed;
    private bool _gameOverRaised;
    private bool _automaticReconnectRunning;
    private bool _automaticReconnectRequested;
    private bool _streamAuthenticationFailed;
    private bool _initialPositionReady;
    private int _automaticReconnectAttempt;
    private string? _appliedInitialFen;
    private string _authoritativeInitialState;
    private string _lastConfirmedState;
    private string? _pendingLocalMove;
    private string? _pendingLocalSnapshot;
    private LichessGameConnectionState _connectionState;

    public LichessGameSession(LichessApiClient api, string gameId, string myColor,
        IChessBoardAdapter adapter)
        : this((ILichessBoardClient)api, gameId, myColor, adapter, null)
    {
    }

    public LichessGameSession(LichessApiClient api, string gameId, string myColor,
        IChessBoardAdapter adapter, LichessGameSessionOptions? options)
        : this((ILichessBoardClient)api, gameId, myColor, adapter, options)
    {
    }

    public LichessGameSession(ILichessBoardClient client, string gameId, string myColor,
        IChessBoardAdapter adapter, LichessGameSessionOptions? options = null)
    {
        _client = client ?? throw new ArgumentNullException(nameof(client));
        GameId = string.IsNullOrWhiteSpace(gameId)
            ? throw new ArgumentException("Game id is required.", nameof(gameId))
            : gameId;
        MyColor = string.IsNullOrWhiteSpace(myColor) ? "unknown" : myColor.ToLowerInvariant();
        _adapter = adapter ?? throw new ArgumentNullException(nameof(adapter));
        _options = options ?? new LichessGameSessionOptions();

        _initialAdapterState = SafeExportState();
        _authoritativeInitialState = _initialAdapterState;
        _lastConfirmedState = _initialAdapterState;
    }

    public ILichessBoardClient BoardClient => _client;
    public string GameId { get; }
    public string MyColor { get; }
    public IReadOnlyList<string> MoveHistory => _moveHistory;
    public LichessGameSessionOptions Options => _options;
    public BoardGameFullEvent? GameFull { get; private set; }
    public BoardGameState? LatestState { get; private set; }
    public string? PendingLocalMove => _pendingLocalMove;
    public bool WhiteOfferingDraw => LatestState?.WhiteOfferingDraw == true;
    public bool BlackOfferingDraw => LatestState?.BlackOfferingDraw == true;
    public LichessGameConnectionState ConnectionState => _connectionState;
    public bool IsConnected => _connectionState == LichessGameConnectionState.Connected;

    public event Action<LichessGameSession, string>? OnOpponentMove;
    public event Action<LichessGameSession, BoardClockState>? OnClockUpdate;
    public event Action<LichessGameSession, BoardGameState>? OnGameOver;
    public event Action<LichessGameSession, BoardChatLineEvent>? OnChatLine;
    public event Action<LichessGameSession, string>? OnDesync;
    public event Action<LichessGameSession, Exception>? OnError;
    public event Action<LichessGameSession, BoardGameFullEvent>? OnGameFull;
    public event Action<LichessGameSession, BoardGameState>? OnStateUpdated;
    public event Action<LichessGameSession, bool, bool>? OnDrawOfferChanged;
    public event Action<LichessGameSession, string?>? OnPendingLocalMoveChanged;
    public event Action<LichessGameSession, LichessGameConnectionState>? OnConnectionStateChanged;
    public event Action<LichessGameSession>? OnUnexpectedCompletion;

    public async Task StartAsync(CancellationToken cancellationToken = default)
    {
        ThrowIfDisposed();
        if (_stream != null)
            return;

        _stopping = false;
        if (_lifetimeCancellation == null || _lifetimeCancellation.IsCancellationRequested)
        {
            _lifetimeCancellation?.Dispose();
            _lifetimeCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
        }

        await _connectionGate.WaitAsync(cancellationToken);
        try
        {
            if (_stream == null)
                await OpenStreamAsync(LichessGameConnectionState.Connecting, cancellationToken);
        }
        finally
        {
            _connectionGate.Release();
        }
    }

    public async Task ReconnectAsync(CancellationToken cancellationToken = default)
    {
        ThrowIfDisposed();
        if (LatestState?.IsFinished == true)
            return;

        _stopping = false;
        if (_lifetimeCancellation == null || _lifetimeCancellation.IsCancellationRequested)
        {
            _lifetimeCancellation?.Dispose();
            _lifetimeCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
        }

        using var linked = CancellationTokenSource.CreateLinkedTokenSource(
            _lifetimeCancellation.Token, cancellationToken);

        try
        {
            await ReconnectCoreAsync(linked.Token);
        }
        catch (OperationCanceledException)
        {
            throw new OperationCanceledException(cancellationToken);
        }
    }

    public async Task StopAsync()
    {
        _stopping = true;
        var lifetime = _lifetimeCancellation;
        if (lifetime != null && !lifetime.IsCancellationRequested)
            lifetime.Cancel();

        await _connectionGate.WaitAsync();
        try
        {
            await CloseCurrentStreamAsync();
        }
        finally
        {
            _connectionGate.Release();
        }

        var reconnectTask = _reconnectTask;
        try
        {
            await reconnectTask;
        }
        catch (OperationCanceledException)
        {
        }

        if (ReferenceEquals(_lifetimeCancellation, lifetime))
            _lifetimeCancellation = null;

        lifetime?.Dispose();

        if (LatestState?.IsFinished == true)
            SetConnectionState(LichessGameConnectionState.Finished);
        else
            SetConnectionState(LichessGameConnectionState.Disconnected);
    }

    public async ValueTask DisposeAsync()
    {
        if (_disposed)
            return;

        _disposed = true;
        await StopAsync();
    }

    public async Task<bool> SubmitLocalMoveAsync(string uci, bool offerDraw = false,
        CancellationToken cancellationToken = default)
    {
        if (!UciMove.TryParse(uci, out var move))
        {
            throw new ArgumentException(
                "Move must be valid UCI notation, for example e2e4 or e7e8q.", nameof(uci));
        }

        if (_pendingLocalMove != null)
            throw new InvalidOperationException("A local move is already waiting for Lichess confirmation.");

        var normalizedMove = move.ToString();
        var snapshot = SafeExportState();
        if (!_adapter.TryApplyLocalMove(normalizedMove))
            return false;

        SetPendingLocalMove(normalizedMove, snapshot);

        try
        {
            var ok = await _client.MakeBoardMoveAsync(
                GameId, normalizedMove, offerDraw, cancellationToken);

            if (!ok && string.Equals(_pendingLocalMove, normalizedMove,
                    StringComparison.OrdinalIgnoreCase))
            {
                RollBackPendingMove("Lichess rejected move " + normalizedMove + ".");
            }

            return ok;
        }
        catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
        {
            if (string.Equals(_pendingLocalMove, normalizedMove, StringComparison.OrdinalIgnoreCase))
                RollBackPendingMove("The pending move was canceled.");

            throw new OperationCanceledException(cancellationToken);
        }
        catch (Exception ex)
        {
            if (string.Equals(_pendingLocalMove, normalizedMove, StringComparison.OrdinalIgnoreCase))
            {
                RollBackPendingMove(
                    "Lichess move request failed for " + normalizedMove + ".");
            }

            OnError?.Invoke(this, ex);
            return false;
        }
    }

    public Task<bool> ResignAsync(CancellationToken cancellationToken = default)
    {
        return _client.ResignBoardGameAsync(GameId, cancellationToken);
    }

    public Task<bool> AbortAsync(CancellationToken cancellationToken = default)
    {
        return _client.AbortBoardGameAsync(GameId, cancellationToken);
    }

    public Task<bool> OfferDrawAsync(CancellationToken cancellationToken = default)
    {
        return _client.HandleDrawOfferAsync(GameId, true, cancellationToken);
    }

    public Task<bool> AcceptDrawAsync(CancellationToken cancellationToken = default)
    {
        return _client.HandleDrawOfferAsync(GameId, true, cancellationToken);
    }

    public Task<bool> DeclineDrawAsync(CancellationToken cancellationToken = default)
    {
        return _client.HandleDrawOfferAsync(GameId, false, cancellationToken);
    }

    public Task<bool> SendChatAsync(string text, BoardChatRoom room = BoardChatRoom.Player,
        CancellationToken cancellationToken = default)
    {
        return _client.SendBoardChatAsync(GameId, text, room, cancellationToken);
    }

    private async Task<bool> ReconnectCoreAsync(CancellationToken cancellationToken)
    {
        await _connectionGate.WaitAsync(cancellationToken);
        try
        {
            if (_stopping || cancellationToken.IsCancellationRequested ||
                LatestState?.IsFinished == true)
            {
                return false;
            }

            await CloseCurrentStreamAsync();
            return await OpenStreamAsync(
                LichessGameConnectionState.Reconnecting, cancellationToken);
        }
        finally
        {
            _connectionGate.Release();
        }
    }

    private async Task<bool> OpenStreamAsync(LichessGameConnectionState openingState,
        CancellationToken cancellationToken)
    {
        SetConnectionState(openingState);

        ILichessBoardEventStream? stream = null;
        try
        {
            stream = await _client.CreateBoardGameStreamAsync(GameId, cancellationToken);
            cancellationToken.ThrowIfCancellationRequested();

            if (_stopping)
            {
                await DisposeUnstartedStreamAsync(stream);
                SetConnectionState(LichessGameConnectionState.Disconnected);
                return false;
            }

            _streamAuthenticationFailed = false;
            _stream = stream;
            stream.LineReceived += HandleStreamLine;
            stream.ErrorReceived += HandleStreamError;
            stream.Completed += HandleStreamCompleted;
            stream.Start();

            if (!ReferenceEquals(_stream, stream))
                return false;

            if (LatestState?.IsFinished == true)
                SetConnectionState(LichessGameConnectionState.Finished);
            else
                SetConnectionState(LichessGameConnectionState.Connected);

            return true;
        }
        catch (OperationCanceledException)
        {
            await DisposeUnstartedStreamAsync(stream);
            SetConnectionState(LichessGameConnectionState.Disconnected);
            throw new OperationCanceledException(cancellationToken);
        }
        catch (Exception ex)
        {
            await DisposeUnstartedStreamAsync(stream);
            SetConnectionState(LichessGameConnectionState.Disconnected);
#pragma warning disable CA2200 // ExceptionDispatchInfo is not whitelisted by s&box.
            throw ex;
#pragma warning restore CA2200
        }
    }

    private async Task DisposeUnstartedStreamAsync(ILichessBoardEventStream? stream)
    {
        if (stream == null)
            return;

        if (ReferenceEquals(_stream, stream))
            _stream = null;

        Unsubscribe(stream);
        await stream.DisposeAsync();
    }

    private async Task CloseCurrentStreamAsync()
    {
        var stream = _stream;
        _stream = null;
        if (stream == null)
            return;

        Unsubscribe(stream);
        await stream.DisposeAsync();
    }

    private void HandleStreamLine(ILichessBoardEventStream stream, JsonElement data)
    {
        if (!ReferenceEquals(stream, _stream))
            return;

        _automaticReconnectAttempt = 0;
        SetConnectionState(LichessGameConnectionState.Connected);

        try
        {
            var type = BoardEventParser.GetEventType(data);
            switch (type)
            {
                case "gameFull":
                {
                    var full = BoardEventParser.ParseGameFull(data);
                    if (full == null)
                        return;

                    GameFull = full;
                    if (PrepareInitialPosition(full.InitialFen))
                        ApplyServerState(full.State, true);

                    OnGameFull?.Invoke(this, full);
                    break;
                }
                case "gameState":
                    if (_initialPositionReady)
                        ApplyServerState(BoardEventParser.ParseGameState(data), false);
                    break;
                case "chatLine":
                {
                    var chat = BoardEventParser.ParseChatLine(data);
                    if (chat != null)
                        OnChatLine?.Invoke(this, chat);
                    break;
                }
            }
        }
        catch (Exception ex)
        {
            OnError?.Invoke(this, ex);
        }
    }

    private void HandleStreamError(ILichessBoardEventStream stream, Exception exception)
    {
        if (!ReferenceEquals(stream, _stream))
            return;

        _streamAuthenticationFailed = IsAuthenticationFailure(exception);
        if (_streamAuthenticationFailed)
        {
            if (_lifetimeCancellation != null &&
                !_lifetimeCancellation.IsCancellationRequested)
            {
                _lifetimeCancellation.Cancel();
            }

            SetConnectionState(LichessGameConnectionState.Disconnected);
        }

        OnError?.Invoke(this, exception);
    }

    private void HandleStreamCompleted(ILichessBoardEventStream stream)
    {
        if (!ReferenceEquals(stream, _stream))
            return;

        _stream = null;
        Unsubscribe(stream);
        _ = DisposeCompletedStreamAsync(stream);

        var authenticationFailure = _streamAuthenticationFailed;
        _streamAuthenticationFailed = false;

        if (LatestState?.IsFinished == true)
        {
            SetConnectionState(LichessGameConnectionState.Finished);
            return;
        }

        if (_stopping || _lifetimeCancellation == null)
        {
            SetConnectionState(LichessGameConnectionState.Disconnected);
            return;
        }

        if (authenticationFailure)
        {
            _automaticReconnectRequested = false;
            SetConnectionState(LichessGameConnectionState.Disconnected);
            OnUnexpectedCompletion?.Invoke(this);
            return;
        }

        if (_lifetimeCancellation.IsCancellationRequested)
        {
            SetConnectionState(LichessGameConnectionState.Disconnected);
            return;
        }

        SetConnectionState(LichessGameConnectionState.Disconnected);
        if (_options.AutoReconnect)
            RequestAutomaticReconnect(_lifetimeCancellation.Token);

        OnUnexpectedCompletion?.Invoke(this);
    }

    private void RequestAutomaticReconnect(CancellationToken cancellationToken)
    {
        _automaticReconnectRequested = true;
        if (_automaticReconnectRunning)
            return;

        _automaticReconnectRequested = false;
        _automaticReconnectRunning = true;
        _reconnectTask = RunAutomaticReconnectAsync(cancellationToken);
    }

    private async Task DisposeCompletedStreamAsync(ILichessBoardEventStream stream)
    {
        try
        {
            await stream.DisposeAsync();
        }
        catch (Exception ex)
        {
            if (!_stopping)
                OnError?.Invoke(this, ex);
        }
    }

    private async Task RunAutomaticReconnectAsync(CancellationToken cancellationToken)
    {
        try
        {
            while (!cancellationToken.IsCancellationRequested && !_stopping &&
                   LatestState?.IsFinished != true)
            {
                TimeSpan delay;
                try
                {
                    delay = _options.GetReconnectDelay(_automaticReconnectAttempt);
                    _automaticReconnectAttempt++;
                }
                catch (Exception ex)
                {
                    OnError?.Invoke(this, ex);
                    return;
                }

                SetConnectionState(LichessGameConnectionState.Reconnecting);

                try
                {
                    await Task.Delay(delay, cancellationToken);
                    if (await ReconnectCoreAsync(cancellationToken))
                        return;
                }
                catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
                {
                    return;
                }
                catch (Exception ex)
                {
                    SetConnectionState(LichessGameConnectionState.Disconnected);
                    OnError?.Invoke(this, ex);

                    if (IsAuthenticationFailure(ex))
                    {
                        _automaticReconnectRequested = false;
                        if (_lifetimeCancellation != null &&
                            !_lifetimeCancellation.IsCancellationRequested)
                        {
                            _lifetimeCancellation.Cancel();
                        }

                        return;
                    }
                }
            }
        }
        finally
        {
            _automaticReconnectRunning = false;
            if (_automaticReconnectRequested &&
                !cancellationToken.IsCancellationRequested && !_stopping &&
                LatestState?.IsFinished != true)
            {
                RequestAutomaticReconnect(cancellationToken);
            }
            else if (cancellationToken.IsCancellationRequested || _stopping ||
                     LatestState?.IsFinished == true)
            {
                _automaticReconnectRequested = false;
            }
        }
    }

    private bool PrepareInitialPosition(string? initialFen)
    {
        var normalizedFen = string.IsNullOrWhiteSpace(initialFen)
            ? "startpos"
            : initialFen.Trim();

        _initialPositionReady = false;
        if (string.Equals(_appliedInitialFen, normalizedFen,
                StringComparison.OrdinalIgnoreCase))
        {
            _initialPositionReady = true;
            return true;
        }

        if (!RestoreSnapshot(_initialAdapterState))
            return false;

        if (!normalizedFen.Equals("startpos", StringComparison.OrdinalIgnoreCase))
        {
            if (_adapter is not IChessInitialPositionAdapter initialPositionAdapter ||
                !initialPositionAdapter.TrySetInitialPosition(normalizedFen))
            {
                RestoreSnapshot(_initialAdapterState);
                OnDesync?.Invoke(this,
                    "The board adapter does not support the game's non-starting FEN.");
                return false;
            }
        }

        _appliedInitialFen = normalizedFen;
        _authoritativeInitialState = SafeExportState();
        _lastConfirmedState = _authoritativeInitialState;
        _moveHistory.Clear();
        SetPendingLocalMove(null, null);
        _initialPositionReady = true;
        return true;
    }

    private void ApplyServerState(BoardGameState? state, bool fromGameFull)
    {
        if (state == null)
            return;

        var oldWhiteDraw = WhiteOfferingDraw;
        var oldBlackDraw = BlackOfferingDraw;
        LatestState = state;

        var serverMoves = state.MoveList;
        if (!IsCompatiblePrefix(serverMoves))
        {
            RebuildAuthoritativeHistory(
                serverMoves, "Server move list diverged from confirmed local history.");
        }
        else
        {
            ApplyNewServerMoves(serverMoves, fromGameFull);
        }

        OnClockUpdate?.Invoke(this, state.Clock);
        OnStateUpdated?.Invoke(this, state);

        if (oldWhiteDraw != WhiteOfferingDraw || oldBlackDraw != BlackOfferingDraw)
        {
            OnDrawOfferChanged?.Invoke(
                this, WhiteOfferingDraw, BlackOfferingDraw);
        }

        if (state.IsFinished)
        {
            SetConnectionState(LichessGameConnectionState.Finished);
            if (_lifetimeCancellation != null &&
                !_lifetimeCancellation.IsCancellationRequested)
            {
                _lifetimeCancellation.Cancel();
            }

            if (!_gameOverRaised)
            {
                _gameOverRaised = true;
                OnGameOver?.Invoke(this, state);
            }
        }
    }

    private void ApplyNewServerMoves(IReadOnlyList<string> serverMoves, bool fromGameFull)
    {
        for (var index = _moveHistory.Count; index < serverMoves.Count; index++)
        {
            var move = serverMoves[index];

            if (_pendingLocalMove != null)
            {
                if (string.Equals(move, _pendingLocalMove,
                        StringComparison.OrdinalIgnoreCase))
                {
                    _moveHistory.Add(move);
                    SetPendingLocalMove(null, null);
                    _lastConfirmedState = SafeExportState();
                    continue;
                }

                RollBackPendingMove(
                    "Server confirmed a different move than the pending local move.");
            }

            var beforeRemoteMove = SafeExportState();
            if (!_adapter.TryApplyRemoteMove(move))
            {
                RestoreSnapshot(beforeRemoteMove);
                OnDesync?.Invoke(this,
                    "Board adapter rejected remote move " + move + ".");
                return;
            }

            _moveHistory.Add(move);
            _lastConfirmedState = SafeExportState();

            if (!fromGameFull && IsOpponentPly(index))
                OnOpponentMove?.Invoke(this, move);
        }
    }

    private bool RebuildAuthoritativeHistory(IReadOnlyList<string> serverMoves, string reason)
    {
        SetPendingLocalMove(null, null);
        if (!RestoreSnapshot(_authoritativeInitialState))
            return false;

        _moveHistory.Clear();
        _lastConfirmedState = _authoritativeInitialState;

        foreach (var move in serverMoves)
        {
            var beforeMove = SafeExportState();
            if (!_adapter.TryApplyRemoteMove(move))
            {
                RestoreSnapshot(beforeMove);
                _lastConfirmedState = SafeExportState();
                OnDesync?.Invoke(this,
                    reason + " Board adapter rejected authoritative move " + move + ".");
                return false;
            }

            _moveHistory.Add(move);
            _lastConfirmedState = SafeExportState();
        }

        OnDesync?.Invoke(this, reason + " Rebuilt from the authoritative history.");
        return true;
    }

    private bool IsCompatiblePrefix(IReadOnlyList<string> serverMoves)
    {
        if (serverMoves.Count < _moveHistory.Count)
            return false;

        for (var i = 0; i < _moveHistory.Count; i++)
        {
            if (!string.Equals(serverMoves[i], _moveHistory[i],
                    StringComparison.OrdinalIgnoreCase))
            {
                return false;
            }
        }

        return true;
    }

    private bool IsOpponentPly(int plyIndex)
    {
        return MyColor switch
        {
            "white" => plyIndex % 2 == 1,
            "black" => plyIndex % 2 == 0,
            _ => true
        };
    }

    private void RollBackPendingMove(string reason)
    {
        if (_pendingLocalSnapshot != null)
            RestoreSnapshot(_pendingLocalSnapshot);

        SetPendingLocalMove(null, null);
        OnDesync?.Invoke(this, reason);
    }

    private void SetPendingLocalMove(string? move, string? snapshot)
    {
        var changed = !string.Equals(
            _pendingLocalMove, move, StringComparison.OrdinalIgnoreCase);
        _pendingLocalMove = move;
        _pendingLocalSnapshot = snapshot;

        if (changed)
            OnPendingLocalMoveChanged?.Invoke(this, move);
    }

    private string SafeExportState()
    {
        try
        {
            return _adapter.ExportState() ?? string.Empty;
        }
        catch (Exception ex)
        {
            OnError?.Invoke(this, ex);
            return string.Empty;
        }
    }

    private bool RestoreSnapshot(string? snapshot)
    {
        try
        {
            _adapter.ImportState(snapshot ?? string.Empty);
            return true;
        }
        catch (Exception ex)
        {
            OnError?.Invoke(this, ex);
            return false;
        }
    }

    private void SetConnectionState(LichessGameConnectionState state)
    {
        if (_connectionState == state)
            return;

        _connectionState = state;
        OnConnectionStateChanged?.Invoke(this, state);
    }

    private void Unsubscribe(ILichessBoardEventStream stream)
    {
        stream.LineReceived -= HandleStreamLine;
        stream.ErrorReceived -= HandleStreamError;
        stream.Completed -= HandleStreamCompleted;
    }

    private static bool IsAuthenticationFailure(Exception exception)
    {
        if (exception is not HttpRequestException httpException)
            return false;

        return httpException.StatusCode == HttpStatusCode.Unauthorized ||
               httpException.StatusCode == HttpStatusCode.Forbidden;
    }

    private void ThrowIfDisposed()
    {
        if (_disposed)
            throw new ObjectDisposedException(nameof(LichessGameSession));
    }
}