186 lines
8.0 KiB
C#
186 lines
8.0 KiB
C#
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
|
|||
|
|
{
|
|||
|
|
// 镜像失败不影响主流程
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|