#if UNITY_EDITOR #nullable enable using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Security.Cryptography; using System.Text; using UnityEditor; using UnityEngine; public static class ShrinkDedicatedServerScaffoldGenerator { private const string FullProjectMenuPath = "ShrinkSDK/网络/生成完整独立服务器工程"; private const string RefreshGeneratedMenuPath = "ShrinkSDK/网络/刷新独立服务器生成合同"; private const string TemplateRelativePath = "Assets/Modules/ShrinkNetwork/Editor/Scaffolding/ServerProjectTemplate"; private const string DefaultGeneratedServerRelativePath = "GeneratedServers/ShrinkNetwork.ServerHost"; private const string TemplateStampFileName = ".shrink-server-template.json"; private const string TemplateManifestFileName = ".shrink-server-template-files.txt"; internal sealed class MessageSpec { public string TypeName = string.Empty; public string Kind = "message"; public int Opcode; public string Route = string.Empty; public string SourcePath = string.Empty; public bool HasResult; public bool IsNetworkEvent; public bool IsDeltaEvent; public string SyncGroup = string.Empty; public string SyncRole = string.Empty; public List<(string Type, string Name)> Properties = new(); } private sealed class NetworkEventSpec { public MessageSpec Message = null!; public string Category = string.Empty; public string SuggestedAction = string.Empty; } internal sealed class SubscriberSpec { public string MemberName = string.Empty; public string SourcePath = string.Empty; public string Authority = string.Empty; public string Permission = string.Empty; } internal sealed class EnumSpec { public string Name = string.Empty; public string SourcePath = string.Empty; public List<(string Name, string? Value)> Members = new(); } internal sealed class DataTypeSpec { public string Name = string.Empty; public string Kind = "class"; public string SourcePath = string.Empty; public List<(string Type, string Name)> Properties = new(); } private sealed class RoomSyncPatternSpec { public string Prefix = string.Empty; public string PrefixToken = string.Empty; public MessageSpec JoinRequest = null!; public MessageSpec JoinResponse = null!; public MessageSpec MoveCommand = null!; public MessageSpec StateDelta = null!; public MessageSpec LeftNotice = null!; public MessageSpec Heartbeat = null!; public string SnapshotCollectionType = string.Empty; public string SnapshotElementType = string.Empty; } private static readonly HashSet KnownTypeTokens = new(StringComparer.Ordinal) { "bool", "byte", "sbyte", "short", "ushort", "int", "uint", "long", "ulong", "float", "double", "decimal", "char", "string", "object", "Guid", "DateTime", "DateTimeOffset", "TimeSpan", "Array", "List", "IList", "IReadOnlyList", "IEnumerable", "ICollection", "Dictionary", "IDictionary", "IReadOnlyDictionary", "HashSet", "Queue", "Stack", "System", "Collections", "Generic" }; private static readonly HashSet TemplateOwnedRoutes = new(StringComparer.Ordinal) { "server/auth/login", "server/auth/login_response" }; private static readonly HashSet TemplateOwnedOpcodes = new() { 1201, 1202 }; private static readonly HashSet TemplateOwnedTypeNames = new(StringComparer.Ordinal) { "ServerAuthLoginRequest", "ServerAuthLoginResponse" }; private static readonly string[] RoomSyncRequiredRouteSuffixes = { "/join_room", "/join_room_response", "/move_command", "/player_state_delta", "/player_left", "/heartbeat" }; [MenuItem(FullProjectMenuPath)] public static void GenerateFullProject() { try { var context = BuildProjectContext(); CopyTemplateProject(context.TemplateRoot, context.ServerProjectRoot); GenerateScaffoldFiles(context.ProjectRoot, context.AssetsPath, context.GeneratedOutputRoot); AssetDatabase.Refresh(); Debug.Log($"[ShrinkNetwork] 已生成完整独立服务器工程:{context.ServerProjectRoot}"); } catch (Exception ex) { Debug.LogError($"[ShrinkNetwork] 生成独立服务器工程失败:{ex.Message}"); Debug.LogException(ex); } } [MenuItem(RefreshGeneratedMenuPath)] public static void RefreshGeneratedOnly() { try { var context = BuildProjectContext(); if (!Directory.Exists(context.ServerProjectRoot)) throw new DirectoryNotFoundException($"未找到已生成的服务器工程:{context.ServerProjectRoot}"); GenerateScaffoldFiles(context.ProjectRoot, context.AssetsPath, context.GeneratedOutputRoot); AssetDatabase.Refresh(); Debug.Log($"[ShrinkNetwork] 已刷新服务器 Generated 合同:{context.GeneratedOutputRoot}"); } catch (Exception ex) { Debug.LogError($"[ShrinkNetwork] 刷新服务器 Generated 合同失败:{ex.Message}"); Debug.LogException(ex); } } private static (string ProjectRoot, string AssetsPath, string TemplateRoot, string ServerProjectRoot, string GeneratedOutputRoot) BuildProjectContext() { var projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); var assetsPath = Path.Combine(projectRoot, "Assets"); var templateRoot = Path.Combine(projectRoot, TemplateRelativePath.Replace('/', Path.DirectorySeparatorChar)); var serverProjectRoot = Path.Combine(projectRoot, DefaultGeneratedServerRelativePath.Replace('/', Path.DirectorySeparatorChar)); var generatedOutputRoot = Path.Combine(serverProjectRoot, "Generated"); return (projectRoot, assetsPath, templateRoot, serverProjectRoot, generatedOutputRoot); } internal static void CopyTemplateProject(string templateRoot, string outputRoot) { if (!Directory.Exists(templateRoot)) throw new DirectoryNotFoundException($"未找到插件内服务器模板:{templateRoot}"); Directory.CreateDirectory(outputRoot); var stampPath = Path.Combine(outputRoot, TemplateStampFileName); if (Directory.EnumerateFileSystemEntries(outputRoot).Any() && !File.Exists(stampPath)) { throw new InvalidOperationException( $"目标目录已存在且不是 ShrinkNetwork 自动生成的模板工程,为避免覆盖人工修改,已中止:{outputRoot}"); } var manifestPath = Path.Combine(outputRoot, TemplateManifestFileName); var previousManagedFiles = ReadTemplateManifest(manifestPath); var templates = new List<(string RelativePath, string OutputPath, byte[] Content)>(); foreach (var templateFile in Directory.EnumerateFiles(templateRoot, "*", SearchOption.AllDirectories)) { var relativePath = Path.GetRelativePath(templateRoot, templateFile); var outputRelativePath = relativePath.EndsWith(".txt", StringComparison.Ordinal) ? relativePath[..^".txt".Length] : relativePath; var content = File.ReadAllText(templateFile, Encoding.UTF8); var normalizedContent = new UTF8Encoding(false).GetBytes(content); var normalizedRelativePath = outputRelativePath.Replace('\\', '/'); templates.Add(( normalizedRelativePath, ResolveManagedOutputPath(outputRoot, normalizedRelativePath), normalizedContent)); } var newManagedPaths = new HashSet(templates.Select(item => item.RelativePath), StringComparer.Ordinal); var conflicts = new List(); foreach (var template in templates) { if (!File.Exists(template.OutputPath)) continue; var currentHash = ComputeSha256(File.ReadAllBytes(template.OutputPath)); if (previousManagedFiles.TryGetValue(template.RelativePath, out var previousHash)) { if (!string.IsNullOrWhiteSpace(previousHash)) { if (!string.Equals(currentHash, previousHash, StringComparison.OrdinalIgnoreCase)) conflicts.Add(template.RelativePath); } else if (!string.Equals(currentHash, ComputeSha256(template.Content), StringComparison.OrdinalIgnoreCase)) { conflicts.Add(template.RelativePath + "(旧清单无法证明文件未被修改)"); } } else if (!string.Equals(currentHash, ComputeSha256(template.Content), StringComparison.OrdinalIgnoreCase)) { conflicts.Add(template.RelativePath + "(不在生成清单中)"); } } foreach (var previousManagedFile in previousManagedFiles) { if (newManagedPaths.Contains(previousManagedFile.Key)) continue; var stalePath = ResolveManagedOutputPath(outputRoot, previousManagedFile.Key); if (!File.Exists(stalePath)) continue; var currentHash = ComputeSha256(File.ReadAllBytes(stalePath)); if (string.IsNullOrWhiteSpace(previousManagedFile.Value) || !string.Equals(currentHash, previousManagedFile.Value, StringComparison.OrdinalIgnoreCase)) { conflicts.Add(previousManagedFile.Key + "(待删除的旧模板文件已被修改)"); } } if (conflicts.Count > 0) { throw new InvalidOperationException( "检测到人工修改的模板托管文件。生成器未写入任何文件,请先保留或迁移这些修改:" + Environment.NewLine + string.Join(Environment.NewLine, conflicts.OrderBy(item => item, StringComparer.Ordinal))); } foreach (var template in templates) { var outputDirectory = Path.GetDirectoryName(template.OutputPath); if (!string.IsNullOrWhiteSpace(outputDirectory)) Directory.CreateDirectory(outputDirectory); File.WriteAllBytes(template.OutputPath, template.Content); } foreach (var previousManagedFile in previousManagedFiles.Keys) { if (newManagedPaths.Contains(previousManagedFile)) continue; var stalePath = ResolveManagedOutputPath(outputRoot, previousManagedFile); if (File.Exists(stalePath)) File.Delete(stalePath); } var manifestLines = templates .OrderBy(item => item.RelativePath, StringComparer.Ordinal) .Select(item => item.RelativePath + "\t" + ComputeSha256(item.Content)); File.WriteAllLines(manifestPath, manifestLines, new UTF8Encoding(false)); var stamp = new StringBuilder(); stamp.AppendLine("{"); stamp.AppendLine(@" ""generatedBy"": ""ShrinkDedicatedServerScaffoldGenerator"","); stamp.AppendLine($@" ""generatedAt"": ""{DateTime.UtcNow:O}"","); stamp.AppendLine($@" ""templateRoot"": ""{templateRoot.Replace("\\", "\\\\")}"""); stamp.AppendLine("}"); File.WriteAllText(stampPath, stamp.ToString(), new UTF8Encoding(false)); } private static Dictionary ReadTemplateManifest(string manifestPath) { var result = new Dictionary(StringComparer.Ordinal); if (!File.Exists(manifestPath)) return result; foreach (var rawLine in File.ReadAllLines(manifestPath, Encoding.UTF8)) { var line = rawLine.Trim(); if (line.Length == 0) continue; var separatorIndex = line.IndexOf('\t'); var relativePath = separatorIndex >= 0 ? line[..separatorIndex] : line; var hash = separatorIndex >= 0 ? line[(separatorIndex + 1)..].Trim() : string.Empty; result[relativePath.Replace('\\', '/')] = hash; } return result; } private static string ResolveManagedOutputPath(string outputRoot, string relativePath) { var normalizedRoot = Path.GetFullPath(outputRoot) .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar; var fullPath = Path.GetFullPath(Path.Combine(normalizedRoot, relativePath.Replace('/', Path.DirectorySeparatorChar))); if (!fullPath.StartsWith(normalizedRoot, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException($"模板清单包含越界路径:{relativePath}"); return fullPath; } private static string ComputeSha256(byte[] content) { using var sha256 = SHA256.Create(); return BitConverter.ToString(sha256.ComputeHash(content)).Replace("-", string.Empty); } private static void GenerateScaffoldFiles(string projectRoot, string assetsPath, string outputDir) { Directory.CreateDirectory(outputDir); var scanResult = ShrinkNetworkSemanticScanner.ScanCompiledPlayerAssemblies(); var messages = scanResult.Messages .OrderBy(item => item.Opcode).ThenBy(item => item.TypeName, StringComparer.Ordinal).ToList(); var subscribers = scanResult.Subscribers.OrderBy(item => item.Permission, StringComparer.Ordinal) .ThenBy(item => item.MemberName, StringComparer.Ordinal).ToList(); var enums = scanResult.Enums .OrderBy(item => item.Name, StringComparer.Ordinal).ToList(); var dataTypes = scanResult.DataTypes .OrderBy(item => item.Name, StringComparer.Ordinal).ToList(); ValidateTemplateConflicts(messages); ValidateDuplicateRegistrations(messages); var roomSyncPatterns = FindRoomSyncPatterns(messages); var networkEvents = BuildNetworkEventSpecs(messages, roomSyncPatterns); ResolvePortableDependencies(messages, enums, dataTypes, out var requiredEnums, out var requiredDataTypes, out var unresolvedTypes); DeleteObsoleteOutputs(outputDir); WriteGeneratedContracts(outputDir, messages, requiredEnums, requiredDataTypes, unresolvedTypes); WriteHandlerTemplate(outputDir, messages, subscribers, roomSyncPatterns, networkEvents); WriteModuleTemplate(outputDir, messages, subscribers, roomSyncPatterns, networkEvents); WritePermissionsMarkdown(outputDir, messages, subscribers, requiredEnums, requiredDataTypes, unresolvedTypes, roomSyncPatterns, networkEvents); } private static void DeleteObsoleteOutputs(string outputDir) { foreach (var fileName in new[] { "UnityGeneratedServerScaffold.g.cs", "UnityGeneratedServerModuleTemplate.g.cs" }) { var filePath = Path.Combine(outputDir, fileName); if (File.Exists(filePath)) File.Delete(filePath); } } private static void ResolvePortableDependencies( IReadOnlyList messages, IReadOnlyList enums, IReadOnlyList dataTypes, out List requiredEnums, out List requiredDataTypes, out SortedSet unresolvedTypes) { var enumMap = enums.ToDictionary(item => item.Name, StringComparer.Ordinal); var dataTypeMap = dataTypes.ToDictionary(item => item.Name, StringComparer.Ordinal); var enumNames = new HashSet(StringComparer.Ordinal); var dataTypeNames = new HashSet(StringComparer.Ordinal); unresolvedTypes = new SortedSet(StringComparer.Ordinal); var queue = new Queue(); foreach (var message in messages) { foreach (var property in message.Properties) { foreach (var dependency in ExtractTypeDependencies(property.Type)) queue.Enqueue(dependency); } } while (queue.Count > 0) { var dependency = queue.Dequeue(); if (string.IsNullOrWhiteSpace(dependency) || KnownTypeTokens.Contains(dependency)) continue; if (enumMap.TryGetValue(dependency, out var enumSpec)) { if (enumNames.Add(enumSpec.Name)) continue; } if (dataTypeMap.TryGetValue(dependency, out var dataTypeSpec)) { if (!dataTypeNames.Add(dataTypeSpec.Name)) continue; foreach (var property in dataTypeSpec.Properties) { foreach (var nestedDependency in ExtractTypeDependencies(property.Type)) queue.Enqueue(nestedDependency); } continue; } unresolvedTypes.Add(dependency); } requiredEnums = enums.Where(item => enumNames.Contains(item.Name)).OrderBy(item => item.Name, StringComparer.Ordinal).ToList(); requiredDataTypes = dataTypes.Where(item => dataTypeNames.Contains(item.Name)).OrderBy(item => item.Name, StringComparer.Ordinal).ToList(); } private static IEnumerable ExtractTypeDependencies(string typeName) { var token = new StringBuilder(); foreach (var character in typeName.Append(' ')) { if (char.IsLetterOrDigit(character) || character == '_') { token.Append(character); continue; } if (token.Length == 0) continue; var value = token.ToString(); token.Clear(); if (!KnownTypeTokens.Contains(value) && !char.IsDigit(value[0])) yield return value; } } private static void WriteGeneratedContracts(string outputDir, IReadOnlyList messages, IReadOnlyList enums, IReadOnlyList dataTypes, ICollection unresolvedTypes) { var builder = new StringBuilder(); builder.AppendLine("// "); builder.AppendLine("#nullable enable"); builder.AppendLine("using System;"); builder.AppendLine("using System.Collections.Generic;"); builder.AppendLine("using ShrinkNetwork;"); builder.AppendLine(); builder.AppendLine("namespace ShrinkNetwork.ServerHost.Generated.Contracts;"); builder.AppendLine(); foreach (var enumSpec in enums) { builder.AppendLine($"public enum {enumSpec.Name}"); builder.AppendLine("{"); for (var i = 0; i < enumSpec.Members.Count; i++) { var member = enumSpec.Members[i]; var suffix = i == enumSpec.Members.Count - 1 ? string.Empty : ","; var valuePart = string.IsNullOrWhiteSpace(member.Value) ? string.Empty : $" = {member.Value}"; builder.AppendLine($" {member.Name}{valuePart}{suffix}"); } builder.AppendLine("}"); builder.AppendLine(); } foreach (var dataType in dataTypes) { builder.AppendLine($"public {dataType.Kind} {dataType.Name}"); builder.AppendLine("{"); foreach (var property in dataType.Properties) { var propertyType = GetPortablePropertyType(property.Type, unresolvedTypes); var comment = GetPortableTypeComment(property.Type, propertyType); if (!string.IsNullOrWhiteSpace(comment)) builder.AppendLine($" // {comment}"); builder.AppendLine($" public {propertyType} {property.Name} {{ get; set; }}{GetDefaultInitializer(propertyType)}"); } builder.AppendLine("}"); builder.AppendLine(); } foreach (var message in messages) { builder.AppendLine($"[ShrinkNetworkMessage({message.Opcode}, \"{message.Route}\")]"); builder.AppendLine($"public sealed class {message.TypeName} : {GetBaseDeclaration(message.Kind)}"); builder.AppendLine("{"); foreach (var property in message.Properties) { var propertyType = GetPortablePropertyType(property.Type, unresolvedTypes); var comment = GetPortableTypeComment(property.Type, propertyType); if (!string.IsNullOrWhiteSpace(comment)) builder.AppendLine($" // {comment}"); builder.AppendLine($" public {propertyType} {property.Name} {{ get; set; }}{GetDefaultInitializer(propertyType)}"); } builder.AppendLine("}"); builder.AppendLine(); } File.WriteAllText(Path.Combine(outputDir, "UnityGeneratedNetworkContracts.g.cs"), builder.ToString(), new UTF8Encoding(false)); } private static void WriteHandlerTemplate( string outputDir, IReadOnlyList messages, IReadOnlyList subscribers, IReadOnlyList roomSyncPatterns, IReadOnlyList networkEvents) { var patternHandledTypes = new HashSet( roomSyncPatterns.SelectMany(GetRoomSyncPatternMessageTypes), StringComparer.Ordinal); var requests = messages.Where(item => item.Kind == "request").ToList(); var genericMessageEvents = networkEvents .Where(item => string.Equals(item.Message.Kind, "message", StringComparison.Ordinal)) .Where(item => !patternHandledTypes.Contains(item.Message.TypeName)) .OrderBy(item => item.Message.Route, StringComparer.Ordinal) .ToList(); var builder = new StringBuilder(); builder.AppendLine("// "); builder.AppendLine("#nullable enable"); builder.AppendLine("using Cysharp.Threading.Tasks;"); builder.AppendLine("using ShrinkNetwork;"); builder.AppendLine("using ShrinkNetwork.ServerHost.Generated.Contracts;"); builder.AppendLine(); builder.AppendLine("namespace ShrinkNetwork.ServerHost.Generated;"); builder.AppendLine(); builder.AppendLine("public static partial class UnityGeneratedServerHandlers"); builder.AppendLine("{"); foreach (var request in requests.Where(item => !IsTemplateOwnedMessage(item) && !patternHandledTypes.Contains(item.TypeName))) { var responseTypeName = FindResponseType(messages, request); if (string.IsNullOrWhiteSpace(responseTypeName)) { builder.AppendLine($" // TODO: 为 {request.TypeName} 手工补一个响应类型,再实现服务端逻辑。"); builder.AppendLine(); continue; } if (request.IsNetworkEvent) { var mode = request.HasResult ? "远端裁决事件请求" : "网络事件请求"; var deltaSuffix = request.IsDeltaEvent ? ",同时声明了增量语义" : string.Empty; builder.AppendLine($" // {request.TypeName} 是 {mode}{deltaSuffix}。"); } builder.AppendLine($" public static UniTask<{responseTypeName}> Handle{request.TypeName}Async("); builder.AppendLine(" ShrinkNetworkContext context,"); builder.AppendLine($" {request.TypeName} request)"); builder.AppendLine(" {"); if (string.Equals(responseTypeName, "ShrinkNetworkEventResultResponse", StringComparison.Ordinal)) { builder.AppendLine(" return UniTask.FromResult(new ShrinkNetworkEventResultResponse"); builder.AppendLine(" {"); builder.AppendLine(" Result = EventResult.DENY,"); builder.AppendLine(" IsCanceled = true"); builder.AppendLine(" });"); } else { builder.AppendLine($" return UniTask.FromResult(new {responseTypeName}"); builder.AppendLine(" {"); builder.AppendLine(" ErrorCode = 501,"); builder.AppendLine(" ErrorMessage = \"自动生成的服务端处理器尚未补齐业务逻辑。\""); builder.AppendLine(" });"); } builder.AppendLine(" }"); builder.AppendLine(); } foreach (var networkEvent in genericMessageEvents) { builder.AppendLine($" // {networkEvent.Message.TypeName} 属于{networkEvent.Category}。"); builder.AppendLine($" // 建议动作:{networkEvent.SuggestedAction}"); builder.AppendLine($" public static UniTask Handle{networkEvent.Message.TypeName}Async("); builder.AppendLine(" ShrinkNetworkContext context,"); builder.AppendLine($" {networkEvent.Message.TypeName} message)"); builder.AppendLine(" {"); builder.AppendLine(" return UniTask.CompletedTask;"); builder.AppendLine(" }"); builder.AppendLine(); } if (roomSyncPatterns.Count > 0) { builder.AppendLine(" /*"); builder.AppendLine(" 下列房间同步请求已由 UnityGeneratedServerModule 按范式直接接管:"); foreach (var pattern in roomSyncPatterns) builder.AppendLine($" - prefix={pattern.Prefix} join={pattern.JoinRequest.TypeName} move={pattern.MoveCommand.TypeName}"); builder.AppendLine(" */"); } builder.AppendLine("}"); builder.AppendLine(); builder.AppendLine("/*"); builder.AppendLine("Unity 订阅扫描结果,可据此补齐权限与服务器逻辑:"); if (subscribers.Count == 0) builder.AppendLine("- 本次扫描未发现带 [ShrinkNetworkSubscribe] 的网络订阅。"); else foreach (var subscriber in subscribers) builder.AppendLine($"- {subscriber.MemberName} authority={subscriber.Authority} permission={subscriber.Permission} source={subscriber.SourcePath}"); builder.AppendLine("*/"); File.WriteAllText(Path.Combine(outputDir, "UnityGeneratedServerHandlers.g.cs"), builder.ToString(), new UTF8Encoding(false)); } private static void WriteModuleTemplate( string outputDir, IReadOnlyList messages, IReadOnlyList subscribers, IReadOnlyList roomSyncPatterns, IReadOnlyList networkEvents) { if (TryWriteRecognizedRoomSyncModule(outputDir, messages, subscribers, roomSyncPatterns, networkEvents)) return; var requests = messages.Where(item => item.Kind == "request").ToList(); var genericMessageEvents = networkEvents .Where(item => string.Equals(item.Message.Kind, "message", StringComparison.Ordinal)) .OrderBy(item => item.Message.Route, StringComparer.Ordinal) .ToList(); var builder = new StringBuilder(); builder.AppendLine("// "); builder.AppendLine("#nullable enable"); builder.AppendLine("using Cysharp.Threading.Tasks;"); builder.AppendLine("using ShrinkNetwork;"); builder.AppendLine("using ShrinkNetwork.ServerHost.Framework;"); builder.AppendLine("using ShrinkNetwork.ServerHost.Generated.Contracts;"); builder.AppendLine(); builder.AppendLine("namespace ShrinkNetwork.ServerHost.Generated;"); builder.AppendLine(); builder.AppendLine("public sealed class UnityGeneratedServerModule : IShrinkServerModule"); builder.AppendLine("{"); builder.AppendLine(" public string Name => \"UnityGenerated\";"); builder.AppendLine(); builder.AppendLine(" public void ConfigureService(ServerModuleContext context, ShrinkNetworkService service, string transportName)"); builder.AppendLine(" {"); foreach (var message in messages.Where(item => !IsTemplateOwnedMessage(item))) builder.AppendLine($" service.RegisterMessage<{message.TypeName}>({message.Opcode}, \"{message.Route}\");"); if (messages.Count > 0) builder.AppendLine(); foreach (var networkEvent in genericMessageEvents) { var permission = GuessPermission(subscribers, networkEvent.Message); var permissionExpression = string.IsNullOrWhiteSpace(permission) ? "(string.IsNullOrWhiteSpace(context.Options.SharedAuthToken) ? null : \"auth.ok\")" : FormatPermission(permission); builder.AppendLine($" service.RegisterHandler<{networkEvent.Message.TypeName}>(UnityGeneratedServerHandlers.Handle{networkEvent.Message.TypeName}Async,"); builder.AppendLine($" new ShrinkNetworkPermissionRequirement(ShrinkNetworkAuthority.ClientOnly, {permissionExpression}));"); } if (genericMessageEvents.Count > 0) builder.AppendLine(); foreach (var request in requests.Where(item => !IsTemplateOwnedMessage(item))) { var responseTypeName = FindResponseType(messages, request); if (string.IsNullOrWhiteSpace(responseTypeName)) { builder.AppendLine($" // TODO: {request.TypeName} 目前没有可推断的响应类型,请手工补充 RegisterRequestHandler。"); continue; } var permission = GuessPermission(subscribers, request); var permissionExpression = string.IsNullOrWhiteSpace(permission) ? "(string.IsNullOrWhiteSpace(context.Options.SharedAuthToken) ? null : \"auth.ok\")" : FormatPermission(permission); builder.AppendLine($" service.RegisterRequestHandler<{request.TypeName}, {responseTypeName}>(UnityGeneratedServerHandlers.Handle{request.TypeName}Async,"); builder.AppendLine($" new ShrinkNetworkPermissionRequirement(ShrinkNetworkAuthority.ClientOnly, {permissionExpression}));"); } builder.AppendLine(" }"); builder.AppendLine(); builder.AppendLine(" public UniTask StartAsync(ServerModuleContext context, CancellationToken cancellationToken)"); builder.AppendLine(" {"); builder.AppendLine(" return UniTask.CompletedTask;"); builder.AppendLine(" }"); builder.AppendLine("}"); File.WriteAllText(Path.Combine(outputDir, "UnityGeneratedServerModule.g.cs"), builder.ToString(), new UTF8Encoding(false)); } private static bool TryWriteRecognizedRoomSyncModule( string outputDir, IReadOnlyList messages, IReadOnlyList subscribers, IReadOnlyList roomSyncPatterns, IReadOnlyList networkEvents) { if (roomSyncPatterns.Count == 0) return false; var patternHandledTypes = new HashSet( roomSyncPatterns.SelectMany(GetRoomSyncPatternMessageTypes), StringComparer.Ordinal); var genericRequests = messages .Where(item => item.Kind == "request") .Where(item => !IsTemplateOwnedMessage(item)) .Where(item => !patternHandledTypes.Contains(item.TypeName)) .ToList(); var genericMessageEvents = networkEvents .Where(item => string.Equals(item.Message.Kind, "message", StringComparison.Ordinal)) .Where(item => !patternHandledTypes.Contains(item.Message.TypeName)) .OrderBy(item => item.Message.Route, StringComparer.Ordinal) .ToList(); var builder = new StringBuilder(); builder.AppendLine("// "); builder.AppendLine("#nullable enable"); builder.AppendLine("using Cysharp.Threading.Tasks;"); builder.AppendLine("using ShrinkNetwork;"); builder.AppendLine("using ShrinkNetwork.ServerHost.Framework;"); builder.AppendLine("using ShrinkNetwork.ServerHost.Generated.Contracts;"); builder.AppendLine(); builder.AppendLine("namespace ShrinkNetwork.ServerHost.Generated;"); builder.AppendLine(); builder.AppendLine("public sealed class UnityGeneratedServerModule : IShrinkServerModule"); builder.AppendLine("{"); foreach (var pattern in roomSyncPatterns) AppendRoomSyncStateClass(builder, pattern); builder.AppendLine(" private readonly object _syncRoot = new();"); foreach (var pattern in roomSyncPatterns) { var token = pattern.PrefixToken; builder.AppendLine($" private readonly Dictionary _{token}Players = new();"); builder.AppendLine($" private readonly Dictionary<(ShrinkNetworkService service, long sessionId), string> _{token}SessionToPlayerId = new();"); builder.AppendLine($" private long _{token}PlayerIdGenerator;"); } builder.AppendLine(); builder.AppendLine(" public string Name => \"UnityGenerated\";"); builder.AppendLine(); builder.AppendLine(" public void ConfigureService(ServerModuleContext context, ShrinkNetworkService service, string transportName)"); builder.AppendLine(" {"); foreach (var message in messages.Where(item => !IsTemplateOwnedMessage(item))) builder.AppendLine($" service.RegisterMessage<{message.TypeName}>({message.Opcode}, \"{message.Route}\");"); builder.AppendLine(); foreach (var pattern in roomSyncPatterns) { var token = pattern.PrefixToken; builder.AppendLine($" var {token}RoomRequirement = new ShrinkNetworkPermissionRequirement("); builder.AppendLine(" ShrinkNetworkAuthority.ClientOnly,"); builder.AppendLine(" string.IsNullOrWhiteSpace(context.Options.SharedAuthToken) ? null : \"auth.ok\");"); builder.AppendLine(); builder.AppendLine($" service.RegisterRequestHandler<{pattern.JoinRequest.TypeName}, {pattern.JoinResponse.TypeName}>(Handle{token}JoinRoomAsync, {token}RoomRequirement);"); builder.AppendLine($" service.RegisterHandler<{pattern.MoveCommand.TypeName}>(Handle{token}MoveAsync, {token}RoomRequirement);"); } if (genericMessageEvents.Count > 0) builder.AppendLine(); foreach (var networkEvent in genericMessageEvents) { var permission = GuessPermission(subscribers, networkEvent.Message); var permissionExpression = string.IsNullOrWhiteSpace(permission) ? "(string.IsNullOrWhiteSpace(context.Options.SharedAuthToken) ? null : \"auth.ok\")" : FormatPermission(permission); builder.AppendLine($" service.RegisterHandler<{networkEvent.Message.TypeName}>(UnityGeneratedServerHandlers.Handle{networkEvent.Message.TypeName}Async,"); builder.AppendLine($" new ShrinkNetworkPermissionRequirement(ShrinkNetworkAuthority.ClientOnly, {permissionExpression}));"); } foreach (var request in genericRequests) { var responseTypeName = FindResponseType(messages, request); if (string.IsNullOrWhiteSpace(responseTypeName)) { builder.AppendLine($" // TODO: {request.TypeName} 目前没有可推断的响应类型,请手工补充 RegisterRequestHandler。"); continue; } var permission = GuessPermission(subscribers, request); var permissionExpression = string.IsNullOrWhiteSpace(permission) ? "(string.IsNullOrWhiteSpace(context.Options.SharedAuthToken) ? null : \"auth.ok\")" : FormatPermission(permission); builder.AppendLine($" service.RegisterRequestHandler<{request.TypeName}, {responseTypeName}>(UnityGeneratedServerHandlers.Handle{request.TypeName}Async,"); builder.AppendLine($" new ShrinkNetworkPermissionRequirement(ShrinkNetworkAuthority.ClientOnly, {permissionExpression}));"); } builder.AppendLine(); builder.AppendLine(" service.OnSessionDisconnected += session =>"); builder.AppendLine(" {"); foreach (var pattern in roomSyncPatterns) builder.AppendLine($" Remove{pattern.PrefixToken}PlayerAsync(service, session.SessionId).Forget();"); builder.AppendLine(" };"); builder.AppendLine(" }"); builder.AppendLine(); builder.AppendLine(" public async UniTask StartAsync(ServerModuleContext context, CancellationToken cancellationToken)"); builder.AppendLine(" {"); builder.AppendLine(" while (!cancellationToken.IsCancellationRequested)"); builder.AppendLine(" {"); builder.AppendLine(" await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken);"); foreach (var pattern in roomSyncPatterns) builder.AppendLine($" await Broadcast{pattern.PrefixToken}HeartbeatAsync();"); builder.AppendLine(" }"); builder.AppendLine(" }"); builder.AppendLine(); foreach (var pattern in roomSyncPatterns) AppendRoomSyncPatternMethods(builder, pattern); builder.AppendLine(" private static bool TryGetSession(ShrinkNetworkService service, long sessionId, out ShrinkNetworkSession session)"); builder.AppendLine(" {"); builder.AppendLine(" return service.Sessions.TryGetValue(sessionId, out session!);"); builder.AppendLine(" }"); builder.AppendLine(); builder.AppendLine(" private static (float x, float y) GetSpawnPosition(int playerIndex)"); builder.AppendLine(" {"); builder.AppendLine(" return (-16f + (playerIndex % 4) * 3.2f, -2.4f);"); builder.AppendLine(" }"); builder.AppendLine("}"); File.WriteAllText(Path.Combine(outputDir, "UnityGeneratedServerModule.g.cs"), builder.ToString(), new UTF8Encoding(false)); return true; } private static void ValidateTemplateConflicts(IReadOnlyList messages) { var conflicts = messages .Where(message => !IsTemplateOwnedMessage(message)) .Where(message => TemplateOwnedOpcodes.Contains(message.Opcode)) .Select(message => $"opcode={message.Opcode} route={message.Route} type={message.TypeName} source={message.SourcePath}") .ToArray(); if (conflicts.Length == 0) return; throw new InvalidOperationException( "扫描到与模板内建鉴权模块冲突的消息编号。请不要占用 1201/1202,或改用插件保留路由。\n" + string.Join("\n", conflicts)); } private static void ValidateDuplicateRegistrations(IReadOnlyList messages) { var filteredMessages = messages.Where(message => !IsTemplateOwnedMessage(message)).ToList(); var duplicateOpcodes = filteredMessages .GroupBy(message => message.Opcode) .Where(group => group.Select(message => $"{message.TypeName}|{message.Route}").Distinct(StringComparer.Ordinal).Count() > 1) .ToArray(); if (duplicateOpcodes.Length > 0) { var details = duplicateOpcodes.Select(group => $"opcode={group.Key}: {string.Join(" ; ", group.Select(message => $"{message.TypeName} route={message.Route} source={message.SourcePath}"))}"); throw new InvalidOperationException( "扫描到重复的消息 opcode。独立服务器生成要求一条消息只对应一个 opcode。\n" + string.Join("\n", details)); } var duplicateRoutes = filteredMessages .GroupBy(message => message.Route, StringComparer.Ordinal) .Where(group => group.Select(message => $"{message.TypeName}|{message.Opcode}").Distinct(StringComparer.Ordinal).Count() > 1) .ToArray(); if (duplicateRoutes.Length == 0) return; var routeDetails = duplicateRoutes.Select(group => $"route={group.Key}: {string.Join(" ; ", group.Select(message => $"{message.TypeName} opcode={message.Opcode} source={message.SourcePath}"))}"); throw new InvalidOperationException( "扫描到重复的消息 route。独立服务器生成要求一条消息只对应一个 route。\n" + string.Join("\n", routeDetails)); } private static List FindRoomSyncPatterns(IReadOnlyList messages) { var patterns = new List(); var attrGroups = messages .Where(message => !string.IsNullOrWhiteSpace(message.SyncGroup)) .GroupBy(message => message.SyncGroup, StringComparer.Ordinal) .OrderBy(group => group.Key, StringComparer.Ordinal) .ToArray(); foreach (var group in attrGroups) { if (TryBuildStateSyncPatternFromAttributes(group.Key, group.ToList(), out var pattern)) patterns.Add(pattern); } var claimedPrefixes = new HashSet(patterns.Select(pattern => pattern.Prefix), StringComparer.Ordinal); var joinMessages = messages .Where(message => string.Equals(message.Kind, "request", StringComparison.Ordinal)) .Where(message => message.Route.EndsWith("/join_room", StringComparison.Ordinal)) .Where(message => !IsTemplateOwnedMessage(message)) .OrderBy(message => message.Route, StringComparer.Ordinal) .ToList(); foreach (var joinMessage in joinMessages) { var prefix = joinMessage.Route[..^"/join_room".Length]; if (string.IsNullOrWhiteSpace(prefix)) continue; if (claimedPrefixes.Contains(prefix)) continue; if (TryBuildRoomSyncPattern(messages, prefix, out var pattern)) patterns.Add(pattern); } return patterns; } private static bool TryBuildStateSyncPatternFromAttributes( string group, IReadOnlyList groupMessages, out RoomSyncPatternSpec pattern) { pattern = null!; if (!TryFindStateSyncRole(groupMessages, "JoinRequest", out var joinRequest) || !TryFindStateSyncRole(groupMessages, "JoinResponse", out var joinResponse) || !TryFindStateSyncRole(groupMessages, "Command", out var moveCommand) || !TryFindStateSyncRole(groupMessages, "StateDelta", out var stateDelta) || !TryFindStateSyncRole(groupMessages, "LeaveNotice", out var leftNotice) || !TryFindStateSyncRole(groupMessages, "Heartbeat", out var heartbeat)) { return false; } return TryBuildStateSyncPatternCore(group, joinRequest, joinResponse, moveCommand, stateDelta, leftNotice, heartbeat, out pattern); } private static bool TryBuildRoomSyncPattern(IReadOnlyList messages, string prefix, out RoomSyncPatternSpec pattern) { if (!TryFindMessageByRoute(messages, prefix + "/join_room", out var joinRequest, "request") || !TryFindMessageByRoute(messages, prefix + "/join_room_response", out var joinResponse, "response") || !TryFindMessageByRoute(messages, prefix + "/move_command", out var moveCommand, "message") || !TryFindMessageByRoute(messages, prefix + "/player_state_delta", out var stateDelta, "message") || !TryFindMessageByRoute(messages, prefix + "/player_left", out var leftNotice, "message") || !TryFindMessageByRoute(messages, prefix + "/heartbeat", out var heartbeat, "message")) { pattern = null!; return false; } return TryBuildStateSyncPatternCore(prefix, joinRequest, joinResponse, moveCommand, stateDelta, leftNotice, heartbeat, out pattern); } private static bool TryFindStateSyncRole(IReadOnlyList messages, string role, out MessageSpec message) { message = messages.FirstOrDefault(item => string.Equals(item.SyncRole, role, StringComparison.Ordinal))!; return message != null; } private static bool TryBuildStateSyncPatternCore( string prefix, MessageSpec joinRequest, MessageSpec joinResponse, MessageSpec moveCommand, MessageSpec stateDelta, MessageSpec leftNotice, MessageSpec heartbeat, out RoomSyncPatternSpec pattern) { pattern = null!; if (!HasRequiredProperties(joinRequest, "PlayerName") || !HasRequiredProperties(joinResponse, "PlayerId", "Players") || !HasRequiredProperties(moveCommand, "PlayerId", "X", "Y", "VX", "VY", "IsGrounded", "Version") || !HasRequiredProperties(stateDelta, "PlayerId", "DisplayName", "X", "Y", "VX", "VY", "IsGrounded", "Version") || !HasRequiredProperties(leftNotice, "PlayerId") || !HasRequiredProperties(heartbeat, "ServerUnixMs")) { return false; } if (!TryGetPropertyType(joinResponse, "Players", out var snapshotCollectionType) || !TryGetCollectionElementType(snapshotCollectionType, out var snapshotElementType)) { return false; } pattern = new RoomSyncPatternSpec { Prefix = prefix, PrefixToken = SanitizeIdentifier(prefix), JoinRequest = joinRequest, JoinResponse = joinResponse, MoveCommand = moveCommand, StateDelta = stateDelta, LeftNotice = leftNotice, Heartbeat = heartbeat, SnapshotCollectionType = snapshotCollectionType, SnapshotElementType = snapshotElementType }; return true; } private static bool TryFindMessageByRoute(IReadOnlyList messages, string route, out MessageSpec message, string kind) { message = messages.FirstOrDefault(item => string.Equals(item.Route, route, StringComparison.Ordinal) && string.Equals(item.Kind, kind, StringComparison.Ordinal))!; return message != null; } private static bool HasRequiredProperties(MessageSpec message, params string[] propertyNames) { var actualProperties = new HashSet(message.Properties.Select(property => property.Name), StringComparer.Ordinal); return propertyNames.All(actualProperties.Contains); } private static bool TryGetPropertyType(MessageSpec message, string propertyName, out string propertyType) { foreach (var property in message.Properties) { if (!string.Equals(property.Name, propertyName, StringComparison.Ordinal)) continue; propertyType = property.Type; return true; } propertyType = string.Empty; return false; } private static bool TryGetCollectionElementType(string collectionType, out string elementType) { if (collectionType.EndsWith("[]", StringComparison.Ordinal)) { elementType = collectionType[..^2]; return true; } var genericStart = collectionType.IndexOf('<'); if (genericStart > 0 && collectionType.EndsWith(">", StringComparison.Ordinal)) { var candidate = collectionType.Substring(genericStart + 1, collectionType.Length - genericStart - 2).Trim(); if (candidate.Length > 0) { elementType = candidate; return true; } } elementType = string.Empty; return false; } private static IEnumerable GetRoomSyncPatternMessageTypes(RoomSyncPatternSpec pattern) { yield return pattern.JoinRequest.TypeName; yield return pattern.JoinResponse.TypeName; yield return pattern.MoveCommand.TypeName; yield return pattern.StateDelta.TypeName; yield return pattern.LeftNotice.TypeName; yield return pattern.Heartbeat.TypeName; } private static string SanitizeIdentifier(string value) { var token = value.Replace("/", "_").Replace("-", "_"); token = new string(token.Select(character => (character >= 'A' && character <= 'Z') || (character >= 'a' && character <= 'z') || (character >= '0' && character <= '9') || character == '_' ? character : '_').ToArray()); if (string.IsNullOrWhiteSpace(token)) return "Room"; if (!char.IsLetter(token[0]) && token[0] != '_') token = "_" + token; return char.ToUpperInvariant(token[0]) + token[1..]; } private static void AppendRoomSyncStateClass(StringBuilder builder, RoomSyncPatternSpec pattern) { var token = pattern.PrefixToken; builder.AppendLine($" private sealed class {token}ServerPlayerState"); builder.AppendLine(" {"); builder.AppendLine(" public string PlayerId = string.Empty;"); builder.AppendLine(" public string DisplayName = string.Empty;"); builder.AppendLine(" public float X;"); builder.AppendLine(" public float Y;"); builder.AppendLine(" public float VX;"); builder.AppendLine(" public float VY;"); builder.AppendLine(" public bool IsGrounded;"); builder.AppendLine(" public long Version;"); builder.AppendLine(" public ShrinkNetworkService Service = null!;"); builder.AppendLine(" public long SessionId;"); builder.AppendLine(" }"); builder.AppendLine(); } private static void AppendRoomSyncPatternMethods(StringBuilder builder, RoomSyncPatternSpec pattern) { var token = pattern.PrefixToken; var joinRequestType = pattern.JoinRequest.TypeName; var joinResponseType = pattern.JoinResponse.TypeName; var moveCommandType = pattern.MoveCommand.TypeName; var stateDeltaType = pattern.StateDelta.TypeName; var leftNoticeType = pattern.LeftNotice.TypeName; var heartbeatType = pattern.Heartbeat.TypeName; var snapshotCollectionType = pattern.SnapshotCollectionType; var snapshotElementType = pattern.SnapshotElementType; var snapshotMaterializer = GetCollectionMaterializer(snapshotCollectionType); builder.AppendLine($" private async UniTask<{joinResponseType}> Handle{token}JoinRoomAsync(ShrinkNetworkContext context, {joinRequestType} request)"); builder.AppendLine(" {"); builder.AppendLine(" var key = (context.Service, context.Session.SessionId);"); builder.AppendLine($" {token}ServerPlayerState player;"); builder.AppendLine($" {snapshotCollectionType} snapshot;"); builder.AppendLine(" var shouldBroadcast = false;"); builder.AppendLine(); builder.AppendLine(" lock (_syncRoot)"); builder.AppendLine(" {"); builder.AppendLine($" if (_{token}SessionToPlayerId.TryGetValue(key, out var existingPlayerId) &&"); builder.AppendLine($" _{token}Players.TryGetValue(existingPlayerId, out player!))"); builder.AppendLine(" {"); builder.AppendLine(" player.DisplayName = string.IsNullOrWhiteSpace(request.PlayerName) ? player.PlayerId : request.PlayerName.Trim();"); builder.AppendLine(" }"); builder.AppendLine(" else"); builder.AppendLine(" {"); builder.AppendLine($" var playerId = $\"{token}-{{Interlocked.Increment(ref _{token}PlayerIdGenerator)}}\";"); builder.AppendLine($" var spawn = GetSpawnPosition(_{token}Players.Count);"); builder.AppendLine($" player = new {token}ServerPlayerState"); builder.AppendLine(" {"); builder.AppendLine(" PlayerId = playerId,"); builder.AppendLine(" DisplayName = string.IsNullOrWhiteSpace(request.PlayerName) ? playerId : request.PlayerName.Trim(),"); builder.AppendLine(" X = spawn.x,"); builder.AppendLine(" Y = spawn.y,"); builder.AppendLine(" VX = 0f,"); builder.AppendLine(" VY = 0f,"); builder.AppendLine(" IsGrounded = false,"); builder.AppendLine(" Version = 1,"); builder.AppendLine(" Service = context.Service,"); builder.AppendLine(" SessionId = context.Session.SessionId"); builder.AppendLine(" };"); builder.AppendLine($" _{token}Players[playerId] = player;"); builder.AppendLine($" _{token}SessionToPlayerId[key] = playerId;"); builder.AppendLine(" shouldBroadcast = true;"); builder.AppendLine(" }"); builder.AppendLine($" snapshot = Build{token}SnapshotUnsafe();"); builder.AppendLine(" }"); builder.AppendLine(); builder.AppendLine(" if (shouldBroadcast)"); builder.AppendLine($" await Broadcast{token}PlayerStateAsync(player, player.PlayerId);"); builder.AppendLine(); builder.AppendLine($" return new {joinResponseType}"); builder.AppendLine(" {"); builder.AppendLine(" PlayerId = player.PlayerId,"); builder.AppendLine(" Players = snapshot"); builder.AppendLine(" };"); builder.AppendLine(" }"); builder.AppendLine(); builder.AppendLine($" private async UniTask Handle{token}MoveAsync(ShrinkNetworkContext context, {moveCommandType} command)"); builder.AppendLine(" {"); builder.AppendLine($" {token}ServerPlayerState? player = null;"); builder.AppendLine(" lock (_syncRoot)"); builder.AppendLine(" {"); builder.AppendLine($" if (!_{token}SessionToPlayerId.TryGetValue((context.Service, context.Session.SessionId), out var playerId))"); builder.AppendLine(" return;"); builder.AppendLine(" if (!string.Equals(playerId, command.PlayerId, StringComparison.Ordinal))"); builder.AppendLine(" return;"); builder.AppendLine($" if (!_{token}Players.TryGetValue(playerId, out player))"); builder.AppendLine(" return;"); builder.AppendLine(" if (command.Version < player.Version)"); builder.AppendLine(" return;"); builder.AppendLine(" player.X = command.X;"); builder.AppendLine(" player.Y = command.Y;"); builder.AppendLine(" player.VX = command.VX;"); builder.AppendLine(" player.VY = command.VY;"); builder.AppendLine(" player.IsGrounded = command.IsGrounded;"); builder.AppendLine(" player.Version = command.Version;"); builder.AppendLine(" }"); builder.AppendLine(); builder.AppendLine(" if (player != null)"); builder.AppendLine($" await Broadcast{token}PlayerStateAsync(player, player.PlayerId);"); builder.AppendLine(" }"); builder.AppendLine(); builder.AppendLine($" private async UniTask Broadcast{token}HeartbeatAsync()"); builder.AppendLine(" {"); builder.AppendLine($" var notice = new {heartbeatType}"); builder.AppendLine(" {"); builder.AppendLine(" ServerUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()"); builder.AppendLine(" };"); builder.AppendLine($" foreach (var player in Snapshot{token}Players())"); builder.AppendLine(" {"); builder.AppendLine(" if (!TryGetSession(player.Service, player.SessionId, out var session))"); builder.AppendLine(" continue;"); builder.AppendLine(" try"); builder.AppendLine(" {"); builder.AppendLine(" await session.SendAsync(notice);"); builder.AppendLine(" }"); builder.AppendLine(" catch"); builder.AppendLine(" {"); builder.AppendLine(" }"); builder.AppendLine(" }"); builder.AppendLine(" }"); builder.AppendLine(); builder.AppendLine($" private async UniTaskVoid Remove{token}PlayerAsync(ShrinkNetworkService service, long sessionId)"); builder.AppendLine(" {"); builder.AppendLine(" string? playerId = null;"); builder.AppendLine(" lock (_syncRoot)"); builder.AppendLine(" {"); builder.AppendLine(" var key = (service, sessionId);"); builder.AppendLine($" if (!_{token}SessionToPlayerId.TryGetValue(key, out playerId))"); builder.AppendLine(" return;"); builder.AppendLine($" _{token}SessionToPlayerId.Remove(key);"); builder.AppendLine($" _{token}Players.Remove(playerId);"); builder.AppendLine(" }"); builder.AppendLine($" var notice = new {leftNoticeType} {{ PlayerId = playerId }};"); builder.AppendLine($" foreach (var player in Snapshot{token}Players())"); builder.AppendLine(" {"); builder.AppendLine(" if (!TryGetSession(player.Service, player.SessionId, out var session))"); builder.AppendLine(" continue;"); builder.AppendLine(" try"); builder.AppendLine(" {"); builder.AppendLine(" await session.SendAsync(notice);"); builder.AppendLine(" }"); builder.AppendLine(" catch"); builder.AppendLine(" {"); builder.AppendLine(" }"); builder.AppendLine(" }"); builder.AppendLine(" }"); builder.AppendLine(); builder.AppendLine($" private async UniTask Broadcast{token}PlayerStateAsync({token}ServerPlayerState state, string? excludedPlayerId = null)"); builder.AppendLine(" {"); builder.AppendLine($" var delta = new {stateDeltaType}"); builder.AppendLine(" {"); builder.AppendLine(" PlayerId = state.PlayerId,"); builder.AppendLine(" DisplayName = state.DisplayName,"); builder.AppendLine(" X = state.X,"); builder.AppendLine(" Y = state.Y,"); builder.AppendLine(" VX = state.VX,"); builder.AppendLine(" VY = state.VY,"); builder.AppendLine(" IsGrounded = state.IsGrounded,"); builder.AppendLine(" Version = state.Version"); builder.AppendLine(" };"); builder.AppendLine($" foreach (var player in Snapshot{token}Players())"); builder.AppendLine(" {"); builder.AppendLine(" if (excludedPlayerId != null && string.Equals(player.PlayerId, excludedPlayerId, StringComparison.Ordinal))"); builder.AppendLine(" continue;"); builder.AppendLine(" if (!TryGetSession(player.Service, player.SessionId, out var session))"); builder.AppendLine(" continue;"); builder.AppendLine(" try"); builder.AppendLine(" {"); builder.AppendLine(" await session.SendAsync(delta);"); builder.AppendLine(" }"); builder.AppendLine(" catch"); builder.AppendLine(" {"); builder.AppendLine(" }"); builder.AppendLine(" }"); builder.AppendLine(" }"); builder.AppendLine(); builder.AppendLine($" private {token}ServerPlayerState[] Snapshot{token}Players()"); builder.AppendLine(" {"); builder.AppendLine(" lock (_syncRoot)"); builder.AppendLine(" {"); builder.AppendLine($" return _{token}Players.Values.Select(player => new {token}ServerPlayerState"); builder.AppendLine(" {"); builder.AppendLine(" PlayerId = player.PlayerId,"); builder.AppendLine(" DisplayName = player.DisplayName,"); builder.AppendLine(" X = player.X,"); builder.AppendLine(" Y = player.Y,"); builder.AppendLine(" VX = player.VX,"); builder.AppendLine(" VY = player.VY,"); builder.AppendLine(" IsGrounded = player.IsGrounded,"); builder.AppendLine(" Version = player.Version,"); builder.AppendLine(" Service = player.Service,"); builder.AppendLine(" SessionId = player.SessionId"); builder.AppendLine($" }}).ToArray();"); builder.AppendLine(" }"); builder.AppendLine(" }"); builder.AppendLine(); builder.AppendLine($" private {snapshotCollectionType} Build{token}SnapshotUnsafe()"); builder.AppendLine(" {"); builder.AppendLine($" return _{token}Players.Values.Select(player => new {snapshotElementType}"); builder.AppendLine(" {"); builder.AppendLine(" PlayerId = player.PlayerId,"); builder.AppendLine(" DisplayName = player.DisplayName,"); builder.AppendLine(" X = player.X,"); builder.AppendLine(" Y = player.Y,"); builder.AppendLine(" VX = player.VX,"); builder.AppendLine(" VY = player.VY,"); builder.AppendLine(" IsGrounded = player.IsGrounded,"); builder.AppendLine(" Version = player.Version"); builder.AppendLine($" }}).{snapshotMaterializer};"); builder.AppendLine(" }"); builder.AppendLine(); } private static string GetCollectionMaterializer(string collectionType) { if (collectionType.StartsWith("List<", StringComparison.Ordinal) || collectionType.StartsWith("IList<", StringComparison.Ordinal) || collectionType.StartsWith("ICollection<", StringComparison.Ordinal)) { return "ToList()"; } return "ToArray()"; } private static void WritePermissionsMarkdown( string outputDir, IReadOnlyList messages, IReadOnlyList subscribers, IReadOnlyList enums, IReadOnlyList dataTypes, IReadOnlyCollection unresolvedTypes, IReadOnlyList roomSyncPatterns, IReadOnlyList networkEvents) { var builder = new StringBuilder(); builder.AppendLine("# 独立服务器脚手架扫描结果"); builder.AppendLine(); builder.AppendLine($"生成时间:{DateTime.Now:yyyy-MM-dd HH:mm:ss}"); builder.AppendLine(); builder.AppendLine("## 已识别范式"); builder.AppendLine(); if (roomSyncPatterns.Count == 0) { builder.AppendLine("- 未识别到可直接生成的状态同步范式。"); builder.AppendLine("- 推荐在消息类上显式标记 `[ShrinkNetworkStateSync(\"group\", role)]`。"); builder.AppendLine("- 旧演示仍可回退为 `/join_room` 等固定路由约定。"); } else { foreach (var pattern in roomSyncPatterns) { builder.AppendLine($"- 状态同步 group=`{pattern.Prefix}`"); builder.AppendLine($" join=`{pattern.JoinRequest.TypeName}` command=`{pattern.MoveCommand.TypeName}` state=`{pattern.StateDelta.TypeName}`"); } } builder.AppendLine(); builder.AppendLine("## 网络消息"); builder.AppendLine(); foreach (var message in messages) { var resultSuffix = message.HasResult ? " hasResult=true" : string.Empty; builder.AppendLine($"- `{message.Opcode}` `{message.Route}` `{message.TypeName}` kind=`{message.Kind}`{resultSuffix} [{message.SourcePath}]"); } builder.AppendLine(); builder.AppendLine("## 网络事件"); builder.AppendLine(); if (networkEvents.Count == 0) { builder.AppendLine("- 本次扫描未识别到 `[ShrinkNetworkEvent]`。"); } else { foreach (var networkEvent in networkEvents) { var message = networkEvent.Message; var tags = new List { networkEvent.Category }; if (message.HasResult) tags.Add("hasResult"); if (message.IsDeltaEvent) tags.Add("delta"); builder.AppendLine($"- `{message.TypeName}` route=`{message.Route}` kind=`{message.Kind}` tags=`{string.Join(", ", tags)}` [{message.SourcePath}]"); builder.AppendLine($" 建议动作:{networkEvent.SuggestedAction}"); } } builder.AppendLine(); builder.AppendLine("### 广播型网络事件"); builder.AppendLine(); AppendNetworkEventCategoryList(builder, networkEvents, "广播型网络事件"); builder.AppendLine(); builder.AppendLine("### 远端裁决网络事件"); builder.AppendLine(); AppendNetworkEventCategoryList(builder, networkEvents, "远端裁决网络事件"); builder.AppendLine(); builder.AppendLine("### 增量网络事件"); builder.AppendLine(); AppendNetworkEventCategoryList(builder, networkEvents, "增量网络事件"); builder.AppendLine(); builder.AppendLine("## 模板保留消息"); builder.AppendLine(); builder.AppendLine("- `1201` `server/auth/login` `ServerAuthLoginRequest`"); builder.AppendLine("- `1202` `server/auth/login_response` `ServerAuthLoginResponse`"); builder.AppendLine(); builder.AppendLine("## 权限与订阅"); builder.AppendLine(); if (subscribers.Count == 0) builder.AppendLine("- 本次扫描未发现 `[ShrinkNetworkSubscribe]`。"); else foreach (var subscriber in subscribers) builder.AppendLine($"- `{subscriber.MemberName}` authority=`{subscriber.Authority}` permission=`{subscriber.Permission}` [{subscriber.SourcePath}]"); builder.AppendLine(); builder.AppendLine("## 已一起导出的依赖类型"); builder.AppendLine(); if (enums.Count == 0 && dataTypes.Count == 0) builder.AppendLine("- 当前消息合同只依赖基础类型。"); else { foreach (var enumSpec in enums) builder.AppendLine($"- enum `{enumSpec.Name}` [{enumSpec.SourcePath}]"); foreach (var dataType in dataTypes) builder.AppendLine($"- {dataType.Kind} `{dataType.Name}` [{dataType.SourcePath}]"); } builder.AppendLine(); builder.AppendLine("## 未解析类型"); builder.AppendLine(); if (unresolvedTypes.Count == 0) builder.AppendLine("- 无。"); else { builder.AppendLine("- 下列类型在生成脚手架时未找到定义,相关属性已回退为 `string` 或 `string[]`,需要手工修正:"); foreach (var unresolvedType in unresolvedTypes) builder.AppendLine($"- `{unresolvedType}`"); } builder.AppendLine(); builder.AppendLine("## 生成产物"); builder.AppendLine(); builder.AppendLine("- `UnityGeneratedNetworkContracts.g.cs`:Unity 网络消息合同副本。"); builder.AppendLine("- `UnityGeneratedServerHandlers.g.cs`:请求处理器与网络事件处理器模板。"); builder.AppendLine("- `UnityGeneratedServerModule.g.cs`:自动加载的项目专属服务器模块,包含网络事件自动注册。"); File.WriteAllText(Path.Combine(outputDir, "UNITY_GENERATED_SERVER_SCAFFOLD.md"), builder.ToString(), new UTF8Encoding(false)); } private static string? FindResponseType(IReadOnlyList messages, MessageSpec request) { var byName = request.TypeName.EndsWith("Request", StringComparison.Ordinal) ? request.TypeName[..^"Request".Length] + "Response" : request.TypeName + "Response"; var matched = messages.FirstOrDefault(item => item.Kind == "response" && string.Equals(item.TypeName, byName, StringComparison.Ordinal)); if (matched != null) return matched.TypeName; var responseRoute = request.Route.EndsWith("_response", StringComparison.Ordinal) ? request.Route : request.Route + "_response"; matched = messages.FirstOrDefault(item => item.Kind == "response" && string.Equals(item.Route, responseRoute, StringComparison.Ordinal)); if (matched != null) return matched.TypeName; return request.HasResult ? "ShrinkNetworkEventResultResponse" : null; } private static List BuildNetworkEventSpecs( IReadOnlyList messages, IReadOnlyList roomSyncPatterns) { var roomSyncMessageTypes = new HashSet( roomSyncPatterns.SelectMany(GetRoomSyncPatternMessageTypes), StringComparer.Ordinal); return messages .Where(message => message.IsNetworkEvent) .OrderBy(message => message.Route, StringComparer.Ordinal) .Select(message => new NetworkEventSpec { Message = message, Category = GetNetworkEventCategory(message), SuggestedAction = GetNetworkEventSuggestedAction(message, roomSyncMessageTypes.Contains(message.TypeName)) }) .ToList(); } private static string GetNetworkEventCategory(MessageSpec message) { if (message.IsDeltaEvent) return "增量网络事件"; if (message.HasResult) return "远端裁决网络事件"; return "广播型网络事件"; } private static string GetNetworkEventSuggestedAction(MessageSpec message, bool isRoomSyncMessage) { if (isRoomSyncMessage) return "该事件已被状态同步范式接管,通常不需要再手工注册额外广播逻辑。"; if (message.IsDeltaEvent) return "校验来源,按业务主键与版本去重,更新服务器状态后再决定是否向其他会话转发。"; if (message.HasResult) return "在服务端完成裁决,返回 EventResult / IsCanceled,并只在必要时附带错误码。"; return "在服务端决定是否落库、审计或转发给其他会话,避免消息进入后没有处理器。"; } private static void AppendNetworkEventCategoryList(StringBuilder builder, IReadOnlyList networkEvents, string category) { var matchedEvents = networkEvents .Where(item => string.Equals(item.Category, category, StringComparison.Ordinal)) .ToList(); if (matchedEvents.Count == 0) { builder.AppendLine("- 无。"); return; } foreach (var networkEvent in matchedEvents) builder.AppendLine($"- `{networkEvent.Message.TypeName}` `{networkEvent.Message.Route}`"); } private static string? GuessPermission(IReadOnlyList subscribers, MessageSpec request) { var routeTail = request.Route.Split('/').LastOrDefault() ?? string.Empty; return subscribers.FirstOrDefault(item => item.MemberName.Contains(routeTail, StringComparison.OrdinalIgnoreCase) || item.SourcePath.Contains(routeTail, StringComparison.OrdinalIgnoreCase))?.Permission; } private static bool HasMessage(IReadOnlyList messages, string typeName, string route) { return messages.Any(item => string.Equals(item.TypeName, typeName, StringComparison.Ordinal) && string.Equals(item.Route, route, StringComparison.Ordinal)); } private static bool IsTemplateOwnedMessage(MessageSpec message) { return TemplateOwnedTypeNames.Contains(message.TypeName) || TemplateOwnedRoutes.Contains(message.Route); } private static string GetPortablePropertyType(string typeName, ICollection unresolvedTypes) { var containsUnresolvedDependency = ExtractTypeDependencies(typeName).Any(unresolvedTypes.Contains); if (!containsUnresolvedDependency) return typeName; return typeName.EndsWith("[]", StringComparison.Ordinal) ? "string[]" : "string"; } private static string? GetPortableTypeComment(string originalType, string emittedType) { if (string.Equals(originalType, emittedType, StringComparison.Ordinal)) return null; return $"原类型 {originalType},当前脚手架暂时回退为 {emittedType},请按实际协议修正。"; } private static string GetBaseDeclaration(string kind) { return kind switch { "request" => "IShrinkNetworkRequest", "response" => "ShrinkRpcResponseBase", _ => "IShrinkNetworkMessage" }; } private static string GetDefaultInitializer(string typeName) { if (typeName.EndsWith("[]", StringComparison.Ordinal)) { var elementType = typeName.Substring(0, typeName.Length - 2).Trim(); return $" = Array.Empty<{elementType}>();"; } return typeName switch { "string" => " = string.Empty;", "string?" => " = string.Empty;", _ => string.Empty }; } private static string FormatPermission(string? permission) { return string.IsNullOrWhiteSpace(permission) ? "null" : $"\"{permission}\""; } } #endif