From bb63b0df2fc5668b54f85c2d55c10f4a0995d738 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Tue, 2 Jun 2026 01:21:38 -0400 Subject: [PATCH] feat(rank-battle): real DoMatching with PvP pair + AI fallback mapping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DoMatchingInternal calls IMatchingPairUpService.TryPairAsync, then maps: - null result → 3002 RETRY (empty node_server_url, no battle_id) - IsAiFallback → 3011 AI_BATTLE_MATCHING_SUCCEEDED - IsOwner → 3007 SUCCEEDED_OWNER (cache pickup) - joiner → 3004 SUCCEEDED BuildForRankBattleAsync's InvalidOperationException (typically "no deck for format") surfaces as 3001 ILLEGAL so the client shows the matchmaking-error dialog rather than retrying. card_master_id is a placeholder (0) per the per-battle card-master split deferral. AI-fallback timing is covered by InProcessPairUp unit tests; controller tests focus on the wire mapping (3002, 3004, 3007). Co-Authored-By: Claude Opus 4.7 --- .../Controllers/RankBattleController.cs | 52 ++++++++- .../Controllers/RankBattleControllerTests.cs | 110 ++++++++++++++++++ 2 files changed, 157 insertions(+), 5 deletions(-) create mode 100644 SVSim.UnitTests/Controllers/RankBattleControllerTests.cs diff --git a/SVSim.EmulatedEntrypoint/Controllers/RankBattleController.cs b/SVSim.EmulatedEntrypoint/Controllers/RankBattleController.cs index 95aa054..62d63bf 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/RankBattleController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/RankBattleController.cs @@ -107,12 +107,54 @@ public sealed class RankBattleController : ControllerBase return Ok(new { }); } - // Filled in by Task 9. - private Task DoMatchingInternal(string mode, Format format, DoMatchingRequestDto req, CancellationToken ct) + private async Task DoMatchingInternal(string mode, Format format, DoMatchingRequestDto req, CancellationToken ct) { - if (!TryGetViewerId(out var _)) return Task.FromResult(Unauthorized()); - // Placeholder; real impl arrives in Task 9. - return Task.FromResult(Ok(new DoMatchingResponseDto { MatchingState = 3002 })); + if (!TryGetViewerId(out var vid)) return Unauthorized(); + + MatchContext ctx; + try + { + ctx = await _ctxBuilder.BuildForRankBattleAsync(vid, format); + } + catch (InvalidOperationException ex) + { + // Most likely cause: viewer has no deck for this format. Surface as 3001 + // RC_BATTLE_MATCHING_ILLEGAL — the client shows the standard matchmaking-error + // dialog rather than retrying forever. + _log.LogWarning(ex, "BuildForRankBattleAsync failed for viewer {Vid} format {Fmt}; returning 3001.", vid, format); + 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, + }; + + return Ok(new DoMatchingResponseDto + { + MatchingState = state, + BattleId = paired.Match.BattleId, + NodeServerUrl = paired.Match.NodeServerUrl, + // Placeholder per spec § Out of scope — per-battle card-master split is deferred. + CardMasterId = 0, + }); } // Filled in by Task 10. diff --git a/SVSim.UnitTests/Controllers/RankBattleControllerTests.cs b/SVSim.UnitTests/Controllers/RankBattleControllerTests.cs new file mode 100644 index 0000000..7a0f953 --- /dev/null +++ b/SVSim.UnitTests/Controllers/RankBattleControllerTests.cs @@ -0,0 +1,110 @@ +using System.Net.Http.Json; +using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; +using NUnit.Framework; +using SVSim.Database.Enums; +using SVSim.UnitTests.Infrastructure; + +namespace SVSim.UnitTests.Controllers; + +[TestFixture] +public class RankBattleControllerTests +{ + [Test] + public async Task DoMatching_rotation_first_poll_returns_3002_RETRY_with_empty_node_server_url() + { + await using var factory = new SVSimTestFactory(); + var viewerId = await factory.SeedViewerAsync(); + await factory.SeedGlobalsAsync(); + await factory.SeedDeckAsync(viewerId, Format.Rotation, 1); + var client = factory.CreateAuthenticatedClient(viewerId); + + var resp = await client.PostAsJsonAsync( + "/rotation_rank_battle/do_matching", + new { deck_no = 1, need_init = 1 }); + + Assert.That(resp.IsSuccessStatusCode, Is.True, $"Expected 2xx, got {resp.StatusCode}"); + var raw = await resp.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(raw); + var data = doc.RootElement; + Assert.That(data.GetProperty("matching_state").GetInt32(), Is.EqualTo(3002)); + Assert.That(data.GetProperty("node_server_url").GetString(), Is.EqualTo(""), + "Empty string, not absent — Phase 2 fix pattern."); + } + + [Test] + public async Task DoMatching_rotation_two_viewers_pair_PvP() + { + await using var factory = new SVSimTestFactory(); + var v1 = await factory.SeedViewerAsync(steamId: 76_561_198_000_000_001UL, displayName: "Alice"); + var v2 = await factory.SeedViewerAsync(steamId: 76_561_198_000_000_002UL, displayName: "Bob"); + await factory.SeedGlobalsAsync(); + await factory.SeedDeckAsync(v1, Format.Rotation, 1); + await factory.SeedDeckAsync(v2, Format.Rotation, 1); + + // Alice polls first → parks. + var c1 = factory.CreateAuthenticatedClient(v1); + var r1 = await c1.PostAsJsonAsync( + "/rotation_rank_battle/do_matching", new { deck_no = 1, need_init = 1 }); + var j1 = JsonDocument.Parse(await r1.Content.ReadAsStringAsync()).RootElement; + Assert.That(j1.GetProperty("matching_state").GetInt32(), Is.EqualTo(3002)); + + // Bob polls — pairs, returns joiner (3004). + var c2 = factory.CreateAuthenticatedClient(v2); + var r2 = await c2.PostAsJsonAsync( + "/rotation_rank_battle/do_matching", new { deck_no = 1, need_init = 1 }); + var j2 = JsonDocument.Parse(await r2.Content.ReadAsStringAsync()).RootElement; + Assert.That(j2.GetProperty("matching_state").GetInt32(), Is.EqualTo(3004), "Joiner = 3004."); + Assert.That(j2.GetProperty("battle_id").GetString(), Is.Not.Null.And.Not.Empty); + Assert.That(j2.GetProperty("node_server_url").GetString(), Is.Not.Empty); + + // Alice polls again — gets cached match, owner role (3007). + var r3 = await c1.PostAsJsonAsync( + "/rotation_rank_battle/do_matching", new { deck_no = 1, need_init = 0 }); + var j3 = JsonDocument.Parse(await r3.Content.ReadAsStringAsync()).RootElement; + Assert.That(j3.GetProperty("matching_state").GetInt32(), Is.EqualTo(3007), "Owner = 3007."); + Assert.That(j3.GetProperty("battle_id").GetString(), Is.EqualTo(j2.GetProperty("battle_id").GetString())); + } + + [Test] + public async Task Finish_emits_stubbed_zeros_with_battle_result_echo() + { + await using var factory = new SVSimTestFactory(); + var viewerId = await factory.SeedViewerAsync(); + var client = factory.CreateAuthenticatedClient(viewerId); + + var resp = await client.PostAsJsonAsync("/ai_rotation_rank_battle/finish", new + { + battle_result = 1, // win + is_retire = 0, + recovery_data = "{}", + class_id = 3, + total_turn = 5, + }); + + Assert.That(resp.IsSuccessStatusCode, Is.True); + using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()); + var data = doc.RootElement; + Assert.That(data.GetProperty("battle_result").GetInt32(), Is.EqualTo(1)); + Assert.That(data.GetProperty("rank").GetInt32(), Is.EqualTo(0)); + Assert.That(data.GetProperty("after_battle_point").GetInt32(), Is.EqualTo(0)); + Assert.That(data.GetProperty("class_level").GetInt32(), Is.EqualTo(1)); + } + + [Test] + public async Task Finish_with_consistency_result_echoes_2() + { + await using var factory = new SVSimTestFactory(); + var viewerId = await factory.SeedViewerAsync(); + var client = factory.CreateAuthenticatedClient(viewerId); + + var resp = await client.PostAsJsonAsync("/rotation_rank_battle/finish", new + { + battle_result = 2, // consistency error + class_id = 3, + }); + + using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()); + Assert.That(doc.RootElement.GetProperty("battle_result").GetInt32(), Is.EqualTo(2)); + } +}