234 lines
9.1 KiB
C#
234 lines
9.1 KiB
C#
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Vanguard.Server.Configuration;
|
|
using Vanguard.Server.Matches;
|
|
using Vanguard.Server.Storage;
|
|
using Vanguard.Shared.Protocol;
|
|
|
|
namespace Vanguard.Server.Tests;
|
|
|
|
public class MatchPersistenceTests
|
|
{
|
|
private static readonly DateTimeOffset Start = new(2026, 10, 21, 12, 0, 0, TimeSpan.Zero);
|
|
|
|
[Fact]
|
|
public async Task EfStore_RoundTrips_Header_And_Intents()
|
|
{
|
|
var time = new FakeTimeProvider(Start);
|
|
var store = NewStore(out _, time);
|
|
var deck = ServerTestData.BuildDeck();
|
|
|
|
var header = new MatchHeader(
|
|
"m1", GameMode.ClassicRanked, "p1", "p2", deck, deck, 42,
|
|
time.GetUtcNow(), Started: false, 300_000, 300_000,
|
|
TurnDeadline: null,
|
|
AbsentSince0: time.GetUtcNow(),
|
|
AbsentSince1: time.GetUtcNow(),
|
|
LastClockSent: time.GetUtcNow(),
|
|
IsFinished: false, Winner: -1, FinishedAt: null, EventSeq: 1);
|
|
|
|
await store.CreateAsync(header);
|
|
await store.AppendIntentAsync(
|
|
header with { EventSeq = 2 },
|
|
new PersistedIntent("i1", 0, "finishMulligan", true, IntentRejectCode.None, null, 2));
|
|
|
|
var loaded = await store.GetAsync("m1");
|
|
Assert.NotNull(loaded);
|
|
Assert.Equal(42UL, loaded!.Header.Seed);
|
|
Assert.Equal(deck, loaded.Header.Deck0);
|
|
Assert.Equal(2, loaded.Header.EventSeq);
|
|
Assert.Single(loaded.Intents);
|
|
Assert.Equal("i1", loaded.Intents[0].IntentId);
|
|
Assert.True(loaded.Intents[0].Accepted);
|
|
|
|
var unfinished = await store.LoadUnfinishedAsync(time.GetUtcNow().AddMinutes(-1));
|
|
Assert.Single(unfinished);
|
|
|
|
await store.UpdateHeaderAsync(loaded.Header with
|
|
{
|
|
IsFinished = true,
|
|
Winner = 0,
|
|
FinishedAt = time.GetUtcNow(),
|
|
});
|
|
|
|
Assert.Empty(await store.LoadUnfinishedAsync(time.GetUtcNow().AddMinutes(-1)));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Match_Is_Restored_By_Replaying_Accepted_Intents()
|
|
{
|
|
var time = new FakeTimeProvider(Start);
|
|
var store = NewStore(out _, time);
|
|
var setup = NewSetup("m1");
|
|
|
|
var original = NewGrain(setup, time, store);
|
|
await store.CreateAsync(original.BuildHeader());
|
|
|
|
var first = await Attach(original, 0, "p1");
|
|
var second = await Attach(original, 1, "p2");
|
|
|
|
await original.SubmitAsync(0, Intent("i1", new FinishMulliganIntent()));
|
|
await original.SubmitAsync(1, Intent("i2", new FinishMulliganIntent()));
|
|
|
|
await original.ResyncAsync(0, 0, fullSnapshot: true);
|
|
var originalView = first.Last<ServerSnapshot>().View;
|
|
|
|
var snapshot = await store.GetAsync("m1");
|
|
Assert.NotNull(snapshot);
|
|
Assert.Equal(originalView.EventSeq, snapshot!.Header.EventSeq);
|
|
|
|
var restored = NewGrain(NewSetup("m1", snapshot.Header.Seed), time, store);
|
|
await restored.RestoreAsync(snapshot);
|
|
|
|
Assert.Equal(original.Summary.EventSeq, restored.Summary.EventSeq);
|
|
Assert.False(restored.Summary.IsFinished);
|
|
|
|
var restoredFirst = await Attach(restored, 0, "p1");
|
|
var restoredSecond = await Attach(restored, 1, "p2");
|
|
await restored.ResyncAsync(0, 0, fullSnapshot: true);
|
|
var restoredView = restoredFirst.Last<ServerSnapshot>().View;
|
|
|
|
Assert.Equal(originalView.EventSeq, restoredView.EventSeq);
|
|
Assert.Equal(originalView.ActivePlayer, restoredView.ActivePlayer);
|
|
Assert.Equal(originalView.TurnNumber, restoredView.TurnNumber);
|
|
Assert.Equal(originalView.Phase, restoredView.Phase);
|
|
Assert.Equal(originalView.Step, restoredView.Step);
|
|
|
|
Assert.Equal(
|
|
originalView.Players[0].Hand.Select(card => card.PrintingId),
|
|
restoredView.Players[0].Hand.Select(card => card.PrintingId));
|
|
Assert.Equal(originalView.Players[1].HandCount, restoredView.Players[1].HandCount);
|
|
Assert.Equal(originalView.Players[0].DeckCount, restoredView.Players[0].DeckCount);
|
|
Assert.Equal(
|
|
originalView.Players[0].Vanguard?.PrintingId,
|
|
restoredView.Players[0].Vanguard?.PrintingId);
|
|
|
|
// повтор уже принятого интента после восстановления не применяется
|
|
await restored.SubmitAsync(1, Intent("i2", new FinishMulliganIntent()));
|
|
var duplicate = restoredSecond.Last<ServerIntentResult>();
|
|
Assert.True(duplicate.Duplicate);
|
|
Assert.Equal(restored.Summary.EventSeq, duplicate.EventSeq);
|
|
|
|
// и матч продолжает принимать новые интенты
|
|
var activeSeat = restoredView.ActivePlayer;
|
|
var activeConnection = activeSeat == 0 ? restoredFirst : restoredSecond;
|
|
|
|
await restored.ResyncAsync(activeSeat, 0, fullSnapshot: true);
|
|
var activeView = activeConnection.Last<ServerSnapshot>().View;
|
|
var next = activeView.LegalIntents.First(intent => intent is not ConcedeIntent);
|
|
|
|
await restored.SubmitAsync(activeSeat, Intent("after-restore", next));
|
|
|
|
Assert.True(restored.Summary.EventSeq > restoredView.EventSeq);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RecoveryService_Restores_Unfinished_Match_After_Restart()
|
|
{
|
|
var time = new FakeTimeProvider(Start);
|
|
var store = NewStore(out _, time);
|
|
var setup = NewSetup("m1");
|
|
|
|
var original = NewGrain(setup, time, store);
|
|
await store.CreateAsync(original.BuildHeader());
|
|
|
|
var first = await Attach(original, 0, "p1");
|
|
await Attach(original, 1, "p2");
|
|
|
|
await original.SubmitAsync(0, Intent("i1", new FinishMulliganIntent()));
|
|
await original.SubmitAsync(1, Intent("i2", new FinishMulliganIntent()));
|
|
|
|
await original.ResyncAsync(0, 0, fullSnapshot: true);
|
|
var originalView = first.Last<ServerSnapshot>().View;
|
|
|
|
var options = ServerTestData.SmallOptions();
|
|
var profiles = new RecordingResultSink();
|
|
|
|
var services = new ServiceCollection();
|
|
services.AddSingleton(ServerTestData.Catalog);
|
|
services.AddSingleton(options);
|
|
services.AddSingleton<TimeProvider>(time);
|
|
services.AddSingleton<IMatchResultSink>(profiles);
|
|
services.AddSingleton<IMatchStore>(store);
|
|
services.AddLogging();
|
|
|
|
await using var provider = services.BuildServiceProvider();
|
|
|
|
var registry = new MatchGrainRegistry(
|
|
provider, options, store, NullLogger<MatchGrainRegistry>.Instance);
|
|
|
|
var recovery = new MatchRecoveryService(
|
|
store, registry, time, NullLogger<MatchRecoveryService>.Instance);
|
|
|
|
await recovery.RecoverAsync();
|
|
|
|
Assert.Equal(1, registry.Count);
|
|
Assert.True(registry.TryGet("m1", out var recovered));
|
|
Assert.Equal(originalView.EventSeq, recovered!.Summary.EventSeq);
|
|
|
|
var connection = new FakeMatchConnection();
|
|
Assert.True(await recovered.AttachAsync(0, "p1", connection, 0));
|
|
|
|
var view = connection.Last<ServerSnapshot>().View;
|
|
Assert.Equal(originalView.EventSeq, view.EventSeq);
|
|
Assert.Equal(originalView.ActivePlayer, view.ActivePlayer);
|
|
Assert.Equal(originalView.Phase, view.Phase);
|
|
Assert.Equal(
|
|
originalView.Players[0].Hand.Select(card => card.PrintingId),
|
|
view.Players[0].Hand.Select(card => card.PrintingId));
|
|
}
|
|
|
|
private static EfMatchStore NewStore(out string databaseName, TimeProvider time)
|
|
{
|
|
databaseName = "matches-" + Guid.NewGuid().ToString("N");
|
|
var options = new DbContextOptionsBuilder<MatchDbContext>()
|
|
.UseInMemoryDatabase(databaseName)
|
|
.Options;
|
|
|
|
return new EfMatchStore(new TestDbContextFactory(options), time);
|
|
}
|
|
|
|
private static MatchSetup NewSetup(string matchId, ulong? seed = null) => new(
|
|
matchId,
|
|
GameMode.ClassicRanked,
|
|
new MatchSeatSetup("p1", ServerTestData.BuildDeck(), 1000),
|
|
new MatchSeatSetup("p2", ServerTestData.BuildDeck(), 1000),
|
|
seed);
|
|
|
|
private static MatchGrain NewGrain(MatchSetup setup, TimeProvider time, IMatchStore store) => new(
|
|
setup,
|
|
ServerTestData.Catalog,
|
|
ServerTestData.SmallOptions(),
|
|
time,
|
|
new RecordingResultSink(),
|
|
store,
|
|
NullLogger<MatchGrain>.Instance);
|
|
|
|
private static ClientIntent Intent(string intentId, IntentPayload payload) => new()
|
|
{
|
|
IntentId = intentId,
|
|
BasedOnSeq = 0,
|
|
Intent = payload,
|
|
};
|
|
|
|
private static async Task<FakeMatchConnection> Attach(
|
|
MatchGrain grain,
|
|
int seat,
|
|
string playerId)
|
|
{
|
|
var connection = new FakeMatchConnection();
|
|
Assert.True(await grain.AttachAsync(seat, playerId, connection, 0));
|
|
return connection;
|
|
}
|
|
|
|
private sealed class TestDbContextFactory : IDbContextFactory<MatchDbContext>
|
|
{
|
|
private readonly DbContextOptions<MatchDbContext> _options;
|
|
|
|
public TestDbContextFactory(DbContextOptions<MatchDbContext> options) => _options = options;
|
|
|
|
public MatchDbContext CreateDbContext() => new(_options);
|
|
}
|
|
}
|