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]