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)
|
||||
|
||||
Reference in New Issue
Block a user