using System.Text; namespace ShrinkNetwork.ServerHost.Framework; public sealed class ServerHostPropertiesLoadResult { public required ServerHostOptions Options { get; init; } public required string FilePath { get; init; } public bool CreatedDefaultFile { get; init; } public IReadOnlyList Warnings { get; init; } = Array.Empty(); } public static class ServerHostProperties { public const string DefaultFileName = "server.properties"; public static ServerHostPropertiesLoadResult LoadOrCreate() { var filePath = ResolveFilePath(); var options = new ServerHostOptions(); var warnings = new List(); var createdDefaultFile = false; if (!File.Exists(filePath)) { var directory = Path.GetDirectoryName(filePath); if (!string.IsNullOrWhiteSpace(directory)) Directory.CreateDirectory(directory); File.WriteAllText(filePath, BuildDefaultFile(filePath, options), Encoding.UTF8); createdDefaultFile = true; } ApplyFile(filePath, options, warnings); options.ApplyEnvironmentOverrides(); NormalizeOptions(options); return new ServerHostPropertiesLoadResult { Options = options, FilePath = filePath, CreatedDefaultFile = createdDefaultFile, Warnings = warnings }; } private static string ResolveFilePath() { var configured = Environment.GetEnvironmentVariable("SHRINK_SERVER_CONFIG_PATH"); if (!string.IsNullOrWhiteSpace(configured)) return Path.GetFullPath(configured.Trim()); var currentDirectoryCandidate = Path.Combine(Environment.CurrentDirectory, DefaultFileName); if (File.Exists(currentDirectoryCandidate)) return currentDirectoryCandidate; return ServerHostOptions.ResolveDefaultConfigFilePath(); } private static void ApplyFile(string filePath, ServerHostOptions options, List warnings) { var directory = Path.GetDirectoryName(filePath) ?? Environment.CurrentDirectory; var lines = File.ReadAllLines(filePath); for (var index = 0; index < lines.Length; index++) { var rawLine = lines[index].Trim(); if (string.IsNullOrWhiteSpace(rawLine) || rawLine.StartsWith("#", StringComparison.Ordinal) || rawLine.StartsWith(";", StringComparison.Ordinal)) continue; var separatorIndex = rawLine.IndexOf('='); if (separatorIndex <= 0) { warnings.Add($"第 {index + 1} 行不是有效的 key=value,将忽略。"); continue; } var key = rawLine[..separatorIndex].Trim(); var value = rawLine[(separatorIndex + 1)..].Trim(); ApplyValue(options, directory, key, value, warnings); } } private static void ApplyValue(ServerHostOptions options, string directory, string key, string value, List warnings) { switch (key) { case "server-port": options.Port = ParseInt(value, options.Port, 1, warnings, key); break; case "unity-assets-path": options.UnityAssetsPath = ResolvePath(directory, value, options.UnityAssetsPath); break; case "scan-output-directory": options.ScanOutputDirectory = ResolvePath(directory, value, options.ScanOutputDirectory); break; case "shared-auth-token": options.SharedAuthToken = value; break; case "allow-anonymous-when-auth-token-missing": options.AllowAnonymousWhenAuthTokenMissing = ParseBool(value, options.AllowAnonymousWhenAuthTokenMissing, warnings, key); break; case "enable-session-tokens": options.EnableSessionTokens = ParseBool(value, options.EnableSessionTokens, warnings, key); break; case "session-token-ttl-seconds": options.SessionTokenTtlSeconds = ParseInt(value, options.SessionTokenTtlSeconds, 60, warnings, key); break; case "session-token-refresh-window-seconds": options.SessionTokenRefreshWindowSeconds = ParseInt(value, options.SessionTokenRefreshWindowSeconds, 0, warnings, key); break; case "disconnect-on-invalid-session-token": options.DisconnectOnInvalidSessionToken = ParseBool(value, options.DisconnectOnInvalidSessionToken, warnings, key); break; case "enable-unity-code-scan": options.EnableUnityCodeScan = ParseBool(value, options.EnableUnityCodeScan, warnings, key); break; case "min-protocol-version": options.MinProtocolVersion = ParseInt(value, options.MinProtocolVersion, 1, warnings, key); break; case "max-protocol-version": options.MaxProtocolVersion = ParseInt(value, options.MaxProtocolVersion, 1, warnings, key); break; case "min-schema-version": options.MinSchemaVersion = ParseInt(value, options.MinSchemaVersion, 1, warnings, key); break; case "max-schema-version": options.MaxSchemaVersion = ParseInt(value, options.MaxSchemaVersion, 1, warnings, key); break; case "disconnect-on-protocol-violation": options.DisconnectOnProtocolViolation = ParseBool(value, options.DisconnectOnProtocolViolation, warnings, key); break; case "diagnostics-log-interval-seconds": options.DiagnosticsLogIntervalSeconds = ParseInt(value, options.DiagnosticsLogIntervalSeconds, 0, warnings, key); break; case "enable-tcp-tls": options.EnableTcpTls = ParseBool(value, options.EnableTcpTls, warnings, key); break; case "tcp-tls-target-host": options.TcpTlsTargetHost = NullIfEmpty(value); break; case "tcp-tls-certificate-path": options.TcpTlsCertificatePath = ResolvePath(directory, value, options.TcpTlsCertificatePath); break; case "tcp-tls-certificate-password": options.TcpTlsCertificatePassword = value; break; case "tcp-tls-check-certificate-revocation": options.TcpTlsCheckCertificateRevocation = ParseBool(value, options.TcpTlsCheckCertificateRevocation, warnings, key); break; default: warnings.Add($"未识别的配置键:{key}"); break; } } private static void NormalizeOptions(ServerHostOptions options) { options.SharedAuthToken = NullIfEmpty(options.SharedAuthToken); options.TcpTlsTargetHost = NullIfEmpty(options.TcpTlsTargetHost); options.TcpTlsCertificatePath = NullIfEmpty(options.TcpTlsCertificatePath); options.TcpTlsCertificatePassword = NullIfEmpty(options.TcpTlsCertificatePassword); options.SessionTokenRefreshWindowSeconds = Math.Max(0, options.SessionTokenRefreshWindowSeconds); options.SessionTokenTtlSeconds = Math.Max(60, options.SessionTokenTtlSeconds); options.Port = Math.Clamp(options.Port, 1, 65535); options.DiagnosticsLogIntervalSeconds = Math.Max(0, options.DiagnosticsLogIntervalSeconds); if (options.MaxProtocolVersion < options.MinProtocolVersion) options.MaxProtocolVersion = options.MinProtocolVersion; if (options.MaxSchemaVersion < options.MinSchemaVersion) options.MaxSchemaVersion = options.MinSchemaVersion; if (options.EnableTcpTls && string.IsNullOrWhiteSpace(options.TcpTlsCertificatePath)) options.EnableTcpTls = false; } private static string BuildDefaultFile(string filePath, ServerHostOptions options) { var directory = Path.GetDirectoryName(filePath) ?? Environment.CurrentDirectory; var builder = new StringBuilder(); builder.AppendLine("# ShrinkNetwork server.properties"); builder.AppendLine("# 类似 Minecraft 的 key=value 配置文件。"); builder.AppendLine("# 优先级:代码默认值 < 本文件 < 环境变量。"); builder.AppendLine("# 修改后需要重启服务器进程。"); builder.AppendLine(); builder.AppendLine("# 基础网络"); builder.AppendLine($"server-port={options.Port}"); builder.AppendLine(); builder.AppendLine("# 代码扫描与生成"); builder.AppendLine($"unity-assets-path={ToRelativePath(directory, options.UnityAssetsPath)}"); builder.AppendLine($"scan-output-directory={ToRelativePath(directory, options.ScanOutputDirectory)}"); builder.AppendLine($"enable-unity-code-scan={options.EnableUnityCodeScan.ToString().ToLowerInvariant()}"); builder.AppendLine(); builder.AppendLine("# 登录与会话"); builder.AppendLine("shared-auth-token="); builder.AppendLine($"allow-anonymous-when-auth-token-missing={options.AllowAnonymousWhenAuthTokenMissing.ToString().ToLowerInvariant()}"); builder.AppendLine($"enable-session-tokens={options.EnableSessionTokens.ToString().ToLowerInvariant()}"); builder.AppendLine($"session-token-ttl-seconds={options.SessionTokenTtlSeconds}"); builder.AppendLine($"session-token-refresh-window-seconds={options.SessionTokenRefreshWindowSeconds}"); builder.AppendLine($"disconnect-on-invalid-session-token={options.DisconnectOnInvalidSessionToken.ToString().ToLowerInvariant()}"); builder.AppendLine(); builder.AppendLine("# 协议兼容"); builder.AppendLine($"min-protocol-version={options.MinProtocolVersion}"); builder.AppendLine($"max-protocol-version={options.MaxProtocolVersion}"); builder.AppendLine($"min-schema-version={options.MinSchemaVersion}"); builder.AppendLine($"max-schema-version={options.MaxSchemaVersion}"); builder.AppendLine($"disconnect-on-protocol-violation={options.DisconnectOnProtocolViolation.ToString().ToLowerInvariant()}"); builder.AppendLine(); builder.AppendLine("# 观测"); builder.AppendLine($"diagnostics-log-interval-seconds={options.DiagnosticsLogIntervalSeconds}"); builder.AppendLine(); builder.AppendLine("# TCP TLS"); builder.AppendLine($"enable-tcp-tls={options.EnableTcpTls.ToString().ToLowerInvariant()}"); builder.AppendLine("tcp-tls-target-host="); builder.AppendLine("tcp-tls-certificate-path="); builder.AppendLine("tcp-tls-certificate-password="); builder.AppendLine($"tcp-tls-check-certificate-revocation={options.TcpTlsCheckCertificateRevocation.ToString().ToLowerInvariant()}"); return builder.ToString(); } private static int ParseInt(string raw, int fallback, int minValue, List warnings, string key) { if (!int.TryParse(raw, out var value)) { warnings.Add($"配置 {key} 不是有效整数,将回退为 {fallback}。"); return fallback; } return Math.Max(minValue, value); } private static bool ParseBool(string raw, bool fallback, List warnings, string key) { if (TryParseBool(raw, out var parsed)) return parsed; warnings.Add($"配置 {key} 不是有效布尔值,将回退为 {fallback}。"); return fallback; } private static bool TryParseBool(string raw, out bool value) { if (bool.TryParse(raw, out value)) return true; switch (raw.Trim().ToLowerInvariant()) { case "1": case "yes": case "on": value = true; return true; case "0": case "no": case "off": value = false; return true; default: value = false; return false; } } private static string ResolvePath(string baseDirectory, string raw, string? fallback) { if (string.IsNullOrWhiteSpace(raw)) return fallback ?? string.Empty; return Path.GetFullPath(Path.IsPathRooted(raw) ? raw : Path.Combine(baseDirectory, raw)); } private static string ToRelativePath(string baseDirectory, string targetPath) { var relative = Path.GetRelativePath(baseDirectory, targetPath); return relative.Replace('\\', '/'); } private static string? NullIfEmpty(string? value) { return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); } }