Files

186 lines
8.0 KiB
C#
Raw Permalink Normal View History

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
{
// 镜像失败不影响主流程
}
}
}