using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; using Vanguard.Rules.Cards; using Vanguard.Server.Configuration; using Vanguard.Server.Matches; using Vanguard.Server.Matchmaking; using Vanguard.Server.Profiles; using Vanguard.Server.Storage; using Vanguard.Server.Tickets; namespace Vanguard.Server.Tests; public class MatchmakingServiceTests { [Fact] public async Task Two_Players_Are_Matched_And_Get_One_Time_Tickets() { var harness = NewHarness(); var first = await harness.Matchmaking.EnqueueAsync( "p1", ServerTestData.BuildDeck(), GameMode.ClassicRanked); var second = await harness.Matchmaking.EnqueueAsync( "p2", ServerTestData.BuildDeck(), GameMode.ClassicRanked); await harness.Matchmaking.MatchmakeAsync(harness.Time.GetUtcNow()); var firstStatus = await harness.Matchmaking.PollAsync(first.QueueId, "p1"); var secondStatus = await harness.Matchmaking.PollAsync(second.QueueId, "p2"); Assert.NotNull(firstStatus); Assert.NotNull(secondStatus); Assert.Equal("matched", firstStatus!.Status); Assert.Equal("matched", secondStatus!.Status); Assert.Equal(firstStatus.MatchId, secondStatus.MatchId); Assert.Equal(0, firstStatus.Seat); Assert.Equal(1, secondStatus.Seat); Assert.Equal("p2", firstStatus.Opponent); Assert.Equal("p1", secondStatus.Opponent); Assert.NotNull(firstStatus.Ticket); Assert.NotNull(secondStatus.Ticket); var firstTicket = await harness.Tickets.RedeemAsync(firstStatus.Ticket); var secondTicket = await harness.Tickets.RedeemAsync(secondStatus.Ticket); Assert.Equal(0, firstTicket!.Seat); Assert.Equal(1, secondTicket!.Seat); Assert.Equal(firstStatus.MatchId, firstTicket.MatchId); Assert.Equal(1, harness.Registry.Count); Assert.Equal(1, harness.Registry.ActiveCount); Assert.Equal(0, harness.Matchmaking.QueuedCount); } [Fact] public async Task Ranked_And_Casual_Queues_Are_Separate() { var harness = NewHarness(); await harness.Matchmaking.EnqueueAsync("p1", ServerTestData.BuildDeck(), GameMode.ClassicRanked); var casual = await harness.Matchmaking.EnqueueAsync( "p2", ServerTestData.BuildDeck(), GameMode.ClassicCasual); await harness.Matchmaking.MatchmakeAsync(harness.Time.GetUtcNow()); Assert.Equal("queued", (await harness.Matchmaking.PollAsync(casual.QueueId, "p2"))!.Status); Assert.Equal(0, harness.Registry.Count); Assert.Equal(2, harness.Matchmaking.QueuedCount); } [Fact] public async Task Window_Expands_While_Waiting() { var harness = NewHarness(); var ticket = await harness.Matchmaking.EnqueueAsync( "p1", ServerTestData.BuildDeck(), GameMode.ClassicRanked); var initial = await harness.Matchmaking.PollAsync(ticket.QueueId, "p1"); Assert.Equal(100, initial!.Window); harness.Time.Advance(TimeSpan.FromSeconds(20)); var later = await harness.Matchmaking.PollAsync(ticket.QueueId, "p1"); Assert.Equal(300, later!.Window); // 100 + 4 * 50 harness.Time.Advance(TimeSpan.FromSeconds(200)); var capped = await harness.Matchmaking.PollAsync(ticket.QueueId, "p1"); Assert.Equal(2000, capped!.Window); } [Fact] public async Task Invalid_Deck_Is_Rejected_At_Enqueue() { var harness = NewHarness(); var deck = ServerTestData.BuildDeck(); deck.RemoveAt(0); var exception = await Assert.ThrowsAsync( () => harness.Matchmaking.EnqueueAsync("p1", deck, GameMode.ClassicRanked)); Assert.Contains(exception.Violations, violation => violation.Code == "deck.size"); } [Fact] public async Task Deck_Without_Starting_Vanguard_Is_Rejected() { var harness = NewHarness(); var deck = new List(); foreach (var printing in ServerTestData.Catalog.Printings .Where(printing => !printing.IsParallel) .OrderBy(printing => printing.PrintingId, StringComparer.Ordinal)) { if (deck.Count >= 50) break; var card = ServerTestData.Catalog.CardOf(printing.PrintingId); if (card.Grade == 0 || card.IsSentinel || card.Trigger == TriggerType.Heal) continue; for (var i = 0; i < 4 && deck.Count < 50; i++) deck.Add(printing.PrintingId); } Assert.Equal(50, deck.Count); var exception = await Assert.ThrowsAsync( () => harness.Matchmaking.EnqueueAsync("p1", deck, GameMode.ClassicRanked)); Assert.Contains(exception.Violations, violation => violation.Code == "deck.starting-vanguard"); } [Fact] public async Task Cancel_Removes_From_Queue() { var harness = NewHarness(); var ticket = await harness.Matchmaking.EnqueueAsync( "p1", ServerTestData.BuildDeck(), GameMode.ClassicRanked); Assert.True(await harness.Matchmaking.CancelAsync(ticket.QueueId, "p1")); var status = await harness.Matchmaking.PollAsync(ticket.QueueId, "p1"); Assert.Equal("cancelled", status!.Status); Assert.Equal(0, harness.Matchmaking.QueuedCount); Assert.False(await harness.Matchmaking.CancelAsync(ticket.QueueId, "p1")); } [Fact] public async Task Second_Queue_Request_Replaces_The_First() { var harness = NewHarness(); var old = await harness.Matchmaking.EnqueueAsync( "p1", ServerTestData.BuildDeck(), GameMode.ClassicRanked); var current = await harness.Matchmaking.EnqueueAsync( "p1", ServerTestData.BuildDeck(), GameMode.ClassicRanked); Assert.Equal("cancelled", (await harness.Matchmaking.PollAsync(old.QueueId, "p1"))!.Status); Assert.Equal("queued", (await harness.Matchmaking.PollAsync(current.QueueId, "p1"))!.Status); Assert.Equal(1, harness.Matchmaking.QueuedCount); } [Fact] public async Task Queue_Expires_After_Timeout() { var harness = NewHarness(); var ticket = await harness.Matchmaking.EnqueueAsync( "p1", ServerTestData.BuildDeck(), GameMode.ClassicRanked); harness.Time.Advance(TimeSpan.FromSeconds(harness.Options.QueueExpirySeconds + 1)); await harness.Matchmaking.MatchmakeAsync(harness.Time.GetUtcNow()); Assert.Equal("expired", (await harness.Matchmaking.PollAsync(ticket.QueueId, "p1"))!.Status); Assert.Equal(0, harness.Matchmaking.QueuedCount); } [Fact] public async Task Ranked_Result_Changes_Hidden_Mmr_Casual_Does_Not() { var profiles = new PlayerProfileStore(); await profiles.RecordAsync(new MatchResult( "m1", GameMode.ClassicRanked, "winner", "loser", 0)); Assert.True(profiles.GetMmr("winner") > 1000); Assert.True(profiles.GetMmr("loser") < 1000); var winnerAfterRanked = profiles.GetMmr("winner"); var loserAfterRanked = profiles.GetMmr("loser"); await profiles.RecordAsync(new MatchResult( "m2", GameMode.ClassicCasual, "winner", "loser", 0)); Assert.Equal(winnerAfterRanked, profiles.GetMmr("winner")); Assert.Equal(loserAfterRanked, profiles.GetMmr("loser")); } [Fact] public async Task Queue_Survives_Reload_From_Store() { var harness = NewHarness(); var ticket = await harness.Matchmaking.EnqueueAsync( "p1", ServerTestData.BuildDeck(), GameMode.ClassicRanked); // новый процесс: очередь пуста, но записи лежат в store var services = new ServiceCollection(); services.AddSingleton(ServerTestData.Catalog); services.AddSingleton(harness.Options); services.AddSingleton(harness.Time); services.AddSingleton(harness.Profiles); services.AddSingleton(harness.MatchStore); services.AddSingleton(harness.QueueStore); services.AddLogging(); await using var provider = services.BuildServiceProvider(); var registry = new MatchGrainRegistry( provider, harness.Options, harness.MatchStore, NullLogger.Instance); var matchmaking = new MatchmakingService( ServerTestData.Catalog, registry, harness.Tickets, harness.Profiles, harness.Options, harness.QueueStore, harness.Time, NullLogger.Instance); await matchmaking.LoadQueuedAsync(); var status = await matchmaking.PollAsync(ticket.QueueId, "p1"); Assert.NotNull(status); Assert.Equal("queued", status!.Status); Assert.Equal(1, matchmaking.QueuedCount); } private static Harness NewHarness() { var time = new FakeTimeProvider(new DateTimeOffset(2026, 10, 21, 12, 0, 0, TimeSpan.Zero)); var options = ServerTestData.SmallOptions(); var profiles = new PlayerProfileStore(); var ticketStore = new InMemoryTicketStore(time); var tickets = new GameTicketService(ticketStore, options, time); var store = new NullMatchStore(); var queueStore = new InMemoryQueueStore(); var services = new ServiceCollection(); services.AddSingleton(ServerTestData.Catalog); services.AddSingleton(options); services.AddSingleton(time); services.AddSingleton(profiles); services.AddSingleton(store); services.AddSingleton(queueStore); services.AddLogging(); var provider = services.BuildServiceProvider(); var registry = new MatchGrainRegistry( provider, options, store, NullLogger.Instance); var matchmaking = new MatchmakingService( ServerTestData.Catalog, registry, tickets, profiles, options, queueStore, time, NullLogger.Instance); return new Harness(time, options, profiles, tickets, store, queueStore, registry, matchmaking); } private sealed record Harness( FakeTimeProvider Time, GameOptions Options, PlayerProfileStore Profiles, GameTicketService Tickets, IMatchStore MatchStore, IQueueStore QueueStore, MatchGrainRegistry Registry, MatchmakingService Matchmaking); }