Add Orleans match hosting behind a config flag
This commit is contained in:
parent
f702d11ae4
commit
c1367d6383
|
|
@ -37,6 +37,7 @@ services:
|
||||||
Storage__Provider: Postgres
|
Storage__Provider: Postgres
|
||||||
Storage__ConnectionString: Host=postgres;Port=5432;Database=vanguard;Username=vanguard;Password=vanguard
|
Storage__ConnectionString: Host=postgres;Port=5432;Database=vanguard;Username=vanguard;Password=vanguard
|
||||||
Redis__Configuration: redis:6379
|
Redis__Configuration: redis:6379
|
||||||
|
Orleans__Enabled: "true"
|
||||||
Jwt__SigningKey: ${VANGUARD_JWT_KEY:-dev-only-vanguard-remaster-signing-key-change-me}
|
Jwt__SigningKey: ${VANGUARD_JWT_KEY:-dev-only-vanguard-remaster-signing-key-change-me}
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
namespace Vanguard.Server.Configuration;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Оркестрация матчей через Orleans. В тестах и локальной разработке по
|
||||||
|
/// умолчанию выключена: матчи живут в одном процессе, как раньше.
|
||||||
|
/// В docker-compose включена, чтобы граны активировались в silo.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class OrleansOptions
|
||||||
|
{
|
||||||
|
public const string SectionName = "Orleans";
|
||||||
|
|
||||||
|
public bool Enabled { get; set; }
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
using Vanguard.Server.Configuration;
|
using Vanguard.Server.Configuration;
|
||||||
|
using Vanguard.Server.Storage;
|
||||||
using Vanguard.Shared.Protocol;
|
using Vanguard.Shared.Protocol;
|
||||||
|
|
||||||
namespace Vanguard.Server.Matches;
|
namespace Vanguard.Server.Matches;
|
||||||
|
|
@ -22,7 +23,15 @@ public sealed record MatchSetup(
|
||||||
GameMode Mode,
|
GameMode Mode,
|
||||||
MatchSeatSetup Seat0,
|
MatchSeatSetup Seat0,
|
||||||
MatchSeatSetup Seat1,
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Снимок и итог матча для регистратуры, health и записи результата.</summary>
|
/// <summary>Снимок и итог матча для регистратуры, health и записи результата.</summary>
|
||||||
public sealed record MatchSummary(
|
public sealed record MatchSummary(
|
||||||
|
|
@ -79,14 +88,10 @@ public interface IMatchGrain
|
||||||
bool HasPlayer(string playerId);
|
bool HasPlayer(string playerId);
|
||||||
|
|
||||||
/// <summary>Подключить место. false — место не принадлежит игроку.</summary>
|
/// <summary>Подключить место. false — место не принадлежит игроку.</summary>
|
||||||
Task<bool> AttachAsync(
|
Task<bool> AttachAsync(int seat, string playerId, long resumeFromSeq);
|
||||||
int seat,
|
|
||||||
string playerId,
|
|
||||||
IMatchConnection connection,
|
|
||||||
long resumeFromSeq);
|
|
||||||
|
|
||||||
/// <summary>Отключить место. Отсчёт отсутствия начинается здесь.</summary>
|
/// <summary>Отключить место. Отсчёт отсутствия начинается здесь.</summary>
|
||||||
Task DetachAsync(int seat, IMatchConnection connection);
|
Task DetachAsync(int seat);
|
||||||
|
|
||||||
/// <summary>Явный выход из матча: сразу поражение вышедшего.</summary>
|
/// <summary>Явный выход из матча: сразу поражение вышедшего.</summary>
|
||||||
Task LeaveAsync(int seat);
|
Task LeaveAsync(int seat);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
|
||||||
|
namespace Vanguard.Server.Matches;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Живые соединения по (matchId, seat). Граны не держат WebSocket внутри
|
||||||
|
/// себя: endpoint регистрирует соединение, а грана отправляет кадры через
|
||||||
|
/// этот реестр. Так состояние грана остаётся сериализуемым.
|
||||||
|
/// </summary>
|
||||||
|
public interface IMatchConnectionRegistry
|
||||||
|
{
|
||||||
|
/// <summary>Зарегистрировать соединение. Возвращает предыдущее, если было.</summary>
|
||||||
|
IMatchConnection? Register(string matchId, int seat, IMatchConnection connection);
|
||||||
|
|
||||||
|
/// <summary>Снять только если текущее соединение — именно это.</summary>
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
@ -27,7 +27,6 @@ public sealed class MatchGrain : IMatchGrain
|
||||||
public required IReadOnlyList<string> Deck { get; init; }
|
public required IReadOnlyList<string> Deck { get; init; }
|
||||||
public required int Mmr { get; init; }
|
public required int Mmr { get; init; }
|
||||||
|
|
||||||
public IMatchConnection? Connection { get; set; }
|
|
||||||
public DateTimeOffset? AbsentSince { get; set; }
|
public DateTimeOffset? AbsentSince { get; set; }
|
||||||
public bool EverConnected { get; set; }
|
public bool EverConnected { get; set; }
|
||||||
public long SentSeq { get; set; }
|
public long SentSeq { get; set; }
|
||||||
|
|
@ -42,6 +41,7 @@ public sealed class MatchGrain : IMatchGrain
|
||||||
private readonly TimeProvider _time;
|
private readonly TimeProvider _time;
|
||||||
private readonly IMatchResultSink _results;
|
private readonly IMatchResultSink _results;
|
||||||
private readonly IMatchStore _store;
|
private readonly IMatchStore _store;
|
||||||
|
private readonly IMatchConnectionRegistry _connections;
|
||||||
private readonly ILogger<MatchGrain> _logger;
|
private readonly ILogger<MatchGrain> _logger;
|
||||||
private readonly string _matchId;
|
private readonly string _matchId;
|
||||||
private readonly GameMode _mode;
|
private readonly GameMode _mode;
|
||||||
|
|
@ -65,6 +65,7 @@ public sealed class MatchGrain : IMatchGrain
|
||||||
TimeProvider time,
|
TimeProvider time,
|
||||||
IMatchResultSink results,
|
IMatchResultSink results,
|
||||||
IMatchStore store,
|
IMatchStore store,
|
||||||
|
IMatchConnectionRegistry connections,
|
||||||
ILogger<MatchGrain> logger)
|
ILogger<MatchGrain> logger)
|
||||||
{
|
{
|
||||||
_matchId = setup.MatchId;
|
_matchId = setup.MatchId;
|
||||||
|
|
@ -73,6 +74,7 @@ public sealed class MatchGrain : IMatchGrain
|
||||||
_time = time;
|
_time = time;
|
||||||
_results = results;
|
_results = results;
|
||||||
_store = store;
|
_store = store;
|
||||||
|
_connections = connections;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_createdAt = time.GetUtcNow();
|
_createdAt = time.GetUtcNow();
|
||||||
|
|
||||||
|
|
@ -119,10 +121,8 @@ public sealed class MatchGrain : IMatchGrain
|
||||||
public async Task<bool> AttachAsync(
|
public async Task<bool> AttachAsync(
|
||||||
int seat,
|
int seat,
|
||||||
string playerId,
|
string playerId,
|
||||||
IMatchConnection connection,
|
|
||||||
long resumeFromSeq)
|
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(
|
_logger.LogWarning(
|
||||||
"Match {MatchId}: player {PlayerId} tried to take seat {Seat}.",
|
"Match {MatchId}: player {PlayerId} tried to take seat {Seat}.",
|
||||||
|
|
@ -135,24 +135,16 @@ public sealed class MatchGrain : IMatchGrain
|
||||||
{
|
{
|
||||||
var state = _seats[seat];
|
var state = _seats[seat];
|
||||||
var now = _time.GetUtcNow();
|
var now = _time.GetUtcNow();
|
||||||
|
var connection = _connections.Get(_matchId, seat);
|
||||||
|
|
||||||
if (state.Connection is { } old && !ReferenceEquals(old, connection))
|
if (connection is null)
|
||||||
{
|
{
|
||||||
_logger.LogInformation(
|
_logger.LogWarning(
|
||||||
"Match {MatchId}: seat {Seat} replaced a live connection.",
|
"Match {MatchId}: seat {Seat} attached without a registered connection.",
|
||||||
_matchId, seat);
|
_matchId, seat);
|
||||||
|
return false;
|
||||||
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.AbsentSince = null;
|
||||||
state.EverConnected = true;
|
state.EverConnected = true;
|
||||||
|
|
||||||
|
|
@ -190,7 +182,6 @@ public sealed class MatchGrain : IMatchGrain
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
_logger.LogDebug(exception, "Match {MatchId}: attach for seat {Seat} failed.", _matchId, seat);
|
_logger.LogDebug(exception, "Match {MatchId}: attach for seat {Seat} failed.", _matchId, seat);
|
||||||
_seats[seat].Connection = null;
|
|
||||||
_seats[seat].AbsentSince ??= _time.GetUtcNow();
|
_seats[seat].AbsentSince ??= _time.GetUtcNow();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
@ -200,7 +191,26 @@ public sealed class MatchGrain : IMatchGrain
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task DetachAsync(int seat, IMatchConnection connection)
|
/// <summary>
|
||||||
|
/// Удобная обёртка для встроенного (не-Orleans) режима и тестов:
|
||||||
|
/// сама регистрирует соединение перед attach.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<bool> 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))
|
if (seat is not (0 or 1))
|
||||||
return;
|
return;
|
||||||
|
|
@ -209,10 +219,6 @@ public sealed class MatchGrain : IMatchGrain
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var state = _seats[seat];
|
var state = _seats[seat];
|
||||||
if (!ReferenceEquals(state.Connection, connection))
|
|
||||||
return; // место уже занято новым соединением
|
|
||||||
|
|
||||||
state.Connection = null;
|
|
||||||
state.AbsentSince = _time.GetUtcNow();
|
state.AbsentSince = _time.GetUtcNow();
|
||||||
|
|
||||||
_logger.LogInformation("Match {MatchId}: seat {Seat} detached.", _matchId, seat);
|
_logger.LogInformation("Match {MatchId}: seat {Seat} detached.", _matchId, seat);
|
||||||
|
|
@ -225,6 +231,13 @@ public sealed class MatchGrain : IMatchGrain
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Снять конкретное соединение; если оно уже заменено — ничего не делать.</summary>
|
||||||
|
public async Task DetachAsync(int seat, IMatchConnection connection)
|
||||||
|
{
|
||||||
|
if (_connections.Unregister(_matchId, seat, connection))
|
||||||
|
await DetachAsync(seat);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task LeaveAsync(int seat)
|
public async Task LeaveAsync(int seat)
|
||||||
{
|
{
|
||||||
if (seat is not (0 or 1))
|
if (seat is not (0 or 1))
|
||||||
|
|
@ -353,7 +366,7 @@ public sealed class MatchGrain : IMatchGrain
|
||||||
await _gate.WaitAsync();
|
await _gate.WaitAsync();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (_seats[seat].Connection is null)
|
if (!_connections.IsConnected(_matchId, seat))
|
||||||
return;
|
return;
|
||||||
|
|
||||||
var now = _time.GetUtcNow();
|
var now = _time.GetUtcNow();
|
||||||
|
|
@ -383,7 +396,7 @@ public sealed class MatchGrain : IMatchGrain
|
||||||
for (var seat = 0; seat < _seats.Length; seat++)
|
for (var seat = 0; seat < _seats.Length; seat++)
|
||||||
{
|
{
|
||||||
var state = _seats[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))
|
if (now - since < TimeSpan.FromSeconds(_options.AbsenceSeconds))
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -468,7 +481,7 @@ public sealed class MatchGrain : IMatchGrain
|
||||||
|
|
||||||
private async Task PushEventsAsync(int seat)
|
private async Task PushEventsAsync(int seat)
|
||||||
{
|
{
|
||||||
if (_seats[seat].Connection is null)
|
if (!_connections.IsConnected(_matchId, seat))
|
||||||
return;
|
return;
|
||||||
|
|
||||||
var from = _seats[seat].SentSeq + 1;
|
var from = _seats[seat].SentSeq + 1;
|
||||||
|
|
@ -514,7 +527,7 @@ public sealed class MatchGrain : IMatchGrain
|
||||||
|
|
||||||
for (var seat = 0; seat < _seats.Length; seat++)
|
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 });
|
await SendToSeatAsync(seat, new ServerClock { Clock = clock, EventSeq = eventSeq });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -529,7 +542,7 @@ public sealed class MatchGrain : IMatchGrain
|
||||||
private async Task<bool> SendToSeatAsync(int seat, ServerFrame frame)
|
private async Task<bool> SendToSeatAsync(int seat, ServerFrame frame)
|
||||||
{
|
{
|
||||||
var state = _seats[seat];
|
var state = _seats[seat];
|
||||||
if (state.Connection is not { } connection)
|
if (_connections.Get(_matchId, seat) is not { } connection)
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
try
|
try
|
||||||
|
|
@ -540,7 +553,7 @@ public sealed class MatchGrain : IMatchGrain
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
_logger.LogDebug(exception, "Match {MatchId}: send to seat {Seat} failed.", _matchId, seat);
|
_logger.LogDebug(exception, "Match {MatchId}: send to seat {Seat} failed.", _matchId, seat);
|
||||||
state.Connection = null;
|
_connections.Unregister(_matchId, seat, connection);
|
||||||
state.AbsentSince ??= _time.GetUtcNow();
|
state.AbsentSince ??= _time.GetUtcNow();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
using System.Diagnostics.CodeAnalysis;
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using Orleans;
|
||||||
using Vanguard.Server.Configuration;
|
using Vanguard.Server.Configuration;
|
||||||
|
using Vanguard.Server.Matches.Orleans;
|
||||||
using Vanguard.Server.Storage;
|
using Vanguard.Server.Storage;
|
||||||
|
|
||||||
namespace Vanguard.Server.Matches;
|
namespace Vanguard.Server.Matches;
|
||||||
|
|
@ -29,8 +32,32 @@ public sealed class MatchGrainRegistry
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private bool UsesOrleans =>
|
||||||
|
_services.GetService<IOptions<OrleansOptions>>()?.Value.Enabled == true;
|
||||||
|
|
||||||
|
private IGrainFactory Grains =>
|
||||||
|
_services.GetService<IGrainFactory>()
|
||||||
|
?? throw new InvalidOperationException(
|
||||||
|
"Orleans включён в конфигурации, но IGrainFactory не зарегистрирован.");
|
||||||
|
|
||||||
public async Task<IMatchGrain> CreateAsync(MatchSetup setup)
|
public async Task<IMatchGrain> CreateAsync(MatchSetup setup)
|
||||||
{
|
{
|
||||||
|
if (UsesOrleans)
|
||||||
|
{
|
||||||
|
var actor = Grains.GetGrain<IMatchActor>(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<MatchGrain>(_services, setup);
|
var grain = ActivatorUtilities.CreateInstance<MatchGrain>(_services, setup);
|
||||||
|
|
||||||
if (!_matches.TryAdd(setup.MatchId, grain))
|
if (!_matches.TryAdd(setup.MatchId, grain))
|
||||||
|
|
@ -57,12 +84,24 @@ public sealed class MatchGrainRegistry
|
||||||
public async Task<IMatchGrain> CreateRestoredAsync(MatchSnapshot snapshot)
|
public async Task<IMatchGrain> CreateRestoredAsync(MatchSnapshot snapshot)
|
||||||
{
|
{
|
||||||
var header = snapshot.Header;
|
var header = snapshot.Header;
|
||||||
var setup = new MatchSetup(
|
var setup = MatchSetup.FromHeader(header);
|
||||||
header.MatchId,
|
|
||||||
header.Mode,
|
if (UsesOrleans)
|
||||||
new MatchSeatSetup(header.Player0Id, header.Deck0, 0),
|
{
|
||||||
new MatchSeatSetup(header.Player1Id, header.Deck1, 0),
|
var actor = Grains.GetGrain<IMatchActor>(header.MatchId);
|
||||||
header.Seed);
|
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<MatchGrain>(_services, setup);
|
var grain = ActivatorUtilities.CreateInstance<MatchGrain>(_services, setup);
|
||||||
await grain.RestoreAsync(snapshot);
|
await grain.RestoreAsync(snapshot);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,149 @@
|
||||||
|
using Orleans;
|
||||||
|
using Vanguard.Server.Configuration;
|
||||||
|
using Vanguard.Server.Matches;
|
||||||
|
|
||||||
|
namespace Vanguard.Server.Matches.Orleans;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Orleans-обёртка над доменным <see cref="MatchGrain"/>. Через границу
|
||||||
|
/// Orleans передаются только простые DTO и байты интентов: соединения и
|
||||||
|
/// движок остаются внутри silo.
|
||||||
|
/// </summary>
|
||||||
|
public interface IMatchActor : IGrainWithStringKey
|
||||||
|
{
|
||||||
|
Task InitializeAsync(MatchSetupData setup);
|
||||||
|
|
||||||
|
/// <summary>Поднять матч из хранилища, если он ещё не в памяти.</summary>
|
||||||
|
Task EnsureInitializedAsync();
|
||||||
|
|
||||||
|
Task<bool> 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<MatchSummaryData> GetSummaryAsync();
|
||||||
|
|
||||||
|
Task<bool> 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);
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Единственная активная копия матча. Доменная логика остаётся в
|
||||||
|
/// <see cref="MatchGrain"/>; этот класс отвечает за активацию, восстановление
|
||||||
|
/// из хранилища и сериализуемую границу Orleans.
|
||||||
|
///
|
||||||
|
/// [KeepAlive] важен: если Orleans выгрузит грана, живые WebSocket-соединения
|
||||||
|
/// останутся в реестре без владельца. Долгоживущие матчи и так ограничены TTL,
|
||||||
|
/// а после рестарта сервера поднимаются из снапшота.
|
||||||
|
/// </summary>
|
||||||
|
[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<MatchGrain> _logger;
|
||||||
|
|
||||||
|
private string _matchId = string.Empty;
|
||||||
|
private MatchGrain? _match;
|
||||||
|
|
||||||
|
public MatchActorGrain(
|
||||||
|
CardCatalog catalog,
|
||||||
|
GameOptions options,
|
||||||
|
TimeProvider time,
|
||||||
|
IMatchResultSink results,
|
||||||
|
IMatchStore store,
|
||||||
|
IMatchConnectionRegistry connections,
|
||||||
|
ILogger<MatchGrain> 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<bool> 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<MatchSummaryData> GetSummaryAsync()
|
||||||
|
{
|
||||||
|
var match = await RequireAsync();
|
||||||
|
return MatchSummaryData.From(match.Summary);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> HasPlayerAsync(string playerId)
|
||||||
|
{
|
||||||
|
var match = await RequireAsync();
|
||||||
|
return match.HasPlayer(playerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<MatchGrain> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,104 @@
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Vanguard.Server.Configuration;
|
||||||
|
using Vanguard.Shared.Protocol;
|
||||||
|
|
||||||
|
namespace Vanguard.Server.Matches.Orleans;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Адаптер, который позволяет HTTP/WebSocket-слою работать с Orleans-граном
|
||||||
|
/// через уже существующий <see cref="IMatchGrain"/>. Сводка кэшируется:
|
||||||
|
/// регистратуре и health нужны только идентификаторы, статус и seq, а они
|
||||||
|
/// обновляются после каждого изменяющего вызова.
|
||||||
|
/// </summary>
|
||||||
|
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<bool> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,8 @@ using Microsoft.AspNetCore.RateLimiting;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using Microsoft.IdentityModel.Tokens;
|
using Microsoft.IdentityModel.Tokens;
|
||||||
|
using Orleans.Configuration;
|
||||||
|
using Orleans.Hosting;
|
||||||
using StackExchange.Redis;
|
using StackExchange.Redis;
|
||||||
using Vanguard.Content;
|
using Vanguard.Content;
|
||||||
using Vanguard.Rules.Cards;
|
using Vanguard.Rules.Cards;
|
||||||
|
|
@ -29,6 +31,23 @@ builder.Services.Configure<StorageOptions>(builder.Configuration.GetSection("Sto
|
||||||
builder.Services.Configure<RedisOptions>(builder.Configuration.GetSection("Redis"));
|
builder.Services.Configure<RedisOptions>(builder.Configuration.GetSection("Redis"));
|
||||||
builder.Services.Configure<MetaOptions>(builder.Configuration.GetSection("Meta"));
|
builder.Services.Configure<MetaOptions>(builder.Configuration.GetSection("Meta"));
|
||||||
builder.Services.Configure<JwtOptions>(builder.Configuration.GetSection("Jwt"));
|
builder.Services.Configure<JwtOptions>(builder.Configuration.GetSection("Jwt"));
|
||||||
|
builder.Services.Configure<OrleansOptions>(builder.Configuration.GetSection(OrleansOptions.SectionName));
|
||||||
|
|
||||||
|
var orleansOptions = builder.Configuration
|
||||||
|
.GetSection(OrleansOptions.SectionName)
|
||||||
|
.Get<OrleansOptions>() ?? new OrleansOptions();
|
||||||
|
|
||||||
|
if (orleansOptions.Enabled)
|
||||||
|
{
|
||||||
|
builder.Host.UseOrleans(silo =>
|
||||||
|
{
|
||||||
|
// Один silo на инстанс сервера. Для нескольких реплик нужен настоящий
|
||||||
|
// кластер (AdoNet/Redis clustering), но контракт грана уже готов.
|
||||||
|
silo.UseLocalhostClustering();
|
||||||
|
silo.Configure<GrainCollectionOptions>(options =>
|
||||||
|
options.CollectionAge = TimeSpan.FromHours(12));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
builder.Services.AddSingleton(sp => sp.GetRequiredService<IOptions<GameOptions>>().Value);
|
builder.Services.AddSingleton(sp => sp.GetRequiredService<IOptions<GameOptions>>().Value);
|
||||||
builder.Services.AddSingleton(sp => sp.GetRequiredService<IOptions<ContentOptions>>().Value);
|
builder.Services.AddSingleton(sp => sp.GetRequiredService<IOptions<ContentOptions>>().Value);
|
||||||
|
|
@ -162,6 +181,7 @@ builder.Services.AddRateLimiter(options =>
|
||||||
});
|
});
|
||||||
|
|
||||||
builder.Services.AddSingleton<GameTicketService>();
|
builder.Services.AddSingleton<GameTicketService>();
|
||||||
|
builder.Services.AddSingleton<IMatchConnectionRegistry, MatchConnectionRegistry>();
|
||||||
builder.Services.AddSingleton<MatchGrainRegistry>();
|
builder.Services.AddSingleton<MatchGrainRegistry>();
|
||||||
builder.Services.AddSingleton<MatchRecoveryService>();
|
builder.Services.AddSingleton<MatchRecoveryService>();
|
||||||
builder.Services.AddSingleton<MatchmakingService>();
|
builder.Services.AddSingleton<MatchmakingService>();
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ public static class GameWebSocketEndpoint
|
||||||
var options = services.GetRequiredService<GameOptions>();
|
var options = services.GetRequiredService<GameOptions>();
|
||||||
var tickets = services.GetRequiredService<GameTicketService>();
|
var tickets = services.GetRequiredService<GameTicketService>();
|
||||||
var matches = services.GetRequiredService<MatchGrainRegistry>();
|
var matches = services.GetRequiredService<MatchGrainRegistry>();
|
||||||
|
var connections = services.GetRequiredService<IMatchConnectionRegistry>();
|
||||||
var time = services.GetRequiredService<TimeProvider>();
|
var time = services.GetRequiredService<TimeProvider>();
|
||||||
var logger = services.GetRequiredService<ILoggerFactory>().CreateLogger("GameSocket");
|
var logger = services.GetRequiredService<ILoggerFactory>().CreateLogger("GameSocket");
|
||||||
|
|
||||||
|
|
@ -32,6 +33,7 @@ public static class GameWebSocketEndpoint
|
||||||
|
|
||||||
IMatchGrain? match = null;
|
IMatchGrain? match = null;
|
||||||
var seat = -1;
|
var seat = -1;
|
||||||
|
var attached = false;
|
||||||
HeartbeatState? heartbeat = null;
|
HeartbeatState? heartbeat = null;
|
||||||
CancellationTokenSource? heartbeatCts = null;
|
CancellationTokenSource? heartbeatCts = null;
|
||||||
Task? heartbeatTask = null;
|
Task? heartbeatTask = null;
|
||||||
|
|
@ -90,13 +92,34 @@ public static class GameWebSocketEndpoint
|
||||||
}
|
}
|
||||||
|
|
||||||
seat = ticket.Seat;
|
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,
|
await FailAsync(connection, ProtocolErrorCode.Forbidden,
|
||||||
"Место в матче не принадлежит игроку.", 4403, "forbidden");
|
"Место в матче не принадлежит игроку.", 4403, "forbidden");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
attached = true;
|
||||||
|
|
||||||
logger.LogInformation(
|
logger.LogInformation(
|
||||||
"Socket {ConnectionId}: {PlayerId} connected to {MatchId} seat {Seat}.",
|
"Socket {ConnectionId}: {PlayerId} connected to {MatchId} seat {Seat}.",
|
||||||
connection.Id, ticket.PlayerId, ticket.MatchId, seat);
|
connection.Id, ticket.PlayerId, ticket.MatchId, seat);
|
||||||
|
|
@ -227,7 +250,9 @@ public static class GameWebSocketEndpoint
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await match.DetachAsync(seat, connection);
|
var detached = connections.Unregister(match.MatchId, seat, connection);
|
||||||
|
if (detached && attached)
|
||||||
|
await match.DetachAsync(seat);
|
||||||
}
|
}
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,8 @@
|
||||||
<PackageReference Include="BCrypt.Net-Next" Version="4.2.1" />
|
<PackageReference Include="BCrypt.Net-Next" Version="4.2.1" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.13" />
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.13" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.4" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.4" />
|
||||||
|
<PackageReference Include="Microsoft.Orleans.Clustering.Localhost" Version="10.4.0" />
|
||||||
|
<PackageReference Include="Microsoft.Orleans.Server" Version="10.4.0" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.4">
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.4">
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
|
|
||||||
|
|
@ -50,5 +50,8 @@
|
||||||
"SigningKey": "dev-only-vanguard-remaster-signing-key-change-me",
|
"SigningKey": "dev-only-vanguard-remaster-signing-key-change-me",
|
||||||
"AccessMinutes": 15,
|
"AccessMinutes": 15,
|
||||||
"RefreshDays": 30
|
"RefreshDays": 30
|
||||||
|
},
|
||||||
|
"Orleans": {
|
||||||
|
"Enabled": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -343,6 +343,7 @@ public class MatchGrainTests
|
||||||
time,
|
time,
|
||||||
sink ?? new RecordingResultSink(),
|
sink ?? new RecordingResultSink(),
|
||||||
new NullMatchStore(),
|
new NullMatchStore(),
|
||||||
|
new MatchConnectionRegistry(),
|
||||||
NullLogger<MatchGrain>.Instance);
|
NullLogger<MatchGrain>.Instance);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -152,6 +152,7 @@ public class MatchPersistenceTests
|
||||||
services.AddSingleton<TimeProvider>(time);
|
services.AddSingleton<TimeProvider>(time);
|
||||||
services.AddSingleton<IMatchResultSink>(profiles);
|
services.AddSingleton<IMatchResultSink>(profiles);
|
||||||
services.AddSingleton<IMatchStore>(store);
|
services.AddSingleton<IMatchStore>(store);
|
||||||
|
services.AddSingleton<IMatchConnectionRegistry, MatchConnectionRegistry>();
|
||||||
services.AddLogging();
|
services.AddLogging();
|
||||||
|
|
||||||
await using var provider = services.BuildServiceProvider();
|
await using var provider = services.BuildServiceProvider();
|
||||||
|
|
@ -169,7 +170,7 @@ public class MatchPersistenceTests
|
||||||
Assert.Equal(originalView.EventSeq, recovered!.Summary.EventSeq);
|
Assert.Equal(originalView.EventSeq, recovered!.Summary.EventSeq);
|
||||||
|
|
||||||
var connection = new FakeMatchConnection();
|
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<ServerSnapshot>().View;
|
var view = connection.Last<ServerSnapshot>().View;
|
||||||
Assert.Equal(originalView.EventSeq, view.EventSeq);
|
Assert.Equal(originalView.EventSeq, view.EventSeq);
|
||||||
|
|
@ -204,6 +205,7 @@ public class MatchPersistenceTests
|
||||||
time,
|
time,
|
||||||
new RecordingResultSink(),
|
new RecordingResultSink(),
|
||||||
store,
|
store,
|
||||||
|
new MatchConnectionRegistry(),
|
||||||
NullLogger<MatchGrain>.Instance);
|
NullLogger<MatchGrain>.Instance);
|
||||||
|
|
||||||
private static ClientIntent Intent(string intentId, IntentPayload payload) => new()
|
private static ClientIntent Intent(string intentId, IntentPayload payload) => new()
|
||||||
|
|
|
||||||
|
|
@ -207,6 +207,7 @@ public class MatchmakingServiceTests
|
||||||
services.AddSingleton<TimeProvider>(harness.Time);
|
services.AddSingleton<TimeProvider>(harness.Time);
|
||||||
services.AddSingleton<IMatchResultSink>(harness.Profiles);
|
services.AddSingleton<IMatchResultSink>(harness.Profiles);
|
||||||
services.AddSingleton<IMatchStore>(harness.MatchStore);
|
services.AddSingleton<IMatchStore>(harness.MatchStore);
|
||||||
|
services.AddSingleton<IMatchConnectionRegistry, MatchConnectionRegistry>();
|
||||||
services.AddSingleton<IQueueStore>(harness.QueueStore);
|
services.AddSingleton<IQueueStore>(harness.QueueStore);
|
||||||
services.AddLogging();
|
services.AddLogging();
|
||||||
|
|
||||||
|
|
@ -247,6 +248,7 @@ public class MatchmakingServiceTests
|
||||||
services.AddSingleton<TimeProvider>(time);
|
services.AddSingleton<TimeProvider>(time);
|
||||||
services.AddSingleton<IMatchResultSink>(profiles);
|
services.AddSingleton<IMatchResultSink>(profiles);
|
||||||
services.AddSingleton<IMatchStore>(store);
|
services.AddSingleton<IMatchStore>(store);
|
||||||
|
services.AddSingleton<IMatchConnectionRegistry, MatchConnectionRegistry>();
|
||||||
services.AddSingleton<IQueueStore>(queueStore);
|
services.AddSingleton<IQueueStore>(queueStore);
|
||||||
services.AddLogging();
|
services.AddLogging();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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<IMatchActor>("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<ServerHello>().Single();
|
||||||
|
Assert.Equal(0, hello.PlayerIndex);
|
||||||
|
Assert.False(hello.Resumed);
|
||||||
|
|
||||||
|
var snapshot = first.All<ServerSnapshot>().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<ServerIntentResult>();
|
||||||
|
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<TimeProvider>(_time);
|
||||||
|
services.AddSingleton<IMatchResultSink>(_results);
|
||||||
|
services.AddSingleton<IMatchStore, NullMatchStore>();
|
||||||
|
services.AddSingleton<IMatchConnectionRegistry>(_connections);
|
||||||
|
services.AddLogging();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -11,6 +11,7 @@
|
||||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.13" />
|
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.13" />
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||||
|
<PackageReference Include="Microsoft.Orleans.TestingHost" Version="10.4.0" />
|
||||||
<PackageReference Include="xunit" Version="2.9.3" />
|
<PackageReference Include="xunit" Version="2.9.3" />
|
||||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue