diff --git a/SVSim.BattleNode/Lifecycle/ScriptedLifecycle.cs b/SVSim.BattleNode/Lifecycle/ScriptedLifecycle.cs index 4a2b499..71e287e 100644 --- a/SVSim.BattleNode/Lifecycle/ScriptedLifecycle.cs +++ b/SVSim.BattleNode/Lifecycle/ScriptedLifecycle.cs @@ -1,4 +1,5 @@ using SVSim.BattleNode.Protocol; +using SVSim.BattleNode.Protocol.Bodies; namespace SVSim.BattleNode.Lifecycle; @@ -7,21 +8,9 @@ namespace SVSim.BattleNode.Lifecycle; /// (Matched / BattleStart / Deal / Swap response / Ready) plus a trivial opponent TurnStart /// the dispatch pushes after the player's TurnEnd. The values are templated from the TK2 /// captures at data_dumps/captures/battle-traffic_tk2_regular.ndjson — anything -/// hardcoded here came from a real prod frame. +/// hardcoded here came from a real prod frame, with names + provenance in +/// . /// -/// -/// "Scripted" means the opponent never reacts to your plays. We push enough to land -/// you on the mulligan screen, run a real mulligan exchange, give you turn 1, transition -/// to "Opponent's turn…" after your TurnEnd, and then sit there indefinitely. This -/// is the documented v1 stopping point. -/// -/// All builders go through , which injects -/// resultCode = 1 into every body. The client's OnReceived drops any -/// synchronize push whose resultCode != Success (absent counts as None=0); leaving -/// it off silently breaks the state machine without surfacing an error. -/// -/// To make this less scripted: see the project README §"Where to extend". -/// public static class ScriptedLifecycle { /// @@ -29,7 +18,7 @@ public static class ScriptedLifecycle /// every card-master version we care about, so the client can render it without /// triggering a card-master-mismatch error. /// - public static readonly long DummyCardId = 100011010; + public const long DummyCardId = 100011010L; /// /// Viewer id we present as the opponent on every scripted push. Out-of-range vs. real @@ -37,87 +26,27 @@ public static class ScriptedLifecycle /// public const long FakeOpponentViewerId = 999_999_999L; - public static MsgEnvelope BuildMatched(long playerViewerId, long opponentViewerId, string battleId) - { - var body = new Dictionary - { - ["selfInfo"] = new Dictionary - { - ["country_code"] = "KOR", - ["userName"] = "Player", - ["sleeveId"] = "3000011", - ["emblemId"] = "701441011", - ["degreeId"] = "300003", - ["fieldId"] = 43, - ["isOfficial"] = 0, - ["oppoId"] = opponentViewerId, - ["seed"] = 17548138L, - }, - ["oppoInfo"] = new Dictionary - { - ["country_code"] = "JPN", - ["userName"] = "Opponent", - ["sleeveId"] = "704141010", - ["emblemId"] = "400001100", - ["degreeId"] = "120027", - ["fieldId"] = 5, - ["isOfficial"] = 0, - ["oppoId"] = playerViewerId, - ["seed"] = 17548138L, - ["oppoDeckCount"] = 30, - }, - ["selfDeck"] = BuildDummyDeck(), - }; - return EnvelopeForPush(NetworkBattleUri.Matched, body, bid: battleId); - } + public static MsgEnvelope BuildMatched(long playerViewerId, long opponentViewerId, string battleId) => + EnvelopeForPush(NetworkBattleUri.Matched, + new MatchedBody( + SelfInfo: ScriptedProfiles.PlayerMatchedProfile with { OppoId = opponentViewerId }, + OppoInfo: ScriptedProfiles.OpponentMatchedProfile with { OppoId = playerViewerId }, + SelfDeck: BuildDummyDeck()), + bid: battleId); - public static MsgEnvelope BuildBattleStart(long playerViewerId) - { - var body = new Dictionary - { - ["turnState"] = 0, // player goes first - ["battleType"] = 11, // TK2 NetworkBattleType - ["selfInfo"] = new Dictionary - { - ["rank"] = "10", - ["battlePoint"] = "6270", - ["classId"] = "1", - ["charaId"] = "1", - ["cardMasterName"] = "card_master_node_10015", - }, - ["oppoInfo"] = new Dictionary - { - ["rank"] = "1", - ["isMasterRank"] = "0", - ["battlePoint"] = 0, - ["masterPoint"] = "0", - ["classId"] = "8", - ["charaId"] = "8", - ["cardMasterName"] = "card_master_node_10015", - }, - }; - return EnvelopeForPush(NetworkBattleUri.BattleStart, body); - } + public static MsgEnvelope BuildBattleStart(long playerViewerId) => + EnvelopeForPush(NetworkBattleUri.BattleStart, + new BattleStartBody( + TurnState: 0, // player goes first + BattleType: 11, // TK2 NetworkBattleType + SelfInfo: ScriptedProfiles.PlayerBattleStartProfile, + OppoInfo: ScriptedProfiles.OpponentBattleStartProfile)); - public static MsgEnvelope BuildDeal() - { - var body = new Dictionary - { - ["self"] = new List - { - new Dictionary { ["pos"] = 0, ["idx"] = 1 }, - new Dictionary { ["pos"] = 1, ["idx"] = 2 }, - new Dictionary { ["pos"] = 2, ["idx"] = 3 }, - }, - ["oppo"] = new List - { - new Dictionary { ["pos"] = 0, ["idx"] = 1 }, - new Dictionary { ["pos"] = 1, ["idx"] = 2 }, - new Dictionary { ["pos"] = 2, ["idx"] = 3 }, - }, - }; - return EnvelopeForPush(NetworkBattleUri.Deal, body); - } + public static MsgEnvelope BuildDeal() => + EnvelopeForPush(NetworkBattleUri.Deal, + new DealBody( + Self: new[] { new PosIdx(0, 1), new PosIdx(1, 2), new PosIdx(2, 3) }, + Oppo: new[] { new PosIdx(0, 1), new PosIdx(1, 2), new PosIdx(2, 3) })); /// /// Initial 3-card hand idxs from . Each position in this array @@ -144,81 +73,55 @@ public static class ScriptedLifecycle return hand; } - public static MsgEnvelope BuildSwapResponse(IReadOnlyList hand) - { - var body = new Dictionary - { - ["self"] = BuildPosIdxList(hand), - }; - return EnvelopeForPush(NetworkBattleUri.Swap, body); - } + public static MsgEnvelope BuildSwapResponse(IReadOnlyList hand) => + EnvelopeForPush(NetworkBattleUri.Swap, + new SwapResponseBody(Self: BuildPosIdxList(hand))); - public static MsgEnvelope BuildReady(IReadOnlyList hand) - { - var body = new Dictionary - { - ["self"] = BuildPosIdxList(hand), - // Opponent hand stays at the static 3 cards for v1. - ["oppo"] = BuildPosIdxList(InitialHand), - ["idxChangeSeed"] = 771335280, - ["spin"] = 243, - }; - return EnvelopeForPush(NetworkBattleUri.Ready, body); - } + public static MsgEnvelope BuildReady(IReadOnlyList hand) => + EnvelopeForPush(NetworkBattleUri.Ready, + new ReadyBody( + Self: BuildPosIdxList(hand), + Oppo: BuildPosIdxList(InitialHand), + IdxChangeSeed: ScriptedProfiles.ReadyIdxChangeSeed, + Spin: ScriptedProfiles.ReadySpin)); /// /// Generic TurnStart push used to transition the client into "Opponent's turn…" state /// after the player's TurnEnd. v1 doesn't simulate the opponent — once this lands the /// client sits at the opponent-turn display indefinitely. /// - public static MsgEnvelope BuildOpponentTurnStart() - { - var body = new Dictionary - { - ["spin"] = 100, - }; - return EnvelopeForPush(NetworkBattleUri.TurnStart, body); - } + public static MsgEnvelope BuildOpponentTurnStart() => + EnvelopeForPush(NetworkBattleUri.TurnStart, + new OpponentTurnStartBody(Spin: ScriptedProfiles.OpponentTurnStartSpin)); - private static List BuildPosIdxList(IReadOnlyList hand) + private static IReadOnlyList BuildPosIdxList(IReadOnlyList hand) { - var list = new List(hand.Count); + var list = new List(hand.Count); for (var pos = 0; pos < hand.Count; pos++) { - list.Add(new Dictionary { ["pos"] = pos, ["idx"] = (int)hand[pos] }); + list.Add(new PosIdx(Pos: pos, Idx: (int)hand[pos])); } return list; } - private static List BuildDummyDeck() + private static IReadOnlyList BuildDummyDeck() { - var deck = new List(30); + var deck = new List(30); for (var i = 1; i <= 30; i++) { - deck.Add(new Dictionary - { - ["idx"] = i, - ["cardId"] = DummyCardId, - }); + deck.Add(new DeckCardRef(Idx: i, CardId: DummyCardId)); } return deck; } - private static MsgEnvelope EnvelopeForPush(NetworkBattleUri uri, Dictionary body, string? bid = null) - { - // Synchronize-push routing in the client's OnReceived drops any frame whose - // resultCode != Success (1). Absent counts as 0 (None) and is also dropped — so we - // MUST include it on every scripted push, not just InitNetwork ack / BattleFinish. - // See server-to-client.md §"Routing in OnReceived" and the matching prod captures. - body["resultCode"] = (int)ReceiveNodeResultCode.Success; - return new MsgEnvelope(uri, + private static MsgEnvelope EnvelopeForPush(NetworkBattleUri uri, IMsgBody body, string? bid = null) => + new(uri, ViewerId: FakeOpponentViewerId, Uuid: "node-stub", Bid: bid, Try: 0, Cat: EmitCategory.Battle, PubSeq: null, - PlaySeq: null, // OutboundSequencer.AssignAndArchive stamps this + PlaySeq: null, Body: body); - } } diff --git a/SVSim.BattleNode/Protocol/MsgEnvelope.cs b/SVSim.BattleNode/Protocol/MsgEnvelope.cs index bfe25df..d5398b4 100644 --- a/SVSim.BattleNode/Protocol/MsgEnvelope.cs +++ b/SVSim.BattleNode/Protocol/MsgEnvelope.cs @@ -1,12 +1,13 @@ using System.Text.Json; +using System.Text.Json.Nodes; 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. +/// The shared envelope on every encrypted msg / synchronize frame. Body is +/// — either a typed body record (outbound) or a +/// (inbound). /// public sealed record MsgEnvelope( NetworkBattleUri Uri, @@ -17,7 +18,7 @@ public sealed record MsgEnvelope( EmitCategory Cat, long? PubSeq, long? PlaySeq, - Dictionary Body) + IMsgBody Body) { private static readonly JsonSerializerOptions Options = CreateOptions(); @@ -30,9 +31,10 @@ public sealed record MsgEnvelope( { var opt = new JsonSerializerOptions { - // Wire-key casing here is bare camelCase — NOT EmulatedEntrypoint's snake_case policy. + // Wire-key casing is bare camelCase via per-field [JsonPropertyName] — + // NOT EmulatedEntrypoint's snake_case policy. The naming-policy line + // that was here previously was dead code (every wire key is explicit). DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, }; opt.Converters.Add(new JsonStringEnumConverter()); return opt; @@ -40,26 +42,76 @@ public sealed record MsgEnvelope( public static string ToJson(MsgEnvelope env) { - var doc = new Dictionary + JsonObject result; + if (env.Body is RawBody raw) { - ["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) - { - 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; + // Inbound-echo path: flatten Entries to top-level keys, same as before + // the typed-body refactor. + result = new JsonObject(); + foreach (var (k, v) in raw.Entries) + { + if (ReservedEnvelopeKeys.Contains(k)) + throw new ArgumentException( + $"RawBody key '{k}' collides with a reserved envelope field. " + + $"Move it to a typed field on MsgEnvelope.", + nameof(env)); + result[k] = ToJsonNode(v); + } } - return JsonSerializer.Serialize(doc, Options); + else + { + // Typed body: serialize via [JsonPropertyName] attributes on the record. + result = (JsonObject)JsonSerializer.SerializeToNode(env.Body, env.Body.GetType(), Options)!; + } + + result["uri"] = env.Uri.ToString(); + result["viewerId"] = env.ViewerId; + result["uuid"] = env.Uuid; + result["try"] = env.Try; + result["cat"] = (int)env.Cat; + if (env.Bid is not null) result["bid"] = env.Bid; + if (env.PubSeq.HasValue) result["pubSeq"] = env.PubSeq.Value; + if (env.PlaySeq.HasValue) result["playSeq"] = env.PlaySeq.Value; + + return result.ToJsonString(Options); + } + + /// + /// Convert a boxed CLR value (as stored in ) to a JsonNode. + /// Explicit type switch on the runtime type — `JsonValue.Create(object?)` would create + /// a `JsonValueCustomized<object>` that requires a TypeInfoResolver at serialize time + /// (introduced in S.T.Json 8.0 source-gen mode). + /// + private static JsonNode? ToJsonNode(object? value) => value switch + { + null => null, + string s => JsonValue.Create(s), + bool b => JsonValue.Create(b), + long l => JsonValue.Create(l), + int i => JsonValue.Create(i), + double d => JsonValue.Create(d), + decimal m => JsonValue.Create(m), + // Inbound-parsed nested objects come through as Dictionary; nested + // arrays as List. FromJson is the source of these shapes — see ToObject. + IDictionary dict => DictToJsonObject(dict), + IReadOnlyList list => ListToJsonArray(list), + _ => throw new InvalidOperationException( + $"RawBody contains a value of unsupported type {value.GetType().FullName}. " + + "Only primitives, nested dicts (object), and nested lists are recognized."), + }; + + private static JsonObject DictToJsonObject(IDictionary dict) + { + var obj = new JsonObject(); + foreach (var (k, v) in dict) obj[k] = ToJsonNode(v); + return obj; + } + + private static JsonArray ListToJsonArray(IReadOnlyList list) + { + var arr = new JsonArray(); + foreach (var v in list) arr.Add(ToJsonNode(v)); + return arr; } public static MsgEnvelope FromJson(string json) @@ -76,14 +128,14 @@ public sealed record MsgEnvelope( 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 bodyDict = new Dictionary(); foreach (var prop in root.EnumerateObject()) { if (ReservedEnvelopeKeys.Contains(prop.Name)) continue; - body[prop.Name] = ToObject(prop.Value); + bodyDict[prop.Name] = ToObject(prop.Value); } - return new MsgEnvelope(uri, viewerId, uuid, bid, @try, cat, pubSeq, playSeq, body); + return new MsgEnvelope(uri, viewerId, uuid, bid, @try, cat, pubSeq, playSeq, new RawBody(bodyDict)); } private static object? ToObject(JsonElement el) => el.ValueKind switch diff --git a/SVSim.BattleNode/Reliability/Gungnir.cs b/SVSim.BattleNode/Reliability/Gungnir.cs index c1644e7..3e28158 100644 --- a/SVSim.BattleNode/Reliability/Gungnir.cs +++ b/SVSim.BattleNode/Reliability/Gungnir.cs @@ -3,7 +3,10 @@ namespace SVSim.BattleNode.Reliability; /// /// Body builders for the alive channel. The timer/loop that drives 5s emits lives on /// BattleSession; this class is just the pure body-shape factory. -/// v1 always reports scs/ocs=ONLINE — real disconnect detection is deferred. +/// v1 always reports scs/ocs=ONLINE — real disconnect detection is deferred. The push +/// body itself is constructed inline in BattleSession.HandleAliveEventAsync using +/// AlivePushBody; only the emit body (sent by us TO the client on the alive channel, +/// currently unused in v1) remains here. /// public static class Gungnir { @@ -14,10 +17,4 @@ public static class Gungnir ["currentSeq"] = tracker.HighWaterMark, // actionSeq omitted in v1 — no turn-transition flag yet. }; - - public static Dictionary BuildAlivePushBody() => new() - { - ["scs"] = "ONLINE", - ["ocs"] = "ONLINE", - }; } diff --git a/SVSim.BattleNode/Sessions/BattleSession.cs b/SVSim.BattleNode/Sessions/BattleSession.cs index 043afda..fbab589 100644 --- a/SVSim.BattleNode/Sessions/BattleSession.cs +++ b/SVSim.BattleNode/Sessions/BattleSession.cs @@ -4,6 +4,7 @@ using System.Text; using Microsoft.Extensions.Logging; using SVSim.BattleNode.Lifecycle; using SVSim.BattleNode.Protocol; +using SVSim.BattleNode.Protocol.Bodies; using SVSim.BattleNode.Reliability; using SVSim.BattleNode.Wire; @@ -169,7 +170,7 @@ public sealed class BattleSession Cat: EmitCategory.General, PubSeq: null, PlaySeq: null, - Body: Gungnir.BuildAlivePushBody()); + Body: new AlivePushBody(Scs: "ONLINE", Ocs: "ONLINE")); await PushNoStockAsync(aliveEnv, eventName: "alive"); } catch (Exception ex) @@ -263,7 +264,7 @@ public sealed class BattleSession Cat: EmitCategory.General, PubSeq: null, PlaySeq: null, - Body: new Dictionary { ["resultCode"] = 1 }); + Body: new ResultCodeOnlyBody()); private MsgEnvelope BuildBattleFinishNoContest() => new( NetworkBattleUri.BattleFinish, @@ -274,7 +275,7 @@ public sealed class BattleSession Cat: EmitCategory.Battle, PubSeq: null, PlaySeq: null, - Body: new Dictionary { ["result"] = 1, ["resultCode"] = 1 }); + Body: new BattleFinishBody(Result: ScriptedProfiles.BattleResultPlayerWins)); private static IReadOnlyList ExtractIdxList(MsgEnvelope env) { @@ -282,7 +283,8 @@ public sealed class BattleSession // string). MsgEnvelope.FromJson should box small ints as long, but a parser quirk // anywhere upstream could yield a different boxed type and OfType would silently // drop the entries — that broke the v1 mulligan during smoke. - if (env.Body.TryGetValue("idxList", out var raw) && raw is System.Collections.IEnumerable seq && raw is not string) + if (env.Body is not RawBody rawBody) return Array.Empty(); + if (rawBody.Entries.TryGetValue("idxList", out var raw) && raw is System.Collections.IEnumerable seq && raw is not string) { var result = new List(); foreach (var item in seq) diff --git a/SVSim.UnitTests/BattleNode/Integration/BattleNodeFlowTests.cs b/SVSim.UnitTests/BattleNode/Integration/BattleNodeFlowTests.cs index df99569..be89b4c 100644 --- a/SVSim.UnitTests/BattleNode/Integration/BattleNodeFlowTests.cs +++ b/SVSim.UnitTests/BattleNode/Integration/BattleNodeFlowTests.cs @@ -66,7 +66,7 @@ public class BattleNodeFlowTests Cat: uri == NetworkBattleUri.InitNetwork ? EmitCategory.General : uri == NetworkBattleUri.InitBattle ? EmitCategory.Matching : EmitCategory.Battle, - PubSeq: pubSeq, PlaySeq: null, Body: body ?? new Dictionary()); + PubSeq: pubSeq, PlaySeq: null, Body: new RawBody(body ?? new Dictionary())); private static string MakeKey() { diff --git a/SVSim.UnitTests/BattleNode/Lifecycle/ScriptedLifecycleTests.cs b/SVSim.UnitTests/BattleNode/Lifecycle/ScriptedLifecycleTests.cs index f9ca3d9..c232553 100644 --- a/SVSim.UnitTests/BattleNode/Lifecycle/ScriptedLifecycleTests.cs +++ b/SVSim.UnitTests/BattleNode/Lifecycle/ScriptedLifecycleTests.cs @@ -1,6 +1,7 @@ using NUnit.Framework; using SVSim.BattleNode.Lifecycle; using SVSim.BattleNode.Protocol; +using SVSim.BattleNode.Protocol.Bodies; namespace SVSim.UnitTests.BattleNode.Lifecycle; @@ -13,40 +14,39 @@ public class ScriptedLifecycleTests var env = ScriptedLifecycle.BuildMatched(playerViewerId: 906243102, opponentViewerId: 847666884, battleId: "b"); Assert.That(env.Uri, Is.EqualTo(NetworkBattleUri.Matched)); - var selfInfo = (Dictionary)env.Body["selfInfo"]!; - Assert.That(selfInfo["oppoId"], Is.EqualTo(847666884L)); - var oppoInfo = (Dictionary)env.Body["oppoInfo"]!; - Assert.That(oppoInfo["oppoId"], Is.EqualTo(906243102L)); + var body = (MatchedBody)env.Body; + Assert.That(body.SelfInfo.OppoId, Is.EqualTo(847666884L)); + Assert.That(body.OppoInfo.OppoId, Is.EqualTo(906243102L)); - // Bid travels in the envelope, not the Body — protect against the Task 5 reserved-keys regression. + // Bid travels in the envelope, not the Body — protect against the reserved-keys regression. Assert.That(env.Bid, Is.EqualTo("b")); - Assert.That(env.Body.ContainsKey("bid"), Is.False); + // Typed bodies can't carry an envelope-level "bid" key by construction (no such property). } [Test] public void BuildMatched_ContainsThirtyCardSelfDeck() { var env = ScriptedLifecycle.BuildMatched(1, 2, "b"); - var deck = (List)env.Body["selfDeck"]!; - Assert.That(deck.Count, Is.EqualTo(30)); + var body = (MatchedBody)env.Body; + Assert.That(body.SelfDeck.Count, Is.EqualTo(30)); } [Test] public void BuildBattleStart_HasTurnStateZeroAndBattleTypeEleven() { var env = ScriptedLifecycle.BuildBattleStart(playerViewerId: 1); - Assert.That(env.Body["turnState"], Is.EqualTo(0)); - Assert.That(env.Body["battleType"], Is.EqualTo(11)); + var body = (BattleStartBody)env.Body; + Assert.That(body.TurnState, Is.EqualTo(0)); + Assert.That(body.BattleType, Is.EqualTo(11)); } [Test] public void BuildDeal_HasThreeSelfAndThreeOppoEntries() { var env = ScriptedLifecycle.BuildDeal(); - var self = (List)env.Body["self"]!; - var oppo = (List)env.Body["oppo"]!; - Assert.That(self.Count, Is.EqualTo(3)); - Assert.That(oppo.Count, Is.EqualTo(3)); + var body = (DealBody)env.Body; + Assert.That(body.Self.Count, Is.EqualTo(3)); + Assert.That(body.Oppo.Count, Is.EqualTo(3)); } [Test] @@ -75,19 +75,19 @@ public class ScriptedLifecycleTests public void BuildSwapResponse_RendersGivenHandAsPositions() { var env = ScriptedLifecycle.BuildSwapResponse(new long[] { 1, 4, 3 }); - var self = (List)env.Body["self"]!; - Assert.That(self.Count, Is.EqualTo(3)); - Assert.That(((Dictionary)self[1]!)["idx"], Is.EqualTo(4)); + var body = (SwapResponseBody)env.Body; + Assert.That(body.Self.Count, Is.EqualTo(3)); + Assert.That(body.Self[1].Idx, Is.EqualTo(4)); } [Test] public void BuildReady_IncludesIdxChangeSeedAndSpin_AndUsesGivenHand() { var env = ScriptedLifecycle.BuildReady(new long[] { 1, 4, 3 }); - Assert.That(env.Body.ContainsKey("idxChangeSeed"), Is.True); - Assert.That(env.Body.ContainsKey("spin"), Is.True); - var self = (List)env.Body["self"]!; - Assert.That(((Dictionary)self[1]!)["idx"], Is.EqualTo(4)); + var body = (ReadyBody)env.Body; + Assert.That(body.IdxChangeSeed, Is.EqualTo(771_335_280)); + Assert.That(body.Spin, Is.EqualTo(243)); + Assert.That(body.Self[1].Idx, Is.EqualTo(4)); } [Test] @@ -95,6 +95,7 @@ public class ScriptedLifecycleTests { var env = ScriptedLifecycle.BuildOpponentTurnStart(); Assert.That(env.Uri, Is.EqualTo(NetworkBattleUri.TurnStart)); - Assert.That(env.Body.ContainsKey("spin"), Is.True); + var body = (OpponentTurnStartBody)env.Body; + Assert.That(body.Spin, Is.EqualTo(100)); } } diff --git a/SVSim.UnitTests/BattleNode/Protocol/MsgEnvelopeTests.cs b/SVSim.UnitTests/BattleNode/Protocol/MsgEnvelopeTests.cs index 5e53342..abb8d27 100644 --- a/SVSim.UnitTests/BattleNode/Protocol/MsgEnvelopeTests.cs +++ b/SVSim.UnitTests/BattleNode/Protocol/MsgEnvelopeTests.cs @@ -1,4 +1,3 @@ -using System.Text.Json; using NUnit.Framework; using SVSim.BattleNode.Protocol; @@ -19,7 +18,8 @@ public class MsgEnvelopeTests var env = MsgEnvelope.FromJson(json); - var idxList = (List)env.Body["idxList"]!; + var raw = (RawBody)env.Body; + var idxList = (List)raw.Entries["idxList"]!; Assert.That(idxList.Count, Is.EqualTo(2)); Assert.That(idxList[0], Is.TypeOf(), "idxList[0] must be boxed long, not double."); Assert.That(idxList[0], Is.EqualTo(2L)); @@ -39,7 +39,7 @@ public class MsgEnvelopeTests Cat: EmitCategory.General, PubSeq: null, PlaySeq: null, - Body: new Dictionary { ["foo"] = 42 }); + Body: new RawBody(new Dictionary { ["foo"] = 42 })); var json = MsgEnvelope.ToJson(env); var back = MsgEnvelope.FromJson(json); @@ -49,7 +49,7 @@ public class MsgEnvelopeTests 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 + Assert.That(((RawBody)back.Body).Entries["foo"], Is.EqualTo(42L)); } [Test] @@ -64,7 +64,7 @@ public class MsgEnvelopeTests Cat: EmitCategory.Battle, PubSeq: null, PlaySeq: 5, - Body: new Dictionary()); + Body: new RawBody(new Dictionary())); var json = MsgEnvelope.ToJson(env); @@ -85,7 +85,7 @@ public class MsgEnvelopeTests } [Test] - public void ToJson_BodyContainingReservedKey_Throws() + public void ToJson_RawBodyContainingReservedKey_Throws() { var env = new MsgEnvelope( Uri: NetworkBattleUri.Loaded, @@ -96,7 +96,7 @@ public class MsgEnvelopeTests Cat: EmitCategory.Battle, PubSeq: null, PlaySeq: null, - Body: new Dictionary { ["uri"] = "Injected" }); + Body: new RawBody(new Dictionary { ["uri"] = "Injected" })); var ex = Assert.Throws(() => MsgEnvelope.ToJson(env)); Assert.That(ex!.Message, Does.Contain("uri")); @@ -114,7 +114,7 @@ public class MsgEnvelopeTests Cat: EmitCategory.Battle, PubSeq: null, PlaySeq: null, - Body: new Dictionary()); + Body: new RawBody(new Dictionary())); var json = MsgEnvelope.ToJson(env); diff --git a/SVSim.UnitTests/BattleNode/Protocol/MsgPayloadCodecTests.cs b/SVSim.UnitTests/BattleNode/Protocol/MsgPayloadCodecTests.cs index afbfb75..fc2b9ef 100644 --- a/SVSim.UnitTests/BattleNode/Protocol/MsgPayloadCodecTests.cs +++ b/SVSim.UnitTests/BattleNode/Protocol/MsgPayloadCodecTests.cs @@ -24,7 +24,7 @@ public class MsgPayloadCodecTests Cat: EmitCategory.Battle, PubSeq: 3, PlaySeq: null, - Body: new Dictionary()); + Body: new RawBody(new Dictionary())); var bytes = MsgPayloadCodec.Encode(env, key: FreshKey()); var back = MsgPayloadCodec.Decode(bytes); @@ -48,6 +48,6 @@ public class MsgPayloadCodecTests Assert.That(env.Uri, Is.EqualTo(NetworkBattleUri.InitNetwork)); Assert.That(env.Cat, Is.EqualTo(EmitCategory.General)); - Assert.That(env.Body["resultCode"], Is.EqualTo(1L)); + Assert.That(((RawBody)env.Body).Entries["resultCode"], Is.EqualTo(1L)); } } diff --git a/SVSim.UnitTests/BattleNode/Reliability/GungnirTests.cs b/SVSim.UnitTests/BattleNode/Reliability/GungnirTests.cs index 40ea1b5..628a257 100644 --- a/SVSim.UnitTests/BattleNode/Reliability/GungnirTests.cs +++ b/SVSim.UnitTests/BattleNode/Reliability/GungnirTests.cs @@ -6,14 +6,6 @@ namespace SVSim.UnitTests.BattleNode.Reliability; [TestFixture] public class GungnirTests { - [Test] - public void BuildAlivePush_AlwaysReturnsScsOnlineOcsOnline() - { - var body = Gungnir.BuildAlivePushBody(); - Assert.That(body["scs"], Is.EqualTo("ONLINE")); - Assert.That(body["ocs"], Is.EqualTo("ONLINE")); - } - [Test] public void BuildAliveEmit_CarriesCurrentSeqFromTracker() { diff --git a/SVSim.UnitTests/BattleNode/Reliability/OutboundSequencerTests.cs b/SVSim.UnitTests/BattleNode/Reliability/OutboundSequencerTests.cs index 21e0698..d27c570 100644 --- a/SVSim.UnitTests/BattleNode/Reliability/OutboundSequencerTests.cs +++ b/SVSim.UnitTests/BattleNode/Reliability/OutboundSequencerTests.cs @@ -9,7 +9,7 @@ public class OutboundSequencerTests { private static MsgEnvelope MakeEnvelope(NetworkBattleUri uri) => new(uri, ViewerId: 1, Uuid: "u", Bid: null, Try: 0, Cat: EmitCategory.Battle, - PubSeq: null, PlaySeq: null, Body: new Dictionary()); + PubSeq: null, PlaySeq: null, Body: new RawBody(new Dictionary())); [Test] public void AssignAndArchive_FirstCall_ReturnsEnvelopeWithPlaySeq1() diff --git a/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs b/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs index 4fff4eb..50b28ce 100644 --- a/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs +++ b/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs @@ -2,6 +2,7 @@ using Microsoft.Extensions.Logging.Abstractions; using NUnit.Framework; using SVSim.BattleNode.Protocol; +using SVSim.BattleNode.Protocol.Bodies; using SVSim.BattleNode.Sessions; namespace SVSim.UnitTests.BattleNode.Sessions; @@ -17,7 +18,7 @@ public class BattleSessionDispatchTests private static MsgEnvelope NewEnvelope(NetworkBattleUri uri) => new(uri, ViewerId: 1, Uuid: "u", Bid: null, Try: 0, Cat: EmitCategory.Battle, - PubSeq: null, PlaySeq: null, Body: new Dictionary()); + PubSeq: null, PlaySeq: null, Body: new RawBody(new Dictionary())); [Test] public void InitNetwork_PushesAckOnly_TransitionsToAwaitingInitBattle() @@ -58,18 +59,22 @@ public class BattleSessionDispatchTests s.ComputeResponses(NewEnvelope(NetworkBattleUri.InitNetwork)); s.ComputeResponses(NewEnvelope(NetworkBattleUri.InitBattle)); s.ComputeResponses(NewEnvelope(NetworkBattleUri.Loaded)); - var swapEnv = NewEnvelope(NetworkBattleUri.Swap); // Simulate the client's Swap{idxList:[2]}: the dict shape produced by MsgEnvelope.FromJson - // (a List of boxed long values). - swapEnv.Body["idxList"] = new List { 2L }; + // (a List of boxed long values), wrapped in a RawBody as the inbound type. + var swapEnv = new MsgEnvelope( + NetworkBattleUri.Swap, ViewerId: 1, Uuid: "u", Bid: null, Try: 0, + Cat: EmitCategory.Battle, PubSeq: null, PlaySeq: null, + Body: new RawBody(new Dictionary + { + ["idxList"] = new List { 2L }, + })); var responses = s.ComputeResponses(swapEnv); - var swapBody = responses[0].Envelope.Body; - var self = (List)swapBody["self"]!; - Assert.That(((Dictionary)self[0]!)["idx"], Is.EqualTo(1)); - Assert.That(((Dictionary)self[1]!)["idx"], Is.EqualTo(4)); // swapped — fresh deck idx - Assert.That(((Dictionary)self[2]!)["idx"], Is.EqualTo(3)); + var swapBody = (SwapResponseBody)responses[0].Envelope.Body; + Assert.That(swapBody.Self[0].Idx, Is.EqualTo(1)); + Assert.That(swapBody.Self[1].Idx, Is.EqualTo(4)); // swapped — fresh deck idx + Assert.That(swapBody.Self[2].Idx, Is.EqualTo(3)); } [Test]