using Microsoft.Extensions.Logging; using Orleans; using Orleans.Concurrency; using Vanguard.Rules.Cards; using Vanguard.Server.Configuration; using Vanguard.Server.Storage; using Vanguard.Shared.Protocol; namespace Vanguard.Server.Matches.Orleans; /// /// Единственная активная копия матча. Доменная логика остаётся в /// ; этот класс отвечает за активацию, восстановление /// из хранилища и сериализуемую границу Orleans. /// /// [KeepAlive] важен: если Orleans выгрузит грана, живые WebSocket-соединения /// останутся в реестре без владельца. Долгоживущие матчи и так ограничены TTL, /// а после рестарта сервера поднимаются из снапшота. /// [KeepAlive] public sealed class MatchActorGrain : Grain, IMatchActor { private readonly CardCatalog _catalog; 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 string _matchId = string.Empty; private MatchGrain? _match; public MatchActorGrain( CardCatalog catalog, GameOptions options, TimeProvider time, IMatchResultSink results, IMatchStore store, IMatchConnectionRegistry connections, ILogger logger) { _catalog = catalog; _options = options; _time = time; _results = results; _store = store; _connections = connections; _logger = logger; } public override async Task OnActivateAsync(CancellationToken cancellationToken) { _matchId = this.GetPrimaryKeyString(); // Новый матч ещё не в хранилище: активация просто оставляет его // неинициализированным до вызова InitializeAsync из регистратуры. var snapshot = await _store.GetAsync(_matchId); if (snapshot is not null) await RestoreInternalAsync(snapshot); await base.OnActivateAsync(cancellationToken); } public async Task InitializeAsync(MatchSetupData setup) { if (_match is not null) return; var setupDomain = setup.ToSetup(); _match = new MatchGrain( setupDomain, _catalog, _options, _time, _results, _store, _connections, _logger); await _store.CreateAsync(_match.BuildHeader()); } public async Task EnsureInitializedAsync() { if (_match is not null) return; var snapshot = await _store.GetAsync(_matchId); if (snapshot is null) throw new InvalidOperationException($"Матч {_matchId} не найден в хранилище."); await RestoreInternalAsync(snapshot); } public async Task AttachAsync(int seat, string playerId, long resumeFromSeq) { var match = await RequireAsync(); return await match.AttachAsync(seat, playerId, resumeFromSeq); } public async Task DetachAsync(int seat) { var match = await RequireAsync(); await match.DetachAsync(seat); } public async Task LeaveAsync(int seat) { var match = await RequireAsync(); await match.LeaveAsync(seat); } public async Task SubmitAsync(int seat, byte[] intentPayload) { var match = await RequireAsync(); var frame = ProtocolSerializer.DecodeClient(intentPayload); if (frame is not ClientIntent intent) throw new InvalidOperationException("Ожидался ClientIntent."); await match.SubmitAsync(seat, intent); } public async Task ResyncAsync(int seat, long fromSeq, bool fullSnapshot) { var match = await RequireAsync(); await match.ResyncAsync(seat, fromSeq, fullSnapshot); } public async Task TickAsync(long nowUnixMs) { var match = await RequireAsync(); await match.TickAsync(DateTimeOffset.FromUnixTimeMilliseconds(nowUnixMs)); } public async Task GetSummaryAsync() { var match = await RequireAsync(); return MatchSummaryData.From(match.Summary); } public async Task HasPlayerAsync(string playerId) { var match = await RequireAsync(); return match.HasPlayer(playerId); } private async Task RequireAsync() { await EnsureInitializedAsync(); return _match!; } private async Task RestoreInternalAsync(MatchSnapshot snapshot) { var setup = MatchSetup.FromHeader(snapshot.Header); _match = new MatchGrain( setup, _catalog, _options, _time, _results, _store, _connections, _logger); await _match.RestoreAsync(snapshot); } }