Files

187 lines
8.0 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
namespace Ananta.Server.Configuration;
/// <summary>
/// GM 面板「实时调参」用的运行时配置编辑器。
///
/// 为什么需要它:以前改任何一个 <c>private-server.json</c> 里的数值都要
/// ① 手编两份 json(源码目录 + bin 运行时副本)② 重启服务端。
/// 现在面板直接改**被加载的那一份** json 并热重载 <see cref="PrivateServerConfigStore"/>,
/// 改完立刻生效(每 tick 读配置的子系统,如车流 / 市民 / 人群)。
///
/// ⛑️ 不是所有配置都能热生效:端口、路径、登录期下发的东西是**启动时读一次**的,
/// 改了要重启。UI 里对这两组做了提示。
/// </summary>
internal static class RuntimeConfigEditor
{
/// <summary>启动时读一次、改完必须重启的顶层分组。</summary>
private static readonly HashSet<string> 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 };
/// <summary>把整份配置原样读成 JsonObject(保留文件里我们没建模的键)。</summary>
internal static JsonObject ReadRaw()
=> JsonNode.Parse(File.ReadAllText(PrivateServerConfigStore.ConfigPath)) as JsonObject
?? new JsonObject();
/// <summary>需要重启才生效的顶层分组名。</summary>
internal static string[] RestartGroups => RestartRequiredGroups.OrderBy(x => x).ToArray();
/// <summary>
/// 应用一组「点号路径 → 新值」的改动:写回 json + 热重载配置。
/// </summary>
/// <param name="changes">形如 <c>{"gameplay.aether.trafficTarget": 12}</c>。值是 JsonNode(字符串用带引号的 JSON 字面量区分类型)。</param>
/// <returns>成功返回 (true, 变更条数, null);失败返回 (false, 0, 错误信息),此时文件未被改动。</returns>
internal static (bool Ok, int Applied, string? Error) Apply(IReadOnlyDictionary<string, JsonNode?> 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<PrivateServerConfig>(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<PrivateServerConfig>(text, ReadOptions)!;
PrivateServerConfigStore.Initialize(reloaded, path);
}
catch (Exception ex)
{
return (true, applied, "已写入并重载失败(下次启动生效): " + ex.Message);
}
return (true, applied, null);
}
/// <summary>
/// 类型守恒:面板传来的都是 JSON 字面量,但用户可能在数字框里敲了 <c>12</c> 也可能敲了 <c>"12"</c>。
/// 以**文件里原有的类型**为准做转换,转不出来就原样写入(让反序列化去报错)。
/// </summary>
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<long>(out _):
if (incoming is JsonValue iv && iv.TryGetValue<long>(out var l)) return JsonValue.Create(l);
if (incoming is JsonValue iv2 && iv2.TryGetValue<double>(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<bool>(out _):
if (incoming is JsonValue bv && bv.TryGetValue<bool>(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<double>(out _):
if (incoming is JsonValue dv && dv.TryGetValue<double>(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());
}
}
/// <summary>
/// 同步到 <c>Ananta.Server/Ananta.App/bin/Debug/net8.0/config/private-server.json</c>。
/// 我们一直手工同步这两份(⛔ 只改源码目录那份不生效),这里顺手做掉,避免面板改完又出现「改了没效果」。
/// </summary>
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
{
// 镜像失败不影响主流程
}
}
}