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);
}
}