797 lines
25 KiB
C#
797 lines
25 KiB
C#
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;
|
||
|
||
/// <summary>
|
||
/// Один матч. Все входы — интенты, подключения и тики — проходят через
|
||
/// один семафор, поэтому состояние движка меняется строго последовательно.
|
||
/// Ничего из ASP.NET Core и WebSocket внутри не лежит: только IMatchConnection.
|
||
/// </summary>
|
||
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<string> 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<string, IntentRecord> _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<MatchGrain> _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<MatchGrain> 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<bool> 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();
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Удобная обёртка для встроенного (не-Orleans) режима и тестов:
|
||
/// сама регистрирует соединение перед attach.
|
||
/// </summary>
|
||
public async Task<bool> 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();
|
||
}
|
||
}
|
||
|
||
/// <summary>Снять конкретное соединение; если оно уже заменено — ничего не делать.</summary>
|
||
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<bool> 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(),
|
||
};
|
||
|
||
/// <summary>Снимок заголовка для хранилища.</summary>
|
||
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);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Восстановить матч из снапшота: заново проиграть принятые команды на
|
||
/// детерминированном движке. Состояние выборов и продолжений
|
||
/// восстанавливается само; делегаты никуда не сериализуются.
|
||
/// </summary>
|
||
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);
|
||
}
|
||
}
|