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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
26
SVSim.BattleNode/Hosting/IWaitingRoom.cs
Normal file
26
SVSim.BattleNode/Hosting/IWaitingRoom.cs
Normal 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);
|
||||
}
|
||||
59
SVSim.BattleNode/Hosting/WaitingRoom.cs
Normal file
59
SVSim.BattleNode/Hosting/WaitingRoom.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
78
SVSim.UnitTests/BattleNode/Hosting/WaitingRoomTests.cs
Normal file
78
SVSim.UnitTests/BattleNode/Hosting/WaitingRoomTests.cs
Normal file
@@ -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<long>(),
|
||||
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<RealParticipant>.Instance);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user