using System.Net.WebSockets; using System.Text; using System.Text.Json; using MessagePack; using Microsoft.Extensions.Logging.Abstractions; using NUnit.Framework; using SVSim.BattleNode.Bridge; using SVSim.BattleNode.Protocol; using SVSim.BattleNode.Sessions; using SVSim.BattleNode.Sessions.Participants; using SVSim.BattleNode.Wire; using SVSim.UnitTests.BattleNode.Infrastructure; namespace SVSim.UnitTests.BattleNode.Sessions.Participants; /// /// Regression tests for the "hand" SIO event handler. The active bug at /// docs/audits/battle-node-sio-events-2026-06-02.md ยง"Active bug" was: /// client-stocked SELECT_SKILL_URI / SLIDE_OBJECT_URI hand emits arrive /// with an ack-id; without a server ack, the client's stockEmitMessageMgr deadlocks /// behind them and every subsequent emit (PlayActions, TurnEndActions, TurnEnd) is queued /// but never transmitted. These tests drive a hand frame through the WS read loop and /// inspect the outbound text-frame queue for the SIO ack. /// [TestFixture] public class RealParticipantHandEventTests { // Any 32-char ASCII string is a valid AES key for NodeCrypto โ€” DecryptForNode reads the // key from the first 32 chars of the encrypted blob. private const string TestKey = "abcdefghijklmnopqrstuvwxyz012345"; [Test] public async Task Stocked_hand_event_acks_with_body_pubSeq() { var ws = new TestWebSocket(); var p = new RealParticipant(ws, viewerId: 906_243_102L, FixtureCtx(), NullLogger.Instance); // Client-shape hand body for a SELECT_SKILL emit: StockHandData[0] = uri_int // (HAND_URI_TYPE.SELECT_SKILL_URI = 2), top-level "try" + "pubSeq" added by // EmitMsgUriPack. The handler reads top-level "pubSeq" โ€” body contents otherwise // don't matter for ack semantics. const long expectedPubSeq = 42L; var body = $"{{\"StockHandData\":[2,906243102,\"u\",1,0,{expectedPubSeq}],\"try\":0,\"pubSeq\":{expectedPubSeq}}}"; EnqueueHandFrame(ws, ackId: 26, body: body); ws.CompleteIncoming(); await p.RunAsync(CancellationToken.None); var ackFrame = FindAckFrame(ws, ackId: 26); Assert.That(ackFrame, Is.Not.Null, $"Expected an SIO Ack frame for ackId=26 in outbound sends; got: [{string.Join(", ", AllTextSends(ws))}]"); Assert.That(ackFrame, Does.Contain($"[{expectedPubSeq}]"), "Ack arg must echo the body's pubSeq so the client's stockEmitMessageMgr.GetSelectData lookup succeeds."); } [Test] public async Task Hand_event_without_ackId_is_swallowed_silently_no_ack_sent() { // Fire-and-forget hand emits (TOUCH_URI, SELECT_OBJECT_URI, TURN_END_READY_URI) arrive // without an ack-id and don't block the client's emit queue. We should swallow them // without trying to decode or ack. var ws = new TestWebSocket(); var p = new RealParticipant(ws, viewerId: 1, FixtureCtx(), NullLogger.Instance); var body = "{\"StockHandData\":[1,1,\"u\",0],\"try\":0}"; EnqueueHandFrame(ws, ackId: null, body: body); ws.CompleteIncoming(); await p.RunAsync(CancellationToken.None); // Only the EIO Open handshake should be in Sends; no Ack frame. var ackFrames = AllTextSends(ws).Where(s => s.StartsWith("43")).ToList(); Assert.That(ackFrames, Is.Empty, $"No-ack-id hand frame must not produce an Ack; got: [{string.Join(", ", ackFrames)}]"); } [Test] public async Task Hand_event_with_missing_pubSeq_falls_back_to_ack_arg_0() { // If a stocked hand frame ever arrives without a pubSeq, we still ack so the // client doesn't softlock โ€” but with arg=0 (the client's GetSelectData lookup // will miss and OnAck fires with null selectData, which is the same path as a // normal cache-miss; not great, but not a deadlock). var ws = new TestWebSocket(); var p = new RealParticipant(ws, viewerId: 1, FixtureCtx(), NullLogger.Instance); var body = "{\"StockHandData\":[2,1,\"u\"],\"try\":0}"; // no pubSeq EnqueueHandFrame(ws, ackId: 99, body: body); ws.CompleteIncoming(); await p.RunAsync(CancellationToken.None); var ackFrame = FindAckFrame(ws, ackId: 99); Assert.That(ackFrame, Is.Not.Null, "Missing-pubSeq fallback should still ack (arg=0), not silently swallow."); Assert.That(ackFrame, Does.Contain("[0]"), "Fallback ack arg should be 0."); } // ----------------------------------------------------------------------- // Helpers // ----------------------------------------------------------------------- /// /// Enqueue an SIO BinaryEvent("hand", {placeholder}) text frame followed by its single /// binary attachment (NodeCrypto-encrypted msgpack-string of ). /// Wire format mirrors what EmitFrontStockData:717-720 emits. /// private static void EnqueueHandFrame(TestWebSocket ws, int? ackId, string body) { // Text frame: 4 (EIO Message) + 5 (SIO BinaryEvent) + 1- (1 attachment) + ackId + json var ackPart = ackId.HasValue ? ackId.Value.ToString() : ""; var text = $"451-{ackPart}[\"hand\",{{\"_placeholder\":true,\"num\":0}}]"; ws.EnqueueIncoming(Encoding.UTF8.GetBytes(text), WebSocketMessageType.Text); // Binary attachment: EIO Message prefix (0x04) + msgpack-string(NodeCrypto.Encrypt(json)). var encrypted = NodeCrypto.EncryptForNode(body, TestKey); var msgpackBytes = MessagePackSerializer.Serialize(encrypted); var prefixed = new byte[msgpackBytes.Length + 1]; prefixed[0] = (byte)EngineIoPacketType.Message; Buffer.BlockCopy(msgpackBytes, 0, prefixed, 1, msgpackBytes.Length); ws.EnqueueIncoming(prefixed, WebSocketMessageType.Binary); } private static IEnumerable AllTextSends(TestWebSocket ws) => ws.Sends .Where(f => f.Type == WebSocketMessageType.Text) .Select(f => Encoding.UTF8.GetString(f.Payload)); /// /// SIO Ack wire form: EIO Message (4) + SIO Ack (3) + ackId + [arg]. Filter the text /// sends for that shape. /// private static string? FindAckFrame(TestWebSocket ws, int ackId) => AllTextSends(ws).FirstOrDefault(s => s.StartsWith($"43{ackId}[")); 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); }