Files
ShrinkNetwork/Editor/Scaffolding/ShrinkDedicatedServerScaffoldGenerator.cs
T
cneicy dd13a01bc9
Publish UPM package / publish (push) Successful in 2s
chore(editor): localize network menus
2026-08-26 12:48:32 +08:00

1523 lines
74 KiB
C#
Raw 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.
#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<string> 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<string> TemplateOwnedRoutes = new(StringComparer.Ordinal)
{
"server/auth/login",
"server/auth/login_response"
};
private static readonly HashSet<int> TemplateOwnedOpcodes = new()
{
1201,
1202
};
private static readonly HashSet<string> 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<string>(templates.Select(item => item.RelativePath), StringComparer.Ordinal);
var conflicts = new List<string>();
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<string, string> ReadTemplateManifest(string manifestPath)
{
var result = new Dictionary<string, string>(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<MessageSpec> messages,
IReadOnlyList<EnumSpec> enums,
IReadOnlyList<DataTypeSpec> dataTypes,
out List<EnumSpec> requiredEnums,
out List<DataTypeSpec> requiredDataTypes,
out SortedSet<string> unresolvedTypes)
{
var enumMap = enums.ToDictionary(item => item.Name, StringComparer.Ordinal);
var dataTypeMap = dataTypes.ToDictionary(item => item.Name, StringComparer.Ordinal);
var enumNames = new HashSet<string>(StringComparer.Ordinal);
var dataTypeNames = new HashSet<string>(StringComparer.Ordinal);
unresolvedTypes = new SortedSet<string>(StringComparer.Ordinal);
var queue = new Queue<string>();
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<string> 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<MessageSpec> messages, IReadOnlyList<EnumSpec> enums, IReadOnlyList<DataTypeSpec> dataTypes, ICollection<string> unresolvedTypes)
{
var builder = new StringBuilder();
builder.AppendLine("// <auto-generated>");
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<MessageSpec> messages,
IReadOnlyList<SubscriberSpec> subscribers,
IReadOnlyList<RoomSyncPatternSpec> roomSyncPatterns,
IReadOnlyList<NetworkEventSpec> networkEvents)
{
var patternHandledTypes = new HashSet<string>(
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("// <auto-generated>");
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<MessageSpec> messages,
IReadOnlyList<SubscriberSpec> subscribers,
IReadOnlyList<RoomSyncPatternSpec> roomSyncPatterns,
IReadOnlyList<NetworkEventSpec> 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("// <auto-generated>");
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<MessageSpec> messages,
IReadOnlyList<SubscriberSpec> subscribers,
IReadOnlyList<RoomSyncPatternSpec> roomSyncPatterns,
IReadOnlyList<NetworkEventSpec> networkEvents)
{
if (roomSyncPatterns.Count == 0)
return false;
var patternHandledTypes = new HashSet<string>(
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("// <auto-generated>");
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<string, {token}ServerPlayerState> _{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<MessageSpec> 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<MessageSpec> 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<RoomSyncPatternSpec> FindRoomSyncPatterns(IReadOnlyList<MessageSpec> messages)
{
var patterns = new List<RoomSyncPatternSpec>();
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<string>(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<MessageSpec> 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<MessageSpec> 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<MessageSpec> 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<MessageSpec> 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<string>(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<string> 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<MessageSpec> messages,
IReadOnlyList<SubscriberSpec> subscribers,
IReadOnlyList<EnumSpec> enums,
IReadOnlyList<DataTypeSpec> dataTypes,
IReadOnlyCollection<string> unresolvedTypes,
IReadOnlyList<RoomSyncPatternSpec> roomSyncPatterns,
IReadOnlyList<NetworkEventSpec> 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("- 旧演示仍可回退为 `<prefix>/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<string> { 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<MessageSpec> 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<NetworkEventSpec> BuildNetworkEventSpecs(
IReadOnlyList<MessageSpec> messages,
IReadOnlyList<RoomSyncPatternSpec> roomSyncPatterns)
{
var roomSyncMessageTypes = new HashSet<string>(
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<NetworkEventSpec> 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<SubscriberSpec> 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<MessageSpec> 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<string> 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