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>
65 lines
2.5 KiB
C#
65 lines
2.5 KiB
C#
using System.Collections.Concurrent;
|
|
using SVSim.BattleNode.Bridge;
|
|
using SVSim.BattleNode.Sessions;
|
|
|
|
namespace SVSim.EmulatedEntrypoint.Matching;
|
|
|
|
/// <summary>
|
|
/// In-process FCFS pair-up: one waiting slot per mode, plus a per-viewer cache of
|
|
/// resolved matches for the first arriver's next poll (consume-on-read).
|
|
/// </summary>
|
|
public sealed class InProcessPairUp : IMatchingPairUpService
|
|
{
|
|
private readonly IMatchingBridge _bridge;
|
|
private readonly ConcurrentDictionary<string, ModeSlot> _slots = new();
|
|
|
|
public InProcessPairUp(IMatchingBridge bridge)
|
|
{
|
|
_bridge = bridge;
|
|
}
|
|
|
|
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<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<PairUpResult?>(null);
|
|
}
|
|
var p1 = slot.Waiting;
|
|
var p2 = player;
|
|
slot.Waiting = null;
|
|
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<PairUpResult?>(new PairUpResult(match, IsOwner: false));
|
|
}
|
|
|
|
// 3. Empty slot — park this caller.
|
|
slot.Waiting = player;
|
|
return Task.FromResult<PairUpResult?>(null);
|
|
}
|
|
}
|
|
|
|
private sealed class ModeSlot
|
|
{
|
|
public BattlePlayer? Waiting { get; set; }
|
|
public Dictionary<long, PendingMatch> Resolved { get; } = new();
|
|
public object Lock { get; } = new();
|
|
}
|
|
}
|