diff --git a/SVSim.EmulatedEntrypoint/Controllers/ArenaTwoPickBattleController.cs b/SVSim.EmulatedEntrypoint/Controllers/ArenaTwoPickBattleController.cs index d87a438..75fa780 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/ArenaTwoPickBattleController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/ArenaTwoPickBattleController.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Mvc; using SVSim.BattleNode.Bridge; +using SVSim.EmulatedEntrypoint.Matching; using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.ArenaTwoPick; using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.ArenaTwoPick; using SVSim.EmulatedEntrypoint.Services; @@ -12,33 +13,67 @@ public class ArenaTwoPickBattleController : SVSimController private readonly IArenaTwoPickService _svc; private readonly IMatchingBridge _matching; private readonly IMatchContextBuilder _matchContextBuilder; + private readonly IMatchingPairUpService _pairUp; public ArenaTwoPickBattleController( IArenaTwoPickService svc, IMatchingBridge matching, - IMatchContextBuilder matchContextBuilder) + IMatchContextBuilder matchContextBuilder, + IMatchingPairUpService pairUp) { _svc = svc; _matching = matching; _matchContextBuilder = matchContextBuilder; + _pairUp = pairUp; } [HttpPost("do_matching")] - public async Task DoMatching([FromBody] DoMatchingRequest req) + public async Task DoMatching( + [FromBody] DoMatchingRequest req, + [FromQuery(Name = "scripted")] string? scripted = null, + 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. + var useScripted = scripted is not null + && (scripted == "1" || string.Equals(scripted, "true", StringComparison.OrdinalIgnoreCase)); try { var ctx = await _matchContextBuilder.BuildForTwoPickAsync(vid); - var match = _matching.RegisterBattle( + + 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), - p2: null, - SVSim.BattleNode.Sessions.BattleType.Scripted); + ct); + if (paired is null) + { + return Ok(new DoMatchingResponseDto + { + MatchingState = 3001, + // BattleId / NodeServerUrl null — client polls again. + }); + } + return Ok(new DoMatchingResponseDto { MatchingState = 3004, - BattleId = match.BattleId, - NodeServerUrl = match.NodeServerUrl, + BattleId = paired.BattleId, + NodeServerUrl = paired.NodeServerUrl, }); } catch (ArenaTwoPickException ex) diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/ArenaTwoPick/DoMatchingResponseDto.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/ArenaTwoPick/DoMatchingResponseDto.cs index 81a73d3..27d4432 100644 --- a/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/ArenaTwoPick/DoMatchingResponseDto.cs +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/ArenaTwoPick/DoMatchingResponseDto.cs @@ -17,10 +17,10 @@ public sealed class DoMatchingResponseDto public int RetryPeriod { get; set; } = 3; [JsonPropertyName("battle_id")] [Key("battle_id")] - public string BattleId { get; set; } = ""; + public string? BattleId { get; set; } [JsonPropertyName("node_server_url")] [Key("node_server_url")] - public string NodeServerUrl { get; set; } = ""; + public string? NodeServerUrl { get; set; } // Required by the client when matching_state ∈ {3004, 3007, 3011} — // DoMatchingBase.SettingCardMasterId does jsonData["card_master_id"].ToInt() diff --git a/SVSim.UnitTests/Controllers/ArenaTwoPickBattleControllerTests.cs b/SVSim.UnitTests/Controllers/ArenaTwoPickBattleControllerTests.cs index edee5e3..f14f3f2 100644 --- a/SVSim.UnitTests/Controllers/ArenaTwoPickBattleControllerTests.cs +++ b/SVSim.UnitTests/Controllers/ArenaTwoPickBattleControllerTests.cs @@ -22,7 +22,7 @@ public class ArenaTwoPickBattleControllerTests deck_no = 1L, need_init = 1, log = 1, excluded_field_id_list = new long[] { }, use_stage_select = 1, is_default_skin = 0, viewer_id = "0", steam_id = 0, steam_session_ticket = "", }; - var resp = await client.PostAsync("/arena_two_pick_battle/do_matching", JsonContent.Create(req)); + var resp = await client.PostAsync("/arena_two_pick_battle/do_matching?scripted=1", JsonContent.Create(req)); Assert.That(resp.StatusCode, Is.EqualTo(HttpStatusCode.OK)); var body = await resp.Content.ReadAsStringAsync(); @@ -39,6 +39,93 @@ public class ArenaTwoPickBattleControllerTests Assert.That(root.GetProperty("card_master_id").GetInt32(), Is.EqualTo(1)); } + [Test] + public async Task DoMatching_solo_poller_returns_3001_RETRY_with_no_BattleId() + { + using var factory = new SVSimTestFactory(); + var vid = await factory.SeedViewerAsync(); + await SeedCompleteTwoPickRunAsync(factory, vid); + using var client = factory.CreateAuthenticatedClient(vid); + + var req = new { + deck_no = 1L, need_init = 1, log = 1, excluded_field_id_list = new long[] { }, use_stage_select = 1, is_default_skin = 0, + viewer_id = "0", steam_id = 0, steam_session_ticket = "", + }; + var resp = await client.PostAsync("/arena_two_pick_battle/do_matching", JsonContent.Create(req)); + + Assert.That(resp.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + var body = await resp.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(body); + var root = doc.RootElement; + + Assert.That(root.GetProperty("matching_state").GetInt32(), Is.EqualTo(3001)); + // BattleId should be ABSENT from the JSON (WhenWritingNull) — TryGetProperty + // returns false when the key isn't present. + Assert.That(root.TryGetProperty("battle_id", out _), Is.False, + "battle_id must be absent from the wire when matching_state==3001 RETRY."); + } + + [Test] + public async Task DoMatching_with_scripted_flag_returns_3004_Scripted_match_immediately() + { + using var factory = new SVSimTestFactory(); + var vid = await factory.SeedViewerAsync(); + await SeedCompleteTwoPickRunAsync(factory, vid); + using var client = factory.CreateAuthenticatedClient(vid); + + var req = new { + deck_no = 1L, need_init = 1, log = 1, excluded_field_id_list = new long[] { }, use_stage_select = 1, is_default_skin = 0, + viewer_id = "0", steam_id = 0, steam_session_ticket = "", + }; + var resp = await client.PostAsync("/arena_two_pick_battle/do_matching?scripted=1", JsonContent.Create(req)); + + Assert.That(resp.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + var body = await resp.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(body); + var root = doc.RootElement; + + Assert.That(root.GetProperty("matching_state").GetInt32(), Is.EqualTo(3004)); + Assert.That(root.GetProperty("battle_id").GetString(), Is.Not.Null.And.Not.Empty); + } + + [Test] + public async Task DoMatching_two_concurrent_pollers_both_return_3004_with_same_BattleId() + { + using var factory = new SVSimTestFactory(); + var vidA = await factory.SeedViewerAsync(steamId: 76_561_198_000_000_011UL); + var vidB = await factory.SeedViewerAsync(steamId: 76_561_198_000_000_012UL); + await SeedCompleteTwoPickRunAsync(factory, vidA); + await SeedCompleteTwoPickRunAsync(factory, vidB); + using var clientA = factory.CreateAuthenticatedClient(vidA); + using var clientB = factory.CreateAuthenticatedClient(vidB); + + var req = new { + deck_no = 1L, need_init = 1, log = 1, excluded_field_id_list = new long[] { }, use_stage_select = 1, is_default_skin = 0, + viewer_id = "0", steam_id = 0, steam_session_ticket = "", + }; + + // A polls first (parks). + var respA1 = await clientA.PostAsync("/arena_two_pick_battle/do_matching", JsonContent.Create(req)); + using var docA1 = JsonDocument.Parse(await respA1.Content.ReadAsStringAsync()); + Assert.That(docA1.RootElement.GetProperty("matching_state").GetInt32(), Is.EqualTo(3001), + "A's first poll parks."); + + // B polls (pairs). + var respB = await clientB.PostAsync("/arena_two_pick_battle/do_matching", JsonContent.Create(req)); + using var docB = JsonDocument.Parse(await respB.Content.ReadAsStringAsync()); + Assert.That(docB.RootElement.GetProperty("matching_state").GetInt32(), Is.EqualTo(3004), + "B's poll pairs with A."); + var bBattleId = docB.RootElement.GetProperty("battle_id").GetString(); + Assert.That(bBattleId, Is.Not.Null.And.Not.Empty); + + // A polls again, picks up the cached result. + var respA2 = await clientA.PostAsync("/arena_two_pick_battle/do_matching", JsonContent.Create(req)); + using var docA2 = JsonDocument.Parse(await respA2.Content.ReadAsStringAsync()); + Assert.That(docA2.RootElement.GetProperty("matching_state").GetInt32(), Is.EqualTo(3004), + "A's second poll picks up the cached match."); + Assert.That(docA2.RootElement.GetProperty("battle_id").GetString(), Is.EqualTo(bBattleId)); + } + [Test] public async Task DoMatching_NoActiveRun_Returns400WithErrorCode() {