diff --git a/SVSim.BattleNode/Wire/SocketIoFrame.cs b/SVSim.BattleNode/Wire/SocketIoFrame.cs new file mode 100644 index 0000000..7001598 --- /dev/null +++ b/SVSim.BattleNode/Wire/SocketIoFrame.cs @@ -0,0 +1,171 @@ +using System.Text; +using System.Text.Json; + +namespace SVSim.BattleNode.Wire; + +/// +/// Socket.IO v2 packet. Wire form: <type><N>-<ackId?>[json-args] where +/// <N>- appears only on binary types (5/6). For binary events/acks, the JSON contains +/// placeholders {"_placeholder":true,"num":N} that index into . +/// +public sealed class SocketIoFrame +{ + public SocketIoPacketType Type { get; } + public int? AckId { get; } + public int AttachmentCount { get; } + public string? EventName { get; } + public JsonElement[] RawArgs { get; } + public IReadOnlyList BinaryAttachments { get; } + + public SocketIoFrame( + SocketIoPacketType type, + int? ackId, + int attachmentCount, + string? eventName, + JsonElement[] rawArgs, + IReadOnlyList binaryAttachments) + { + Type = type; + AckId = ackId; + AttachmentCount = attachmentCount; + EventName = eventName; + RawArgs = rawArgs; + BinaryAttachments = binaryAttachments; + } + + /// + /// Parse the text portion of a SIO frame. For binary events the attachments arrive as separate + /// WS frames after the text — the caller wires them up via . + /// + public static SocketIoFrame Parse(string raw) + { + if (string.IsNullOrEmpty(raw)) + throw new ArgumentException("Empty SIO payload", nameof(raw)); + + var type = (SocketIoPacketType)(raw[0] - '0'); + var cursor = 1; + + var attachmentCount = 0; + if (type is SocketIoPacketType.BinaryEvent or SocketIoPacketType.BinaryAck) + { + var dashIdx = raw.IndexOf('-', cursor); + if (dashIdx < 0) + throw new ArgumentException("Binary frame missing '-' separator", nameof(raw)); + if (!int.TryParse(raw.AsSpan(cursor, dashIdx - cursor), out attachmentCount)) + throw new ArgumentException("Binary frame attachment count not parseable", nameof(raw)); + cursor = dashIdx + 1; + } + + // Skip namespace (only present if a '/' starts here, terminated by ','). + if (cursor < raw.Length && raw[cursor] == '/') + { + var commaIdx = raw.IndexOf(',', cursor); + cursor = commaIdx >= 0 ? commaIdx + 1 : raw.Length; + } + + int? ackId = null; + if (cursor < raw.Length && char.IsDigit(raw[cursor])) + { + var start = cursor; + while (cursor < raw.Length && char.IsDigit(raw[cursor])) cursor++; + ackId = int.Parse(raw.AsSpan(start, cursor - start)); + } + + var argsJson = cursor < raw.Length ? raw.Substring(cursor) : string.Empty; + var allElements = string.IsNullOrEmpty(argsJson) + ? Array.Empty() + : JsonDocument.Parse(argsJson).RootElement.EnumerateArray().ToArray(); + + string? eventName = null; + JsonElement[] rawArgs; + if (type is SocketIoPacketType.Event or SocketIoPacketType.BinaryEvent && allElements.Length > 0) + { + eventName = allElements[0].GetString(); + // RawArgs excludes the leading event-name element so callers index args from 0. + rawArgs = allElements.Length > 1 ? allElements[1..] : Array.Empty(); + } + else + { + rawArgs = allElements; + } + + return new SocketIoFrame(type, ackId, attachmentCount, eventName, rawArgs, Array.Empty()); + } + + /// + /// Return a new frame with the given binary attachments attached. Throws if the count doesn't + /// match the header's declared attachment count. + /// + public SocketIoFrame WithAttachments(IReadOnlyList attachments) + { + if (attachments.Count != AttachmentCount) + throw new ArgumentException( + $"Attachment count mismatch: header says {AttachmentCount}, got {attachments.Count}"); + return new SocketIoFrame(Type, AckId, AttachmentCount, EventName, RawArgs, attachments); + } + + /// + /// Build a binary event frame for the given event name + binary attachments. + /// The JSON args become [eventName, {_placeholder:true,num:0}, {_placeholder:true,num:1}, ...]. + /// + public static SocketIoFrame BinaryEventWithAttachments(string eventName, IReadOnlyList attachments) + { + // Build the placeholder-only portion (without the event name) — event name is stored separately. + var sb = new StringBuilder(); + sb.Append('['); + for (var i = 0; i < attachments.Count; i++) + { + if (i > 0) sb.Append(','); + sb.Append("{\"_placeholder\":true,\"num\":").Append(i).Append('}'); + } + sb.Append(']'); + var args = JsonDocument.Parse(sb.ToString()).RootElement.EnumerateArray().ToArray(); + + return new SocketIoFrame( + SocketIoPacketType.BinaryEvent, + ackId: null, + attachmentCount: attachments.Count, + eventName: eventName, + rawArgs: args, + binaryAttachments: attachments); + } + + /// Build an ack response with a single int argument (the spec's pubSeq echo). + public static SocketIoFrame AckResponse(int ackId, int arg) + { + var args = JsonDocument.Parse($"[{arg}]").RootElement.EnumerateArray().ToArray(); + return new SocketIoFrame( + SocketIoPacketType.Ack, ackId, 0, null, args, Array.Empty()); + } + + /// + /// Encode to the wire form: (text payload, ordered list of binary attachments). + /// The caller is responsible for sending the text frame first then each binary attachment frame. + /// + public (string Text, IReadOnlyList Binaries) Encode() + { + var sb = new StringBuilder(); + sb.Append((int)Type); + if (Type is SocketIoPacketType.BinaryEvent or SocketIoPacketType.BinaryAck) + { + sb.Append(AttachmentCount).Append('-'); + } + if (AckId.HasValue) sb.Append(AckId.Value); + // Re-serialize args — for event/binary-event types, re-prepend the event name. + sb.Append('['); + var prependEventName = Type is SocketIoPacketType.Event or SocketIoPacketType.BinaryEvent + && EventName is not null; + if (prependEventName) + { + sb.Append('"').Append(EventName).Append('"'); + if (RawArgs.Length > 0) sb.Append(','); + } + for (var i = 0; i < RawArgs.Length; i++) + { + if (i > 0) sb.Append(','); + sb.Append(RawArgs[i].GetRawText()); + } + sb.Append(']'); + return (sb.ToString(), BinaryAttachments); + } +} diff --git a/SVSim.BattleNode/Wire/SocketIoPacketType.cs b/SVSim.BattleNode/Wire/SocketIoPacketType.cs new file mode 100644 index 0000000..11394ad --- /dev/null +++ b/SVSim.BattleNode/Wire/SocketIoPacketType.cs @@ -0,0 +1,12 @@ +namespace SVSim.BattleNode.Wire; + +public enum SocketIoPacketType +{ + Connect = 0, + Disconnect = 1, + Event = 2, + Ack = 3, + Error = 4, + BinaryEvent = 5, + BinaryAck = 6, +} diff --git a/SVSim.UnitTests/BattleNode/Wire/SocketIoFrameTests.cs b/SVSim.UnitTests/BattleNode/Wire/SocketIoFrameTests.cs new file mode 100644 index 0000000..841d731 --- /dev/null +++ b/SVSim.UnitTests/BattleNode/Wire/SocketIoFrameTests.cs @@ -0,0 +1,88 @@ +using System.Text.Json; +using NUnit.Framework; +using SVSim.BattleNode.Wire; + +namespace SVSim.UnitTests.BattleNode.Wire; + +[TestFixture] +public class SocketIoFrameTests +{ + [Test] + public void Parse_ConnectPacket_HasNoPayload() + { + var frame = SocketIoFrame.Parse("0"); + Assert.That(frame.Type, Is.EqualTo(SocketIoPacketType.Connect)); + Assert.That(frame.AckId, Is.Null); + Assert.That(frame.AttachmentCount, Is.EqualTo(0)); + } + + [Test] + public void Parse_EventWithAck_ExtractsAckIdAndArgs() + { + var frame = SocketIoFrame.Parse("27[\"msg\",42]"); + Assert.That(frame.Type, Is.EqualTo(SocketIoPacketType.Event)); + Assert.That(frame.AckId, Is.EqualTo(7)); + Assert.That(frame.EventName, Is.EqualTo("msg")); + Assert.That(frame.RawArgs[0].GetInt32(), Is.EqualTo(42)); + } + + [Test] + public void Parse_BinaryEvent_RecordsAttachmentCount_WithAttachments_Assembles() + { + var attachment = new byte[] { 0x01, 0x02, 0x03 }; + var header = SocketIoFrame.Parse("51-[\"msg\",{\"_placeholder\":true,\"num\":0}]"); + var assembled = header.WithAttachments(new[] { attachment }); + + Assert.That(assembled.Type, Is.EqualTo(SocketIoPacketType.BinaryEvent)); + Assert.That(assembled.AckId, Is.Null); + Assert.That(assembled.AttachmentCount, Is.EqualTo(1)); + Assert.That(assembled.BinaryAttachments[0], Is.EqualTo(attachment)); + Assert.That(assembled.EventName, Is.EqualTo("msg")); + } + + [Test] + public void Parse_BinaryEventWithAckId_ExtractsBoth() + { + var frame = SocketIoFrame.Parse("51-3[\"msg\",{\"_placeholder\":true,\"num\":0}]"); + Assert.That(frame.Type, Is.EqualTo(SocketIoPacketType.BinaryEvent)); + Assert.That(frame.AckId, Is.EqualTo(3)); + Assert.That(frame.AttachmentCount, Is.EqualTo(1)); + } + + [Test] + public void Parse_AckResponse_ExtractsIdAndIntArg() + { + var frame = SocketIoFrame.Parse("37[123]"); + Assert.That(frame.Type, Is.EqualTo(SocketIoPacketType.Ack)); + Assert.That(frame.AckId, Is.EqualTo(7)); + Assert.That(frame.RawArgs[0].GetInt32(), Is.EqualTo(123)); + } + + [Test] + public void Encode_BinaryEventWithAttachment_EmitsCountDashAndPlaceholder() + { + var attachment = new byte[] { 0xff }; + var frame = SocketIoFrame.BinaryEventWithAttachments("synchronize", new[] { attachment }); + + var (text, bins) = frame.Encode(); + Assert.That(text, Is.EqualTo("51-[\"synchronize\",{\"_placeholder\":true,\"num\":0}]")); + Assert.That(bins.Single(), Is.EqualTo(attachment)); + } + + [Test] + public void Encode_AckResponse_IsTypeIdAndArrayOfArgs() + { + var frame = SocketIoFrame.AckResponse(ackId: 7, arg: 123); + var (text, bins) = frame.Encode(); + + Assert.That(text, Is.EqualTo("37[123]")); + Assert.That(bins, Is.Empty); + } + + [Test] + public void WithAttachments_CountMismatch_Throws() + { + var header = SocketIoFrame.Parse("51-[\"msg\",{\"_placeholder\":true,\"num\":0}]"); + Assert.Throws(() => header.WithAttachments(Array.Empty())); + } +}