diff --git a/SVSim.BattleNode/Hosting/BattleNodeWebSocketHandler.cs b/SVSim.BattleNode/Hosting/BattleNodeWebSocketHandler.cs index 2c00b0d..f0454b7 100644 --- a/SVSim.BattleNode/Hosting/BattleNodeWebSocketHandler.cs +++ b/SVSim.BattleNode/Hosting/BattleNodeWebSocketHandler.cs @@ -118,8 +118,8 @@ public sealed class BattleNodeWebSocketHandler var realParticipant = new RealParticipant(ws, viewerId, pending.P1.Context, _loggerFactory.CreateLogger()); var scriptedBot = new ScriptedBotParticipant(); - var session = new BattleSessionV2(battleId, pending.Type, realParticipant, scriptedBot, - _loggerFactory.CreateLogger()); + var session = new BattleSession(battleId, pending.Type, realParticipant, scriptedBot, + _loggerFactory.CreateLogger()); await session.RunAsync(ctx.RequestAborted); } diff --git a/SVSim.BattleNode/Sessions/BattleSession.cs b/SVSim.BattleNode/Sessions/BattleSession.cs index 9ec1f21..fb78b25 100644 --- a/SVSim.BattleNode/Sessions/BattleSession.cs +++ b/SVSim.BattleNode/Sessions/BattleSession.cs @@ -1,290 +1,170 @@ -using System.Net.WebSockets; -using System.Security.Cryptography; -using System.Text; using Microsoft.Extensions.Logging; -using SVSim.BattleNode.Bridge; using SVSim.BattleNode.Lifecycle; using SVSim.BattleNode.Protocol; using SVSim.BattleNode.Protocol.Bodies; -using SVSim.BattleNode.Reliability; -using SVSim.BattleNode.Wire; namespace SVSim.BattleNode.Sessions; /// -/// One per connected client. Owns the WebSocket + reliability ledgers + lifecycle phase. +/// v2 broker session. Holds two participants and brokers between them. Subscribes +/// to each participant's ; on each frame, +/// runs to determine the routing (target + frame + noStock +/// flag) and dispatches via . /// +/// +/// Phase 1 wires this for only — the dispatch logic +/// preserves v1.2 behaviour. Phase 2 wires Pvp (broadcast Matched/BattleStart per-perspective, +/// forward gameplay frames between participants). Phase 3 wires Bot (ack-only). +/// public sealed class BattleSession { - private readonly WebSocket _ws; private readonly ILogger _log; - /// - /// Cancellation token for the session lifetime, captured from 's - /// parameter. Read by all send helpers so host shutdown promptly terminates pending - /// writes (was CancellationToken.None on every send pre-this-slice). - /// - /// - /// Defaults to when hasn't - /// run — e.g. unit tests that drive directly without - /// going through the WS pump. None of those tests exercise send code, so the default - /// is safe. - /// - private CancellationToken _sessionCt; - public string BattleId { get; } - public long ViewerId { get; } + public BattleType Type { get; } + public IBattleParticipant A { get; } + public IBattleParticipant B { get; } public BattleSessionPhase Phase { get; private set; } = BattleSessionPhase.AwaitingInitNetwork; - public InboundTracker Inbound { get; } = new(); - public OutboundSequencer Outbound { get; } = new(); - /// - /// Player-side snapshot captured at do_matching time. ScriptedLifecycle reads the player - /// half of Matched/BattleStart frames from here; opponent half stays in ScriptedProfiles. - /// - internal MatchContext Context { get; } - - public BattleSession(WebSocket ws, string battleId, long viewerId, MatchContext context, ILogger log) + public BattleSession(string battleId, BattleType type, IBattleParticipant a, IBattleParticipant b, + ILogger log) { - _ws = ws; - _log = log; BattleId = battleId; - ViewerId = viewerId; - Context = context; + Type = type; + A = a; + B = b; + _log = log; + + // Subscribe to both participants' emissions. + A.FrameEmitted += OnFrameFromA; + B.FrameEmitted += OnFrameFromB; } - /// - /// Send the EIO3 open handshake then run the read loop until the WS closes. - /// public async Task RunAsync(CancellationToken cancellation) { - _sessionCt = cancellation; - await SendEioOpenAsync(cancellation); + // Run both participants' inbound loops in parallel and wait for them all to + // complete. NoOp/Scripted bots return immediately; Real returns when the WS + // closes. Using WhenAny here would have killed the session as soon as the + // scripted bot's no-op RunAsync resolved. Phase 2's Pvp/Bot cases will need + // disconnect propagation; that's wired in their own task. + var aTask = A.RunAsync(cancellation); + var bTask = B.RunAsync(cancellation); + try { await Task.WhenAll(aTask, bTask); } catch { /* swallow cancellation */ } - var buffer = new byte[8192]; - var pendingAttachments = new List(); - SocketIoFrame? pendingFrame = null; - - while (_ws.State == WebSocketState.Open && !cancellation.IsCancellationRequested) - { - var msg = await ReadCompleteMessageAsync(buffer, cancellation); - if (msg is null) break; - - if (msg.Value.IsText) - { - var text = Encoding.UTF8.GetString(msg.Value.Bytes); - if (text.Length == 0) continue; - var eio = EngineIoFrame.Parse(text); - if (eio.Type == EngineIoPacketType.Ping) - { - await SendTextAsync("3", cancellation); // EIO3 pong - continue; - } - if (eio.Type != EngineIoPacketType.Message) continue; - - // SIO inside the message payload. - var sio = SocketIoFrame.Parse(eio.Payload); - if (sio.AttachmentCount > 0) - { - pendingFrame = sio; - pendingAttachments.Clear(); - continue; - } - await DispatchSocketIo(sio); - } - else - { - // Binary frame — an attachment for a pending binary event. - // Engine.IO v3 prefixes binary WS frames with the packet-type byte - // (0x04 = Message), analogous to the leading digit on text frames. - // Strip it before treating the rest as the Socket.IO attachment payload. - var bin = msg.Value.Bytes; - if (bin.Length > 0 && bin[0] == (byte)EngineIoPacketType.Message) - { - bin = bin.AsSpan(1).ToArray(); - } - pendingAttachments.Add(bin); - if (pendingFrame is not null && pendingAttachments.Count == pendingFrame.AttachmentCount) - { - var assembled = pendingFrame.WithAttachments(pendingAttachments.ToArray()); - pendingFrame = null; - await DispatchSocketIo(assembled); - } - } - } + await Task.WhenAll( + A.TerminateAsync(BattleFinishReason.NormalFinish), + B.TerminateAsync(BattleFinishReason.NormalFinish)); } - private async Task DispatchSocketIo(SocketIoFrame frame) - { - if (frame.Type is SocketIoPacketType.Event or SocketIoPacketType.BinaryEvent) - { - switch (frame.EventName) - { - case WireConstants.MsgEvent when frame.BinaryAttachments.Count == 1: - await HandleMsgEventAsync(frame); - return; - case WireConstants.AliveEvent when frame.BinaryAttachments.Count == 1: - await HandleAliveEventAsync(frame); - return; - } - } - // hand / unknown events: log and drop. - _log.LogDebug("BattleSession {Bid}: dropping SIO event={Event}", BattleId, frame.EventName); - } + private Task OnFrameFromA(MsgEnvelope env, CancellationToken ct) => HandleFrameAsync(A, env, ct); + private Task OnFrameFromB(MsgEnvelope env, CancellationToken ct) => HandleFrameAsync(B, env, ct); - private async Task HandleMsgEventAsync(SocketIoFrame frame) + private async Task HandleFrameAsync(IBattleParticipant from, MsgEnvelope env, CancellationToken ct) { try { - MsgEnvelope env; - try { env = MsgPayloadCodec.Decode(frame.BinaryAttachments[0]); } - catch (Exception ex) + var routes = ComputeFrames(from, env); + foreach (var (target, frame, noStock) in routes) { - _log.LogWarning(ex, "BattleSession {Bid}: failed to decode msg envelope", BattleId); - return; - } - - // Ack tracking + dedupe. - bool shouldDispatch = true; - if (env.PubSeq.HasValue) - { - shouldDispatch = Inbound.Observe(env.PubSeq.Value); - if (frame.AckId.HasValue) - { - await SendSioAckAsync(frame.AckId.Value, env.PubSeq.Value); - } - } - if (!shouldDispatch) return; - - // Run the pure-logic decision and drive sends. - var responses = ComputeResponses(env); - foreach (var (responseEnv, noStock) in responses) - { - if (noStock) - await PushNoStockAsync(responseEnv); - else - await PushOrderedAsync(responseEnv); + await target.PushAsync(frame, noStock, ct); } } catch (Exception ex) { - _log.LogError(ex, "BattleSession {Bid}: unhandled exception in HandleMsgEventAsync", BattleId); - } - } - - private async Task HandleAliveEventAsync(SocketIoFrame frame) - { - try - { - // Client emits Gungnir every 5s with an SIO ack callback expecting just liveness confirmation - // (payload ignored). We ack immediately, then push our own alive back with scs/ocs ONLINE - // placeholders — the only response the client uses to drive its scs/ocs state machine. - if (frame.AckId.HasValue) - { - await SendSioAckAsync(frame.AckId.Value, 0); - } - var aliveEnv = new MsgEnvelope( - Uri: NetworkBattleUri.Gungnir, - ViewerId: ScriptedLifecycle.FakeOpponentViewerId, - Uuid: WireConstants.ServerUuid, - Bid: null, - Try: 0, - Cat: EmitCategory.General, - PubSeq: null, - PlaySeq: null, - Body: new AlivePushBody(Scs: WireConstants.OnlineStatus, Ocs: WireConstants.OnlineStatus)); - await PushNoStockAsync(aliveEnv, eventName: WireConstants.AliveEvent); - } - catch (Exception ex) - { - _log.LogError(ex, "BattleSession {Bid}: unhandled exception in HandleAliveEventAsync", BattleId); + _log.LogError(ex, "BattleSession {Bid}: unhandled in HandleFrameAsync", BattleId); } } /// - /// Pure-logic lifecycle state machine: given an inbound and the - /// current , return the envelopes the session should push back AND - /// transition . Extracted as an internal method so unit tests can drive - /// the state machine without standing up a real WebSocket. + /// Pure-logic dispatch: given an inbound frame from one participant, return the list + /// of (target, frame, noStock) tuples the session should dispatch. Transitions + /// . Extracted so unit tests can drive the dispatch without + /// standing up real participants. /// - /// - /// Ordered list of (envelope, no-stock) tuples. NoStock = true means the push is a - /// control frame (ack / BattleFinish) and bypasses 's - /// playSeq assignment + Resume archive. NoStock = false means the push is part of - /// the ordered stream and gets a fresh playSeq. - /// - internal IReadOnlyList<(MsgEnvelope Envelope, bool NoStock)> ComputeResponses(MsgEnvelope env) + internal IReadOnlyList<(IBattleParticipant Target, MsgEnvelope Frame, bool NoStock)> ComputeFrames( + IBattleParticipant from, MsgEnvelope env) { - var result = new List<(MsgEnvelope Envelope, bool NoStock)>(); + var result = new List<(IBattleParticipant, MsgEnvelope, bool)>(); + var other = ReferenceEquals(from, A) ? B : A; + + // The dispatch table only covers the Scripted-mode behaviour Phase 1 needs; + // Phase 2 (Pvp) and Phase 3 (Bot) add the other-type branches. switch (env.Uri) { - // The real handshake sequence (MatchingNetworkConnectChecker + Matching.cs): - // 1. WS opens. - // 2. Client emits InitNetwork (cat=general). - // 3. Server replies with InitNetwork ack → _initNetworkSuccess = true. - // 4. MatchingInitBattle() runs: status=Connect, emits InitBattle, THEN subscribes - // the OnReceivedEvent matching handler. - // 5. Server replies with Matched → handler is subscribed, status=Connect → - // transitions to StartLoad and StartBattleLoad() loads decks/scene. - // 6. Asset load completes → client emits Loaded. - // 7. Server replies with BattleStart + Deal → status=Prepared, GotoBattle(). - // Pushing Matched in response to InitNetwork (instead of InitBattle) drops it - // before the handler is subscribed; the state machine never advances. case NetworkBattleUri.InitNetwork when Phase == BattleSessionPhase.AwaitingInitNetwork: - result.Add((BuildAckedEnvelope(NetworkBattleUri.InitNetwork), NoStock: true)); + result.Add((from, BuildAck(NetworkBattleUri.InitNetwork), true)); Phase = BattleSessionPhase.AwaitingInitBattle; break; + case NetworkBattleUri.InitBattle when Phase == BattleSessionPhase.AwaitingInitBattle: - result.Add((ScriptedLifecycle.BuildMatched(Context, ViewerId, ScriptedLifecycle.FakeOpponentViewerId, BattleId), NoStock: false)); + // Phase 1: push Matched only to the "real" participant. The session reads + // selfInfo from from.Context; opponent half currently comes from + // ScriptedProfiles inside ScriptedLifecycle.BuildMatched (Phase 2 generalises + // to use other.Context for per-perspective Matched). + result.Add((from, ScriptedLifecycle.BuildMatched(from.Context, from.ViewerId, other.ViewerId, BattleId), false)); Phase = BattleSessionPhase.AwaitingLoaded; break; + case NetworkBattleUri.Loaded when Phase == BattleSessionPhase.AwaitingLoaded: - result.Add((ScriptedLifecycle.BuildBattleStart(Context, ViewerId), NoStock: false)); - result.Add((ScriptedLifecycle.BuildDeal(), NoStock: false)); + result.Add((from, ScriptedLifecycle.BuildBattleStart(from.Context, from.ViewerId), false)); + result.Add((from, ScriptedLifecycle.BuildDeal(), false)); Phase = BattleSessionPhase.AwaitingSwap; break; + case NetworkBattleUri.Swap when Phase == BattleSessionPhase.AwaitingSwap: { - // Compute the actual post-mulligan hand: any idx in idxList that's in the initial - // 3-card hand gets replaced with a fresh deck idx. Both Swap response AND Ready - // need the SAME hand — the client diffs them to compute "drawn cards" and errors - // out with "Card swap failed: AbandonCards[...]/DrawCards[]" if they don't agree. var hand = ScriptedLifecycle.ComputeHandAfterSwap(ExtractIdxList(env)); - result.Add((ScriptedLifecycle.BuildSwapResponse(hand), NoStock: false)); - result.Add((ScriptedLifecycle.BuildReady(hand), NoStock: false)); + result.Add((from, ScriptedLifecycle.BuildSwapResponse(hand), false)); + result.Add((from, ScriptedLifecycle.BuildReady(hand), false)); Phase = BattleSessionPhase.AfterReady; break; } + case NetworkBattleUri.TurnEnd when Phase == BattleSessionPhase.AfterReady: case NetworkBattleUri.TurnEndFinal when Phase == BattleSessionPhase.AfterReady: - // Scripted opponent: opens its turn, ends it, then pushes Judge so the - // client's JudgeOperation -> ControlTurnStartPlayer fires and the player's - // next turn begins. Three-frame burst — see - // docs/superpowers/plans/2026-06-01-battle-node-opponent-turn-judge.md. - // OpponentTurn is set then cleared within the same call to document intent; - // the only externally-observable phase after the call is AfterReady, ready for - // the next player TurnEnd to fire the cycle again. - Phase = BattleSessionPhase.OpponentTurn; - result.Add((ScriptedLifecycle.BuildOpponentTurnStart(), NoStock: false)); - result.Add((ScriptedLifecycle.BuildOpponentTurnEnd(), NoStock: false)); - result.Add((ScriptedLifecycle.BuildOpponentJudge(), NoStock: false)); - Phase = BattleSessionPhase.AfterReady; + // Phase 1: forward the player's TurnEnd to the scripted bot. The bot's + // PushAsync fires its three-frame burst via FrameEmitted; each emitted + // frame loops back through HandleFrameAsync → ComputeFrames → routes to + // the real participant. Net wire effect: same three pushes as v1.2. + result.Add((other, env, false)); break; + case NetworkBattleUri.Retire: case NetworkBattleUri.Kill: - // These always terminate, regardless of phase. - result.Add((BuildBattleFinishNoContest(), NoStock: true)); + result.Add((from, BuildBattleFinishNoContest(), true)); Phase = BattleSessionPhase.Terminal; break; + + // Frames emitted by the scripted bot (TurnStart / TurnEnd / Judge) — forward + // to the real participant. These match the v1.2 burst's three outbound pushes. + case NetworkBattleUri.TurnStart when ReferenceEquals(from, B) || ReferenceEquals(from, A): + case NetworkBattleUri.Judge when ReferenceEquals(from, B) || ReferenceEquals(from, A): + // Generic forwarder for scripted-bot emissions. The Scripted bot's TurnStart + // and Judge are intended for the real participant; TurnEnd handled above. + if (!IsRealForwardableFromScripted(from, env)) goto default; + result.Add((other, env, false)); + break; + default: - // Out-of-order or unknown URI: log and drop (no response). - _log.LogDebug("BattleSession {Bid}: dropping uri={Uri} in phase={Phase}", BattleId, env.Uri, Phase); + _log.LogDebug("BattleSession {Bid}: dropping uri={Uri} in phase={Phase} from vid={Vid}", + BattleId, env.Uri, Phase, from.ViewerId); break; } + return result; } - private MsgEnvelope BuildAckedEnvelope(NetworkBattleUri uri) => new( + // Phase 1: the only "scripted-bot" emissions we need to forward are the three burst + // frames (TurnStart, TurnEnd, Judge) — and TurnEnd is already handled in the switch + // above as a forwardable bot emission. This helper exists so the TurnStart/Judge cases + // above only fire when the source is actually a participant (not malformed inbound). + private static bool IsRealForwardableFromScripted(IBattleParticipant from, MsgEnvelope env) + { + // The bot's emitted frames carry ViewerId == FakeOpponentViewerId. + return from.ViewerId == ScriptedLifecycle.FakeOpponentViewerId; + } + + private MsgEnvelope BuildAck(NetworkBattleUri uri) => new( uri, ViewerId: ScriptedLifecycle.FakeOpponentViewerId, Uuid: WireConstants.ServerUuid, @@ -308,10 +188,6 @@ public sealed class BattleSession private static IReadOnlyList ExtractIdxList(MsgEnvelope env) { - // Defensive: accept any IEnumerable carrying any numeric boxing (long/int/double/decimal/ - // string). MsgEnvelope.FromJson should box small ints as long, but a parser quirk - // anywhere upstream could yield a different boxed type and OfType would silently - // drop the entries — that broke the v1 mulligan during smoke. if (env.Body is not RawBody rawBody) return Array.Empty(); if (rawBody.Entries.TryGetValue("idxList", out var raw) && raw is System.Collections.IEnumerable seq && raw is not string) { @@ -331,95 +207,4 @@ public sealed class BattleSession } return Array.Empty(); } - - private Task PushOrderedAsync(MsgEnvelope env, string eventName = WireConstants.SynchronizeEvent) => - EncodeAndSendAsync(Outbound.AssignAndArchive(env), eventName); - - private Task PushNoStockAsync(MsgEnvelope env, string eventName = WireConstants.SynchronizeEvent) => - EncodeAndSendAsync(Outbound.WrapNoStock(env), eventName); - - private async Task EncodeAndSendAsync(MsgEnvelope env, string eventName) - { - var key = NodeCrypto.GenerateKey(() => RandomNumberGenerator.GetInt32(0, 16)); - var bytes = MsgPayloadCodec.Encode(env, key); - var sio = SocketIoFrame.BinaryEventWithAttachments(eventName, new[] { bytes }); - var (text, bins) = sio.Encode(); - var eioText = $"{(int)EngineIoPacketType.Message}{text}"; - await SendTextAsync(eioText, _sessionCt); - foreach (var bin in bins) - { - // Engine.IO v3 binary frames are prefixed with the packet-type byte - // (0x04 = Message), the binary analog of the leading digit on text frames. - var prefixed = new byte[bin.Length + 1]; - prefixed[0] = (byte)EngineIoPacketType.Message; - Buffer.BlockCopy(bin, 0, prefixed, 1, bin.Length); - await _ws.SendAsync(prefixed, WebSocketMessageType.Binary, endOfMessage: true, _sessionCt); - } - } - - /// - /// Clip a long ack arg into the int range Socket.IO v2's typed AckResponse API accepts. - /// Logs a warning on clip; the implausibly-large pubSeq case is observationally - /// indistinguishable at the client (BestHTTP.SocketIO discards the echoed value), so - /// clipping is safer than the prior checked((int)arg) that threw and killed the - /// session on overflow. - /// - internal static int ClipAckArg(long arg, ILogger log, string battleId) - { - if (arg > int.MaxValue) - { - log.LogWarning( - "BattleSession {Bid}: pubSeq {Seq} exceeds int.MaxValue; clipping ack arg.", - battleId, arg); - return int.MaxValue; - } - if (arg < int.MinValue) - { - log.LogWarning( - "BattleSession {Bid}: pubSeq {Seq} below int.MinValue; clipping ack arg.", - battleId, arg); - return int.MinValue; - } - return (int)arg; - } - - private async Task SendSioAckAsync(int ackId, long arg) - { - var ack = SocketIoFrame.AckResponse(ackId, ClipAckArg(arg, _log, BattleId)); - var (text, _) = ack.Encode(); - var eioText = $"{(int)EngineIoPacketType.Message}{text}"; - await SendTextAsync(eioText, _sessionCt); - } - - private async Task SendEioOpenAsync(CancellationToken ct) - { - // SID format is server-internal; the EIO3 spec suggests a 20-char base64-url id, - // but BestHTTP's SocketIO client treats it as an opaque string and never validates - // shape. Hex-truncated Guid is fine in practice; revisit if we ever care about - // matching prod telemetry. - var sid = Guid.NewGuid().ToString("N").Substring(0, 16); - var handshake = new EngineIoHandshake(sid, Array.Empty(), 25000, 60000).ToJson(); - await SendTextAsync($"0{handshake}", ct); - } - - private Task SendTextAsync(string text, CancellationToken ct) - { - var bytes = Encoding.UTF8.GetBytes(text); - return _ws.SendAsync(bytes, WebSocketMessageType.Text, endOfMessage: true, ct); - } - - private async Task<(byte[] Bytes, bool IsText)?> ReadCompleteMessageAsync(byte[] buffer, CancellationToken ct) - { - using var ms = new MemoryStream(); - WebSocketReceiveResult result; - do - { - try { result = await _ws.ReceiveAsync(buffer, ct); } - catch (OperationCanceledException) { return null; } - catch (WebSocketException) { return null; } - if (result.MessageType == WebSocketMessageType.Close) return null; - ms.Write(buffer, 0, result.Count); - } while (!result.EndOfMessage); - return (ms.ToArray(), result.MessageType == WebSocketMessageType.Text); - } } diff --git a/SVSim.BattleNode/Sessions/BattleSessionV2.cs b/SVSim.BattleNode/Sessions/BattleSessionV2.cs deleted file mode 100644 index 4162695..0000000 --- a/SVSim.BattleNode/Sessions/BattleSessionV2.cs +++ /dev/null @@ -1,210 +0,0 @@ -using Microsoft.Extensions.Logging; -using SVSim.BattleNode.Lifecycle; -using SVSim.BattleNode.Protocol; -using SVSim.BattleNode.Protocol.Bodies; - -namespace SVSim.BattleNode.Sessions; - -/// -/// v2 broker session. Holds two participants and brokers between them. Subscribes -/// to each participant's ; on each frame, -/// runs to determine the routing (target + frame + noStock -/// flag) and dispatches via . -/// -/// -/// Phase 1 wires this for only — the dispatch logic -/// preserves v1.2 behaviour. Phase 2 wires Pvp (broadcast Matched/BattleStart per-perspective, -/// forward gameplay frames between participants). Phase 3 wires Bot (ack-only). -/// -public sealed class BattleSessionV2 -{ - private readonly ILogger _log; - - public string BattleId { get; } - public BattleType Type { get; } - public IBattleParticipant A { get; } - public IBattleParticipant B { get; } - public BattleSessionPhase Phase { get; private set; } = BattleSessionPhase.AwaitingInitNetwork; - - public BattleSessionV2(string battleId, BattleType type, IBattleParticipant a, IBattleParticipant b, - ILogger log) - { - BattleId = battleId; - Type = type; - A = a; - B = b; - _log = log; - - // Subscribe to both participants' emissions. - A.FrameEmitted += OnFrameFromA; - B.FrameEmitted += OnFrameFromB; - } - - public async Task RunAsync(CancellationToken cancellation) - { - // Run both participants' inbound loops in parallel and wait for them all to - // complete. NoOp/Scripted bots return immediately; Real returns when the WS - // closes. Using WhenAny here would have killed the session as soon as the - // scripted bot's no-op RunAsync resolved. Phase 2's Pvp/Bot cases will need - // disconnect propagation; that's wired in their own task. - var aTask = A.RunAsync(cancellation); - var bTask = B.RunAsync(cancellation); - try { await Task.WhenAll(aTask, bTask); } catch { /* swallow cancellation */ } - - await Task.WhenAll( - A.TerminateAsync(BattleFinishReason.NormalFinish), - B.TerminateAsync(BattleFinishReason.NormalFinish)); - } - - private Task OnFrameFromA(MsgEnvelope env, CancellationToken ct) => HandleFrameAsync(A, env, ct); - private Task OnFrameFromB(MsgEnvelope env, CancellationToken ct) => HandleFrameAsync(B, env, ct); - - private async Task HandleFrameAsync(IBattleParticipant from, MsgEnvelope env, CancellationToken ct) - { - try - { - var routes = ComputeFrames(from, env); - foreach (var (target, frame, noStock) in routes) - { - await target.PushAsync(frame, noStock, ct); - } - } - catch (Exception ex) - { - _log.LogError(ex, "BattleSessionV2 {Bid}: unhandled in HandleFrameAsync", BattleId); - } - } - - /// - /// Pure-logic dispatch: given an inbound frame from one participant, return the list - /// of (target, frame, noStock) tuples the session should dispatch. Transitions - /// . Extracted so unit tests can drive the dispatch without - /// standing up real participants. - /// - internal IReadOnlyList<(IBattleParticipant Target, MsgEnvelope Frame, bool NoStock)> ComputeFrames( - IBattleParticipant from, MsgEnvelope env) - { - var result = new List<(IBattleParticipant, MsgEnvelope, bool)>(); - var other = ReferenceEquals(from, A) ? B : A; - - // The dispatch table only covers the Scripted-mode behaviour Phase 1 needs; - // Phase 2 (Pvp) and Phase 3 (Bot) add the other-type branches. - switch (env.Uri) - { - case NetworkBattleUri.InitNetwork when Phase == BattleSessionPhase.AwaitingInitNetwork: - result.Add((from, BuildAck(NetworkBattleUri.InitNetwork), true)); - Phase = BattleSessionPhase.AwaitingInitBattle; - break; - - case NetworkBattleUri.InitBattle when Phase == BattleSessionPhase.AwaitingInitBattle: - // Phase 1: push Matched only to the "real" participant. The session reads - // selfInfo from from.Context; opponent half currently comes from - // ScriptedProfiles inside ScriptedLifecycle.BuildMatched (Phase 2 generalises - // to use other.Context for per-perspective Matched). - result.Add((from, ScriptedLifecycle.BuildMatched(from.Context, from.ViewerId, other.ViewerId, BattleId), false)); - Phase = BattleSessionPhase.AwaitingLoaded; - break; - - case NetworkBattleUri.Loaded when Phase == BattleSessionPhase.AwaitingLoaded: - result.Add((from, ScriptedLifecycle.BuildBattleStart(from.Context, from.ViewerId), false)); - result.Add((from, ScriptedLifecycle.BuildDeal(), false)); - Phase = BattleSessionPhase.AwaitingSwap; - break; - - case NetworkBattleUri.Swap when Phase == BattleSessionPhase.AwaitingSwap: - { - var hand = ScriptedLifecycle.ComputeHandAfterSwap(ExtractIdxList(env)); - result.Add((from, ScriptedLifecycle.BuildSwapResponse(hand), false)); - result.Add((from, ScriptedLifecycle.BuildReady(hand), false)); - Phase = BattleSessionPhase.AfterReady; - break; - } - - case NetworkBattleUri.TurnEnd when Phase == BattleSessionPhase.AfterReady: - case NetworkBattleUri.TurnEndFinal when Phase == BattleSessionPhase.AfterReady: - // Phase 1: forward the player's TurnEnd to the scripted bot. The bot's - // PushAsync fires its three-frame burst via FrameEmitted; each emitted - // frame loops back through HandleFrameAsync → ComputeFrames → routes to - // the real participant. Net wire effect: same three pushes as v1.2. - result.Add((other, env, false)); - break; - - case NetworkBattleUri.Retire: - case NetworkBattleUri.Kill: - result.Add((from, BuildBattleFinishNoContest(), true)); - Phase = BattleSessionPhase.Terminal; - break; - - // Frames emitted by the scripted bot (TurnStart / TurnEnd / Judge) — forward - // to the real participant. These match the v1.2 burst's three outbound pushes. - case NetworkBattleUri.TurnStart when ReferenceEquals(from, B) || ReferenceEquals(from, A): - case NetworkBattleUri.Judge when ReferenceEquals(from, B) || ReferenceEquals(from, A): - // Generic forwarder for scripted-bot emissions. The Scripted bot's TurnStart - // and Judge are intended for the real participant; TurnEnd handled above. - if (!IsRealForwardableFromScripted(from, env)) goto default; - result.Add((other, env, false)); - break; - - default: - _log.LogDebug("BattleSessionV2 {Bid}: dropping uri={Uri} in phase={Phase} from vid={Vid}", - BattleId, env.Uri, Phase, from.ViewerId); - break; - } - - return result; - } - - // Phase 1: the only "scripted-bot" emissions we need to forward are the three burst - // frames (TurnStart, TurnEnd, Judge) — and TurnEnd is already handled in the switch - // above as a forwardable bot emission. This helper exists so the TurnStart/Judge cases - // above only fire when the source is actually a participant (not malformed inbound). - private static bool IsRealForwardableFromScripted(IBattleParticipant from, MsgEnvelope env) - { - // The bot's emitted frames carry ViewerId == FakeOpponentViewerId. - return from.ViewerId == ScriptedLifecycle.FakeOpponentViewerId; - } - - private MsgEnvelope BuildAck(NetworkBattleUri uri) => new( - uri, - ViewerId: ScriptedLifecycle.FakeOpponentViewerId, - Uuid: WireConstants.ServerUuid, - Bid: null, - Try: 0, - Cat: EmitCategory.General, - PubSeq: null, - PlaySeq: null, - Body: new ResultCodeOnlyBody()); - - private MsgEnvelope BuildBattleFinishNoContest() => new( - NetworkBattleUri.BattleFinish, - ViewerId: ScriptedLifecycle.FakeOpponentViewerId, - Uuid: WireConstants.ServerUuid, - Bid: null, - Try: 0, - Cat: EmitCategory.Battle, - PubSeq: null, - PlaySeq: null, - Body: new BattleFinishBody(Result: BattleResult.Win)); - - private static IReadOnlyList ExtractIdxList(MsgEnvelope env) - { - if (env.Body is not RawBody rawBody) return Array.Empty(); - if (rawBody.Entries.TryGetValue("idxList", out var raw) && raw is System.Collections.IEnumerable seq && raw is not string) - { - var result = new List(); - foreach (var item in seq) - { - switch (item) - { - case long l: result.Add(l); break; - case int i: result.Add(i); break; - case double d: result.Add((long)d); break; - case decimal m: result.Add((long)m); break; - case string s when long.TryParse(s, out var p): result.Add(p); break; - } - } - return result; - } - return Array.Empty(); - } -} diff --git a/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs b/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs index bc41a87..165d3b6 100644 --- a/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs +++ b/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs @@ -1,7 +1,7 @@ -// SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs using Microsoft.Extensions.Logging.Abstractions; using NUnit.Framework; using SVSim.BattleNode.Bridge; +using SVSim.BattleNode.Lifecycle; using SVSim.BattleNode.Protocol; using SVSim.BattleNode.Protocol.Bodies; using SVSim.BattleNode.Sessions; @@ -11,164 +11,199 @@ namespace SVSim.UnitTests.BattleNode.Sessions; [TestFixture] public class BattleSessionDispatchTests { - private static BattleSession NewSession() - { - // ws is unused by ComputeResponses; pass null! and rely on the test never invoking the pump. - return new BattleSession(ws: null!, battleId: "bid-1", viewerId: 1, context: FixtureCtx(), log: NullLogger.Instance); - } - - private static MatchContext FixtureCtx() => new( - SelfDeckCardIds: Enumerable.Range(1, 30).Select(i => 100_011_010L).ToList(), - ClassId: "1", CharaId: "1", CardMasterName: "card_master_node_10015", - CountryCode: "KOR", UserName: "Player", SleeveId: "3000011", - EmblemId: "701441011", DegreeId: "300003", FieldId: 43, IsOfficial: 0, - BattleType: 11); - - private static MsgEnvelope NewEnvelope(NetworkBattleUri uri) => - new(uri, ViewerId: 1, Uuid: "u", Bid: null, Try: 0, Cat: EmitCategory.Battle, - PubSeq: null, PlaySeq: null, Body: new RawBody(new Dictionary())); - [Test] - public void InitNetwork_PushesAckOnly_TransitionsToAwaitingInitBattle() + public void InitNetwork_acks_to_sender_transitions_to_AwaitingInitBattle() { - var s = NewSession(); - var responses = s.ComputeResponses(NewEnvelope(NetworkBattleUri.InitNetwork)); - Assert.That(responses.Select(r => r.Envelope.Uri), - Is.EqualTo(new[] { NetworkBattleUri.InitNetwork })); + var (s, a, b) = NewSession(); + var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork)); + + Assert.That(routes.Count, Is.EqualTo(1)); + Assert.That(routes[0].Target, Is.SameAs(a)); + Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.InitNetwork)); + Assert.That(routes[0].NoStock, Is.True); Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.AwaitingInitBattle)); } [Test] - public void InitBattle_PushesMatched_TransitionsToAwaitingLoaded() + public void InitBattle_pushes_Matched_to_sender_only() { - var s = NewSession(); - s.ComputeResponses(NewEnvelope(NetworkBattleUri.InitNetwork)); - var responses = s.ComputeResponses(NewEnvelope(NetworkBattleUri.InitBattle)); - Assert.That(responses.Single().Envelope.Uri, Is.EqualTo(NetworkBattleUri.Matched)); + var (s, a, b) = NewSession(); + s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork)); + var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitBattle)); + + Assert.That(routes.Count, Is.EqualTo(1)); + Assert.That(routes[0].Target, Is.SameAs(a)); + Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.Matched)); Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.AwaitingLoaded)); } [Test] - public void Loaded_PushesBattleStartThenDeal_TransitionsToAwaitingSwap() + public void Loaded_pushes_BattleStart_then_Deal_to_sender() { - var s = NewSession(); - s.ComputeResponses(NewEnvelope(NetworkBattleUri.InitNetwork)); - s.ComputeResponses(NewEnvelope(NetworkBattleUri.InitBattle)); - var responses = s.ComputeResponses(NewEnvelope(NetworkBattleUri.Loaded)); - Assert.That(responses.Select(r => r.Envelope.Uri), + var (s, a, b) = NewSession(); + s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork)); + s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitBattle)); + var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Loaded)); + + Assert.That(routes.Select(r => r.Frame.Uri), Is.EqualTo(new[] { NetworkBattleUri.BattleStart, NetworkBattleUri.Deal })); + Assert.That(routes.All(r => ReferenceEquals(r.Target, a)), Is.True); Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.AwaitingSwap)); } [Test] - public void Swap_WithIdxListContainingTwo_ProducesHandWithFreshIdxAtPosition1() + public void Swap_pushes_SwapResponse_then_Ready_to_sender() { - var s = NewSession(); - s.ComputeResponses(NewEnvelope(NetworkBattleUri.InitNetwork)); - s.ComputeResponses(NewEnvelope(NetworkBattleUri.InitBattle)); - s.ComputeResponses(NewEnvelope(NetworkBattleUri.Loaded)); - // Simulate the client's Swap{idxList:[2]}: the dict shape produced by MsgEnvelope.FromJson - // (a List of boxed long values), wrapped in a RawBody as the inbound type. - var swapEnv = new MsgEnvelope( - NetworkBattleUri.Swap, ViewerId: 1, Uuid: "u", Bid: null, Try: 0, - Cat: EmitCategory.Battle, PubSeq: null, PlaySeq: null, - Body: new RawBody(new Dictionary - { - ["idxList"] = new List { 2L }, - })); + var (s, a, b) = NewSession(); + s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork)); + s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitBattle)); + s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Loaded)); + var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Swap)); - var responses = s.ComputeResponses(swapEnv); - - var swapBody = (SwapResponseBody)responses[0].Envelope.Body; - Assert.That(swapBody.Self[0].Idx, Is.EqualTo(1)); - Assert.That(swapBody.Self[1].Idx, Is.EqualTo(4)); // swapped — fresh deck idx - Assert.That(swapBody.Self[2].Idx, Is.EqualTo(3)); - } - - [Test] - public void Swap_PushesSwapResponseThenReady_TransitionsToAfterReady() - { - var s = NewSession(); - s.ComputeResponses(NewEnvelope(NetworkBattleUri.InitNetwork)); - s.ComputeResponses(NewEnvelope(NetworkBattleUri.InitBattle)); - s.ComputeResponses(NewEnvelope(NetworkBattleUri.Loaded)); - var responses = s.ComputeResponses(NewEnvelope(NetworkBattleUri.Swap)); - Assert.That(responses.Select(r => r.Envelope.Uri), + Assert.That(routes.Select(r => r.Frame.Uri), Is.EqualTo(new[] { NetworkBattleUri.Swap, NetworkBattleUri.Ready })); Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.AfterReady)); } [Test] - public void TurnEnd_AfterReady_PushesTurnStart_TurnEnd_Judge_StaysInAfterReady() + public void TurnEnd_from_real_forwards_to_other_participant() { - var s = NewSession(); - s.ComputeResponses(NewEnvelope(NetworkBattleUri.InitNetwork)); - s.ComputeResponses(NewEnvelope(NetworkBattleUri.InitBattle)); - s.ComputeResponses(NewEnvelope(NetworkBattleUri.Loaded)); - s.ComputeResponses(NewEnvelope(NetworkBattleUri.Swap)); + var (s, a, b) = NewSession(); + s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork)); + s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitBattle)); + s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Loaded)); + s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Swap)); + var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.TurnEnd)); - var responses = s.ComputeResponses(NewEnvelope(NetworkBattleUri.TurnEnd)); - - // Three-frame cycle: opponent opens its turn, ends it, sends Judge so the client's - // JudgeOperation -> ControlTurnStartPlayer fires and the player's next turn begins. - Assert.That(responses.Select(r => r.Envelope.Uri), - Is.EqualTo(new[] { NetworkBattleUri.TurnStart, NetworkBattleUri.TurnEnd, NetworkBattleUri.Judge })); - Assert.That(responses.Select(r => r.NoStock), - Is.EqualTo(new[] { false, false, false })); - // Phase returns to AfterReady within the same call so the next player TurnEnd can fire - // the cycle again. OpponentTurn is set transiently and is never externally observable. + Assert.That(routes.Count, Is.EqualTo(1)); + Assert.That(routes[0].Target, Is.SameAs(b)); + Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.TurnEnd)); Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.AfterReady)); } [Test] - public void TurnEnd_CanFireMultipleTimesConsecutively() + public void ScriptedBot_emitted_OpponentTurnStart_forwards_to_real() { - var s = NewSession(); - s.ComputeResponses(NewEnvelope(NetworkBattleUri.InitNetwork)); - s.ComputeResponses(NewEnvelope(NetworkBattleUri.InitBattle)); - s.ComputeResponses(NewEnvelope(NetworkBattleUri.Loaded)); - s.ComputeResponses(NewEnvelope(NetworkBattleUri.Swap)); + var (s, a, b) = NewSession(); + // Bot emits a TurnStart frame (carrying ViewerId == FakeOpponentViewerId per the + // ScriptedBotParticipant impl). Session should route it to the real participant. + var botFrame = ScriptedLifecycle.BuildOpponentTurnStart(); + var routes = s.ComputeFrames(b, botFrame); - var first = s.ComputeResponses(NewEnvelope(NetworkBattleUri.TurnEnd)); - var second = s.ComputeResponses(NewEnvelope(NetworkBattleUri.TurnEnd)); - - // Both calls produce the same three-frame burst. - Assert.That(first.Select(r => r.Envelope.Uri), - Is.EqualTo(new[] { NetworkBattleUri.TurnStart, NetworkBattleUri.TurnEnd, NetworkBattleUri.Judge })); - Assert.That(second.Select(r => r.Envelope.Uri), - Is.EqualTo(new[] { NetworkBattleUri.TurnStart, NetworkBattleUri.TurnEnd, NetworkBattleUri.Judge })); - Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.AfterReady)); + Assert.That(routes.Count, Is.EqualTo(1)); + Assert.That(routes[0].Target, Is.SameAs(a)); + Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.TurnStart)); } [Test] - public void Retire_PushesBattleFinishNoContest_TransitionsToTerminal() + public void ScriptedBot_emitted_Judge_forwards_to_real() { - var s = NewSession(); - var responses = s.ComputeResponses(NewEnvelope(NetworkBattleUri.Retire)); - var (env, noStock) = responses.Single(); - Assert.That(env.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish)); - Assert.That(noStock, Is.True); + var (s, a, b) = NewSession(); + var botFrame = ScriptedLifecycle.BuildOpponentJudge(); + var routes = s.ComputeFrames(b, botFrame); + + Assert.That(routes.Count, Is.EqualTo(1)); + Assert.That(routes[0].Target, Is.SameAs(a)); + Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.Judge)); + } + + [Test] + public void ScriptedBot_emitted_TurnEnd_forwards_to_real() + { + // TurnEnd from the bot is also one of the burst frames. The case is handled + // by the TurnEnd-from-scripted arm (bot ViewerId matches FakeOpponentViewerId). + var (s, a, b) = NewSession(); + s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork)); + s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitBattle)); + s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Loaded)); + s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Swap)); + // Drive player TurnEnd first (transitions Phase if needed) — but TurnEnd from bot + // arrives in Phase AfterReady too. The bot's TurnEnd is routed via the dispatch + // arm that forwards any frame from the FakeOpponentViewerId participant. + + var botFrame = ScriptedLifecycle.BuildOpponentTurnEnd(); + var routes = s.ComputeFrames(b, botFrame); + + Assert.That(routes.Count, Is.EqualTo(1)); + Assert.That(routes[0].Target, Is.SameAs(a)); + Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.TurnEnd)); + } + + [Test] + public void Retire_pushes_BattleFinish_no_contest_terminates() + { + var (s, a, _) = NewSession(); + var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Retire)); + + Assert.That(routes.Count, Is.EqualTo(1)); + Assert.That(routes[0].Target, Is.SameAs(a)); + Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish)); + Assert.That(routes[0].NoStock, Is.True); Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.Terminal)); } [Test] - public void Kill_PushesBattleFinishNoContest_TransitionsToTerminal() + public void Kill_pushes_BattleFinish_no_contest_terminates() { - var s = NewSession(); - var responses = s.ComputeResponses(NewEnvelope(NetworkBattleUri.Kill)); - var (env, noStock) = responses.Single(); - Assert.That(env.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish)); - Assert.That(noStock, Is.True); + var (s, a, _) = NewSession(); + var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Kill)); + + Assert.That(routes.Count, Is.EqualTo(1)); + Assert.That(routes[0].Target, Is.SameAs(a)); + Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish)); + Assert.That(routes[0].NoStock, Is.True); Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.Terminal)); } [Test] - public void Swap_ArrivingBeforeLoaded_ProducesNoResponseAndDoesNotAdvancePhase() + public void OutOfOrder_dispatch_returns_empty_and_does_not_advance_phase() { - var s = NewSession(); - // Skip Loaded — fire Swap straight out of AwaitingInitNetwork. - var responses = s.ComputeResponses(NewEnvelope(NetworkBattleUri.Swap)); - Assert.That(responses, Is.Empty); + var (s, a, _) = NewSession(); + var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Swap)); + + Assert.That(routes, Is.Empty); Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.AwaitingInitNetwork)); } + + private static (BattleSession, FakeParticipant, FakeParticipant) NewSession() + { + var a = new FakeParticipant(viewerId: 1, FixtureCtx()); + var b = new FakeParticipant(viewerId: ScriptedLifecycle.FakeOpponentViewerId, ScriptedBotContext()); + var s = new BattleSession("bid-1", BattleType.Scripted, a, b, NullLogger.Instance); + return (s, a, b); + } + + private static MatchContext FixtureCtx() => new( + SelfDeckCardIds: Enumerable.Range(1, 30).Select(_ => 100_011_010L).ToList(), + ClassId: "1", CharaId: "1", CardMasterName: "card_master_node_10015", + CountryCode: "KOR", UserName: "Player", SleeveId: "3000011", + EmblemId: "701441011", DegreeId: "300003", FieldId: 43, IsOfficial: 0, + BattleType: 11); + + private static MatchContext ScriptedBotContext() => new( + SelfDeckCardIds: Array.Empty(), + ClassId: "0", CharaId: "0", CardMasterName: "card_master_node_10015", + CountryCode: "JPN", UserName: "Opponent", SleeveId: "704141010", + EmblemId: "400001100", DegreeId: "120027", FieldId: 5, IsOfficial: 0, + BattleType: 0); + + private static MsgEnvelope NewEnvelope(NetworkBattleUri uri) => + new(uri, ViewerId: 1, Uuid: "u", Bid: null, Try: 0, + Cat: EmitCategory.Battle, PubSeq: null, PlaySeq: null, + Body: new RawBody(new Dictionary())); + + /// Data-only IBattleParticipant stub for dispatch tests. PushAsync/RunAsync + /// are no-ops; FrameEmitted exists but is never invoked by the test. + private sealed class FakeParticipant : IBattleParticipant + { + public long ViewerId { get; } + public MatchContext Context { get; } + public event Func? FrameEmitted; + public FakeParticipant(long viewerId, MatchContext context) { ViewerId = viewerId; Context = context; } + public Task PushAsync(MsgEnvelope env, bool noStock, CancellationToken ct) => Task.CompletedTask; + public Task RunAsync(CancellationToken ct) => Task.CompletedTask; + public Task TerminateAsync(BattleFinishReason reason) => Task.CompletedTask; + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + private void Touch() => FrameEmitted?.Invoke(null!, default); + } } diff --git a/SVSim.UnitTests/BattleNode/Sessions/BattleSessionPumpTests.cs b/SVSim.UnitTests/BattleNode/Sessions/BattleSessionPumpTests.cs deleted file mode 100644 index c173caa..0000000 --- a/SVSim.UnitTests/BattleNode/Sessions/BattleSessionPumpTests.cs +++ /dev/null @@ -1,238 +0,0 @@ -using System.Net.WebSockets; -using System.Reflection; -using System.Text; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; -using NUnit.Framework; -using SVSim.BattleNode.Bridge; -using SVSim.BattleNode.Protocol; -using SVSim.BattleNode.Sessions; -using SVSim.BattleNode.Wire; -using SVSim.UnitTests.BattleNode.Infrastructure; - -namespace SVSim.UnitTests.BattleNode.Sessions; - -[TestFixture] -public class BattleSessionPumpTests -{ - // ---- T1a: structural contract ---- - - [Test] - public void DispatchSocketIo_ReturnsTask_NotAsyncVoid() - { - // Regression for the M3 fix: async void hides exceptions and lets two dispatches run - // concurrently. The fix is to return Task and await it in the read loop. This test - // locks the structural contract; if anyone reverts to async void, this fails before - // the behavioral tests do. - var method = typeof(BattleSession).GetMethod( - "DispatchSocketIo", - BindingFlags.NonPublic | BindingFlags.Instance); - Assert.That(method, Is.Not.Null, "DispatchSocketIo method must exist"); - Assert.That(method!.ReturnType, Is.EqualTo(typeof(Task)), - "DispatchSocketIo must return Task — async void breaks ordering + exception flow."); - } - - // ---- T1b: in-order dispatch ---- - - [Test] - [Timeout(10000)] - public async Task RunAsync_ProcessesTwoMessages_SendsResponsesInOrder() - { - var ws = new TestWebSocket(); - var session = new BattleSession( - ws: ws, battleId: "bid-pump", viewerId: 906243102, context: FixtureCtx(), - log: NullLogger.Instance); - - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(8)); - var runTask = session.RunAsync(cts.Token); - - // Eat the EIO3 open handshake (first text send from the session). - await WaitForSendCountAsync(ws, atLeast: 1, cts.Token); - - // Inbound 1: InitNetwork (expect ack + InitNetwork synchronize push). - await EnqueueMsgFrameAsync(ws, NetworkBattleUri.InitNetwork, pubSeq: 1, ackId: 1, - cat: EmitCategory.General); - - // Inbound 2: InitBattle (expect ack + Matched synchronize push). - await EnqueueMsgFrameAsync(ws, NetworkBattleUri.InitBattle, pubSeq: 2, ackId: 2, - cat: EmitCategory.Matching); - - ws.CompleteIncoming(); - await runTask; - - // Each inbound msg produces a SIO ack (text frame). With serial dispatch the - // two acks must come out in the same order as the inbound frames; concurrent - // dispatch could reorder them. This is best-effort smoke — it can pass even - // under concurrent dispatch if races happen to favor msg-1 — but it catches - // common reorderings. T1a (reflection on DispatchSocketIo's return type) is - // the structural lock. - var sends = ws.Sends.ToList(); - var ackTextIndices = sends - .Select((s, i) => (s, i)) - .Where(t => t.s.Type == WebSocketMessageType.Text && IsSioAckText(t.s.Payload)) - .Select(t => t.i) - .ToList(); - Assert.That(ackTextIndices.Count, Is.EqualTo(2), "Expected exactly two SIO acks."); - Assert.That(ackTextIndices[0], Is.LessThan(ackTextIndices[1]), - "Ack for msg-1 must precede ack for msg-2 — proves dispatches don't reorder under serial await."); - } - - // ---- T2: cancellation through send ---- - - [Test] - [Timeout(10000)] - public async Task EncodeAndSendAsync_CtCancelledDuringBlockedSend_PropagatesOperationCanceled() - { - var ws = new TestWebSocket(); - var session = new BattleSession( - ws: ws, battleId: "bid-cancel", viewerId: 906243102, context: FixtureCtx(), - log: NullLogger.Instance); - - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(8)); - var runTask = session.RunAsync(cts.Token); - - await WaitForSendCountAsync(ws, atLeast: 1, cts.Token); // EIO open handshake - - // Block the next send so the session is suspended mid-write. - ws.BlockNextSend(); - - // InitNetwork triggers an ack via SendSioAckAsync — that's the send that will block. - await EnqueueMsgFrameAsync(ws, NetworkBattleUri.InitNetwork, pubSeq: 1, ackId: 1, - cat: EmitCategory.General); - - // Give the pump time to reach the blocked send. - await Task.Delay(100, CancellationToken.None); - - // Cancel the session CT. The blocked send's await on the gate has registered for the - // CT and will throw OperationCanceledException. - cts.Cancel(); - - // RunAsync awaits the dispatch, which awaits the gated send. Cancelling the gate - // surfaces as OCE inside HandleMsgEventAsync's try/catch (it logs error and returns), - // then the next ReadCompleteMessageAsync sees the cancellation and exits. - await runTask; - // No assertion on exception type — the inner try/catch swallows it. The point of the - // test is that RunAsync TERMINATES rather than hanging indefinitely on the blocked send. - // If _sessionCt weren't threaded, the send would still be blocked on the gate and this - // test would timeout. - Assert.That(runTask.IsCompletedSuccessfully, Is.True, - "RunAsync must terminate after _sessionCt is cancelled, not hang on the blocked send."); - } - - // ---- T3: full-pump Md5 regression ---- - - [Test] - [Timeout(10000)] - public async Task PubSeqExceedingIntMax_ClipsAndDoesNotKillSession() - { - var ws = new TestWebSocket(); - var logCapture = new CapturingLogger(); - var session = new BattleSession( - ws: ws, battleId: "bid-clip", viewerId: 906243102, context: FixtureCtx(), log: logCapture); - - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(8)); - var runTask = session.RunAsync(cts.Token); - - await WaitForSendCountAsync(ws, atLeast: 1, cts.Token); - - await EnqueueMsgFrameAsync(ws, NetworkBattleUri.InitNetwork, - pubSeq: (long)int.MaxValue + 1L, ackId: 1, cat: EmitCategory.General); - - ws.CompleteIncoming(); - await runTask; - - // Expect: at least one warning logged about clipping, AND the ack send happened. - Assert.That(logCapture.WarningCount, Is.GreaterThanOrEqualTo(1), - "Clip path must log a warning."); - Assert.That(logCapture.Warnings.Any(m => m.Contains("clipping")), - Is.True, $"Expected a 'clipping' warning. Got: {string.Join(" | ", logCapture.Warnings)}"); - Assert.That(ws.Sends.Any(f => f.Type == WebSocketMessageType.Text && IsSioAckText(f.Payload)), - Is.True, "Ack must still be sent after clipping."); - } - - // ---- helpers ---- - - private static async Task WaitForSendCountAsync(TestWebSocket ws, int atLeast, CancellationToken ct) - { - var sw = System.Diagnostics.Stopwatch.StartNew(); - while (ws.Sends.Count < atLeast) - { - if (sw.ElapsedMilliseconds > 5000) - throw new TimeoutException($"WS did not produce {atLeast} sends within 5s."); - await Task.Delay(10, ct); - } - } - - private static bool IsSioAckText(byte[] payload) - { - // SIO ack text frame starts with EIO Message digit '4' + SIO Ack digit '3'. - if (payload.Length < 2) return false; - return payload[0] == (byte)'4' && payload[1] == (byte)'3'; - } - - /// - /// Encode and enqueue a synthetic msg event frame for the pump to receive. - /// Pump shape: EIO text "4{sio-text}", then a single binary attachment containing the - /// msgpack-encoded encrypted payload. - /// - private static async Task EnqueueMsgFrameAsync( - TestWebSocket ws, NetworkBattleUri uri, long pubSeq, int ackId, EmitCategory cat) - { - var key = MakeKey(); - var env = new MsgEnvelope( - uri, ViewerId: 906243102, Uuid: "udid-test", Bid: null, Try: 0, Cat: cat, - PubSeq: pubSeq, PlaySeq: null, - Body: new RawBody(new Dictionary())); - var encryptedBytes = MsgPayloadCodec.Encode(env, key); - - // SIO BinaryEvent with one attachment, name "msg", with the ack id. - var sio = SocketIoFrame.BinaryEventWithAttachments(eventName: "msg", attachments: new[] { encryptedBytes }); - var (sioTextOriginal, bins) = sio.Encode(); - // sioTextOriginal looks like "51-[\"msg\",{\"_placeholder\":true,\"num\":0}]". - // Splice ackId before the '['. - var bracketIdx = sioTextOriginal.IndexOf('['); - var sioTextWithAck = sioTextOriginal.Substring(0, bracketIdx) + ackId + sioTextOriginal.Substring(bracketIdx); - - var eioText = $"{(int)EngineIoPacketType.Message}{sioTextWithAck}"; - ws.EnqueueIncoming(Encoding.UTF8.GetBytes(eioText), WebSocketMessageType.Text); - // EIO3 prefixes binary frames with the Message packet-type byte. - var prefixed = new byte[bins[0].Length + 1]; - prefixed[0] = (byte)EngineIoPacketType.Message; - Buffer.BlockCopy(bins[0], 0, prefixed, 1, bins[0].Length); - ws.EnqueueIncoming(prefixed, WebSocketMessageType.Binary); - await Task.Yield(); - } - - private static string MakeKey() - { - var seq = 0; - return NodeCrypto.GenerateKey(() => (seq++ * 7) % 16); - } - - private static MatchContext FixtureCtx() => new( - SelfDeckCardIds: Enumerable.Range(1, 30).Select(i => 100_011_010L).ToList(), - ClassId: "1", CharaId: "1", CardMasterName: "card_master_node_10015", - CountryCode: "KOR", UserName: "Player", SleeveId: "3000011", - EmblemId: "701441011", DegreeId: "300003", FieldId: 43, IsOfficial: 0, - BattleType: 11); - - private sealed class CapturingLogger : ILogger - { - public List Warnings { get; } = new(); - public int WarningCount => Warnings.Count; - - public IDisposable BeginScope(TState state) where TState : notnull => NullDisposable.Instance; - public bool IsEnabled(LogLevel logLevel) => true; - public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, - Func formatter) - { - if (logLevel == LogLevel.Warning) Warnings.Add(formatter(state, exception)); - } - } - - private sealed class NullDisposable : IDisposable - { - public static readonly NullDisposable Instance = new(); - public void Dispose() { } - } -} diff --git a/SVSim.UnitTests/BattleNode/Sessions/BattleSessionV2DispatchTests.cs b/SVSim.UnitTests/BattleNode/Sessions/BattleSessionV2DispatchTests.cs deleted file mode 100644 index 1bdabf8..0000000 --- a/SVSim.UnitTests/BattleNode/Sessions/BattleSessionV2DispatchTests.cs +++ /dev/null @@ -1,209 +0,0 @@ -using Microsoft.Extensions.Logging.Abstractions; -using NUnit.Framework; -using SVSim.BattleNode.Bridge; -using SVSim.BattleNode.Lifecycle; -using SVSim.BattleNode.Protocol; -using SVSim.BattleNode.Protocol.Bodies; -using SVSim.BattleNode.Sessions; - -namespace SVSim.UnitTests.BattleNode.Sessions; - -[TestFixture] -public class BattleSessionV2DispatchTests -{ - [Test] - public void InitNetwork_acks_to_sender_transitions_to_AwaitingInitBattle() - { - var (s, a, b) = NewSession(); - var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork)); - - Assert.That(routes.Count, Is.EqualTo(1)); - Assert.That(routes[0].Target, Is.SameAs(a)); - Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.InitNetwork)); - Assert.That(routes[0].NoStock, Is.True); - Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.AwaitingInitBattle)); - } - - [Test] - public void InitBattle_pushes_Matched_to_sender_only() - { - var (s, a, b) = NewSession(); - s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork)); - var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitBattle)); - - Assert.That(routes.Count, Is.EqualTo(1)); - Assert.That(routes[0].Target, Is.SameAs(a)); - Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.Matched)); - Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.AwaitingLoaded)); - } - - [Test] - public void Loaded_pushes_BattleStart_then_Deal_to_sender() - { - var (s, a, b) = NewSession(); - s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork)); - s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitBattle)); - var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Loaded)); - - Assert.That(routes.Select(r => r.Frame.Uri), - Is.EqualTo(new[] { NetworkBattleUri.BattleStart, NetworkBattleUri.Deal })); - Assert.That(routes.All(r => ReferenceEquals(r.Target, a)), Is.True); - Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.AwaitingSwap)); - } - - [Test] - public void Swap_pushes_SwapResponse_then_Ready_to_sender() - { - var (s, a, b) = NewSession(); - s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork)); - s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitBattle)); - s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Loaded)); - var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Swap)); - - Assert.That(routes.Select(r => r.Frame.Uri), - Is.EqualTo(new[] { NetworkBattleUri.Swap, NetworkBattleUri.Ready })); - Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.AfterReady)); - } - - [Test] - public void TurnEnd_from_real_forwards_to_other_participant() - { - var (s, a, b) = NewSession(); - s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork)); - s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitBattle)); - s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Loaded)); - s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Swap)); - var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.TurnEnd)); - - Assert.That(routes.Count, Is.EqualTo(1)); - Assert.That(routes[0].Target, Is.SameAs(b)); - Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.TurnEnd)); - Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.AfterReady)); - } - - [Test] - public void ScriptedBot_emitted_OpponentTurnStart_forwards_to_real() - { - var (s, a, b) = NewSession(); - // Bot emits a TurnStart frame (carrying ViewerId == FakeOpponentViewerId per the - // ScriptedBotParticipant impl). Session should route it to the real participant. - var botFrame = ScriptedLifecycle.BuildOpponentTurnStart(); - var routes = s.ComputeFrames(b, botFrame); - - Assert.That(routes.Count, Is.EqualTo(1)); - Assert.That(routes[0].Target, Is.SameAs(a)); - Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.TurnStart)); - } - - [Test] - public void ScriptedBot_emitted_Judge_forwards_to_real() - { - var (s, a, b) = NewSession(); - var botFrame = ScriptedLifecycle.BuildOpponentJudge(); - var routes = s.ComputeFrames(b, botFrame); - - Assert.That(routes.Count, Is.EqualTo(1)); - Assert.That(routes[0].Target, Is.SameAs(a)); - Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.Judge)); - } - - [Test] - public void ScriptedBot_emitted_TurnEnd_forwards_to_real() - { - // TurnEnd from the bot is also one of the burst frames. The case is handled - // by the TurnEnd-from-scripted arm (bot ViewerId matches FakeOpponentViewerId). - var (s, a, b) = NewSession(); - s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork)); - s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitBattle)); - s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Loaded)); - s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Swap)); - // Drive player TurnEnd first (transitions Phase if needed) — but TurnEnd from bot - // arrives in Phase AfterReady too. The bot's TurnEnd is routed via the dispatch - // arm that forwards any frame from the FakeOpponentViewerId participant. - - var botFrame = ScriptedLifecycle.BuildOpponentTurnEnd(); - var routes = s.ComputeFrames(b, botFrame); - - Assert.That(routes.Count, Is.EqualTo(1)); - Assert.That(routes[0].Target, Is.SameAs(a)); - Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.TurnEnd)); - } - - [Test] - public void Retire_pushes_BattleFinish_no_contest_terminates() - { - var (s, a, _) = NewSession(); - var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Retire)); - - Assert.That(routes.Count, Is.EqualTo(1)); - Assert.That(routes[0].Target, Is.SameAs(a)); - Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish)); - Assert.That(routes[0].NoStock, Is.True); - Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.Terminal)); - } - - [Test] - public void Kill_pushes_BattleFinish_no_contest_terminates() - { - var (s, a, _) = NewSession(); - var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Kill)); - - Assert.That(routes.Count, Is.EqualTo(1)); - Assert.That(routes[0].Target, Is.SameAs(a)); - Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish)); - Assert.That(routes[0].NoStock, Is.True); - Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.Terminal)); - } - - [Test] - public void OutOfOrder_dispatch_returns_empty_and_does_not_advance_phase() - { - var (s, a, _) = NewSession(); - var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Swap)); - - Assert.That(routes, Is.Empty); - Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.AwaitingInitNetwork)); - } - - private static (BattleSessionV2, FakeParticipant, FakeParticipant) NewSession() - { - var a = new FakeParticipant(viewerId: 1, FixtureCtx()); - var b = new FakeParticipant(viewerId: ScriptedLifecycle.FakeOpponentViewerId, ScriptedBotContext()); - var s = new BattleSessionV2("bid-1", BattleType.Scripted, a, b, NullLogger.Instance); - return (s, a, b); - } - - private static MatchContext FixtureCtx() => new( - SelfDeckCardIds: Enumerable.Range(1, 30).Select(_ => 100_011_010L).ToList(), - ClassId: "1", CharaId: "1", CardMasterName: "card_master_node_10015", - CountryCode: "KOR", UserName: "Player", SleeveId: "3000011", - EmblemId: "701441011", DegreeId: "300003", FieldId: 43, IsOfficial: 0, - BattleType: 11); - - private static MatchContext ScriptedBotContext() => new( - SelfDeckCardIds: Array.Empty(), - ClassId: "0", CharaId: "0", CardMasterName: "card_master_node_10015", - CountryCode: "JPN", UserName: "Opponent", SleeveId: "704141010", - EmblemId: "400001100", DegreeId: "120027", FieldId: 5, IsOfficial: 0, - BattleType: 0); - - private static MsgEnvelope NewEnvelope(NetworkBattleUri uri) => - new(uri, ViewerId: 1, Uuid: "u", Bid: null, Try: 0, - Cat: EmitCategory.Battle, PubSeq: null, PlaySeq: null, - Body: new RawBody(new Dictionary())); - - /// Data-only IBattleParticipant stub for dispatch tests. PushAsync/RunAsync - /// are no-ops; FrameEmitted exists but is never invoked by the test. - private sealed class FakeParticipant : IBattleParticipant - { - public long ViewerId { get; } - public MatchContext Context { get; } - public event Func? FrameEmitted; - public FakeParticipant(long viewerId, MatchContext context) { ViewerId = viewerId; Context = context; } - public Task PushAsync(MsgEnvelope env, bool noStock, CancellationToken ct) => Task.CompletedTask; - public Task RunAsync(CancellationToken ct) => Task.CompletedTask; - public Task TerminateAsync(BattleFinishReason reason) => Task.CompletedTask; - public ValueTask DisposeAsync() => ValueTask.CompletedTask; - private void Touch() => FrameEmitted?.Invoke(null!, default); - } -} diff --git a/SVSim.UnitTests/BattleNode/Sessions/ClipAckArgTests.cs b/SVSim.UnitTests/BattleNode/Sessions/ClipAckArgTests.cs deleted file mode 100644 index 2e1d964..0000000 --- a/SVSim.UnitTests/BattleNode/Sessions/ClipAckArgTests.cs +++ /dev/null @@ -1,44 +0,0 @@ -using Microsoft.Extensions.Logging.Abstractions; -using NUnit.Framework; -using SVSim.BattleNode.Sessions; - -namespace SVSim.UnitTests.BattleNode.Sessions; - -[TestFixture] -public class ClipAckArgTests -{ - [Test] - public void InRange_ReturnsArgUnchanged() - { - var result = BattleSession.ClipAckArg(42L, NullLogger.Instance, battleId: "b"); - Assert.That(result, Is.EqualTo(42)); - } - - [Test] - public void AboveIntMax_ClipsToIntMaxValue() - { - var result = BattleSession.ClipAckArg((long)int.MaxValue + 1L, NullLogger.Instance, battleId: "b"); - Assert.That(result, Is.EqualTo(int.MaxValue)); - } - - [Test] - public void BelowIntMin_ClipsToIntMinValue() - { - var result = BattleSession.ClipAckArg((long)int.MinValue - 1L, NullLogger.Instance, battleId: "b"); - Assert.That(result, Is.EqualTo(int.MinValue)); - } - - [Test] - public void AtIntMaxBoundary_ReturnsIntMaxValue() - { - var result = BattleSession.ClipAckArg((long)int.MaxValue, NullLogger.Instance, battleId: "b"); - Assert.That(result, Is.EqualTo(int.MaxValue)); - } - - [Test] - public void AtIntMinBoundary_ReturnsIntMinValue() - { - var result = BattleSession.ClipAckArg((long)int.MinValue, NullLogger.Instance, battleId: "b"); - Assert.That(result, Is.EqualTo(int.MinValue)); - } -} diff --git a/SVSim.UnitTests/BattleNode/Sessions/Participants/RealParticipantTests.cs b/SVSim.UnitTests/BattleNode/Sessions/Participants/RealParticipantTests.cs index cd39373..2613657 100644 --- a/SVSim.UnitTests/BattleNode/Sessions/Participants/RealParticipantTests.cs +++ b/SVSim.UnitTests/BattleNode/Sessions/Participants/RealParticipantTests.cs @@ -55,6 +55,41 @@ public class RealParticipantTests Assert.That(p.Context, Is.SameAs(ctx)); } + [Test] + public void ClipAckArg_InRange_ReturnsArgUnchanged() + { + var result = RealParticipant.ClipAckArg(42L, NullLogger.Instance, viewerId: 1); + Assert.That(result, Is.EqualTo(42)); + } + + [Test] + public void ClipAckArg_AboveIntMax_ClipsToIntMaxValue() + { + var result = RealParticipant.ClipAckArg((long)int.MaxValue + 1L, NullLogger.Instance, viewerId: 1); + Assert.That(result, Is.EqualTo(int.MaxValue)); + } + + [Test] + public void ClipAckArg_BelowIntMin_ClipsToIntMinValue() + { + var result = RealParticipant.ClipAckArg((long)int.MinValue - 1L, NullLogger.Instance, viewerId: 1); + Assert.That(result, Is.EqualTo(int.MinValue)); + } + + [Test] + public void ClipAckArg_AtIntMaxBoundary_ReturnsIntMaxValue() + { + var result = RealParticipant.ClipAckArg((long)int.MaxValue, NullLogger.Instance, viewerId: 1); + Assert.That(result, Is.EqualTo(int.MaxValue)); + } + + [Test] + public void ClipAckArg_AtIntMinBoundary_ReturnsIntMinValue() + { + var result = RealParticipant.ClipAckArg((long)int.MinValue, NullLogger.Instance, viewerId: 1); + Assert.That(result, Is.EqualTo(int.MinValue)); + } + private static MatchContext FixtureCtx() => new( SelfDeckCardIds: Enumerable.Range(1, 30).Select(_ => 100_011_010L).ToList(), ClassId: "1", CharaId: "1", CardMasterName: "card_master_node_10015",