From 28b1d7531a507205ed8e6f7d384a4903c7f01716 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Mon, 1 Jun 2026 22:06:49 -0400 Subject: [PATCH] feat(emulated-entrypoint): InProcessPairUp service for TK2 PvP matching Tiny per-mode FCFS slot. First poller parks; second pairs and triggers bridge.RegisterBattle(p1, p2, Pvp). Match cached for first poller's next poll (consume-on-read). No MMR, no cross-mode, no timeouts -- the proper queue API is a separate spec; this is the smallest thing that lets TK2 PvP work end-to-end. --- .../Matching/IMatchingPairUpService.cs | 20 ++++++ .../Matching/InProcessPairUp.cs | 62 +++++++++++++++++ SVSim.EmulatedEntrypoint/Program.cs | 5 ++ .../Matching/InProcessPairUpTests.cs | 69 +++++++++++++++++++ 4 files changed, 156 insertions(+) create mode 100644 SVSim.EmulatedEntrypoint/Matching/IMatchingPairUpService.cs create mode 100644 SVSim.EmulatedEntrypoint/Matching/InProcessPairUp.cs create mode 100644 SVSim.UnitTests/Matching/InProcessPairUpTests.cs diff --git a/SVSim.EmulatedEntrypoint/Matching/IMatchingPairUpService.cs b/SVSim.EmulatedEntrypoint/Matching/IMatchingPairUpService.cs new file mode 100644 index 0000000..ccce4e4 --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Matching/IMatchingPairUpService.cs @@ -0,0 +1,20 @@ +using SVSim.BattleNode.Bridge; + +namespace SVSim.EmulatedEntrypoint.Matching; + +/// +/// Minimal in-process matching queue stand-in. The proper queue API is a separate +/// spec; this is enough to actually pair two viewers polling /do_matching on the +/// same mode. +/// +public interface IMatchingPairUpService +{ + /// + /// Try to pair the calling viewer with an already-waiting partner. + /// Returns the resolved when a partner was found + /// (either this call paired with a waiter, or a previous pairing's result is + /// still cached for this viewer). Returns null if this viewer is the first + /// arriver and should be parked (caller returns 3001 RETRY). + /// + Task TryPairAsync(string mode, BattlePlayer player, CancellationToken ct); +} diff --git a/SVSim.EmulatedEntrypoint/Matching/InProcessPairUp.cs b/SVSim.EmulatedEntrypoint/Matching/InProcessPairUp.cs new file mode 100644 index 0000000..38bdfbf --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Matching/InProcessPairUp.cs @@ -0,0 +1,62 @@ +using System.Collections.Concurrent; +using SVSim.BattleNode.Bridge; +using SVSim.BattleNode.Sessions; + +namespace SVSim.EmulatedEntrypoint.Matching; + +/// +/// In-process FCFS pair-up: one waiting slot per mode, plus a per-viewer cache of +/// resolved matches for the first arriver's next poll (consume-on-read). +/// +public sealed class InProcessPairUp : IMatchingPairUpService +{ + private readonly IMatchingBridge _bridge; + private readonly ConcurrentDictionary _slots = new(); + + public InProcessPairUp(IMatchingBridge bridge) + { + _bridge = bridge; + } + + public Task TryPairAsync(string mode, BattlePlayer player, CancellationToken ct) + { + var slot = _slots.GetOrAdd(mode, _ => new ModeSlot()); + lock (slot.Lock) + { + // 1. Already-resolved match cached for this viewer? Consume + return. + if (slot.Resolved.TryGetValue(player.ViewerId, out var cached)) + { + slot.Resolved.Remove(player.ViewerId); + return Task.FromResult(cached); + } + + // 2. Someone already waiting in this slot? Pair with them. + if (slot.Waiting is not null) + { + if (slot.Waiting.ViewerId == player.ViewerId) + { + // Same viewer polled twice while parked — keep them parked. + return Task.FromResult(null); + } + var p1 = slot.Waiting; + var p2 = player; + slot.Waiting = null; + var match = _bridge.RegisterBattle(p1, p2, BattleType.Pvp); + // Cache the result for the FIRST arriver's next poll (consume-on-read). + slot.Resolved[p1.ViewerId] = match; + return Task.FromResult(match); + } + + // 3. Empty slot — park this caller. + slot.Waiting = player; + return Task.FromResult(null); + } + } + + private sealed class ModeSlot + { + public BattlePlayer? Waiting { get; set; } + public Dictionary Resolved { get; } = new(); + public object Lock { get; } = new(); + } +} diff --git a/SVSim.EmulatedEntrypoint/Program.cs b/SVSim.EmulatedEntrypoint/Program.cs index 8d5d46d..ffb8078 100644 --- a/SVSim.EmulatedEntrypoint/Program.cs +++ b/SVSim.EmulatedEntrypoint/Program.cs @@ -13,6 +13,7 @@ using SVSim.Database.Repositories.Viewer; using SVSim.Database.Services; using SVSim.EmulatedEntrypoint.Configuration; using SVSim.EmulatedEntrypoint.Extensions; +using SVSim.EmulatedEntrypoint.Matching; using SVSim.EmulatedEntrypoint.Middlewares; using SVSim.EmulatedEntrypoint.Security.SteamSessionAuthentication; using SVSim.EmulatedEntrypoint.Services; @@ -124,6 +125,10 @@ public class Program // BestHTTP's SocketManager parses this as the Socket.IO v2 endpoint URL. opt.NodeServerUrl = "localhost:5148/socket.io/"; }); + // In-process FCFS pair-up for TK2 PvP /do_matching. Singleton: per-mode state is + // process-wide. Proper queue API is a separate spec; this is enough to actually + // pair two viewers polling the same mode end-to-end. + builder.Services.AddSingleton(); builder.Services.AddTransient(); builder.Services.AddTransient(); diff --git a/SVSim.UnitTests/Matching/InProcessPairUpTests.cs b/SVSim.UnitTests/Matching/InProcessPairUpTests.cs new file mode 100644 index 0000000..cacafc1 --- /dev/null +++ b/SVSim.UnitTests/Matching/InProcessPairUpTests.cs @@ -0,0 +1,69 @@ +using NUnit.Framework; +using SVSim.BattleNode.Bridge; +using SVSim.BattleNode.Sessions; +using SVSim.EmulatedEntrypoint.Matching; + +namespace SVSim.UnitTests.Matching; + +[TestFixture] +public class InProcessPairUpTests +{ + [Test] + public async Task TryPairAsync_on_empty_slot_returns_null_and_parks() + { + var bridge = new MatchingBridge(new InMemoryBattleSessionStore(), new BattleNodeOptions()); + var svc = new InProcessPairUp(bridge); + + var match = await svc.TryPairAsync("tk2", new BattlePlayer(1, Ctx()), CancellationToken.None); + + Assert.That(match, Is.Null); + } + + [Test] + public async Task TryPairAsync_with_waiting_partner_pairs_and_returns_match() + { + var bridge = new MatchingBridge(new InMemoryBattleSessionStore(), new BattleNodeOptions()); + var svc = new InProcessPairUp(bridge); + + await svc.TryPairAsync("tk2", new BattlePlayer(1, Ctx()), CancellationToken.None); + var match = await svc.TryPairAsync("tk2", new BattlePlayer(2, Ctx()), CancellationToken.None); + + Assert.That(match, Is.Not.Null); + Assert.That(match!.BattleId, Is.Not.Empty); + } + + [Test] + public async Task First_arrivers_next_poll_returns_cached_match_then_evicts() + { + var bridge = new MatchingBridge(new InMemoryBattleSessionStore(), new BattleNodeOptions()); + var svc = new InProcessPairUp(bridge); + + await svc.TryPairAsync("tk2", new BattlePlayer(1, Ctx()), CancellationToken.None); // park + var secondPaired = await svc.TryPairAsync("tk2", new BattlePlayer(2, Ctx()), CancellationToken.None); // pair + var firstCached = await svc.TryPairAsync("tk2", new BattlePlayer(1, Ctx()), CancellationToken.None); // consume + var firstAgain = await svc.TryPairAsync("tk2", new BattlePlayer(1, Ctx()), CancellationToken.None); // post-consume + + Assert.That(firstCached, Is.Not.Null); + Assert.That(firstCached!.BattleId, Is.EqualTo(secondPaired!.BattleId)); + Assert.That(firstAgain, Is.Null, "Consumed entry must be evicted; next call re-parks."); + } + + [Test] + public async Task Different_modes_do_not_pair_across_slots() + { + var bridge = new MatchingBridge(new InMemoryBattleSessionStore(), new BattleNodeOptions()); + var svc = new InProcessPairUp(bridge); + + await svc.TryPairAsync("tk2", new BattlePlayer(1, Ctx()), CancellationToken.None); + var rankMatch = await svc.TryPairAsync("rank_rotation", new BattlePlayer(2, Ctx()), CancellationToken.None); + + Assert.That(rankMatch, Is.Null, "Different mode shouldn't pair with tk2's waiting viewer."); + } + + private static MatchContext Ctx() => new( + SelfDeckCardIds: Enumerable.Range(1, 30).Select(_ => 100_011_010L).ToList(), + ClassId: "1", CharaId: "1", CardMasterName: "card_master_node_10015", + CountryCode: "KOR", UserName: "Player", SleeveId: "3000011", + EmblemId: "701441011", DegreeId: "300003", FieldId: 43, IsOfficial: 0, + BattleType: 11); +}