commit 8eaaa3040a59c7f7f2b595b69931cc5443ac66e3 Author: cneicy Date: Wed Aug 26 02:50:34 2026 +0800 chore: initialize standalone UPM package diff --git a/.gitea/workflows/publish.yml b/.gitea/workflows/publish.yml new file mode 100644 index 0000000..ad69e9b --- /dev/null +++ b/.gitea/workflows/publish.yml @@ -0,0 +1,49 @@ +name: Publish UPM package + +on: + push: + tags: + - 'v*' + workflow_dispatch: + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + env: + NODE_AUTH_TOKEN: ${{ secrets.SHRINKSDK_PACKAGE_TOKEN }} + steps: + - name: Fetch tagged revision + shell: bash + run: | + set -eu + ref="${{ gitea.sha }}" + test -n "$ref" + git init . + git remote add origin "https://git.crash.work/ShrinkSDK/ShrinkNetwork.git" + git fetch --depth=1 origin "$ref" + git checkout --detach FETCH_HEAD + + - name: Validate immutable release version + shell: bash + run: | + set -eu + tag="$(git describe --exact-match --tags HEAD)" + version="$(node -p "require('./package.json').version")" + test "$tag" = "v$version" + npm pack --dry-run + + - name: Publish to ShrinkSDK registry + shell: bash + run: | + set -eu + : "${NODE_AUTH_TOKEN:?SHRINKSDK_PACKAGE_TOKEN is required}" + npmrc="$HOME/.npmrc" + cleanup() { rm -f "$npmrc"; } + trap cleanup EXIT + printf '%s\n' \ + 'registry=https://git.crash.work/api/packages/ShrinkSDK/npm/' \ + '//git.crash.work/api/packages/ShrinkSDK/npm/:_authToken=${NODE_AUTH_TOKEN}' > "$npmrc" + npm publish --registry=https://git.crash.work/api/packages/ShrinkSDK/npm/ \ No newline at end of file diff --git a/.gitea/workflows/unity-verify.yml b/.gitea/workflows/unity-verify.yml new file mode 100644 index 0000000..bc29919 --- /dev/null +++ b/.gitea/workflows/unity-verify.yml @@ -0,0 +1,39 @@ +name: Verify standalone Unity package + +on: + workflow_dispatch: + +jobs: + editmode: + runs-on: unity-2022.3.62f3 + container: + image: docker.1panel.live/unityci/editor:ubuntu-2022.3.62f3-windows-mono-3 + volumes: + - /www/dk_project/bt-recovery/gitea/runner/seedskey-unity-license:/root/.local/share/unity3d/Unity + - /www/dk_project/bt-recovery/gitea/runner/seedskey-unity-entitlements:/root/.config/unity3d/Unity/licenses + steps: + - name: Fetch selected revision + shell: bash + run: | + set -eu + ref="${{ gitea.sha }}" + git init . + git remote add origin "https://git.crash.work/ShrinkSDK/ShrinkNetwork.git" + git fetch --depth=1 origin "$ref" + git checkout --detach FETCH_HEAD + + - name: Run package EditMode tests + shell: bash + run: | + set -eu + unity_bin="$(command -v unity-editor || command -v unity || command -v Unity || true)" + test -n "$unity_bin" + "$unity_bin" \ + -batchmode \ + -nographics \ + -quit \ + -projectPath "$PWD/Development~/UnityProject" \ + -runTests \ + -testPlatform EditMode \ + -testResults "$PWD/TestResults/editmode.xml" \ + -logFile "$PWD/TestResults/unity.log" \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..246935d --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +/Development~/UnityProject/[Ll]ibrary/ +/Development~/UnityProject/[Tt]emp/ +/Development~/UnityProject/[Oo]bj/ +/Development~/UnityProject/[Ll]ogs/ +/Development~/UnityProject/[Uu]ser[Ss]ettings/ +/Development~/UnityProject/TestResults/ +/Tools~/**/[Bb]in/ +/Tools~/**/[Oo]bj/ +*.user +*.DotSettings.user diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..700f03d --- /dev/null +++ b/.npmignore @@ -0,0 +1,8 @@ +.git/ +.gitea/ +Development~/ +Tools~/ +*.csproj +*.sln +*.user +*.DotSettings.user diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..ce0e3ed --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,29 @@ +# Changelog + +本文件记录 `ShrinkNetwork` 在当前工作区中的包内变更。 + +## [0.2.0] - 2026-05-18 + +### Changed + +- 静态消息合同与静态 handler 的默认自动发现从运行时全域反射扫描切换为编译期注册表 `ShrinkNetworkGeneratedRegistry`。 +- `ShrinkNetworkRuntime.Default` 启动时优先消费编译期消息/handler 清单,降低默认服务的注册开销。 +- `RegisterHandlers(object target)` 的实例注册路径保持不变,继续服务于模组实例和运行时对象。 + +## [0.1.0] - 2026-04-07 + +### Added + +- 正式建立 RPC 优先的 Unity 网络层,包含服务、会话、路由、消息注册、权限与日志基础设施。 +- 建立属性式注册模型:`[ShrinkNetworkMessage]`、`[ShrinkNetworkSubscriber]`、`[ShrinkNetworkSubscribe]`。 +- 提供 `ShrinkJsonNetworkSerializer` 与 `ShrinkMessagePackNetworkSerializer`。 +- 提供 `Loopback`、TCP、KCP 传输实现,以及独立服务器生成器与生成宿主链路。 +- 新增会话令牌能力,`ShrinkNetworkPacket`、`ShrinkNetworkSession`、`ShrinkNetworkService` 支持令牌签发、回传与逐包校验。 +- TCP 传输与生成宿主补上可选 TLS 接入。 +- 宿主侧补上 `server.properties` 文本配置、协议版本窗口与基础指标摘要。 + +### Changed + +- 运行时公共类型按真实语义收口可空性,覆盖消息元数据、权限、注册器、路由器、会话、服务、序列化器与传输层。 +- `GetDefaultInitializer(...)` 现会为任意 `T[]` 生成 `Array.Empty()`,避免重新生成合同后数组默认值 warning 回弹。 +- 当前工作区中 `ShrinkSDK.sln` 与独立宿主工程已重新收口到 `0 warning / 0 error`。 diff --git a/CHANGELOG.md.meta b/CHANGELOG.md.meta new file mode 100644 index 0000000..b1526f0 --- /dev/null +++ b/CHANGELOG.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 2ccd00ba8f4c15241be2c1d6200fb547 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Development~/UnityProject/.gitignore b/Development~/UnityProject/.gitignore new file mode 100644 index 0000000..aa2eb00 --- /dev/null +++ b/Development~/UnityProject/.gitignore @@ -0,0 +1,6 @@ +[Ll]ibrary/ +[Tt]emp/ +[Oo]bj/ +[Ll]ogs/ +[Uu]ser[Ss]ettings/ +TestResults/ \ No newline at end of file diff --git a/Development~/UnityProject/Assets/.gitkeep b/Development~/UnityProject/Assets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/Development~/UnityProject/Packages/manifest.json b/Development~/UnityProject/Packages/manifest.json new file mode 100644 index 0000000..20c1270 --- /dev/null +++ b/Development~/UnityProject/Packages/manifest.json @@ -0,0 +1,15 @@ +{ + "scopedRegistries": [ + { + "name": "ShrinkSDK", + "url": "https://git.crash.work/api/packages/ShrinkSDK/npm/", + "scopes": [ + "com.cneicy" + ] + } + ], + "dependencies": { + "com.unity.test-framework": "1.1.33", + "com.cneicy.shrink-network": "file:../../.." + } +} diff --git a/Development~/UnityProject/ProjectSettings/ProjectVersion.txt b/Development~/UnityProject/ProjectSettings/ProjectVersion.txt new file mode 100644 index 0000000..587f809 --- /dev/null +++ b/Development~/UnityProject/ProjectSettings/ProjectVersion.txt @@ -0,0 +1,2 @@ +m_EditorVersion: 2022.3.62f3 +m_EditorVersionWithRevision: 2022.3.62f3 (96770f904ca7) diff --git a/Editor.meta b/Editor.meta new file mode 100644 index 0000000..b5ab00e --- /dev/null +++ b/Editor.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 1142dff5a900c294e9a66f527fa1710a +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/AssemblyInfo.cs b/Editor/AssemblyInfo.cs new file mode 100644 index 0000000..6727862 --- /dev/null +++ b/Editor/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("ShrinkNetwork.Editor.Tests")] diff --git a/Editor/AssemblyInfo.cs.meta b/Editor/AssemblyInfo.cs.meta new file mode 100644 index 0000000..8adbc8f --- /dev/null +++ b/Editor/AssemblyInfo.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 360b0480ff82f4d4bbb7cecae33717a3 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/Scaffolding.meta b/Editor/Scaffolding.meta new file mode 100644 index 0000000..eddb8b2 --- /dev/null +++ b/Editor/Scaffolding.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 7fbdc5e00c5febc45b3353592b8b008a +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/Scaffolding/ServerProjectTemplate.meta b/Editor/Scaffolding/ServerProjectTemplate.meta new file mode 100644 index 0000000..ef4626f --- /dev/null +++ b/Editor/Scaffolding/ServerProjectTemplate.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 5612ce6a5722f164ca2cc030308b2a37 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/Scaffolding/ServerProjectTemplate/AuthServerModule.cs.txt b/Editor/Scaffolding/ServerProjectTemplate/AuthServerModule.cs.txt new file mode 100644 index 0000000..c16608e --- /dev/null +++ b/Editor/Scaffolding/ServerProjectTemplate/AuthServerModule.cs.txt @@ -0,0 +1,184 @@ +using Cysharp.Threading.Tasks; +using ShrinkNetwork; +using ShrinkNetwork.ServerHost.Framework; + +namespace ShrinkNetwork.ServerHost; + +public sealed class AuthServerModule : IShrinkServerModule +{ + public const int LoginRequestOpcode = 1201; + public const int LoginResponseOpcode = 1202; + public const int RefreshRequestOpcode = 1203; + public const int RefreshResponseOpcode = 1204; + public const string LoginRoute = "server/auth/login"; + public const string RefreshRoute = "server/auth/refresh"; + private static readonly string[] DefaultGrantedPermissions = { "auth.ok", "room.access" }; + + public string Name => "Auth"; + + public void ConfigureService(ServerModuleContext context, ShrinkNetworkService service, string transportName) + { + service.RegisterMessage(LoginRequestOpcode, LoginRoute); + service.RegisterMessage(LoginResponseOpcode, "server/auth/login_response"); + service.RegisterMessage(RefreshRequestOpcode, RefreshRoute); + service.RegisterMessage(RefreshResponseOpcode, "server/auth/refresh_response"); + service.RegisterRequestHandler((ctx, request) => + HandleLoginAsync(context, transportName, ctx, request)); + service.RegisterRequestHandler((ctx, request) => + HandleRefreshAsync(context, ctx, request)); + } + + public UniTask StartAsync(ServerModuleContext context, CancellationToken cancellationToken) + { + return UniTask.CompletedTask; + } + + private UniTask HandleLoginAsync( + ServerModuleContext moduleContext, + string transportName, + ShrinkNetworkContext context, + ServerAuthLoginRequest request) + { + var authToken = moduleContext.Options.SharedAuthToken; + var requiresAuth = !string.IsNullOrWhiteSpace(authToken); + if (!requiresAuth && !moduleContext.Options.AllowAnonymousWhenAuthTokenMissing) + { + return UniTask.FromResult(new ServerAuthLoginResponse + { + ErrorCode = 503, + ErrorMessage = "服务端未配置 SHRINK_SERVER_AUTH_TOKEN,且未显式允许匿名登录。", + IsAuthenticated = false + }); + } + + if (requiresAuth && !string.Equals(authToken, request.Token, StringComparison.Ordinal)) + { + return UniTask.FromResult(new ServerAuthLoginResponse + { + ErrorCode = ShrinkRpcErrorCode.AuthenticationFailed, + ErrorMessage = "鉴权失败,口令不正确。", + IsAuthenticated = false + }); + } + + var authName = string.IsNullOrWhiteSpace(request.ClientName) + ? $"{transportName}-{context.Session.SessionId}" + : request.ClientName.Trim(); + var authState = moduleContext.AuthStore.MarkAuthenticated(context.Service, context.Session.SessionId, authName, + BuildSessionTokenTtl(moduleContext.Options)); + + context.Session.Items["auth.name"] = authName; + context.Session.Items["auth.at_utc"] = DateTimeOffset.UtcNow; + context.Session.SetSessionToken(authState.SessionToken, authState.SessionTokenExpiresAtUtc); + foreach (var permission in DefaultGrantedPermissions) + context.Session.GrantPermission(permission); + + return UniTask.FromResult(new ServerAuthLoginResponse + { + IsAuthenticated = true, + AuthName = authName, + SessionToken = authState.SessionToken, + SessionTokenExpiresAtUnixTimeSeconds = authState.SessionTokenExpiresAtUtc.ToUnixTimeSeconds(), + RefreshRecommendedAtUnixTimeSeconds = BuildRefreshRecommendedAtUnixTimeSeconds( + authState.SessionTokenExpiresAtUtc, + moduleContext.Options), + GrantedPermissions = DefaultGrantedPermissions.ToArray(), + ServerMessage = requiresAuth ? "鉴权成功。" : "服务端已显式允许匿名登录。" + }); + } + + private UniTask HandleRefreshAsync( + ServerModuleContext moduleContext, + ShrinkNetworkContext context, + ServerAuthRefreshRequest request) + { + var sessionToken = string.IsNullOrWhiteSpace(request.SessionToken) + ? context.Packet.SessionToken + : request.SessionToken.Trim(); + if (!moduleContext.AuthStore.TryRefreshSessionToken(context.Service, context.Session.SessionId, sessionToken, + BuildSessionTokenTtl(moduleContext.Options), BuildRefreshWindow(moduleContext.Options), out var authState, + out var failureReason)) + { + return UniTask.FromResult(new ServerAuthRefreshResponse + { + ErrorCode = string.Equals(failureReason, "会话令牌已过期。", StringComparison.Ordinal) + ? ShrinkRpcErrorCode.SessionTokenExpired + : ShrinkRpcErrorCode.AuthenticationFailed, + ErrorMessage = failureReason, + IsAuthenticated = false + }); + } + + context.Session.SetSessionToken(authState.SessionToken, authState.SessionTokenExpiresAtUtc); + context.Session.Items["auth.name"] = authState.AuthName; + foreach (var permission in DefaultGrantedPermissions) + context.Session.GrantPermission(permission); + + return UniTask.FromResult(new ServerAuthRefreshResponse + { + IsAuthenticated = true, + AuthName = authState.AuthName, + SessionToken = authState.SessionToken, + SessionTokenExpiresAtUnixTimeSeconds = authState.SessionTokenExpiresAtUtc.ToUnixTimeSeconds(), + RefreshRecommendedAtUnixTimeSeconds = BuildRefreshRecommendedAtUnixTimeSeconds( + authState.SessionTokenExpiresAtUtc, + moduleContext.Options), + ServerMessage = "会话令牌已刷新。" + }); + } + + private static TimeSpan BuildSessionTokenTtl(ServerHostOptions options) + { + return TimeSpan.FromSeconds(Math.Max(60, options.SessionTokenTtlSeconds)); + } + + private static TimeSpan BuildRefreshWindow(ServerHostOptions options) + { + return TimeSpan.FromSeconds(Math.Max(0, options.SessionTokenRefreshWindowSeconds)); + } + + private static long BuildRefreshRecommendedAtUnixTimeSeconds(DateTimeOffset expiresAtUtc, ServerHostOptions options) + { + var refreshWindow = BuildRefreshWindow(options); + if (refreshWindow <= TimeSpan.Zero) + return 0; + + return expiresAtUtc.Subtract(refreshWindow).ToUnixTimeSeconds(); + } +} + +[ShrinkNetworkMessage(AuthServerModule.LoginRequestOpcode, AuthServerModule.LoginRoute)] +public sealed class ServerAuthLoginRequest : IShrinkNetworkRequest +{ + public string ClientName { get; set; } = string.Empty; + public string Token { get; set; } = string.Empty; +} + +[ShrinkNetworkMessage(AuthServerModule.LoginResponseOpcode, "server/auth/login_response")] +public sealed class ServerAuthLoginResponse : ShrinkRpcResponseBase +{ + public bool IsAuthenticated { get; set; } + public string AuthName { get; set; } = string.Empty; + public string SessionToken { get; set; } = string.Empty; + public long SessionTokenExpiresAtUnixTimeSeconds { get; set; } + public long RefreshRecommendedAtUnixTimeSeconds { get; set; } + public string[] GrantedPermissions { get; set; } = Array.Empty(); + public string ServerMessage { get; set; } = string.Empty; +} + +[ShrinkNetworkMessage(AuthServerModule.RefreshRequestOpcode, AuthServerModule.RefreshRoute)] +public sealed class ServerAuthRefreshRequest : IShrinkNetworkRequest +{ + public string SessionToken { get; set; } = string.Empty; +} + +[ShrinkNetworkMessage(AuthServerModule.RefreshResponseOpcode, "server/auth/refresh_response")] +public sealed class ServerAuthRefreshResponse : ShrinkRpcResponseBase +{ + public bool IsAuthenticated { get; set; } + public string AuthName { get; set; } = string.Empty; + public string SessionToken { get; set; } = string.Empty; + public long SessionTokenExpiresAtUnixTimeSeconds { get; set; } + public long RefreshRecommendedAtUnixTimeSeconds { get; set; } + public string ServerMessage { get; set; } = string.Empty; +} diff --git a/Editor/Scaffolding/ServerProjectTemplate/AuthServerModule.cs.txt.meta b/Editor/Scaffolding/ServerProjectTemplate/AuthServerModule.cs.txt.meta new file mode 100644 index 0000000..94ad30e --- /dev/null +++ b/Editor/Scaffolding/ServerProjectTemplate/AuthServerModule.cs.txt.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 983129d399a30af419b08bc0cb951054 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/Scaffolding/ServerProjectTemplate/Framework.meta b/Editor/Scaffolding/ServerProjectTemplate/Framework.meta new file mode 100644 index 0000000..1804050 --- /dev/null +++ b/Editor/Scaffolding/ServerProjectTemplate/Framework.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 5e85da237e6287d4fbf41163901f9ff8 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/Scaffolding/ServerProjectTemplate/Framework/IShrinkServerModule.cs.txt b/Editor/Scaffolding/ServerProjectTemplate/Framework/IShrinkServerModule.cs.txt new file mode 100644 index 0000000..9b70de7 --- /dev/null +++ b/Editor/Scaffolding/ServerProjectTemplate/Framework/IShrinkServerModule.cs.txt @@ -0,0 +1,13 @@ +using Cysharp.Threading.Tasks; +using ShrinkNetwork; + +namespace ShrinkNetwork.ServerHost.Framework; + +public interface IShrinkServerModule +{ + string Name { get; } + + void ConfigureService(ServerModuleContext context, ShrinkNetworkService service, string transportName); + + UniTask StartAsync(ServerModuleContext context, CancellationToken cancellationToken); +} diff --git a/Editor/Scaffolding/ServerProjectTemplate/Framework/IShrinkServerModule.cs.txt.meta b/Editor/Scaffolding/ServerProjectTemplate/Framework/IShrinkServerModule.cs.txt.meta new file mode 100644 index 0000000..d881d17 --- /dev/null +++ b/Editor/Scaffolding/ServerProjectTemplate/Framework/IShrinkServerModule.cs.txt.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: c849f6eb91ed5824db0497318345601b +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/Scaffolding/ServerProjectTemplate/Framework/ServerAuthStore.cs.txt b/Editor/Scaffolding/ServerProjectTemplate/Framework/ServerAuthStore.cs.txt new file mode 100644 index 0000000..6981f37 --- /dev/null +++ b/Editor/Scaffolding/ServerProjectTemplate/Framework/ServerAuthStore.cs.txt @@ -0,0 +1,152 @@ +using System.Collections.Concurrent; +using System.Security.Cryptography; +using ShrinkNetwork; + +namespace ShrinkNetwork.ServerHost.Framework; + +public sealed class ServerAuthStore +{ + public sealed class AuthState + { + public string AuthName { get; set; } = string.Empty; + public bool IsAuthenticated { get; set; } + public DateTimeOffset AuthenticatedAtUtc { get; set; } + public DateTimeOffset LastSeenAtUtc { get; set; } + public string SessionToken { get; set; } = string.Empty; + public DateTimeOffset SessionTokenExpiresAtUtc { get; set; } + } + + private readonly ConcurrentDictionary<(ShrinkNetworkService service, long sessionId), AuthState> _states = new(); + + public AuthState MarkAuthenticated(ShrinkNetworkService service, long sessionId, string authName, TimeSpan tokenTtl) + { + var now = DateTimeOffset.UtcNow; + var state = new AuthState + { + AuthName = authName, + IsAuthenticated = true, + AuthenticatedAtUtc = now, + LastSeenAtUtc = now, + SessionToken = CreateSessionToken(), + SessionTokenExpiresAtUtc = now.Add(tokenTtl) + }; + _states[(service, sessionId)] = state; + return CloneState(state); + } + + public void Remove(ShrinkNetworkService service, long sessionId) + { + _states.TryRemove((service, sessionId), out _); + } + + public bool IsAuthenticated(ShrinkNetworkService service, long sessionId) + { + return _states.TryGetValue((service, sessionId), out var state) && state.IsAuthenticated; + } + + public bool TryGetState(ShrinkNetworkService service, long sessionId, out AuthState state) + { + if (_states.TryGetValue((service, sessionId), out var current)) + { + state = CloneState(current); + return true; + } + + state = null!; + return false; + } + + public bool TryValidateSessionToken(ShrinkNetworkService service, long sessionId, string? sessionToken, out AuthState state, + out string failureReason) + { + if (!_states.TryGetValue((service, sessionId), out var current) || !current.IsAuthenticated) + { + state = null!; + failureReason = "会话未认证。"; + return false; + } + + if (string.IsNullOrWhiteSpace(sessionToken)) + { + state = null!; + failureReason = "缺少会话令牌。"; + return false; + } + + if (!string.Equals(current.SessionToken, sessionToken.Trim(), StringComparison.Ordinal)) + { + state = null!; + failureReason = "会话令牌不匹配。"; + return false; + } + + var now = DateTimeOffset.UtcNow; + if (current.SessionTokenExpiresAtUtc <= now) + { + state = null!; + failureReason = "会话令牌已过期。"; + return false; + } + + current.LastSeenAtUtc = now; + state = CloneState(current); + failureReason = string.Empty; + return true; + } + + public bool TryRefreshSessionToken(ShrinkNetworkService service, long sessionId, string? sessionToken, TimeSpan tokenTtl, + TimeSpan refreshWindow, out AuthState state, out string failureReason) + { + if (!TryValidateSessionToken(service, sessionId, sessionToken, out _, out failureReason)) + { + state = null!; + return false; + } + + var key = (service, sessionId); + if (!_states.TryGetValue(key, out var current)) + { + state = null!; + failureReason = "认证状态不存在。"; + return false; + } + + var now = DateTimeOffset.UtcNow; + if (refreshWindow > TimeSpan.Zero && current.SessionTokenExpiresAtUtc - now > refreshWindow) + { + state = null!; + failureReason = "当前还不在会话令牌续期窗口内。"; + return false; + } + + current.SessionToken = CreateSessionToken(); + current.SessionTokenExpiresAtUtc = now.Add(tokenTtl); + current.LastSeenAtUtc = now; + state = CloneState(current); + failureReason = string.Empty; + return true; + } + + private static AuthState CloneState(AuthState state) + { + return new AuthState + { + AuthName = state.AuthName, + IsAuthenticated = state.IsAuthenticated, + AuthenticatedAtUtc = state.AuthenticatedAtUtc, + LastSeenAtUtc = state.LastSeenAtUtc, + SessionToken = state.SessionToken, + SessionTokenExpiresAtUtc = state.SessionTokenExpiresAtUtc + }; + } + + private static string CreateSessionToken() + { + var buffer = new byte[32]; + RandomNumberGenerator.Fill(buffer); + return Convert.ToBase64String(buffer) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); + } +} diff --git a/Editor/Scaffolding/ServerProjectTemplate/Framework/ServerAuthStore.cs.txt.meta b/Editor/Scaffolding/ServerProjectTemplate/Framework/ServerAuthStore.cs.txt.meta new file mode 100644 index 0000000..a55fc39 --- /dev/null +++ b/Editor/Scaffolding/ServerProjectTemplate/Framework/ServerAuthStore.cs.txt.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 911b8227233322049a930fb0ed3c2701 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/Scaffolding/ServerProjectTemplate/Framework/ServerHostOptions.cs.txt b/Editor/Scaffolding/ServerProjectTemplate/Framework/ServerHostOptions.cs.txt new file mode 100644 index 0000000..0bd7eda --- /dev/null +++ b/Editor/Scaffolding/ServerProjectTemplate/Framework/ServerHostOptions.cs.txt @@ -0,0 +1,145 @@ +using ShrinkNetwork; + +namespace ShrinkNetwork.ServerHost.Framework; + +public sealed class ServerHostOptions +{ + public int Port { get; set; } = 17777; + public string UnityAssetsPath { get; set; } = ResolveDefaultUnityAssetsPath(); + public string ScanOutputDirectory { get; set; } = ResolveDefaultScanOutputDirectory(); + public string? SharedAuthToken { get; set; } + public bool AllowAnonymousWhenAuthTokenMissing { get; set; } + public bool EnableSessionTokens { get; set; } = true; + public int SessionTokenTtlSeconds { get; set; } = 1800; + public int SessionTokenRefreshWindowSeconds { get; set; } = 300; + public bool DisconnectOnInvalidSessionToken { get; set; } = true; + public bool EnableUnityCodeScan { get; set; } = true; + public int MinProtocolVersion { get; set; } = ShrinkNetworkProtocol.CurrentProtocolVersion; + public int MaxProtocolVersion { get; set; } = ShrinkNetworkProtocol.CurrentProtocolVersion; + public int MinSchemaVersion { get; set; } = ShrinkNetworkProtocol.CurrentSchemaVersion; + public int MaxSchemaVersion { get; set; } = ShrinkNetworkProtocol.CurrentSchemaVersion; + public bool DisconnectOnProtocolViolation { get; set; } = true; + public int DiagnosticsLogIntervalSeconds { get; set; } = 60; + public bool EnableTcpTls { get; set; } + public string? TcpTlsTargetHost { get; set; } + public string? TcpTlsCertificatePath { get; set; } + public string? TcpTlsCertificatePassword { get; set; } + public bool TcpTlsCheckCertificateRevocation { get; set; } + + public void ApplyEnvironmentOverrides() + { + SharedAuthToken = ReadOptionalStringFromEnvironment("SHRINK_SERVER_AUTH_TOKEN", SharedAuthToken); + AllowAnonymousWhenAuthTokenMissing = ReadBoolFromEnvironment( + "SHRINK_SERVER_ALLOW_ANONYMOUS_WHEN_AUTH_TOKEN_MISSING", + AllowAnonymousWhenAuthTokenMissing); + EnableSessionTokens = ReadBoolFromEnvironment("SHRINK_SERVER_ENABLE_SESSION_TOKENS", EnableSessionTokens); + SessionTokenTtlSeconds = ReadIntFromEnvironment("SHRINK_SERVER_SESSION_TOKEN_TTL_SECONDS", SessionTokenTtlSeconds, 60); + SessionTokenRefreshWindowSeconds = ReadIntFromEnvironment( + "SHRINK_SERVER_SESSION_TOKEN_REFRESH_WINDOW_SECONDS", + SessionTokenRefreshWindowSeconds, + 0); + DisconnectOnInvalidSessionToken = ReadBoolFromEnvironment( + "SHRINK_SERVER_DISCONNECT_ON_INVALID_SESSION_TOKEN", + DisconnectOnInvalidSessionToken); + EnableUnityCodeScan = ReadBoolFromEnvironment("SHRINK_SERVER_ENABLE_UNITY_CODE_SCAN", EnableUnityCodeScan); + MinProtocolVersion = ReadIntFromEnvironment("SHRINK_SERVER_MIN_PROTOCOL_VERSION", MinProtocolVersion, 1); + MaxProtocolVersion = ReadIntFromEnvironment("SHRINK_SERVER_MAX_PROTOCOL_VERSION", MaxProtocolVersion, 1); + MinSchemaVersion = ReadIntFromEnvironment("SHRINK_SERVER_MIN_SCHEMA_VERSION", MinSchemaVersion, 1); + MaxSchemaVersion = ReadIntFromEnvironment("SHRINK_SERVER_MAX_SCHEMA_VERSION", MaxSchemaVersion, 1); + DisconnectOnProtocolViolation = ReadBoolFromEnvironment( + "SHRINK_SERVER_DISCONNECT_ON_PROTOCOL_VIOLATION", + DisconnectOnProtocolViolation); + DiagnosticsLogIntervalSeconds = ReadIntFromEnvironment( + "SHRINK_SERVER_DIAGNOSTICS_LOG_INTERVAL_SECONDS", + DiagnosticsLogIntervalSeconds, + 0); + EnableTcpTls = ReadBoolFromEnvironment("SHRINK_SERVER_ENABLE_TCP_TLS", EnableTcpTls); + TcpTlsTargetHost = ReadOptionalStringFromEnvironment("SHRINK_SERVER_TLS_TARGET_HOST", TcpTlsTargetHost); + TcpTlsCertificatePath = ReadOptionalStringFromEnvironment("SHRINK_SERVER_TLS_CERT_PATH", TcpTlsCertificatePath); + TcpTlsCertificatePassword = ReadOptionalStringFromEnvironment( + "SHRINK_SERVER_TLS_CERT_PASSWORD", + TcpTlsCertificatePassword); + TcpTlsCheckCertificateRevocation = ReadBoolFromEnvironment( + "SHRINK_SERVER_TLS_CHECK_CERTIFICATE_REVOCATION", + TcpTlsCheckCertificateRevocation); + } + + public static string ResolveHostProjectDirectory() + { + var resolved = TryFindProjectDirectory(AppContext.BaseDirectory); + if (!string.IsNullOrWhiteSpace(resolved)) + return resolved; + + resolved = TryFindProjectDirectory(Environment.CurrentDirectory); + if (!string.IsNullOrWhiteSpace(resolved)) + return resolved; + + return Environment.CurrentDirectory; + } + + public static string ResolveDefaultConfigFilePath() + { + return Path.Combine(ResolveHostProjectDirectory(), ServerHostProperties.DefaultFileName); + } + + public static string ResolveDefaultUnityAssetsPath() + { + return Path.GetFullPath(Path.Combine(ResolveHostProjectDirectory(), "..", "..", "Assets")); + } + + public static string ResolveDefaultScanOutputDirectory() + { + return Path.GetFullPath(Path.Combine(ResolveHostProjectDirectory(), "Generated")); + } + + private static string? TryFindProjectDirectory(string startDirectory) + { + if (string.IsNullOrWhiteSpace(startDirectory) || !Directory.Exists(startDirectory)) + return null; + + var directory = new DirectoryInfo(Path.GetFullPath(startDirectory)); + for (var depth = 0; directory != null && depth < 8; depth++, directory = directory.Parent) + { + var candidate = Path.Combine(directory.FullName, "ShrinkNetwork.ServerHost.csproj"); + if (File.Exists(candidate)) + return directory.FullName; + } + + return null; + } + + private static int ReadIntFromEnvironment(string name, int fallback, int minValue) + { + var raw = Environment.GetEnvironmentVariable(name); + if (!int.TryParse(raw, out var value)) + return fallback; + + return Math.Max(minValue, value); + } + + private static bool ReadBoolFromEnvironment(string name, bool fallback) + { + var raw = Environment.GetEnvironmentVariable(name); + if (string.IsNullOrWhiteSpace(raw)) + return fallback; + + if (bool.TryParse(raw, out var parsed)) + return parsed; + + return raw switch + { + "1" => true, + "0" => false, + _ => fallback + }; + } + + private static string? ReadOptionalStringFromEnvironment(string name, string? fallback) + { + var raw = Environment.GetEnvironmentVariable(name); + if (raw == null) + return fallback; + + return string.IsNullOrWhiteSpace(raw) ? string.Empty : raw.Trim(); + } +} diff --git a/Editor/Scaffolding/ServerProjectTemplate/Framework/ServerHostOptions.cs.txt.meta b/Editor/Scaffolding/ServerProjectTemplate/Framework/ServerHostOptions.cs.txt.meta new file mode 100644 index 0000000..43bda2b --- /dev/null +++ b/Editor/Scaffolding/ServerProjectTemplate/Framework/ServerHostOptions.cs.txt.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 515c40ff2d019da4985e05bd55a5443f +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/Scaffolding/ServerProjectTemplate/Framework/ServerHostProperties.cs.txt b/Editor/Scaffolding/ServerProjectTemplate/Framework/ServerHostProperties.cs.txt new file mode 100644 index 0000000..1188e67 --- /dev/null +++ b/Editor/Scaffolding/ServerProjectTemplate/Framework/ServerHostProperties.cs.txt @@ -0,0 +1,280 @@ +using System.Text; + +namespace ShrinkNetwork.ServerHost.Framework; + +public sealed class ServerHostPropertiesLoadResult +{ + public required ServerHostOptions Options { get; init; } + public required string FilePath { get; init; } + public bool CreatedDefaultFile { get; init; } + public IReadOnlyList Warnings { get; init; } = Array.Empty(); +} + +public static class ServerHostProperties +{ + public const string DefaultFileName = "server.properties"; + + public static ServerHostPropertiesLoadResult LoadOrCreate() + { + var filePath = ResolveFilePath(); + var options = new ServerHostOptions(); + var warnings = new List(); + var createdDefaultFile = false; + + if (!File.Exists(filePath)) + { + var directory = Path.GetDirectoryName(filePath); + if (!string.IsNullOrWhiteSpace(directory)) + Directory.CreateDirectory(directory); + + File.WriteAllText(filePath, BuildDefaultFile(filePath, options), Encoding.UTF8); + createdDefaultFile = true; + } + + ApplyFile(filePath, options, warnings); + options.ApplyEnvironmentOverrides(); + NormalizeOptions(options); + + return new ServerHostPropertiesLoadResult + { + Options = options, + FilePath = filePath, + CreatedDefaultFile = createdDefaultFile, + Warnings = warnings + }; + } + + private static string ResolveFilePath() + { + var configured = Environment.GetEnvironmentVariable("SHRINK_SERVER_CONFIG_PATH"); + if (!string.IsNullOrWhiteSpace(configured)) + return Path.GetFullPath(configured.Trim()); + + var currentDirectoryCandidate = Path.Combine(Environment.CurrentDirectory, DefaultFileName); + if (File.Exists(currentDirectoryCandidate)) + return currentDirectoryCandidate; + + return ServerHostOptions.ResolveDefaultConfigFilePath(); + } + + private static void ApplyFile(string filePath, ServerHostOptions options, List warnings) + { + var directory = Path.GetDirectoryName(filePath) ?? Environment.CurrentDirectory; + var lines = File.ReadAllLines(filePath); + for (var index = 0; index < lines.Length; index++) + { + var rawLine = lines[index].Trim(); + if (string.IsNullOrWhiteSpace(rawLine) || rawLine.StartsWith("#", StringComparison.Ordinal) || + rawLine.StartsWith(";", StringComparison.Ordinal)) + continue; + + var separatorIndex = rawLine.IndexOf('='); + if (separatorIndex <= 0) + { + warnings.Add($"第 {index + 1} 行不是有效的 key=value,将忽略。"); + continue; + } + + var key = rawLine[..separatorIndex].Trim(); + var value = rawLine[(separatorIndex + 1)..].Trim(); + ApplyValue(options, directory, key, value, warnings); + } + } + + private static void ApplyValue(ServerHostOptions options, string directory, string key, string value, List warnings) + { + switch (key) + { + case "server-port": + options.Port = ParseInt(value, options.Port, 1, warnings, key); + break; + case "unity-assets-path": + options.UnityAssetsPath = ResolvePath(directory, value, options.UnityAssetsPath); + break; + case "scan-output-directory": + options.ScanOutputDirectory = ResolvePath(directory, value, options.ScanOutputDirectory); + break; + case "shared-auth-token": + options.SharedAuthToken = value; + break; + case "allow-anonymous-when-auth-token-missing": + options.AllowAnonymousWhenAuthTokenMissing = ParseBool(value, options.AllowAnonymousWhenAuthTokenMissing, warnings, key); + break; + case "enable-session-tokens": + options.EnableSessionTokens = ParseBool(value, options.EnableSessionTokens, warnings, key); + break; + case "session-token-ttl-seconds": + options.SessionTokenTtlSeconds = ParseInt(value, options.SessionTokenTtlSeconds, 60, warnings, key); + break; + case "session-token-refresh-window-seconds": + options.SessionTokenRefreshWindowSeconds = ParseInt(value, options.SessionTokenRefreshWindowSeconds, 0, warnings, key); + break; + case "disconnect-on-invalid-session-token": + options.DisconnectOnInvalidSessionToken = ParseBool(value, options.DisconnectOnInvalidSessionToken, warnings, key); + break; + case "enable-unity-code-scan": + options.EnableUnityCodeScan = ParseBool(value, options.EnableUnityCodeScan, warnings, key); + break; + case "min-protocol-version": + options.MinProtocolVersion = ParseInt(value, options.MinProtocolVersion, 1, warnings, key); + break; + case "max-protocol-version": + options.MaxProtocolVersion = ParseInt(value, options.MaxProtocolVersion, 1, warnings, key); + break; + case "min-schema-version": + options.MinSchemaVersion = ParseInt(value, options.MinSchemaVersion, 1, warnings, key); + break; + case "max-schema-version": + options.MaxSchemaVersion = ParseInt(value, options.MaxSchemaVersion, 1, warnings, key); + break; + case "disconnect-on-protocol-violation": + options.DisconnectOnProtocolViolation = ParseBool(value, options.DisconnectOnProtocolViolation, warnings, key); + break; + case "diagnostics-log-interval-seconds": + options.DiagnosticsLogIntervalSeconds = ParseInt(value, options.DiagnosticsLogIntervalSeconds, 0, warnings, key); + break; + case "enable-tcp-tls": + options.EnableTcpTls = ParseBool(value, options.EnableTcpTls, warnings, key); + break; + case "tcp-tls-target-host": + options.TcpTlsTargetHost = NullIfEmpty(value); + break; + case "tcp-tls-certificate-path": + options.TcpTlsCertificatePath = ResolvePath(directory, value, options.TcpTlsCertificatePath); + break; + case "tcp-tls-certificate-password": + options.TcpTlsCertificatePassword = value; + break; + case "tcp-tls-check-certificate-revocation": + options.TcpTlsCheckCertificateRevocation = ParseBool(value, options.TcpTlsCheckCertificateRevocation, warnings, key); + break; + default: + warnings.Add($"未识别的配置键:{key}"); + break; + } + } + + private static void NormalizeOptions(ServerHostOptions options) + { + options.SharedAuthToken = NullIfEmpty(options.SharedAuthToken); + options.TcpTlsTargetHost = NullIfEmpty(options.TcpTlsTargetHost); + options.TcpTlsCertificatePath = NullIfEmpty(options.TcpTlsCertificatePath); + options.TcpTlsCertificatePassword = NullIfEmpty(options.TcpTlsCertificatePassword); + options.SessionTokenRefreshWindowSeconds = Math.Max(0, options.SessionTokenRefreshWindowSeconds); + options.SessionTokenTtlSeconds = Math.Max(60, options.SessionTokenTtlSeconds); + options.Port = Math.Clamp(options.Port, 1, 65535); + options.DiagnosticsLogIntervalSeconds = Math.Max(0, options.DiagnosticsLogIntervalSeconds); + if (options.MaxProtocolVersion < options.MinProtocolVersion) + options.MaxProtocolVersion = options.MinProtocolVersion; + if (options.MaxSchemaVersion < options.MinSchemaVersion) + options.MaxSchemaVersion = options.MinSchemaVersion; + if (options.EnableTcpTls && string.IsNullOrWhiteSpace(options.TcpTlsCertificatePath)) + options.EnableTcpTls = false; + } + + private static string BuildDefaultFile(string filePath, ServerHostOptions options) + { + var directory = Path.GetDirectoryName(filePath) ?? Environment.CurrentDirectory; + var builder = new StringBuilder(); + builder.AppendLine("# ShrinkNetwork server.properties"); + builder.AppendLine("# 类似 Minecraft 的 key=value 配置文件。"); + builder.AppendLine("# 优先级:代码默认值 < 本文件 < 环境变量。"); + builder.AppendLine("# 修改后需要重启服务器进程。"); + builder.AppendLine(); + builder.AppendLine("# 基础网络"); + builder.AppendLine($"server-port={options.Port}"); + builder.AppendLine(); + builder.AppendLine("# 代码扫描与生成"); + builder.AppendLine($"unity-assets-path={ToRelativePath(directory, options.UnityAssetsPath)}"); + builder.AppendLine($"scan-output-directory={ToRelativePath(directory, options.ScanOutputDirectory)}"); + builder.AppendLine($"enable-unity-code-scan={options.EnableUnityCodeScan.ToString().ToLowerInvariant()}"); + builder.AppendLine(); + builder.AppendLine("# 登录与会话"); + builder.AppendLine("shared-auth-token="); + builder.AppendLine($"allow-anonymous-when-auth-token-missing={options.AllowAnonymousWhenAuthTokenMissing.ToString().ToLowerInvariant()}"); + builder.AppendLine($"enable-session-tokens={options.EnableSessionTokens.ToString().ToLowerInvariant()}"); + builder.AppendLine($"session-token-ttl-seconds={options.SessionTokenTtlSeconds}"); + builder.AppendLine($"session-token-refresh-window-seconds={options.SessionTokenRefreshWindowSeconds}"); + builder.AppendLine($"disconnect-on-invalid-session-token={options.DisconnectOnInvalidSessionToken.ToString().ToLowerInvariant()}"); + builder.AppendLine(); + builder.AppendLine("# 协议兼容"); + builder.AppendLine($"min-protocol-version={options.MinProtocolVersion}"); + builder.AppendLine($"max-protocol-version={options.MaxProtocolVersion}"); + builder.AppendLine($"min-schema-version={options.MinSchemaVersion}"); + builder.AppendLine($"max-schema-version={options.MaxSchemaVersion}"); + builder.AppendLine($"disconnect-on-protocol-violation={options.DisconnectOnProtocolViolation.ToString().ToLowerInvariant()}"); + builder.AppendLine(); + builder.AppendLine("# 观测"); + builder.AppendLine($"diagnostics-log-interval-seconds={options.DiagnosticsLogIntervalSeconds}"); + builder.AppendLine(); + builder.AppendLine("# TCP TLS"); + builder.AppendLine($"enable-tcp-tls={options.EnableTcpTls.ToString().ToLowerInvariant()}"); + builder.AppendLine("tcp-tls-target-host="); + builder.AppendLine("tcp-tls-certificate-path="); + builder.AppendLine("tcp-tls-certificate-password="); + builder.AppendLine($"tcp-tls-check-certificate-revocation={options.TcpTlsCheckCertificateRevocation.ToString().ToLowerInvariant()}"); + return builder.ToString(); + } + + private static int ParseInt(string raw, int fallback, int minValue, List warnings, string key) + { + if (!int.TryParse(raw, out var value)) + { + warnings.Add($"配置 {key} 不是有效整数,将回退为 {fallback}。"); + return fallback; + } + + return Math.Max(minValue, value); + } + + private static bool ParseBool(string raw, bool fallback, List warnings, string key) + { + if (TryParseBool(raw, out var parsed)) + return parsed; + + warnings.Add($"配置 {key} 不是有效布尔值,将回退为 {fallback}。"); + return fallback; + } + + private static bool TryParseBool(string raw, out bool value) + { + if (bool.TryParse(raw, out value)) + return true; + + switch (raw.Trim().ToLowerInvariant()) + { + case "1": + case "yes": + case "on": + value = true; + return true; + case "0": + case "no": + case "off": + value = false; + return true; + default: + value = false; + return false; + } + } + + private static string ResolvePath(string baseDirectory, string raw, string? fallback) + { + if (string.IsNullOrWhiteSpace(raw)) + return fallback ?? string.Empty; + + return Path.GetFullPath(Path.IsPathRooted(raw) ? raw : Path.Combine(baseDirectory, raw)); + } + + private static string ToRelativePath(string baseDirectory, string targetPath) + { + var relative = Path.GetRelativePath(baseDirectory, targetPath); + return relative.Replace('\\', '/'); + } + + private static string? NullIfEmpty(string? value) + { + return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + } +} diff --git a/Editor/Scaffolding/ServerProjectTemplate/Framework/ServerHostProperties.cs.txt.meta b/Editor/Scaffolding/ServerProjectTemplate/Framework/ServerHostProperties.cs.txt.meta new file mode 100644 index 0000000..d4f1101 --- /dev/null +++ b/Editor/Scaffolding/ServerProjectTemplate/Framework/ServerHostProperties.cs.txt.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: f4f074bc9686c304d8875fa11b191fdb +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/Scaffolding/ServerProjectTemplate/Framework/ServerModuleContext.cs.txt b/Editor/Scaffolding/ServerProjectTemplate/Framework/ServerModuleContext.cs.txt new file mode 100644 index 0000000..562c238 --- /dev/null +++ b/Editor/Scaffolding/ServerProjectTemplate/Framework/ServerModuleContext.cs.txt @@ -0,0 +1,24 @@ +using ShrinkNetwork; + +namespace ShrinkNetwork.ServerHost.Framework; + +public sealed class ServerModuleContext +{ + private readonly Dictionary _services = new(StringComparer.OrdinalIgnoreCase); + + public ServerModuleContext(ServerHostOptions options, ServerAuthStore authStore) + { + Options = options; + AuthStore = authStore; + } + + public ServerHostOptions Options { get; } + public ServerAuthStore AuthStore { get; } + public UnityNetworkScanManifest? ScanManifest { get; set; } + public IReadOnlyDictionary Services => _services; + + public void RegisterService(string transportName, ShrinkNetworkService service) + { + _services[transportName] = service; + } +} diff --git a/Editor/Scaffolding/ServerProjectTemplate/Framework/ServerModuleContext.cs.txt.meta b/Editor/Scaffolding/ServerProjectTemplate/Framework/ServerModuleContext.cs.txt.meta new file mode 100644 index 0000000..b4d74cd --- /dev/null +++ b/Editor/Scaffolding/ServerProjectTemplate/Framework/ServerModuleContext.cs.txt.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: e12dcb63434cc454bbee36f180eb6534 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/Scaffolding/ServerProjectTemplate/Framework/ShrinkDedicatedServerApp.cs.txt b/Editor/Scaffolding/ServerProjectTemplate/Framework/ShrinkDedicatedServerApp.cs.txt new file mode 100644 index 0000000..233498f --- /dev/null +++ b/Editor/Scaffolding/ServerProjectTemplate/Framework/ShrinkDedicatedServerApp.cs.txt @@ -0,0 +1,123 @@ +using Cysharp.Threading.Tasks; +using ShrinkNetwork; + +namespace ShrinkNetwork.ServerHost.Framework; + +public sealed class ShrinkDedicatedServerApp +{ + private readonly List _modules; + private readonly ServerModuleContext _context; + + public ShrinkDedicatedServerApp(ServerHostOptions options, params IShrinkServerModule[] modules) + { + _modules = modules.ToList(); + _context = new ServerModuleContext(options, new ServerAuthStore()); + } + + public ServerModuleContext Context => _context; + + public async UniTask StartAsync(CancellationToken cancellationToken = default) + { + if (_context.Options.EnableUnityCodeScan) + { + var manifest = UnityNetworkCodeScanner.Scan(_context.Options.UnityAssetsPath); + UnityNetworkCodeScanner.WriteOutputs(manifest, _context.Options.ScanOutputDirectory); + _context.ScanManifest = manifest; + } + + var tcpService = CreateService("TCP"); + var kcpService = CreateService("KCP"); + + _context.RegisterService("TCP", tcpService); + _context.RegisterService("KCP", kcpService); + + foreach (var module in _modules) + { + module.ConfigureService(_context, tcpService, "TCP"); + module.ConfigureService(_context, kcpService, "KCP"); + } + + tcpService.BindTransport(new TcpServerTransport(System.Net.IPAddress.Any, _context.Options.Port, + tlsOptions: BuildTcpTlsOptions())); + kcpService.BindTransport(new KcpServerTransport(System.Net.IPAddress.Any, _context.Options.Port, new ShrinkKcpTransportOptions + { + Interval = 10, + UpdateIntervalMs = 10, + IdleTimeoutMs = 15000, + HandshakeRetryMs = 250, + ConnectTimeoutMs = 4000 + })); + + foreach (var module in _modules) + module.StartAsync(_context, cancellationToken).Forget(); + + await UniTask.CompletedTask; + } + + private ShrinkNetworkService CreateService(string transportName) + { + var service = new ShrinkNetworkService(new ShrinkJsonNetworkSerializer(), + new ShrinkNetworkMessageRegistry(), + new ShrinkNetworkRouter()); + service.MinProtocolVersion = _context.Options.MinProtocolVersion; + service.MaxProtocolVersion = _context.Options.MaxProtocolVersion; + service.MinSchemaVersion = _context.Options.MinSchemaVersion; + service.MaxSchemaVersion = _context.Options.MaxSchemaVersion; + service.DisconnectOnProtocolViolation = _context.Options.DisconnectOnProtocolViolation; + service.IncomingPacketValidator = (session, packet) => ValidateIncomingPacket(service, session, packet); + + service.OnSessionConnected += session => + { + session.SetPeerKind(ShrinkNetworkPeerKind.Client); + ShrinkNetworkLogger.Info($"[{transportName}] Client connected: {session.SessionId} {session.RemoteAddress}"); + }; + service.OnSessionDisconnected += session => + { + _context.AuthStore.Remove(service, session.SessionId); + ShrinkNetworkLogger.Info($"[{transportName}] Client disconnected: {session.SessionId}"); + }; + + return service; + } + + private ShrinkIncomingPacketValidationResult ValidateIncomingPacket( + ShrinkNetworkService service, + ShrinkNetworkSession session, + ShrinkNetworkPacket packet) + { + if (!_context.Options.EnableSessionTokens) + return ShrinkIncomingPacketValidationResult.Allow; + if (packet.Kind == ShrinkNetworkPacketKind.Response) + return ShrinkIncomingPacketValidationResult.Allow; + if (string.Equals(packet.Route, AuthServerModule.LoginRoute, StringComparison.Ordinal) || + string.Equals(packet.Route, AuthServerModule.RefreshRoute, StringComparison.Ordinal)) + return ShrinkIncomingPacketValidationResult.Allow; + if (!_context.AuthStore.TryGetState(service, session.SessionId, out _)) + return ShrinkIncomingPacketValidationResult.Allow; + + if (_context.AuthStore.TryValidateSessionToken(service, session.SessionId, packet.SessionToken, out var authState, + out var failureReason)) + { + session.SetSessionToken(authState.SessionToken, authState.SessionTokenExpiresAtUtc); + return ShrinkIncomingPacketValidationResult.Allow; + } + + return ShrinkIncomingPacketValidationResult.Reject(failureReason, + _context.Options.DisconnectOnInvalidSessionToken); + } + + private ShrinkTcpTlsOptions? BuildTcpTlsOptions() + { + if (!_context.Options.EnableTcpTls) + return null; + + return new ShrinkTcpTlsOptions + { + Enabled = true, + TargetHost = _context.Options.TcpTlsTargetHost, + ServerCertificatePath = _context.Options.TcpTlsCertificatePath, + ServerCertificatePassword = _context.Options.TcpTlsCertificatePassword, + CheckCertificateRevocation = _context.Options.TcpTlsCheckCertificateRevocation + }; + } +} diff --git a/Editor/Scaffolding/ServerProjectTemplate/Framework/ShrinkDedicatedServerApp.cs.txt.meta b/Editor/Scaffolding/ServerProjectTemplate/Framework/ShrinkDedicatedServerApp.cs.txt.meta new file mode 100644 index 0000000..76831ae --- /dev/null +++ b/Editor/Scaffolding/ServerProjectTemplate/Framework/ShrinkDedicatedServerApp.cs.txt.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 6706e29aa428c5e41a63e978feb7d5a0 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/Scaffolding/ServerProjectTemplate/Framework/UnityNetworkCodeScanner.cs.txt b/Editor/Scaffolding/ServerProjectTemplate/Framework/UnityNetworkCodeScanner.cs.txt new file mode 100644 index 0000000..5dad9ab --- /dev/null +++ b/Editor/Scaffolding/ServerProjectTemplate/Framework/UnityNetworkCodeScanner.cs.txt @@ -0,0 +1,145 @@ +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace ShrinkNetwork.ServerHost.Framework; + +public sealed class UnityNetworkScanManifest +{ + public string AssetsPath { get; set; } = string.Empty; + public DateTimeOffset ScannedAtUtc { get; set; } + public List Messages { get; set; } = new(); + public List Subscribers { get; set; } = new(); +} + +public sealed class UnityNetworkMessageEntry +{ + public string FilePath { get; set; } = string.Empty; + public string TypeName { get; set; } = string.Empty; + public int Opcode { get; set; } + public string Route { get; set; } = string.Empty; +} + +public sealed class UnityNetworkSubscriberEntry +{ + public string FilePath { get; set; } = string.Empty; + public string MemberName { get; set; } = string.Empty; + public string Authority { get; set; } = string.Empty; + public string Permission { get; set; } = string.Empty; +} + +public static class UnityNetworkCodeScanner +{ + private static readonly Regex MessageRegex = new( + @"\[ShrinkNetworkMessage\((?-?\d+)\s*,\s*""(?[^""]+)""\)\][\s\S]*?(?:class|struct)\s+(?\w+)", + RegexOptions.Compiled); + + private static readonly Regex SubscriberRegex = new( + @"\[ShrinkNetworkSubscribe\((?.*?)\)\][\s\S]*?(?:UniTask<.*?>|UniTask|void|Task<.*?>|Task)\s+(?\w+)\s*\(", + RegexOptions.Compiled); + + private static readonly Regex AuthorityRegex = new( + @"Authority\s*=\s*ShrinkNetworkAuthority\.(?\w+)", + RegexOptions.Compiled); + + private static readonly Regex PermissionRegex = new( + @"Permission\s*=\s*""(?[^""]+)""", + RegexOptions.Compiled); + + public static UnityNetworkScanManifest Scan(string assetsPath) + { + var manifest = new UnityNetworkScanManifest + { + AssetsPath = assetsPath, + ScannedAtUtc = DateTimeOffset.UtcNow + }; + + if (!Directory.Exists(assetsPath)) + return manifest; + + foreach (var file in Directory.EnumerateFiles(assetsPath, "*.cs", SearchOption.AllDirectories)) + { + if (file.IndexOf($"{Path.DirectorySeparatorChar}Editor{Path.DirectorySeparatorChar}", StringComparison.OrdinalIgnoreCase) >= 0) + continue; + + var content = File.ReadAllText(file); + var relativePath = Path.GetRelativePath(assetsPath, file).Replace('\\', '/'); + + foreach (Match match in MessageRegex.Matches(content)) + { + manifest.Messages.Add(new UnityNetworkMessageEntry + { + FilePath = relativePath, + TypeName = match.Groups["name"].Value, + Opcode = int.Parse(match.Groups["opcode"].Value), + Route = match.Groups["route"].Value + }); + } + + foreach (Match match in SubscriberRegex.Matches(content)) + { + var args = match.Groups["args"].Value; + manifest.Subscribers.Add(new UnityNetworkSubscriberEntry + { + FilePath = relativePath, + MemberName = match.Groups["name"].Value, + Authority = AuthorityRegex.Match(args).Groups["value"].Value, + Permission = PermissionRegex.Match(args).Groups["value"].Value + }); + } + } + + manifest.Messages = manifest.Messages + .OrderBy(item => item.Opcode) + .ThenBy(item => item.TypeName, StringComparer.Ordinal) + .ToList(); + + manifest.Subscribers = manifest.Subscribers + .OrderBy(item => item.Permission, StringComparer.Ordinal) + .ThenBy(item => item.MemberName, StringComparer.Ordinal) + .ToList(); + + return manifest; + } + + public static void WriteOutputs(UnityNetworkScanManifest manifest, string outputDirectory) + { + Directory.CreateDirectory(outputDirectory); + + var jsonPath = Path.Combine(outputDirectory, "unity-network-scan.json"); + var markdownPath = Path.Combine(outputDirectory, "UNITY_NETWORK_SCAN.md"); + + File.WriteAllText(jsonPath, JsonSerializer.Serialize(manifest, new JsonSerializerOptions + { + WriteIndented = true + })); + + var markdown = new StringBuilder(); + markdown.AppendLine("# Unity 网络代码扫描清单"); + markdown.AppendLine(); + markdown.AppendLine($"扫描时间:{manifest.ScannedAtUtc:yyyy-MM-dd HH:mm:ss} UTC"); + markdown.AppendLine($"Assets 路径:`{manifest.AssetsPath}`"); + markdown.AppendLine(); + markdown.AppendLine("## 网络消息"); + markdown.AppendLine(); + + foreach (var message in manifest.Messages) + markdown.AppendLine($"- `{message.Opcode}` `{message.Route}` `{message.TypeName}` [{message.FilePath}]"); + + markdown.AppendLine(); + markdown.AppendLine("## 订阅与权限"); + markdown.AppendLine(); + + if (manifest.Subscribers.Count == 0) + { + markdown.AppendLine("- 本次扫描未发现 `[ShrinkNetworkSubscribe]`。"); + } + else + { + foreach (var subscriber in manifest.Subscribers) + markdown.AppendLine($"- `{subscriber.MemberName}` authority=`{subscriber.Authority}` permission=`{subscriber.Permission}` [{subscriber.FilePath}]"); + } + + File.WriteAllText(markdownPath, markdown.ToString()); + } +} diff --git a/Editor/Scaffolding/ServerProjectTemplate/Framework/UnityNetworkCodeScanner.cs.txt.meta b/Editor/Scaffolding/ServerProjectTemplate/Framework/UnityNetworkCodeScanner.cs.txt.meta new file mode 100644 index 0000000..41afa89 --- /dev/null +++ b/Editor/Scaffolding/ServerProjectTemplate/Framework/UnityNetworkCodeScanner.cs.txt.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: a958cc5087369284e8ec56efb9e33a45 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/Scaffolding/ServerProjectTemplate/KcpServerTransport.cs.txt b/Editor/Scaffolding/ServerProjectTemplate/KcpServerTransport.cs.txt new file mode 100644 index 0000000..e6af5f4 --- /dev/null +++ b/Editor/Scaffolding/ServerProjectTemplate/KcpServerTransport.cs.txt @@ -0,0 +1,340 @@ +using System.Net; +using System.Net.Sockets; +using System.Security.Cryptography; +using Cysharp.Threading.Tasks; + +namespace ShrinkNetwork.ServerHost; + +using ShrinkNetwork; + +public sealed class KcpServerTransport : IShrinkNetworkAsyncTransport, IShrinkNetworkSessionControlTransport +{ + private sealed class KcpSessionState : IDisposable + { + public KcpSessionState(long sessionId, IPEndPoint remoteEndPoint, ShrinkKcpPeer peer) + { + SessionId = sessionId; + RemoteEndPoint = remoteEndPoint; + Peer = peer; + } + + public long SessionId { get; } + public IPEndPoint RemoteEndPoint { get; } + public ShrinkKcpPeer Peer { get; } + public string RemoteAddress => RemoteEndPoint.ToString(); + + public void Dispose() + { + Peer.Dispose(); + } + } + + private readonly object _syncRoot = new(); + private readonly Dictionary _sessionsById = new(); + private readonly Dictionary _sessionsByConversationId = new(); + private readonly UdpClient _udpClient; + private readonly ShrinkKcpTransportOptions _options; + + private CancellationTokenSource? _cts; + private long _sessionIdGenerator; + + public KcpServerTransport(IPAddress ipAddress, int port, ShrinkKcpTransportOptions? options = null) + { + _udpClient = new UdpClient(new IPEndPoint(ipAddress, port)); + _options = (options ?? new ShrinkKcpTransportOptions()).Clone(); + _options.Validate(); + _udpClient.Client.ReceiveBufferSize = _options.ReceiveBufferSize; + } + + public bool IsStarted { get; private set; } + + public event Action? OnEvent; + + public void Start() + { + if (IsStarted) + return; + + IsStarted = true; + _cts = new CancellationTokenSource(); + _ = ReceiveLoopAsync(_cts.Token); + _ = UpdateLoopAsync(_cts.Token); + } + + public void Stop() + { + if (!IsStarted) + return; + + IsStarted = false; + _cts?.Cancel(); + + List sessions; + lock (_syncRoot) + { + sessions = _sessionsById.Values.ToList(); + _sessionsById.Clear(); + _sessionsByConversationId.Clear(); + } + + foreach (var session in sessions) + { + TrySendDisconnect(session); + session.Dispose(); + OnEvent?.Invoke(ShrinkNetworkTransportEvent.Disconnected(session.SessionId, session.RemoteAddress)); + } + + _udpClient.Close(); + } + + public void Send(long sessionId, byte[] packetData) + { + SendAsync(sessionId, packetData).Forget(); + } + + public bool DisconnectSession(long sessionId, string? reason = null) + { + KcpSessionState? session; + lock (_syncRoot) + { + _sessionsById.TryGetValue(sessionId, out session); + } + + if (session == null) + return false; + + DisconnectSession(sessionId, sendRemoteNotice: true, "server-side kick"); + if (!string.IsNullOrWhiteSpace(reason)) + ShrinkNetworkLogger.Warn($"[ShrinkNetwork][KCP-Server] Disconnect session {sessionId}: {reason}"); + return true; + } + + public UniTask SendAsync(long sessionId, byte[] packetData) + { + KcpSessionState session; + lock (_syncRoot) + { + if (!_sessionsById.TryGetValue(sessionId, out session!)) + throw new InvalidOperationException($"Session {sessionId} is not connected."); + } + + session.Peer.Send(packetData ?? Array.Empty()); + return UniTask.CompletedTask; + } + + private async Task ReceiveLoopAsync(CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + UdpReceiveResult result; + try + { + result = await _udpClient.ReceiveAsync(cancellationToken); + } + catch (OperationCanceledException) + { + break; + } + catch (ObjectDisposedException) + { + break; + } + catch + { + if (!IsStarted) + break; + continue; + } + + try + { + HandleDatagram(result.Buffer, result.RemoteEndPoint); + } + catch (Exception ex) + { + ShrinkNetworkLogger.Exception(ex); + ShrinkNetworkLogger.Warn($"[ShrinkNetwork] KCP server datagram handling failed: {ex.Message}"); + } + } + } + + private async Task UpdateLoopAsync(CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + List snapshot; + lock (_syncRoot) + { + snapshot = _sessionsById.Values.ToList(); + } + + foreach (var session in snapshot) + { + try + { + session.Peer.Tick(packet => OnEvent?.Invoke(ShrinkNetworkTransportEvent.Packet(session.SessionId, packet))); + + if (DateTime.UtcNow.Ticks - session.Peer.LastReceiveUtcTicks > + TimeSpan.FromMilliseconds(_options.IdleTimeoutMs).Ticks) + { + DisconnectSession(session.SessionId, sendRemoteNotice: true, "idle timeout"); + } + } + catch (Exception ex) + { + ShrinkNetworkLogger.Exception(ex); + DisconnectSession(session.SessionId, sendRemoteNotice: true, $"peer tick failed: {ex.GetType().Name}: {ex.Message}"); + } + } + + try + { + await Task.Delay(_options.UpdateIntervalMs, cancellationToken); + } + catch (OperationCanceledException) + { + break; + } + } + } + + private void HandleDatagram(byte[] datagram, IPEndPoint remoteEndPoint) + { + if (datagram == null || datagram.Length == 0) + return; + + if (ShrinkKcpTransportProtocol.TryReadConnectRequest(datagram, out var nonce, out var requestedConversationId)) + { + HandleConnectRequest(remoteEndPoint, nonce, requestedConversationId); + return; + } + + if (ShrinkKcpTransportProtocol.TryReadDisconnect(datagram, out var disconnectedConversationId)) + { + HandleDisconnect(remoteEndPoint, disconnectedConversationId); + return; + } + + if (!ShrinkKcpTransportProtocol.TryReadDataPacket(datagram, out var conversationId, out var payloadOffset, + out var payloadLength)) + return; + + KcpSessionState? session; + lock (_syncRoot) + { + _sessionsByConversationId.TryGetValue(conversationId, out session); + } + + if (session == null) + return; + if (!Equals(session.RemoteEndPoint, remoteEndPoint)) + return; + + session.Peer.Input(datagram, payloadOffset, payloadLength, + packet => OnEvent?.Invoke(ShrinkNetworkTransportEvent.Packet(session.SessionId, packet))); + } + + private void HandleConnectRequest(IPEndPoint remoteEndPoint, long nonce, uint requestedConversationId) + { + KcpSessionState? existingSession; + lock (_syncRoot) + { + existingSession = _sessionsById.Values.FirstOrDefault(x => Equals(x.RemoteEndPoint, remoteEndPoint)); + } + + if (existingSession != null) + { + ShrinkNetworkLogger.Info( + $"[ShrinkNetwork][KCP-Server] Reusing session {existingSession.SessionId} for endpoint {existingSession.RemoteAddress}."); + SendEnvelope(existingSession.RemoteEndPoint, + ShrinkKcpTransportProtocol.CreateConnectAccept(nonce, existingSession.Peer.ConversationId)); + return; + } + + var conversationId = AllocateConversationId(requestedConversationId); + var sessionId = Interlocked.Increment(ref _sessionIdGenerator); + var peer = new ShrinkKcpPeer(conversationId, _options, + payload => SendEnvelope(remoteEndPoint, ShrinkKcpTransportProtocol.CreateDataPacket(conversationId, payload))); + var session = new KcpSessionState(sessionId, remoteEndPoint, peer); + + lock (_syncRoot) + { + _sessionsById[sessionId] = session; + _sessionsByConversationId[conversationId] = session; + } + + SendEnvelope(remoteEndPoint, ShrinkKcpTransportProtocol.CreateConnectAccept(nonce, conversationId)); + OnEvent?.Invoke(ShrinkNetworkTransportEvent.Connected(sessionId, remoteEndPoint.ToString())); + } + + private void HandleDisconnect(IPEndPoint remoteEndPoint, uint conversationId) + { + KcpSessionState? session; + lock (_syncRoot) + { + _sessionsByConversationId.TryGetValue(conversationId, out session); + } + + if (session == null) + return; + if (!Equals(session.RemoteEndPoint, remoteEndPoint)) + return; + + DisconnectSession(session.SessionId, sendRemoteNotice: false, "remote disconnect packet"); + } + + private void DisconnectSession(long sessionId, bool sendRemoteNotice, string reason) + { + KcpSessionState? session; + lock (_syncRoot) + { + if (!_sessionsById.Remove(sessionId, out session)) + return; + + _sessionsByConversationId.Remove(session.Peer.ConversationId); + } + + if (sendRemoteNotice) + TrySendDisconnect(session); + + ShrinkNetworkLogger.Warn( + $"[ShrinkNetwork][KCP-Server] Session {sessionId} {session.RemoteAddress} disconnected: {reason}"); + session.Dispose(); + OnEvent?.Invoke(ShrinkNetworkTransportEvent.Disconnected(sessionId, session.RemoteAddress)); + } + + private uint AllocateConversationId(uint requestedConversationId) + { + lock (_syncRoot) + { + if (requestedConversationId != 0 && !_sessionsByConversationId.ContainsKey(requestedConversationId)) + return requestedConversationId; + } + + while (true) + { + var conversationId = unchecked((uint)RandomNumberGenerator.GetInt32(1, int.MaxValue)); + lock (_syncRoot) + { + if (!_sessionsByConversationId.ContainsKey(conversationId)) + return conversationId; + } + } + } + + private void TrySendDisconnect(KcpSessionState session) + { + try + { + SendEnvelope(session.RemoteEndPoint, ShrinkKcpTransportProtocol.CreateDisconnect(session.Peer.ConversationId)); + } + catch + { + } + } + + private void SendEnvelope(IPEndPoint remoteEndPoint, byte[] datagram) + { + _udpClient.Send(datagram, datagram.Length, remoteEndPoint); + } +} diff --git a/Editor/Scaffolding/ServerProjectTemplate/KcpServerTransport.cs.txt.meta b/Editor/Scaffolding/ServerProjectTemplate/KcpServerTransport.cs.txt.meta new file mode 100644 index 0000000..d45c947 --- /dev/null +++ b/Editor/Scaffolding/ServerProjectTemplate/KcpServerTransport.cs.txt.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 22712a66ebe45004494fc762ea5f3de4 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/Scaffolding/ServerProjectTemplate/Program.cs.txt b/Editor/Scaffolding/ServerProjectTemplate/Program.cs.txt new file mode 100644 index 0000000..9eeecf0 --- /dev/null +++ b/Editor/Scaffolding/ServerProjectTemplate/Program.cs.txt @@ -0,0 +1,109 @@ +using System.Reflection; +using ShrinkNetwork; +using ShrinkNetwork.ServerHost; +using ShrinkNetwork.ServerHost.Framework; + +ShrinkNetworkLogger.InfoHandler = msg => Console.WriteLine(msg); +ShrinkNetworkLogger.WarningHandler = msg => Console.WriteLine("[Warn] " + msg); +ShrinkNetworkLogger.ErrorHandler = msg => Console.Error.WriteLine(msg); +ShrinkNetworkLogger.ExceptionHandler = ex => Console.Error.WriteLine(ex); + +var config = ServerHostProperties.LoadOrCreate(); +var options = config.Options; +var modules = DiscoverModules(); +var app = new ShrinkDedicatedServerApp(options, modules); + +await app.StartAsync(); + +Console.WriteLine("ShrinkNetwork 独立服务器模板已启动。"); +Console.WriteLine($"配置文件:{config.FilePath}"); +Console.WriteLine($"首次生成默认配置:{config.CreatedDefaultFile}"); +foreach (var warning in config.Warnings) + Console.WriteLine($"[Warn] 配置文件:{warning}"); +Console.WriteLine($"TCP: 0.0.0.0:{options.Port}"); +Console.WriteLine($"KCP: 0.0.0.0:{options.Port}"); +Console.WriteLine($"Unity 扫描输出目录:{options.ScanOutputDirectory}"); +Console.WriteLine($"Unity 资产扫描目录:{options.UnityAssetsPath}"); +Console.WriteLine($"共享口令鉴权已启用:{!string.IsNullOrWhiteSpace(options.SharedAuthToken)}"); +Console.WriteLine($"缺少口令时允许匿名登录:{options.AllowAnonymousWhenAuthTokenMissing}"); +Console.WriteLine($"会话令牌已启用:{options.EnableSessionTokens}"); +Console.WriteLine($"会话令牌 TTL(秒):{options.SessionTokenTtlSeconds}"); +Console.WriteLine($"会话令牌续期窗口(秒):{options.SessionTokenRefreshWindowSeconds}"); +Console.WriteLine($"会话令牌校验失败时断开会话:{options.DisconnectOnInvalidSessionToken}"); +Console.WriteLine($"协议版本范围:{options.MinProtocolVersion}-{options.MaxProtocolVersion}"); +Console.WriteLine($"Schema 版本范围:{options.MinSchemaVersion}-{options.MaxSchemaVersion}"); +Console.WriteLine($"TCP TLS 已启用:{options.EnableTcpTls}"); +if (options.EnableTcpTls) +{ + Console.WriteLine($"TCP TLS 证书路径:{options.TcpTlsCertificatePath}"); + Console.WriteLine($"TCP TLS SNI/目标主机:{options.TcpTlsTargetHost}"); +} +if (string.IsNullOrWhiteSpace(options.SharedAuthToken) && !options.AllowAnonymousWhenAuthTokenMissing) +{ + Console.WriteLine("[Warn] 当前未配置 SHRINK_SERVER_AUTH_TOKEN,登录请求默认会被拒绝。"); +} +Console.WriteLine("[Info] 配置优先级:代码默认值 < server.properties < 环境变量"); +Console.WriteLine("已加载模块:"); +foreach (var module in modules) + Console.WriteLine($"- {module.Name}"); +Console.WriteLine("按 Ctrl+C 退出。"); + +using var metricsCts = new CancellationTokenSource(); +if (options.DiagnosticsLogIntervalSeconds > 0) + _ = RunDiagnosticsLoopAsync(app, options.DiagnosticsLogIntervalSeconds, metricsCts.Token); + +Console.CancelKeyPress += (_, args) => +{ + args.Cancel = true; + metricsCts.Cancel(); + DumpDiagnostics(app); + Environment.Exit(0); +}; + +try +{ + await Task.Delay(Timeout.Infinite, metricsCts.Token); +} +catch (OperationCanceledException) +{ +} + +static IShrinkServerModule[] DiscoverModules() +{ + return Assembly.GetExecutingAssembly() + .GetTypes() + .Where(type => typeof(IShrinkServerModule).IsAssignableFrom(type)) + .Where(type => !type.IsAbstract && !type.IsInterface) + .Where(type => type.GetConstructor(Type.EmptyTypes) != null) + .Select(type => (IShrinkServerModule)Activator.CreateInstance(type)!) + .OrderBy(module => module.Name, StringComparer.Ordinal) + .ToArray(); +} + +static async Task RunDiagnosticsLoopAsync(ShrinkDedicatedServerApp app, int intervalSeconds, CancellationToken cancellationToken) +{ + while (!cancellationToken.IsCancellationRequested) + { + try + { + await Task.Delay(TimeSpan.FromSeconds(intervalSeconds), cancellationToken); + } + catch (OperationCanceledException) + { + break; + } + + DumpDiagnostics(app); + } +} + +static void DumpDiagnostics(ShrinkDedicatedServerApp app) +{ + Console.WriteLine("[Metrics] ShrinkNetwork service snapshots:"); + foreach (var pair in app.Context.Services.OrderBy(item => item.Key, StringComparer.OrdinalIgnoreCase)) + { + var metrics = pair.Value.GetDiagnosticsSnapshot(); + Console.WriteLine( + $"[Metrics][{pair.Key}] sessions={metrics.CurrentSessions} connected={metrics.SessionsConnected} disconnected={metrics.SessionsDisconnected} sent={metrics.PacketsSent}/{metrics.BytesSent}B recv={metrics.PacketsReceived}/{metrics.BytesReceived}B rpc={metrics.RpcStarted}/{metrics.RpcCompleted} timeout={metrics.RpcTimedOut} canceled={metrics.RpcCanceled} failed={metrics.RpcFailed} proto={metrics.ProtocolViolations} authRejected={metrics.AuthRejectedCount} denied={metrics.PermissionDeniedCount} handlerEx={metrics.HandlerExceptionCount} unknownOpcode={metrics.UnknownOpcodeCount} dispatchMiss={metrics.DispatchMissCount} serialization={metrics.SerializationErrorCount} queueReject={metrics.DispatchQueueRejectedCount}"); + } +} diff --git a/Editor/Scaffolding/ServerProjectTemplate/Program.cs.txt.meta b/Editor/Scaffolding/ServerProjectTemplate/Program.cs.txt.meta new file mode 100644 index 0000000..32c904d --- /dev/null +++ b/Editor/Scaffolding/ServerProjectTemplate/Program.cs.txt.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 088e5ef7fb8b5014d9a5afcbb1441d4b +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/Scaffolding/ServerProjectTemplate/README.md.txt b/Editor/Scaffolding/ServerProjectTemplate/README.md.txt new file mode 100644 index 0000000..5e5179f --- /dev/null +++ b/Editor/Scaffolding/ServerProjectTemplate/README.md.txt @@ -0,0 +1,143 @@ +# ShrinkNetwork.ServerHost + +这是由 `ShrinkNetwork` 插件生成的独立服务器基础模板。 + +默认提供: +- 可编译、可启动的宿主入口 +- TCP / KCP 双传输监听 +- 类似 Minecraft 的 `server.properties` 文本配置 +- Unity 项目扫描后的 `Generated/*` 自动装配 +- 基础鉴权、会话令牌、协议版本闸门与周期性指标摘要 + +## 模板结构 + +- `Program.cs` + 宿主入口,负责加载 `server.properties`、启动模块、打印配置摘要和输出指标。 +- `Framework/` + 宿主框架层,包括配置、鉴权状态存储与应用启动器。 +- `AuthServerModule.cs` + 内置鉴权模块,提供 `server/auth/login` 与 `server/auth/refresh`。 +- `TcpServerTransport.cs` + TCP 传输层,可选 TLS。 +- `KcpServerTransport.cs` + KCP 传输层。 +- `Generated/` + Unity 扫描后产出的项目专属合约、副本、处理器与模块入口。 + +## 生成后你要关注的文件 + +- `Generated/UnityGeneratedNetworkContracts.g.cs` + 当前 Unity 项目扫描得到的消息合约副本。 +- `Generated/UnityGeneratedServerHandlers.g.cs` + 自动生成的处理器骨架。 +- `Generated/UnityGeneratedServerModule.g.cs` + 自动加载的项目专属服务器模块。 + +说明: +- `UnityGeneratedServerModule.g.cs` 是项目级生成物,不是通用插件内置玩法模块。 +- 如果你有正式业务逻辑,建议把自动生成骨架整理成你自己的正式模块文件。 + +## 鉴权 + +## 配置文件 + +- 默认配置文件名:`server.properties` +- 默认位置:`GeneratedServers/ShrinkNetwork.ServerHost/server.properties` +- 首次启动如果文件不存在,宿主会自动生成一份默认配置。 +- 优先级:代码默认值 < `server.properties` < 环境变量 +- 可通过环境变量 `SHRINK_SERVER_CONFIG_PATH` 指向自定义配置文件路径。 + +常用键: +- `server-port` +- `shared-auth-token` +- `enable-session-tokens` +- `session-token-ttl-seconds` +- `session-token-refresh-window-seconds` +- `enable-unity-code-scan` +- `unity-assets-path` +- `scan-output-directory` +- `enable-tcp-tls` +- `tcp-tls-certificate-path` + +环境变量: +- `SHRINK_SERVER_AUTH_TOKEN` + +行为: +- 默认情况下,如果未设置 `SHRINK_SERVER_AUTH_TOKEN`,`server/auth/login` 会直接拒绝,避免把匿名放行作为线上默认值。 +- 如果你明确要跑内网演示,可在 `ServerHostOptions.AllowAnonymousWhenAuthTokenMissing = true` 后再允许匿名登录。 +- 设置口令后,客户端应先完成登录,再进入后续项目逻辑。 + +## 会话令牌 + +默认行为: +- `server/auth/login` 成功后,宿主会签发一个内存态会话令牌,并把它放进响应包头与响应体。 +- 已登录会话的后续消息 / RPC 必须携带当前会话令牌,否则会被宿主拒绝;默认还会直接断开该会话。 +- 客户端可通过 `server/auth/refresh` 在续期窗口内轮换新令牌,避免长连接在固定 TTL 后硬过期。 + +关键配置: +- `ServerHostOptions.EnableSessionTokens` +- `ServerHostOptions.SessionTokenTtlSeconds` +- `ServerHostOptions.SessionTokenRefreshWindowSeconds` +- `ServerHostOptions.DisconnectOnInvalidSessionToken` + +对应环境变量: +- `SHRINK_SERVER_ENABLE_SESSION_TOKENS` +- `SHRINK_SERVER_SESSION_TOKEN_TTL_SECONDS` +- `SHRINK_SERVER_SESSION_TOKEN_REFRESH_WINDOW_SECONDS` +- `SHRINK_SERVER_DISCONNECT_ON_INVALID_SESSION_TOKEN` + +当前范围: +- 已覆盖“登录签发 / 包头自动携带 / 服务端逐包校验 / 显式刷新” +- 还没有接入外部身份源、分布式会话存储、多实例共享撤销表 + +## 协议兼容与指标 + +- 模板宿主默认只接受当前 `ShrinkNetworkProtocol.CurrentProtocolVersion / CurrentSchemaVersion`。 +- 可通过 `ServerHostOptions.MinProtocolVersion / MaxProtocolVersion / MinSchemaVersion / MaxSchemaVersion` 调整兼容窗口。 +- 检测到协议版本越界时,默认直接断开会话。 +- 宿主默认每 `60` 秒输出一次基础指标摘要,也会在 `Ctrl+C` 退出前打印最后一份快照。 +- 指标摘要现已包含 `authRejected`,可直接看到会话令牌校验失败次数。 + +## TCP TLS + +- 如果设置了 `SHRINK_SERVER_TLS_CERT_PATH`,宿主会自动为 TCP 监听启用 TLS。 +- 可选环境变量: + - `SHRINK_SERVER_TLS_CERT_PASSWORD` + - `SHRINK_SERVER_TLS_TARGET_HOST` +- 当前只覆盖“服务端证书 + 客户端校验”主链,还没有扩展到双向证书认证。 + +## 运行方式 + +在仓库根目录执行: + +```powershell +dotnet run --project .\GeneratedServers\ShrinkNetwork.ServerHost\ShrinkNetwork.ServerHost.csproj +``` + +如果只想改文本配置,不想改环境变量,直接编辑: + +```powershell +notepad .\GeneratedServers\ShrinkNetwork.ServerHost\server.properties +``` + +如果要启用共享口令鉴权: + +```powershell +$env:SHRINK_SERVER_AUTH_TOKEN = "your-token" +dotnet run --project .\GeneratedServers\ShrinkNetwork.ServerHost\ShrinkNetwork.ServerHost.csproj +``` + +如果要直接跑运行时烟测: + +```powershell +$env:SHRINK_SERVER_AUTH_TOKEN = "smoke-token" +$env:SHRINK_SERVER_SESSION_TOKEN_TTL_SECONDS = "120" +$env:SHRINK_SERVER_SESSION_TOKEN_REFRESH_WINDOW_SECONDS = "120" +dotnet run --project .\GeneratedServers\ShrinkNetwork.ServerHost\ShrinkNetwork.ServerHost.csproj +``` + +另开一个终端执行: + +```powershell +dotnet run --project .\GeneratedServers\ShrinkNetwork.RuntimeSmoke\ShrinkNetwork.RuntimeSmoke.csproj -- 127.0.0.1 17777 smoke-token runtime-smoke +``` diff --git a/Editor/Scaffolding/ServerProjectTemplate/README.md.txt.meta b/Editor/Scaffolding/ServerProjectTemplate/README.md.txt.meta new file mode 100644 index 0000000..deaee61 --- /dev/null +++ b/Editor/Scaffolding/ServerProjectTemplate/README.md.txt.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 105169ed61f386646a3f1562026c28ec +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/Scaffolding/ServerProjectTemplate/ShrinkNetwork.ServerHost.csproj.txt b/Editor/Scaffolding/ServerProjectTemplate/ShrinkNetwork.ServerHost.csproj.txt new file mode 100644 index 0000000..0272ffc --- /dev/null +++ b/Editor/Scaffolding/ServerProjectTemplate/ShrinkNetwork.ServerHost.csproj.txt @@ -0,0 +1,43 @@ + + + Exe + net8.0 + enable + enable + latest + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Editor/Scaffolding/ServerProjectTemplate/ShrinkNetwork.ServerHost.csproj.txt.meta b/Editor/Scaffolding/ServerProjectTemplate/ShrinkNetwork.ServerHost.csproj.txt.meta new file mode 100644 index 0000000..bbde57b --- /dev/null +++ b/Editor/Scaffolding/ServerProjectTemplate/ShrinkNetwork.ServerHost.csproj.txt.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 5b2ae563558cfd04cb1511ec9a57ea16 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/Scaffolding/ServerProjectTemplate/TcpServerTransport.cs.txt b/Editor/Scaffolding/ServerProjectTemplate/TcpServerTransport.cs.txt new file mode 100644 index 0000000..7ee2edb --- /dev/null +++ b/Editor/Scaffolding/ServerProjectTemplate/TcpServerTransport.cs.txt @@ -0,0 +1,266 @@ +using System.Buffers.Binary; +using System.Collections.Concurrent; +using System.IO; +using System.Net; +using System.Net.Security; +using System.Net.Sockets; +using System.Security.Cryptography.X509Certificates; +using System.Threading; +using Cysharp.Threading.Tasks; + +namespace ShrinkNetwork.ServerHost; + +using ShrinkNetwork; + +public sealed class TcpServerTransport : IShrinkNetworkAsyncTransport, IShrinkNetworkSessionControlTransport +{ + private readonly ConcurrentDictionary _clients = new(); + private readonly ConcurrentDictionary _sendLocks = new(); + private readonly ConcurrentDictionary _streams = new(); + private readonly TcpListener _listener; + private readonly int _maxPacketSize; + private readonly ShrinkTcpTlsOptions? _tlsOptions; + private long _sessionIdGenerator; + private CancellationTokenSource? _cts; + private X509Certificate2? _serverCertificate; + + public TcpServerTransport(IPAddress ipAddress, int port, int maxPacketSize = 64 * 1024, + ShrinkTcpTlsOptions? tlsOptions = null) + { + if (maxPacketSize <= 0) + throw new ArgumentOutOfRangeException(nameof(maxPacketSize)); + + _listener = new TcpListener(ipAddress, port); + _maxPacketSize = maxPacketSize; + _tlsOptions = tlsOptions?.Clone(); + } + + public bool IsStarted { get; private set; } + + public event Action? OnEvent; + + public void Start() + { + if (IsStarted) + return; + + IsStarted = true; + _cts = new CancellationTokenSource(); + EnsureTlsCertificateLoaded(); + _listener.Start(); + _ = AcceptLoopAsync(_cts.Token); + } + + public void Stop() + { + if (!IsStarted) + return; + + IsStarted = false; + _cts?.Cancel(); + _listener.Stop(); + + foreach (var pair in _clients) + { + pair.Value.Close(); + } + + _clients.Clear(); + + foreach (var pair in _streams) + pair.Value.Dispose(); + + _streams.Clear(); + + foreach (var pair in _sendLocks) + pair.Value.Dispose(); + + _sendLocks.Clear(); + } + + public void Send(long sessionId, byte[] packetData) + { + SendAsync(sessionId, packetData).Forget(); + } + + public bool DisconnectSession(long sessionId, string? reason = null) + { + if (!_clients.TryGetValue(sessionId, out var client)) + return false; + + try + { + if (_streams.TryRemove(sessionId, out var stream)) + stream.Dispose(); + client.Close(); + } + catch + { + } + + if (!string.IsNullOrWhiteSpace(reason)) + ShrinkNetworkLogger.Warn($"[ShrinkNetwork][TCP-Server] Disconnect session {sessionId}: {reason}"); + return true; + } + + public async UniTask SendAsync(long sessionId, byte[] packetData) + { + if (!_clients.TryGetValue(sessionId, out var client)) + throw new InvalidOperationException($"Session {sessionId} is not connected."); + if (!_streams.TryGetValue(sessionId, out var stream)) + throw new InvalidOperationException($"Session {sessionId} stream is not initialized."); + + if (!_sendLocks.TryGetValue(sessionId, out var sendLock)) + throw new InvalidOperationException($"Session {sessionId} send lock is not initialized."); + if ((packetData?.Length ?? 0) > _maxPacketSize) + throw new InvalidOperationException($"TCP packet is too large. Size={(packetData?.Length ?? 0)}, Limit={_maxPacketSize}."); + + await SendInternalAsync(stream, sendLock, packetData ?? Array.Empty(), CancellationToken.None); + } + + private async Task AcceptLoopAsync(CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + TcpClient client; + try + { + client = await _listener.AcceptTcpClientAsync(cancellationToken); + } + catch (OperationCanceledException) + { + break; + } + catch + { + if (!IsStarted) + break; + + ShrinkNetworkLogger.Warn("[ShrinkNetwork][TCP-Server] Accept failed, retrying."); + continue; + } + + var sessionId = Interlocked.Increment(ref _sessionIdGenerator); + client.NoDelay = true; + var stream = await CreateServerStreamAsync(client, cancellationToken); + _clients[sessionId] = client; + _streams[sessionId] = stream; + _sendLocks[sessionId] = new SemaphoreSlim(1, 1); + var remoteAddress = client.Client.RemoteEndPoint?.ToString() ?? "unknown"; + OnEvent?.Invoke(ShrinkNetworkTransportEvent.Connected(sessionId, remoteAddress)); + _ = ReceiveLoopAsync(sessionId, client, stream, cancellationToken); + } + } + + private async Task CreateServerStreamAsync(TcpClient client, CancellationToken cancellationToken) + { + Stream stream = client.GetStream(); + var tlsOptions = _tlsOptions; + if (tlsOptions == null || !tlsOptions.Enabled) + return stream; + + if (_serverCertificate == null) + throw new InvalidOperationException("TCP TLS is enabled, but the server certificate is not loaded."); + + var sslStream = new SslStream(stream, false); + await sslStream.AuthenticateAsServerAsync(_serverCertificate, false, tlsOptions.EnabledProtocols, + tlsOptions.CheckCertificateRevocation); + return sslStream; + } + + private void EnsureTlsCertificateLoaded() + { + var tlsOptions = _tlsOptions; + if (tlsOptions == null || !tlsOptions.Enabled) + { + _serverCertificate = null; + return; + } + + if (string.IsNullOrWhiteSpace(tlsOptions.ServerCertificatePath)) + throw new InvalidOperationException("TCP TLS is enabled, but ServerCertificatePath is empty."); + + _serverCertificate = new X509Certificate2(tlsOptions.ServerCertificatePath, tlsOptions.ServerCertificatePassword); + } + + private async Task ReceiveLoopAsync(long sessionId, TcpClient client, Stream stream, CancellationToken cancellationToken) + { + Exception? disconnectException = null; + try + { + var lengthBuffer = new byte[4]; + + while (!cancellationToken.IsCancellationRequested) + { + await ReadExactlyAsync(stream, lengthBuffer, cancellationToken); + var length = BinaryPrimitives.ReadInt32LittleEndian(lengthBuffer); + if (length <= 0) + throw new InvalidDataException($"Invalid packet length: {length}"); + if (length > _maxPacketSize) + throw new InvalidDataException($"TCP packet length exceeded limit. Length={length}, Limit={_maxPacketSize}"); + + var payload = new byte[length]; + await ReadExactlyAsync(stream, payload, cancellationToken); + OnEvent?.Invoke(ShrinkNetworkTransportEvent.Packet(sessionId, payload)); + } + } + catch (OperationCanceledException) + { + } + catch (Exception ex) + { + disconnectException = ex; + } + finally + { + if (_clients.TryRemove(sessionId, out var removed)) + { + if (_streams.TryRemove(sessionId, out var ownedStream)) + ownedStream.Dispose(); + if (_sendLocks.TryRemove(sessionId, out var sendLock)) + sendLock.Dispose(); + + var remoteAddress = removed.Client.RemoteEndPoint?.ToString() ?? "unknown"; + if (disconnectException != null) + { + ShrinkNetworkLogger.Warn( + $"[ShrinkNetwork][TCP-Server] Session {sessionId} {remoteAddress} disconnected: {disconnectException.GetType().Name}: {disconnectException.Message}"); + } + + removed.Close(); + OnEvent?.Invoke(ShrinkNetworkTransportEvent.Disconnected(sessionId, remoteAddress)); + } + } + } + + private static async Task SendInternalAsync(Stream stream, SemaphoreSlim sendLock, byte[] packetData, + CancellationToken cancellationToken) + { + await sendLock.WaitAsync(cancellationToken); + try + { + var header = new byte[4]; + BinaryPrimitives.WriteInt32LittleEndian(header, packetData.Length); + await stream.WriteAsync(header, cancellationToken); + if (packetData.Length > 0) + await stream.WriteAsync(packetData, cancellationToken); + await stream.FlushAsync(cancellationToken); + } + finally + { + sendLock.Release(); + } + } + + private static async Task ReadExactlyAsync(Stream stream, byte[] buffer, CancellationToken cancellationToken) + { + var offset = 0; + while (offset < buffer.Length) + { + var read = await stream.ReadAsync(buffer.AsMemory(offset, buffer.Length - offset), cancellationToken); + if (read <= 0) + throw new IOException("Remote closed."); + offset += read; + } + } +} diff --git a/Editor/Scaffolding/ServerProjectTemplate/TcpServerTransport.cs.txt.meta b/Editor/Scaffolding/ServerProjectTemplate/TcpServerTransport.cs.txt.meta new file mode 100644 index 0000000..a8fb6e7 --- /dev/null +++ b/Editor/Scaffolding/ServerProjectTemplate/TcpServerTransport.cs.txt.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 6083285ae11674f40b2db7dba5818bfa +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/Scaffolding/ServerProjectTemplate/server.properties.txt b/Editor/Scaffolding/ServerProjectTemplate/server.properties.txt new file mode 100644 index 0000000..1333c81 --- /dev/null +++ b/Editor/Scaffolding/ServerProjectTemplate/server.properties.txt @@ -0,0 +1,37 @@ +# ShrinkNetwork server.properties +# 类似 Minecraft 的 key=value 配置文件。 +# 优先级:代码默认值 < 本文件 < 环境变量。 +# 修改后需要重启服务器进程。 + +# 基础网络 +server-port=17777 + +# 代码扫描与生成 +unity-assets-path=../../Assets +scan-output-directory=Generated +enable-unity-code-scan=true + +# 登录与会话 +shared-auth-token= +allow-anonymous-when-auth-token-missing=false +enable-session-tokens=true +session-token-ttl-seconds=1800 +session-token-refresh-window-seconds=300 +disconnect-on-invalid-session-token=true + +# 协议兼容 +min-protocol-version=1 +max-protocol-version=1 +min-schema-version=1 +max-schema-version=1 +disconnect-on-protocol-violation=true + +# 观测 +diagnostics-log-interval-seconds=60 + +# TCP TLS +enable-tcp-tls=false +tcp-tls-target-host= +tcp-tls-certificate-path= +tcp-tls-certificate-password= +tcp-tls-check-certificate-revocation=false diff --git a/Editor/Scaffolding/ServerProjectTemplate/server.properties.txt.meta b/Editor/Scaffolding/ServerProjectTemplate/server.properties.txt.meta new file mode 100644 index 0000000..f7033d1 --- /dev/null +++ b/Editor/Scaffolding/ServerProjectTemplate/server.properties.txt.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: f19321a4ef92c44409dc7f9a14fe3e57 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/Scaffolding/ShrinkDedicatedServerScaffoldGenerator.cs b/Editor/Scaffolding/ShrinkDedicatedServerScaffoldGenerator.cs new file mode 100644 index 0000000..674c2b3 --- /dev/null +++ b/Editor/Scaffolding/ShrinkDedicatedServerScaffoldGenerator.cs @@ -0,0 +1,1524 @@ +#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 LegacyMenuPath = "ShrinkSDK/Network/生成独立服务器脚手架"; + private const string FullProjectMenuPath = "ShrinkSDK/Network/生成完整独立服务器工程"; + private const string RefreshGeneratedMenuPath = "ShrinkSDK/Network/刷新独立服务器 Generated 合同"; + private const string TemplateRelativePath = "Assets/Modules/ShrinkNetwork/Editor/Scaffolding/ServerProjectTemplate"; + private const string DefaultGeneratedServerRelativePath = "GeneratedServers/ShrinkNetwork.ServerHost"; + private const string TemplateStampFileName = ".shrink-server-template.json"; + private const string TemplateManifestFileName = ".shrink-server-template-files.txt"; + + internal sealed class MessageSpec + { + public string TypeName = string.Empty; + public string Kind = "message"; + public int Opcode; + public string Route = string.Empty; + public string SourcePath = string.Empty; + public bool HasResult; + public bool IsNetworkEvent; + public bool IsDeltaEvent; + public string SyncGroup = string.Empty; + public string SyncRole = string.Empty; + public List<(string Type, string Name)> Properties = new(); + } + + private sealed class NetworkEventSpec + { + public MessageSpec Message = null!; + public string Category = string.Empty; + public string SuggestedAction = string.Empty; + } + + internal sealed class SubscriberSpec + { + public string MemberName = string.Empty; + public string SourcePath = string.Empty; + public string Authority = string.Empty; + public string Permission = string.Empty; + } + + internal sealed class EnumSpec + { + public string Name = string.Empty; + public string SourcePath = string.Empty; + public List<(string Name, string? Value)> Members = new(); + } + + internal sealed class DataTypeSpec + { + public string Name = string.Empty; + public string Kind = "class"; + public string SourcePath = string.Empty; + public List<(string Type, string Name)> Properties = new(); + } + + private sealed class RoomSyncPatternSpec + { + public string Prefix = string.Empty; + public string PrefixToken = string.Empty; + public MessageSpec JoinRequest = null!; + public MessageSpec JoinResponse = null!; + public MessageSpec MoveCommand = null!; + public MessageSpec StateDelta = null!; + public MessageSpec LeftNotice = null!; + public MessageSpec Heartbeat = null!; + public string SnapshotCollectionType = string.Empty; + public string SnapshotElementType = string.Empty; + } + + private static readonly HashSet KnownTypeTokens = new(StringComparer.Ordinal) + { + "bool", "byte", "sbyte", "short", "ushort", "int", "uint", "long", "ulong", + "float", "double", "decimal", "char", "string", "object", "Guid", "DateTime", + "DateTimeOffset", "TimeSpan", "Array", "List", "IList", "IReadOnlyList", "IEnumerable", + "ICollection", "Dictionary", "IDictionary", "IReadOnlyDictionary", "HashSet", "Queue", + "Stack", "System", "Collections", "Generic" + }; + + private static readonly HashSet TemplateOwnedRoutes = new(StringComparer.Ordinal) + { + "server/auth/login", + "server/auth/login_response" + }; + + private static readonly HashSet TemplateOwnedOpcodes = new() + { + 1201, + 1202 + }; + + private static readonly HashSet TemplateOwnedTypeNames = new(StringComparer.Ordinal) + { + "ServerAuthLoginRequest", + "ServerAuthLoginResponse" + }; + + private static readonly string[] RoomSyncRequiredRouteSuffixes = + { + "/join_room", + "/join_room_response", + "/move_command", + "/player_state_delta", + "/player_left", + "/heartbeat" + }; + + [MenuItem(LegacyMenuPath)] + [MenuItem(FullProjectMenuPath)] + public static void GenerateFullProject() + { + try + { + var context = BuildProjectContext(); + CopyTemplateProject(context.TemplateRoot, context.ServerProjectRoot); + GenerateScaffoldFiles(context.ProjectRoot, context.AssetsPath, context.GeneratedOutputRoot); + AssetDatabase.Refresh(); + Debug.Log($"[ShrinkNetwork] 已生成完整独立服务器工程:{context.ServerProjectRoot}"); + } + catch (Exception ex) + { + Debug.LogError($"[ShrinkNetwork] 生成独立服务器工程失败:{ex.Message}"); + Debug.LogException(ex); + } + } + + [MenuItem(RefreshGeneratedMenuPath)] + public static void RefreshGeneratedOnly() + { + try + { + var context = BuildProjectContext(); + if (!Directory.Exists(context.ServerProjectRoot)) + throw new DirectoryNotFoundException($"未找到已生成的服务器工程:{context.ServerProjectRoot}"); + + GenerateScaffoldFiles(context.ProjectRoot, context.AssetsPath, context.GeneratedOutputRoot); + AssetDatabase.Refresh(); + Debug.Log($"[ShrinkNetwork] 已刷新服务器 Generated 合同:{context.GeneratedOutputRoot}"); + } + catch (Exception ex) + { + Debug.LogError($"[ShrinkNetwork] 刷新服务器 Generated 合同失败:{ex.Message}"); + Debug.LogException(ex); + } + } + + private static (string ProjectRoot, string AssetsPath, string TemplateRoot, string ServerProjectRoot, string GeneratedOutputRoot) + BuildProjectContext() + { + var projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); + var assetsPath = Path.Combine(projectRoot, "Assets"); + var templateRoot = Path.Combine(projectRoot, TemplateRelativePath.Replace('/', Path.DirectorySeparatorChar)); + var serverProjectRoot = Path.Combine(projectRoot, DefaultGeneratedServerRelativePath.Replace('/', Path.DirectorySeparatorChar)); + var generatedOutputRoot = Path.Combine(serverProjectRoot, "Generated"); + return (projectRoot, assetsPath, templateRoot, serverProjectRoot, generatedOutputRoot); + } + + internal static void CopyTemplateProject(string templateRoot, string outputRoot) + { + if (!Directory.Exists(templateRoot)) + throw new DirectoryNotFoundException($"未找到插件内服务器模板:{templateRoot}"); + + Directory.CreateDirectory(outputRoot); + var stampPath = Path.Combine(outputRoot, TemplateStampFileName); + if (Directory.EnumerateFileSystemEntries(outputRoot).Any() && !File.Exists(stampPath)) + { + throw new InvalidOperationException( + $"目标目录已存在且不是 ShrinkNetwork 自动生成的模板工程,为避免覆盖人工修改,已中止:{outputRoot}"); + } + + var manifestPath = Path.Combine(outputRoot, TemplateManifestFileName); + var previousManagedFiles = ReadTemplateManifest(manifestPath); + var templates = new List<(string RelativePath, string OutputPath, byte[] Content)>(); + foreach (var templateFile in Directory.EnumerateFiles(templateRoot, "*", SearchOption.AllDirectories)) + { + var relativePath = Path.GetRelativePath(templateRoot, templateFile); + var outputRelativePath = relativePath.EndsWith(".txt", StringComparison.Ordinal) + ? relativePath[..^".txt".Length] + : relativePath; + var content = File.ReadAllText(templateFile, Encoding.UTF8); + var normalizedContent = new UTF8Encoding(false).GetBytes(content); + var normalizedRelativePath = outputRelativePath.Replace('\\', '/'); + templates.Add(( + normalizedRelativePath, + ResolveManagedOutputPath(outputRoot, normalizedRelativePath), + normalizedContent)); + } + + var newManagedPaths = new HashSet(templates.Select(item => item.RelativePath), StringComparer.Ordinal); + var conflicts = new List(); + foreach (var template in templates) + { + if (!File.Exists(template.OutputPath)) + continue; + + var currentHash = ComputeSha256(File.ReadAllBytes(template.OutputPath)); + if (previousManagedFiles.TryGetValue(template.RelativePath, out var previousHash)) + { + if (!string.IsNullOrWhiteSpace(previousHash)) + { + if (!string.Equals(currentHash, previousHash, StringComparison.OrdinalIgnoreCase)) + conflicts.Add(template.RelativePath); + } + else if (!string.Equals(currentHash, ComputeSha256(template.Content), StringComparison.OrdinalIgnoreCase)) + { + conflicts.Add(template.RelativePath + "(旧清单无法证明文件未被修改)"); + } + } + else if (!string.Equals(currentHash, ComputeSha256(template.Content), StringComparison.OrdinalIgnoreCase)) + { + conflicts.Add(template.RelativePath + "(不在生成清单中)"); + } + } + + foreach (var previousManagedFile in previousManagedFiles) + { + if (newManagedPaths.Contains(previousManagedFile.Key)) + continue; + + var stalePath = ResolveManagedOutputPath(outputRoot, previousManagedFile.Key); + if (!File.Exists(stalePath)) + continue; + + var currentHash = ComputeSha256(File.ReadAllBytes(stalePath)); + if (string.IsNullOrWhiteSpace(previousManagedFile.Value) || + !string.Equals(currentHash, previousManagedFile.Value, StringComparison.OrdinalIgnoreCase)) + { + conflicts.Add(previousManagedFile.Key + "(待删除的旧模板文件已被修改)"); + } + } + + if (conflicts.Count > 0) + { + throw new InvalidOperationException( + "检测到人工修改的模板托管文件。生成器未写入任何文件,请先保留或迁移这些修改:" + + Environment.NewLine + string.Join(Environment.NewLine, conflicts.OrderBy(item => item, StringComparer.Ordinal))); + } + + foreach (var template in templates) + { + var outputDirectory = Path.GetDirectoryName(template.OutputPath); + if (!string.IsNullOrWhiteSpace(outputDirectory)) + Directory.CreateDirectory(outputDirectory); + File.WriteAllBytes(template.OutputPath, template.Content); + } + + foreach (var previousManagedFile in previousManagedFiles.Keys) + { + if (newManagedPaths.Contains(previousManagedFile)) + continue; + var stalePath = ResolveManagedOutputPath(outputRoot, previousManagedFile); + if (File.Exists(stalePath)) + File.Delete(stalePath); + } + + var manifestLines = templates + .OrderBy(item => item.RelativePath, StringComparer.Ordinal) + .Select(item => item.RelativePath + "\t" + ComputeSha256(item.Content)); + File.WriteAllLines(manifestPath, manifestLines, new UTF8Encoding(false)); + + var stamp = new StringBuilder(); + stamp.AppendLine("{"); + stamp.AppendLine(@" ""generatedBy"": ""ShrinkDedicatedServerScaffoldGenerator"","); + stamp.AppendLine($@" ""generatedAt"": ""{DateTime.UtcNow:O}"","); + stamp.AppendLine($@" ""templateRoot"": ""{templateRoot.Replace("\\", "\\\\")}"""); + stamp.AppendLine("}"); + File.WriteAllText(stampPath, stamp.ToString(), new UTF8Encoding(false)); + } + + private static Dictionary ReadTemplateManifest(string manifestPath) + { + var result = new Dictionary(StringComparer.Ordinal); + if (!File.Exists(manifestPath)) + return result; + + foreach (var rawLine in File.ReadAllLines(manifestPath, Encoding.UTF8)) + { + var line = rawLine.Trim(); + if (line.Length == 0) + continue; + + var separatorIndex = line.IndexOf('\t'); + var relativePath = separatorIndex >= 0 ? line[..separatorIndex] : line; + var hash = separatorIndex >= 0 ? line[(separatorIndex + 1)..].Trim() : string.Empty; + result[relativePath.Replace('\\', '/')] = hash; + } + + return result; + } + + private static string ResolveManagedOutputPath(string outputRoot, string relativePath) + { + var normalizedRoot = Path.GetFullPath(outputRoot) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar; + var fullPath = Path.GetFullPath(Path.Combine(normalizedRoot, relativePath.Replace('/', Path.DirectorySeparatorChar))); + if (!fullPath.StartsWith(normalizedRoot, StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException($"模板清单包含越界路径:{relativePath}"); + return fullPath; + } + + private static string ComputeSha256(byte[] content) + { + using var sha256 = SHA256.Create(); + return BitConverter.ToString(sha256.ComputeHash(content)).Replace("-", string.Empty); + } + + private static void GenerateScaffoldFiles(string projectRoot, string assetsPath, string outputDir) + { + Directory.CreateDirectory(outputDir); + + var scanResult = ShrinkNetworkSemanticScanner.ScanCompiledPlayerAssemblies(); + var messages = scanResult.Messages + .OrderBy(item => item.Opcode).ThenBy(item => item.TypeName, StringComparer.Ordinal).ToList(); + var subscribers = scanResult.Subscribers.OrderBy(item => item.Permission, StringComparer.Ordinal) + .ThenBy(item => item.MemberName, StringComparer.Ordinal).ToList(); + var enums = scanResult.Enums + .OrderBy(item => item.Name, StringComparer.Ordinal).ToList(); + var dataTypes = scanResult.DataTypes + .OrderBy(item => item.Name, StringComparer.Ordinal).ToList(); + + ValidateTemplateConflicts(messages); + ValidateDuplicateRegistrations(messages); + var roomSyncPatterns = FindRoomSyncPatterns(messages); + var networkEvents = BuildNetworkEventSpecs(messages, roomSyncPatterns); + + ResolvePortableDependencies(messages, enums, dataTypes, out var requiredEnums, out var requiredDataTypes, out var unresolvedTypes); + + DeleteObsoleteOutputs(outputDir); + WriteGeneratedContracts(outputDir, messages, requiredEnums, requiredDataTypes, unresolvedTypes); + WriteHandlerTemplate(outputDir, messages, subscribers, roomSyncPatterns, networkEvents); + WriteModuleTemplate(outputDir, messages, subscribers, roomSyncPatterns, networkEvents); + WritePermissionsMarkdown(outputDir, messages, subscribers, requiredEnums, requiredDataTypes, unresolvedTypes, roomSyncPatterns, networkEvents); + } + + private static void DeleteObsoleteOutputs(string outputDir) + { + foreach (var fileName in new[] { "UnityGeneratedServerScaffold.g.cs", "UnityGeneratedServerModuleTemplate.g.cs" }) + { + var filePath = Path.Combine(outputDir, fileName); + if (File.Exists(filePath)) + File.Delete(filePath); + } + } + + private static void ResolvePortableDependencies( + IReadOnlyList messages, + IReadOnlyList enums, + IReadOnlyList dataTypes, + out List requiredEnums, + out List requiredDataTypes, + out SortedSet unresolvedTypes) + { + var enumMap = enums.ToDictionary(item => item.Name, StringComparer.Ordinal); + var dataTypeMap = dataTypes.ToDictionary(item => item.Name, StringComparer.Ordinal); + var enumNames = new HashSet(StringComparer.Ordinal); + var dataTypeNames = new HashSet(StringComparer.Ordinal); + unresolvedTypes = new SortedSet(StringComparer.Ordinal); + var queue = new Queue(); + + foreach (var message in messages) + { + foreach (var property in message.Properties) + { + foreach (var dependency in ExtractTypeDependencies(property.Type)) + queue.Enqueue(dependency); + } + } + + while (queue.Count > 0) + { + var dependency = queue.Dequeue(); + if (string.IsNullOrWhiteSpace(dependency) || KnownTypeTokens.Contains(dependency)) + continue; + + if (enumMap.TryGetValue(dependency, out var enumSpec)) + { + if (enumNames.Add(enumSpec.Name)) + continue; + } + + if (dataTypeMap.TryGetValue(dependency, out var dataTypeSpec)) + { + if (!dataTypeNames.Add(dataTypeSpec.Name)) + continue; + + foreach (var property in dataTypeSpec.Properties) + { + foreach (var nestedDependency in ExtractTypeDependencies(property.Type)) + queue.Enqueue(nestedDependency); + } + + continue; + } + + unresolvedTypes.Add(dependency); + } + + requiredEnums = enums.Where(item => enumNames.Contains(item.Name)).OrderBy(item => item.Name, StringComparer.Ordinal).ToList(); + requiredDataTypes = dataTypes.Where(item => dataTypeNames.Contains(item.Name)).OrderBy(item => item.Name, StringComparer.Ordinal).ToList(); + } + + private static IEnumerable ExtractTypeDependencies(string typeName) + { + var token = new StringBuilder(); + foreach (var character in typeName.Append(' ')) + { + if (char.IsLetterOrDigit(character) || character == '_') + { + token.Append(character); + continue; + } + + if (token.Length == 0) + continue; + + var value = token.ToString(); + token.Clear(); + if (!KnownTypeTokens.Contains(value) && !char.IsDigit(value[0])) + yield return value; + } + } + + private static void WriteGeneratedContracts(string outputDir, IReadOnlyList messages, IReadOnlyList enums, IReadOnlyList dataTypes, ICollection unresolvedTypes) + { + var builder = new StringBuilder(); + builder.AppendLine("// "); + builder.AppendLine("#nullable enable"); + builder.AppendLine("using System;"); + builder.AppendLine("using System.Collections.Generic;"); + builder.AppendLine("using ShrinkNetwork;"); + builder.AppendLine(); + builder.AppendLine("namespace ShrinkNetwork.ServerHost.Generated.Contracts;"); + builder.AppendLine(); + + foreach (var enumSpec in enums) + { + builder.AppendLine($"public enum {enumSpec.Name}"); + builder.AppendLine("{"); + for (var i = 0; i < enumSpec.Members.Count; i++) + { + var member = enumSpec.Members[i]; + var suffix = i == enumSpec.Members.Count - 1 ? string.Empty : ","; + var valuePart = string.IsNullOrWhiteSpace(member.Value) ? string.Empty : $" = {member.Value}"; + builder.AppendLine($" {member.Name}{valuePart}{suffix}"); + } + builder.AppendLine("}"); + builder.AppendLine(); + } + + foreach (var dataType in dataTypes) + { + builder.AppendLine($"public {dataType.Kind} {dataType.Name}"); + builder.AppendLine("{"); + foreach (var property in dataType.Properties) + { + var propertyType = GetPortablePropertyType(property.Type, unresolvedTypes); + var comment = GetPortableTypeComment(property.Type, propertyType); + if (!string.IsNullOrWhiteSpace(comment)) builder.AppendLine($" // {comment}"); + builder.AppendLine($" public {propertyType} {property.Name} {{ get; set; }}{GetDefaultInitializer(propertyType)}"); + } + builder.AppendLine("}"); + builder.AppendLine(); + } + + foreach (var message in messages) + { + builder.AppendLine($"[ShrinkNetworkMessage({message.Opcode}, \"{message.Route}\")]"); + builder.AppendLine($"public sealed class {message.TypeName} : {GetBaseDeclaration(message.Kind)}"); + builder.AppendLine("{"); + foreach (var property in message.Properties) + { + var propertyType = GetPortablePropertyType(property.Type, unresolvedTypes); + var comment = GetPortableTypeComment(property.Type, propertyType); + if (!string.IsNullOrWhiteSpace(comment)) builder.AppendLine($" // {comment}"); + builder.AppendLine($" public {propertyType} {property.Name} {{ get; set; }}{GetDefaultInitializer(propertyType)}"); + } + builder.AppendLine("}"); + builder.AppendLine(); + } + + File.WriteAllText(Path.Combine(outputDir, "UnityGeneratedNetworkContracts.g.cs"), builder.ToString(), new UTF8Encoding(false)); + } + + private static void WriteHandlerTemplate( + string outputDir, + IReadOnlyList messages, + IReadOnlyList subscribers, + IReadOnlyList roomSyncPatterns, + IReadOnlyList networkEvents) + { + var patternHandledTypes = new HashSet( + roomSyncPatterns.SelectMany(GetRoomSyncPatternMessageTypes), + StringComparer.Ordinal); + var requests = messages.Where(item => item.Kind == "request").ToList(); + var genericMessageEvents = networkEvents + .Where(item => string.Equals(item.Message.Kind, "message", StringComparison.Ordinal)) + .Where(item => !patternHandledTypes.Contains(item.Message.TypeName)) + .OrderBy(item => item.Message.Route, StringComparer.Ordinal) + .ToList(); + var builder = new StringBuilder(); + builder.AppendLine("// "); + builder.AppendLine("#nullable enable"); + builder.AppendLine("using Cysharp.Threading.Tasks;"); + builder.AppendLine("using ShrinkNetwork;"); + builder.AppendLine("using ShrinkNetwork.ServerHost.Generated.Contracts;"); + builder.AppendLine(); + builder.AppendLine("namespace ShrinkNetwork.ServerHost.Generated;"); + builder.AppendLine(); + builder.AppendLine("public static partial class UnityGeneratedServerHandlers"); + builder.AppendLine("{"); + + foreach (var request in requests.Where(item => !IsTemplateOwnedMessage(item) && !patternHandledTypes.Contains(item.TypeName))) + { + var responseTypeName = FindResponseType(messages, request); + if (string.IsNullOrWhiteSpace(responseTypeName)) + { + builder.AppendLine($" // TODO: 为 {request.TypeName} 手工补一个响应类型,再实现服务端逻辑。"); + builder.AppendLine(); + continue; + } + + if (request.IsNetworkEvent) + { + var mode = request.HasResult ? "远端裁决事件请求" : "网络事件请求"; + var deltaSuffix = request.IsDeltaEvent ? ",同时声明了增量语义" : string.Empty; + builder.AppendLine($" // {request.TypeName} 是 {mode}{deltaSuffix}。"); + } + + builder.AppendLine($" public static UniTask<{responseTypeName}> Handle{request.TypeName}Async("); + builder.AppendLine(" ShrinkNetworkContext context,"); + builder.AppendLine($" {request.TypeName} request)"); + builder.AppendLine(" {"); + + if (string.Equals(responseTypeName, "ShrinkNetworkEventResultResponse", StringComparison.Ordinal)) + { + builder.AppendLine(" return UniTask.FromResult(new ShrinkNetworkEventResultResponse"); + builder.AppendLine(" {"); + builder.AppendLine(" Result = EventResult.DENY,"); + builder.AppendLine(" IsCanceled = true"); + builder.AppendLine(" });"); + } + else + { + builder.AppendLine($" return UniTask.FromResult(new {responseTypeName}"); + builder.AppendLine(" {"); + builder.AppendLine(" ErrorCode = 501,"); + builder.AppendLine(" ErrorMessage = \"自动生成的服务端处理器尚未补齐业务逻辑。\""); + builder.AppendLine(" });"); + } + + builder.AppendLine(" }"); + builder.AppendLine(); + } + + foreach (var networkEvent in genericMessageEvents) + { + builder.AppendLine($" // {networkEvent.Message.TypeName} 属于{networkEvent.Category}。"); + builder.AppendLine($" // 建议动作:{networkEvent.SuggestedAction}"); + builder.AppendLine($" public static UniTask Handle{networkEvent.Message.TypeName}Async("); + builder.AppendLine(" ShrinkNetworkContext context,"); + builder.AppendLine($" {networkEvent.Message.TypeName} message)"); + builder.AppendLine(" {"); + builder.AppendLine(" return UniTask.CompletedTask;"); + builder.AppendLine(" }"); + builder.AppendLine(); + } + + if (roomSyncPatterns.Count > 0) + { + builder.AppendLine(" /*"); + builder.AppendLine(" 下列房间同步请求已由 UnityGeneratedServerModule 按范式直接接管:"); + foreach (var pattern in roomSyncPatterns) + builder.AppendLine($" - prefix={pattern.Prefix} join={pattern.JoinRequest.TypeName} move={pattern.MoveCommand.TypeName}"); + builder.AppendLine(" */"); + } + + builder.AppendLine("}"); + builder.AppendLine(); + builder.AppendLine("/*"); + builder.AppendLine("Unity 订阅扫描结果,可据此补齐权限与服务器逻辑:"); + if (subscribers.Count == 0) builder.AppendLine("- 本次扫描未发现带 [ShrinkNetworkSubscribe] 的网络订阅。"); + else foreach (var subscriber in subscribers) builder.AppendLine($"- {subscriber.MemberName} authority={subscriber.Authority} permission={subscriber.Permission} source={subscriber.SourcePath}"); + builder.AppendLine("*/"); + + File.WriteAllText(Path.Combine(outputDir, "UnityGeneratedServerHandlers.g.cs"), builder.ToString(), new UTF8Encoding(false)); + } + + private static void WriteModuleTemplate( + string outputDir, + IReadOnlyList messages, + IReadOnlyList subscribers, + IReadOnlyList roomSyncPatterns, + IReadOnlyList networkEvents) + { + if (TryWriteRecognizedRoomSyncModule(outputDir, messages, subscribers, roomSyncPatterns, networkEvents)) + return; + + var requests = messages.Where(item => item.Kind == "request").ToList(); + var genericMessageEvents = networkEvents + .Where(item => string.Equals(item.Message.Kind, "message", StringComparison.Ordinal)) + .OrderBy(item => item.Message.Route, StringComparer.Ordinal) + .ToList(); + var builder = new StringBuilder(); + builder.AppendLine("// "); + builder.AppendLine("#nullable enable"); + builder.AppendLine("using Cysharp.Threading.Tasks;"); + builder.AppendLine("using ShrinkNetwork;"); + builder.AppendLine("using ShrinkNetwork.ServerHost.Framework;"); + builder.AppendLine("using ShrinkNetwork.ServerHost.Generated.Contracts;"); + builder.AppendLine(); + builder.AppendLine("namespace ShrinkNetwork.ServerHost.Generated;"); + builder.AppendLine(); + builder.AppendLine("public sealed class UnityGeneratedServerModule : IShrinkServerModule"); + builder.AppendLine("{"); + builder.AppendLine(" public string Name => \"UnityGenerated\";"); + builder.AppendLine(); + builder.AppendLine(" public void ConfigureService(ServerModuleContext context, ShrinkNetworkService service, string transportName)"); + builder.AppendLine(" {"); + foreach (var message in messages.Where(item => !IsTemplateOwnedMessage(item))) + builder.AppendLine($" service.RegisterMessage<{message.TypeName}>({message.Opcode}, \"{message.Route}\");"); + if (messages.Count > 0) builder.AppendLine(); + foreach (var networkEvent in genericMessageEvents) + { + var permission = GuessPermission(subscribers, networkEvent.Message); + var permissionExpression = string.IsNullOrWhiteSpace(permission) + ? "(string.IsNullOrWhiteSpace(context.Options.SharedAuthToken) ? null : \"auth.ok\")" + : FormatPermission(permission); + builder.AppendLine($" service.RegisterHandler<{networkEvent.Message.TypeName}>(UnityGeneratedServerHandlers.Handle{networkEvent.Message.TypeName}Async,"); + builder.AppendLine($" new ShrinkNetworkPermissionRequirement(ShrinkNetworkAuthority.ClientOnly, {permissionExpression}));"); + } + if (genericMessageEvents.Count > 0) builder.AppendLine(); + foreach (var request in requests.Where(item => !IsTemplateOwnedMessage(item))) + { + var responseTypeName = FindResponseType(messages, request); + if (string.IsNullOrWhiteSpace(responseTypeName)) + { + builder.AppendLine($" // TODO: {request.TypeName} 目前没有可推断的响应类型,请手工补充 RegisterRequestHandler。"); + continue; + } + var permission = GuessPermission(subscribers, request); + var permissionExpression = string.IsNullOrWhiteSpace(permission) + ? "(string.IsNullOrWhiteSpace(context.Options.SharedAuthToken) ? null : \"auth.ok\")" + : FormatPermission(permission); + builder.AppendLine($" service.RegisterRequestHandler<{request.TypeName}, {responseTypeName}>(UnityGeneratedServerHandlers.Handle{request.TypeName}Async,"); + builder.AppendLine($" new ShrinkNetworkPermissionRequirement(ShrinkNetworkAuthority.ClientOnly, {permissionExpression}));"); + } + builder.AppendLine(" }"); + builder.AppendLine(); + builder.AppendLine(" public UniTask StartAsync(ServerModuleContext context, CancellationToken cancellationToken)"); + builder.AppendLine(" {"); + builder.AppendLine(" return UniTask.CompletedTask;"); + builder.AppendLine(" }"); + builder.AppendLine("}"); + + File.WriteAllText(Path.Combine(outputDir, "UnityGeneratedServerModule.g.cs"), builder.ToString(), new UTF8Encoding(false)); + } + + private static bool TryWriteRecognizedRoomSyncModule( + string outputDir, + IReadOnlyList messages, + IReadOnlyList subscribers, + IReadOnlyList roomSyncPatterns, + IReadOnlyList networkEvents) + { + if (roomSyncPatterns.Count == 0) + return false; + + var patternHandledTypes = new HashSet( + roomSyncPatterns.SelectMany(GetRoomSyncPatternMessageTypes), + StringComparer.Ordinal); + var genericRequests = messages + .Where(item => item.Kind == "request") + .Where(item => !IsTemplateOwnedMessage(item)) + .Where(item => !patternHandledTypes.Contains(item.TypeName)) + .ToList(); + var genericMessageEvents = networkEvents + .Where(item => string.Equals(item.Message.Kind, "message", StringComparison.Ordinal)) + .Where(item => !patternHandledTypes.Contains(item.Message.TypeName)) + .OrderBy(item => item.Message.Route, StringComparer.Ordinal) + .ToList(); + + var builder = new StringBuilder(); + builder.AppendLine("// "); + builder.AppendLine("#nullable enable"); + builder.AppendLine("using Cysharp.Threading.Tasks;"); + builder.AppendLine("using ShrinkNetwork;"); + builder.AppendLine("using ShrinkNetwork.ServerHost.Framework;"); + builder.AppendLine("using ShrinkNetwork.ServerHost.Generated.Contracts;"); + builder.AppendLine(); + builder.AppendLine("namespace ShrinkNetwork.ServerHost.Generated;"); + builder.AppendLine(); + builder.AppendLine("public sealed class UnityGeneratedServerModule : IShrinkServerModule"); + builder.AppendLine("{"); + + foreach (var pattern in roomSyncPatterns) + AppendRoomSyncStateClass(builder, pattern); + + builder.AppendLine(" private readonly object _syncRoot = new();"); + foreach (var pattern in roomSyncPatterns) + { + var token = pattern.PrefixToken; + builder.AppendLine($" private readonly Dictionary _{token}Players = new();"); + builder.AppendLine($" private readonly Dictionary<(ShrinkNetworkService service, long sessionId), string> _{token}SessionToPlayerId = new();"); + builder.AppendLine($" private long _{token}PlayerIdGenerator;"); + } + + builder.AppendLine(); + builder.AppendLine(" public string Name => \"UnityGenerated\";"); + builder.AppendLine(); + builder.AppendLine(" public void ConfigureService(ServerModuleContext context, ShrinkNetworkService service, string transportName)"); + builder.AppendLine(" {"); + foreach (var message in messages.Where(item => !IsTemplateOwnedMessage(item))) + builder.AppendLine($" service.RegisterMessage<{message.TypeName}>({message.Opcode}, \"{message.Route}\");"); + builder.AppendLine(); + foreach (var pattern in roomSyncPatterns) + { + var token = pattern.PrefixToken; + builder.AppendLine($" var {token}RoomRequirement = new ShrinkNetworkPermissionRequirement("); + builder.AppendLine(" ShrinkNetworkAuthority.ClientOnly,"); + builder.AppendLine(" string.IsNullOrWhiteSpace(context.Options.SharedAuthToken) ? null : \"auth.ok\");"); + builder.AppendLine(); + builder.AppendLine($" service.RegisterRequestHandler<{pattern.JoinRequest.TypeName}, {pattern.JoinResponse.TypeName}>(Handle{token}JoinRoomAsync, {token}RoomRequirement);"); + builder.AppendLine($" service.RegisterHandler<{pattern.MoveCommand.TypeName}>(Handle{token}MoveAsync, {token}RoomRequirement);"); + } + + if (genericMessageEvents.Count > 0) + builder.AppendLine(); + + foreach (var networkEvent in genericMessageEvents) + { + var permission = GuessPermission(subscribers, networkEvent.Message); + var permissionExpression = string.IsNullOrWhiteSpace(permission) + ? "(string.IsNullOrWhiteSpace(context.Options.SharedAuthToken) ? null : \"auth.ok\")" + : FormatPermission(permission); + builder.AppendLine($" service.RegisterHandler<{networkEvent.Message.TypeName}>(UnityGeneratedServerHandlers.Handle{networkEvent.Message.TypeName}Async,"); + builder.AppendLine($" new ShrinkNetworkPermissionRequirement(ShrinkNetworkAuthority.ClientOnly, {permissionExpression}));"); + } + + foreach (var request in genericRequests) + { + var responseTypeName = FindResponseType(messages, request); + if (string.IsNullOrWhiteSpace(responseTypeName)) + { + builder.AppendLine($" // TODO: {request.TypeName} 目前没有可推断的响应类型,请手工补充 RegisterRequestHandler。"); + continue; + } + + var permission = GuessPermission(subscribers, request); + var permissionExpression = string.IsNullOrWhiteSpace(permission) + ? "(string.IsNullOrWhiteSpace(context.Options.SharedAuthToken) ? null : \"auth.ok\")" + : FormatPermission(permission); + builder.AppendLine($" service.RegisterRequestHandler<{request.TypeName}, {responseTypeName}>(UnityGeneratedServerHandlers.Handle{request.TypeName}Async,"); + builder.AppendLine($" new ShrinkNetworkPermissionRequirement(ShrinkNetworkAuthority.ClientOnly, {permissionExpression}));"); + } + + builder.AppendLine(); + builder.AppendLine(" service.OnSessionDisconnected += session =>"); + builder.AppendLine(" {"); + foreach (var pattern in roomSyncPatterns) + builder.AppendLine($" Remove{pattern.PrefixToken}PlayerAsync(service, session.SessionId).Forget();"); + builder.AppendLine(" };"); + builder.AppendLine(" }"); + builder.AppendLine(); + builder.AppendLine(" public async UniTask StartAsync(ServerModuleContext context, CancellationToken cancellationToken)"); + builder.AppendLine(" {"); + builder.AppendLine(" while (!cancellationToken.IsCancellationRequested)"); + builder.AppendLine(" {"); + builder.AppendLine(" await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken);"); + foreach (var pattern in roomSyncPatterns) + builder.AppendLine($" await Broadcast{pattern.PrefixToken}HeartbeatAsync();"); + builder.AppendLine(" }"); + builder.AppendLine(" }"); + builder.AppendLine(); + + foreach (var pattern in roomSyncPatterns) + AppendRoomSyncPatternMethods(builder, pattern); + + builder.AppendLine(" private static bool TryGetSession(ShrinkNetworkService service, long sessionId, out ShrinkNetworkSession session)"); + builder.AppendLine(" {"); + builder.AppendLine(" return service.Sessions.TryGetValue(sessionId, out session!);"); + builder.AppendLine(" }"); + builder.AppendLine(); + builder.AppendLine(" private static (float x, float y) GetSpawnPosition(int playerIndex)"); + builder.AppendLine(" {"); + builder.AppendLine(" return (-16f + (playerIndex % 4) * 3.2f, -2.4f);"); + builder.AppendLine(" }"); + builder.AppendLine("}"); + + File.WriteAllText(Path.Combine(outputDir, "UnityGeneratedServerModule.g.cs"), builder.ToString(), new UTF8Encoding(false)); + return true; + } + + private static void ValidateTemplateConflicts(IReadOnlyList messages) + { + var conflicts = messages + .Where(message => !IsTemplateOwnedMessage(message)) + .Where(message => TemplateOwnedOpcodes.Contains(message.Opcode)) + .Select(message => $"opcode={message.Opcode} route={message.Route} type={message.TypeName} source={message.SourcePath}") + .ToArray(); + if (conflicts.Length == 0) + return; + + throw new InvalidOperationException( + "扫描到与模板内建鉴权模块冲突的消息编号。请不要占用 1201/1202,或改用插件保留路由。\n" + + string.Join("\n", conflicts)); + } + + private static void ValidateDuplicateRegistrations(IReadOnlyList messages) + { + var filteredMessages = messages.Where(message => !IsTemplateOwnedMessage(message)).ToList(); + var duplicateOpcodes = filteredMessages + .GroupBy(message => message.Opcode) + .Where(group => group.Select(message => $"{message.TypeName}|{message.Route}").Distinct(StringComparer.Ordinal).Count() > 1) + .ToArray(); + if (duplicateOpcodes.Length > 0) + { + var details = duplicateOpcodes.Select(group => + $"opcode={group.Key}: {string.Join(" ; ", group.Select(message => $"{message.TypeName} route={message.Route} source={message.SourcePath}"))}"); + throw new InvalidOperationException( + "扫描到重复的消息 opcode。独立服务器生成要求一条消息只对应一个 opcode。\n" + + string.Join("\n", details)); + } + + var duplicateRoutes = filteredMessages + .GroupBy(message => message.Route, StringComparer.Ordinal) + .Where(group => group.Select(message => $"{message.TypeName}|{message.Opcode}").Distinct(StringComparer.Ordinal).Count() > 1) + .ToArray(); + if (duplicateRoutes.Length == 0) + return; + + var routeDetails = duplicateRoutes.Select(group => + $"route={group.Key}: {string.Join(" ; ", group.Select(message => $"{message.TypeName} opcode={message.Opcode} source={message.SourcePath}"))}"); + throw new InvalidOperationException( + "扫描到重复的消息 route。独立服务器生成要求一条消息只对应一个 route。\n" + + string.Join("\n", routeDetails)); + } + + private static List FindRoomSyncPatterns(IReadOnlyList messages) + { + var patterns = new List(); + var attrGroups = messages + .Where(message => !string.IsNullOrWhiteSpace(message.SyncGroup)) + .GroupBy(message => message.SyncGroup, StringComparer.Ordinal) + .OrderBy(group => group.Key, StringComparer.Ordinal) + .ToArray(); + + foreach (var group in attrGroups) + { + if (TryBuildStateSyncPatternFromAttributes(group.Key, group.ToList(), out var pattern)) + patterns.Add(pattern); + } + + var claimedPrefixes = new HashSet(patterns.Select(pattern => pattern.Prefix), StringComparer.Ordinal); + var joinMessages = messages + .Where(message => string.Equals(message.Kind, "request", StringComparison.Ordinal)) + .Where(message => message.Route.EndsWith("/join_room", StringComparison.Ordinal)) + .Where(message => !IsTemplateOwnedMessage(message)) + .OrderBy(message => message.Route, StringComparer.Ordinal) + .ToList(); + + foreach (var joinMessage in joinMessages) + { + var prefix = joinMessage.Route[..^"/join_room".Length]; + if (string.IsNullOrWhiteSpace(prefix)) + continue; + if (claimedPrefixes.Contains(prefix)) + continue; + + if (TryBuildRoomSyncPattern(messages, prefix, out var pattern)) + patterns.Add(pattern); + } + + return patterns; + } + + private static bool TryBuildStateSyncPatternFromAttributes( + string group, + IReadOnlyList groupMessages, + out RoomSyncPatternSpec pattern) + { + pattern = null!; + if (!TryFindStateSyncRole(groupMessages, "JoinRequest", out var joinRequest) || + !TryFindStateSyncRole(groupMessages, "JoinResponse", out var joinResponse) || + !TryFindStateSyncRole(groupMessages, "Command", out var moveCommand) || + !TryFindStateSyncRole(groupMessages, "StateDelta", out var stateDelta) || + !TryFindStateSyncRole(groupMessages, "LeaveNotice", out var leftNotice) || + !TryFindStateSyncRole(groupMessages, "Heartbeat", out var heartbeat)) + { + return false; + } + + return TryBuildStateSyncPatternCore(group, joinRequest, joinResponse, moveCommand, stateDelta, leftNotice, heartbeat, out pattern); + } + + private static bool TryBuildRoomSyncPattern(IReadOnlyList messages, string prefix, out RoomSyncPatternSpec pattern) + { + if (!TryFindMessageByRoute(messages, prefix + "/join_room", out var joinRequest, "request") || + !TryFindMessageByRoute(messages, prefix + "/join_room_response", out var joinResponse, "response") || + !TryFindMessageByRoute(messages, prefix + "/move_command", out var moveCommand, "message") || + !TryFindMessageByRoute(messages, prefix + "/player_state_delta", out var stateDelta, "message") || + !TryFindMessageByRoute(messages, prefix + "/player_left", out var leftNotice, "message") || + !TryFindMessageByRoute(messages, prefix + "/heartbeat", out var heartbeat, "message")) + { + pattern = null!; + return false; + } + + return TryBuildStateSyncPatternCore(prefix, joinRequest, joinResponse, moveCommand, stateDelta, leftNotice, heartbeat, out pattern); + } + + private static bool TryFindStateSyncRole(IReadOnlyList messages, string role, out MessageSpec message) + { + message = messages.FirstOrDefault(item => string.Equals(item.SyncRole, role, StringComparison.Ordinal))!; + return message != null; + } + + private static bool TryBuildStateSyncPatternCore( + string prefix, + MessageSpec joinRequest, + MessageSpec joinResponse, + MessageSpec moveCommand, + MessageSpec stateDelta, + MessageSpec leftNotice, + MessageSpec heartbeat, + out RoomSyncPatternSpec pattern) + { + pattern = null!; + if (!HasRequiredProperties(joinRequest, "PlayerName") || + !HasRequiredProperties(joinResponse, "PlayerId", "Players") || + !HasRequiredProperties(moveCommand, "PlayerId", "X", "Y", "VX", "VY", "IsGrounded", "Version") || + !HasRequiredProperties(stateDelta, "PlayerId", "DisplayName", "X", "Y", "VX", "VY", "IsGrounded", "Version") || + !HasRequiredProperties(leftNotice, "PlayerId") || + !HasRequiredProperties(heartbeat, "ServerUnixMs")) + { + return false; + } + + if (!TryGetPropertyType(joinResponse, "Players", out var snapshotCollectionType) || + !TryGetCollectionElementType(snapshotCollectionType, out var snapshotElementType)) + { + return false; + } + + pattern = new RoomSyncPatternSpec + { + Prefix = prefix, + PrefixToken = SanitizeIdentifier(prefix), + JoinRequest = joinRequest, + JoinResponse = joinResponse, + MoveCommand = moveCommand, + StateDelta = stateDelta, + LeftNotice = leftNotice, + Heartbeat = heartbeat, + SnapshotCollectionType = snapshotCollectionType, + SnapshotElementType = snapshotElementType + }; + return true; + } + + private static bool TryFindMessageByRoute(IReadOnlyList messages, string route, out MessageSpec message, string kind) + { + message = messages.FirstOrDefault(item => + string.Equals(item.Route, route, StringComparison.Ordinal) && + string.Equals(item.Kind, kind, StringComparison.Ordinal))!; + return message != null; + } + + private static bool HasRequiredProperties(MessageSpec message, params string[] propertyNames) + { + var actualProperties = new HashSet(message.Properties.Select(property => property.Name), StringComparer.Ordinal); + return propertyNames.All(actualProperties.Contains); + } + + private static bool TryGetPropertyType(MessageSpec message, string propertyName, out string propertyType) + { + foreach (var property in message.Properties) + { + if (!string.Equals(property.Name, propertyName, StringComparison.Ordinal)) + continue; + + propertyType = property.Type; + return true; + } + + propertyType = string.Empty; + return false; + } + + private static bool TryGetCollectionElementType(string collectionType, out string elementType) + { + if (collectionType.EndsWith("[]", StringComparison.Ordinal)) + { + elementType = collectionType[..^2]; + return true; + } + + var genericStart = collectionType.IndexOf('<'); + if (genericStart > 0 && collectionType.EndsWith(">", StringComparison.Ordinal)) + { + var candidate = collectionType.Substring(genericStart + 1, collectionType.Length - genericStart - 2).Trim(); + if (candidate.Length > 0) + { + elementType = candidate; + return true; + } + } + + elementType = string.Empty; + return false; + } + + private static IEnumerable GetRoomSyncPatternMessageTypes(RoomSyncPatternSpec pattern) + { + yield return pattern.JoinRequest.TypeName; + yield return pattern.JoinResponse.TypeName; + yield return pattern.MoveCommand.TypeName; + yield return pattern.StateDelta.TypeName; + yield return pattern.LeftNotice.TypeName; + yield return pattern.Heartbeat.TypeName; + } + + private static string SanitizeIdentifier(string value) + { + var token = value.Replace("/", "_").Replace("-", "_"); + token = new string(token.Select(character => + (character >= 'A' && character <= 'Z') || + (character >= 'a' && character <= 'z') || + (character >= '0' && character <= '9') || + character == '_' + ? character + : '_').ToArray()); + if (string.IsNullOrWhiteSpace(token)) + return "Room"; + if (!char.IsLetter(token[0]) && token[0] != '_') + token = "_" + token; + return char.ToUpperInvariant(token[0]) + token[1..]; + } + + private static void AppendRoomSyncStateClass(StringBuilder builder, RoomSyncPatternSpec pattern) + { + var token = pattern.PrefixToken; + builder.AppendLine($" private sealed class {token}ServerPlayerState"); + builder.AppendLine(" {"); + builder.AppendLine(" public string PlayerId = string.Empty;"); + builder.AppendLine(" public string DisplayName = string.Empty;"); + builder.AppendLine(" public float X;"); + builder.AppendLine(" public float Y;"); + builder.AppendLine(" public float VX;"); + builder.AppendLine(" public float VY;"); + builder.AppendLine(" public bool IsGrounded;"); + builder.AppendLine(" public long Version;"); + builder.AppendLine(" public ShrinkNetworkService Service = null!;"); + builder.AppendLine(" public long SessionId;"); + builder.AppendLine(" }"); + builder.AppendLine(); + } + + private static void AppendRoomSyncPatternMethods(StringBuilder builder, RoomSyncPatternSpec pattern) + { + var token = pattern.PrefixToken; + var joinRequestType = pattern.JoinRequest.TypeName; + var joinResponseType = pattern.JoinResponse.TypeName; + var moveCommandType = pattern.MoveCommand.TypeName; + var stateDeltaType = pattern.StateDelta.TypeName; + var leftNoticeType = pattern.LeftNotice.TypeName; + var heartbeatType = pattern.Heartbeat.TypeName; + var snapshotCollectionType = pattern.SnapshotCollectionType; + var snapshotElementType = pattern.SnapshotElementType; + var snapshotMaterializer = GetCollectionMaterializer(snapshotCollectionType); + + builder.AppendLine($" private async UniTask<{joinResponseType}> Handle{token}JoinRoomAsync(ShrinkNetworkContext context, {joinRequestType} request)"); + builder.AppendLine(" {"); + builder.AppendLine(" var key = (context.Service, context.Session.SessionId);"); + builder.AppendLine($" {token}ServerPlayerState player;"); + builder.AppendLine($" {snapshotCollectionType} snapshot;"); + builder.AppendLine(" var shouldBroadcast = false;"); + builder.AppendLine(); + builder.AppendLine(" lock (_syncRoot)"); + builder.AppendLine(" {"); + builder.AppendLine($" if (_{token}SessionToPlayerId.TryGetValue(key, out var existingPlayerId) &&"); + builder.AppendLine($" _{token}Players.TryGetValue(existingPlayerId, out player!))"); + builder.AppendLine(" {"); + builder.AppendLine(" player.DisplayName = string.IsNullOrWhiteSpace(request.PlayerName) ? player.PlayerId : request.PlayerName.Trim();"); + builder.AppendLine(" }"); + builder.AppendLine(" else"); + builder.AppendLine(" {"); + builder.AppendLine($" var playerId = $\"{token}-{{Interlocked.Increment(ref _{token}PlayerIdGenerator)}}\";"); + builder.AppendLine($" var spawn = GetSpawnPosition(_{token}Players.Count);"); + builder.AppendLine($" player = new {token}ServerPlayerState"); + builder.AppendLine(" {"); + builder.AppendLine(" PlayerId = playerId,"); + builder.AppendLine(" DisplayName = string.IsNullOrWhiteSpace(request.PlayerName) ? playerId : request.PlayerName.Trim(),"); + builder.AppendLine(" X = spawn.x,"); + builder.AppendLine(" Y = spawn.y,"); + builder.AppendLine(" VX = 0f,"); + builder.AppendLine(" VY = 0f,"); + builder.AppendLine(" IsGrounded = false,"); + builder.AppendLine(" Version = 1,"); + builder.AppendLine(" Service = context.Service,"); + builder.AppendLine(" SessionId = context.Session.SessionId"); + builder.AppendLine(" };"); + builder.AppendLine($" _{token}Players[playerId] = player;"); + builder.AppendLine($" _{token}SessionToPlayerId[key] = playerId;"); + builder.AppendLine(" shouldBroadcast = true;"); + builder.AppendLine(" }"); + builder.AppendLine($" snapshot = Build{token}SnapshotUnsafe();"); + builder.AppendLine(" }"); + builder.AppendLine(); + builder.AppendLine(" if (shouldBroadcast)"); + builder.AppendLine($" await Broadcast{token}PlayerStateAsync(player, player.PlayerId);"); + builder.AppendLine(); + builder.AppendLine($" return new {joinResponseType}"); + builder.AppendLine(" {"); + builder.AppendLine(" PlayerId = player.PlayerId,"); + builder.AppendLine(" Players = snapshot"); + builder.AppendLine(" };"); + builder.AppendLine(" }"); + builder.AppendLine(); + + builder.AppendLine($" private async UniTask Handle{token}MoveAsync(ShrinkNetworkContext context, {moveCommandType} command)"); + builder.AppendLine(" {"); + builder.AppendLine($" {token}ServerPlayerState? player = null;"); + builder.AppendLine(" lock (_syncRoot)"); + builder.AppendLine(" {"); + builder.AppendLine($" if (!_{token}SessionToPlayerId.TryGetValue((context.Service, context.Session.SessionId), out var playerId))"); + builder.AppendLine(" return;"); + builder.AppendLine(" if (!string.Equals(playerId, command.PlayerId, StringComparison.Ordinal))"); + builder.AppendLine(" return;"); + builder.AppendLine($" if (!_{token}Players.TryGetValue(playerId, out player))"); + builder.AppendLine(" return;"); + builder.AppendLine(" if (command.Version < player.Version)"); + builder.AppendLine(" return;"); + builder.AppendLine(" player.X = command.X;"); + builder.AppendLine(" player.Y = command.Y;"); + builder.AppendLine(" player.VX = command.VX;"); + builder.AppendLine(" player.VY = command.VY;"); + builder.AppendLine(" player.IsGrounded = command.IsGrounded;"); + builder.AppendLine(" player.Version = command.Version;"); + builder.AppendLine(" }"); + builder.AppendLine(); + builder.AppendLine(" if (player != null)"); + builder.AppendLine($" await Broadcast{token}PlayerStateAsync(player, player.PlayerId);"); + builder.AppendLine(" }"); + builder.AppendLine(); + + builder.AppendLine($" private async UniTask Broadcast{token}HeartbeatAsync()"); + builder.AppendLine(" {"); + builder.AppendLine($" var notice = new {heartbeatType}"); + builder.AppendLine(" {"); + builder.AppendLine(" ServerUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()"); + builder.AppendLine(" };"); + builder.AppendLine($" foreach (var player in Snapshot{token}Players())"); + builder.AppendLine(" {"); + builder.AppendLine(" if (!TryGetSession(player.Service, player.SessionId, out var session))"); + builder.AppendLine(" continue;"); + builder.AppendLine(" try"); + builder.AppendLine(" {"); + builder.AppendLine(" await session.SendAsync(notice);"); + builder.AppendLine(" }"); + builder.AppendLine(" catch"); + builder.AppendLine(" {"); + builder.AppendLine(" }"); + builder.AppendLine(" }"); + builder.AppendLine(" }"); + builder.AppendLine(); + + builder.AppendLine($" private async UniTaskVoid Remove{token}PlayerAsync(ShrinkNetworkService service, long sessionId)"); + builder.AppendLine(" {"); + builder.AppendLine(" string? playerId = null;"); + builder.AppendLine(" lock (_syncRoot)"); + builder.AppendLine(" {"); + builder.AppendLine(" var key = (service, sessionId);"); + builder.AppendLine($" if (!_{token}SessionToPlayerId.TryGetValue(key, out playerId))"); + builder.AppendLine(" return;"); + builder.AppendLine($" _{token}SessionToPlayerId.Remove(key);"); + builder.AppendLine($" _{token}Players.Remove(playerId);"); + builder.AppendLine(" }"); + builder.AppendLine($" var notice = new {leftNoticeType} {{ PlayerId = playerId }};"); + builder.AppendLine($" foreach (var player in Snapshot{token}Players())"); + builder.AppendLine(" {"); + builder.AppendLine(" if (!TryGetSession(player.Service, player.SessionId, out var session))"); + builder.AppendLine(" continue;"); + builder.AppendLine(" try"); + builder.AppendLine(" {"); + builder.AppendLine(" await session.SendAsync(notice);"); + builder.AppendLine(" }"); + builder.AppendLine(" catch"); + builder.AppendLine(" {"); + builder.AppendLine(" }"); + builder.AppendLine(" }"); + builder.AppendLine(" }"); + builder.AppendLine(); + + builder.AppendLine($" private async UniTask Broadcast{token}PlayerStateAsync({token}ServerPlayerState state, string? excludedPlayerId = null)"); + builder.AppendLine(" {"); + builder.AppendLine($" var delta = new {stateDeltaType}"); + builder.AppendLine(" {"); + builder.AppendLine(" PlayerId = state.PlayerId,"); + builder.AppendLine(" DisplayName = state.DisplayName,"); + builder.AppendLine(" X = state.X,"); + builder.AppendLine(" Y = state.Y,"); + builder.AppendLine(" VX = state.VX,"); + builder.AppendLine(" VY = state.VY,"); + builder.AppendLine(" IsGrounded = state.IsGrounded,"); + builder.AppendLine(" Version = state.Version"); + builder.AppendLine(" };"); + builder.AppendLine($" foreach (var player in Snapshot{token}Players())"); + builder.AppendLine(" {"); + builder.AppendLine(" if (excludedPlayerId != null && string.Equals(player.PlayerId, excludedPlayerId, StringComparison.Ordinal))"); + builder.AppendLine(" continue;"); + builder.AppendLine(" if (!TryGetSession(player.Service, player.SessionId, out var session))"); + builder.AppendLine(" continue;"); + builder.AppendLine(" try"); + builder.AppendLine(" {"); + builder.AppendLine(" await session.SendAsync(delta);"); + builder.AppendLine(" }"); + builder.AppendLine(" catch"); + builder.AppendLine(" {"); + builder.AppendLine(" }"); + builder.AppendLine(" }"); + builder.AppendLine(" }"); + builder.AppendLine(); + + builder.AppendLine($" private {token}ServerPlayerState[] Snapshot{token}Players()"); + builder.AppendLine(" {"); + builder.AppendLine(" lock (_syncRoot)"); + builder.AppendLine(" {"); + builder.AppendLine($" return _{token}Players.Values.Select(player => new {token}ServerPlayerState"); + builder.AppendLine(" {"); + builder.AppendLine(" PlayerId = player.PlayerId,"); + builder.AppendLine(" DisplayName = player.DisplayName,"); + builder.AppendLine(" X = player.X,"); + builder.AppendLine(" Y = player.Y,"); + builder.AppendLine(" VX = player.VX,"); + builder.AppendLine(" VY = player.VY,"); + builder.AppendLine(" IsGrounded = player.IsGrounded,"); + builder.AppendLine(" Version = player.Version,"); + builder.AppendLine(" Service = player.Service,"); + builder.AppendLine(" SessionId = player.SessionId"); + builder.AppendLine($" }}).ToArray();"); + builder.AppendLine(" }"); + builder.AppendLine(" }"); + builder.AppendLine(); + + builder.AppendLine($" private {snapshotCollectionType} Build{token}SnapshotUnsafe()"); + builder.AppendLine(" {"); + builder.AppendLine($" return _{token}Players.Values.Select(player => new {snapshotElementType}"); + builder.AppendLine(" {"); + builder.AppendLine(" PlayerId = player.PlayerId,"); + builder.AppendLine(" DisplayName = player.DisplayName,"); + builder.AppendLine(" X = player.X,"); + builder.AppendLine(" Y = player.Y,"); + builder.AppendLine(" VX = player.VX,"); + builder.AppendLine(" VY = player.VY,"); + builder.AppendLine(" IsGrounded = player.IsGrounded,"); + builder.AppendLine(" Version = player.Version"); + builder.AppendLine($" }}).{snapshotMaterializer};"); + builder.AppendLine(" }"); + builder.AppendLine(); + } + + private static string GetCollectionMaterializer(string collectionType) + { + if (collectionType.StartsWith("List<", StringComparison.Ordinal) || + collectionType.StartsWith("IList<", StringComparison.Ordinal) || + collectionType.StartsWith("ICollection<", StringComparison.Ordinal)) + { + return "ToList()"; + } + + return "ToArray()"; + } + + private static void WritePermissionsMarkdown( + string outputDir, + IReadOnlyList messages, + IReadOnlyList subscribers, + IReadOnlyList enums, + IReadOnlyList dataTypes, + IReadOnlyCollection unresolvedTypes, + IReadOnlyList roomSyncPatterns, + IReadOnlyList networkEvents) + { + var builder = new StringBuilder(); + builder.AppendLine("# 独立服务器脚手架扫描结果"); + builder.AppendLine(); + builder.AppendLine($"生成时间:{DateTime.Now:yyyy-MM-dd HH:mm:ss}"); + builder.AppendLine(); + builder.AppendLine("## 已识别范式"); + builder.AppendLine(); + if (roomSyncPatterns.Count == 0) + { + builder.AppendLine("- 未识别到可直接生成的状态同步范式。"); + builder.AppendLine("- 推荐在消息类上显式标记 `[ShrinkNetworkStateSync(\"group\", role)]`。"); + builder.AppendLine("- 旧演示仍可回退为 `/join_room` 等固定路由约定。"); + } + else + { + foreach (var pattern in roomSyncPatterns) + { + builder.AppendLine($"- 状态同步 group=`{pattern.Prefix}`"); + builder.AppendLine($" join=`{pattern.JoinRequest.TypeName}` command=`{pattern.MoveCommand.TypeName}` state=`{pattern.StateDelta.TypeName}`"); + } + } + builder.AppendLine(); + builder.AppendLine("## 网络消息"); + builder.AppendLine(); + foreach (var message in messages) + { + var resultSuffix = message.HasResult ? " hasResult=true" : string.Empty; + builder.AppendLine($"- `{message.Opcode}` `{message.Route}` `{message.TypeName}` kind=`{message.Kind}`{resultSuffix} [{message.SourcePath}]"); + } + builder.AppendLine(); + builder.AppendLine("## 网络事件"); + builder.AppendLine(); + if (networkEvents.Count == 0) + { + builder.AppendLine("- 本次扫描未识别到 `[ShrinkNetworkEvent]`。"); + } + else + { + foreach (var networkEvent in networkEvents) + { + var message = networkEvent.Message; + var tags = new List { networkEvent.Category }; + if (message.HasResult) + tags.Add("hasResult"); + if (message.IsDeltaEvent) + tags.Add("delta"); + builder.AppendLine($"- `{message.TypeName}` route=`{message.Route}` kind=`{message.Kind}` tags=`{string.Join(", ", tags)}` [{message.SourcePath}]"); + builder.AppendLine($" 建议动作:{networkEvent.SuggestedAction}"); + } + } + builder.AppendLine(); + builder.AppendLine("### 广播型网络事件"); + builder.AppendLine(); + AppendNetworkEventCategoryList(builder, networkEvents, "广播型网络事件"); + builder.AppendLine(); + builder.AppendLine("### 远端裁决网络事件"); + builder.AppendLine(); + AppendNetworkEventCategoryList(builder, networkEvents, "远端裁决网络事件"); + builder.AppendLine(); + builder.AppendLine("### 增量网络事件"); + builder.AppendLine(); + AppendNetworkEventCategoryList(builder, networkEvents, "增量网络事件"); + builder.AppendLine(); + builder.AppendLine("## 模板保留消息"); + builder.AppendLine(); + builder.AppendLine("- `1201` `server/auth/login` `ServerAuthLoginRequest`"); + builder.AppendLine("- `1202` `server/auth/login_response` `ServerAuthLoginResponse`"); + builder.AppendLine(); + builder.AppendLine("## 权限与订阅"); + builder.AppendLine(); + if (subscribers.Count == 0) builder.AppendLine("- 本次扫描未发现 `[ShrinkNetworkSubscribe]`。"); + else foreach (var subscriber in subscribers) builder.AppendLine($"- `{subscriber.MemberName}` authority=`{subscriber.Authority}` permission=`{subscriber.Permission}` [{subscriber.SourcePath}]"); + builder.AppendLine(); + builder.AppendLine("## 已一起导出的依赖类型"); + builder.AppendLine(); + if (enums.Count == 0 && dataTypes.Count == 0) builder.AppendLine("- 当前消息合同只依赖基础类型。"); + else + { + foreach (var enumSpec in enums) builder.AppendLine($"- enum `{enumSpec.Name}` [{enumSpec.SourcePath}]"); + foreach (var dataType in dataTypes) builder.AppendLine($"- {dataType.Kind} `{dataType.Name}` [{dataType.SourcePath}]"); + } + builder.AppendLine(); + builder.AppendLine("## 未解析类型"); + builder.AppendLine(); + if (unresolvedTypes.Count == 0) builder.AppendLine("- 无。"); + else + { + builder.AppendLine("- 下列类型在生成脚手架时未找到定义,相关属性已回退为 `string` 或 `string[]`,需要手工修正:"); + foreach (var unresolvedType in unresolvedTypes) builder.AppendLine($"- `{unresolvedType}`"); + } + builder.AppendLine(); + builder.AppendLine("## 生成产物"); + builder.AppendLine(); + builder.AppendLine("- `UnityGeneratedNetworkContracts.g.cs`:Unity 网络消息合同副本。"); + builder.AppendLine("- `UnityGeneratedServerHandlers.g.cs`:请求处理器与网络事件处理器模板。"); + builder.AppendLine("- `UnityGeneratedServerModule.g.cs`:自动加载的项目专属服务器模块,包含网络事件自动注册。"); + File.WriteAllText(Path.Combine(outputDir, "UNITY_GENERATED_SERVER_SCAFFOLD.md"), builder.ToString(), new UTF8Encoding(false)); + } + + private static string? FindResponseType(IReadOnlyList messages, MessageSpec request) + { + var byName = request.TypeName.EndsWith("Request", StringComparison.Ordinal) ? request.TypeName[..^"Request".Length] + "Response" : request.TypeName + "Response"; + var matched = messages.FirstOrDefault(item => item.Kind == "response" && string.Equals(item.TypeName, byName, StringComparison.Ordinal)); + if (matched != null) return matched.TypeName; + + var responseRoute = request.Route.EndsWith("_response", StringComparison.Ordinal) ? request.Route : request.Route + "_response"; + matched = messages.FirstOrDefault(item => item.Kind == "response" && string.Equals(item.Route, responseRoute, StringComparison.Ordinal)); + if (matched != null) return matched.TypeName; + + return request.HasResult ? "ShrinkNetworkEventResultResponse" : null; + } + + private static List BuildNetworkEventSpecs( + IReadOnlyList messages, + IReadOnlyList roomSyncPatterns) + { + var roomSyncMessageTypes = new HashSet( + roomSyncPatterns.SelectMany(GetRoomSyncPatternMessageTypes), + StringComparer.Ordinal); + return messages + .Where(message => message.IsNetworkEvent) + .OrderBy(message => message.Route, StringComparer.Ordinal) + .Select(message => new NetworkEventSpec + { + Message = message, + Category = GetNetworkEventCategory(message), + SuggestedAction = GetNetworkEventSuggestedAction(message, roomSyncMessageTypes.Contains(message.TypeName)) + }) + .ToList(); + } + + private static string GetNetworkEventCategory(MessageSpec message) + { + if (message.IsDeltaEvent) + return "增量网络事件"; + if (message.HasResult) + return "远端裁决网络事件"; + return "广播型网络事件"; + } + + private static string GetNetworkEventSuggestedAction(MessageSpec message, bool isRoomSyncMessage) + { + if (isRoomSyncMessage) + return "该事件已被状态同步范式接管,通常不需要再手工注册额外广播逻辑。"; + if (message.IsDeltaEvent) + return "校验来源,按业务主键与版本去重,更新服务器状态后再决定是否向其他会话转发。"; + if (message.HasResult) + return "在服务端完成裁决,返回 EventResult / IsCanceled,并只在必要时附带错误码。"; + return "在服务端决定是否落库、审计或转发给其他会话,避免消息进入后没有处理器。"; + } + + private static void AppendNetworkEventCategoryList(StringBuilder builder, IReadOnlyList networkEvents, string category) + { + var matchedEvents = networkEvents + .Where(item => string.Equals(item.Category, category, StringComparison.Ordinal)) + .ToList(); + if (matchedEvents.Count == 0) + { + builder.AppendLine("- 无。"); + return; + } + + foreach (var networkEvent in matchedEvents) + builder.AppendLine($"- `{networkEvent.Message.TypeName}` `{networkEvent.Message.Route}`"); + } + + private static string? GuessPermission(IReadOnlyList subscribers, MessageSpec request) + { + var routeTail = request.Route.Split('/').LastOrDefault() ?? string.Empty; + return subscribers.FirstOrDefault(item => + item.MemberName.Contains(routeTail, StringComparison.OrdinalIgnoreCase) || + item.SourcePath.Contains(routeTail, StringComparison.OrdinalIgnoreCase))?.Permission; + } + + private static bool HasMessage(IReadOnlyList messages, string typeName, string route) + { + return messages.Any(item => + string.Equals(item.TypeName, typeName, StringComparison.Ordinal) && + string.Equals(item.Route, route, StringComparison.Ordinal)); + } + + private static bool IsTemplateOwnedMessage(MessageSpec message) + { + return TemplateOwnedTypeNames.Contains(message.TypeName) || TemplateOwnedRoutes.Contains(message.Route); + } + + private static string GetPortablePropertyType(string typeName, ICollection unresolvedTypes) + { + var containsUnresolvedDependency = ExtractTypeDependencies(typeName).Any(unresolvedTypes.Contains); + if (!containsUnresolvedDependency) return typeName; + return typeName.EndsWith("[]", StringComparison.Ordinal) ? "string[]" : "string"; + } + + private static string? GetPortableTypeComment(string originalType, string emittedType) + { + if (string.Equals(originalType, emittedType, StringComparison.Ordinal)) return null; + return $"原类型 {originalType},当前脚手架暂时回退为 {emittedType},请按实际协议修正。"; + } + + private static string GetBaseDeclaration(string kind) + { + return kind switch + { + "request" => "IShrinkNetworkRequest", + "response" => "ShrinkRpcResponseBase", + _ => "IShrinkNetworkMessage" + }; + } + + private static string GetDefaultInitializer(string typeName) + { + if (typeName.EndsWith("[]", StringComparison.Ordinal)) + { + var elementType = typeName.Substring(0, typeName.Length - 2).Trim(); + return $" = Array.Empty<{elementType}>();"; + } + + return typeName switch + { + "string" => " = string.Empty;", + "string?" => " = string.Empty;", + _ => string.Empty + }; + } + + private static string FormatPermission(string? permission) + { + return string.IsNullOrWhiteSpace(permission) ? "null" : $"\"{permission}\""; + } +} +#endif diff --git a/Editor/Scaffolding/ShrinkDedicatedServerScaffoldGenerator.cs.meta b/Editor/Scaffolding/ShrinkDedicatedServerScaffoldGenerator.cs.meta new file mode 100644 index 0000000..fec18ba --- /dev/null +++ b/Editor/Scaffolding/ShrinkDedicatedServerScaffoldGenerator.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 2f19393d389423f41b6e04815cedb1fc +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/Scaffolding/ShrinkNetworkSemanticScanner.cs b/Editor/Scaffolding/ShrinkNetworkSemanticScanner.cs new file mode 100644 index 0000000..bbe3f6f --- /dev/null +++ b/Editor/Scaffolding/ShrinkNetworkSemanticScanner.cs @@ -0,0 +1,417 @@ +#if UNITY_EDITOR +#nullable enable +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Reflection; +using UnityEditor.Compilation; + +internal sealed class ShrinkNetworkSemanticScanResult +{ + public List Messages { get; } = new(); + public List Subscribers { get; } = new(); + public List Enums { get; } = new(); + public List DataTypes { get; } = new(); +} + +internal static class ShrinkNetworkSemanticScanner +{ + private const string MessageAttributeName = "ShrinkNetwork.ShrinkNetworkMessageAttribute"; + private const string StateSyncAttributeName = "ShrinkNetwork.ShrinkNetworkStateSyncAttribute"; + private const string SubscribeAttributeName = "ShrinkNetwork.ShrinkNetworkSubscribeAttribute"; + private const string MessageInterfaceName = "ShrinkNetwork.IShrinkNetworkMessage"; + private const string RequestInterfaceName = "ShrinkNetwork.IShrinkNetworkRequest"; + private const string ResponseBaseName = "ShrinkNetwork.ShrinkRpcResponseBase"; + private const string ResultEventInterfaceName = "ShrinkEventBus.IShrinkResultEvent`1"; + private const string NetworkEventAttributeName = "ShrinkNetwork.Integration.EventBus.ShrinkNetworkEventAttribute"; + private const string DeltaEventInterfaceName = "ShrinkNetwork.Integration.EventBus.IShrinkNetworkDeltaEvent"; + + private static readonly Dictionary TypeAliases = new() + { + [typeof(void)] = "void", + [typeof(bool)] = "bool", + [typeof(byte)] = "byte", + [typeof(sbyte)] = "sbyte", + [typeof(short)] = "short", + [typeof(ushort)] = "ushort", + [typeof(int)] = "int", + [typeof(uint)] = "uint", + [typeof(long)] = "long", + [typeof(ulong)] = "ulong", + [typeof(float)] = "float", + [typeof(double)] = "double", + [typeof(decimal)] = "decimal", + [typeof(char)] = "char", + [typeof(string)] = "string", + [typeof(object)] = "object" + }; + + internal static ShrinkNetworkSemanticScanResult ScanCompiledPlayerAssemblies() + { + var loadedAssemblies = AppDomain.CurrentDomain.GetAssemblies() + .Where(assembly => !assembly.IsDynamic) + .GroupBy(assembly => assembly.GetName().Name ?? string.Empty, StringComparer.Ordinal) + .ToDictionary(group => group.Key, group => group.First(), StringComparer.Ordinal); + var playerAssemblyNames = new HashSet( + CompilationPipeline.GetAssemblies(AssembliesType.Player).Select(assembly => assembly.name), + StringComparer.Ordinal); + var types = playerAssemblyNames + .Where(loadedAssemblies.ContainsKey) + .SelectMany(name => GetLoadableTypes(loadedAssemblies[name])) + .Where(type => type != null) + .Distinct() + .ToArray(); + + return ScanTypes(types!); + } + + internal static ShrinkNetworkSemanticScanResult ScanTypesForTests(params Type[] types) + { + return ScanTypes(types); + } + + internal static string FormatTypeForTests(Type type) + { + return FormatType(type); + } + + private static ShrinkNetworkSemanticScanResult ScanTypes(IEnumerable inputTypes) + { + var types = inputTypes + .Where(type => type != null && !type.ContainsGenericParameters) + .Distinct() + .OrderBy(type => type.FullName, StringComparer.Ordinal) + .ToArray(); + var availableTypes = new HashSet(types); + var result = new ShrinkNetworkSemanticScanResult(); + var messageTypes = new HashSet(); + + foreach (var type in types) + { + var messageAttribute = FindAttribute(type.CustomAttributes, MessageAttributeName); + if (messageAttribute == null || !IsNetworkContract(type)) + continue; + + result.Messages.Add(BuildMessageSpec(type, messageAttribute)); + messageTypes.Add(type); + } + + ThrowOnPortableNameCollision( + result.Messages.Select(message => (message.TypeName, message.SourcePath)), + "网络消息"); + + foreach (var type in types) + AddSubscriberSpecs(type, result.Subscribers); + + AddPortableDependencySpecs(result, messageTypes, availableTypes); + return result; + } + + private static ShrinkDedicatedServerScaffoldGenerator.MessageSpec BuildMessageSpec( + Type type, + CustomAttributeData messageAttribute) + { + if (messageAttribute.ConstructorArguments.Count == 0) + throw new InvalidOperationException($"{type.FullName} 的 ShrinkNetworkMessage 缺少 opcode。"); + + var opcode = Convert.ToInt32(messageAttribute.ConstructorArguments[0].Value, CultureInfo.InvariantCulture); + var route = messageAttribute.ConstructorArguments.Count > 1 + ? messageAttribute.ConstructorArguments[1].Value as string ?? string.Empty + : string.Empty; + if (string.IsNullOrWhiteSpace(route)) + throw new InvalidOperationException($"{type.FullName} 的 ShrinkNetworkMessage 必须声明非空 route,服务器合同无法依赖运行时回退值。"); + + var stateSyncAttributes = type.CustomAttributes + .Where(attribute => IsAttribute(attribute, StateSyncAttributeName)) + .ToArray(); + if (stateSyncAttributes.Length > 1) + throw new InvalidOperationException($"{type.FullName} 声明了多个 ShrinkNetworkStateSync,服务器脚手架要求每个消息只有一个同步角色。"); + + var stateSync = stateSyncAttributes.FirstOrDefault(); + var spec = new ShrinkDedicatedServerScaffoldGenerator.MessageSpec + { + TypeName = type.Name, + Kind = GetMessageKind(type), + Opcode = opcode, + Route = route.Trim(), + SourcePath = GetSourceName(type), + HasResult = ImplementsOpenGeneric(type, ResultEventInterfaceName), + IsNetworkEvent = HasAttribute(type, NetworkEventAttributeName), + IsDeltaEvent = Implements(type, DeltaEventInterfaceName), + SyncGroup = GetConstructorString(stateSync, 0), + SyncRole = GetConstructorEnumName(stateSync, 1) + }; + spec.Properties.AddRange(GetSerializableProperties(type) + .Select(property => (FormatType(property.PropertyType), property.Name))); + return spec; + } + + private static void AddSubscriberSpecs( + Type type, + ICollection target) + { + const BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | + BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly; + foreach (var method in type.GetMethods(flags).OrderBy(method => method.MetadataToken)) + { + foreach (var attribute in method.CustomAttributes.Where(item => IsAttribute(item, SubscribeAttributeName))) + { + target.Add(new ShrinkDedicatedServerScaffoldGenerator.SubscriberSpec + { + MemberName = type.Name + "." + method.Name, + SourcePath = GetSourceName(type), + Authority = GetNamedEnumName(attribute, "Authority"), + Permission = GetNamedString(attribute, "Permission") + }); + } + } + } + + private static void AddPortableDependencySpecs( + ShrinkNetworkSemanticScanResult result, + ISet messageTypes, + ISet availableTypes) + { + var queue = new Queue(messageTypes + .SelectMany(GetSerializableProperties) + .Select(property => property.PropertyType)); + var visited = new HashSet(); + var dependencyTypes = new HashSet(); + + while (queue.Count > 0) + { + var dependency = UnwrapType(queue.Dequeue()); + if (dependency == null || !visited.Add(dependency)) + continue; + + if (dependency.IsGenericType) + { + foreach (var argument in dependency.GetGenericArguments()) + queue.Enqueue(argument); + } + + if (!availableTypes.Contains(dependency) || messageTypes.Contains(dependency) || IsFrameworkType(dependency)) + continue; + + dependencyTypes.Add(dependency); + if (!dependency.IsEnum) + { + foreach (var property in GetSerializableProperties(dependency)) + queue.Enqueue(property.PropertyType); + } + } + + ThrowOnPortableNameCollision( + dependencyTypes.Select(type => (type.Name, GetSourceName(type))), + "消息依赖类型"); + + foreach (var type in dependencyTypes.OrderBy(type => type.Name, StringComparer.Ordinal)) + { + if (type.IsEnum) + result.Enums.Add(BuildEnumSpec(type)); + else + result.DataTypes.Add(BuildDataTypeSpec(type)); + } + } + + private static ShrinkDedicatedServerScaffoldGenerator.EnumSpec BuildEnumSpec(Type type) + { + var spec = new ShrinkDedicatedServerScaffoldGenerator.EnumSpec + { + Name = type.Name, + SourcePath = GetSourceName(type) + }; + var underlyingType = Enum.GetUnderlyingType(type); + foreach (var name in Enum.GetNames(type)) + { + var rawValue = Enum.Parse(type, name); + var value = underlyingType == typeof(ulong) || underlyingType == typeof(uint) || underlyingType == typeof(ushort) || underlyingType == typeof(byte) + ? Convert.ToUInt64(rawValue, CultureInfo.InvariantCulture).ToString(CultureInfo.InvariantCulture) + : Convert.ToInt64(rawValue, CultureInfo.InvariantCulture).ToString(CultureInfo.InvariantCulture); + spec.Members.Add((name, value)); + } + return spec; + } + + private static ShrinkDedicatedServerScaffoldGenerator.DataTypeSpec BuildDataTypeSpec(Type type) + { + var spec = new ShrinkDedicatedServerScaffoldGenerator.DataTypeSpec + { + Name = type.Name, + Kind = type.IsValueType ? "struct" : "class", + SourcePath = GetSourceName(type) + }; + spec.Properties.AddRange(GetSerializableProperties(type) + .Select(property => (FormatType(property.PropertyType), property.Name))); + return spec; + } + + private static IEnumerable GetSerializableProperties(Type type) + { + const BindingFlags flags = BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly; + return type.GetProperties(flags) + .Where(property => property.GetIndexParameters().Length == 0 && + property.GetMethod?.IsPublic == true && + property.SetMethod?.IsPublic == true) + .OrderBy(property => property.MetadataToken); + } + + private static string FormatType(Type type) + { + if (TypeAliases.TryGetValue(type, out var alias)) + return alias; + if (type.IsArray) + return FormatType(type.GetElementType()!) + "[" + new string(',', type.GetArrayRank() - 1) + "]"; + if (type.IsGenericParameter) + return type.Name; + if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Nullable<>)) + return FormatType(type.GetGenericArguments()[0]) + "?"; + if (type.IsGenericType) + { + var name = type.Name; + var backtickIndex = name.IndexOf('`'); + if (backtickIndex >= 0) + name = name[..backtickIndex]; + return name + "<" + string.Join(", ", type.GetGenericArguments().Select(FormatType)) + ">"; + } + return type.Name; + } + + private static Type? UnwrapType(Type type) + { + while (type.IsArray || type.IsByRef || type.IsPointer) + type = type.GetElementType()!; + if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Nullable<>)) + return type.GetGenericArguments()[0]; + return type; + } + + private static bool IsFrameworkType(Type type) + { + var namespaceName = type.Namespace ?? string.Empty; + return type.Assembly == typeof(string).Assembly || + namespaceName.StartsWith("System", StringComparison.Ordinal) || + namespaceName.StartsWith("Unity", StringComparison.Ordinal); + } + + private static bool IsNetworkContract(Type type) + { + return Implements(type, MessageInterfaceName) || Implements(type, RequestInterfaceName) || Inherits(type, ResponseBaseName); + } + + private static string GetMessageKind(Type type) + { + if (Implements(type, RequestInterfaceName)) + return "request"; + if (Inherits(type, ResponseBaseName)) + return "response"; + return "message"; + } + + private static bool Implements(Type type, string interfaceFullName) + { + return type.GetInterfaces().Any(item => string.Equals(item.FullName, interfaceFullName, StringComparison.Ordinal)); + } + + private static bool ImplementsOpenGeneric(Type type, string interfaceFullName) + { + return type.GetInterfaces().Any(item => + item.IsGenericType && + string.Equals(item.GetGenericTypeDefinition().FullName, interfaceFullName, + StringComparison.Ordinal)); + } + + private static bool Inherits(Type type, string baseTypeFullName) + { + for (var current = type.BaseType; current != null; current = current.BaseType) + { + if (string.Equals(current.FullName, baseTypeFullName, StringComparison.Ordinal)) + return true; + } + return false; + } + + private static bool HasAttribute(MemberInfo member, string attributeFullName) + { + return FindAttribute(member.CustomAttributes, attributeFullName) != null; + } + + private static CustomAttributeData? FindAttribute(IEnumerable attributes, string attributeFullName) + { + return attributes.FirstOrDefault(attribute => IsAttribute(attribute, attributeFullName)); + } + + private static bool IsAttribute(CustomAttributeData attribute, string attributeFullName) + { + return string.Equals(attribute.AttributeType.FullName, attributeFullName, StringComparison.Ordinal); + } + + private static string GetConstructorString(CustomAttributeData? attribute, int index) + { + return attribute != null && attribute.ConstructorArguments.Count > index + ? attribute.ConstructorArguments[index].Value as string ?? string.Empty + : string.Empty; + } + + private static string GetConstructorEnumName(CustomAttributeData? attribute, int index) + { + if (attribute == null || attribute.ConstructorArguments.Count <= index) + return string.Empty; + return GetEnumName(attribute.ConstructorArguments[index]); + } + + private static string GetNamedEnumName(CustomAttributeData attribute, string memberName) + { + var argument = attribute.NamedArguments.FirstOrDefault(item => string.Equals(item.MemberName, memberName, StringComparison.Ordinal)); + return argument.MemberName == null ? string.Empty : GetEnumName(argument.TypedValue); + } + + private static string GetNamedString(CustomAttributeData attribute, string memberName) + { + var argument = attribute.NamedArguments.FirstOrDefault(item => string.Equals(item.MemberName, memberName, StringComparison.Ordinal)); + return argument.MemberName == null ? string.Empty : argument.TypedValue.Value as string ?? string.Empty; + } + + private static string GetEnumName(CustomAttributeTypedArgument argument) + { + if (!argument.ArgumentType.IsEnum || argument.Value == null) + return argument.Value?.ToString() ?? string.Empty; + return Enum.GetName(argument.ArgumentType, argument.Value) ?? argument.Value.ToString() ?? string.Empty; + } + + private static void ThrowOnPortableNameCollision(IEnumerable<(string Name, string Source)> items, string category) + { + var collisions = items + .GroupBy(item => item.Name, StringComparer.Ordinal) + .Where(group => group.Select(item => item.Source).Distinct(StringComparer.Ordinal).Skip(1).Any()) + .OrderBy(group => group.Key, StringComparer.Ordinal) + .ToArray(); + if (collisions.Length == 0) + return; + + var details = collisions.Select(group => + group.Key + ": " + string.Join(", ", group.Select(item => item.Source).Distinct(StringComparer.Ordinal).OrderBy(value => value, StringComparer.Ordinal))); + throw new InvalidOperationException( + $"{category}存在命名空间不同但简单类型名相同的类型。独立服务器合同会去掉命名空间,无法安全生成:" + + Environment.NewLine + string.Join(Environment.NewLine, details)); + } + + private static string GetSourceName(Type type) + { + return (type.Assembly.GetName().Name ?? "unknown") + "::" + (type.FullName ?? type.Name); + } + + private static IEnumerable GetLoadableTypes(System.Reflection.Assembly assembly) + { + try + { + return assembly.GetTypes(); + } + catch (ReflectionTypeLoadException exception) + { + return exception.Types.OfType(); + } + } +} +#endif diff --git a/Editor/Scaffolding/ShrinkNetworkSemanticScanner.cs.meta b/Editor/Scaffolding/ShrinkNetworkSemanticScanner.cs.meta new file mode 100644 index 0000000..e3ba193 --- /dev/null +++ b/Editor/Scaffolding/ShrinkNetworkSemanticScanner.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 81f2d65d24dd8184ea8b396ba83ba159 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/ShrinkNetwork.Editor.asmdef b/Editor/ShrinkNetwork.Editor.asmdef new file mode 100644 index 0000000..09f56ae --- /dev/null +++ b/Editor/ShrinkNetwork.Editor.asmdef @@ -0,0 +1,17 @@ +{ + "name": "ShrinkNetwork.Editor", + "rootNamespace": "ShrinkNetwork.Editor", + "references": [ + "ShrinkNetwork.Runtime" + ], + "includePlatforms": [ + "Editor" + ], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/Editor/ShrinkNetwork.Editor.asmdef.meta b/Editor/ShrinkNetwork.Editor.asmdef.meta new file mode 100644 index 0000000..0cbce8f --- /dev/null +++ b/Editor/ShrinkNetwork.Editor.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 12dae8366427fee4b805ff52065bd1c0 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Plugins.meta b/Plugins.meta new file mode 100644 index 0000000..846a47c --- /dev/null +++ b/Plugins.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 9462a0dab15fd2d41b197b999c9d7355 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Plugins/Kcp-CSharp.dll b/Plugins/Kcp-CSharp.dll new file mode 100644 index 0000000..8ef6c71 Binary files /dev/null and b/Plugins/Kcp-CSharp.dll differ diff --git a/Plugins/Kcp-CSharp.dll.meta b/Plugins/Kcp-CSharp.dll.meta new file mode 100644 index 0000000..371e035 --- /dev/null +++ b/Plugins/Kcp-CSharp.dll.meta @@ -0,0 +1,33 @@ +fileFormatVersion: 2 +guid: e2750876f7909154ea645e32a5e8e4f6 +PluginImporter: + externalObjects: {} + serializedVersion: 2 + iconMap: {} + executionOrder: {} + defineConstraints: [] + isPreloaded: 0 + isOverridable: 0 + isExplicitlyReferenced: 0 + validateReferences: 1 + platformData: + - first: + Any: + second: + enabled: 1 + settings: {} + - first: + Editor: Editor + second: + enabled: 0 + settings: + DefaultValueInitialized: true + - first: + Windows Store Apps: WindowsStoreApps + second: + enabled: 0 + settings: + CPU: AnyCPU + userData: + assetBundleName: + assetBundleVariant: diff --git a/Plugins/System.Runtime.CompilerServices.Unsafe.dll b/Plugins/System.Runtime.CompilerServices.Unsafe.dll new file mode 100644 index 0000000..491a80a Binary files /dev/null and b/Plugins/System.Runtime.CompilerServices.Unsafe.dll differ diff --git a/Plugins/System.Runtime.CompilerServices.Unsafe.dll.meta b/Plugins/System.Runtime.CompilerServices.Unsafe.dll.meta new file mode 100644 index 0000000..4081cbc --- /dev/null +++ b/Plugins/System.Runtime.CompilerServices.Unsafe.dll.meta @@ -0,0 +1,33 @@ +fileFormatVersion: 2 +guid: 6d7ddf89a31144f6a0b498e1a7ff2be3 +PluginImporter: + externalObjects: {} + serializedVersion: 2 + iconMap: {} + executionOrder: {} + defineConstraints: [] + isPreloaded: 0 + isOverridable: 0 + isExplicitlyReferenced: 0 + validateReferences: 1 + platformData: + - first: + Any: + second: + enabled: 1 + settings: {} + - first: + Editor: Editor + second: + enabled: 0 + settings: + DefaultValueInitialized: true + - first: + Windows Store Apps: WindowsStoreApps + second: + enabled: 0 + settings: + CPU: AnyCPU + userData: + assetBundleName: + assetBundleVariant: diff --git a/README.md b/README.md new file mode 100644 index 0000000..ed4bf3f --- /dev/null +++ b/README.md @@ -0,0 +1,367 @@ +# ShrinkNetwork + +一个为 Unity C# 项目设计的轻量网络框架。重点不是“再包一层 Socket”,而是把消息声明、权限校验、RPC、序列化、传输层,以及独立服务器生成流程收拢到一套统一范式里。 + +## ✨ 特性概览 + +| 特性 | 说明 | +|------|------| +| 🔒 **强类型消息** | 基于 `[ShrinkNetworkMessage]` 声明消息、请求、响应,客户端与服务端共享同一套合同 | +| ⚡ **RPC 内建** | `RpcAsync` / `CallAsync` 请求响应模型,超时、错误码、路由重载统一处理 | +| 🧭 **权限与来源校验** | `ShrinkNetworkAuthority` + `Permission` 双重约束,处理器注册时直接绑定 | +| 🔁 **多传输切换** | 内置 `Loopback / TCP / KCP`,同一套业务层代码可复用 | +| 📦 **双序列化实现** | `JSON / MessagePack` 可切换,适配调试与正式环境 | +| 🧩 **特性自动注册** | 编译期注册表 + 运行时实例注册并存,静态消息与静态处理器默认不再做全域反射扫描 | +| 🏗️ **独立服务器生成** | Unity 菜单扫描项目代码,生成完整 .NET 独立服务器工程 | +| 🌉 **桥接扩展友好** | 可选接入 `ShrinkNetwork.Integration.EventBus` 等桥接层,运行时自动发现并接入 | + +## 📦 依赖 + +- Unity 2022.3+ +- [UniTask](https://github.com/Cysharp/UniTask) `2.x` +- [Newtonsoft.Json](https://docs.unity3d.com/Packages/com.unity.nuget.newtonsoft-json@3.2/manual/index.html) +- [MessagePack for C#](https://github.com/MessagePack-CSharp/MessagePack-CSharp) `3.1.4`(模板工程已同步) + +## ⚙️ 目录结构 + +`Assets/Modules/ShrinkNetwork/` 当前按职责拆分为: + +| 目录 | 说明 | +|------|------| +| `Runtime/Core/` | `ShrinkNetworkService`、`ShrinkNetworkSession`、上下文与日志 | +| `Runtime/Metadata/` | 消息合同、属性、权限、RPC、传输事件等基础类型 | +| `Runtime/Routing/` | 注册表、反射辅助、路由派发 | +| `Runtime/Serialization/` | 序列化接口与 `JSON / MessagePack` 实现 | +| `Runtime/Transport/` | `Loopback / TCP / KCP` 传输层 | +| `Editor/Scaffolding/` | 独立服务器模板与生成器 | +| `Samples/PingClient/` | 最小客户端示例 | +| `Plugins/` | 运行时第三方 DLL | + +说明: + +- `Assets/Scenes/` 下的演示控制器仍放在场景目录,不并入插件本体。 +- 项目专属独立服务器逻辑不常驻仓库,而是通过扫描结果生成到 `GeneratedServers/`。 + +## 🚀 快速上手 + +### 第一步:声明消息 + +```csharp +using ShrinkNetwork; + +[ShrinkNetworkMessage(1001, "auth/ping")] +public sealed class PingRequest : IShrinkNetworkRequest +{ + public string Text { get; set; } = string.Empty; +} + +[ShrinkNetworkMessage(1002, "auth/ping_response")] +public sealed class PingResponse : ShrinkRpcResponseBase +{ + public string Reply { get; set; } = string.Empty; +} +``` + +### 第二步:声明处理器 + +```csharp +using Cysharp.Threading.Tasks; +using ShrinkNetwork; + +[ShrinkNetworkSubscriber] +public static class PingHandlers +{ + [ShrinkNetworkSubscribe( + Authority = ShrinkNetworkAuthority.ClientOnly, + Permission = "rpc.ping")] + private static UniTask HandlePing( + ShrinkNetworkContext context, + PingRequest request) + { + return UniTask.FromResult(new PingResponse + { + Reply = "pong:" + request.Text + }); + } +} +``` + +### 第三步:启动服务并自动注册 + +```csharp +var service = new ShrinkNetworkService( + new ShrinkMessagePackNetworkSerializer(), + new ShrinkNetworkMessageRegistry(), + new ShrinkNetworkRouter()); + +service.AutoRegisterAll(); +service.BindTransport(new ShrinkTcpClientTransport("127.0.0.1", 17777)); +``` + +### 跨线程派发与背压 + +`ShrinkNetworkService` 默认使用 inline 调度,适合独立服务器。传输层可能在后台线程触发事件;Unity 侧不要让网络线程直接进入会访问 Unity API 的业务 handler,应显式注入调度器: + +```csharp +var dispatchQueue = new ShrinkNetworkDispatchQueue(2048); +service.DispatchScheduler = dispatchQueue; + +// 在 Unity 主线程的 Update 中按预算泵出任务 +dispatchQueue.PumpAsync(128).Forget(); +``` + +队列满时默认拒绝新任务,并通过 `GetDiagnosticsSnapshot().DispatchQueueRejectedCount` 计数。实时状态可以选择丢弃策略;登录、控制命令和 RPC 不应与可丢弃状态共用同一个队列。 + +### 第四步:发送消息或发起 RPC + +```csharp +await session.SendAsync(new PingRequest +{ + Text = "hello" +}); + +var response = await session.RpcAsync( + new PingRequest { Text = "hello" }); +``` + +## 📖 核心概念 + +### 消息模型 + +ShrinkNetwork 把网络载荷分成三类: + +| 类型 | 基接口 / 基类 | 用途 | +|------|---------------|------| +| 普通消息 | `IShrinkNetworkMessage` | 单向通知、状态广播 | +| 请求 | `IShrinkNetworkRequest` | 发起 RPC | +| 响应 | `ShrinkRpcResponseBase` | 返回错误码、错误消息与业务载荷 | + +推荐规则: + +- 所有网络消息都显式声明 `[ShrinkNetworkMessage(opcode, route)]` +- `opcode` 与 `route` 一一对应,不要复用 +- 客户端与服务端共用同一消息类型定义 + +### 自动注册 + +运行时支持两类自动化: + +```csharp +service.AutoRegisterAttributedMessages(); // 消费编译期消息清单 +service.AutoRegisterStaticHandlers(); // 消费编译期静态处理器清单 +service.AutoRegisterAll(); // 两者都做 +``` + +这套机制适合插件化模块与独立服务器共用同一批消息合同。 + +当前版本中: + +- 静态 `[ShrinkNetworkMessage]` / `[ShrinkNetworkSubscriber]` 由编译期注册表 `ShrinkNetworkGeneratedRegistry` 收口 +- `RegisterHandlers(object target)` 仍保留实例注册路径,继续支持模组实例和运行时对象 + +### 权限模型 + +处理器可通过 `[ShrinkNetworkSubscribe]` 直接声明来源和权限: + +```csharp +[ShrinkNetworkSubscribe( + Authority = ShrinkNetworkAuthority.ClientOnly, + Permission = "room.join")] +``` + +常见用途: + +- 限制某消息只能由客户端发起 +- 登录后再开放某些协议 +- 独立服务器按 `Permission` 做集中裁决 + +### 状态同步范式 + +如果你希望独立服务器生成器直接产出“可运行”的同步模块,推荐额外声明: + +```csharp +[ShrinkNetworkStateSync("lan", ShrinkNetworkStateSyncRole.JoinRequest)] +``` + +当前内置识别的角色有: + +- `JoinRequest` +- `JoinResponse` +- `Command` +- `StateDelta` +- `LeaveNotice` +- `Heartbeat` + +示例: + +```csharp +[ShrinkNetworkMessage(4111, "lan/player_state_delta")] +[ShrinkNetworkStateSync("lan", ShrinkNetworkStateSyncRole.StateDelta)] +public sealed class LanPlayerStateDelta : IShrinkNetworkMessage +{ + public string PlayerId { get; set; } = string.Empty; + public string DisplayName { get; set; } = string.Empty; + public float X { get; set; } + public float Y { get; set; } + public float VX { get; set; } + public float VY { get; set; } + public bool IsGrounded { get; set; } + public long Version { get; set; } +} +``` + +说明: + +- 优先推荐显式特性声明 +- 旧演示仍保留基于 route 的回退识别 +- `opcode` / `route` 冲突不会被框架吞掉,生成阶段会直接报错并指出来源 + +## 🏗️ 独立服务器生成 + +Unity 菜单: + +- `ShrinkSDK/Network/生成完整独立服务器工程` +- `ShrinkSDK/Network/刷新独立服务器 Generated 合同` + +生成结果默认输出到: + +`GeneratedServers/ShrinkNetwork.ServerHost/` + +生成内容分两部分: + +- 模板提供:宿主、TCP / KCP 服务端传输、基础鉴权与扩展点 +- 语义扫描生成:从 Unity 已编译的 Player 程序集读取类型、特性、继承与属性元数据,产出 `Generated/*.g.cs` 项目专属消息合同、处理器模板和自动模块 + +完整工程生成会用 SHA-256 清单保护模板托管文件。再次生成前先校验全部文件仍等于上次生成版本;检测到人工修改时不会写入或删除任何模板文件。`Generated/*.g.cs` 仍属于可重建区域。 + +当前生成器会优先识别三类高价值范式: + +- `[ShrinkNetworkStateSync]` 标记的状态同步消息组 +- `[ShrinkNetworkEvent]` 标记的广播型 / 裁决型 / 增量型网络事件 +- `[ShrinkNetworkSubscribe]` 标记的权限声明 + +扫描以编译后类型为准,因此支持 `partial` 和复杂泛型。导出合同会去掉命名空间;不同命名空间存在同名消息或同名依赖类型时,生成器会列出冲突来源并中止,而不是静默保留第一项。仅手写 `RegisterMessage()` 不属于服务器生成输入。 + +这意味着即便不手写额外适配层,只要项目遵守同一套特性范式,独立服务器也能先生成出一套可编译、可注册、可继续补业务的骨架。 + +## 🔧 API 参考 + +### ShrinkNetworkService + +```csharp +service.BindTransport(IShrinkNetworkTransport transport) +service.RegisterMessage(int opcode, string route = null) +service.RegisterHandler(Func handler, requirement) +service.RegisterRequestHandler(Func> handler, requirement) +service.AutoRegisterAttributedMessages() +service.AutoRegisterStaticHandlers() +service.AutoRegisterAll() +``` + +### ShrinkNetworkSession + +```csharp +session.SendAsync(message, route = null) +session.NotifyAsync(message, route = null) +session.RpcAsync(request, route = null) +session.RpcAsync(request, ShrinkRpcCallOptions options) +``` + +### ShrinkRpcCallOptions + +```csharp +new ShrinkRpcCallOptions +{ + TimeoutMs = 10000, + RouteOverride = "custom/route", + RequestTokenOverride = (ShrinkRequestToken)123, + DebugLabel = "LoginRpc" +} +``` + +### 序列化 + +```csharp +new ShrinkJsonNetworkSerializer() +new ShrinkMessagePackNetworkSerializer() +``` + +## ✅ 最佳实践 + +- 所有消息统一用 `[ShrinkNetworkMessage]` 声明,不要只靠手写 `RegisterMessage()` +- 客户端与服务端共用合同类型,不要维护两套“看起来一样”的 DTO +- 对高频实时协议优先考虑 `MessagePack + KCP` +- 对生成器可识别的同步模块,优先补齐 `[ShrinkNetworkStateSync]` +- 如果项目接入了桥接包,优先让 `ShrinkNetworkService` 走自动接入,不要到处手工注册扩展 + +## ⚠️ 注意事项 + +- `BindTransport(...)` 只负责绑定传输与启动监听,不会替你注册业务 handler +- EventBus 桥接统一使用 `Post/PostAsync` 和编译期生成注册;未安装 `ShrinkNetwork.Integration.EventBus` 时不会建立桥接,也不会做运行时程序集扫描 +- `GeneratedServers/*/Generated/` 中的产物允许重建,不要把人工业务逻辑直接写进自动生成文件;模板托管区虽有哈希保护,也应通过独立业务文件扩展 +- 运行依赖中的 `Kcp-CSharp.dll` 与 `System.Runtime.CompilerServices.Unsafe.dll` 需要与 Unity 平台兼容 + +## ✅ 已验证 + +当前命令行验证已通过: + +- `dotnet build .\Assembly-CSharp.csproj` +- `dotnet build .\Assembly-CSharp-Editor.csproj` +- `dotnet build .\GeneratedServers\ShrinkNetwork.ServerHost\ShrinkNetwork.ServerHost.csproj /nr:false` + +当前未覆盖: + +- 在 Unity 编辑器中实际点击菜单,重新生成完整独立服务器工程后的运行时回归 + +## 📄 License + +## 会话令牌 + +- `ShrinkNetworkPacket` 现已携带 `SessionToken` 与 `SessionTokenExpiresAtUnixTimeSeconds`。 +- `ShrinkNetworkSession` 会在收到服务端响应包时自动学习并更新会话令牌,后续 `SendAsync / NotifyAsync / RpcAsync` 会自动把令牌带回去。 +- 宿主侧如启用了会话令牌校验,推荐流程是: + 1. 先调用 `server/auth/login` + 2. 拿到登录响应后继续正常发包 + 3. 在到达续期窗口后调用 `server/auth/refresh` +- 当前实现是“宿主内存态会话令牌”: + - 适合单进程独立服 + - 不包含跨进程共享、外部身份提供商或持久化会话存储 + +## TCP TLS + +- `ShrinkTcpClientTransport` 与 `ShrinkTcpServerTransport` 现已支持可选 TLS。 +- 客户端通过 `ShrinkTcpTlsOptions` 传入: + - `Enabled` + - `TargetHost` + - `AllowInvalidServerCertificate` + - `CheckCertificateRevocation` + - `EnabledProtocols` +- 服务端额外需要: + - `ServerCertificatePath` + - `ServerCertificatePassword` +- 当前范围仅覆盖“服务端证书 + 客户端校验”主链,不包含双向证书认证。 + +```csharp +var clientTransport = new ShrinkTcpClientTransport( + "127.0.0.1", + 17777, + tlsOptions: new ShrinkTcpTlsOptions + { + Enabled = true, + TargetHost = "game.example.com" + }); + +var serverTransport = new ShrinkTcpServerTransport( + System.Net.IPAddress.Any, + 17777, + tlsOptions: new ShrinkTcpTlsOptions + { + Enabled = true, + ServerCertificatePath = "certs/server.pfx", + ServerCertificatePassword = "change-me" + }); +``` + +## 📄 License + +[MIT](LICENSE) diff --git a/README.md.meta b/README.md.meta new file mode 100644 index 0000000..b6c0cad --- /dev/null +++ b/README.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 42cb0c91805d8764eaeb4946b931cb79 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime.meta b/Runtime.meta new file mode 100644 index 0000000..5f27fed --- /dev/null +++ b/Runtime.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: fa9ffde1ea564904089266d4cf32db3a +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Core.meta b/Runtime/Core.meta new file mode 100644 index 0000000..46866e1 --- /dev/null +++ b/Runtime/Core.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: afa48bd281d22914ea8fe82af25901b1 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Core/ShrinkNetworkContext.cs b/Runtime/Core/ShrinkNetworkContext.cs new file mode 100644 index 0000000..6a55d30 --- /dev/null +++ b/Runtime/Core/ShrinkNetworkContext.cs @@ -0,0 +1,31 @@ +#nullable enable + +using Cysharp.Threading.Tasks; + +namespace ShrinkNetwork +{ + public sealed class ShrinkNetworkContext + { + public ShrinkNetworkService Service { get; } + public ShrinkNetworkSession Session { get; } + public ShrinkNetworkPacket Packet { get; } + + public ShrinkNetworkContext(ShrinkNetworkService service, ShrinkNetworkSession session, ShrinkNetworkPacket packet) + { + Service = service; + Session = session; + Packet = packet; + } + + public bool IsRequest => Packet.Kind == ShrinkNetworkPacketKind.Request; + public bool IsResponse => Packet.Kind == ShrinkNetworkPacketKind.Response; + public ShrinkRequestToken RequestToken => Packet.RequestToken; + public string? Route => Packet.Route; + + public UniTask ReplyAsync(TResponse response) + where TResponse : class, IShrinkNetworkResponse + { + return Service.SendResponseAsync(Session, response, Packet.RequestToken, Packet.Route); + } + } +} diff --git a/Runtime/Core/ShrinkNetworkContext.cs.meta b/Runtime/Core/ShrinkNetworkContext.cs.meta new file mode 100644 index 0000000..20fa46a --- /dev/null +++ b/Runtime/Core/ShrinkNetworkContext.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 41088f41c7fd6b544a1ec928650e42be +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Core/ShrinkNetworkLogger.cs b/Runtime/Core/ShrinkNetworkLogger.cs new file mode 100644 index 0000000..f9fea1b --- /dev/null +++ b/Runtime/Core/ShrinkNetworkLogger.cs @@ -0,0 +1,53 @@ +using System; + +namespace ShrinkNetwork +{ + public static class ShrinkNetworkLogger + { + public static Action InfoHandler { get; set; } = DefaultInfo; + public static Action WarningHandler { get; set; } = DefaultWarning; + public static Action ErrorHandler { get; set; } = DefaultError; + public static Action ExceptionHandler { get; set; } = DefaultException; + + public static void Info(string message) => InfoHandler?.Invoke(message); + public static void Warn(string message) => WarningHandler?.Invoke(message); + public static void Error(string message) => ErrorHandler?.Invoke(message); + public static void Exception(Exception ex) => ExceptionHandler?.Invoke(ex); + + private static void DefaultInfo(string message) + { +#if UNITY_5_3_OR_NEWER + UnityEngine.Debug.Log(message); +#else + Console.WriteLine(message); +#endif + } + + private static void DefaultWarning(string message) + { +#if UNITY_5_3_OR_NEWER + UnityEngine.Debug.LogWarning(message); +#else + Console.WriteLine("[Warn] " + message); +#endif + } + + private static void DefaultError(string message) + { +#if UNITY_5_3_OR_NEWER + UnityEngine.Debug.LogError(message); +#else + Console.Error.WriteLine(message); +#endif + } + + private static void DefaultException(Exception ex) + { +#if UNITY_5_3_OR_NEWER + UnityEngine.Debug.LogException(ex); +#else + Console.Error.WriteLine(ex); +#endif + } + } +} diff --git a/Runtime/Core/ShrinkNetworkLogger.cs.meta b/Runtime/Core/ShrinkNetworkLogger.cs.meta new file mode 100644 index 0000000..0f88c60 --- /dev/null +++ b/Runtime/Core/ShrinkNetworkLogger.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1250bb387385afa40abf0450af4616f8 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Core/ShrinkNetworkRuntime.cs b/Runtime/Core/ShrinkNetworkRuntime.cs new file mode 100644 index 0000000..7c02afe --- /dev/null +++ b/Runtime/Core/ShrinkNetworkRuntime.cs @@ -0,0 +1,26 @@ +using UnityEngine; + +namespace ShrinkNetwork +{ + public static class ShrinkNetworkRuntime + { + public static ShrinkNetworkService Default { get; private set; } + + static ShrinkNetworkRuntime() + { + RebuildDefault(); + } + + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] + private static void ResetOnPlayModeEnter() + { + RebuildDefault(); + } + + private static void RebuildDefault() + { + Default = new ShrinkNetworkService(); + ShrinkNetworkGeneratedRegistry.RegisterAll(Default); + } + } +} diff --git a/Runtime/Core/ShrinkNetworkRuntime.cs.meta b/Runtime/Core/ShrinkNetworkRuntime.cs.meta new file mode 100644 index 0000000..2581083 --- /dev/null +++ b/Runtime/Core/ShrinkNetworkRuntime.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f9c281d5168894c40843e80276fe1592 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Core/ShrinkNetworkService.cs b/Runtime/Core/ShrinkNetworkService.cs new file mode 100644 index 0000000..40e65ad --- /dev/null +++ b/Runtime/Core/ShrinkNetworkService.cs @@ -0,0 +1,857 @@ +#nullable enable + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using Cysharp.Threading.Tasks; + +namespace ShrinkNetwork +{ + public sealed class ShrinkNetworkService + { + private sealed class PendingRequest + { + public long SessionId; + public Type ResponseType = null!; + public UniTaskCompletionSource CompletionSource = null!; + } + + private readonly ConcurrentDictionary _sessions = new(); + private readonly ConcurrentDictionary _pendingRequests = new(); + private int _requestTokenGenerator; + private IShrinkNetworkTransport? _transport; + private long _sessionsConnected; + private long _sessionsDisconnected; + private long _packetsSent; + private long _packetsReceived; + private long _bytesSent; + private long _bytesReceived; + private long _rpcStarted; + private long _rpcCompleted; + private long _rpcTimedOut; + private long _rpcCanceled; + private long _rpcFailed; + private long _protocolViolations; + private long _authRejectedCount; + private long _permissionDeniedCount; + private long _handlerExceptionCount; + private long _unknownOpcodeCount; + private long _dispatchMissCount; + private long _serializationErrorCount; + private long _dispatchQueueRejectedCount; + private IShrinkNetworkDispatchScheduler _dispatchScheduler; + + public ShrinkNetworkService() + : this(new ShrinkJsonNetworkSerializer(), new ShrinkNetworkMessageRegistry(), new ShrinkNetworkRouter()) + { + } + + public ShrinkNetworkService(IShrinkNetworkSerializer serializer, ShrinkNetworkMessageRegistry messageRegistry, + ShrinkNetworkRouter router, IShrinkNetworkDispatchScheduler? dispatchScheduler = null) + { + Serializer = serializer ?? throw new ArgumentNullException(nameof(serializer)); + MessageRegistry = messageRegistry ?? throw new ArgumentNullException(nameof(messageRegistry)); + Router = router ?? throw new ArgumentNullException(nameof(router)); + _dispatchScheduler = dispatchScheduler ?? ShrinkNetworkDispatchSchedulers.Inline; + } + + public IShrinkNetworkSerializer Serializer { get; } + public ShrinkNetworkMessageRegistry MessageRegistry { get; } + public ShrinkNetworkRouter Router { get; } + public IReadOnlyDictionary Sessions => _sessions; + public int MinProtocolVersion { get; set; } = ShrinkNetworkProtocol.CurrentProtocolVersion; + public int MaxProtocolVersion { get; set; } = ShrinkNetworkProtocol.CurrentProtocolVersion; + public int MinSchemaVersion { get; set; } = ShrinkNetworkProtocol.CurrentSchemaVersion; + public int MaxSchemaVersion { get; set; } = ShrinkNetworkProtocol.CurrentSchemaVersion; + public bool DisconnectOnProtocolViolation { get; set; } = true; + public Func? IncomingPacketValidator { get; set; } + + public IShrinkNetworkDispatchScheduler DispatchScheduler + { + get => _dispatchScheduler; + set => _dispatchScheduler = value ?? throw new ArgumentNullException(nameof(value)); + } + + public event Action? OnSessionConnected; + public event Action? OnSessionDisconnected; + + public void BindTransport(IShrinkNetworkTransport? transport) + { + if (_transport != null) + _transport.OnEvent -= OnTransportEvent; + + _transport = transport; + + if (_transport != null) + { + _transport.OnEvent += OnTransportEvent; + if (!_transport.IsStarted) + _transport.Start(); + return; + } + } + + public void RegisterMessage(int opcode, string? route = null) where TMessage : IShrinkNetworkMessage + => MessageRegistry.Register(opcode, route); + + public void RegisterMessage(Type messageType, int opcode, string? route = null) + => MessageRegistry.Register(messageType, opcode, route); + + public void RegisterHandler(Func handler, + ShrinkNetworkPermissionRequirement requirement = default) + where TMessage : IShrinkNetworkMessage + => Router.RegisterHandler(handler, requirement); + + public void RegisterHandler(Type messageType, Func handler, + ShrinkNetworkPermissionRequirement requirement = default) + => Router.RegisterHandler(messageType, handler, requirement); + + public void RegisterRequestHandler(Func> handler, + ShrinkNetworkPermissionRequirement requirement = default) + where TRequest : IShrinkNetworkRequest + where TResponse : class, IShrinkNetworkResponse + => Router.RegisterRequestHandler(handler, requirement); + + public void RegisterRequestHandler(Type requestType, Type responseType, + Func> handler, + ShrinkNetworkPermissionRequirement requirement = default) + => Router.RegisterRequestHandler(requestType, responseType, handler, requirement); + + public void RegisterRpc(Func> handler, + ShrinkNetworkPermissionRequirement requirement = default) + where TRequest : IShrinkNetworkRequest + where TResponse : class, IShrinkNetworkResponse + => RegisterRequestHandler(handler, requirement); + + public void AutoRegisterAttributedMessages() + => ShrinkNetworkRegHelper.RegisterAttributedMessages(this); + + public void AutoRegisterStaticHandlers() + => ShrinkNetworkRegHelper.RegisterStaticHandlers(this); + + public void RegisterHandlers(object target) + => ShrinkNetworkRegHelper.RegisterHandlers(this, target); + + public void AutoRegisterAll() + { + AutoRegisterAttributedMessages(); + AutoRegisterStaticHandlers(); + } + + public ShrinkNetworkServiceDiagnosticsSnapshot GetDiagnosticsSnapshot() + { + return new ShrinkNetworkServiceDiagnosticsSnapshot + { + CurrentSessions = _sessions.Count, + SessionsConnected = Volatile.Read(ref _sessionsConnected), + SessionsDisconnected = Volatile.Read(ref _sessionsDisconnected), + PacketsSent = Volatile.Read(ref _packetsSent), + PacketsReceived = Volatile.Read(ref _packetsReceived), + BytesSent = Volatile.Read(ref _bytesSent), + BytesReceived = Volatile.Read(ref _bytesReceived), + RpcStarted = Volatile.Read(ref _rpcStarted), + RpcCompleted = Volatile.Read(ref _rpcCompleted), + RpcTimedOut = Volatile.Read(ref _rpcTimedOut), + RpcCanceled = Volatile.Read(ref _rpcCanceled), + RpcFailed = Volatile.Read(ref _rpcFailed), + ProtocolViolations = Volatile.Read(ref _protocolViolations), + AuthRejectedCount = Volatile.Read(ref _authRejectedCount), + PermissionDeniedCount = Volatile.Read(ref _permissionDeniedCount), + HandlerExceptionCount = Volatile.Read(ref _handlerExceptionCount), + UnknownOpcodeCount = Volatile.Read(ref _unknownOpcodeCount), + DispatchMissCount = Volatile.Read(ref _dispatchMissCount), + SerializationErrorCount = Volatile.Read(ref _serializationErrorCount), + DispatchQueueRejectedCount = Volatile.Read(ref _dispatchQueueRejectedCount) + }; + } + + public UniTask SendAsync(ShrinkNetworkSession session, TMessage message, string? route = null) + where TMessage : IShrinkNetworkMessage + => SendInternalAsync(session, message, ShrinkNetworkPacketKind.Message, ShrinkRequestToken.Default, route); + + public UniTask SendAsync(ShrinkNetworkSession session, IShrinkNetworkMessage message, string? route = null) + { + if (message == null) + throw new ArgumentNullException(nameof(message)); + + return SendInternalAsync(session, message, message.GetType(), ShrinkNetworkPacketKind.Message, ShrinkRequestToken.Default, route); + } + + public UniTask NotifyAsync(ShrinkNetworkSession session, TMessage message, string? route = null) + where TMessage : IShrinkNetworkMessage + => SendAsync(session, message, route); + + public UniTask NotifyAsync(ShrinkNetworkSession session, IShrinkNetworkMessage message, string? route = null) + => SendAsync(session, message, route); + + /// + /// Sends a message whose payload has already been serialized. The payload is + /// treated as immutable and can be reused for multiple sessions. + /// + public UniTask SendSerializedAsync(ShrinkNetworkSession session, Type messageType, byte[] payload, + string? route = null) + { + return SendPacketAsync(session, messageType, ShrinkNetworkPacketKind.Message, + ShrinkRequestToken.Default, route, payload); + } + + public UniTask CallAsync(ShrinkNetworkSession session, TRequest request, string? route = null) + where TRequest : IShrinkNetworkRequest + where TResponse : class, IShrinkNetworkResponse + => CallAsync(session, request, new ShrinkRpcCallOptions { RouteOverride = route }); + + public async UniTask CallAsync(ShrinkNetworkSession session, TRequest request, + ShrinkRpcCallOptions options) + where TRequest : IShrinkNetworkRequest + where TResponse : class, IShrinkNetworkResponse + { + if (session == null) + throw new ArgumentNullException(nameof(session)); + + Interlocked.Increment(ref _rpcStarted); + var requestToken = options?.RequestTokenOverride ?? new ShrinkRequestToken(Interlocked.Increment(ref _requestTokenGenerator)); + var pending = new PendingRequest + { + SessionId = session.SessionId, + ResponseType = typeof(TResponse), + CompletionSource = new UniTaskCompletionSource() + }; + if (!_pendingRequests.TryAdd(requestToken, pending)) + { + throw new InvalidOperationException( + $"Pending request token already exists. RequestToken={requestToken}"); + } + + try + { + await SendInternalAsync(session, request, ShrinkNetworkPacketKind.Request, requestToken, options?.RouteOverride); + var response = await WaitForPendingResponse(requestToken, pending, options); + EnsureResponseSucceeded(response); + Interlocked.Increment(ref _rpcCompleted); + return response; + } + catch + { + _pendingRequests.TryRemove(requestToken, out _); + throw; + } + } + + public UniTask RpcAsync(ShrinkNetworkSession session, TRequest request, string? route = null) + where TRequest : IShrinkNetworkRequest + where TResponse : class, IShrinkNetworkResponse + => CallAsync(session, request, route); + + public UniTask RpcAsync(ShrinkNetworkSession session, TRequest request, + ShrinkRpcCallOptions options) + where TRequest : IShrinkNetworkRequest + where TResponse : class, IShrinkNetworkResponse + => CallAsync(session, request, options); + + internal UniTask SendResponseAsync(ShrinkNetworkSession session, TResponse response, ShrinkRequestToken requestToken, string? route = null) + where TResponse : class, IShrinkNetworkResponse + => SendInternalAsync(session, response, typeof(TResponse), ShrinkNetworkPacketKind.Response, requestToken, route); + + internal UniTask SendResponseAsync(ShrinkNetworkSession session, IShrinkNetworkResponse response, Type responseType, + ShrinkRequestToken requestToken, string? route = null) + => SendInternalAsync(session, response, responseType, ShrinkNetworkPacketKind.Response, requestToken, route); + + private UniTask SendInternalAsync(ShrinkNetworkSession session, TMessage message, + ShrinkNetworkPacketKind kind, ShrinkRequestToken requestToken, string? route) + where TMessage : IShrinkNetworkMessage + => SendInternalAsync(session, message, typeof(TMessage), kind, requestToken, route); + + private UniTask SendInternalAsync(ShrinkNetworkSession session, IShrinkNetworkMessage message, Type messageType, + ShrinkNetworkPacketKind kind, ShrinkRequestToken requestToken, string? route) + { + if (session == null) + throw new ArgumentNullException(nameof(session)); + if (message == null) + throw new ArgumentNullException(nameof(message)); + if (messageType == null) + throw new ArgumentNullException(nameof(messageType)); + + return SendPacketAsync(session, messageType, kind, requestToken, route, Serializer.Serialize(message)); + } + + private async UniTask SendPacketAsync(ShrinkNetworkSession session, Type messageType, + ShrinkNetworkPacketKind kind, ShrinkRequestToken requestToken, string? route, byte[] payload) + { + if (session == null) + throw new ArgumentNullException(nameof(session)); + if (messageType == null) + throw new ArgumentNullException(nameof(messageType)); + if (payload == null) + throw new ArgumentNullException(nameof(payload)); + + var transport = _transport ?? throw new InvalidOperationException("Transport is not bound."); + var meta = MessageRegistry.GetMeta(messageType); + var packet = new ShrinkNetworkPacket + { + Opcode = meta.Opcode, + RequestToken = requestToken, + SessionToken = session.SessionToken, + SessionTokenExpiresAtUnixTimeSeconds = session.SessionTokenExpiresAtUtc?.ToUnixTimeSeconds() ?? 0, + Route = string.IsNullOrWhiteSpace(route) ? meta.Route : route.Trim(), + Kind = kind, + Payload = payload + }; + + var packetData = Serializer.Serialize(packet); + Interlocked.Increment(ref _packetsSent); + Interlocked.Add(ref _bytesSent, packetData.Length); + if (transport is IShrinkNetworkAsyncTransport asyncTransport) + { + await asyncTransport.SendAsync(session.SessionId, packetData); + return; + } + + transport.Send(session.SessionId, packetData); + } + + private void OnTransportEvent(ShrinkNetworkTransportEvent evt) + { + if (evt == null) + return; + + // 连接生命周期必须在传输回调返回前提交,否则快速退役/重激活时, + // 旧的断开事件可能晚于新的连接事件完成,观察者会读到过期会话视图。 + if (evt.Type == ShrinkNetworkTransportEventType.Connected) + { + HandleConnected(evt); + return; + } + + if (evt.Type == ShrinkNetworkTransportEventType.Disconnected) + { + HandleDisconnected(evt); + return; + } + + ScheduleTransportEventAsync(evt).Forget(); + } + + private async UniTaskVoid ScheduleTransportEventAsync(ShrinkNetworkTransportEvent evt) + { + try + { + var scheduled = await DispatchScheduler.ScheduleAsync(() => HandleTransportEventAsync(evt)); + if (!scheduled) + Interlocked.Increment(ref _dispatchQueueRejectedCount); + } + catch (Exception ex) + { + ShrinkNetworkLogger.Exception(ex); + ShrinkNetworkLogger.Error($"[ShrinkNetwork] Transport event dispatch failed: {ex.Message}"); + } + } + + private UniTask HandleTransportEventAsync(ShrinkNetworkTransportEvent evt) + { + switch (evt.Type) + { + case ShrinkNetworkTransportEventType.Connected: + HandleConnected(evt); + return UniTask.CompletedTask; + case ShrinkNetworkTransportEventType.Disconnected: + HandleDisconnected(evt); + return UniTask.CompletedTask; + case ShrinkNetworkTransportEventType.Packet: + return HandlePacketAsync(evt); + default: + return UniTask.CompletedTask; + } + } + + private void HandleConnected(ShrinkNetworkTransportEvent evt) + { + var session = _sessions.AddOrUpdate(evt.SessionId, + _ => new ShrinkNetworkSession(evt.SessionId, evt.RemoteAddress, this), + (_, existing) => + { + existing.RemoteAddress = evt.RemoteAddress; + return existing; + }); + + Interlocked.Increment(ref _sessionsConnected); + OnSessionConnected?.Invoke(session); + } + + private void HandleDisconnected(ShrinkNetworkTransportEvent evt) + { + if (_sessions.TryRemove(evt.SessionId, out var session)) + { + FailPendingRequestsForSession(evt.SessionId); + Interlocked.Increment(ref _sessionsDisconnected); + OnSessionDisconnected?.Invoke(session); + } + } + + private async UniTask HandlePacketAsync(ShrinkNetworkTransportEvent evt) + { + try + { + Interlocked.Increment(ref _packetsReceived); + Interlocked.Add(ref _bytesReceived, evt.PacketData.Length); + var packet = Serializer.Deserialize(evt.PacketData); + + if (!_sessions.TryGetValue(evt.SessionId, out var session)) + { + session = _sessions.GetOrAdd(evt.SessionId, + id => new ShrinkNetworkSession(id, evt.RemoteAddress, this)); + } + + if (!ValidatePacketCompatibility(packet, evt.SessionId)) + return; + + ApplySessionTokenFromPacket(session, packet); + + if (packet.Kind == ShrinkNetworkPacketKind.Response) + { + HandleResponse(packet); + return; + } + + if (!ValidateIncomingPacket(session, packet)) + return; + + if (!MessageRegistry.TryGetMeta(packet.Opcode, out var meta)) + { + Interlocked.Increment(ref _unknownOpcodeCount); + ShrinkNetworkLogger.Warn($"[ShrinkNetwork] Unregistered opcode: {packet.Opcode}"); + return; + } + + var resolvedMeta = meta!; + var message = Serializer.Deserialize(packet.Payload, resolvedMeta.MessageType); + if (message == null) + { + ShrinkNetworkLogger.Warn($"[ShrinkNetwork] Failed to deserialize message for opcode {packet.Opcode}."); + Interlocked.Increment(ref _serializationErrorCount); + return; + } + + var context = new ShrinkNetworkContext(this, session, packet); + + var handled = await Router.DispatchAsync(context, message, resolvedMeta.MessageType); + if (!handled) + { + Interlocked.Increment(ref _dispatchMissCount); + ShrinkNetworkLogger.Warn($"[ShrinkNetwork] No handler found for {resolvedMeta.MessageType.FullName}"); + } + } + catch (Exception ex) + { + Interlocked.Increment(ref _serializationErrorCount); + ShrinkNetworkLogger.Exception(ex); + ShrinkNetworkLogger.Error($"[ShrinkNetwork] Packet handling failed: {ex.Message}"); + } + } + + private void HandleResponse(ShrinkNetworkPacket packet) + { + if (!_pendingRequests.TryRemove(packet.RequestToken, out var pending)) + { + ShrinkNetworkLogger.Warn($"[ShrinkNetwork] Pending request not found. RequestToken={packet.RequestToken}"); + return; + } + + try + { + var response = Serializer.Deserialize(packet.Payload, pending.ResponseType); + pending.CompletionSource.TrySetResult(response); + } + catch (Exception ex) + { + pending.CompletionSource.TrySetException(ex); + } + } + + private async UniTask WaitForPendingResponse(ShrinkRequestToken requestToken, PendingRequest pending, + ShrinkRpcCallOptions? options) + where TResponse : class, IShrinkNetworkResponse + { + var timeoutMs = options?.TimeoutMs ?? 10000; + var externalToken = options?.CancellationToken ?? default; + var debugLabel = string.IsNullOrWhiteSpace(options?.DebugLabel) ? string.Empty : options.DebugLabel.Trim(); + + using var timeoutCts = timeoutMs > 0 ? new CancellationTokenSource(timeoutMs) : null; + using var linkedCts = CreateLinkedTokenSource(externalToken, timeoutCts?.Token ?? default); + + try + { + var boxed = await pending.CompletionSource.Task.AttachExternalCancellation(linkedCts?.Token ?? default); + if (boxed is not TResponse response) + throw new ShrinkRpcException(ShrinkRpcErrorCode.InvalidResponse, BuildRpcMessage("RPC returned an invalid response.", requestToken, debugLabel)); + + return response; + } + catch (OperationCanceledException) when (externalToken.IsCancellationRequested) + { + _pendingRequests.TryRemove(requestToken, out _); + Interlocked.Increment(ref _rpcCanceled); + Interlocked.Increment(ref _rpcFailed); + throw new ShrinkRpcException(ShrinkRpcErrorCode.Canceled, BuildRpcMessage("RPC call was canceled.", requestToken, debugLabel)); + } + catch (OperationCanceledException) + { + _pendingRequests.TryRemove(requestToken, out _); + Interlocked.Increment(ref _rpcTimedOut); + Interlocked.Increment(ref _rpcFailed); + throw new ShrinkRpcException(ShrinkRpcErrorCode.Timeout, BuildRpcMessage("RPC call timed out.", requestToken, debugLabel)); + } + } + + private void FailPendingRequestsForSession(long sessionId) + { + var pendingEntries = _pendingRequests + .Where(pair => pair.Value.SessionId == sessionId) + .ToArray(); + if (pendingEntries.Length == 0) + return; + + foreach (var pendingEntry in pendingEntries) + { + if (_pendingRequests.TryRemove(pendingEntry.Key, out var pending)) + { + pending.CompletionSource.TrySetException( + new ShrinkRpcException(ShrinkRpcErrorCode.ConnectionClosed, + $"RPC peer disconnected. RequestToken={pendingEntry.Key}, SessionId={sessionId}")); + } + } + } + + private static string BuildRpcMessage(string message, ShrinkRequestToken requestToken, string debugLabel) + { + if (string.IsNullOrWhiteSpace(debugLabel)) + return $"{message} RequestToken={requestToken}"; + + return $"{message} RequestToken={requestToken}, Label={debugLabel}"; + } + + private static CancellationTokenSource? CreateLinkedTokenSource(CancellationToken left, CancellationToken right) + { + if (left.CanBeCanceled && right.CanBeCanceled) + return CancellationTokenSource.CreateLinkedTokenSource(left, right); + + if (left.CanBeCanceled) + return CancellationTokenSource.CreateLinkedTokenSource(left); + + if (right.CanBeCanceled) + return CancellationTokenSource.CreateLinkedTokenSource(right); + + return null; + } + + private static void EnsureResponseSucceeded(TResponse response) + where TResponse : class, IShrinkNetworkResponse + { + if (response == null) + throw new ShrinkRpcException(ShrinkRpcErrorCode.InvalidResponse, "RPC returned a null response."); + + if (response.ErrorCode != 0) + throw new ShrinkRpcException(response.ErrorCode, + string.IsNullOrWhiteSpace(response.ErrorMessage) ? "RPC call failed." : response.ErrorMessage); + } + + internal void ReportPermissionDenied(Type messageType) + { + Interlocked.Increment(ref _permissionDeniedCount); + } + + internal void ReportHandlerException(Type messageType, Exception ex) + { + Interlocked.Increment(ref _handlerExceptionCount); + Interlocked.Increment(ref _rpcFailed); + } + + private bool ValidatePacketCompatibility(ShrinkNetworkPacket packet, long sessionId) + { + var protocolAllowed = packet.ProtocolVersion >= MinProtocolVersion && packet.ProtocolVersion <= MaxProtocolVersion; + var schemaAllowed = packet.SchemaVersion >= MinSchemaVersion && packet.SchemaVersion <= MaxSchemaVersion; + if (protocolAllowed && schemaAllowed) + return true; + + Interlocked.Increment(ref _protocolViolations); + var reason = + $"protocol/schema mismatch. Protocol={packet.ProtocolVersion}, Schema={packet.SchemaVersion}, AllowedProtocol={MinProtocolVersion}-{MaxProtocolVersion}, AllowedSchema={MinSchemaVersion}-{MaxSchemaVersion}"; + ShrinkNetworkLogger.Warn($"[ShrinkNetwork] Session {sessionId} rejected: {reason}"); + if (DisconnectOnProtocolViolation && _transport is IShrinkNetworkSessionControlTransport sessionControl) + sessionControl.DisconnectSession(sessionId, reason); + return false; + } + + private bool ValidateIncomingPacket(ShrinkNetworkSession session, ShrinkNetworkPacket packet) + { + var validator = IncomingPacketValidator; + if (validator == null) + return true; + + var result = validator(session, packet); + if (result == null || result.IsAllowed) + return true; + + Interlocked.Increment(ref _authRejectedCount); + var reason = string.IsNullOrWhiteSpace(result.Reason) + ? "incoming packet rejected by validator." + : result.Reason.Trim(); + ShrinkNetworkLogger.Warn($"[ShrinkNetwork] Session {session.SessionId} rejected: {reason}"); + if (result.DisconnectSession && _transport is IShrinkNetworkSessionControlTransport sessionControl) + sessionControl.DisconnectSession(session.SessionId, reason); + return false; + } + + private static void ApplySessionTokenFromPacket(ShrinkNetworkSession session, ShrinkNetworkPacket packet) + { + if (string.IsNullOrWhiteSpace(packet.SessionToken)) + return; + + var expiresAtUtc = packet.SessionTokenExpiresAtUnixTimeSeconds > 0 + ? DateTimeOffset.FromUnixTimeSeconds(packet.SessionTokenExpiresAtUnixTimeSeconds) + : (DateTimeOffset?)null; + session.SetSessionToken(packet.SessionToken, expiresAtUtc); + } + + } + + public interface IShrinkNetworkDispatchScheduler + { + /// + /// Schedules a transport event. The result is false when the scheduler + /// rejects or drops the work because its bounded queue is full. + /// + UniTask ScheduleAsync(Func callback); + } + + public static class ShrinkNetworkDispatchSchedulers + { + public static IShrinkNetworkDispatchScheduler Inline { get; } = + new ShrinkNetworkInlineDispatchScheduler(); + } + + public sealed class ShrinkNetworkInlineDispatchScheduler : IShrinkNetworkDispatchScheduler + { + public async UniTask ScheduleAsync(Func callback) + { + if (callback == null) + throw new ArgumentNullException(nameof(callback)); + + await callback(); + return true; + } + } + + public enum ShrinkNetworkDispatchOverflowPolicy + { + Reject = 0, + DropNewest = 1, + DropOldest = 2 + } + + /// + /// A caller-pumped, bounded dispatch queue. Unity can pump it from Update + /// while a dedicated server can keep the default inline scheduler. + /// + public sealed class ShrinkNetworkDispatchQueue : IShrinkNetworkDispatchScheduler, IDisposable + { + private sealed class WorkItem + { + public Func Callback = null!; + public UniTaskCompletionSource Completion = null!; + } + + private readonly ConcurrentQueue _queue = new(); + private readonly object _lifecycleLock = new(); + private readonly int _capacity; + private readonly ShrinkNetworkDispatchOverflowPolicy _overflowPolicy; + private int _queuedCount; + private int _pumping; + private int _disposed; + private long _rejectedCount; + private long _droppedCount; + + public ShrinkNetworkDispatchQueue(int capacity, + ShrinkNetworkDispatchOverflowPolicy overflowPolicy = ShrinkNetworkDispatchOverflowPolicy.Reject) + { + if (capacity <= 0) + throw new ArgumentOutOfRangeException(nameof(capacity)); + + _capacity = capacity; + _overflowPolicy = overflowPolicy; + } + + public int Capacity => _capacity; + public int PendingCount => Volatile.Read(ref _queuedCount); + public long RejectedCount => Volatile.Read(ref _rejectedCount); + public long DroppedCount => Volatile.Read(ref _droppedCount); + + public UniTask ScheduleAsync(Func callback) + { + if (callback == null) + throw new ArgumentNullException(nameof(callback)); + if (Volatile.Read(ref _disposed) != 0) + return UniTask.FromException(new ObjectDisposedException(nameof(ShrinkNetworkDispatchQueue))); + + var item = new WorkItem + { + Callback = callback, + Completion = new UniTaskCompletionSource() + }; + + while (true) + { + if (Volatile.Read(ref _queuedCount) >= _capacity) + { + switch (_overflowPolicy) + { + case ShrinkNetworkDispatchOverflowPolicy.Reject: + Interlocked.Increment(ref _rejectedCount); + return UniTask.FromResult(false); + case ShrinkNetworkDispatchOverflowPolicy.DropNewest: + Interlocked.Increment(ref _droppedCount); + return UniTask.FromResult(false); + case ShrinkNetworkDispatchOverflowPolicy.DropOldest: + if (_queue.TryDequeue(out var dropped)) + { + Interlocked.Decrement(ref _queuedCount); + Interlocked.Increment(ref _droppedCount); + dropped.Completion.TrySetResult(false); + continue; + } + + Thread.Yield(); + continue; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var currentCount = Volatile.Read(ref _queuedCount); + if (currentCount >= _capacity || + Interlocked.CompareExchange(ref _queuedCount, currentCount + 1, currentCount) != currentCount) + { + continue; + } + + lock (_lifecycleLock) + { + if (Volatile.Read(ref _disposed) != 0) + { + Interlocked.Decrement(ref _queuedCount); + item.Completion.TrySetResult(false); + return item.Completion.Task; + } + + _queue.Enqueue(item); + return item.Completion.Task; + } + } + } + + public UniTask PumpAsync(int maxItems) + { + if (maxItems <= 0) + throw new ArgumentOutOfRangeException(nameof(maxItems)); + if (Interlocked.Exchange(ref _pumping, 1) == 1) + return UniTask.FromResult(0); + + return PumpCoreAsync(maxItems); + } + + public void Dispose() + { + lock (_lifecycleLock) + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + return; + + while (_queue.TryDequeue(out var item)) + { + Interlocked.Decrement(ref _queuedCount); + item.Completion.TrySetResult(false); + } + } + } + + private async UniTask PumpCoreAsync(int maxItems) + { + var processed = 0; + try + { + while (processed < maxItems && _queue.TryDequeue(out var item)) + { + Interlocked.Decrement(ref _queuedCount); + await ExecuteItemAsync(item); + processed++; + } + + return processed; + } + finally + { + Volatile.Write(ref _pumping, 0); + } + } + + private static async UniTask ExecuteItemAsync(WorkItem item) + { + try + { + await item.Callback(); + item.Completion.TrySetResult(true); + } + catch (Exception ex) + { + item.Completion.TrySetException(ex); + } + } + } + + public sealed class ShrinkNetworkServiceDiagnosticsSnapshot + { + public int CurrentSessions { get; set; } + public long SessionsConnected { get; set; } + public long SessionsDisconnected { get; set; } + public long PacketsSent { get; set; } + public long PacketsReceived { get; set; } + public long BytesSent { get; set; } + public long BytesReceived { get; set; } + public long RpcStarted { get; set; } + public long RpcCompleted { get; set; } + public long RpcTimedOut { get; set; } + public long RpcCanceled { get; set; } + public long RpcFailed { get; set; } + public long ProtocolViolations { get; set; } + public long AuthRejectedCount { get; set; } + public long PermissionDeniedCount { get; set; } + public long HandlerExceptionCount { get; set; } + public long UnknownOpcodeCount { get; set; } + public long DispatchMissCount { get; set; } + public long SerializationErrorCount { get; set; } + public long DispatchQueueRejectedCount { get; set; } + } + + public sealed class ShrinkIncomingPacketValidationResult + { + public static readonly ShrinkIncomingPacketValidationResult Allow = new() + { + IsAllowed = true + }; + + public bool IsAllowed { get; set; } + public bool DisconnectSession { get; set; } + public string Reason { get; set; } = string.Empty; + + public static ShrinkIncomingPacketValidationResult Reject(string reason, bool disconnectSession = true) + { + return new ShrinkIncomingPacketValidationResult + { + IsAllowed = false, + DisconnectSession = disconnectSession, + Reason = reason ?? string.Empty + }; + } + } +} diff --git a/Runtime/Core/ShrinkNetworkService.cs.meta b/Runtime/Core/ShrinkNetworkService.cs.meta new file mode 100644 index 0000000..5a513c8 --- /dev/null +++ b/Runtime/Core/ShrinkNetworkService.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: eb6513d534c7574478bd07ffd195cf26 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Core/ShrinkNetworkSession.cs b/Runtime/Core/ShrinkNetworkSession.cs new file mode 100644 index 0000000..1f00fde --- /dev/null +++ b/Runtime/Core/ShrinkNetworkSession.cs @@ -0,0 +1,113 @@ +#nullable enable + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using Cysharp.Threading.Tasks; + +namespace ShrinkNetwork +{ + public sealed class ShrinkNetworkSession + { + private readonly ConcurrentDictionary _items = new(); + private readonly ConcurrentDictionary _permissions = new(); + + internal ShrinkNetworkSession(long sessionId, string remoteAddress, ShrinkNetworkService service) + { + SessionId = sessionId; + RemoteAddress = remoteAddress; + Service = service; + } + + public long SessionId { get; } + public string RemoteAddress { get; internal set; } + public ShrinkNetworkService Service { get; } + public ShrinkNetworkPeerKind PeerKind { get; private set; } = ShrinkNetworkPeerKind.Unknown; + public string SessionToken { get; private set; } = string.Empty; + public DateTimeOffset? SessionTokenExpiresAtUtc { get; private set; } + + public IDictionary Items => _items; + + public void SetPeerKind(ShrinkNetworkPeerKind peerKind) + { + PeerKind = peerKind; + } + + public void SetSessionToken(string sessionToken, DateTimeOffset? expiresAtUtc = null) + { + SessionToken = string.IsNullOrWhiteSpace(sessionToken) ? string.Empty : sessionToken.Trim(); + SessionTokenExpiresAtUtc = string.IsNullOrWhiteSpace(SessionToken) ? null : expiresAtUtc; + } + + public void ClearSessionToken() + { + SessionToken = string.Empty; + SessionTokenExpiresAtUtc = null; + } + + public void GrantPermission(string permission) + { + if (!string.IsNullOrWhiteSpace(permission)) + _permissions[permission.Trim()] = 0; + } + + public void RevokePermission(string permission) + { + if (!string.IsNullOrWhiteSpace(permission)) + _permissions.TryRemove(permission.Trim(), out _); + } + + public bool HasPermission(string permission) + { + return !string.IsNullOrWhiteSpace(permission) && _permissions.ContainsKey(permission.Trim()); + } + + public IReadOnlyCollection GetPermissions() => _permissions.Keys.ToArray(); + + public UniTask SendAsync(TMessage message, string? route = null) + where TMessage : IShrinkNetworkMessage + => Service.SendAsync(this, message, route); + + public UniTask SendAsync(IShrinkNetworkMessage message, string? route = null) + => Service.SendAsync(this, message, route); + + public UniTask NotifyAsync(TMessage message, string? route = null) + where TMessage : IShrinkNetworkMessage + => Service.SendAsync(this, message, route); + + public UniTask NotifyAsync(IShrinkNetworkMessage message, string? route = null) + => Service.SendAsync(this, message, route); + + public UniTask CallAsync(TRequest request, string? route = null) + where TRequest : IShrinkNetworkRequest + where TResponse : class, IShrinkNetworkResponse + => Service.CallAsync(this, request, route); + + public UniTask CallAsync(TRequest request, ShrinkRpcCallOptions options) + where TRequest : IShrinkNetworkRequest + where TResponse : class, IShrinkNetworkResponse + => Service.CallAsync(this, request, options); + + public UniTask RpcAsync(TRequest request, string? route = null) + where TRequest : IShrinkNetworkRequest + where TResponse : class, IShrinkNetworkResponse + => Service.CallAsync(this, request, route); + + public UniTask RpcAsync(TRequest request, ShrinkRpcCallOptions options) + where TRequest : IShrinkNetworkRequest + where TResponse : class, IShrinkNetworkResponse + => Service.CallAsync(this, request, options); + + public UniTask RpcAsync(TRequest request, int timeoutMs, + CancellationToken cancellationToken = default) + where TRequest : IShrinkNetworkRequest + where TResponse : class, IShrinkNetworkResponse + => Service.CallAsync(this, request, new ShrinkRpcCallOptions + { + TimeoutMs = timeoutMs, + CancellationToken = cancellationToken + }); + } +} diff --git a/Runtime/Core/ShrinkNetworkSession.cs.meta b/Runtime/Core/ShrinkNetworkSession.cs.meta new file mode 100644 index 0000000..b5f78f3 --- /dev/null +++ b/Runtime/Core/ShrinkNetworkSession.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 26140534bef037a44a72d8c9eadb99b6 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Metadata.meta b/Runtime/Metadata.meta new file mode 100644 index 0000000..3e9fff9 --- /dev/null +++ b/Runtime/Metadata.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 56839208d6041c545bc4c52c7d1c7472 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Metadata/ShrinkNetworkAttributes.cs b/Runtime/Metadata/ShrinkNetworkAttributes.cs new file mode 100644 index 0000000..bbc2a59 --- /dev/null +++ b/Runtime/Metadata/ShrinkNetworkAttributes.cs @@ -0,0 +1,54 @@ +#nullable enable + +using System; + +namespace ShrinkNetwork +{ + [AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)] + public sealed class ShrinkNetworkMessageAttribute : Attribute + { + public int Opcode { get; } + public string? Route { get; } + + public ShrinkNetworkMessageAttribute(int opcode, string? route = null) + { + Opcode = opcode; + Route = string.IsNullOrWhiteSpace(route) ? null : route.Trim(); + } + } + + [AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)] + public sealed class ShrinkNetworkSubscriberAttribute : Attribute + { + } + + public enum ShrinkNetworkStateSyncRole + { + JoinRequest = 0, + JoinResponse = 1, + Command = 2, + StateDelta = 3, + LeaveNotice = 4, + Heartbeat = 5 + } + + [AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = true)] + public sealed class ShrinkNetworkStateSyncAttribute : Attribute + { + public string Group { get; } + public ShrinkNetworkStateSyncRole Role { get; } + + public ShrinkNetworkStateSyncAttribute(string group, ShrinkNetworkStateSyncRole role) + { + Group = string.IsNullOrWhiteSpace(group) ? string.Empty : group.Trim(); + Role = role; + } + } + + [AttributeUsage(AttributeTargets.Method, Inherited = false, AllowMultiple = true)] + public sealed class ShrinkNetworkSubscribeAttribute : Attribute + { + public ShrinkNetworkAuthority Authority { get; set; } = ShrinkNetworkAuthority.Any; + public string? Permission { get; set; } + } +} diff --git a/Runtime/Metadata/ShrinkNetworkAttributes.cs.meta b/Runtime/Metadata/ShrinkNetworkAttributes.cs.meta new file mode 100644 index 0000000..3283685 --- /dev/null +++ b/Runtime/Metadata/ShrinkNetworkAttributes.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c0a58e8dd0b553646bc53a29187ee423 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Metadata/ShrinkNetworkMessageContracts.cs b/Runtime/Metadata/ShrinkNetworkMessageContracts.cs new file mode 100644 index 0000000..c951518 --- /dev/null +++ b/Runtime/Metadata/ShrinkNetworkMessageContracts.cs @@ -0,0 +1,16 @@ +namespace ShrinkNetwork +{ + public interface IShrinkNetworkMessage + { + } + + public interface IShrinkNetworkRequest : IShrinkNetworkMessage + { + } + + public interface IShrinkNetworkResponse : IShrinkNetworkMessage + { + int ErrorCode { get; set; } + string ErrorMessage { get; set; } + } +} diff --git a/Runtime/Metadata/ShrinkNetworkMessageContracts.cs.meta b/Runtime/Metadata/ShrinkNetworkMessageContracts.cs.meta new file mode 100644 index 0000000..30126a7 --- /dev/null +++ b/Runtime/Metadata/ShrinkNetworkMessageContracts.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 2183af3c779211e43933d975814eb435 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Metadata/ShrinkNetworkPacket.cs b/Runtime/Metadata/ShrinkNetworkPacket.cs new file mode 100644 index 0000000..01299cd --- /dev/null +++ b/Runtime/Metadata/ShrinkNetworkPacket.cs @@ -0,0 +1,33 @@ +#nullable enable + +using System; + +namespace ShrinkNetwork +{ + public static class ShrinkNetworkProtocol + { + public const int CurrentProtocolVersion = 1; + public const int CurrentSchemaVersion = 1; + } + + public enum ShrinkNetworkPacketKind + { + Message = 0, + Request = 1, + Response = 2 + } + + [Serializable] + public sealed class ShrinkNetworkPacket + { + public int ProtocolVersion = ShrinkNetworkProtocol.CurrentProtocolVersion; + public int SchemaVersion = ShrinkNetworkProtocol.CurrentSchemaVersion; + public int Opcode; + public ShrinkRequestToken RequestToken; + public string SessionToken = string.Empty; + public long SessionTokenExpiresAtUnixTimeSeconds; + public string? Route; + public ShrinkNetworkPacketKind Kind; + public byte[] Payload = Array.Empty(); + } +} diff --git a/Runtime/Metadata/ShrinkNetworkPacket.cs.meta b/Runtime/Metadata/ShrinkNetworkPacket.cs.meta new file mode 100644 index 0000000..22e0eca --- /dev/null +++ b/Runtime/Metadata/ShrinkNetworkPacket.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d6e511da358c6c64d8c34fdb62928178 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Metadata/ShrinkNetworkPermissions.cs b/Runtime/Metadata/ShrinkNetworkPermissions.cs new file mode 100644 index 0000000..9475200 --- /dev/null +++ b/Runtime/Metadata/ShrinkNetworkPermissions.cs @@ -0,0 +1,64 @@ +#nullable enable + +using System; +using System.Collections.Generic; + +namespace ShrinkNetwork +{ + public enum ShrinkNetworkPeerKind + { + Unknown = 0, + Client = 1, + Server = 2, + TrustedServer = 3 + } + + public enum ShrinkNetworkAuthority + { + Any = 0, + ClientOnly = 1, + ServerOnly = 2, + TrustedServerOnly = 3 + } + + public readonly struct ShrinkNetworkPermissionRequirement + { + public ShrinkNetworkAuthority Authority { get; } + public string? Permission { get; } + + public ShrinkNetworkPermissionRequirement(ShrinkNetworkAuthority authority, string? permission) + { + Authority = authority; + Permission = string.IsNullOrWhiteSpace(permission) ? null : permission.Trim(); + } + } + + internal static class ShrinkNetworkPermissionValidator + { + public static bool IsAllowed(ShrinkNetworkSession session, ShrinkNetworkPermissionRequirement requirement) + { + if (session == null) + return false; + + if (!CheckAuthority(session.PeerKind, requirement.Authority)) + return false; + + if (!string.IsNullOrEmpty(requirement.Permission) && !session.HasPermission(requirement.Permission)) + return false; + + return true; + } + + private static bool CheckAuthority(ShrinkNetworkPeerKind peerKind, ShrinkNetworkAuthority authority) + { + return authority switch + { + ShrinkNetworkAuthority.Any => true, + ShrinkNetworkAuthority.ClientOnly => peerKind == ShrinkNetworkPeerKind.Client, + ShrinkNetworkAuthority.ServerOnly => peerKind == ShrinkNetworkPeerKind.Server || peerKind == ShrinkNetworkPeerKind.TrustedServer, + ShrinkNetworkAuthority.TrustedServerOnly => peerKind == ShrinkNetworkPeerKind.TrustedServer, + _ => false + }; + } + } +} diff --git a/Runtime/Metadata/ShrinkNetworkPermissions.cs.meta b/Runtime/Metadata/ShrinkNetworkPermissions.cs.meta new file mode 100644 index 0000000..d511aa4 --- /dev/null +++ b/Runtime/Metadata/ShrinkNetworkPermissions.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c6a9dc7f8d56afd41b1e47fac50c5180 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Metadata/ShrinkNetworkRpc.cs b/Runtime/Metadata/ShrinkNetworkRpc.cs new file mode 100644 index 0000000..57687b2 --- /dev/null +++ b/Runtime/Metadata/ShrinkNetworkRpc.cs @@ -0,0 +1,71 @@ +#nullable enable +using System; +using System.Threading; + +namespace ShrinkNetwork +{ + public readonly struct ShrinkRequestToken : IEquatable + { + public static readonly ShrinkRequestToken Default = new(0); + + public ShrinkRequestToken(int value) + { + Value = value; + } + + public int Value { get; } + public bool IsDefault => Value == 0; + + public bool Equals(ShrinkRequestToken other) => Value == other.Value; + public override bool Equals(object? obj) => obj is ShrinkRequestToken other && Equals(other); + public override int GetHashCode() => Value; + public override string ToString() => Value.ToString(); + + public static bool operator ==(ShrinkRequestToken left, ShrinkRequestToken right) => left.Equals(right); + public static bool operator !=(ShrinkRequestToken left, ShrinkRequestToken right) => !left.Equals(right); + public static explicit operator int(ShrinkRequestToken token) => token.Value; + public static explicit operator ShrinkRequestToken(int value) => new(value); + } + + public sealed class ShrinkRpcCallOptions + { + public int TimeoutMs { get; set; } = 10000; + public string? RouteOverride { get; set; } + public ShrinkRequestToken? RequestTokenOverride { get; set; } + public string? DebugLabel { get; set; } + public CancellationToken CancellationToken { get; set; } = default; + } + + public sealed class ShrinkRpcException : Exception + { + public int ErrorCode { get; } + + public ShrinkRpcException(int errorCode, string message) + : base(message) + { + ErrorCode = errorCode; + } + } + + public static class ShrinkRpcErrorCode + { + public const int Unknown = 1; + public const int Timeout = 2; + public const int Canceled = 3; + public const int HandlerException = 4; + public const int InvalidResponse = 5; + public const int PermissionDenied = 6; + public const int ConnectionClosed = 7; + public const int ProtocolMismatch = 8; + public const int AuthenticationFailed = 9; + public const int SessionTokenExpired = 10; + } + + public abstract class ShrinkRpcResponseBase : IShrinkNetworkResponse + { + public int ErrorCode { get; set; } + public string ErrorMessage { get; set; } = string.Empty; + + public bool IsSuccess => ErrorCode == 0; + } +} diff --git a/Runtime/Metadata/ShrinkNetworkRpc.cs.meta b/Runtime/Metadata/ShrinkNetworkRpc.cs.meta new file mode 100644 index 0000000..dd54fc9 --- /dev/null +++ b/Runtime/Metadata/ShrinkNetworkRpc.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e430341a390625b4a90e3608f6bbc2f9 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Metadata/ShrinkNetworkTransportEvent.cs b/Runtime/Metadata/ShrinkNetworkTransportEvent.cs new file mode 100644 index 0000000..6e8ac12 --- /dev/null +++ b/Runtime/Metadata/ShrinkNetworkTransportEvent.cs @@ -0,0 +1,49 @@ +#nullable enable + +namespace ShrinkNetwork +{ + public enum ShrinkNetworkTransportEventType + { + Connected = 0, + Disconnected = 1, + Packet = 2 + } + + public sealed class ShrinkNetworkTransportEvent + { + public ShrinkNetworkTransportEventType Type; + public long SessionId; + public string RemoteAddress = string.Empty; + public byte[] PacketData = System.Array.Empty(); + + public static ShrinkNetworkTransportEvent Connected(long sessionId, string remoteAddress) + { + return new ShrinkNetworkTransportEvent + { + Type = ShrinkNetworkTransportEventType.Connected, + SessionId = sessionId, + RemoteAddress = remoteAddress + }; + } + + public static ShrinkNetworkTransportEvent Disconnected(long sessionId, string remoteAddress) + { + return new ShrinkNetworkTransportEvent + { + Type = ShrinkNetworkTransportEventType.Disconnected, + SessionId = sessionId, + RemoteAddress = remoteAddress + }; + } + + public static ShrinkNetworkTransportEvent Packet(long sessionId, byte[] packetData) + { + return new ShrinkNetworkTransportEvent + { + Type = ShrinkNetworkTransportEventType.Packet, + SessionId = sessionId, + PacketData = packetData + }; + } + } +} diff --git a/Runtime/Metadata/ShrinkNetworkTransportEvent.cs.meta b/Runtime/Metadata/ShrinkNetworkTransportEvent.cs.meta new file mode 100644 index 0000000..0221b7b --- /dev/null +++ b/Runtime/Metadata/ShrinkNetworkTransportEvent.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 0fbbbfca75a4ebd4c924c2cf328d549d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Routing.meta b/Runtime/Routing.meta new file mode 100644 index 0000000..6cd97d4 --- /dev/null +++ b/Runtime/Routing.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 050397bcd638e4e42952d69d28425c9a +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Routing/ShrinkNetworkGeneratedRegistry.cs b/Runtime/Routing/ShrinkNetworkGeneratedRegistry.cs new file mode 100644 index 0000000..753bf6c --- /dev/null +++ b/Runtime/Routing/ShrinkNetworkGeneratedRegistry.cs @@ -0,0 +1,81 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; + +namespace ShrinkNetwork +{ + [AttributeUsage(AttributeTargets.Assembly, AllowMultiple = true)] + public sealed class ShrinkNetworkMessageRegistryAttribute : Attribute + { + public ShrinkNetworkMessageRegistryAttribute(params Type[] messageTypes) + { + MessageTypes = messageTypes ?? Array.Empty(); + } + + public Type[] MessageTypes { get; } + } + + [AttributeUsage(AttributeTargets.Assembly, AllowMultiple = true)] + public sealed class ShrinkNetworkStaticSubscriberRegistryAttribute : Attribute + { + public ShrinkNetworkStaticSubscriberRegistryAttribute(params Type[] subscriberTypes) + { + SubscriberTypes = subscriberTypes ?? Array.Empty(); + } + + public Type[] SubscriberTypes { get; } + } + + internal static class ShrinkNetworkGeneratedRegistry + { + public static IReadOnlyList GetAttributedMessageTypes() + => GetAssemblyRegisteredTypes(attribute => attribute.MessageTypes); + + public static IReadOnlyList GetStaticSubscriberTypes() + => GetAssemblyRegisteredTypes(attribute => attribute.SubscriberTypes); + + public static void RegisterAll(ShrinkNetworkService service) + { + if (service == null) + throw new ArgumentNullException(nameof(service)); + + ShrinkNetworkRegHelper.RegisterAttributedMessages(service, GetAttributedMessageTypes()); + ShrinkNetworkRegHelper.RegisterStaticHandlers(service, GetStaticSubscriberTypes()); + } + + private static IReadOnlyList GetAssemblyRegisteredTypes(Func selector) + where TAttribute : Attribute + { + var types = new List(); + var seen = new HashSet(); + foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies()) + { + object[] attributes; + try + { + attributes = assembly.GetCustomAttributes(typeof(TAttribute), false); + } + catch + { + continue; + } + + foreach (var attribute in attributes.OfType()) + { + foreach (var registeredType in selector(attribute) ?? Array.Empty()) + { + if (registeredType == null || !seen.Add(registeredType)) + continue; + + types.Add(registeredType); + } + } + } + + return types; + } + } +} diff --git a/Runtime/Routing/ShrinkNetworkGeneratedRegistry.cs.meta b/Runtime/Routing/ShrinkNetworkGeneratedRegistry.cs.meta new file mode 100644 index 0000000..93c80f5 --- /dev/null +++ b/Runtime/Routing/ShrinkNetworkGeneratedRegistry.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c8557e972f759fd4cac12069d5ac43e8 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Routing/ShrinkNetworkMessageRegistry.cs b/Runtime/Routing/ShrinkNetworkMessageRegistry.cs new file mode 100644 index 0000000..31ebdf3 --- /dev/null +++ b/Runtime/Routing/ShrinkNetworkMessageRegistry.cs @@ -0,0 +1,60 @@ +#nullable enable + +using System; +using System.Collections.Generic; + +namespace ShrinkNetwork +{ + public sealed class ShrinkNetworkMessageRegistry + { + private readonly Dictionary _opcodeToMeta = new(); + private readonly Dictionary _typeToMeta = new(); + + public void Register(int opcode, string? route = null) where TMessage : IShrinkNetworkMessage + => Register(typeof(TMessage), opcode, route); + + public void Register(Type messageType, int opcode, string? route = null) + { + if (messageType == null) + throw new ArgumentNullException(nameof(messageType)); + if (!typeof(IShrinkNetworkMessage).IsAssignableFrom(messageType)) + throw new ArgumentException($"Type {messageType.FullName} is not a network message.", nameof(messageType)); + if (_opcodeToMeta.ContainsKey(opcode)) + throw new InvalidOperationException($"Opcode {opcode} is already registered."); + if (_typeToMeta.ContainsKey(messageType)) + throw new InvalidOperationException($"Message type {messageType.FullName} is already registered."); + + var meta = new ShrinkNetworkMessageMeta(opcode, messageType, route); + _opcodeToMeta.Add(opcode, meta); + _typeToMeta.Add(messageType, meta); + } + + public bool TryGetMeta(int opcode, out ShrinkNetworkMessageMeta? meta) => _opcodeToMeta.TryGetValue(opcode, out meta); + + public bool TryGetMeta(Type type, out ShrinkNetworkMessageMeta? meta) => _typeToMeta.TryGetValue(type, out meta); + + public ShrinkNetworkMessageMeta GetMeta() where TMessage : IShrinkNetworkMessage => GetMeta(typeof(TMessage)); + + public ShrinkNetworkMessageMeta GetMeta(Type type) + { + if (_typeToMeta.TryGetValue(type, out var meta)) + return meta; + + throw new KeyNotFoundException($"Message type {type.FullName} is not registered."); + } + } + + public sealed class ShrinkNetworkMessageMeta + { + public int Opcode { get; } + public Type MessageType { get; } + public string? Route { get; } + + public ShrinkNetworkMessageMeta(int opcode, Type messageType, string? route) + { + Opcode = opcode; + MessageType = messageType; + Route = route; + } + } +} diff --git a/Runtime/Routing/ShrinkNetworkMessageRegistry.cs.meta b/Runtime/Routing/ShrinkNetworkMessageRegistry.cs.meta new file mode 100644 index 0000000..f1a835f --- /dev/null +++ b/Runtime/Routing/ShrinkNetworkMessageRegistry.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 3ba2cec4b827a0a4b86f42a16fd10393 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Routing/ShrinkNetworkRegHelper.cs b/Runtime/Routing/ShrinkNetworkRegHelper.cs new file mode 100644 index 0000000..c2b3543 --- /dev/null +++ b/Runtime/Routing/ShrinkNetworkRegHelper.cs @@ -0,0 +1,210 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.Reflection; +using Cysharp.Threading.Tasks; + +namespace ShrinkNetwork +{ + public static class ShrinkNetworkRegHelper + { + private static readonly MethodInfo AwaitUniTaskResponseMethod = + typeof(ShrinkNetworkRegHelper).GetMethod(nameof(AwaitUniTaskResponse), BindingFlags.NonPublic | BindingFlags.Static)!; + + public static void RegisterAttributedMessages(ShrinkNetworkService service) + { + RegisterAttributedMessages(service, ShrinkNetworkGeneratedRegistry.GetAttributedMessageTypes()); + } + + public static void RegisterAttributedMessages(ShrinkNetworkService service, IEnumerable messageTypes) + { + if (service == null) + throw new ArgumentNullException(nameof(service)); + if (messageTypes == null) + throw new ArgumentNullException(nameof(messageTypes)); + + foreach (var type in messageTypes) + { + if (type == null || !typeof(IShrinkNetworkMessage).IsAssignableFrom(type)) + continue; + + var attr = type.GetCustomAttribute(false); + if (attr == null || service.MessageRegistry.TryGetMeta(type, out _)) + continue; + + service.RegisterMessage(type, attr.Opcode, attr.Route); + } + } + + public static void RegisterStaticHandlers(ShrinkNetworkService service) + => RegisterStaticHandlers(service, ShrinkNetworkGeneratedRegistry.GetStaticSubscriberTypes()); + + public static void RegisterStaticHandlers(ShrinkNetworkService service, IEnumerable subscriberTypes) + { + if (service == null) + throw new ArgumentNullException(nameof(service)); + if (subscriberTypes == null) + throw new ArgumentNullException(nameof(subscriberTypes)); + + foreach (var type in subscriberTypes) + { + if (type == null || type.GetCustomAttribute(false) == null) + continue; + + ScanMethodsAndRegister(service, null, type, + type.GetMethods(BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic)); + } + } + + public static void RegisterHandlers(ShrinkNetworkService service, object target) + => RegisterHandlersInternal(service, target); + + private static void RegisterHandlersInternal(ShrinkNetworkService service, object? target) + { + if (target == null) + return; + + var ownerType = target.GetType(); + if (ownerType.GetCustomAttribute(false) == null) + return; + + ScanMethodsAndRegister(service, target, ownerType, + ownerType.GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)); + } + + private static void ScanMethodsAndRegister(ShrinkNetworkService service, object? target, Type ownerType, MethodInfo[] methods) + { + foreach (var method in methods) + { + if (target != null && method.IsStatic) + continue; + var attributes = method.GetCustomAttributes(typeof(ShrinkNetworkSubscribeAttribute), false); + if (attributes.Length == 0) + continue; + var subscribeAttr = (ShrinkNetworkSubscribeAttribute)attributes[0]; + var requirement = new ShrinkNetworkPermissionRequirement(subscribeAttr.Authority, subscribeAttr.Permission); + + if (!TryParseHandlerSignature(method, out var hasContext, out var messageType, out var responseType)) + { + ShrinkNetworkLogger.Warn($"[ShrinkNetwork] Invalid handler signature: {ownerType.FullName}.{method.Name}"); + continue; + } + + var resolvedMessageType = messageType!; + EnsureMessageRegistered(service, resolvedMessageType); + if (responseType != null) + EnsureMessageRegistered(service, responseType); + + if (responseType == null) + { + service.RegisterHandler(resolvedMessageType, (context, message) => + InvokeMessageHandler(target, method, hasContext, context, message), requirement); + continue; + } + + var resolvedResponseType = responseType!; + service.RegisterRequestHandler(resolvedMessageType, resolvedResponseType, (context, request) => + InvokeRequestHandler(target, method, hasContext, resolvedResponseType, context, request), requirement); + } + } + + private static bool TryParseHandlerSignature(MethodInfo method, out bool hasContext, out Type? messageType, out Type? responseType) + { + hasContext = false; + messageType = null; + responseType = null; + + var parameters = method.GetParameters(); + if (parameters.Length == 1) + { + messageType = parameters[0].ParameterType; + } + else if (parameters.Length == 2 && parameters[0].ParameterType == typeof(ShrinkNetworkContext)) + { + hasContext = true; + messageType = parameters[1].ParameterType; + } + else + { + return false; + } + + if (!typeof(IShrinkNetworkMessage).IsAssignableFrom(messageType)) + return false; + + var returnType = method.ReturnType; + if (returnType == typeof(void) || returnType == typeof(UniTask)) + return true; + + if (typeof(IShrinkNetworkResponse).IsAssignableFrom(returnType)) + { + responseType = returnType; + return true; + } + + if (returnType.IsGenericType && returnType.GetGenericTypeDefinition() == typeof(UniTask<>)) + { + var resultType = returnType.GetGenericArguments()[0]; + if (typeof(IShrinkNetworkResponse).IsAssignableFrom(resultType)) + { + responseType = resultType; + return true; + } + } + + return false; + } + + private static void EnsureMessageRegistered(ShrinkNetworkService service, Type messageType) + { + if (service.MessageRegistry.TryGetMeta(messageType, out _)) + return; + + var attr = messageType.GetCustomAttribute(false); + if (attr == null) + throw new InvalidOperationException($"Message type {messageType.FullName} must declare [ShrinkNetworkMessage]."); + + service.RegisterMessage(messageType, attr.Opcode, attr.Route); + } + + private static async UniTask InvokeMessageHandler(object? target, MethodInfo method, bool hasContext, + ShrinkNetworkContext context, object message) + { + var args = hasContext ? new object?[] { context, message } : new object?[] { message }; + var result = method.Invoke(target, args); + if (method.ReturnType == typeof(UniTask)) + await (UniTask)(result ?? throw new InvalidOperationException($"Handler returned null UniTask: {method.DeclaringType?.FullName}.{method.Name}")); + } + + private static async UniTask InvokeRequestHandler(object? target, MethodInfo method, bool hasContext, + Type responseType, ShrinkNetworkContext context, object request) + { + var args = hasContext ? new object?[] { context, request } : new object?[] { request }; + var result = method.Invoke(target, args); + if (result == null) + return null; + + if (responseType.IsInstanceOfType(result)) + return result; + + if (method.ReturnType.IsGenericType && method.ReturnType.GetGenericTypeDefinition() == typeof(UniTask<>)) + return await AwaitUniTaskResponseObject(result, responseType); + + return result; + } + + private static UniTask AwaitUniTaskResponseObject(object taskObject, Type responseType) + { + return (UniTask)AwaitUniTaskResponseMethod + .MakeGenericMethod(responseType) + .Invoke(null, new[] { taskObject })!; + } + + private static async UniTask AwaitUniTaskResponse(UniTask task) + where TResponse : class, IShrinkNetworkResponse + { + return await task; + } + } +} diff --git a/Runtime/Routing/ShrinkNetworkRegHelper.cs.meta b/Runtime/Routing/ShrinkNetworkRegHelper.cs.meta new file mode 100644 index 0000000..fd59909 --- /dev/null +++ b/Runtime/Routing/ShrinkNetworkRegHelper.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: fc0b4f8cf9205df4caedc2cb1dc8b6bf +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Routing/ShrinkNetworkRouter.cs b/Runtime/Routing/ShrinkNetworkRouter.cs new file mode 100644 index 0000000..52f8a4f --- /dev/null +++ b/Runtime/Routing/ShrinkNetworkRouter.cs @@ -0,0 +1,152 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using Cysharp.Threading.Tasks; + +namespace ShrinkNetwork +{ + public sealed class ShrinkNetworkRouter + { + private sealed class MessageHandlerRegistration + { + public ShrinkNetworkPermissionRequirement Requirement; + public Func Handler = null!; + } + + private sealed class RequestHandlerRegistration + { + public Type ResponseType = null!; + public ShrinkNetworkPermissionRequirement Requirement; + public Func> Handler = null!; + } + + private readonly Dictionary _messageHandlers = new(); + private readonly Dictionary _requestHandlers = new(); + + public void RegisterHandler(Func handler, + ShrinkNetworkPermissionRequirement requirement = default) + where TMessage : IShrinkNetworkMessage + { + RegisterHandler(typeof(TMessage), (context, message) => handler(context, (TMessage)message), requirement); + } + + public void RegisterHandler(Type messageType, Func handler, + ShrinkNetworkPermissionRequirement requirement = default) + { + if (messageType == null) + throw new ArgumentNullException(nameof(messageType)); + if (handler == null) + throw new ArgumentNullException(nameof(handler)); + if (_messageHandlers.ContainsKey(messageType) || _requestHandlers.ContainsKey(messageType)) + throw new InvalidOperationException($"Handler already exists for {messageType.FullName}."); + + _messageHandlers.Add(messageType, new MessageHandlerRegistration + { + Requirement = requirement, + Handler = handler + }); + } + + public void RegisterRequestHandler(Func> handler, + ShrinkNetworkPermissionRequirement requirement = default) + where TRequest : IShrinkNetworkRequest + where TResponse : class, IShrinkNetworkResponse + { + RegisterRequestHandler(typeof(TRequest), typeof(TResponse), + async (context, message) => await handler(context, (TRequest)message), requirement); + } + + public void RegisterRequestHandler(Type requestType, Type responseType, + Func> handler, + ShrinkNetworkPermissionRequirement requirement = default) + { + if (requestType == null) + throw new ArgumentNullException(nameof(requestType)); + if (responseType == null) + throw new ArgumentNullException(nameof(responseType)); + if (handler == null) + throw new ArgumentNullException(nameof(handler)); + if (_messageHandlers.ContainsKey(requestType) || _requestHandlers.ContainsKey(requestType)) + throw new InvalidOperationException($"Handler already exists for {requestType.FullName}."); + + _requestHandlers.Add(requestType, new RequestHandlerRegistration + { + ResponseType = responseType, + Requirement = requirement, + Handler = handler + }); + } + + public async UniTask DispatchAsync(ShrinkNetworkContext context, object message, Type messageType) + { + if (context.Packet.Kind == ShrinkNetworkPacketKind.Request && + _requestHandlers.TryGetValue(messageType, out var requestHandler)) + { + if (!ShrinkNetworkPermissionValidator.IsAllowed(context.Session, requestHandler.Requirement)) + { + context.Service.ReportPermissionDenied(messageType); + var denied = CreatePermissionDeniedResponse(requestHandler.ResponseType, requestHandler.Requirement); + await context.Service.SendResponseAsync(context.Session, denied, requestHandler.ResponseType, + context.Packet.RequestToken, context.Packet.Route); + return true; + } + + try + { + var response = await requestHandler.Handler(context, message); + if (response is IShrinkNetworkResponse networkResponse) + await context.Service.SendResponseAsync(context.Session, networkResponse, requestHandler.ResponseType, + context.Packet.RequestToken, context.Packet.Route); + } + catch (Exception ex) + { + context.Service.ReportHandlerException(messageType, ex); + var errorResponse = CreateErrorResponse(requestHandler.ResponseType, ex); + await context.Service.SendResponseAsync(context.Session, errorResponse, requestHandler.ResponseType, + context.Packet.RequestToken, context.Packet.Route); + } + + return true; + } + + if (_messageHandlers.TryGetValue(messageType, out var messageHandler)) + { + if (!ShrinkNetworkPermissionValidator.IsAllowed(context.Session, messageHandler.Requirement)) + { + context.Service.ReportPermissionDenied(messageType); + ShrinkNetworkLogger.Warn($"[ShrinkNetwork] Permission denied for message {messageType.FullName} on session {context.Session.SessionId}."); + return true; + } + + await messageHandler.Handler(context, message); + return true; + } + + return false; + } + + private static IShrinkNetworkResponse CreateErrorResponse(Type responseType, Exception ex) + { + if (Activator.CreateInstance(responseType) is not IShrinkNetworkResponse response) + throw new InvalidOperationException($"Response type {responseType.FullName} cannot be instantiated.", ex); + + response.ErrorCode = ShrinkRpcErrorCode.HandlerException; + response.ErrorMessage = ex.Message; + return response; + } + + private static IShrinkNetworkResponse CreatePermissionDeniedResponse(Type responseType, + ShrinkNetworkPermissionRequirement requirement) + { + if (Activator.CreateInstance(responseType) is not IShrinkNetworkResponse response) + throw new InvalidOperationException($"Response type {responseType.FullName} cannot be instantiated."); + + response.ErrorCode = ShrinkRpcErrorCode.PermissionDenied; + response.ErrorMessage = string.IsNullOrEmpty(requirement.Permission) + ? $"Permission denied. Authority={requirement.Authority}" + : $"Permission denied. Authority={requirement.Authority}, Permission={requirement.Permission}"; + return response; + } + } +} diff --git a/Runtime/Routing/ShrinkNetworkRouter.cs.meta b/Runtime/Routing/ShrinkNetworkRouter.cs.meta new file mode 100644 index 0000000..9b0f8c8 --- /dev/null +++ b/Runtime/Routing/ShrinkNetworkRouter.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 89e99852277437b439ff7891cd6aa8d5 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Serialization.meta b/Runtime/Serialization.meta new file mode 100644 index 0000000..af5d010 --- /dev/null +++ b/Runtime/Serialization.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: a5ee1de0ef0ca6a4ba49cc8d900808f5 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Serialization/IShrinkNetworkSerializer.cs b/Runtime/Serialization/IShrinkNetworkSerializer.cs new file mode 100644 index 0000000..9b2a032 --- /dev/null +++ b/Runtime/Serialization/IShrinkNetworkSerializer.cs @@ -0,0 +1,11 @@ +using System; + +namespace ShrinkNetwork +{ + public interface IShrinkNetworkSerializer + { + byte[] Serialize(object value); + object Deserialize(byte[] payload, Type type); + T Deserialize(byte[] payload); + } +} diff --git a/Runtime/Serialization/IShrinkNetworkSerializer.cs.meta b/Runtime/Serialization/IShrinkNetworkSerializer.cs.meta new file mode 100644 index 0000000..123ada9 --- /dev/null +++ b/Runtime/Serialization/IShrinkNetworkSerializer.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8f1353960f8123d4f96a4815b3627051 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Serialization/ShrinkJsonNetworkSerializer.cs b/Runtime/Serialization/ShrinkJsonNetworkSerializer.cs new file mode 100644 index 0000000..5561e0d --- /dev/null +++ b/Runtime/Serialization/ShrinkJsonNetworkSerializer.cs @@ -0,0 +1,29 @@ +#nullable enable + +using System; +using System.Text; +using Newtonsoft.Json; + +namespace ShrinkNetwork +{ + public sealed class ShrinkJsonNetworkSerializer : IShrinkNetworkSerializer + { + private static readonly JsonSerializerSettings Settings = new() + { + NullValueHandling = NullValueHandling.Ignore, + DefaultValueHandling = DefaultValueHandling.Include, + Formatting = Formatting.None + }; + + public byte[] Serialize(object value) + => Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(value, Settings)); + + public object Deserialize(byte[] payload, Type type) + => JsonConvert.DeserializeObject(Encoding.UTF8.GetString(payload), type, Settings) + ?? throw new JsonSerializationException($"Failed to deserialize payload into {type.FullName}."); + + public T Deserialize(byte[] payload) + => JsonConvert.DeserializeObject(Encoding.UTF8.GetString(payload), Settings) + ?? throw new JsonSerializationException($"Failed to deserialize payload into {typeof(T).FullName}."); + } +} diff --git a/Runtime/Serialization/ShrinkJsonNetworkSerializer.cs.meta b/Runtime/Serialization/ShrinkJsonNetworkSerializer.cs.meta new file mode 100644 index 0000000..0dfc041 --- /dev/null +++ b/Runtime/Serialization/ShrinkJsonNetworkSerializer.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f2a0f828927e72b4b9d1542cc8a24b8c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Serialization/ShrinkMessagePackNetworkSerializer.cs b/Runtime/Serialization/ShrinkMessagePackNetworkSerializer.cs new file mode 100644 index 0000000..4a7bbdb --- /dev/null +++ b/Runtime/Serialization/ShrinkMessagePackNetworkSerializer.cs @@ -0,0 +1,150 @@ +#nullable enable + +using System; +using System.Linq; +using System.Reflection; + +namespace ShrinkNetwork +{ + public sealed class ShrinkMessagePackNetworkSerializer : IShrinkNetworkSerializer + { + private readonly MethodInfo _serializeMethod; + private readonly MethodInfo _deserializeMethod; + private readonly object? _serializerOptions; + + public ShrinkMessagePackNetworkSerializer() + { + var serializerType = Type.GetType("MessagePack.MessagePackSerializer, MessagePack"); + if (serializerType == null) + { + throw new InvalidOperationException( + "MessagePack assembly was not found. Please install MessagePack-CSharp before using ShrinkMessagePackNetworkSerializer."); + } + + _serializerOptions = ResolveSerializerOptions(serializerType.Assembly); + + var serializeMethod = serializerType + .GetMethods(BindingFlags.Public | BindingFlags.Static) + .FirstOrDefault(m => + { + if (m.Name != "Serialize") + return false; + var parameters = m.GetParameters(); + return parameters.Length >= 2 && + parameters[0].ParameterType == typeof(Type) && + parameters[1].ParameterType == typeof(object); + }); + + var deserializeMethod = serializerType + .GetMethods(BindingFlags.Public | BindingFlags.Static) + .FirstOrDefault(m => + { + if (m.Name != "Deserialize") + return false; + var parameters = m.GetParameters(); + return parameters.Length >= 2 && + parameters[0].ParameterType == typeof(Type) && + (parameters[1].ParameterType == typeof(byte[]) || + parameters[1].ParameterType == typeof(ReadOnlyMemory)); + }); + + if (serializeMethod == null || deserializeMethod == null) + throw new MissingMethodException("MessagePack serialize/deserialize API not found."); + + _serializeMethod = serializeMethod; + _deserializeMethod = deserializeMethod; + } + + public byte[] Serialize(object value) + { + if (value == null) + return Array.Empty(); + + var parameters = BuildParameters(_serializeMethod, value.GetType(), value, _serializerOptions); + return (byte[])_serializeMethod.Invoke(null, parameters)!; + } + + public object Deserialize(byte[] payload, Type type) + { + payload ??= Array.Empty(); + var parameters = BuildParameters(_deserializeMethod, type, payload, _serializerOptions); + return _deserializeMethod.Invoke(null, parameters) + ?? throw new InvalidOperationException($"MessagePack returned null for type {type.FullName}."); + } + + public T Deserialize(byte[] payload) + { + return (T)Deserialize(payload, typeof(T)); + } + + private static object?[] BuildParameters(MethodInfo method, Type type, object valueOrBytes, object? serializerOptions) + { + var parameters = method.GetParameters(); + var args = new object?[parameters.Length]; + + if (parameters.Length > 0) + args[0] = type; + if (parameters.Length > 1) + args[1] = ConvertPrimaryArgument(parameters[1].ParameterType, valueOrBytes); + + for (var i = 2; i < parameters.Length; i++) + { + args[i] = ResolveAdditionalArgument(parameters[i], serializerOptions); + } + + return args; + } + + private static object? ResolveAdditionalArgument(ParameterInfo parameter, object? serializerOptions) + { + if (serializerOptions != null && parameter.ParameterType.IsInstanceOfType(serializerOptions)) + return serializerOptions; + + return parameter.HasDefaultValue + ? parameter.DefaultValue + : GetDefault(parameter.ParameterType); + } + + private static object ConvertPrimaryArgument(Type parameterType, object value) + { + if (parameterType == typeof(ReadOnlyMemory) && value is byte[] bytes) + return new ReadOnlyMemory(bytes); + + return value; + } + + private static object? ResolveSerializerOptions(Assembly serializerAssembly) + { + var contractlessResolverType = serializerAssembly.GetType("MessagePack.Resolvers.ContractlessStandardResolver"); + if (contractlessResolverType != null) + { + var optionsField = contractlessResolverType.GetField("Options", + BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static); + var options = optionsField?.GetValue(null); + if (options != null) + return options; + + var instanceField = contractlessResolverType.GetField("Instance", + BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static); + var instance = instanceField?.GetValue(null); + if (instance != null) + { + var optionsType = serializerAssembly.GetType("MessagePack.MessagePackSerializerOptions"); + var standardProperty = optionsType?.GetProperty("Standard", BindingFlags.Public | BindingFlags.Static); + var standardOptions = standardProperty?.GetValue(null); + var withResolverMethod = optionsType?.GetMethod("WithResolver", BindingFlags.Public | BindingFlags.Instance); + var resolvedOptions = withResolverMethod?.Invoke(standardOptions, new[] { instance }); + if (resolvedOptions != null) + return resolvedOptions; + } + } + + return null; + } + + private static object? GetDefault(Type type) + { + return type.IsValueType ? Activator.CreateInstance(type) : null; + } + } +} diff --git a/Runtime/Serialization/ShrinkMessagePackNetworkSerializer.cs.meta b/Runtime/Serialization/ShrinkMessagePackNetworkSerializer.cs.meta new file mode 100644 index 0000000..cfd6e06 --- /dev/null +++ b/Runtime/Serialization/ShrinkMessagePackNetworkSerializer.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 88c6239e9fc049f43b2999d7311eec1d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/ShrinkNetwork.Runtime.asmdef b/Runtime/ShrinkNetwork.Runtime.asmdef new file mode 100644 index 0000000..cb5be45 --- /dev/null +++ b/Runtime/ShrinkNetwork.Runtime.asmdef @@ -0,0 +1,16 @@ +{ + "name": "ShrinkNetwork.Runtime", + "rootNamespace": "ShrinkNetwork", + "references": [ + "UniTask", + "Newtonsoft.Json" + ], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": true, + "overrideReferences": false, + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/Runtime/ShrinkNetwork.Runtime.asmdef.meta b/Runtime/ShrinkNetwork.Runtime.asmdef.meta new file mode 100644 index 0000000..03d222a --- /dev/null +++ b/Runtime/ShrinkNetwork.Runtime.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 74abbc4dbafd9ac449e816fe51c954b9 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Transport.meta b/Runtime/Transport.meta new file mode 100644 index 0000000..0781f43 --- /dev/null +++ b/Runtime/Transport.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 1acf353c64b11ae49a4410edde41da6a +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Transport/Abstractions.meta b/Runtime/Transport/Abstractions.meta new file mode 100644 index 0000000..5cc7521 --- /dev/null +++ b/Runtime/Transport/Abstractions.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 4eee09e255b4c294983b08e0d13eb8e1 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Transport/Abstractions/IShrinkNetworkAsyncTransport.cs b/Runtime/Transport/Abstractions/IShrinkNetworkAsyncTransport.cs new file mode 100644 index 0000000..f10e203 --- /dev/null +++ b/Runtime/Transport/Abstractions/IShrinkNetworkAsyncTransport.cs @@ -0,0 +1,9 @@ +using Cysharp.Threading.Tasks; + +namespace ShrinkNetwork +{ + public interface IShrinkNetworkAsyncTransport : IShrinkNetworkTransport + { + UniTask SendAsync(long sessionId, byte[] packetData); + } +} diff --git a/Runtime/Transport/Abstractions/IShrinkNetworkAsyncTransport.cs.meta b/Runtime/Transport/Abstractions/IShrinkNetworkAsyncTransport.cs.meta new file mode 100644 index 0000000..62588f9 --- /dev/null +++ b/Runtime/Transport/Abstractions/IShrinkNetworkAsyncTransport.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c9741e558eb992d4eae93726109250eb +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Transport/Abstractions/IShrinkNetworkTransport.cs b/Runtime/Transport/Abstractions/IShrinkNetworkTransport.cs new file mode 100644 index 0000000..1d4a8ce --- /dev/null +++ b/Runtime/Transport/Abstractions/IShrinkNetworkTransport.cs @@ -0,0 +1,47 @@ +#nullable enable +using System; +using System.Security.Authentication; + +namespace ShrinkNetwork +{ + public interface IShrinkNetworkTransport + { + bool IsStarted { get; } + + event Action OnEvent; + + void Start(); + void Stop(); + void Send(long sessionId, byte[] packetData); + } + + public interface IShrinkNetworkSessionControlTransport + { + bool DisconnectSession(long sessionId, string? reason = null); + } + + public sealed class ShrinkTcpTlsOptions + { + public bool Enabled { get; set; } + public string? TargetHost { get; set; } + public bool AllowInvalidServerCertificate { get; set; } + public string? ServerCertificatePath { get; set; } + public string? ServerCertificatePassword { get; set; } + public bool CheckCertificateRevocation { get; set; } = true; + public SslProtocols EnabledProtocols { get; set; } = SslProtocols.None; + + public ShrinkTcpTlsOptions Clone() + { + return new ShrinkTcpTlsOptions + { + Enabled = Enabled, + TargetHost = TargetHost, + AllowInvalidServerCertificate = AllowInvalidServerCertificate, + ServerCertificatePath = ServerCertificatePath, + ServerCertificatePassword = ServerCertificatePassword, + CheckCertificateRevocation = CheckCertificateRevocation, + EnabledProtocols = EnabledProtocols + }; + } + } +} diff --git a/Runtime/Transport/Abstractions/IShrinkNetworkTransport.cs.meta b/Runtime/Transport/Abstractions/IShrinkNetworkTransport.cs.meta new file mode 100644 index 0000000..b40e0cc --- /dev/null +++ b/Runtime/Transport/Abstractions/IShrinkNetworkTransport.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 427412ffd76529b418e1446e059516b5 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Transport/Kcp.meta b/Runtime/Transport/Kcp.meta new file mode 100644 index 0000000..10bc393 --- /dev/null +++ b/Runtime/Transport/Kcp.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 369ef8103f7eb084f9235125630ff1b6 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Transport/Kcp/ShrinkKcpClientTransport.cs b/Runtime/Transport/Kcp/ShrinkKcpClientTransport.cs new file mode 100644 index 0000000..3653244 --- /dev/null +++ b/Runtime/Transport/Kcp/ShrinkKcpClientTransport.cs @@ -0,0 +1,278 @@ +using System; +using System.Net; +using System.Net.Sockets; +using System.Security.Cryptography; +using System.Threading; +using System.Threading.Tasks; +using Cysharp.Threading.Tasks; + +namespace ShrinkNetwork +{ + public sealed class ShrinkKcpClientTransport : IShrinkNetworkAsyncTransport + { + private readonly string _host; + private readonly int _port; + private readonly long _sessionId; + private readonly ShrinkKcpTransportOptions _options; + private readonly object _syncRoot = new(); + + private CancellationTokenSource _cts; + private UdpClient _udpClient; + private ShrinkKcpPeer _peer; + private long _handshakeNonce; + private int _started; + private int _connected; + + public ShrinkKcpClientTransport(string host, int port, ShrinkKcpTransportOptions options = null, long sessionId = 1) + { + if (string.IsNullOrWhiteSpace(host)) + throw new ArgumentException("Host cannot be empty.", nameof(host)); + if (port <= 0 || port > 65535) + throw new ArgumentOutOfRangeException(nameof(port)); + + _host = host.Trim(); + _port = port; + _sessionId = sessionId; + _options = (options ?? new ShrinkKcpTransportOptions()).Clone(); + _options.Validate(); + } + + public bool IsStarted => Volatile.Read(ref _started) == 1; + + public event Action OnEvent; + + public void Start() + { + if (Interlocked.Exchange(ref _started, 1) == 1) + return; + + _cts = new CancellationTokenSource(); + _udpClient = new UdpClient(AddressFamily.InterNetwork); + _udpClient.Client.ReceiveBufferSize = _options.ReceiveBufferSize; + _udpClient.Connect(_host, _port); + + _handshakeNonce = CreateHandshakeNonce(); + ReceiveLoopAsync(_cts.Token).Forget(); + HandshakeLoopAsync(_cts.Token).Forget(); + UpdateLoopAsync(_cts.Token).Forget(); + } + + public void Stop() + { + var remote = $"{_host}:{_port}"; + var wasConnected = Interlocked.Exchange(ref _connected, 0) == 1; + + if (Interlocked.Exchange(ref _started, 0) == 0) + return; + + try + { + var peer = _peer; + if (peer != null) + SendDatagram(ShrinkKcpTransportProtocol.CreateDisconnect(peer.ConversationId)); + } + catch + { + } + + _cts?.Cancel(); + + lock (_syncRoot) + { + _peer?.Dispose(); + _peer = null; + + try + { + _udpClient?.Close(); + } + catch + { + } + + _udpClient = null; + } + + if (wasConnected) + OnEvent?.Invoke(ShrinkNetworkTransportEvent.Disconnected(_sessionId, remote)); + } + + public void Send(long sessionId, byte[] packetData) + { + SendAsync(sessionId, packetData).Forget(); + } + + public UniTask SendAsync(long sessionId, byte[] packetData) + { + if (!IsStarted) + throw new InvalidOperationException("Transport is not started."); + if (sessionId != _sessionId) + throw new InvalidOperationException($"Unsupported session id {sessionId}. This transport only supports {_sessionId}."); + if (Volatile.Read(ref _connected) != 1) + throw new InvalidOperationException("KCP client is not connected."); + + lock (_syncRoot) + { + _peer?.Send(packetData ?? Array.Empty()); + } + + return UniTask.CompletedTask; + } + + private async UniTaskVoid HandshakeLoopAsync(CancellationToken cancellationToken) + { + var startedAt = DateTime.UtcNow; + + try + { + while (!cancellationToken.IsCancellationRequested && Volatile.Read(ref _connected) == 0) + { + if ((DateTime.UtcNow - startedAt).TotalMilliseconds > _options.ConnectTimeoutMs) + throw new TimeoutException($"KCP connect timed out: {_host}:{_port}"); + + SendDatagram(ShrinkKcpTransportProtocol.CreateConnectRequest(_handshakeNonce, _options.ConversationId)); + await Task.Delay(_options.HandshakeRetryMs, cancellationToken); + } + } + catch (OperationCanceledException) + { + } + catch (Exception ex) + { + ShrinkNetworkLogger.Exception(ex); + ShrinkNetworkLogger.Error($"[ShrinkNetwork] KCP connect failed: {_host}:{_port} {ex.Message}"); + Stop(); + } + } + + private async UniTaskVoid ReceiveLoopAsync(CancellationToken cancellationToken) + { + try + { + while (!cancellationToken.IsCancellationRequested) + { + UdpReceiveResult result; + try + { + result = await _udpClient.ReceiveAsync(); + } + catch (ObjectDisposedException) + { + break; + } + catch (SocketException) when (!IsStarted) + { + break; + } + + HandleDatagram(result.Buffer, result.RemoteEndPoint); + } + } + catch (Exception ex) + { + ShrinkNetworkLogger.Exception(ex); + ShrinkNetworkLogger.Warn($"[ShrinkNetwork] KCP receive loop ended: {ex.Message}"); + } + finally + { + Stop(); + } + } + + private async UniTaskVoid UpdateLoopAsync(CancellationToken cancellationToken) + { + try + { + while (!cancellationToken.IsCancellationRequested) + { + var shouldStop = false; + + lock (_syncRoot) + { + _peer?.Tick(packet => OnEvent?.Invoke(ShrinkNetworkTransportEvent.Packet(_sessionId, packet))); + + if (_peer != null && + Volatile.Read(ref _connected) == 1 && + DateTime.UtcNow.Ticks - _peer.LastReceiveUtcTicks > TimeSpan.FromMilliseconds(_options.IdleTimeoutMs).Ticks) + { + ShrinkNetworkLogger.Warn($"[ShrinkNetwork] KCP idle timeout: {_host}:{_port}"); + shouldStop = true; + } + } + + if (shouldStop) + { + Stop(); + return; + } + + await Task.Delay(_options.UpdateIntervalMs, cancellationToken); + } + } + catch (OperationCanceledException) + { + } + } + + private void HandleDatagram(byte[] datagram, IPEndPoint remoteEndPoint) + { + if (datagram == null || datagram.Length == 0) + return; + + if (ShrinkKcpTransportProtocol.TryReadConnectAccept(datagram, out var nonce, out var conversationId)) + { + if (nonce != _handshakeNonce) + return; + + lock (_syncRoot) + { + if (_peer == null) + { + _peer = new ShrinkKcpPeer(conversationId, _options, + payload => SendDatagram(ShrinkKcpTransportProtocol.CreateDataPacket(conversationId, payload))); + } + } + + if (Interlocked.Exchange(ref _connected, 1) == 0) + OnEvent?.Invoke(ShrinkNetworkTransportEvent.Connected(_sessionId, remoteEndPoint.ToString())); + + return; + } + + if (ShrinkKcpTransportProtocol.TryReadDisconnect(datagram, out var disconnectedConversationId)) + { + if (_peer != null && _peer.ConversationId == disconnectedConversationId) + Stop(); + return; + } + + if (!ShrinkKcpTransportProtocol.TryReadDataPacket(datagram, out var dataConversationId, out var payloadOffset, + out var payloadLength)) + return; + + lock (_syncRoot) + { + if (_peer == null || _peer.ConversationId != dataConversationId) + return; + + _peer.Input(datagram, payloadOffset, payloadLength, + packet => OnEvent?.Invoke(ShrinkNetworkTransportEvent.Packet(_sessionId, packet))); + } + } + + private void SendDatagram(byte[] datagram) + { + if (_udpClient == null) + return; + + _udpClient.Send(datagram, datagram.Length); + } + + private static long CreateHandshakeNonce() + { + Span buffer = stackalloc byte[sizeof(long)]; + RandomNumberGenerator.Fill(buffer); + return BitConverter.ToInt64(buffer); + } + } +} diff --git a/Runtime/Transport/Kcp/ShrinkKcpClientTransport.cs.meta b/Runtime/Transport/Kcp/ShrinkKcpClientTransport.cs.meta new file mode 100644 index 0000000..dc57afc --- /dev/null +++ b/Runtime/Transport/Kcp/ShrinkKcpClientTransport.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d3a2f267e77939e4d94652f195495f68 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Transport/Kcp/ShrinkKcpPeer.cs b/Runtime/Transport/Kcp/ShrinkKcpPeer.cs new file mode 100644 index 0000000..1962589 --- /dev/null +++ b/Runtime/Transport/Kcp/ShrinkKcpPeer.cs @@ -0,0 +1,209 @@ +using System; +using System.Runtime.InteropServices; +using kcp; + +namespace ShrinkNetwork +{ + internal sealed unsafe class ShrinkKcpPeer : IDisposable + { + private readonly object _syncRoot = new(); + private readonly Action _sendDatagram; + private readonly byte[] _receiveBuffer; + private readonly GCHandle _selfHandle; + private IKCPCB* _kcp; + private bool _disposed; + private uint _nextUpdateTime; + + public ShrinkKcpPeer(uint conversationId, ShrinkKcpTransportOptions options, Action sendDatagram) + { + if (conversationId == 0) + throw new ArgumentOutOfRangeException(nameof(conversationId)); + if (options == null) + throw new ArgumentNullException(nameof(options)); + if (sendDatagram == null) + throw new ArgumentNullException(nameof(sendDatagram)); + + options.Validate(); + + ConversationId = conversationId; + _sendDatagram = sendDatagram; + _receiveBuffer = new byte[options.MaxMessageSize]; + _selfHandle = GCHandle.Alloc(this); + _kcp = KCP_INTERFACE.ikcp_create(conversationId, (void*)GCHandle.ToIntPtr(_selfHandle)); + if (_kcp == null) + throw new InvalidOperationException("KCP create failed."); + + KCP_INTERFACE.ikcp_setmtu(_kcp, options.Mtu); + KCP_INTERFACE.ikcp_wndsize(_kcp, options.SendWindow, options.ReceiveWindow); + KCP_INTERFACE.ikcp_nodelay(_kcp, options.NoDelay ? 1 : 0, options.Interval, options.Resend, + options.DisableCongestionControl ? 1 : 0); + KCP_INTERFACE.ikcp_setoutput(_kcp, &HandleOutput); + + LastReceiveUtcTicks = DateTime.UtcNow.Ticks; + _nextUpdateTime = GetNowMs(); + } + + public uint ConversationId { get; } + public long LastReceiveUtcTicks { get; private set; } + + public void Input(byte[] datagram, int offset, int length, Action onPacket) + { + if (datagram == null) + throw new ArgumentNullException(nameof(datagram)); + if (offset < 0 || offset > datagram.Length) + throw new ArgumentOutOfRangeException(nameof(offset)); + if (length < 0 || offset + length > datagram.Length) + throw new ArgumentOutOfRangeException(nameof(length)); + if (length == 0) + return; + if (onPacket == null) + throw new ArgumentNullException(nameof(onPacket)); + + lock (_syncRoot) + { + EnsureNotDisposed(); + + fixed (byte* basePtr = datagram) + { + var result = KCP_INTERFACE.ikcp_input(_kcp, basePtr + offset, length); + if (result < 0) + throw new InvalidOperationException($"KCP input failed. Result={result}"); + } + + LastReceiveUtcTicks = DateTime.UtcNow.Ticks; + UpdateInternal(GetNowMs()); + DrainReceiveQueue(onPacket); + } + } + + public void Send(byte[] payload) + { + if (payload == null) + throw new ArgumentNullException(nameof(payload)); + if (payload.Length == 0) + return; + + lock (_syncRoot) + { + EnsureNotDisposed(); + + fixed (byte* payloadPtr = payload) + { + var result = KCP_INTERFACE.ikcp_send(_kcp, payloadPtr, payload.Length); + if (result < 0) + throw new InvalidOperationException($"KCP send failed. Result={result}"); + } + + UpdateInternal(GetNowMs()); + } + } + + public void Tick(Action onPacket) + { + if (onPacket == null) + throw new ArgumentNullException(nameof(onPacket)); + + lock (_syncRoot) + { + if (_disposed) + return; + + var now = GetNowMs(); + if (now < _nextUpdateTime) + return; + + UpdateInternal(now); + DrainReceiveQueue(onPacket); + } + } + + public void Dispose() + { + lock (_syncRoot) + { + if (_disposed) + return; + + _disposed = true; + if (_kcp != null) + { + KCP_INTERFACE.ikcp_release(_kcp); + _kcp = null; + } + + if (_selfHandle.IsAllocated) + _selfHandle.Free(); + } + } + + private void EnsureNotDisposed() + { + if (_disposed || _kcp == null) + throw new ObjectDisposedException(nameof(ShrinkKcpPeer)); + } + + private void UpdateInternal(uint now) + { + KCP_INTERFACE.ikcp_update(_kcp, now); + _nextUpdateTime = KCP_INTERFACE.ikcp_check(_kcp, now); + } + + private void DrainReceiveQueue(Action onPacket) + { + while (true) + { + var peekSize = KCP_INTERFACE.ikcp_peeksize(_kcp); + if (peekSize < 0) + return; + + if (peekSize > _receiveBuffer.Length) + throw new InvalidOperationException( + $"KCP message too large. Size={peekSize}, Buffer={_receiveBuffer.Length}"); + + var received = 0; + fixed (byte* receivePtr = _receiveBuffer) + { + received = KCP_INTERFACE.ikcp_recv(_kcp, receivePtr, peekSize); + } + + if (received <= 0) + return; + + LastReceiveUtcTicks = DateTime.UtcNow.Ticks; + var packet = new byte[received]; + Buffer.BlockCopy(_receiveBuffer, 0, packet, 0, received); + onPacket(packet); + } + } + + private static uint GetNowMs() + { + // KCP expects a wrapping uint32 millisecond clock; Environment.TickCount + // matches that contract and still works on Unity's .NET Framework target. + return unchecked((uint)Environment.TickCount); + } + + private int HandleOutputInternal(byte* buffer, int length) + { + if (length <= 0) + return 0; + + var datagram = new byte[length]; + Marshal.Copy((IntPtr)buffer, datagram, 0, length); + _sendDatagram(datagram); + return 0; + } + + private static int HandleOutput(byte* buffer, int length, IKCPCB* kcp, void* user) + { + if (user == null) + return -1; + + var handle = GCHandle.FromIntPtr((IntPtr)user); + if (handle.Target is not ShrinkKcpPeer peer) + return -1; + + return peer.HandleOutputInternal(buffer, length); + } + } +} diff --git a/Runtime/Transport/Kcp/ShrinkKcpPeer.cs.meta b/Runtime/Transport/Kcp/ShrinkKcpPeer.cs.meta new file mode 100644 index 0000000..d0c47aa --- /dev/null +++ b/Runtime/Transport/Kcp/ShrinkKcpPeer.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4b2e3e60a75261f46afb0b89e32d921c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Transport/Kcp/ShrinkKcpServerTransport.cs b/Runtime/Transport/Kcp/ShrinkKcpServerTransport.cs new file mode 100644 index 0000000..0b3abc4 --- /dev/null +++ b/Runtime/Transport/Kcp/ShrinkKcpServerTransport.cs @@ -0,0 +1,315 @@ +#nullable enable +using System; +using System.Buffers.Binary; +using System.Collections.Concurrent; +using System.Linq; +using System.Net; +using System.Net.Sockets; +using System.Security.Cryptography; +using System.Threading; +using System.Threading.Tasks; +using Cysharp.Threading.Tasks; + +namespace ShrinkNetwork +{ + public sealed class ShrinkKcpServerTransport : IShrinkNetworkAsyncTransport, IShrinkNetworkSessionControlTransport + { + private sealed class SessionState : IDisposable + { + public long SessionId; + public uint ConversationId; + public IPEndPoint RemoteEndPoint = null!; + public ShrinkKcpPeer Peer = null!; + public readonly object SyncRoot = new(); + + public void Dispose() + { + Peer.Dispose(); + } + } + + private readonly ConcurrentDictionary _sessions = new(); + private readonly ConcurrentDictionary _endpointToSessionId = new(); + private readonly ConcurrentDictionary _conversationToSessionId = new(); + private readonly ShrinkKcpTransportOptions _options; + private readonly IPAddress _listeningAddress; + private readonly int _port; + private CancellationTokenSource? _cts; + private UdpClient? _udpClient; + private long _sessionIdGenerator; + private int _conversationIdGenerator; + + public ShrinkKcpServerTransport(IPAddress listeningAddress, int port, ShrinkKcpTransportOptions? options = null) + { + if (port <= 0 || port > 65535) + throw new ArgumentOutOfRangeException(nameof(port)); + + _listeningAddress = listeningAddress ?? throw new ArgumentNullException(nameof(listeningAddress)); + _port = port; + _options = (options ?? new ShrinkKcpTransportOptions()).Clone(); + _options.Validate(); + } + + public bool IsStarted { get; private set; } + + public event Action? OnEvent; + + public void Start() + { + if (IsStarted) + return; + + _udpClient = new UdpClient(new IPEndPoint(_listeningAddress, _port)); + _udpClient.Client.ReceiveBufferSize = _options.ReceiveBufferSize; + _cts = new CancellationTokenSource(); + IsStarted = true; + ReceiveLoopAsync(_cts.Token).Forget(); + UpdateLoopAsync(_cts.Token).Forget(); + } + + public void Stop() + { + if (!IsStarted) + return; + + IsStarted = false; + _cts?.Cancel(); + + try + { + _udpClient?.Close(); + } + catch + { + } + + _udpClient = null; + + foreach (var state in _sessions.Values.ToArray()) + RemoveSession(state.SessionId, true); + + _endpointToSessionId.Clear(); + _conversationToSessionId.Clear(); + } + + public void Send(long sessionId, byte[] packetData) + { + SendAsync(sessionId, packetData).Forget(); + } + + public bool DisconnectSession(long sessionId, string? reason = null) + { + if (!_sessions.ContainsKey(sessionId)) + return false; + + RemoveSession(sessionId, true); + if (!string.IsNullOrWhiteSpace(reason)) + ShrinkNetworkLogger.Warn($"[ShrinkNetwork][KCP-Server] Disconnect session {sessionId}: {reason}"); + return true; + } + + public UniTask SendAsync(long sessionId, byte[] packetData) + { + if (!_sessions.TryGetValue(sessionId, out var session)) + throw new InvalidOperationException($"Session {sessionId} is not connected."); + + lock (session.SyncRoot) + { + session.Peer.Send(packetData ?? Array.Empty()); + } + + return UniTask.CompletedTask; + } + + private async UniTaskVoid ReceiveLoopAsync(CancellationToken cancellationToken) + { + try + { + while (!cancellationToken.IsCancellationRequested) + { + UdpReceiveResult result; + try + { + if (_udpClient == null) + break; + + result = await _udpClient.ReceiveAsync(); + } + catch (ObjectDisposedException) + { + break; + } + catch (SocketException) when (!IsStarted) + { + break; + } + + HandleDatagram(result.Buffer, result.RemoteEndPoint); + } + } + catch (Exception ex) + { + ShrinkNetworkLogger.Exception(ex); + ShrinkNetworkLogger.Warn($"[ShrinkNetwork] KCP server receive loop ended: {ex.Message}"); + } + finally + { + Stop(); + } + } + + private async UniTaskVoid UpdateLoopAsync(CancellationToken cancellationToken) + { + try + { + while (!cancellationToken.IsCancellationRequested) + { + var sessions = _sessions.Values.ToArray(); + foreach (var session in sessions) + { + var shouldDisconnect = false; + lock (session.SyncRoot) + { + session.Peer.Tick(packet => OnEvent?.Invoke( + ShrinkNetworkTransportEvent.Packet(session.SessionId, packet))); + + if (DateTime.UtcNow.Ticks - session.Peer.LastReceiveUtcTicks > + TimeSpan.FromMilliseconds(_options.IdleTimeoutMs).Ticks) + { + shouldDisconnect = true; + } + } + + if (shouldDisconnect) + RemoveSession(session.SessionId, true); + } + + await Task.Delay(_options.UpdateIntervalMs, cancellationToken); + } + } + catch (OperationCanceledException) + { + } + } + + private void HandleDatagram(byte[] datagram, IPEndPoint remoteEndPoint) + { + if (datagram == null || datagram.Length == 0) + return; + + if (ShrinkKcpTransportProtocol.TryReadConnectRequest(datagram, out var nonce, out _)) + { + HandleConnectRequest(remoteEndPoint, nonce); + return; + } + + if (ShrinkKcpTransportProtocol.TryReadDisconnect(datagram, out var disconnectedConversationId)) + { + if (_conversationToSessionId.TryGetValue(disconnectedConversationId, out var disconnectedSessionId)) + { + if (_sessions.TryGetValue(disconnectedSessionId, out var disconnectedSession) && + string.Equals(disconnectedSession.RemoteEndPoint.ToString(), remoteEndPoint.ToString(), StringComparison.Ordinal)) + { + RemoveSession(disconnectedSessionId, false); + } + } + return; + } + + if (!ShrinkKcpTransportProtocol.TryReadDataPacket(datagram, out var conversationId, out var payloadOffset, + out var payloadLength)) + return; + + if (!_conversationToSessionId.TryGetValue(conversationId, out var sessionId)) + return; + + if (!_sessions.TryGetValue(sessionId, out var session)) + return; + + if (!string.Equals(session.RemoteEndPoint.ToString(), remoteEndPoint.ToString(), StringComparison.Ordinal)) + return; + + lock (session.SyncRoot) + { + session.Peer.Input(datagram, payloadOffset, payloadLength, + packet => OnEvent?.Invoke(ShrinkNetworkTransportEvent.Packet(session.SessionId, packet))); + } + } + + private void HandleConnectRequest(IPEndPoint remoteEndPoint, long nonce) + { + var endpointKey = remoteEndPoint.ToString(); + if (_endpointToSessionId.TryGetValue(endpointKey, out var existingSessionId) && + _sessions.TryGetValue(existingSessionId, out var existingSession)) + { + SendDatagram(remoteEndPoint, + ShrinkKcpTransportProtocol.CreateConnectAccept(nonce, existingSession.ConversationId)); + return; + } + + var sessionId = Interlocked.Increment(ref _sessionIdGenerator); + var conversationId = NextConversationId(); + var session = new SessionState + { + SessionId = sessionId, + ConversationId = conversationId, + RemoteEndPoint = remoteEndPoint, + Peer = new ShrinkKcpPeer(conversationId, _options, + payload => SendDatagram(remoteEndPoint, + ShrinkKcpTransportProtocol.CreateDataPacket(conversationId, payload))) + }; + + _sessions[sessionId] = session; + _endpointToSessionId[endpointKey] = sessionId; + _conversationToSessionId[conversationId] = sessionId; + SendDatagram(remoteEndPoint, ShrinkKcpTransportProtocol.CreateConnectAccept(nonce, conversationId)); + OnEvent?.Invoke(ShrinkNetworkTransportEvent.Connected(sessionId, endpointKey)); + } + + private void RemoveSession(long sessionId, bool notifyDisconnect) + { + if (!_sessions.TryRemove(sessionId, out var session)) + return; + + _endpointToSessionId.TryRemove(session.RemoteEndPoint.ToString(), out _); + _conversationToSessionId.TryRemove(session.ConversationId, out _); + + if (notifyDisconnect) + { + try + { + SendDatagram(session.RemoteEndPoint, + ShrinkKcpTransportProtocol.CreateDisconnect(session.ConversationId)); + } + catch + { + } + } + + session.Dispose(); + OnEvent?.Invoke(ShrinkNetworkTransportEvent.Disconnected(sessionId, session.RemoteEndPoint.ToString())); + } + + private void SendDatagram(IPEndPoint remoteEndPoint, byte[] datagram) + { + if (_udpClient == null) + return; + + _udpClient.Send(datagram, datagram.Length, remoteEndPoint); + } + + private uint NextConversationId() + { + Span buffer = stackalloc byte[sizeof(uint)]; + while (true) + { + RandomNumberGenerator.Fill(buffer); + var next = BinaryPrimitives.ReadUInt32LittleEndian(buffer); + if (next == 0) + continue; + if (!_conversationToSessionId.ContainsKey(next)) + return next; + } + } + } +} diff --git a/Runtime/Transport/Kcp/ShrinkKcpServerTransport.cs.meta b/Runtime/Transport/Kcp/ShrinkKcpServerTransport.cs.meta new file mode 100644 index 0000000..fc21da0 --- /dev/null +++ b/Runtime/Transport/Kcp/ShrinkKcpServerTransport.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f98f0b2007fe1944bacbe69c189f034d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Transport/Kcp/ShrinkKcpTransportOptions.cs b/Runtime/Transport/Kcp/ShrinkKcpTransportOptions.cs new file mode 100644 index 0000000..a78e2c4 --- /dev/null +++ b/Runtime/Transport/Kcp/ShrinkKcpTransportOptions.cs @@ -0,0 +1,69 @@ +using System; + +namespace ShrinkNetwork +{ + public sealed class ShrinkKcpTransportOptions + { + public uint ConversationId { get; set; } + public int Mtu { get; set; } = 1200; + public int SendWindow { get; set; } = 256; + public int ReceiveWindow { get; set; } = 256; + public bool NoDelay { get; set; } = true; + public int Interval { get; set; } = 10; + public int Resend { get; set; } = 2; + public bool DisableCongestionControl { get; set; } = true; + public int UpdateIntervalMs { get; set; } = 10; + public int ConnectTimeoutMs { get; set; } = 5000; + public int HandshakeRetryMs { get; set; } = 1000; + public int IdleTimeoutMs { get; set; } = 15000; + public int ReceiveBufferSize { get; set; } = 64 * 1024; + public int MaxMessageSize { get; set; } = 64 * 1024; + + public void Validate() + { + if (Mtu < 576 || Mtu > 1400) + throw new ArgumentOutOfRangeException(nameof(Mtu), "MTU must be between 576 and 1400."); + if (SendWindow <= 0) + throw new ArgumentOutOfRangeException(nameof(SendWindow)); + if (ReceiveWindow <= 0) + throw new ArgumentOutOfRangeException(nameof(ReceiveWindow)); + if (Interval <= 0 || Interval > 5000) + throw new ArgumentOutOfRangeException(nameof(Interval)); + if (Resend < 0 || Resend > 2) + throw new ArgumentOutOfRangeException(nameof(Resend)); + if (UpdateIntervalMs <= 0 || UpdateIntervalMs > 5000) + throw new ArgumentOutOfRangeException(nameof(UpdateIntervalMs)); + if (ConnectTimeoutMs <= 0) + throw new ArgumentOutOfRangeException(nameof(ConnectTimeoutMs)); + if (HandshakeRetryMs <= 0) + throw new ArgumentOutOfRangeException(nameof(HandshakeRetryMs)); + if (IdleTimeoutMs <= 0) + throw new ArgumentOutOfRangeException(nameof(IdleTimeoutMs)); + if (ReceiveBufferSize <= 0) + throw new ArgumentOutOfRangeException(nameof(ReceiveBufferSize)); + if (MaxMessageSize <= 0) + throw new ArgumentOutOfRangeException(nameof(MaxMessageSize)); + } + + public ShrinkKcpTransportOptions Clone() + { + return new ShrinkKcpTransportOptions + { + ConversationId = ConversationId, + Mtu = Mtu, + SendWindow = SendWindow, + ReceiveWindow = ReceiveWindow, + NoDelay = NoDelay, + Interval = Interval, + Resend = Resend, + DisableCongestionControl = DisableCongestionControl, + UpdateIntervalMs = UpdateIntervalMs, + ConnectTimeoutMs = ConnectTimeoutMs, + HandshakeRetryMs = HandshakeRetryMs, + IdleTimeoutMs = IdleTimeoutMs, + ReceiveBufferSize = ReceiveBufferSize, + MaxMessageSize = MaxMessageSize + }; + } + } +} diff --git a/Runtime/Transport/Kcp/ShrinkKcpTransportOptions.cs.meta b/Runtime/Transport/Kcp/ShrinkKcpTransportOptions.cs.meta new file mode 100644 index 0000000..ab9f5f3 --- /dev/null +++ b/Runtime/Transport/Kcp/ShrinkKcpTransportOptions.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f95a6da969d7c904f94a5a2ebe17ba8e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Transport/Kcp/ShrinkKcpTransportProtocol.cs b/Runtime/Transport/Kcp/ShrinkKcpTransportProtocol.cs new file mode 100644 index 0000000..52a5529 --- /dev/null +++ b/Runtime/Transport/Kcp/ShrinkKcpTransportProtocol.cs @@ -0,0 +1,114 @@ +using System; +using System.Buffers.Binary; + +namespace ShrinkNetwork +{ + internal enum ShrinkKcpEnvelopeKind : byte + { + ConnectRequest = 1, + ConnectAccept = 2, + Disconnect = 3, + Data = 4 + } + + internal static class ShrinkKcpTransportProtocol + { + private const int KindSize = 1; + private const int ConversationSize = 4; + private const int NonceSize = 8; + + public static byte[] CreateConnectRequest(long nonce, uint requestedConversationId) + { + var buffer = new byte[KindSize + NonceSize + ConversationSize]; + buffer[0] = (byte)ShrinkKcpEnvelopeKind.ConnectRequest; + BinaryPrimitives.WriteInt64LittleEndian(buffer.AsSpan(1, NonceSize), nonce); + BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(1 + NonceSize, ConversationSize), requestedConversationId); + return buffer; + } + + public static bool TryReadConnectRequest(byte[] datagram, out long nonce, out uint requestedConversationId) + { + nonce = default; + requestedConversationId = default; + if (datagram == null || datagram.Length != KindSize + NonceSize + ConversationSize) + return false; + if (datagram[0] != (byte)ShrinkKcpEnvelopeKind.ConnectRequest) + return false; + + nonce = BinaryPrimitives.ReadInt64LittleEndian(datagram.AsSpan(1, NonceSize)); + requestedConversationId = BinaryPrimitives.ReadUInt32LittleEndian(datagram.AsSpan(1 + NonceSize, ConversationSize)); + return true; + } + + public static byte[] CreateConnectAccept(long nonce, uint conversationId) + { + var buffer = new byte[KindSize + NonceSize + ConversationSize]; + buffer[0] = (byte)ShrinkKcpEnvelopeKind.ConnectAccept; + BinaryPrimitives.WriteInt64LittleEndian(buffer.AsSpan(1, NonceSize), nonce); + BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(1 + NonceSize, ConversationSize), conversationId); + return buffer; + } + + public static bool TryReadConnectAccept(byte[] datagram, out long nonce, out uint conversationId) + { + nonce = default; + conversationId = default; + if (datagram == null || datagram.Length != KindSize + NonceSize + ConversationSize) + return false; + if (datagram[0] != (byte)ShrinkKcpEnvelopeKind.ConnectAccept) + return false; + + nonce = BinaryPrimitives.ReadInt64LittleEndian(datagram.AsSpan(1, NonceSize)); + conversationId = BinaryPrimitives.ReadUInt32LittleEndian(datagram.AsSpan(1 + NonceSize, ConversationSize)); + return true; + } + + public static byte[] CreateDisconnect(uint conversationId) + { + var buffer = new byte[KindSize + ConversationSize]; + buffer[0] = (byte)ShrinkKcpEnvelopeKind.Disconnect; + BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(1, ConversationSize), conversationId); + return buffer; + } + + public static bool TryReadDisconnect(byte[] datagram, out uint conversationId) + { + conversationId = default; + if (datagram == null || datagram.Length != KindSize + ConversationSize) + return false; + if (datagram[0] != (byte)ShrinkKcpEnvelopeKind.Disconnect) + return false; + + conversationId = BinaryPrimitives.ReadUInt32LittleEndian(datagram.AsSpan(1, ConversationSize)); + return true; + } + + public static byte[] CreateDataPacket(uint conversationId, byte[] payload) + { + payload ??= Array.Empty(); + + var buffer = new byte[KindSize + ConversationSize + payload.Length]; + buffer[0] = (byte)ShrinkKcpEnvelopeKind.Data; + BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(1, ConversationSize), conversationId); + if (payload.Length > 0) + Buffer.BlockCopy(payload, 0, buffer, KindSize + ConversationSize, payload.Length); + return buffer; + } + + public static bool TryReadDataPacket(byte[] datagram, out uint conversationId, out int payloadOffset, out int payloadLength) + { + conversationId = default; + payloadOffset = default; + payloadLength = default; + if (datagram == null || datagram.Length < KindSize + ConversationSize) + return false; + if (datagram[0] != (byte)ShrinkKcpEnvelopeKind.Data) + return false; + + conversationId = BinaryPrimitives.ReadUInt32LittleEndian(datagram.AsSpan(1, ConversationSize)); + payloadOffset = KindSize + ConversationSize; + payloadLength = datagram.Length - payloadOffset; + return payloadLength >= 0; + } + } +} diff --git a/Runtime/Transport/Kcp/ShrinkKcpTransportProtocol.cs.meta b/Runtime/Transport/Kcp/ShrinkKcpTransportProtocol.cs.meta new file mode 100644 index 0000000..57b3188 --- /dev/null +++ b/Runtime/Transport/Kcp/ShrinkKcpTransportProtocol.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: fdba195453eed5943bd86e3a1b8358f7 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Transport/Loopback.meta b/Runtime/Transport/Loopback.meta new file mode 100644 index 0000000..d24cabb --- /dev/null +++ b/Runtime/Transport/Loopback.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 88da155e143f26c44b97c25bcddc003a +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Transport/Loopback/ShrinkLoopbackTransport.cs b/Runtime/Transport/Loopback/ShrinkLoopbackTransport.cs new file mode 100644 index 0000000..256b70b --- /dev/null +++ b/Runtime/Transport/Loopback/ShrinkLoopbackTransport.cs @@ -0,0 +1,77 @@ +#nullable enable +using System; +using System.Collections.Generic; +using Cysharp.Threading.Tasks; + +namespace ShrinkNetwork +{ + public sealed class ShrinkLoopbackTransport : IShrinkNetworkAsyncTransport, IShrinkNetworkSessionControlTransport + { + private readonly HashSet _openedSessions = new(); + private ShrinkLoopbackTransport? _peer; + + public bool IsStarted { get; private set; } + + public event Action? OnEvent; + + public void LinkPeer(ShrinkLoopbackTransport peer) + { + _peer = peer; + if (peer._peer != this) + peer.LinkPeer(this); + } + + public void Start() + { + IsStarted = true; + } + + public void Stop() + { + IsStarted = false; + } + + public void OpenSession(long sessionId, string remoteAddress = "loopback") + { + _openedSessions.Add(sessionId); + _peer?._openedSessions.Add(sessionId); + OnEvent?.Invoke(ShrinkNetworkTransportEvent.Connected(sessionId, remoteAddress)); + _peer?.OnEvent?.Invoke(ShrinkNetworkTransportEvent.Connected(sessionId, remoteAddress)); + } + + public void CloseSession(long sessionId, string remoteAddress = "loopback") + { + _openedSessions.Remove(sessionId); + _peer?._openedSessions.Remove(sessionId); + OnEvent?.Invoke(ShrinkNetworkTransportEvent.Disconnected(sessionId, remoteAddress)); + _peer?.OnEvent?.Invoke(ShrinkNetworkTransportEvent.Disconnected(sessionId, remoteAddress)); + } + + public bool DisconnectSession(long sessionId, string? reason = null) + { + if (!_openedSessions.Contains(sessionId)) + return false; + + CloseSession(sessionId); + return true; + } + + public void Send(long sessionId, byte[] packetData) + { + SendAsync(sessionId, packetData).Forget(); + } + + public UniTask SendAsync(long sessionId, byte[] packetData) + { + if (!IsStarted) + throw new InvalidOperationException("Transport has not been started."); + if (_peer == null) + throw new InvalidOperationException("Peer transport is not linked."); + if (!_openedSessions.Contains(sessionId)) + throw new InvalidOperationException($"Session {sessionId} is not open."); + + _peer.OnEvent?.Invoke(ShrinkNetworkTransportEvent.Packet(sessionId, packetData)); + return UniTask.CompletedTask; + } + } +} diff --git a/Runtime/Transport/Loopback/ShrinkLoopbackTransport.cs.meta b/Runtime/Transport/Loopback/ShrinkLoopbackTransport.cs.meta new file mode 100644 index 0000000..21e0983 --- /dev/null +++ b/Runtime/Transport/Loopback/ShrinkLoopbackTransport.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9311f3622af8377419ba8273a873ee28 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Transport/Tcp.meta b/Runtime/Transport/Tcp.meta new file mode 100644 index 0000000..7e54e9b --- /dev/null +++ b/Runtime/Transport/Tcp.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: c231f4f8533ee0c4381cc5a35929621d +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Transport/Tcp/ShrinkTcpClientTransport.cs b/Runtime/Transport/Tcp/ShrinkTcpClientTransport.cs new file mode 100644 index 0000000..896eb0e --- /dev/null +++ b/Runtime/Transport/Tcp/ShrinkTcpClientTransport.cs @@ -0,0 +1,243 @@ +#nullable enable +using System; +using System.Buffers.Binary; +using System.IO; +using System.Net.Sockets; +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; +using System.Threading; +using Cysharp.Threading.Tasks; + +namespace ShrinkNetwork +{ + public sealed class ShrinkTcpClientTransport : IShrinkNetworkAsyncTransport + { + private readonly string _host; + private readonly int _port; + private readonly long _sessionId; + private readonly int _maxPacketSize; + private readonly ShrinkTcpTlsOptions? _tlsOptions; + private readonly SemaphoreSlim _sendLock = new(1, 1); + + private TcpClient? _client; + private Stream? _stream; + private CancellationTokenSource? _cts; + private int _started; + + public ShrinkTcpClientTransport(string host, int port, long sessionId = 1, int maxPacketSize = 64 * 1024, + ShrinkTcpTlsOptions? tlsOptions = null) + { + if (string.IsNullOrWhiteSpace(host)) + throw new ArgumentException("Host cannot be empty.", nameof(host)); + if (port <= 0 || port > 65535) + throw new ArgumentOutOfRangeException(nameof(port)); + if (maxPacketSize <= 0) + throw new ArgumentOutOfRangeException(nameof(maxPacketSize)); + + _host = host.Trim(); + _port = port; + _sessionId = sessionId; + _maxPacketSize = maxPacketSize; + _tlsOptions = tlsOptions?.Clone(); + } + + public bool IsStarted => Volatile.Read(ref _started) == 1; + + public event Action? OnEvent; + + public void Start() + { + if (Interlocked.Exchange(ref _started, 1) == 1) + return; + + _cts = new CancellationTokenSource(); + ConnectAsync(_cts.Token).Forget(); + } + + public void Stop() + { + if (Interlocked.Exchange(ref _started, 0) == 0) + return; + + _cts?.Cancel(); + + try + { + _stream?.Dispose(); + } + catch + { + } + + try + { + _client?.Close(); + } + catch + { + } + + _client = null; + _stream = null; + } + + public void Send(long sessionId, byte[] packetData) + { + SendAsync(sessionId, packetData).Forget(); + } + + public UniTask SendAsync(long sessionId, byte[] packetData) + { + if (!IsStarted) + throw new InvalidOperationException("Transport is not started."); + if (sessionId != _sessionId) + throw new InvalidOperationException($"Unsupported session id {sessionId}. This transport only supports {_sessionId}."); + if (_client == null || !_client.Connected || _stream == null) + throw new InvalidOperationException("TCP client is not connected."); + if ((packetData?.Length ?? 0) > _maxPacketSize) + throw new InvalidOperationException($"TCP packet is too large. Size={(packetData?.Length ?? 0)}, Limit={_maxPacketSize}."); + + return SendInternalAsync(_stream, _sendLock, packetData ?? Array.Empty(), + _cts?.Token ?? CancellationToken.None); + } + + private async UniTaskVoid ConnectAsync(CancellationToken cancellationToken) + { + try + { + _client = new TcpClient(); + _client.NoDelay = true; + await _client.ConnectAsync(_host, _port); + var stream = await CreateConnectedStreamAsync(_client, cancellationToken); + _stream = stream; + + if (!IsStarted) + { + stream.Dispose(); + _client.Close(); + return; + } + + var remote = _client.Client.RemoteEndPoint?.ToString() ?? $"{_host}:{_port}"; + OnEvent?.Invoke(ShrinkNetworkTransportEvent.Connected(_sessionId, remote)); + ReceiveLoopAsync(_client, stream, cancellationToken).Forget(); + } + catch (Exception ex) + { + ShrinkNetworkLogger.Exception(ex); + ShrinkNetworkLogger.Error($"[ShrinkNetwork] TCP connect failed: {_host}:{_port} {ex.Message}"); + Stop(); + } + } + + private async UniTask CreateConnectedStreamAsync(TcpClient client, CancellationToken cancellationToken) + { + Stream stream = client.GetStream(); + var tlsOptions = _tlsOptions; + if (tlsOptions == null || !tlsOptions.Enabled) + return stream; + + var targetHost = string.IsNullOrWhiteSpace(tlsOptions.TargetHost) ? _host : tlsOptions.TargetHost.Trim(); + var sslStream = new SslStream(stream, false, (_, _, _, errors) => + { + if (tlsOptions.AllowInvalidServerCertificate) + return true; + + return errors == SslPolicyErrors.None; + }); + await sslStream.AuthenticateAsClientAsync(targetHost, null, tlsOptions.EnabledProtocols, + tlsOptions.CheckCertificateRevocation); + return sslStream; + } + + private async UniTaskVoid ReceiveLoopAsync(TcpClient client, Stream stream, CancellationToken cancellationToken) + { + try + { + var lengthBuffer = new byte[4]; + + while (!cancellationToken.IsCancellationRequested) + { + await ReadExactlyAsync(stream, lengthBuffer, cancellationToken); + var length = BinaryPrimitives.ReadInt32LittleEndian(lengthBuffer); + if (length <= 0) + throw new InvalidDataException($"Invalid packet length: {length}"); + if (length > _maxPacketSize) + throw new InvalidDataException($"TCP packet length exceeded limit. Length={length}, Limit={_maxPacketSize}"); + + var payload = new byte[length]; + await ReadExactlyAsync(stream, payload, cancellationToken); + OnEvent?.Invoke(ShrinkNetworkTransportEvent.Packet(_sessionId, payload)); + } + } + catch (OperationCanceledException) + { + } + catch (IOException ex) when (IsExpectedDisconnect(ex)) + { + ShrinkNetworkLogger.Warn($"[ShrinkNetwork] TCP receive loop ended: {ex.Message}"); + } + catch (SocketException ex) when (IsExpectedDisconnect(ex)) + { + ShrinkNetworkLogger.Warn($"[ShrinkNetwork] TCP receive loop ended: {ex.Message}"); + } + catch (Exception ex) + { + ShrinkNetworkLogger.Exception(ex); + ShrinkNetworkLogger.Warn($"[ShrinkNetwork] TCP receive loop ended: {ex.Message}"); + } + finally + { + var remote = client.Client.RemoteEndPoint?.ToString() ?? $"{_host}:{_port}"; + OnEvent?.Invoke(ShrinkNetworkTransportEvent.Disconnected(_sessionId, remote)); + Stop(); + } + } + + private static bool IsExpectedDisconnect(IOException ex) + { + if (string.Equals(ex.Message, "Remote closed.", StringComparison.Ordinal)) + return true; + + return ex.InnerException is SocketException socketException && IsExpectedDisconnect(socketException); + } + + private static bool IsExpectedDisconnect(SocketException ex) + { + return ex.SocketErrorCode == SocketError.ConnectionReset || + ex.SocketErrorCode == SocketError.ConnectionAborted || + ex.SocketErrorCode == SocketError.Shutdown; + } + + private static async UniTask SendInternalAsync(Stream stream, SemaphoreSlim sendLock, byte[] packetData, + CancellationToken cancellationToken) + { + await sendLock.WaitAsync(cancellationToken); + try + { + var header = new byte[4]; + BinaryPrimitives.WriteInt32LittleEndian(header, packetData.Length); + await stream.WriteAsync(header, cancellationToken); + if (packetData.Length > 0) + await stream.WriteAsync(packetData, cancellationToken); + await stream.FlushAsync(cancellationToken); + } + finally + { + sendLock.Release(); + } + } + + private static async UniTask ReadExactlyAsync(Stream stream, byte[] buffer, CancellationToken cancellationToken) + { + var offset = 0; + while (offset < buffer.Length) + { + var read = await stream.ReadAsync(buffer.AsMemory(offset, buffer.Length - offset), cancellationToken); + if (read <= 0) + throw new IOException("Remote closed."); + offset += read; + } + } + } +} diff --git a/Runtime/Transport/Tcp/ShrinkTcpClientTransport.cs.meta b/Runtime/Transport/Tcp/ShrinkTcpClientTransport.cs.meta new file mode 100644 index 0000000..3e7d93b --- /dev/null +++ b/Runtime/Transport/Tcp/ShrinkTcpClientTransport.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d7ce61d2fb059e44a8aad25fc98d6b65 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Transport/Tcp/ShrinkTcpServerTransport.cs b/Runtime/Transport/Tcp/ShrinkTcpServerTransport.cs new file mode 100644 index 0000000..8ca183a --- /dev/null +++ b/Runtime/Transport/Tcp/ShrinkTcpServerTransport.cs @@ -0,0 +1,295 @@ +#nullable enable +using System; +using System.Buffers.Binary; +using System.Collections.Concurrent; +using System.IO; +using System.Net; +using System.Net.Security; +using System.Net.Sockets; +using System.Security.Cryptography.X509Certificates; +using System.Threading; +using System.Threading.Tasks; +using Cysharp.Threading.Tasks; + +namespace ShrinkNetwork +{ + public sealed class ShrinkTcpServerTransport : IShrinkNetworkAsyncTransport, IShrinkNetworkSessionControlTransport + { + private readonly ConcurrentDictionary _clients = new(); + private readonly ConcurrentDictionary _sendLocks = new(); + private readonly ConcurrentDictionary _streams = new(); + private readonly TcpListener _listener; + private readonly int _maxPacketSize; + private readonly ShrinkTcpTlsOptions? _tlsOptions; + private CancellationTokenSource? _cts; + private long _sessionIdGenerator; + private X509Certificate2? _serverCertificate; + + public ShrinkTcpServerTransport(IPAddress ipAddress, int port, int maxPacketSize = 64 * 1024, + ShrinkTcpTlsOptions? tlsOptions = null) + { + if (port <= 0 || port > 65535) + throw new ArgumentOutOfRangeException(nameof(port)); + if (maxPacketSize <= 0) + throw new ArgumentOutOfRangeException(nameof(maxPacketSize)); + + _listener = new TcpListener(ipAddress, port); + _maxPacketSize = maxPacketSize; + _tlsOptions = tlsOptions?.Clone(); + ListeningAddress = ipAddress; + ListeningPort = port; + } + + public IPAddress ListeningAddress { get; } + public int ListeningPort { get; } + public bool IsStarted { get; private set; } + + public event Action? OnEvent; + + public void Start() + { + if (IsStarted) + return; + + IsStarted = true; + _cts = new CancellationTokenSource(); + EnsureTlsCertificateLoaded(); + _listener.Start(); + _ = AcceptLoopAsync(_cts.Token); + } + + public void Stop() + { + if (!IsStarted) + return; + + IsStarted = false; + _cts?.Cancel(); + _listener.Stop(); + + foreach (var pair in _clients) + { + try + { + pair.Value.Close(); + } + catch + { + } + } + + _clients.Clear(); + foreach (var pair in _streams) + { + try + { + pair.Value.Dispose(); + } + catch + { + } + } + + _streams.Clear(); + + foreach (var pair in _sendLocks) + { + pair.Value.Dispose(); + } + + _sendLocks.Clear(); + } + + public void Send(long sessionId, byte[] packetData) + { + SendAsync(sessionId, packetData).Forget(); + } + + public bool DisconnectSession(long sessionId, string? reason = null) + { + if (!_clients.TryGetValue(sessionId, out var client)) + return false; + + try + { + if (_streams.TryRemove(sessionId, out var stream)) + stream.Dispose(); + client.Close(); + } + catch + { + } + + if (!string.IsNullOrWhiteSpace(reason)) + ShrinkNetworkLogger.Warn($"[ShrinkNetwork][TCP-Server] Disconnect session {sessionId}: {reason}"); + return true; + } + + public async UniTask SendAsync(long sessionId, byte[] packetData) + { + if (!_clients.TryGetValue(sessionId, out var client)) + throw new InvalidOperationException($"Session {sessionId} is not connected."); + if (!_streams.TryGetValue(sessionId, out var stream)) + throw new InvalidOperationException($"Session {sessionId} stream is not initialized."); + + if (!_sendLocks.TryGetValue(sessionId, out var sendLock)) + throw new InvalidOperationException($"Session {sessionId} send lock is not initialized."); + if ((packetData?.Length ?? 0) > _maxPacketSize) + throw new InvalidOperationException($"TCP packet is too large. Size={(packetData?.Length ?? 0)}, Limit={_maxPacketSize}."); + + await SendInternalAsync(stream, sendLock, packetData ?? Array.Empty(), CancellationToken.None); + } + + private async Task AcceptLoopAsync(CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + TcpClient client; + try + { + var acceptTask = _listener.AcceptTcpClientAsync(); + var completedTask = await Task.WhenAny(acceptTask, Task.Delay(Timeout.Infinite, cancellationToken)); + if (completedTask != acceptTask) + break; + + client = await acceptTask; + } + catch (OperationCanceledException) + { + break; + } + catch (ObjectDisposedException) + { + break; + } + catch + { + if (!IsStarted) + break; + continue; + } + + var sessionId = Interlocked.Increment(ref _sessionIdGenerator); + client.NoDelay = true; + var stream = await CreateServerStreamAsync(client, cancellationToken); + _clients[sessionId] = client; + _streams[sessionId] = stream; + _sendLocks[sessionId] = new SemaphoreSlim(1, 1); + var remoteAddress = client.Client.RemoteEndPoint?.ToString() ?? "unknown"; + OnEvent?.Invoke(ShrinkNetworkTransportEvent.Connected(sessionId, remoteAddress)); + _ = ReceiveLoopAsync(sessionId, client, stream, cancellationToken); + } + } + + private async Task CreateServerStreamAsync(TcpClient client, CancellationToken cancellationToken) + { + Stream stream = client.GetStream(); + var tlsOptions = _tlsOptions; + if (tlsOptions == null || !tlsOptions.Enabled) + return stream; + + if (_serverCertificate == null) + throw new InvalidOperationException("TCP TLS is enabled, but the server certificate is not loaded."); + + var sslStream = new SslStream(stream, false); + await sslStream.AuthenticateAsServerAsync(_serverCertificate, false, tlsOptions.EnabledProtocols, + tlsOptions.CheckCertificateRevocation); + return sslStream; + } + + private void EnsureTlsCertificateLoaded() + { + var tlsOptions = _tlsOptions; + if (tlsOptions == null || !tlsOptions.Enabled) + { + _serverCertificate = null; + return; + } + + if (string.IsNullOrWhiteSpace(tlsOptions.ServerCertificatePath)) + throw new InvalidOperationException("TCP TLS is enabled, but ServerCertificatePath is empty."); + + _serverCertificate = new X509Certificate2(tlsOptions.ServerCertificatePath, tlsOptions.ServerCertificatePassword); + } + + private async Task ReceiveLoopAsync(long sessionId, TcpClient client, Stream stream, CancellationToken cancellationToken) + { + try + { + var lengthBuffer = new byte[4]; + + while (!cancellationToken.IsCancellationRequested) + { + await ReadExactlyAsync(stream, lengthBuffer, cancellationToken); + var length = BinaryPrimitives.ReadInt32LittleEndian(lengthBuffer); + if (length <= 0) + throw new InvalidDataException($"Invalid packet length: {length}"); + if (length > _maxPacketSize) + throw new InvalidDataException($"TCP packet length exceeded limit. Length={length}, Limit={_maxPacketSize}"); + + var payload = new byte[length]; + await ReadExactlyAsync(stream, payload, cancellationToken); + OnEvent?.Invoke(ShrinkNetworkTransportEvent.Packet(sessionId, payload)); + } + } + catch (OperationCanceledException) + { + } + catch + { + } + finally + { + if (_clients.TryRemove(sessionId, out var removed)) + { + if (_streams.TryRemove(sessionId, out var ownedStream)) + ownedStream.Dispose(); + if (_sendLocks.TryRemove(sessionId, out var sendLock)) + sendLock.Dispose(); + + var remoteAddress = removed.Client.RemoteEndPoint?.ToString() ?? "unknown"; + try + { + removed.Close(); + } + catch + { + } + + OnEvent?.Invoke(ShrinkNetworkTransportEvent.Disconnected(sessionId, remoteAddress)); + } + } + } + + private static async Task SendInternalAsync(Stream stream, SemaphoreSlim sendLock, byte[] packetData, + CancellationToken cancellationToken) + { + await sendLock.WaitAsync(cancellationToken); + try + { + var header = new byte[4]; + BinaryPrimitives.WriteInt32LittleEndian(header, packetData.Length); + await stream.WriteAsync(header, cancellationToken); + if (packetData.Length > 0) + await stream.WriteAsync(packetData, cancellationToken); + await stream.FlushAsync(cancellationToken); + } + finally + { + sendLock.Release(); + } + } + + private static async Task ReadExactlyAsync(Stream stream, byte[] buffer, CancellationToken cancellationToken) + { + var offset = 0; + while (offset < buffer.Length) + { + var read = await stream.ReadAsync(buffer.AsMemory(offset, buffer.Length - offset), cancellationToken); + if (read <= 0) + throw new IOException("Remote closed."); + offset += read; + } + } + } +} diff --git a/Runtime/Transport/Tcp/ShrinkTcpServerTransport.cs.meta b/Runtime/Transport/Tcp/ShrinkTcpServerTransport.cs.meta new file mode 100644 index 0000000..86718d8 --- /dev/null +++ b/Runtime/Transport/Tcp/ShrinkTcpServerTransport.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 734d880602bc42eb8a14ae1e811e1114 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Transport/Tcp/ShrinkTcpTlsOptions.cs b/Runtime/Transport/Tcp/ShrinkTcpTlsOptions.cs new file mode 100644 index 0000000..0ad5e50 --- /dev/null +++ b/Runtime/Transport/Tcp/ShrinkTcpTlsOptions.cs @@ -0,0 +1,2 @@ +// 占位文件:兼容尚未刷新的 Unity / 模板工程引用。 +// 实际定义已移动到 Runtime/Transport/Abstractions/IShrinkNetworkTransport.cs。 diff --git a/Runtime/Transport/Tcp/ShrinkTcpTlsOptions.cs.meta b/Runtime/Transport/Tcp/ShrinkTcpTlsOptions.cs.meta new file mode 100644 index 0000000..599ae6f --- /dev/null +++ b/Runtime/Transport/Tcp/ShrinkTcpTlsOptions.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 253f494145698ce49ba13db227f91603 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Samples.meta b/Samples.meta new file mode 100644 index 0000000..e86b63d --- /dev/null +++ b/Samples.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: d7c276fde01583e44a941bdc77daad20 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Samples/PingClient.meta b/Samples/PingClient.meta new file mode 100644 index 0000000..9f491c5 --- /dev/null +++ b/Samples/PingClient.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 1c3d6394ac2212a4ca92238bce111ea4 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Samples/PingClient/ShrinkNetworkPingClientExample.cs b/Samples/PingClient/ShrinkNetworkPingClientExample.cs new file mode 100644 index 0000000..020476f --- /dev/null +++ b/Samples/PingClient/ShrinkNetworkPingClientExample.cs @@ -0,0 +1,167 @@ +using System; +using Cysharp.Threading.Tasks; +using ShrinkNetwork; +using UnityEngine; + +public enum ShrinkNetworkExampleTransportKind +{ + Tcp = 0, + Kcp = 1 +} + +[AddComponentMenu("ShrinkSDK/Shrink Network Ping Client Example")] +[DisallowMultipleComponent] +public sealed class ShrinkNetworkPingClientExample : MonoBehaviour +{ + [SerializeField] private ShrinkNetworkExampleTransportKind transportKind = ShrinkNetworkExampleTransportKind.Tcp; + [SerializeField] private string host = "127.0.0.1"; + [SerializeField] private int port = 17001; + [SerializeField] private bool connectOnStart = true; + [SerializeField] private bool sendPingOnConnected = true; + [SerializeField] private string pingText = "unity"; + + private ShrinkNetworkService _service; + private IShrinkNetworkTransport _transport; + private ShrinkNetworkSession _session; + private bool _isSending; + + private void Reset() + { + host = "127.0.0.1"; + port = transportKind == ShrinkNetworkExampleTransportKind.Kcp ? 17002 : 17001; + connectOnStart = true; + sendPingOnConnected = true; + pingText = "unity"; + } + + private void Start() + { + if (connectOnStart) + Connect(); + } + + private void OnDestroy() + { + Disconnect(); + } + + private void OnValidate() + { + if (port < 1) + port = 1; + else if (port > 65535) + port = 65535; + } + + [ContextMenu("Connect")] + public void Connect() + { + Disconnect(); + + _service = new ShrinkNetworkService( + new ShrinkMessagePackNetworkSerializer(), + new ShrinkNetworkMessageRegistry(), + new ShrinkNetworkRouter()); + _service.AutoRegisterAttributedMessages(); + _service.OnSessionConnected += HandleSessionConnected; + _service.OnSessionDisconnected += HandleSessionDisconnected; + + _transport = CreateTransport(); + _service.BindTransport(_transport); + + Debug.Log($"[ShrinkNetworkExample] Connecting to {transportKind} {host}:{port}"); + } + + [ContextMenu("Disconnect")] + public void Disconnect() + { + if (_service != null) + { + _service.OnSessionConnected -= HandleSessionConnected; + _service.OnSessionDisconnected -= HandleSessionDisconnected; + } + + _transport?.Stop(); + _transport = null; + _service = null; + _session = null; + _isSending = false; + } + + [ContextMenu("Send Ping")] + public void SendPing() + { + SendPingAsync().Forget(); + } + + public async UniTask SendPingAsync() + { + if (_session == null) + { + Debug.LogWarning("[ShrinkNetworkExample] No active session. Connect first."); + return; + } + + if (_isSending) + return; + + _isSending = true; + try + { + var response = await _session.RpcAsync(new PingRequest + { + Text = pingText + }); + Debug.Log($"[ShrinkNetworkExample] Ping reply: {response.Reply}"); + } + catch (Exception ex) + { + Debug.LogException(ex, this); + } + finally + { + _isSending = false; + } + } + + private IShrinkNetworkTransport CreateTransport() + { + return transportKind == ShrinkNetworkExampleTransportKind.Kcp + ? new ShrinkKcpClientTransport(host, port, new ShrinkKcpTransportOptions + { + Interval = 10, + UpdateIntervalMs = 10, + IdleTimeoutMs = 20000 + }) + : new ShrinkTcpClientTransport(host, port); + } + + private void HandleSessionConnected(ShrinkNetworkSession session) + { + _session = session; + Debug.Log($"[ShrinkNetworkExample] Connected: {session.RemoteAddress}"); + + if (sendPingOnConnected) + SendPingAsync().Forget(); + } + + private void HandleSessionDisconnected(ShrinkNetworkSession session) + { + if (_session == session) + _session = null; + + Debug.Log($"[ShrinkNetworkExample] Disconnected: {session.RemoteAddress}"); + } +} + +[ShrinkNetworkMessage(1001, "auth/ping")] +public sealed class PingRequest : IShrinkNetworkRequest +{ + public string Text { get; set; } = ""; +} + +[ShrinkNetworkMessage(1002, "auth/ping_response")] +public sealed class PingResponse : ShrinkRpcResponseBase +{ + public string Reply { get; set; } = ""; +} diff --git a/Samples/PingClient/ShrinkNetworkPingClientExample.cs.meta b/Samples/PingClient/ShrinkNetworkPingClientExample.cs.meta new file mode 100644 index 0000000..2a1efca --- /dev/null +++ b/Samples/PingClient/ShrinkNetworkPingClientExample.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 807c6f7bb5cd6bb44ab2ee58fc9796cb +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests.meta b/Tests.meta new file mode 100644 index 0000000..5caedd0 --- /dev/null +++ b/Tests.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: ebb3512e8166c5d419afec30a64df768 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Editor.meta b/Tests/Editor.meta new file mode 100644 index 0000000..d003cb8 --- /dev/null +++ b/Tests/Editor.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 3f8ea5f563bc3464dad27b5fa7c63adb +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Editor/ShrinkNetwork.Editor.Tests.asmdef b/Tests/Editor/ShrinkNetwork.Editor.Tests.asmdef new file mode 100644 index 0000000..eb7fa79 --- /dev/null +++ b/Tests/Editor/ShrinkNetwork.Editor.Tests.asmdef @@ -0,0 +1,23 @@ +{ + "name": "ShrinkNetwork.Editor.Tests", + "rootNamespace": "ShrinkNetwork.Editor.Tests", + "references": [ + "ShrinkNetwork.Runtime", + "ShrinkNetwork.Editor" + ], + "includePlatforms": [ + "Editor" + ], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "autoReferenced": false, + "defineConstraints": [ + "UNITY_INCLUDE_TESTS" + ], + "versionDefines": [], + "noEngineReferences": false, + "optionalUnityReferences": [ + "TestAssemblies" + ] +} diff --git a/Tests/Editor/ShrinkNetwork.Editor.Tests.asmdef.meta b/Tests/Editor/ShrinkNetwork.Editor.Tests.asmdef.meta new file mode 100644 index 0000000..4eb28b3 --- /dev/null +++ b/Tests/Editor/ShrinkNetwork.Editor.Tests.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: ff014bde3ee6e184cb3bb7ffbc42d917 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Editor/ShrinkNetworkSemanticScannerTests.cs b/Tests/Editor/ShrinkNetworkSemanticScannerTests.cs new file mode 100644 index 0000000..bde7bbc --- /dev/null +++ b/Tests/Editor/ShrinkNetworkSemanticScannerTests.cs @@ -0,0 +1,138 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using NUnit.Framework; +using ShrinkNetwork; + +namespace ShrinkNetwork.Editor.Tests +{ + public sealed class ShrinkNetworkSemanticScannerTests + { + [Test] + public void NamespaceCollisionIsRejectedInsteadOfSilentlyDroppingAContract() + { + var exception = Assert.Throws(() => + ShrinkNetworkSemanticScanner.ScanTypesForTests( + typeof(CollisionA.SharedMessage), + typeof(CollisionB.SharedMessage))); + + StringAssert.Contains("SharedMessage", exception!.Message); + StringAssert.Contains(typeof(CollisionA.SharedMessage).FullName, exception.Message); + StringAssert.Contains(typeof(CollisionB.SharedMessage).FullName, exception.Message); + } + + [Test] + public void PartialMessageUsesTheCompiledTypeAndIncludesEveryPart() + { + var result = ShrinkNetworkSemanticScanner.ScanTypesForTests(typeof(PartialMessage)); + + CollectionAssert.AreEqual( + new[] { "First", "Second" }, + result.Messages.Single().Properties.Select(property => property.Name)); + } + + [Test] + public void ComplexGenericPropertyKeepsItsSemanticTypeShape() + { + var result = ShrinkNetworkSemanticScanner.ScanTypesForTests( + typeof(ComplexGenericMessage), + typeof(SemanticPayload)); + + var property = result.Messages.Single().Properties.Single(); + Assert.AreEqual("Dictionary>", property.Type); + Assert.AreEqual("Payloads", property.Name); + Assert.AreEqual("SemanticPayload", result.DataTypes.Single().Name); + } + } + + public sealed class ShrinkDedicatedServerTemplateProtectionTests + { + private string _root = string.Empty; + private string _templateRoot = string.Empty; + private string _outputRoot = string.Empty; + + [SetUp] + public void SetUp() + { + _root = Path.Combine(Path.GetTempPath(), "ShrinkNetworkGeneratorTests", Guid.NewGuid().ToString("N")); + _templateRoot = Path.Combine(_root, "Template"); + _outputRoot = Path.Combine(_root, "Output"); + Directory.CreateDirectory(_templateRoot); + } + + [TearDown] + public void TearDown() + { + if (Directory.Exists(_root)) + Directory.Delete(_root, true); + } + + [Test] + public void ModifiedManagedFileBlocksRegenerationBeforeAnyFileIsWritten() + { + File.WriteAllText(Path.Combine(_templateRoot, "README.md.txt"), "generated-v1"); + File.WriteAllText(Path.Combine(_templateRoot, "settings.json.txt"), "settings-v1"); + ShrinkDedicatedServerScaffoldGenerator.CopyTemplateProject(_templateRoot, _outputRoot); + + File.WriteAllText(Path.Combine(_outputRoot, "README.md"), "manual-change"); + File.WriteAllText(Path.Combine(_templateRoot, "settings.json.txt"), "settings-v2"); + + Assert.Throws(() => + ShrinkDedicatedServerScaffoldGenerator.CopyTemplateProject(_templateRoot, _outputRoot)); + Assert.AreEqual("manual-change", File.ReadAllText(Path.Combine(_outputRoot, "README.md"))); + Assert.AreEqual("settings-v1", File.ReadAllText(Path.Combine(_outputRoot, "settings.json"))); + } + + [Test] + public void UnmodifiedManagedFilesCanBeRegeneratedFromANewerTemplate() + { + File.WriteAllText(Path.Combine(_templateRoot, "README.md.txt"), "generated-v1"); + ShrinkDedicatedServerScaffoldGenerator.CopyTemplateProject(_templateRoot, _outputRoot); + File.WriteAllText(Path.Combine(_templateRoot, "README.md.txt"), "generated-v2"); + + ShrinkDedicatedServerScaffoldGenerator.CopyTemplateProject(_templateRoot, _outputRoot); + + Assert.AreEqual("generated-v2", File.ReadAllText(Path.Combine(_outputRoot, "README.md"))); + } + } + + [ShrinkNetworkMessage(9101, "tests/partial")] + internal sealed partial class PartialMessage : IShrinkNetworkMessage + { + public int First { get; set; } + } + + internal sealed partial class PartialMessage + { + public string Second { get; set; } = string.Empty; + } + + [ShrinkNetworkMessage(9102, "tests/complex-generic")] + internal sealed class ComplexGenericMessage : IShrinkNetworkMessage + { + public Dictionary> Payloads { get; set; } = new(); + } + + internal sealed class SemanticPayload + { + public Guid Id { get; set; } + } +} + +namespace ShrinkNetwork.Editor.Tests.CollisionA +{ + [ShrinkNetworkMessage(9110, "tests/collision-a")] + internal sealed class SharedMessage : IShrinkNetworkMessage + { + } +} + +namespace ShrinkNetwork.Editor.Tests.CollisionB +{ + [ShrinkNetworkMessage(9111, "tests/collision-b")] + internal sealed class SharedMessage : IShrinkNetworkMessage + { + } +} diff --git a/Tests/Editor/ShrinkNetworkSemanticScannerTests.cs.meta b/Tests/Editor/ShrinkNetworkSemanticScannerTests.cs.meta new file mode 100644 index 0000000..9e953d2 --- /dev/null +++ b/Tests/Editor/ShrinkNetworkSemanticScannerTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: dac93fdc7d35e3c4db7bdbeb9cc25f62 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/package.json b/package.json new file mode 100644 index 0000000..81d0bcc --- /dev/null +++ b/package.json @@ -0,0 +1,25 @@ +{ + "name": "com.cneicy.shrink-network", + "version": "0.2.0", + "displayName": "ShrinkNetwork", + "description": "面向 Unity 的轻量网络框架,提供会话、消息注册、RPC、权限控制,以及 TCP/KCP 传输抽象。", + "unity": "2022.3", + "dependencies": { + "com.cneicy.shrink-shared-codegen": "0.1.0", + "com.cysharp.unitask": "2.5.10", + "com.unity.nuget.newtonsoft-json": "3.2.2" + }, + "keywords": [ + "network", + "rpc", + "session", + "transport", + "message" + ], + "author": { + "name": "cneicy", + "url": "https://git.crash.work/ShrinkSDK" + }, + "documentationUrl": "https://git.crash.work/ShrinkSDK/ShrinkNetwork", + "changelogUrl": "https://git.crash.work/ShrinkSDK/ShrinkNetwork/src/branch/main/CHANGELOG.md" +} diff --git a/package.json.meta b/package.json.meta new file mode 100644 index 0000000..f518a56 --- /dev/null +++ b/package.json.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: c63c077256163d341b3b2ce5711cf5c9 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: