refactor(battle-node): switch MsgEnvelope.Body to IMsgBody, migrate all sites
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<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
|
||||
/// <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<object>` 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
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user