diff --git a/SVSim.BattleNode/Bridge/BattleNodeOptions.cs b/SVSim.BattleNode/Bridge/BattleNodeOptions.cs
index 0e28827..dbf448f 100644
--- a/SVSim.BattleNode/Bridge/BattleNodeOptions.cs
+++ b/SVSim.BattleNode/Bridge/BattleNodeOptions.cs
@@ -8,4 +8,11 @@ namespace SVSim.BattleNode.Bridge;
public sealed class BattleNodeOptions
{
public string NodeServerUrl { get; set; } = "localhost:5148/socket.io/";
+
+ ///
+ /// 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.
+ ///
+ public TimeSpan WaitingRoomTimeout { get; set; } = TimeSpan.FromSeconds(60);
}
diff --git a/SVSim.BattleNode/Hosting/BattleNodeExtensions.cs b/SVSim.BattleNode/Hosting/BattleNodeExtensions.cs
index 93086e0..8c07fb8 100644
--- a/SVSim.BattleNode/Hosting/BattleNodeExtensions.cs
+++ b/SVSim.BattleNode/Hosting/BattleNodeExtensions.cs
@@ -30,6 +30,7 @@ public static class BattleNodeExtensions
services.AddSingleton(options);
services.AddSingleton();
services.AddSingleton();
+ services.AddSingleton();
services.AddSingleton();
return services;
}
diff --git a/SVSim.BattleNode/Hosting/IWaitingRoom.cs b/SVSim.BattleNode/Hosting/IWaitingRoom.cs
new file mode 100644
index 0000000..7bc0e48
--- /dev/null
+++ b/SVSim.BattleNode/Hosting/IWaitingRoom.cs
@@ -0,0 +1,26 @@
+using SVSim.BattleNode.Sessions.Participants;
+
+namespace SVSim.BattleNode.Hosting;
+
+///
+/// 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).
+///
+public interface IWaitingRoom
+{
+ /// 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).
+ RealParticipant? Pair(string battleId, RealParticipant self);
+
+ /// Park as the first arriver; await pairing or timeout. Returns the
+ /// second arriver on pairing; null on timeout / cancellation / TryAdd race.
+ Task ParkAsync(string battleId, RealParticipant self,
+ TimeSpan timeout, CancellationToken ct);
+
+ /// Best-effort cleanup; idempotent. Called on timeout or cancellation
+ /// so a stale TCS doesn't linger if the first arriver disconnects before
+ /// pairing.
+ void Evict(string battleId);
+}
diff --git a/SVSim.BattleNode/Hosting/WaitingRoom.cs b/SVSim.BattleNode/Hosting/WaitingRoom.cs
new file mode 100644
index 0000000..66a0f62
--- /dev/null
+++ b/SVSim.BattleNode/Hosting/WaitingRoom.cs
@@ -0,0 +1,59 @@
+using System.Collections.Concurrent;
+using SVSim.BattleNode.Sessions.Participants;
+
+namespace SVSim.BattleNode.Hosting;
+
+///
+/// In-process . 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).
+///
+public sealed class WaitingRoom : IWaitingRoom
+{
+ private readonly ConcurrentDictionary _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 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 SecondArriverTcs { get; } =
+ new(TaskCreationOptions.RunContinuationsAsynchronously);
+ public Slot(RealParticipant first) => FirstArriver = first;
+ }
+}
diff --git a/SVSim.UnitTests/BattleNode/Hosting/WaitingRoomTests.cs b/SVSim.UnitTests/BattleNode/Hosting/WaitingRoomTests.cs
new file mode 100644
index 0000000..775f041
--- /dev/null
+++ b/SVSim.UnitTests/BattleNode/Hosting/WaitingRoomTests.cs
@@ -0,0 +1,78 @@
+using Microsoft.Extensions.Logging.Abstractions;
+using NUnit.Framework;
+using SVSim.BattleNode.Bridge;
+using SVSim.BattleNode.Hosting;
+using SVSim.BattleNode.Sessions.Participants;
+using SVSim.UnitTests.BattleNode.Infrastructure;
+
+namespace SVSim.UnitTests.BattleNode.Hosting;
+
+[TestFixture]
+public class WaitingRoomTests
+{
+ [Test]
+ public void Pair_on_empty_slot_returns_null()
+ {
+ var room = new WaitingRoom();
+ var participant = NewParticipant(viewerId: 1);
+
+ var paired = room.Pair("bid-1", participant);
+
+ Assert.That(paired, Is.Null);
+ }
+
+ [Test]
+ public async Task Park_then_Pair_resolves_with_each_arriver_seeing_the_other()
+ {
+ var room = new WaitingRoom();
+ var first = NewParticipant(viewerId: 1);
+ var second = NewParticipant(viewerId: 2);
+
+ var parkTask = room.ParkAsync("bid-1", first, TimeSpan.FromSeconds(5), CancellationToken.None);
+ // Yield so Park's TryAdd lands first.
+ await Task.Yield();
+
+ var firstReturnedToSecond = room.Pair("bid-1", second);
+ var secondReturnedToFirst = await parkTask;
+
+ Assert.That(firstReturnedToSecond, Is.SameAs(first),
+ "Pair must return the first arriver to the second.");
+ Assert.That(secondReturnedToFirst, Is.SameAs(second),
+ "Park must return the second arriver to the first.");
+ }
+
+ [Test]
+ public async Task Park_times_out_returns_null_and_evicts_slot()
+ {
+ var room = new WaitingRoom();
+ var first = NewParticipant(viewerId: 1);
+
+ var second = await room.ParkAsync("bid-1", first, TimeSpan.FromMilliseconds(50), CancellationToken.None);
+
+ Assert.That(second, Is.Null);
+ // Slot should be evicted; a subsequent Pair returns null (no first arriver).
+ var paired = room.Pair("bid-1", NewParticipant(viewerId: 2));
+ Assert.That(paired, Is.Null);
+ }
+
+ [Test]
+ public void Evict_is_idempotent()
+ {
+ var room = new WaitingRoom();
+
+ Assert.DoesNotThrow(() => room.Evict("bid-1"));
+ Assert.DoesNotThrow(() => room.Evict("bid-1"));
+ }
+
+ private static RealParticipant NewParticipant(long viewerId)
+ {
+ var ws = new TestWebSocket();
+ var ctx = new MatchContext(
+ SelfDeckCardIds: Array.Empty(),
+ ClassId: "1", CharaId: "1", CardMasterName: "card_master_node_10015",
+ CountryCode: "KOR", UserName: "Player", SleeveId: "0",
+ EmblemId: "0", DegreeId: "0", FieldId: 0, IsOfficial: 0,
+ BattleType: 11);
+ return new RealParticipant(ws, viewerId, ctx, NullLogger.Instance);
+ }
+}