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, FinishReason: MatchFinishReason.Normal, 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().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().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(); 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().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().View; var options = ServerTestData.SmallOptions(); var profiles = new RecordingResultSink(); var services = new ServiceCollection(); services.AddSingleton(ServerTestData.Catalog); services.AddSingleton(options); services.AddSingleton(time); services.AddSingleton(profiles); services.AddSingleton(store); services.AddSingleton(); services.AddLogging(); await using var provider = services.BuildServiceProvider(); var registry = new MatchGrainRegistry( provider, options, store, NullLogger.Instance); var recovery = new MatchRecoveryService( store, registry, time, NullLogger.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 ((MatchGrain)recovered!).AttachAsync(0, "p1", connection, 0)); var view = connection.Last().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() .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, new MatchConnectionRegistry(), NullLogger.Instance); private static ClientIntent Intent(string intentId, IntentPayload payload) => new() { IntentId = intentId, BasedOnSeq = 0, Intent = payload, }; private static async Task 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 { private readonly DbContextOptions _options; public TestDbContextFactory(DbContextOptions options) => _options = options; public MatchDbContext CreateDbContext() => new(_options); } }