1523 lines
74 KiB
C#
1523 lines
74 KiB
C#
#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
|