222 lines
8.2 KiB
C#
222 lines
8.2 KiB
C#
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Vanguard.Server.Configuration;
|
|
using Vanguard.Server.Matches;
|
|
using Vanguard.Server.Matchmaking;
|
|
using Vanguard.Server.Profiles;
|
|
using Vanguard.Server.Tickets;
|
|
|
|
namespace Vanguard.Server.Tests;
|
|
|
|
public class MatchmakingServiceTests
|
|
{
|
|
[Fact]
|
|
public void Two_Players_Are_Matched_And_Get_One_Time_Tickets()
|
|
{
|
|
var harness = NewHarness();
|
|
|
|
var first = harness.Matchmaking.Enqueue("p1", ServerTestData.BuildDeck(), GameMode.ClassicRanked);
|
|
var second = harness.Matchmaking.Enqueue("p2", ServerTestData.BuildDeck(), GameMode.ClassicRanked);
|
|
|
|
harness.Matchmaking.Matchmake(harness.Time.GetUtcNow());
|
|
|
|
var firstStatus = harness.Matchmaking.Poll(first.QueueId, "p1");
|
|
var secondStatus = harness.Matchmaking.Poll(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 = harness.Tickets.Redeem(firstStatus.Ticket);
|
|
var secondTicket = harness.Tickets.Redeem(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 void Ranked_And_Casual_Queues_Are_Separate()
|
|
{
|
|
var harness = NewHarness();
|
|
|
|
harness.Matchmaking.Enqueue("p1", ServerTestData.BuildDeck(), GameMode.ClassicRanked);
|
|
var casual = harness.Matchmaking.Enqueue("p2", ServerTestData.BuildDeck(), GameMode.ClassicCasual);
|
|
|
|
harness.Matchmaking.Matchmake(harness.Time.GetUtcNow());
|
|
|
|
Assert.Equal("queued", harness.Matchmaking.Poll(casual.QueueId, "p2")!.Status);
|
|
Assert.Equal(0, harness.Registry.Count);
|
|
Assert.Equal(2, harness.Matchmaking.QueuedCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void Window_Expands_While_Waiting()
|
|
{
|
|
var harness = NewHarness();
|
|
var ticket = harness.Matchmaking.Enqueue("p1", ServerTestData.BuildDeck(), GameMode.ClassicRanked);
|
|
|
|
var initial = harness.Matchmaking.Poll(ticket.QueueId, "p1")!;
|
|
Assert.Equal(100, initial.Window);
|
|
|
|
harness.Time.Advance(TimeSpan.FromSeconds(20));
|
|
var later = harness.Matchmaking.Poll(ticket.QueueId, "p1")!;
|
|
Assert.Equal(300, later.Window); // 100 + 4 * 50
|
|
|
|
harness.Time.Advance(TimeSpan.FromSeconds(200));
|
|
var capped = harness.Matchmaking.Poll(ticket.QueueId, "p1")!;
|
|
Assert.Equal(2000, capped.Window);
|
|
}
|
|
|
|
[Fact]
|
|
public void Invalid_Deck_Is_Rejected_At_Enqueue()
|
|
{
|
|
var harness = NewHarness();
|
|
var deck = ServerTestData.BuildDeck();
|
|
deck.RemoveAt(0);
|
|
|
|
var exception = Assert.Throws<DeckRejectedException>(
|
|
() => harness.Matchmaking.Enqueue("p1", deck, GameMode.ClassicRanked));
|
|
|
|
Assert.Contains(exception.Violations, violation => violation.Code == "deck.size");
|
|
}
|
|
|
|
[Fact]
|
|
public void 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 == Vanguard.Rules.Cards.TriggerType.Heal)
|
|
continue;
|
|
|
|
for (var i = 0; i < 4 && deck.Count < 50; i++)
|
|
deck.Add(printing.PrintingId);
|
|
}
|
|
|
|
Assert.Equal(50, deck.Count);
|
|
|
|
var exception = Assert.Throws<DeckRejectedException>(
|
|
() => harness.Matchmaking.Enqueue("p1", deck, GameMode.ClassicRanked));
|
|
|
|
Assert.Contains(exception.Violations,
|
|
violation => violation.Code == "deck.starting-vanguard");
|
|
}
|
|
|
|
[Fact]
|
|
public void Cancel_Removes_From_Queue()
|
|
{
|
|
var harness = NewHarness();
|
|
var ticket = harness.Matchmaking.Enqueue("p1", ServerTestData.BuildDeck(), GameMode.ClassicRanked);
|
|
|
|
Assert.True(harness.Matchmaking.Cancel(ticket.QueueId, "p1"));
|
|
|
|
var status = harness.Matchmaking.Poll(ticket.QueueId, "p1")!;
|
|
Assert.Equal("cancelled", status.Status);
|
|
Assert.Equal(0, harness.Matchmaking.QueuedCount);
|
|
Assert.False(harness.Matchmaking.Cancel(ticket.QueueId, "p1"));
|
|
}
|
|
|
|
[Fact]
|
|
public void Second_Queue_Request_Replaces_The_First()
|
|
{
|
|
var harness = NewHarness();
|
|
var old = harness.Matchmaking.Enqueue("p1", ServerTestData.BuildDeck(), GameMode.ClassicRanked);
|
|
var current = harness.Matchmaking.Enqueue("p1", ServerTestData.BuildDeck(), GameMode.ClassicRanked);
|
|
|
|
Assert.Equal("cancelled", harness.Matchmaking.Poll(old.QueueId, "p1")!.Status);
|
|
Assert.Equal("queued", harness.Matchmaking.Poll(current.QueueId, "p1")!.Status);
|
|
Assert.Equal(1, harness.Matchmaking.QueuedCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void Queue_Expires_After_Timeout()
|
|
{
|
|
var harness = NewHarness();
|
|
var ticket = harness.Matchmaking.Enqueue("p1", ServerTestData.BuildDeck(), GameMode.ClassicRanked);
|
|
|
|
harness.Time.Advance(TimeSpan.FromSeconds(harness.Options.QueueExpirySeconds + 1));
|
|
harness.Matchmaking.Matchmake(harness.Time.GetUtcNow());
|
|
|
|
Assert.Equal("expired", harness.Matchmaking.Poll(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"));
|
|
}
|
|
|
|
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 tickets = new GameTicketService(options, time);
|
|
|
|
var services = new ServiceCollection();
|
|
services.AddSingleton(ServerTestData.Catalog);
|
|
services.AddSingleton(options);
|
|
services.AddSingleton<TimeProvider>(time);
|
|
services.AddSingleton<IMatchResultSink>(profiles);
|
|
services.AddLogging();
|
|
|
|
var provider = services.BuildServiceProvider();
|
|
var registry = new MatchGrainRegistry(provider, options, NullLogger<MatchGrainRegistry>.Instance);
|
|
var matchmaking = new MatchmakingService(
|
|
ServerTestData.Catalog,
|
|
registry,
|
|
tickets,
|
|
profiles,
|
|
options,
|
|
time,
|
|
NullLogger<MatchmakingService>.Instance);
|
|
|
|
return new Harness(time, options, profiles, tickets, registry, matchmaking);
|
|
}
|
|
|
|
private sealed record Harness(
|
|
FakeTimeProvider Time,
|
|
GameOptions Options,
|
|
PlayerProfileStore Profiles,
|
|
GameTicketService Tickets,
|
|
MatchGrainRegistry Registry,
|
|
MatchmakingService Matchmaking);
|
|
}
|