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); + } +}