VanguardReMaster/server/src/Vanguard.Server/Matches/MatchGrain.cs

797 lines
25 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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