using Vanguard.Rules.Cards;
using Vanguard.Rules.Engine;
using Vanguard.Server.Configuration;
using Vanguard.Shared.Projection;
using Vanguard.Shared.Protocol;
namespace Vanguard.Server.Matches;
///
/// Один матч. Все входы — интенты, подключения и тики — проходят через
/// один семафор, поэтому состояние движка меняется строго последовательно.
/// Ничего из ASP.NET Core и WebSocket внутри не лежит: только IMatchConnection.
///
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 Deck { get; init; }
public required int Mmr { get; init; }
public IMatchConnection? Connection { get; set; }
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 _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 ILogger _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;
public MatchGrain(
MatchSetup setup,
CardCatalog catalog,
GameOptions options,
TimeProvider time,
IMatchResultSink results,
ILogger logger)
{
_matchId = setup.MatchId;
_mode = setup.Mode;
_options = options;
_time = time;
_results = results;
_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 = 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();
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 AttachAsync(
int seat,
string playerId,
IMatchConnection connection,
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();
if (state.Connection is { } old && !ReferenceEquals(old, connection))
{
_logger.LogInformation(
"Match {MatchId}: seat {Seat} replaced a live connection.",
_matchId, seat);
try
{
await old.CloseAsync(4409, "replaced");
}
catch (Exception exception)
{
_logger.LogDebug(exception, "Match {MatchId}: closing old connection failed.", _matchId);
}
}
state.Connection = connection;
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);
return true;
}
catch (Exception exception)
{
_logger.LogDebug(exception, "Match {MatchId}: attach for seat {Seat} failed.", _matchId, seat);
_seats[seat].Connection = null;
_seats[seat].AbsentSince ??= _time.GetUtcNow();
return false;
}
finally
{
_gate.Release();
}
}
public async Task DetachAsync(int seat, IMatchConnection connection)
{
if (seat is not (0 or 1))
return;
await _gate.WaitAsync();
try
{
var state = _seats[seat];
if (!ReferenceEquals(state.Connection, connection))
return; // место уже занято новым соединением
state.Connection = null;
state.AbsentSince = _time.GetUtcNow();
_logger.LogInformation("Match {MatchId}: seat {Seat} detached.", _matchId, seat);
}
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;
if (isConcede)
{
_engine.Finish(1 - seat);
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);
}
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 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);
await PushEventsAsync(0);
await PushEventsAsync(1);
await SendClockToAllAsync(now);
if (_engine.State.IsFinished)
await OnFinishedAsync(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 (_seats[seat].Connection is null)
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 (state.Connection is null && 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);
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);
return;
}
_seats[active].BankMs = remaining;
_turnDeadline = now;
}
if (now - _lastClockSent >= TimeSpan.FromSeconds(_options.ClockSyncSeconds))
await SendClockToAllAsync(now);
}
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 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 (_seats[seat].Connection is null)
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 (_seats[seat].Connection is not null)
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 SendToSeatAsync(int seat, ServerFrame frame)
{
var state = _seats[seat];
if (state.Connection 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);
state.Connection = null;
state.AbsentSince ??= _time.GetUtcNow();
return false;
}
}
private async Task FinishFromServerAsync(int winner, DateTimeOffset now)
{
_engine.Finish(winner);
RefreshSummary();
await PushEventsAsync(0);
await PushEventsAsync(1);
await SendClockToAllAsync(now);
await OnFinishedAsync(now);
}
private async Task OnFinishedAsync(DateTimeOffset now)
{
if (_resultReported)
return;
_resultReported = true;
_finishedAt = now;
RefreshSummary();
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));
}
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(),
};
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);
}
}