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 @@
+