diff --git a/SVSim.EmulatedEntrypoint/Matching/InProcessPairUp.cs b/SVSim.EmulatedEntrypoint/Matching/InProcessPairUp.cs index d611ddf..6999bdf 100644 --- a/SVSim.EmulatedEntrypoint/Matching/InProcessPairUp.cs +++ b/SVSim.EmulatedEntrypoint/Matching/InProcessPairUp.cs @@ -1,64 +1,129 @@ using System.Collections.Concurrent; +using Microsoft.Extensions.DependencyInjection; using SVSim.BattleNode.Bridge; using SVSim.BattleNode.Sessions; +using SVSim.Database.Models.Config; +using SVSim.Database.Services; 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). +/// In-process pair-up service: one slot per mode, FCFS pairing for PvP, plus an +/// AI-fallback branch for modes whose is +/// . The proper matching-queue API +/// is a separate spec; this is the Phase-2 + Phase-3 placeholder. /// +/// +/// Singleton (process-wide slot state) consuming a scoped +/// via . The config read is cheap — one DB read per +/// pair-up call — and avoids caching policy decisions across config edits. +/// public sealed class InProcessPairUp : IMatchingPairUpService { + /// + /// Safety backstop: if a waiter has been parked for more than this and a new + /// arriver shows up, treat the slot as empty (the original waiter has + /// presumably stopped polling). Well above the AI-fallback threshold so it + /// only fires for PvpOnly modes. + /// + private static readonly TimeSpan StaleWaiterEvictionAge = TimeSpan.FromMinutes(5); + private readonly IMatchingBridge _bridge; + private readonly ModePolicyRegistry _policies; + private readonly IServiceScopeFactory _scopeFactory; + private readonly TimeProvider _clock; private readonly ConcurrentDictionary _slots = new(); - public InProcessPairUp(IMatchingBridge bridge) + public InProcessPairUp( + IMatchingBridge bridge, + ModePolicyRegistry policies, + IServiceScopeFactory scopeFactory, + TimeProvider clock) { _bridge = bridge; + _policies = policies; + _scopeFactory = scopeFactory; + _clock = clock; } public Task TryPairAsync(string mode, BattlePlayer player, CancellationToken ct) { + var policy = _policies.For(mode); + var threshold = TimeSpan.FromSeconds(GetThresholdSeconds()); var slot = _slots.GetOrAdd(mode, _ => new ModeSlot()); + lock (slot.Lock) { // 1. Already-resolved match cached for this viewer? Consume + return. - // This caller is the FIRST arriver picking up their cached pair — owner role. + // The caller is the FIRST arriver picking up their cached pair — owner role. if (slot.Resolved.TryGetValue(player.ViewerId, out var cached)) { slot.Resolved.Remove(player.ViewerId); - return Task.FromResult(new PairUpResult(cached, IsOwner: true, IsAiFallback: false)); + return Task.FromResult( + new PairUpResult(cached.Match, IsOwner: true, IsAiFallback: cached.IsAiFallback)); } - // 2. Someone already waiting in this slot? Pair with them. - // This caller is the SECOND arriver who triggered the pair — joiner role. - if (slot.Waiting is not null) + // 2. Stale waiter eviction backstop. + if (slot.Waiting is not null && slot.WaitingSince is { } since + && _clock.GetUtcNow() - since > StaleWaiterEvictionAge) + { + slot.Waiting = null; + slot.WaitingSince = null; + } + + // 3. Different viewer already waiting? Pair them. + if (slot.Waiting is not null && slot.Waiting.ViewerId != player.ViewerId) { - 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; + slot.WaitingSince = 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(new PairUpResult(match, IsOwner: false, IsAiFallback: false)); + // Cache for the FIRST arriver's next poll (consume-on-read). + slot.Resolved[p1.ViewerId] = (match, IsAiFallback: false); + return Task.FromResult( + new PairUpResult(match, IsOwner: false, IsAiFallback: false)); } - // 3. Empty slot — park this caller. - slot.Waiting = player; + // 4. Caller IS the waiter AND policy permits AI fallback AND threshold elapsed? + if (slot.Waiting?.ViewerId == player.ViewerId + && policy.Kind == PolicyKind.PvpFirstThenAiFallback + && slot.WaitingSince is { } parkedAt + && _clock.GetUtcNow() - parkedAt >= threshold) + { + slot.Waiting = null; + slot.WaitingSince = null; + var match = _bridge.RegisterBattle(player, null, BattleType.Bot); + return Task.FromResult( + new PairUpResult(match, IsOwner: true, IsAiFallback: true)); + } + + // 5. Park (first time only — preserve WaitingSince across sub-threshold re-polls). + if (slot.Waiting is null) + { + slot.Waiting = player; + slot.WaitingSince = _clock.GetUtcNow(); + } return Task.FromResult(null); } } + /// + /// Resolves the current AI-fallback threshold from the scoped + /// . Singleton-safe via per-call scope creation. + /// + private int GetThresholdSeconds() + { + using var scope = _scopeFactory.CreateScope(); + var config = scope.ServiceProvider.GetRequiredService(); + return config.Get().RankBattleAiFallbackThresholdSeconds; + } + private sealed class ModeSlot { public BattlePlayer? Waiting { get; set; } - public Dictionary Resolved { get; } = new(); + public DateTimeOffset? WaitingSince { 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 12492fd..2c927e7 100644 --- a/SVSim.EmulatedEntrypoint/Program.cs +++ b/SVSim.EmulatedEntrypoint/Program.cs @@ -128,9 +128,15 @@ public class Program // in appsettings*.json — see appsettings.Development.json for SoloDefaultsToScripted. builder.Configuration.GetSection("BattleNode").Bind(opt); }); - // 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. + // In-process FCFS pair-up for TK2 PvP /do_matching, plus rank-battle's AI-fallback + // branch. 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(new ModePolicyRegistry(new[] + { + new ModePolicy("arena_two_pick_battle", PolicyKind.PvpOnly), + new ModePolicy("rotation_rank_battle", PolicyKind.PvpFirstThenAiFallback), + new ModePolicy("unlimited_rank_battle", PolicyKind.PvpFirstThenAiFallback), + })); builder.Services.AddSingleton(); builder.Services.AddTransient(); diff --git a/SVSim.UnitTests/Matching/InProcessPairUpRankFallbackTests.cs b/SVSim.UnitTests/Matching/InProcessPairUpRankFallbackTests.cs new file mode 100644 index 0000000..6c3da6d --- /dev/null +++ b/SVSim.UnitTests/Matching/InProcessPairUpRankFallbackTests.cs @@ -0,0 +1,156 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Time.Testing; +using Moq; +using NUnit.Framework; +using SVSim.BattleNode.Bridge; +using SVSim.BattleNode.Sessions; +using SVSim.Database.Models.Config; +using SVSim.Database.Services; +using SVSim.EmulatedEntrypoint.Matching; + +namespace SVSim.UnitTests.Matching; + +[TestFixture] +[FixtureLifeCycle(LifeCycle.InstancePerTestCase)] +public class InProcessPairUpRankFallbackTests +{ + private FakeTimeProvider _clock = null!; + private Mock _bridge = null!; + private Mock _config = null!; + private ModePolicyRegistry _policies = null!; + private InProcessPairUp _pairUp = null!; + + [SetUp] + public void SetUp() + { + _clock = new FakeTimeProvider(startDateTime: new DateTimeOffset(2026, 6, 2, 0, 0, 0, TimeSpan.Zero)); + _bridge = new Mock(); + _config = new Mock(); + _config.Setup(c => c.Get()) + .Returns(new MatchingConfig { RankBattleAiFallbackThresholdSeconds = 15 }); + _policies = new ModePolicyRegistry(new[] + { + new ModePolicy("rotation_rank_battle", PolicyKind.PvpFirstThenAiFallback), + new ModePolicy("unlimited_rank_battle", PolicyKind.PvpFirstThenAiFallback), + new ModePolicy("arena_two_pick_battle", PolicyKind.PvpOnly), + }); + + // Build a tiny service provider exposing the mock IGameConfigService as scoped, + // and inject IServiceScopeFactory into InProcessPairUp the same way prod does. + var services = new ServiceCollection(); + services.AddScoped(_ => _config.Object); + var sp = services.BuildServiceProvider(); + _pairUp = new InProcessPairUp(_bridge.Object, _policies, sp.GetRequiredService(), _clock); + } + + private static BattlePlayer Player(long id) => + new(id, new MatchContext( + SelfDeckCardIds: Array.Empty(), ClassId: "0", CharaId: "0", + CardMasterName: "card_master_node_10015", + CountryCode: "JP", UserName: $"P{id}", SleeveId: "0", + EmblemId: "0", DegreeId: "0", FieldId: 0, IsOfficial: 0, BattleType: 11)); + + [Test] + public async Task TK2_policy_is_PvpOnly_no_fallback_regression() + { + var p = Player(1); + var first = await _pairUp.TryPairAsync("arena_two_pick_battle", p, default); + Assert.That(first, Is.Null, "First poll should park."); + + _clock.Advance(TimeSpan.FromSeconds(20)); // Past the rotation threshold. + var second = await _pairUp.TryPairAsync("arena_two_pick_battle", p, default); + + Assert.That(second, Is.Null, "TK2 must not fall back to AI even past threshold."); + _bridge.Verify(b => b.RegisterBattle(It.IsAny(), It.IsAny(), BattleType.Bot), Times.Never); + } + + [Test] + public async Task Rotation_first_poll_parks_no_fallback() + { + var p = Player(1); + var result = await _pairUp.TryPairAsync("rotation_rank_battle", p, default); + Assert.That(result, Is.Null, "First poll should park even on fallback-eligible modes."); + _bridge.Verify(b => b.RegisterBattle(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task Rotation_second_poll_under_threshold_stays_parked() + { + var p = Player(1); + await _pairUp.TryPairAsync("rotation_rank_battle", p, default); + _clock.Advance(TimeSpan.FromSeconds(5)); + + var result = await _pairUp.TryPairAsync("rotation_rank_battle", p, default); + + Assert.That(result, Is.Null, "Sub-threshold polls should keep the viewer parked."); + _bridge.Verify(b => b.RegisterBattle(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task Rotation_poll_past_threshold_falls_back_to_Bot() + { + var p = Player(1); + var bid = "bot-bid-1"; + var url = "http://node.local/socket.io/"; + _bridge.Setup(b => b.RegisterBattle(p, null, BattleType.Bot)) + .Returns(new PendingMatch(bid, url)); + + await _pairUp.TryPairAsync("rotation_rank_battle", p, default); + _clock.Advance(TimeSpan.FromSeconds(16)); + + var result = await _pairUp.TryPairAsync("rotation_rank_battle", p, default); + + Assert.That(result, Is.Not.Null); + Assert.That(result!.IsAiFallback, Is.True); + Assert.That(result.IsOwner, Is.True); + Assert.That(result.Match.BattleId, Is.EqualTo(bid)); + _bridge.Verify(b => b.RegisterBattle(p, null, BattleType.Bot), Times.Once); + } + + [Test] + public async Task Rotation_partner_arrives_before_threshold_pairs_PvP() + { + var pA = Player(1); + var pB = Player(2); + _bridge.Setup(b => b.RegisterBattle(pA, pB, BattleType.Pvp)) + .Returns(new PendingMatch("pvp-bid", "http://node.local/socket.io/")); + + await _pairUp.TryPairAsync("rotation_rank_battle", pA, default); + _clock.Advance(TimeSpan.FromSeconds(10)); // Sub-threshold. + var joinerResult = await _pairUp.TryPairAsync("rotation_rank_battle", pB, default); + + Assert.That(joinerResult, Is.Not.Null); + Assert.That(joinerResult!.IsAiFallback, Is.False, "Pair-up wins over AI fallback when partner arrives in window."); + Assert.That(joinerResult.IsOwner, Is.False, "Joiner role."); + _bridge.Verify(b => b.RegisterBattle(pA, pB, BattleType.Pvp), Times.Once); + _bridge.Verify(b => b.RegisterBattle(It.IsAny(), null, BattleType.Bot), Times.Never); + } + + [Test] + public async Task Rotation_stale_waiter_evicted_on_next_arriver() + { + var pA = Player(1); + var pB = Player(2); + _bridge.Setup(b => b.RegisterBattle(It.IsAny(), null, BattleType.Bot)) + .Returns((p, _, _) => new PendingMatch("bot-" + p.ViewerId, "http://node.local/socket.io/")); + + await _pairUp.TryPairAsync("rotation_rank_battle", pA, default); + _clock.Advance(TimeSpan.FromMinutes(6)); // Past the 5-minute stale eviction. + + var resultB = await _pairUp.TryPairAsync("rotation_rank_battle", pB, default); + + // B sees an empty slot (A evicted as stale) and becomes the new waiter. + Assert.That(resultB, Is.Null); + _bridge.Verify(b => b.RegisterBattle(pA, pB, BattleType.Pvp), Times.Never, "Stale A should not have paired with B."); + } + + [Test] + public async Task Unlimited_independent_from_Rotation() + { + var p = Player(1); + await _pairUp.TryPairAsync("rotation_rank_battle", p, default); + var unlimitedResult = await _pairUp.TryPairAsync("unlimited_rank_battle", p, default); + + Assert.That(unlimitedResult, Is.Null, "Per-mode slots must be independent."); + } +} diff --git a/SVSim.UnitTests/Matching/InProcessPairUpTests.cs b/SVSim.UnitTests/Matching/InProcessPairUpTests.cs index 127175e..4b54f2f 100644 --- a/SVSim.UnitTests/Matching/InProcessPairUpTests.cs +++ b/SVSim.UnitTests/Matching/InProcessPairUpTests.cs @@ -1,6 +1,11 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Time.Testing; +using Moq; using NUnit.Framework; using SVSim.BattleNode.Bridge; using SVSim.BattleNode.Sessions; +using SVSim.Database.Models.Config; +using SVSim.Database.Services; using SVSim.EmulatedEntrypoint.Matching; namespace SVSim.UnitTests.Matching; @@ -11,8 +16,7 @@ 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 svc = BuildSvc(); var match = await svc.TryPairAsync("tk2", new BattlePlayer(1, Ctx()), CancellationToken.None); @@ -22,8 +26,7 @@ public class InProcessPairUpTests [Test] public async Task TryPairAsync_with_waiting_partner_pairs_returns_match_as_joiner() { - var bridge = new MatchingBridge(new InMemoryBattleSessionStore(), new BattleNodeOptions()); - var svc = new InProcessPairUp(bridge); + var svc = BuildSvc(); await svc.TryPairAsync("tk2", new BattlePlayer(1, Ctx()), CancellationToken.None); var result = await svc.TryPairAsync("tk2", new BattlePlayer(2, Ctx()), CancellationToken.None); @@ -32,13 +35,14 @@ public class InProcessPairUpTests Assert.That(result!.Match.BattleId, Is.Not.Empty); Assert.That(result.IsOwner, Is.False, "The second arriver (who triggered the pair) is the joiner — wire matching_state 3004."); + Assert.That(result.IsAiFallback, Is.False, + "TK2 is PvpOnly — never falls back to AI."); } [Test] public async Task First_arrivers_next_poll_returns_cached_match_as_owner_then_evicts() { - var bridge = new MatchingBridge(new InMemoryBattleSessionStore(), new BattleNodeOptions()); - var svc = new InProcessPairUp(bridge); + var svc = BuildSvc(); await svc.TryPairAsync("tk2", new BattlePlayer(1, Ctx()), CancellationToken.None); // park var secondPaired = await svc.TryPairAsync("tk2", new BattlePlayer(2, Ctx()), CancellationToken.None); // pair @@ -57,8 +61,7 @@ public class InProcessPairUpTests [Test] public async Task Different_modes_do_not_pair_across_slots() { - var bridge = new MatchingBridge(new InMemoryBattleSessionStore(), new BattleNodeOptions()); - var svc = new InProcessPairUp(bridge); + var svc = BuildSvc(); await svc.TryPairAsync("tk2", new BattlePlayer(1, Ctx()), CancellationToken.None); var rankMatch = await svc.TryPairAsync("rank_rotation", new BattlePlayer(2, Ctx()), CancellationToken.None); @@ -66,6 +69,26 @@ public class InProcessPairUpTests Assert.That(rankMatch, Is.Null, "Different mode shouldn't pair with tk2's waiting viewer."); } + /// + /// Builds an InProcessPairUp with a real MatchingBridge (so BattleIds are real) + /// + a fake clock, default-threshold MatchingConfig, and an empty policy registry + /// (so unknown modes default to PvpOnly — preserving Phase 2 behaviour for + /// these legacy tests). + /// + private static InProcessPairUp BuildSvc() + { + var bridge = new MatchingBridge(new InMemoryBattleSessionStore(), new BattleNodeOptions()); + var clock = new FakeTimeProvider(); + var config = new Mock(); + config.Setup(c => c.Get()).Returns(new MatchingConfig()); + var policies = new ModePolicyRegistry(Array.Empty()); + + var services = new ServiceCollection(); + services.AddScoped(_ => config.Object); + var sp = services.BuildServiceProvider(); + return new InProcessPairUp(bridge, policies, sp.GetRequiredService(), clock); + } + private static MatchContext Ctx() => new( SelfDeckCardIds: Enumerable.Range(1, 30).Select(_ => 100_011_010L).ToList(), ClassId: "1", CharaId: "1", CardMasterName: "card_master_node_10015", diff --git a/SVSim.UnitTests/SVSim.UnitTests.csproj b/SVSim.UnitTests/SVSim.UnitTests.csproj index 5cf500a..40a3946 100644 --- a/SVSim.UnitTests/SVSim.UnitTests.csproj +++ b/SVSim.UnitTests/SVSim.UnitTests.csproj @@ -10,6 +10,7 @@ +