feat(battle-node): WaitingRoom for PvP WS rendezvous

Per-BattleId slot keyed dict. Pair returns the first arriver to the
second; ParkAsync awaits a TCS and returns the second arriver. Timeout
defaults to BattleNodeOptions.WaitingRoomTimeout (60s); evict on timeout
keeps the dict clean. Singleton in DI; consumed by the handler in the
next task.
This commit is contained in:
gamer147
2026-06-01 21:55:11 -04:00
parent db054205b3
commit 2789dc08cb
5 changed files with 171 additions and 0 deletions

View File

@@ -8,4 +8,11 @@ namespace SVSim.BattleNode.Bridge;
public sealed class BattleNodeOptions
{
public string NodeServerUrl { get; set; } = "localhost:5148/socket.io/";
/// <summary>
/// How long the first arriver's WS waits for a partner before disconnecting.
/// Matches the architecture spec's 60s default; override (typically lower)
/// in tests via the factory.
/// </summary>
public TimeSpan WaitingRoomTimeout { get; set; } = TimeSpan.FromSeconds(60);
}

View File

@@ -30,6 +30,7 @@ public static class BattleNodeExtensions
services.AddSingleton(options);
services.AddSingleton<IBattleSessionStore, InMemoryBattleSessionStore>();
services.AddSingleton<IMatchingBridge, MatchingBridge>();
services.AddSingleton<IWaitingRoom, WaitingRoom>();
services.AddSingleton<BattleNodeWebSocketHandler>();
return services;
}

View File

@@ -0,0 +1,26 @@
using SVSim.BattleNode.Sessions.Participants;
namespace SVSim.BattleNode.Hosting;
/// <summary>
/// Per-BattleId WS rendezvous for PvP. First arriver parks; second arriver pairs.
/// The handler reads the result and either constructs the session (second arriver)
/// or awaits termination via the participant's session-finished signal (first arriver).
/// </summary>
public interface IWaitingRoom
{
/// <summary>Try to claim a previously-parked first arriver. Returns the first
/// arriver (and clears the slot) if one is parked; null if this caller is the
/// first arriver (caller should then ParkAsync).</summary>
RealParticipant? Pair(string battleId, RealParticipant self);
/// <summary>Park as the first arriver; await pairing or timeout. Returns the
/// second arriver on pairing; null on timeout / cancellation / TryAdd race.</summary>
Task<RealParticipant?> ParkAsync(string battleId, RealParticipant self,
TimeSpan timeout, CancellationToken ct);
/// <summary>Best-effort cleanup; idempotent. Called on timeout or cancellation
/// so a stale TCS doesn't linger if the first arriver disconnects before
/// pairing.</summary>
void Evict(string battleId);
}

View File

@@ -0,0 +1,59 @@
using System.Collections.Concurrent;
using SVSim.BattleNode.Sessions.Participants;
namespace SVSim.BattleNode.Hosting;
/// <summary>
/// In-process <see cref="IWaitingRoom"/>. Backed by a ConcurrentDictionary of slots
/// keyed by BattleId. Each slot holds the first arriver's RealParticipant and a
/// TaskCompletionSource that gets set when the second arriver Pairs (or cancelled
/// on timeout / abort).
/// </summary>
public sealed class WaitingRoom : IWaitingRoom
{
private readonly ConcurrentDictionary<string, Slot> _rooms = new();
public RealParticipant? Pair(string battleId, RealParticipant self)
{
if (!_rooms.TryRemove(battleId, out var slot)) return null;
// Hand `self` (second arriver) to the first arriver's ParkAsync...
slot.SecondArriverTcs.TrySetResult(self);
// ...and return the first arriver to the second arriver's handler.
return slot.FirstArriver;
}
public async Task<RealParticipant?> ParkAsync(string battleId, RealParticipant self,
TimeSpan timeout, CancellationToken ct)
{
var slot = new Slot(self);
if (!_rooms.TryAdd(battleId, slot))
{
// Race: a concurrent Park already created a slot for the same BattleId.
// The bridge mints a fresh BattleId per registration, so this is rare;
// caller can re-Pair as insurance.
return null;
}
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeoutCts.CancelAfter(timeout);
using var reg = timeoutCts.Token.Register(() => slot.SecondArriverTcs.TrySetCanceled());
try
{
return await slot.SecondArriverTcs.Task;
}
catch (OperationCanceledException)
{
Evict(battleId);
return null;
}
}
public void Evict(string battleId) => _rooms.TryRemove(battleId, out _);
private sealed class Slot
{
public RealParticipant FirstArriver { get; }
public TaskCompletionSource<RealParticipant> SecondArriverTcs { get; } =
new(TaskCreationOptions.RunContinuationsAsynchronously);
public Slot(RealParticipant first) => FirstArriver = first;
}
}