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

576 lines
17 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.Server.Configuration;
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 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<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 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;
public MatchGrain(
MatchSetup setup,
CardCatalog catalog,
GameOptions options,
TimeProvider time,
IMatchResultSink results,
ILogger<MatchGrain> 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<bool> 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<bool> 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);
}
}