From 672a89ed46560890d0148dd2107917f2f4acd512 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Tue, 2 Jun 2026 15:18:48 -0400 Subject: [PATCH] refactor(matching): IMatchingResolver shared by every do_matching family MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SoloDefaultsToScripted was only consulted by ArenaTwoPickBattleController; RankBattleController did its own inline pair-up + state-code mapping and ignored the flag entirely. Result: turning on the flag globally only short-circuited TK2 polls, while rank-battle polls still parked for the PvpFirstThenAiFallback threshold (15s) before resolving — surfaced today when the user set the flag and saw rank-battle still queue, then bot- battle via the client-side AI (not the server-side Scripted lifecycle we need to test WS traffic against). New IMatchingResolver owns the cross-cutting decisions: - honor scriptedOptIn (per-request) OR options.SoloDefaultsToScripted (process-wide) — bypass pair-up, register Scripted, return 3004 - otherwise call IMatchingPairUpService.TryPairAsync and translate the PairUpResult to the 3002/3004/3007/3011 vocabulary Family controllers shed the duplicated logic: - ArenaTwoPickBattleController: ~50 LOC → ~25; preserves ?scripted=1 query opt-in (parsed permissively for "1"/"true") and the ArenaTwoPickException catch - RankBattleController: ~30 LOC → ~12; preserves the 3001 mapping for InvalidOperationException (no deck for format) and card_master_id emission DoMatchingContractTests is the durable enforcement: parametrized over TK2 + rotation + unlimited rank, asserts SoloDefaultsToScripted=true makes every family's first poll skip 3002 and return SUCCEEDED with a battle_id + node_server_url. Adding a fourth family that forgets to route through IMatchingResolver fails this test — that's the point. MatchingResolverTests covers the six resolver paths in isolation with mocks; per-test Harness locals (not fixture-level fields) because the assembly is [Parallelizable(ParallelScope.All)] and shared mocks race. 957 tests passing (was 948; +9: 6 resolver + 3 contract parametrizations). No regressions in the existing TK2 / rank-battle controller suites. Co-Authored-By: Claude Opus 4.7 --- .../ArenaTwoPickBattleController.cs | 70 ++-------- .../Controllers/RankBattleController.cs | 40 ++---- .../Matching/IMatchingResolver.cs | 54 ++++++++ .../Matching/MatchingResolver.cs | 63 +++++++++ SVSim.EmulatedEntrypoint/Program.cs | 6 +- .../Controllers/DoMatchingContractTests.cs | 118 ++++++++++++++++ .../Matching/MatchingResolverTests.cs | 129 ++++++++++++++++++ 7 files changed, 392 insertions(+), 88 deletions(-) create mode 100644 SVSim.EmulatedEntrypoint/Matching/IMatchingResolver.cs create mode 100644 SVSim.EmulatedEntrypoint/Matching/MatchingResolver.cs create mode 100644 SVSim.UnitTests/Controllers/DoMatchingContractTests.cs create mode 100644 SVSim.UnitTests/Matching/MatchingResolverTests.cs diff --git a/SVSim.EmulatedEntrypoint/Controllers/ArenaTwoPickBattleController.cs b/SVSim.EmulatedEntrypoint/Controllers/ArenaTwoPickBattleController.cs index 3dab706..7483f00 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/ArenaTwoPickBattleController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/ArenaTwoPickBattleController.cs @@ -11,23 +11,17 @@ namespace SVSim.EmulatedEntrypoint.Controllers; public class ArenaTwoPickBattleController : SVSimController { private readonly IArenaTwoPickService _svc; - private readonly IMatchingBridge _matching; private readonly IMatchContextBuilder _matchContextBuilder; - private readonly IMatchingPairUpService _pairUp; - private readonly BattleNodeOptions _battleNodeOptions; + private readonly IMatchingResolver _resolver; public ArenaTwoPickBattleController( IArenaTwoPickService svc, - IMatchingBridge matching, IMatchContextBuilder matchContextBuilder, - IMatchingPairUpService pairUp, - BattleNodeOptions battleNodeOptions) + IMatchingResolver resolver) { _svc = svc; - _matching = matching; _matchContextBuilder = matchContextBuilder; - _pairUp = pairUp; - _battleNodeOptions = battleNodeOptions; + _resolver = resolver; } [HttpPost("do_matching")] @@ -37,59 +31,21 @@ public class ArenaTwoPickBattleController : SVSimController CancellationToken ct = default) { if (!TryGetViewerId(out var vid)) return Unauthorized(); - // Accept "1" or "true" (case-insensitive) as opt-in for the legacy Scripted path. - // ASP.NET's default bool binder rejects "1", so do a permissive parse here. - // The server-side BattleNodeOptions.SoloDefaultsToScripted flag is the other - // route — it bypasses pair-up for every solo poll, useful when the live client - // (which can't append query params) needs a Scripted match. - var useScripted = (scripted is not null - && (scripted == "1" || string.Equals(scripted, "true", StringComparison.OrdinalIgnoreCase))) - || _battleNodeOptions.SoloDefaultsToScripted; + // Accept "1" or "true" (case-insensitive) as per-request opt-in for the Scripted + // path. ASP.NET's default bool binder rejects "1", so parse permissively here. + // BattleNodeOptions.SoloDefaultsToScripted is the process-wide equivalent and is + // applied inside the resolver. + var scriptedOptIn = scripted is not null + && (scripted == "1" || string.Equals(scripted, "true", StringComparison.OrdinalIgnoreCase)); try { var ctx = await _matchContextBuilder.BuildForTwoPickAsync(vid); - - if (useScripted) - { - var scriptedMatch = _matching.RegisterBattle( - new SVSim.BattleNode.Bridge.BattlePlayer(vid, ctx), - p2: null, - SVSim.BattleNode.Sessions.BattleType.Scripted); - return Ok(new DoMatchingResponseDto - { - MatchingState = 3004, - BattleId = scriptedMatch.BattleId, - NodeServerUrl = scriptedMatch.NodeServerUrl, - }); - } - - var paired = await _pairUp.TryPairAsync( - "arena_two_pick_battle", - new SVSim.BattleNode.Bridge.BattlePlayer(vid, ctx), - ct); - if (paired is null) - { - // 3002 = RC_BATTLE_MATCHING_RETRY: client polls again. 3001 is ILLEGAL - // and shows an error dialog on the client side. node_server_url must be - // present (the client's DoMatchingBase.SettingDoMatchingData calls - // .ToString() on it without a Keys.Contains guard); prod sends "" while - // waiting and the real URL only on SUCCEEDED. battle_id stays absent - // (its accessor IS guarded). - return Ok(new DoMatchingResponseDto - { - MatchingState = 3002, - NodeServerUrl = "", - }); - } - - // Owner (first arriver, cache hit) gets 3007 = RC_BATTLE_MATCHING_SUCCEEDED_OWNER; - // joiner (second arriver who triggered the pair) gets 3004 = RC_BATTLE_MATCHING_SUCCEEDED. - // See PairUpResult docs for why this split is observationally inert in TK2 today. + var r = await _resolver.ResolveAsync("arena_two_pick_battle", new BattlePlayer(vid, ctx), scriptedOptIn, ct); return Ok(new DoMatchingResponseDto { - MatchingState = paired.IsOwner ? 3007 : 3004, - BattleId = paired.Match.BattleId, - NodeServerUrl = paired.Match.NodeServerUrl, + MatchingState = r.MatchingState, + BattleId = r.BattleId, + NodeServerUrl = r.NodeServerUrl, }); } catch (ArenaTwoPickException ex) diff --git a/SVSim.EmulatedEntrypoint/Controllers/RankBattleController.cs b/SVSim.EmulatedEntrypoint/Controllers/RankBattleController.cs index 9526919..0d525bd 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/RankBattleController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/RankBattleController.cs @@ -23,23 +23,20 @@ namespace SVSim.EmulatedEntrypoint.Controllers; [Authorize(AuthenticationSchemes = SteamAuthenticationConstants.SchemeName)] public sealed class RankBattleController : ControllerBase { - private readonly IMatchingPairUpService _pairUp; - private readonly IMatchingBridge _bridge; + private readonly IMatchingResolver _resolver; private readonly IBattleSessionStore _sessionStore; private readonly IMatchContextBuilder _ctxBuilder; private readonly IBotRoster _botRoster; private readonly ILogger _log; public RankBattleController( - IMatchingPairUpService pairUp, - IMatchingBridge bridge, + IMatchingResolver resolver, IBattleSessionStore sessionStore, IMatchContextBuilder ctxBuilder, IBotRoster botRoster, ILogger log) { - _pairUp = pairUp; - _bridge = bridge; + _resolver = resolver; _sessionStore = sessionStore; _ctxBuilder = ctxBuilder; _botRoster = botRoster; @@ -135,33 +132,16 @@ public sealed class RankBattleController : ControllerBase return Ok(new DoMatchingResponseDto { MatchingState = 3001, NodeServerUrl = "" }); } - var paired = await _pairUp.TryPairAsync(mode, new BattlePlayer(vid, ctx), ct); - - if (paired is null) - { - // Parked. 3002 RETRY. node_server_url must be present as empty string — - // client's DoMatchingBase parser calls .ToString() without a guard. - return Ok(new DoMatchingResponseDto - { - MatchingState = 3002, - NodeServerUrl = "", - }); - } - - // Owner cache-pickup → 3007 (PvP) or 3011 (AI fallback). - // Joiner (only PvP) → 3004. - var state = paired switch - { - { IsAiFallback: true } => 3011, - { IsOwner: true } => 3007, - _ => 3004, - }; + // Rank battle has no ?scripted=1 query opt-in (no live capture has shown such a + // param on the rank URLs). The process-wide BattleNodeOptions.SoloDefaultsToScripted + // toggle is the only scripted entry point and is honored inside the resolver. + var r = await _resolver.ResolveAsync(mode, new BattlePlayer(vid, ctx), scriptedOptIn: false, ct); return Ok(new DoMatchingResponseDto { - MatchingState = state, - BattleId = paired.Match.BattleId, - NodeServerUrl = paired.Match.NodeServerUrl, + MatchingState = r.MatchingState, + BattleId = r.BattleId, + NodeServerUrl = r.NodeServerUrl, // Placeholder per spec § Out of scope — per-battle card-master split is deferred. CardMasterId = 0, }); diff --git a/SVSim.EmulatedEntrypoint/Matching/IMatchingResolver.cs b/SVSim.EmulatedEntrypoint/Matching/IMatchingResolver.cs new file mode 100644 index 0000000..3a30e41 --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Matching/IMatchingResolver.cs @@ -0,0 +1,54 @@ +using SVSim.BattleNode.Bridge; + +namespace SVSim.EmulatedEntrypoint.Matching; + +/// +/// Single source of truth for how a /do_matching request is resolved into a wire +/// matching_state + battle_id + node_server_url across every battle family. +/// +/// Lives here (and not on each controller) because the resolution rules are the same +/// regardless of which URL family carried the request: +/// +/// +/// Honor the dev-affordance scripted opt-in (route flag and/or +/// ) — bypass pair-up, +/// register a Scripted match, return immediately. +/// Otherwise consult and translate the +/// resulting into a wire matching_state per the +/// 3002 / 3004 / 3007 / 3011 vocabulary. +/// +/// +/// Family-specific details (DTO shapes, family-specific request fields like +/// card_master_id, error-mapping like rank-battle's 3001 on a missing deck) stay +/// on the controllers. The resolver only owns the cross-cutting "did the flag win, did +/// pair-up resolve, what's the state code" decision. +/// +/// +public interface IMatchingResolver +{ + /// + /// The matching-mode key the resolver passes through to + /// — one of the + /// registry's mode names (e.g. "arena_two_pick_battle", + /// "rotation_rank_battle", "unlimited_rank_battle"). + /// + /// Caller's (viewer-id + built MatchContext). + /// + /// Per-request opt-in from a controller-specific signal (e.g. TK2's ?scripted=1 + /// query param). OR'd with ; + /// either being true short-circuits to a Scripted match. + /// + Task ResolveAsync( + string mode, + BattlePlayer player, + bool scriptedOptIn, + CancellationToken ct); +} + +/// +/// Wire-level outcome of a /do_matching resolution. Always carries a non-null +/// — empty string while parked (3002), real URL on resolution — +/// because the client's DoMatchingBase.SettingDoMatchingData() calls +/// .ToString() on the wire field without a Keys.Contains guard. +/// +public sealed record MatchingResolution(int MatchingState, string? BattleId, string NodeServerUrl); diff --git a/SVSim.EmulatedEntrypoint/Matching/MatchingResolver.cs b/SVSim.EmulatedEntrypoint/Matching/MatchingResolver.cs new file mode 100644 index 0000000..2b2bd6d --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Matching/MatchingResolver.cs @@ -0,0 +1,63 @@ +using SVSim.BattleNode.Bridge; +using SVSim.BattleNode.Sessions; + +namespace SVSim.EmulatedEntrypoint.Matching; + +/// +public sealed class MatchingResolver : IMatchingResolver +{ + private readonly IMatchingBridge _bridge; + private readonly IMatchingPairUpService _pairUp; + private readonly BattleNodeOptions _options; + + public MatchingResolver( + IMatchingBridge bridge, + IMatchingPairUpService pairUp, + BattleNodeOptions options) + { + _bridge = bridge; + _pairUp = pairUp; + _options = options; + } + + public Task ResolveAsync( + string mode, + BattlePlayer player, + bool scriptedOptIn, + CancellationToken ct) + { + // Dev-affordance short-circuit. Either a per-request flag (e.g. ?scripted=1) or the + // process-wide BattleNodeOptions.SoloDefaultsToScripted toggle puts us here. + // Registers a Scripted match (server-side scripted opponent in BattleSession) and + // returns matching_state=3004 SUCCEEDED so the client opens the WS and proceeds. + if (scriptedOptIn || _options.SoloDefaultsToScripted) + { + var m = _bridge.RegisterBattle(player, p2: null, BattleType.Scripted); + return Task.FromResult(new MatchingResolution(3004, m.BattleId, m.NodeServerUrl)); + } + + return ResolveViaPairUpAsync(mode, player, ct); + } + + private async Task ResolveViaPairUpAsync(string mode, BattlePlayer player, CancellationToken ct) + { + var paired = await _pairUp.TryPairAsync(mode, player, ct); + if (paired is null) + { + // Parked. matching_state 3002 RETRY. node_server_url MUST be present as empty + // string (the client unguarded-.ToString()s it before consulting matching_state). + return new MatchingResolution(3002, BattleId: null, ""); + } + + // 3011 = AI_BATTLE_MATCHING_SUCCEEDED (PvpFirstThenAiFallback policy's threshold fired) + // 3007 = RC_BATTLE_MATCHING_SUCCEEDED_OWNER (first arriver, cache pickup) + // 3004 = RC_BATTLE_MATCHING_SUCCEEDED (joiner — triggered the pair) + var state = paired switch + { + { IsAiFallback: true } => 3011, + { IsOwner: true } => 3007, + _ => 3004, + }; + return new MatchingResolution(state, paired.Match.BattleId, paired.Match.NodeServerUrl); + } +} diff --git a/SVSim.EmulatedEntrypoint/Program.cs b/SVSim.EmulatedEntrypoint/Program.cs index a3ed915..07e87b8 100644 --- a/SVSim.EmulatedEntrypoint/Program.cs +++ b/SVSim.EmulatedEntrypoint/Program.cs @@ -138,6 +138,10 @@ public class Program new ModePolicy("unlimited_rank_battle", PolicyKind.PvpFirstThenAiFallback), })); builder.Services.AddSingleton(); + // Single resolver shared by every /do_matching family controller. Owns the scripted- + // flag short-circuit + the pair-up → matching_state mapping. Singleton: stateless, + // all deps are singletons too. + builder.Services.AddSingleton(); // Phase 3: bot roster used by RankBattleController.AiStart to compose oppo_info. // Transient because BotRoster depends on the transient IGlobalsRepository. builder.Services.AddTransient(); @@ -156,7 +160,7 @@ public class Program }); var app = builder.Build(); - + // Update database (skipped for non-relational providers, e.g. InMemory in tests, and // skipped under the "Testing" environment where the test fixture has already called // EnsureCreated against a SQLite in-memory DB — the Postgres migrations would fail there). diff --git a/SVSim.UnitTests/Controllers/DoMatchingContractTests.cs b/SVSim.UnitTests/Controllers/DoMatchingContractTests.cs new file mode 100644 index 0000000..a5a5e33 --- /dev/null +++ b/SVSim.UnitTests/Controllers/DoMatchingContractTests.cs @@ -0,0 +1,118 @@ +using System.Net.Http.Json; +using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; +using NUnit.Framework; +using SVSim.BattleNode.Bridge; +using SVSim.Database; +using SVSim.Database.Enums; +using SVSim.Database.Models; +using SVSim.UnitTests.Infrastructure; + +namespace SVSim.UnitTests.Controllers; + +/// +/// Cross-family contract for /do_matching. The single load-bearing assertion: when +/// is true, every family's +/// first poll must bypass pair-up and return a SUCCEEDED matching_state with a battle_id + +/// node_server_url — not the 3002 RETRY of the normal pair-up path. +/// +/// Adding a new family is the failure trigger for this test: the new controller MUST route +/// through , or this test +/// fails. That's the point — the test enforces "stay in line" across families. +/// +/// +[TestFixture] +public class DoMatchingContractTests +{ + private static readonly object DoMatchingBody = new + { + deck_no = 1L, + need_init = 1, + log = 1, + excluded_field_id_list = Array.Empty(), + use_stage_select = 1, + is_default_skin = 0, + viewer_id = "0", + steam_id = 0, + steam_session_ticket = "", + }; + + [TestCase("/arena_two_pick_battle/do_matching", FamilyKind.TwoPick)] + [TestCase("/rotation_rank_battle/do_matching", FamilyKind.RankRotation)] + [TestCase("/unlimited_rank_battle/do_matching", FamilyKind.RankUnlimited)] + public async Task SoloDefaultsToScripted_short_circuits_every_family_to_immediate_SUCCEEDED(string url, FamilyKind family) + { + await using var factory = new SVSimTestFactory(); + factory.Services.GetRequiredService().SoloDefaultsToScripted = true; + + var viewerId = await factory.SeedViewerAsync(); + await SetupFamilyAsync(factory, viewerId, family); + using var client = factory.CreateAuthenticatedClient(viewerId); + + var resp = await client.PostAsJsonAsync(url, DoMatchingBody); + + Assert.That(resp.IsSuccessStatusCode, Is.True, $"Expected 2xx from {url}, got {resp.StatusCode}."); + using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()); + var root = doc.RootElement; + + var state = root.GetProperty("matching_state").GetInt32(); + Assert.That(state, Is.Not.EqualTo(3002), + $"{url}: SoloDefaultsToScripted=true must bypass pair-up; saw matching_state=3002 RETRY which means the family didn't honor the flag (probably forgot to route through IMatchingResolver)."); + Assert.That(state, Is.AnyOf(3004, 3007, 3011), + $"{url}: matching_state must be SUCCEEDED (3004), SUCCEEDED_OWNER (3007), or AI_SUCCEEDED (3011) — got {state}."); + + Assert.That(root.GetProperty("battle_id").GetString(), Is.Not.Null.And.Not.Empty, + $"{url}: SUCCEEDED responses must carry battle_id."); + Assert.That(root.GetProperty("node_server_url").GetString(), Does.Contain("/socket.io/"), + $"{url}: node_server_url must point at the WS endpoint."); + } + + // Each family has different prerequisites — TK2 needs an active draft run, rank needs + // a deck for the requested format. The factory's seeders are sufficient for both. + public enum FamilyKind { TwoPick, RankRotation, RankUnlimited } + + private static async Task SetupFamilyAsync(SVSimTestFactory factory, long viewerId, FamilyKind family) + { + switch (family) + { + case FamilyKind.TwoPick: + await SeedCompleteTwoPickRunAsync(factory, viewerId); + break; + case FamilyKind.RankRotation: + await factory.SeedGlobalsAsync(); + await factory.SeedDeckAsync(viewerId, Format.Rotation, number: 1); + break; + case FamilyKind.RankUnlimited: + await factory.SeedGlobalsAsync(); + await factory.SeedDeckAsync(viewerId, Format.Unlimited, number: 1); + break; + default: + throw new ArgumentOutOfRangeException(nameof(family)); + } + } + + // Mirrors ArenaTwoPickBattleControllerTests.SeedCompleteTwoPickRunAsync. Duplicated + // rather than promoted because the original is a private static there and only this + // test class needs to share it cross-family today; promote if a third caller surfaces. + private static async Task SeedCompleteTwoPickRunAsync(SVSimTestFactory factory, long viewerId) + { + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var deck = Enumerable.Range(1, 30).Select(i => 100_011_000L + i).ToList(); + db.ViewerArenaTwoPickRuns.Add(new ViewerArenaTwoPickRun + { + ViewerId = viewerId, + EntryId = 1, + ClassId = 1, + LeaderSkinId = 1, + SelectedCardIdsJson = JsonSerializer.Serialize(deck), + IsSelectCompleted = true, + MaxBattleCount = 5, + CandidateClassIdsJson = "[1,2,3]", + PendingPickSetsJson = "[]", + ResultListJson = "[]", + NextCandidateId = 1, + }); + await db.SaveChangesAsync(); + } +} diff --git a/SVSim.UnitTests/Matching/MatchingResolverTests.cs b/SVSim.UnitTests/Matching/MatchingResolverTests.cs new file mode 100644 index 0000000..51b4198 --- /dev/null +++ b/SVSim.UnitTests/Matching/MatchingResolverTests.cs @@ -0,0 +1,129 @@ +using Moq; +using NUnit.Framework; +using SVSim.BattleNode.Bridge; +using SVSim.BattleNode.Sessions; +using SVSim.EmulatedEntrypoint.Matching; + +namespace SVSim.UnitTests.Matching; + +/// +/// Per-test locals (no fixture-level fields) because the assembly runs with +/// [Parallelizable(ParallelScope.All)] — shared _resolver/_bridge +/// fields would race across concurrent tests in this fixture. +/// +[TestFixture] +public class MatchingResolverTests +{ + private sealed record Harness( + Mock Bridge, + Mock PairUp, + BattleNodeOptions Options, + MatchingResolver Resolver); + + private static Harness BuildHarness() + { + var bridge = new Mock(MockBehavior.Strict); + var pairUp = new Mock(MockBehavior.Strict); + var options = new BattleNodeOptions { NodeServerUrl = "localhost:5148/socket.io/" }; + return new Harness(bridge, pairUp, options, new MatchingResolver(bridge.Object, pairUp.Object, options)); + } + + private static BattlePlayer Player(long vid = 1) => + new(vid, new MatchContext( + SelfDeckCardIds: Array.Empty(), ClassId: "0", CharaId: "0", + CardMasterName: "card_master_node_10015", + CountryCode: "JP", UserName: $"P{vid}", SleeveId: "0", + EmblemId: "0", DegreeId: "0", FieldId: 0, IsOfficial: 0, BattleType: 11)); + + [Test] + public async Task When_scriptedOptIn_is_true_registers_Scripted_and_returns_3004() + { + var h = BuildHarness(); + var player = Player(); + h.Bridge.Setup(b => b.RegisterBattle(player, null, BattleType.Scripted)) + .Returns(new PendingMatch("bid-scripted", "node.local/socket.io/")); + + var r = await h.Resolver.ResolveAsync("arena_two_pick_battle", player, scriptedOptIn: true, default); + + Assert.That(r.MatchingState, Is.EqualTo(3004)); + Assert.That(r.BattleId, Is.EqualTo("bid-scripted")); + Assert.That(r.NodeServerUrl, Is.EqualTo("node.local/socket.io/")); + h.Bridge.VerifyAll(); + h.PairUp.Verify(p => p.TryPairAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task When_options_SoloDefaultsToScripted_is_true_registers_Scripted_for_any_mode() + { + // Cross-family contract: the process-wide flag overrides pair-up for every mode, + // not just TK2. + var h = BuildHarness(); + h.Options.SoloDefaultsToScripted = true; + var player = Player(); + h.Bridge.Setup(b => b.RegisterBattle(player, null, BattleType.Scripted)) + .Returns(new PendingMatch("bid-rank-scripted", "node.local/socket.io/")); + + var r = await h.Resolver.ResolveAsync("rotation_rank_battle", player, scriptedOptIn: false, default); + + Assert.That(r.MatchingState, Is.EqualTo(3004)); + Assert.That(r.BattleId, Is.EqualTo("bid-rank-scripted")); + h.PairUp.Verify(p => p.TryPairAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task When_neither_flag_set_calls_pairUp_and_parks_returns_3002_with_empty_url() + { + var h = BuildHarness(); + var player = Player(); + h.PairUp.Setup(p => p.TryPairAsync("arena_two_pick_battle", player, It.IsAny())) + .ReturnsAsync((PairUpResult?)null); + + var r = await h.Resolver.ResolveAsync("arena_two_pick_battle", player, scriptedOptIn: false, default); + + Assert.That(r.MatchingState, Is.EqualTo(3002)); + Assert.That(r.BattleId, Is.Null); + Assert.That(r.NodeServerUrl, Is.EqualTo(""), "Empty string (not null) — client unguarded-.ToString()s it."); + h.Bridge.Verify(b => b.RegisterBattle(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task Pair_owner_role_returns_3007() + { + var h = BuildHarness(); + var player = Player(); + h.PairUp.Setup(p => p.TryPairAsync("rotation_rank_battle", player, It.IsAny())) + .ReturnsAsync(new PairUpResult(new PendingMatch("bid-x", "node.local/socket.io/"), IsOwner: true, IsAiFallback: false)); + + var r = await h.Resolver.ResolveAsync("rotation_rank_battle", player, scriptedOptIn: false, default); + + Assert.That(r.MatchingState, Is.EqualTo(3007)); + Assert.That(r.BattleId, Is.EqualTo("bid-x")); + } + + [Test] + public async Task Pair_joiner_role_returns_3004() + { + var h = BuildHarness(); + var player = Player(); + h.PairUp.Setup(p => p.TryPairAsync("rotation_rank_battle", player, It.IsAny())) + .ReturnsAsync(new PairUpResult(new PendingMatch("bid-x", "node.local/socket.io/"), IsOwner: false, IsAiFallback: false)); + + var r = await h.Resolver.ResolveAsync("rotation_rank_battle", player, scriptedOptIn: false, default); + + Assert.That(r.MatchingState, Is.EqualTo(3004)); + } + + [Test] + public async Task AI_fallback_returns_3011_regardless_of_owner_flag() + { + // IsAiFallback wins the switch even if IsOwner is also true (the resolver's first arm). + var h = BuildHarness(); + var player = Player(); + h.PairUp.Setup(p => p.TryPairAsync("unlimited_rank_battle", player, It.IsAny())) + .ReturnsAsync(new PairUpResult(new PendingMatch("bid-ai", "node.local/socket.io/"), IsOwner: true, IsAiFallback: true)); + + var r = await h.Resolver.ResolveAsync("unlimited_rank_battle", player, scriptedOptIn: false, default); + + Assert.That(r.MatchingState, Is.EqualTo(3011)); + } +}