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.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 <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>
/// <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
{
/// <summary>
@@ -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.
/// </summary>
public static readonly long DummyCardId = 100011010;
public const long DummyCardId = 100011010L;
/// <summary>
/// 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>
public const long FakeOpponentViewerId = 999_999_999L;
public static MsgEnvelope BuildMatched(long playerViewerId, long opponentViewerId, string battleId)
{
var body = new Dictionary<string, object?>
{
["selfInfo"] = new Dictionary<string, object?>
{
["country_code"] = "KOR",
["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 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<string, object?>
{
["turnState"] = 0, // player goes first
["battleType"] = 11, // TK2 NetworkBattleType
["selfInfo"] = new Dictionary<string, object?>
{
["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 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<string, object?>
{
["self"] = 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 },
},
["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);
}
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) }));
/// <summary>
/// Initial 3-card hand idxs from <see cref="BuildDeal"/>. Each position in this array
@@ -144,81 +73,55 @@ public static class ScriptedLifecycle
return hand;
}
public static MsgEnvelope BuildSwapResponse(IReadOnlyList<long> hand)
{
var body = new Dictionary<string, object?>
{
["self"] = BuildPosIdxList(hand),
};
return EnvelopeForPush(NetworkBattleUri.Swap, body);
}
public static MsgEnvelope BuildSwapResponse(IReadOnlyList<long> hand) =>
EnvelopeForPush(NetworkBattleUri.Swap,
new SwapResponseBody(Self: BuildPosIdxList(hand)));
public static MsgEnvelope BuildReady(IReadOnlyList<long> hand)
{
var body = new Dictionary<string, object?>
{
["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<long> hand) =>
EnvelopeForPush(NetworkBattleUri.Ready,
new ReadyBody(
Self: BuildPosIdxList(hand),
Oppo: BuildPosIdxList(InitialHand),
IdxChangeSeed: ScriptedProfiles.ReadyIdxChangeSeed,
Spin: ScriptedProfiles.ReadySpin));
/// <summary>
/// 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.
/// </summary>
public static MsgEnvelope BuildOpponentTurnStart()
{
var body = new Dictionary<string, object?>
{
["spin"] = 100,
};
return EnvelopeForPush(NetworkBattleUri.TurnStart, body);
}
public static MsgEnvelope BuildOpponentTurnStart() =>
EnvelopeForPush(NetworkBattleUri.TurnStart,
new OpponentTurnStartBody(Spin: ScriptedProfiles.OpponentTurnStartSpin));
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++)
{
list.Add(new Dictionary<string, object?> { ["pos"] = pos, ["idx"] = (int)hand[pos] });
list.Add(new PosIdx(Pos: pos, Idx: (int)hand[pos]));
}
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++)
{
deck.Add(new Dictionary<string, object?>
{
["idx"] = i,
["cardId"] = DummyCardId,
});
deck.Add(new DeckCardRef(Idx: i, CardId: DummyCardId));
}
return deck;
}
private static MsgEnvelope EnvelopeForPush(NetworkBattleUri uri, Dictionary<string, object?> 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);
}
}

View File

@@ -1,12 +1,13 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
namespace SVSim.BattleNode.Protocol;
/// <summary>
/// The shared envelope on every encrypted msg / synchronize frame. Body is opaque
/// (Dictionary&lt;string, object?&gt;) 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
/// <see cref="IMsgBody"/> — either a typed body record (outbound) or a
/// <see cref="RawBody"/> (inbound).
/// </summary>
public sealed record MsgEnvelope(
NetworkBattleUri Uri,
@@ -17,7 +18,7 @@ public sealed record MsgEnvelope(
EmitCategory Cat,
long? PubSeq,
long? PlaySeq,
Dictionary<string, object?> 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<string, object?>
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);
}
/// <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)
@@ -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<string, object?>();
var bodyDict = new Dictionary<string, object?>();
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

View File

@@ -3,7 +3,10 @@ namespace SVSim.BattleNode.Reliability;
/// <summary>
/// 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.
/// </summary>
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<string, object?> BuildAlivePushBody() => new()
{
["scs"] = "ONLINE",
["ocs"] = "ONLINE",
};
}

View File

@@ -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<string, object?> { ["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<string, object?> { ["result"] = 1, ["resultCode"] = 1 });
Body: new BattleFinishBody(Result: ScriptedProfiles.BattleResultPlayerWins));
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
// anywhere upstream could yield a different boxed type and OfType<long> 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<long>();
if (rawBody.Entries.TryGetValue("idxList", out var raw) && raw is System.Collections.IEnumerable seq && raw is not string)
{
var result = new List<long>();
foreach (var item in seq)

View File

@@ -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<string, object?>());
PubSeq: pubSeq, PlaySeq: null, Body: new RawBody(body ?? new Dictionary<string, object?>()));
private static string MakeKey()
{

View File

@@ -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<string, object?>)env.Body["selfInfo"]!;
Assert.That(selfInfo["oppoId"], Is.EqualTo(847666884L));
var oppoInfo = (Dictionary<string, object?>)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<object?>)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<object?>)env.Body["self"]!;
var oppo = (List<object?>)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<object?>)env.Body["self"]!;
Assert.That(self.Count, Is.EqualTo(3));
Assert.That(((Dictionary<string, object?>)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<object?>)env.Body["self"]!;
Assert.That(((Dictionary<string, object?>)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));
}
}

View File

@@ -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<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[0], Is.TypeOf<long>(), "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<string, object?> { ["foo"] = 42 });
Body: new RawBody(new Dictionary<string, object?> { ["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<string, object?>());
Body: new RawBody(new Dictionary<string, object?>()));
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<string, object?> { ["uri"] = "Injected" });
Body: new RawBody(new Dictionary<string, object?> { ["uri"] = "Injected" }));
var ex = Assert.Throws<ArgumentException>(() => 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<string, object?>());
Body: new RawBody(new Dictionary<string, object?>()));
var json = MsgEnvelope.ToJson(env);

View File

@@ -24,7 +24,7 @@ public class MsgPayloadCodecTests
Cat: EmitCategory.Battle,
PubSeq: 3,
PlaySeq: null,
Body: new Dictionary<string, object?>());
Body: new RawBody(new Dictionary<string, object?>()));
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));
}
}

View File

@@ -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()
{

View File

@@ -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<string, object?>());
PubSeq: null, PlaySeq: null, Body: new RawBody(new Dictionary<string, object?>()));
[Test]
public void AssignAndArchive_FirstCall_ReturnsEnvelopeWithPlaySeq1()

View File

@@ -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<string, object?>());
PubSeq: null, PlaySeq: null, Body: new RawBody(new Dictionary<string, object?>()));
[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<object?> of boxed long values).
swapEnv.Body["idxList"] = new List<object?> { 2L };
// (a List<object?> 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<string, object?>
{
["idxList"] = new List<object?> { 2L },
}));
var responses = s.ComputeResponses(swapEnv);
var swapBody = responses[0].Envelope.Body;
var self = (List<object?>)swapBody["self"]!;
Assert.That(((Dictionary<string, object?>)self[0]!)["idx"], Is.EqualTo(1));
Assert.That(((Dictionary<string, object?>)self[1]!)["idx"], Is.EqualTo(4)); // swapped — fresh deck idx
Assert.That(((Dictionary<string, object?>)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]