using Vanguard.Rules.Cards; using Vanguard.Rules.Engine; using Vanguard.Rules.Replay; using Vanguard.Server.Configuration; using Vanguard.Server.Storage; using Vanguard.Shared.Projection; using Vanguard.Shared.Protocol; namespace Vanguard.Server.Matches; /// /// Один матч. Все входы — интенты, подключения и тики — проходят через /// один семафор, поэтому состояние движка меняется строго последовательно. /// Ничего из ASP.NET Core и WebSocket внутри не лежит: только IMatchConnection. /// public sealed class MatchGrain : IMatchGrain { private readonly record struct IntentRecord( bool Accepted, IntentRejectCode Code, string? Reason, long EventSeq); private sealed class Seat { public required string PlayerId { get; init; } public required IReadOnlyList Deck { get; init; } public required int Mmr { get; init; } public DateTimeOffset? AbsentSince { get; set; } public bool EverConnected { get; set; } public long SentSeq { get; set; } public long BankMs { get; set; } } private readonly SemaphoreSlim _gate = new(1, 1); private readonly Dictionary _intents = new(StringComparer.Ordinal); private readonly GameEngine _engine; private readonly Seat[] _seats; private readonly GameOptions _options; private readonly TimeProvider _time; private readonly IMatchResultSink _results; private readonly IMatchStore _store; private readonly IMatchConnectionRegistry _connections; private readonly ILogger _logger; private readonly string _matchId; private readonly GameMode _mode; private readonly DateTimeOffset _createdAt; private volatile MatchSummary _summary = null!; private bool _started; private DateTimeOffset _turnDeadline; private DateTimeOffset _lastClockSent; private DateTimeOffset? _finishedAt; private bool _resultReported; private bool _restoring; private MatchFinishReason _finishReason = MatchFinishReason.Normal; private const string ConcedeCommand = "concede"; public MatchGrain( MatchSetup setup, CardCatalog catalog, GameOptions options, TimeProvider time, IMatchResultSink results, IMatchStore store, IMatchConnectionRegistry connections, ILogger logger) { _matchId = setup.MatchId; _mode = setup.Mode; _options = options; _time = time; _results = results; _store = store; _connections = connections; _logger = logger; _createdAt = time.GetUtcNow(); _seats = new[] { new Seat { PlayerId = setup.Seat0.PlayerId, Deck = setup.Seat0.Deck, Mmr = setup.Seat0.Mmr, BankMs = options.BankSeconds * 1000L, AbsentSince = _createdAt, }, new Seat { PlayerId = setup.Seat1.PlayerId, Deck = setup.Seat1.Deck, Mmr = setup.Seat1.Mmr, BankMs = options.BankSeconds * 1000L, AbsentSince = _createdAt, }, }; var seed = setup.Seed ?? unchecked((ulong)Random.Shared.NextInt64()); _engine = new GameEngine( catalog, seed, setup.Seat0.Deck.ToList(), setup.Seat1.Deck.ToList(), player0Name: setup.Seat0.PlayerId, player1Name: setup.Seat1.PlayerId); _engine.Start(setup.FirstPlayer); RefreshSummary(); } public string MatchId => _matchId; public MatchSummary Summary => _summary; public bool HasPlayer(string playerId) => _seats[0].PlayerId == playerId || _seats[1].PlayerId == playerId; public async Task AttachAsync( int seat, string playerId, long resumeFromSeq) { if (seat is not (0 or 1) || _seats[seat].PlayerId != playerId) { _logger.LogWarning( "Match {MatchId}: player {PlayerId} tried to take seat {Seat}.", _matchId, playerId, seat); return false; } await _gate.WaitAsync(); try { var state = _seats[seat]; var now = _time.GetUtcNow(); var connection = _connections.Get(_matchId, seat); if (connection is null) { _logger.LogWarning( "Match {MatchId}: seat {Seat} attached without a registered connection.", _matchId, seat); return false; } state.AbsentSince = null; state.EverConnected = true; if (!_started && _seats[0].EverConnected && _seats[1].EverConnected) StartClock(now); var to = _engine.Events.Count; var resumed = resumeFromSeq > 0 && resumeFromSeq <= to; await connection.SendAsync(new ServerHello { ProtocolVersion = ProtocolConstants.Version, MatchId = _matchId, PlayerIndex = seat, Resumed = resumed, EventSeq = to, ServerTimeMs = now.ToUnixTimeMilliseconds(), }); if (resumed) await SendEventsAsync(seat, resumeFromSeq + 1); else await SendSnapshotAsync(seat, now); await SendClockAsync(seat, now); _logger.LogInformation( "Match {MatchId}: seat {Seat} attached (resume from {Seq}, now {Now}).", _matchId, seat, resumeFromSeq, to); await PersistHeaderAsync(); return true; } catch (Exception exception) { _logger.LogDebug(exception, "Match {MatchId}: attach for seat {Seat} failed.", _matchId, seat); _seats[seat].AbsentSince ??= _time.GetUtcNow(); return false; } finally { _gate.Release(); } } /// /// Удобная обёртка для встроенного (не-Orleans) режима и тестов: /// сама регистрирует соединение перед attach. /// public async Task AttachAsync( int seat, string playerId, IMatchConnection connection, long resumeFromSeq) { var previous = _connections.Register(_matchId, seat, connection); if (previous is not null && !ReferenceEquals(previous, connection)) { try { await previous.CloseAsync(4409, "replaced"); } catch (Exception exception) { _logger.LogDebug(exception, "Match {MatchId}: closing old connection failed.", _matchId); } } var attached = await AttachAsync(seat, playerId, resumeFromSeq); if (!attached) _connections.Unregister(_matchId, seat, connection); return attached; } public async Task DetachAsync(int seat) { if (seat is not (0 or 1)) return; await _gate.WaitAsync(); try { var state = _seats[seat]; state.AbsentSince = _time.GetUtcNow(); _logger.LogInformation("Match {MatchId}: seat {Seat} detached.", _matchId, seat); await PersistHeaderAsync(); } finally { _gate.Release(); } } /// Снять конкретное соединение; если оно уже заменено — ничего не делать. public async Task DetachAsync(int seat, IMatchConnection connection) { if (_connections.Unregister(_matchId, seat, connection)) await DetachAsync(seat); } public async Task LeaveAsync(int seat) { if (seat is not (0 or 1)) return; await _gate.WaitAsync(); try { if (_engine.State.IsFinished) return; _logger.LogInformation( "Match {MatchId}: seat {Seat} left the match, opponent wins.", _matchId, seat); await FinishFromServerAsync(1 - seat, _time.GetUtcNow(), MatchFinishReason.Leave); } finally { _gate.Release(); } } public async Task SubmitAsync(int seat, ClientIntent intent) { if (seat is not (0 or 1)) return; await _gate.WaitAsync(); try { var intentId = intent.IntentId ?? string.Empty; var now = _time.GetUtcNow(); if (intentId.Length is 0 or > 64 || intent.Intent is null) { await SendResultAsync( seat, intentId, accepted: false, IntentRejectCode.Malformed, "Некорректный intentId или полезная нагрузка.", _engine.Events.Count, duplicate: false); return; } if (_intents.TryGetValue(intentId, out var seen)) { await SendResultAsync( seat, intentId, seen.Accepted, seen.Code, seen.Reason, seen.EventSeq, duplicate: true); return; } if (_engine.State.IsFinished) { await RejectAsync(seat, intentId, IntentRejectCode.MatchFinished, "Партия завершена."); return; } var isConcede = intent.Intent is ConcedeIntent; if (!isConcede && seat != _engine.State.ActivePlayer) { await RejectAsync(seat, intentId, IntentRejectCode.NotYourTurn, "Сейчас решает другой игрок."); return; } var decisionBefore = _engine.State.ActivePlayer; CommandResult result; var commandText = string.Empty; if (isConcede) { _finishReason = MatchFinishReason.Concede; _engine.Finish(1 - seat); commandText = ConcedeCommand; result = CommandResult.Ok(); } else { var command = IntentMapping.ToCommand(intent.Intent); if (command is null) { await RejectAsync(seat, intentId, IntentRejectCode.NotSupported, "Интент не поддержан."); return; } result = _engine.Execute(command); if (result.Accepted) commandText = CommandCodec.Encode(command); } var code = result.Accepted ? IntentRejectCode.None : IntentRejectCode.Illegal; var record = new IntentRecord(result.Accepted, code, result.Reason, _engine.Events.Count); _intents[intentId] = record; RefreshSummary(); await PersistIntentAsync(new PersistedIntent( intentId, seat, commandText, record.Accepted, record.Code, record.Reason, record.EventSeq)); await SendResultAsync( seat, intentId, record.Accepted, record.Code, record.Reason, record.EventSeq, duplicate: false); if (!record.Accepted) return; if (!_engine.State.IsFinished && _engine.State.ActivePlayer != decisionBefore) ResetTurnClock(now); // историю и награды пишем до финального события: клиент, увидев // GameFinished, сразу может запросить историю и реплей if (_engine.State.IsFinished) await OnFinishedAsync(now); await PushEventsAsync(0); await PushEventsAsync(1); await SendClockToAllAsync(now); } finally { _gate.Release(); } } public async Task ResyncAsync(int seat, long fromSeq, bool fullSnapshot) { if (seat is not (0 or 1)) return; await _gate.WaitAsync(); try { if (!_connections.IsConnected(_matchId, seat)) return; var now = _time.GetUtcNow(); var to = _engine.Events.Count; if (fullSnapshot || fromSeq < 1 || fromSeq > to + 1) await SendSnapshotAsync(seat, now); else await SendEventsAsync(seat, fromSeq); await SendClockAsync(seat, now); } finally { _gate.Release(); } } public async Task TickAsync(DateTimeOffset now) { await _gate.WaitAsync(); try { if (_engine.State.IsFinished) return; for (var seat = 0; seat < _seats.Length; seat++) { var state = _seats[seat]; if (!_connections.IsConnected(_matchId, seat) && state.AbsentSince is { } since) { if (now - since < TimeSpan.FromSeconds(_options.AbsenceSeconds)) continue; _logger.LogInformation( "Match {MatchId}: seat {Seat} absent for too long, opponent wins.", _matchId, seat); await FinishFromServerAsync(1 - seat, now, MatchFinishReason.Absence); return; } } if (!_started) return; var active = _engine.State.ActivePlayer; if (now > _turnDeadline) { var overMs = (long)(now - _turnDeadline).TotalMilliseconds; var remaining = _seats[active].BankMs - overMs; if (remaining <= 0) { _seats[active].BankMs = 0; _logger.LogInformation( "Match {MatchId}: seat {Seat} ran out of time.", _matchId, active); await FinishFromServerAsync(1 - active, now, MatchFinishReason.Timeout); return; } _seats[active].BankMs = remaining; _turnDeadline = now; } if (now - _lastClockSent >= TimeSpan.FromSeconds(_options.ClockSyncSeconds)) { await SendClockToAllAsync(now); await PersistHeaderAsync(); } } finally { _gate.Release(); } } private async Task RejectAsync( int seat, string intentId, IntentRejectCode code, string reason) { _intents[intentId] = new IntentRecord(false, code, reason, _engine.Events.Count); await PersistIntentAsync(new PersistedIntent( intentId, seat, string.Empty, false, code, reason, _engine.Events.Count)); await SendResultAsync( seat, intentId, accepted: false, code, reason, _engine.Events.Count, duplicate: false); } private async Task SendResultAsync( int seat, string intentId, bool accepted, IntentRejectCode code, string? reason, long eventSeq, bool duplicate) { await SendToSeatAsync(seat, new ServerIntentResult { IntentId = intentId, Accepted = accepted, Code = code, Reason = reason, EventSeq = eventSeq, Duplicate = duplicate, }); } private async Task PushEventsAsync(int seat) { if (!_connections.IsConnected(_matchId, seat)) return; var from = _seats[seat].SentSeq + 1; var to = _engine.Events.Count; if (from > to) return; await SendEventsAsync(seat, from); } private async Task SendEventsAsync(int seat, long fromSeq) { var to = _engine.Events.Count; var from = Math.Max(1, fromSeq); var events = EventProjection.ProjectRange(_engine.Events, seat, from); _seats[seat].SentSeq = to; await SendToSeatAsync(seat, new ServerEvents { FromSeq = from, ToSeq = to, Events = events, }); } private async Task SendSnapshotAsync(int seat, DateTimeOffset now) { var to = _engine.Events.Count; var view = MatchProjection.ForViewer(_engine, seat, _matchId, to, BuildClock(now)); _seats[seat].SentSeq = to; await SendToSeatAsync(seat, new ServerSnapshot { View = view }); } private async Task SendClockToAllAsync(DateTimeOffset now) { _lastClockSent = now; var clock = BuildClock(now); var eventSeq = _engine.Events.Count; for (var seat = 0; seat < _seats.Length; seat++) { if (_connections.IsConnected(_matchId, seat)) await SendToSeatAsync(seat, new ServerClock { Clock = clock, EventSeq = eventSeq }); } } private async Task SendClockAsync(int seat, DateTimeOffset now) => await SendToSeatAsync(seat, new ServerClock { Clock = BuildClock(now), EventSeq = _engine.Events.Count, }); private async Task SendToSeatAsync(int seat, ServerFrame frame) { var state = _seats[seat]; if (_connections.Get(_matchId, seat) is not { } connection) return false; try { await connection.SendAsync(frame); return true; } catch (Exception exception) { _logger.LogDebug(exception, "Match {MatchId}: send to seat {Seat} failed.", _matchId, seat); _connections.Unregister(_matchId, seat, connection); state.AbsentSince ??= _time.GetUtcNow(); return false; } } private async Task FinishFromServerAsync( int winner, DateTimeOffset now, MatchFinishReason reason) { _finishReason = reason; _engine.Finish(winner); RefreshSummary(); await OnFinishedAsync(now); await PushEventsAsync(0); await PushEventsAsync(1); await SendClockToAllAsync(now); } private async Task OnFinishedAsync(DateTimeOffset now) { if (_resultReported) return; _resultReported = true; _finishedAt = now; RefreshSummary(); await PersistHeaderAsync(); var winner = _engine.State.Winner ?? -1; if (winner is not (0 or 1)) return; try { await _results.RecordAsync(new MatchResult( _matchId, _mode, _seats[0].PlayerId, _seats[1].PlayerId, winner, _finishReason)); } catch (Exception exception) { _logger.LogError(exception, "Match {MatchId}: recording the result failed.", _matchId); } } private void StartClock(DateTimeOffset now) { _started = true; _turnDeadline = now.AddSeconds(_options.TurnSeconds); _lastClockSent = now.AddSeconds(-_options.ClockSyncSeconds); } private void ResetTurnClock(DateTimeOffset now) { if (!_started) return; _turnDeadline = now.AddSeconds(_options.TurnSeconds); } private ClockView BuildClock(DateTimeOffset now) => new() { DecisionPlayer = _engine.State.ActivePlayer, TurnDeadlineMs = _started && !_engine.State.IsFinished ? _turnDeadline.ToUnixTimeMilliseconds() : 0, Bank0Ms = _seats[0].BankMs, Bank1Ms = _seats[1].BankMs, IsRunning = _started && !_engine.State.IsFinished, ServerTimeMs = now.ToUnixTimeMilliseconds(), }; /// Снимок заголовка для хранилища. internal MatchHeader BuildHeader() => new( _matchId, _mode, _seats[0].PlayerId, _seats[1].PlayerId, _seats[0].Deck, _seats[1].Deck, _engine.State.Seed, _createdAt, _started, _seats[0].BankMs, _seats[1].BankMs, _started ? _turnDeadline : null, _seats[0].AbsentSince, _seats[1].AbsentSince, _started ? _lastClockSent : _createdAt, _engine.State.IsFinished, _engine.State.Winner ?? -1, _finishedAt, _finishReason, _engine.Events.Count); private async Task PersistHeaderAsync() { if (_restoring) return; try { await _store.UpdateHeaderAsync(BuildHeader()); } catch (Exception exception) { _logger.LogError(exception, "Match {MatchId}: persisting header failed.", _matchId); } } private async Task PersistIntentAsync(PersistedIntent intent) { if (_restoring) return; try { await _store.AppendIntentAsync(BuildHeader(), intent); } catch (Exception exception) { _logger.LogError(exception, "Match {MatchId}: persisting intent failed.", _matchId); } } /// /// Восстановить матч из снапшота: заново проиграть принятые команды на /// детерминированном движке. Состояние выборов и продолжений /// восстанавливается само; делегаты никуда не сериализуются. /// public async Task RestoreAsync(MatchSnapshot snapshot) { await _gate.WaitAsync(); try { _restoring = true; var header = snapshot.Header; if (header.MatchId != _matchId) throw new InvalidOperationException( $"Снапшот {header.MatchId} не принадлежит матчу {_matchId}."); foreach (var intent in snapshot.Intents) { if (!intent.Accepted) { _intents[intent.IntentId] = new IntentRecord( false, intent.Code, intent.Reason, intent.EventSeq); continue; } if (intent.Command == ConcedeCommand) { _engine.Finish(intent.Seat == 0 ? 1 : 0); } else { if (string.IsNullOrWhiteSpace(intent.Command)) throw new InvalidOperationException( $"Принятый интент {intent.IntentId} не содержит команды."); var command = CommandCodec.Decode(intent.Command); var result = _engine.Execute(command); if (!result.Accepted) throw new InvalidOperationException( $"Реплей отклонил {intent.IntentId}: {result.Reason}"); } _intents[intent.IntentId] = new IntentRecord( true, IntentRejectCode.None, null, _engine.Events.Count); } if (_engine.Events.Count != header.EventSeq) throw new InvalidOperationException( $"Рассинхрон реплея {header.MatchId}: событий {_engine.Events.Count}, " + $"в снапшоте {header.EventSeq}."); _started = header.Started; _seats[0].BankMs = header.Bank0Ms; _seats[1].BankMs = header.Bank1Ms; _seats[0].EverConnected = header.Started; _seats[1].EverConnected = header.Started; var now = _time.GetUtcNow(); _seats[0].AbsentSince = now; _seats[1].AbsentSince = now; _lastClockSent = header.LastClockSent; _finishedAt = header.FinishedAt; _resultReported = header.IsFinished; _finishReason = header.FinishReason; if (_started) _turnDeadline = header.TurnDeadline ?? now.AddSeconds(_options.TurnSeconds); RefreshSummary(); _logger.LogInformation( "Match {MatchId}: restored {Intents} intents, sequence {Seq}.", _matchId, snapshot.Intents.Count, header.EventSeq); } finally { _restoring = false; _gate.Release(); } } private void RefreshSummary() { _summary = new MatchSummary( _matchId, _mode, _seats[0].PlayerId, _seats[1].PlayerId, _engine.State.IsFinished, _engine.State.Winner ?? -1, _engine.Events.Count, _createdAt, _finishedAt); } }