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.");
}