diff --git a/.github/workflows/topology-equivalence.yml b/.github/workflows/topology-equivalence.yml new file mode 100644 index 00000000..6dc7feac --- /dev/null +++ b/.github/workflows/topology-equivalence.yml @@ -0,0 +1,35 @@ +name: Topology Equivalence Gate + +# C143c D9:跨 Role 消息链路语义等价 CI 门禁。 +# 4 条代表性链路 × 2 拓扑(All-in-One 单进程 / 三进程)在同一套固定预期下运行, +# 任一用例失败即阻塞合并。这是 DynamicPhase-0 核心契约(AC-2)的守护门。 + +on: + pull_request: + branches: + - main + workflow_dispatch: + +jobs: + topology-equivalence: + runs-on: ubuntu-latest + steps: + - name: Checkout Repository + uses: actions/checkout@v3 + + - name: Setup .NET + uses: actions/setup-dotnet@v3 + with: + dotnet-version: 10.0.x + + - name: Restore + run: dotnet restore Server.slnx + + - name: Build + run: dotnet build Server.slnx --no-restore + + - name: Run Topology Equivalence Suite + run: > + dotnet test Tests/GameFrameX.Tests/GameFrameX.Tests.csproj --no-build + --filter "FullyQualifiedName~GameFrameX.Tests.Topology.Equivalence" + --logger GitHubActions diff --git a/Directory.Build.props b/Directory.Build.props index 1f39ea25..3018e20b 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -40,6 +40,16 @@ false + + + Debug + AnyCPU + + $(MSBuildProjectDirectory)/../bin/app_debug diff --git a/GameFrameX.NetWork.RemoteMessaging/GlobalUsings.cs b/GameFrameX.NetWork.RemoteMessaging/GlobalUsings.cs index 261b987e..1e0c3920 100644 --- a/GameFrameX.NetWork.RemoteMessaging/GlobalUsings.cs +++ b/GameFrameX.NetWork.RemoteMessaging/GlobalUsings.cs @@ -37,4 +37,5 @@ global using GameFrameX.NetWork.RemoteMessaging.Resilience; global using GameFrameX.NetWork.RemoteMessaging.Transport; global using GameFrameX.NetWork.RemoteMessaging.Versioning; -global using GameFrameX.Utility; \ No newline at end of file +global using GameFrameX.Utility; +global using GameFrameX.NetWork.RemoteMessaging.Routing; diff --git a/GameFrameX.NetWork.RemoteMessaging/Routing/ILocalRoleMessageDispatcher.cs b/GameFrameX.NetWork.RemoteMessaging/Routing/ILocalRoleMessageDispatcher.cs new file mode 100644 index 00000000..d99b47b7 --- /dev/null +++ b/GameFrameX.NetWork.RemoteMessaging/Routing/ILocalRoleMessageDispatcher.cs @@ -0,0 +1,57 @@ +// ========================================================================================== +// GameFrameX 组织及其衍生项目的版权、商标、专利及其他相关权利 +// GameFrameX organization and its derivative projects' copyrights, trademarks, patents, and related rights +// 均受中华人民共和国及相关国际法律法规保护。 +// are protected by the laws of the People's Republic of China and related international regulations. +// 使用本项目须严格遵守相应法律法规与开源许可证之规定。 +// Usage of this project must strictly comply with applicable laws, regulations, and open-source licenses. +// 本项目采用 Apache License 2.0 单协议分发, +// This project is licensed solely under the Apache License 2.0, +// 完整许可证文本请参见源代码根目录下的 LICENSE 文件。 +// please see the LICENSE file in the root directory of the source code for the full license text. +// 禁止利用本项目实施任何危害国家安全、破坏社会秩序、 +// It is prohibited to use this project to engage in any activities that endanger national security, disrupt social order, +// 侵犯他人合法权益等法律法规所禁止的行为! +// or infringe upon the legitimate rights and interests of others as prohibited by laws and regulations! +// 因基于本项目二次开发所产生的一切法律纠纷与责任, +// Any legal disputes or liabilities arising from secondary development based on this project +// 本组织与贡献者概不承担。 +// shall be borne solely by the developer; the project organization and contributors assume no responsibility. +// GitHub 仓库:https://github.com/GameFrameX +// GitHub Repository: https://github.com/GameFrameX +// Gitee 仓库:https://gitee.com/GameFrameX +// Gitee Repository: https://gitee.com/GameFrameX +// CNB 仓库:https://cnb.cool/GameFrameX +// CNB Repository: https://cnb.cool/GameFrameX +// 官方文档:https://gameframex.doc.alianblank.com/ +// Official Documentation: https://gameframex.doc.alianblank.com/ +// ========================================================================================== + + +namespace GameFrameX.NetWork.RemoteMessaging.Routing; + +/// +/// 本地 Role 消息投递缝(C143c D3 case 1)。 +/// +/// +/// The local in-process delivery seam behind D3 case 1. +/// Production delivery goes through the actor pipeline (Actor.Tell/SendAsync semantics); +/// this interface is the explicit seam so the routing decision itself stays free of +/// actor-world dependencies (GameFrameX.Core is outside this assembly's reference closure). +/// The production actor-backed dispatcher arrives with C143e; until then the router is +/// wired without one and a case 1 hit fails loudly with . +/// +public interface ILocalRoleMessageDispatcher +{ + /// + /// 将信封投递给本进程目标 Role 的消息处理队列。 + /// + /// + /// Delivers the envelope to the target role's in-process message handling queue. + /// The returned task completes when the message has been handed to (or processed by) + /// the target queue, preserving per-role FIFO ordering for messages routed in order. + /// + /// 路由信封(目标 Role 必属于本进程角色集)/ The routing envelope (target role is hosted by this process) + /// 取消操作的令牌 / The cancellation token + Task DispatchAsync(MessageEnvelope envelope, CancellationToken cancellationToken = default); +} diff --git a/GameFrameX.NetWork.RemoteMessaging/Routing/IRemoteRoleRouter.cs b/GameFrameX.NetWork.RemoteMessaging/Routing/IRemoteRoleRouter.cs new file mode 100644 index 00000000..5404aadf --- /dev/null +++ b/GameFrameX.NetWork.RemoteMessaging/Routing/IRemoteRoleRouter.cs @@ -0,0 +1,58 @@ +// ========================================================================================== +// GameFrameX 组织及其衍生项目的版权、商标、专利及其他相关权利 +// GameFrameX organization and its derivative projects' copyrights, trademarks, patents, and related rights +// 均受中华人民共和国及相关国际法律法规保护。 +// are protected by the laws of the People's Republic of China and related international regulations. +// 使用本项目须严格遵守相应法律法规与开源许可证之规定。 +// Usage of this project must strictly comply with applicable laws, regulations, and open-source licenses. +// 本项目采用 Apache License 2.0 单协议分发, +// This project is licensed solely under the Apache License 2.0, +// 完整许可证文本请参见源代码根目录下的 LICENSE 文件。 +// please see the LICENSE file in the root directory of the source code for the full license text. +// 禁止利用本项目实施任何危害国家安全、破坏社会秩序、 +// It is prohibited to use this project to engage in any activities that endanger national security, disrupt social order, +// 侵犯他人合法权益等法律法规所禁止的行为! +// or infringe upon the legitimate rights and interests of others as prohibited by laws and regulations! +// 因基于本项目二次开发所产生的一切法律纠纷与责任, +// Any legal disputes or liabilities arising from secondary development based on this project +// 本组织与贡献者概不承担。 +// shall be borne solely by the developer; the project organization and contributors assume no responsibility. +// GitHub 仓库:https://github.com/GameFrameX +// GitHub Repository: https://github.com/GameFrameX +// Gitee 仓库:https://gitee.com/GameFrameX +// Gitee Repository: https://gitee.com/GameFrameX +// CNB 仓库:https://cnb.cool/GameFrameX +// CNB Repository: https://cnb.cool/GameFrameX +// 官方文档:https://gameframex.doc.alianblank.com/ +// Official Documentation: https://gameframex.doc.alianblank.com/ +// ========================================================================================== + + +namespace GameFrameX.NetWork.RemoteMessaging.Routing; + +/// +/// 跨进程 Role 消息转发缝(C143c D3 case 2/3)。 +/// +/// +/// The remote forwarding seam behind D3 case 2/3. +/// Case 2 forwards to a known target instance id; case 3 picks any active instance of +/// the target role from the reachability table. The real implementation (endpoint +/// reachability table + ForwardToRemoteServerAsync) is delivered by C143d; until then +/// is the placeholder and throws +/// . Topology equivalence tests substitute a +/// loopback forwarder to simulate the remote hop inside a single test process. +/// +public interface IRemoteRoleRouter +{ + /// + /// 将信封转发给目标 Role 所在的远端进程。 + /// + /// + /// Forwards the envelope to the remote process hosting the target role + /// (a known instance for D3 case 2, or any active instance for case 3). + /// + /// 路由信封(目标 Role 不属于本进程角色集)/ The routing envelope (target role is hosted by another process) + /// 取消操作的令牌 / The cancellation token + /// 投递分支恒为 / Always returns + Task ForwardAsync(MessageEnvelope envelope, CancellationToken cancellationToken = default); +} diff --git a/GameFrameX.NetWork.RemoteMessaging/Routing/IRoleRouter.cs b/GameFrameX.NetWork.RemoteMessaging/Routing/IRoleRouter.cs new file mode 100644 index 00000000..a052efa9 --- /dev/null +++ b/GameFrameX.NetWork.RemoteMessaging/Routing/IRoleRouter.cs @@ -0,0 +1,57 @@ +// ========================================================================================== +// GameFrameX 组织及其衍生项目的版权、商标、专利及其他相关权利 +// GameFrameX organization and its derivative projects' copyrights, trademarks, patents, and related rights +// 均受中华人民共和国及相关国际法律法规保护。 +// are protected by the laws of the People's Republic of China and related international regulations. +// 使用本项目须严格遵守相应法律法规与开源许可证之规定。 +// Usage of this project must strictly comply with applicable laws, regulations, and open-source licenses. +// 本项目采用 Apache License 2.0 单协议分发, +// This project is licensed solely under the Apache License 2.0, +// 完整许可证文本请参见源代码根目录下的 LICENSE 文件。 +// please see the LICENSE file in the root directory of the source code for the full license text. +// 禁止利用本项目实施任何危害国家安全、破坏社会秩序、 +// It is prohibited to use this project to engage in any activities that endanger national security, disrupt social order, +// 侵犯他人合法权益等法律法规所禁止的行为! +// or infringe upon the legitimate rights and interests of others as prohibited by laws and regulations! +// 因基于本项目二次开发所产生的一切法律纠纷与责任, +// Any legal disputes or liabilities arising from secondary development based on this project +// 本组织与贡献者概不承担。 +// shall be borne solely by the developer; the project organization and contributors assume no responsibility. +// GitHub 仓库:https://github.com/GameFrameX +// GitHub Repository: https://github.com/GameFrameX +// Gitee 仓库:https://gitee.com/GameFrameX +// Gitee Repository: https://gitee.com/GameFrameX +// CNB 仓库:https://cnb.cool/GameFrameX +// CNB Repository: https://cnb.cool/GameFrameX +// 官方文档:https://gameframex.doc.alianblank.com/ +// Official Documentation: https://gameframex.doc.alianblank.com/ +// ========================================================================================== + + +namespace GameFrameX.NetWork.RemoteMessaging.Routing; + +/// +/// 跨 Role 消息路由缝(C143c D3)。 +/// +/// +/// The cross-role message routing seam (C143c D3). +/// Implementations apply the three-step decision to every envelope: +/// target role hosted by this process goes to local in-process delivery (case 1); +/// everything else is handed to the remote forwarding seam (case 2/3, delivered by C143d). +/// Business code reaches the process-wide instance through . +/// +public interface IRoleRouter +{ + /// + /// 路由一封跨 Role 消息信封。 + /// + /// + /// Routes one cross-role message envelope. + /// + /// 路由信封 / The routing envelope + /// 取消操作的令牌 / The cancellation token + /// 实际命中的投递分支 / The delivery branch that was actually hit + /// 为 null 时抛出 / Thrown when is null + /// 当路由决策失败(空目标 Role / 缺本地投递器 / 缺远程转发器)时抛出 / Thrown when routing cannot decide a route + Task RouteAsync(MessageEnvelope envelope, CancellationToken cancellationToken = default); +} diff --git a/GameFrameX.NetWork.RemoteMessaging/Routing/InProcessRoleRouter.cs b/GameFrameX.NetWork.RemoteMessaging/Routing/InProcessRoleRouter.cs new file mode 100644 index 00000000..00952509 --- /dev/null +++ b/GameFrameX.NetWork.RemoteMessaging/Routing/InProcessRoleRouter.cs @@ -0,0 +1,147 @@ +// ========================================================================================== +// GameFrameX 组织及其衍生项目的版权、商标、专利及其他相关权利 +// GameFrameX organization and its derivative projects' copyrights, trademarks, patents, and related rights +// 均受中华人民共和国及相关国际法律法规保护。 +// are protected by the laws of the People's Republic of China and related international regulations. +// 使用本项目须严格遵守相应法律法规与开源许可证之规定。 +// Usage of this project must strictly comply with applicable laws, regulations, and open-source licenses. +// 本项目采用 Apache License 2.0 单协议分发, +// This project is licensed solely under the Apache License 2.0, +// 完整许可证文本请参见源代码根目录下的 LICENSE 文件。 +// please see the LICENSE file in the root directory of the source code for the full license text. +// 禁止利用本项目实施任何危害国家安全、破坏社会秩序、 +// It is prohibited to use this project to engage in any activities that endanger national security, disrupt social order, +// 侵犯他人合法权益等法律法规所禁止的行为! +// or infringe upon the legitimate rights and interests of others as prohibited by laws and regulations! +// 因基于本项目二次开发所产生的一切法律纠纷与责任, +// Any legal disputes or liabilities arising from secondary development based on this project +// 本组织与贡献者概不承担。 +// shall be borne solely by the developer; the project organization and contributors assume no responsibility. +// GitHub 仓库:https://github.com/GameFrameX +// GitHub Repository: https://github.com/GameFrameX +// Gitee 仓库:https://gitee.com/GameFrameX +// Gitee Repository: https://gitee.com/GameFrameX +// CNB 仓库:https://cnb.cool/GameFrameX +// CNB Repository: https://cnb.cool/GameFrameX +// 官方文档:https://gameframex.doc.alianblank.com/ +// Official Documentation: https://gameframex.doc.alianblank.com/ +// ========================================================================================== + + +namespace GameFrameX.NetWork.RemoteMessaging.Routing; + +/// +/// 进程内跨 Role 路由器(C143c D3 三步判定)。 +/// +/// +/// In-process cross-role router implementing the D3 three-step decision. +/// The hosted role names are captured as a defensive snapshot at construction time +/// (mirroring the RoleSet snapshot semantics of C143b), so this instance always routes +/// against the process shape it was built for. The constructor accepts plain role names +/// instead of the StartUp RoleSet type because this assembly must not depend on the +/// startup module; the launch flow passes the published role snapshot when wiring +/// . +/// Failure semantics: every undecidable route throws — +/// the router never drops a message and never falls back to another branch silently. +/// +public sealed class InProcessRoleRouter : IRoleRouter +{ + /// + /// 本进程承载的 Role 名快照。 + /// + /// + /// The defensive snapshot of role names hosted by this process. + /// Uses the default ordinal comparer, matching the RoleSet snapshot semantics of C143b. + /// + private readonly HashSet _hostedRoleNames; + + /// + /// 本地投递缝(case 1;可为 null,为 null 时 case 1 命中即显式失败)。 + /// + /// + /// The local delivery seam (case 1); may be null, in which case a case 1 hit fails loudly. + /// + private readonly ILocalRoleMessageDispatcher _localDispatcher; + + /// + /// 远程转发缝(case 2/3;可为 null,为 null 时非本进程目标即显式失败)。 + /// + /// + /// The remote forwarding seam (case 2/3); may be null, in which case any non-local target fails loudly. + /// + private readonly IRemoteRoleRouter _remoteRouter; + + /// + /// 初始化进程内跨 Role 路由器。 + /// + /// + /// Initializes the router with a defensive copy of the hosted role names + /// and the optional delivery seams. Both seams are optional on purpose: + /// production wiring (GameApp) installs the router before the actor-backed local + /// dispatcher exists (C143e) and before remote forwarding exists (C143d), so a + /// premature route attempt fails with instead + /// of silently succeeding against a half-built pipeline. + /// + /// 本进程承载的 Role 名集合(构造时防御性拷贝)/ The role names hosted by this process (defensively copied) + /// 本地投递缝;null 表示 case 1 命中时显式失败 / The local delivery seam; null makes case 1 hits fail loudly + /// 远程转发缝;null 表示 case 2/3 显式失败 / The remote forwarding seam; null makes case 2/3 fail loudly + /// 为 null 时抛出 / Thrown when is null + public InProcessRoleRouter(IEnumerable hostedRoleNames, ILocalRoleMessageDispatcher localDispatcher = null, IRemoteRoleRouter remoteRouter = null) + { + ArgumentNullException.ThrowIfNull(hostedRoleNames, nameof(hostedRoleNames)); + + _hostedRoleNames = new HashSet(hostedRoleNames); + _localDispatcher = localDispatcher; + _remoteRouter = remoteRouter; + } + + /// + /// 路由一封跨 Role 消息信封(D3 三步判定)。 + /// + /// + /// Applies the D3 three-step decision to the envelope: + /// target role hosted by this process goes to the local dispatcher (case 1); + /// anything else goes to the remote forwarding seam (case 2/3). + /// An empty target role, a case 1 hit without a local dispatcher, or a non-local + /// target without a remote forwarder all throw — + /// never a silent fallback. + /// + /// 路由信封 / The routing envelope + /// 取消操作的令牌 / The cancellation token + /// 实际命中的投递分支 / The delivery branch that was actually hit + /// 为 null 时抛出 / Thrown when is null + /// 当路由决策失败时抛出 / Thrown when routing cannot decide a route + public async Task RouteAsync(MessageEnvelope envelope, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(envelope, nameof(envelope)); + + if (string.IsNullOrWhiteSpace(envelope.TargetRole)) + { + throw new RouteNotFoundException(envelope.TargetRole, "Cannot route an envelope without a target role."); + } + + // D3 case 1:目标 Role 属于本进程角色集 → 本地投递 + if (_hostedRoleNames.Contains(envelope.TargetRole)) + { + if (_localDispatcher == null) + { + throw new RouteNotFoundException( + envelope.TargetRole, + $"Target role '{envelope.TargetRole}' is hosted by this process but no local message dispatcher is configured (the actor-backed dispatcher arrives with C143e)."); + } + + await _localDispatcher.DispatchAsync(envelope, cancellationToken); + return RoleRouteDelivery.LocalActor; + } + + // D3 case 2/3:目标 Role 不属于本进程角色集 → 远程转发缝(真实实现随 C143d 交付) + if (_remoteRouter == null) + { + throw new RouteNotFoundException( + envelope.TargetRole, + $"Target role '{envelope.TargetRole}' is not hosted by this process and no remote role router is configured (remote forwarding arrives with C143d)."); + } + + return await _remoteRouter.ForwardAsync(envelope, cancellationToken); + } +} diff --git a/GameFrameX.NetWork.RemoteMessaging/Routing/MessageEnvelope.cs b/GameFrameX.NetWork.RemoteMessaging/Routing/MessageEnvelope.cs new file mode 100644 index 00000000..0d2478b8 --- /dev/null +++ b/GameFrameX.NetWork.RemoteMessaging/Routing/MessageEnvelope.cs @@ -0,0 +1,106 @@ +// ========================================================================================== +// GameFrameX 组织及其衍生项目的版权、商标、专利及其他相关权利 +// GameFrameX organization and its derivative projects' copyrights, trademarks, patents, and related rights +// 均受中华人民共和国及相关国际法律法规保护。 +// are protected by the laws of the People's Republic of China and relevant international regulations. +// 使用本项目须严格遵守相应法律法规与开源许可证之规定。 +// Usage of this project must strictly comply with applicable laws, regulations, and open-source licenses. +// 本项目采用 Apache License 2.0 单协议分发, +// This project is licensed solely under the Apache License 2.0, +// 完整许可证文本请参见源代码根目录下的 LICENSE 文件。 +// please see the LICENSE file in the root directory of the source code for the full license text. +// 禁止利用本项目实施任何危害国家安全、破坏社会秩序、 +// It is prohibited to use this project to engage in any activities that endanger national security, disrupt social order, +// 侵犯他人合法权益等法律法规所禁止的行为! +// or infringe upon the legitimate rights and interests of others as prohibited by laws and regulations! +// 因基于本项目二次开发所产生的一切法律纠纷与责任, +// Any legal disputes or liabilities arising from secondary development based on this project +// 本组织与贡献者概不承担。 +// shall be borne solely by the developer; the project organization and contributors assume no responsibility. +// GitHub 仓库:https://github.com/GameFrameX +// GitHub Repository: https://github.com/GameFrameX +// Gitee 仓库:https://gitee.com/GameFrameX +// Gitee Repository: https://gitee.com/GameFrameX +// CNB 仓库:https://cnb.cool/GameFrameX +// CNB Repository: https://cnb.cool/GameFrameX +// 官方文档:https://gameframex.doc.alianblank.com/ +// Official Documentation: https://gameframex.doc.alianblank.com/ +// ========================================================================================== + + +namespace GameFrameX.NetWork.RemoteMessaging.Routing; + +/// +/// 跨 Role 路由信封(C143c D3)。 +/// +/// +/// Envelope for cross-role message routing (C143c D3). +/// Carries everything the role routing seam needs to make its three-step decision: +/// the target role name (process locality check), an optional target instance id +/// (distinguishes D3 case 2 "known instance" from case 3 "any active instance"), +/// the target actor id used by local in-process delivery, and the message payload itself. +/// The envelope is immutable after construction; routing never mutates it. +/// +public sealed class MessageEnvelope +{ + /// + /// 初始化路由信封。 + /// + /// + /// Initializes the routing envelope. + /// An empty or whitespace target role is intentionally not rejected here: + /// role-name validity is a routing decision, and the router fails it loudly with + /// instead of a silent fallback (C143c risk mitigation). + /// + /// 目标 Role 的服务器类型名 / The target role server type name + /// 要路由的消息 / The message to route + /// 本地投递目标 ActorId(跨进程跳时忽略)/ The local delivery target actor id (ignored on remote hops) + /// 可选的目标实例 Id(非空走 D3 case 2,空走 case 3)/ Optional target instance id (non-null selects D3 case 2, null selects case 3) + /// 为 null 时抛出 / Thrown when is null + public MessageEnvelope(string targetRole, MessageObject message, long targetActorId = 0, string targetInstanceId = null) + { + ArgumentNullException.ThrowIfNull(message, nameof(message)); + + TargetRole = targetRole; + Message = message; + TargetActorId = targetActorId; + TargetInstanceId = targetInstanceId; + } + + /// + /// 获取目标 Role 的服务器类型名。 + /// + /// + /// Gets the target role server type name. + /// + /// 目标 Role 名 / The target role name + public string TargetRole { get; } + + /// + /// 获取要路由的消息。 + /// + /// + /// Gets the message to route. + /// + /// 消息负载 / The message payload + public MessageObject Message { get; } + + /// + /// 获取本地投递目标 ActorId。 + /// + /// + /// Gets the local delivery target actor id; ignored on remote hops. + /// + /// 本地投递目标 ActorId / The local delivery target actor id + public long TargetActorId { get; } + + /// + /// 获取可选的目标实例 Id。 + /// + /// + /// Gets the optional target instance id. A non-null value selects D3 case 2 + /// (forward to a known instance); null selects D3 case 3 (any active instance of the role). + /// + /// 目标实例 Id;未指定时为 null / The target instance id, or null when unspecified + public string TargetInstanceId { get; } +} diff --git a/GameFrameX.NetWork.RemoteMessaging/Routing/RemoteRoleRouter.cs b/GameFrameX.NetWork.RemoteMessaging/Routing/RemoteRoleRouter.cs new file mode 100644 index 00000000..5b64179f --- /dev/null +++ b/GameFrameX.NetWork.RemoteMessaging/Routing/RemoteRoleRouter.cs @@ -0,0 +1,64 @@ +// ========================================================================================== +// GameFrameX 组织及其衍生项目的版权、商标、专利及其他相关权利 +// GameFrameX organization and its derivative projects' copyrights, trademarks, patents, and related rights +// 均受中华人民共和国及相关国际法律法规保护。 +// are protected by the laws of the People's Republic of China and related international regulations. +// 使用本项目须严格遵守相应法律法规与开源许可证之规定。 +// Usage of this project must strictly comply with applicable laws, regulations, and open-source licenses. +// 本项目采用 Apache License 2.0 单协议分发, +// This project is licensed solely under the Apache License 2.0, +// 完整许可证文本请参见源代码根目录下的 LICENSE 文件。 +// please see the LICENSE file in the root directory of the source code for the full license text. +// 禁止利用本项目实施任何危害国家安全、破坏社会秩序、 +// It is prohibited to use this project to engage in any activities that endanger national security, disrupt social order, +// 侵犯他人合法权益等法律法规所禁止的行为! +// or infringe upon the legitimate rights and interests of others as prohibited by laws and regulations! +// 因基于本项目二次开发所产生的一切法律纠纷与责任, +// Any legal disputes or liabilities arising from secondary development based on this project +// 本组织与贡献者概不承担。 +// shall be borne solely by the developer; the project organization and contributors assume no responsibility. +// GitHub 仓库:https://github.com/GameFrameX +// GitHub Repository: https://github.com/GameFrameX +// Gitee 仓库:https://gitee.com/GameFrameX +// Gitee Repository: https://gitee.com/GameFrameX +// CNB 仓库:https://cnb.cool/GameFrameX +// CNB Repository: https://cnb.cool/GameFrameX +// 官方文档:https://gameframex.doc.alianblank.com/ +// Official Documentation: https://gameframex.doc.alianblank.com/ +// ========================================================================================== + + +namespace GameFrameX.NetWork.RemoteMessaging.Routing; + +/// +/// 跨进程 Role 转发占位实现(C143c → C143d)。 +/// +/// +/// Placeholder implementation of the D3 case 2/3 remote forwarding seam. +/// Every forward attempt throws on purpose: +/// real forwarding needs the endpoint reachability table and ForwardToRemoteServerAsync, +/// which are delivered by change C143d. Keeping the placeholder as the production default +/// makes premature cross-process routing fail loudly at the seam instead of silently +/// dead-lettering messages. Topology equivalence tests replace it with a loopback forwarder. +/// +public sealed class RemoteRoleRouter : IRemoteRoleRouter +{ + /// + /// 转发占位:恒抛 。 + /// + /// + /// Placeholder forward: always throws . + /// + /// 路由信封 / The routing envelope + /// 取消操作的令牌 / The cancellation token + /// 恒不返回 / Never returns + /// 为 null 时抛出 / Thrown when is null + /// 恒抛出,等待 C143d 实现可达表转发 / Always thrown until C143d implements reachability-table forwarding + public Task ForwardAsync(MessageEnvelope envelope, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(envelope, nameof(envelope)); + + throw new NotImplementedException( + $"Remote role forwarding for target role '{envelope.TargetRole}' (D3 case 2/3) is not implemented yet; it arrives with change C143d together with the endpoint reachability table."); + } +} diff --git a/GameFrameX.NetWork.RemoteMessaging/Routing/RoleRouteDelivery.cs b/GameFrameX.NetWork.RemoteMessaging/Routing/RoleRouteDelivery.cs new file mode 100644 index 00000000..5654ab07 --- /dev/null +++ b/GameFrameX.NetWork.RemoteMessaging/Routing/RoleRouteDelivery.cs @@ -0,0 +1,60 @@ +// ========================================================================================== +// GameFrameX 组织及其衍生项目的版权、商标、专利及其他相关权利 +// GameFrameX organization and its derivative projects' copyrights, trademarks, patents, and related rights +// 均受中华人民共和国及相关国际法律法规保护。 +// are protected by the laws of the People's Republic of China and related international regulations. +// 使用本项目须严格遵守相应法律法规与开源许可证之规定。 +// Usage of this project must strictly comply with applicable laws, regulations, and open-source licenses. +// 本项目采用 Apache License 2.0 单协议分发, +// This project is licensed solely under the Apache License 2.0, +// 完整许可证文本请参见源代码根目录下的 LICENSE 文件。 +// please see the LICENSE file in the root directory of the source code for the full license text. +// 禁止利用本项目实施任何危害国家安全、破坏社会秩序、 +// It is prohibited to use this project to engage in any activities that endanger national security, disrupt social order, +// 侵犯他人合法权益等法律法规所禁止的行为! +// or infringe upon the legitimate rights and interests of others as prohibited by laws and regulations! +// 因基于本项目二次开发所产生的一切法律纠纷与责任, +// Any legal disputes or liabilities arising from secondary development based on this project +// 本组织与贡献者概不承担。 +// shall be borne solely by the developer; the project organization and contributors assume no responsibility. +// GitHub 仓库:https://github.com/GameFrameX +// GitHub Repository: https://github.com/GameFrameX +// Gitee 仓库:https://gitee.com/GameFrameX +// Gitee Repository: https://gitee.com/GameFrameX +// CNB 仓库:https://cnb.cool/GameFrameX +// CNB Repository: https://cnb.cool/GameFrameX +// 官方文档:https://gameframex.doc.alianblank.com/ +// Official Documentation: https://gameframex.doc.alianblank.com/ +// ========================================================================================== + + +namespace GameFrameX.NetWork.RemoteMessaging.Routing; + +/// +/// 跨 Role 路由投递结果(C143c D3)。 +/// +/// +/// Delivery result of cross-role routing (C143c D3). +/// Tells the caller which branch of the three-step routing decision actually delivered +/// the message, so topology equivalence tests can assert that the All-in-One topology +/// hits the local in-process path 100% of the time. +/// Values start at 1 on purpose: an uninitialized field must never read as a valid delivery. +/// +public enum RoleRouteDelivery +{ + /// + /// D3 case 1:目标 Role 属于本进程角色集,经本地投递缝送达。 + /// + /// + /// D3 case 1: the target role belongs to this process and was delivered through the local dispatcher. + /// + LocalActor = 1, + + /// + /// D3 case 2/3:目标 Role 不属于本进程角色集,经远程转发缝投出。 + /// + /// + /// D3 case 2/3: the target role is hosted by another process and was handed to the remote forwarding seam. + /// + RemoteForwarded = 2, +} diff --git a/GameFrameX.NetWork.RemoteMessaging/Routing/RoleRouterHolder.cs b/GameFrameX.NetWork.RemoteMessaging/Routing/RoleRouterHolder.cs new file mode 100644 index 00000000..be27d074 --- /dev/null +++ b/GameFrameX.NetWork.RemoteMessaging/Routing/RoleRouterHolder.cs @@ -0,0 +1,102 @@ +// ========================================================================================== +// GameFrameX 组织及其衍生项目的版权、商标、专利及其他相关权利 +// GameFrameX organization and its derivative projects' copyrights, trademarks, patents, and related rights +// 均受中华人民共和国及相关国际法律法规保护。 +// are protected by the laws of the People's Republic of China and related international regulations. +// 使用本项目须严格遵守相应法律法规与开源许可证之规定。 +// Usage of this project must strictly comply with applicable laws, regulations, and open-source licenses. +// 本项目采用 Apache License 2.0 单协议分发, +// This project is licensed solely under the Apache License 2.0, +// 完整许可证文本请参见源代码根目录下的 LICENSE 文件。 +// please see the LICENSE file in the root directory of the source code for the full license text. +// 禁止利用本项目实施任何危害国家安全、破坏社会秩序、 +// It is prohibited to use this project to engage in any activities that endanger national security, disrupt social order, +// 侵犯他人合法权益等法律法规所禁止的行为! +// or infringe upon the legitimate rights and interests of others as prohibited by laws and regulations! +// 因基于本项目二次开发所产生的一切法律纠纷与责任, +// Any legal disputes or liabilities arising from secondary development based on this project +// 本组织与贡献者概不承担。 +// shall be borne solely by the developer; the project organization and contributors assume no responsibility. +// GitHub 仓库:https://github.com/GameFrameX +// GitHub Repository: https://github.com/GameFrameX +// Gitee 仓库:https://gitee.com/GameFrameX +// Gitee Repository: https://gitee.com/GameFrameX +// CNB 仓库:https://cnb.cool/GameFrameX +// CNB Repository: https://cnb.cool/GameFrameX +// 官方文档:https://gameframex.doc.alianblank.com/ +// Official Documentation: https://gameframex.doc.alianblank.com/ +// ========================================================================================== + + +namespace GameFrameX.NetWork.RemoteMessaging.Routing; + +/// +/// 跨 Role 路由器全局持有者(C143c)。 +/// +/// +/// Global holder for the process-wide instance, +/// following the UnifiedMessageSenderHolder pattern (this stack has no DI container; +/// global infrastructure is reached through typed holders). +/// is called by the launch flow (GameApp) right after the +/// process role snapshot is published; business code reads . +/// +public static class RoleRouterHolder +{ + /// + /// 全局路由器实例。 + /// + /// + /// The global router instance. + /// + private static IRoleRouter _router; + + /// + /// 初始化互斥锁。 + /// + /// + /// The initialization lock. + /// + private static readonly object InitializeLock = new object(); + + /// + /// 获取全局跨 Role 路由器实例。必须在调用 之后使用。 + /// + /// + /// Gets the process-wide role router. Must be used only after . + /// Reading before initialization throws instead of returning null: routing against a + /// missing router must fail loudly at the call site (never a silent drop). + /// + /// 全局路由器实例 / The process-wide router instance + /// 当尚未调用 时抛出 / Thrown when has not been called + public static IRoleRouter Current + { + get + { + if (_router == null) + { + throw new InvalidOperationException("RoleRouterHolder has not been initialized; call RoleRouterHolder.Initialize during launch before routing."); + } + + return _router; + } + } + + /// + /// 初始化全局路由器。启动流程调用一次。 + /// + /// + /// Initializes the process-wide router. Called once during launch + /// (after the role snapshot is published, before any host starts). + /// + /// 路由器实例 / The router instance + /// 为 null 时抛出 / Thrown when is null + public static void Initialize(IRoleRouter router) + { + ArgumentNullException.ThrowIfNull(router, nameof(router)); + + lock (InitializeLock) + { + _router = router; + } + } +} diff --git a/GameFrameX.NetWork.RemoteMessaging/Routing/RouteNotFoundException.cs b/GameFrameX.NetWork.RemoteMessaging/Routing/RouteNotFoundException.cs new file mode 100644 index 00000000..810cf811 --- /dev/null +++ b/GameFrameX.NetWork.RemoteMessaging/Routing/RouteNotFoundException.cs @@ -0,0 +1,82 @@ +// ========================================================================================== +// GameFrameX 组织及其衍生项目的版权、商标、专利及其他相关权利 +// GameFrameX organization and its derivative projects' copyrights, trademarks, patents, and related rights +// 均受中华人民共和国及相关国际法律法规保护。 +// are protected by the laws of the People's Republic of China and related international regulations. +// 使用本项目须严格遵守相应法律法规与开源许可证之规定。 +// Usage of this project must strictly comply with applicable laws, regulations, and open-source licenses. +// 本项目采用 Apache License 2.0 单协议分发, +// This project is licensed solely under the Apache License 2.0, +// 完整许可证文本请参见源代码根目录下的 LICENSE 文件。 +// please see the LICENSE file in the root directory of the source code for the full license text. +// 禁止利用本项目实施任何危害国家安全、破坏社会秩序、 +// It is prohibited to use this project to engage in any activities that endanger national security, disrupt social order, +// 侵犯他人合法权益等法律法规所禁止的行为! +// or infringe upon the legitimate rights and interests of others as prohibited by laws and regulations! +// 因基于本项目二次开发所产生的一切法律纠纷与责任, +// Any legal disputes or liabilities arising from secondary development based on this project +// 本组织与贡献者概不承担。 +// shall be borne solely by the developer; the project organization and contributors assume no responsibility. +// GitHub 仓库:https://github.com/GameFrameX +// GitHub Repository: https://github.com/GameFrameX +// Gitee 仓库:https://gitee.com/GameFrameX +// Gitee Repository: https://gitee.com/GameFrameX +// CNB 仓库:https://cnb.cool/GameFrameX +// CNB Repository: https://cnb.cool/GameFrameX +// 官方文档:https://gameframex.doc.alianblank.com/ +// Official Documentation: https://gameframex.doc.alianblank.com/ +// ========================================================================================== + + +namespace GameFrameX.NetWork.RemoteMessaging.Routing; + +/// +/// 跨 Role 路由决策失败异常(C143c D3)。 +/// +/// +/// Thrown when the cross-role routing seam cannot decide where a message must go +/// (empty target role, target role not hosted by this process with no remote forwarder +/// configured, or a local role hit with no local dispatcher configured). +/// The router never falls back silently: an undecidable route is always a loud failure (C143c risk mitigation). +/// +public sealed class RouteNotFoundException : Exception +{ + /// + /// 初始化路由决策失败异常。 + /// + /// + /// Initializes the exception with the target role that could not be routed. + /// + /// 无法路由的目标 Role 名(可能为 null 或空白)/ The target role that could not be routed (may be null or whitespace) + /// 失败原因描述 / The failure description + public RouteNotFoundException(string targetRole, string message) + : base(message) + { + TargetRole = targetRole; + } + + /// + /// 初始化路由决策失败异常(含内部异常)。 + /// + /// + /// Initializes the exception with the target role, a failure description and an inner exception. + /// + /// 无法路由的目标 Role 名(可能为 null 或空白)/ The target role that could not be routed (may be null or whitespace) + /// 失败原因描述 / The failure description + /// 内部异常 / The inner exception + public RouteNotFoundException(string targetRole, string message, Exception innerException) + : base(message, innerException) + { + TargetRole = targetRole; + } + + /// + /// 获取无法路由的目标 Role 名。 + /// + /// + /// Gets the target role that could not be routed; may be null or whitespace + /// when the failure was an empty role name. + /// + /// 目标 Role 名 / The target role name + public string TargetRole { get; } +} diff --git a/GameFrameX.StartUp/GameApp.cs b/GameFrameX.StartUp/GameApp.cs index 13a93786..9c96022c 100644 --- a/GameFrameX.StartUp/GameApp.cs +++ b/GameFrameX.StartUp/GameApp.cs @@ -37,6 +37,7 @@ using GameFrameX.Foundation.Options.Attributes; using GameFrameX.Foundation.Utility; using GameFrameX.Localization; +using GameFrameX.NetWork.RemoteMessaging.Routing; using GameFrameX.StartUp.Abstractions; using GameFrameX.StartUp.Options; using GameFrameX.Utility; @@ -325,6 +326,10 @@ private static void Launcher(string[] args, IReadOnlyList + diff --git a/Tests/GameFrameX.Tests/GameFrameX.Tests.csproj b/Tests/GameFrameX.Tests/GameFrameX.Tests.csproj index a199662d..5a866aa0 100644 --- a/Tests/GameFrameX.Tests/GameFrameX.Tests.csproj +++ b/Tests/GameFrameX.Tests/GameFrameX.Tests.csproj @@ -27,6 +27,11 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/Tests/GameFrameX.Tests/Topology/Equivalence/ChainExecutionResult.cs b/Tests/GameFrameX.Tests/Topology/Equivalence/ChainExecutionResult.cs new file mode 100644 index 00000000..6306b3bb --- /dev/null +++ b/Tests/GameFrameX.Tests/Topology/Equivalence/ChainExecutionResult.cs @@ -0,0 +1,54 @@ +// ========================================================================================== +// GameFrameX 组织及其衍生项目的版权、商标、专利及其他相关权利 +// GameFrameX organization and its derivative projects' copyrights, trademarks, patents, and related rights +// 均受中华人民共和国及相关国际法律法规保护。 +// are protected by the laws of the People's Republic of China and related international regulations. +// 使用本项目须严格遵守相应法律法规与开源许可证之规定。 +// Usage of this project must strictly comply with applicable laws, regulations, and open-source licenses. +// 本项目采用 Apache License 2.0 单协议分发, +// This project is licensed solely under the Apache License 2.0, +// 完整许可证文本请参见源代码根目录下的 LICENSE 文件。 +// please see the LICENSE file in the root directory of the source code for the full license text. +// 禁止利用本项目实施任何危害国家安全、破坏社会秩序、 +// It is prohibited to use this project to engage in any activities that endanger national security, disrupt social order, +// 侵犯他人合法权益等法律法规所禁止的行为! +// or infringe upon the legitimate rights and interests of others as prohibited by laws and regulations! +// 因基于本项目二次开发所产生的一切法律纠纷与责任, +// Any legal disputes or liabilities arising from secondary development based on this project +// 本组织与贡献者概不承担。 +// shall be borne solely by the developer; the project organization and contributors assume no responsibility. +// GitHub 仓库:https://github.com/GameFrameX +// GitHub Repository: https://github.com/GameFrameX +// Gitee 仓库:https://gitee.com/GameFrameX +// Gitee Repository: https://gitee.com/GameFrameX +// CNB 仓库:https://cnb.cool/GameFrameX +// CNB Repository: https://cnb.cool/GameFrameX +// 官方文档:https://gameframex.doc.alianblank.com/ +// Official Documentation: https://gameframex.doc.alianblank.com/ +// ========================================================================================== + + +using GameFrameX.NetWork.RemoteMessaging.Routing; + +namespace GameFrameX.Tests.Topology.Equivalence; + +/// +/// 语义等价用例集的链路执行结果快照(C143c D9)。 +/// +/// +/// Deterministic snapshot of one chain execution: the delivery branch of every hop, +/// the per-role receive order, and the final business state. Both topologies capture +/// the same shape so tests can assert against fixed expectations (and, transitively, +/// equivalence between topologies). +/// +public sealed class ChainExecutionResult +{ + /// 每一跳的投递分支(含处理器内发起的应答跳)/ The delivery branch of every hop, including reply hops initiated inside handlers + public IReadOnlyList Deliveries { get; set; } + + /// 每 Role 的到达序(消息类型名)/ The per-role receive order (message type names) + public IReadOnlyDictionary> ArrivalOrderByRole { get; set; } + + /// 最终状态快照(确定性拼接)/ The final state snapshot (deterministic concatenation) + public string StateSnapshot { get; set; } +} diff --git a/Tests/GameFrameX.Tests/Topology/Equivalence/CrossRoleChains.cs b/Tests/GameFrameX.Tests/Topology/Equivalence/CrossRoleChains.cs new file mode 100644 index 00000000..bb484af2 --- /dev/null +++ b/Tests/GameFrameX.Tests/Topology/Equivalence/CrossRoleChains.cs @@ -0,0 +1,286 @@ +// ========================================================================================== +// GameFrameX 组织及其衍生项目的版权、商标、专利及其他相关权利 +// GameFrameX organization and its derivative projects' copyrights, trademarks, patents, and related rights +// 均受中华人民共和国及相关国际法律法规保护。 +// are protected by the laws of the People's Republic of China and related international regulations. +// 使用本项目须严格遵守相应法律法规与开源许可证之规定。 +// Usage of this project must strictly comply with applicable laws, regulations, and open-source licenses. +// 本项目采用 Apache License 2.0 单协议分发, +// This project is licensed solely under the Apache License 2.0, +// 完整许可证文本请参见源代码根目录下的 LICENSE 文件。 +// please see the LICENSE file in the root directory of the source code for the full license text. +// 禁止利用本项目实施任何危害国家安全、破坏社会秩序、 +// It is prohibited to use this project to engage in any activities that endanger national security, disrupt social order, +// 侵犯他人合法权益等法律法规所禁止的行为! +// or infringe upon the legitimate rights and interests of others as prohibited by laws and regulations! +// 因基于本项目二次开发所产生的一切法律纠纷与责任, +// Any legal disputes or liabilities arising from secondary development based on this project +// 本组织与贡献者概不承担。 +// shall be borne solely by the developer; the project organization and contributors assume no responsibility. +// GitHub 仓库:https://github.com/GameFrameX +// GitHub Repository: https://github.com/GameFrameX +// Gitee 仓库:https://gitee.com/GameFrameX +// Gitee Repository: https://gitee.com/GameFrameX +// CNB 仓库:https://cnb.cool/GameFrameX +// CNB Repository: https://cnb.cool/GameFrameX +// 官方文档:https://gameframex.doc.alianblank.com/ +// Official Documentation: https://gameframex.doc.alianblank.com/ +// ========================================================================================== + + +using GameFrameX.NetWork.RemoteMessaging.Routing; + +namespace GameFrameX.Tests.Topology.Equivalence; + +/// +/// 语义等价用例集的 4 条代表性跨 Role 消息链路(C143c D9)。 +/// +/// +/// The four representative cross-role chains of the equivalence suite (C143c D9): +/// Gate→Game session establishment, Game→Social friend query, Game→Match match request +/// and Match→Game settlement callback. Every chain installs its role handlers on the +/// world, then issues three sequential request round trips (deterministic player ids), +/// so receive order and final state are fully deterministic and comparable across +/// topologies. Reply hops are routed from inside the request handler — the same +/// request/reply shape business code will use through the routing seam. +/// All state values are derived deterministically from the request ids: equivalence +/// assertions compare fixed strings, never random values. +/// +public static class CrossRoleChains +{ + /// + /// Gate Role 名。 + /// + public const string GateRoleName = "Gate"; + + /// + /// Game Role 名。 + /// + public const string GameRoleName = "Game"; + + /// + /// Social Role 名。 + /// + public const string SocialRoleName = "Social"; + + /// + /// Match Role 名。 + /// + public const string MatchRoleName = "Match"; + + /// + /// 链路使用的玩家 Id 序列(确定性)。 + /// + public static readonly long[] ChainPlayerIds = new long[] { 1001, 1002, 1003 }; + + /// + /// 链路使用的对局 Id 序列(确定性)。 + /// + public static readonly long[] ChainMatchIds = new long[] { 5001, 5002, 5003 }; + + /// + /// 链路一:Gate→Game 会话建立(Game 应答回 Gate)。 + /// + /// 拓扑世界 / The topology world + /// 跳超时(毫秒)/ The hop timeout in milliseconds + /// Game 处理延时(毫秒,超时等价对用)/ The Game handler delay in milliseconds (for the timeout equivalence pair) + /// 链路执行结果 / The chain execution result + public static async Task RunGateToGameSessionEstablishAsync(TopologyWorld world, int hopTimeoutMilliseconds, int gameHandlerDelayMilliseconds = 0) + { + var establishedPlayerIds = new List(); + var acknowledgedPlayerIds = new List(); + + world.GetMailbox(GameRoleName).SetHandler(async envelope => + { + if (envelope.Message is EquivalenceTestMessages.SessionEstablishRequest request) + { + if (gameHandlerDelayMilliseconds > 0) + { + await Task.Delay(gameHandlerDelayMilliseconds); + } + + establishedPlayerIds.Add(request.PlayerId); + world.SetRoleState(GameRoleName, "SessionEstablished", string.Join(",", establishedPlayerIds)); + await world.RouteAsync( + GameRoleName, + GateRoleName, + new EquivalenceTestMessages.SessionEstablishResponse { PlayerId = request.PlayerId, Accepted = true }, + hopTimeoutMilliseconds); + } + }); + + world.GetMailbox(GateRoleName).SetHandler(envelope => + { + if (envelope.Message is EquivalenceTestMessages.SessionEstablishResponse response) + { + acknowledgedPlayerIds.Add(response.PlayerId); + world.SetRoleState(GateRoleName, "SessionAcknowledged", string.Join(",", acknowledgedPlayerIds)); + } + + return Task.CompletedTask; + }); + + foreach (var playerId in ChainPlayerIds) + { + await world.RouteAsync( + GateRoleName, + GameRoleName, + new EquivalenceTestMessages.SessionEstablishRequest { PlayerId = playerId }, + hopTimeoutMilliseconds); + } + + return world.CaptureResult(); + } + + /// + /// 链路二:Game→Social 好友查询(Social 应答回 Game)。 + /// + /// 拓扑世界 / The topology world + /// 跳超时(毫秒)/ The hop timeout in milliseconds + /// Social 处理延时(毫秒,超时等价对用)/ The Social handler delay in milliseconds (for the timeout equivalence pair) + /// 链路执行结果 / The chain execution result + public static async Task RunGameToSocialFriendQueryAsync(TopologyWorld world, int hopTimeoutMilliseconds, int socialHandlerDelayMilliseconds = 0) + { + var servedPlayerIds = new List(); + var receivedFriendLists = new List(); + + world.GetMailbox(SocialRoleName).SetHandler(async envelope => + { + if (envelope.Message is EquivalenceTestMessages.FriendListRequest request) + { + if (socialHandlerDelayMilliseconds > 0) + { + await Task.Delay(socialHandlerDelayMilliseconds); + } + + servedPlayerIds.Add(request.PlayerId); + world.SetRoleState(SocialRoleName, "FriendListServed", string.Join(",", servedPlayerIds)); + var friendPlayerIds = new long[] { request.PlayerId + 1000, request.PlayerId + 2000 }; + await world.RouteAsync( + SocialRoleName, + GameRoleName, + new EquivalenceTestMessages.FriendListResponse { PlayerId = request.PlayerId, FriendPlayerIds = friendPlayerIds }, + hopTimeoutMilliseconds); + } + }); + + world.GetMailbox(GameRoleName).SetHandler(envelope => + { + if (envelope.Message is EquivalenceTestMessages.FriendListResponse response) + { + receivedFriendLists.Add($"{response.PlayerId}:{string.Join("+", response.FriendPlayerIds)}"); + world.SetRoleState(GameRoleName, "FriendQueryResult", string.Join(",", receivedFriendLists)); + } + + return Task.CompletedTask; + }); + + foreach (var playerId in ChainPlayerIds) + { + await world.RouteAsync( + GameRoleName, + SocialRoleName, + new EquivalenceTestMessages.FriendListRequest { PlayerId = playerId }, + hopTimeoutMilliseconds); + } + + return world.CaptureResult(); + } + + /// + /// 链路三:Game→Match 匹配请求(Match 应答回 Game)。 + /// + /// 拓扑世界 / The topology world + /// 跳超时(毫秒)/ The hop timeout in milliseconds + /// 链路执行结果 / The chain execution result + public static async Task RunGameToMatchJoinRequestAsync(TopologyWorld world, int hopTimeoutMilliseconds) + { + var issuedTickets = new List(); + var receivedTickets = new List(); + + world.GetMailbox(MatchRoleName).SetHandler(async envelope => + { + if (envelope.Message is EquivalenceTestMessages.MatchJoinRequest request) + { + var ticketId = $"ticket-{request.PlayerId}"; + issuedTickets.Add($"{request.PlayerId}:{ticketId}"); + world.SetRoleState(MatchRoleName, "MatchTicketIssued", string.Join(",", issuedTickets)); + await world.RouteAsync( + MatchRoleName, + GameRoleName, + new EquivalenceTestMessages.MatchJoinResponse { PlayerId = request.PlayerId, TicketId = ticketId }, + hopTimeoutMilliseconds); + } + }); + + world.GetMailbox(GameRoleName).SetHandler(envelope => + { + if (envelope.Message is EquivalenceTestMessages.MatchJoinResponse response) + { + receivedTickets.Add($"{response.PlayerId}:{response.TicketId}"); + world.SetRoleState(GameRoleName, "MatchTicketReceived", string.Join(",", receivedTickets)); + } + + return Task.CompletedTask; + }); + + foreach (var playerId in ChainPlayerIds) + { + await world.RouteAsync( + GameRoleName, + MatchRoleName, + new EquivalenceTestMessages.MatchJoinRequest { PlayerId = playerId }, + hopTimeoutMilliseconds); + } + + return world.CaptureResult(); + } + + /// + /// 链路四:Match→Game 结算回调(Game 确认回 Match)。 + /// + /// 拓扑世界 / The topology world + /// 跳超时(毫秒)/ The hop timeout in milliseconds + /// 链路执行结果 / The chain execution result + public static async Task RunMatchToGameSettlementCallbackAsync(TopologyWorld world, int hopTimeoutMilliseconds) + { + var appliedSettlements = new List(); + var acknowledgedSettlements = new List(); + + world.GetMailbox(GameRoleName).SetHandler(async envelope => + { + if (envelope.Message is EquivalenceTestMessages.SettlementCallbackMessage callback) + { + appliedSettlements.Add($"{callback.MatchId}:{callback.WinnerPlayerId}"); + world.SetRoleState(GameRoleName, "SettlementApplied", string.Join(",", appliedSettlements)); + await world.RouteAsync( + GameRoleName, + MatchRoleName, + new EquivalenceTestMessages.SettlementAckMessage { MatchId = callback.MatchId, WinnerPlayerId = callback.WinnerPlayerId }, + hopTimeoutMilliseconds); + } + }); + + world.GetMailbox(MatchRoleName).SetHandler(envelope => + { + if (envelope.Message is EquivalenceTestMessages.SettlementAckMessage acknowledgement) + { + acknowledgedSettlements.Add($"{acknowledgement.MatchId}:{acknowledgement.WinnerPlayerId}"); + world.SetRoleState(MatchRoleName, "SettlementAcknowledged", string.Join(",", acknowledgedSettlements)); + } + + return Task.CompletedTask; + }); + + for (var index = 0; index < ChainMatchIds.Length; index++) + { + await world.RouteAsync( + MatchRoleName, + GameRoleName, + new EquivalenceTestMessages.SettlementCallbackMessage { MatchId = ChainMatchIds[index], WinnerPlayerId = 2000 + index }, + hopTimeoutMilliseconds); + } + + return world.CaptureResult(); + } +} diff --git a/Tests/GameFrameX.Tests/Topology/Equivalence/EquivalenceTestMessages.cs b/Tests/GameFrameX.Tests/Topology/Equivalence/EquivalenceTestMessages.cs new file mode 100644 index 00000000..dad2e214 --- /dev/null +++ b/Tests/GameFrameX.Tests/Topology/Equivalence/EquivalenceTestMessages.cs @@ -0,0 +1,187 @@ +// ========================================================================================== +// GameFrameX 组织及其衍生项目的版权、商标、专利及其他相关权利 +// GameFrameX organization and its derivative projects' copyrights, trademarks, patents, and related rights +// 均受中华人民共和国及相关国际法律法规保护。 +// are protected by the laws of the People's Republic of China and related international regulations. +// 使用本项目须严格遵守相应法律法规与开源许可证之规定。 +// Usage of this project must strictly comply with applicable laws, regulations, and open-source licenses. +// 本项目采用 Apache License 2.0 单协议分发, +// This project is licensed solely under the Apache License 2.0, +// 完整许可证文本请参见源代码根目录下的 LICENSE 文件。 +// please see the LICENSE file in the root directory of the source code for the full license text. +// 禁止利用本项目实施任何危害国家安全、破坏社会秩序、 +// It is prohibited to use this project to engage in any activities that endanger national security, disrupt social order, +// 侵犯他人合法权益等法律法规所禁止的行为! +// or infringe upon the legitimate rights and interests of others as prohibited by laws and regulations! +// 因基于本项目二次开发所产生的一切法律纠纷与责任, +// Any legal disputes or liabilities arising from secondary development based on this project +// 本组织与贡献者概不承担。 +// shall be borne solely by the developer; the project organization and contributors assume no responsibility. +// GitHub 仓库:https://github.com/GameFrameX +// GitHub Repository: https://github.com/GameFrameX +// Gitee 仓库:https://gitee.com/GameFrameX +// Gitee Repository: https://gitee.com/GameFrameX +// CNB 仓库:https://cnb.cool/GameFrameX +// CNB Repository: https://cnb.cool/GameFrameX +// 官方文档:https://gameframex.doc.alianblank.com/ +// Official Documentation: https://gameframex.doc.alianblank.com/ +// ========================================================================================== + + +using GameFrameX.NetWork.Messages; + +namespace GameFrameX.Tests.Topology.Equivalence; + +/// +/// 语义等价用例集的测试消息定义(C143c D9)。 +/// +/// +/// Test messages for the topology equivalence suite (C143c D9). +/// Four representative cross-role chains need request/response pairs; they are nested +/// in one static class because they are pure test payloads with no behavior of their own. +/// Values are deterministic (derived from player ids) so both topologies can be compared +/// through identical state snapshots. The messages are never serialized in this suite, +/// so no protobuf contracts are required. +/// +public static class EquivalenceTestMessages +{ + /// + /// 会话建立请求(Gate→Game)。 + /// + public sealed class SessionEstablishRequest : MessageObject + { + /// 玩家 Id / The player id + public long PlayerId { get; set; } + + /// + public override void Clear() + { + PlayerId = 0; + } + } + + /// + /// 会话建立应答(Game→Gate)。 + /// + public sealed class SessionEstablishResponse : MessageObject + { + /// 玩家 Id / The player id + public long PlayerId { get; set; } + + /// 是否已接受 / Whether the session was accepted + public bool Accepted { get; set; } + + /// + public override void Clear() + { + PlayerId = 0; + Accepted = false; + } + } + + /// + /// 好友列表查询请求(Game→Social)。 + /// + public sealed class FriendListRequest : MessageObject + { + /// 玩家 Id / The player id + public long PlayerId { get; set; } + + /// + public override void Clear() + { + PlayerId = 0; + } + } + + /// + /// 好友列表查询应答(Social→Game)。 + /// + public sealed class FriendListResponse : MessageObject + { + /// 玩家 Id / The player id + public long PlayerId { get; set; } + + /// 好友 Id 列表(确定性构造)/ The friend id list (deterministically built) + public long[] FriendPlayerIds { get; set; } + + /// + public override void Clear() + { + PlayerId = 0; + FriendPlayerIds = null; + } + } + + /// + /// 匹配加入请求(Game→Match)。 + /// + public sealed class MatchJoinRequest : MessageObject + { + /// 玩家 Id / The player id + public long PlayerId { get; set; } + + /// + public override void Clear() + { + PlayerId = 0; + } + } + + /// + /// 匹配加入应答(Match→Game)。 + /// + public sealed class MatchJoinResponse : MessageObject + { + /// 玩家 Id / The player id + public long PlayerId { get; set; } + + /// 匹配票据(由玩家 Id 确定性生成)/ The match ticket (deterministically derived from the player id) + public string TicketId { get; set; } + + /// + public override void Clear() + { + PlayerId = 0; + TicketId = null; + } + } + + /// + /// 结算回调(Match→Game)。 + /// + public sealed class SettlementCallbackMessage : MessageObject + { + /// 对局 Id / The match id + public long MatchId { get; set; } + + /// 胜者玩家 Id / The winner player id + public long WinnerPlayerId { get; set; } + + /// + public override void Clear() + { + MatchId = 0; + WinnerPlayerId = 0; + } + } + + /// + /// 结算确认(Game→Match)。 + /// + public sealed class SettlementAckMessage : MessageObject + { + /// 对局 Id / The match id + public long MatchId { get; set; } + + /// 已落地的胜者玩家 Id / The applied winner player id + public long WinnerPlayerId { get; set; } + + /// + public override void Clear() + { + MatchId = 0; + WinnerPlayerId = 0; + } + } +} diff --git a/Tests/GameFrameX.Tests/Topology/Equivalence/InProcessRoleRouterTests.cs b/Tests/GameFrameX.Tests/Topology/Equivalence/InProcessRoleRouterTests.cs new file mode 100644 index 00000000..f7599bf8 --- /dev/null +++ b/Tests/GameFrameX.Tests/Topology/Equivalence/InProcessRoleRouterTests.cs @@ -0,0 +1,191 @@ +// ========================================================================================== +// GameFrameX 组织及其衍生项目的版权、商标、专利及其他相关权利 +// GameFrameX organization and its derivative projects' copyrights, trademarks, patents, and related rights +// 均受中华人民共和国及相关国际法律法规保护。 +// are protected by the laws of the People's Republic of China and related international regulations. +// 使用本项目须严格遵守相应法律法规与开源许可证之规定。 +// Usage of this project must strictly comply with applicable laws, regulations, and open-source licenses. +// 本项目采用 Apache License 2.0 单协议分发, +// This project is licensed solely under the Apache License 2.0, +// 完整许可证文本请参见源代码根目录下的 LICENSE 文件。 +// please see the LICENSE file in the root directory of the source code for the full license text. +// 禁止利用本项目实施任何危害国家安全、破坏社会秩序、 +// It is prohibited to use this project to engage in any activities that endanger national security, disrupt social order, +// 侵犯他人合法权益等法律法规所禁止的行为! +// or infringe upon the legitimate rights and interests of others as prohibited by laws and regulations! +// 因基于本项目二次开发所产生的一切法律纠纷与责任, +// Any legal disputes or liabilities arising from secondary development based on this project +// 本组织与贡献者概不承担。 +// shall be borne solely by the developer; the project organization and contributors assume no responsibility. +// GitHub 仓库:https://github.com/GameFrameX +// GitHub Repository: https://github.com/GameFrameX +// Gitee 仓库:https://gitee.com/GameFrameX +// Gitee Repository: https://gitee.com/GameFrameX +// CNB 仓库:https://cnb.cool/GameFrameX +// CNB Repository: https://cnb.cool/GameFrameX +// 官方文档:https://gameframex.doc.alianblank.com/ +// Official Documentation: https://gameframex.doc.alianblank.com/ +// ========================================================================================== + + +using GameFrameX.NetWork.RemoteMessaging.Routing; + +namespace GameFrameX.Tests.Topology.Equivalence; + +/// +/// InProcessRoleRouter D3 三步判定单元测试(C143c)。 +/// +/// +/// Unit tests for the InProcessRoleRouter three-step decision (C143c): +/// case 1 goes through the local dispatcher (mock), case 2/3 delegates to the remote +/// seam — with the production placeholder that throws NotImplementedException — and +/// every undecidable route fails loudly with RouteNotFoundException. +/// +public class InProcessRoleRouterTests +{ + /// + /// 记录型本地投递器(mock Actor 投递缝)。 + /// + private sealed class RecordingLocalDispatcher : ILocalRoleMessageDispatcher + { + /// 已收到的信封 / The received envelopes + public List ReceivedEnvelopes { get; } = new List(); + + /// + public Task DispatchAsync(MessageEnvelope envelope, CancellationToken cancellationToken = default) + { + ReceivedEnvelopes.Add(envelope); + return Task.CompletedTask; + } + } + + /// + /// 固定应答型远程转发器(mock D3 case 2/3 缝)。 + /// + private sealed class StubRemoteRoleRouter : IRemoteRoleRouter + { + /// 已收到的信封 / The received envelopes + public List ReceivedEnvelopes { get; } = new List(); + + /// + public Task ForwardAsync(MessageEnvelope envelope, CancellationToken cancellationToken = default) + { + ReceivedEnvelopes.Add(envelope); + return Task.FromResult(RoleRouteDelivery.RemoteForwarded); + } + } + + /// + /// case 1:目标 Role 属于本进程角色集 → 经本地投递器送达并返回 LocalActor。 + /// + [Fact] + public async Task RouteAsync_TargetRoleHostedLocally_DispatchesThroughLocalDispatcher() + { + var localDispatcher = new RecordingLocalDispatcher(); + var router = new InProcessRoleRouter(new List { "Game", "Social" }, localDispatcher); + var envelope = new MessageEnvelope("Game", new EquivalenceTestMessages.SessionEstablishRequest { PlayerId = 1001 }); + + var delivery = await router.RouteAsync(envelope); + + Assert.Equal(RoleRouteDelivery.LocalActor, delivery); + Assert.Single(localDispatcher.ReceivedEnvelopes); + Assert.Same(envelope, localDispatcher.ReceivedEnvelopes[0]); + } + + /// + /// case 1 命中但未配置本地投递器 → 显式抛 RouteNotFoundException。 + /// + [Fact] + public async Task RouteAsync_LocalTargetWithoutDispatcher_ThrowsRouteNotFound() + { + var router = new InProcessRoleRouter(new List { "Game" }, null); + var envelope = new MessageEnvelope("Game", new EquivalenceTestMessages.SessionEstablishRequest()); + + var exception = await Assert.ThrowsAsync(() => router.RouteAsync(envelope)); + + Assert.Equal("Game", exception.TargetRole); + } + + /// + /// case 2/3:目标 Role 不属于本进程 → 委托远程缝;生产占位 RemoteRoleRouter 恒抛 NotImplementedException。 + /// + [Fact] + public async Task RouteAsync_RemoteTargetWithPlaceholderForwarder_ThrowsNotImplemented() + { + var router = new InProcessRoleRouter(new List { "Gate" }, new RecordingLocalDispatcher(), new RemoteRoleRouter()); + var envelope = new MessageEnvelope("Game", new EquivalenceTestMessages.SessionEstablishRequest()); + + await Assert.ThrowsAsync(() => router.RouteAsync(envelope)); + } + + /// + /// case 2/3:目标 Role 不属于本进程 → 委托远程缝并原样返回转发结果。 + /// + [Fact] + public async Task RouteAsync_RemoteTargetWithForwarder_ForwardsAndReturnsRemoteForwarded() + { + var remoteRouter = new StubRemoteRoleRouter(); + var router = new InProcessRoleRouter(new List { "Gate" }, new RecordingLocalDispatcher(), remoteRouter); + var envelope = new MessageEnvelope("Game", new EquivalenceTestMessages.SessionEstablishRequest(), targetInstanceId: "game-instance-01"); + + var delivery = await router.RouteAsync(envelope); + + Assert.Equal(RoleRouteDelivery.RemoteForwarded, delivery); + Assert.Single(remoteRouter.ReceivedEnvelopes); + Assert.Same(envelope, remoteRouter.ReceivedEnvelopes[0]); + } + + /// + /// case 2/3 未配置远程缝 → 显式抛 RouteNotFoundException(不静默)。 + /// + [Fact] + public async Task RouteAsync_RemoteTargetWithoutForwarder_ThrowsRouteNotFound() + { + var router = new InProcessRoleRouter(new List { "Gate" }, new RecordingLocalDispatcher()); + var envelope = new MessageEnvelope("Game", new EquivalenceTestMessages.SessionEstablishRequest()); + + var exception = await Assert.ThrowsAsync(() => router.RouteAsync(envelope)); + + Assert.Equal("Game", exception.TargetRole); + } + + /// + /// 空白目标 Role → 路由决策失败抛 RouteNotFoundException。 + /// + [Fact] + public async Task RouteAsync_EmptyTargetRole_ThrowsRouteNotFound() + { + var router = new InProcessRoleRouter(new List { "Game" }, new RecordingLocalDispatcher()); + var envelope = new MessageEnvelope(" ", new EquivalenceTestMessages.SessionEstablishRequest()); + + await Assert.ThrowsAsync(() => router.RouteAsync(envelope)); + } + + /// + /// null 信封 → ArgumentNullException。 + /// + [Fact] + public async Task RouteAsync_NullEnvelope_ThrowsArgumentNullException() + { + var router = new InProcessRoleRouter(new List { "Game" }, new RecordingLocalDispatcher()); + + await Assert.ThrowsAsync(() => router.RouteAsync(null)); + } + + /// + /// 构造后源集合变更不影响路由判定(角色集快照防御性拷贝)。 + /// + [Fact] + public async Task Constructor_DefensivelyCopiesHostedRoleNames_LaterMutationDoesNotAffectRouting() + { + var hostedRoleNames = new List { "Game" }; + var localDispatcher = new RecordingLocalDispatcher(); + var router = new InProcessRoleRouter(hostedRoleNames, localDispatcher); + + hostedRoleNames.Clear(); + + var delivery = await router.RouteAsync(new MessageEnvelope("Game", new EquivalenceTestMessages.SessionEstablishRequest())); + + Assert.Equal(RoleRouteDelivery.LocalActor, delivery); + } +} diff --git a/Tests/GameFrameX.Tests/Topology/Equivalence/LoopbackRemoteRoleRouter.cs b/Tests/GameFrameX.Tests/Topology/Equivalence/LoopbackRemoteRoleRouter.cs new file mode 100644 index 00000000..887914c2 --- /dev/null +++ b/Tests/GameFrameX.Tests/Topology/Equivalence/LoopbackRemoteRoleRouter.cs @@ -0,0 +1,86 @@ +// ========================================================================================== +// GameFrameX 组织及其衍生项目的版权、商标、专利及其他相关权利 +// GameFrameX organization and its derivative projects' copyrights, trademarks, patents, and related rights +// 均受中华人民共和国及相关国际法律法规保护。 +// are protected by the laws of the People's Republic of China and related international regulations. +// 使用本项目须严格遵守相应法律法规与开源许可证之规定。 +// Usage of this project must strictly comply with applicable laws, regulations, and open-source licenses. +// 本项目采用 Apache License 2.0 单协议分发, +// This project is licensed solely under the Apache License 2.0, +// 完整许可证文本请参见源代码根目录下的 LICENSE 文件。 +// please see the LICENSE file in the root directory of the source code for the full license text. +// 禁止利用本项目实施任何危害国家安全、破坏社会秩序、 +// It is prohibited to use this project to engage in any activities that endanger national security, disrupt social order, +// 侵犯他人合法权益等法律法规所禁止的行为! +// or infringe upon the legitimate rights and interests of others as prohibited by laws and regulations! +// 因基于本项目二次开发所产生的一切法律纠纷与责任, +// Any legal disputes or liabilities arising from secondary development based on this project +// 本组织与贡献者概不承担。 +// shall be borne solely by the developer; the project organization and contributors assume no responsibility. +// GitHub 仓库:https://github.com/GameFrameX +// GitHub Repository: https://github.com/GameFrameX +// Gitee 仓库:https://gitee.com/GameFrameX +// Gitee Repository: https://gitee.com/GameFrameX +// CNB 仓库:https://cnb.cool/GameFrameX +// CNB Repository: https://cnb.cool/GameFrameX +// 官方文档:https://gameframex.doc.alianblank.com/ +// Official Documentation: https://gameframex.doc.alianblank.com/ +// ========================================================================================== + + +using GameFrameX.NetWork.RemoteMessaging.Routing; + +namespace GameFrameX.Tests.Topology.Equivalence; + +/// +/// 多进程拓扑的环回远程转发器(C143c D9 等价用例集)。 +/// +/// +/// Loopback remote forwarder for the MultiProcess topology fixture (C143c D9). +/// Inside one test process the D3 case 2/3 remote hop cannot use real RemoteMessaging +/// (the production forwarder is the C143d placeholder that throws NotImplementedException), +/// so the fixture substitutes this loopback: it resolves the target role to the simulated +/// process cell that hosts it and routes the envelope into that cell's router — the same +/// decision C143d's reachability table will make, minus the physical transport. +/// Cancellation flows through the whole path so timeout behavior stays comparable +/// across topologies. +/// +public sealed class LoopbackRemoteRoleRouter : IRemoteRoleRouter +{ + /// + /// 目标 Role 名 → 承载进程 cell 的路由器。 + /// + private readonly IReadOnlyDictionary _cellRoutersByRole; + + /// + /// 初始化环回转发器。 + /// + /// 目标 Role 名 → cell 路由器映射 / The map of target role name to the hosting cell's router + public LoopbackRemoteRoleRouter(IReadOnlyDictionary cellRoutersByRole) + { + _cellRoutersByRole = cellRoutersByRole ?? throw new ArgumentNullException(nameof(cellRoutersByRole)); + } + + /// + /// 将信封环回路由进承载目标 Role 的 cell。 + /// + /// 路由信封 / The routing envelope + /// 取消操作的令牌 / The cancellation token + /// 恒为 / Always + /// 当目标 Role 不在任何 cell 时抛出 / Thrown when no cell hosts the target role + public async Task ForwardAsync(MessageEnvelope envelope, CancellationToken cancellationToken = default) + { + if (envelope == null) + { + throw new ArgumentNullException(nameof(envelope)); + } + + if (!_cellRoutersByRole.TryGetValue(envelope.TargetRole, out var cellRouter)) + { + throw new RouteNotFoundException(envelope.TargetRole, $"No simulated process cell hosts target role '{envelope.TargetRole}'."); + } + + await cellRouter.RouteAsync(envelope, cancellationToken); + return RoleRouteDelivery.RemoteForwarded; + } +} diff --git a/Tests/GameFrameX.Tests/Topology/Equivalence/RoleMailbox.cs b/Tests/GameFrameX.Tests/Topology/Equivalence/RoleMailbox.cs new file mode 100644 index 00000000..b793e2f4 --- /dev/null +++ b/Tests/GameFrameX.Tests/Topology/Equivalence/RoleMailbox.cs @@ -0,0 +1,193 @@ +// ========================================================================================== +// GameFrameX 组织及其衍生项目的版权、商标、专利及其他相关权利 +// GameFrameX organization and its derivative projects' copyrights, trademarks, patents, and related rights +// 均受中华人民共和国及相关国际法律法规保护。 +// are protected by the laws of the People's Republic of China and related international regulations. +// 使用本项目须严格遵守相应法律法规与开源许可证之规定。 +// Usage of this project must strictly comply with applicable laws, regulations, and open-source licenses. +// 本项目采用 Apache License 2.0 单协议分发, +// This project is licensed solely under the Apache License 2.0, +// 完整许可证文本请参见源代码根目录下的 LICENSE 文件。 +// please see the LICENSE file in the root directory of the source code for the full license text. +// 禁止利用本项目实施任何危害国家安全、破坏社会秩序、 +// It is prohibited to use this project to engage in any activities that endanger national security, disrupt social order, +// 侵犯他人合法权益等法律法规所禁止的行为! +// or infringe upon the legitimate rights and interests of others as prohibited by laws and regulations! +// 因基于本项目二次开发所产生的一切法律纠纷与责任, +// Any legal disputes or liabilities arising from secondary development based on this project +// 本组织与贡献者概不承担。 +// shall be borne solely by the developer; the project organization and contributors assume no responsibility. +// GitHub 仓库:https://github.com/GameFrameX +// GitHub Repository: https://github.com/GameFrameX +// Gitee 仓库:https://gitee.com/GameFrameX +// Gitee Repository: https://gitee.com/GameFrameX +// CNB 仓库:https://cnb.cool/GameFrameX +// CNB Repository: https://cnb.cool/GameFrameX +// 官方文档:https://gameframex.doc.alianblank.com/ +// Official Documentation: https://gameframex.doc.alianblank.com/ +// ========================================================================================== + + +using System.Threading.Channels; +using GameFrameX.NetWork.RemoteMessaging.Routing; + +namespace GameFrameX.Tests.Topology.Equivalence; + +/// +/// 等价用例集的单 Role 邮箱(C143c D9)。 +/// +/// +/// Per-role mailbox for the topology equivalence suite (C143c D9). +/// Simulates one role's in-process message queue with the same guarantees the actor +/// pipeline provides in production: strict per-role FIFO processing, one message at a +/// time, and senders may either fire-and-forget or await the handler's completion +/// (the Actor.Tell/SendAsync semantics behind D3 case 1). +/// Known ceiling: this is a lightweight queue, not the real Actor; the real actor-backed +/// local dispatcher arrives with C143e and is exercised there. +/// The arrival log records the message type name of every processed message so the +/// equivalence tests can assert identical receive order across topologies. +/// +public sealed class RoleMailbox +{ + /// + /// 邮箱工作项:信封 + 完成源。 + /// + private sealed class MailboxWorkItem + { + /// + /// 初始化工作项。 + /// + public MailboxWorkItem(MessageEnvelope envelope) + { + Envelope = envelope; + Completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + } + + /// 路由信封 / The routing envelope + public MessageEnvelope Envelope { get; } + + /// 处理完成源 / The handler completion source + public TaskCompletionSource Completion { get; } + } + + /// + /// Role 名。 + /// + private readonly string _roleName; + + /// + /// FIFO 工作项通道(单消费者)。 + /// + private readonly Channel _channel = Channel.CreateUnbounded( + new UnboundedChannelOptions { SingleReader = true, SingleWriter = false }); + + /// + /// 单消费者处理任务。 + /// + private readonly Task _processingTask; + + /// + /// 到达日志锁。 + /// + private readonly object _arrivalLogLock = new object(); + + /// + /// 按处理顺序记录的消息类型名日志。 + /// + private readonly List _arrivalLog = new List(); + + /// + /// 当前消息处理器。 + /// + private Func _handler = _ => Task.CompletedTask; + + /// + /// 初始化单 Role 邮箱并启动 FIFO 消费者。 + /// + /// Role 名 / The role name + public RoleMailbox(string roleName) + { + _roleName = roleName; + _processingTask = Task.Run(ProcessAsync); + } + + /// + /// 获取 Role 名。 + /// + public string RoleName + { + get { return _roleName; } + } + + /// + /// 设置消息处理器(每个测试安装一次)。 + /// + /// 消息处理器 / The message handler + public void SetHandler(Func handler) + { + _handler = handler ?? throw new ArgumentNullException(nameof(handler)); + } + + /// + /// 投递一封信封:入队后等待处理完成,等待期尊重取消令牌(超时语义)。 + /// + /// + /// Enqueues the envelope and waits for its handler to complete. + /// The enqueue itself is never cancelled by the hop timeout (the message has been + /// committed to the queue once routing accepted it); only the wait observes the + /// cancellation token, which is exactly the SendAsync-with-timeout semantics both + /// topologies are compared on. + /// + /// 路由信封 / The routing envelope + /// 取消操作的令牌 / The cancellation token + public async Task DeliverAsync(MessageEnvelope envelope, CancellationToken cancellationToken) + { + var item = new MailboxWorkItem(envelope); + await _channel.Writer.WriteAsync(item, CancellationToken.None); + await item.Completion.Task.WaitAsync(cancellationToken); + } + + /// + /// 获取到达日志快照(按处理顺序的消息类型名)。 + /// + /// 消息类型名列表 / The ordered message type names + public IReadOnlyList GetArrivalLog() + { + lock (_arrivalLogLock) + { + return _arrivalLog.ToList(); + } + } + + /// + /// 关闭邮箱(停止接收新消息,测试收尾用)。 + /// + public void Close() + { + _channel.Writer.TryComplete(); + } + + /// + /// 单消费者处理循环:严格按 FIFO 逐条调用处理器并记录到达日志。 + /// + private async Task ProcessAsync() + { + await foreach (var item in _channel.Reader.ReadAllAsync()) + { + lock (_arrivalLogLock) + { + _arrivalLog.Add(item.Envelope.Message.GetType().Name); + } + + try + { + await _handler(item.Envelope); + item.Completion.SetResult(true); + } + catch (Exception exception) + { + item.Completion.SetException(exception); + } + } + } +} diff --git a/Tests/GameFrameX.Tests/Topology/Equivalence/TopologyEquivalenceTests.cs b/Tests/GameFrameX.Tests/Topology/Equivalence/TopologyEquivalenceTests.cs new file mode 100644 index 00000000..99d2d201 --- /dev/null +++ b/Tests/GameFrameX.Tests/Topology/Equivalence/TopologyEquivalenceTests.cs @@ -0,0 +1,293 @@ +// ========================================================================================== +// GameFrameX 组织及其衍生项目的版权、商标、专利及其他相关权利 +// GameFrameX organization and its derivative projects' copyrights, trademarks, patents, and related rights +// 均受中华人民共和国及相关国际法律法规保护。 +// are protected by the laws of the People's Republic of China and related international regulations. +// 使用本项目须严格遵守相应法律法规与开源许可证之规定。 +// Usage of this project must strictly comply with applicable laws, regulations, and open-source licenses. +// 本项目采用 Apache License 2.0 单协议分发, +// This project is licensed solely under the Apache License 2.0, +// 完整许可证文本请参见源代码根目录下的 LICENSE 文件。 +// please see the LICENSE file in the root directory of the source code for the full license text. +// 禁止利用本项目实施任何危害国家安全、破坏社会秩序、 +// It is prohibited to use this project to engage in any activities that endanger national security, disrupt social order, +// 侵犯他人合法权益等法律法规所禁止的行为! +// or infringe upon the legitimate rights and interests of others as prohibited by laws and regulations! +// 因基于本项目二次开发所产生的一切法律纠纷与责任, +// Any legal disputes or liabilities arising from secondary development based on this project +// 本组织与贡献者概不承担。 +// shall be borne solely by the developer; the project organization and contributors assume no responsibility. +// GitHub 仓库:https://github.com/GameFrameX +// GitHub Repository: https://github.com/GameFrameX +// Gitee 仓库:https://gitee.com/GameFrameX +// Gitee Repository: https://gitee.com/GameFrameX +// CNB 仓库:https://cnb.cool/GameFrameX +// CNB Repository: https://cnb.cool/GameFrameX +// 官方文档:https://gameframex.doc.alianblank.com/ +// Official Documentation: https://gameframex.doc.alianblank.com/ +// ========================================================================================== + + +using GameFrameX.NetWork.RemoteMessaging.Routing; + +namespace GameFrameX.Tests.Topology.Equivalence; + +/// +/// 跨 Role 消息链路语义等价测试(C143c D9,AC-2)。 +/// +/// +/// Topology equivalence suite (C143c D9, AC-2): every representative chain runs in the +/// All-in-One topology and in the design-source §D9 three-process topology, and both +/// assert the SAME fixed expectations — per-role receive order, final business state +/// snapshot, and the D3 delivery branch sequence — so semantic equivalence between the +/// topologies is enforced by the CI gate (topology-equivalence.yml). +/// The All-in-One cases additionally prove the D3 acceptance property: case 1 local +/// delivery is hit by 100% of the hops. +/// +public class TopologyEquivalenceTests +{ + /// + /// 等价链路种类。 + /// + public enum EquivalenceChainKind + { + /// Gate→Game 会话建立 / Gate→Game session establishment + GateToGameSessionEstablish = 1, + + /// Game→Social 好友查询 / Game→Social friend query + GameToSocialFriendQuery = 2, + + /// Game→Match 匹配请求 / Game→Match match request + GameToMatchJoinRequest = 3, + + /// Match→Game 结算回调 / Match→Game settlement callback + MatchToGameSettlementCallback = 4, + } + + /// + /// 等价拓扑种类。 + /// + public enum EquivalenceTopologyKind + { + /// All-in-One 单进程 / All-in-One single process + AllInOne = 1, + + /// 三进程(设计源 §D9:Gate | Game+Social | Match)/ Three processes (§D9) + MultiProcess = 2, + } + + /// + /// 常规链路跳超时(毫秒)——远大于处理耗时,两拓扑都应成功。 + /// + private const int ComfortableHopTimeoutMilliseconds = 5000; + + /// + /// 紧跳超时(毫秒)——远小于处理器延时,两拓扑都应失败。 + /// + private const int TightHopTimeoutMilliseconds = 80; + + /// + /// 处理器延时(毫秒)——制造必超时的处理耗时。 + /// + private const int HandlerDelayMilliseconds = 400; + + /// + /// 四条代表性链路 × 两拓扑:消息序、最终状态、投递分支全部符合固定预期(8 用例)。 + /// + [Theory] + [InlineData(EquivalenceChainKind.GateToGameSessionEstablish, EquivalenceTopologyKind.AllInOne)] + [InlineData(EquivalenceChainKind.GateToGameSessionEstablish, EquivalenceTopologyKind.MultiProcess)] + [InlineData(EquivalenceChainKind.GameToSocialFriendQuery, EquivalenceTopologyKind.AllInOne)] + [InlineData(EquivalenceChainKind.GameToSocialFriendQuery, EquivalenceTopologyKind.MultiProcess)] + [InlineData(EquivalenceChainKind.GameToMatchJoinRequest, EquivalenceTopologyKind.AllInOne)] + [InlineData(EquivalenceChainKind.GameToMatchJoinRequest, EquivalenceTopologyKind.MultiProcess)] + [InlineData(EquivalenceChainKind.MatchToGameSettlementCallback, EquivalenceTopologyKind.AllInOne)] + [InlineData(EquivalenceChainKind.MatchToGameSettlementCallback, EquivalenceTopologyKind.MultiProcess)] + public async Task CrossRoleChain_BothTopologies_PreserveOrderStateAndDeliveryBranch( + EquivalenceChainKind chainKind, + EquivalenceTopologyKind topologyKind) + { + using (var world = CreateWorld(topologyKind)) + { + var result = await RunChainAsync(world, chainKind, ComfortableHopTimeoutMilliseconds); + + AssertExpectedArrivalOrder(chainKind, result); + AssertExpectedStateSnapshot(chainKind, result); + AssertExpectedDeliveryBranches(chainKind, topologyKind, result); + } + } + + /// + /// 超时行为等价:相同紧超时参数下,两拓扑对本地跳(Game→Social)与跨进程跳(Gate→Game)都同样失败。 + /// + [Fact] + public async Task CrossRoleChain_WithTightTimeout_FailsInBothTopologies() + { + using (var allInOneWorld = CreateWorld(EquivalenceTopologyKind.AllInOne)) + using (var multiProcessWorld = CreateWorld(EquivalenceTopologyKind.MultiProcess)) + { + // 本地跳(两拓扑内 Game→Social 均为 case 1) + await Assert.ThrowsAnyAsync(() => + CrossRoleChains.RunGameToSocialFriendQueryAsync(allInOneWorld, TightHopTimeoutMilliseconds, HandlerDelayMilliseconds)); + await Assert.ThrowsAnyAsync(() => + CrossRoleChains.RunGameToSocialFriendQueryAsync(multiProcessWorld, TightHopTimeoutMilliseconds, HandlerDelayMilliseconds)); + + // 跨进程跳(三进程拓扑内 Gate→Game 经环回转发缝) + await Assert.ThrowsAnyAsync(() => + CrossRoleChains.RunGateToGameSessionEstablishAsync(allInOneWorld, TightHopTimeoutMilliseconds, HandlerDelayMilliseconds)); + await Assert.ThrowsAnyAsync(() => + CrossRoleChains.RunGateToGameSessionEstablishAsync(multiProcessWorld, TightHopTimeoutMilliseconds, HandlerDelayMilliseconds)); + } + } + + /// + /// 创建指定拓扑的世界。 + /// + private static TopologyWorld CreateWorld(EquivalenceTopologyKind topologyKind) + { + if (topologyKind == EquivalenceTopologyKind.AllInOne) + { + return TopologyWorld.CreateAllInOne(new List + { + CrossRoleChains.GateRoleName, + CrossRoleChains.GameRoleName, + CrossRoleChains.SocialRoleName, + CrossRoleChains.MatchRoleName, + }); + } + + return TopologyWorld.CreateMultiProcess( + CrossRoleChains.GateRoleName, + CrossRoleChains.GameRoleName, + CrossRoleChains.SocialRoleName, + CrossRoleChains.MatchRoleName); + } + + /// + /// 运行指定链路。 + /// + private static Task RunChainAsync(TopologyWorld world, EquivalenceChainKind chainKind, int hopTimeoutMilliseconds) + { + if (chainKind == EquivalenceChainKind.GateToGameSessionEstablish) + { + return CrossRoleChains.RunGateToGameSessionEstablishAsync(world, hopTimeoutMilliseconds); + } + + if (chainKind == EquivalenceChainKind.GameToSocialFriendQuery) + { + return CrossRoleChains.RunGameToSocialFriendQueryAsync(world, hopTimeoutMilliseconds); + } + + if (chainKind == EquivalenceChainKind.GameToMatchJoinRequest) + { + return CrossRoleChains.RunGameToMatchJoinRequestAsync(world, hopTimeoutMilliseconds); + } + + return CrossRoleChains.RunMatchToGameSettlementCallbackAsync(world, hopTimeoutMilliseconds); + } + + /// + /// 断言每 Role 到达序符合发送序,且无关 Role 零到达(无误路由)。 + /// + private static void AssertExpectedArrivalOrder(EquivalenceChainKind chainKind, ChainExecutionResult result) + { + if (chainKind == EquivalenceChainKind.GateToGameSessionEstablish) + { + Assert.Equal(Repeat("SessionEstablishRequest", 3), result.ArrivalOrderByRole[CrossRoleChains.GameRoleName]); + Assert.Equal(Repeat("SessionEstablishResponse", 3), result.ArrivalOrderByRole[CrossRoleChains.GateRoleName]); + Assert.Empty(result.ArrivalOrderByRole[CrossRoleChains.SocialRoleName]); + Assert.Empty(result.ArrivalOrderByRole[CrossRoleChains.MatchRoleName]); + } + else if (chainKind == EquivalenceChainKind.GameToSocialFriendQuery) + { + Assert.Equal(Repeat("FriendListRequest", 3), result.ArrivalOrderByRole[CrossRoleChains.SocialRoleName]); + Assert.Equal(Repeat("FriendListResponse", 3), result.ArrivalOrderByRole[CrossRoleChains.GameRoleName]); + Assert.Empty(result.ArrivalOrderByRole[CrossRoleChains.GateRoleName]); + Assert.Empty(result.ArrivalOrderByRole[CrossRoleChains.MatchRoleName]); + } + else if (chainKind == EquivalenceChainKind.GameToMatchJoinRequest) + { + Assert.Equal(Repeat("MatchJoinRequest", 3), result.ArrivalOrderByRole[CrossRoleChains.MatchRoleName]); + Assert.Equal(Repeat("MatchJoinResponse", 3), result.ArrivalOrderByRole[CrossRoleChains.GameRoleName]); + Assert.Empty(result.ArrivalOrderByRole[CrossRoleChains.GateRoleName]); + Assert.Empty(result.ArrivalOrderByRole[CrossRoleChains.SocialRoleName]); + } + else + { + Assert.Equal(Repeat("SettlementCallbackMessage", 3), result.ArrivalOrderByRole[CrossRoleChains.GameRoleName]); + Assert.Equal(Repeat("SettlementAckMessage", 3), result.ArrivalOrderByRole[CrossRoleChains.MatchRoleName]); + Assert.Empty(result.ArrivalOrderByRole[CrossRoleChains.GateRoleName]); + Assert.Empty(result.ArrivalOrderByRole[CrossRoleChains.SocialRoleName]); + } + } + + /// + /// 断言最终业务状态快照等于固定预期(两拓扑共用同一常量 ⇒ 等价)。 + /// + private static void AssertExpectedStateSnapshot(EquivalenceChainKind chainKind, ChainExecutionResult result) + { + if (chainKind == EquivalenceChainKind.GateToGameSessionEstablish) + { + Assert.Equal( + "Game|SessionEstablished=1001,1002,1003;Gate|SessionAcknowledged=1001,1002,1003", + result.StateSnapshot); + } + else if (chainKind == EquivalenceChainKind.GameToSocialFriendQuery) + { + Assert.Equal( + "Game|FriendQueryResult=1001:2001+3001,1002:2002+3002,1003:2003+3003;Social|FriendListServed=1001,1002,1003", + result.StateSnapshot); + } + else if (chainKind == EquivalenceChainKind.GameToMatchJoinRequest) + { + Assert.Equal( + "Game|MatchTicketReceived=1001:ticket-1001,1002:ticket-1002,1003:ticket-1003;Match|MatchTicketIssued=1001:ticket-1001,1002:ticket-1002,1003:ticket-1003", + result.StateSnapshot); + } + else + { + Assert.Equal( + "Game|SettlementApplied=5001:2000,5002:2001,5003:2002;Match|SettlementAcknowledged=5001:2000,5002:2001,5003:2002", + result.StateSnapshot); + } + } + + /// + /// 断言投递分支序列:All-in-One 恒 case 1(D3 验收:100% 命中);三进程拓扑按 cell 划分分支。 + /// + private static void AssertExpectedDeliveryBranches( + EquivalenceChainKind chainKind, + EquivalenceTopologyKind topologyKind, + ChainExecutionResult result) + { + // 3 次请求 + 3 次应答 + Assert.Equal(6, result.Deliveries.Count); + + if (topologyKind == EquivalenceTopologyKind.AllInOne) + { + // D3 验收:All-in-One 形态下 case 1 本地直投被 100% 命中 + Assert.All(result.Deliveries, delivery => Assert.Equal(RoleRouteDelivery.LocalActor, delivery)); + return; + } + + // 三进程拓扑:Gate | Game+Social | Match —— 链路二的全部跳都在 Game+Social cell 内,其余链路全部跨 cell + var expectedBranch = chainKind == EquivalenceChainKind.GameToSocialFriendQuery + ? RoleRouteDelivery.LocalActor + : RoleRouteDelivery.RemoteForwarded; + Assert.All(result.Deliveries, delivery => Assert.Equal(expectedBranch, delivery)); + } + + /// + /// 构造重复的消息名序列。 + /// + private static List Repeat(string messageName, int count) + { + var names = new List(count); + for (var index = 0; index < count; index++) + { + names.Add(messageName); + } + + return names; + } +} diff --git a/Tests/GameFrameX.Tests/Topology/Equivalence/TopologyWorld.cs b/Tests/GameFrameX.Tests/Topology/Equivalence/TopologyWorld.cs new file mode 100644 index 00000000..67a94fe0 --- /dev/null +++ b/Tests/GameFrameX.Tests/Topology/Equivalence/TopologyWorld.cs @@ -0,0 +1,273 @@ +// ========================================================================================== +// GameFrameX 组织及其衍生项目的版权、商标、专利及其他相关权利 +// GameFrameX organization and its derivative projects' copyrights, trademarks, patents, and related rights +// 均受中华人民共和国及相关国际法律法规保护。 +// are protected by the laws of the People's Republic of China and related international regulations. +// 使用本项目须严格遵守相应法律法规与开源许可证之规定。 +// Usage of this project must strictly comply with applicable laws, regulations, and open-source licenses. +// 本项目采用 Apache License 2.0 单协议分发, +// This project is licensed solely under the Apache License 2.0, +// 完整许可证文本请参见源代码根目录下的 LICENSE 文件。 +// please see the LICENSE file in the root directory of the source code for the full license text. +// 禁止利用本项目实施任何危害国家安全、破坏社会秩序、 +// It is prohibited to use this project to engage in any activities that endanger national security, disrupt social order, +// 侵犯他人合法权益等法律法规所禁止的行为! +// or infringe upon the legitimate rights and interests of others as prohibited by laws and regulations! +// 因基于本项目二次开发所产生的一切法律纠纷与责任, +// Any legal disputes or liabilities arising from secondary development based on this project +// 本组织与贡献者概不承担。 +// shall be borne solely by the developer; the project organization and contributors assume no responsibility. +// GitHub 仓库:https://github.com/GameFrameX +// GitHub Repository: https://github.com/GameFrameX +// Gitee 仓库:https://gitee.com/GameFrameX +// Gitee Repository: https://gitee.com/GameFrameX +// CNB 仓库:https://cnb.cool/GameFrameX +// CNB Repository: https://cnb.cool/GameFrameX +// 官方文档:https://gameframex.doc.alianblank.com/ +// Official Documentation: https://gameframex.doc.alianblank.com/ +// ========================================================================================== + + +using System.Collections.Concurrent; +using GameFrameX.NetWork.Messages; +using GameFrameX.NetWork.RemoteMessaging.Routing; + +namespace GameFrameX.Tests.Topology.Equivalence; + +/// +/// 等价用例集的拓扑世界:把若干模拟进程 cell 组装成一种可路由的拓扑(C143c D9)。 +/// +/// +/// Topology world for the equivalence suite (C143c D9): assembles simulated process cells +/// into a routable topology. AllInOne is one cell hosting every role (every hop must hit +/// D3 case 1); MultiProcess follows the design source §D9 three-process split +/// (Gate | Game+Social | Match) with a loopback forwarder standing in for the C143d +/// remote hop. Handlers may route replies back through — a +/// handler runs on its own mailbox consumer, so cross-role round trips never deadlock. +/// +public sealed class TopologyWorld : IDisposable +{ + /// + /// 单个模拟进程 cell:角色集 + 路由器 + 邮箱。 + /// + private sealed class TopologyCell + { + /// cell 承载的 Role 名 / The role names hosted by this cell + public List RoleNames { get; } = new List(); + + /// cell 路由器 / The cell router + public InProcessRoleRouter Router { get; set; } + + /// Role → 邮箱 / Role to mailbox + public Dictionary Mailboxes { get; } = new Dictionary(); + } + + /// + /// cell 内本地投递器:按信封目标 Role 分发到对应邮箱。 + /// + private sealed class CellMailboxDispatcher : ILocalRoleMessageDispatcher + { + /// cell 邮箱表 / The cell mailboxes + private readonly IReadOnlyDictionary _mailboxes; + + /// + /// 初始化投递器。 + /// + public CellMailboxDispatcher(IReadOnlyDictionary mailboxes) + { + _mailboxes = mailboxes; + } + + /// + public Task DispatchAsync(MessageEnvelope envelope, CancellationToken cancellationToken = default) + { + if (envelope == null) + { + throw new ArgumentNullException(nameof(envelope)); + } + + if (!_mailboxes.TryGetValue(envelope.TargetRole, out var mailbox)) + { + throw new RouteNotFoundException(envelope.TargetRole, $"No mailbox for target role '{envelope.TargetRole}' in this cell."); + } + + return mailbox.DeliverAsync(envelope, cancellationToken); + } + } + + /// + /// 拓扑内全部 cell。 + /// + private readonly List _cells = new List(); + + /// + /// Role 名 → 承载 cell。 + /// + private readonly Dictionary _cellByRole = new Dictionary(); + + /// + /// Role 名 → 邮箱。 + /// + private readonly Dictionary _mailboxByRole = new Dictionary(); + + /// + /// 全部 Role 名(固定顺序)。 + /// + private readonly List _allRoleNames; + + /// + /// 每一跳投递分支记录(含处理器内应答跳)。 + /// + private readonly ConcurrentQueue _deliveries = new ConcurrentQueue(); + + /// + /// Role 业务状态存储(确定性快照用)。 + /// + private readonly ConcurrentDictionary _roleState = new ConcurrentDictionary(); + + /// + /// 初始化拓扑世界。 + /// + /// 每个 cell 承载的 Role 名分组 / The role name group of every cell + private TopologyWorld(IEnumerable> cellRoleGroups) + { + var allRoleNames = new List(); + var cellRoutersByRolePlaceholder = new Dictionary(); + LoopbackRemoteRoleRouter loopbackForwarder = new LoopbackRemoteRoleRouter(cellRoutersByRolePlaceholder); + + foreach (var roleGroup in cellRoleGroups) + { + var cell = new TopologyCell(); + cell.RoleNames.AddRange(roleGroup); + foreach (var roleName in roleGroup) + { + var mailbox = new RoleMailbox(roleName); + cell.Mailboxes[roleName] = mailbox; + _mailboxByRole[roleName] = mailbox; + _cellByRole[roleName] = cell; + allRoleNames.Add(roleName); + } + + var dispatcher = new CellMailboxDispatcher(cell.Mailboxes); + cell.Router = new InProcessRoleRouter(cell.RoleNames, dispatcher, loopbackForwarder); + _cells.Add(cell); + foreach (var roleName in roleGroup) + { + cellRoutersByRolePlaceholder[roleName] = cell.Router; + } + } + + _allRoleNames = allRoleNames; + } + + /// + /// 创建 All-in-One 拓扑:单 cell 承载全部 Role,每一跳都必须命中 D3 case 1。 + /// + /// 全部 Role 名 / All role names + /// 拓扑世界 / The topology world + public static TopologyWorld CreateAllInOne(IReadOnlyList roleNames) + { + return new TopologyWorld(new List> { roleNames }); + } + + /// + /// 创建三进程拓扑(设计源 §D9):Gate 进程 / Game+Social 进程 / Match 进程。 + /// + /// Gate Role 名 / The Gate role name + /// Game Role 名 / The Game role name + /// Social Role 名 / The Social role name + /// Match Role 名 / The Match role name + /// 拓扑世界 / The topology world + public static TopologyWorld CreateMultiProcess(string gateRoleName, string gameRoleName, string socialRoleName, string matchRoleName) + { + var cellRoleGroups = new List> + { + new List { gateRoleName }, + new List { gameRoleName, socialRoleName }, + new List { matchRoleName }, + }; + return new TopologyWorld(cellRoleGroups); + } + + /// + /// 获取指定 Role 的邮箱。 + /// + /// Role 名 / The role name + public RoleMailbox GetMailbox(string roleName) + { + return _mailboxByRole[roleName]; + } + + /// + /// 从源 Role 向目标 Role 路由一封消息(带跳超时),并记录投递分支。 + /// + /// + /// Routes one message hop from the source role's cell router — the routing decision + /// belongs to the sending process (D3), so the source role selects the cell. + /// The hop timeout is applied through a cancellation token, identically in both topologies. + /// + /// 源 Role 名 / The source role name + /// 目标 Role 名 / The target role name + /// 消息 / The message + /// 跳超时(毫秒)/ The hop timeout in milliseconds + /// 投递分支 / The delivery branch + public async Task RouteAsync(string sourceRoleName, string targetRoleName, MessageObject message, int hopTimeoutMilliseconds) + { + var envelope = new MessageEnvelope(targetRoleName, message); + using (var cancellationSource = new CancellationTokenSource(hopTimeoutMilliseconds)) + { + var delivery = await _cellByRole[sourceRoleName].Router.RouteAsync(envelope, cancellationSource.Token); + _deliveries.Enqueue(delivery); + return delivery; + } + } + + /// + /// 写入一条 Role 业务状态(确定性快照的组成项)。 + /// + /// Role 名 / The role name + /// 状态键 / The state key + /// 状态值 / The state value + public void SetRoleState(string roleName, string stateKey, string stateValue) + { + _roleState[$"{roleName}|{stateKey}"] = stateValue; + } + + /// + /// 捕获链路执行结果快照。 + /// + /// 链路执行结果 / The chain execution result + public ChainExecutionResult CaptureResult() + { + var arrivalOrderByRole = new Dictionary>(); + foreach (var roleName in _allRoleNames) + { + arrivalOrderByRole[roleName] = _mailboxByRole[roleName].GetArrivalLog(); + } + + var stateSnapshot = string.Join( + ";", + _roleState.OrderBy(pair => pair.Key, StringComparer.Ordinal).Select(pair => $"{pair.Key}={pair.Value}")); + + return new ChainExecutionResult + { + Deliveries = _deliveries.ToList(), + ArrivalOrderByRole = arrivalOrderByRole, + StateSnapshot = stateSnapshot, + }; + } + + /// + /// 关闭全部邮箱。 + /// + public void Dispose() + { + foreach (var cell in _cells) + { + foreach (var mailbox in cell.Mailboxes.Values) + { + mailbox.Close(); + } + } + } +}