VanguardReMaster/server/tests/Vanguard.Server.Tests/MatchPersistenceTests.cs

237 lines
9.3 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,
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<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.AddSingleton<IMatchConnectionRegistry, MatchConnectionRegistry>();
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 ((MatchGrain)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,
new MatchConnectionRegistry(),
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);
}
}