From 4cc8b3c01ce1f82ae111812c9aad625cedcd9d22 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Sun, 31 May 2026 21:55:11 -0400 Subject: [PATCH] fix(battle-node): MsgEnvelope rejects reserved Body keys + complete ReceiveNodeResultCode ToJson now throws ArgumentException when a Body key collides with a reserved envelope field (uri/viewerId/uuid/bid/try/cat/pubSeq/playSeq); FromJson reuses the same shared ReservedEnvelopeKeys HashSet. ReceiveNodeResultCode expanded from 9 to 31 codes to mirror the full enums.md catalog. Two regression tests added for the collision guard and PascalCase uri serialization. Co-Authored-By: Claude Sonnet 4.6 --- SVSim.BattleNode/Protocol/MsgEnvelope.cs | 17 +++++++-- .../Protocol/ReceiveNodeResultCode.cs | 29 +++++++++++++- .../BattleNode/Protocol/MsgEnvelopeTests.cs | 38 +++++++++++++++++++ 3 files changed, 79 insertions(+), 5 deletions(-) diff --git a/SVSim.BattleNode/Protocol/MsgEnvelope.cs b/SVSim.BattleNode/Protocol/MsgEnvelope.cs index a45d665..a4bdc9c 100644 --- a/SVSim.BattleNode/Protocol/MsgEnvelope.cs +++ b/SVSim.BattleNode/Protocol/MsgEnvelope.cs @@ -21,6 +21,11 @@ public sealed record MsgEnvelope( { private static readonly JsonSerializerOptions Options = CreateOptions(); + private static readonly HashSet ReservedEnvelopeKeys = new() + { + "uri", "viewerId", "uuid", "bid", "try", "cat", "pubSeq", "playSeq", + }; + private static JsonSerializerOptions CreateOptions() { var opt = new JsonSerializerOptions @@ -46,7 +51,14 @@ public sealed record MsgEnvelope( 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; + foreach (var (k, v) in env.Body) + { + if (ReservedEnvelopeKeys.Contains(k)) + throw new ArgumentException( + $"Body key '{k}' collides with a reserved envelope field. Move it to a typed field on MsgEnvelope.", + nameof(env)); + doc[k] = v; + } return JsonSerializer.Serialize(doc, Options); } @@ -65,10 +77,9 @@ public sealed record MsgEnvelope( 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; + if (ReservedEnvelopeKeys.Contains(prop.Name)) continue; body[prop.Name] = ToObject(prop.Value); } diff --git a/SVSim.BattleNode/Protocol/ReceiveNodeResultCode.cs b/SVSim.BattleNode/Protocol/ReceiveNodeResultCode.cs index 2c1a3a9..818b85d 100644 --- a/SVSim.BattleNode/Protocol/ReceiveNodeResultCode.cs +++ b/SVSim.BattleNode/Protocol/ReceiveNodeResultCode.cs @@ -1,6 +1,10 @@ namespace SVSim.BattleNode.Protocol; -/// The "resultCode" field on synchronize pushes. 1=Success, else error. +/// +/// The "resultCode" field on synchronize pushes. 1 = Success, else error. +/// Mirrors the full catalog from docs/api-spec/in-battle/enums.md, including +/// source typos in the original spec (RoomBattleReadeError, RoomTornament*). +/// public enum ReceiveNodeResultCode { None = 0, @@ -8,9 +12,30 @@ public enum ReceiveNodeResultCode Different_UUID = 30001, RedisReplyError = 30002, UnexistUserinfoError = 30003, + RoomStatusInfoError = 30101, + RoomCreateError = 30102, + RoomEntryError = 30103, + RoomKickError = 30104, + RoomLeaveError = 30105, + RoomReleaseError = 30106, + RoomForceReleaseError = 30107, + RoomReenterError = 30108, + RoomBattleReadeError = 30109, // source typo per spec, preserved + RoomTournamentDeckError = 30110, + RoomTournamentError = 30111, + RoomSetupLock = 30112, MatchingTimeOut = 30201, UnmatchedError = 30211, CurrentBattleError = 30212, UnexpectedPhaseError = 30213, - // Other codes from spec/enums.md added as needed; v1 only triggers the four above. + WatchError = 30302, + SwapTimeoutError = 31001, + FoundRemovedUserErrorSelf = 32101, + FoundRemovedUserErrorOppo = 32102, + FoundRemovedUserErrorWatcher = 32103, + RoomTimeEndError = 32104, + WatcherInRemovedOwnerRoomError = 32105, + RoomTornamentOwnTimeEndError = 32106, // source typo per spec, preserved + RoomTornamentOppoTimeEndError = 32107, // source typo per spec, preserved + BattleFinishTimeEnd = 32108, } diff --git a/SVSim.UnitTests/BattleNode/Protocol/MsgEnvelopeTests.cs b/SVSim.UnitTests/BattleNode/Protocol/MsgEnvelopeTests.cs index 04e66e9..a1d2212 100644 --- a/SVSim.UnitTests/BattleNode/Protocol/MsgEnvelopeTests.cs +++ b/SVSim.UnitTests/BattleNode/Protocol/MsgEnvelopeTests.cs @@ -63,4 +63,42 @@ public class MsgEnvelopeTests Assert.That(env.Uri, Is.EqualTo(NetworkBattleUri.PlayActions)); Assert.That(env.Cat, Is.EqualTo(EmitCategory.Battle)); } + + [Test] + public void ToJson_BodyContainingReservedKey_Throws() + { + var env = new MsgEnvelope( + Uri: NetworkBattleUri.Loaded, + ViewerId: 1, + Uuid: "u", + Bid: null, + Try: 0, + Cat: EmitCategory.Battle, + PubSeq: null, + PlaySeq: null, + Body: new Dictionary { ["uri"] = "Injected" }); + + var ex = Assert.Throws(() => MsgEnvelope.ToJson(env)); + Assert.That(ex!.Message, Does.Contain("uri")); + } + + [Test] + public void ToJson_UriField_SerializesAsExactPascalCaseMemberName() + { + var env = new MsgEnvelope( + Uri: NetworkBattleUri.PlayActions, + ViewerId: 1, + Uuid: "u", + Bid: null, + Try: 0, + Cat: EmitCategory.Battle, + PubSeq: null, + PlaySeq: null, + Body: new Dictionary()); + + var json = MsgEnvelope.ToJson(env); + + // Wire form must be PascalCase exactly — not "playActions", not "play_actions". + Assert.That(json, Does.Contain("\"uri\":\"PlayActions\"")); + } }