From 6c6664f011354478c9ee6f0c2eeb6d66143a793e Mon Sep 17 00:00:00 2001 From: gamer147 Date: Sun, 31 May 2026 21:34:11 -0400 Subject: [PATCH] feat(battle-node): EngineIoFrame parse/encode for EIO3 packets --- SVSim.BattleNode/Wire/EngineIoFrame.cs | 21 ++++++++ SVSim.BattleNode/Wire/EngineIoHandshake.cs | 22 ++++++++ SVSim.BattleNode/Wire/EngineIoPacketType.cs | 12 +++++ .../BattleNode/Wire/EngineIoFrameTests.cs | 51 +++++++++++++++++++ 4 files changed, 106 insertions(+) create mode 100644 SVSim.BattleNode/Wire/EngineIoFrame.cs create mode 100644 SVSim.BattleNode/Wire/EngineIoHandshake.cs create mode 100644 SVSim.BattleNode/Wire/EngineIoPacketType.cs create mode 100644 SVSim.UnitTests/BattleNode/Wire/EngineIoFrameTests.cs diff --git a/SVSim.BattleNode/Wire/EngineIoFrame.cs b/SVSim.BattleNode/Wire/EngineIoFrame.cs new file mode 100644 index 0000000..6bf88e7 --- /dev/null +++ b/SVSim.BattleNode/Wire/EngineIoFrame.cs @@ -0,0 +1,21 @@ +namespace SVSim.BattleNode.Wire; + +/// +/// Engine.IO v3 packet in WebSocket transport mode. Wire form: <digit><payload>. +/// +public sealed record EngineIoFrame(EngineIoPacketType Type, string Payload) +{ + public static EngineIoFrame Parse(string raw) + { + if (string.IsNullOrEmpty(raw)) + throw new ArgumentException("Empty EIO frame", nameof(raw)); + var typeChar = raw[0]; + if (typeChar < '0' || typeChar > '6') + throw new ArgumentException($"Invalid EIO type char '{typeChar}'", nameof(raw)); + var type = (EngineIoPacketType)(typeChar - '0'); + var payload = raw.Length > 1 ? raw.Substring(1) : string.Empty; + return new EngineIoFrame(type, payload); + } + + public string Encode() => $"{(int)Type}{Payload}"; +} diff --git a/SVSim.BattleNode/Wire/EngineIoHandshake.cs b/SVSim.BattleNode/Wire/EngineIoHandshake.cs new file mode 100644 index 0000000..8343728 --- /dev/null +++ b/SVSim.BattleNode/Wire/EngineIoHandshake.cs @@ -0,0 +1,22 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SVSim.BattleNode.Wire; + +/// +/// Payload of an EIO3 Open packet. Sent by the server to the client immediately after the WS upgrade. +/// +public sealed record EngineIoHandshake( + [property: JsonPropertyName("sid")] string Sid, + [property: JsonPropertyName("upgrades")] string[] Upgrades, + [property: JsonPropertyName("pingInterval")] int PingInterval, + [property: JsonPropertyName("pingTimeout")] int PingTimeout) +{ + // Wire-key casing here is bare camelCase — NOT EmulatedEntrypoint's snake_case policy. + private static readonly JsonSerializerOptions Options = new() + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + + public string ToJson() => JsonSerializer.Serialize(this, Options); +} diff --git a/SVSim.BattleNode/Wire/EngineIoPacketType.cs b/SVSim.BattleNode/Wire/EngineIoPacketType.cs new file mode 100644 index 0000000..5c64b7c --- /dev/null +++ b/SVSim.BattleNode/Wire/EngineIoPacketType.cs @@ -0,0 +1,12 @@ +namespace SVSim.BattleNode.Wire; + +public enum EngineIoPacketType +{ + Open = 0, + Close = 1, + Ping = 2, + Pong = 3, + Message = 4, + Upgrade = 5, + Noop = 6, +} diff --git a/SVSim.UnitTests/BattleNode/Wire/EngineIoFrameTests.cs b/SVSim.UnitTests/BattleNode/Wire/EngineIoFrameTests.cs new file mode 100644 index 0000000..4735857 --- /dev/null +++ b/SVSim.UnitTests/BattleNode/Wire/EngineIoFrameTests.cs @@ -0,0 +1,51 @@ +using NUnit.Framework; +using SVSim.BattleNode.Wire; + +namespace SVSim.UnitTests.BattleNode.Wire; + +[TestFixture] +public class EngineIoFrameTests +{ + [TestCase("0{\"sid\":\"abc\"}", EngineIoPacketType.Open, "{\"sid\":\"abc\"}")] + [TestCase("2", EngineIoPacketType.Ping, "")] + [TestCase("3", EngineIoPacketType.Pong, "")] + [TestCase("4hello", EngineIoPacketType.Message, "hello")] + public void Parse_RecognizesTypeAndPayload(string raw, EngineIoPacketType expectedType, string expectedPayload) + { + var frame = EngineIoFrame.Parse(raw); + + Assert.That(frame.Type, Is.EqualTo(expectedType)); + Assert.That(frame.Payload, Is.EqualTo(expectedPayload)); + } + + [TestCase(EngineIoPacketType.Open, "{\"sid\":\"abc\"}", "0{\"sid\":\"abc\"}")] + [TestCase(EngineIoPacketType.Pong, "", "3")] + [TestCase(EngineIoPacketType.Message, "2[\"msg\",{}]", "42[\"msg\",{}]")] + public void Encode_EmitsTypeDigitFollowedByPayload(EngineIoPacketType type, string payload, string expected) + { + var frame = new EngineIoFrame(type, payload); + + Assert.That(frame.Encode(), Is.EqualTo(expected)); + } + + [Test] + public void Parse_EmptyInput_Throws() + { + Assert.Throws(() => EngineIoFrame.Parse("")); + } + + [Test] + public void Parse_NonDigitFirstChar_Throws() + { + Assert.Throws(() => EngineIoFrame.Parse("xfoo")); + } + + [Test] + public void EngineIoHandshake_SerializesWithCamelCaseKeys() + { + var hs = new EngineIoHandshake("abc123", Array.Empty(), 25000, 60000); + + Assert.That(hs.ToJson(), + Is.EqualTo("{\"sid\":\"abc123\",\"upgrades\":[],\"pingInterval\":25000,\"pingTimeout\":60000}")); + } +}