using System.Text.Json; using System.Text.Json.Nodes; using System.Text.Json.Serialization; namespace SVSim.BattleNode.Protocol; /// /// The shared envelope on every encrypted msg / synchronize frame. Body is /// — either a typed body record (outbound) or a /// (inbound). /// public sealed record MsgEnvelope( NetworkBattleUri Uri, long ViewerId, string Uuid, string? Bid, int Try, EmitCategory Cat, long? PubSeq, long? PlaySeq, IMsgBody Body) { private static readonly JsonSerializerOptions Options = CreateOptions(); private static readonly HashSet ReservedEnvelopeKeys = new() { "uri", "viewerId", "uuid", "bid", "try", "cat", "pubSeq", "playSeq", }; private static JsonSerializerOptions CreateOptions() { var opt = new JsonSerializerOptions { // 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, }; opt.Converters.Add(new JsonStringEnumConverter()); return opt; } public static string ToJson(MsgEnvelope env) { // Envelope fields MUST come before body fields on the wire. The client's // RealTimeNetworkAgent.SetNetworkInfo iterates the dict in insertion order and // clears _selfDeck on the "uri" key (via GameMgr.InitializeSelfInfo). Any body // field processed before "uri" is wiped before Matching.StartBattleLoad reads // it back. The prod wire emits envelope keys first; we must too. var result = new JsonObject(); 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; if (env.Body is RawBody raw) { // Inbound-echo path: flatten Entries to top-level keys. 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); } } else { // Typed body: serialize via [JsonPropertyName] attributes on the record, // then layer each field onto `result` after the envelope keys. DeepClone // because S.T.Json JsonNodes can only have one parent; reassigning a node // owned by `bodyNode` to `result` would throw without the clone. var bodyNode = (JsonObject)JsonSerializer.SerializeToNode(env.Body, env.Body.GetType(), Options)!; foreach (var prop in bodyNode) { result[prop.Key] = prop.Value?.DeepClone(); } } return result.ToJsonString(Options); } /// /// Convert a boxed CLR value (as stored in ) to a JsonNode. /// Explicit type switch on the runtime type — `JsonValue.Create(object?)` would create /// a `JsonValueCustomized<object>` that requires a TypeInfoResolver at serialize time /// (introduced in S.T.Json 8.0 source-gen mode). /// private static JsonNode? ToJsonNode(object? value) => value switch { null => null, string s => JsonValue.Create(s), bool b => JsonValue.Create(b), long l => JsonValue.Create(l), int i => JsonValue.Create(i), double d => JsonValue.Create(d), decimal m => JsonValue.Create(m), // Inbound-parsed nested objects come through as Dictionary; nested // arrays as List. FromJson is the source of these shapes — see ToObject. IDictionary dict => DictToJsonObject(dict), IReadOnlyList list => ListToJsonArray(list), _ => throw new InvalidOperationException( $"RawBody contains a value of unsupported type {value.GetType().FullName}. " + "Only primitives, nested dicts (object), and nested lists are recognized."), }; private static JsonObject DictToJsonObject(IDictionary dict) { var obj = new JsonObject(); foreach (var (k, v) in dict) obj[k] = ToJsonNode(v); return obj; } private static JsonArray ListToJsonArray(IReadOnlyList list) { var arr = new JsonArray(); foreach (var v in list) arr.Add(ToJsonNode(v)); return arr; } public static MsgEnvelope FromJson(string json) { using var doc = JsonDocument.Parse(json); var root = doc.RootElement; var uri = Enum.Parse(root.GetProperty("uri").GetString()!); var viewerId = root.GetProperty("viewerId").GetInt64(); var uuid = root.GetProperty("uuid").GetString()!; var bid = root.TryGetProperty("bid", out var bidEl) ? bidEl.GetString() : null; var @try = root.TryGetProperty("try", out var tryEl) ? tryEl.GetInt32() : 0; var cat = root.TryGetProperty("cat", out var catEl) ? (EmitCategory)catEl.GetInt32() : EmitCategory.Battle; 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 bodyDict = new Dictionary(); foreach (var prop in root.EnumerateObject()) { if (ReservedEnvelopeKeys.Contains(prop.Name)) continue; bodyDict[prop.Name] = ToObject(prop.Value); } return new MsgEnvelope(uri, viewerId, uuid, bid, @try, cat, pubSeq, playSeq, new RawBody(bodyDict)); } private static object? ToObject(JsonElement el) => el.ValueKind switch { JsonValueKind.String => el.GetString(), // Extracted to a helper because writing the conditional inline as // el.TryGetInt64(out var l) ? l : el.GetDouble() // unifies the conditional's branches to the common implicit-convertible type. long→double // is implicit; so the result type collapses to double and the long value silently widens. // Downstream OfType filters then drop the (now boxed-double) entries, which broke // the mulligan idxList extraction. Separate method returns object explicitly so each // branch boxes its own runtime type. JsonValueKind.Number => ParseNumber(el), JsonValueKind.True => true, JsonValueKind.False => false, JsonValueKind.Null => null, JsonValueKind.Array => el.EnumerateArray().Select(ToObject).ToList(), JsonValueKind.Object => el.EnumerateObject().ToDictionary(p => p.Name, p => ToObject(p.Value)), _ => el.GetRawText(), }; private static object ParseNumber(JsonElement el) { if (el.TryGetInt64(out var l)) return l; return el.GetDouble(); } }