diff --git a/SVSim.EmulatedEntrypoint/Controllers/ArenaTwoPickBattleController.cs b/SVSim.EmulatedEntrypoint/Controllers/ArenaTwoPickBattleController.cs index 3e372fa..8bf8357 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/ArenaTwoPickBattleController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/ArenaTwoPickBattleController.cs @@ -75,11 +75,14 @@ public class ArenaTwoPickBattleController : SVSimController }); } + // 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. return Ok(new DoMatchingResponseDto { - MatchingState = 3004, - BattleId = paired.BattleId, - NodeServerUrl = paired.NodeServerUrl, + MatchingState = paired.IsOwner ? 3007 : 3004, + BattleId = paired.Match.BattleId, + NodeServerUrl = paired.Match.NodeServerUrl, }); } catch (ArenaTwoPickException ex) diff --git a/SVSim.EmulatedEntrypoint/Matching/IMatchingPairUpService.cs b/SVSim.EmulatedEntrypoint/Matching/IMatchingPairUpService.cs index ccce4e4..ffbadcc 100644 --- a/SVSim.EmulatedEntrypoint/Matching/IMatchingPairUpService.cs +++ b/SVSim.EmulatedEntrypoint/Matching/IMatchingPairUpService.cs @@ -11,10 +11,28 @@ public interface IMatchingPairUpService { /// /// Try to pair the calling viewer with an already-waiting partner. - /// Returns the resolved when a partner was found + /// 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). + /// arriver and should be parked (caller returns 3002 RETRY). /// - Task TryPairAsync(string mode, BattlePlayer player, CancellationToken ct); + Task TryPairAsync(string mode, BattlePlayer player, CancellationToken ct); } + +/// +/// A resolved pair-up for a single caller. +/// +/// distinguishes the "original waiter" (first arriver, whose +/// cached result is being consumed on a follow-up poll — maps to wire matching_state +/// 3007 = RC_BATTLE_MATCHING_SUCCEEDED_OWNER) from the "joiner" (second arriver, +/// whose poll triggered the pair — maps to 3004 = RC_BATTLE_MATCHING_SUCCEEDED). +/// +/// +/// The split mirrors prod's TK2 wire flow (waiter sees 3007, joiner sees 3004) but +/// is observationally inert in the public-matching code path: the client's +/// Matching class writes isOwner from the response into a field that +/// nothing else in TK2/ranked reads. We send the split anyway for prod fidelity in +/// case a future flow (rematch UI, private rooms grafted on top) starts consuming it. +/// +/// +public sealed record PairUpResult(PendingMatch Match, bool IsOwner); diff --git a/SVSim.EmulatedEntrypoint/Matching/InProcessPairUp.cs b/SVSim.EmulatedEntrypoint/Matching/InProcessPairUp.cs index 38bdfbf..59e2b15 100644 --- a/SVSim.EmulatedEntrypoint/Matching/InProcessPairUp.cs +++ b/SVSim.EmulatedEntrypoint/Matching/InProcessPairUp.cs @@ -18,25 +18,27 @@ public sealed class InProcessPairUp : IMatchingPairUpService _bridge = bridge; } - public Task TryPairAsync(string mode, BattlePlayer player, CancellationToken ct) + 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. + // This 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(cached); + return Task.FromResult(new PairUpResult(cached, IsOwner: true)); } // 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) { if (slot.Waiting.ViewerId == player.ViewerId) { // Same viewer polled twice while parked — keep them parked. - return Task.FromResult(null); + return Task.FromResult(null); } var p1 = slot.Waiting; var p2 = player; @@ -44,12 +46,12 @@ public sealed class InProcessPairUp : IMatchingPairUpService 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); + return Task.FromResult(new PairUpResult(match, IsOwner: false)); } // 3. Empty slot — park this caller. slot.Waiting = player; - return Task.FromResult(null); + return Task.FromResult(null); } } diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/ArenaTwoPick/DoMatchingResponseDto.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/ArenaTwoPick/DoMatchingResponseDto.cs index 27d4432..ae38be0 100644 --- a/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/ArenaTwoPick/DoMatchingResponseDto.cs +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/ArenaTwoPick/DoMatchingResponseDto.cs @@ -4,6 +4,21 @@ using SVSim.EmulatedEntrypoint.Models.Dtos.Common; namespace SVSim.EmulatedEntrypoint.Models.Dtos.Responses.ArenaTwoPick; +/// +/// Wire-shape notes vs prod do_matching captures (2026-05-31 TK2 capture): +/// +/// Prod sends room_id from matching_state 3003 onward. We deliberately +/// omit it. The client's DoMatchingDetail data model has no room_id +/// field and DoMatchingBase.SettingDoMatchingData() never reads the key; +/// private-room flows (RoomConnectController) get their room id from a +/// separate API (OpenRoomBattleCreate*) and don't consult this response. +/// Re-add only if a downstream consumer surfaces. +/// node_server_url must always be present (empty string while waiting, +/// actual URL on SUCCEEDED/SUCCEEDED_OWNER). The client's accessor is unguarded. +/// battle_id stays absent on RETRY (its accessor IS guarded via +/// Keys.Contains). +/// +/// [MessagePackObject] public sealed class DoMatchingResponseDto { diff --git a/SVSim.UnitTests/Controllers/ArenaTwoPickBattleControllerTests.cs b/SVSim.UnitTests/Controllers/ArenaTwoPickBattleControllerTests.cs index 32435db..461ba9f 100644 --- a/SVSim.UnitTests/Controllers/ArenaTwoPickBattleControllerTests.cs +++ b/SVSim.UnitTests/Controllers/ArenaTwoPickBattleControllerTests.cs @@ -95,7 +95,7 @@ public class ArenaTwoPickBattleControllerTests } [Test] - public async Task DoMatching_two_concurrent_pollers_both_return_3004_with_same_BattleId() + public async Task DoMatching_two_pollers_get_3004_joiner_and_3007_owner_with_same_BattleId() { using var factory = new SVSimTestFactory(); var vidA = await factory.SeedViewerAsync(steamId: 76_561_198_000_000_011UL); @@ -116,20 +116,24 @@ public class ArenaTwoPickBattleControllerTests Assert.That(docA1.RootElement.GetProperty("matching_state").GetInt32(), Is.EqualTo(3002), "A's first poll parks (3002 = RETRY)."); - // B polls (pairs). + // B polls and triggers the pair — B is the JOINER (3004). 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."); + "B (second arriver, triggered the pair) is the joiner — wire matching_state 3004."); 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. + // A polls again, picks up the cached pair — A is the OWNER (3007). 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)); + Assert.That(docA2.RootElement.GetProperty("matching_state").GetInt32(), Is.EqualTo(3007), + "A (first arriver, picked up cached pair) is the owner — wire matching_state 3007."); + Assert.That(docA2.RootElement.GetProperty("battle_id").GetString(), Is.EqualTo(bBattleId), + "Owner and joiner must see the same battle_id."); + Assert.That(docA2.RootElement.GetProperty("node_server_url").GetString(), + Is.EqualTo(docB.RootElement.GetProperty("node_server_url").GetString()), + "Owner and joiner must see the same node_server_url."); } [Test] diff --git a/SVSim.UnitTests/Matching/InProcessPairUpTests.cs b/SVSim.UnitTests/Matching/InProcessPairUpTests.cs index cacafc1..127175e 100644 --- a/SVSim.UnitTests/Matching/InProcessPairUpTests.cs +++ b/SVSim.UnitTests/Matching/InProcessPairUpTests.cs @@ -20,20 +20,22 @@ public class InProcessPairUpTests } [Test] - public async Task TryPairAsync_with_waiting_partner_pairs_and_returns_match() + 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); await svc.TryPairAsync("tk2", new BattlePlayer(1, Ctx()), CancellationToken.None); - var match = await svc.TryPairAsync("tk2", new BattlePlayer(2, Ctx()), CancellationToken.None); + var result = await svc.TryPairAsync("tk2", new BattlePlayer(2, Ctx()), CancellationToken.None); - Assert.That(match, Is.Not.Null); - Assert.That(match!.BattleId, Is.Not.Empty); + Assert.That(result, Is.Not.Null); + 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."); } [Test] - public async Task First_arrivers_next_poll_returns_cached_match_then_evicts() + 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); @@ -44,7 +46,11 @@ public class InProcessPairUpTests 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(firstCached!.Match.BattleId, Is.EqualTo(secondPaired!.Match.BattleId)); + Assert.That(firstCached.IsOwner, Is.True, + "The first arriver picking up their cached pair is the owner — wire matching_state 3007."); + Assert.That(secondPaired.IsOwner, Is.False, + "Sanity: the same pair-up returns IsOwner=true to the cached/first arriver and IsOwner=false to the joiner."); Assert.That(firstAgain, Is.Null, "Consumed entry must be evicted; next call re-parks."); }