using System.Text.Json; using System.Text.Json.Nodes; using System.Text.Json.Serialization; namespace Ananta.Server.Configuration; /// /// GM 面板「实时调参」用的运行时配置编辑器。 /// /// 为什么需要它:以前改任何一个 private-server.json 里的数值都要 /// ① 手编两份 json(源码目录 + bin 运行时副本)② 重启服务端。 /// 现在面板直接改**被加载的那一份** json 并热重载 , /// 改完立刻生效(每 tick 读配置的子系统,如车流 / 市民 / 人群)。 /// /// ⛑️ 不是所有配置都能热生效:端口、路径、登录期下发的东西是**启动时读一次**的, /// 改了要重启。UI 里对这两组做了提示。 /// internal static class RuntimeConfigEditor { /// 启动时读一次、改完必须重启的顶层分组。 private static readonly HashSet RestartRequiredGroups = new(StringComparer.OrdinalIgnoreCase) { "network", "paths", "admin", "logging", "client" }; private static readonly JsonSerializerOptions ReadOptions = new() { PropertyNameCaseInsensitive = true, ReadCommentHandling = JsonCommentHandling.Skip, AllowTrailingCommas = true, Converters = { new JsonStringEnumConverter(System.Text.Json.JsonNamingPolicy.CamelCase) }, }; private static readonly JsonSerializerOptions WriteOptions = new() { WriteIndented = true }; /// 把整份配置原样读成 JsonObject(保留文件里我们没建模的键)。 internal static JsonObject ReadRaw() => JsonNode.Parse(File.ReadAllText(PrivateServerConfigStore.ConfigPath)) as JsonObject ?? new JsonObject(); /// 需要重启才生效的顶层分组名。 internal static string[] RestartGroups => RestartRequiredGroups.OrderBy(x => x).ToArray(); /// /// 应用一组「点号路径 → 新值」的改动:写回 json + 热重载配置。 /// /// 形如 {"gameplay.aether.trafficTarget": 12}。值是 JsonNode(字符串用带引号的 JSON 字面量区分类型)。 /// 成功返回 (true, 变更条数, null);失败返回 (false, 0, 错误信息),此时文件未被改动。 internal static (bool Ok, int Applied, string? Error) Apply(IReadOnlyDictionary changes) { if (changes.Count == 0) return (true, 0, null); var path = PrivateServerConfigStore.ConfigPath; string backup; JsonObject root; try { backup = File.ReadAllText(path); root = JsonNode.Parse(backup) as JsonObject ?? new JsonObject(); } catch (Exception ex) { return (false, 0, "读配置失败: " + ex.Message); } var applied = 0; foreach (var (key, value) in changes) { var segments = key.Split('.', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); if (segments.Length == 0) continue; // 定位(必要时创建)父级对象 var node = root; for (var i = 0; i < segments.Length - 1; i++) { if (node[segments[i]] is not JsonObject child) { child = new JsonObject(); node[segments[i]] = child; } node = child; } var leaf = segments[^1]; // 类型守恒:原来是数字就写数字,原来是布尔就写布尔,避免 "target": "12" 这种把配置写坏。 node[leaf] = Coerce(node[leaf], value); applied++; } string text; try { text = root.ToJsonString(WriteOptions); // 先反序列化验一遍,写坏了直接回滚——不能让面板把一个跑着的服务端配置写崩。 var probe = JsonSerializer.Deserialize(text, ReadOptions) ?? throw new InvalidDataException("配置反序列化为 null"); probe.Validate(); } catch (Exception ex) { return (false, 0, "校验失败(文件未改动): " + ex.Message); } try { File.WriteAllText(path, text); MirrorToRuntimeCopy(text); } catch (Exception ex) { // 写一半失败就还原,别留下半截文件。 try { File.WriteAllText(path, backup); } catch { /* 尽力而为 */ } return (false, 0, "写文件失败(已还原): " + ex.Message); } try { var reloaded = JsonSerializer.Deserialize(text, ReadOptions)!; PrivateServerConfigStore.Initialize(reloaded, path); } catch (Exception ex) { return (true, applied, "已写入并重载失败(下次启动生效): " + ex.Message); } return (true, applied, null); } /// /// 类型守恒:面板传来的都是 JSON 字面量,但用户可能在数字框里敲了 12 也可能敲了 "12"。 /// 以**文件里原有的类型**为准做转换,转不出来就原样写入(让反序列化去报错)。 /// private static JsonNode? Coerce(JsonNode? existing, JsonNode? incoming) { if (incoming is null) return null; // 原来没有这个键(新增项)→ 原样写。 if (existing is null) return incoming.DeepClone(); switch (existing) { case JsonValue oldVal when oldVal.TryGetValue(out _): if (incoming is JsonValue iv && iv.TryGetValue(out var l)) return JsonValue.Create(l); if (incoming is JsonValue iv2 && iv2.TryGetValue(out var d)) return JsonValue.Create((long)Math.Round(d)); if (long.TryParse(incoming.ToString(), out var pl)) return JsonValue.Create(pl); return incoming.DeepClone(); case JsonValue oldVal2 when oldVal2.TryGetValue(out _): if (incoming is JsonValue bv && bv.TryGetValue(out var b)) return JsonValue.Create(b); if (bool.TryParse(incoming.ToString(), out var pb)) return JsonValue.Create(pb); return JsonValue.Create(incoming.ToString() is "1" or "true" or "True"); case JsonValue oldVal3 when oldVal3.TryGetValue(out _): if (incoming is JsonValue dv && dv.TryGetValue(out var dd)) return JsonValue.Create(dd); if (double.TryParse(incoming.ToString(), System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var pd)) return JsonValue.Create(pd); return incoming.DeepClone(); default: return JsonValue.Create(incoming.ToString()); } } /// /// 同步到 Ananta.Server/Ananta.App/bin/Debug/net8.0/config/private-server.json。 /// 我们一直手工同步这两份(⛔ 只改源码目录那份不生效),这里顺手做掉,避免面板改完又出现「改了没效果」。 /// private static void MirrorToRuntimeCopy(string text) { try { var root = PrivateServerConfigStore.ProjectRoot; var mirror = Path.Combine(root, "Ananta.Server", "Ananta.App", "bin", "Debug", "net8.0", "config", "private-server.json"); if (!File.Exists(mirror)) return; if (string.Equals(Path.GetFullPath(mirror), Path.GetFullPath(PrivateServerConfigStore.ConfigPath), StringComparison.OrdinalIgnoreCase)) return; File.WriteAllText(mirror, text); } catch { // 镜像失败不影响主流程 } } }