diff --git a/docker-compose.yml b/docker-compose.yml index d7089a0..435274a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -37,6 +37,7 @@ services: Storage__Provider: Postgres Storage__ConnectionString: Host=postgres;Port=5432;Database=vanguard;Username=vanguard;Password=vanguard Redis__Configuration: redis:6379 + Orleans__Enabled: "true" Jwt__SigningKey: ${VANGUARD_JWT_KEY:-dev-only-vanguard-remaster-signing-key-change-me} depends_on: postgres: diff --git a/server/src/Vanguard.Server/Configuration/OrleansOptions.cs b/server/src/Vanguard.Server/Configuration/OrleansOptions.cs new file mode 100644 index 0000000..4f180de --- /dev/null +++ b/server/src/Vanguard.Server/Configuration/OrleansOptions.cs @@ -0,0 +1,13 @@ +namespace Vanguard.Server.Configuration; + +/// +/// Оркестрация матчей через Orleans. В тестах и локальной разработке по +/// умолчанию выключена: матчи живут в одном процессе, как раньше. +/// В docker-compose включена, чтобы граны активировались в silo. +/// +public sealed class OrleansOptions +{ + public const string SectionName = "Orleans"; + + public bool Enabled { get; set; } +} diff --git a/server/src/Vanguard.Server/Matches/IMatchGrain.cs b/server/src/Vanguard.Server/Matches/IMatchGrain.cs index d53589f..b454433 100644 --- a/server/src/Vanguard.Server/Matches/IMatchGrain.cs +++ b/server/src/Vanguard.Server/Matches/IMatchGrain.cs @@ -1,4 +1,5 @@ using Vanguard.Server.Configuration; +using Vanguard.Server.Storage; using Vanguard.Shared.Protocol; namespace Vanguard.Server.Matches; @@ -22,7 +23,15 @@ public sealed record MatchSetup( GameMode Mode, MatchSeatSetup Seat0, MatchSeatSetup Seat1, - ulong? Seed = null); + ulong? Seed = null) +{ + public static MatchSetup FromHeader(MatchHeader header) => new( + header.MatchId, + header.Mode, + new MatchSeatSetup(header.Player0Id, header.Deck0, 0), + new MatchSeatSetup(header.Player1Id, header.Deck1, 0), + header.Seed); +} /// Снимок и итог матча для регистратуры, health и записи результата. public sealed record MatchSummary( @@ -79,14 +88,10 @@ public interface IMatchGrain bool HasPlayer(string playerId); /// Подключить место. false — место не принадлежит игроку. - Task AttachAsync( - int seat, - string playerId, - IMatchConnection connection, - long resumeFromSeq); + Task AttachAsync(int seat, string playerId, long resumeFromSeq); /// Отключить место. Отсчёт отсутствия начинается здесь. - Task DetachAsync(int seat, IMatchConnection connection); + Task DetachAsync(int seat); /// Явный выход из матча: сразу поражение вышедшего. Task LeaveAsync(int seat); diff --git a/server/src/Vanguard.Server/Matches/MatchConnectionRegistry.cs b/server/src/Vanguard.Server/Matches/MatchConnectionRegistry.cs new file mode 100644 index 0000000..858759a --- /dev/null +++ b/server/src/Vanguard.Server/Matches/MatchConnectionRegistry.cs @@ -0,0 +1,61 @@ +using System.Collections.Concurrent; + +namespace Vanguard.Server.Matches; + +/// +/// Живые соединения по (matchId, seat). Граны не держат WebSocket внутри +/// себя: endpoint регистрирует соединение, а грана отправляет кадры через +/// этот реестр. Так состояние грана остаётся сериализуемым. +/// +public interface IMatchConnectionRegistry +{ + /// Зарегистрировать соединение. Возвращает предыдущее, если было. + IMatchConnection? Register(string matchId, int seat, IMatchConnection connection); + + /// Снять только если текущее соединение — именно это. + bool Unregister(string matchId, int seat, IMatchConnection connection); + + IMatchConnection? Get(string matchId, int seat); + + bool IsConnected(string matchId, int seat); +} + +public sealed class MatchConnectionRegistry : IMatchConnectionRegistry +{ + private readonly ConcurrentDictionary<(string MatchId, int Seat), IMatchConnection> _connections = new(); + + public IMatchConnection? Register(string matchId, int seat, IMatchConnection connection) + { + var key = (matchId, seat); + + while (true) + { + if (_connections.TryGetValue(key, out var previous)) + { + if (_connections.TryUpdate(key, connection, previous)) + return previous; + + continue; + } + + if (_connections.TryAdd(key, connection)) + return null; + } + } + + public bool Unregister(string matchId, int seat, IMatchConnection connection) + { + if (!_connections.TryGetValue((matchId, seat), out var current) + || !ReferenceEquals(current, connection)) + { + return false; + } + + return _connections.TryRemove(new KeyValuePair<(string, int), IMatchConnection>((matchId, seat), connection)); + } + + public IMatchConnection? Get(string matchId, int seat) => + _connections.TryGetValue((matchId, seat), out var connection) ? connection : null; + + public bool IsConnected(string matchId, int seat) => _connections.ContainsKey((matchId, seat)); +} diff --git a/server/src/Vanguard.Server/Matches/MatchGrain.cs b/server/src/Vanguard.Server/Matches/MatchGrain.cs index 65607d4..0eac1eb 100644 --- a/server/src/Vanguard.Server/Matches/MatchGrain.cs +++ b/server/src/Vanguard.Server/Matches/MatchGrain.cs @@ -27,7 +27,6 @@ public sealed class MatchGrain : IMatchGrain 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; } @@ -42,6 +41,7 @@ public sealed class MatchGrain : IMatchGrain private readonly TimeProvider _time; private readonly IMatchResultSink _results; private readonly IMatchStore _store; + private readonly IMatchConnectionRegistry _connections; private readonly ILogger _logger; private readonly string _matchId; private readonly GameMode _mode; @@ -65,6 +65,7 @@ public sealed class MatchGrain : IMatchGrain TimeProvider time, IMatchResultSink results, IMatchStore store, + IMatchConnectionRegistry connections, ILogger logger) { _matchId = setup.MatchId; @@ -73,6 +74,7 @@ public sealed class MatchGrain : IMatchGrain _time = time; _results = results; _store = store; + _connections = connections; _logger = logger; _createdAt = time.GetUtcNow(); @@ -119,10 +121,8 @@ public sealed class MatchGrain : IMatchGrain public async Task AttachAsync( int seat, string playerId, - IMatchConnection connection, long resumeFromSeq) - { - if (seat is not (0 or 1) || _seats[seat].PlayerId != playerId) + { if (seat is not (0 or 1) || _seats[seat].PlayerId != playerId) { _logger.LogWarning( "Match {MatchId}: player {PlayerId} tried to take seat {Seat}.", @@ -135,24 +135,16 @@ public sealed class MatchGrain : IMatchGrain { var state = _seats[seat]; var now = _time.GetUtcNow(); + var connection = _connections.Get(_matchId, seat); - if (state.Connection is { } old && !ReferenceEquals(old, connection)) + if (connection is null) { - _logger.LogInformation( - "Match {MatchId}: seat {Seat} replaced a live connection.", + _logger.LogWarning( + "Match {MatchId}: seat {Seat} attached without a registered connection.", _matchId, seat); - - try - { - await old.CloseAsync(4409, "replaced"); - } - catch (Exception exception) - { - _logger.LogDebug(exception, "Match {MatchId}: closing old connection failed.", _matchId); - } + return false; } - state.Connection = connection; state.AbsentSince = null; state.EverConnected = true; @@ -190,7 +182,6 @@ public sealed class MatchGrain : IMatchGrain 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; } @@ -200,7 +191,26 @@ public sealed class MatchGrain : IMatchGrain } } - public async Task DetachAsync(int seat, IMatchConnection connection) + /// + /// Удобная обёртка для встроенного (не-Orleans) режима и тестов: + /// сама регистрирует соединение перед attach. + /// + public async Task AttachAsync( + int seat, + string playerId, + IMatchConnection connection, + long resumeFromSeq) + { + _connections.Register(_matchId, seat, connection); + 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; @@ -209,10 +219,6 @@ public sealed class MatchGrain : IMatchGrain 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); @@ -225,6 +231,13 @@ public sealed class MatchGrain : IMatchGrain } } + /// Снять конкретное соединение; если оно уже заменено — ничего не делать. + 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)) @@ -353,7 +366,7 @@ public sealed class MatchGrain : IMatchGrain await _gate.WaitAsync(); try { - if (_seats[seat].Connection is null) + if (!_connections.IsConnected(_matchId, seat)) return; var now = _time.GetUtcNow(); @@ -383,7 +396,7 @@ public sealed class MatchGrain : IMatchGrain for (var seat = 0; seat < _seats.Length; seat++) { var state = _seats[seat]; - if (state.Connection is null && state.AbsentSince is { } since) + if (!_connections.IsConnected(_matchId, seat) && state.AbsentSince is { } since) { if (now - since < TimeSpan.FromSeconds(_options.AbsenceSeconds)) continue; @@ -468,7 +481,7 @@ public sealed class MatchGrain : IMatchGrain private async Task PushEventsAsync(int seat) { - if (_seats[seat].Connection is null) + if (!_connections.IsConnected(_matchId, seat)) return; var from = _seats[seat].SentSeq + 1; @@ -514,7 +527,7 @@ public sealed class MatchGrain : IMatchGrain for (var seat = 0; seat < _seats.Length; seat++) { - if (_seats[seat].Connection is not null) + if (_connections.IsConnected(_matchId, seat)) await SendToSeatAsync(seat, new ServerClock { Clock = clock, EventSeq = eventSeq }); } } @@ -529,7 +542,7 @@ public sealed class MatchGrain : IMatchGrain private async Task SendToSeatAsync(int seat, ServerFrame frame) { var state = _seats[seat]; - if (state.Connection is not { } connection) + if (_connections.Get(_matchId, seat) is not { } connection) return false; try @@ -540,7 +553,7 @@ public sealed class MatchGrain : IMatchGrain catch (Exception exception) { _logger.LogDebug(exception, "Match {MatchId}: send to seat {Seat} failed.", _matchId, seat); - state.Connection = null; + _connections.Unregister(_matchId, seat, connection); state.AbsentSince ??= _time.GetUtcNow(); return false; } diff --git a/server/src/Vanguard.Server/Matches/MatchGrainRegistry.cs b/server/src/Vanguard.Server/Matches/MatchGrainRegistry.cs index 7d65a89..24df3fd 100644 --- a/server/src/Vanguard.Server/Matches/MatchGrainRegistry.cs +++ b/server/src/Vanguard.Server/Matches/MatchGrainRegistry.cs @@ -1,6 +1,9 @@ using System.Collections.Concurrent; using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.Options; +using Orleans; using Vanguard.Server.Configuration; +using Vanguard.Server.Matches.Orleans; using Vanguard.Server.Storage; namespace Vanguard.Server.Matches; @@ -29,8 +32,32 @@ public sealed class MatchGrainRegistry _logger = logger; } + private bool UsesOrleans => + _services.GetService>()?.Value.Enabled == true; + + private IGrainFactory Grains => + _services.GetService() + ?? throw new InvalidOperationException( + "Orleans включён в конфигурации, но IGrainFactory не зарегистрирован."); + public async Task CreateAsync(MatchSetup setup) { + if (UsesOrleans) + { + var actor = Grains.GetGrain(setup.MatchId); + var adapter = new OrleansMatchAdapter(setup, actor, _logger); + await adapter.InitializeAsync(); + + if (!_matches.TryAdd(setup.MatchId, adapter)) + throw new InvalidOperationException($"Матч уже существует: {setup.MatchId}"); + + _logger.LogInformation( + "Match {MatchId} created in Orleans: {Mode}, {Player0} vs {Player1}.", + setup.MatchId, setup.Mode, setup.Seat0.PlayerId, setup.Seat1.PlayerId); + + return adapter; + } + var grain = ActivatorUtilities.CreateInstance(_services, setup); if (!_matches.TryAdd(setup.MatchId, grain)) @@ -57,12 +84,24 @@ public sealed class MatchGrainRegistry public async Task CreateRestoredAsync(MatchSnapshot snapshot) { var header = snapshot.Header; - var setup = new MatchSetup( - header.MatchId, - header.Mode, - new MatchSeatSetup(header.Player0Id, header.Deck0, 0), - new MatchSeatSetup(header.Player1Id, header.Deck1, 0), - header.Seed); + var setup = MatchSetup.FromHeader(header); + + if (UsesOrleans) + { + var actor = Grains.GetGrain(header.MatchId); + await actor.EnsureInitializedAsync(); + var summary = await actor.GetSummaryAsync(); + var adapter = new OrleansMatchAdapter(setup, actor, _logger, summary.ToSummary()); + + if (!_matches.TryAdd(header.MatchId, adapter)) + throw new InvalidOperationException($"Матч уже восстановлен: {header.MatchId}"); + + _logger.LogInformation( + "Match {MatchId} restored in Orleans: {Mode}, {Player0} vs {Player1}, seq {Seq}.", + header.MatchId, header.Mode, header.Player0Id, header.Player1Id, header.EventSeq); + + return adapter; + } var grain = ActivatorUtilities.CreateInstance(_services, setup); await grain.RestoreAsync(snapshot); diff --git a/server/src/Vanguard.Server/Matches/Orleans/IMatchActor.cs b/server/src/Vanguard.Server/Matches/Orleans/IMatchActor.cs new file mode 100644 index 0000000..3b7927c --- /dev/null +++ b/server/src/Vanguard.Server/Matches/Orleans/IMatchActor.cs @@ -0,0 +1,149 @@ +using Orleans; +using Vanguard.Server.Configuration; +using Vanguard.Server.Matches; + +namespace Vanguard.Server.Matches.Orleans; + +/// +/// Orleans-обёртка над доменным . Через границу +/// Orleans передаются только простые DTO и байты интентов: соединения и +/// движок остаются внутри silo. +/// +public interface IMatchActor : IGrainWithStringKey +{ + Task InitializeAsync(MatchSetupData setup); + + /// Поднять матч из хранилища, если он ещё не в памяти. + Task EnsureInitializedAsync(); + + Task AttachAsync(int seat, string playerId, long resumeFromSeq); + + Task DetachAsync(int seat); + + Task LeaveAsync(int seat); + + Task SubmitAsync(int seat, byte[] intentPayload); + + Task ResyncAsync(int seat, long fromSeq, bool fullSnapshot); + + Task TickAsync(long nowUnixMs); + + Task GetSummaryAsync(); + + Task HasPlayerAsync(string playerId); +} + +[GenerateSerializer] +public sealed class MatchSeatData +{ + [Id(0)] + public string PlayerId { get; set; } = string.Empty; + + [Id(1)] + public string[] Deck { get; set; } = []; + + [Id(2)] + public int Mmr { get; set; } +} + +[GenerateSerializer] +public sealed class MatchSetupData +{ + [Id(0)] + public string MatchId { get; set; } = string.Empty; + + [Id(1)] + public GameMode Mode { get; set; } + + [Id(2)] + public MatchSeatData Seat0 { get; set; } = new(); + + [Id(3)] + public MatchSeatData Seat1 { get; set; } = new(); + + [Id(4)] + public ulong? Seed { get; set; } + + public static MatchSetupData From(MatchSetup setup) => new() + { + MatchId = setup.MatchId, + Mode = setup.Mode, + Seat0 = new MatchSeatData + { + PlayerId = setup.Seat0.PlayerId, + Deck = setup.Seat0.Deck.ToArray(), + Mmr = setup.Seat0.Mmr, + }, + Seat1 = new MatchSeatData + { + PlayerId = setup.Seat1.PlayerId, + Deck = setup.Seat1.Deck.ToArray(), + Mmr = setup.Seat1.Mmr, + }, + Seed = setup.Seed, + }; + + public MatchSetup ToSetup() => new( + MatchId, + Mode, + new MatchSeatSetup(Seat0.PlayerId, Seat0.Deck, Seat0.Mmr), + new MatchSeatSetup(Seat1.PlayerId, Seat1.Deck, Seat1.Mmr), + Seed); +} + +[GenerateSerializer] +public sealed class MatchSummaryData +{ + [Id(0)] + public string MatchId { get; set; } = string.Empty; + + [Id(1)] + public GameMode Mode { get; set; } + + [Id(2)] + public string Player0Id { get; set; } = string.Empty; + + [Id(3)] + public string Player1Id { get; set; } = string.Empty; + + [Id(4)] + public bool IsFinished { get; set; } + + [Id(5)] + public int Winner { get; set; } + + [Id(6)] + public long EventSeq { get; set; } + + [Id(7)] + public long CreatedAtUnixMs { get; set; } + + [Id(8)] + public long? FinishedAtUnixMs { get; set; } + + public static MatchSummaryData From(MatchSummary summary) => new() + { + MatchId = summary.MatchId, + Mode = summary.Mode, + Player0Id = summary.Player0Id, + Player1Id = summary.Player1Id, + IsFinished = summary.IsFinished, + Winner = summary.Winner, + EventSeq = summary.EventSeq, + CreatedAtUnixMs = summary.CreatedAt.ToUnixTimeMilliseconds(), + FinishedAtUnixMs = summary.FinishedAt?.ToUnixTimeMilliseconds(), + }; + + public MatchSummary ToSummary() => new( + MatchId, + Mode, + Player0Id, + Player1Id, + IsFinished, + Winner, + EventSeq, + DateTimeOffset.FromUnixTimeMilliseconds(CreatedAtUnixMs), + FinishedAtUnixMs is { } finished + ? DateTimeOffset.FromUnixTimeMilliseconds(finished) + : null); +} diff --git a/server/src/Vanguard.Server/Matches/Orleans/MatchActorGrain.cs b/server/src/Vanguard.Server/Matches/Orleans/MatchActorGrain.cs new file mode 100644 index 0000000..5ee9d48 --- /dev/null +++ b/server/src/Vanguard.Server/Matches/Orleans/MatchActorGrain.cs @@ -0,0 +1,170 @@ +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; + +/// +/// Единственная активная копия матча. Доменная логика остаётся в +/// ; этот класс отвечает за активацию, восстановление +/// из хранилища и сериализуемую границу Orleans. +/// +/// [KeepAlive] важен: если Orleans выгрузит грана, живые WebSocket-соединения +/// останутся в реестре без владельца. Долгоживущие матчи и так ограничены TTL, +/// а после рестарта сервера поднимаются из снапшота. +/// +[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 _logger; + + private string _matchId = string.Empty; + private MatchGrain? _match; + + public MatchActorGrain( + CardCatalog catalog, + GameOptions options, + TimeProvider time, + IMatchResultSink results, + IMatchStore store, + IMatchConnectionRegistry connections, + ILogger 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 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 GetSummaryAsync() + { + var match = await RequireAsync(); + return MatchSummaryData.From(match.Summary); + } + + public async Task HasPlayerAsync(string playerId) + { + var match = await RequireAsync(); + return match.HasPlayer(playerId); + } + + private async Task 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); + } +} diff --git a/server/src/Vanguard.Server/Matches/Orleans/OrleansMatchAdapter.cs b/server/src/Vanguard.Server/Matches/Orleans/OrleansMatchAdapter.cs new file mode 100644 index 0000000..4d3d396 --- /dev/null +++ b/server/src/Vanguard.Server/Matches/Orleans/OrleansMatchAdapter.cs @@ -0,0 +1,104 @@ +using Microsoft.Extensions.Logging; +using Vanguard.Server.Configuration; +using Vanguard.Shared.Protocol; + +namespace Vanguard.Server.Matches.Orleans; + +/// +/// Адаптер, который позволяет HTTP/WebSocket-слою работать с Orleans-граном +/// через уже существующий . Сводка кэшируется: +/// регистратуре и health нужны только идентификаторы, статус и seq, а они +/// обновляются после каждого изменяющего вызова. +/// +public sealed class OrleansMatchAdapter : IMatchGrain +{ + private readonly MatchSetup _setup; + private readonly IMatchActor _actor; + private readonly ILogger _logger; + private volatile MatchSummary _summary; + + public OrleansMatchAdapter( + MatchSetup setup, + IMatchActor actor, + ILogger logger, + MatchSummary? initialSummary = null) + { + _setup = setup; + _actor = actor; + _logger = logger; + _summary = initialSummary ?? new MatchSummary( + setup.MatchId, + setup.Mode, + setup.Seat0.PlayerId, + setup.Seat1.PlayerId, + IsFinished: false, + Winner: -1, + EventSeq: 0, + CreatedAt: DateTimeOffset.UtcNow, + FinishedAt: null); + } + + public string MatchId => _setup.MatchId; + + public MatchSummary Summary => _summary; + + public bool HasPlayer(string playerId) => + _setup.Seat0.PlayerId == playerId || _setup.Seat1.PlayerId == playerId; + + public async Task InitializeAsync() + { + await _actor.InitializeAsync(MatchSetupData.From(_setup)); + await RefreshAsync(); + } + + public async Task AttachAsync(int seat, string playerId, long resumeFromSeq) + { + var attached = await _actor.AttachAsync(seat, playerId, resumeFromSeq); + if (attached) + await RefreshAsync(); + + return attached; + } + + public async Task DetachAsync(int seat) + { + await _actor.DetachAsync(seat); + } + + public async Task LeaveAsync(int seat) + { + await _actor.LeaveAsync(seat); + await RefreshAsync(); + } + + public async Task SubmitAsync(int seat, ClientIntent intent) + { + await _actor.SubmitAsync(seat, ProtocolSerializer.Encode(intent)); + await RefreshAsync(); + } + + public async Task ResyncAsync(int seat, long fromSeq, bool fullSnapshot) + { + await _actor.ResyncAsync(seat, fromSeq, fullSnapshot); + } + + public async Task TickAsync(DateTimeOffset now) + { + await _actor.TickAsync(now.ToUnixTimeMilliseconds()); + await RefreshAsync(); + } + + private async Task RefreshAsync() + { + try + { + var summary = await _actor.GetSummaryAsync(); + _summary = summary.ToSummary(); + } + catch (Exception exception) + { + _logger.LogDebug(exception, "Match {MatchId}: summary refresh failed.", _setup.MatchId); + throw; + } + } +} diff --git a/server/src/Vanguard.Server/Program.cs b/server/src/Vanguard.Server/Program.cs index 0a15b37..68d45fc 100644 --- a/server/src/Vanguard.Server/Program.cs +++ b/server/src/Vanguard.Server/Program.cs @@ -6,6 +6,8 @@ using Microsoft.AspNetCore.RateLimiting; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Tokens; +using Orleans.Configuration; +using Orleans.Hosting; using StackExchange.Redis; using Vanguard.Content; using Vanguard.Rules.Cards; @@ -29,6 +31,23 @@ builder.Services.Configure(builder.Configuration.GetSection("Sto builder.Services.Configure(builder.Configuration.GetSection("Redis")); builder.Services.Configure(builder.Configuration.GetSection("Meta")); builder.Services.Configure(builder.Configuration.GetSection("Jwt")); +builder.Services.Configure(builder.Configuration.GetSection(OrleansOptions.SectionName)); + +var orleansOptions = builder.Configuration + .GetSection(OrleansOptions.SectionName) + .Get() ?? new OrleansOptions(); + +if (orleansOptions.Enabled) +{ + builder.Host.UseOrleans(silo => + { + // Один silo на инстанс сервера. Для нескольких реплик нужен настоящий + // кластер (AdoNet/Redis clustering), но контракт грана уже готов. + silo.UseLocalhostClustering(); + silo.Configure(options => + options.CollectionAge = TimeSpan.FromHours(12)); + }); +} builder.Services.AddSingleton(sp => sp.GetRequiredService>().Value); builder.Services.AddSingleton(sp => sp.GetRequiredService>().Value); @@ -162,6 +181,7 @@ builder.Services.AddRateLimiter(options => }); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/server/src/Vanguard.Server/Realtime/GameWebSocketEndpoint.cs b/server/src/Vanguard.Server/Realtime/GameWebSocketEndpoint.cs index 19a6d16..913d7d7 100644 --- a/server/src/Vanguard.Server/Realtime/GameWebSocketEndpoint.cs +++ b/server/src/Vanguard.Server/Realtime/GameWebSocketEndpoint.cs @@ -24,6 +24,7 @@ public static class GameWebSocketEndpoint var options = services.GetRequiredService(); var tickets = services.GetRequiredService(); var matches = services.GetRequiredService(); + var connections = services.GetRequiredService(); var time = services.GetRequiredService(); var logger = services.GetRequiredService().CreateLogger("GameSocket"); @@ -32,6 +33,7 @@ public static class GameWebSocketEndpoint IMatchGrain? match = null; var seat = -1; + var attached = false; HeartbeatState? heartbeat = null; CancellationTokenSource? heartbeatCts = null; Task? heartbeatTask = null; @@ -90,13 +92,34 @@ public static class GameWebSocketEndpoint } seat = ticket.Seat; - if (!await match.AttachAsync(seat, ticket.PlayerId, connection, hello.ResumeFromSeq)) + + var previous = connections.Register(ticket.MatchId, seat, connection); + if (previous is not null && !ReferenceEquals(previous, connection)) { + logger.LogInformation( + "Socket {ConnectionId}: replacing a live connection at {MatchId} seat {Seat}.", + connection.Id, ticket.MatchId, seat); + + try + { + await previous.CloseAsync(4409, "replaced"); + } + catch (Exception exception) + { + logger.LogDebug(exception, "Old connection close failed."); + } + } + + if (!await match.AttachAsync(seat, ticket.PlayerId, hello.ResumeFromSeq)) + { + connections.Unregister(ticket.MatchId, seat, connection); await FailAsync(connection, ProtocolErrorCode.Forbidden, "Место в матче не принадлежит игроку.", 4403, "forbidden"); return; } + attached = true; + logger.LogInformation( "Socket {ConnectionId}: {PlayerId} connected to {MatchId} seat {Seat}.", connection.Id, ticket.PlayerId, ticket.MatchId, seat); @@ -227,7 +250,9 @@ public static class GameWebSocketEndpoint { try { - await match.DetachAsync(seat, connection); + var detached = connections.Unregister(match.MatchId, seat, connection); + if (detached && attached) + await match.DetachAsync(seat); } catch (Exception exception) { diff --git a/server/src/Vanguard.Server/Vanguard.Server.csproj b/server/src/Vanguard.Server/Vanguard.Server.csproj index f1d05f2..c7e914f 100644 --- a/server/src/Vanguard.Server/Vanguard.Server.csproj +++ b/server/src/Vanguard.Server/Vanguard.Server.csproj @@ -17,6 +17,8 @@ + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/server/src/Vanguard.Server/appsettings.json b/server/src/Vanguard.Server/appsettings.json index cf93612..efb7f0d 100644 --- a/server/src/Vanguard.Server/appsettings.json +++ b/server/src/Vanguard.Server/appsettings.json @@ -50,5 +50,8 @@ "SigningKey": "dev-only-vanguard-remaster-signing-key-change-me", "AccessMinutes": 15, "RefreshDays": 30 + }, + "Orleans": { + "Enabled": false } } diff --git a/server/tests/Vanguard.Server.Tests/MatchGrainTests.cs b/server/tests/Vanguard.Server.Tests/MatchGrainTests.cs index 694381d..5c0a0e9 100644 --- a/server/tests/Vanguard.Server.Tests/MatchGrainTests.cs +++ b/server/tests/Vanguard.Server.Tests/MatchGrainTests.cs @@ -343,6 +343,7 @@ public class MatchGrainTests time, sink ?? new RecordingResultSink(), new NullMatchStore(), + new MatchConnectionRegistry(), NullLogger.Instance); } diff --git a/server/tests/Vanguard.Server.Tests/MatchPersistenceTests.cs b/server/tests/Vanguard.Server.Tests/MatchPersistenceTests.cs index b8fef6b..75b9380 100644 --- a/server/tests/Vanguard.Server.Tests/MatchPersistenceTests.cs +++ b/server/tests/Vanguard.Server.Tests/MatchPersistenceTests.cs @@ -152,6 +152,7 @@ public class MatchPersistenceTests services.AddSingleton(time); services.AddSingleton(profiles); services.AddSingleton(store); + services.AddSingleton(); services.AddLogging(); await using var provider = services.BuildServiceProvider(); @@ -169,7 +170,7 @@ public class MatchPersistenceTests Assert.Equal(originalView.EventSeq, recovered!.Summary.EventSeq); var connection = new FakeMatchConnection(); - Assert.True(await recovered.AttachAsync(0, "p1", connection, 0)); + Assert.True(await ((MatchGrain)recovered!).AttachAsync(0, "p1", connection, 0)); var view = connection.Last().View; Assert.Equal(originalView.EventSeq, view.EventSeq); @@ -204,6 +205,7 @@ public class MatchPersistenceTests time, new RecordingResultSink(), store, + new MatchConnectionRegistry(), NullLogger.Instance); private static ClientIntent Intent(string intentId, IntentPayload payload) => new() diff --git a/server/tests/Vanguard.Server.Tests/MatchmakingServiceTests.cs b/server/tests/Vanguard.Server.Tests/MatchmakingServiceTests.cs index b00bd9c..aa3dc5d 100644 --- a/server/tests/Vanguard.Server.Tests/MatchmakingServiceTests.cs +++ b/server/tests/Vanguard.Server.Tests/MatchmakingServiceTests.cs @@ -207,6 +207,7 @@ public class MatchmakingServiceTests services.AddSingleton(harness.Time); services.AddSingleton(harness.Profiles); services.AddSingleton(harness.MatchStore); + services.AddSingleton(); services.AddSingleton(harness.QueueStore); services.AddLogging(); @@ -247,6 +248,7 @@ public class MatchmakingServiceTests services.AddSingleton(time); services.AddSingleton(profiles); services.AddSingleton(store); + services.AddSingleton(); services.AddSingleton(queueStore); services.AddLogging(); diff --git a/server/tests/Vanguard.Server.Tests/OrleansMatchTests.cs b/server/tests/Vanguard.Server.Tests/OrleansMatchTests.cs new file mode 100644 index 0000000..80bbabe --- /dev/null +++ b/server/tests/Vanguard.Server.Tests/OrleansMatchTests.cs @@ -0,0 +1,112 @@ +using Microsoft.Extensions.DependencyInjection; +using Orleans.Hosting; +using Orleans.TestingHost; +using Vanguard.Rules.Cards; +using Vanguard.Rules.Engine; +using Vanguard.Server.Configuration; +using Vanguard.Server.Matches; +using Vanguard.Server.Matches.Orleans; +using Vanguard.Server.Storage; +using Vanguard.Shared.Protocol; + +namespace Vanguard.Server.Tests; + +public class OrleansMatchTests +{ + private static readonly DateTimeOffset Start = new(2026, 10, 21, 12, 0, 0, TimeSpan.Zero); + + [Fact] + public async Task Grain_Initializes_Attaches_And_Submits_Intent() + { + var time = new FakeTimeProvider(Start); + var connections = new MatchConnectionRegistry(); + var results = new RecordingResultSink(); + + var builder = new TestClusterBuilder(); + builder.AddSiloBuilderConfigurator(new SiloConfigurator(time, connections, results)); + var cluster = builder.Build(); + + try + { + await cluster.DeployAsync(); + + var actor = cluster.Client.GetGrain("orleans-match"); + var setup = new MatchSetup( + "orleans-match", + GameMode.ClassicRanked, + new MatchSeatSetup("p1", ServerTestData.BuildDeck(), 1000), + new MatchSeatSetup("p2", ServerTestData.BuildDeck(), 1000)); + + await actor.InitializeAsync(MatchSetupData.From(setup)); + + var first = new FakeMatchConnection(); + connections.Register("orleans-match", 0, first); + + Assert.True(await actor.AttachAsync(0, "p1", 0)); + + var hello = first.All().Single(); + Assert.Equal(0, hello.PlayerIndex); + Assert.False(hello.Resumed); + + var snapshot = first.All().Single().View; + Assert.Equal(5, snapshot.Players[0].Hand.Count); + Assert.Empty(snapshot.Players[1].Hand); + + var summary = await actor.GetSummaryAsync(); + var intent = new ClientIntent + { + IntentId = "orleans-intent-1", + BasedOnSeq = summary.EventSeq, + Intent = new FinishMulliganIntent(), + }; + + await actor.SubmitAsync(0, ProtocolSerializer.Encode(intent)); + + var accepted = first.Last(); + Assert.True(accepted.Accepted); + + var after = await actor.GetSummaryAsync(); + Assert.True(after.EventSeq >= summary.EventSeq); + Assert.True(await actor.HasPlayerAsync("p1")); + Assert.False(await actor.HasPlayerAsync("stranger")); + } + finally + { + await cluster.StopAsync(); + cluster.Dispose(); + } + } + + private sealed class SiloConfigurator : ISiloConfigurator + { + private readonly FakeTimeProvider _time; + private readonly IMatchConnectionRegistry _connections; + private readonly IMatchResultSink _results; + + public SiloConfigurator( + FakeTimeProvider time, + IMatchConnectionRegistry connections, + IMatchResultSink results) + { + _time = time; + _connections = connections; + _results = results; + } + + public void Configure(ISiloBuilder siloBuilder) + { + siloBuilder.ConfigureServices(services => + { + var options = ServerTestData.SmallOptions(); + + services.AddSingleton(ServerTestData.Catalog); + services.AddSingleton(options); + services.AddSingleton(_time); + services.AddSingleton(_results); + services.AddSingleton(); + services.AddSingleton(_connections); + services.AddLogging(); + }); + } + } +} diff --git a/server/tests/Vanguard.Server.Tests/Vanguard.Server.Tests.csproj b/server/tests/Vanguard.Server.Tests/Vanguard.Server.Tests.csproj index 37ac378..5550e5d 100644 --- a/server/tests/Vanguard.Server.Tests/Vanguard.Server.Tests.csproj +++ b/server/tests/Vanguard.Server.Tests/Vanguard.Server.Tests.csproj @@ -11,6 +11,7 @@ +