refactor(battle-node): switch MsgEnvelope.Body to IMsgBody, migrate all sites

This commit is contained in:
gamer147
2026-06-01 10:40:09 -04:00
parent 118be92dc5
commit 5ee270eb16
11 changed files with 183 additions and 231 deletions

View File

@@ -1,4 +1,5 @@
using SVSim.BattleNode.Protocol; using SVSim.BattleNode.Protocol;
using SVSim.BattleNode.Protocol.Bodies;
namespace SVSim.BattleNode.Lifecycle; namespace SVSim.BattleNode.Lifecycle;
@@ -7,21 +8,9 @@ namespace SVSim.BattleNode.Lifecycle;
/// (Matched / BattleStart / Deal / Swap response / Ready) plus a trivial opponent TurnStart /// (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 /// the dispatch pushes after the player's TurnEnd. The values are templated from the TK2
/// captures at <c>data_dumps/captures/battle-traffic_tk2_regular.ndjson</c> — anything /// captures at <c>data_dumps/captures/battle-traffic_tk2_regular.ndjson</c> — anything
/// hardcoded here came from a real prod frame. /// hardcoded here came from a real prod frame, with names + provenance in
/// <see cref="ScriptedProfiles"/>.
/// </summary> /// </summary>
/// <remarks>
/// <para>"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 <c>TurnEnd</c>, and then sit there indefinitely. This
/// is the documented v1 stopping point.</para>
/// <para>
/// All builders go through <see cref="EnvelopeForPush"/>, which injects
/// <c>resultCode = 1</c> into every body. The client's <c>OnReceived</c> drops any
/// synchronize push whose <c>resultCode != Success</c> (absent counts as None=0); leaving
/// it off silently breaks the state machine without surfacing an error.
/// </para>
/// <para>To make this less scripted: see the project README §"Where to extend".</para>
/// </remarks>
public static class ScriptedLifecycle public static class ScriptedLifecycle
{ {
/// <summary> /// <summary>
@@ -29,7 +18,7 @@ public static class ScriptedLifecycle
/// every card-master version we care about, so the client can render it without /// every card-master version we care about, so the client can render it without
/// triggering a card-master-mismatch error. /// triggering a card-master-mismatch error.
/// </summary> /// </summary>
public static readonly long DummyCardId = 100011010; public const long DummyCardId = 100011010L;
/// <summary> /// <summary>
/// Viewer id we present as the opponent on every scripted push. Out-of-range vs. real /// Viewer id we present as the opponent on every scripted push. Out-of-range vs. real
@@ -37,87 +26,27 @@ public static class ScriptedLifecycle
/// </summary> /// </summary>
public const long FakeOpponentViewerId = 999_999_999L; public const long FakeOpponentViewerId = 999_999_999L;
public static MsgEnvelope BuildMatched(long playerViewerId, long opponentViewerId, string battleId) public static MsgEnvelope BuildMatched(long playerViewerId, long opponentViewerId, string battleId) =>
{ EnvelopeForPush(NetworkBattleUri.Matched,
var body = new Dictionary<string, object?> new MatchedBody(
{ SelfInfo: ScriptedProfiles.PlayerMatchedProfile with { OppoId = opponentViewerId },
["selfInfo"] = new Dictionary<string, object?> OppoInfo: ScriptedProfiles.OpponentMatchedProfile with { OppoId = playerViewerId },
{ SelfDeck: BuildDummyDeck()),
["country_code"] = "KOR", bid: battleId);
["userName"] = "Player",
["sleeveId"] = "3000011",
["emblemId"] = "701441011",
["degreeId"] = "300003",
["fieldId"] = 43,
["isOfficial"] = 0,
["oppoId"] = opponentViewerId,
["seed"] = 17548138L,
},
["oppoInfo"] = new Dictionary<string, object?>
{
["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 BuildBattleStart(long playerViewerId) public static MsgEnvelope BuildBattleStart(long playerViewerId) =>
{ EnvelopeForPush(NetworkBattleUri.BattleStart,
var body = new Dictionary<string, object?> new BattleStartBody(
{ TurnState: 0, // player goes first
["turnState"] = 0, // player goes first BattleType: 11, // TK2 NetworkBattleType
["battleType"] = 11, // TK2 NetworkBattleType SelfInfo: ScriptedProfiles.PlayerBattleStartProfile,
["selfInfo"] = new Dictionary<string, object?> OppoInfo: ScriptedProfiles.OpponentBattleStartProfile));
{
["rank"] = "10",
["battlePoint"] = "6270",
["classId"] = "1",
["charaId"] = "1",
["cardMasterName"] = "card_master_node_10015",
},
["oppoInfo"] = new Dictionary<string, object?>
{
["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 BuildDeal() public static MsgEnvelope BuildDeal() =>
{ EnvelopeForPush(NetworkBattleUri.Deal,
var body = new Dictionary<string, object?> new DealBody(
{ Self: new[] { new PosIdx(0, 1), new PosIdx(1, 2), new PosIdx(2, 3) },
["self"] = new List<object?> Oppo: new[] { new PosIdx(0, 1), new PosIdx(1, 2), new PosIdx(2, 3) }));
{
new Dictionary<string, object?> { ["pos"] = 0, ["idx"] = 1 },
new Dictionary<string, object?> { ["pos"] = 1, ["idx"] = 2 },
new Dictionary<string, object?> { ["pos"] = 2, ["idx"] = 3 },
},
["oppo"] = new List<object?>
{
new Dictionary<string, object?> { ["pos"] = 0, ["idx"] = 1 },
new Dictionary<string, object?> { ["pos"] = 1, ["idx"] = 2 },
new Dictionary<string, object?> { ["pos"] = 2, ["idx"] = 3 },
},
};
return EnvelopeForPush(NetworkBattleUri.Deal, body);
}
/// <summary> /// <summary>
/// Initial 3-card hand idxs from <see cref="BuildDeal"/>. Each position in this array /// Initial 3-card hand idxs from <see cref="BuildDeal"/>. Each position in this array
@@ -144,81 +73,55 @@ public static class ScriptedLifecycle
return hand; return hand;
} }
public static MsgEnvelope BuildSwapResponse(IReadOnlyList<long> hand) public static MsgEnvelope BuildSwapResponse(IReadOnlyList<long> hand) =>
{ EnvelopeForPush(NetworkBattleUri.Swap,
var body = new Dictionary<string, object?> new SwapResponseBody(Self: BuildPosIdxList(hand)));
{
["self"] = BuildPosIdxList(hand),
};
return EnvelopeForPush(NetworkBattleUri.Swap, body);
}
public static MsgEnvelope BuildReady(IReadOnlyList<long> hand) public static MsgEnvelope BuildReady(IReadOnlyList<long> hand) =>
{ EnvelopeForPush(NetworkBattleUri.Ready,
var body = new Dictionary<string, object?> new ReadyBody(
{ Self: BuildPosIdxList(hand),
["self"] = BuildPosIdxList(hand), Oppo: BuildPosIdxList(InitialHand),
// Opponent hand stays at the static 3 cards for v1. IdxChangeSeed: ScriptedProfiles.ReadyIdxChangeSeed,
["oppo"] = BuildPosIdxList(InitialHand), Spin: ScriptedProfiles.ReadySpin));
["idxChangeSeed"] = 771335280,
["spin"] = 243,
};
return EnvelopeForPush(NetworkBattleUri.Ready, body);
}
/// <summary> /// <summary>
/// Generic TurnStart push used to transition the client into "Opponent's turn…" state /// 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 /// after the player's TurnEnd. v1 doesn't simulate the opponent — once this lands the
/// client sits at the opponent-turn display indefinitely. /// client sits at the opponent-turn display indefinitely.
/// </summary> /// </summary>
public static MsgEnvelope BuildOpponentTurnStart() public static MsgEnvelope BuildOpponentTurnStart() =>
{ EnvelopeForPush(NetworkBattleUri.TurnStart,
var body = new Dictionary<string, object?> new OpponentTurnStartBody(Spin: ScriptedProfiles.OpponentTurnStartSpin));
{
["spin"] = 100,
};
return EnvelopeForPush(NetworkBattleUri.TurnStart, body);
}
private static List<object?> BuildPosIdxList(IReadOnlyList<long> hand) private static IReadOnlyList<PosIdx> BuildPosIdxList(IReadOnlyList<long> hand)
{ {
var list = new List<object?>(hand.Count); var list = new List<PosIdx>(hand.Count);
for (var pos = 0; pos < hand.Count; pos++) for (var pos = 0; pos < hand.Count; pos++)
{ {
list.Add(new Dictionary<string, object?> { ["pos"] = pos, ["idx"] = (int)hand[pos] }); list.Add(new PosIdx(Pos: pos, Idx: (int)hand[pos]));
} }
return list; return list;
} }
private static List<object?> BuildDummyDeck() private static IReadOnlyList<DeckCardRef> BuildDummyDeck()
{ {
var deck = new List<object?>(30); var deck = new List<DeckCardRef>(30);
for (var i = 1; i <= 30; i++) for (var i = 1; i <= 30; i++)
{ {
deck.Add(new Dictionary<string, object?> deck.Add(new DeckCardRef(Idx: i, CardId: DummyCardId));
{
["idx"] = i,
["cardId"] = DummyCardId,
});
} }
return deck; return deck;
} }
private static MsgEnvelope EnvelopeForPush(NetworkBattleUri uri, Dictionary<string, object?> body, string? bid = null) private static MsgEnvelope EnvelopeForPush(NetworkBattleUri uri, IMsgBody body, string? bid = null) =>
{ new(uri,
// 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,
ViewerId: FakeOpponentViewerId, ViewerId: FakeOpponentViewerId,
Uuid: "node-stub", Uuid: "node-stub",
Bid: bid, Bid: bid,
Try: 0, Try: 0,
Cat: EmitCategory.Battle, Cat: EmitCategory.Battle,
PubSeq: null, PubSeq: null,
PlaySeq: null, // OutboundSequencer.AssignAndArchive stamps this PlaySeq: null,
Body: body); Body: body);
}
} }

View File

@@ -1,12 +1,13 @@
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
namespace SVSim.BattleNode.Protocol; namespace SVSim.BattleNode.Protocol;
/// <summary> /// <summary>
/// The shared envelope on every encrypted msg / synchronize frame. Body is opaque /// The shared envelope on every encrypted msg / synchronize frame. Body is
/// (Dictionary&lt;string, object?&gt;) because shape varies per Uri — typed register-action /// <see cref="IMsgBody"/> — either a typed body record (outbound) or a
/// models come in a later slice. /// <see cref="RawBody"/> (inbound).
/// </summary> /// </summary>
public sealed record MsgEnvelope( public sealed record MsgEnvelope(
NetworkBattleUri Uri, NetworkBattleUri Uri,
@@ -17,7 +18,7 @@ public sealed record MsgEnvelope(
EmitCategory Cat, EmitCategory Cat,
long? PubSeq, long? PubSeq,
long? PlaySeq, long? PlaySeq,
Dictionary<string, object?> Body) IMsgBody Body)
{ {
private static readonly JsonSerializerOptions Options = CreateOptions(); private static readonly JsonSerializerOptions Options = CreateOptions();
@@ -30,9 +31,10 @@ public sealed record MsgEnvelope(
{ {
var opt = new JsonSerializerOptions 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, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
}; };
opt.Converters.Add(new JsonStringEnumConverter()); opt.Converters.Add(new JsonStringEnumConverter());
return opt; return opt;
@@ -40,26 +42,76 @@ public sealed record MsgEnvelope(
public static string ToJson(MsgEnvelope env) public static string ToJson(MsgEnvelope env)
{ {
var doc = new Dictionary<string, object?> JsonObject result;
if (env.Body is RawBody raw)
{ {
["uri"] = env.Uri.ToString(), // Inbound-echo path: flatten Entries to top-level keys, same as before
["viewerId"] = env.ViewerId, // the typed-body refactor.
["uuid"] = env.Uuid, result = new JsonObject();
["try"] = env.Try, foreach (var (k, v) in raw.Entries)
["cat"] = (int)env.Cat, {
}; if (ReservedEnvelopeKeys.Contains(k))
if (env.Bid is not null) doc["bid"] = env.Bid; throw new ArgumentException(
if (env.PubSeq.HasValue) doc["pubSeq"] = env.PubSeq.Value; $"RawBody key '{k}' collides with a reserved envelope field. " +
if (env.PlaySeq.HasValue) doc["playSeq"] = env.PlaySeq.Value; $"Move it to a typed field on MsgEnvelope.",
foreach (var (k, v) in env.Body) nameof(env));
{ result[k] = ToJsonNode(v);
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); 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);
}
/// <summary>
/// Convert a boxed CLR value (as stored in <see cref="RawBody.Entries"/>) to a JsonNode.
/// Explicit type switch on the runtime type — `JsonValue.Create(object?)` would create
/// a `JsonValueCustomized&lt;object&gt;` that requires a TypeInfoResolver at serialize time
/// (introduced in S.T.Json 8.0 source-gen mode).
/// </summary>
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<string, object?>; nested
// arrays as List<object?>. FromJson is the source of these shapes — see ToObject.
IDictionary<string, object?> dict => DictToJsonObject(dict),
IReadOnlyList<object?> 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<string, object?> dict)
{
var obj = new JsonObject();
foreach (var (k, v) in dict) obj[k] = ToJsonNode(v);
return obj;
}
private static JsonArray ListToJsonArray(IReadOnlyList<object?> list)
{
var arr = new JsonArray();
foreach (var v in list) arr.Add(ToJsonNode(v));
return arr;
} }
public static MsgEnvelope FromJson(string json) 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 pubSeq = root.TryGetProperty("pubSeq", out var psEl) ? psEl.GetInt64() : (long?)null;
var playSeq = root.TryGetProperty("playSeq", out var plsEl) ? plsEl.GetInt64() : (long?)null; var playSeq = root.TryGetProperty("playSeq", out var plsEl) ? plsEl.GetInt64() : (long?)null;
var body = new Dictionary<string, object?>(); var bodyDict = new Dictionary<string, object?>();
foreach (var prop in root.EnumerateObject()) foreach (var prop in root.EnumerateObject())
{ {
if (ReservedEnvelopeKeys.Contains(prop.Name)) continue; 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 private static object? ToObject(JsonElement el) => el.ValueKind switch

View File

@@ -3,7 +3,10 @@ namespace SVSim.BattleNode.Reliability;
/// <summary> /// <summary>
/// Body builders for the alive channel. The timer/loop that drives 5s emits lives on /// 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. /// 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.
/// </summary> /// </summary>
public static class Gungnir public static class Gungnir
{ {
@@ -14,10 +17,4 @@ public static class Gungnir
["currentSeq"] = tracker.HighWaterMark, ["currentSeq"] = tracker.HighWaterMark,
// actionSeq omitted in v1 — no turn-transition flag yet. // actionSeq omitted in v1 — no turn-transition flag yet.
}; };
public static Dictionary<string, object?> BuildAlivePushBody() => new()
{
["scs"] = "ONLINE",
["ocs"] = "ONLINE",
};
} }

View File

@@ -4,6 +4,7 @@ using System.Text;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using SVSim.BattleNode.Lifecycle; using SVSim.BattleNode.Lifecycle;
using SVSim.BattleNode.Protocol; using SVSim.BattleNode.Protocol;
using SVSim.BattleNode.Protocol.Bodies;
using SVSim.BattleNode.Reliability; using SVSim.BattleNode.Reliability;
using SVSim.BattleNode.Wire; using SVSim.BattleNode.Wire;
@@ -169,7 +170,7 @@ public sealed class BattleSession
Cat: EmitCategory.General, Cat: EmitCategory.General,
PubSeq: null, PubSeq: null,
PlaySeq: null, PlaySeq: null,
Body: Gungnir.BuildAlivePushBody()); Body: new AlivePushBody(Scs: "ONLINE", Ocs: "ONLINE"));
await PushNoStockAsync(aliveEnv, eventName: "alive"); await PushNoStockAsync(aliveEnv, eventName: "alive");
} }
catch (Exception ex) catch (Exception ex)
@@ -263,7 +264,7 @@ public sealed class BattleSession
Cat: EmitCategory.General, Cat: EmitCategory.General,
PubSeq: null, PubSeq: null,
PlaySeq: null, PlaySeq: null,
Body: new Dictionary<string, object?> { ["resultCode"] = 1 }); Body: new ResultCodeOnlyBody());
private MsgEnvelope BuildBattleFinishNoContest() => new( private MsgEnvelope BuildBattleFinishNoContest() => new(
NetworkBattleUri.BattleFinish, NetworkBattleUri.BattleFinish,
@@ -274,7 +275,7 @@ public sealed class BattleSession
Cat: EmitCategory.Battle, Cat: EmitCategory.Battle,
PubSeq: null, PubSeq: null,
PlaySeq: null, PlaySeq: null,
Body: new Dictionary<string, object?> { ["result"] = 1, ["resultCode"] = 1 }); Body: new BattleFinishBody(Result: ScriptedProfiles.BattleResultPlayerWins));
private static IReadOnlyList<long> ExtractIdxList(MsgEnvelope env) private static IReadOnlyList<long> ExtractIdxList(MsgEnvelope env)
{ {
@@ -282,7 +283,8 @@ public sealed class BattleSession
// string). MsgEnvelope.FromJson should box small ints as long, but a parser quirk // string). MsgEnvelope.FromJson should box small ints as long, but a parser quirk
// anywhere upstream could yield a different boxed type and OfType<long> would silently // anywhere upstream could yield a different boxed type and OfType<long> would silently
// drop the entries — that broke the v1 mulligan during smoke. // 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<long>();
if (rawBody.Entries.TryGetValue("idxList", out var raw) && raw is System.Collections.IEnumerable seq && raw is not string)
{ {
var result = new List<long>(); var result = new List<long>();
foreach (var item in seq) foreach (var item in seq)

View File

@@ -66,7 +66,7 @@ public class BattleNodeFlowTests
Cat: uri == NetworkBattleUri.InitNetwork ? EmitCategory.General Cat: uri == NetworkBattleUri.InitNetwork ? EmitCategory.General
: uri == NetworkBattleUri.InitBattle ? EmitCategory.Matching : uri == NetworkBattleUri.InitBattle ? EmitCategory.Matching
: EmitCategory.Battle, : EmitCategory.Battle,
PubSeq: pubSeq, PlaySeq: null, Body: body ?? new Dictionary<string, object?>()); PubSeq: pubSeq, PlaySeq: null, Body: new RawBody(body ?? new Dictionary<string, object?>()));
private static string MakeKey() private static string MakeKey()
{ {

View File

@@ -1,6 +1,7 @@
using NUnit.Framework; using NUnit.Framework;
using SVSim.BattleNode.Lifecycle; using SVSim.BattleNode.Lifecycle;
using SVSim.BattleNode.Protocol; using SVSim.BattleNode.Protocol;
using SVSim.BattleNode.Protocol.Bodies;
namespace SVSim.UnitTests.BattleNode.Lifecycle; namespace SVSim.UnitTests.BattleNode.Lifecycle;
@@ -13,40 +14,39 @@ public class ScriptedLifecycleTests
var env = ScriptedLifecycle.BuildMatched(playerViewerId: 906243102, opponentViewerId: 847666884, battleId: "b"); var env = ScriptedLifecycle.BuildMatched(playerViewerId: 906243102, opponentViewerId: 847666884, battleId: "b");
Assert.That(env.Uri, Is.EqualTo(NetworkBattleUri.Matched)); Assert.That(env.Uri, Is.EqualTo(NetworkBattleUri.Matched));
var selfInfo = (Dictionary<string, object?>)env.Body["selfInfo"]!; var body = (MatchedBody)env.Body;
Assert.That(selfInfo["oppoId"], Is.EqualTo(847666884L)); Assert.That(body.SelfInfo.OppoId, Is.EqualTo(847666884L));
var oppoInfo = (Dictionary<string, object?>)env.Body["oppoInfo"]!; Assert.That(body.OppoInfo.OppoId, Is.EqualTo(906243102L));
Assert.That(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.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] [Test]
public void BuildMatched_ContainsThirtyCardSelfDeck() public void BuildMatched_ContainsThirtyCardSelfDeck()
{ {
var env = ScriptedLifecycle.BuildMatched(1, 2, "b"); var env = ScriptedLifecycle.BuildMatched(1, 2, "b");
var deck = (List<object?>)env.Body["selfDeck"]!; var body = (MatchedBody)env.Body;
Assert.That(deck.Count, Is.EqualTo(30)); Assert.That(body.SelfDeck.Count, Is.EqualTo(30));
} }
[Test] [Test]
public void BuildBattleStart_HasTurnStateZeroAndBattleTypeEleven() public void BuildBattleStart_HasTurnStateZeroAndBattleTypeEleven()
{ {
var env = ScriptedLifecycle.BuildBattleStart(playerViewerId: 1); var env = ScriptedLifecycle.BuildBattleStart(playerViewerId: 1);
Assert.That(env.Body["turnState"], Is.EqualTo(0)); var body = (BattleStartBody)env.Body;
Assert.That(env.Body["battleType"], Is.EqualTo(11)); Assert.That(body.TurnState, Is.EqualTo(0));
Assert.That(body.BattleType, Is.EqualTo(11));
} }
[Test] [Test]
public void BuildDeal_HasThreeSelfAndThreeOppoEntries() public void BuildDeal_HasThreeSelfAndThreeOppoEntries()
{ {
var env = ScriptedLifecycle.BuildDeal(); var env = ScriptedLifecycle.BuildDeal();
var self = (List<object?>)env.Body["self"]!; var body = (DealBody)env.Body;
var oppo = (List<object?>)env.Body["oppo"]!; Assert.That(body.Self.Count, Is.EqualTo(3));
Assert.That(self.Count, Is.EqualTo(3)); Assert.That(body.Oppo.Count, Is.EqualTo(3));
Assert.That(oppo.Count, Is.EqualTo(3));
} }
[Test] [Test]
@@ -75,19 +75,19 @@ public class ScriptedLifecycleTests
public void BuildSwapResponse_RendersGivenHandAsPositions() public void BuildSwapResponse_RendersGivenHandAsPositions()
{ {
var env = ScriptedLifecycle.BuildSwapResponse(new long[] { 1, 4, 3 }); var env = ScriptedLifecycle.BuildSwapResponse(new long[] { 1, 4, 3 });
var self = (List<object?>)env.Body["self"]!; var body = (SwapResponseBody)env.Body;
Assert.That(self.Count, Is.EqualTo(3)); Assert.That(body.Self.Count, Is.EqualTo(3));
Assert.That(((Dictionary<string, object?>)self[1]!)["idx"], Is.EqualTo(4)); Assert.That(body.Self[1].Idx, Is.EqualTo(4));
} }
[Test] [Test]
public void BuildReady_IncludesIdxChangeSeedAndSpin_AndUsesGivenHand() public void BuildReady_IncludesIdxChangeSeedAndSpin_AndUsesGivenHand()
{ {
var env = ScriptedLifecycle.BuildReady(new long[] { 1, 4, 3 }); var env = ScriptedLifecycle.BuildReady(new long[] { 1, 4, 3 });
Assert.That(env.Body.ContainsKey("idxChangeSeed"), Is.True); var body = (ReadyBody)env.Body;
Assert.That(env.Body.ContainsKey("spin"), Is.True); Assert.That(body.IdxChangeSeed, Is.EqualTo(771_335_280));
var self = (List<object?>)env.Body["self"]!; Assert.That(body.Spin, Is.EqualTo(243));
Assert.That(((Dictionary<string, object?>)self[1]!)["idx"], Is.EqualTo(4)); Assert.That(body.Self[1].Idx, Is.EqualTo(4));
} }
[Test] [Test]
@@ -95,6 +95,7 @@ public class ScriptedLifecycleTests
{ {
var env = ScriptedLifecycle.BuildOpponentTurnStart(); var env = ScriptedLifecycle.BuildOpponentTurnStart();
Assert.That(env.Uri, Is.EqualTo(NetworkBattleUri.TurnStart)); 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));
} }
} }

View File

@@ -1,4 +1,3 @@
using System.Text.Json;
using NUnit.Framework; using NUnit.Framework;
using SVSim.BattleNode.Protocol; using SVSim.BattleNode.Protocol;
@@ -19,7 +18,8 @@ public class MsgEnvelopeTests
var env = MsgEnvelope.FromJson(json); var env = MsgEnvelope.FromJson(json);
var idxList = (List<object?>)env.Body["idxList"]!; var raw = (RawBody)env.Body;
var idxList = (List<object?>)raw.Entries["idxList"]!;
Assert.That(idxList.Count, Is.EqualTo(2)); Assert.That(idxList.Count, Is.EqualTo(2));
Assert.That(idxList[0], Is.TypeOf<long>(), "idxList[0] must be boxed long, not double."); Assert.That(idxList[0], Is.TypeOf<long>(), "idxList[0] must be boxed long, not double.");
Assert.That(idxList[0], Is.EqualTo(2L)); Assert.That(idxList[0], Is.EqualTo(2L));
@@ -39,7 +39,7 @@ public class MsgEnvelopeTests
Cat: EmitCategory.General, Cat: EmitCategory.General,
PubSeq: null, PubSeq: null,
PlaySeq: null, PlaySeq: null,
Body: new Dictionary<string, object?> { ["foo"] = 42 }); Body: new RawBody(new Dictionary<string, object?> { ["foo"] = 42 }));
var json = MsgEnvelope.ToJson(env); var json = MsgEnvelope.ToJson(env);
var back = MsgEnvelope.FromJson(json); var back = MsgEnvelope.FromJson(json);
@@ -49,7 +49,7 @@ public class MsgEnvelopeTests
Assert.That(back.Uuid, Is.EqualTo("udid-1234")); Assert.That(back.Uuid, Is.EqualTo("udid-1234"));
Assert.That(back.Bid, Is.EqualTo("597830888107")); Assert.That(back.Bid, Is.EqualTo("597830888107"));
Assert.That(back.Cat, Is.EqualTo(EmitCategory.General)); 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] [Test]
@@ -64,7 +64,7 @@ public class MsgEnvelopeTests
Cat: EmitCategory.Battle, Cat: EmitCategory.Battle,
PubSeq: null, PubSeq: null,
PlaySeq: 5, PlaySeq: 5,
Body: new Dictionary<string, object?>()); Body: new RawBody(new Dictionary<string, object?>()));
var json = MsgEnvelope.ToJson(env); var json = MsgEnvelope.ToJson(env);
@@ -85,7 +85,7 @@ public class MsgEnvelopeTests
} }
[Test] [Test]
public void ToJson_BodyContainingReservedKey_Throws() public void ToJson_RawBodyContainingReservedKey_Throws()
{ {
var env = new MsgEnvelope( var env = new MsgEnvelope(
Uri: NetworkBattleUri.Loaded, Uri: NetworkBattleUri.Loaded,
@@ -96,7 +96,7 @@ public class MsgEnvelopeTests
Cat: EmitCategory.Battle, Cat: EmitCategory.Battle,
PubSeq: null, PubSeq: null,
PlaySeq: null, PlaySeq: null,
Body: new Dictionary<string, object?> { ["uri"] = "Injected" }); Body: new RawBody(new Dictionary<string, object?> { ["uri"] = "Injected" }));
var ex = Assert.Throws<ArgumentException>(() => MsgEnvelope.ToJson(env)); var ex = Assert.Throws<ArgumentException>(() => MsgEnvelope.ToJson(env));
Assert.That(ex!.Message, Does.Contain("uri")); Assert.That(ex!.Message, Does.Contain("uri"));
@@ -114,7 +114,7 @@ public class MsgEnvelopeTests
Cat: EmitCategory.Battle, Cat: EmitCategory.Battle,
PubSeq: null, PubSeq: null,
PlaySeq: null, PlaySeq: null,
Body: new Dictionary<string, object?>()); Body: new RawBody(new Dictionary<string, object?>()));
var json = MsgEnvelope.ToJson(env); var json = MsgEnvelope.ToJson(env);

View File

@@ -24,7 +24,7 @@ public class MsgPayloadCodecTests
Cat: EmitCategory.Battle, Cat: EmitCategory.Battle,
PubSeq: 3, PubSeq: 3,
PlaySeq: null, PlaySeq: null,
Body: new Dictionary<string, object?>()); Body: new RawBody(new Dictionary<string, object?>()));
var bytes = MsgPayloadCodec.Encode(env, key: FreshKey()); var bytes = MsgPayloadCodec.Encode(env, key: FreshKey());
var back = MsgPayloadCodec.Decode(bytes); var back = MsgPayloadCodec.Decode(bytes);
@@ -48,6 +48,6 @@ public class MsgPayloadCodecTests
Assert.That(env.Uri, Is.EqualTo(NetworkBattleUri.InitNetwork)); Assert.That(env.Uri, Is.EqualTo(NetworkBattleUri.InitNetwork));
Assert.That(env.Cat, Is.EqualTo(EmitCategory.General)); 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));
} }
} }

View File

@@ -6,14 +6,6 @@ namespace SVSim.UnitTests.BattleNode.Reliability;
[TestFixture] [TestFixture]
public class GungnirTests 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] [Test]
public void BuildAliveEmit_CarriesCurrentSeqFromTracker() public void BuildAliveEmit_CarriesCurrentSeqFromTracker()
{ {

View File

@@ -9,7 +9,7 @@ public class OutboundSequencerTests
{ {
private static MsgEnvelope MakeEnvelope(NetworkBattleUri uri) => private static MsgEnvelope MakeEnvelope(NetworkBattleUri uri) =>
new(uri, ViewerId: 1, Uuid: "u", Bid: null, Try: 0, Cat: EmitCategory.Battle, new(uri, ViewerId: 1, Uuid: "u", Bid: null, Try: 0, Cat: EmitCategory.Battle,
PubSeq: null, PlaySeq: null, Body: new Dictionary<string, object?>()); PubSeq: null, PlaySeq: null, Body: new RawBody(new Dictionary<string, object?>()));
[Test] [Test]
public void AssignAndArchive_FirstCall_ReturnsEnvelopeWithPlaySeq1() public void AssignAndArchive_FirstCall_ReturnsEnvelopeWithPlaySeq1()

View File

@@ -2,6 +2,7 @@
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using NUnit.Framework; using NUnit.Framework;
using SVSim.BattleNode.Protocol; using SVSim.BattleNode.Protocol;
using SVSim.BattleNode.Protocol.Bodies;
using SVSim.BattleNode.Sessions; using SVSim.BattleNode.Sessions;
namespace SVSim.UnitTests.BattleNode.Sessions; namespace SVSim.UnitTests.BattleNode.Sessions;
@@ -17,7 +18,7 @@ public class BattleSessionDispatchTests
private static MsgEnvelope NewEnvelope(NetworkBattleUri uri) => private static MsgEnvelope NewEnvelope(NetworkBattleUri uri) =>
new(uri, ViewerId: 1, Uuid: "u", Bid: null, Try: 0, Cat: EmitCategory.Battle, new(uri, ViewerId: 1, Uuid: "u", Bid: null, Try: 0, Cat: EmitCategory.Battle,
PubSeq: null, PlaySeq: null, Body: new Dictionary<string, object?>()); PubSeq: null, PlaySeq: null, Body: new RawBody(new Dictionary<string, object?>()));
[Test] [Test]
public void InitNetwork_PushesAckOnly_TransitionsToAwaitingInitBattle() public void InitNetwork_PushesAckOnly_TransitionsToAwaitingInitBattle()
@@ -58,18 +59,22 @@ public class BattleSessionDispatchTests
s.ComputeResponses(NewEnvelope(NetworkBattleUri.InitNetwork)); s.ComputeResponses(NewEnvelope(NetworkBattleUri.InitNetwork));
s.ComputeResponses(NewEnvelope(NetworkBattleUri.InitBattle)); s.ComputeResponses(NewEnvelope(NetworkBattleUri.InitBattle));
s.ComputeResponses(NewEnvelope(NetworkBattleUri.Loaded)); s.ComputeResponses(NewEnvelope(NetworkBattleUri.Loaded));
var swapEnv = NewEnvelope(NetworkBattleUri.Swap);
// Simulate the client's Swap{idxList:[2]}: the dict shape produced by MsgEnvelope.FromJson // Simulate the client's Swap{idxList:[2]}: the dict shape produced by MsgEnvelope.FromJson
// (a List<object?> of boxed long values). // (a List<object?> of boxed long values), wrapped in a RawBody as the inbound type.
swapEnv.Body["idxList"] = new List<object?> { 2L }; 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<string, object?>
{
["idxList"] = new List<object?> { 2L },
}));
var responses = s.ComputeResponses(swapEnv); var responses = s.ComputeResponses(swapEnv);
var swapBody = responses[0].Envelope.Body; var swapBody = (SwapResponseBody)responses[0].Envelope.Body;
var self = (List<object?>)swapBody["self"]!; Assert.That(swapBody.Self[0].Idx, Is.EqualTo(1));
Assert.That(((Dictionary<string, object?>)self[0]!)["idx"], Is.EqualTo(1)); Assert.That(swapBody.Self[1].Idx, Is.EqualTo(4)); // swapped — fresh deck idx
Assert.That(((Dictionary<string, object?>)self[1]!)["idx"], Is.EqualTo(4)); // swapped — fresh deck idx Assert.That(swapBody.Self[2].Idx, Is.EqualTo(3));
Assert.That(((Dictionary<string, object?>)self[2]!)["idx"], Is.EqualTo(3));
} }
[Test] [Test]