279 lines
11 KiB
C#
279 lines
11 KiB
C#
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<DeckRejectedException>(
|
|
() => 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<string>();
|
|
|
|
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<DeckRejectedException>(
|
|
() => 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<TimeProvider>(harness.Time);
|
|
services.AddSingleton<IMatchResultSink>(harness.Profiles);
|
|
services.AddSingleton<IMatchStore>(harness.MatchStore);
|
|
services.AddSingleton<IQueueStore>(harness.QueueStore);
|
|
services.AddLogging();
|
|
|
|
await using var provider = services.BuildServiceProvider();
|
|
var registry = new MatchGrainRegistry(
|
|
provider, harness.Options, harness.MatchStore, NullLogger<MatchGrainRegistry>.Instance);
|
|
var matchmaking = new MatchmakingService(
|
|
ServerTestData.Catalog,
|
|
registry,
|
|
harness.Tickets,
|
|
harness.Profiles,
|
|
harness.Options,
|
|
harness.QueueStore,
|
|
harness.Time,
|
|
NullLogger<MatchmakingService>.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<TimeProvider>(time);
|
|
services.AddSingleton<IMatchResultSink>(profiles);
|
|
services.AddSingleton<IMatchStore>(store);
|
|
services.AddSingleton<IQueueStore>(queueStore);
|
|
services.AddLogging();
|
|
|
|
var provider = services.BuildServiceProvider();
|
|
var registry = new MatchGrainRegistry(
|
|
provider, options, store, NullLogger<MatchGrainRegistry>.Instance);
|
|
var matchmaking = new MatchmakingService(
|
|
ServerTestData.Catalog,
|
|
registry,
|
|
tickets,
|
|
profiles,
|
|
options,
|
|
queueStore,
|
|
time,
|
|
NullLogger<MatchmakingService>.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);
|
|
}
|