diff --git a/SVSim.BattleNode/Protocol/EmitCategory.cs b/SVSim.BattleNode/Protocol/EmitCategory.cs new file mode 100644 index 0000000..87477c3 --- /dev/null +++ b/SVSim.BattleNode/Protocol/EmitCategory.cs @@ -0,0 +1,11 @@ +namespace SVSim.BattleNode.Protocol; + +/// The "cat" field on the msg envelope. +public enum EmitCategory +{ + Battle = 1, + Matching = 2, + Room = 3, + Watch = 11, + General = 99, +} diff --git a/SVSim.BattleNode/Protocol/MsgEnvelope.cs b/SVSim.BattleNode/Protocol/MsgEnvelope.cs new file mode 100644 index 0000000..a45d665 --- /dev/null +++ b/SVSim.BattleNode/Protocol/MsgEnvelope.cs @@ -0,0 +1,89 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SVSim.BattleNode.Protocol; + +/// +/// The shared envelope on every encrypted msg / synchronize frame. Body is opaque +/// (Dictionary<string, object?>) because shape varies per Uri — typed register-action +/// models come in a later slice. +/// +public sealed record MsgEnvelope( + NetworkBattleUri Uri, + long ViewerId, + string Uuid, + string? Bid, + int Try, + EmitCategory Cat, + long? PubSeq, + long? PlaySeq, + Dictionary Body) +{ + private static readonly JsonSerializerOptions Options = CreateOptions(); + + private static JsonSerializerOptions CreateOptions() + { + var opt = new JsonSerializerOptions + { + // Wire-key casing here is bare camelCase — NOT EmulatedEntrypoint's snake_case policy. + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; + opt.Converters.Add(new JsonStringEnumConverter()); + return opt; + } + + public static string ToJson(MsgEnvelope env) + { + var doc = new Dictionary + { + ["uri"] = env.Uri.ToString(), + ["viewerId"] = env.ViewerId, + ["uuid"] = env.Uuid, + ["try"] = env.Try, + ["cat"] = (int)env.Cat, + }; + if (env.Bid is not null) doc["bid"] = env.Bid; + if (env.PubSeq.HasValue) doc["pubSeq"] = env.PubSeq.Value; + if (env.PlaySeq.HasValue) doc["playSeq"] = env.PlaySeq.Value; + foreach (var (k, v) in env.Body) doc[k] = v; + return JsonSerializer.Serialize(doc, Options); + } + + public static MsgEnvelope FromJson(string json) + { + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + var uri = Enum.Parse(root.GetProperty("uri").GetString()!); + var viewerId = root.GetProperty("viewerId").GetInt64(); + var uuid = root.GetProperty("uuid").GetString()!; + var bid = root.TryGetProperty("bid", out var bidEl) ? bidEl.GetString() : null; + var @try = root.TryGetProperty("try", out var tryEl) ? tryEl.GetInt32() : 0; + var cat = root.TryGetProperty("cat", out var catEl) ? (EmitCategory)catEl.GetInt32() : EmitCategory.Battle; + var pubSeq = root.TryGetProperty("pubSeq", out var psEl) ? psEl.GetInt64() : (long?)null; + var playSeq = root.TryGetProperty("playSeq", out var plsEl) ? plsEl.GetInt64() : (long?)null; + + var body = new Dictionary(); + var reserved = new HashSet { "uri", "viewerId", "uuid", "bid", "try", "cat", "pubSeq", "playSeq" }; + foreach (var prop in root.EnumerateObject()) + { + if (reserved.Contains(prop.Name)) continue; + body[prop.Name] = ToObject(prop.Value); + } + + return new MsgEnvelope(uri, viewerId, uuid, bid, @try, cat, pubSeq, playSeq, body); + } + + private static object? ToObject(JsonElement el) => el.ValueKind switch + { + JsonValueKind.String => el.GetString(), + JsonValueKind.Number => el.TryGetInt64(out var l) ? l : el.GetDouble(), + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.Null => null, + JsonValueKind.Array => el.EnumerateArray().Select(ToObject).ToList(), + JsonValueKind.Object => el.EnumerateObject().ToDictionary(p => p.Name, p => ToObject(p.Value)), + _ => el.GetRawText(), + }; +} diff --git a/SVSim.BattleNode/Protocol/NetworkBattleUri.cs b/SVSim.BattleNode/Protocol/NetworkBattleUri.cs new file mode 100644 index 0000000..b5420e5 --- /dev/null +++ b/SVSim.BattleNode/Protocol/NetworkBattleUri.cs @@ -0,0 +1,46 @@ +namespace SVSim.BattleNode.Protocol; + +/// +/// Discriminator for every msg/synchronize envelope. Wire form is the bare member name +/// (case-sensitive). See docs/api-spec/in-battle/enums.md. +/// +public enum NetworkBattleUri +{ + None, + Resume, + Retry, + InitNetwork, + InitBattle, + InitRoomBattle, + Matched, + Loaded, + Deal, + Swap, + Ready, + TurnStart, + TurnEndActions, + TurnEnd, + TurnEndFinal, + PlayActions, + BattleStart, + BattleFinish, + ChatStamp, + Gungnir, + Echo, + Retire, + OppoDisconnect, + End, + Judge, + Touch, + SelectSkill, + SelectObject, + SlideObject, + TurnEndReady, + RecoveryStart, + RecoveryEnd, + JudgeResult, + Maintenance, + ReplayFinish, + Kill, + Watch, +} diff --git a/SVSim.BattleNode/Protocol/ReceiveNodeResultCode.cs b/SVSim.BattleNode/Protocol/ReceiveNodeResultCode.cs new file mode 100644 index 0000000..2c1a3a9 --- /dev/null +++ b/SVSim.BattleNode/Protocol/ReceiveNodeResultCode.cs @@ -0,0 +1,16 @@ +namespace SVSim.BattleNode.Protocol; + +/// The "resultCode" field on synchronize pushes. 1=Success, else error. +public enum ReceiveNodeResultCode +{ + None = 0, + Success = 1, + Different_UUID = 30001, + RedisReplyError = 30002, + UnexistUserinfoError = 30003, + MatchingTimeOut = 30201, + UnmatchedError = 30211, + CurrentBattleError = 30212, + UnexpectedPhaseError = 30213, + // Other codes from spec/enums.md added as needed; v1 only triggers the four above. +} diff --git a/SVSim.UnitTests/BattleNode/Protocol/MsgEnvelopeTests.cs b/SVSim.UnitTests/BattleNode/Protocol/MsgEnvelopeTests.cs new file mode 100644 index 0000000..04e66e9 --- /dev/null +++ b/SVSim.UnitTests/BattleNode/Protocol/MsgEnvelopeTests.cs @@ -0,0 +1,66 @@ +using System.Text.Json; +using NUnit.Framework; +using SVSim.BattleNode.Protocol; + +namespace SVSim.UnitTests.BattleNode.Protocol; + +[TestFixture] +public class MsgEnvelopeTests +{ + [Test] + public void Roundtrip_PreservesEnvelopeAndBody() + { + var env = new MsgEnvelope( + Uri: NetworkBattleUri.InitNetwork, + ViewerId: 906243102, + Uuid: "udid-1234", + Bid: "597830888107", + Try: 0, + Cat: EmitCategory.General, + PubSeq: null, + PlaySeq: null, + Body: new Dictionary { ["foo"] = 42 }); + + var json = MsgEnvelope.ToJson(env); + var back = MsgEnvelope.FromJson(json); + + Assert.That(back.Uri, Is.EqualTo(NetworkBattleUri.InitNetwork)); + Assert.That(back.ViewerId, Is.EqualTo(906243102)); + Assert.That(back.Uuid, Is.EqualTo("udid-1234")); + Assert.That(back.Bid, Is.EqualTo("597830888107")); + Assert.That(back.Cat, Is.EqualTo(EmitCategory.General)); + Assert.That(back.Body["foo"], Is.EqualTo(42L)); // JsonElement → int64 + } + + [Test] + public void ToJson_OmitsNullEnvelopeFields() + { + var env = new MsgEnvelope( + Uri: NetworkBattleUri.Ready, + ViewerId: 1, + Uuid: "u", + Bid: null, + Try: 0, + Cat: EmitCategory.Battle, + PubSeq: null, + PlaySeq: 5, + Body: new Dictionary()); + + var json = MsgEnvelope.ToJson(env); + + Assert.That(json, Does.Not.Contain("\"bid\"")); + Assert.That(json, Does.Not.Contain("\"pubSeq\"")); + Assert.That(json, Does.Contain("\"playSeq\":5")); + } + + [Test] + public void FromJson_DispatchesUriToEnum() + { + const string json = "{\"uri\":\"PlayActions\",\"viewerId\":1,\"uuid\":\"u\",\"try\":0,\"cat\":1}"; + + var env = MsgEnvelope.FromJson(json); + + Assert.That(env.Uri, Is.EqualTo(NetworkBattleUri.PlayActions)); + Assert.That(env.Cat, Is.EqualTo(EmitCategory.Battle)); + } +}