feat(arena-tk2): split do_matching success into 3007 owner / 3004 joiner

Mirrors prod's TK2 wire flow: the first arriver (parked, picks up cached
pair on a later poll) gets matching_state 3007 (SUCCEEDED_OWNER); the
second arriver (whose poll triggered the pair) gets 3004 (SUCCEEDED).

Observationally inert in the public matching code path today — the
client's Matching class writes isOwner from the response into a field
that nothing in TK2/ranked reads. Matching_Room (private rooms) DOES
read it but from a separate code path that doesn't consult our response.
We send the split anyway for prod fidelity and to leave room for future
flows (rematch UI, etc.) that might start consuming it.

TryPairAsync now returns PairUpResult(Match, IsOwner) instead of bare
PendingMatch?, so the controller can decide owner vs joiner without
re-deriving it.

Also documents on DoMatchingResponseDto why we omit prod's `room_id`
field (not in the client's DoMatchingDetail model; private-room flows
get their room id from a different API and don't consult this response).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-06-01 23:24:13 -04:00
parent 0ecd565774
commit 8112b3f81f
6 changed files with 72 additions and 24 deletions

View File

@@ -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)

View File

@@ -11,10 +11,28 @@ public interface IMatchingPairUpService
{
/// <summary>
/// Try to pair the calling viewer with an already-waiting partner.
/// Returns the resolved <see cref="PendingMatch"/> when a partner was found
/// Returns the resolved <see cref="PairUpResult"/> 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).
/// </summary>
Task<PendingMatch?> TryPairAsync(string mode, BattlePlayer player, CancellationToken ct);
Task<PairUpResult?> TryPairAsync(string mode, BattlePlayer player, CancellationToken ct);
}
/// <summary>
/// A resolved pair-up for a single caller.
/// <para>
/// <see cref="IsOwner"/> 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).
/// </para>
/// <para>
/// 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
/// <c>Matching</c> class writes <c>isOwner</c> 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.
/// </para>
/// </summary>
public sealed record PairUpResult(PendingMatch Match, bool IsOwner);

View File

@@ -18,25 +18,27 @@ public sealed class InProcessPairUp : IMatchingPairUpService
_bridge = bridge;
}
public Task<PendingMatch?> TryPairAsync(string mode, BattlePlayer player, CancellationToken ct)
public Task<PairUpResult?> 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<PendingMatch?>(cached);
return Task.FromResult<PairUpResult?>(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<PendingMatch?>(null);
return Task.FromResult<PairUpResult?>(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<PendingMatch?>(match);
return Task.FromResult<PairUpResult?>(new PairUpResult(match, IsOwner: false));
}
// 3. Empty slot — park this caller.
slot.Waiting = player;
return Task.FromResult<PendingMatch?>(null);
return Task.FromResult<PairUpResult?>(null);
}
}

View File

@@ -4,6 +4,21 @@ using SVSim.EmulatedEntrypoint.Models.Dtos.Common;
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Responses.ArenaTwoPick;
/// <remarks>
/// Wire-shape notes vs prod do_matching captures (2026-05-31 TK2 capture):
/// <list type="bullet">
/// <item>Prod sends <c>room_id</c> from matching_state 3003 onward. We deliberately
/// omit it. The client's <c>DoMatchingDetail</c> data model has no <c>room_id</c>
/// field and <c>DoMatchingBase.SettingDoMatchingData()</c> never reads the key;
/// private-room flows (<c>RoomConnectController</c>) get their room id from a
/// separate API (<c>OpenRoomBattleCreate*</c>) and don't consult this response.
/// Re-add only if a downstream consumer surfaces.</item>
/// <item><c>node_server_url</c> must always be present (empty string while waiting,
/// actual URL on SUCCEEDED/SUCCEEDED_OWNER). The client's accessor is unguarded.</item>
/// <item><c>battle_id</c> stays absent on RETRY (its accessor IS guarded via
/// <c>Keys.Contains</c>).</item>
/// </list>
/// </remarks>
[MessagePackObject]
public sealed class DoMatchingResponseDto
{

View File

@@ -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]

View File

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