171 lines
5.2 KiB
C#
171 lines
5.2 KiB
C#
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;
|
||
|
||
/// <summary>
|
||
/// Единственная активная копия матча. Доменная логика остаётся в
|
||
/// <see cref="MatchGrain"/>; этот класс отвечает за активацию, восстановление
|
||
/// из хранилища и сериализуемую границу Orleans.
|
||
///
|
||
/// [KeepAlive] важен: если Orleans выгрузит грана, живые WebSocket-соединения
|
||
/// останутся в реестре без владельца. Долгоживущие матчи и так ограничены TTL,
|
||
/// а после рестарта сервера поднимаются из снапшота.
|
||
/// </summary>
|
||
[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<MatchGrain> _logger;
|
||
|
||
private string _matchId = string.Empty;
|
||
private MatchGrain? _match;
|
||
|
||
public MatchActorGrain(
|
||
CardCatalog catalog,
|
||
GameOptions options,
|
||
TimeProvider time,
|
||
IMatchResultSink results,
|
||
IMatchStore store,
|
||
IMatchConnectionRegistry connections,
|
||
ILogger<MatchGrain> 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<bool> 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<MatchSummaryData> GetSummaryAsync()
|
||
{
|
||
var match = await RequireAsync();
|
||
return MatchSummaryData.From(match.Summary);
|
||
}
|
||
|
||
public async Task<bool> HasPlayerAsync(string playerId)
|
||
{
|
||
var match = await RequireAsync();
|
||
return match.HasPlayer(playerId);
|
||
}
|
||
|
||
private async Task<MatchGrain> 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);
|
||
}
|
||
}
|