From 9c6d3ea7250956e73abdfd256429d8eb82e97937 Mon Sep 17 00:00:00 2001 From: Blank Date: Sat, 24 Oct 2026 03:33:37 +0800 Subject: [PATCH 1/6] =?UTF-8?q?feat(capability):=20=E9=87=8D=E5=86=99?= =?UTF-8?q?=E8=83=BD=E5=8A=9B=E5=AE=BF=E4=B8=BB=E6=9C=BA=E5=88=B6=E4=B8=BA?= =?UTF-8?q?=E4=B8=89=E6=AE=B5=E7=94=9F=E5=91=BD=E5=91=A8=E6=9C=9F=E4=B8=8E?= =?UTF-8?q?=E6=97=8F=E7=BB=84=E5=90=88=E9=AA=A8=E6=9E=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ICapabilityModule 改 Register/Activate/Deactivate 三段生命周期并按拓扑序激活、 失败逆序回滚;桥机制化为泛型 CapabilityBridge 与钩子 CapabilityHook(版本化 撤销、同进程声明释放);服务注册改描述符表并支持显式 Override;新增 CapabilityComposition.Compose 族组合入口、事件总线、Options 三层逐字段绑定、 Register 前依赖检查(必选缺失与环路一次报全、可选缺失打 Info)、调度器硬化 (时钟注入与 per-job 隔离);admin 组装上移机制层,同名 action 失败并按能力 双向对账,新增 list_capability_services / list_capability_settings 元端点。 Linear: GFX-998 --- .../CapabilityActivationContext.cs | 62 ++ .../CapabilityAssemblyFilter.cs | 26 +- .../CapabilityBridge.cs | 69 ++ .../CapabilityBridgeLease.cs | 71 +++ .../CapabilityBridgePublication.cs | 62 ++ .../CapabilityBridgePublisher.cs | 78 +++ .../CapabilityBridgeUnavailableException.cs | 68 ++ .../CapabilityContractSlot.cs | 315 ++++++++++ ...s => CapabilityDependencyAbsenceReason.cs} | 28 +- .../CapabilityDependencyAssemblyState.cs | 46 ++ .../CapabilityDependencyKind.cs | 46 ++ .../CapabilityDependencyReport.cs | 75 +++ .../CapabilityFamilyContext.cs | 182 ++++++ .../CapabilityHook.cs | 99 +++ .../CapabilityHookHandle.cs | 63 ++ .../CapabilityHookLease.cs | 64 ++ .../CapabilityHookSlots.cs | 127 ++++ .../CapabilityJobs.cs | 29 +- .../CapabilityManifest.cs | 29 +- .../CapabilityOptionalDependency.cs | 72 +++ .../CapabilityOptionsValidation.cs | 122 ++++ .../CapabilityRegistrationContext.cs | 281 ++++++--- .../CapabilityReport.cs | 35 +- .../CapabilityServiceRegistration.cs | 85 +++ .../CapabilityServiceResolver.cs | 103 +++ .../CapabilityServices.cs | 517 +++++++++++++-- .../CapabilitySettingsField.cs | 68 ++ .../CapabilitySettingsFieldSource.cs | 51 ++ ...meX.Capability.Hosting.Abstractions.csproj | 10 +- .../ICapabilityAssemblyLog.cs | 53 ++ .../ICapabilityEventBus.cs | 56 ++ .../ICapabilityEventPublisher.cs | 48 ++ .../ICapabilityFamily.cs | 63 ++ .../ICapabilityModule.cs | 29 +- .../ICapabilityOptions.cs | 46 ++ .../AdminActionRegistry.cs | 42 +- .../AdminApiComposition.cs | 196 ++++++ .../AdminApiDispatcher.cs | 130 +++- .../AdminApiServer.cs | 11 +- ...eFrameX.Capability.Hosting.AdminApi.csproj | 1 + .../CapabilityAssembly.cs | 63 +- .../CapabilityAssemblyException.cs | 78 +++ .../CapabilityAssemblyLogRenderer.cs | 304 +++++++++ .../CapabilityComposition.cs | 134 ++++ .../CapabilityCompositionOptions.cs | 239 +++++++ .../CapabilityDependencyChecker.cs | 444 +++++++++++++ .../CapabilityEventBus.cs | 202 ++++++ .../CapabilityHost.cs | 266 ++++++-- .../CapabilityHostBuilder.cs | 368 +++++++---- .../CapabilityOptionsBinder.cs | 471 ++++++++++++++ .../CapabilityRuntime.cs | 108 ++++ .../CapabilityScheduler.cs | 185 ++++-- .../CapabilitySchedulerJobKind.cs | 46 ++ .../CapabilitySchedulerLogObserver.cs | 86 +++ .../ConsoleCapabilityAssemblyLog.cs | 66 ++ .../ICapabilityAdminApiPlane.cs | 53 ++ .../ICapabilitySchedulerObserver.cs | 56 ++ .../ISchedulerClock.cs | 44 ++ .../Audit/IOnlineAuditStore.cs | 0 .../Audit/InMemoryOnlineAuditStore.cs | 0 .../Audit/OnlineAuditEntry.cs | 0 .../Audit/OnlineAuditIngestOutcome.cs | 0 .../Audit/OnlineAuditService.cs | 0 .../IOnlineChatModerationHook.cs | 0 .../OnlineChatMessage.cs | 0 .../OnlineChatMessageState.cs | 0 .../OnlineChatModerationVerdict.cs | 0 GameFrameX.Localization/Keys.Capability.cs | 589 +++++++++++++++++- GameFrameX.Localization/Keys.Launcher.cs | 209 ------- .../Localization/Messages/Resources.resx | 237 +++++-- .../Messages/Resources.zh-CN.resx | 237 +++++-- .../OnlineCapabilityCompositionTests.cs | 317 ---------- 72 files changed, 7267 insertions(+), 1093 deletions(-) create mode 100644 Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilityActivationContext.cs create mode 100644 Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilityBridge.cs create mode 100644 Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilityBridgeLease.cs create mode 100644 Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilityBridgePublication.cs create mode 100644 Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilityBridgePublisher.cs create mode 100644 Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilityBridgeUnavailableException.cs create mode 100644 Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilityContractSlot.cs rename Capability/GameFrameX.Capability.Hosting.Abstractions/{CapabilityEventNotification.cs => CapabilityDependencyAbsenceReason.cs} (71%) create mode 100644 Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilityDependencyAssemblyState.cs create mode 100644 Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilityDependencyKind.cs create mode 100644 Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilityDependencyReport.cs create mode 100644 Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilityFamilyContext.cs create mode 100644 Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilityHook.cs create mode 100644 Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilityHookHandle.cs create mode 100644 Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilityHookLease.cs create mode 100644 Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilityHookSlots.cs create mode 100644 Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilityOptionalDependency.cs create mode 100644 Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilityOptionsValidation.cs create mode 100644 Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilityServiceRegistration.cs create mode 100644 Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilityServiceResolver.cs create mode 100644 Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilitySettingsField.cs create mode 100644 Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilitySettingsFieldSource.cs create mode 100644 Capability/GameFrameX.Capability.Hosting.Abstractions/ICapabilityAssemblyLog.cs create mode 100644 Capability/GameFrameX.Capability.Hosting.Abstractions/ICapabilityEventBus.cs create mode 100644 Capability/GameFrameX.Capability.Hosting.Abstractions/ICapabilityEventPublisher.cs create mode 100644 Capability/GameFrameX.Capability.Hosting.Abstractions/ICapabilityFamily.cs create mode 100644 Capability/GameFrameX.Capability.Hosting.Abstractions/ICapabilityOptions.cs create mode 100644 Capability/GameFrameX.Capability.Hosting.AdminApi/AdminApiComposition.cs create mode 100644 Capability/GameFrameX.Capability.Hosting.Core/CapabilityAssemblyException.cs create mode 100644 Capability/GameFrameX.Capability.Hosting.Core/CapabilityAssemblyLogRenderer.cs create mode 100644 Capability/GameFrameX.Capability.Hosting.Core/CapabilityComposition.cs create mode 100644 Capability/GameFrameX.Capability.Hosting.Core/CapabilityCompositionOptions.cs create mode 100644 Capability/GameFrameX.Capability.Hosting.Core/CapabilityDependencyChecker.cs create mode 100644 Capability/GameFrameX.Capability.Hosting.Core/CapabilityEventBus.cs create mode 100644 Capability/GameFrameX.Capability.Hosting.Core/CapabilityOptionsBinder.cs create mode 100644 Capability/GameFrameX.Capability.Hosting.Core/CapabilityRuntime.cs create mode 100644 Capability/GameFrameX.Capability.Hosting.Core/CapabilitySchedulerJobKind.cs create mode 100644 Capability/GameFrameX.Capability.Hosting.Core/CapabilitySchedulerLogObserver.cs create mode 100644 Capability/GameFrameX.Capability.Hosting.Core/ConsoleCapabilityAssemblyLog.cs create mode 100644 Capability/GameFrameX.Capability.Hosting.Core/ICapabilityAdminApiPlane.cs create mode 100644 Capability/GameFrameX.Capability.Hosting.Core/ICapabilitySchedulerObserver.cs create mode 100644 Capability/GameFrameX.Capability.Hosting.Core/ISchedulerClock.cs rename Capability/{GameFrameX.Capability.Online.Operations => GameFrameX.Capability.Online.Abstractions}/Audit/IOnlineAuditStore.cs (100%) rename Capability/{GameFrameX.Capability.Online.Operations => GameFrameX.Capability.Online.Abstractions}/Audit/InMemoryOnlineAuditStore.cs (100%) rename Capability/{GameFrameX.Capability.Online.Operations => GameFrameX.Capability.Online.Abstractions}/Audit/OnlineAuditEntry.cs (100%) rename Capability/{GameFrameX.Capability.Online.Operations => GameFrameX.Capability.Online.Abstractions}/Audit/OnlineAuditIngestOutcome.cs (100%) rename Capability/{GameFrameX.Capability.Online.Operations => GameFrameX.Capability.Online.Abstractions}/Audit/OnlineAuditService.cs (100%) rename Capability/{GameFrameX.Capability.Online.Social => GameFrameX.Capability.Online.Abstractions}/IOnlineChatModerationHook.cs (100%) rename Capability/{GameFrameX.Capability.Online.Social => GameFrameX.Capability.Online.Abstractions}/OnlineChatMessage.cs (100%) rename Capability/{GameFrameX.Capability.Online.Social => GameFrameX.Capability.Online.Abstractions}/OnlineChatMessageState.cs (100%) rename Capability/{GameFrameX.Capability.Online.Social => GameFrameX.Capability.Online.Abstractions}/OnlineChatModerationVerdict.cs (100%) delete mode 100644 Tests/GameFrameX.Tests/Online/OnlineCapabilityCompositionTests.cs diff --git a/Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilityActivationContext.cs b/Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilityActivationContext.cs new file mode 100644 index 000000000..1944e3572 --- /dev/null +++ b/Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilityActivationContext.cs @@ -0,0 +1,62 @@ +// ========================================================================================== +// 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 refer to 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.Capability.Hosting.Abstractions; + +/// +/// 能力激活上下文(宿主启动期对单个能力模块开放的激活面:只读解析 + 桥发布)。 +/// +/// 维护约束:全部模块已完成 Register,本上下文的 可枚举依赖关系之外的扩展点 +///(GetServices 此时才可用);单实例解析仍做来源检查(枚举之外不允许偷用未声明依赖的单实现); +/// 桥的发布者身份 = 当前模块 manifest Id,由机制注入;撤销由宿主停止期自动完成,模块无撤销入口。 +/// +/// +public sealed class CapabilityActivationContext +{ + /// + /// 只读解析面(按本能力限定来源检查;GetServices 在本上下文可用)。 + /// + public CapabilityServiceResolver Services { get; } + + /// + /// 桥发布面(发布者身份 = 本能力)。 + /// + public CapabilityBridgePublisher Bridges { get; } + + /// + /// 初始化 (仅机制构造)。 + /// + /// 按本能力限定的解析器。 + /// 桥发布面。 + internal CapabilityActivationContext(CapabilityServiceResolver services, CapabilityBridgePublisher bridges) + { + Services = services ?? throw new ArgumentNullException(nameof(services)); + Bridges = bridges ?? throw new ArgumentNullException(nameof(bridges)); + } +} diff --git a/Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilityAssemblyFilter.cs b/Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilityAssemblyFilter.cs index c3c1f0d80..c7b7ba6a0 100644 --- a/Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilityAssemblyFilter.cs +++ b/Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilityAssemblyFilter.cs @@ -35,6 +35,8 @@ namespace GameFrameX.Capability.Hosting.Abstractions; /// /// 维护约束: 缺省(null)= 装配全部已发现能力; /// 恒为排除语义且优先于清单段;过滤器引用未发现的能力 Id 时装配 fail-fast。 +/// 构造期规整(Trim、丢弃空白条目):规整后为空的清单段视为 null、排除表视为空——保证「空过滤器 = 全装配」红线 +/// 在机制层成立,配置写成 "Capabilities": [] 或含空白条目时不会静默装配零能力,也不会把空白条目误报为未发现能力。 /// /// public sealed class CapabilityAssemblyFilter @@ -42,12 +44,28 @@ public sealed class CapabilityAssemblyFilter /// /// 初始化 。 /// - /// 能力清单段(null = 全部)。 - /// 配置排除表。 + /// 能力清单段(null、空或仅含空白条目 = 全部)。 + /// 配置排除表(null、空或仅含空白条目 = 无排除)。 public CapabilityAssemblyFilter(IReadOnlyList enabledIds = null, IReadOnlyList disabledIds = null) { - EnabledIds = enabledIds; - DisabledIds = disabledIds ?? Array.Empty(); + EnabledIds = NormalizeIds(enabledIds); + DisabledIds = NormalizeIds(disabledIds) ?? Array.Empty(); + } + + /// + /// 规整能力 Id 列表(Trim、丢弃空白条目);无有效条目时返回 null(该维度不过滤)。 + /// + /// 原始列表(可为 null)。 + /// 规整后的数组;无有效条目时为 null。 + private static string[] NormalizeIds(IReadOnlyList source) + { + if (source == null) + { + return null; + } + + var ids = source.Where(id => !string.IsNullOrWhiteSpace(id)).Select(id => id.Trim()).ToArray(); + return ids.Length == 0 ? null : ids; } /// diff --git a/Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilityBridge.cs b/Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilityBridge.cs new file mode 100644 index 000000000..e48070afa --- /dev/null +++ b/Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilityBridge.cs @@ -0,0 +1,69 @@ +// ========================================================================================== +// 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 refer to 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.Capability.Hosting.Abstractions; + +/// +/// 能力桥消费入口(按契约类型的进程内定位;发布侧只经 +/// 的 Bridges.Publish<TContract>,撤销由宿主停止期自动完成)。 +/// +/// 维护约束(桥显式失败红线):消费方逐调用复查活跃位;发布者缺席 返回 false、 +/// 已撤销 / 已替换抛 (族层映射为 8002)——不回旧数据、不降级、 +/// 各域不得自写缺席日志。本入口只在 Hotfix / Apps 消费侧合法(GFX0018 符号检查)。 +/// +/// +/// 契约类型(每域一个契约接口)。 +public static class CapabilityBridge where TContract : class +{ + /// + /// 契约槽位(与 各自独立、共用同一套槽位实现)。 + /// + private static readonly CapabilityContractSlot Slot = new CapabilityContractSlot(); + + /// + /// 尝试获取契约租约。 + /// + /// 输出:契约租约(发布者缺席为 null)。 + /// 发布者在场返回 true;缺席返回 false(消费方按依赖不可用显式失败处理)。 + public static bool TryAcquire(out CapabilityBridgeLease lease) + { + return Slot.TryAcquire(out lease); + } + + /// + /// 发布契约实例(仅激活上下文的桥发布面调用;发布者身份 = 当前模块 Id;同一契约重复发布 fail-fast)。 + /// + /// 契约实现。 + /// 发布者能力 Id。 + /// 撤销凭据(宿主停止期统一撤销)。 + internal static IDisposable Publish(TContract contract, string publisherId) + { + return Slot.Publish(contract, publisherId); + } +} diff --git a/Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilityBridgeLease.cs b/Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilityBridgeLease.cs new file mode 100644 index 000000000..7afaf8efc --- /dev/null +++ b/Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilityBridgeLease.cs @@ -0,0 +1,71 @@ +// ========================================================================================== +// 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 refer to 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.Capability.Hosting.Abstractions; + +/// +/// 能力桥租约(消费侧持有;每次访问 复查活跃位)。 +/// +/// 维护约束:已撤销 / 已替换的租约在访问时抛 +/// (族层映射为 8002 显式失败);租约不可续期、不可重新挂载——失效后消费方重新 +/// 取新租约。 +/// +/// +/// 契约类型。 +public sealed class CapabilityBridgeLease where TContract : class +{ + /// + /// 所属契约槽位。 + /// + private readonly CapabilityContractSlot _slot; + + /// + /// 租约捕获的槽位版本号。 + /// + private readonly int _version; + + /// + /// 初始化 (仅机制构造)。 + /// + /// 契约槽位。 + /// 获取租约时的槽位版本号。 + internal CapabilityBridgeLease(CapabilityContractSlot slot, int version) + { + _slot = slot; + _version = version; + } + + /// + /// 获取契约实现(每次访问复查活跃位;已撤销 / 已替换抛 )。 + /// + public TContract Contract + { + get { return _slot.GetPublished(_version); } + } +} diff --git a/Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilityBridgePublication.cs b/Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilityBridgePublication.cs new file mode 100644 index 000000000..841381edd --- /dev/null +++ b/Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilityBridgePublication.cs @@ -0,0 +1,62 @@ +// ========================================================================================== +// 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 refer to 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.Capability.Hosting.Abstractions; + +/// +/// 桥撤销凭据(桥发布时由槽位返回;宿主停止期统一 Dispose 撤销该模块发布的全部桥)。 +/// +/// 维护约束:撤销由机制自动完成,模块与组合根不得持有或调用本类型;Dispose 幂等 +/// (槽位按版本判定,重复撤销为空操作)。 +/// +/// +internal sealed class CapabilityBridgePublication : IDisposable +{ + /// + /// 撤销回调(指向所属槽位的版本化撤销)。 + /// + private readonly Action _retract; + + /// + /// 初始化 。 + /// + /// 撤销回调(不可为 null)。 + public CapabilityBridgePublication(Action retract) + { + _retract = retract ?? throw new ArgumentNullException(nameof(retract)); + } + + /// + /// 撤销发布(版本化幂等撤销)。 + /// + public void Dispose() + { + _retract(); + } +} diff --git a/Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilityBridgePublisher.cs b/Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilityBridgePublisher.cs new file mode 100644 index 000000000..d0a449dbe --- /dev/null +++ b/Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilityBridgePublisher.cs @@ -0,0 +1,78 @@ +// ========================================================================================== +// 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 refer to 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.Capability.Hosting.Abstractions; + +/// +/// 能力桥发布面(激活上下文的 Bridges 属性;发布者身份 = 当前模块 manifest Id,由机制注入)。 +/// +/// 维护约束: 只在能力模块 ActivateAsync 内合法(GFX0018 符号检查); +/// 同一契约重复发布 = 装配失败(一个契约只有一个发布者);撤销由宿主停止期按发布记录自动完成, +/// 模块与组合根不得自行撤销。 +/// +/// +public sealed class CapabilityBridgePublisher +{ + /// + /// 发布者能力 Id(由机制注入)。 + /// + private readonly string _publisherCapabilityId; + + /// + /// 撤销凭据收集器(宿主停止期统一撤销)。 + /// + private readonly List _publications; + + /// + /// 初始化 (仅机制构造)。 + /// + /// 发布者能力 Id。 + /// 撤销凭据收集器(宿主持有)。 + internal CapabilityBridgePublisher(string publisherCapabilityId, List publications) + { + _publisherCapabilityId = publisherCapabilityId; + _publications = publications; + } + + /// + /// 发布一个契约实现(撤销由宿主停止期自动完成;消费方经 取租约)。 + /// + /// 契约类型(每域一个契约接口)。 + /// 契约实现(不可为 null)。 + public void Publish(TContract contract) where TContract : class + { + if (contract == null) + { + throw new ArgumentNullException(nameof(contract)); + } + + var publication = CapabilityBridge.Publish(contract, _publisherCapabilityId); + _publications.Add(publication); + } +} diff --git a/Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilityBridgeUnavailableException.cs b/Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilityBridgeUnavailableException.cs new file mode 100644 index 000000000..007dab0eb --- /dev/null +++ b/Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilityBridgeUnavailableException.cs @@ -0,0 +1,68 @@ +// ========================================================================================== +// 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 refer to 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.Foundation.Localization.Core; + +namespace GameFrameX.Capability.Hosting.Abstractions; + +/// +/// 能力桥契约不可用异常(租约已撤销 / 已替换;消费方不得回旧数据或降级,族层统一映射为 8002 依赖不可用)。 +/// +/// 维护约束:本异常是桥显式失败红线的机制载体——捕获后只能走统一失败路径(族层错误码映射), +/// 不得吞掉后回退空实现。 +/// +/// +public sealed class CapabilityBridgeUnavailableException : Exception +{ + /// + /// 初始化 。 + /// + /// 契约类型全名(诊断用)。 + public CapabilityBridgeUnavailableException(string contractTypeName) + : base(BuildMessage(contractTypeName)) + { + ContractTypeName = contractTypeName ?? string.Empty; + } + + /// + /// 获取契约类型全名。 + /// + public string ContractTypeName { get; } + + /// + /// 构造异常消息(本地化)。 + /// + /// 契约类型全名。 + /// 异常消息。 + private static string BuildMessage(string contractTypeName) + { + // Localization: Capability.Hosting.BridgeUnavailable - 能力桥契约已撤销或被替换。 + return string.Format(LocalizationService.GetString(Localization.Keys.Capability.Hosting.BridgeUnavailable), contractTypeName); + } +} diff --git a/Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilityContractSlot.cs b/Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilityContractSlot.cs new file mode 100644 index 000000000..2495a2068 --- /dev/null +++ b/Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilityContractSlot.cs @@ -0,0 +1,315 @@ +// ========================================================================================== +// 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 refer to 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.Foundation.Localization.Core; + +namespace GameFrameX.Capability.Hosting.Abstractions; + +/// +/// 契约槽位( 与 共用的 +/// 每契约静态槽实现,方向相反:桥是能力发布、消费方取租约;钩子是外部挂载、能力读当前实现)。 +/// +/// 维护约束:逐调用复查活跃位(版本号变化即失效);撤销 / 替换后旧租约显式失败、不回旧数据、不降级; +/// 线程安全(独立锁;槽内不做任何日志或回调,避免锁内重入)。 +/// +/// +/// 契约类型。 +internal sealed class CapabilityContractSlot where TContract : class +{ + /// + /// 槽位锁(发布 / 撤销 / 挂载 / 摘下 / 读取共用)。 + /// + private readonly object _sync = new(); + + /// + /// 当前发布 / 挂载的实现实例(null = 空槽,钩子形态回退默认实现)。 + /// + private TContract _published; + + /// + /// 钩子默认实现(能力声明;桥形态恒为 null)。 + /// + private TContract _defaultImplementation; + + /// + /// 当前发布者 / 声明者能力 Id(重复发布 / 重复声明诊断用)。 + /// + private string _ownerId; + + /// + /// 版本号(每次发布 / 撤销 / 挂载 / 摘下递增;租约凭版本失效)。 + /// + private int _version; + + /// + /// 是否存在活跃发布 / 挂载。 + /// + private bool _hasPublished; + + /// + /// 是否已声明默认实现(钩子形态审计用)。 + /// + private bool _hasDefault; + + /// + /// 默认声明版本号(每次声明 / 释放递增;释放凭据凭版本生效,已被新声明取代的旧凭据为空操作)。 + /// + private int _declarationVersion; + + /// + /// 发布一个契约实例(桥形态;同一契约重复发布 = 装配期 fail-fast)。 + /// + /// 契约实现(不可为 null)。 + /// 发布者能力 Id(诊断用)。 + /// 撤销凭据(宿主停止期统一撤销)。 + internal IDisposable Publish(TContract contract, string publisherId) + { + if (contract == null) + { + throw new ArgumentNullException(nameof(contract)); + } + + var capturedVersion = 0; + lock (_sync) + { + if (_hasPublished) + { + // Localization: Capability.Hosting.BridgeDuplicatePublish - 能力桥契约重复发布。 + throw new InvalidOperationException(string.Format(LocalizationService.GetString(Localization.Keys.Capability.Hosting.BridgeDuplicatePublish), typeof(TContract).FullName, _ownerId, publisherId)); + } + + _published = contract; + _ownerId = publisherId; + _hasPublished = true; + _version++; + capturedVersion = _version; + } + + var capturedSlot = this; + return new CapabilityBridgePublication(() => capturedSlot.Retract(capturedVersion)); + } + + /// + /// 撤销发布(仅当凭据版本仍是当前活跃版本时生效;重复撤销为空操作)。 + /// + /// 撤销凭据捕获的版本号。 + private void Retract(int capturedVersion) + { + lock (_sync) + { + if (_hasPublished && _version == capturedVersion) + { + _published = null; + _ownerId = null; + _hasPublished = false; + _version++; + } + } + } + + /// + /// 尝试获取契约租约(发布者缺席返回 false)。 + /// + /// 输出:契约租约。 + /// 发布者在场返回 true。 + internal bool TryAcquire(out CapabilityBridgeLease lease) + { + lock (_sync) + { + if (!_hasPublished) + { + lease = null; + return false; + } + + lease = new CapabilityBridgeLease(this, _version); + return true; + } + } + + /// + /// 按租约版本读取契约实现(版本不符 = 已撤销 / 已替换,显式失败)。 + /// + /// 租约捕获的版本号。 + /// 契约实现。 + internal TContract GetPublished(int capturedVersion) + { + lock (_sync) + { + if (!_hasPublished || _version != capturedVersion) + { + // Localization: Capability.Hosting.BridgeUnavailable - 能力桥契约已撤销或被替换。 + throw new CapabilityBridgeUnavailableException(typeof(TContract).FullName); + } + + return _published; + } + } + + /// + /// 声明钩子默认实现(能力 Register 期;同一契约在活跃声明期内重复声明 = 装配期 fail-fast)。 + /// + /// 维护约束:槽位是进程级静态的,声明必须随声明方宿主的生命周期释放(宿主停止 / 激活失败 / 装配失败), + /// 否则同进程第二次组合(重组、测试进程内多次装配)会被残留声明误判为重复声明。 + /// + /// + /// 默认实现(不可为 null)。 + /// 声明者能力 Id(诊断用)。 + /// 释放凭据(宿主统一释放;仅当凭据版本仍是当前声明版本时生效,重复释放为空操作)。 + internal IDisposable DeclareDefault(TContract defaultImplementation, string declarerId) + { + if (defaultImplementation == null) + { + throw new ArgumentNullException(nameof(defaultImplementation)); + } + + var capturedVersion = 0; + lock (_sync) + { + if (_hasDefault) + { + // Localization: Capability.Hosting.HookDuplicateDeclaration - 能力钩子重复声明。 + throw new InvalidOperationException(string.Format(LocalizationService.GetString(Localization.Keys.Capability.Hosting.HookDuplicateDeclaration), typeof(TContract).FullName, _ownerId, declarerId)); + } + + _defaultImplementation = defaultImplementation; + _ownerId = declarerId; + _hasDefault = true; + _declarationVersion++; + capturedVersion = _declarationVersion; + } + + var capturedSlot = this; + return new CapabilityBridgePublication(() => capturedSlot.ReleaseDefault(capturedVersion)); + } + + /// + /// 释放默认声明(仅当凭据版本仍是当前声明版本时生效;挂载实现不受影响,由挂载方租约自行摘下)。 + /// + /// 释放凭据捕获的声明版本号。 + private void ReleaseDefault(int capturedVersion) + { + lock (_sync) + { + if (_hasDefault && _declarationVersion == capturedVersion) + { + _defaultImplementation = null; + _ownerId = null; + _hasDefault = false; + _declarationVersion++; + } + } + } + + /// + /// 挂载一个钩子实现(原子替换旧挂载并使旧租约失效;返回可摘下的挂载回调)。 + /// + /// 挂载实现(不可为 null)。 + /// 摘下回调(仅当挂载仍是当前版本时生效;已被替换的旧回调为空操作)。 + internal Action Attach(TContract implementation) + { + if (implementation == null) + { + throw new ArgumentNullException(nameof(implementation)); + } + + var capturedVersion = 0; + lock (_sync) + { + _published = implementation; + _hasPublished = true; + _version++; + capturedVersion = _version; + } + + var capturedSlot = this; + return () => capturedSlot.Detach(capturedVersion); + } + + /// + /// 摘下挂载(仅当租约版本仍是当前挂载版本时生效;回默认实现)。 + /// + /// 挂载租约捕获的版本号。 + private void Detach(int capturedVersion) + { + lock (_sync) + { + if (_hasPublished && _version == capturedVersion) + { + _published = null; + _hasPublished = false; + _version++; + } + } + } + + /// + /// 获取钩子当前生效实现(挂载在场用挂载实现,否则用声明的默认实现)。 + /// + internal TContract GetCurrent() + { + lock (_sync) + { + if (_hasPublished) + { + return _published; + } + + return _defaultImplementation; + } + } + + /// + /// 获取是否存在活跃挂载(装配期「已挂载但无能力声明」审计用)。 + /// + internal bool IsAttached + { + get + { + lock (_sync) + { + return _hasPublished; + } + } + } + + /// + /// 获取是否已声明默认实现(装配期「已挂载但无能力声明」审计用)。 + /// + internal bool HasDefault + { + get + { + lock (_sync) + { + return _hasDefault; + } + } + } +} diff --git a/Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilityEventNotification.cs b/Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilityDependencyAbsenceReason.cs similarity index 71% rename from Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilityEventNotification.cs rename to Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilityDependencyAbsenceReason.cs index 8e8682a1c..451482f46 100644 --- a/Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilityEventNotification.cs +++ b/Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilityDependencyAbsenceReason.cs @@ -30,35 +30,27 @@ namespace GameFrameX.Capability.Hosting.Abstractions; /// -/// 能力事件通知(事件驱动 job 的统一爆发载荷;机制层不解释内容)。 +/// 能力依赖缺席原因(区分配置问题与部署产物问题:前两种改配置,后一种补部署产物)。 /// -public sealed class CapabilityEventNotification +public enum CapabilityDependencyAbsenceReason { /// - /// 初始化 。 + /// 无缺席(依赖已装配)。 /// - /// 事件类型名(能力自定义)。 - /// 事件载荷 JSON 文本(可为 null)。 - /// 事件发生时刻(Unix 毫秒)。 - public CapabilityEventNotification(string type, string payloadJson, long occurredAtUnixMilliseconds) - { - Type = type; - PayloadJson = payloadJson; - OccurredAtUnixMilliseconds = occurredAtUnixMilliseconds; - } + None = 0, /// - /// 获取事件类型名。 + /// 白名单未收录(能力清单段不含该能力)。 /// - public string Type { get; } + NotInEnableList = 1, /// - /// 获取事件载荷 JSON 文本。 + /// 排除表命中(配置排除表排除了该能力)。 /// - public string PayloadJson { get; } + ExcludedByDisableList = 2, /// - /// 获取事件发生时刻(Unix 毫秒)。 + /// 部署目录未发现(部署产物缺失该能力包)。 /// - public long OccurredAtUnixMilliseconds { get; } + NotDiscovered = 3, } diff --git a/Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilityDependencyAssemblyState.cs b/Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilityDependencyAssemblyState.cs new file mode 100644 index 000000000..4d261cf91 --- /dev/null +++ b/Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilityDependencyAssemblyState.cs @@ -0,0 +1,46 @@ +// ========================================================================================== +// 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 refer to 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.Capability.Hosting.Abstractions; + +/// +/// 能力依赖的装配状态。 +/// +public enum CapabilityDependencyAssemblyState +{ + /// + /// 已装配(参与定序边)。 + /// + Assembled = 0, + + /// + /// 未装配(可选依赖的正常形态;必选依赖不会以本状态进入报告)。 + /// + NotAssembled = 1, +} diff --git a/Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilityDependencyKind.cs b/Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilityDependencyKind.cs new file mode 100644 index 000000000..ea591e9a5 --- /dev/null +++ b/Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilityDependencyKind.cs @@ -0,0 +1,46 @@ +// ========================================================================================== +// 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 refer to 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.Capability.Hosting.Abstractions; + +/// +/// 能力依赖种类(对方未装配时:必选 = 装配失败;可选 = 对应功能不装配)。 +/// +public enum CapabilityDependencyKind +{ + /// + /// 必选依赖(对方必须装配,缺失即装配失败)。 + /// + Required = 0, + + /// + /// 可选依赖(对方缺失时对应功能不装配,能力其余功能正常运行)。 + /// + Optional = 1, +} diff --git a/Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilityDependencyReport.cs b/Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilityDependencyReport.cs new file mode 100644 index 000000000..c24f951cf --- /dev/null +++ b/Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilityDependencyReport.cs @@ -0,0 +1,75 @@ +// ========================================================================================== +// 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 refer to 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.Capability.Hosting.Abstractions; + +/// +/// 能力依赖状态条目(装配报告与 list_capabilities 的依赖数据面)。 +/// +/// 维护约束:必选缺失不会出现在报告里(装配期 fail-fast,报告只承载成功形态与可选缺失); +/// 可选缺失必须携带缺席原因与缺失功能文案,运维不依赖日志也能查到少了什么。 +/// +/// +public sealed class CapabilityDependencyReport +{ + /// + /// 初始化 。 + /// + /// 依赖的能力 Id。 + /// 依赖种类(必选 / 可选)。 + /// 装配状态。 + /// 缺席原因(已装配时为 None)。 + public CapabilityDependencyReport(string capabilityId, CapabilityDependencyKind kind, CapabilityDependencyAssemblyState state, CapabilityDependencyAbsenceReason absenceReason) + { + CapabilityId = capabilityId; + Kind = kind; + State = state; + AbsenceReason = absenceReason; + } + + /// + /// 获取依赖的能力 Id。 + /// + public string CapabilityId { get; } + + /// + /// 获取依赖种类。 + /// + public CapabilityDependencyKind Kind { get; } + + /// + /// 获取装配状态。 + /// + public CapabilityDependencyAssemblyState State { get; } + + /// + /// 获取缺席原因(已装配时为 None;区分白名单未收录 / 排除表命中 / 部署目录未发现)。 + /// + public CapabilityDependencyAbsenceReason AbsenceReason { get; } +} diff --git a/Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilityFamilyContext.cs b/Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilityFamilyContext.cs new file mode 100644 index 000000000..f86994378 --- /dev/null +++ b/Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilityFamilyContext.cs @@ -0,0 +1,182 @@ +// ========================================================================================== +// 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 refer to 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.Text.Json; + +namespace GameFrameX.Capability.Hosting.Abstractions; + +/// +/// 族基建注册面( 的上下文:族配置段 + 事件发布口 + 服务注册 / 覆盖)。 +/// +/// 维护约束:族基建注册先于模块扫描与注册,来源标记 = 族 Id(对全部模块可解析); +/// 族级配置键(如 UseGameDbStores)由族自行从 解读, +/// 机制只解释 Settings 子对象(能力级参数);store 覆盖走 +/// (一个默认 + 至多一个覆盖)。 +/// +/// +public sealed class CapabilityFamilyContext +{ + /// + /// 获取族配置段(原始 JSON;default 表示进程配置中没有该段)。 + /// + public JsonElement ConfigurationSection { get; } + + /// + /// 获取机制事件发布口(族业务发布面委托到机制总线的接线位)。 + /// + public ICapabilityEventPublisher EventPublisher { get; } + + /// + /// 服务注册表。 + /// + private readonly CapabilityServices _services; + + /// + /// 族 Id(注册来源标记)。 + /// + private readonly string _familyId; + + /// + /// 初始化 (仅机制装配器构造)。 + /// + /// 族 Id。 + /// 族配置段(原始 JSON)。 + /// 机制事件发布口。 + /// 服务注册表。 + internal CapabilityFamilyContext(string familyId, JsonElement configurationSection, ICapabilityEventPublisher eventPublisher, CapabilityServices services) + { + _familyId = familyId; + ConfigurationSection = configurationSection; + EventPublisher = eventPublisher ?? throw new ArgumentNullException(nameof(eventPublisher)); + _services = services ?? throw new ArgumentNullException(nameof(services)); + } + + /// + /// 注册一个族基建默认服务(实例形态;来源 = 族 Id,对全部模块可解析)。 + /// + /// 服务类型键。 + /// 服务实例(不可为 null)。 + public void AddService(TService instance) where TService : class + { + AddService(string.Empty, instance); + } + + /// + /// 注册一个族基建默认服务(懒工厂形态)。 + /// + /// 服务类型键。 + /// 懒工厂(不可为 null)。 + public void AddService(Func factory) where TService : class + { + AddService(string.Empty, factory); + } + + /// + /// 注册一个族基建命名服务(实例形态)。 + /// + /// 服务类型键。 + /// 命名键(非空)。 + /// 服务实例(不可为 null)。 + public void AddService(string name, TService instance) where TService : class + { + if (instance == null) + { + throw new ArgumentNullException(nameof(instance)); + } + + var capturedInstance = instance; + AddRegistration(name, resolver => capturedInstance, false); + } + + /// + /// 注册一个族基建命名服务(懒工厂形态)。 + /// + /// 服务类型键。 + /// 命名键(非空)。 + /// 懒工厂(不可为 null)。 + public void AddService(string name, Func factory) where TService : class + { + if (factory == null) + { + throw new ArgumentNullException(nameof(factory)); + } + + AddRegistration(name, factory, false); + } + + /// + /// 声明一个族级覆盖(替换模块默认实现,如 GameDb store;同键第二个覆盖 = 装配失败)。 + /// + /// 服务类型键。 + /// 覆盖实现(不可为 null)。 + public void Override(TService instance) where TService : class + { + Override(instance == null ? null : new Func(resolver => instance)); + } + + /// + /// 声明一个族级覆盖(懒工厂形态)。 + /// + /// 服务类型键。 + /// 覆盖懒工厂(不可为 null)。 + public void Override(Func factory) where TService : class + { + if (factory == null) + { + throw new ArgumentNullException(nameof(factory)); + } + + AddRegistration(string.Empty, factory, true); + } + + /// + /// 落一条注册描述符(来源 = 族 Id)。 + /// + /// 服务类型键。 + /// 命名键(空串 = 默认实现)。 + /// 懒工厂。 + /// 是否覆盖。 + private void AddRegistration(string name, Func factory, bool isOverride) where TService : class + { + if (!string.IsNullOrEmpty(name) && string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentException("Service name must be a non-empty string.", nameof(name)); + } + + var registration = new CapabilityServiceRegistration(typeof(TService), name ?? string.Empty, resolver => factory(resolver), _familyId, isOverride); + if (isOverride) + { + _services.AddOverride(registration); + } + else + { + _services.AddDefault(registration); + } + } +} diff --git a/Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilityHook.cs b/Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilityHook.cs new file mode 100644 index 000000000..72651b9f2 --- /dev/null +++ b/Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilityHook.cs @@ -0,0 +1,99 @@ +// ========================================================================================== +// 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 refer to 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.Capability.Hosting.Abstractions; + +/// +/// 能力钩子挂载入口(Hotfix 侧发布、能力侧消费;与 方向相反、 +/// 共用同一套槽位实现)。 +/// +/// 维护约束:钩子不在装配期注入实例——能力只持有槽位句柄(), +/// 每次调用读 ; 原子替换旧挂载并使旧租约失效, +/// 新版本热更装载完成即重挂、旧版本退役时其租约已失效,能力侧不会钉住旧程序集。 +/// 只在 Hotfix 程序集合法(GFX0018 符号检查)。 +/// +/// +/// 钩子契约类型。 +public static class CapabilityHook where TContract : class +{ + /// + /// 钩子槽位(与 各自独立、共用同一套槽位实现)。 + /// + private static readonly CapabilityContractSlot Slot = new CapabilityContractSlot(); + + /// + /// 声明钩子与默认策略(能力 Register 期经 调用)。 + /// + /// 维护约束:释放凭据写入 ,由声明方宿主在停止 / 激活失败 / 装配失败时统一释放; + /// 释放后槽位回到未声明状态,同进程再次组合可重新声明。 + /// + /// + /// 默认策略实现。 + /// 声明者能力 Id(重复声明诊断用)。 + /// 声明释放凭据收集器(宿主持有)。 + /// 能力侧句柄。 + internal static CapabilityHookHandle Declare(TContract defaultImplementation, string declarerId, List declarationReleases) + { + if (defaultImplementation == null) + { + throw new ArgumentNullException(nameof(defaultImplementation)); + } + + if (declarationReleases == null) + { + throw new ArgumentNullException(nameof(declarationReleases)); + } + + var slotRelease = Slot.DeclareDefault(defaultImplementation, declarerId); + CapabilityHookSlots.MarkDeclared(typeof(TContract)); + declarationReleases.Add(new CapabilityBridgePublication(() => + { + slotRelease.Dispose(); + CapabilityHookSlots.MarkUndeclared(typeof(TContract)); + })); + return new CapabilityHookHandle(Slot); + } + + /// + /// 挂载一个钩子实现(原子替换旧挂载并使旧租约失效;摘下后回能力声明的默认策略)。 + /// + /// 挂载实现(不可为 null)。 + /// 挂载租约(Dispose 摘下当前挂载;已被替换的旧租约 Dispose 为空操作)。 + public static CapabilityHookLease Attach(TContract implementation) + { + if (implementation == null) + { + throw new ArgumentNullException(nameof(implementation)); + } + + CapabilityHookSlots.MarkAttached(typeof(TContract)); + var detach = Slot.Attach(implementation); + return new CapabilityHookLease(detach); + } +} diff --git a/Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilityHookHandle.cs b/Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilityHookHandle.cs new file mode 100644 index 000000000..da9271ebe --- /dev/null +++ b/Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilityHookHandle.cs @@ -0,0 +1,63 @@ +// ========================================================================================== +// 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 refer to 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.Capability.Hosting.Abstractions; + +/// +/// 能力钩子句柄(能力侧 Register 期 声明默认策略后获得)。 +/// +/// 维护约束:句柄可以缓存, 不可以缓存——每次调用读槽位当前挂载, +/// 热更重挂后能力立即读到新实现;无挂载时回声明的默认策略。 +/// +/// +/// 钩子契约类型。 +public sealed class CapabilityHookHandle where TContract : class +{ + /// + /// 钩子槽位。 + /// + private readonly CapabilityContractSlot _slot; + + /// + /// 初始化 (仅机制构造)。 + /// + /// 钩子槽位(已声明默认实现)。 + internal CapabilityHookHandle(CapabilityContractSlot slot) + { + _slot = slot; + } + + /// + /// 获取当前生效的钩子实现(挂载在场用挂载实现,否则用声明的默认策略;不可缓存返回值)。 + /// + public TContract Current + { + get { return _slot.GetCurrent(); } + } +} diff --git a/Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilityHookLease.cs b/Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilityHookLease.cs new file mode 100644 index 000000000..34db2f6bd --- /dev/null +++ b/Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilityHookLease.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 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 refer to 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.Capability.Hosting.Abstractions; + +/// +/// 能力钩子挂载租约(Hotfix 侧持有;Dispose 摘下当前挂载、回能力声明的默认策略)。 +/// +/// 维护约束:新版本 会使旧租约失效—— +/// 失效租约的 Dispose 为空操作(不得摘下新挂载);挂载编排接入热更装载 / 退役流程, +/// 旧版本退役时其租约已失效。 +/// +/// +public sealed class CapabilityHookLease : IDisposable where TContract : class +{ + + /// + /// 摘下回调(指向所属槽位的版本化摘下)。 + /// + private readonly Action _detach; + + /// + /// 初始化 (仅机制构造)。 + /// + /// 摘下回调。 + internal CapabilityHookLease(Action detach) + { + _detach = detach ?? throw new ArgumentNullException(nameof(detach)); + } + + /// + /// 摘下当前挂载(已被替换的旧租约为空操作;摘下后能力读到默认策略)。 + /// + public void Dispose() + { + _detach(); + } +} diff --git a/Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilityHookSlots.cs b/Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilityHookSlots.cs new file mode 100644 index 000000000..881c7e22e --- /dev/null +++ b/Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilityHookSlots.cs @@ -0,0 +1,127 @@ +// ========================================================================================== +// 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 refer to 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.Capability.Hosting.Abstractions; + +/// +/// 钩子槽位审计登记(跨契约的声明 / 挂载记账;装配期「已挂载但无能力声明」Info 审计的数据面)。 +/// +/// 维护约束:仅机制使用——声明记录来自模块 Register 期的 DeclareHook,挂载记录来自 Hotfix 装载期的 +/// ;先挂后声明是常态(热更装载在组合之前)。 +/// +/// +internal static class CapabilityHookSlots +{ + /// + /// 登记锁。 + /// + private static readonly object Sync = new object(); + + /// + /// 已声明的钩子契约类型 → 未释放的声明次数(能力 Register 期 +1、声明方宿主释放时 -1,归零移除)。 + /// 维护约束:必须按次数记账而不是集合——旧宿主释放与新宿主重新声明可能交错到达, + /// 集合语义下迟到的释放会抹掉新声明,使「已挂载但无能力声明」审计误报。 + /// + private static readonly Dictionary DeclarationCountsByContract = new Dictionary(); + + /// + /// 已挂载的钩子契约类型集合(Hotfix 装载期;挂载后摘下仍保留记录——审计关注「出现过挂载」)。 + /// + private static readonly HashSet AttachedContracts = new HashSet(); + + /// + /// 登记一次钩子声明(模块 Register 期;声明次数 +1)。 + /// + /// 钩子契约类型。 + internal static void MarkDeclared(Type contract) + { + lock (Sync) + { + DeclarationCountsByContract.TryGetValue(contract, out var count); + DeclarationCountsByContract[contract] = count + 1; + } + } + + /// + /// 撤销一次钩子声明登记(声明方宿主释放声明时调用;与 成对,次数 -1,归零移除)。 + /// + /// 钩子契约类型。 + internal static void MarkUndeclared(Type contract) + { + lock (Sync) + { + if (!DeclarationCountsByContract.TryGetValue(contract, out var count)) + { + return; + } + + if (count <= 1) + { + DeclarationCountsByContract.Remove(contract); + } + else + { + DeclarationCountsByContract[contract] = count - 1; + } + } + } + + /// + /// 登记一次钩子挂载(Hotfix 装载期)。 + /// + /// 钩子契约类型。 + internal static void MarkAttached(Type contract) + { + lock (Sync) + { + AttachedContracts.Add(contract); + } + } + + /// + /// 获取已挂载但没有任何能力声明的钩子契约集合(对应能力未装配或未声明该钩子;装配期打 Info)。 + /// + /// 未被声明的已挂载契约类型集合。 + internal static IReadOnlyList GetAttachedWithoutDeclaration() + { + lock (Sync) + { + var result = new List(); + foreach (var contract in AttachedContracts) + { + if (!DeclarationCountsByContract.ContainsKey(contract)) + { + result.Add(contract); + } + } + + return result; + } + } +} diff --git a/Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilityJobs.cs b/Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilityJobs.cs index 29004252b..2eb68e7e5 100644 --- a/Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilityJobs.cs +++ b/Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilityJobs.cs @@ -30,7 +30,7 @@ namespace GameFrameX.Capability.Hosting.Abstractions; /// -/// 逐滴答 job(每个调度滴算按装配序执行一次)。 +/// 逐滴答 job(每个调度滴算按装配序执行一次;单个 job 异常由调度壳隔离)。 /// public sealed class CapabilityTickJob { @@ -89,30 +89,3 @@ public CapabilityIntervalJob(string name, TimeSpan interval, Func public Func Run { get; } } - -/// -/// 事件驱动 job(宿主事件入口同步分发)。 -/// -public sealed class CapabilityEventJob -{ - /// - /// 初始化 。 - /// - /// job 全名(能力Id/jobId)。 - /// job 委托。 - public CapabilityEventJob(string name, Action run) - { - Name = name; - Run = run; - } - - /// - /// 获取 job 全名。 - /// - public string Name { get; } - - /// - /// 获取 job 委托。 - /// - public Action Run { get; } -} diff --git a/Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilityManifest.cs b/Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilityManifest.cs index d932126de..d0dd8099f 100644 --- a/Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilityManifest.cs +++ b/Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilityManifest.cs @@ -30,11 +30,12 @@ namespace GameFrameX.Capability.Hosting.Abstractions; /// -/// 能力静态清单(能力包对外的自描述:标识 / 版本 / 暴露的 admin action / 依赖能力)。 +/// 能力静态清单(能力包对外的自描述:标识 / 版本 / 暴露的 admin action / 依赖声明 / Options 根类型)。 /// -/// 维护约束:Id 全宿主唯一(重复即装配失败); 只允许引用其它能力的 Id, -/// 装配期按其做拓扑排序与闭包校验; 是声明面, -/// 实际注册的 admin action 集合必须在组合期与之对账(声明未注册 = 启动失败)。 +/// 维护约束:Id 全宿主唯一(重复即装配失败);(必选)与 (可选) +/// 只允许引用其它能力的 Id,装配期在任何模块 Register 之前统一检查(必选缺失 / 成环 = 装配失败,一次报全); +/// 是声明面,实际注册的 admin action 集合必须在组合期与之双向对账; +/// 是本能力唯一的 Options 根类型(默认值唯一来源;无参数能力不声明)。 /// /// public sealed class CapabilityManifest @@ -45,13 +46,17 @@ public sealed class CapabilityManifest /// 能力标识(非空;建议小写连字符命名,如 online-assets)。 /// 能力版本(非空)。 /// 暴露的 admin action 名集合(无则为空集合)。 - /// 依赖的其它能力 Id 集合(无则为空集合)。 - public CapabilityManifest(string id, string version, IReadOnlyList actions = null, IReadOnlyList requires = null) + /// 必选依赖的其它能力 Id 集合(缺失即装配失败)。 + /// 可选依赖声明集合(缺失时对应功能不装配,能力其余功能不受影响)。 + /// Options 根类型(无参数能力传 null;须为含公共无参构造的类)。 + public CapabilityManifest(string id, string version, IReadOnlyList actions = null, IReadOnlyList requires = null, IReadOnlyList optional = null, Type optionsType = null) { Id = id; Version = version; Actions = actions ?? Array.Empty(); Requires = requires ?? Array.Empty(); + Optional = optional ?? Array.Empty(); + OptionsType = optionsType; } /// @@ -70,7 +75,17 @@ public CapabilityManifest(string id, string version, IReadOnlyList actio public IReadOnlyList Actions { get; } /// - /// 获取依赖的其它能力 Id 集合。 + /// 获取必选依赖的其它能力 Id 集合(对方未装配 = 装配失败)。 /// public IReadOnlyList Requires { get; } + + /// + /// 获取可选依赖声明集合(对方未装配 = 对应功能不装配,能力其余功能正常运行)。 + /// + public IReadOnlyList Optional { get; } + + /// + /// 获取 Options 根类型(本能力参数默认值的唯一来源;null = 无参数能力)。 + /// + public Type OptionsType { get; } } diff --git a/Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilityOptionalDependency.cs b/Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilityOptionalDependency.cs new file mode 100644 index 000000000..1d51a17a2 --- /dev/null +++ b/Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilityOptionalDependency.cs @@ -0,0 +1,72 @@ +// ========================================================================================== +// 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 refer to 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.Capability.Hosting.Abstractions; + +/// +/// 可选依赖声明(manifest 条目:依赖能力 Id + 缺失时少了什么功能)。 +/// +/// 维护约束:可选依赖缺失是正常装配形态——模块必须完整装配并正常运行,只是对应功能不装配; +/// 做不到这一点的依赖就不是可选,应声明为必选。 只描述缺失的功能 +/// (如「对局结算发奖」),缺失原因与「不影响其余功能运行」由机制统一追加,各包不得自行措辞。 +/// +/// +public sealed class CapabilityOptionalDependency +{ + /// + /// 初始化 。 + /// + /// 依赖的能力 Id(非空)。 + /// 缺失功能的本地化文案键(非空)。 + public CapabilityOptionalDependency(string capabilityId, string absentFeatureLocalizationKey) + { + if (string.IsNullOrWhiteSpace(capabilityId)) + { + throw new ArgumentException("Capability id must be a non-empty string.", nameof(capabilityId)); + } + + if (string.IsNullOrWhiteSpace(absentFeatureLocalizationKey)) + { + throw new ArgumentException("Absent feature localization key must be a non-empty string.", nameof(absentFeatureLocalizationKey)); + } + + CapabilityId = capabilityId; + AbsentFeatureLocalizationKey = absentFeatureLocalizationKey; + } + + /// + /// 获取依赖的能力 Id。 + /// + public string CapabilityId { get; } + + /// + /// 获取缺失功能的本地化文案键(只写功能,不写原因与「不影响」,二者由机制追加)。 + /// + public string AbsentFeatureLocalizationKey { get; } +} diff --git a/Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilityOptionsValidation.cs b/Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilityOptionsValidation.cs new file mode 100644 index 000000000..e34055100 --- /dev/null +++ b/Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilityOptionsValidation.cs @@ -0,0 +1,122 @@ +// ========================================================================================== +// 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 refer to 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.Capability.Hosting.Abstractions; + +/// +/// Options 校验问题收集器( 的写出面)。 +/// +/// 维护约束:问题必须附字段路径(如 Friend.FriendLimit);任一问题 = 装配期 fail-fast, +/// 机制把全部问题一次报全(改一次配置即可看到全部问题)。 +/// +/// +public sealed class CapabilityOptionsValidation +{ + /// + /// 问题清单(字段路径 + 描述)。 + /// + private readonly List _problems = new(); + + /// + /// 初始化 。 + /// + public CapabilityOptionsValidation() + { + } + + /// + /// 获取是否通过校验(无任何问题)。 + /// + public bool IsValid + { + get { return _problems.Count == 0; } + } + + /// + /// 获取全部问题(字段路径 + 描述)。 + /// + public IReadOnlyList Problems + { + get { return _problems; } + } + + /// + /// 记录一个问题。 + /// + /// 字段路径(相对 Options 根类型,如 Friend.FriendLimit)。 + /// 问题描述。 + public void AddError(string fieldPath, string message) + { + if (string.IsNullOrWhiteSpace(fieldPath)) + { + throw new ArgumentException("Field path must be a non-empty string.", nameof(fieldPath)); + } + + _problems.Add(fieldPath + ": " + (message ?? string.Empty)); + } + + /// + /// 要求字段为正数(非正即记录问题)。 + /// + /// 字段路径。 + /// 字段值。 + public void RequirePositive(string fieldPath, int value) + { + if (value <= 0) + { + AddError(fieldPath, "must be positive (actual: " + value + ")"); + } + } + + /// + /// 要求字段为正数(非正即记录问题)。 + /// + /// 字段路径。 + /// 字段值。 + public void RequirePositive(string fieldPath, long value) + { + if (value <= 0) + { + AddError(fieldPath, "must be positive (actual: " + value + ")"); + } + } + + /// + /// 要求字符串字段非空(null 或空白即记录问题)。 + /// + /// 字段路径。 + /// 字段值。 + public void RequireNonEmpty(string fieldPath, string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + AddError(fieldPath, "must be a non-empty string"); + } + } +} diff --git a/Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilityRegistrationContext.cs b/Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilityRegistrationContext.cs index 8092db031..27cfdaea1 100644 --- a/Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilityRegistrationContext.cs +++ b/Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilityRegistrationContext.cs @@ -1,21 +1,21 @@ // ========================================================================================== // GameFrameX 组织及其衍生项目的版权、商标、专利及其他相关权利 -// GameFrameX organization and its derivative projects' copyrights, trademarks, patents and other rights +// 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 other international regulations. -// 使用本项目须严格遵守相关法律法规与开源许可证之规定。 -// Usage of this project must strictly comply with relevant laws, regulations, and open-source licenses. +// 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 refer to the full license text in the LICENSE file located at the root of the source code. +// 完整许可证文本请参见源代码根目录下的 LICENSE 文件。 +// please refer to 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 activity that endangers national security, disrupts social order, -// 侵犯公民合法权益等法律法规所禁止的行为! -// or infringe upon the legal rights and interests of citizens as prohibited by laws and regulations! +// 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, -// 本组织不承担任何责任,开发者应自行承担。 +// 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 @@ -24,22 +24,24 @@ // CNB 仓库:https://cnb.cool/GameFrameX // CNB Repository: https://cnb.cool/GameFrameX // 官方文档:https://gameframex.doc.alianblank.com/ -// Official documentation: https://gameframex.doc.alianblank.com/ +// Official Documentation: https://gameframex.doc.alianblank.com/ // ========================================================================================== +using GameFrameX.Foundation.Localization.Core; + namespace GameFrameX.Capability.Hosting.Abstractions; /// -/// 能力注册上下文(装配期对单个能力模块开放的注册面)。 +/// 能力注册上下文(Register 期对单个能力模块开放的唯一注册面)。 /// -/// 维护约束:三类调度 job 的执行语义由调度壳统一承载—— -/// (每调度滴答顺序执行,执行序 = 全宿主装配序)、 -/// (独立周期,到点执行)、 -/// (事件驱动爆发口,由宿主事件入口同步分发); -/// job 全名(能力Id/jobId)全宿主唯一,重复即装配失败。 -/// 服务共享协议(C231):本上下文同时开放宿主共享的服务注册表面 -/// ( / / ), -/// 模块间跨域依赖经此解析而非组合根直连——先序模块注册的产物对后序模块可见(装配序 = Requires 拓扑序)。 +/// 维护约束(装配红线): +/// job 全名(能力Id/jobId)全宿主唯一,重复即装配失败; +/// 服务注册为描述符形态(类型键 + 命名键 + 懒工厂 + 来源 = 本能力); +/// 单实例解析按本能力做来源检查——只允许解析自己 / 族基建 / 宿主覆盖 / 直接声明的必选与在场可选依赖, +/// 越界即装配期 fail-fast(不靠装配顺序碰巧可用); +/// 只能在 Activate 期用(Register 期枚举到的是部分实现,结果随顺序变化),本上下文内调用直接抛出; +/// 事件订阅只能在 Register 期声明(),Activate 开始后总线拒绝发布之前的积压; +/// 返回机制合并校验后的最终实例(包内默认 → 配置段 → 代码 Configure → Validate)。 /// /// public sealed class CapabilityRegistrationContext @@ -61,100 +63,218 @@ public IReadOnlyList IntervalJobs } /// - /// 事件驱动 job 注册表。 + /// 逐滴答 job 收集表。 /// - public IReadOnlyList EventJobs - { - get { return _eventJobs; } - } + private readonly List _tickJobs = new(); /// - /// 本宿主共享的服务注册表(组合根预置与全部能力模块注册共用;装配后经 取用)。 + /// 间隔 job 收集表。 /// - /// - /// The host-wide shared service registry (shared between composition-root pre-seeds and every capability - /// module registration; consumable after assembly through ). - /// - public CapabilityServices Services { get; } + private readonly List _intervalJobs = new(); /// - /// 逐滴答 job 收集表。 + /// 已注册 job 全名集合(防重复)。 /// - private readonly List _tickJobs = new(); + private readonly HashSet _registeredJobNames = new(StringComparer.Ordinal); /// - /// 间隔 job 收集表。 + /// 所属能力的清单(job 全名限定与解析来源检查主体)。 /// - private readonly List _intervalJobs = new(); + private readonly CapabilityManifest _manifest; /// - /// 事件驱动 job 收集表。 + /// 宿主共享服务注册表(装配器逐模块注入同一实例)。 /// - private readonly List _eventJobs = new(); + private readonly CapabilityServices _services; /// - /// 已注册 job 全名集合(防重复)。 + /// 机制事件总线(Register 期订阅声明)。 /// - private readonly HashSet _registeredJobNames = new(StringComparer.Ordinal); + private readonly ICapabilityEventBus _eventBus; /// - /// 所属能力的清单(job 全名限定用)。 + /// 机制合并校验后的 Options 实例(manifest 未声明 Options 根类型时为 null)。 /// - private readonly CapabilityManifest _manifest; + private readonly object _boundOptions; /// - /// 初始化 (独立注册表形态;共享注册表由装配器注入)。 + /// 钩子声明释放凭据收集器(宿主持有;停止 / 激活失败 / 装配失败时统一释放进程级静态槽位的默认声明)。 + /// + private readonly List _hookDeclarationReleases; + + /// + /// 初始化 (仅机制装配器构造)。 /// /// 所属能力清单。 - public CapabilityRegistrationContext(CapabilityManifest manifest) - : this(manifest, new CapabilityServices()) + /// 宿主共享服务注册表。 + /// 机制事件总线。 + /// 合并校验后的 Options 实例(无参数能力为 null)。 + /// 钩子声明释放凭据收集器(宿主持有,不可为 null)。 + internal CapabilityRegistrationContext(CapabilityManifest manifest, CapabilityServices services, ICapabilityEventBus eventBus, object boundOptions, List hookDeclarationReleases) { + _manifest = manifest ?? throw new ArgumentNullException(nameof(manifest)); + _services = services ?? throw new ArgumentNullException(nameof(services)); + _eventBus = eventBus ?? throw new ArgumentNullException(nameof(eventBus)); + _boundOptions = boundOptions; + _hookDeclarationReleases = hookDeclarationReleases ?? throw new ArgumentNullException(nameof(hookDeclarationReleases)); } /// - /// 初始化 (共享注册表形态,装配器逐模块注入同一实例)。 + /// 注册本能力的一个默认服务(实例形态;按 + 空命名键索引,来源 = 本能力)。 /// - /// 所属能力清单。 - /// 宿主共享服务注册表(不可为 null)。 - public CapabilityRegistrationContext(CapabilityManifest manifest, CapabilityServices services) + /// 服务类型键(调用点书写形态,可为接口)。 + /// 服务实例(不可为 null)。 + public void AddService(TService instance) where TService : class { - _manifest = manifest ?? throw new ArgumentNullException(nameof(manifest)); - Services = services ?? throw new ArgumentNullException(nameof(services)); + if (instance == null) + { + throw new ArgumentNullException(nameof(instance)); + } + + var capturedInstance = instance; + AddRegistration(string.Empty, resolver => capturedInstance); + } + + /// + /// 注册本能力的一个默认服务(懒工厂形态;首次解析时构造,宿主内单例)。 + /// + /// 服务类型键。 + /// 懒工厂(参数为按本能力限定的解析器;不可为 null)。 + public void AddService(Func factory) where TService : class + { + if (factory == null) + { + throw new ArgumentNullException(nameof(factory)); + } + + AddRegistration(string.Empty, factory); } /// - /// 注册本能力的一个服务产物(按 索引;对后序模块与组合根可见)。 + /// 注册本能力的一个命名服务(实例形态;同类型键多实现时的命名寻址,如 party / group 探针)。 /// - /// 服务类型(调用点书写形态,可为接口)。 - /// 服务实例(不可为 null)。 - public void AddService(TService service) + /// 服务类型键。 + /// 命名键(非空)。 + /// 服务实例(不可为 null)。 + public void AddService(string name, TService instance) where TService : class { - Services.AddService(service); + if (instance == null) + { + throw new ArgumentNullException(nameof(instance)); + } + + ValidateServiceName(name); + var capturedInstance = instance; + AddRegistration(name, resolver => capturedInstance); + } + + /// + /// 注册本能力的一个命名服务(懒工厂形态)。 + /// + /// 服务类型键。 + /// 命名键(非空)。 + /// 懒工厂(不可为 null)。 + public void AddService(string name, Func factory) where TService : class + { + if (factory == null) + { + throw new ArgumentNullException(nameof(factory)); + } + + ValidateServiceName(name); + AddRegistration(name, factory); } /// - /// 解析先序能力或组合根预置的服务(缺失即抛 ;用于模块声明的硬依赖)。 + /// 解析一个服务(缺失或来源越界即装配期 fail-fast;用于直接声明的必选 / 在场可选依赖)。 /// - /// 服务类型。 + /// 服务类型键。 /// 服务实例。 - public TService GetService() + public TService GetService() where TService : class + { + return _services.ResolveScoped(_manifest.Id, string.Empty, false); + } + + /// + /// 按可选依赖语义解析(唯一合法缺席 = manifest 声明过的可选能力未装配,返回 null)。 + /// + /// 服务类型键。 + /// 服务实例;声明的可选依赖未装配时为 null。 + public TService GetOptionalService() where TService : class + { + return _services.ResolveScoped(_manifest.Id, string.Empty, true); + } + + /// + /// 枚举同类型键的全部实现(Register 期不可用——本方法在注册上下文内恒抛出; + /// 依赖关系之外的扩展点在 的 Services 上枚举)。 + /// + /// 服务类型键。 + /// 恒抛出 。 + public IReadOnlyList GetServices() where TService : class + { + return _services.ResolveAllScoped(_manifest.Id); + } + + /// + /// 获取机制合并校验后的 Options 实例(包内默认 → 配置段 → 代码 Configure → Validate;此后无写入入口)。 + /// + /// Options 根类型(须与 manifest 声明一致)。 + /// 合并校验后的实例。 + public TOptions GetOptions() where TOptions : class + { + if (_boundOptions == null) + { + // Localization: Capability.Hosting.OptionsTypeMismatch - Options 根类型不符。 + throw new InvalidOperationException(string.Format(LocalizationService.GetString(Localization.Keys.Capability.Hosting.OptionsTypeMismatch), _manifest.Id, "null", typeof(TOptions).FullName)); + } + + if (!(_boundOptions is TOptions matched)) + { + // Localization: Capability.Hosting.OptionsTypeMismatch - Options 根类型不符。 + throw new InvalidOperationException(string.Format(LocalizationService.GetString(Localization.Keys.Capability.Hosting.OptionsTypeMismatch), _manifest.Id, _boundOptions.GetType().FullName, typeof(TOptions).FullName)); + } + + return matched; + } + + /// + /// 声明一个能力钩子与默认策略(得到可缓存的句柄;热更侧经 挂载)。 + /// + /// 钩子契约类型。 + /// 默认策略实现(不可为 null;无挂载时生效)。 + /// 钩子句柄(句柄可缓存,Current 不可缓存)。 + public CapabilityHookHandle DeclareHook(TContract defaultImplementation) where TContract : class { - return Services.GetService(); + return CapabilityHook.Declare(defaultImplementation, _manifest.Id, _hookDeclarationReleases); + } + + /// + /// 声明一个无默认的宿主扩展点(宿主必须 Override<TService> 提供;缺失即装配期 fail-fast)。 + /// + /// 宿主服务类型键。 + public void RequireHostService() where TService : class + { + if (!_services.HasOverride(typeof(TService), string.Empty)) + { + // Localization: Capability.Hosting.HostServiceMissing - 声明的宿主服务未提供。 + throw new InvalidOperationException(string.Format(LocalizationService.GetString(Localization.Keys.Capability.Hosting.HostServiceMissing), _manifest.Id, typeof(TService).FullName)); + } } /// - /// 尝试解析服务(缺失返回 false;用于可选依赖与替换缝探测——未命中时模块自行构造默认实现)。 + /// 声明一个事件订阅(Register 期专用;Activate 开始后总线才接受发布,保证后注册订阅者不丢事件)。 /// - /// 服务类型。 - /// 输出:服务实例;缺失为 default。 - /// 命中返回 true。 - public bool TryGetService(out TService service) + /// 事件载荷类型。 + /// 主题(命名约定 <族>.<域>.<事件>,非空)。 + /// 同步订阅体(单个订阅体异常由机制记日志后继续分发)。 + public void Subscribe(string topic, Action handler) { - return Services.TryGetService(out service); + _eventBus.Subscribe(topic, handler); } /// - /// 注册逐滴答 job(每个调度滴算按装配序执行;单 job 异常吞掉不中断当轮其余 job)。 + /// 注册逐滴答 job(每个调度滴答按装配序执行;单个 job 异常记结构化日志后继续当轮剩余 job)。 /// /// job 标识(能力内唯一,非空)。 /// job 委托。 @@ -169,7 +289,7 @@ public void AddTickJob(string jobId, Func job) } /// - /// 注册间隔 job(独立周期到点执行,与滴答序无关)。 + /// 注册间隔 job(独立周期到点执行,与滴答序无关;异常只影响自身本轮)。 /// /// job 标识(能力内唯一,非空)。 /// 执行周期(调度壳按 100ms 下限钳制)。 @@ -185,18 +305,27 @@ public void AddIntervalJob(string jobId, TimeSpan interval, Func - /// 注册事件驱动 job(宿主事件入口同步分发;单 job 异常吞掉不影响其它 job)。 + /// 落一条默认服务描述符(来源 = 本能力;命名键空串 = 默认实现)。 /// - /// job 标识(能力内唯一,非空)。 - /// job 委托。 - public void AddEventJob(string jobId, Action job) + /// 服务类型键。 + /// 命名键(空串 = 默认实现)。 + /// 懒工厂。 + private void AddRegistration(string name, Func factory) where TService : class { - if (job == null) + var registration = new CapabilityServiceRegistration(typeof(TService), name ?? string.Empty, resolver => factory(resolver), _manifest.Id, false); + _services.AddDefault(registration); + } + + /// + /// 校验命名键(命名注册非空;默认注册不经本方法)。 + /// + /// 命名键。 + private static void ValidateServiceName(string name) + { + if (string.IsNullOrWhiteSpace(name)) { - throw new ArgumentNullException(nameof(job)); + throw new ArgumentException("Service name must be a non-empty string.", nameof(name)); } - - _eventJobs.Add(new CapabilityEventJob(CreateJobName(jobId), job)); } /// diff --git a/Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilityReport.cs b/Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilityReport.cs index 6998629d4..6e49a0577 100644 --- a/Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilityReport.cs +++ b/Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilityReport.cs @@ -30,7 +30,7 @@ namespace GameFrameX.Capability.Hosting.Abstractions; /// -/// 启动能力报告条目(一个能力的装配结果)。 +/// 启动能力报告条目(一个能力的装配结果:清单 + 判定 + 依赖状态 + 缺失功能 + Options 字段生效值)。 /// public sealed class CapabilityReportEntry { @@ -40,11 +40,19 @@ public sealed class CapabilityReportEntry /// 能力清单。 /// 来源程序集名。 /// 装配判定。 - public CapabilityReportEntry(CapabilityManifest manifest, string sourceAssemblyName, CapabilityFilterState state) + /// 依赖状态条目(无则为空集合)。 + /// 可选依赖缺失的功能文案集合(无则为空集合)。 + /// Options 字段生效值条目(无参数能力为空集合)。 + /// 配置段为本能力写了 Settings 但其未被装配(忽略并在报告标注)。 + public CapabilityReportEntry(CapabilityManifest manifest, string sourceAssemblyName, CapabilityFilterState state, IReadOnlyList dependencies = null, IReadOnlyList absentFeatures = null, IReadOnlyList settingsFields = null, bool settingsConfiguredButNotAssembled = false) { Manifest = manifest; SourceAssemblyName = sourceAssemblyName; State = state; + Dependencies = dependencies ?? Array.Empty(); + AbsentFeatures = absentFeatures ?? Array.Empty(); + SettingsFields = settingsFields ?? Array.Empty(); + SettingsConfiguredButNotAssembled = settingsConfiguredButNotAssembled; } /// @@ -61,10 +69,31 @@ public CapabilityReportEntry(CapabilityManifest manifest, string sourceAssemblyN /// 获取装配判定(启用才参与拓扑定序)。 /// public CapabilityFilterState State { get; } + + /// + /// 获取依赖状态条目(必选 + 可选;可选缺失含缺席原因)。 + /// + public IReadOnlyList Dependencies { get; } + + /// + /// 获取可选依赖缺失的功能文案集合(本地化后的功能描述;运维不依赖日志也能查到少了什么)。 + /// + public IReadOnlyList AbsentFeatures { get; } + + /// + /// 获取 Options 字段生效值条目(字段路径 + 生效值 + 来源)。 + /// + public IReadOnlyList SettingsFields { get; } + + /// + /// 获取是否「已配置但未装配」(配置段为本能力写了 Settings 但其被过滤器排除;该段被忽略并在此标注)。 + /// + public bool SettingsConfiguredButNotAssembled { get; } } /// -/// 启动能力报告(装配完成后的全量能力快照:启用清单 / 装配顺序 / 禁用项)。 +/// 启动能力报告(装配完成后的全量能力快照:启用清单 / 装配顺序 / 禁用项 / 依赖状态 / Options 生效值; +/// 装配日志与 list_capabilities 共用同一份数据面,两边不会对不上)。 /// public sealed class CapabilityReport { diff --git a/Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilityServiceRegistration.cs b/Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilityServiceRegistration.cs new file mode 100644 index 000000000..c805cea40 --- /dev/null +++ b/Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilityServiceRegistration.cs @@ -0,0 +1,85 @@ +// ========================================================================================== +// 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 refer to 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.Capability.Hosting.Abstractions; + +/// +/// 能力服务注册描述符(类型键 + 命名键 + 懒工厂 + 来源 + 覆盖声明的机制注册单元)。 +/// +/// 维护约束:服务键 = 调用点书写的 TService 类型与命名键 +/// (无接口自动映射、无自动构造——显式注册优于反射魔法,不上通用 DI 容器); +/// 同键「一个默认 + 至多一个覆盖」,第二个默认或第二个覆盖均装配失败; +/// 是解析来源检查(Register 期只允许解析自己 / 族基建 / 宿主覆盖 / +/// 直接声明的必选与在场可选依赖)与 admin 审计面的依据。 +/// +/// +public sealed class CapabilityServiceRegistration +{ + /// + /// 初始化 。 + /// + /// 服务类型键(不可为 null)。 + /// 命名键(缺省实现传空串;不可为 null)。 + /// 懒工厂(首次解析时构造,宿主内单例;不可为 null)。 + /// 来源(族 Id 或能力 Id;不可为 null 或空)。 + /// 是否显式覆盖声明(覆盖实现解析时总是胜出)。 + public CapabilityServiceRegistration(Type serviceType, string name, Func factory, string ownerCapabilityId, bool isOverride) + { + ServiceType = serviceType ?? throw new ArgumentNullException(nameof(serviceType)); + Name = name ?? string.Empty; + Factory = factory ?? throw new ArgumentNullException(nameof(factory)); + OwnerCapabilityId = ownerCapabilityId ?? throw new ArgumentNullException(nameof(ownerCapabilityId)); + IsOverride = isOverride; + } + + /// + /// 获取服务类型键(调用点书写形态,可为接口)。 + /// + public Type ServiceType { get; } + + /// + /// 获取命名键(空串 = 默认实现)。 + /// + public string Name { get; } + + /// + /// 获取懒工厂(首次解析时构造,宿主内单例;工厂内解析按工厂所属能力做来源检查)。 + /// + public Func Factory { get; } + + /// + /// 获取来源(族 Id 或能力 Id;admin 审计面)。 + /// + public string OwnerCapabilityId { get; } + + /// + /// 获取是否为显式覆盖声明(族或宿主替换模块默认实现)。 + /// + public bool IsOverride { get; } +} diff --git a/Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilityServiceResolver.cs b/Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilityServiceResolver.cs new file mode 100644 index 000000000..41e030f3d --- /dev/null +++ b/Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilityServiceResolver.cs @@ -0,0 +1,103 @@ +// ========================================================================================== +// 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 refer to 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.Capability.Hosting.Abstractions; + +/// +/// 能力服务解析器(懒工厂与激活上下文共用的按消费者身份限定的只读解析面)。 +/// +/// 维护约束:单实例解析按消费者(工厂所属能力或当前模块)做来源检查——只允许解析 +/// 自己 / 族基建 / 宿主覆盖 / 直接声明的必选与在场可选依赖; +/// 只在 Activate 期可用(全部模块完成 Register 后枚举才完整)。 +/// +/// +public sealed class CapabilityServiceResolver +{ + /// + /// 所属服务注册表。 + /// + private readonly CapabilityServices _services; + + /// + /// 消费者能力 Id(来源检查主体)。 + /// + private readonly string _consumerCapabilityId; + + /// + /// 初始化 (仅机制构造)。 + /// + /// 服务注册表。 + /// 消费者能力 Id。 + internal CapabilityServiceResolver(CapabilityServices services, string consumerCapabilityId) + { + _services = services ?? throw new ArgumentNullException(nameof(services)); + _consumerCapabilityId = consumerCapabilityId; + } + + /// + /// 解析一个服务(缺失即抛 ;来源越界即装配期 fail-fast)。 + /// + /// 服务类型键。 + /// 服务实例。 + public TService GetService() where TService : class + { + return _services.ResolveScoped(_consumerCapabilityId, string.Empty, false); + } + + /// + /// 解析一个命名服务(同键多实现时的命名寻址;缺失即抛)。 + /// + /// 服务类型键。 + /// 命名键(空串 = 默认实现)。 + /// 服务实例。 + public TService GetService(string name) where TService : class + { + return _services.ResolveScoped(_consumerCapabilityId, name, false); + } + + /// + /// 按可选依赖语义解析(唯一合法缺席 = manifest 声明过的可选能力未装配;返回 null)。 + /// + /// 服务类型键。 + /// 服务实例;声明过的可选依赖未装配时为 null。 + public TService GetOptionalService() where TService : class + { + return _services.ResolveScoped(_consumerCapabilityId, string.Empty, true); + } + + /// + /// 枚举同类型键的全部实现(命名 + 默认;仅 Activate 期可用,Register 期调用直接抛出)。 + /// + /// 服务类型键。 + /// 实现集合(注册序;无实现为空集合)。 + public IReadOnlyList GetServices() where TService : class + { + return _services.ResolveAllScoped(_consumerCapabilityId); + } +} diff --git a/Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilityServices.cs b/Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilityServices.cs index f540bd40b..7bbd41fbe 100644 --- a/Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilityServices.cs +++ b/Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilityServices.cs @@ -1,18 +1,21 @@ // ========================================================================================== // GameFrameX 组织及其衍生项目的版权、商标、专利及其他相关权利 -// GameFrameX organization and its derivative projects' copyrights, trademarks, patents and other rights +// 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 refer to the full license text in the LICENSE file located at the root of the source code. +// 完整许可证文本请参见源代码根目录下的 LICENSE 文件。 +// please refer to 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 activity that endangers national security, disrupts social order, -// 侵犯公民合法权益等法律法规所禁止的行为! -// or infringe upon the legal rights and interests of citizens as prohibited by laws and regulations! +// 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, -// 本组织不承担任何责任,开发者应自行承担。 +// 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 @@ -21,88 +24,500 @@ // CNB 仓库:https://cnb.cool/GameFrameX // CNB Repository: https://cnb.cool/GameFrameX // 官方文档:https://gameframex.doc.alianblank.com/ -// Official documentation: https://gameframex.doc.alianblank.com/ +// Official Documentation: https://gameframex.doc.alianblank.com/ // ========================================================================================== +using GameFrameX.Foundation.Localization.Core; + namespace GameFrameX.Capability.Hosting.Abstractions; /// -/// 能力服务注册表(能力族的服务共享协议载体,C231):组合根预置() -/// 与能力模块注册()共用同一张按服务类型索引的表; -/// 装配完成后经 对组合根开放取用。 +/// 能力服务注册表(描述符表形态:族基建预置、宿主覆盖与能力模块注册共用同一张 +/// 「类型键 + 命名键」索引表;装配完成后经能力宿主的服务面对组合根开放取用)。 /// -/// 维护约束:服务键 = 调用点书写的 TService 类型(无接口自动映射);同类型重复注册即装配失败(对齐 -/// manifest Id 唯一 / job 全名唯一的既有口径);模块间解析依赖拓扑定序——先序模块注册的产物对后序模块可见, -/// 反向不可见;机制包不持有任何能力类型(Hosting=0 红线)。 +/// 维护约束(装配红线):同键「一个默认 + 至多一个覆盖」——第二个默认或第二个覆盖均装配失败; +/// 解析时覆盖实现总是胜出(与注册先后无关,「组合根先预置、模块再探测」的时序约定已废); +/// Register 期与懒工厂内的单实例解析按消费者做来源检查(自己 / 族基建 / 宿主覆盖 / 直接声明的必选与 +/// 在场可选依赖),越界即装配期 fail-fast;GetServices 枚举只在 Activate 期可用; +/// 机制包不持有任何能力类型(零能力硬编码红线)。 /// /// -/// -/// The capability service registry (the capability-family service-sharing protocol, C231): composition-root -/// pre-seeds () and capability-module registrations -/// () share one table indexed by service type; -/// after assembly it is exposed to the composition root through . -/// The service key is the TService type as written at the call site (no automatic interface mapping); -/// duplicate registration of the same type fails assembly (mirroring the manifest-id and job-name uniqueness -/// rules); cross-module resolution relies on topological ordering — products registered by earlier modules are -/// visible to later modules and never the reverse; the mechanism packages hold no capability types. -/// public sealed class CapabilityServices { /// - /// 按服务类型索引的注册表。 + /// 注册与解析共用锁(懒工厂首次构造在锁内执行;同线程重入解析安全)。 + /// + private readonly object _sync = new(); + + /// + /// 注册描述符表(注册序;审计与元端点数据源,被覆盖的默认实现保留在表内)。 + /// + private readonly List _registrations = new(); + + /// + /// 键 → 胜出注册条目索引(键 = 类型键 + 命名键;覆盖注册即替换胜出条目)。 + /// + private readonly Dictionary _entriesByKey = new(StringComparer.Ordinal); + + /// + /// 消费者能力 Id → 允许解析的来源 Id 集合(族 Id 恒在允许集)。 + /// + private readonly Dictionary> _allowedOwnersByConsumer = new(StringComparer.Ordinal); + + /// + /// 消费者能力 Id → 已声明但未装配的可选依赖 Id 列表(每个启用模块都有条目,可为空列表)。 + /// 维护约束:模块作用域的可选解析只有在该列表非空时才允许返回 null——对方能力在场却没注册该服务 + /// 必须 fail-fast(设计 §4.9.1),不能静默丢功能。 + /// + private readonly Dictionary> _unassembledOptionalByConsumer = new(StringComparer.Ordinal); + + /// + /// 懒工厂解析链(正在构造中的键描述,按进入顺序;仅在 内读写——工厂在锁内执行,链天然单线程)。 + /// + private readonly List _resolutionChain = new(); + + /// + /// 族 Id(族基建与宿主覆盖的来源标记;族基建注册前由装配器注入)。 + /// + private string _familyCapabilityId; + + /// + /// 是否已进入 Activate 期(GetServices 枚举开关)。 /// - private readonly Dictionary _services = new(); + private bool _activationStarted; /// - /// 注册一个服务实例(按 索引;同类型重复注册即抛 )。 + /// 注册条目(描述符 + 懒实例缓存)。 /// - /// 服务类型(调用点书写形态,可为接口)。 - /// 服务实例(不可为 null)。 - public void AddService(TService service) + private sealed class RegistrationEntry { - if (service == null) + /// + /// 注册描述符。 + /// + public CapabilityServiceRegistration Registration; + + /// + /// 懒构造的实例(首次解析前为 null)。 + /// + public object Instance; + + /// + /// 是否已完成首次解析。 + /// + public bool Resolved; + + /// + /// 是否正在执行懒工厂(同键重入 = 循环依赖)。 + /// + public bool Resolving; + } + + /// + /// 初始化 (空表;族 Id 与解析策略由装配器随后注入)。 + /// + public CapabilityServices() + { + } + + /// + /// 获取全部注册描述符(注册序;admin 元端点与审计面数据源)。 + /// + public IReadOnlyList Registrations + { + get { - throw new ArgumentNullException(nameof(service)); + lock (_sync) + { + return _registrations.ToArray(); + } } + } + + /// + /// 解析默认命名服务(宿主侧取用:装配完成后的组合根 / 管理面;不做来源检查)。 + /// + /// 服务类型键。 + /// 服务实例。 + public TService GetService() where TService : class + { + return GetService(string.Empty); + } - if (_services.ContainsKey(typeof(TService))) + /// + /// 解析命名服务(宿主侧取用;缺失即抛 )。 + /// + /// 服务类型键。 + /// 命名键(空串 = 默认实现)。 + /// 服务实例。 + public TService GetService(string name) where TService : class + { + var entry = FindEntry(typeof(TService), name ?? string.Empty); + if (entry == null) + { + // Localization: Capability.Hosting.ServiceNotRegistered - 能力服务未注册。 + throw new InvalidOperationException(string.Format(LocalizationService.GetString(Localization.Keys.Capability.Hosting.ServiceNotRegistered), BuildKeyDescription(typeof(TService), name))); + } + + return (TService)GetInstance(entry); + } + + /// + /// 按可选语义解析默认命名服务(宿主侧取用;缺失返回 null)。 + /// + /// 服务类型键。 + /// 服务实例;未注册为 null。 + public TService GetOptionalService() where TService : class + { + var entry = FindEntry(typeof(TService), string.Empty); + return entry == null ? null : (TService)GetInstance(entry); + } + + /// + /// 枚举同类型键的全部实现(宿主侧取用;注册序;同键被覆盖的默认实现不列出)。 + /// + /// 服务类型键。 + /// 实现集合(无实现为空集合)。 + public IReadOnlyList GetServices() where TService : class + { + return ResolveAllScoped(null); + } + + /// + /// 注入族 Id(族基建注册前由装配器调用一次)。 + /// + /// 族 Id。 + internal void SetFamilyCapabilityId(string familyCapabilityId) + { + lock (_sync) { - throw new InvalidOperationException("Duplicate capability service registration: " + typeof(TService).FullName); + _familyCapabilityId = familyCapabilityId; } + } - _services[typeof(TService)] = service; + /// + /// 注册默认实现(能力模块或族基建;同键第二个默认 = 装配失败;键上已有覆盖时覆盖仍胜出)。 + /// + /// 注册描述符( 为 false)。 + internal void AddDefault(CapabilityServiceRegistration registration) + { + if (registration == null) + { + throw new ArgumentNullException(nameof(registration)); + } + + if (registration.IsOverride) + { + throw new ArgumentException("Registration must be a default (non-override) registration.", nameof(registration)); + } + + lock (_sync) + { + if (TryFindEntryLocked(registration.ServiceType, registration.Name, out var existing)) + { + if (!existing.Registration.IsOverride) + { + // Localization: Capability.Hosting.ServiceRegistrationDuplicate - 能力服务重复注册。 + throw new InvalidOperationException(string.Format(LocalizationService.GetString(Localization.Keys.Capability.Hosting.ServiceRegistrationDuplicate), BuildKeyDescription(registration.ServiceType, registration.Name))); + } + + // 覆盖先注册(族基建 / 宿主覆盖先于模块默认是常态):默认入审计表,键上仍解析覆盖实现。 + _registrations.Add(registration); + return; + } + + _registrations.Add(registration); + _entriesByKey[BuildKey(registration.ServiceType, registration.Name)] = new RegistrationEntry { Registration = registration, }; + } } /// - /// 解析一个服务(缺失即抛 ;用于模块声明的硬依赖)。 + /// 注册覆盖实现(族或宿主;同键第二个覆盖 = 装配失败;解析时覆盖胜出——与注册先后无关)。 /// - /// 服务类型。 + /// 覆盖描述符( 为 true)。 + internal void AddOverride(CapabilityServiceRegistration registration) + { + if (registration == null) + { + throw new ArgumentNullException(nameof(registration)); + } + + if (!registration.IsOverride) + { + throw new ArgumentException("Registration must be an override registration.", nameof(registration)); + } + + lock (_sync) + { + if (TryFindEntryLocked(registration.ServiceType, registration.Name, out var existingOverride) && existingOverride.Registration.IsOverride) + { + // Localization: Capability.Hosting.ServiceOverrideDuplicate - 能力服务覆盖重复。 + throw new InvalidOperationException(string.Format(LocalizationService.GetString(Localization.Keys.Capability.Hosting.ServiceOverrideDuplicate), BuildKeyDescription(registration.ServiceType, registration.Name))); + } + + _registrations.Add(registration); + _entriesByKey[BuildKey(registration.ServiceType, registration.Name)] = new RegistrationEntry { Registration = registration, }; + } + } + + /// + /// 查找键上的覆盖实现(宿主扩展点声明校验:RequireHostService 需要)。 + /// + /// 服务类型键。 + /// 命名键。 + /// 存在覆盖实现返回 true。 + internal bool HasOverride(Type serviceType, string name) + { + lock (_sync) + { + return TryFindEntryLocked(serviceType, name, out var entry) && entry.Registration.IsOverride; + } + } + + /// + /// 注入解析来源策略(依赖检查完成后、Register 循环前由装配器调用一次)。 + /// + /// 消费者能力 Id → 允许解析的来源 Id 集合。 + /// 消费者能力 Id → 已声明但未装配的可选依赖 Id 列表(每个启用模块一条)。 + internal void SetResolutionPolicy(IReadOnlyDictionary> allowedOwnersByConsumer, IReadOnlyDictionary> unassembledOptionalByConsumer) + { + lock (_sync) + { + _allowedOwnersByConsumer.Clear(); + foreach (var pair in allowedOwnersByConsumer) + { + _allowedOwnersByConsumer[pair.Key] = pair.Value; + } + + _unassembledOptionalByConsumer.Clear(); + foreach (var pair in unassembledOptionalByConsumer) + { + _unassembledOptionalByConsumer[pair.Key] = pair.Value; + } + } + } + + /// + /// 标记进入 Activate 期(宿主启动时调用;开启 GetServices 枚举)。 + /// + internal void MarkActivationStarted() + { + lock (_sync) + { + _activationStarted = true; + } + } + + /// + /// 按消费者限定的单实例解析(来源检查 + 懒构造;消费者为 null 表示宿主侧取用,跳过来源检查)。 + /// 可选语义未命中时:消费者是启用模块且声明的可选依赖全部已装配(或未声明任何可选依赖)→ + /// 抛 (能力在场但服务未注册);存在未装配的已声明可选依赖 → 返回 null。 + /// 非模块消费者(族基建懒工厂)没有 manifest 可选声明,保持返回 null。 + /// + /// 服务类型键。 + /// 消费者能力 Id(null = 宿主侧)。 + /// 命名键。 + /// 是否可选语义(声明的可选能力缺席时返回 null 而非抛出)。 + /// 服务实例;可选语义且声明的可选能力缺席时为 null。 + internal TService ResolveScoped(string consumerCapabilityId, string name, bool optional) where TService : class + { + RegistrationEntry entry; + IReadOnlyList unassembledOptional = null; + lock (_sync) + { + entry = TryFindEntryLocked(typeof(TService), name, out var found) ? found : null; + if (entry == null && consumerCapabilityId != null) + { + _unassembledOptionalByConsumer.TryGetValue(consumerCapabilityId, out unassembledOptional); + } + } + + if (entry == null) + { + if (optional && (unassembledOptional == null || unassembledOptional.Count > 0)) + { + return null; + } + + if (optional) + { + // Localization: Capability.Hosting.OptionalServiceNotRegistered - 可选依赖均已装配但服务未注册。 + throw new InvalidOperationException(string.Format(LocalizationService.GetString(Localization.Keys.Capability.Hosting.OptionalServiceNotRegistered), consumerCapabilityId, BuildKeyDescription(typeof(TService), name))); + } + + // Localization: Capability.Hosting.ServiceNotRegistered - 能力服务未注册。 + throw new InvalidOperationException(string.Format(LocalizationService.GetString(Localization.Keys.Capability.Hosting.ServiceNotRegistered), BuildKeyDescription(typeof(TService), name))); + } + + ValidateResolutionScope(consumerCapabilityId, entry.Registration); + return (TService)GetInstance(entry); + } + + /// + /// 按消费者限定的多实现枚举(仅 Activate 期;不做来源检查——枚举即依赖关系之外扩展点的合法用法; + /// 同键被覆盖的默认实现不列出)。 + /// + /// 服务类型键。 + /// 消费者能力 Id(诊断用)。 + /// 实现集合(注册序)。 + internal IReadOnlyList ResolveAllScoped(string consumerCapabilityId) where TService : class + { + lock (_sync) + { + if (!_activationStarted) + { + // Localization: Capability.Hosting.ServicesEnumerationForbidden - GetServices 只能在 Activate 期调用。 + throw new InvalidOperationException(string.Format(LocalizationService.GetString(Localization.Keys.Capability.Hosting.ServicesEnumerationForbidden), consumerCapabilityId ?? "host", typeof(TService).FullName)); + } + + var result = new List(); + for (var i = 0; i < _registrations.Count; i++) + { + var registration = _registrations[i]; + if (registration.ServiceType != typeof(TService)) + { + continue; + } + + var winner = _entriesByKey[BuildKey(registration.ServiceType, registration.Name)]; + if (!ReferenceEquals(winner.Registration, registration)) + { + continue; + } + + result.Add((TService)GetInstance(winner)); + } + + return result; + } + } + + /// + /// 解析来源检查(消费者只允许解析自己 / 族基建 / 宿主覆盖 / 直接声明的必选与在场可选依赖的产物)。 + /// + /// 消费者能力 Id(null = 宿主侧,跳过检查)。 + /// 命中描述符。 + private void ValidateResolutionScope(string consumerCapabilityId, CapabilityServiceRegistration registration) + { + if (consumerCapabilityId == null) + { + return; + } + + lock (_sync) + { + if (string.Equals(registration.OwnerCapabilityId, _familyCapabilityId, StringComparison.Ordinal)) + { + return; + } + + if (_allowedOwnersByConsumer.TryGetValue(consumerCapabilityId, out var allowed) && allowed.Contains(registration.OwnerCapabilityId)) + { + return; + } + } + + // Localization: Capability.Hosting.ServiceResolutionForbidden - Register 期越界解析。 + throw new InvalidOperationException(string.Format(LocalizationService.GetString(Localization.Keys.Capability.Hosting.ServiceResolutionForbidden), consumerCapabilityId, registration.ServiceType.FullName, registration.OwnerCapabilityId)); + } + + /// + /// 懒构造并缓存实例(首次解析时执行工厂;工厂内解析按工厂所属能力做来源检查)。 + /// 维护约束:工厂执行期间条目标记为「解析中」,同键重入即循环依赖——直接抛 + /// 并列出完整解析链;否则可重入的锁会让 A→B→A 无限递归直至不可捕获的栈溢出。 + /// + /// 注册条目。 /// 服务实例。 - public TService GetService() + private object GetInstance(RegistrationEntry entry) { - if (TryGetService(out var service)) + lock (_sync) { - return service; + if (entry.Resolved) + { + return entry.Instance; + } + + var keyDescription = BuildKeyDescription(entry.Registration.ServiceType, entry.Registration.Name); + if (entry.Resolving) + { + var chain = new List(_resolutionChain) { keyDescription, }; + // Localization: Capability.Hosting.ServiceResolutionCycle - 懒工厂循环解析。 + throw new InvalidOperationException(string.Format(LocalizationService.GetString(Localization.Keys.Capability.Hosting.ServiceResolutionCycle), string.Join(" -> ", chain))); + } + + entry.Resolving = true; + _resolutionChain.Add(keyDescription); + try + { + var resolver = new CapabilityServiceResolver(this, entry.Registration.OwnerCapabilityId); + var instance = entry.Registration.Factory(resolver); + if (instance == null) + { + throw new InvalidOperationException("Capability service factory returned null: " + keyDescription); + } + + entry.Instance = instance; + entry.Resolved = true; + return instance; + } + finally + { + entry.Resolving = false; + _resolutionChain.RemoveAt(_resolutionChain.Count - 1); + } } + } - throw new InvalidOperationException("Capability service not registered: " + typeof(TService).FullName); + /// + /// 查找注册条目(线程安全)。 + /// + /// 服务类型键。 + /// 命名键。 + /// 注册条目;未注册为 null。 + private RegistrationEntry FindEntry(Type serviceType, string name) + { + lock (_sync) + { + return TryFindEntryLocked(serviceType, name, out var entry) ? entry : null; + } } /// - /// 尝试解析一个服务(缺失返回 false 且输出 default;用于可选依赖与替换缝探测)。 + /// 查找注册条目(须在锁内调用)。 /// - /// 服务类型。 - /// 输出:服务实例;缺失为 default。 + /// 服务类型键。 + /// 命名键。 + /// 输出:注册条目。 /// 命中返回 true。 - public bool TryGetService(out TService service) + private bool TryFindEntryLocked(Type serviceType, string name, out RegistrationEntry entry) + { + return _entriesByKey.TryGetValue(BuildKey(serviceType, name), out entry); + } + + /// + /// 生成键(类型全名 + 命名键;Ordinal)。 + /// + /// 服务类型键。 + /// 命名键。 + /// 索引键。 + private static string BuildKey(Type serviceType, string name) + { + return serviceType.FullName + "|" + (name ?? string.Empty); + } + + /// + /// 生成键描述(诊断与错误信息用)。 + /// + /// 服务类型键。 + /// 命名键。 + /// 键描述(类型全名,命名键非空时附命名键)。 + private static string BuildKeyDescription(Type serviceType, string name) { - if (_services.TryGetValue(typeof(TService), out var registered) && registered is TService typed) + var displayName = serviceType.FullName; + if (!string.IsNullOrEmpty(name)) { - service = typed; - return true; + displayName += "[" + name + "]"; } - service = default; - return false; + return displayName; } } diff --git a/Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilitySettingsField.cs b/Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilitySettingsField.cs new file mode 100644 index 000000000..fa501eac5 --- /dev/null +++ b/Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilitySettingsField.cs @@ -0,0 +1,68 @@ +// ========================================================================================== +// 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 refer to 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.Capability.Hosting.Abstractions; + +/// +/// Options 字段生效值条目(list_capability_settings 元端点与装配报告的数据面)。 +/// +/// 维护约束:来源只有三层——默认(包内字段初始值)/ 配置段(Settings 按字段覆盖)/ 代码 +///(Configure 回调改写,由机制比较回调前后值得出);生效值为合并校验后的最终值。 +/// +/// +public sealed class CapabilitySettingsField +{ + /// + /// 初始化 。 + /// + /// 字段路径(相对 Options 根类型,如 Friend.FriendLimit)。 + /// 生效值。 + /// 来源(默认 / 配置段 / 代码)。 + public CapabilitySettingsField(string fieldPath, object effectiveValue, CapabilitySettingsFieldSource source) + { + FieldPath = fieldPath; + EffectiveValue = effectiveValue; + Source = source; + } + + /// + /// 获取字段路径(相对 Options 根类型)。 + /// + public string FieldPath { get; } + + /// + /// 获取生效值(合并校验后的最终值)。 + /// + public object EffectiveValue { get; } + + /// + /// 获取来源。 + /// + public CapabilitySettingsFieldSource Source { get; } +} diff --git a/Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilitySettingsFieldSource.cs b/Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilitySettingsFieldSource.cs new file mode 100644 index 000000000..b0f3d4b96 --- /dev/null +++ b/Capability/GameFrameX.Capability.Hosting.Abstractions/CapabilitySettingsFieldSource.cs @@ -0,0 +1,51 @@ +// ========================================================================================== +// 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 refer to 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.Capability.Hosting.Abstractions; + +/// +/// Options 字段值来源(覆盖优先级:包内默认 < 配置段 < 代码 Configure;显示取最后写入层)。 +/// +public enum CapabilitySettingsFieldSource +{ + /// + /// 默认(包内字段初始值,未经配置段与代码改写)。 + /// + Default = 0, + + /// + /// 配置段(Settings 子对象按字段覆盖)。 + /// + ConfigurationSection = 1, + + /// + /// 代码(Configure 回调改写,由机制比较回调前后值得出)。 + /// + Code = 2, +} diff --git a/Capability/GameFrameX.Capability.Hosting.Abstractions/GameFrameX.Capability.Hosting.Abstractions.csproj b/Capability/GameFrameX.Capability.Hosting.Abstractions/GameFrameX.Capability.Hosting.Abstractions.csproj index 5e87e22de..2dc0f6caf 100644 --- a/Capability/GameFrameX.Capability.Hosting.Abstractions/GameFrameX.Capability.Hosting.Abstractions.csproj +++ b/Capability/GameFrameX.Capability.Hosting.Abstractions/GameFrameX.Capability.Hosting.Abstractions.csproj @@ -2,9 +2,17 @@ - GameFrameX.Capability.Hosting.Abstractions,GFX 能力组件机制契约:能力 manifest/模块注册契约/三类调度 job 契约/装配过滤器/能力报告类型. Capability hosting mechanism contracts for the GFX capability packages: manifests, module registration, three scheduler job kinds, assembly filter and capability report types. Documentation: https://gameframex.doc.alianblank.com + GameFrameX.Capability.Hosting.Abstractions,GFX 能力组件机制契约:能力 manifest(必选/可选依赖/Options 根类型)/三段生命周期模块契约/桥与钩子槽位/服务注册描述符/能力族契约/事件总线契约/装配过滤器/能力报告. Capability hosting mechanism contracts for the GFX capability packages: manifests with required/optional dependencies and options root types, three-phase module lifecycle, bridge and hook slots, service registration descriptors, capability family contracts, event bus contracts, assembly filter and capability report types. Documentation: https://gameframex.doc.alianblank.com GameFrameX,Capability,Hosting,Abstractions,Manifest,Scheduler,GameServer 10 + + + + + + + + diff --git a/Capability/GameFrameX.Capability.Hosting.Abstractions/ICapabilityAssemblyLog.cs b/Capability/GameFrameX.Capability.Hosting.Abstractions/ICapabilityAssemblyLog.cs new file mode 100644 index 000000000..db425800a --- /dev/null +++ b/Capability/GameFrameX.Capability.Hosting.Abstractions/ICapabilityAssemblyLog.cs @@ -0,0 +1,53 @@ +// ========================================================================================== +// 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 refer to 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.Capability.Hosting.Abstractions; + +/// +/// 能力装配日志出口(组合选项的必填依赖;机制不绑定具体日志框架)。 +/// +/// 维护约束:级别只用 Info 与 Error——必选缺失、环路、汇总失败用 Error,其余(开始、排除、逐能力汇总、 +/// 可选缺失、完成)用 Info;文案全部走本地化(Capability.Hosting.Assembly.*); +/// Launcher 桥接到统一日志,测试换成可断言的收集器。 +/// +/// +public interface ICapabilityAssemblyLog +{ + /// + /// 记录一条 Info 级装配日志。 + /// + /// 日志内容(已本地化)。 + void Info(string message); + + /// + /// 记录一条 Error 级装配日志(必选缺失 / 环路 / 汇总失败)。 + /// + /// 日志内容(已本地化)。 + void Error(string message); +} diff --git a/Capability/GameFrameX.Capability.Hosting.Abstractions/ICapabilityEventBus.cs b/Capability/GameFrameX.Capability.Hosting.Abstractions/ICapabilityEventBus.cs new file mode 100644 index 000000000..ca050b9f2 --- /dev/null +++ b/Capability/GameFrameX.Capability.Hosting.Abstractions/ICapabilityEventBus.cs @@ -0,0 +1,56 @@ +// ========================================================================================== +// 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 refer to 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.Capability.Hosting.Abstractions; + +/// +/// 机制事件总线契约(订阅声明 + 发布口;机制层唯一事件语义,替代原「事件 job 爆发口 + InMemory 发布器」双通道)。 +/// +/// 维护约束:订阅只能在 Register 期声明(模块经 ); +/// Activate 开始前拒绝发布;分发同步、按订阅注册序;单个订阅体异常记日志后继续分发。 +/// +/// +public interface ICapabilityEventBus +{ + /// + /// 声明一个订阅(Register 期专用)。 + /// + /// 事件载荷类型。 + /// 主题(命名约定 <族>.<域>.<事件>,非空)。 + /// 同步订阅体。 + void Subscribe(string topic, Action handler); + + /// + /// 发布一条事件(Activate 开始前拒绝)。 + /// + /// 事件载荷类型。 + /// 主题(非空)。 + /// 事件载荷。 + void Publish(string topic, TEvent eventPayload); +} diff --git a/Capability/GameFrameX.Capability.Hosting.Abstractions/ICapabilityEventPublisher.cs b/Capability/GameFrameX.Capability.Hosting.Abstractions/ICapabilityEventPublisher.cs new file mode 100644 index 000000000..8b554d030 --- /dev/null +++ b/Capability/GameFrameX.Capability.Hosting.Abstractions/ICapabilityEventPublisher.cs @@ -0,0 +1,48 @@ +// ========================================================================================== +// 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 refer to 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.Capability.Hosting.Abstractions; + +/// +/// 能力事件发布口(族基建把业务发布面(如 Online 的 IOnlineEventPublisher)委托到机制总线的接线位)。 +/// +/// 维护约束:发布在宿主 Activate 开始前被拒绝(Register 期发出的事件后注册订阅者收不到); +/// 分发语义由机制总线统一(同步、订阅注册序、单个订阅体异常记日志后继续)。 +/// +/// +public interface ICapabilityEventPublisher +{ + /// + /// 发布一条事件(同步分发到该主题的全部订阅者)。 + /// + /// 事件载荷类型。 + /// 主题(命名约定 <族>.<域>.<事件>,非空)。 + /// 事件载荷。 + void Publish(string topic, TEvent eventPayload); +} diff --git a/Capability/GameFrameX.Capability.Hosting.Abstractions/ICapabilityFamily.cs b/Capability/GameFrameX.Capability.Hosting.Abstractions/ICapabilityFamily.cs new file mode 100644 index 000000000..00765def9 --- /dev/null +++ b/Capability/GameFrameX.Capability.Hosting.Abstractions/ICapabilityFamily.cs @@ -0,0 +1,63 @@ +// ========================================================================================== +// 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 refer to 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.Capability.Hosting.Abstractions; + +/// +/// 能力族契约(一族能力的组合根骨架:族标识 + 配置段名 + 部署程序集前缀 + 族基建注册面)。 +/// +/// 维护约束(机制开放红线):族实现落在本族脊柱包(如 Online.Abstractions),机制层(Hosting 三件套) +/// 零能力硬编码——第二个能力族只需实现本接口,无需改动机制; 在 +/// 模块扫描与注册之前执行,族基建(作用域 / 事件出口 / 幂等 / 审计 / store 覆盖)经 +/// 以描述符形态注册。 +/// +/// +public interface ICapabilityFamily +{ + /// + /// 获取族标识(如 online;族基建与宿主覆盖的来源标记)。 + /// + string FamilyId { get; } + + /// + /// 获取配置段名(如 Online;诊断与错误路径前缀用,机制不解释其余族级键)。 + /// + string ConfigurationSectionName { get; } + + /// + /// 获取部署程序集前缀(部署目录命名约定扫描圈定 {前缀}.*.dll,如 GameFrameX.Capability.Online)。 + /// + string DeploymentAssemblyPrefix { get; } + + /// + /// 注册族基建(在模块扫描与注册之前执行一次)。 + /// + /// 族基建注册面(配置段 + 事件发布口 + 服务注册 / 覆盖)。 + void RegisterInfrastructure(CapabilityFamilyContext context); +} diff --git a/Capability/GameFrameX.Capability.Hosting.Abstractions/ICapabilityModule.cs b/Capability/GameFrameX.Capability.Hosting.Abstractions/ICapabilityModule.cs index 94d7ef49f..acda29e86 100644 --- a/Capability/GameFrameX.Capability.Hosting.Abstractions/ICapabilityModule.cs +++ b/Capability/GameFrameX.Capability.Hosting.Abstractions/ICapabilityModule.cs @@ -30,10 +30,17 @@ namespace GameFrameX.Capability.Hosting.Abstractions; /// -/// 能力模块契约(一个能力包对机制的全部贡献入口)。 +/// 能力模块契约(一个能力包对机制的全部贡献入口,三段生命周期)。 /// /// 维护约束(机制开放红线):实现类不得依赖机制包以外的任何能力包;机制包(Hosting 三件套) -/// 对能力实现零引用、零硬编码清单——新增能力 = 新 csproj + 自带 manifest + 本接口实现,机制零改动。 +/// 对能力实现零引用、零硬编码清单——新增能力 = 新增部署程序集 + 自带 manifest + 本接口实现,机制零改动。 +/// +/// +/// 生命周期约束: +/// 只做注册(服务 / 调度 job / 事件订阅 / 钩子声明 / 宿主扩展点声明),不做 IO; +/// 在宿主启动期按依赖拓扑序执行(发布桥、预建资源、建立订阅), +/// 任一模块激活失败即宿主启动失败(已激活模块逆序回滚); +/// 在宿主停止期按拓扑逆序执行(释放资源),失败只记日志不阻断其余模块停止。 /// /// public interface ICapabilityModule @@ -44,8 +51,24 @@ public interface ICapabilityModule CapabilityManifest Manifest { get; } /// - /// 向机制注册本能力的贡献(调度 job / 事件 job;admin action 经管理面注册契约另行贡献)。 + /// 装配期注册(全宿主拓扑定序后逐能力执行一次;只收集声明,不得做 IO 或启动后台资源)。 /// /// 注册上下文(仅本能力可见)。 void Register(CapabilityRegistrationContext context); + + /// + /// 宿主启动期激活(Requires 拓扑序):发布桥、预建资源;不得重复执行。 + /// 维护约束:任一模块激活失败 = 宿主启动失败(机制对已激活模块逆序回滚后抛出);桥的撤销由机制在停止期自动完成,模块不得自行撤销。 + /// + /// 激活上下文(只读解析面 + 桥发布面)。 + /// 取消令牌。 + /// 异步任务。 + Task ActivateAsync(CapabilityActivationContext context, CancellationToken cancellationToken); + + /// + /// 宿主停止期去激活(拓扑逆序):释放资源;失败记日志,不阻断其余模块停止。 + /// + /// 取消令牌。 + /// 异步任务。 + Task DeactivateAsync(CancellationToken cancellationToken); } diff --git a/Capability/GameFrameX.Capability.Hosting.Abstractions/ICapabilityOptions.cs b/Capability/GameFrameX.Capability.Hosting.Abstractions/ICapabilityOptions.cs new file mode 100644 index 000000000..53628091a --- /dev/null +++ b/Capability/GameFrameX.Capability.Hosting.Abstractions/ICapabilityOptions.cs @@ -0,0 +1,46 @@ +// ========================================================================================== +// 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 refer to 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.Capability.Hosting.Abstractions; + +/// +/// 能力 Options 校验契约(Options 根类型可选实现;机制在合并完成后统一调用)。 +/// +/// 维护约束:校验的是合并后的最终值(包内默认 → 配置段 → 代码 Configure 的叠加结果); +/// 任一问题 = 装配期 fail-fast;只读属性与常量不可配置(如 MaxIdentifierLength 维持 const)。 +/// +/// +public interface ICapabilityOptions +{ + /// + /// 校验合并后的最终值;问题写入 (附字段路径)。 + /// + /// 校验问题收集器。 + void Validate(CapabilityOptionsValidation validation); +} diff --git a/Capability/GameFrameX.Capability.Hosting.AdminApi/AdminActionRegistry.cs b/Capability/GameFrameX.Capability.Hosting.AdminApi/AdminActionRegistry.cs index 580788ca2..ca6222511 100644 --- a/Capability/GameFrameX.Capability.Hosting.AdminApi/AdminActionRegistry.cs +++ b/Capability/GameFrameX.Capability.Hosting.AdminApi/AdminActionRegistry.cs @@ -24,7 +24,6 @@ // 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.Foundation.Localization.Core; @@ -43,8 +42,9 @@ namespace GameFrameX.Capability.Hosting.AdminApi; /// /// admin action 注册表(能力侧向管理面贡献 action 的唯一入口;大小写不敏感)。 /// -/// 维护约束:action 名跨能力全宿主唯一(重复注册即后者覆盖前者并视为装配缺陷, -/// 组合期对账会以「manifest 声明 ⊆ 实注册」拦截漏注册)。 +/// 维护约束(同名 fail-fast 红线):action 名跨能力全宿主唯一——同名重复注册即装配失败 +///(后者覆盖前者曾是装配缺陷,现直接拒绝);组合期对账双向(manifest 声明 ⊆ 实注册 且 +/// 实注册 ⊆ manifest 声明)。 /// /// public sealed class AdminActionRegistry @@ -55,7 +55,12 @@ public sealed class AdminActionRegistry private readonly Dictionary _actions = new(StringComparer.OrdinalIgnoreCase); /// - /// 注册 action。 + /// 当前贡献者能力 Id(组装器逐模块注册前设置;同名冲突诊断用)。 + /// + private string _currentOwnerCapabilityId; + + /// + /// 注册 action(同名重复 = 装配失败)。 /// /// action 名(路由末段)。 /// 处理器(返回业务数据载荷;抛 映射协议码)。 @@ -73,7 +78,13 @@ public void Register(string action, AdminActionHandler handler, bool wrapIdempot throw new ArgumentNullException(nameof(handler)); } - _actions[action] = new AdminActionRegistration(handler, wrapIdempotency); + if (_actions.ContainsKey(action)) + { + // Localization: Capability.Hosting.AdminApi.ActionNameDuplicate - Admin action 重复注册。 + throw new InvalidOperationException(string.Format(LocalizationService.GetString(Localization.Keys.Capability.Hosting.AdminApi.ActionNameDuplicate), action, _actions[action].OwnerCapabilityId)); + } + + _actions[action] = new AdminActionRegistration(handler, wrapIdempotency, _currentOwnerCapabilityId); } /// @@ -93,6 +104,15 @@ public AdminActionRegistration Find(string action) { return action == null ? null : _actions.TryGetValue(action, out var registration) ? registration : null; } + + /// + /// 设置当前贡献者能力 Id(组装器逐模块注册前调用;同名冲突诊断与审计用)。 + /// + /// 贡献者能力 Id(元端点注册时可传机制标记)。 + internal void SetCurrentOwnerCapabilityId(string ownerCapabilityId) + { + _currentOwnerCapabilityId = ownerCapabilityId; + } } /// @@ -105,10 +125,12 @@ public sealed class AdminActionRegistration /// /// 处理器。 /// 是否幂等包裹。 - public AdminActionRegistration(AdminActionHandler handler, bool wrapIdempotency) + /// 贡献者能力 Id(审计用;元端点为机制标记)。 + public AdminActionRegistration(AdminActionHandler handler, bool wrapIdempotency, string ownerCapabilityId) { Handler = handler; WrapIdempotency = wrapIdempotency; + OwnerCapabilityId = ownerCapabilityId ?? string.Empty; } /// @@ -120,8 +142,12 @@ public AdminActionRegistration(AdminActionHandler handler, bool wrapIdempotency) /// 获取是否经调度器层幂等包裹。 /// public bool WrapIdempotency { get; } -} + /// + /// 获取贡献者能力 Id(同名冲突诊断与审计用)。 + /// + public string OwnerCapabilityId { get; } +} /// /// admin action 贡献者契约(能力模块实现本接口向管理面注册 action;与 ICapabilityModule 搭配使用)。 @@ -133,4 +159,4 @@ public interface IAdminActionContributor /// /// action 注册表。 void RegisterAdminActions(AdminActionRegistry registry); -} \ No newline at end of file +} diff --git a/Capability/GameFrameX.Capability.Hosting.AdminApi/AdminApiComposition.cs b/Capability/GameFrameX.Capability.Hosting.AdminApi/AdminApiComposition.cs new file mode 100644 index 000000000..d930d23b4 --- /dev/null +++ b/Capability/GameFrameX.Capability.Hosting.AdminApi/AdminApiComposition.cs @@ -0,0 +1,196 @@ +// ========================================================================================== +// 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 refer to 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/ +// ========================================================================================== + +using GameFrameX.Capability.Hosting.Abstractions; +using GameFrameX.Capability.Hosting.Core; +using GameFrameX.Foundation.Localization.Core; + +namespace GameFrameX.Capability.Hosting.AdminApi; + +/// +/// 管理面组装工厂(自组合根上移进机制:遍历启用模块的 注册 action、 +/// 双向对账、装配适配器与管理面服务;业务面与 admin-only 形态共用同一次组合结果)。 +/// +/// 维护约束:action 名跨能力全宿主唯一(同名注册 fail-fast);对账双向——manifest 声明 ⊆ 实注册 且 +/// 实注册 ⊆ manifest 声明(多报漏报均装配失败,一次报全);作用域 / 幂等 / 错误码适配器经宿主共享服务面 +/// 解析(族基建注册;缺失即装配失败,幂等适配器可空)。 +/// +/// +public static class AdminApiComposition +{ + /// + /// 启用管理面(Hosting.AdminApi 包的组合接线扩展:设置管理面工厂;机制组合时构建管理面)。 + /// + /// 组合选项。 + /// 管理面监听选项改写回调(可空 = 全默认)。 + /// 组合选项(链式)。 + public static CapabilityCompositionOptions UseAdminApi(this CapabilityCompositionOptions options, Action configure = null) + { + if (options == null) + { + throw new ArgumentNullException(nameof(options)); + } + + var adminApiOptions = new AdminApiOptions(); + configure?.Invoke(adminApiOptions); + options.SetAdminApiPlaneFactory(host => CreateAdminApiPlane(host, adminApiOptions)); + return options; + } + + /// + /// 组装管理面(遍历模块贡献 action → 双向对账 → 解析适配器 → 调度器 + HTTP 服务)。 + /// + /// 已装配能力宿主。 + /// 管理面监听选项。 + /// 管理面(宿主启停编排承载)。 + public static ICapabilityAdminApiPlane CreateAdminApiPlane(CapabilityHost host, AdminApiOptions options) + { + if (host == null) + { + throw new ArgumentNullException(nameof(host)); + } + + if (options == null || !options.IsValid()) + { + // Localization: Capability.Hosting.AdminApi.AdminApiOptionsInvalid - 管理面监听选项非法。 + throw new InvalidOperationException(LocalizationService.GetString(Localization.Keys.Capability.Hosting.AdminApi.AdminApiOptionsInvalid)); + } + + var registry = new AdminActionRegistry(); + foreach (var module in host.Modules) + { + if (module is IAdminActionContributor contributor) + { + registry.SetCurrentOwnerCapabilityId(module.Manifest.Id); + contributor.RegisterAdminActions(registry); + } + } + + registry.SetCurrentOwnerCapabilityId(null); + ReconcileDeclaredActions(registry, host.Report); + + var errorCodes = ResolveRequiredService(host); + var scopeAdapter = ResolveRequiredService(host); + var idempotencyAdapter = host.Services.GetOptionalService(); + var dispatcher = new AdminApiDispatcher( + registry, + errorCodes, + scopeAdapter, + new AdminApiClaimedScope(host.TenantId, host.AppId, host.ServerId), + idempotencyAdapter, + host.Report, + host.Services); + return new AdminApiServer(options, dispatcher); + } + + /// + /// 解析族基建必须提供的管理面服务(缺失即装配失败)。 + /// + /// 服务类型键。 + /// 能力宿主。 + /// 服务实例。 + private static TService ResolveRequiredService(CapabilityHost host) where TService : class + { + var service = host.Services.GetOptionalService(); + if (service == null) + { + // Localization: Capability.Hosting.AdminApi.AdapterMissing - 管理面缺少必需适配器服务。 + throw new InvalidOperationException(string.Format(LocalizationService.GetString(Localization.Keys.Capability.Hosting.AdminApi.AdapterMissing), typeof(TService).FullName)); + } + + return service; + } + + /// + /// 组合期双向对账(按能力分组:每个启用能力的 manifest 声明集 == 该能力名下的实注册集;问题一次报全后 fail-fast)。 + /// + /// 维护约束:归属以注册时的贡献者能力()为准, + /// 不能用全宿主汇总集合比对——否则「A 声明却忘注册、B 注册却未声明」的跨能力冒名会互相抵消、 + /// 把调用路由到错误的域处理器。元端点在对账之后注册,不参与对账。 + /// + /// + /// action 注册表(模块贡献完毕)。 + /// 能力报告。 + private static void ReconcileDeclaredActions(AdminActionRegistry registry, CapabilityReport report) + { + var problems = new List(); + var declaredActionsByCapability = new Dictionary>(StringComparer.Ordinal); + var allDeclaredActions = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var entry in report.Entries) + { + if (entry.State != CapabilityFilterState.Enabled) + { + continue; + } + + var declaredActions = new HashSet(StringComparer.OrdinalIgnoreCase); + declaredActionsByCapability[entry.Manifest.Id] = declaredActions; + foreach (var action in entry.Manifest.Actions) + { + declaredActions.Add(action); + allDeclaredActions.Add(action); + var registration = registry.Find(action); + if (registration == null) + { + // Localization: Capability.Hosting.AdminApi.ActionNotRegistered - 能力声明的 action 未注册。 + problems.Add(string.Format(LocalizationService.GetString(Localization.Keys.Capability.Hosting.AdminApi.ActionNotRegistered), action, entry.Manifest.Id)); + } + else if (!string.Equals(registration.OwnerCapabilityId, entry.Manifest.Id, StringComparison.Ordinal)) + { + // Localization: Capability.Hosting.AdminApi.ActionOwnerMismatch - 能力声明的 action 被其它能力注册(跨能力冒名)。 + problems.Add(string.Format(LocalizationService.GetString(Localization.Keys.Capability.Hosting.AdminApi.ActionOwnerMismatch), action, entry.Manifest.Id, registration.OwnerCapabilityId)); + } + } + } + + foreach (var registeredAction in registry.ActionNames) + { + var ownerCapabilityId = registry.Find(registeredAction).OwnerCapabilityId; + if (declaredActionsByCapability.TryGetValue(ownerCapabilityId, out var ownerDeclaredActions) && ownerDeclaredActions.Contains(registeredAction)) + { + continue; + } + + if (allDeclaredActions.Contains(registeredAction)) + { + // 其它能力声明了该 action:已在声明侧按「跨能力冒名」报出,不重复报。 + continue; + } + + // Localization: Capability.Hosting.AdminApi.ActionNotDeclared - 能力注册的 action 未在其 manifest 声明。 + problems.Add(string.Format(LocalizationService.GetString(Localization.Keys.Capability.Hosting.AdminApi.ActionNotDeclared), registeredAction, ownerCapabilityId)); + } + + if (problems.Count > 0) + { + // Localization: Capability.Hosting.AdminApi.ReconcileFailed - Admin action 对账失败。 + var message = string.Format(LocalizationService.GetString(Localization.Keys.Capability.Hosting.AdminApi.ReconcileFailed), problems.Count) + Environment.NewLine + string.Join(Environment.NewLine, problems); + throw new InvalidOperationException(message); + } + } +} diff --git a/Capability/GameFrameX.Capability.Hosting.AdminApi/AdminApiDispatcher.cs b/Capability/GameFrameX.Capability.Hosting.AdminApi/AdminApiDispatcher.cs index ad6786fa5..c51f7dc1a 100644 --- a/Capability/GameFrameX.Capability.Hosting.AdminApi/AdminApiDispatcher.cs +++ b/Capability/GameFrameX.Capability.Hosting.AdminApi/AdminApiDispatcher.cs @@ -75,12 +75,17 @@ public sealed class AdminApiDispatcher private readonly AdminApiClaimedScope _authorizedScope; /// - /// 能力报告(null = 无报告面,未安装判定不可用)。 + /// 能力报告(null = 无报告面,未安装判定与元端点不可用)。 /// private readonly CapabilityReport _report; /// - /// 初始化 并注册 list_capabilities 元端点(有报告面时)。 + /// 能力服务注册表(null = 无服务面,list_capability_services 不可用)。 + /// + private readonly CapabilityServices _services; + + /// + /// 初始化 并注册元端点(有报告面 / 服务面时)。 /// /// action 注册表(能力侧已贡献)。 /// 管道错误码映射。 @@ -88,7 +93,8 @@ public sealed class AdminApiDispatcher /// 宿主授权作用域。 /// 幂等适配器(可空)。 /// 能力报告(可空)。 - public AdminApiDispatcher(AdminActionRegistry registry, AdminApiErrorCodes errorCodes, IAdminApiScopeAdapter scopeAdapter, AdminApiClaimedScope authorizedScope, IAdminApiIdempotencyAdapter idempotencyAdapter = null, CapabilityReport report = null) + /// 能力服务注册表(可空)。 + public AdminApiDispatcher(AdminActionRegistry registry, AdminApiErrorCodes errorCodes, IAdminApiScopeAdapter scopeAdapter, AdminApiClaimedScope authorizedScope, IAdminApiIdempotencyAdapter idempotencyAdapter = null, CapabilityReport report = null, CapabilityServices services = null) { _registry = registry ?? throw new ArgumentNullException(nameof(registry)); _errorCodes = errorCodes ?? throw new ArgumentNullException(nameof(errorCodes)); @@ -96,10 +102,21 @@ public AdminApiDispatcher(AdminActionRegistry registry, AdminApiErrorCodes error _authorizedScope = authorizedScope ?? throw new ArgumentNullException(nameof(authorizedScope)); _idempotencyAdapter = idempotencyAdapter; _report = report; + _services = services; if (report != null) { + registry.SetCurrentOwnerCapabilityId("mechanism"); registry.Register("list_capabilities", ListCapabilitiesAsync, false); + registry.Register("list_capability_settings", ListCapabilitySettingsAsync, false); + } + + if (services != null) + { + registry.SetCurrentOwnerCapabilityId("mechanism"); + registry.Register("list_capability_services", ListCapabilityServicesAsync, false); } + + registry.SetCurrentOwnerCapabilityId(null); } /// @@ -182,7 +199,7 @@ private object BuildNotInstalledPayload(CapabilityReportEntry disabledOwner) } /// - /// list_capabilities 元端点处理器(能力报告面:启用清单/顺序/来源/禁用项)。 + /// list_capabilities 元端点处理器(能力报告面:启用清单 / 顺序 / 依赖状态 / 缺失功能 / 禁用项)。 /// /// 请求。 /// 已通过守卫的作用域。 @@ -193,14 +210,30 @@ private Task ListCapabilitiesAsync(AdminApiRequest request, AdminApiClai var capabilities = new List(_report.Entries.Count); foreach (var entry in _report.Entries) { + var dependencies = new List(entry.Dependencies.Count); + foreach (var dependency in entry.Dependencies) + { + dependencies.Add(new + { + dependency.CapabilityId, + Kind = dependency.Kind.ToString(), + State = dependency.State.ToString(), + AbsenceReason = dependency.AbsenceReason.ToString(), + }); + } + capabilities.Add(new { entry.Manifest.Id, entry.Manifest.Version, entry.Manifest.Actions, entry.Manifest.Requires, + OptionalCapabilityIds = ToOptionalCapabilityIds(entry.Manifest), entry.SourceAssemblyName, State = entry.State.ToString(), + Dependencies = dependencies, + entry.AbsentFeatures, + entry.SettingsConfiguredButNotAssembled, }); } @@ -212,6 +245,95 @@ private Task ListCapabilitiesAsync(AdminApiRequest request, AdminApiClai return Task.FromResult(payload); } + /// + /// list_capability_services 元端点处理器(服务注册表面:类型键 / 命名键 / 来源 / 是否覆盖)。 + /// + /// 请求。 + /// 已通过守卫的作用域。 + /// 取消令牌。 + /// 服务注册表载荷。 + private Task ListCapabilityServicesAsync(AdminApiRequest request, AdminApiClaimedScope scope, CancellationToken cancellationToken) + { + var overriddenKeys = new HashSet(StringComparer.Ordinal); + var registrations = _services.Registrations; + for (var i = 0; i < registrations.Count; i++) + { + if (registrations[i].IsOverride) + { + overriddenKeys.Add(registrations[i].ServiceType.FullName + "|" + registrations[i].Name); + } + } + + var services = new List(registrations.Count); + for (var i = 0; i < registrations.Count; i++) + { + var registration = registrations[i]; + services.Add(new + { + ServiceType = registration.ServiceType.FullName, + registration.Name, + registration.OwnerCapabilityId, + registration.IsOverride, + IsOverridden = overriddenKeys.Contains(registration.ServiceType.FullName + "|" + registration.Name), + }); + } + + object payload = new { Services = services, }; + return Task.FromResult(payload); + } + + /// + /// list_capability_settings 元端点处理器(Options 字段生效值面:每个字段的生效值与来源)。 + /// + /// 请求。 + /// 已通过守卫的作用域。 + /// 取消令牌。 + /// Options 生效值载荷。 + private Task ListCapabilitySettingsAsync(AdminApiRequest request, AdminApiClaimedScope scope, CancellationToken cancellationToken) + { + var capabilities = new List(_report.Entries.Count); + foreach (var entry in _report.Entries) + { + var fields = new List(entry.SettingsFields.Count); + foreach (var field in entry.SettingsFields) + { + fields.Add(new + { + field.FieldPath, + Value = field.EffectiveValue, + Source = field.Source.ToString(), + }); + } + + capabilities.Add(new + { + entry.Manifest.Id, + State = entry.State.ToString(), + SettingsFields = fields, + entry.SettingsConfiguredButNotAssembled, + }); + } + + object payload = new { Capabilities = capabilities, }; + return Task.FromResult(payload); + } + + /// + /// 提取 manifest 可选依赖 Id 集合(载荷显示用)。 + /// + /// 能力清单。 + /// 可选依赖 Id 数组。 + private static string[] ToOptionalCapabilityIds(CapabilityManifest manifest) + { + var identifiers = new string[manifest.Optional.Count]; + for (var i = 0; i < manifest.Optional.Count; i++) + { + identifiers[i] = manifest.Optional[i].CapabilityId; + } + + return identifiers; + } + /// /// 直连执行(无调度器层幂等包裹)。 /// diff --git a/Capability/GameFrameX.Capability.Hosting.AdminApi/AdminApiServer.cs b/Capability/GameFrameX.Capability.Hosting.AdminApi/AdminApiServer.cs index c071fe04e..c99eb419f 100644 --- a/Capability/GameFrameX.Capability.Hosting.AdminApi/AdminApiServer.cs +++ b/Capability/GameFrameX.Capability.Hosting.AdminApi/AdminApiServer.cs @@ -29,6 +29,7 @@ using System.Net; using System.Threading.RateLimiting; +using GameFrameX.Capability.Hosting.Core; using GameFrameX.Foundation.Localization.Core; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; @@ -44,10 +45,11 @@ namespace GameFrameX.Capability.Hosting.AdminApi; /// /// 维护约束:环境中立(不读 ASPNETCORE_* 环境假设,无配置文件依赖,装配选项即全部配置); /// 与 Game 服主进程的协议端口互不冲突(独立端口);停止走优雅关闭(在途请求完成后退出); -/// 与业务装配开关正交(管理面可独立启停,零能力形态下全部 action 返回未安装码)。 +/// 与业务装配开关正交(管理面可独立启停,零能力形态下全部 action 返回未安装码); +/// 实现 挂进 (管理面后启先停)。 /// /// -public sealed class AdminApiServer +public sealed class AdminApiServer : ICapabilityAdminApiPlane { /// /// 限流策略名(按客户端 IP 分区的固定窗口)。 @@ -83,8 +85,9 @@ public AdminApiServer(AdminApiOptions options, AdminApiDispatcher dispatcher) /// /// 启动监听(返回即已就绪;长驻服务由宿主生命周期承载——RunAsync 会二次触发 Kestrel 启动被拒绝,禁用)。 /// + /// 取消令牌。 /// 异步任务。 - public async Task StartAsync() + public async Task StartAsync(CancellationToken cancellationToken = default) { if (_application != null) { @@ -144,7 +147,7 @@ public async Task StartAsync() } _application = application; - await application.StartAsync().ConfigureAwait(false); + await application.StartAsync(cancellationToken).ConfigureAwait(false); } /// diff --git a/Capability/GameFrameX.Capability.Hosting.AdminApi/GameFrameX.Capability.Hosting.AdminApi.csproj b/Capability/GameFrameX.Capability.Hosting.AdminApi/GameFrameX.Capability.Hosting.AdminApi.csproj index 7998e640e..b2fa21efe 100644 --- a/Capability/GameFrameX.Capability.Hosting.AdminApi/GameFrameX.Capability.Hosting.AdminApi.csproj +++ b/Capability/GameFrameX.Capability.Hosting.AdminApi/GameFrameX.Capability.Hosting.AdminApi.csproj @@ -20,6 +20,7 @@ + diff --git a/Capability/GameFrameX.Capability.Hosting.Core/CapabilityAssembly.cs b/Capability/GameFrameX.Capability.Hosting.Core/CapabilityAssembly.cs index cc211e89e..e07d47043 100644 --- a/Capability/GameFrameX.Capability.Hosting.Core/CapabilityAssembly.cs +++ b/Capability/GameFrameX.Capability.Hosting.Core/CapabilityAssembly.cs @@ -24,29 +24,30 @@ // 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.Reflection; using GameFrameX.Capability.Hosting.Abstractions; +using GameFrameX.Foundation.Localization.Core; namespace GameFrameX.Capability.Hosting.Core; /// -/// 能力自动装配扫描器(组合根反射扫描已加载程序集发现 实现; -/// 机制开放红线的发现面——机制包不含任何能力硬编码清单)。 +/// 能力自动装配扫描器(机制层统一扫描约定:部署目录命名约定圈定 {前缀}.*.dll + 反射发现 +/// 实现——机制开放红线的发现面,机制包不含任何能力硬编码清单)。 /// -/// 维护约束:只认非抽象、含公共无参构造的实现类型;同 Id 多实现取首个发现(按程序集名 + 类型名稳定排序), -/// 其余忽略——扫描序不构成装配序(装配序由 Requires 拓扑 + 加入序决定)。 +/// 维护约束:只认非抽象、含公共无参构造的实现类型;同一 Id 多实现 = 装配失败(fail-fast, +/// 不再静默取首);.NET 懒加载下未被代码触碰的能力包程序集不在 AppDomain 已加载集—— +/// 按部署目录命名约定圈定扫描面:新增能力 = 部署产物落地即被发现,宿主零改动。 /// /// public static class CapabilityAssembly { /// - /// 扫描指定程序集集合(缺省 = 当前 AppDomain 全部已加载程序集)。 + /// 扫描指定程序集集合(缺省 = 当前 AppDomain 全部已加载程序集;同 Id 多实现 fail-fast)。 /// /// 程序集集合(null = 全部已加载)。 - /// 发现的能力模块(按程序集名 + 类型名稳定排序)。 + /// 发现的能力模块(按程序集名 + 类型名稳定排序;同 Id 多实现抛 )。 public static IReadOnlyList DiscoverModules(IEnumerable assemblies = null) { var targetAssemblies = assemblies ?? AppDomain.CurrentDomain.GetAssemblies(); @@ -61,39 +62,59 @@ public static IReadOnlyList DiscoverModules(IEnumerable string.Compare(LeftKey(left), LeftKey(right), StringComparison.Ordinal)); var modules = new List(moduleTypes.Count); - var seenIds = new HashSet(StringComparer.Ordinal); + var modulesById = new Dictionary(StringComparer.Ordinal); foreach (var moduleType in moduleTypes) { var module = (ICapabilityModule)Activator.CreateInstance(moduleType); - if (seenIds.Add(module.Manifest.Id)) + if (modulesById.TryGetValue(module.Manifest.Id, out var existing)) { - modules.Add(module); + // Localization: Capability.Hosting.DuplicateCapability - 能力 Id 重复注册。 + throw new InvalidOperationException(string.Format(LocalizationService.GetString(Localization.Keys.Capability.Hosting.DuplicateCapability), module.Manifest.Id + " (" + existing.GetType().Assembly.GetName().Name + " / " + moduleType.Assembly.GetName().Name + ")")); } + + modulesById.Add(module.Manifest.Id, module); + modules.Add(module); } return modules; } /// - /// 扫描并装配(发现 → 过滤器 → 构建;组合根一行装配形态)。 + /// 加载部署目录内符合能力包命名约定({前缀}.*.dll)的全部程序集(扫描数据源)。 /// - /// 宿主选项。 - /// 装配过滤器(null = 全部启用)。 - /// 能力宿主。 - public static CapabilityHost BuildHost(CapabilityHostOptions options, CapabilityAssemblyFilter filter = null) + /// 部署程序集前缀(如 GameFrameX.Capability.Online;非空)。 + /// 部署目录内可加载的能力包程序集集合。 + public static IReadOnlyList LoadDeploymentAssemblies(string prefix) { - var builder = new CapabilityHostBuilder(); - foreach (var module in DiscoverModules()) + if (string.IsNullOrWhiteSpace(prefix)) { - builder.AddModule(module); + throw new ArgumentException("Deployment assembly prefix must be a non-empty string.", nameof(prefix)); } - if (filter != null) + var assemblies = new List(); + foreach (var path in Directory.GetFiles(AppContext.BaseDirectory, prefix.Trim() + ".*.dll")) { - builder.SetFilter(filter); + try + { + assemblies.Add(Assembly.Load(AssemblyName.GetAssemblyName(path))); + } + catch (Exception) + { + // 解析失败的文件(原生库 / 损坏产物)跳过——扫描面只关心能力包程序集。 + } } - return builder.Build(options); + return assemblies; + } + + /// + /// 从部署目录按命名约定发现能力模块(加载 + 扫描 + 同 Id fail-fast;组合器的生产数据源)。 + /// + /// 部署程序集前缀(非空)。 + /// 发现的能力模块。 + public static IReadOnlyList DiscoverFromDeploymentDirectory(string prefix) + { + return DiscoverModules(LoadDeploymentAssemblies(prefix)); } /// diff --git a/Capability/GameFrameX.Capability.Hosting.Core/CapabilityAssemblyException.cs b/Capability/GameFrameX.Capability.Hosting.Core/CapabilityAssemblyException.cs new file mode 100644 index 000000000..7cbcd72a1 --- /dev/null +++ b/Capability/GameFrameX.Capability.Hosting.Core/CapabilityAssemblyException.cs @@ -0,0 +1,78 @@ +// ========================================================================================== +// 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 refer to 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/ +// ========================================================================================== + + +using System.Text; + +namespace GameFrameX.Capability.Hosting.Core; + +/// +/// 能力装配汇总异常(依赖检查不通过时携带全部问题条目一次抛出;运维改一次配置即可看到全部问题)。 +/// +/// 维护约束:抛出前机制已把每条问题逐条打 Error; 与日志逐条对应 +///(必选缺失含缺席原因、环路含完整路径)。 +/// +/// +public sealed class CapabilityAssemblyException : Exception +{ + /// + /// 初始化 。 + /// + /// 全部问题条目(已本地化;至少一条)。 + public CapabilityAssemblyException(IReadOnlyList problems) + : base(BuildMessage(problems)) + { + Problems = problems; + } + + /// + /// 获取全部问题条目(已本地化)。 + /// + public IReadOnlyList Problems { get; } + + /// + /// 构造异常消息(逐条换行)。 + /// + /// 问题条目。 + /// 汇总消息。 + private static string BuildMessage(IReadOnlyList problems) + { + var message = new StringBuilder(); + for (var i = 0; i < problems.Count; i++) + { + if (i > 0) + { + message.AppendLine(); + } + + message.Append(problems[i]); + } + + return message.ToString(); + } +} diff --git a/Capability/GameFrameX.Capability.Hosting.Core/CapabilityAssemblyLogRenderer.cs b/Capability/GameFrameX.Capability.Hosting.Core/CapabilityAssemblyLogRenderer.cs new file mode 100644 index 000000000..9b1e4b093 --- /dev/null +++ b/Capability/GameFrameX.Capability.Hosting.Core/CapabilityAssemblyLogRenderer.cs @@ -0,0 +1,304 @@ +// ========================================================================================== +// 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 refer to 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/ +// ========================================================================================== + +using GameFrameX.Capability.Hosting.Abstractions; +using GameFrameX.Foundation.Localization.Core; + +namespace GameFrameX.Capability.Hosting.Core; + +/// +/// 装配日志渲染器(从 渲染装配日志;list_capabilities 读同一份报告, +/// 两边不会对不上)。 +/// +/// 维护约束(文案红线):全部文案走本地化(Capability.Hosting.Assembly.*);级别只用 Info 与 Error +/// ——必选缺失、环路、汇总失败用 Error,其余(开始、排除、逐能力汇总、可选缺失、完成)用 Info; +/// 可选缺失文案格式固定:{能力} 可选依赖 {依赖} 未装配({原因}):缺失功能「{功能}」,不影响 {能力} 其余功能运行。 +/// +/// +internal static class CapabilityAssemblyLogRenderer +{ + /// + /// 可选依赖逐项列出的上限(超出改用计数形态;两种形态数据同源)。 + /// + private const int OptionalListedLimit = 3; + + /// + /// 渲染成功装配日志(开始行 + 排除行 + 逐能力汇总行 + 可选缺失行 + 完成行)。 + /// + /// 能力报告。 + /// 日志出口。 + public static void RenderSuccess(CapabilityReport report, ICapabilityAssemblyLog log) + { + var enabledCount = report.EnabledIdsInOrder.Count; + var excludedCount = report.Entries.Count - enabledCount; + // Localization: Capability.Hosting.Assembly.Started - 能力装配开始。 + log.Info(string.Format(LocalizationService.GetString(Localization.Keys.Capability.Hosting.Assembly.AssemblyStarted), report.Entries.Count, enabledCount, excludedCount)); + + RenderExcludedEntries(report, log); + RenderModuleSummaries(report, log); + RenderOptionalDependencyMissing(report, log); + + var absentFeatureCount = CountAbsentFeatures(report); + // Localization: Capability.Hosting.Assembly.Completed - 能力装配完成。 + log.Info(string.Format(LocalizationService.GetString(Localization.Keys.Capability.Hosting.Assembly.AssemblyCompleted), enabledCount, absentFeatureCount)); + } + + /// + /// 渲染失败装配日志(汇总头 + 逐条问题;必选缺失 / 环路)。 + /// + /// 全部问题条目(已本地化)。 + /// 日志出口。 + public static void RenderFailures(IReadOnlyList problems, ICapabilityAssemblyLog log) + { + // Localization: Capability.Hosting.Assembly.Failed - 能力装配失败。 + log.Error(string.Format(LocalizationService.GetString(Localization.Keys.Capability.Hosting.Assembly.AssemblyFailed), problems.Count)); + for (var i = 0; i < problems.Count; i++) + { + log.Error(" " + problems[i]); + } + } + + /// + /// 渲染已挂载但无能力声明的钩子(Info;对应能力未装配或未声明该钩子)。 + /// + /// 未被声明的已挂载契约类型。 + /// 日志出口。 + public static void RenderHooksWithoutDeclaration(IReadOnlyList contractTypes, ICapabilityAssemblyLog log) + { + for (var i = 0; i < contractTypes.Count; i++) + { + // Localization: Capability.Hosting.Assembly.HookNotDeclared - 钩子已挂载但无能力声明。 + log.Info(string.Format(LocalizationService.GetString(Localization.Keys.Capability.Hosting.Assembly.AssemblyHookNotDeclared), contractTypes[i].FullName)); + } + } + + /// + /// 渲染排除行(每条注明缺席原因:白名单未收录 / 配置排除表)。 + /// + /// 能力报告。 + /// 日志出口。 + private static void RenderExcludedEntries(CapabilityReport report, ICapabilityAssemblyLog log) + { + for (var i = 0; i < report.Entries.Count; i++) + { + var entry = report.Entries[i]; + if (entry.State != CapabilityFilterState.NotInEnableList && entry.State != CapabilityFilterState.ExcludedByDisableList) + { + continue; + } + + var reason = entry.State == CapabilityFilterState.ExcludedByDisableList + ? CapabilityDependencyAbsenceReason.ExcludedByDisableList + : CapabilityDependencyAbsenceReason.NotInEnableList; + // Localization: Capability.Hosting.Assembly.Excluded - 已排除。 + log.Info(string.Format(LocalizationService.GetString(Localization.Keys.Capability.Hosting.Assembly.AssemblyExcluded), entry.Manifest.Id, CapabilityDependencyChecker.DescribeReason(reason))); + } + } + + /// + /// 渲染逐能力汇总行(装配序;无依赖显示来源程序集,有依赖显示依赖描述)。 + /// + /// 能力报告。 + /// 日志出口。 + private static void RenderModuleSummaries(CapabilityReport report, ICapabilityAssemblyLog log) + { + var entryById = new Dictionary(StringComparer.Ordinal); + for (var i = 0; i < report.Entries.Count; i++) + { + entryById[report.Entries[i].Manifest.Id] = report.Entries[i]; + } + + for (var i = 0; i < report.EnabledIdsInOrder.Count; i++) + { + var entry = entryById[report.EnabledIdsInOrder[i]]; + var ordinal = i + 1; + var dependencyDescriptor = BuildDependencyDescriptor(entry); + if (dependencyDescriptor == null) + { + // Localization: Capability.Hosting.Assembly.ModuleNoDependency - 无依赖能力汇总行。 + log.Info(string.Format(LocalizationService.GetString(Localization.Keys.Capability.Hosting.Assembly.AssemblyModuleNoDependency), ordinal, entry.Manifest.Id, entry.Manifest.Version, entry.SourceAssemblyName)); + continue; + } + + // Localization: Capability.Hosting.Assembly.ModuleWithDependency - 带依赖能力汇总行。 + log.Info(string.Format(LocalizationService.GetString(Localization.Keys.Capability.Hosting.Assembly.AssemblyModuleWithDependency), ordinal, entry.Manifest.Id, entry.Manifest.Version, dependencyDescriptor)); + } + } + + /// + /// 构造依赖描述(必选段 + 可选段;可选 ≤ 3 个逐项列状态,超出用计数形态)。 + /// + /// 报告条目。 + /// 依赖描述;无任何依赖为 null。 + private static string BuildDependencyDescriptor(CapabilityReportEntry entry) + { + var requiredIds = new List(); + var optionalIds = new List(); + for (var i = 0; i < entry.Dependencies.Count; i++) + { + if (entry.Dependencies[i].Kind == CapabilityDependencyKind.Required) + { + requiredIds.Add(entry.Dependencies[i].CapabilityId); + } + else + { + optionalIds.Add(entry.Dependencies[i]); + } + } + + if (requiredIds.Count == 0 && optionalIds.Count == 0) + { + return null; + } + + var segments = new List(); + if (requiredIds.Count > 0) + { + // Localization: Capability.Hosting.Assembly.DependencyRequired - 必选依赖段。 + segments.Add(string.Format(LocalizationService.GetString(Localization.Keys.Capability.Hosting.Assembly.AssemblyDependencyRequired), string.Join(" ", requiredIds))); + } + + if (optionalIds.Count > 0) + { + segments.Add(BuildOptionalSegment(optionalIds)); + } + + return string.Join(" ", segments); + } + + /// + /// 构造可选依赖段(≤ 上限逐项列装配状态,超出用「X 个已装配,Y 个未装配」计数形态)。 + /// + /// 可选依赖条目。 + /// 可选依赖段文案。 + private static string BuildOptionalSegment(List optionalDependencies) + { + if (optionalDependencies.Count > OptionalListedLimit) + { + var assembledCount = 0; + var missingCount = 0; + for (var i = 0; i < optionalDependencies.Count; i++) + { + if (optionalDependencies[i].State == CapabilityDependencyAssemblyState.Assembled) + { + assembledCount++; + } + else + { + missingCount++; + } + } + + // Localization: Capability.Hosting.Assembly.DependencyOptionalCounted - 可选依赖计数段。 + return string.Format(LocalizationService.GetString(Localization.Keys.Capability.Hosting.Assembly.AssemblyDependencyOptionalCounted), assembledCount, missingCount); + } + + var tokens = new List(); + for (var i = 0; i < optionalDependencies.Count; i++) + { + if (optionalDependencies[i].State == CapabilityDependencyAssemblyState.Assembled) + { + // Localization: Capability.Hosting.Assembly.OptionalStateAssembled - 已装配状态。 + tokens.Add(string.Format(LocalizationService.GetString(Localization.Keys.Capability.Hosting.Assembly.AssemblyOptionalStateAssembled), optionalDependencies[i].CapabilityId)); + } + else + { + // Localization: Capability.Hosting.Assembly.OptionalStateNotAssembled - 未装配状态。 + tokens.Add(string.Format(LocalizationService.GetString(Localization.Keys.Capability.Hosting.Assembly.AssemblyOptionalStateNotAssembled), optionalDependencies[i].CapabilityId)); + } + } + + // Localization: Capability.Hosting.Assembly.DependencyOptionalListed - 可选依赖逐项段。 + return string.Format(LocalizationService.GetString(Localization.Keys.Capability.Hosting.Assembly.AssemblyDependencyOptionalListed), string.Join(" ", tokens)); + } + + /// + /// 渲染可选缺失行(固定文案:能力 可选依赖 依赖 未装配(原因):缺失功能「功能」,不影响 能力 其余功能运行)。 + /// + /// 能力报告。 + /// 日志出口。 + private static void RenderOptionalDependencyMissing(CapabilityReport report, ICapabilityAssemblyLog log) + { + for (var i = 0; i < report.Entries.Count; i++) + { + var entry = report.Entries[i]; + if (entry.State != CapabilityFilterState.Enabled) + { + continue; + } + + for (var j = 0; j < entry.Dependencies.Count; j++) + { + var dependency = entry.Dependencies[j]; + if (dependency.Kind != CapabilityDependencyKind.Optional || dependency.State != CapabilityDependencyAssemblyState.NotAssembled) + { + continue; + } + + var featureText = FindAbsentFeatureText(entry.Manifest, dependency.CapabilityId); + // Localization: Capability.Hosting.Assembly.OptionalDependencyMissing - 可选依赖缺失固定文案。 + log.Info(string.Format(LocalizationService.GetString(Localization.Keys.Capability.Hosting.Assembly.AssemblyOptionalDependencyMissing), entry.Manifest.Id, dependency.CapabilityId, CapabilityDependencyChecker.DescribeReason(dependency.AbsenceReason), featureText)); + } + } + } + + /// + /// 查找可选依赖声明的缺失功能文案(本地化)。 + /// + /// 能力清单。 + /// 依赖能力 Id。 + /// 缺失功能文案。 + private static string FindAbsentFeatureText(CapabilityManifest manifest, string dependencyId) + { + for (var i = 0; i < manifest.Optional.Count; i++) + { + if (string.Equals(manifest.Optional[i].CapabilityId, dependencyId, StringComparison.Ordinal)) + { + return LocalizationService.GetString(manifest.Optional[i].AbsentFeatureLocalizationKey); + } + } + + return string.Empty; + } + + /// + /// 统计可选依赖缺失的功能总数(完成行用)。 + /// + /// 能力报告。 + /// 缺失功能总数。 + private static int CountAbsentFeatures(CapabilityReport report) + { + var count = 0; + for (var i = 0; i < report.Entries.Count; i++) + { + count += report.Entries[i].AbsentFeatures.Count; + } + + return count; + } +} diff --git a/Capability/GameFrameX.Capability.Hosting.Core/CapabilityComposition.cs b/Capability/GameFrameX.Capability.Hosting.Core/CapabilityComposition.cs new file mode 100644 index 000000000..d748fcaf3 --- /dev/null +++ b/Capability/GameFrameX.Capability.Hosting.Core/CapabilityComposition.cs @@ -0,0 +1,134 @@ +// ========================================================================================== +// 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 refer to 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/ +// ========================================================================================== + +using GameFrameX.Capability.Hosting.Abstractions; +using GameFrameX.Foundation.Localization.Core; + +namespace GameFrameX.Capability.Hosting.Core; + +/// +/// 能力组合入口(机制层唯一组合面:族骨架 + 部署目录扫描 + 过滤 + 依赖检查 + 定序 + Options 合并 + +/// 逐模块 Register + 产出 ——「每域手工六件套」降为「每域一个契约接口」后, +/// 编排责任全部收回机制层)。 +/// +/// 维护约束(机制开放红线):机制包零能力硬编码——第二个能力族只需提供 +/// 实现,机制零改动;装配问题逐条打日志后抛汇总异常(一次报全);业务面与管理面至少启用其一。 +/// +/// +public static class CapabilityComposition +{ + /// + /// 组合一个能力族(族基建 → 宿主覆盖 → 部署目录扫描 → 同 Id 校验 → 过滤 → 依赖检查 → 定序 → + /// Options 合并 → Register → 宿主 / 管理面 / 报告)。 + /// + /// 能力族。 + /// 组合选项。 + /// 双面组合产物(未启用的面为 null)。 + public static CapabilityRuntime Compose(ICapabilityFamily family, CapabilityCompositionOptions options) + { + if (family == null) + { + throw new ArgumentNullException(nameof(family)); + } + + if (options == null) + { + throw new ArgumentNullException(nameof(options)); + } + + if (!options.EnableRuntime && options.AdminApiPlaneFactory == null) + { + // Localization: Capability.Hosting.Composition.NoPlaneEnabled - 未启用任何能力面。 + throw new InvalidOperationException(LocalizationService.GetString(Localization.Keys.Capability.Hosting.Composition.NoPlaneEnabled)); + } + + if (options.HostOptions == null) + { + options.HostOptions = new CapabilityHostOptions(); + } + + var hostOptions = options.HostOptions; + if (!hostOptions.IsValid()) + { + // Localization: Capability.Hosting.OptionsInvalid - 能力宿主选项非法(作用域三元组 / 调度间隔)。 + throw new InvalidOperationException(LocalizationService.GetString(Localization.Keys.Capability.Hosting.OptionsInvalid)); + } + + var services = new CapabilityServices(); + services.SetFamilyCapabilityId(family.FamilyId); + var eventBus = new CapabilityEventBus(options.AssemblyLog); + var familyContext = new CapabilityFamilyContext(family.FamilyId, options.FamilyConfigurationSection, eventBus, services); + family.RegisterInfrastructure(familyContext); + + var overrideEntries = options.OverrideEntries; + for (var i = 0; i < overrideEntries.Count; i++) + { + var entry = overrideEntries[i]; + services.AddOverride(new CapabilityServiceRegistration(entry.ServiceType, string.Empty, entry.Factory, family.FamilyId, true)); + } + + var modules = CapabilityAssembly.DiscoverFromDeploymentDirectory(family.DeploymentAssemblyPrefix); + + var builder = new CapabilityHostBuilder(services, eventBus, family, options); + CapabilityHost host; + try + { + host = builder.Build(modules); + } + catch (CapabilityAssemblyException exception) + { + CapabilityAssemblyLogRenderer.RenderFailures(exception.Problems, options.AssemblyLog); + throw; + } + + CapabilityAssemblyLogRenderer.RenderSuccess(host.Report, options.AssemblyLog); + + ICapabilityAdminApiPlane adminApiPlane = null; + if (options.AdminApiPlaneFactory != null) + { + try + { + adminApiPlane = options.AdminApiPlaneFactory(host); + } + catch (Exception) + { + // 管理面组装失败 = 组合失败:宿主不会交给调用方,Register 期钩子声明必须就地释放。 + host.ReleaseHookDeclarations(); + throw; + } + } + + if (!options.EnableRuntime) + { + // 仅管理面形态:业务宿主不对外交付、永不启停,钩子声明无人消费,立即释放进程级槽位。 + host.ReleaseHookDeclarations(); + } + + return new CapabilityRuntime(options.EnableRuntime ? host : null, adminApiPlane, host.Report); + } +} diff --git a/Capability/GameFrameX.Capability.Hosting.Core/CapabilityCompositionOptions.cs b/Capability/GameFrameX.Capability.Hosting.Core/CapabilityCompositionOptions.cs new file mode 100644 index 000000000..ed6a06a72 --- /dev/null +++ b/Capability/GameFrameX.Capability.Hosting.Core/CapabilityCompositionOptions.cs @@ -0,0 +1,239 @@ +// ========================================================================================== +// 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 refer to 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/ +// ========================================================================================== + +using System.Text.Json; +using GameFrameX.Capability.Hosting.Abstractions; + +namespace GameFrameX.Capability.Hosting.Core; + +/// +/// 能力组合选项( 的唯一配置面:族配置段 + 装配日志出口 + +/// 双面开关 + 宿主选项 + 代码层 Options 覆盖与服务覆盖 + 管理面工厂)。 +/// +/// 维护约束(GFX0018 符号检查面): / / +/// 只在宿主组合入口合法;装配日志出口是必填参数(调用方无法遗漏); +/// 机制只解释族配置段里的 Settings 子对象(能力级参数),族级键由宿主 / 族自行解读后映射到本类属性。 +/// +/// +public sealed class CapabilityCompositionOptions +{ + /// + /// 代码层 Options 覆盖回调(Configure 注册序执行;按 Options 根类型精确匹配目标能力)。 + /// + private readonly List _configureEntries = new(); + + /// + /// 宿主服务覆盖(Override 注册序落表;同键第二个覆盖 = 装配失败)。 + /// + private readonly List _overrideEntries = new(); + + /// + /// 管理面工厂(由 Hosting.AdminApi 包的 UseAdminApi 扩展设置)。 + /// + private Func _adminApiPlaneFactory; + + /// + /// 初始化 。 + /// + /// 族配置段原始 JSON(如 Online 段;无段传 default)。 + /// 装配日志出口(必填;Launcher 桥接统一日志,测试用收集器)。 + public CapabilityCompositionOptions(JsonElement familyConfigurationSection, ICapabilityAssemblyLog assemblyLog) + { + FamilyConfigurationSection = familyConfigurationSection; + AssemblyLog = assemblyLog ?? throw new ArgumentNullException(nameof(assemblyLog)); + } + + /// + /// 获取族配置段原始 JSON(机制只解释其 Settings 子对象)。 + /// + public JsonElement FamilyConfigurationSection { get; } + + /// + /// 获取装配日志出口(必填;装配期与运行期观测共用)。 + /// + public ICapabilityAssemblyLog AssemblyLog { get; } + + /// + /// 获取或设置是否启用业务运行面(默认 true;关闭时 为 null—— + /// admin-only 形态,模块仍注册但宿主不激活、调度不启动)。 + /// + public bool EnableRuntime { get; set; } = true; + + /// + /// 获取或设置业务宿主选项(作用域三元组 + 调度间隔;作用域对管理面授权同样必填)。 + /// + public CapabilityHostOptions HostOptions { get; set; } = new CapabilityHostOptions(); + + /// + /// 获取或设置能力装配过滤器(白名单 / 排除表;null = 全装配——空过滤器全装配红线)。 + /// + public CapabilityAssemblyFilter Filter { get; set; } + + /// + /// 获取或设置调度时钟(null = 系统时钟;测试注入虚拟时钟驱动 interval 到期)。 + /// + public ISchedulerClock SchedulerClock { get; set; } + + /// + /// 获取或设置调度观测出口(null = 缺省日志观察器;宿主接入真实指标管道时替换)。 + /// + public ICapabilitySchedulerObserver SchedulerObserver { get; set; } + + /// + /// 声明代码层 Options 覆盖(只改需要改的字段;合并优先级:包内默认 < 配置段 < 代码 Configure)。 + /// + /// Options 根类型(须由某个已发现能力声明)。 + /// 改写回调(不可为 null)。 + public void Configure(Action configure) where TOptions : class + { + if (configure == null) + { + throw new ArgumentNullException(nameof(configure)); + } + + _configureEntries.Add(new ConfigureEntry(typeof(TOptions), options => configure((TOptions)options))); + } + + /// + /// 声明一个宿主服务覆盖(实例形态;满足模块的 RequireHostService 或替换模块默认实现)。 + /// + /// 服务类型键。 + /// 覆盖实现(不可为 null)。 + public void Override(TService instance) where TService : class + { + if (instance == null) + { + throw new ArgumentNullException(nameof(instance)); + } + + var capturedInstance = instance; + _overrideEntries.Add(new OverrideEntry(typeof(TService), resolver => capturedInstance)); + } + + /// + /// 声明一个宿主服务覆盖(懒工厂形态)。 + /// + /// 服务类型键。 + /// 覆盖懒工厂(不可为 null)。 + public void Override(Func factory) where TService : class + { + if (factory == null) + { + throw new ArgumentNullException(nameof(factory)); + } + + _overrideEntries.Add(new OverrideEntry(typeof(TService), resolver => factory(resolver))); + } + + /// + /// 设置管理面工厂(Hosting.AdminApi 包的 UseAdminApi 扩展调用;机制组合时构建管理面)。 + /// + /// 管理面工厂(不可为 null;入参为已装配宿主)。 + public void SetAdminApiPlaneFactory(Func factory) + { + _adminApiPlaneFactory = factory ?? throw new ArgumentNullException(nameof(factory)); + } + + /// + /// 获取代码层 Options 覆盖回调表(机制装配期读取)。 + /// + internal IReadOnlyList ConfigureEntries + { + get { return _configureEntries; } + } + + /// + /// 获取宿主服务覆盖表(机制装配期读取)。 + /// + internal IReadOnlyList OverrideEntries + { + get { return _overrideEntries; } + } + + /// + /// 获取管理面工厂(未设置时启用管理面 = 组合失败:需引用 Hosting.AdminApi 并调用 UseAdminApi)。 + /// + internal Func AdminApiPlaneFactory + { + get { return _adminApiPlaneFactory; } + } + + /// + /// 代码层 Options 覆盖回调条目。 + /// + internal sealed class ConfigureEntry + { + /// + /// 初始化 。 + /// + /// Options 根类型。 + /// 装箱改写回调。 + public ConfigureEntry(Type optionsType, Action configure) + { + OptionsType = optionsType; + Configure = configure; + } + + /// + /// 获取 Options 根类型。 + /// + public Type OptionsType { get; } + + /// + /// 获取装箱改写回调。 + /// + public Action Configure { get; } + } + + /// + /// 宿主服务覆盖条目。 + /// + internal sealed class OverrideEntry + { + /// + /// 初始化 。 + /// + /// 服务类型键。 + /// 懒工厂。 + public OverrideEntry(Type serviceType, Func factory) + { + ServiceType = serviceType; + Factory = factory; + } + + /// + /// 获取服务类型键。 + /// + public Type ServiceType { get; } + + /// + /// 获取懒工厂。 + /// + public Func Factory { get; } + } +} diff --git a/Capability/GameFrameX.Capability.Hosting.Core/CapabilityDependencyChecker.cs b/Capability/GameFrameX.Capability.Hosting.Core/CapabilityDependencyChecker.cs new file mode 100644 index 000000000..25346b359 --- /dev/null +++ b/Capability/GameFrameX.Capability.Hosting.Core/CapabilityDependencyChecker.cs @@ -0,0 +1,444 @@ +// ========================================================================================== +// 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 refer to 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/ +// ========================================================================================== + +using GameFrameX.Capability.Hosting.Abstractions; +using GameFrameX.Foundation.Localization.Core; + +namespace GameFrameX.Capability.Hosting.Core; + +/// +/// 依赖检查器(过滤之后、拓扑定序之前执行——早于任何模块 Register;检查不通过不执行任何模块代码)。 +/// +/// 维护约束(一次报全红线):全部必选缺失与环路先收集(环路列完整路径,如 a → b → c → a), +/// 逐条打 Error 后由组合入口抛 ;可选缺失只记数据(Info 行由 +/// 日志渲染器统一输出,固定文案由机制拼接);缺席原因区分白名单未收录 / 排除表命中 / 部署目录未发现; +/// 定序只由依赖声明推导(必选边 + 在场可选边),同层按能力 Id 序数(StringComparer.Ordinal)。 +/// +/// +internal static class CapabilityDependencyChecker +{ + /// + /// 执行依赖检查与拓扑定序。 + /// + /// 全部已发现模块(含被过滤的)。 + /// 过滤器判定启用的模块。 + /// 装配过滤器(缺席原因判定用)。 + /// 检查结果(问题条目 / 依赖状态 / 缺失功能 / 解析来源策略 / 拓扑序)。 + public static Result Check(IReadOnlyList allModules, IReadOnlyList enabledModules, CapabilityAssemblyFilter filter) + { + var manifestsById = new Dictionary(StringComparer.Ordinal); + for (var i = 0; i < allModules.Count; i++) + { + manifestsById[allModules[i].Manifest.Id] = allModules[i].Manifest; + } + + var enabledIds = new HashSet(StringComparer.Ordinal); + for (var i = 0; i < enabledModules.Count; i++) + { + enabledIds.Add(enabledModules[i].Manifest.Id); + } + + var result = new Result(); + var edges = new Dictionary>(StringComparer.Ordinal); + for (var i = 0; i < enabledModules.Count; i++) + { + edges[enabledModules[i].Manifest.Id] = new List(); + } + + for (var i = 0; i < enabledModules.Count; i++) + { + CheckModule(enabledModules[i].Manifest, manifestsById, enabledIds, filter, result, edges); + } + + CollectCycles(enabledModules, edges, result); + if (result.Problems.Count == 0) + { + OrderByDependencies(enabledModules, edges, result); + } + + return result; + } + + /// + /// 检查单个模块的必选 / 可选依赖(必选缺失记问题;可选缺失记数据;在场依赖记定序边与来源策略)。 + /// + /// 被检查模块清单。 + /// 全部已发现清单表。 + /// 启用能力 Id 集。 + /// 装配过滤器。 + /// 检查结果累积器。 + /// 定序边累积器(依赖方 → 依赖集合)。 + private static void CheckModule(CapabilityManifest manifest, Dictionary manifestsById, HashSet enabledIds, CapabilityAssemblyFilter filter, Result result, Dictionary> edges) + { + var dependencies = new List(); + var allowedOwners = new HashSet(StringComparer.Ordinal) { manifest.Id, }; + var unassembledOptional = new List(); + + for (var i = 0; i < manifest.Requires.Count; i++) + { + var requiredId = manifest.Requires[i]; + var reason = ResolveAbsenceReason(requiredId, manifestsById, enabledIds, filter); + if (reason == CapabilityDependencyAbsenceReason.None) + { + dependencies.Add(new CapabilityDependencyReport(requiredId, CapabilityDependencyKind.Required, CapabilityDependencyAssemblyState.Assembled, CapabilityDependencyAbsenceReason.None)); + edges[manifest.Id].Add(requiredId); + allowedOwners.Add(requiredId); + continue; + } + + // Localization: Capability.Hosting.Assembly.RequiredDependencyMissing - 必选依赖未装配。 + result.Problems.Add(string.Format(LocalizationService.GetString(Localization.Keys.Capability.Hosting.Assembly.AssemblyRequiredDependencyMissing), manifest.Id, requiredId, DescribeReason(reason))); + dependencies.Add(new CapabilityDependencyReport(requiredId, CapabilityDependencyKind.Required, CapabilityDependencyAssemblyState.NotAssembled, reason)); + } + + for (var i = 0; i < manifest.Optional.Count; i++) + { + var optional = manifest.Optional[i]; + var reason = ResolveAbsenceReason(optional.CapabilityId, manifestsById, enabledIds, filter); + if (reason == CapabilityDependencyAbsenceReason.None) + { + dependencies.Add(new CapabilityDependencyReport(optional.CapabilityId, CapabilityDependencyKind.Optional, CapabilityDependencyAssemblyState.Assembled, CapabilityDependencyAbsenceReason.None)); + edges[manifest.Id].Add(optional.CapabilityId); + allowedOwners.Add(optional.CapabilityId); + continue; + } + + dependencies.Add(new CapabilityDependencyReport(optional.CapabilityId, CapabilityDependencyKind.Optional, CapabilityDependencyAssemblyState.NotAssembled, reason)); + unassembledOptional.Add(optional.CapabilityId); + result.AbsentFeaturesByCapability.TryGetValue(manifest.Id, out var absentFeatures); + if (absentFeatures == null) + { + absentFeatures = new List(); + result.AbsentFeaturesByCapability[manifest.Id] = absentFeatures; + } + + absentFeatures.Add(LocalizationService.GetString(optional.AbsentFeatureLocalizationKey)); + } + + result.DependenciesByCapability[manifest.Id] = dependencies; + result.AllowedOwnersByConsumer[manifest.Id] = allowedOwners; + result.UnassembledOptionalByConsumer[manifest.Id] = unassembledOptional; + } + + /// + /// 解析依赖目标的缺席原因(已装配 = None;白名单未收录 / 排除表命中 / 部署目录未发现)。 + /// + /// 依赖能力 Id。 + /// 全部已发现清单表。 + /// 启用能力 Id 集。 + /// 装配过滤器。 + /// 缺席原因(None = 已装配)。 + private static CapabilityDependencyAbsenceReason ResolveAbsenceReason(string dependencyId, Dictionary manifestsById, HashSet enabledIds, CapabilityAssemblyFilter filter) + { + if (enabledIds.Contains(dependencyId)) + { + return CapabilityDependencyAbsenceReason.None; + } + + if (!manifestsById.TryGetValue(dependencyId, out var manifest)) + { + return CapabilityDependencyAbsenceReason.NotDiscovered; + } + + var state = filter.Match(manifest); + if (state == CapabilityFilterState.ExcludedByDisableList) + { + return CapabilityDependencyAbsenceReason.ExcludedByDisableList; + } + + return CapabilityDependencyAbsenceReason.NotInEnableList; + } + + /// + /// 收集全部循环依赖(必选边 + 在场可选边;每个环以完整路径记一条问题,规范化去重)。 + /// + /// 启用模块。 + /// 定序边(依赖方 → 依赖集合)。 + /// 检查结果累积器。 + private static void CollectCycles(IReadOnlyList enabledModules, Dictionary> edges, Result result) + { + var nodeState = new Dictionary(StringComparer.Ordinal); + var stack = new List(); + var seenCycles = new HashSet(StringComparer.Ordinal); + for (var i = 0; i < enabledModules.Count; i++) + { + if (!nodeState.TryGetValue(enabledModules[i].Manifest.Id, out var state) || state == 0) + { + DepthFirstSearch(enabledModules[i].Manifest.Id, edges, nodeState, stack, seenCycles, result); + } + } + } + + /// + /// 深度优先搜索(0 = 未访问 / 1 = 在栈 / 2 = 已完成;回边即成环)。 + /// + /// 当前能力 Id。 + /// 定序边。 + /// 节点状态表。 + /// 当前路径栈。 + /// 已报告环的规范化键集。 + /// 检查结果累积器。 + private static void DepthFirstSearch(string current, Dictionary> edges, Dictionary nodeState, List stack, HashSet seenCycles, Result result) + { + nodeState[current] = 1; + stack.Add(current); + if (edges.TryGetValue(current, out var dependencies)) + { + for (var i = 0; i < dependencies.Count; i++) + { + var next = dependencies[i]; + if (!nodeState.TryGetValue(next, out var nextState)) + { + nextState = 0; + } + + if (nextState == 1) + { + ReportCycle(next, stack, seenCycles, result); + continue; + } + + if (nextState == 0) + { + DepthFirstSearch(next, edges, nodeState, stack, seenCycles, result); + } + } + } + + stack.RemoveAt(stack.Count - 1); + nodeState[current] = 2; + } + + /// + /// 报告一个环(从栈中回边目标起截取路径;规范化旋转去重后记一条问题)。 + /// + /// 回边目标(环起点)。 + /// 当前路径栈。 + /// 已报告环的规范化键集。 + /// 检查结果累积器。 + private static void ReportCycle(string cycleStart, List stack, HashSet seenCycles, Result result) + { + var startIndex = stack.LastIndexOf(cycleStart); + if (startIndex < 0) + { + return; + } + + var cycle = new List(); + for (var i = startIndex; i < stack.Count; i++) + { + cycle.Add(stack[i]); + } + + cycle.Add(cycleStart); + var canonicalKey = BuildCanonicalCycleKey(cycle); + if (!seenCycles.Add(canonicalKey)) + { + return; + } + + var path = string.Join(" → ", cycle); + // Localization: Capability.Hosting.Assembly.DependencyCycle - 依赖成环。 + result.Problems.Add(string.Format(LocalizationService.GetString(Localization.Keys.Capability.Hosting.Assembly.AssemblyDependencyCycle), path)); + } + + /// + /// 生成环的规范化键(旋转到字典序最小起点;同一环只报一次)。 + /// + /// 环路径(首尾相同)。 + /// 规范化键。 + private static string BuildCanonicalCycleKey(List cycle) + { + var nodes = cycle.Count - 1; + var bestOffset = 0; + for (var offset = 1; offset < nodes; offset++) + { + for (var i = 0; i < nodes; i++) + { + var comparison = string.CompareOrdinal(cycle[(offset + i) % nodes], cycle[(bestOffset + i) % nodes]); + if (comparison < 0) + { + bestOffset = offset; + break; + } + + if (comparison > 0) + { + break; + } + } + } + + var parts = new string[nodes]; + for (var i = 0; i < nodes; i++) + { + parts[i] = cycle[(bestOffset + i) % nodes]; + } + + return string.Join("|", parts); + } + + /// + /// 按依赖边拓扑定序(Kahn:同层按能力 Id 序数取最小——装配顺序不与扫描序、程序集名挂钩)。 + /// + /// 启用模块。 + /// 定序边(依赖方 → 依赖集合)。 + /// 检查结果累积器(写入 Order)。 + private static void OrderByDependencies(IReadOnlyList enabledModules, Dictionary> edges, Result result) + { + var inDegree = new Dictionary(StringComparer.Ordinal); + var dependents = new Dictionary>(StringComparer.Ordinal); + foreach (var pair in edges) + { + inDegree[pair.Key] = 0; + dependents[pair.Key] = new List(); + } + + foreach (var pair in edges) + { + for (var i = 0; i < pair.Value.Count; i++) + { + var dependency = pair.Value[i]; + inDegree[pair.Key]++; + if (!dependents.ContainsKey(dependency)) + { + dependents[dependency] = new List(); + } + + dependents[dependency].Add(pair.Key); + } + } + + var ready = new SortedSet(StringComparer.Ordinal); + foreach (var pair in inDegree) + { + if (pair.Value == 0) + { + ready.Add(pair.Key); + } + } + + var ordered = new List(enabledModules.Count); + while (ready.Count > 0) + { + var id = ready.Min; + ready.Remove(id); + ordered.Add(id); + var dependentIds = dependents[id]; + for (var i = 0; i < dependentIds.Count; i++) + { + var dependentId = dependentIds[i]; + if (!inDegree.ContainsKey(dependentId)) + { + continue; + } + + inDegree[dependentId]--; + if (inDegree[dependentId] == 0) + { + ready.Add(dependentId); + } + } + } + + result.Order = ordered; + } + + /// + /// 缺席原因文案(区分配置问题与部署产物问题)。 + /// + /// 缺席原因。 + /// 本地化文案。 + public static string DescribeReason(CapabilityDependencyAbsenceReason reason) + { + switch (reason) + { + case CapabilityDependencyAbsenceReason.NotInEnableList: + // Localization: Capability.Hosting.Assembly.ReasonNotInEnableList - 白名单未收录。 + return LocalizationService.GetString(Localization.Keys.Capability.Hosting.Assembly.AssemblyReasonNotInEnableList); + case CapabilityDependencyAbsenceReason.ExcludedByDisableList: + // Localization: Capability.Hosting.Assembly.ReasonExcludedByDisableList - 配置排除表。 + return LocalizationService.GetString(Localization.Keys.Capability.Hosting.Assembly.AssemblyReasonExcludedByDisableList); + case CapabilityDependencyAbsenceReason.NotDiscovered: + // Localization: Capability.Hosting.Assembly.ReasonNotDiscovered - 部署目录未发现。 + return LocalizationService.GetString(Localization.Keys.Capability.Hosting.Assembly.AssemblyReasonNotDiscovered); + default: + return string.Empty; + } + } + + /// + /// 依赖检查结果(问题条目 + 依赖状态数据 + 解析来源策略 + 拓扑序)。 + /// + internal sealed class Result + { + /// + /// 初始化 。 + /// + public Result() + { + Problems = new List(); + DependenciesByCapability = new Dictionary>(StringComparer.Ordinal); + AbsentFeaturesByCapability = new Dictionary>(StringComparer.Ordinal); + AllowedOwnersByConsumer = new Dictionary>(StringComparer.Ordinal); + UnassembledOptionalByConsumer = new Dictionary>(StringComparer.Ordinal); + Order = new List(); + } + + /// + /// 获取全部问题条目(必选缺失 + 环路;已本地化;非空即装配失败)。 + /// + public List Problems { get; } + + /// + /// 获取能力 Id → 依赖状态条目表(报告数据面)。 + /// + public Dictionary> DependenciesByCapability { get; } + + /// + /// 获取能力 Id → 缺失功能文案表(可选缺失;报告与日志共用)。 + /// + public Dictionary> AbsentFeaturesByCapability { get; } + + /// + /// 获取消费者能力 Id → 允许解析的来源 Id 集合(含自身 + 直接必选与在场可选依赖;族 Id 由注册表另行放行)。 + /// + public Dictionary> AllowedOwnersByConsumer { get; } + + /// + /// 获取消费者能力 Id → 已声明但未装配的可选依赖 Id 列表(每个启用模块一条,可为空)。 + /// 模块作用域的 GetOptionalService 只有列表非空时才允许未命中返回 null,否则按「能力在场但服务未注册」fail-fast。 + /// + public Dictionary> UnassembledOptionalByConsumer { get; } + + /// + /// 获取拓扑定序后的能力 Id 序(同层按 Id 序数;无问题时才有意义)。 + /// + public List Order { get; set; } + } +} diff --git a/Capability/GameFrameX.Capability.Hosting.Core/CapabilityEventBus.cs b/Capability/GameFrameX.Capability.Hosting.Core/CapabilityEventBus.cs new file mode 100644 index 000000000..dd3e310e6 --- /dev/null +++ b/Capability/GameFrameX.Capability.Hosting.Core/CapabilityEventBus.cs @@ -0,0 +1,202 @@ +// ========================================================================================== +// 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 refer to 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.Capability.Hosting.Abstractions; +using GameFrameX.Foundation.Localization.Core; + +namespace GameFrameX.Capability.Hosting.Core; + +/// +/// 机制事件总线(机制层唯一事件语义:Register 期订阅声明、同步注册序分发、单订阅异常记日志继续)。 +/// +/// 维护约束:订阅只在 Register 期经注册上下文声明; 在宿主 Activate 开始前 +/// 拒绝(Register 期发出的事件后注册订阅者收不到);主题命名约定 <族>.<域>.<事件> +///(内容机制不解释);载荷类型按订阅时的泛型参数精确匹配,同主题不同载荷类型互不投递。 +/// +/// +public sealed class CapabilityEventBus : ICapabilityEventBus, ICapabilityEventPublisher +{ + /// + /// 订阅表锁。 + /// + private readonly object _sync = new(); + + /// + /// 订阅表(注册序)。 + /// + private readonly List _subscriptions = new(); + + /// + /// 装配日志出口(订阅体异常记录用)。 + /// + private readonly ICapabilityAssemblyLog _assemblyLog; + + /// + /// 是否已进入 Activate 期(发布开关)。 + /// + private bool _acceptingPublishes; + + /// + /// 初始化 。 + /// + /// 装配日志出口(不可为 null)。 + public CapabilityEventBus(ICapabilityAssemblyLog assemblyLog) + { + _assemblyLog = assemblyLog ?? throw new ArgumentNullException(nameof(assemblyLog)); + } + + /// + /// 声明一个订阅(Register 期经注册上下文调用;注册序即分发序)。 + /// + /// 事件载荷类型。 + /// 主题(非空)。 + /// 同步订阅体(不可为 null)。 + public void Subscribe(string topic, Action handler) + { + if (string.IsNullOrWhiteSpace(topic)) + { + throw new ArgumentException("Event topic must be a non-empty string.", nameof(topic)); + } + + if (handler == null) + { + throw new ArgumentNullException(nameof(handler)); + } + + Action typedHandler = payload => handler((TEvent)payload); + lock (_sync) + { + _subscriptions.Add(new Subscription(topic, typeof(TEvent), typedHandler)); + } + } + + /// + /// 发布一条事件(Activate 开始前拒绝;同步按订阅注册序分发,单个订阅体异常记日志后继续)。 + /// + /// 事件载荷类型。 + /// 主题(非空)。 + /// 事件载荷。 + public void Publish(string topic, TEvent eventPayload) + { + if (string.IsNullOrWhiteSpace(topic)) + { + throw new ArgumentException("Event topic must be a non-empty string.", nameof(topic)); + } + + Subscription[] snapshot; + lock (_sync) + { + if (!_acceptingPublishes) + { + // Localization: Capability.Hosting.EventPublishRejected - Activate 开始前拒绝发布事件。 + throw new InvalidOperationException(string.Format(LocalizationService.GetString(Localization.Keys.Capability.Hosting.EventPublishRejected), topic)); + } + + snapshot = BuildSnapshotLocked(topic, typeof(TEvent)); + } + + for (var i = 0; i < snapshot.Length; i++) + { + try + { + snapshot[i].Handler(eventPayload); + } + catch (Exception exception) + { + // Localization: Capability.Hosting.EventBus.SubscriberFailed - 能力事件订阅处理失败。 + _assemblyLog.Error(string.Format(LocalizationService.GetString(Localization.Keys.Capability.Hosting.EventBusSubscriberFailed), topic, exception.Message)); + } + } + } + + /// + /// 标记进入 Activate 期(宿主启动时调用一次;开启发布)。 + /// + internal void MarkActivationBegun() + { + lock (_sync) + { + _acceptingPublishes = true; + } + } + + /// + /// 构建主题 + 载荷类型的订阅快照(须在锁内调用)。 + /// + /// 主题。 + /// 载荷类型。 + /// 订阅快照(注册序)。 + private Subscription[] BuildSnapshotLocked(string topic, Type payloadType) + { + var matched = new List(); + for (var i = 0; i < _subscriptions.Count; i++) + { + if (string.Equals(_subscriptions[i].Topic, topic, StringComparison.Ordinal) && _subscriptions[i].PayloadType == payloadType) + { + matched.Add(_subscriptions[i]); + } + } + + return matched.ToArray(); + } + + /// + /// 订阅条目(主题 + 载荷类型 + 装箱处理器)。 + /// + private sealed class Subscription + { + /// + /// 主题。 + /// + public readonly string Topic; + + /// + /// 载荷类型。 + /// + public readonly Type PayloadType; + + /// + /// 装箱处理器。 + /// + public readonly Action Handler; + + /// + /// 初始化 。 + /// + /// 主题。 + /// 载荷类型。 + /// 装箱处理器。 + public Subscription(string topic, Type payloadType, Action handler) + { + Topic = topic; + PayloadType = payloadType; + Handler = handler; + } + } +} diff --git a/Capability/GameFrameX.Capability.Hosting.Core/CapabilityHost.cs b/Capability/GameFrameX.Capability.Hosting.Core/CapabilityHost.cs index 5a0febdf4..97bbc07f7 100644 --- a/Capability/GameFrameX.Capability.Hosting.Core/CapabilityHost.cs +++ b/Capability/GameFrameX.Capability.Hosting.Core/CapabilityHost.cs @@ -24,42 +24,36 @@ // 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.Capability.Hosting.Abstractions; using GameFrameX.Foundation.Localization.Core; -using GameFrameX.Capability.Hosting.Abstractions; namespace GameFrameX.Capability.Hosting.Core; /// -/// 能力宿主(装配完成的运行时承载:授权作用域 + 调度壳 + 能力报告 + 事件爆发口)。 +/// 能力宿主(装配完成的运行时承载:授权作用域 + 服务注册表 + 调度壳 + 事件总线 + 能力报告 + 三段生命周期编排)。 /// -/// 维护约束:本类是机制承接位(原 OnlineRuntimeHost 的机制半边),不持有任何能力域状态; -/// admin 面与管理端点归 Capability.Hosting.AdminApi,由组合根另行装配。 +/// 维护约束(启停序红线): 与 经同一把生命周期闸串行执行—— +/// 启动进行中到达的停止会等待启动结束(成功或回滚完毕)后再停,不会与激活交错。 +/// 按拓扑序逐模块 ActivateAsync,全部激活成功后启动调度器;任一模块激活失败或 +/// 调度器启动失败,即先对失败模块本身执行一次 DeactivateAsync(激活失败场景),再对已激活模块逆序 +/// DeactivateAsync,撤销全部桥并释放钩子声明后重抛原异常(回滚中的停用失败只记日志、不覆盖原异常)。 +/// 先停调度器,再按拓扑逆序逐模块 DeactivateAsync(失败记日志不阻断其余模块停止), +/// 最后机制自动撤销全部桥(撤销不再由模块或组合根手写)。本类不持有任何能力域状态;admin 面归 Hosting.AdminApi。 /// /// public sealed class CapabilityHost { - /// - /// 宿主共享服务注册表(组合根预置 + 能力模块注册的产物;「壳取用叶子包装配产物」的服务共享面,C231)。 + /// 宿主共享服务注册表(族基建 + 宿主覆盖 + 能力模块注册的产物;装配后对组合根与管理面开放取用)。 /// - /// - /// The host-wide shared service registry (composition-root pre-seeds plus capability-module products; - /// the service-sharing face for the shell consuming leaf-package assembly products, C231). - /// public CapabilityServices Services { get; } /// - /// 启用能力模块实例表(Requires 拓扑序;admin 组装的唯一模块来源——模块自装配后携带域状态, + /// 启用能力模块实例表(依赖拓扑序;admin 组装的唯一模块来源——模块自装配后携带域状态, /// 必须与 Register 共用同一实例,禁止二次实例化)。 /// - /// - /// The enabled capability module instances in Requires topological order; the single module source for - /// admin assembly — modules carry domain state after self-assembly and must reuse the same instances that - /// registered, never a second instantiation. - /// public IReadOnlyList Modules { get; } /// @@ -78,10 +72,35 @@ public sealed class CapabilityHost public CapabilityScheduler Scheduler { get; } /// - /// 启动标记(重复启动保护)。 + /// 机制事件总线(Activate 开始后接受发布)。 + /// + public CapabilityEventBus EventBus { get; } + + /// + /// 装配日志出口(激活回滚与停止失败记录用)。 + /// + private readonly ICapabilityAssemblyLog _assemblyLog; + + /// + /// 全部模块的桥撤销凭据(激活期收集;停止期逆序撤销——消费方旧租约随即显式 8002)。 + /// + private readonly List _bridgePublications = new(); + + /// + /// 本宿主模块 Register 期声明的钩子默认实现释放凭据(进程级静态槽位;停止 / 激活失败 / 组合失败时统一释放, + /// 保证同进程再次组合可重新声明)。 /// - private bool _started; + private readonly List _hookDeclarationReleases; + /// + /// 宿主状态(0 = 已装配未启动,1 = 运行中,2 = 已停止);只在 内读写。 + /// + private int _state; + + /// + /// 生命周期闸(串行化启动与停止:两者都是跨 await 的多步序列,交错会让停止撤桥后启动继续发布新桥)。 + /// + private readonly SemaphoreSlim _lifecycleGate = new(1, 1); /// /// 获取授权租户标识。 @@ -108,25 +127,17 @@ public long ServerId } /// - /// 初始化 (共享服务注册表与模块实例缺省自建形态)。 - /// - /// 装配选项。 - /// 能力报告。 - /// 调度壳(承载本宿主的全部注册 job)。 - public CapabilityHost(CapabilityHostOptions options, CapabilityReport report, CapabilityScheduler scheduler) - : this(options, report, scheduler, new CapabilityServices(), Array.Empty()) - { - } - - /// - /// 初始化 (完整形态:装配器注入共享服务注册表与启用模块实例表)。 + /// 初始化 (完整形态;仅机制组合器构造)。 /// /// 装配选项。 /// 能力报告。 /// 调度壳(承载本宿主的全部注册 job)。 /// 宿主共享服务注册表。 /// 启用能力模块实例表(拓扑序)。 - public CapabilityHost(CapabilityHostOptions options, CapabilityReport report, CapabilityScheduler scheduler, CapabilityServices services, IReadOnlyList modules) + /// 机制事件总线。 + /// 装配日志出口。 + /// 钩子声明释放凭据(所有权移交本宿主)。 + internal CapabilityHost(CapabilityHostOptions options, CapabilityReport report, CapabilityScheduler scheduler, CapabilityServices services, IReadOnlyList modules, CapabilityEventBus eventBus, ICapabilityAssemblyLog assemblyLog, List hookDeclarationReleases) { Options = options ?? throw new ArgumentNullException(nameof(options)); if (!options.IsValid()) @@ -139,39 +150,202 @@ public CapabilityHost(CapabilityHostOptions options, CapabilityReport report, Ca Scheduler = scheduler ?? throw new ArgumentNullException(nameof(scheduler)); Services = services ?? throw new ArgumentNullException(nameof(services)); Modules = modules ?? throw new ArgumentNullException(nameof(modules)); + EventBus = eventBus ?? throw new ArgumentNullException(nameof(eventBus)); + _assemblyLog = assemblyLog ?? throw new ArgumentNullException(nameof(assemblyLog)); + _hookDeclarationReleases = hookDeclarationReleases ?? throw new ArgumentNullException(nameof(hookDeclarationReleases)); + } + + /// + /// 启动宿主(拓扑序逐模块 ActivateAsync → 启动调度器;任一步失败即回滚后重抛;可重复调用幂等)。 + /// 与 串行执行。 + /// + /// 取消令牌。 + /// 异步任务。 + public async Task StartAsync(CancellationToken cancellationToken = default) + { + await _lifecycleGate.WaitAsync(CancellationToken.None).ConfigureAwait(false); + try + { + await StartCoreAsync(cancellationToken).ConfigureAwait(false); + } + finally + { + _lifecycleGate.Release(); + } } + /// - /// 分发一条能力事件(同步驱动事件 job;P1 机制预留爆发口)。 + /// 停止宿主(停调度器 → 逆序逐模块 DeactivateAsync(失败记日志不阻断)→ 机制自动撤销全部桥)。 + /// 启动进行中调用时等待启动结束后再停。 /// - /// 事件通知。 - public void RaiseEvent(CapabilityEventNotification notification) + /// 取消令牌。 + /// 异步任务。 + public async Task StopAsync(CancellationToken cancellationToken = default) { - Scheduler.RaiseEvent(notification); + await _lifecycleGate.WaitAsync(CancellationToken.None).ConfigureAwait(false); + try + { + await StopCoreAsync(cancellationToken).ConfigureAwait(false); + } + finally + { + _lifecycleGate.Release(); + } } /// - /// 启动宿主(启动调度壳;可重复调用幂等)。 + /// 启动主体(调用方已持有生命周期闸)。 /// + /// 取消令牌。 /// 异步任务。 - public Task StartAsync() + private async Task StartCoreAsync(CancellationToken cancellationToken) { - if (_started) + if (_state != 0) + { + return; + } + + _state = 1; + EventBus.MarkActivationBegun(); + Services.MarkActivationStarted(); + + var activatedModules = new List(); + for (var i = 0; i < Modules.Count; i++) { - return Task.CompletedTask; + var module = Modules[i]; + var modulePublications = new List(); + var resolver = new CapabilityServiceResolver(Services, module.Manifest.Id); + var publisher = new CapabilityBridgePublisher(module.Manifest.Id, modulePublications); + var context = new CapabilityActivationContext(resolver, publisher); + try + { + await module.ActivateAsync(context, cancellationToken).ConfigureAwait(false); + } + catch (Exception) + { + // Localization: Capability.Hosting.ActivationFailed - 激活失败逆序回滚。 + _assemblyLog.Error(string.Format(LocalizationService.GetString(Localization.Keys.Capability.Hosting.ActivationFailed), module.Manifest.Id, activatedModules.Count)); + // 失败模块在抛出前可能已取得资源(桥以外的订阅 / 句柄):机制保证「Activate 抛异常 ⇒ 该模块必被 Deactivate」。 + await DeactivateModuleAsync(module, CancellationToken.None).ConfigureAwait(false); + await RollBackActivationAsync(activatedModules, modulePublications).ConfigureAwait(false); + throw; + } + + activatedModules.Add(module); + _bridgePublications.AddRange(modulePublications); } - _started = true; - Scheduler.Start(); - return Task.CompletedTask; + try + { + Scheduler.Start(); + } + catch (Exception) + { + // Localization: Capability.Hosting.SchedulerStartFailed - 调度器启动失败逆序回滚。 + _assemblyLog.Error(string.Format(LocalizationService.GetString(Localization.Keys.Capability.Hosting.SchedulerStartFailed), activatedModules.Count)); + await RollBackActivationAsync(activatedModules, null).ConfigureAwait(false); + throw; + } } /// - /// 停止宿主(停调度壳,等待当轮完成)。 + /// 停止主体(调用方已持有生命周期闸)。 /// + /// 取消令牌。 /// 异步任务。 - public async Task StopAsync() + private async Task StopCoreAsync(CancellationToken cancellationToken) { - _started = false; + if (_state == 0) + { + // 已装配从未启动:无激活资源需回收,但 Register 期钩子声明仍占用进程级槽位,必须释放。 + _state = 2; + ReleaseHookDeclarations(); + return; + } + + if (_state != 1) + { + return; + } + + _state = 2; await Scheduler.StopAsync().ConfigureAwait(false); + await DeactivateModulesInReverseAsync(Modules, cancellationToken).ConfigureAwait(false); + ReleaseInReverse(_bridgePublications); + ReleaseHookDeclarations(); + } + + /// + /// 启动失败回滚(逆序 Deactivate 已激活模块 → 撤销失败模块未入账的桥与全部已入账桥 → 释放钩子声明 → 置已停止)。 + /// + /// 已激活成功的模块(正序)。 + /// 失败模块已发布但未入账的桥凭据(调度器启动失败时为 null)。 + /// 异步任务。 + private async Task RollBackActivationAsync(List activatedModules, List pendingPublications) + { + await DeactivateModulesInReverseAsync(activatedModules, CancellationToken.None).ConfigureAwait(false); + if (pendingPublications != null) + { + ReleaseInReverse(pendingPublications); + } + + ReleaseInReverse(_bridgePublications); + ReleaseHookDeclarations(); + _state = 2; + } + + /// + /// 释放本宿主声明的全部钩子默认实现(幂等;组合器在业务面未启用 / 组合后续步骤失败时直接调用)。 + /// + internal void ReleaseHookDeclarations() + { + ReleaseInReverse(_hookDeclarationReleases); + } + + /// + /// 逆序释放凭据并清空收集器(桥撤销与钩子声明释放共用;重复调用为空操作)。 + /// + /// 释放凭据收集器。 + internal static void ReleaseInReverse(List releases) + { + for (var i = releases.Count - 1; i >= 0; i--) + { + releases[i].Dispose(); + } + + releases.Clear(); + } + + /// + /// 逆序执行模块 DeactivateAsync(单个失败记 Error 日志,不阻断其余模块停止)。 + /// + /// 模块集合(正序传入,内部逆序执行)。 + /// 取消令牌。 + /// 异步任务。 + private async Task DeactivateModulesInReverseAsync(IReadOnlyList modules, CancellationToken cancellationToken) + { + for (var i = modules.Count - 1; i >= 0; i--) + { + await DeactivateModuleAsync(modules[i], cancellationToken).ConfigureAwait(false); + } + } + + /// + /// 执行单个模块 DeactivateAsync(失败记 Error 日志、不外抛:停止链与激活回滚都不得被单模块停用失败打断或覆盖原异常)。 + /// + /// 模块。 + /// 取消令牌。 + /// 异步任务。 + private async Task DeactivateModuleAsync(ICapabilityModule module, CancellationToken cancellationToken) + { + try + { + await module.DeactivateAsync(cancellationToken).ConfigureAwait(false); + } + catch (Exception exception) + { + // Localization: Capability.Hosting.DeactivationFailed - 停止失败不阻断其余模块。 + _assemblyLog.Error(string.Format(LocalizationService.GetString(Localization.Keys.Capability.Hosting.DeactivationFailed), module.Manifest.Id, exception.Message)); + } } } diff --git a/Capability/GameFrameX.Capability.Hosting.Core/CapabilityHostBuilder.cs b/Capability/GameFrameX.Capability.Hosting.Core/CapabilityHostBuilder.cs index e9466f2d7..e88d93b8e 100644 --- a/Capability/GameFrameX.Capability.Hosting.Core/CapabilityHostBuilder.cs +++ b/Capability/GameFrameX.Capability.Hosting.Core/CapabilityHostBuilder.cs @@ -24,136 +24,149 @@ // 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.Text.Json; +using GameFrameX.Capability.Hosting.Abstractions; using GameFrameX.Foundation.Localization.Core; -using GameFrameX.Capability.Hosting.Abstractions; namespace GameFrameX.Capability.Hosting.Core; /// -/// 能力装配器(显式链式装配入口;自动扫描经 发现模块后同样走本装配器)。 +/// 能力装配器( 的内部实现:过滤 → 依赖检查 → 拓扑定序 → +/// 解析来源策略 → Options 合并 → 逐模块 Register → 产出宿主与报告)。 /// -/// 维护约束(装配红线):manifest Id 全宿主唯一;Requires 做闭包校验(缺依赖/成环即装配失败 fail-fast); -/// 启用集合按拓扑定序,同层稳定序 = 模块加入序;过滤器引用未发现的能力 Id 即装配失败; -/// 调度 job 全名(能力Id/jobId)唯一,重复即装配失败。 +/// 维护约束(装配红线,全部 fail-fast):过滤器引用未发现的能力 Id 即失败;依赖检查在任何模块 +/// Register 之前完成(必选缺失 / 成环 = 一次报全的 , +/// 不执行任何模块代码);同层按能力 Id 序数定序;Settings 出现未发现的能力 Id 即失败、 +/// 被过滤能力的设置段忽略并标注;job 全名(能力Id/jobId)唯一;机制包零能力硬编码。 /// /// -public sealed class CapabilityHostBuilder +internal sealed class CapabilityHostBuilder { /// - /// 已加入模块(加入序)。 + /// 宿主共享服务注册表(族基建已预置)。 /// - private readonly List _modules = new(); + private readonly CapabilityServices _services; /// - /// 装配过滤器(null = 全部启用)。 + /// 机制事件总线。 /// - private CapabilityAssemblyFilter _filter; + private readonly CapabilityEventBus _eventBus; /// - /// 宿主共享服务注册表(组合根预置与模块注册共用;装配后经 取用)。 + /// 能力族。 /// - private readonly CapabilityServices _services = new(); + private readonly ICapabilityFamily _family; /// - /// 加入一个能力模块。 + /// 组合选项。 /// - /// 能力模块。 - /// 本装配器(链式)。 - public CapabilityHostBuilder AddModule(ICapabilityModule module) - { - if (module == null) - { - throw new ArgumentNullException(nameof(module)); - } - - _modules.Add(module); - return this; - } + private readonly CapabilityCompositionOptions _options; /// - /// 设置装配过滤器(配置排除表与能力清单段)。 + /// 初始化 (仅 构造)。 /// - /// 过滤器(null = 全部启用)。 - /// 本装配器(链式)。 - public CapabilityHostBuilder SetFilter(CapabilityAssemblyFilter filter) + /// 服务注册表(族基建已预置)。 + /// 机制事件总线。 + /// 能力族。 + /// 组合选项。 + public CapabilityHostBuilder(CapabilityServices services, CapabilityEventBus eventBus, ICapabilityFamily family, CapabilityCompositionOptions options) { - _filter = filter; - return this; + _services = services ?? throw new ArgumentNullException(nameof(services)); + _eventBus = eventBus ?? throw new ArgumentNullException(nameof(eventBus)); + _family = family ?? throw new ArgumentNullException(nameof(family)); + _options = options ?? throw new ArgumentNullException(nameof(options)); } /// - /// 预置一个服务(组合根侧的服务共享面:平台服务 / 包级 options / store 替换缝共用; - /// 同类型重复注册即装配失败)。 + /// 装配(过滤器校验 → 过滤 → 依赖检查 → 定序 → 来源策略 → Options 合并 → Register → 产出宿主)。 /// - /// 服务类型(调用点书写形态,可为接口)。 - /// 服务实例(不可为 null)。 - /// 本装配器(链式)。 - public CapabilityHostBuilder UseService(TService service) + /// 全部已发现模块(发现序)。 + /// 能力宿主(含报告与调度壳)。 + public CapabilityHost Build(IReadOnlyList modules) { - _services.AddService(service); - return this; - } - - /// - /// 装配(闭包校验 → 过滤 → 拓扑定序 → 逐能力注册 → 产出宿主与能力报告)。 - /// - /// 宿主选项。 - /// 能力宿主。 - public CapabilityHost Build(CapabilityHostOptions options) - { - if (options == null) + var filter = _options.Filter ?? new CapabilityAssemblyFilter(); + var manifestsById = new Dictionary(StringComparer.Ordinal); + for (var i = 0; i < modules.Count; i++) { - throw new ArgumentNullException(nameof(options)); + manifestsById[modules[i].Manifest.Id] = modules[i]; } - var filter = _filter ?? new CapabilityAssemblyFilter(); - var manifestsById = new Dictionary(StringComparer.Ordinal); - foreach (var module in _modules) + ValidateFilterTargets(filter, manifestsById); + + var enabledModules = new List(); + for (var i = 0; i < modules.Count; i++) { - var id = module.Manifest.Id; - if (manifestsById.ContainsKey(id)) + if (filter.Match(modules[i].Manifest) == CapabilityFilterState.Enabled) { - // Localization: Capability.Hosting.DuplicateCapability - 能力 Id 重复注册。 - throw new InvalidOperationException(string.Format(LocalizationService.GetString(Localization.Keys.Capability.Hosting.DuplicateCapability), id)); + enabledModules.Add(modules[i]); } + } - manifestsById.Add(id, module); + // 依赖检查在任何模块 Register 之前:检查不通过就不执行任何模块代码,不会留下装了一半的状态。 + var dependencyResult = CapabilityDependencyChecker.Check(modules, enabledModules, filter); + if (dependencyResult.Problems.Count > 0) + { + throw new CapabilityAssemblyException(dependencyResult.Problems); } - ValidateFilterTargets(filter, manifestsById); + _services.SetResolutionPolicy(dependencyResult.AllowedOwnersByConsumer, dependencyResult.UnassembledOptionalByConsumer); + + var orderedModules = OrderModulesByIds(manifestsById, dependencyResult.Order); + var settingsSections = ExtractSettingsSections(manifestsById, enabledModules); + ValidateConfigureTargets(manifestsById, enabledModules); - var entries = new List(); - var enabled = new List(); - foreach (var module in _modules) + var boundOptionsByCapability = new Dictionary(StringComparer.Ordinal); + var settingsFieldsByCapability = new Dictionary>(StringComparer.Ordinal); + foreach (var module in orderedModules) { - var state = filter.Match(module.Manifest); - entries.Add(new CapabilityReportEntry(module.Manifest, module.GetType().Assembly.GetName().Name, state)); - if (state == CapabilityFilterState.Enabled) + var errorPathPrefix = _family.ConfigurationSectionName + ".Settings." + module.Manifest.Id; + settingsSections.TryGetValue(module.Manifest.Id, out var settingsElement); + var binding = CapabilityOptionsBinder.Bind(module.Manifest, settingsElement, _options.ConfigureEntries, errorPathPrefix); + if (binding.Instance != null) { - enabled.Add(module); + boundOptionsByCapability[module.Manifest.Id] = binding.Instance; + } + + if (binding.Fields.Count > 0) + { + settingsFieldsByCapability[module.Manifest.Id] = binding.Fields; } } - var ordered = OrderByRequires(enabled); + // 钩子声明写入进程级静态槽位:装配任一步失败必须释放已声明项,否则同进程再次组合会误判重复声明。 + var hookDeclarationReleases = new List(); + try + { + var tickJobs = new List(); + var intervalJobs = new List(); + foreach (var module in orderedModules) + { + boundOptionsByCapability.TryGetValue(module.Manifest.Id, out var boundOptions); + var context = new CapabilityRegistrationContext(module.Manifest, _services, _eventBus, boundOptions, hookDeclarationReleases); + module.Register(context); + tickJobs.AddRange(context.TickJobs); + intervalJobs.AddRange(context.IntervalJobs); + } + + var hooksWithoutDeclaration = CapabilityHookSlots.GetAttachedWithoutDeclaration(); + if (hooksWithoutDeclaration.Count > 0) + { + CapabilityAssemblyLogRenderer.RenderHooksWithoutDeclaration(hooksWithoutDeclaration, _options.AssemblyLog); + } - var tickJobs = new List(); - var intervalJobs = new List(); - var eventJobs = new List(); - foreach (var module in ordered) + var entries = BuildReportEntries(modules, filter, dependencyResult, settingsSections, settingsFieldsByCapability); + var report = new CapabilityReport(entries, dependencyResult.Order); + var schedulerObserver = _options.SchedulerObserver ?? new CapabilitySchedulerLogObserver(_options.AssemblyLog); + var scheduler = new CapabilityScheduler(_options.HostOptions.SchedulerIntervalMilliseconds, tickJobs, intervalJobs, _options.SchedulerClock, schedulerObserver); + return new CapabilityHost(_options.HostOptions, report, scheduler, _services, orderedModules, _eventBus, _options.AssemblyLog, hookDeclarationReleases); + } + catch (Exception) { - var context = new CapabilityRegistrationContext(module.Manifest, _services); - module.Register(context); - tickJobs.AddRange(context.TickJobs); - intervalJobs.AddRange(context.IntervalJobs); - eventJobs.AddRange(context.EventJobs); + CapabilityHost.ReleaseInReverse(hookDeclarationReleases); + throw; } - - var report = new CapabilityReport(entries, ordered.Select(m => m.Manifest.Id).ToArray()); - var scheduler = new CapabilityScheduler(options.SchedulerIntervalMilliseconds, tickJobs, intervalJobs, eventJobs); - return new CapabilityHost(options, report, scheduler, _services, ordered); } /// @@ -186,52 +199,187 @@ private static void ValidateFilterTargets(CapabilityAssemblyFilter filter, Dicti } /// - /// 按 Requires 拓扑定序(Kahn 稳定版:同层保持加入序;缺依赖或成环即 fail-fast)。 + /// 按拓扑序 Id 序列取模块实例表。 + /// + /// 已发现模块表。 + /// 拓扑序 Id 序列。 + /// 拓扑序模块序列。 + private static List OrderModulesByIds(Dictionary manifestsById, List orderedIds) + { + var orderedModules = new List(orderedIds.Count); + for (var i = 0; i < orderedIds.Count; i++) + { + orderedModules.Add(manifestsById[orderedIds[i]]); + } + + return orderedModules; + } + + /// + /// 提取 Settings 各能力配置段(未发现的能力 Id = fail-fast;被过滤能力不入返回表——调用方标注忽略)。 /// - /// 启用模块(加入序)。 - /// 拓扑定序后的模块序列。 - private static List OrderByRequires(List enabled) + /// 已发现模块表。 + /// 启用模块。 + /// 能力 Id → 配置段节点表(仅启用能力)。 + private Dictionary ExtractSettingsSections(Dictionary manifestsById, List enabledModules) { - var byId = enabled.ToDictionary(m => m.Manifest.Id, StringComparer.Ordinal); - var inDegree = enabled.ToDictionary(m => m.Manifest.Id, _ => 0, StringComparer.Ordinal); - var dependents = enabled.ToDictionary(m => m.Manifest.Id, _ => new List(), StringComparer.Ordinal); - foreach (var module in enabled) + var sections = new Dictionary(StringComparer.Ordinal); + var section = _options.FamilyConfigurationSection; + if (section.ValueKind != JsonValueKind.Object) { - foreach (var requiredId in module.Manifest.Requires) + return sections; + } + + JsonElement settingsElement = default; + var hasSettings = false; + foreach (var property in section.EnumerateObject()) + { + if (string.Equals(property.Name, "Settings", StringComparison.OrdinalIgnoreCase)) { - if (!byId.TryGetValue(requiredId, out var required)) - { - // Localization: Capability.Hosting.RequirementMissing - 能力依赖缺失。 - throw new InvalidOperationException(string.Format(LocalizationService.GetString(Localization.Keys.Capability.Hosting.RequirementMissing), module.Manifest.Id, requiredId)); - } + settingsElement = property.Value; + hasSettings = true; + break; + } + } + + if (!hasSettings) + { + return sections; + } - inDegree[module.Manifest.Id]++; - dependents[requiredId].Add(module.Manifest.Id); + if (settingsElement.ValueKind != JsonValueKind.Object) + { + // Settings 节点必须是「能力 Id → 配置段」对象;null / 数组 / 标量按类型不符 fail-fast,带族段路径。 + // Localization: Capability.Hosting.Assembly.SettingsFieldTypeMismatch - Options 字段类型不符。 + throw new InvalidOperationException(string.Format(LocalizationService.GetString(Localization.Keys.Capability.Hosting.Assembly.AssemblySettingsFieldTypeMismatch), _family.ConfigurationSectionName + ".Settings", "object", settingsElement.ValueKind.ToString())); + } + + var enabledIds = new HashSet(StringComparer.Ordinal); + for (var i = 0; i < enabledModules.Count; i++) + { + enabledIds.Add(enabledModules[i].Manifest.Id); + } + + + foreach (var property in settingsElement.EnumerateObject()) + { + if (!manifestsById.ContainsKey(property.Name)) + { + // Localization: Capability.Hosting.Assembly.SettingsUnknownCapability - Settings 配置段出现未发现的能力。 + throw new InvalidOperationException(string.Format(LocalizationService.GetString(Localization.Keys.Capability.Hosting.Assembly.AssemblySettingsUnknownCapability), property.Name)); + } + + if (!enabledIds.Contains(property.Name)) + { + // 被过滤器排除的能力:设置段忽略,报告标注「已配置但未装配」。 + continue; + } + + sections[property.Name] = property.Value; + } + + return sections; + } + + /// + /// 校验代码 Configure 目标(引用未由任何已发现能力声明的 Options 类型 = fail-fast;被过滤能力忽略)。 + /// + /// 已发现模块表。 + /// 启用模块。 + private void ValidateConfigureTargets(Dictionary manifestsById, List enabledModules) + { + var declaredOptionsTypes = new HashSet(); + for (var i = 0; i < enabledModules.Count; i++) + { + var optionsType = enabledModules[i].Manifest.OptionsType; + if (optionsType != null) + { + declaredOptionsTypes.Add(optionsType); } } - var ready = new Queue(enabled.Where(m => inDegree[m.Manifest.Id] == 0).Select(m => m.Manifest.Id)); - var ordered = new List(enabled.Count); - while (ready.Count > 0) + var configureEntries = _options.ConfigureEntries; + for (var i = 0; i < configureEntries.Count; i++) { - var id = ready.Dequeue(); - ordered.Add(byId[id]); - foreach (var dependentId in dependents[id]) + if (declaredOptionsTypes.Contains(configureEntries[i].OptionsType)) + { + continue; + } + + var declaredByAnyDiscovered = false; + foreach (var pair in manifestsById) { - inDegree[dependentId]--; - if (inDegree[dependentId] == 0) + if (pair.Value.Manifest.OptionsType == configureEntries[i].OptionsType) { - ready.Enqueue(dependentId); + declaredByAnyDiscovered = true; + break; } } + + if (!declaredByAnyDiscovered) + { + // Localization: Capability.Hosting.ConfigureTargetMissing - 代码 Configure 引用的 Options 类型未声明。 + throw new InvalidOperationException(string.Format(LocalizationService.GetString(Localization.Keys.Capability.Hosting.ConfigureTargetMissing), configureEntries[i].OptionsType.FullName)); + } + + // 声明该 Options 的能力被过滤器排除:Configure 回调忽略(与 Settings 段忽略规则一致)。 } + } - if (ordered.Count != enabled.Count) + /// + /// 构造报告条目(发现序;启用条目带依赖状态 / 缺失功能 / Options 字段;被过滤且配置了 Settings 的条目标注)。 + /// + /// 全部已发现模块。 + /// 过滤器。 + /// 依赖检查结果。 + /// 启用能力的配置段表。 + /// Options 字段生效值表。 + /// 报告条目集合。 + private List BuildReportEntries(IReadOnlyList modules, CapabilityAssemblyFilter filter, CapabilityDependencyChecker.Result dependencyResult, Dictionary settingsSections, Dictionary> settingsFieldsByCapability) + { + var entries = new List(modules.Count); + var settingsConfiguredIds = CollectSettingsConfiguredIds(); + for (var i = 0; i < modules.Count; i++) { - // Localization: Capability.Hosting.RequirementCycle - 能力依赖成环。 - throw new InvalidOperationException(LocalizationService.GetString(Localization.Keys.Capability.Hosting.RequirementCycle)); + var module = modules[i]; + var manifest = module.Manifest; + var state = filter.Match(manifest); + dependencyResult.DependenciesByCapability.TryGetValue(manifest.Id, out var dependencies); + dependencyResult.AbsentFeaturesByCapability.TryGetValue(manifest.Id, out var absentFeatures); + settingsFieldsByCapability.TryGetValue(manifest.Id, out var settingsFields); + var settingsConfiguredButNotAssembled = state != CapabilityFilterState.Enabled && settingsConfiguredIds.Contains(manifest.Id); + entries.Add(new CapabilityReportEntry(manifest, module.GetType().Assembly.GetName().Name, state, dependencies, absentFeatures, settingsFields, settingsConfiguredButNotAssembled)); + } + + return entries; + } + + /// + /// 收集配置段写了 Settings 的全部能力 Id(含被过滤的——标注「已配置但未装配」用)。 + /// + /// 能力 Id 集合。 + private HashSet CollectSettingsConfiguredIds() + { + var identifiers = new HashSet(StringComparer.Ordinal); + var section = _options.FamilyConfigurationSection; + if (section.ValueKind != JsonValueKind.Object) + { + return identifiers; + } + + foreach (var property in section.EnumerateObject()) + { + if (string.Equals(property.Name, "Settings", StringComparison.OrdinalIgnoreCase) && property.Value.ValueKind == JsonValueKind.Object) + { + foreach (var settingsProperty in property.Value.EnumerateObject()) + { + identifiers.Add(settingsProperty.Name); + } + + break; + } } - return ordered; + return identifiers; } } diff --git a/Capability/GameFrameX.Capability.Hosting.Core/CapabilityOptionsBinder.cs b/Capability/GameFrameX.Capability.Hosting.Core/CapabilityOptionsBinder.cs new file mode 100644 index 000000000..75934b25b --- /dev/null +++ b/Capability/GameFrameX.Capability.Hosting.Core/CapabilityOptionsBinder.cs @@ -0,0 +1,471 @@ +// ========================================================================================== +// 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 refer to 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/ +// ========================================================================================== + +using System.Text.Json; +using GameFrameX.Capability.Hosting.Abstractions; +using GameFrameX.Foundation.Localization.Core; + +namespace GameFrameX.Capability.Hosting.Core; + +/// +/// Options 绑定器(合并管线:机制 new 默认实例 → 配置段按字段覆盖 → 代码 Configure 回调 → Validate)。 +/// +/// 维护约束(fail-fast 红线):整对象注入不存在——没写到的字段保持默认值而不是 0 / null; +/// 字段名大小写不敏感;未知字段或类型不符 = 装配失败且错误信息带完整路径(如 +/// Online.Settings.online-social.Friend.FriendLimt);绑定器自行遍历 JSON 节点,不依赖反序列化选项发现未知字段; +/// 每字段来源(默认 / 配置段 / 代码)由三层快照比较得出;Validate 的全部问题一次报全。 +/// +/// +internal static class CapabilityOptionsBinder +{ + /// + /// 绑定一个能力的 Options(按 manifest 声明的根类型;无配置段与代码 Configure 时仍产出默认实例与来源表)。 + /// + /// 能力清单。 + /// 本能力的配置段节点(无则为 default(JsonElement))。 + /// 代码层 Configure 回调表。 + /// 错误信息路径前缀(如 Online.Settings.online-social)。 + /// 绑定结果(合并校验后的实例 + 每字段生效值与来源)。 + public static BindingResult Bind(CapabilityManifest manifest, JsonElement settingsElement, IReadOnlyList configureEntries, string errorPathPrefix) + { + var optionsType = manifest.OptionsType; + if (optionsType == null) + { + // 能力未声明 Options 根类型 = 该能力不接受任何配置;写了带字段的配置段必须 fail-fast(否则配置被静默吞掉)。 + // 空对象 {} 视为未配置、允许。 + if (settingsElement.ValueKind == JsonValueKind.Object) + { + foreach (var jsonProperty in settingsElement.EnumerateObject()) + { + // Localization: Capability.Hosting.Assembly.SettingsUnknownField - Options 字段不存在或不可配置。 + throw new InvalidOperationException(string.Format(LocalizationService.GetString(Localization.Keys.Capability.Hosting.Assembly.AssemblySettingsUnknownField), errorPathPrefix + "." + jsonProperty.Name)); + } + } + else if (settingsElement.ValueKind != JsonValueKind.Undefined) + { + // Localization: Capability.Hosting.Assembly.SettingsFieldTypeMismatch - Options 字段类型不符。 + throw new InvalidOperationException(string.Format(LocalizationService.GetString(Localization.Keys.Capability.Hosting.Assembly.AssemblySettingsFieldTypeMismatch), errorPathPrefix, "object", DescribeJsonKind(settingsElement))); + } + + return new BindingResult(null, new List()); + } + + if (optionsType.IsAbstract || optionsType.GetConstructor(Type.EmptyTypes) == null) + { + throw new InvalidOperationException("Capability options root type must be a non-abstract class with a public parameterless constructor: " + optionsType.FullName); + } + + var instance = Activator.CreateInstance(optionsType); + var configuredPaths = new HashSet(StringComparer.Ordinal); + + if (settingsElement.ValueKind == JsonValueKind.Object) + { + ApplySection(instance, settingsElement, errorPathPrefix, configuredPaths); + } + else if (settingsElement.ValueKind != JsonValueKind.Undefined) + { + // Localization: Capability.Hosting.Assembly.SettingsFieldTypeMismatch - Options 字段类型不符。 + throw new InvalidOperationException(string.Format(LocalizationService.GetString(Localization.Keys.Capability.Hosting.Assembly.AssemblySettingsFieldTypeMismatch), errorPathPrefix, "object", settingsElement.ValueKind.ToString())); + } + + var beforeConfigure = SnapshotLeafValues(instance, string.Empty); + for (var i = 0; i < configureEntries.Count; i++) + { + if (configureEntries[i].OptionsType == optionsType) + { + configureEntries[i].Configure(instance); + } + } + + var afterConfigure = SnapshotLeafValues(instance, string.Empty); + var fields = new List(afterConfigure.Count); + foreach (var pair in afterConfigure) + { + var source = CapabilitySettingsFieldSource.Default; + // configuredPaths 记录带错误路径前缀的完整路径(错误信息复用同一路径);快照键为相对路径,比较前补齐前缀。 + if (configuredPaths.Contains(errorPathPrefix + "." + pair.Key)) + { + source = CapabilitySettingsFieldSource.ConfigurationSection; + } + + if (beforeConfigure.TryGetValue(pair.Key, out var beforeValue) && !ValuesEqual(beforeValue, pair.Value)) + { + source = CapabilitySettingsFieldSource.Code; + } + + fields.Add(new CapabilitySettingsField(pair.Key, pair.Value, source)); + } + + Validate(manifest, instance); + return new BindingResult(instance, fields); + } + + /// + /// 应用配置段(按字段覆盖;未知字段 / 类型不符 / 只读属性 = fail-fast,错误带完整路径)。 + /// + /// 目标实例。 + /// 配置段节点。 + /// 当前路径(错误信息用)。 + /// 被配置段触碰的字段路径集合。 + private static void ApplySection(object instance, JsonElement element, string path, HashSet configuredPaths) + { + foreach (var jsonProperty in element.EnumerateObject()) + { + var property = FindWritableProperty(instance.GetType(), jsonProperty.Name); + if (property == null) + { + // Localization: Capability.Hosting.Assembly.SettingsUnknownField - Options 字段不存在或不可配置。 + throw new InvalidOperationException(string.Format(LocalizationService.GetString(Localization.Keys.Capability.Hosting.Assembly.AssemblySettingsUnknownField), path + "." + jsonProperty.Name)); + } + + var propertyPath = path + "." + property.Name; + ApplyValue(instance, property, jsonProperty.Value, propertyPath, configuredPaths); + } + } + + /// + /// 应用单个字段值(对象递归;基元 / 数组按类型转换)。 + /// + /// 目标实例。 + /// 目标属性。 + /// JSON 值。 + /// 字段完整路径。 + /// 被配置段触碰的字段路径集合。 + private static void ApplyValue(object instance, System.Reflection.PropertyInfo property, JsonElement value, string propertyPath, HashSet configuredPaths) + { + if (value.ValueKind == JsonValueKind.Object) + { + if (property.PropertyType.IsClass) + { + var target = property.GetValue(instance); + if (target == null) + { + if (property.PropertyType.GetConstructor(Type.EmptyTypes) == null) + { + // Localization: Capability.Hosting.Assembly.SettingsFieldTypeMismatch - Options 字段类型不符。 + throw new InvalidOperationException(string.Format(LocalizationService.GetString(Localization.Keys.Capability.Hosting.Assembly.AssemblySettingsFieldTypeMismatch), propertyPath, property.PropertyType.FullName, "object")); + } + + target = Activator.CreateInstance(property.PropertyType); + property.SetValue(instance, target); + } + + ApplySection(target, value, propertyPath, configuredPaths); + configuredPaths.Add(propertyPath); + return; + } + + // Localization: Capability.Hosting.Assembly.SettingsFieldTypeMismatch - Options 字段类型不符。 + throw new InvalidOperationException(string.Format(LocalizationService.GetString(Localization.Keys.Capability.Hosting.Assembly.AssemblySettingsFieldTypeMismatch), propertyPath, property.PropertyType.FullName, "object")); + } + + if (value.ValueKind == JsonValueKind.Array) + { + ApplyArrayValue(instance, property, value, propertyPath, configuredPaths); + return; + } + + property.SetValue(instance, ConvertScalar(value, property.PropertyType, propertyPath)); + configuredPaths.Add(propertyPath); + } + + /// + /// 应用数组字段值(支持基元一维数组:string / int / long / bool / double)。 + /// + /// 目标实例。 + /// 目标属性。 + /// JSON 数组值。 + /// 字段完整路径。 + /// 被配置段触碰的字段路径集合。 + private static void ApplyArrayValue(object instance, System.Reflection.PropertyInfo property, JsonElement value, string propertyPath, HashSet configuredPaths) + { + if (!property.PropertyType.IsArray || property.PropertyType.GetArrayRank() != 1) + { + // Localization: Capability.Hosting.Assembly.SettingsFieldTypeMismatch - Options 字段类型不符。 + throw new InvalidOperationException(string.Format(LocalizationService.GetString(Localization.Keys.Capability.Hosting.Assembly.AssemblySettingsFieldTypeMismatch), propertyPath, property.PropertyType.FullName, "array")); + } + + var elementType = property.PropertyType.GetElementType(); + var items = new List(); + var index = 0; + foreach (var item in value.EnumerateArray()) + { + items.Add(ConvertScalar(item, elementType, propertyPath + "[" + index + "]")); + index++; + } + + var array = Array.CreateInstance(elementType, items.Count); + for (var i = 0; i < items.Count; i++) + { + array.SetValue(items[i], i); + } + + property.SetValue(instance, array); + configuredPaths.Add(propertyPath); + } + + /// + /// 标量转换(JSON 基元 → 属性类型;类型不符 fail-fast 带完整路径与期望 / 实际类型)。 + /// + /// JSON 标量值。 + /// 目标类型。 + /// 字段完整路径。 + /// 转换结果。 + private static object ConvertScalar(JsonElement value, Type targetType, string propertyPath) + { + if (value.ValueKind == JsonValueKind.Null) + { + // 只有 string 字段允许显式 null;嵌套 Options 子对象 / 数组写 null 会让 Validate 与模块解引用到空引用,按类型不符 fail-fast。 + if (targetType == typeof(string)) + { + return null; + } + + // Localization: Capability.Hosting.Assembly.SettingsFieldTypeMismatch - Options 字段类型不符。 + throw new InvalidOperationException(string.Format(LocalizationService.GetString(Localization.Keys.Capability.Hosting.Assembly.AssemblySettingsFieldTypeMismatch), propertyPath, targetType.FullName, "null")); + } + + if (targetType == typeof(string)) + { + if (value.ValueKind == JsonValueKind.String) + { + return value.GetString(); + } + } + else if (targetType == typeof(bool)) + { + if (value.ValueKind == JsonValueKind.True) + { + return true; + } + + if (value.ValueKind == JsonValueKind.False) + { + return false; + } + } + else if (targetType == typeof(int)) + { + if (value.ValueKind == JsonValueKind.Number && value.TryGetInt32(out var intValue)) + { + return intValue; + } + } + else if (targetType == typeof(long)) + { + if (value.ValueKind == JsonValueKind.Number && value.TryGetInt64(out var longValue)) + { + return longValue; + } + } + else if (targetType == typeof(double)) + { + if (value.ValueKind == JsonValueKind.Number && value.TryGetDouble(out var doubleValue)) + { + return doubleValue; + } + } + + // Localization: Capability.Hosting.Assembly.SettingsFieldTypeMismatch - Options 字段类型不符。 + throw new InvalidOperationException(string.Format(LocalizationService.GetString(Localization.Keys.Capability.Hosting.Assembly.AssemblySettingsFieldTypeMismatch), propertyPath, targetType.FullName, DescribeJsonKind(value))); + } + + /// + /// 校验合并后的最终值(实现 时调用;全部问题一次报全)。 + /// + /// 能力清单。 + /// 合并后的实例。 + private static void Validate(CapabilityManifest manifest, object instance) + { + if (!(instance is ICapabilityOptions options)) + { + return; + } + + var validation = new CapabilityOptionsValidation(); + options.Validate(validation); + if (!validation.IsValid) + { + // Localization: Capability.Hosting.Assembly.OptionsValidationFailed - Options 校验失败。 + throw new InvalidOperationException(string.Format(LocalizationService.GetString(Localization.Keys.Capability.Hosting.Assembly.AssemblyOptionsValidationFailed), manifest.Id, string.Join("; ", validation.Problems))); + } + } + + /// + /// 快照全部叶子字段值(字段路径 → 当前值;类字段递归、数组与基元为叶子)。 + /// + /// 实例。 + /// 路径前缀(根为空串)。 + /// 叶子字段值快照。 + private static Dictionary SnapshotLeafValues(object instance, string pathPrefix) + { + var values = new Dictionary(StringComparer.Ordinal); + CollectLeafValues(instance, pathPrefix, values); + return values; + } + + /// + /// 递归收集叶子字段值。 + /// + /// 实例。 + /// 路径前缀。 + /// 收集目标。 + private static void CollectLeafValues(object instance, string pathPrefix, Dictionary values) + { + if (instance == null) + { + return; + } + + var properties = instance.GetType().GetProperties(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance); + for (var i = 0; i < properties.Length; i++) + { + var property = properties[i]; + if (!property.CanRead || property.GetIndexParameters().Length > 0) + { + continue; + } + + var propertyPath = pathPrefix.Length == 0 ? property.Name : pathPrefix + "." + property.Name; + var propertyValue = property.GetValue(instance); + // 数组同为引用类型,但按叶子快照(与 ApplyArrayValue 的整字段覆盖、ValuesEqual 的逐元素比较对齐)。 + if (propertyValue != null && property.PropertyType.IsClass && property.PropertyType != typeof(string) && !property.PropertyType.IsArray) + { + CollectLeafValues(propertyValue, propertyPath, values); + continue; + } + + values[propertyPath] = propertyValue; + } + } + + /// + /// 查找可写属性(字段名大小写不敏感;只读属性视为不可配置)。 + /// + /// 目标类型。 + /// 配置段字段名。 + /// 命中属性;未命中或不可写为 null。 + private static System.Reflection.PropertyInfo FindWritableProperty(Type type, string name) + { + var properties = type.GetProperties(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance); + for (var i = 0; i < properties.Length; i++) + { + if (!string.Equals(properties[i].Name, name, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (!properties[i].CanWrite || properties[i].GetIndexParameters().Length > 0) + { + return null; + } + + return properties[i]; + } + + return null; + } + + /// + /// 值相等比较(基元 / 字符串 / 数组)。 + /// + /// 前值。 + /// 后值。 + /// 相等返回 true。 + private static bool ValuesEqual(object left, object right) + { + if (left is Array leftArray && right is Array rightArray) + { + if (leftArray.Length != rightArray.Length) + { + return false; + } + + for (var i = 0; i < leftArray.Length; i++) + { + if (!Equals(leftArray.GetValue(i), rightArray.GetValue(i))) + { + return false; + } + } + + return true; + } + + return Equals(left, right); + } + + /// + /// 描述 JSON 值种类(错误信息用)。 + /// + /// JSON 值。 + /// 种类描述。 + private static string DescribeJsonKind(JsonElement value) + { + switch (value.ValueKind) + { + case JsonValueKind.String: + return "string"; + case JsonValueKind.Number: + return "number"; + case JsonValueKind.True: + return "true"; + case JsonValueKind.False: + return "false"; + default: + return value.ValueKind.ToString().ToLowerInvariant(); + } + } + + /// + /// 绑定结果(实例 + 每字段生效值与来源)。 + /// + internal sealed class BindingResult + { + /// + /// 初始化 。 + /// + /// 合并校验后的实例(无参数能力为 null)。 + /// 每字段生效值与来源。 + public BindingResult(object instance, List fields) + { + Instance = instance; + Fields = fields; + } + + /// + /// 获取合并校验后的实例。 + /// + public object Instance { get; } + + /// + /// 获取每字段生效值与来源。 + /// + public List Fields { get; } + } +} diff --git a/Capability/GameFrameX.Capability.Hosting.Core/CapabilityRuntime.cs b/Capability/GameFrameX.Capability.Hosting.Core/CapabilityRuntime.cs new file mode 100644 index 000000000..28e8720a6 --- /dev/null +++ b/Capability/GameFrameX.Capability.Hosting.Core/CapabilityRuntime.cs @@ -0,0 +1,108 @@ +// ========================================================================================== +// 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 refer to 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/ +// ========================================================================================== + +using GameFrameX.Capability.Hosting.Abstractions; + +namespace GameFrameX.Capability.Hosting.Core; + +/// +/// 能力组合产物(一次 的双面承载:业务宿主 + 管理面 + 报告; +/// 未启用的面为 null)。 +/// +/// 维护约束(启停序):启动 = 业务宿主先启(拓扑序 Activate + 调度)再启管理面(监听开放时桥已发布); +/// 停止 = 管理面先停再停业务宿主(停调度 → 逆序 Deactivate → 机制自动撤销全部桥)。 +/// admin-only 形态(EnableRuntime=false)下 为 null——模块已注册(管理面 action 可用) +/// 但宿主不激活、调度不启动。 +/// +/// +public sealed class CapabilityRuntime +{ + /// + /// 初始化 (仅机制组合器构造)。 + /// + /// 业务能力宿主(业务面未启用时为 null)。 + /// 管理面(未启用时为 null)。 + /// 能力报告(业务与管理面共用同一份)。 + internal CapabilityRuntime(CapabilityHost host, ICapabilityAdminApiPlane adminApi, CapabilityReport report) + { + Host = host; + AdminApi = adminApi; + Report = report; + } + + /// + /// 获取业务能力宿主(业务面未启用时为 null)。 + /// + public CapabilityHost Host { get; } + + /// + /// 获取管理面(未启用时为 null)。 + /// + public ICapabilityAdminApiPlane AdminApi { get; } + + /// + /// 获取启动能力报告。 + /// + public CapabilityReport Report { get; } + + /// + /// 启动(业务宿主先启:拓扑序 Activate + 调度;管理面后启:监听开放时桥已发布;可重复调用幂等)。 + /// + /// 取消令牌。 + /// 异步任务。 + public async Task StartAsync(CancellationToken cancellationToken = default) + { + if (Host != null) + { + await Host.StartAsync(cancellationToken).ConfigureAwait(false); + } + + if (AdminApi != null) + { + await AdminApi.StartAsync(cancellationToken).ConfigureAwait(false); + } + } + + /// + /// 停止(管理面先停;业务宿主后停:停调度 → 逆序 Deactivate → 撤销全部桥)。 + /// + /// 取消令牌。 + /// 异步任务。 + public async Task StopAsync(CancellationToken cancellationToken = default) + { + if (AdminApi != null) + { + await AdminApi.StopAsync().ConfigureAwait(false); + } + + if (Host != null) + { + await Host.StopAsync(cancellationToken).ConfigureAwait(false); + } + } +} diff --git a/Capability/GameFrameX.Capability.Hosting.Core/CapabilityScheduler.cs b/Capability/GameFrameX.Capability.Hosting.Core/CapabilityScheduler.cs index 41f9f2ccc..f28493a2d 100644 --- a/Capability/GameFrameX.Capability.Hosting.Core/CapabilityScheduler.cs +++ b/Capability/GameFrameX.Capability.Hosting.Core/CapabilityScheduler.cs @@ -24,19 +24,22 @@ // 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.Diagnostics; using GameFrameX.Capability.Hosting.Abstractions; +using GameFrameX.Foundation.Localization.Core; + namespace GameFrameX.Capability.Hosting.Core; /// -/// 能力调度壳(注册制后台驱动:逐滴答 job 按装配序顺序执行 + 间隔 job 独立周期 + 事件驱动 job 爆发口)。 +/// 能力调度壳(注册制后台驱动:逐滴答 job 按装配序执行 + 间隔 job 独立周期)。 /// -/// 维护约束:单循环顺序驱动(不做多任务并发——单进程默认实现下并发 job 对同一存储集合操作会放大竞态); -/// 逐滴答 job 单个异常吞掉并继续当轮其余 job(原 OnlineRuntimeScheduler 语义:单轮失败不终止循环); -/// 间隔/事件 job 各自独立吞异常。一次性生命周期:停止( / ) -/// 即释放停止令牌,不可重启。 +/// 维护约束(硬化红线):单循环顺序驱动(不做多任务并发——单进程默认实现下并发 job 对同一存储集合操作会放大竞态); +/// per-job 隔离——单个 job 异常记结构化日志(job 全名 + 异常)后继续当轮剩余 job,间隔 job 异常只影响自身本轮; +/// 每轮耗时 / job 执行数 / 失败数经观测出口上报;时钟可注入(,缺省系统时钟); +/// 一次性生命周期——停止( / )后再 抛 +/// 。 /// /// public sealed class CapabilityScheduler : IDisposable @@ -57,9 +60,14 @@ public sealed class CapabilityScheduler : IDisposable private readonly IReadOnlyList _intervalJobs; /// - /// 事件驱动 job 集合。 + /// 可注入时钟(缺省系统时钟)。 /// - private readonly IReadOnlyList _eventJobs; + private readonly ISchedulerClock _clock; + + /// + /// 观测出口(job 失败与每轮指标;缺省无观测)。 + /// + private readonly ICapabilitySchedulerObserver _observer; /// /// 间隔 job 到期记账器。 @@ -76,6 +84,11 @@ public sealed class CapabilityScheduler : IDisposable /// private Task _loopTask; + /// + /// 已停止标记(一次性生命周期)。 + /// + private bool _stopped; + /// /// 同步锁(Start/Stop 竞态保护)。 /// @@ -87,23 +100,31 @@ public sealed class CapabilityScheduler : IDisposable /// 驱动间隔毫秒(小于 100 按 100 钳制)。 /// 逐滴答 job 集合(装配序即执行序)。 /// 间隔 job 集合。 - /// 事件驱动 job 集合。 - public CapabilityScheduler(int intervalMilliseconds, IReadOnlyList tickJobs, IReadOnlyList intervalJobs = null, IReadOnlyList eventJobs = null) + /// 调度时钟(null = 系统时钟)。 + /// 观测出口(null = 无观测)。 + public CapabilityScheduler(int intervalMilliseconds, IReadOnlyList tickJobs, IReadOnlyList intervalJobs = null, ISchedulerClock clock = null, ICapabilitySchedulerObserver observer = null) { _intervalMilliseconds = Math.Max(100, intervalMilliseconds); _tickJobs = tickJobs ?? Array.Empty(); _intervalJobs = intervalJobs ?? Array.Empty(); - _eventJobs = eventJobs ?? Array.Empty(); - _intervalWatch = new IntervalWatch(_intervalJobs); + _clock = clock ?? SystemSchedulerClock.Instance; + _observer = observer; + _intervalWatch = new IntervalWatch(_intervalJobs, _clock); } /// - /// 启动调度循环(可重复调用:已启动时为空操作)。 + /// 启动调度循环(可重复调用:已启动时为空操作;停止后再启动抛 )。 /// public void Start() { lock (_sync) { + if (_stopped) + { + // Localization: Capability.Hosting.Scheduler.RestartAfterStop - 调度器停止后不可重新启动。 + throw new InvalidOperationException(LocalizationService.GetString(Localization.Keys.Capability.Hosting.SchedulerRestartAfterStop)); + } + if (_loopTask != null) { return; @@ -114,8 +135,7 @@ public void Start() } /// - /// 停止调度循环(等待当轮完成后退出并释放停止令牌;可重复调用)。 - /// 停止后令牌已释放,不可重启( 在停止后为空操作)。 + /// 停止调度循环(等待当轮完成后退出并释放停止令牌;可重复调用;停止后不可重启)。 /// /// 异步任务。 public async Task StopAsync() @@ -129,6 +149,7 @@ public async Task StopAsync() _cancellation.Cancel(); } + _stopped = true; loopTask = _loopTask; } @@ -149,12 +170,13 @@ public async Task StopAsync() } /// - /// 释放资源(兜底释放停止令牌;常规停机走 以等待当轮完成)。 + /// 释放资源(兜底停止;常规停机走 以等待当轮完成)。 /// public void Dispose() { lock (_sync) { + _stopped = true; if (!_cancellation.IsCancellationRequested) { _cancellation.Cancel(); @@ -164,30 +186,6 @@ public void Dispose() } } - /// - /// 分发一条能力事件(同步逐个驱动事件 job;单 job 异常吞掉不影响其它 job 与调用方)。 - /// - /// 事件通知。 - public void RaiseEvent(CapabilityEventNotification notification) - { - if (notification == null) - { - throw new ArgumentNullException(nameof(notification)); - } - - for (var i = 0; i < _eventJobs.Count; i++) - { - try - { - _eventJobs[i].Run(notification); - } - catch (Exception) - { - // 事件 job 异常不阻断其它 job,也不向事件来源传播。 - } - } - } - /// /// 调度循环主体(逐滴答 job + 到期间隔 job 顺序驱动)。 /// @@ -211,33 +209,56 @@ private async Task RunAsync(CancellationToken cancellationToken) } catch (Exception) { - // 单轮失败不终止循环(见类注释维护约束)。 + // 单轮失败不终止循环(调度壳自身异常兜底;job 异常已在 per-job 隔离内消化)。 } } } } /// - /// 执行单轮逐滴答调度(按装配序顺序驱动;单个 job 异常结束当轮滴答并交还循环吞掉)。 + /// 执行单轮逐滴答调度(按装配序顺序驱动;单个 job 异常上报观测出口后继续当轮剩余 job; + /// 每轮耗时 / 执行数 / 失败数上报观测出口)。 /// /// 停止令牌。 /// 异步任务。 public async Task TickAsync(CancellationToken cancellationToken = default) { + var executedJobCount = 0; + var failedJobCount = 0; + var stopwatch = Stopwatch.StartNew(); for (var i = 0; i < _tickJobs.Count; i++) { - await _tickJobs[i].Run(cancellationToken).ConfigureAwait(false); + var job = _tickJobs[i]; + try + { + await job.Run(cancellationToken).ConfigureAwait(false); + executedJobCount++; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception exception) + { + // per-job 隔离:坏 job 不拖垮当轮其余 job,失败上报观测出口(结构化日志 / 指标)。 + failedJobCount++; + executedJobCount++; + ReportJobFailure(CapabilitySchedulerJobKind.Tick, job.Name, exception); + } } + + stopwatch.Stop(); + ReportTickRoundCompleted(stopwatch.Elapsed, executedJobCount, failedJobCount); } /// - /// 执行到期的间隔 job(各自独立吞异常;到期判定含本轮补偿)。 + /// 执行到期的间隔 job(各自独立隔离;到期判定含本轮补偿)。 /// /// 停止令牌。 /// 异步任务。 private async Task TickDueIntervalJobsAsync(CancellationToken cancellationToken) { - var now = DateTimeOffset.UtcNow; + var now = _clock.UtcNow; for (var i = 0; i < _intervalJobs.Count; i++) { var job = _intervalJobs[i]; @@ -254,15 +275,78 @@ private async Task TickDueIntervalJobsAsync(CancellationToken cancellationToken) { throw; } - catch (Exception) + catch (Exception exception) { - // 间隔 job 异常只影响自身本轮,不影响其它 job 与循环。 + // 间隔 job 异常只影响自身本轮,失败上报观测出口。 + ReportJobFailure(CapabilitySchedulerJobKind.Interval, job.Name, exception); } } } /// - /// 间隔 job 到期记账(首次按周期对齐,其后按上次执行时刻累加)。 + /// 上报 job 失败(观测出口异常吞掉——观测不得阻断调度循环)。 + /// + /// job 种类。 + /// job 全名。 + /// job 异常。 + private void ReportJobFailure(CapabilitySchedulerJobKind jobKind, string jobName, Exception exception) + { + try + { + _observer?.OnJobFailed(jobKind, jobName, exception); + } + catch (Exception) + { + // 观测出口自身异常不影响调度。 + } + } + + /// + /// 上报一轮 tick 指标(观测出口异常吞掉)。 + /// + /// 本轮耗时。 + /// 执行 job 数。 + /// 失败 job 数。 + private void ReportTickRoundCompleted(TimeSpan elapsed, int executedJobCount, int failedJobCount) + { + try + { + _observer?.OnTickRoundCompleted(elapsed, executedJobCount, failedJobCount); + } + catch (Exception) + { + // 观测出口自身异常不影响调度。 + } + } + + /// + /// 系统时钟(缺省实现)。 + /// + private sealed class SystemSchedulerClock : ISchedulerClock + { + /// + /// 单例。 + /// + public static readonly SystemSchedulerClock Instance = new SystemSchedulerClock(); + + /// + /// 初始化 。 + /// + private SystemSchedulerClock() + { + } + + /// + /// 获取当前 UTC 时刻。 + /// + public DateTimeOffset UtcNow + { + get { return DateTimeOffset.UtcNow; } + } + } + + /// + /// 间隔 job 到期记账(首次按周期对齐,其后按上次执行时刻累加;时钟可注入)。 /// private sealed class IntervalWatch { @@ -275,9 +359,10 @@ private sealed class IntervalWatch /// 初始化记账器(把首租期设为「当前时刻 + 周期」)。 /// /// 间隔 job 集合。 - public IntervalWatch(IReadOnlyList jobs) + /// 时钟。 + public IntervalWatch(IReadOnlyList jobs, ISchedulerClock clock) { - var now = DateTimeOffset.UtcNow; + var now = clock.UtcNow; for (var i = 0; i < jobs.Count; i++) { _nextDueByName[jobs[i].Name] = now + ToInterval(jobs[i].Interval); diff --git a/Capability/GameFrameX.Capability.Hosting.Core/CapabilitySchedulerJobKind.cs b/Capability/GameFrameX.Capability.Hosting.Core/CapabilitySchedulerJobKind.cs new file mode 100644 index 000000000..05c746710 --- /dev/null +++ b/Capability/GameFrameX.Capability.Hosting.Core/CapabilitySchedulerJobKind.cs @@ -0,0 +1,46 @@ +// ========================================================================================== +// 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 refer to 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.Capability.Hosting.Core; + +/// +/// 调度 job 种类(观测出口用)。 +/// +public enum CapabilitySchedulerJobKind +{ + /// + /// 逐滴答 job(每轮按装配序执行)。 + /// + Tick = 0, + + /// + /// 间隔 job(独立周期到点执行)。 + /// + Interval = 1, +} diff --git a/Capability/GameFrameX.Capability.Hosting.Core/CapabilitySchedulerLogObserver.cs b/Capability/GameFrameX.Capability.Hosting.Core/CapabilitySchedulerLogObserver.cs new file mode 100644 index 000000000..643e6661a --- /dev/null +++ b/Capability/GameFrameX.Capability.Hosting.Core/CapabilitySchedulerLogObserver.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 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 refer to 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/ +// ========================================================================================== + +using GameFrameX.Capability.Hosting.Abstractions; +using GameFrameX.Foundation.Localization.Core; + +namespace GameFrameX.Capability.Hosting.Core; + +/// +/// 调度日志观察器(缺省观测出口:job 失败按结构化文案写装配日志出口;宿主注入真实指标管道时被替换)。 +/// +/// 维护约束:每轮指标(耗时 / 执行数 / 失败数)不落日志(归指标管道),本实现只承载失败行; +/// 观测回调自身异常吞掉(不得阻断调度循环)。 +/// +/// +internal sealed class CapabilitySchedulerLogObserver : ICapabilitySchedulerObserver +{ + /// + /// 装配日志出口。 + /// + private readonly ICapabilityAssemblyLog _assemblyLog; + + /// + /// 初始化 。 + /// + /// 装配日志出口。 + public CapabilitySchedulerLogObserver(ICapabilityAssemblyLog assemblyLog) + { + _assemblyLog = assemblyLog; + } + + /// + /// 上报一次 job 执行失败(按种类选结构化文案记 Error)。 + /// + /// job 种类。 + /// job 全名。 + /// job 抛出的异常。 + public void OnJobFailed(CapabilitySchedulerJobKind jobKind, string jobName, Exception exception) + { + var message = exception == null ? string.Empty : exception.Message; + if (jobKind == CapabilitySchedulerJobKind.Interval) + { + // Localization: Capability.Hosting.Scheduler.IntervalJobFailed - 能力调度间隔 job 失败。 + _assemblyLog.Error(string.Format(LocalizationService.GetString(Localization.Keys.Capability.Hosting.SchedulerIntervalJobFailed), jobName, message)); + return; + } + + // Localization: Capability.Hosting.Scheduler.TickJobFailed - 能力调度逐滴答 job 失败。 + _assemblyLog.Error(string.Format(LocalizationService.GetString(Localization.Keys.Capability.Hosting.SchedulerTickJobFailed), jobName, message)); + } + + /// + /// 上报一轮 tick 完成(日志观察器不落每轮指标行——归真实指标管道)。 + /// + /// 本轮耗时。 + /// 执行 job 数。 + /// 失败 job 数。 + public void OnTickRoundCompleted(TimeSpan elapsed, int executedJobCount, int failedJobCount) + { + } +} diff --git a/Capability/GameFrameX.Capability.Hosting.Core/ConsoleCapabilityAssemblyLog.cs b/Capability/GameFrameX.Capability.Hosting.Core/ConsoleCapabilityAssemblyLog.cs new file mode 100644 index 000000000..4b939a35f --- /dev/null +++ b/Capability/GameFrameX.Capability.Hosting.Core/ConsoleCapabilityAssemblyLog.cs @@ -0,0 +1,66 @@ +// ========================================================================================== +// 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 refer to 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.Capability.Hosting.Abstractions; + +namespace GameFrameX.Capability.Hosting.Core; + +/// +/// 控制台装配日志出口( 的开箱实现;模板与测试用)。 +/// +/// 维护约束:生产装配(Launcher)应桥接统一日志框架,不要使用本实现。 +/// +/// +public sealed class ConsoleCapabilityAssemblyLog : ICapabilityAssemblyLog +{ + /// + /// 初始化 。 + /// + public ConsoleCapabilityAssemblyLog() + { + } + + /// + /// 记录一条 Info 级装配日志。 + /// + /// 日志内容。 + public void Info(string message) + { + Console.WriteLine("[Info] " + message); + } + + /// + /// 记录一条 Error 级装配日志。 + /// + /// 日志内容。 + public void Error(string message) + { + Console.WriteLine("[Error] " + message); + } +} diff --git a/Capability/GameFrameX.Capability.Hosting.Core/ICapabilityAdminApiPlane.cs b/Capability/GameFrameX.Capability.Hosting.Core/ICapabilityAdminApiPlane.cs new file mode 100644 index 000000000..2b15135d7 --- /dev/null +++ b/Capability/GameFrameX.Capability.Hosting.Core/ICapabilityAdminApiPlane.cs @@ -0,0 +1,53 @@ +// ========================================================================================== +// 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 refer to 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/ +// ========================================================================================== + +namespace GameFrameX.Capability.Hosting.Core; + +/// +/// 能力管理面接口( 的承载面;由 Hosting.AdminApi 包实现, +/// Core 不引用 AspNetCore——管理面实现经 +/// 挂进组合)。 +/// +/// 维护约束:启停序由 编排(Host 先启后停、管理面后启先停)。 +/// +/// +public interface ICapabilityAdminApiPlane +{ + /// + /// 启动管理面(返回即已就绪监听)。 + /// + /// 取消令牌。 + /// 异步任务。 + Task StartAsync(CancellationToken cancellationToken); + + /// + /// 停止管理面(优雅关闭:在途请求完成后退出)。 + /// + /// 异步任务。 + Task StopAsync(); +} diff --git a/Capability/GameFrameX.Capability.Hosting.Core/ICapabilitySchedulerObserver.cs b/Capability/GameFrameX.Capability.Hosting.Core/ICapabilitySchedulerObserver.cs new file mode 100644 index 000000000..01c0a24e4 --- /dev/null +++ b/Capability/GameFrameX.Capability.Hosting.Core/ICapabilitySchedulerObserver.cs @@ -0,0 +1,56 @@ +// ========================================================================================== +// 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 refer to 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.Capability.Hosting.Core; + +/// +/// 调度观测出口(job 失败与每轮 tick 指标的上报面;宿主经组合选项注入,缺省为日志观察器)。 +/// +/// 维护约束:观测回调不得抛出异常阻断调度(实现方自行兜底);指标口径对齐既有三指标 +///(每轮耗时 / job 执行数 / 失败数)。 +/// +/// +public interface ICapabilitySchedulerObserver +{ + /// + /// 上报一次 job 执行失败(per-job 隔离后继续当轮剩余 job)。 + /// + /// job 种类(逐滴答 / 间隔)。 + /// job 全名(能力Id/jobId)。 + /// job 抛出的异常。 + void OnJobFailed(CapabilitySchedulerJobKind jobKind, string jobName, Exception exception); + + /// + /// 上报一轮 tick 完成(每轮耗时 / job 执行数 / 失败数)。 + /// + /// 本轮耗时。 + /// 执行 job 数。 + /// 失败 job 数。 + void OnTickRoundCompleted(TimeSpan elapsed, int executedJobCount, int failedJobCount); +} diff --git a/Capability/GameFrameX.Capability.Hosting.Core/ISchedulerClock.cs b/Capability/GameFrameX.Capability.Hosting.Core/ISchedulerClock.cs new file mode 100644 index 000000000..e68ad84b4 --- /dev/null +++ b/Capability/GameFrameX.Capability.Hosting.Core/ISchedulerClock.cs @@ -0,0 +1,44 @@ +// ========================================================================================== +// 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 refer to 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.Capability.Hosting.Core; + +/// +/// 调度时钟(间隔 job 到期判定的可注入时钟;缺省系统时钟)。 +/// +/// 维护约束:测试注入虚拟时钟驱动 interval 到期,不再真实等待。 +/// +/// +public interface ISchedulerClock +{ + /// + /// 获取当前 UTC 时刻。 + /// + DateTimeOffset UtcNow { get; } +} diff --git a/Capability/GameFrameX.Capability.Online.Operations/Audit/IOnlineAuditStore.cs b/Capability/GameFrameX.Capability.Online.Abstractions/Audit/IOnlineAuditStore.cs similarity index 100% rename from Capability/GameFrameX.Capability.Online.Operations/Audit/IOnlineAuditStore.cs rename to Capability/GameFrameX.Capability.Online.Abstractions/Audit/IOnlineAuditStore.cs diff --git a/Capability/GameFrameX.Capability.Online.Operations/Audit/InMemoryOnlineAuditStore.cs b/Capability/GameFrameX.Capability.Online.Abstractions/Audit/InMemoryOnlineAuditStore.cs similarity index 100% rename from Capability/GameFrameX.Capability.Online.Operations/Audit/InMemoryOnlineAuditStore.cs rename to Capability/GameFrameX.Capability.Online.Abstractions/Audit/InMemoryOnlineAuditStore.cs diff --git a/Capability/GameFrameX.Capability.Online.Operations/Audit/OnlineAuditEntry.cs b/Capability/GameFrameX.Capability.Online.Abstractions/Audit/OnlineAuditEntry.cs similarity index 100% rename from Capability/GameFrameX.Capability.Online.Operations/Audit/OnlineAuditEntry.cs rename to Capability/GameFrameX.Capability.Online.Abstractions/Audit/OnlineAuditEntry.cs diff --git a/Capability/GameFrameX.Capability.Online.Operations/Audit/OnlineAuditIngestOutcome.cs b/Capability/GameFrameX.Capability.Online.Abstractions/Audit/OnlineAuditIngestOutcome.cs similarity index 100% rename from Capability/GameFrameX.Capability.Online.Operations/Audit/OnlineAuditIngestOutcome.cs rename to Capability/GameFrameX.Capability.Online.Abstractions/Audit/OnlineAuditIngestOutcome.cs diff --git a/Capability/GameFrameX.Capability.Online.Operations/Audit/OnlineAuditService.cs b/Capability/GameFrameX.Capability.Online.Abstractions/Audit/OnlineAuditService.cs similarity index 100% rename from Capability/GameFrameX.Capability.Online.Operations/Audit/OnlineAuditService.cs rename to Capability/GameFrameX.Capability.Online.Abstractions/Audit/OnlineAuditService.cs diff --git a/Capability/GameFrameX.Capability.Online.Social/IOnlineChatModerationHook.cs b/Capability/GameFrameX.Capability.Online.Abstractions/IOnlineChatModerationHook.cs similarity index 100% rename from Capability/GameFrameX.Capability.Online.Social/IOnlineChatModerationHook.cs rename to Capability/GameFrameX.Capability.Online.Abstractions/IOnlineChatModerationHook.cs diff --git a/Capability/GameFrameX.Capability.Online.Social/OnlineChatMessage.cs b/Capability/GameFrameX.Capability.Online.Abstractions/OnlineChatMessage.cs similarity index 100% rename from Capability/GameFrameX.Capability.Online.Social/OnlineChatMessage.cs rename to Capability/GameFrameX.Capability.Online.Abstractions/OnlineChatMessage.cs diff --git a/Capability/GameFrameX.Capability.Online.Social/OnlineChatMessageState.cs b/Capability/GameFrameX.Capability.Online.Abstractions/OnlineChatMessageState.cs similarity index 100% rename from Capability/GameFrameX.Capability.Online.Social/OnlineChatMessageState.cs rename to Capability/GameFrameX.Capability.Online.Abstractions/OnlineChatMessageState.cs diff --git a/Capability/GameFrameX.Capability.Online.Social/OnlineChatModerationVerdict.cs b/Capability/GameFrameX.Capability.Online.Abstractions/OnlineChatModerationVerdict.cs similarity index 100% rename from Capability/GameFrameX.Capability.Online.Social/OnlineChatModerationVerdict.cs rename to Capability/GameFrameX.Capability.Online.Abstractions/OnlineChatModerationVerdict.cs diff --git a/GameFrameX.Localization/Keys.Capability.cs b/GameFrameX.Localization/Keys.Capability.cs index d59d6e2d2..437d15ed6 100644 --- a/GameFrameX.Localization/Keys.Capability.cs +++ b/GameFrameX.Localization/Keys.Capability.cs @@ -142,6 +142,56 @@ public static class AdminApi /// 用途: 管理面组合工厂校验 AdminApiOptions 未通过 /// public const string AdminApiOptionsInvalid = "Capability.Hosting.AdminApi.OptionsInvalid"; + + /// + /// Admin action 重复注册:{0}(能力或来源:{1}) + /// + /// + /// 键名: Capability.Hosting.AdminApi.ActionNameDuplicate + /// 用途: 同名 action 第二次注册(fail-fast,不再静默覆盖) + /// 参数: {0} - action 名;{1} - 能力或来源 + /// + public const string ActionNameDuplicate = "Capability.Hosting.AdminApi.ActionNameDuplicate"; + + /// + /// 能力 {1} 注册的 action 未在其 manifest 声明:{0} + /// + /// + /// 键名: Capability.Hosting.AdminApi.ActionNotDeclared + /// 用途: 按能力分组的双向对账:某能力实注册 ⊄ 该能力声明即失败 + /// 参数: {0} - action 名;{1} - 注册该 action 的能力 Id + /// + public const string ActionNotDeclared = "Capability.Hosting.AdminApi.ActionNotDeclared"; + + /// + /// 能力 {1} 声明的 action {0} 被能力 {2} 注册(跨能力冒名) + /// + /// + /// 键名: Capability.Hosting.AdminApi.ActionOwnerMismatch + /// 用途: 按能力分组的双向对账:声明者与注册者不是同一能力即失败(防止调用被路由到错误的域处理器) + /// 参数: {0} - action 名;{1} - 声明该 action 的能力 Id;{2} - 实际注册该 action 的能力 Id + /// + public const string ActionOwnerMismatch = "Capability.Hosting.AdminApi.ActionOwnerMismatch"; + + /// + /// 管理面缺少必需适配器服务:{0}(族基建须注册) + /// + /// + /// 键名: Capability.Hosting.AdminApi.AdapterMissing + /// 用途: 管理面组装所需适配器服务缺席(fail-fast) + /// 参数: {0} - 适配器服务类型 + /// + public const string AdapterMissing = "Capability.Hosting.AdminApi.AdapterMissing"; + + /// + /// Admin action 对账失败({0} 项): + /// + /// + /// 键名: Capability.Hosting.AdminApi.ReconcileFailed + /// 用途: 双向对账失败汇总标题(Error),后接逐条问题 + /// 参数: {0} - 问题条目数 + /// + public const string ReconcileFailed = "Capability.Hosting.AdminApi.ReconcileFailed"; } /// @@ -154,10 +204,547 @@ public static class Composition /// /// /// 键名: Capability.Hosting.Composition.NoPlaneEnabled - /// 用途: AddOnlineCapabilities 组合回调后 EnableRuntime 与 EnableAdminApi 均为 false + /// 用途: CapabilityComposition.Compose 时 EnableRuntime 为 false 且未设置管理面工厂 /// public const string NoPlaneEnabled = "Capability.Hosting.Composition.NoPlaneEnabled"; } + + /// + /// 装配日志相关消息(内容从 CapabilityReport 渲染,级别只用 Info 与 Error) + /// + public static class Assembly + { + /// + /// 能力装配开始:发现 {0} 个,启用 {1} 个,排除 {2} 个 + /// + /// + /// 键名: Capability.Hosting.Assembly.Started + /// 用途: 装配日志首行(Info) + /// 参数: {0} - 发现的能力数;{1} - 过滤后启用的能力数;{2} - 被排除的能力数 + /// + public const string AssemblyStarted = "Capability.Hosting.Assembly.Started"; + + /// + /// 已排除 {0}:{1} + /// + /// + /// 键名: Capability.Hosting.Assembly.Excluded + /// 用途: 逐条披露被过滤器排除的能力(Info) + /// 参数: {0} - 能力 Id;{1} - 排除原因 + /// + public const string AssemblyExcluded = "Capability.Hosting.Assembly.Excluded"; + + /// + /// #{0} {1} {2} {3} + /// + /// + /// 键名: Capability.Hosting.Assembly.ModuleNoDependency + /// 用途: 无依赖能力的逐能力汇总行(Info) + /// 参数: {0} - 装配序号;{1} - 能力 Id;{2} - 版本;{3} - 程序集名 + /// + public const string AssemblyModuleNoDependency = "Capability.Hosting.Assembly.ModuleNoDependency"; + + /// + /// #{0} {1} {2} {3} + /// + /// + /// 键名: Capability.Hosting.Assembly.ModuleWithDependency + /// 用途: 有依赖能力的逐能力汇总行(Info),依赖描述由 依赖/可选状态 键拼接 + /// 参数: {0} - 装配序号;{1} - 能力 Id;{2} - 版本;{3} - 依赖描述 + /// + public const string AssemblyModuleWithDependency = "Capability.Hosting.Assembly.ModuleWithDependency"; + + /// + /// 必选 {0} + /// + /// + /// 键名: Capability.Hosting.Assembly.DependencyRequired + /// 用途: 汇总行内单条必选依赖片段 + /// 参数: {0} - 被依赖能力 Id + /// + public const string AssemblyDependencyRequired = "Capability.Hosting.Assembly.DependencyRequired"; + + /// + /// 可选 {0} + /// + /// + /// 键名: Capability.Hosting.Assembly.DependencyOptionalListed + /// 用途: 汇总行内单条可选依赖片段(后接装配状态) + /// 参数: {0} - 被依赖能力 Id(含装配状态后缀) + /// + public const string AssemblyDependencyOptionalListed = "Capability.Hosting.Assembly.DependencyOptionalListed"; + + /// + /// 可选 {0} 个已装配,{1} 个未装配 + /// + /// + /// 键名: Capability.Hosting.Assembly.DependencyOptionalCounted + /// 用途: 可选依赖较多时汇总行的计数式描述 + /// 参数: {0} - 已装配的可选依赖数;{1} - 未装配的可选依赖数 + /// + public const string AssemblyDependencyOptionalCounted = "Capability.Hosting.Assembly.DependencyOptionalCounted"; + + /// + /// {0}(已装配) + /// + /// + /// 键名: Capability.Hosting.Assembly.OptionalStateAssembled + /// 用途: 可选依赖已装配状态后缀 + /// 参数: {0} - 被依赖能力 Id + /// + public const string AssemblyOptionalStateAssembled = "Capability.Hosting.Assembly.OptionalStateAssembled"; + + /// + /// {0}(未装配) + /// + /// + /// 键名: Capability.Hosting.Assembly.OptionalStateNotAssembled + /// 用途: 可选依赖未装配状态后缀 + /// 参数: {0} - 被依赖能力 Id + /// + public const string AssemblyOptionalStateNotAssembled = "Capability.Hosting.Assembly.OptionalStateNotAssembled"; + + /// + /// {0} 可选依赖 {1} 未装配({2}):缺失功能「{3}」,不影响 {0} 其余功能运行 + /// + /// + /// 键名: Capability.Hosting.Assembly.OptionalDependencyMissing + /// 用途: 可选依赖缺失只在装配期打一次 Info;缺失功能文案由能力包提供,原因与"不影响运行"由机制统一拼接 + /// 参数: {0} - 依赖方能力 Id;{1} - 被依赖能力 Id;{2} - 缺失原因;{3} - 缺失功能描述 + /// + public const string AssemblyOptionalDependencyMissing = "Capability.Hosting.Assembly.OptionalDependencyMissing"; + + /// + /// 能力装配完成:{0} 个能力,{1} 项功能因可选依赖缺失未装配,均不影响能力运行 + /// + /// + /// 键名: Capability.Hosting.Assembly.Completed + /// 用途: 装配日志末行(Info) + /// 参数: {0} - 装配能力数;{1} - 因可选依赖缺失未装配的功能数 + /// + public const string AssemblyCompleted = "Capability.Hosting.Assembly.Completed"; + + /// + /// 能力装配失败({0} 项): + /// + /// + /// 键名: Capability.Hosting.Assembly.Failed + /// 用途: 汇总失败标题行(Error),后接逐条问题条目 + /// 参数: {0} - 问题条目数 + /// + public const string AssemblyFailed = "Capability.Hosting.Assembly.Failed"; + + /// + /// {0} 必选依赖 {1} 未装配({2}) + /// + /// + /// 键名: Capability.Hosting.Assembly.RequiredDependencyMissing + /// 用途: 必选依赖缺失问题条目(Error) + /// 参数: {0} - 依赖方能力 Id;{1} - 被依赖能力 Id;{2} - 缺失原因 + /// + public const string AssemblyRequiredDependencyMissing = "Capability.Hosting.Assembly.RequiredDependencyMissing"; + + /// + /// 依赖成环:{0} + /// + /// + /// 键名: Capability.Hosting.Assembly.DependencyCycle + /// 用途: 环路问题条目(Error),打印完整环路路径 + /// 参数: {0} - 环路路径(如 a → b → c → a) + /// + public const string AssemblyDependencyCycle = "Capability.Hosting.Assembly.DependencyCycle"; + + /// + /// 配置排除表 + /// + /// + /// 键名: Capability.Hosting.Assembly.ReasonExcludedByDisableList + /// 用途: 缺失/排除原因片段:排除表命中 + /// + public const string AssemblyReasonExcludedByDisableList = "Capability.Hosting.Assembly.ReasonExcludedByDisableList"; + + /// + /// 白名单未收录 + /// + /// + /// 键名: Capability.Hosting.Assembly.ReasonNotInEnableList + /// 用途: 缺失/排除原因片段:白名单未收录 + /// + public const string AssemblyReasonNotInEnableList = "Capability.Hosting.Assembly.ReasonNotInEnableList"; + + /// + /// 部署目录未发现 + /// + /// + /// 键名: Capability.Hosting.Assembly.ReasonNotDiscovered + /// 用途: 缺失/排除原因片段:部署目录未发现该能力包 + /// + public const string AssemblyReasonNotDiscovered = "Capability.Hosting.Assembly.ReasonNotDiscovered"; + + /// + /// 钩子 {0} 已挂载但没有任何能力声明(对应能力未装配或未声明该钩子) + /// + /// + /// 键名: Capability.Hosting.Assembly.HookNotDeclared + /// 用途: Hotfix 挂载钩子而对应能力被过滤时的 Info 披露 + /// 参数: {0} - 钩子契约类型名 + /// + public const string AssemblyHookNotDeclared = "Capability.Hosting.Assembly.HookNotDeclared"; + + /// + /// Settings 配置段出现未发现的能力:{0} + /// + /// + /// 键名: Capability.Hosting.Assembly.SettingsUnknownCapability + /// 用途: Options 配置绑定发现未知能力 Id(fail-fast) + /// 参数: {0} - 未发现的能力 Id + /// + public const string AssemblySettingsUnknownCapability = "Capability.Hosting.Assembly.SettingsUnknownCapability"; + + /// + /// Options 字段不存在或不可配置:{0} + /// + /// + /// 键名: Capability.Hosting.Assembly.SettingsUnknownField + /// 用途: Options 配置绑定发现未知字段或只读/常量字段(fail-fast) + /// 参数: {0} - 完整字段路径(如 Online.Settings.online-social.Friend.FriendLimt) + /// + public const string AssemblySettingsUnknownField = "Capability.Hosting.Assembly.SettingsUnknownField"; + + /// + /// Options 字段类型不符:{0}(期望 {1},实际 {2}) + /// + /// + /// 键名: Capability.Hosting.Assembly.SettingsFieldTypeMismatch + /// 用途: Options 配置绑定类型不匹配(fail-fast) + /// 参数: {0} - 完整字段路径;{1} - 期望类型;{2} - 实际类型 + /// + public const string AssemblySettingsFieldTypeMismatch = "Capability.Hosting.Assembly.SettingsFieldTypeMismatch"; + + /// + /// 能力 {0} Options 校验失败:{1} + /// + /// + /// 键名: Capability.Hosting.Assembly.OptionsValidationFailed + /// 用途: 合并后 Options 最终值未通过 Validate(fail-fast) + /// 参数: {0} - 能力 Id;{1} - 校验问题描述 + /// + public const string AssemblyOptionsValidationFailed = "Capability.Hosting.Assembly.OptionsValidationFailed"; + } + + /// + /// 能力服务重复注册:{0} + /// + /// + /// 键名: Capability.Hosting.ServiceRegistrationDuplicate + /// 用途: 同类型键+命名键重复注册默认实现(fail-fast) + /// 参数: {0} - 类型键+命名键描述 + /// + public const string ServiceRegistrationDuplicate = "Capability.Hosting.ServiceRegistrationDuplicate"; + + /// + /// 能力服务覆盖重复:{0} + /// + /// + /// 键名: Capability.Hosting.ServiceOverrideDuplicate + /// 用途: 同键第二个覆盖声明(fail-fast;一个默认 + 至多一个覆盖) + /// 参数: {0} - 类型键+命名键描述 + /// + public const string ServiceOverrideDuplicate = "Capability.Hosting.ServiceOverrideDuplicate"; + + /// + /// 能力服务未注册:{0} + /// + /// + /// 键名: Capability.Hosting.ServiceNotRegistered + /// 用途: GetService 解析不到已声明的服务(fail-fast;可选缺席走 GetOptionalService 返回 null) + /// 参数: {0} - 服务类型描述 + /// + public const string ServiceNotRegistered = "Capability.Hosting.ServiceNotRegistered"; + + /// + /// 能力 {0} 按可选语义解析服务 {1} 未命中,但其声明的可选依赖均已装配(或未声明任何可选依赖):能力在场却未注册该服务 + /// + /// + /// 键名: Capability.Hosting.OptionalServiceNotRegistered + /// 用途: 模块作用域 GetOptionalService 的 fail-fast(设计 §4.9.1:对方装了却没注册该服务),防止功能被静默丢弃 + /// 参数: {0} - 解析方能力 Id;{1} - 服务类型描述 + /// + public const string OptionalServiceNotRegistered = "Capability.Hosting.OptionalServiceNotRegistered"; + + /// + /// 能力服务懒工厂循环解析:{0} + /// + /// + /// 键名: Capability.Hosting.ServiceResolutionCycle + /// 用途: 懒工厂解析链中同键重入(A→B→A),fail-fast 代替无限递归栈溢出 + /// 参数: {0} - 解析链(键描述以 " -> " 连接,末项为重入键) + /// + public const string ServiceResolutionCycle = "Capability.Hosting.ServiceResolutionCycle"; + + /// + /// 能力 {0} 解析了未声明来源的服务 {1}(来源:{2});Register/懒工厂期只允许解析 自己/族基建/宿主覆盖/直接声明的必选与在场可选依赖 + /// + /// + /// 键名: Capability.Hosting.ServiceResolutionForbidden + /// 用途: Register 期解析来源检查(fail-fast),拦截靠装配顺序碰巧可用的未声明依赖 + /// 参数: {0} - 解析方能力 Id;{1} - 服务类型;{2} - 实际来源能力 Id + /// + public const string ServiceResolutionForbidden = "Capability.Hosting.ServiceResolutionForbidden"; + + /// + /// GetServices 只能在 Activate 期调用:{0} / {1} + /// + /// + /// 键名: Capability.Hosting.ServicesEnumerationForbidden + /// 用途: Register 期调用 GetServices 直接抛出(fail-fast),枚举结果不得随装配顺序变化 + /// 参数: {0} - 能力 Id;{1} - 服务类型 + /// + public const string ServicesEnumerationForbidden = "Capability.Hosting.ServicesEnumerationForbidden"; + + /// + /// 能力 {0} 声明的宿主服务未提供(需宿主 Override):{1} + /// + /// + /// 键名: Capability.Hosting.HostServiceMissing + /// 用途: RequireHostService 声明的扩展点宿主未提供(fail-fast) + /// 参数: {0} - 声明方能力 Id;{1} - 宿主服务类型 + /// + public const string HostServiceMissing = "Capability.Hosting.HostServiceMissing"; + + /// + /// 能力桥契约重复发布:{0}({1} → {2}) + /// + /// + /// 键名: Capability.Hosting.BridgeDuplicatePublish + /// 用途: 同一契约第二个发布者(fail-fast;一个契约只有一个发布者) + /// 参数: {0} - 契约类型;{1} - 原发布者能力 Id;{2} - 新发布者能力 Id + /// + public const string BridgeDuplicatePublish = "Capability.Hosting.BridgeDuplicatePublish"; + + /// + /// 能力桥契约已撤销或被替换:{0}(消费方按 8002 处理) + /// + /// + /// 键名: Capability.Hosting.BridgeUnavailable + /// 用途: CapabilityBridgeUnavailableException 文案;消费方族层映射为 8002 + /// 参数: {0} - 契约类型 + /// + public const string BridgeUnavailable = "Capability.Hosting.BridgeUnavailable"; + + /// + /// Activate 开始前拒绝发布事件:{0} + /// + /// + /// 键名: Capability.Hosting.EventPublishRejected + /// 用途: Register 期发布事件直接拒绝(fail-fast),避免后注册的订阅者漏收 + /// 参数: {0} - 事件 topic + /// + public const string EventPublishRejected = "Capability.Hosting.EventPublishRejected"; + + /// + /// 能力事件订阅处理失败:{0}({1}) + /// + /// + /// 键名: Capability.Hosting.EventBus.SubscriberFailed + /// 用途: 单个订阅体异常记日志后继续分发(不中断总线) + /// 参数: {0} - 事件 topic;{1} - 异常描述 + /// + public const string EventBusSubscriberFailed = "Capability.Hosting.EventBus.SubscriberFailed"; + + /// + /// 调度器停止后不可重新启动。 + /// + /// + /// 键名: Capability.Hosting.Scheduler.RestartAfterStop + /// 用途: Stop 之后再 Start 抛 InvalidOperationException(一次性生命周期显式化) + /// + public const string SchedulerRestartAfterStop = "Capability.Hosting.Scheduler.RestartAfterStop"; + + /// + /// 能力调度逐滴答 job 失败:{0}({1}) + /// + /// + /// 键名: Capability.Hosting.Scheduler.TickJobFailed + /// 用途: per-job 隔离:单 job 异常记结构化日志后继续当轮剩余 job + /// 参数: {0} - job 全名(能力Id/jobId);{1} - 异常描述 + /// + public const string SchedulerTickJobFailed = "Capability.Hosting.Scheduler.TickJobFailed"; + + /// + /// 能力调度间隔 job 失败:{0}({1}) + /// + /// + /// 键名: Capability.Hosting.Scheduler.IntervalJobFailed + /// 用途: per-job 隔离:interval job 异常记结构化日志后继续 + /// 参数: {0} - job 全名(能力Id/jobId);{1} - 异常描述 + /// + public const string SchedulerIntervalJobFailed = "Capability.Hosting.Scheduler.IntervalJobFailed"; + + /// + /// 能力 {0} 激活失败,已逆序回滚 {1} 个已激活能力 + /// + /// + /// 键名: Capability.Hosting.ActivationFailed + /// 用途: ActivateAsync 中途失败时对已激活模块逆序 Deactivate 后抛出 + /// 参数: {0} - 失败能力 Id;{1} - 已回滚的已激活能力数 + /// + public const string ActivationFailed = "Capability.Hosting.ActivationFailed"; + + /// + /// 能力调度器启动失败,已逆序回滚 {0} 个已激活能力 + /// + /// + /// 键名: Capability.Hosting.Scheduler.StartFailed + /// 用途: 全部模块激活成功后调度器启动失败(如已被停止),对已激活模块逆序 Deactivate、撤桥后抛出 + /// 参数: {0} - 已回滚的已激活能力数 + /// + public const string SchedulerStartFailed = "Capability.Hosting.Scheduler.StartFailed"; + + /// + /// 能力 {0} 停止失败(不阻断其余能力停止):{1} + /// + /// + /// 键名: Capability.Hosting.DeactivationFailed + /// 用途: DeactivateAsync 失败记日志,不阻断其余模块停止 + /// 参数: {0} - 能力 Id;{1} - 异常描述 + /// + public const string DeactivationFailed = "Capability.Hosting.DeactivationFailed"; + + /// + /// 能力 {0} 声明的 Options 根类型与请求类型不符:{1} ≠ {2} + /// + /// + /// 键名: Capability.Hosting.OptionsTypeMismatch + /// 用途: GetOptions 请求类型与 manifest 声明的根类型不一致(fail-fast) + /// 参数: {0} - 能力 Id;{1} - 声明的根类型;{2} - 请求类型 + /// + public const string OptionsTypeMismatch = "Capability.Hosting.OptionsTypeMismatch"; + + /// + /// 代码 Configure 引用的 Options 类型未由任何已发现能力声明:{0} + /// + /// + /// 键名: Capability.Hosting.ConfigureTargetMissing + /// 用途: CapabilityCompositionOptions.Configure<T> 的 T 无声明来源(fail-fast) + /// 参数: {0} - Options 类型名 + /// + public const string ConfigureTargetMissing = "Capability.Hosting.ConfigureTargetMissing"; + + /// + /// 能力钩子重复声明:{0}({1} → {2}) + /// + /// + /// 键名: Capability.Hosting.HookDuplicateDeclaration + /// 用途: 同一钩子契约第二个声明者(fail-fast) + /// 参数: {0} - 钩子契约类型;{1} - 原声明者能力 Id;{2} - 新声明者能力 Id + /// + public const string HookDuplicateDeclaration = "Capability.Hosting.HookDuplicateDeclaration"; + + /// + /// 启用管理面需引用 GameFrameX.Capability.Hosting.AdminApi 并调用 UseAdminApi。 + /// + /// + /// 键名: Capability.Hosting.AdminApiPlaneFactoryMissing + /// 用途: EnableAdminApi 开启但管理面工厂程序集未被引用/注册(fail-fast) + /// + public const string AdminApiPlaneFactoryMissing = "Capability.Hosting.AdminApiPlaneFactoryMissing"; + } + + /// + /// Online 能力族相关消息 + /// + public static class Online + { + /// + /// online-match(对局)能力相关消息 + /// + public static class Match + { + /// + /// 对局结算发奖 + /// + /// + /// 键名: Capability.Online.Match.OptionalAssetsAbsent + /// 用途: online-match 可选依赖 online-assets 未装配时披露的缺失功能名(结算结果照常记录) + /// + public const string OptionalAssetsAbsent = "Capability.Online.Match.OptionalAssetsAbsent"; + + /// + /// 匹配域(420 入队 / 取消 / 状态查询) + /// + /// + /// 键名: Capability.Online.Match.OptionalMatchmakingAbsent + /// 用途: online-match 可选依赖 online-matchmaking 未装配时披露的缺失功能名(房间直开局不受影响) + /// + public const string OptionalMatchmakingAbsent = "Capability.Online.Match.OptionalMatchmakingAbsent"; + } + + /// + /// online-competition(竞赛)能力相关消息 + /// + public static class Competition + { + /// + /// 赛季、锦标赛 + /// + /// + /// 键名: Capability.Online.Competition.OptionalAssetsAbsent + /// 用途: online-competition 可选依赖 online-assets 未装配时披露的缺失功能名 + /// + public const string OptionalAssetsAbsent = "Capability.Online.Competition.OptionalAssetsAbsent"; + + /// + /// 对局结算写榜投影 + /// + /// + /// 键名: Capability.Online.Competition.OptionalMatchAbsent + /// 用途: online-competition 可选依赖 online-match 未装配时披露的缺失功能名 + /// + public const string OptionalMatchAbsent = "Capability.Online.Competition.OptionalMatchAbsent"; + } + + /// + /// online-operations(运营)能力相关消息 + /// + public static class Operations + { + /// + /// 运营总览资产分项 + /// + /// + /// 键名: Capability.Online.Operations.OptionalAssetsAbsent + /// 用途: online-operations 可选依赖 online-assets 未装配时披露的缺失功能名 + /// + public const string OptionalAssetsAbsent = "Capability.Online.Operations.OptionalAssetsAbsent"; + + /// + /// 运营总览社交分项 + /// + /// + /// 键名: Capability.Online.Operations.OptionalSocialAbsent + /// 用途: online-operations 可选依赖 online-social 未装配时披露的缺失功能名 + /// + public const string OptionalSocialAbsent = "Capability.Online.Operations.OptionalSocialAbsent"; + + /// + /// 运营总览对局分项 + /// + /// + /// 键名: Capability.Online.Operations.OptionalMatchAbsent + /// 用途: online-operations 可选依赖 online-match 未装配时披露的缺失功能名 + /// + public const string OptionalMatchAbsent = "Capability.Online.Operations.OptionalMatchAbsent"; + + /// + /// 运营总览匹配分项 + /// + /// + /// 键名: Capability.Online.Operations.OptionalMatchmakingAbsent + /// 用途: online-operations 可选依赖 online-matchmaking 未装配时披露的缺失功能名 + /// + public const string OptionalMatchmakingAbsent = "Capability.Online.Operations.OptionalMatchmakingAbsent"; + } } } } diff --git a/GameFrameX.Localization/Keys.Launcher.cs b/GameFrameX.Localization/Keys.Launcher.cs index 3fe3e153b..87e6a5d94 100644 --- a/GameFrameX.Localization/Keys.Launcher.cs +++ b/GameFrameX.Localization/Keys.Launcher.cs @@ -131,215 +131,6 @@ public static class Launcher /// public const string HotfixModuleLoadEnd = "Launcher.HotfixModuleLoadEnd"; - /// - /// 开始装配 Online 平台管理面(admin API,端口 {0})... - /// - /// - /// 键名: Launcher.OnlineAdminStartBegin - /// 用途: 记录开始装配 Online 管理面的日志 - /// 参数: {0} - admin API 监听端口 - /// - public const string OnlineAdminStartBegin = "Launcher.OnlineAdminStartBegin"; - - /// - /// Online 平台管理面装配完成(租户 {0},应用 {1},区服 {2})... - /// - /// - /// 键名: Launcher.OnlineAdminStartEnd - /// 用途: 记录 Online 管理面装配完成的日志 - /// 参数: {0} - 租户标识;{1} - 应用标识;{2} - 区服标识 - /// - public const string OnlineAdminStartEnd = "Launcher.OnlineAdminStartEnd"; - - /// - /// Online 好友查询桥已发布(租户 {0},应用 {1},区服 {2})... - /// - /// - /// 键名: Launcher.OnlineFriendBridgePublished - /// 用途: 记录好友查询桥租约发布(C243 组合根接线) - /// 参数: {0} - 租户标识;{1} - 应用标识;{2} - 区服标识 - /// - public const string OnlineFriendBridgePublished = "Launcher.OnlineFriendBridgePublished"; - - /// - /// Online 好友查询桥已撤销... - /// - /// - /// 键名: Launcher.OnlineFriendBridgeRetracted - /// 用途: 记录好友查询桥租约撤销(进程停止路径) - /// - public const string OnlineFriendBridgeRetracted = "Launcher.OnlineFriendBridgeRetracted"; - - /// - /// Online 好友查询桥验证种子已建立(仅供部署验证):请求主体 {0},种子好友数 {1} - /// - /// - /// 键名: Launcher.OnlineFriendBridgeSeeded - /// 用途: 披露验证种子经真实服务写路径建立的好友关系(C243) - /// 参数: {0} - 请求主体(玩家全局 actor)标识;{1} - 建立成功的好友数 - /// - public const string OnlineFriendBridgeSeeded = "Launcher.OnlineFriendBridgeSeeded"; - - /// - /// Online 好友域桥契约面缺失(online-social 能力被排除或未装配)——好友消息将显式失败 - /// - /// - /// 键名: Launcher.OnlineFriendBridgeFaceMissing - /// 用途: C244 好友域接管:发布期无法解析好友域契约面或授权作用域(Error 披露,不终止启动) - /// - public const string OnlineFriendBridgeFaceMissing = "Launcher.OnlineFriendBridgeFaceMissing"; - - /// - /// 旧好友关系迁移单条失败:{0} <-> {1}, Code: {2}, Message: {3} - /// - /// - /// 键名: Launcher.OnlineFriendLegacyMigrationEntryFailed - /// 用途: C244 旧好友数据迁移单条收敛失败时逐条披露(containment,不中断整体迁移) - /// 参数: {0} - 一侧玩家ID;{1} - 另一侧玩家ID;{2} - 协议错误码;{3} - 失败描述 - /// - public const string OnlineFriendLegacyMigrationEntryFailed = "Launcher.OnlineFriendLegacyMigrationEntryFailed"; - - /// - /// 旧好友数据迁移完成:已建立 {0},待答申请 {1},失败 {2}(旧数据零改动) - /// - /// - /// 键名: Launcher.OnlineFriendLegacyMigrationDone - /// 用途: C244 旧好友数据一次性迁移的汇总披露 - /// 参数: {0} - 已建立关系数;{1} - 待答申请数;{2} - 失败数 - /// - public const string OnlineFriendLegacyMigrationDone = "Launcher.OnlineFriendLegacyMigrationDone"; - - /// - /// Online 对局桥已发布(租户 {0},应用 {1},区服 {2})... - /// - /// - /// 键名: Launcher.OnlineMatchBridgePublished - /// 用途: 记录对局桥租约发布(C245 组合根接线) - /// 参数: {0} - 租户标识;{1} - 应用标识;{2} - 区服标识 - /// - public const string OnlineMatchBridgePublished = "Launcher.OnlineMatchBridgePublished"; - - /// - /// Online 对局桥已撤销... - /// - /// - /// 键名: Launcher.OnlineMatchBridgeRetracted - /// 用途: 记录对局桥租约撤销(进程停止路径) - /// - public const string OnlineMatchBridgeRetracted = "Launcher.OnlineMatchBridgeRetracted"; - - /// - /// Online 对局桥榜单已确保存在(仅供样例验证):{0} - /// - /// - /// 键名: Launcher.OnlineMatchBridgeBoardEnsured - /// 用途: 披露组合根经真实榜单服务确保写榜目标存在(C245);已存在时幂等跳过 - /// 参数: {0} - 榜单标识 - /// - public const string OnlineMatchBridgeBoardEnsured = "Launcher.OnlineMatchBridgeBoardEnsured"; - - /// - /// 对局桥开关开启但业务运行时未启用(IsEnableOnline=false)——不支持该拓扑,启动终止 - /// - /// - /// 键名: Launcher.OnlineMatchBridgeRuntimeGateMissing - /// 用途: C245 组合根 fail-fast:桥开关与业务运行时开关矛盾 - /// - public const string OnlineMatchBridgeRuntimeGateMissing = "Launcher.OnlineMatchBridgeRuntimeGateMissing"; - - /// - /// 对局桥开关开启但作用域三元组缺失(OnlineTenantId/OnlineAppId/ServerId)——启动终止 - /// - /// - /// 键名: Launcher.OnlineMatchBridgeScopeMissing - /// 用途: C245 组合根 fail-fast:桥开关开启但授权作用域不完整 - /// - public const string OnlineMatchBridgeScopeMissing = "Launcher.OnlineMatchBridgeScopeMissing"; - - /// - /// 对局桥契约面缺失(online-match 能力被排除或未装配)——不支持该拓扑,启动终止 - /// - /// - /// 键名: Launcher.OnlineMatchBridgeFaceMissing - /// 用途: C245 组合根 fail-fast:发布期无法解析对局桥契约面或授权作用域 - /// - public const string OnlineMatchBridgeFaceMissing = "Launcher.OnlineMatchBridgeFaceMissing"; - - /// - /// 对局桥榜单确保失败:{0},代码:{1},消息:{2} - /// - /// - /// 键名: Launcher.OnlineMatchBridgeBoardEnsureFailed - /// 用途: C245 组合根:写榜目标榜单确保失败(配置矛盾或榜单服务不可用),启动终止 - /// 参数: {0} - 榜单标识;{1} - 错误码;{2} - 错误消息 - /// - public const string OnlineMatchBridgeBoardEnsureFailed = "Launcher.OnlineMatchBridgeBoardEnsureFailed"; - - /// - /// 匹配域桥契约面不可解析(online-match/matchmaking 未装配或被排除),420 匹配消息将显式失败 - /// - /// - /// 键名: Launcher.OnlineMatchDomainBridgeFaceMissing - /// 用途: C246 组合根:发布期无法解析匹配域契约面或授权作用域,Error 披露不抛出 - /// - public const string OnlineMatchDomainBridgeFaceMissing = "Launcher.OnlineMatchDomainBridgeFaceMissing"; - - /// - /// Online 匹配域桥已发布(租户 {0},应用 {1},区服 {2}) - /// - /// - /// 键名: Launcher.OnlineMatchDomainBridgePublished - /// 用途: C246 组合根:匹配域桥租约发布确认 - /// 参数: {0} - 租户标识;{1} - 应用标识;{2} - 区服标识 - /// - public const string OnlineMatchDomainBridgePublished = "Launcher.OnlineMatchDomainBridgePublished"; - - /// - /// Online 匹配成功通知未装配(事件总线或契约面不可解析),等待方经状态查询兜底 - /// - /// - /// 键名: Launcher.OnlineMatchDomainBridgeNotifyMissing - /// 用途: C246 组合根:匹配成功通知订阅装配缺席披露 - /// - public const string OnlineMatchDomainBridgeNotifyMissing = "Launcher.OnlineMatchDomainBridgeNotifyMissing"; - - /// - /// Online 匹配域桥已撤销 - /// - /// - /// 键名: Launcher.OnlineMatchDomainBridgeRetracted - /// 用途: C246 组合根:匹配域桥租约撤销确认 - /// - public const string OnlineMatchDomainBridgeRetracted = "Launcher.OnlineMatchDomainBridgeRetracted"; - - /// - /// Online 会话域桥已发布(租户 {0},应用 {1},区服 {2}) - /// - /// - /// 键名: Launcher.OnlineSessionDomainBridgePublished - /// 用途: C204 组合根:会话域桥租约发布确认 - /// 参数: {0} - 租户标识;{1} - 应用标识;{2} - 区服标识 - /// - public const string OnlineSessionDomainBridgePublished = "Launcher.OnlineSessionDomainBridgePublished"; - - /// - /// Online 会话域桥契约面不可解析(online-foundation 未装配或被排除),会话恢复不可用 - /// - /// - /// 键名: Launcher.OnlineSessionDomainBridgeFaceMissing - /// 用途: C204 组合根:会话域桥契约面缺席披露(登录不下发凭据、恢复请求显式失败) - /// - public const string OnlineSessionDomainBridgeFaceMissing = "Launcher.OnlineSessionDomainBridgeFaceMissing"; - - /// - /// Online 会话域桥已撤销 - /// - /// - /// 键名: Launcher.OnlineSessionDomainBridgeRetracted - /// 用途: C204 组合根:会话域桥租约撤销确认 - /// - public const string OnlineSessionDomainBridgeRetracted = "Launcher.OnlineSessionDomainBridgeRetracted"; - /// /// 进入游戏主循环... diff --git a/GameFrameX.Localization/Localization/Messages/Resources.resx b/GameFrameX.Localization/Localization/Messages/Resources.resx index 0d34eb3fb..af5a33f6e 100644 --- a/GameFrameX.Localization/Localization/Messages/Resources.resx +++ b/GameFrameX.Localization/Localization/Messages/Resources.resx @@ -387,57 +387,6 @@ Hotfix module loading completed... - - - Starting Online admin API assembly (port {0})... - - - Online admin API assembly completed (tenant {0}, app {1}, server {2})... - - - Online friend-query bridge published (tenant {0}, app {1}, server {2})... - - - Online friend-query bridge retracted... - - - Online friend-query bridge verification seeds established (deployment verification only): requester {0}, seeded friends {1} - - - The friend-query bridge contract face is missing (the online-social capability is excluded or not assembled) - unsupported topology, startup aborted - - - Online match bridge published (tenant {0}, app {1}, server {2})... - - - Online match bridge retracted... - - - Online match bridge leaderboard ensured to exist (sample verification only): {0} - - - The match bridge switch is on but the business runtime gate is off (IsEnableOnline=false) - unsupported topology, startup aborted - - - The match bridge switch is on but the authorized scope triple is missing (OnlineTenantId/OnlineAppId/ServerId) - startup aborted - - - The match bridge contract face is missing (the online-match capability is excluded or not assembled) - unsupported topology, startup aborted - - - Failed to ensure the match bridge leaderboard exists: {0}, Code: {1}, Message: {2} - - - The match-domain bridge contract face cannot be resolved (online-match/matchmaking not assembled or excluded); the domain-420 match messages will fail explicitly - - - Online match-domain bridge published (tenant {0}, app {1}, server {2}) - - - Online match-made notification is not wired (event bus or contract face unresolvable); waiting players fall back to status polling - - - Online match-domain bridge retracted Entering game main loop... @@ -1466,6 +1415,177 @@ Admin API listening options are invalid (port / prefix). + + Capability assembly started: {0} discovered, {1} enabled, {2} excluded. + + + Excluded {0}: {1} + + + #{0} {1} {2} {3} + + + #{0} {1} {2} {3} + + + required {0} + + + optional {0} + + + optional: {0} assembled, {1} not assembled + + + {0}(assembled) + + + {0}(not assembled) + + + {0} optional dependency {1} is not assembled ({2}): feature "{3}" is missing; the remaining features of {0} are unaffected. + + + Capability assembly completed: {0} capabilities, {1} features skipped due to missing optional dependencies, none affecting capability operation. + + + Capability assembly failed ({0} issue(s)): + + + {0} requires capability {1} which is not assembled ({2}). + + + Capability requirements form a cycle: {0} + + + configuration disable list + + + not included in the enable list + + + not discovered in the deployment directory + + + Hook {0} is attached but no capability declares it (the owning capability is not assembled or does not declare the hook) + + + The Settings section references an unknown capability: {0} + + + Options field does not exist or is not configurable: {0} + + + Options field type mismatch: {0} (expected {1}, actual {2}) + + + Capability {0} options validation failed: {1} + + + match settlement reward granting (settlement results are still recorded) + + + matchmaking domain (420 enqueue / cancel / status query) + + + seasons and tournaments + + + leaderboard projection from match settlement + + + operations overview assets section + + + operations overview social section + + + operations overview match section + + + operations overview matchmaking section + + + Duplicate capability service registration: {0} + + + Duplicate capability service override: {0} + + + Capability service not registered: {0} + + + Capability {0} resolved optional service {1} but it is not registered while all of its declared optional dependencies are assembled (or none are declared): a present capability did not register the service + + + Capability service lazy factories form a resolution cycle: {0} + + + Capability {0} resolved service {1} from an undeclared source ({2}); during Register / lazy factories only self, family infrastructure, host overrides, and directly declared required or present optional dependencies may be resolved + + + GetServices may only be called during Activate: {0} / {1} + + + Capability {0} declares a host service that was not provided (host Override required): {1} + + + Duplicate capability bridge contract publish: {0} ({1} → {2}) + + + Capability bridge contract retracted or replaced: {0} (consumers map it to 8002) + + + Event publish rejected before Activate: {0} + + + Capability event subscriber failed: {0} ({1}) + + + The scheduler cannot be restarted after it has stopped. + + + Capability scheduler tick job failed: {0} ({1}) + + + Capability scheduler interval job failed: {0} ({1}) + + + Capability {0} activation failed; {1} activated capabilities were rolled back in reverse order + + + Capability scheduler failed to start; {0} activated capabilities were rolled back in reverse order + + + Capability {0} deactivation failed (other capabilities continue stopping): {1} + + + The options root type declared by capability {0} does not match the requested type: {1} ≠ {2} + + + Code Configure references an options type not declared by any discovered capability: {0} + + + Duplicate capability hook declaration: {0} ({1} → {2}) + + + Enabling the admin plane requires referencing GameFrameX.Capability.Hosting.AdminApi and calling UseAdminApi. + + + Duplicate admin action registration: {0} (capability or source: {1}) + + + Capability {1} registers action {0} but does not declare it in its manifest. + + + Capability {1} declares action {0} but it is registered by capability {2}. + + + The admin plane is missing a required adapter service: {0} (family infrastructure must register it) + + + Admin action reconciliation failed ({0} issue(s)): + Scope is missing the player identity slot (PlayerId <= 0); cannot derive player context. @@ -2469,12 +2589,6 @@ The match-domain bridge call threw an exception, PlayerId: {0} - - Legacy friend-relation migration failed for one entry: {0} <-> {1}, Code: {2}, Message: {3} - - - Legacy friend-data migration finished: established {0}, pending requests {1}, failed {2} (legacy data untouched) - The session-domain bridge is unavailable (not published or retracted), PlayerId: {0}, Code: {1} @@ -2496,15 +2610,6 @@ Stale disconnect callback skipped online coordination (player already holds a newer session), PlayerId: {0} - - The Online session-domain bridge has been published (tenant {0}, app {1}, server {2}) - - - The Online session-domain bridge contract face cannot be resolved (online-foundation not assembled or excluded); session recovery is unavailable - - - The Online session-domain bridge has been retracted - Bot-{0} entered the match; disconnect drill scheduled in {1} seconds (match recovery scenario) diff --git a/GameFrameX.Localization/Localization/Messages/Resources.zh-CN.resx b/GameFrameX.Localization/Localization/Messages/Resources.zh-CN.resx index 4102e1fe8..09aec3a1c 100644 --- a/GameFrameX.Localization/Localization/Messages/Resources.zh-CN.resx +++ b/GameFrameX.Localization/Localization/Messages/Resources.zh-CN.resx @@ -417,57 +417,6 @@ 加载热更新模块结束... - - - 开始装配 Online 平台管理面(admin API,端口 {0})... - - - Online 平台管理面装配完成(租户 {0},应用 {1},区服 {2})... - - - Online 好友查询桥已发布(租户 {0},应用 {1},区服 {2})... - - - Online 好友查询桥已撤销... - - - Online 好友查询桥验证种子已建立(仅供部署验证):请求主体 {0},种子好友数 {1} - - - 好友查询桥契约面缺失(online-social 能力被排除或未装配)——不支持该拓扑,启动终止 - - - Online 对局桥已发布(租户 {0},应用 {1},区服 {2})... - - - Online 对局桥已撤销... - - - Online 对局桥榜单已确保存在(仅供样例验证):{0} - - - 对局桥开关开启但业务运行时未启用(IsEnableOnline=false)——不支持该拓扑,启动终止 - - - 对局桥开关开启但作用域三元组缺失(OnlineTenantId/OnlineAppId/ServerId)——启动终止 - - - 对局桥契约面缺失(online-match 能力被排除或未装配)——不支持该拓扑,启动终止 - - - 对局桥榜单确保失败:{0},代码:{1},消息:{2} - - - Online 匹配域桥契约面不可解析(online-match/matchmaking 未装配或被排除),420 匹配消息将显式失败 - - - Online 匹配域桥已发布(租户 {0},应用 {1},区服 {2}) - - - Online 匹配成功通知未装配(事件总线或契约面不可解析),等待方经状态查询兜底 - - - Online 匹配域桥已撤销 进入游戏主循环... @@ -1464,6 +1413,177 @@ 管理面监听选项非法(端口 / 前缀)。 + + 能力装配开始:发现 {0} 个,启用 {1} 个,排除 {2} 个 + + + 已排除 {0}:{1} + + + #{0} {1} {2} {3} + + + #{0} {1} {2} {3} + + + 必选 {0} + + + 可选 {0} + + + 可选 {0} 个已装配,{1} 个未装配 + + + {0}(已装配) + + + {0}(未装配) + + + {0} 可选依赖 {1} 未装配({2}):缺失功能「{3}」,不影响 {0} 其余功能运行 + + + 能力装配完成:{0} 个能力,{1} 项功能因可选依赖缺失未装配,均不影响能力运行 + + + 能力装配失败({0} 项): + + + {0} 必选依赖 {1} 未装配({2}) + + + 依赖成环:{0} + + + 配置排除表 + + + 白名单未收录 + + + 部署目录未发现 + + + 钩子 {0} 已挂载但没有任何能力声明(对应能力未装配或未声明该钩子) + + + Settings 配置段出现未发现的能力:{0} + + + Options 字段不存在或不可配置:{0} + + + Options 字段类型不符:{0}(期望 {1},实际 {2}) + + + 能力 {0} Options 校验失败:{1} + + + 对局结算发奖(结算结果照常记录) + + + 匹配域(420 入队 / 取消 / 状态查询) + + + 赛季、锦标赛 + + + 对局结算写榜投影 + + + 运营总览资产分项 + + + 运营总览社交分项 + + + 运营总览对局分项 + + + 运营总览匹配分项 + + + 能力服务重复注册:{0} + + + 能力服务覆盖重复:{0} + + + 能力服务未注册:{0} + + + 能力 {0} 按可选语义解析服务 {1} 未命中,但其声明的可选依赖均已装配(或未声明任何可选依赖):能力在场却未注册该服务 + + + 能力服务懒工厂循环解析:{0} + + + 能力 {0} 解析了未声明来源的服务 {1}(来源:{2});Register/懒工厂期只允许解析 自己/族基建/宿主覆盖/直接声明的必选与在场可选依赖 + + + GetServices 只能在 Activate 期调用:{0} / {1} + + + 能力 {0} 声明的宿主服务未提供(需宿主 Override):{1} + + + 能力桥契约重复发布:{0}({1} → {2}) + + + 能力桥契约已撤销或被替换:{0}(消费方按 8002 处理) + + + Activate 开始前拒绝发布事件:{0} + + + 能力事件订阅处理失败:{0}({1}) + + + 调度器停止后不可重新启动。 + + + 能力调度逐滴答 job 失败:{0}({1}) + + + 能力调度间隔 job 失败:{0}({1}) + + + 能力 {0} 激活失败,已逆序回滚 {1} 个已激活能力 + + + 能力调度器启动失败,已逆序回滚 {0} 个已激活能力 + + + 能力 {0} 停止失败(不阻断其余能力停止):{1} + + + 能力 {0} 声明的 Options 根类型与请求类型不符:{1} ≠ {2} + + + 代码 Configure 引用的 Options 类型未由任何已发现能力声明:{0} + + + 能力钩子重复声明:{0}({1} → {2}) + + + 启用管理面需引用 GameFrameX.Capability.Hosting.AdminApi 并调用 UseAdminApi。 + + + Admin action 重复注册:{0}(能力或来源:{1}) + + + 能力 {1} 注册的 action 未在其 manifest 声明:{0} + + + 能力 {1} 声明的 action {0} 被能力 {2} 注册(跨能力冒名) + + + 管理面缺少必需适配器服务:{0}(族基建须注册) + + + Admin action 对账失败({0} 项): + 作用域缺少玩家主体位(PlayerId <= 0),不能派生玩家上下文。 @@ -2497,12 +2617,6 @@ 匹配域桥调用异常, PlayerId: {0} - - 旧好友关系迁移单条失败:{0} <-> {1}, Code: {2}, Message: {3} - - - 旧好友数据迁移完成:已建立 {0},待答申请 {1},失败 {2}(旧数据零改动) - 会话域桥不可用(未发布或已撤销), PlayerId: {0}, Code: {1} @@ -2524,15 +2638,6 @@ 断线跳过协同(旧回调迟到,玩家已有新会话), PlayerId: {0} - - Online 会话域桥已发布(租户 {0},应用 {1},区服 {2}) - - - Online 会话域桥契约面不可解析(online-foundation 未装配或被排除),会话恢复不可用 - - - Online 会话域桥已撤销 - 机器人-{0}入场完成,安排 {1} 秒后断线演练(match 恢复场景) diff --git a/Tests/GameFrameX.Tests/Online/OnlineCapabilityCompositionTests.cs b/Tests/GameFrameX.Tests/Online/OnlineCapabilityCompositionTests.cs deleted file mode 100644 index f8d877b50..000000000 --- a/Tests/GameFrameX.Tests/Online/OnlineCapabilityCompositionTests.cs +++ /dev/null @@ -1,317 +0,0 @@ -// ========================================================================================== -// GameFrameX 组织及其衍生项目的版权、商标、专利及其他相关权利 -// GameFrameX organization and its derivative projects' copyrights, trademarks, patents and related rights -// 均受中华人民共和国及相关国际法律法规保护。 -// are protected by the laws of the PRC 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 refer to the LICENSE file at the root 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 legal rights and interests of others as prohibited by laws and regulations! -// 因基于本项目二次开发所产生的一切法律纠纷与责任, -// Any legal disputes or liabilities arising from secondary development, -// 本组织与贡献者概不承担任何责任。 -// 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://www.cnb.cool/GameFrameX -// CNB Repository: https://www.cnb.cool/GameFrameX -// 官方文档:https://gameframex.doc.alianblank.com/ -// Official Documentation: https://gameframex.doc.alianblank.com/ -// ========================================================================================== - -using System; -using System.Linq; -using System.Text.Json; -using System.Threading.Tasks; -using GameFrameX.Capability.Hosting.Abstractions; -using GameFrameX.Capability.Hosting.AdminApi; -using GameFrameX.Capability.Online.Abstractions.Contracts; -using GameFrameX.Foundation.Localization.Core; -using GameFrameX.Launcher.Online; -using Xunit; - -namespace GameFrameX.Tests.Online; - -/// -/// Online 能力单入口组合测试(C229):双闸组合 × 过滤器形态、空列表全装配红线、 -/// admin-only 目录如实反映配置排除、组合期 fail-fast(未知 Id / 闭包缺口 / 无面启用 / 选项非法)。 -/// -public class OnlineCapabilityCompositionTests -{ - /// - /// 以最小合法作用域构造编排回调(双闸由用例覆写)。 - /// - /// 用例追加的编排动作。 - /// 组合回调。 - private static Action Compose(Action configure = null) - { - return options => - { - options.TenantId = 1; - options.AppId = 1; - options.ServerId = 1001; - configure?.Invoke(options); - }; - } - - /// - /// 业务闸单开:组合产物只含运行时,报告全装配(7 能力拓扑序)。 - /// - [Fact] - public void AddOnlineCapabilities_RuntimeGate_ComposesRuntimeWithoutAdmin() - { - var composition = OnlineCapabilityComposition.AddOnlineCapabilities(Compose(options => options.EnableRuntime = true)); - - Assert.NotNull(composition.Runtime); - Assert.Null(composition.AdminApi); - Assert.Equal(new[] - { - "online-assets", "online-foundation", "online-social", "online-matchmaking", - "online-match", "online-competition", "online-operations", - }, composition.Runtime.Report.EnabledIdsInOrder); - } - - /// - /// 管理面闸单开:组合产物只含 admin-only HTTP 服务(业务运行时不构造)。 - /// - [Fact] - public void AddOnlineCapabilities_AdminGateOnly_ComposesAdminWithoutRuntime() - { - var composition = OnlineCapabilityComposition.AddOnlineCapabilities(Compose(options => options.EnableAdminApi = true)); - - Assert.Null(composition.Runtime); - Assert.NotNull(composition.AdminApi); - } - - /// - /// 生产装配面(C248):业务运行时组合产物内 Hotfix 切换执行器与登记期制品获取器均为生产实现非空 - ///(消除「生产路径恒缺席 8002」;替换缝仍开放)。 - /// - [Fact] - public void AddOnlineCapabilities_Runtime_PreSeedsHotfixExecutorAndArtifactFetcher() - { - var composition = OnlineCapabilityComposition.AddOnlineCapabilities(Compose(options => options.EnableRuntime = true)); - - var executor = composition.Runtime.Services.GetService(); - var fetcher = composition.Runtime.Services.GetService(); - Assert.IsType(executor); - Assert.IsType(fetcher); - } - - /// - /// 双闸齐开:一次回调同时产出运行时与管理面(admin 复用同一入口产物)。 - /// - [Fact] - public void AddOnlineCapabilities_BothGates_ComposesBothFaces() - { - var composition = OnlineCapabilityComposition.AddOnlineCapabilities(Compose(options => - { - options.EnableRuntime = true; - options.EnableAdminApi = true; - })); - - Assert.NotNull(composition.Runtime); - Assert.NotNull(composition.AdminApi); - } - - /// - /// 空列表 = 全装配红线:Capabilities / Disable 为空数组时不过滤。 - /// - [Fact] - public void AddOnlineCapabilities_EmptyFilterLists_AssembleAll() - { - var composition = OnlineCapabilityComposition.AddOnlineCapabilities(Compose(options => - { - options.EnableRuntime = true; - options.Capabilities = Array.Empty(); - options.Disable = Array.Empty(); - })); - - Assert.Equal(7, composition.Runtime.Report.Entries.Count(entry => entry.State == CapabilityFilterState.Enabled)); - } - - /// - /// 空白条目规整:全空白列表视为未过滤(等价 null)。 - /// - [Fact] - public void AddOnlineCapabilities_BlankEntries_TreatedAsUnfiltered() - { - var composition = OnlineCapabilityComposition.AddOnlineCapabilities(Compose(options => - { - options.EnableRuntime = true; - options.Capabilities = new[] { " ", string.Empty }; - options.Disable = new[] { " " }; - })); - - Assert.Equal(7, composition.Runtime.Report.Entries.Count(entry => entry.State == CapabilityFilterState.Enabled)); - } - /// - /// 排除表生效:被排除能力(叶子能力 online-operations,无下游依赖)在运行时报告中为 - /// ExcludedByDisableList,其余保持装配。 - /// - [Fact] - public void AddOnlineCapabilities_DisableList_ExcludesCapabilityFromReport() - { - var composition = OnlineCapabilityComposition.AddOnlineCapabilities(Compose(options => - { - options.EnableRuntime = true; - options.Disable = new[] { "online-operations" }; - })); - - var entries = composition.Runtime.Report.Entries.ToDictionary(entry => entry.Manifest.Id); - Assert.Equal(CapabilityFilterState.ExcludedByDisableList, entries["online-operations"].State); - Assert.Equal(6, composition.Runtime.Report.EnabledIdsInOrder.Count); - Assert.DoesNotContain("online-operations", composition.Runtime.Report.EnabledIdsInOrder); - } - - /// - /// 排除表在业务形态的对外行为:被排除能力的 admin action 按未安装(4003)裁决且载荷指向该能力。 - /// - [Fact] - public async Task AddOnlineCapabilities_DisabledCapabilityAction_AnswersNotInstalledAsync() - { - var composition = OnlineCapabilityComposition.AddOnlineCapabilities(Compose(options => - { - options.EnableRuntime = true; - options.Disable = new[] { "online-operations" }; - })); - - var dispatcher = OnlineAdminApiComposition.CreateDispatcher(composition.Runtime); - var response = await dispatcher.DispatchAsync("query_online_overview", "{\"TenantId\":1,\"AppId\":1,\"ServerId\":1001,\"RequestId\":\"r1\"}"); - using var outer = JsonDocument.Parse(response); - var innerJson = outer.RootElement.GetProperty("data").GetString(); - using var inner = JsonDocument.Parse(innerJson); - Assert.Equal((int)OnlineErrorCode.CapabilityNotInstalled, inner.RootElement.GetProperty("Code").GetInt32()); - using var payload = JsonDocument.Parse(inner.RootElement.GetProperty("Data").GetRawText()); - Assert.Equal("online-operations", payload.RootElement.GetProperty("NotInstalledCapability").GetString()); - } - - - /// - /// admin-only 形态接入同一配置段:排除/白名单外条目在目录报告中如实反映,其余仍为未装配; - /// 被排除能力与未装配能力的 action 一律 4003。 - /// - [Fact] - public async Task AdminOnlyDispatcher_WithFilter_ReflectsExclusionInCatalogAsync() - { - var filter = new CapabilityAssemblyFilter(null, new[] { "online-assets" }); - var dispatcher = OnlineAdminApiComposition.CreateAdminOnlyDispatcher(new CapabilityHostOptions { TenantId = 1, AppId = 1, ServerId = 1001 }, filter); - - var listing = await dispatcher.DispatchAsync("list_capabilities", "{\"TenantId\":1,\"AppId\":1,\"ServerId\":1001}"); - using (var outer = JsonDocument.Parse(listing)) - { - var innerJson = outer.RootElement.GetProperty("data").GetString(); - using var inner = JsonDocument.Parse(innerJson); - var entries = inner.RootElement.GetProperty("Data").GetProperty("Capabilities").EnumerateArray() - .ToDictionary(entry => entry.GetProperty("Id").GetString()); - Assert.Equal(7, entries.Count); - Assert.Equal("ExcludedByDisableList", entries["online-assets"].GetProperty("State").GetString()); - Assert.Equal("NotAssembled", entries["online-foundation"].GetProperty("State").GetString()); - } - - var notInstalled = await dispatcher.DispatchAsync("grant_asset", "{\"TenantId\":1,\"AppId\":1,\"ServerId\":1001,\"RequestId\":\"r2\"}"); - using var outerNotInstalled = JsonDocument.Parse(notInstalled); - var innerNotInstalledJson = outerNotInstalled.RootElement.GetProperty("data").GetString(); - using var innerNotInstalled = JsonDocument.Parse(innerNotInstalledJson); - Assert.Equal((int)OnlineErrorCode.CapabilityNotInstalled, innerNotInstalled.RootElement.GetProperty("Code").GetInt32()); - } - - /// - /// 白名单闭包缺口:白名单含 online-matchmaking 而未含其依赖 online-social,组合期 fail-fast。 - /// - [Fact] - public void AddOnlineCapabilities_EnableListClosureGap_FailsFast() - { - Assert.Throws(() => OnlineCapabilityComposition.AddOnlineCapabilities(Compose(options => - { - options.EnableRuntime = true; - options.Capabilities = new[] { "online-matchmaking" }; - }))); - } - - /// - /// 白名单闭包完整:含全部依赖链时按白名单装配。 - /// - [Fact] - public void AddOnlineCapabilities_EnableListWithClosure_AssemblesWhitelist() - { - var composition = OnlineCapabilityComposition.AddOnlineCapabilities(Compose(options => - { - options.EnableRuntime = true; - options.Capabilities = new[] { "online-foundation", "online-social", "online-matchmaking" }; - })); - - Assert.Equal(new[] { "online-foundation", "online-social", "online-matchmaking" }, - composition.Runtime.Report.EnabledIdsInOrder); - } - - /// - /// 未知能力 Id:过滤器引用未发现的能力,组合期 fail-fast。 - /// - [Fact] - public void AddOnlineCapabilities_UnknownCapabilityId_FailsFast() - { - Assert.Throws(() => OnlineCapabilityComposition.AddOnlineCapabilities(Compose(options => - { - options.EnableRuntime = true; - options.Capabilities = new[] { "online-ghost" }; - }))); - } - - /// - /// 双闸均未启用:组合期 fail-fast,消息键为 Capability.Hosting.Composition.NoPlaneEnabled。 - /// - [Fact] - public void AddOnlineCapabilities_NoPlaneEnabled_FailsFastWithKey() - { - var exception = Assert.Throws( - () => OnlineCapabilityComposition.AddOnlineCapabilities(Compose())); - - Assert.Equal(LocalizationService.GetString(GameFrameX.Localization.Keys.Capability.Hosting.Composition.NoPlaneEnabled), exception.Message); - } - - /// - /// 回调缺失:参数校验直接拒绝。 - /// - [Fact] - public void AddOnlineCapabilities_NullConfigure_Throws() - { - Assert.Throws(() => OnlineCapabilityComposition.AddOnlineCapabilities(null)); - } - - /// - /// 作用域非法:透传宿主选项校验 fail-fast(TenantId=0)。 - /// - [Fact] - public void AddOnlineCapabilities_InvalidScope_FailsFast() - { - Assert.Throws(() => OnlineCapabilityComposition.AddOnlineCapabilities(options => - { - options.TenantId = 0; - options.AppId = 1; - options.ServerId = 1001; - options.EnableRuntime = true; - })); - } - - /// - /// 管理面监听选项非法:透传管理面组合工厂校验 fail-fast(端口 0)。 - /// - [Fact] - public void AddOnlineCapabilities_InvalidAdminOptions_FailsFast() - { - Assert.Throws(() => OnlineCapabilityComposition.AddOnlineCapabilities(Compose(options => - { - options.EnableAdminApi = true; - options.AdminApi = new AdminApiOptions { AdminPort = 0, AdminApiPrefix = "online/admin" }; - }))); - } -} From 0be4619ce77c7846fdfbda39ee02c06352b42f59 Mon Sep 17 00:00:00 2001 From: Blank Date: Sat, 24 Oct 2026 03:33:37 +0800 Subject: [PATCH 2/6] =?UTF-8?q?feat(capability):=20=E8=83=BD=E5=8A=9B?= =?UTF-8?q?=E5=8C=85=E6=94=B9=E8=B5=B0=E6=96=B0=E6=B3=A8=E5=85=A5=E5=A5=91?= =?UTF-8?q?=E7=BA=A6=E5=B9=B6=E5=88=A0=E9=99=A4=E6=97=A7=E6=A1=A5=E4=B8=8E?= =?UTF-8?q?=E6=8E=A2=E6=B5=8B=E5=9B=9E=E9=80=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Online.Abstractions 承载 OnlineCapabilityFamily 族基建与管理面适配器,删除 Bridges 目录四对 holder/lease;七个能力包补 Activate/Deactivate,桥发布、榜单 预建、撮合通知订阅与旧好友迁移迁入所属模块;manifest 按 B佬 裁定改写必选/可选 依赖(match/competition/operations 的 assets 等边转可选并披露缺失功能);Options 根类型收编全部默认值并删除兜底;TryGetService 逐处改 GetService/GetOptionalService, 事件订阅改走机制总线并统一小写 topic。 Linear: GFX-998 --- .../Audit/IOnlineAuditStore.cs | 2 +- .../Audit/InMemoryOnlineAuditStore.cs | 2 +- .../Audit/OnlineAuditEntry.cs | 2 +- .../Audit/OnlineAuditIngestOutcome.cs | 2 +- .../Audit/OnlineAuditService.cs | 17 +- .../Bridges/OnlineFriendDomainBridge.cs | 223 -------------- .../Bridges/OnlineFriendDomainBridgeHolder.cs | 99 ------ .../Bridges/OnlineMatchDomainBridge.cs | 204 ------------- .../Bridges/OnlineMatchDomainBridgeHolder.cs | 81 ----- .../Bridges/OnlineMatchGameBridge.cs | 151 ---------- .../Bridges/OnlineMatchGameBridgeHolder.cs | 91 ------ .../Bridges/OnlineSessionDomainBridge.cs | 129 -------- .../OnlineSessionDomainBridgeHolder.cs | 76 ----- .../Contracts/IOnlineAuditReader.cs | 6 +- .../Contracts/IOnlineFriendDomain.cs | 4 +- .../Contracts/IOnlineMatchDomain.cs | 3 +- .../Contracts/IOnlineMatchGameBridge.cs | 4 +- .../Contracts}/IOnlinePartyPresenceProbe.cs | 2 +- .../Contracts}/IOnlinePlayerDirectory.cs | 2 +- .../Contracts}/IOnlineSocialPresenceProbe.cs | 2 +- .../Contracts/OnlineAuditPage.cs | 6 +- .../Contracts}/OnlinePlayerDirectoryEntry.cs | 2 +- .../Events/OnlineEventPublisher.cs | 39 ++- .../IOnlineChatModerationHook.cs | 4 +- .../OnlineCapabilityFamily.cs | 144 +++++++++ .../OnlineChatMessage.cs | 2 +- .../OnlineChatMessageState.cs | 2 +- .../OnlineChatModerationVerdict.cs | 4 +- .../Scope/IOnlineAuthorizedScopeProvider.cs | 47 +++ ...GameFrameX.Capability.Online.Assets.csproj | 2 +- .../OnlineAssetEvents.cs | 6 +- .../OnlineAssetsCapabilityModule.cs | 99 +++--- .../OnlineAssetsHostingExtensions.cs | 95 ------ .../OnlineAssetsOptions.cs | 50 ---- ...rameX.Capability.Online.Competition.csproj | 6 +- .../Leaderboard/OnlineLeaderboardEvents.cs | 4 +- .../OnlineLeaderboardProjectionOptions.cs | 5 +- .../Leaderboard/OnlineLeaderboardService.cs | 6 +- .../OnlineCompetitionCapabilityModule.cs | 173 ++++++----- .../OnlineCompetitionHostingExtensions.cs | 111 ------- .../OnlineCompetitionOptions.cs | 43 ++- .../Season/OnlineSeasonEvents.cs | 8 +- .../Tournament/OnlineTournamentEvents.cs | 10 +- .../Tournament/OnlineTournamentService.cs | 6 +- ...ns.cs => OnlineAuthorizedScopeProvider.cs} | 51 ++-- .../OnlineFoundationCapabilityModule.cs | 103 ++++--- .../OnlineFoundationOptions.cs | 39 ++- .../Presence/OnlinePartyPresenceProbe.cs | 72 +++++ .../Presence/OnlinePresenceEvents.cs | 2 +- .../Presence/OnlineSocialPresenceProbe.cs | 71 +++++ .../Session/OnlineSessionEvents.cs | 20 +- .../Storage/OnlinePlayerStorageService.cs | 6 +- .../GameFrameX.Capability.Online.Match.csproj | 8 +- .../OnlineMatchActor.cs | 6 +- .../OnlineMatchCapabilityModule.cs | 139 ++++++--- .../OnlineMatchHostingExtensions.cs | 93 ------ .../OnlineMatchOptions.cs | 37 ++- .../OnlineMatchRuntime.cs | 6 +- .../OnlineMatchRuntimeEvents.cs | 4 +- ...rameX.Capability.Online.Matchmaking.csproj | 2 +- .../OnlineMatchEvents.cs | 4 +- .../OnlineMatchListingService.cs | 2 +- .../OnlineMatchTicketService.cs | 6 +- .../OnlineMatchmakerCoordinator.cs | 6 +- .../OnlineMatchmakingCapabilityModule.cs | 91 +++--- .../OnlineMatchmakingHostingExtensions.cs | 106 ------- .../OnlineMatchmakingOptions.cs | 35 ++- .../Party/OnlinePartyEvents.cs | 4 +- .../Party/OnlinePartyService.cs | 15 +- .../AdminApi/OnlineAdminConsoleHandlers.cs | 2 +- ...FrameX.Capability.Online.Operations.csproj | 6 +- .../OnlineHotfixArtifactFetcher.cs | 6 +- .../HotfixRollback/OnlineHotfixEvents.cs | 4 +- .../OnlineHotfixRollbackService.cs | 2 +- .../OnlineOperationsCapabilityModule.cs | 281 ++++++++++-------- .../OnlineOperationsHostingExtensions.cs | 126 -------- .../OnlineOperationsOptions.cs | 42 ++- .../Timeline/OnlinePlayerTimelineService.cs | 6 +- ...rameX.Capability.Online.Persistence.csproj | 6 +- .../OnlinePersistenceHostingExtensions.cs | 75 ----- .../ChatMessageStateTransition.cs | 2 + ...GameFrameX.Capability.Online.Social.csproj | 8 +- .../IOnlineChatStore.cs | 2 + .../InMemoryOnlineChatStore.cs | 2 + .../OnlineChatAuditProjection.cs | 1 + .../OnlineChatEvents.cs | 8 +- .../OnlineChatHistoryPage.cs | 1 + .../OnlineChatModerationAllowAllHook.cs | 58 ++++ .../OnlineChatService.cs | 96 ++++-- .../OnlineFriendLegacyMigration.cs | 205 +++++++++++++ .../OnlineFriendOptions.cs | 65 ++++ .../OnlineFriendService.cs | 25 +- .../OnlineNotificationEvents.cs | 2 +- .../OnlineSocialCapabilityModule.cs | 187 +++++++----- .../OnlineSocialEvents.cs | 12 +- .../OnlineSocialHostingExtensions.cs | 144 --------- .../OnlineSocialOptions.cs | 89 ++++-- 97 files changed, 1804 insertions(+), 2563 deletions(-) delete mode 100644 Capability/GameFrameX.Capability.Online.Abstractions/Bridges/OnlineFriendDomainBridge.cs delete mode 100644 Capability/GameFrameX.Capability.Online.Abstractions/Bridges/OnlineFriendDomainBridgeHolder.cs delete mode 100644 Capability/GameFrameX.Capability.Online.Abstractions/Bridges/OnlineMatchDomainBridge.cs delete mode 100644 Capability/GameFrameX.Capability.Online.Abstractions/Bridges/OnlineMatchDomainBridgeHolder.cs delete mode 100644 Capability/GameFrameX.Capability.Online.Abstractions/Bridges/OnlineMatchGameBridge.cs delete mode 100644 Capability/GameFrameX.Capability.Online.Abstractions/Bridges/OnlineMatchGameBridgeHolder.cs delete mode 100644 Capability/GameFrameX.Capability.Online.Abstractions/Bridges/OnlineSessionDomainBridge.cs delete mode 100644 Capability/GameFrameX.Capability.Online.Abstractions/Bridges/OnlineSessionDomainBridgeHolder.cs rename Capability/{GameFrameX.Capability.Online.Matchmaking/Party => GameFrameX.Capability.Online.Abstractions/Contracts}/IOnlinePartyPresenceProbe.cs (98%) rename Capability/{GameFrameX.Capability.Online.Social => GameFrameX.Capability.Online.Abstractions/Contracts}/IOnlinePlayerDirectory.cs (98%) rename Capability/{GameFrameX.Capability.Online.Social => GameFrameX.Capability.Online.Abstractions/Contracts}/IOnlineSocialPresenceProbe.cs (98%) rename Capability/{GameFrameX.Capability.Online.Social => GameFrameX.Capability.Online.Abstractions/Contracts}/OnlinePlayerDirectoryEntry.cs (97%) create mode 100644 Capability/GameFrameX.Capability.Online.Abstractions/OnlineCapabilityFamily.cs create mode 100644 Capability/GameFrameX.Capability.Online.Abstractions/Scope/IOnlineAuthorizedScopeProvider.cs delete mode 100644 Capability/GameFrameX.Capability.Online.Assets/OnlineAssetsHostingExtensions.cs delete mode 100644 Capability/GameFrameX.Capability.Online.Assets/OnlineAssetsOptions.cs delete mode 100644 Capability/GameFrameX.Capability.Online.Competition/OnlineCompetitionHostingExtensions.cs rename Capability/GameFrameX.Capability.Online.Foundation/{OnlineFoundationHostingExtensions.cs => OnlineAuthorizedScopeProvider.cs} (50%) create mode 100644 Capability/GameFrameX.Capability.Online.Foundation/Presence/OnlinePartyPresenceProbe.cs create mode 100644 Capability/GameFrameX.Capability.Online.Foundation/Presence/OnlineSocialPresenceProbe.cs delete mode 100644 Capability/GameFrameX.Capability.Online.Match/OnlineMatchHostingExtensions.cs delete mode 100644 Capability/GameFrameX.Capability.Online.Matchmaking/OnlineMatchmakingHostingExtensions.cs delete mode 100644 Capability/GameFrameX.Capability.Online.Operations/OnlineOperationsHostingExtensions.cs delete mode 100644 Capability/GameFrameX.Capability.Online.Persistence/OnlinePersistenceHostingExtensions.cs create mode 100644 Capability/GameFrameX.Capability.Online.Social/OnlineChatModerationAllowAllHook.cs create mode 100644 Capability/GameFrameX.Capability.Online.Social/OnlineFriendLegacyMigration.cs create mode 100644 Capability/GameFrameX.Capability.Online.Social/OnlineFriendOptions.cs delete mode 100644 Capability/GameFrameX.Capability.Online.Social/OnlineSocialHostingExtensions.cs diff --git a/Capability/GameFrameX.Capability.Online.Abstractions/Audit/IOnlineAuditStore.cs b/Capability/GameFrameX.Capability.Online.Abstractions/Audit/IOnlineAuditStore.cs index 467a7e57a..2b45dcdb0 100644 --- a/Capability/GameFrameX.Capability.Online.Abstractions/Audit/IOnlineAuditStore.cs +++ b/Capability/GameFrameX.Capability.Online.Abstractions/Audit/IOnlineAuditStore.cs @@ -32,7 +32,7 @@ using System.Threading; using System.Threading.Tasks; -namespace GameFrameX.Capability.Online.Operations.Audit; +namespace GameFrameX.Capability.Online.Abstractions.Audit; /// /// 统一审计存储契约(统一审计链路的唯一持久化面;生产持久化实现归运行时装配)。 diff --git a/Capability/GameFrameX.Capability.Online.Abstractions/Audit/InMemoryOnlineAuditStore.cs b/Capability/GameFrameX.Capability.Online.Abstractions/Audit/InMemoryOnlineAuditStore.cs index ba06b0380..ba9048f7f 100644 --- a/Capability/GameFrameX.Capability.Online.Abstractions/Audit/InMemoryOnlineAuditStore.cs +++ b/Capability/GameFrameX.Capability.Online.Abstractions/Audit/InMemoryOnlineAuditStore.cs @@ -33,7 +33,7 @@ using System.Threading; using System.Threading.Tasks; -namespace GameFrameX.Capability.Online.Operations.Audit; +namespace GameFrameX.Capability.Online.Abstractions.Audit; /// /// 统一审计内存存储(单进程默认实现;生产持久化实现归运行时装配)。 diff --git a/Capability/GameFrameX.Capability.Online.Abstractions/Audit/OnlineAuditEntry.cs b/Capability/GameFrameX.Capability.Online.Abstractions/Audit/OnlineAuditEntry.cs index 500ad6ae1..20d5af55f 100644 --- a/Capability/GameFrameX.Capability.Online.Abstractions/Audit/OnlineAuditEntry.cs +++ b/Capability/GameFrameX.Capability.Online.Abstractions/Audit/OnlineAuditEntry.cs @@ -30,7 +30,7 @@ using GameFrameX.Capability.Online.Abstractions.Contracts; using System.Collections.Generic; -namespace GameFrameX.Capability.Online.Operations.Audit; +namespace GameFrameX.Capability.Online.Abstractions.Audit; /// /// 统一审计接入条目(Admin 既有支付/奖励/邮件/兑换码/远程配置/处罚审计 diff --git a/Capability/GameFrameX.Capability.Online.Abstractions/Audit/OnlineAuditIngestOutcome.cs b/Capability/GameFrameX.Capability.Online.Abstractions/Audit/OnlineAuditIngestOutcome.cs index 749633fa4..25950ce92 100644 --- a/Capability/GameFrameX.Capability.Online.Abstractions/Audit/OnlineAuditIngestOutcome.cs +++ b/Capability/GameFrameX.Capability.Online.Abstractions/Audit/OnlineAuditIngestOutcome.cs @@ -27,7 +27,7 @@ // Official documentation: https://gameframex.doc.alianblank.com/ // ========================================================================================== -namespace GameFrameX.Capability.Online.Operations.Audit; +namespace GameFrameX.Capability.Online.Abstractions.Audit; /// /// 统一审计接入回执(幂等语义:重复接入同一 EventId 不报错、不重复落档、不产生副作用, diff --git a/Capability/GameFrameX.Capability.Online.Abstractions/Audit/OnlineAuditService.cs b/Capability/GameFrameX.Capability.Online.Abstractions/Audit/OnlineAuditService.cs index 18c938956..933905041 100644 --- a/Capability/GameFrameX.Capability.Online.Abstractions/Audit/OnlineAuditService.cs +++ b/Capability/GameFrameX.Capability.Online.Abstractions/Audit/OnlineAuditService.cs @@ -36,7 +36,7 @@ using GameFrameX.Capability.Online.Abstractions.Events; using GameFrameX.Capability.Online.Abstractions.Scope; -namespace GameFrameX.Capability.Online.Operations.Audit; +namespace GameFrameX.Capability.Online.Abstractions.Audit; /// /// 统一审计链路服务(Admin 既有支付/奖励/邮件/兑换码/远程配置/处罚审计与 @@ -81,7 +81,14 @@ public sealed class OnlineAuditService : IOnlineAuditReader public OnlineAuditService(IOnlineAuditStore store, OnlineEventSanitizer sanitizer = null) { _store = store ?? throw new ArgumentNullException(nameof(store)); - _sanitizer = sanitizer ?? new OnlineEventSanitizer(); + if (sanitizer != null) + { + _sanitizer = sanitizer; + } + else + { + _sanitizer = new OnlineEventSanitizer(); + } } /// @@ -148,7 +155,11 @@ public async Task> QueryAsync(OnlineScope scope, O return OnlineResult.Fail(OnlineErrorCode.ParameterInvalid, "统一审计检索必须锚定租户与 App(TenantId / AppId 大于 0;区服与玩家走查询条件可选过滤)"); } - var request = query ?? new OnlineAuditQuery(); + var request = query; + if (request == null) + { + request = new OnlineAuditQuery(); + } var pageSize = request.PageSize <= 0 ? DefaultPageSize : request.PageSize; if (pageSize > MaxPageSize) diff --git a/Capability/GameFrameX.Capability.Online.Abstractions/Bridges/OnlineFriendDomainBridge.cs b/Capability/GameFrameX.Capability.Online.Abstractions/Bridges/OnlineFriendDomainBridge.cs deleted file mode 100644 index 5e372ef98..000000000 --- a/Capability/GameFrameX.Capability.Online.Abstractions/Bridges/OnlineFriendDomainBridge.cs +++ /dev/null @@ -1,223 +0,0 @@ -// ========================================================================================== -// 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 refer to 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 and liabilities arising from secondary development based on this project -// 本组织及其贡献者不承担任何责任,开发者应自行承担。 -// The organization and contributors assume no responsibility; developers shall bear the responsibility themselves. -// 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.Generic; -using System.Threading; -using System.Threading.Tasks; -using GameFrameX.Capability.Online.Abstractions.Contracts; -using GameFrameX.Capability.Online.Abstractions.Scope; - -namespace GameFrameX.Capability.Online.Abstractions.Bridges; - -/// -/// 好友域桥租约(C244,沿 C243 / C245 对局桥形态): -/// 组合根发布给进程内消费方(Hotfix 薄适配器)的吊销式访问句柄——持有域契约面与授权作用域三元组, -/// 撤销后任何旧引用调用一律显式 失败。 -/// -/// 维护约束:撤销是一次性闸门( 幂等);每次调用前复查活跃位——宿主停止 / 重启 / -/// 重新发布后,先前捕获的 lease 引用不得继续读写旧好友数据;作用域三元组来自组合根授权上下文, -/// 玩家位由调用方按请求主体填充,lease 不缓存任何请求态。 -/// -/// -/// -/// The friend-domain bridge lease (C244, following the C243 friend-query and C245 match-bridge lease -/// shapes): the revocable access handle published by the composition root for in-process consumers -/// (the Hotfix thin adapter) — it carries the domain contract face and the authorized scope triple, -/// and once revoked every call through a stale reference fails explicitly with -/// . Revocation is a one-way idempotent gate -/// re-checked before each call, so references captured before a host stop / restart / republish can -/// never keep reading or writing stale friend data; the scope triple comes from the composition -/// root's authorized context and the player position is filled per request by the caller — the lease -/// itself caches no request state. -/// -public sealed class OnlineFriendDomainBridgeLease : IOnlineFriendDomain -{ - /// 契约面(撤销后不再可达)。 - private readonly IOnlineFriendDomain domain; - - /// 活跃位(volatile:撤销后旧引用立即显式失败)。 - private volatile bool active = true; - - /// - /// 初始化 。 - /// - /// 域契约面。 - /// 授权作用域(三元组;玩家位为 0)。 - internal OnlineFriendDomainBridgeLease(IOnlineFriendDomain domain, OnlineScope authorizedScope) - { - this.domain = domain ?? throw new ArgumentNullException(nameof(domain)); - AuthorizedScope = authorizedScope ?? throw new ArgumentNullException(nameof(authorizedScope)); - } - - /// - /// 获取授权作用域三元组(租户 / App / 区服;玩家位为 0,由调用方按请求主体填充)。 - /// - public OnlineScope AuthorizedScope { get; } - - /// - /// 获取租约是否仍然活跃(撤销后为 false;活跃位不保证后续调用成功,仅保证撤销可见)。 - /// - public bool IsActive - { - get { return active; } - } - - /// - /// 撤销租约(幂等;由 驱动)。 - /// - internal void Deactivate() - { - active = false; - } - - /// - /// 经租约列出好友列表:活跃位复查后委托契约面;已撤销返回显式 - /// 失败(不回旧数据、不抛未分类异常)。 - /// - /// 生效作用域(必须含玩家主体位)。 - /// 取消令牌。 - /// 好友列表条目。 - public Task>> ListFriendsAsync(OnlineScope scope, CancellationToken cancellationToken = default) - { - if (!active) - { - return Task.FromResult(OnlineResult>.Fail( - OnlineErrorCode.DependencyUnavailable, "好友域桥已撤销(宿主未运行或已停止)")); - } - - return domain.ListFriendsAsync(scope, cancellationToken); - } - - /// - /// 经租约列出全量关系:活跃位复查后委托契约面;已撤销返回显式失败。 - /// - /// 生效作用域(必须含玩家主体位)。 - /// 取消令牌。 - /// 关系条目列表。 - public Task>> ListRelationsAsync(OnlineScope scope, CancellationToken cancellationToken = default) - { - if (!active) - { - return Task.FromResult(OnlineResult>.Fail( - OnlineErrorCode.DependencyUnavailable, "好友域桥已撤销(宿主未运行或已停止)")); - } - - return domain.ListRelationsAsync(scope, cancellationToken); - } - - /// - /// 经租约发起好友申请:活跃位复查后委托契约面;已撤销返回显式失败。 - /// - /// 生效作用域(必须含玩家主体位)。 - /// 目标玩家标识。 - /// 取消令牌。 - /// 操作前后的关系分类。 - public Task> RequestAsync(OnlineScope scope, long targetPlayerId, CancellationToken cancellationToken = default) - { - if (!active) - { - return Task.FromResult(OnlineResult.Fail( - OnlineErrorCode.DependencyUnavailable, "好友域桥已撤销(宿主未运行或已停止)")); - } - - return domain.RequestAsync(scope, targetPlayerId, cancellationToken); - } - - /// - /// 经租约接受好友申请:活跃位复查后委托契约面;已撤销返回显式失败。 - /// - /// 生效作用域(必须含玩家主体位)。 - /// 申请来源玩家标识。 - /// 取消令牌。 - /// 操作前后的关系分类。 - public Task> AcceptAsync(OnlineScope scope, long fromPlayerId, CancellationToken cancellationToken = default) - { - if (!active) - { - return Task.FromResult(OnlineResult.Fail( - OnlineErrorCode.DependencyUnavailable, "好友域桥已撤销(宿主未运行或已停止)")); - } - - return domain.AcceptAsync(scope, fromPlayerId, cancellationToken); - } - - /// - /// 经租约拒绝好友申请:活跃位复查后委托契约面;已撤销返回显式失败。 - /// - /// 生效作用域(必须含玩家主体位)。 - /// 申请来源玩家标识。 - /// 取消令牌。 - /// 操作前后的关系分类。 - public Task> RejectAsync(OnlineScope scope, long fromPlayerId, CancellationToken cancellationToken = default) - { - if (!active) - { - return Task.FromResult(OnlineResult.Fail( - OnlineErrorCode.DependencyUnavailable, "好友域桥已撤销(宿主未运行或已停止)")); - } - - return domain.RejectAsync(scope, fromPlayerId, cancellationToken); - } - - /// - /// 经租约解除好友关系:活跃位复查后委托契约面;已撤销返回显式失败。 - /// - /// 生效作用域(必须含玩家主体位)。 - /// 对方玩家标识。 - /// 取消令牌。 - /// 操作前后的关系分类。 - public Task> RemoveAsync(OnlineScope scope, long targetPlayerId, CancellationToken cancellationToken = default) - { - if (!active) - { - return Task.FromResult(OnlineResult.Fail( - OnlineErrorCode.DependencyUnavailable, "好友域桥已撤销(宿主未运行或已停止)")); - } - - return domain.RemoveAsync(scope, targetPlayerId, cancellationToken); - } - - /// - /// 经租约直接建立好友关系:活跃位复查后委托契约面;已撤销返回显式失败。 - /// - /// 生效作用域(必须含玩家主体位)。 - /// 目标玩家标识。 - /// 取消令牌。 - /// 操作前后的关系分类。 - public Task> EstablishAsync(OnlineScope scope, long targetPlayerId, CancellationToken cancellationToken = default) - { - if (!active) - { - return Task.FromResult(OnlineResult.Fail( - OnlineErrorCode.DependencyUnavailable, "好友域桥已撤销(宿主未运行或已停止)")); - } - - return domain.EstablishAsync(scope, targetPlayerId, cancellationToken); - } -} diff --git a/Capability/GameFrameX.Capability.Online.Abstractions/Bridges/OnlineFriendDomainBridgeHolder.cs b/Capability/GameFrameX.Capability.Online.Abstractions/Bridges/OnlineFriendDomainBridgeHolder.cs deleted file mode 100644 index 3cfa99e61..000000000 --- a/Capability/GameFrameX.Capability.Online.Abstractions/Bridges/OnlineFriendDomainBridgeHolder.cs +++ /dev/null @@ -1,99 +0,0 @@ -// ========================================================================================== -// 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 refer to 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 -// 本组织及其贡献者不承担任何责任,开发者应自行承担。 -// The organization and contributors assume no responsibility; developers shall bear the responsibility themselves. -// 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.Capability.Online.Abstractions.Contracts; -using GameFrameX.Capability.Online.Abstractions.Scope; - -namespace GameFrameX.Capability.Online.Abstractions.Bridges; - -/// -/// 好友域桥发布器(C244,沿 C243/C245 holder 形态):进程级类型化 holder—— -/// 组合根(Game 型启动)在业务运行时启动后发布 , -/// 进程停止前撤销;消费方(Hotfix 薄适配器)逐请求读取 ,不缓存引用。 -/// -/// 维护约束:发布 / 撤销必须成对出现在同一组合根(AppStartUpGame);再发布替换并吊销既有租约, -/// 使任何历史引用立即显式失效;holder 不承载能力装配与路由——只有发布 / 撤销生命周期对。 -/// -/// -/// -/// The friend-domain bridge publisher (C244, following the C243/C245 holder shape): the process-wide -/// typed holder — the composition root (the Game-shape startup) publishes an -/// after the business runtime starts and retracts it -/// before process shutdown; consumers (the Hotfix thin adapter) read per -/// request without caching. Publish and retract must stay paired inside the same composition root -/// (AppStartUpGame); republishing replaces and revokes the previous lease so that any historical -/// reference fails explicitly at once; the holder carries no capability assembly and no routing — -/// only the publish / retract lifecycle pair. -/// -public static class OnlineFriendDomainBridgeHolder -{ - /// 当前租约(volatile 读;未发布 / 已撤销为 null)。 - private static volatile OnlineFriendDomainBridgeLease current; - - /// - /// 获取当前租约(未发布或已撤销时为 null;调用方不得跨请求缓存本值)。 - /// - public static OnlineFriendDomainBridgeLease Current - { - get { return current; } - } - - /// - /// 获取是否已发布有效租约。 - /// - public static bool IsPublished - { - get { return current != null; } - } - - /// - /// 发布租约(组合根在业务运行时启动后调用):替换并撤销既有租约,返回新租约。 - /// - /// 域契约面。 - /// 授权作用域(三元组)。 - /// 新发布的租约。 - public static OnlineFriendDomainBridgeLease Publish(IOnlineFriendDomain domain, OnlineScope authorizedScope) - { - var lease = new OnlineFriendDomainBridgeLease(domain, authorizedScope); - var previous = current; - current = lease; - previous?.Deactivate(); - return lease; - } - - /// - /// 撤销租约(组合根在进程停止路径调用):清空当前位并吊销全部既有租约(幂等)。 - /// - public static void Retract() - { - var previous = current; - current = null; - previous?.Deactivate(); - } -} diff --git a/Capability/GameFrameX.Capability.Online.Abstractions/Bridges/OnlineMatchDomainBridge.cs b/Capability/GameFrameX.Capability.Online.Abstractions/Bridges/OnlineMatchDomainBridge.cs deleted file mode 100644 index 736e2d034..000000000 --- a/Capability/GameFrameX.Capability.Online.Abstractions/Bridges/OnlineMatchDomainBridge.cs +++ /dev/null @@ -1,204 +0,0 @@ -// ========================================================================================== -// 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 refer to the LICENSE file at the source code root 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 disputes and liabilities arising from secondary development, -// 本组织不承担任何责任,开发者须自行承担。 -// The organization shall not be responsible for any disputes; developers shall bear the responsibility. -// ========================================================================================== - -using System; -using System.Threading; -using System.Threading.Tasks; -using GameFrameX.Capability.Online.Abstractions.Contracts; -using GameFrameX.Capability.Online.Abstractions.Scope; - -namespace GameFrameX.Capability.Online.Abstractions.Bridges; - -/// -/// 匹配域桥租约(C246,沿 C244 好友域桥 / C245 对局桥形态):组合根发布给进程内消费方 -/// (Hotfix 薄适配器)的吊销式访问句柄——持有域契约面与授权作用域三元组,撤销后任何旧引用调用 -/// 一律显式 失败。 -/// -/// 维护约束:撤销是一次性闸门( 幂等);每次调用前复查活跃位——宿主停止 / -/// 重启 / 重新发布后,先前捕获的 lease 引用不得继续读写旧匹配数据;作用域三元组来自组合根授权上下文, -/// 玩家位由调用方按请求主体填充,lease 不缓存任何请求态。 -/// -/// -public sealed class OnlineMatchDomainBridgeLease : IOnlineMatchDomain -{ - /// 契约面(撤销后不再可达)。 - private readonly IOnlineMatchDomain domain; - - /// 活跃位(volatile:撤销后旧引用立即显式失败)。 - private volatile bool active = true; - - /// - /// 初始化 。 - /// - /// 域契约面。 - /// 授权作用域(三元组;玩家位为 0)。 - internal OnlineMatchDomainBridgeLease(IOnlineMatchDomain domain, OnlineScope authorizedScope) - { - this.domain = domain ?? throw new ArgumentNullException(nameof(domain)); - AuthorizedScope = authorizedScope ?? throw new ArgumentNullException(nameof(authorizedScope)); - } - - /// - /// 获取授权作用域三元组(租户 / App / 区服;玩家位为 0,由调用方按请求主体填充)。 - /// - public OnlineScope AuthorizedScope { get; } - - /// - /// 获取租约是否仍然活跃(撤销后为 false;活跃位不保证后续调用成功,仅保证撤销可见)。 - /// - public bool IsActive - { - get { return active; } - } - - /// - /// 撤销租约(幂等;由 驱动)。 - /// - internal void Deactivate() - { - active = false; - } - - /// - /// 经租约入队:活跃位复查后委托契约面;已撤销返回显式 失败。 - /// - /// 生效作用域(必须含玩家主体位)。 - /// 匹配模式。 - /// 玩家评分。 - /// 取消令牌。 - /// 入队回执。 - public Task> EnqueueAsync(OnlineScope scope, int mode, int rating, CancellationToken cancellationToken = default) - { - if (!active) - { - return Task.FromResult(OnlineResult.Fail( - OnlineErrorCode.DependencyUnavailable, "匹配域桥已撤销(宿主未运行或已停止)")); - } - - return domain.EnqueueAsync(scope, mode, rating, cancellationToken); - } - - /// - /// 经租约取消:活跃位复查后委托契约面;已撤销返回显式失败。 - /// - /// 生效作用域(必须含玩家主体位)。 - /// 取消令牌。 - /// 取消回执。 - public Task> CancelAsync(OnlineScope scope, CancellationToken cancellationToken = default) - { - if (!active) - { - return Task.FromResult(OnlineResult.Fail( - OnlineErrorCode.DependencyUnavailable, "匹配域桥已撤销(宿主未运行或已停止)")); - } - - return domain.CancelAsync(scope, cancellationToken); - } - - /// - /// 经租约查询状态:活跃位复查后委托契约面;已撤销返回显式失败。 - /// - /// 生效作用域(必须含玩家主体位)。 - /// 取消令牌。 - /// 状态回执。 - public Task> GetStatusAsync(OnlineScope scope, CancellationToken cancellationToken = default) - { - if (!active) - { - return Task.FromResult(OnlineResult.Fail( - OnlineErrorCode.DependencyUnavailable, "匹配域桥已撤销(宿主未运行或已停止)")); - } - - return domain.GetStatusAsync(scope, cancellationToken); - } - - /// - /// 经租约入场:活跃位复查后委托契约面;已撤销返回显式失败。 - /// - /// 生效作用域(必须含玩家主体位)。 - /// 协议数值对局标识。 - /// 取消令牌。 - /// 入场回执。 - public Task> EnterAsync(OnlineScope scope, long matchId, CancellationToken cancellationToken = default) - { - if (!active) - { - return Task.FromResult(OnlineResult.Fail( - OnlineErrorCode.DependencyUnavailable, "匹配域桥已撤销(宿主未运行或已停止)")); - } - - return domain.EnterAsync(scope, matchId, cancellationToken); - } - - /// - /// 经租约解析分配:活跃位复查后委托契约面;已撤销返回显式失败。 - /// - /// 租户标识。 - /// App 标识。 - /// 分配标识。 - /// 取消令牌。 - /// 分配解析视图。 - public Task> ResolveAssignmentAsync(long tenantId, long appId, string assignmentId, CancellationToken cancellationToken = default) - { - if (!active) - { - return Task.FromResult(OnlineResult.Fail( - OnlineErrorCode.DependencyUnavailable, "匹配域桥已撤销(宿主未运行或已停止)")); - } - - return domain.ResolveAssignmentAsync(tenantId, appId, assignmentId, cancellationToken); - } - - /// - /// 经租约会话中介断线通知:活跃位复查后委托契约面;已撤销返回显式失败。 - /// - /// 生效作用域(必须含玩家主体位)。 - /// 取消令牌。 - /// 断线标记回执。 - public Task> NotifyPlayerDisconnectedAsync(OnlineScope scope, CancellationToken cancellationToken = default) - { - if (!active) - { - return Task.FromResult(OnlineResult.Fail( - OnlineErrorCode.DependencyUnavailable, "匹配域桥已撤销(宿主未运行或已停止)")); - } - - return domain.NotifyPlayerDisconnectedAsync(scope, cancellationToken); - } - - /// - /// 经租约会话中介恢复:活跃位复查后委托契约面;已撤销返回显式失败。 - /// - /// 生效作用域(必须含玩家主体位)。 - /// 客户端最后确认的服务器序号。 - /// 取消令牌。 - /// 恢复回执。 - public Task> TryResumeForPlayerAsync(OnlineScope scope, long lastAcknowledgedSequence, CancellationToken cancellationToken = default) - { - if (!active) - { - return Task.FromResult(OnlineResult.Fail( - OnlineErrorCode.DependencyUnavailable, "匹配域桥已撤销(宿主未运行或已停止)")); - } - - return domain.TryResumeForPlayerAsync(scope, lastAcknowledgedSequence, cancellationToken); - } -} diff --git a/Capability/GameFrameX.Capability.Online.Abstractions/Bridges/OnlineMatchDomainBridgeHolder.cs b/Capability/GameFrameX.Capability.Online.Abstractions/Bridges/OnlineMatchDomainBridgeHolder.cs deleted file mode 100644 index e685c09ef..000000000 --- a/Capability/GameFrameX.Capability.Online.Abstractions/Bridges/OnlineMatchDomainBridgeHolder.cs +++ /dev/null @@ -1,81 +0,0 @@ -// ========================================================================================== -// 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 refer to the LICENSE file at the source code root 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 disputes and liabilities arising from secondary development, -// 本组织不承担任何责任,开发者须自行承担。 -// The organization shall not be responsible for any disputes; developers shall bear the responsibility. -// ========================================================================================== - -using GameFrameX.Capability.Online.Abstractions.Contracts; -using GameFrameX.Capability.Online.Abstractions.Scope; - -namespace GameFrameX.Capability.Online.Abstractions.Bridges; - -/// -/// 匹配域桥发布器(C246,沿 C244 好友域桥 / C245 对局桥 holder 形态):进程级类型化 holder—— -/// 组合根(Game 型启动)在业务运行时启动后发布 , -/// 进程停止前撤销;消费方(Hotfix 薄适配器)逐请求读取 ,不缓存引用。 -/// -/// 维护约束:发布 / 撤销必须成对出现在同一组合根(AppStartUpGame);再发布替换并吊销既有租约, -/// 使任何历史引用立即显式失效;holder 不承载能力装配与路由——只有发布 / 撤销生命周期对。 -/// -/// -public static class OnlineMatchDomainBridgeHolder -{ - /// 当前租约(volatile 读;未发布 / 已撤销为 null)。 - private static volatile OnlineMatchDomainBridgeLease current; - - /// - /// 获取当前租约(未发布或已撤销时为 null;调用方不得跨请求缓存本值)。 - /// - public static OnlineMatchDomainBridgeLease Current - { - get { return current; } - } - - /// - /// 获取是否已发布有效租约。 - /// - public static bool IsPublished - { - get { return current != null; } - } - - /// - /// 发布租约(组合根在业务运行时启动后调用):替换并撤销既有租约,返回新租约。 - /// - /// 域契约面。 - /// 授权作用域(三元组)。 - /// 新发布的租约。 - public static OnlineMatchDomainBridgeLease Publish(IOnlineMatchDomain domain, OnlineScope authorizedScope) - { - var lease = new OnlineMatchDomainBridgeLease(domain, authorizedScope); - var previous = current; - current = lease; - previous?.Deactivate(); - return lease; - } - - /// - /// 撤销租约(组合根在进程停止路径调用):清空当前位并吊销全部既有租约(幂等)。 - /// - public static void Retract() - { - var previous = current; - current = null; - previous?.Deactivate(); - } -} diff --git a/Capability/GameFrameX.Capability.Online.Abstractions/Bridges/OnlineMatchGameBridge.cs b/Capability/GameFrameX.Capability.Online.Abstractions/Bridges/OnlineMatchGameBridge.cs deleted file mode 100644 index d5e1c4b7b..000000000 --- a/Capability/GameFrameX.Capability.Online.Abstractions/Bridges/OnlineMatchGameBridge.cs +++ /dev/null @@ -1,151 +0,0 @@ -// ========================================================================================== -// 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 applicable 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 refer to 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 legal rights and interests of others as prohibited by laws and regulations! -// 因基于本项目二次开发所产生的一切纠纷与责任, -// Any legal disputes or liabilities arising from secondary development based on this project -// 本组织不承担任何责任,开发者应自行承担。 -// The organization and contributors shall not be responsible; developers shall bear the responsibility themselves. -// ========================================================================================== - -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using GameFrameX.Capability.Online.Abstractions.Contracts; -using GameFrameX.Capability.Online.Abstractions.Scope; - -namespace GameFrameX.Capability.Online.Abstractions.Bridges; - -/// -/// 对局样例桥租约(C245,沿 C243/C244 好友桥租约形态):组合根发布给进程内 -/// 消费方(Hotfix 薄适配器)的吊销式访问句柄——持有契约面与授权作用域三元组,撤销后任何旧引用 -/// 调用一律显式 失败。 -/// -/// 维护约束:撤销是一次性闸门( 幂等);每次调用前复查活跃位——宿主停止 / -/// 重启 / 重新发布后,先前捕获的 lease 引用不得继续读写旧对局数据;作用域三元组来自组合根授权 -/// 上下文,玩家位由调用方按请求主体填充,lease 不缓存任何请求态。 -/// -/// -/// -/// The sample match bridge lease (C245, following the C243/C244 friend-bridge lease shape): -/// the revocable access handle published by the composition root for in-process consumers (the Hotfix -/// thin adapter) — it carries the contract face and the authorized scope triple, and once revoked every -/// call through a stale reference fails explicitly with . -/// Revocation is a one-way idempotent gate re-checked before each call, so references captured before a -/// host stop / restart / republish can never keep reading or writing stale match data; the scope triple -/// comes from the composition root's authorized context and the player position is filled per request by -/// the caller — the lease itself caches no request state. -/// -public sealed class OnlineMatchGameBridgeLease : IOnlineMatchGameBridge -{ - /// 契约面(撤销后不再可达)。 - private readonly IOnlineMatchGameBridge bridge; - - /// 活跃位(volatile:撤销后旧引用立即显式失败)。 - private volatile bool active = true; - - /// - /// 初始化 。 - /// - /// 契约面。 - /// 授权作用域(三元组;玩家位为 0)。 - internal OnlineMatchGameBridgeLease(IOnlineMatchGameBridge bridge, OnlineScope authorizedScope) - { - this.bridge = bridge ?? throw new ArgumentNullException(nameof(bridge)); - AuthorizedScope = authorizedScope ?? throw new ArgumentNullException(nameof(authorizedScope)); - } - - /// - /// 获取授权作用域三元组(租户 / App / 区服;玩家位为 0,由调用方按请求主体填充)。 - /// - public OnlineScope AuthorizedScope { get; } - - /// - /// 获取租约是否仍然活跃(撤销后为 false;活跃位不保证后续调用成功,仅保证撤销可见)。 - /// - public bool IsActive - { - get { return active; } - } - - /// - /// 撤销租约(幂等;由 驱动)。 - /// - internal void Deactivate() - { - active = false; - } - - /// - /// 经租约开启一局平台对局:活跃位复查后委托契约面;已撤销返回显式 - /// 失败(不回旧数据、不抛未分类异常)。 - /// - /// 生效作用域(必须含玩家主体位)。 - /// 房间标识。 - /// 局号。 - /// 本局玩家集合。 - /// 取消令牌。 - /// 开局后的对局视图。 - public Task> StartRoomGameAsync(OnlineScope scope, long roomId, int round, IReadOnlyList playerIds, CancellationToken cancellationToken = default) - { - if (!active) - { - return Task.FromResult(OnlineResult.Fail( - OnlineErrorCode.DependencyUnavailable, "对局桥已撤销(宿主未运行或已停止)")); - } - - return bridge.StartRoomGameAsync(scope, roomId, round, playerIds, cancellationToken); - } - - /// - /// 经租约提交一次出拳意图:活跃位复查后委托契约面;已撤销返回显式 - /// 失败。 - /// - /// 生效作用域(必须含玩家主体位)。 - /// 房间标识。 - /// 局号。 - /// 出拳值(1 = 石头,2 = 剪刀,3 = 布)。 - /// 取消令牌。 - /// 提交后的对局视图。 - public Task> SubmitGestureAsync(OnlineScope scope, long roomId, int round, int gesture, CancellationToken cancellationToken = default) - { - if (!active) - { - return Task.FromResult(OnlineResult.Fail( - OnlineErrorCode.DependencyUnavailable, "对局桥已撤销(宿主未运行或已停止)")); - } - - return bridge.SubmitGestureAsync(scope, roomId, round, gesture, cancellationToken); - } - - /// - /// 经租约查询对局视图:活跃位复查后委托契约面;已撤销返回显式 - /// 失败。 - /// - /// 生效作用域(必须含玩家主体位)。 - /// 房间标识。 - /// 局号。 - /// 取消令牌。 - /// 对局视图。 - public Task> GetGameInfoAsync(OnlineScope scope, long roomId, int round, CancellationToken cancellationToken = default) - { - if (!active) - { - return Task.FromResult(OnlineResult.Fail( - OnlineErrorCode.DependencyUnavailable, "对局桥已撤销(宿主未运行或已停止)")); - } - - return bridge.GetGameInfoAsync(scope, roomId, round, cancellationToken); - } -} diff --git a/Capability/GameFrameX.Capability.Online.Abstractions/Bridges/OnlineMatchGameBridgeHolder.cs b/Capability/GameFrameX.Capability.Online.Abstractions/Bridges/OnlineMatchGameBridgeHolder.cs deleted file mode 100644 index d7a0d5b85..000000000 --- a/Capability/GameFrameX.Capability.Online.Abstractions/Bridges/OnlineMatchGameBridgeHolder.cs +++ /dev/null @@ -1,91 +0,0 @@ -// ========================================================================================== -// 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 applicable 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 refer to 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 legal rights and interests of others as prohibited by laws and regulations! -// 因基于本项目二次开发所产生的一切纠纷与责任, -// Any legal disputes or liabilities arising from secondary development based on this project -// 本组织不承担任何责任,开发者应自行承担。 -// The organization and contributors shall not be responsible; developers shall bear the responsibility themselves. -// ========================================================================================== - -using GameFrameX.Capability.Online.Abstractions.Contracts; -using GameFrameX.Capability.Online.Abstractions.Scope; - -namespace GameFrameX.Capability.Online.Abstractions.Bridges; - -/// -/// 对局样例桥发布器(C245,沿 C243/C244 好友桥 holder 形态):进程级类型化 holder—— -/// 组合根(Game 型启动)在业务运行时启动后发布 , -/// 进程停止前撤销;消费方(Hotfix 薄适配器)逐请求读取 ,不缓存引用。 -/// -/// 维护约束:发布即替换(旧 lease 自动撤销)——宿主重启语义下旧引用不存活; -/// 撤销清空当前位并吊销全部既有 lease;本 holder 不做能力装配、不做路由,只承载「发布 / 撤销」这对生命周期动作。 -/// -/// -/// -/// The sample match bridge publisher (C245, following the C243/C244 friend-bridge holder -/// shape): the process-wide typed holder — the composition root (the Game-shape startup) publishes an -/// after the business runtime starts and retracts it before -/// process shutdown; consumers (the Hotfix thin adapter) read per request and -/// never cache the reference. Publishing replaces (and revokes) any previous lease so no stale -/// reference survives a host restart; retracting clears the current slot and revokes every lease ever -/// handed out. The holder carries no capability assembly and no routing — only the publish / retract -/// lifecycle pair. -/// -public static class OnlineMatchGameBridgeHolder -{ - /// 当前租约(volatile 读;未发布 / 已撤销为 null)。 - private static volatile OnlineMatchGameBridgeLease current; - - /// - /// 获取当前租约(未发布或已撤销时为 null;调用方不得跨请求缓存本值)。 - /// - public static OnlineMatchGameBridgeLease Current - { - get { return current; } - } - - /// - /// 获取是否已发布有效租约。 - /// - public static bool IsPublished - { - get { return current != null; } - } - - /// - /// 发布租约(组合根在业务运行时启动后调用):替换并撤销既有租约,返回新租约。 - /// - /// 契约面。 - /// 授权作用域(三元组)。 - /// 新发布的租约。 - public static OnlineMatchGameBridgeLease Publish(IOnlineMatchGameBridge bridge, OnlineScope authorizedScope) - { - var lease = new OnlineMatchGameBridgeLease(bridge, authorizedScope); - var previous = current; - current = lease; - previous?.Deactivate(); - return lease; - } - - /// - /// 撤销租约(组合根在进程停止路径调用):清空当前位并吊销全部既有租约(幂等)。 - /// - public static void Retract() - { - var previous = current; - current = null; - previous?.Deactivate(); - } -} diff --git a/Capability/GameFrameX.Capability.Online.Abstractions/Bridges/OnlineSessionDomainBridge.cs b/Capability/GameFrameX.Capability.Online.Abstractions/Bridges/OnlineSessionDomainBridge.cs deleted file mode 100644 index 5e9a050e4..000000000 --- a/Capability/GameFrameX.Capability.Online.Abstractions/Bridges/OnlineSessionDomainBridge.cs +++ /dev/null @@ -1,129 +0,0 @@ -// ========================================================================================== -// GameFrameX 组织及其衍生项目的版权、商标、专利及其他相关权利 -// GameFrameX organization and its derivative projects' copyrights, trademarks, patents, and other related rights -// 均受中华人民共和国及相关国际法律法规保护。 -// are protected by the laws and regulations of the People's Republic of China and 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 at 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. - -using System; -using System.Threading; -using System.Threading.Tasks; -using GameFrameX.Capability.Online.Abstractions.Contracts; -using GameFrameX.Capability.Online.Abstractions.Scope; - -namespace GameFrameX.Capability.Online.Abstractions.Bridges; - -/// -/// 会话域桥租约(C204,沿 C246 形态):组合根发布给进程内 -/// 消费方(Hotfix 薄适配器)的吊销式访问句柄——持有会话恢复契约面与授权作用域三元组, -/// 撤销后任何旧引用调用一律显式 失败。 -/// -/// 维护约束:撤销是一次性闸门(幂等);每次调用前复查活跃位——宿主停止 / 重启 / 重新发布后, -/// 先前捕获的 lease 引用不得继续读写旧会话数据;作用域三元组来自组合根授权上下文, -/// 玩家位由调用方按请求主体填充,lease 不缓存任何请求态。 -/// -/// -public sealed class OnlineSessionDomainBridgeLease : IOnlineSessionReconnectContract -{ - /// 契约面(撤销后不再可达)。 - private readonly IOnlineSessionReconnectContract contract; - - /// 活跃位(volatile:撤销后旧引用立即显式失败)。 - private volatile bool active = true; - - /// - /// 初始化 。 - /// - /// 会话恢复契约面。 - /// 授权作用域(三元组;玩家位为 0)。 - internal OnlineSessionDomainBridgeLease(IOnlineSessionReconnectContract contract, OnlineScope authorizedScope) - { - this.contract = contract ?? throw new ArgumentNullException(nameof(contract)); - AuthorizedScope = authorizedScope ?? throw new ArgumentNullException(nameof(authorizedScope)); - } - - /// - /// 获取授权作用域三元组(租户 / App / 区服;玩家位为 0,由调用方按请求主体填充)。 - /// - public OnlineScope AuthorizedScope { get; } - - /// - /// 获取租约是否仍然活跃(撤销后为 false;活跃位不保证后续调用成功,仅保证撤销可见)。 - /// - public bool IsActive - { - get { return active; } - } - - /// - /// 撤销租约(幂等;由 驱动)。 - /// - internal void Deactivate() - { - active = false; - } - - /// - /// 经租约登录签发:活跃位复查后委托契约面;已撤销返回显式失败。 - /// - /// 生效作用域(必须含玩家主体位)。 - /// 当前物理连接标识。 - /// 取消令牌。 - /// 签发结果。 - public Task> IssueForLoginAsync(OnlineScope scope, string connectionId, CancellationToken cancellationToken = default) - { - if (!active) - { - return Task.FromResult(OnlineResult.Fail( - OnlineErrorCode.DependencyUnavailable, "会话域桥已撤销(宿主未运行或已停止)")); - } - - return contract.IssueForLoginAsync(scope, connectionId, cancellationToken); - } - - /// - /// 经租约恢复:活跃位复查后委托契约面;已撤销返回显式失败。 - /// - /// 生效作用域(必须含玩家主体位)。 - /// Token 明文。 - /// 新物理连接标识。 - /// 取消令牌。 - /// 恢复结果。 - public Task> ResumeAsync(OnlineScope scope, string token, string connectionId, CancellationToken cancellationToken = default) - { - if (!active) - { - return Task.FromResult(OnlineResult.Fail( - OnlineErrorCode.DependencyUnavailable, "会话域桥已撤销(宿主未运行或已停止)")); - } - - return contract.ResumeAsync(scope, token, connectionId, cancellationToken); - } - - /// - /// 经租约断线通知:活跃位复查后委托契约面;已撤销返回显式失败。 - /// - /// 生效作用域(必须含玩家主体位)。 - /// 重连窗口(秒)。 - /// 取消令牌。 - /// 操作结果。 - public Task> NotifyPlayerDisconnectedAsync(OnlineScope scope, long reconnectWindowSeconds, CancellationToken cancellationToken = default) - { - if (!active) - { - return Task.FromResult(OnlineResult.Fail( - OnlineErrorCode.DependencyUnavailable, "会话域桥已撤销(宿主未运行或已停止)")); - } - - return contract.NotifyPlayerDisconnectedAsync(scope, reconnectWindowSeconds, cancellationToken); - } -} diff --git a/Capability/GameFrameX.Capability.Online.Abstractions/Bridges/OnlineSessionDomainBridgeHolder.cs b/Capability/GameFrameX.Capability.Online.Abstractions/Bridges/OnlineSessionDomainBridgeHolder.cs deleted file mode 100644 index 4ce0c0926..000000000 --- a/Capability/GameFrameX.Capability.Online.Abstractions/Bridges/OnlineSessionDomainBridgeHolder.cs +++ /dev/null @@ -1,76 +0,0 @@ -// ========================================================================================== -// GameFrameX 组织及其衍生项目的版权、商标、专利及其他相关权利 -// GameFrameX organization and its derivative projects' copyrights, trademarks, patents, and other related rights -// 均受中华人民共和国及相关国际法律法规保护。 -// are protected by the laws and regulations of the People's Republic of China and 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 at 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. - -using GameFrameX.Capability.Online.Abstractions.Contracts; -using GameFrameX.Capability.Online.Abstractions.Scope; - -namespace GameFrameX.Capability.Online.Abstractions.Bridges; - -/// -/// 会话域桥发布器(C204,沿 C246 形态):进程级类型化 -/// holder——组合根(Game 型启动)在业务运行时启动后发布 , -/// 进程停止前撤销;消费方(Hotfix 薄适配器)逐请求读取 ,不缓存引用。 -/// -/// 维护约束:发布 / 撤销必须成对出现在同一组合根(AppStartUpGame);再发布替换并吊销既有租约, -/// 使任何历史引用立即显式失效;holder 不承载能力装配与路由——只有发布 / 撤销生命周期对。 -/// -/// -public static class OnlineSessionDomainBridgeHolder -{ - /// 当前租约(volatile 读;未发布 / 已撤销为 null)。 - private static volatile OnlineSessionDomainBridgeLease current; - - /// - /// 获取当前租约(未发布或已撤销时为 null;调用方不得跨请求缓存本值)。 - /// - public static OnlineSessionDomainBridgeLease Current - { - get { return current; } - } - - /// - /// 获取是否已发布有效租约。 - /// - public static bool IsPublished - { - get { return current != null; } - } - - /// - /// 发布租约(组合根在业务运行时启动后调用):替换并撤销既有租约,返回新租约。 - /// - /// 会话恢复契约面。 - /// 授权作用域(三元组)。 - /// 新发布的租约。 - public static OnlineSessionDomainBridgeLease Publish(IOnlineSessionReconnectContract contract, OnlineScope authorizedScope) - { - var lease = new OnlineSessionDomainBridgeLease(contract, authorizedScope); - var previous = current; - current = lease; - previous?.Deactivate(); - return lease; - } - - /// - /// 撤销租约(组合根在进程停止路径调用):清空当前位并吊销全部既有租约(幂等)。 - /// - public static void Retract() - { - var previous = current; - current = null; - previous?.Deactivate(); - } -} diff --git a/Capability/GameFrameX.Capability.Online.Abstractions/Contracts/IOnlineAuditReader.cs b/Capability/GameFrameX.Capability.Online.Abstractions/Contracts/IOnlineAuditReader.cs index 66c92e14e..fa493c182 100644 --- a/Capability/GameFrameX.Capability.Online.Abstractions/Contracts/IOnlineAuditReader.cs +++ b/Capability/GameFrameX.Capability.Online.Abstractions/Contracts/IOnlineAuditReader.cs @@ -37,7 +37,7 @@ namespace GameFrameX.Capability.Online.Abstractions.Contracts; /// 不引入对聚合域(Operations/Audit 实现体)的编译期依赖。 /// /// 维护约束:作用域只锚定 TenantId/AppId 两键(审计是跨区服检索域);实现方 = OnlineAuditService -/// (P4 归 Operations 包);装配经能力服务共享面注入(组合根 UseService<IOnlineAuditReader>)。 +///(C294 起随族基建落在 Online.Abstractions.Audit,由 OnlineCapabilityFamily 预置到共享服务面)。 /// /// /// @@ -46,8 +46,8 @@ namespace GameFrameX.Capability.Online.Abstractions.Contracts; /// handlers query domain-scoped audit trails (e.g. query_asset_audit) through this interface without a /// compile-time dependency on the aggregate domain (the Operations/Audit implementation). The scope anchors /// on TenantId/AppId only (audit is a cross-server retrieval domain); the implementor is -/// OnlineAuditService (moving to the Operations package in P4); wiring goes through the capability -/// service-sharing face (composition-root UseService<IOnlineAuditReader>). +/// OnlineAuditService (relocated into Online.Abstractions.Audit as family infrastructure by +/// C294, pre-seeded onto the shared service face by OnlineCapabilityFamily). /// public interface IOnlineAuditReader { diff --git a/Capability/GameFrameX.Capability.Online.Abstractions/Contracts/IOnlineFriendDomain.cs b/Capability/GameFrameX.Capability.Online.Abstractions/Contracts/IOnlineFriendDomain.cs index bafeed70c..228d16dd2 100644 --- a/Capability/GameFrameX.Capability.Online.Abstractions/Contracts/IOnlineFriendDomain.cs +++ b/Capability/GameFrameX.Capability.Online.Abstractions/Contracts/IOnlineFriendDomain.cs @@ -36,7 +36,7 @@ namespace GameFrameX.Capability.Online.Abstractions.Contracts; /// /// 好友域桥契约面(C244,承接 C243 查询缝并扩展为全家族):好友域全部协议行为的最小桥接契约, -/// 供 Hotfix 协议面薄适配器经 OnlineFriendDomainBridgeHolder 进程内消费。 +/// 供 Hotfix 协议面薄适配器经 CapabilityBridge<IOnlineFriendDomain> 进程内消费。 /// /// 维护约束: /// ① 定位一律以「玩家对」表达(协议面没有关系标识),关系标识编排归实现方(Social 域面); @@ -51,7 +51,7 @@ namespace GameFrameX.Capability.Online.Abstractions.Contracts; /// /// The friend-domain bridge contract face (C244, extending the C243 query seam into the full family): /// the minimal bridging contract for every friend-domain protocol behaviour, consumed in-process by -/// the Hotfix protocol thin adapter through OnlineFriendDomainBridgeHolder. Locating is always +/// the Hotfix protocol thin adapter through CapabilityBridge<IOnlineFriendDomain>. Locating is always /// expressed as player pairs (the protocol face carries no friendship ids — id orchestration belongs /// to the implementing face); the scope triple comes from the composition root's authorized scope, /// the player position is filled with the requesting session identity, and client-supplied fields can diff --git a/Capability/GameFrameX.Capability.Online.Abstractions/Contracts/IOnlineMatchDomain.cs b/Capability/GameFrameX.Capability.Online.Abstractions/Contracts/IOnlineMatchDomain.cs index f2866e564..d0d64f224 100644 --- a/Capability/GameFrameX.Capability.Online.Abstractions/Contracts/IOnlineMatchDomain.cs +++ b/Capability/GameFrameX.Capability.Online.Abstractions/Contracts/IOnlineMatchDomain.cs @@ -27,7 +27,8 @@ namespace GameFrameX.Capability.Online.Abstractions.Contracts; /// /// 匹配域桥契约面(C246):旧 420 协议族(入队 / 取消 / 状态)与新增入场、分配解析的最小桥接契约, -/// 供 Hotfix 协议面薄适配器与组合根通知推送方经 OnlineMatchDomainBridgeHolder 进程内消费。 +/// 供 Hotfix 协议面薄适配器(经 CapabilityBridge<IOnlineMatchDomain> 进程内消费)与 +/// online-match 模块 Activate 期通知推送订阅方使用。 /// /// 维护约束: /// ① 匹配池唯一权威 = 票据域(实现方内部经同一票据存储入队并驱动撮合),本契约不得另建队列或池副本; diff --git a/Capability/GameFrameX.Capability.Online.Abstractions/Contracts/IOnlineMatchGameBridge.cs b/Capability/GameFrameX.Capability.Online.Abstractions/Contracts/IOnlineMatchGameBridge.cs index 7a871dbe6..2cb12f1f0 100644 --- a/Capability/GameFrameX.Capability.Online.Abstractions/Contracts/IOnlineMatchGameBridge.cs +++ b/Capability/GameFrameX.Capability.Online.Abstractions/Contracts/IOnlineMatchGameBridge.cs @@ -28,7 +28,7 @@ namespace GameFrameX.Capability.Online.Abstractions.Contracts; /// /// 对局样例桥契约面(C245,D4 RPS 垂直切片):房间制协议入口到平台对局运行时的最小桥接契约, -/// 供 Hotfix 协议面薄适配器经 OnlineMatchGameBridgeHolder 进程内消费。 +/// 供 Hotfix 协议面薄适配器经 CapabilityBridge<IOnlineMatchGameBridge> 进程内消费。 /// /// 维护约束:本契约首版只承载样例玩法(模式 410 石头剪刀布)——创建/入场的泛化(匹配票据、 /// 观战、跨服)随 C246 匹配域接入另行收敛,不预先发明抽象;作用域三元组由组合根授权作用域供给, @@ -39,7 +39,7 @@ namespace GameFrameX.Capability.Online.Abstractions.Contracts; /// /// The sample match bridge contract face (C245, the D4 RPS vertical slice): the minimal bridging /// contract from the room-shaped protocol entries to the platform match runtime, consumed in-process -/// by the Hotfix protocol thin adapter through OnlineMatchGameBridgeHolder. This contract +/// by the Hotfix protocol thin adapter through CapabilityBridge<IOnlineMatchGameBridge>. This contract /// carries the sample game (mode 410 rock-paper-scissors) only for the first version — generalizing /// creation/entry (matchmaking tickets, spectating, cross-server) converges with the C246 match /// domain bridge instead of being invented here. The scope triple comes from the composition root's diff --git a/Capability/GameFrameX.Capability.Online.Matchmaking/Party/IOnlinePartyPresenceProbe.cs b/Capability/GameFrameX.Capability.Online.Abstractions/Contracts/IOnlinePartyPresenceProbe.cs similarity index 98% rename from Capability/GameFrameX.Capability.Online.Matchmaking/Party/IOnlinePartyPresenceProbe.cs rename to Capability/GameFrameX.Capability.Online.Abstractions/Contracts/IOnlinePartyPresenceProbe.cs index a86cb44c0..8eb8c0cfe 100644 --- a/Capability/GameFrameX.Capability.Online.Matchmaking/Party/IOnlinePartyPresenceProbe.cs +++ b/Capability/GameFrameX.Capability.Online.Abstractions/Contracts/IOnlinePartyPresenceProbe.cs @@ -27,7 +27,7 @@ // Official documentation: https://gameframex.doc.alianblank.com/ // ========================================================================================== -namespace GameFrameX.Capability.Online.Matchmaking.Party; +namespace GameFrameX.Capability.Online.Abstractions.Contracts; /// /// 队伍在线事实探针(成员部分离线处理必须基于唯一在线事实源)。 diff --git a/Capability/GameFrameX.Capability.Online.Social/IOnlinePlayerDirectory.cs b/Capability/GameFrameX.Capability.Online.Abstractions/Contracts/IOnlinePlayerDirectory.cs similarity index 98% rename from Capability/GameFrameX.Capability.Online.Social/IOnlinePlayerDirectory.cs rename to Capability/GameFrameX.Capability.Online.Abstractions/Contracts/IOnlinePlayerDirectory.cs index 8b30f8cf5..bca704ced 100644 --- a/Capability/GameFrameX.Capability.Online.Social/IOnlinePlayerDirectory.cs +++ b/Capability/GameFrameX.Capability.Online.Abstractions/Contracts/IOnlinePlayerDirectory.cs @@ -27,7 +27,7 @@ // Official documentation: https://gameframex.doc.alianblank.com/ // ========================================================================================== -namespace GameFrameX.Capability.Online.Social; +namespace GameFrameX.Capability.Online.Abstractions.Contracts; /// /// 玩家目录查询契约(「好友支持搜索」的可插拔输入)。 diff --git a/Capability/GameFrameX.Capability.Online.Social/IOnlineSocialPresenceProbe.cs b/Capability/GameFrameX.Capability.Online.Abstractions/Contracts/IOnlineSocialPresenceProbe.cs similarity index 98% rename from Capability/GameFrameX.Capability.Online.Social/IOnlineSocialPresenceProbe.cs rename to Capability/GameFrameX.Capability.Online.Abstractions/Contracts/IOnlineSocialPresenceProbe.cs index 04dee3c0a..e88a6ef54 100644 --- a/Capability/GameFrameX.Capability.Online.Social/IOnlineSocialPresenceProbe.cs +++ b/Capability/GameFrameX.Capability.Online.Abstractions/Contracts/IOnlineSocialPresenceProbe.cs @@ -27,7 +27,7 @@ // Official documentation: https://gameframex.doc.alianblank.com/ // ========================================================================================== -namespace GameFrameX.Capability.Online.Social; +namespace GameFrameX.Capability.Online.Abstractions.Contracts; /// /// 玩家在线事实探针(社交域读取在线状态的唯一入口)。 diff --git a/Capability/GameFrameX.Capability.Online.Abstractions/Contracts/OnlineAuditPage.cs b/Capability/GameFrameX.Capability.Online.Abstractions/Contracts/OnlineAuditPage.cs index 1ed85d5fe..665f920a3 100644 --- a/Capability/GameFrameX.Capability.Online.Abstractions/Contracts/OnlineAuditPage.cs +++ b/Capability/GameFrameX.Capability.Online.Abstractions/Contracts/OnlineAuditPage.cs @@ -60,6 +60,10 @@ public sealed class OnlineAuditPage public OnlineAuditPage(IReadOnlyList records, OnlinePageCursor cursor) { Records = records ?? new List(); - Cursor = cursor ?? new OnlinePageCursor(string.Empty, false); + Cursor = cursor; + if (cursor == null) + { + Cursor = new OnlinePageCursor(string.Empty, false); + } } } \ No newline at end of file diff --git a/Capability/GameFrameX.Capability.Online.Social/OnlinePlayerDirectoryEntry.cs b/Capability/GameFrameX.Capability.Online.Abstractions/Contracts/OnlinePlayerDirectoryEntry.cs similarity index 97% rename from Capability/GameFrameX.Capability.Online.Social/OnlinePlayerDirectoryEntry.cs rename to Capability/GameFrameX.Capability.Online.Abstractions/Contracts/OnlinePlayerDirectoryEntry.cs index 11b83236e..a4b21902f 100644 --- a/Capability/GameFrameX.Capability.Online.Social/OnlinePlayerDirectoryEntry.cs +++ b/Capability/GameFrameX.Capability.Online.Abstractions/Contracts/OnlinePlayerDirectoryEntry.cs @@ -27,7 +27,7 @@ // Official documentation: https://gameframex.doc.alianblank.com/ // ========================================================================================== -namespace GameFrameX.Capability.Online.Social; +namespace GameFrameX.Capability.Online.Abstractions.Contracts; /// /// 玩家目录条目(好友搜索与展示名解析的最小投影)。 diff --git a/Capability/GameFrameX.Capability.Online.Abstractions/Events/OnlineEventPublisher.cs b/Capability/GameFrameX.Capability.Online.Abstractions/Events/OnlineEventPublisher.cs index cde706d63..f2ac8b401 100644 --- a/Capability/GameFrameX.Capability.Online.Abstractions/Events/OnlineEventPublisher.cs +++ b/Capability/GameFrameX.Capability.Online.Abstractions/Events/OnlineEventPublisher.cs @@ -27,37 +27,41 @@ // Official documentation: https://gameframex.doc.alianblank.com/ // ========================================================================================== -using GameFrameX.Foundation.Idempotency; - namespace GameFrameX.Capability.Online.Abstractions.Events; +using GameFrameX.Capability.Hosting.Abstractions; /// -/// Online 事件发布基座(组装 Foundation IEventPublisher 通用原语, -/// Online 语义(作用域字段承载)由 完成)。 -/// 维护约束:发布前必须完成信封校验(Foundation 发布器保证非法信封不被分发);业务侧只允许经本基座发布 Online 事件, -/// 禁止绕过作用域承载直发裸信封。 +/// Online 事件发布基座(C294 起委托机制事件总线 , +/// 由族基建 OnlineCapabilityFamily.RegisterInfrastructure 经族上下文的事件发布口构造)。 +/// +/// 维护约束:主题 = 事件类型字符串本身(<族>.<域>.<事件> 小写形态,如 +/// online.match.settled,由各域事件常量类保证);载荷类型固定为 , +/// 订阅方以相同主题 + 相同载荷类型经 Register 期 Subscribe 声明;宿主 Activate 开始前总线拒绝发布 +/// (Register 期发出的事件后注册订阅者收不到);单订阅体异常由机制记日志后继续分发, +/// 业务发布方不再感知订阅体异常。 +/// /// public sealed class OnlineEventPublisher : IOnlineEventPublisher { /// - /// Foundation 事件发布器。 + /// 机制事件发布口(族上下文注入;实际实现为机制 CapabilityEventBus)。 /// - private readonly IEventPublisher _eventPublisher; + private readonly ICapabilityEventPublisher _eventPublisher; /// /// 初始化 。 /// - /// Foundation 事件发布器(传输实现由装配方决定)。 - public OnlineEventPublisher(IEventPublisher eventPublisher) + /// 机制事件发布口(不可为 null)。 + public OnlineEventPublisher(ICapabilityEventPublisher eventPublisher) { _eventPublisher = eventPublisher ?? throw new ArgumentNullException(nameof(eventPublisher)); } /// - /// 发布一个 Online 事件(映射为 Foundation 信封后分发)。 + /// 发布一个 Online 事件(载荷 = ,主题 = 事件类型字符串;同步分发到该主题的全部订阅者)。 /// /// 待发布事件。 - /// 取消令牌。 + /// 取消令牌(分发为同步语义,本参数仅占位契约)。 /// 完成通知。 public Task PublishAsync(OnlineEvent onlineEvent, CancellationToken cancellationToken = default) { @@ -66,7 +70,12 @@ public Task PublishAsync(OnlineEvent onlineEvent, CancellationToken cancellation throw new ArgumentNullException(nameof(onlineEvent)); } - var envelope = OnlineEventEnvelopeMapper.ToEnvelope(onlineEvent); - return _eventPublisher.PublishAsync(envelope, cancellationToken); + if (string.IsNullOrWhiteSpace(onlineEvent.EventType)) + { + throw new ArgumentException("Online event type (also the bus topic) must be a non-empty string.", nameof(onlineEvent)); + } + + _eventPublisher.Publish(onlineEvent.EventType, onlineEvent); + return Task.CompletedTask; } -} \ No newline at end of file +} diff --git a/Capability/GameFrameX.Capability.Online.Abstractions/IOnlineChatModerationHook.cs b/Capability/GameFrameX.Capability.Online.Abstractions/IOnlineChatModerationHook.cs index e02e59540..6f7c95458 100644 --- a/Capability/GameFrameX.Capability.Online.Abstractions/IOnlineChatModerationHook.cs +++ b/Capability/GameFrameX.Capability.Online.Abstractions/IOnlineChatModerationHook.cs @@ -27,7 +27,7 @@ // Official documentation: https://gameframex.doc.alianblank.com/ // ========================================================================================== -namespace GameFrameX.Capability.Online.Social; +namespace GameFrameX.Capability.Online.Abstractions; /// /// 聊天内容审核扩展点(插件不影响主流程)。 @@ -36,7 +36,7 @@ namespace GameFrameX.Capability.Online.Social; /// 未装配时若拒绝发送,等于把「有没有接审核服务」变成聊天能不能用的开关。 /// /// -/// 维护约束(异常语义):实现**允许抛异常**, 会捕获并放行、 +/// 维护约束(异常语义):实现**允许抛异常**,GameFrameX.Capability.Online.Social.OnlineChatService 会捕获并放行、 /// 同时发出审核失败事件留审计。即审核服务宕机不得导致聊天不可用—— /// 这与「挂插件」的取舍一致:宁可短暂放行未经审核的内容,也不能让全服聊天停摆; /// 放行事件会把这段时间标记出来,便于事后回扫。**实现方不得自己吞掉异常后返回放行**, diff --git a/Capability/GameFrameX.Capability.Online.Abstractions/OnlineCapabilityFamily.cs b/Capability/GameFrameX.Capability.Online.Abstractions/OnlineCapabilityFamily.cs new file mode 100644 index 000000000..1cc2435a2 --- /dev/null +++ b/Capability/GameFrameX.Capability.Online.Abstractions/OnlineCapabilityFamily.cs @@ -0,0 +1,144 @@ +// ========================================================================================== +// 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; +using System.Text.Json; +using GameFrameX.Capability.Hosting.Abstractions; +using GameFrameX.Capability.Online.Abstractions.Audit; +using GameFrameX.Capability.Online.Abstractions.Events; +using GameFrameX.Capability.Online.Abstractions.Contracts; +using GameFrameX.Capability.Online.Abstractions.Idempotency; +using GameFrameX.Capability.Online.Abstractions.Scope; +using GameFrameX.Foundation.Idempotency; + +namespace GameFrameX.Capability.Online.Abstractions; + +/// +/// Online 能力族组合根骨架(C294,实现 ;机制层零能力硬编码, +/// 族基建收敛在族脊柱包内)。族标识 online,配置段 Online,部署程序集前缀 +/// GameFrameX.Capability.Online。 +/// +/// 维护约束(族基建预置,语句级迁移自旧组合根 BuildScannedRuntime):授权作用域(三元组取自 +/// 族配置段 TenantId/AppId/ServerId,缺失或非法 = 装配期 fail-fast——作用域是 +/// 数据隔离根边界,无默认值可回落)、Online 事件出口( 委托机制 +/// 事件总线)、幂等(,InMemory 协调器 + 失败重放策略 ReExecute)、 +/// 统一审计( 具型 + 读面,同源单例)。 +/// 上述服务来源 = 族 Id,对全部能力模块可解析。 +/// +/// +/// 不在本族预置的宿主扩展点:Hotfix 版本切换执行器(IOnlineHotfixRollbackExecutor,宿主经 +/// Override 满足 Operations 模块的 RequireHostService);GameDb 一级 store 覆盖 +///(Online.UseGameDbStores,宿主经 Override<TStore> 预置五个 GameDb 适配器—— +/// 族脊柱包不引用 Persistence 包,覆盖走宿主侧路径)。 +/// +/// +public sealed class OnlineCapabilityFamily : ICapabilityFamily +{ + /// + /// 获取族标识(族基建与宿主覆盖的来源标记)。 + /// + public string FamilyId + { + get { return "online"; } + } + + /// + /// 获取配置段名(诊断与错误路径前缀;机制只解释 Settings 子对象,其余族级键由本族解读)。 + /// + public string ConfigurationSectionName + { + get { return "Online"; } + } + + /// + /// 获取部署程序集前缀(部署目录命名约定扫描圈定 GameFrameX.Capability.Online.*.dll)。 + /// + public string DeploymentAssemblyPrefix + { + get { return "GameFrameX.Capability.Online"; } + } + + /// + /// 注册族基建(机制在模块扫描与注册之前调用一次;来源 = 族 Id,对全部模块可解析)。 + /// + /// 族基建注册面(配置段 + 事件发布口 + 服务注册)。 + public void RegisterInfrastructure(CapabilityFamilyContext context) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + // 授权作用域:三元组取自族配置段;缺失 / 非正数 = 装配期 fail-fast(无默认值可回落)。 + var tenantId = ReadScopeKey(context.ConfigurationSection, "TenantId"); + var appId = ReadScopeKey(context.ConfigurationSection, "AppId"); + var serverId = ReadScopeKey(context.ConfigurationSection, "ServerId"); + var scope = new OnlineScope(tenantId, appId, serverId); + context.AddService(scope); + + // 事件出口:业务发布面委托机制事件总线(主题 = 事件类型字符串,Activate 开始前拒绝发布)。 + context.AddService(new OnlineEventPublisher(context.EventPublisher)); + + // 幂等:InMemory 协调器 + 失败重放策略 ReExecute(语句级迁移自旧组合根预置)。 + var coordinator = new IdempotencyCoordinator(new InMemoryIdempotencyStore(), SystemClock.Instance, + new IdempotencyOptions { FailedReplayPolicy = FailedReplayPolicy.ReExecute, }); + var idempotency = new OnlineIdempotencyService(coordinator); + context.AddService(idempotency); + + // 管理面适配器:错误码表 / 作用域守卫 / 幂等包裹由族提供(机制管理面组装按类型键解析,缺失即 AdapterMissing fail-fast); + // 作用域守卫与幂等包裹必须绑定本族同一实例,保证 admin 判定与业务面同源。 + context.AddService(OnlineAdminApiAdapters.ErrorCodes); + context.AddService(OnlineAdminApiAdapters.CreateScopeAdapter(scope)); + context.AddService(OnlineAdminApiAdapters.CreateIdempotencyAdapter(idempotency)); + + // 统一审计:具型 + 读面同源单例(Assets 审计留痕与 Operations 审计桥共享同一实例)。 + var audit = new OnlineAuditService(new InMemoryOnlineAuditStore()); + context.AddService(audit); + context.AddService(audit); + } + + /// + /// 读取族配置段的作用域键(正整数;缺失、类型不符或非正数 = 装配期 fail-fast)。 + /// + /// 族配置段(原始 JSON)。 + /// 键名。 + /// 键值。 + private static long ReadScopeKey(JsonElement section, string key) + { + if (section.ValueKind != JsonValueKind.Object + || !section.TryGetProperty(key, out var value) + || value.ValueKind != JsonValueKind.Number + || !value.TryGetInt64(out var parsed) + || parsed <= 0) + { + throw new InvalidOperationException("Online family configuration section requires positive integer key '" + key + "' (path: Online." + key + ")."); + } + + return parsed; + } +} diff --git a/Capability/GameFrameX.Capability.Online.Abstractions/OnlineChatMessage.cs b/Capability/GameFrameX.Capability.Online.Abstractions/OnlineChatMessage.cs index ea324588b..3c6246e92 100644 --- a/Capability/GameFrameX.Capability.Online.Abstractions/OnlineChatMessage.cs +++ b/Capability/GameFrameX.Capability.Online.Abstractions/OnlineChatMessage.cs @@ -27,7 +27,7 @@ // Official documentation: https://gameframex.doc.alianblank.com/ // ========================================================================================== -namespace GameFrameX.Capability.Online.Social; +namespace GameFrameX.Capability.Online.Abstractions; using GameFrameX.Capability.Online.Abstractions.Contracts; diff --git a/Capability/GameFrameX.Capability.Online.Abstractions/OnlineChatMessageState.cs b/Capability/GameFrameX.Capability.Online.Abstractions/OnlineChatMessageState.cs index cce7bf50c..f69dd0b74 100644 --- a/Capability/GameFrameX.Capability.Online.Abstractions/OnlineChatMessageState.cs +++ b/Capability/GameFrameX.Capability.Online.Abstractions/OnlineChatMessageState.cs @@ -27,7 +27,7 @@ // Official documentation: https://gameframex.doc.alianblank.com/ // ========================================================================================== -namespace GameFrameX.Capability.Online.Social; +namespace GameFrameX.Capability.Online.Abstractions; /// /// 聊天消息状态。 diff --git a/Capability/GameFrameX.Capability.Online.Abstractions/OnlineChatModerationVerdict.cs b/Capability/GameFrameX.Capability.Online.Abstractions/OnlineChatModerationVerdict.cs index 4cc45d80a..e0caccc6a 100644 --- a/Capability/GameFrameX.Capability.Online.Abstractions/OnlineChatModerationVerdict.cs +++ b/Capability/GameFrameX.Capability.Online.Abstractions/OnlineChatModerationVerdict.cs @@ -27,13 +27,13 @@ // Official documentation: https://gameframex.doc.alianblank.com/ // ========================================================================================== -namespace GameFrameX.Capability.Online.Social; +namespace GameFrameX.Capability.Online.Abstractions; /// /// 内容审核裁决结果(审核扩展点的返回载体)。 /// /// 维护约束:本类型**不携带拒绝错误码**——审核拒绝一律映射 RiskControlRejected(风控段), -/// 由 统一落定。让插件自选错误码会把错误码表的控制权交给外部实现, +/// 由 GameFrameX.Capability.Online.Social.OnlineChatService 统一落定。让插件自选错误码会把错误码表的控制权交给外部实现, /// 消息里出现任意段位的码就再也说不清是谁定的。 /// /// diff --git a/Capability/GameFrameX.Capability.Online.Abstractions/Scope/IOnlineAuthorizedScopeProvider.cs b/Capability/GameFrameX.Capability.Online.Abstractions/Scope/IOnlineAuthorizedScopeProvider.cs new file mode 100644 index 000000000..376d429e9 --- /dev/null +++ b/Capability/GameFrameX.Capability.Online.Abstractions/Scope/IOnlineAuthorizedScopeProvider.cs @@ -0,0 +1,47 @@ +// ========================================================================================== +// 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.Capability.Online.Abstractions.Scope; + +/// +/// 宿主授权作用域发布契约(C294):把族基建预置的 三元组 +/// (Tenant / App / Server)以桥契约形态暴露给进程内消费方(主要是 Hotfix 协议薄适配器—— +/// 其程序集不引用宿主配置,无法另行读取作用域)。 +/// +/// 维护约束:由 online-foundation 模块在 ActivateAsync 期经 +/// CapabilityBridge<IOnlineAuthorizedScopeProvider> 发布一次(全宿主唯一发布者); +/// 消费方 TryAcquire 失败即显式 8002,不得回落到自造默认作用域——作用域是数据隔离根边界。 +/// +/// +public interface IOnlineAuthorizedScopeProvider +{ + /// + /// 获取宿主授权作用域(族配置段三元组构造,全宿主单例语义)。 + /// + OnlineScope AuthorizedScope { get; } +} diff --git a/Capability/GameFrameX.Capability.Online.Assets/GameFrameX.Capability.Online.Assets.csproj b/Capability/GameFrameX.Capability.Online.Assets/GameFrameX.Capability.Online.Assets.csproj index cdbdca00c..00730000d 100644 --- a/Capability/GameFrameX.Capability.Online.Assets/GameFrameX.Capability.Online.Assets.csproj +++ b/Capability/GameFrameX.Capability.Online.Assets/GameFrameX.Capability.Online.Assets.csproj @@ -2,7 +2,7 @@ - GameFrameX.Capability.Online.Assets,GFX Online 资产能力包:钱包/库存/不可变账本/统一发放入口/跨服发放路由(InMemory 默认存储,UseOnlineAssetStore 替换缝)+ hosting 扩展(AddOnlineAssetsRuntime)+ admin handlers(grant/revoke/查询族). Assets capability package for the GFX Online family: wallet, inventory, immutable ledger, unified grant entry and cross-server grant routing with in-memory default stores, the hosting extension and admin handlers. Documentation: https://gameframex.doc.alianblank.com + GameFrameX.Capability.Online.Assets,GFX Online 资产能力包:钱包/库存/不可变账本/统一发放入口/跨服发放路由(InMemory 默认存储,族/宿主 Override 可替换)+ admin handlers(grant/revoke/查询族). Assets capability package for the GFX Online family: wallet, inventory, immutable ledger, unified grant entry and cross-server grant routing with in-memory default stores overridable by family/host, plus admin handlers. Documentation: https://gameframex.doc.alianblank.com GameFrameX,Capability,Online,Assets,Wallet,Ledger,Grant,GameServer 10 diff --git a/Capability/GameFrameX.Capability.Online.Assets/OnlineAssetEvents.cs b/Capability/GameFrameX.Capability.Online.Assets/OnlineAssetEvents.cs index 00c0d97ca..9755345ed 100644 --- a/Capability/GameFrameX.Capability.Online.Assets/OnlineAssetEvents.cs +++ b/Capability/GameFrameX.Capability.Online.Assets/OnlineAssetEvents.cs @@ -45,13 +45,13 @@ namespace GameFrameX.Capability.Online.Assets; public static class OnlineAssetEvents { /// 资产发放事实(Grant/Reissue 操作)。 - public const string AssetGranted = "Online.Asset.Granted"; + public const string AssetGranted = "online.asset.granted"; /// 资产扣除事实(Deduct/Revoke 操作)。 - public const string AssetDeducted = "Online.Asset.Deducted"; + public const string AssetDeducted = "online.asset.deducted"; /// 资产人工调整事实(Adjust 操作,含系统补偿反转)。 - public const string AssetAdjusted = "Online.Asset.Adjusted"; + public const string AssetAdjusted = "online.asset.adjusted"; /// 事件来源标识。 public const string Source = "online-assets"; diff --git a/Capability/GameFrameX.Capability.Online.Assets/OnlineAssetsCapabilityModule.cs b/Capability/GameFrameX.Capability.Online.Assets/OnlineAssetsCapabilityModule.cs index 219cf5449..b272ed529 100644 --- a/Capability/GameFrameX.Capability.Online.Assets/OnlineAssetsCapabilityModule.cs +++ b/Capability/GameFrameX.Capability.Online.Assets/OnlineAssetsCapabilityModule.cs @@ -1,30 +1,30 @@ // ========================================================================================== // GameFrameX 组织及其衍生项目的版权、商标、专利及其他相关权利 -// GameFrameX organization and its derivative projects' copyrights, trademarks, patents and other rights +// 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 other international regulations. // 使用本项目须严格遵守相关法律法规与开源许可证之规定。 -// Usage of this project must strictly comply with relevant laws, regulations, and open-source licenses. +// 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 refer to the full license text in the LICENSE file located at the root of the source code. +// 完整许可证文本请参见源代码根目录下的 LICENSE 文件。 +// please refer to the LICENSE file located at the root of the source code for the full license text. // 禁止利用本项目实施任何危害国家安全、破坏社会秩序、 -// It is prohibited to use this project to engage in any activity that endangers national security, disrupts social order, +// It is prohibited to use this project to engage in any activities that endanger national security, disrupt social order, // 侵犯公民合法权益等法律法规所禁止的行为! -// or infringe upon the legal rights and interests of citizens as prohibited by laws and regulations! +// or infringe upon the legal rights and interests of others as prohibited by laws and regulations! // 因基于本项目二次开发所产生的一切法律纠纷与责任, // Any legal disputes or liabilities arising from secondary development, -// 本组织不承担任何责任,开发者应自行承担。 +// 本组织与贡献者概不承担任何责任,开发者须自行承担。 // 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 +// Gitee Repository: https://github.com/GameFrameX // CNB 仓库:https://cnb.cool/GameFrameX -// CNB Repository: https://cnb.cool/GameFrameX +// CNB Repository: https://github.com/GameFrameX // 官方文档:https://gameframex.doc.alianblank.com/ -// Official documentation: https://gameframex.doc.alianblank.com/ +// Official Documentation: https://gameframex.doc.alianblank.com/ // ========================================================================================== using GameFrameX.Capability.Hosting.Abstractions; @@ -39,28 +39,31 @@ namespace GameFrameX.Capability.Online.Assets; /// -/// 资产能力(钱包 / 库存 / 账本 / 统一发放)的自装配模块(C231 P3,自 Runtime 壳过渡模块无参化迁入)。 +/// 资产能力(钱包 / 库存 / 账本 / 统一发放)的自装配模块(C231 P3 迁入;C294 改写为三段生命周期机制模块)。 /// -/// 维护约束(服务共享协议):模块无参构造( 扫描可发现);Register 自装配本域—— -/// store 经 探测组合根预置 / UseOnlineAssetStore -/// 替换缝,未命中构造 InMemory 默认;跨域硬依赖(幂等 / 事件出口 / 审计读面)经上下文解析; -/// 对外产物(store 接口 + 查询 / 发放服务)注册回共享面供后序能力与组合根取用。 +/// 维护约束(服务共享协议):模块无参构造( 扫描可发现);Register +/// 自装配本域——两 store 以懒默认注册后取用(族 / 宿主 Override 自动胜出,模块不做探测回退);跨域硬依赖 +///(幂等 / 事件出口 / 审计读面)经上下文解析(审计读面为族基建预置的 ); +/// 对外产物(查询 / 发放服务)注册回共享面供后序能力取用。本能力无 Options 根类型、无依赖声明; +/// ActivateAsync 无桥与预建资源(空实现)、DeactivateAsync 无自持资源(空实现)。 /// /// /// -/// The self-assembling module of the assets capability (wallet / inventory / ledger / unified granting; -/// C231 P3, migrated from the Runtime-shell transitional module and made parameterless). The module has a -/// parameterless constructor (discoverable by scanning); Register -/// self-assembles the domain — stores probe the composition-root pre-seed / the UseOnlineAssetStore -/// replacement seam via and fall back to -/// in-memory defaults; cross-domain hard dependencies (idempotency / the event outlet / the audit read face) -/// resolve through the context; outward products (store interfaces plus the query / grant services) are -/// registered back into the shared face for later capabilities and the composition root. +/// The self-assembling module of the assets capability (wallet / inventory / ledger / unified +/// granting; C231 P3, rewritten by C294 for the three-stage lifecycle). The module has a +/// parameterless constructor (discoverable by scanning); +/// Register self-assembles the domain — the two stores register lazy in-memory defaults and +/// resolve through the shared face (family / host overrides win automatically, no probing +/// fallback); cross-domain hard dependencies (idempotency / the event outlet / the audit read face) +/// resolve through the context (the audit read face is the family-preset +/// ); outward products (the query / grant services) are registered +/// back into the shared face for later capabilities. The capability declares no options root and no +/// dependencies; ActivateAsync / DeactivateAsync hold nothing to do. /// public sealed class OnlineAssetsCapabilityModule : ICapabilityModule, IAdminActionContributor { /// - /// 资产能力清单(Id 冻结 = 终态包名;action 集合与幂等包裹位逐字迁移自壳过渡模块)。 + /// 资产能力清单(Id 冻结 = 终态包名;action 集合与幂等包裹位逐字迁移自壳过渡模块;无依赖、无 Options)。 /// public static readonly CapabilityManifest ManifestInstance = new( "online-assets", @@ -68,12 +71,12 @@ public sealed class OnlineAssetsCapabilityModule : ICapabilityModule, IAdminActi new[] { "grant_asset", "revoke_asset", "query_wallet_ledger", "query_asset_audit", "query_transaction_detail" }); /// - /// 资产存储(钱包 / 库存 / 账本;Register 期解析或构造)。 + /// 资产存储(钱包 / 库存 / 账本;Register 期解析)。 /// private IOnlineAssetStore _assetStore; /// - /// 资产交易存储(Register 期解析或构造)。 + /// 资产交易存储(Register 期解析)。 /// private IOnlineAssetTransactionStore _assetTransactionStore; @@ -88,7 +91,7 @@ public sealed class OnlineAssetsCapabilityModule : ICapabilityModule, IAdminActi private OnlineGrantService _grantService; /// - /// 统一审计读面(Register 期解析;组合根预置或聚合域缺席时的空读面)。 + /// 统一审计读面(Register 期解析;族基建预置 OnlineAuditService 的读面)。 /// private IOnlineAuditReader _audit; @@ -101,19 +104,14 @@ public CapabilityManifest Manifest /// public void Register(CapabilityRegistrationContext context) { - if (!context.TryGetService(out _assetStore)) - { - _assetStore = new InMemoryOnlineAssetStore(); - context.AddService(_assetStore); - } + // 两 store:懒默认注册后经共享面取用(族 / 宿主 GameDb Override 自动胜出)。 + context.AddService(static _ => new InMemoryOnlineAssetStore()); + context.AddService(static _ => new InMemoryOnlineAssetTransactionStore()); + _assetStore = context.GetService(); + _assetTransactionStore = context.GetService(); - if (!context.TryGetService(out _assetTransactionStore)) - { - _assetTransactionStore = new InMemoryOnlineAssetTransactionStore(); - context.AddService(_assetTransactionStore); - } - - _audit = context.TryGetService(out IOnlineAuditReader auditReader) ? auditReader : new EmptyAuditReader(); + // 跨域硬依赖:审计读面 / 幂等 / 事件出口均为族基建预置,直接解析。 + _audit = context.GetService(); var idempotency = context.GetService(); var eventPublisher = context.GetService(); _assetQuery = new OnlineAssetQueryService(_assetStore, _assetTransactionStore); @@ -123,21 +121,20 @@ public void Register(CapabilityRegistrationContext context) } /// - public void RegisterAdminActions(AdminActionRegistry registry) + public Task ActivateAsync(CapabilityActivationContext context, CancellationToken cancellationToken) { - new OnlineAdminAssetHandlers(_grantService, _assetQuery, _audit, _assetTransactionStore).Register(registry); + return Task.CompletedTask; } - /// - /// 聚合审计域缺席时的空读面(最小组合根语义:审计能力未安装 = 无审计记录;全能力形态由组合根预置真实读面, - /// 行为与壳直连逐语句等价)。 - /// - private sealed class EmptyAuditReader : IOnlineAuditReader + /// + public Task DeactivateAsync(CancellationToken cancellationToken) { - /// - public Task> QueryAsync(OnlineScope scope, OnlineAuditQuery query = null, CancellationToken cancellationToken = default) - { - return Task.FromResult(OnlineResult.Ok(new OnlineAuditPage(Array.Empty(), null))); - } + return Task.CompletedTask; + } + + /// + public void RegisterAdminActions(AdminActionRegistry registry) + { + new OnlineAdminAssetHandlers(_grantService, _assetQuery, _audit, _assetTransactionStore).Register(registry); } } diff --git a/Capability/GameFrameX.Capability.Online.Assets/OnlineAssetsHostingExtensions.cs b/Capability/GameFrameX.Capability.Online.Assets/OnlineAssetsHostingExtensions.cs deleted file mode 100644 index 7181d8c90..000000000 --- a/Capability/GameFrameX.Capability.Online.Assets/OnlineAssetsHostingExtensions.cs +++ /dev/null @@ -1,95 +0,0 @@ -// ========================================================================================== -// GameFrameX 组织及其衍生项目的版权、商标、专利及其他相关权利 -// GameFrameX organization and its derivative projects' copyrights, trademarks, patents and other rights -// 均受中华人民共和国及相关国际法律法规保护。 -// are protected by the laws of the People's Republic of China and other international regulations. -// 使用本项目须严格遵守相关法律法规与开源许可证之规定。 -// Usage of this project must strictly comply with relevant laws, regulations, and open-source licenses. -// 本项目采用 Apache License 2.0 单协议分发, -// This project is licensed solely under the Apache License 2.0, -// 完整许可证文本请参见源代码根目录的 LICENSE 文件。 -// please refer to the full license text in the LICENSE file located at the root of the source code. -// 禁止利用本项目实施任何危害国家安全、破坏社会秩序、 -// It is prohibited to use this project to engage in any activity that endangers national security, disrupts social order, -// 侵犯公民合法权益等法律法规所禁止的行为! -// or infringe upon the legal rights and interests of citizens as prohibited by laws and regulations! -// 因基于本项目二次开发所产生的一切法律纠纷与责任, -// Any legal disputes or liabilities arising from secondary development, -// 本组织不承担任何责任,开发者应自行承担。 -// 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.Capability.Hosting.Core; - -namespace GameFrameX.Capability.Online.Assets; - -/// -/// 资产能力包的 hosting 扩展(C231 P3): 装配入口(回调式,注册自装配模块) -/// 与 / 存储替换缝。 -/// -/// 维护约束:options 经共享服务面预置(模块无参构造,显式链式与 P4 真扫描共用同一模块类); -/// 替换缝在模块 Register 期探测命中,未命中构造 InMemory 默认。 -/// -/// -/// -/// Hosting extensions of the assets capability package (C231 P3): the -/// assembly entry (callback style, registering the self-assembling module) plus the -/// / store replacement seams. -/// Options are pre-seeded through the shared service face (the module stays parameterless, shared by explicit -/// chaining and the P4 real scan); replacement seams are probed at module Register time, falling back -/// to in-memory defaults. -/// -public static class OnlineAssetsHostingExtensions -{ - /// - /// 装配资产能力运行时(注册自装配模块;options => ... 回调承载包级编排参数)。 - /// - /// 能力装配器。 - /// 包级选项回调(可空 = 全默认)。 - /// 本装配器(链式)。 - public static CapabilityHostBuilder AddOnlineAssetsRuntime(this CapabilityHostBuilder builder, Action configure = null) - { - ArgumentNullException.ThrowIfNull(builder); - - var options = new OnlineAssetsOptions(); - configure?.Invoke(options); - builder.UseService(options); - return builder.AddModule(new OnlineAssetsCapabilityModule()); - } - - /// - /// 替换资产存储(钱包 / 库存 / 账本;默认 InMemory,生产持久化实现经本缝注入)。 - /// - /// 能力装配器。 - /// 存储实现。 - /// 本装配器(链式)。 - public static CapabilityHostBuilder UseOnlineAssetStore(this CapabilityHostBuilder builder, IOnlineAssetStore store) - { - ArgumentNullException.ThrowIfNull(builder); - - builder.UseService(store); - return builder; - } - - /// - /// 替换资产交易存储(默认 InMemory,生产持久化实现经本缝注入)。 - /// - /// 能力装配器。 - /// 存储实现。 - /// 本装配器(链式)。 - public static CapabilityHostBuilder UseOnlineAssetTransactionStore(this CapabilityHostBuilder builder, IOnlineAssetTransactionStore store) - { - ArgumentNullException.ThrowIfNull(builder); - - builder.UseService(store); - return builder; - } -} diff --git a/Capability/GameFrameX.Capability.Online.Assets/OnlineAssetsOptions.cs b/Capability/GameFrameX.Capability.Online.Assets/OnlineAssetsOptions.cs deleted file mode 100644 index 47d733fcd..000000000 --- a/Capability/GameFrameX.Capability.Online.Assets/OnlineAssetsOptions.cs +++ /dev/null @@ -1,50 +0,0 @@ -// ========================================================================================== -// GameFrameX 组织及其衍生项目的版权、商标、专利及其他相关权利 -// GameFrameX organization and its derivative projects' copyrights, trademarks, patents and other rights -// 均受中华人民共和国及相关国际法律法规保护。 -// are protected by the laws of the People's Republic of China and other international regulations. -// 使用本项目须严格遵守相关法律法规与开源许可证之规定。 -// Usage of this project must strictly comply with relevant laws, regulations, and open-source licenses. -// 本项目采用 Apache License 2.0 单协议分发, -// This project is licensed solely under the Apache License 2.0, -// 完整许可证文本请参见源代码根目录的 LICENSE 文件。 -// please refer to the full license text in the LICENSE file located at the root of the source code. -// 禁止利用本项目实施任何危害国家安全、破坏社会秩序、 -// It is prohibited to use this project to engage in any activity that endangers national security, disrupts social order, -// 侵犯公民合法权益等法律法规所禁止的行为! -// or infringe upon the legal rights and interests of citizens as prohibited by laws and regulations! -// 因基于本项目二次开发所产生的一切法律纠纷与责任, -// Any legal disputes or liabilities arising from secondary development, -// 本组织不承担任何责任,开发者应自行承担。 -// 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.Capability.Online.Assets; - -/// -/// 资产能力包级装配选项( 回调承载面,C231)。 -/// -/// 维护约束(能力族全局约定,C227/C229 裁定):编排参数一律经 Action<TOptions> 回调承载(.NET -/// AddXxx(options => ...) 惯例);P3 期无包级编排参数(存储替换走 UseOnlineAssetStore 缝, -/// 配置段绑定归组合根),成员随真实需求落位;禁止回退平铺配置键。 -/// -/// -/// -/// Package-level assembly options for the assets capability (the callback face of -/// , C231). Following the capability-family -/// global convention (ruled by C227/C229), orchestration parameters ride an Action<TOptions> callback -/// (the .NET AddXxx(options => ...) convention); P3 carries no package-level knobs (store replacement -/// goes through the UseOnlineAssetStore seam and config-section binding belongs to composition roots); -/// members land as real needs appear; flat configuration keys stay forbidden. -/// -public sealed class OnlineAssetsOptions -{ -} diff --git a/Capability/GameFrameX.Capability.Online.Competition/GameFrameX.Capability.Online.Competition.csproj b/Capability/GameFrameX.Capability.Online.Competition/GameFrameX.Capability.Online.Competition.csproj index 44a3c51ff..4079ad48f 100644 --- a/Capability/GameFrameX.Capability.Online.Competition/GameFrameX.Capability.Online.Competition.csproj +++ b/Capability/GameFrameX.Capability.Online.Competition/GameFrameX.Capability.Online.Competition.csproj @@ -2,9 +2,11 @@ - GameFrameX.Capability.Online.Competition,GFX Online 竞技能力包:排行榜/赛季/锦标赛/LiveOps 承载(InMemory 默认存储,UseOnlineCompetitionStore 族替换缝)+ hosting 扩展(AddOnlineCompetitionRuntime)+ admin handlers(LiveOps 七 action). Competition capability package for the GFX Online family: leaderboards, seasons, tournaments and the LiveOps registry with in-memory default stores, the hosting extension and admin handlers. Documentation: https://gameframex.doc.alianblank.com + GameFrameX.Capability.Online.Competition,GFX Online 竞技能力包:排行榜/赛季/锦标赛/LiveOps 承载(InMemory 默认存储,懒默认描述符,族/宿主覆盖恒胜出)+ 自装配模块(OnlineCompetitionCapabilityModule,Options=OnlineCompetitionOptions)+ admin handlers(LiveOps 七 action). Competition capability package for the GFX Online family: leaderboards, seasons, tournaments and the LiveOps registry with in-memory default stores, the self-assembling capability module and admin handlers. Documentation: https://gameframex.doc.alianblank.com GameFrameX,Capability,Online,Competition,Leaderboard,Season,Tournament,LiveOps,GameServer - 10 + diff --git a/Capability/GameFrameX.Capability.Online.Competition/Leaderboard/OnlineLeaderboardEvents.cs b/Capability/GameFrameX.Capability.Online.Competition/Leaderboard/OnlineLeaderboardEvents.cs index a14a2a785..797125442 100644 --- a/Capability/GameFrameX.Capability.Online.Competition/Leaderboard/OnlineLeaderboardEvents.cs +++ b/Capability/GameFrameX.Capability.Online.Competition/Leaderboard/OnlineLeaderboardEvents.cs @@ -46,10 +46,10 @@ namespace GameFrameX.Capability.Online.Competition.Leaderboard; public static class OnlineLeaderboardEvents { /// 榜单创建。 - public const string LeaderboardCreated = "Online.Leaderboard.Created"; + public const string LeaderboardCreated = "online.leaderboard.created"; /// 榜上分数更新(可信写入)。 - public const string ScoreUpdated = "Online.Leaderboard.ScoreUpdated"; + public const string ScoreUpdated = "online.leaderboard.scoreupdated"; /// 事件来源标识。 public const string Source = "online-leaderboard"; diff --git a/Capability/GameFrameX.Capability.Online.Competition/Leaderboard/OnlineLeaderboardProjectionOptions.cs b/Capability/GameFrameX.Capability.Online.Competition/Leaderboard/OnlineLeaderboardProjectionOptions.cs index 5ab01d8e0..241945c3f 100644 --- a/Capability/GameFrameX.Capability.Online.Competition/Leaderboard/OnlineLeaderboardProjectionOptions.cs +++ b/Capability/GameFrameX.Capability.Online.Competition/Leaderboard/OnlineLeaderboardProjectionOptions.cs @@ -30,8 +30,9 @@ namespace GameFrameX.Capability.Online.Competition.Leaderboard; /// -/// 排行榜投影选项(组合根预置共享服务;模块 Register 期探测命中且 -/// 非空白才装配 Match.Settled 投影,空 / 缺省 = 不投影,不报错)。 +/// 排行榜投影选项( 的子对象; +/// 非空白才装配 Match.Settled 投影与激活期榜单预建, +/// 空白 / 缺省 = 不投影,不报错)。 /// public sealed class OnlineLeaderboardProjectionOptions { diff --git a/Capability/GameFrameX.Capability.Online.Competition/Leaderboard/OnlineLeaderboardService.cs b/Capability/GameFrameX.Capability.Online.Competition/Leaderboard/OnlineLeaderboardService.cs index 69cb80ae3..08ba8a240 100644 --- a/Capability/GameFrameX.Capability.Online.Competition/Leaderboard/OnlineLeaderboardService.cs +++ b/Capability/GameFrameX.Capability.Online.Competition/Leaderboard/OnlineLeaderboardService.cs @@ -73,12 +73,12 @@ public sealed class OnlineLeaderboardService /// 初始化 。 /// /// 排行榜存储。 - /// 组件选项(空则取默认值)。 + /// 组件选项(必填;默认值唯一来源 = 字段初始值)。 /// 事件发布器(可空;未接线时不发事件)。 - public OnlineLeaderboardService(IOnlineLeaderboardStore store, OnlineLeaderboardOptions options = null, IOnlineEventPublisher eventPublisher = null) + public OnlineLeaderboardService(IOnlineLeaderboardStore store, OnlineLeaderboardOptions options, IOnlineEventPublisher eventPublisher = null) { _store = store ?? throw new ArgumentNullException(nameof(store)); - _options = options ?? new OnlineLeaderboardOptions(); + _options = options ?? throw new ArgumentNullException(nameof(options)); _eventPublisher = eventPublisher; _rateLimiter = new OnlineLeaderboardRateLimiter(_options.RateLimitMaxSubmissions, _options.RateLimitWindowSeconds); } diff --git a/Capability/GameFrameX.Capability.Online.Competition/OnlineCompetitionCapabilityModule.cs b/Capability/GameFrameX.Capability.Online.Competition/OnlineCompetitionCapabilityModule.cs index 9b2709190..fe5691b81 100644 --- a/Capability/GameFrameX.Capability.Online.Competition/OnlineCompetitionCapabilityModule.cs +++ b/Capability/GameFrameX.Capability.Online.Competition/OnlineCompetitionCapabilityModule.cs @@ -14,7 +14,6 @@ // 侵犯他人合法权益等法律法规所禁止的行为! // or infringe upon the legal 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 @@ -28,6 +27,7 @@ // ========================================================================================== using System; +using System.Threading; using System.Threading.Tasks; using GameFrameX.Capability.Hosting.Abstractions; using GameFrameX.Capability.Hosting.AdminApi; @@ -40,47 +40,41 @@ using GameFrameX.Capability.Online.Abstractions.Contracts; using GameFrameX.Capability.Online.Abstractions.Events; using GameFrameX.Capability.Online.Abstractions.Idempotency; +using GameFrameX.Capability.Online.Abstractions.Scope; using GameFrameX.Capability.Online.Match; -using GameFrameX.Foundation.Idempotency; namespace GameFrameX.Capability.Online.Competition; /// -/// 竞技能力(榜单 / 赛季 / 锦标赛 / LiveOps 承载)的自装配模块(C232 P4 迁入)。 +/// 竞技能力(榜单 / 赛季 / 锦标赛 / LiveOps 承载)的自装配模块(C294 迁入新机制三段生命周期)。 /// /// 维护约束(服务共享协议):模块无参构造(扫描可发现);Register 自装配本域——榜单 / 赛季 / -/// 锦标赛三个 store 经 探测替换缝,未命中构造 -/// InMemory 默认;赛季 / 锦标赛对发放服务(,assets 域产物)的依赖经上下文探测, -/// 缺席(assets 能力被过滤)时不装配两者(壳时代 Seasons / Tournaments 置 null 的模块化等价);对外产物 -/// (榜单 / 赛季 / 锦标赛服务与 LiveOps 注册表)注册回共享面。C245 追加:组合根预置 -/// (board id 非空白)且对局结果存储(online-match 域产物)与幂等 -/// 服务探测命中时装配 Match.Settled 排行榜投影(订阅 + 失败重投 tick),未预置 / 依赖缺席不装配不报错 -/// (与赛季 / 锦标赛缺席装配同口径的模块化等价)。 +/// 锦标赛三个 store 以懒默认描述符注册(族 / 宿主覆盖恒胜出,模块不感知);赛季 / 锦标赛对发放服务 +/// (,assets 域产物)的依赖经 manifest 可选声明 + GetOptionalService, +/// 缺席(assets 能力被过滤)时不装配两者;对外产物(榜单 / 赛季 / 锦标赛服务与 LiveOps 注册表)注册回 +/// 共享面。 为唯一参数面(含榜单组件参数与 Match.Settled 投影配置, +/// 后者收编族级键 MatchBridgeLeaderboardId):投影榜 id 非空白且对局结果存储(online-match 可选产物) +/// 在场时装配 Match.Settled 排行榜投影(机制总线订阅 + 失败重投 tick); +/// ActivateAsync 在投影榜 id 非空白时经榜单服务预建目标榜(查询-创建,见方法注释)。 /// /// -/// -/// The self-assembling module of the competition capability (leaderboards, seasons, tournaments and the -/// LiveOps registry; migrated by C232 P4). The module has a parameterless constructor (discoverable by -/// scanning); Register self-assembles the domain — the leaderboard / season / tournament stores -/// probe the replacement seams with in-memory fallbacks; the season / tournament services probe the grant -/// service (an assets-domain product) through the context and stay unassembled when absent (the modular -/// equivalent of the shell-era null seasons / tournaments); outward products are registered back into the -/// shared face. Since C245 a Match.Settled leaderboard projection (event-bus subscription plus a -/// failure-retry tick job) is assembled when the composition root pre-seeds projection options with a -/// non-blank board id and the match result store / idempotency service are probeable; otherwise it stays -/// unassembled without error. -/// public sealed class OnlineCompetitionCapabilityModule : ICapabilityModule, IAdminActionContributor { /// - /// 竞技能力清单(Id 冻结 = 终态包名;action 集合与幂等包裹位逐字迁移自壳过渡模块; - /// 依赖追加 online-match——Match.Settled 投影真实依赖对局结果存储,Match 不引用 Competition,拓扑无环)。 + /// 竞技能力清单(Id 冻结 = 终态包名;action 集合逐字迁移自壳过渡模块;可选依赖 online-assets / online-match + /// 经 声明,缺席时披露对应缺失功能名)。 /// public static readonly CapabilityManifest ManifestInstance = new( "online-competition", "1.0.0", - new[] { "publishRemoteConfig", "syncDeviceGroup", "syncScheduledTask", "pushAnnouncement", "syncPlayerSegment", "publishConfigRollout", "rollbackConfigRollout" }, - new[] { "online-assets", "online-match" }); + new[] { "publishRemoteConfig", "syncDeviceGroup", "syncScheduledTask", "pushAnnouncement", "syncPlayerSegment", "publishConfigRollout", "rollbackConfigRollout", "query_leaderboard" }, + null, + new[] + { + new CapabilityOptionalDependency("online-assets", Localization.Keys.Capability.Online.Competition.OptionalAssetsAbsent), + new CapabilityOptionalDependency("online-match", Localization.Keys.Capability.Online.Competition.OptionalMatchAbsent), + }, + typeof(OnlineCompetitionOptions)); /// /// LiveOps 注册表(Register 期构造;admin 面 handler 取用)。 @@ -88,15 +82,20 @@ public sealed class OnlineCompetitionCapabilityModule : ICapabilityModule, IAdmi private OnlineLiveOpsRegistry _liveOps; /// - /// 排行榜服务(Register 期构造;admin 面 handler 与投影链取用)。 + /// 排行榜服务(Register 期构造;admin 面 handler、投影链与激活期榜单预建取用)。 /// private OnlineLeaderboardService _leaderboards; /// - /// Match.Settled 排行榜投影器(Register 期探测装配;未预置投影选项或依赖缺席时为 null)。 + /// Match.Settled 排行榜投影器(Register 期装配;投影配置空白或 online-match 缺席时为 null)。 /// private OnlineMatchSettledLeaderboardProjector _projectionProjector; + /// + /// Match.Settled 投影目标榜单标识(Register 期从 Options 读出;空白 = 不投影不预建;ActivateAsync 预建判据)。 + /// + private string _projectionBoardId; + /// public CapabilityManifest Manifest { @@ -106,81 +105,93 @@ public CapabilityManifest Manifest /// public void Register(CapabilityRegistrationContext context) { + var options = context.GetOptions(); var eventPublisher = context.GetService(); - var leaderboardStore = ResolveStore(context, static () => new InMemoryOnlineLeaderboardStore()); - var seasonStore = ResolveStore(context, static () => new InMemoryOnlineSeasonStore()); - var tournamentStore = ResolveStore(context, static () => new InMemoryOnlineTournamentStore()); - var leaderboards = new OnlineLeaderboardService(leaderboardStore, null, eventPublisher); + // 三 store 懒默认描述符(族 / 宿主覆盖恒胜出;GetService 命中覆盖或本默认)。 + context.AddService(static _ => new InMemoryOnlineLeaderboardStore()); + var leaderboardStore = context.GetService(); + context.AddService(static _ => new InMemoryOnlineSeasonStore()); + var seasonStore = context.GetService(); + context.AddService(static _ => new InMemoryOnlineTournamentStore()); + var tournamentStore = context.GetService(); + + var leaderboards = new OnlineLeaderboardService(leaderboardStore, options.Leaderboard, eventPublisher); _leaderboards = leaderboards; context.AddService(leaderboards); _liveOps = new OnlineLiveOpsRegistry(); context.AddService(_liveOps); - if (context.TryGetService(out OnlineGrantService grantService)) + // 发放服务(assets 域可选产物):在场才装配赛季 / 锦标赛(壳时代 Seasons / Tournaments 置 null 的模块化等价)。 + var grantService = context.GetOptionalService(); + if (grantService != null) { context.AddService(new OnlineSeasonService(seasonStore, leaderboards, grantService, eventPublisher)); context.AddService(new OnlineTournamentService(tournamentStore, leaderboards, grantService, eventPublisher)); } - // Match.Settled → 排行榜投影(C245):组合根预置 OnlineLeaderboardProjectionOptions(board id 空白 = 不投影) - // 且对局结果存储 / 幂等服务探测命中才装配;订阅事件总线同步投影(异常吞掉不阻断分发——Operations 审计桥先例), - // 失败经 tick job leaderboard-projection-retry 重投;结果存储未命中(online-match 被过滤)→ 不装配不报错 - // (模块化等价先例 = 赛季 / 锦标赛对发放服务的缺席装配)。 - var projectionOptions = context.TryGetService(out OnlineLeaderboardProjectionOptions projectionOptionsValue) ? projectionOptionsValue : null; - if (projectionOptions != null - && !string.IsNullOrWhiteSpace(projectionOptions.MatchSettledBoardId) - && context.TryGetService(out IOnlineMatchResultStore projectionResultStore) - && context.TryGetService(out OnlineIdempotencyService projectionIdempotency)) + // Match.Settled → 排行榜投影:Options 投影榜 id 非空白(空白 = 不投影)且对局结果存储在场 + // (online-match 被过滤 → 不装配不报错)才装配;订阅走机制事件总线(Register 期声明, + // 单个订阅体异常由机制记日志后继续分发,业务侧不再自行吞异常),失败经 tick job 重投。 + _projectionBoardId = options.LeaderboardProjection.MatchSettledBoardId; + var projectionResultStore = context.GetOptionalService(); + if (!string.IsNullOrWhiteSpace(_projectionBoardId) && projectionResultStore != null) { - _projectionProjector = new OnlineMatchSettledLeaderboardProjector(projectionResultStore, leaderboards, projectionIdempotency, projectionOptions.MatchSettledBoardId); - context.AddService(_projectionProjector); - var projectionEventBus = context.GetService(); - projectionEventBus.Subscribe(envelope => - { - try - { - var onlineEvent = OnlineEventEnvelopeMapper.FromEnvelope(envelope); - if (onlineEvent.EventType == OnlineMatchRuntimeEvents.MatchSettled) - { - _projectionProjector.HandleEventAsync(onlineEvent).GetAwaiter().GetResult(); - } - } - catch (Exception) - { - // 投影订阅体异常不得阻断业务事件分发(InMemoryEventPublisher 异常策略是向上传播, - // 此处吞掉;失败已由投影器记待重试,经 QueryStatus / admin 查询面可观测)。 - } - }); - context.AddTickJob("leaderboard-projection-retry", ct => _projectionProjector.RetryPendingAsync(ct)); + var projectionIdempotency = context.GetService(); + var projector = new OnlineMatchSettledLeaderboardProjector(projectionResultStore, leaderboards, projectionIdempotency, _projectionBoardId); + _projectionProjector = projector; + context.AddService(projector); + context.Subscribe(OnlineMatchRuntimeEvents.MatchSettled, onlineEvent => _projectionProjector.HandleEventAsync(onlineEvent).GetAwaiter().GetResult()); + context.AddTickJob("leaderboard-projection-retry", cancellationToken => _projectionProjector.RetryPendingAsync(cancellationToken)); } } /// - public void RegisterAdminActions(AdminActionRegistry registry) + public async Task ActivateAsync(CapabilityActivationContext context, CancellationToken cancellationToken) { - OnlineAdminLiveOpsHandlers.RegisterLiveOps(registry, _liveOps); - OnlineAdminLeaderboardHandlers.RegisterLeaderboardQuery(registry, _leaderboards); - } + if (string.IsNullOrWhiteSpace(_projectionBoardId)) + { + return; + } - /// - /// 解析存储(替换缝探测——命中用组合根注入实例,未命中构造 InMemory 默认并注册回共享面)。 - /// - /// 存储接口。 - /// 注册上下文。 - /// 默认构造。 - /// 存储实例。 - private static TStore ResolveStore(CapabilityRegistrationContext context, Func defaultFactory) - where TStore : class - { - if (context.TryGetService(out TStore store)) + // 榜单预建(迁自 Launcher OnlineMatchBridgeWiring.EnsureMatchLeaderboardBoardAsync):投影目标榜 + // 查询-创建——GetTop 探针命中即已存在;ResourceNotFound 视作首建,按 Sum / Descending 创建; + // 其余失败码或创建失败 = 激活失败(抛 InvalidOperationException 由机制逆序回滚)。 + var scope = context.Services.GetService(); + var probe = await _leaderboards.GetTopAsync(scope, new OnlineLeaderboardTopQuery { LeaderboardId = _projectionBoardId, Count = 1, }, cancellationToken).ConfigureAwait(false); + if (probe.IsSuccess) { - return store; + return; } - store = defaultFactory(); - context.AddService(store); - return store; + if (probe.Code != OnlineErrorCode.ResourceNotFound) + { + throw new InvalidOperationException("Leaderboard pre-build probe failed for board '" + _projectionBoardId + "' (code: " + probe.Code + ")."); + } + + var created = await _leaderboards.CreateAsync(scope, new OnlineLeaderboardCreateRequest + { + LeaderboardId = _projectionBoardId, + SortOrder = OnlineLeaderboardSortOrder.Descending, + ScoreUpdatePolicy = OnlineLeaderboardScoreUpdatePolicy.Sum, + }, 0, cancellationToken).ConfigureAwait(false); + if (!created.IsSuccess) + { + throw new InvalidOperationException("Leaderboard pre-build create failed for board '" + _projectionBoardId + "' (code: " + created.Code + ")."); + } + } + + /// + public Task DeactivateAsync(CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + /// + public void RegisterAdminActions(AdminActionRegistry registry) + { + OnlineAdminLiveOpsHandlers.RegisterLiveOps(registry, _liveOps); + OnlineAdminLeaderboardHandlers.RegisterLeaderboardQuery(registry, _leaderboards); } } diff --git a/Capability/GameFrameX.Capability.Online.Competition/OnlineCompetitionHostingExtensions.cs b/Capability/GameFrameX.Capability.Online.Competition/OnlineCompetitionHostingExtensions.cs deleted file mode 100644 index 30dd6bb1c..000000000 --- a/Capability/GameFrameX.Capability.Online.Competition/OnlineCompetitionHostingExtensions.cs +++ /dev/null @@ -1,111 +0,0 @@ -// ========================================================================================== -// GameFrameX 组织及其衍生项目的版权、商标、专利及其他相关权利 -// GameFrameX organization and its derivative projects' copyrights, trademarks, patents and related rights -// 均受中华人民共和国及相关国际法律法规保护。 -// are protected by the laws of the PRC 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 refer to the LICENSE file at the root 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, -// 本组织与贡献者概不承担任何责任。 -// 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://www.cnb.cool/GameFrameX -// CNB Repository: https://www.cnb.cool/GameFrameX -// 官方文档:https://gameframex.doc.alianblank.com/ -// Official Documentation: https://gameframex.doc.alianblank.com/ -// ========================================================================================== - -using GameFrameX.Capability.Hosting.Core; -using GameFrameX.Capability.Online.Competition.Leaderboard; -using GameFrameX.Capability.Online.Competition.Season; -using GameFrameX.Capability.Online.Competition.Tournament; - -namespace GameFrameX.Capability.Online.Competition; - -/// -/// 竞技能力包的 hosting 扩展(C232 P4): 装配入口(回调式,注册自装配模块) -/// 与三个存储替换缝( / / -/// )。 -/// -/// 维护约束:options 经共享服务面预置(模块无参构造,显式链式与 P4 真扫描共用同一模块类); -/// 替换缝在模块 Register 期探测命中,未命中构造 InMemory 默认。 -/// -/// -/// -/// Hosting extensions of the competition capability package (C232 P4): the -/// assembly entry (callback style, registering the self-assembling module) plus three store replacement seams. -/// Options are pre-seeded through the shared service face; the replacement seams are probed at module -/// Register time with in-memory fallbacks. -/// -public static class OnlineCompetitionHostingExtensions -{ - /// - /// 装配竞技能力运行时(注册自装配模块;options => ... 回调承载包级编排参数)。 - /// - /// 能力装配器。 - /// 包级选项回调(可空 = 全默认)。 - /// 本装配器(链式)。 - public static CapabilityHostBuilder AddOnlineCompetitionRuntime(this CapabilityHostBuilder builder, Action configure = null) - { - ArgumentNullException.ThrowIfNull(builder); - - var options = new OnlineCompetitionOptions(); - configure?.Invoke(options); - builder.UseService(options); - return builder.AddModule(new OnlineCompetitionCapabilityModule()); - } - - /// - /// 替换榜单存储(默认 InMemory,生产持久化实现经本缝注入)。 - /// - /// 能力装配器。 - /// 存储实现。 - /// 本装配器(链式)。 - public static CapabilityHostBuilder UseOnlineLeaderboardStore(this CapabilityHostBuilder builder, IOnlineLeaderboardStore store) - { - ArgumentNullException.ThrowIfNull(builder); - - builder.UseService(store); - return builder; - } - - /// - /// 替换赛季存储(默认 InMemory,生产持久化实现经本缝注入)。 - /// - /// 能力装配器。 - /// 存储实现。 - /// 本装配器(链式)。 - public static CapabilityHostBuilder UseOnlineSeasonStore(this CapabilityHostBuilder builder, IOnlineSeasonStore store) - { - ArgumentNullException.ThrowIfNull(builder); - - builder.UseService(store); - return builder; - } - - /// - /// 替换锦标赛存储(默认 InMemory,生产持久化实现经本缝注入)。 - /// - /// 能力装配器。 - /// 存储实现。 - /// 本装配器(链式)。 - public static CapabilityHostBuilder UseOnlineTournamentStore(this CapabilityHostBuilder builder, IOnlineTournamentStore store) - { - ArgumentNullException.ThrowIfNull(builder); - - builder.UseService(store); - return builder; - } -} diff --git a/Capability/GameFrameX.Capability.Online.Competition/OnlineCompetitionOptions.cs b/Capability/GameFrameX.Capability.Online.Competition/OnlineCompetitionOptions.cs index 1e083a1ee..9911d224b 100644 --- a/Capability/GameFrameX.Capability.Online.Competition/OnlineCompetitionOptions.cs +++ b/Capability/GameFrameX.Capability.Online.Competition/OnlineCompetitionOptions.cs @@ -4,7 +4,7 @@ // 均受中华人民共和国及相关国际法律法规保护。 // are protected by the laws of the PRC and relevant international regulations. // 使用本项目须严格遵守相关法律法规与开源许可证之规定。 -// Usage of this project must strictly comply with applicable laws, regulations, and open-source licenses. +// Usage of this project must strictly comply with relevant laws, regulations, and open-source licenses. // 本项目采用 Apache License 2.0 单协议分发, // This project is licensed solely under the Apache License 2.0, // 完整许可证文本请参见源代码根目录下的 LICENSE 文件。 @@ -12,10 +12,10 @@ // 禁止利用本项目实施任何危害国家安全、破坏社会秩序、 // 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! +// or infringe upon the legal rights and interests of others, as prohibited by laws and regulations! // 因基于本项目二次开发所产生的一切法律纠纷与责任, -// Any legal disputes or liabilities arising from secondary development, -// 本组织与贡献者概不承担任何责任。 +// 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 @@ -24,19 +24,38 @@ // CNB 仓库:https://www.cnb.cool/GameFrameX // CNB Repository: https://www.cnb.cool/GameFrameX // 官方文档:https://gameframex.doc.alianblank.com/ -// Official Documentation: https://gameframex.doc.alianblank.com/ +// Official documentation: https://gameframex.doc.alianblank.com/ // ========================================================================================== +using GameFrameX.Capability.Hosting.Abstractions; +using GameFrameX.Capability.Online.Competition.Leaderboard; + namespace GameFrameX.Capability.Online.Competition; /// -/// 竞技能力包级选项(C232 P4 迁入;当前无包级编排参数,占位对齐包族选项形态—— -/// 榜单选项在 域级定义)。 +/// 竞技能力包级 Options 根类型(C294:聚合榜单组件参数与 Match.Settled 投影配置; +/// 投影子对象收编族级键 MatchBridgeLeaderboardId → Settings.online-competition.LeaderboardProjection.MatchSettledBoardId, +/// 默认值唯一来源 = 字段初始值)。 /// -/// -/// Package-level options of the competition capability (migrated by C232 P4; currently a placeholder -/// with no orchestration parameters — leaderboard options live at the domain level). -/// -public sealed class OnlineCompetitionOptions +public sealed class OnlineCompetitionOptions : ICapabilityOptions { + /// + /// 获取或设置排行榜组件选项(防刷阈值 / 限流窗口 / 读缓存 / 分页上限)。 + /// + public OnlineLeaderboardOptions Leaderboard { get; set; } = new OnlineLeaderboardOptions(); + + /// + /// 获取或设置 Match.Settled 写榜投影配置(MatchSettledBoardId 空白 = 不投影不预建)。 + /// + public OnlineLeaderboardProjectionOptions LeaderboardProjection { get; set; } = new OnlineLeaderboardProjectionOptions(); + + /// + public void Validate(CapabilityOptionsValidation validation) + { + validation.RequirePositive("Leaderboard.DefaultMaxScorePerSubmission", Leaderboard.DefaultMaxScorePerSubmission); + validation.RequirePositive("Leaderboard.RateLimitMaxSubmissions", Leaderboard.RateLimitMaxSubmissions); + validation.RequirePositive("Leaderboard.RateLimitWindowSeconds", Leaderboard.RateLimitWindowSeconds); + validation.RequirePositive("Leaderboard.CacheTtlSeconds", Leaderboard.CacheTtlSeconds); + validation.RequirePositive("Leaderboard.MaxPageSize", Leaderboard.MaxPageSize); + } } diff --git a/Capability/GameFrameX.Capability.Online.Competition/Season/OnlineSeasonEvents.cs b/Capability/GameFrameX.Capability.Online.Competition/Season/OnlineSeasonEvents.cs index 7b3848ec0..25d71cfe5 100644 --- a/Capability/GameFrameX.Capability.Online.Competition/Season/OnlineSeasonEvents.cs +++ b/Capability/GameFrameX.Capability.Online.Competition/Season/OnlineSeasonEvents.cs @@ -47,16 +47,16 @@ namespace GameFrameX.Capability.Online.Competition.Season; public static class OnlineSeasonEvents { /// 赛季创建(排期已登记)。 - public const string SeasonCreated = "Online.Season.Created"; + public const string SeasonCreated = "online.season.created"; /// 赛季开始(进入进行中)。 - public const string SeasonStarted = "Online.Season.Started"; + public const string SeasonStarted = "online.season.started"; /// 赛季结束(快照已落档、榜单已重置)。 - public const string SeasonEnded = "Online.Season.Ended"; + public const string SeasonEnded = "online.season.ended"; /// 赛季结算(奖励已按快照名次发放完毕)。 - public const string SeasonSettled = "Online.Season.Settled"; + public const string SeasonSettled = "online.season.settled"; /// 事件来源标识。 public const string Source = "online-season"; diff --git a/Capability/GameFrameX.Capability.Online.Competition/Tournament/OnlineTournamentEvents.cs b/Capability/GameFrameX.Capability.Online.Competition/Tournament/OnlineTournamentEvents.cs index 259372be9..b6e0f1f1d 100644 --- a/Capability/GameFrameX.Capability.Online.Competition/Tournament/OnlineTournamentEvents.cs +++ b/Capability/GameFrameX.Capability.Online.Competition/Tournament/OnlineTournamentEvents.cs @@ -51,19 +51,19 @@ namespace GameFrameX.Capability.Online.Competition.Tournament; public static class OnlineTournamentEvents { /// 赛事创建(排期已登记)。 - public const string TournamentCreated = "Online.Tournament.Created"; + public const string TournamentCreated = "online.tournament.created"; /// 赛事开始(进入进行中,报名窗口关闭)。 - public const string TournamentStarted = "Online.Tournament.Started"; + public const string TournamentStarted = "online.tournament.started"; /// 玩家报名成功(玩家维度事件;重放报名不重发)。 - public const string TournamentRegistered = "Online.Tournament.Registered"; + public const string TournamentRegistered = "online.tournament.registered"; /// 赛事结束(成绩已从关联榜单冻结落档;**不重置榜单**)。 - public const string TournamentEnded = "Online.Tournament.Ended"; + public const string TournamentEnded = "online.tournament.ended"; /// 赛事结算(奖励已按冻结成绩名次发放完毕)。 - public const string TournamentSettled = "Online.Tournament.Settled"; + public const string TournamentSettled = "online.tournament.settled"; /// 事件来源标识。 public const string Source = "online-tournament"; diff --git a/Capability/GameFrameX.Capability.Online.Competition/Tournament/OnlineTournamentService.cs b/Capability/GameFrameX.Capability.Online.Competition/Tournament/OnlineTournamentService.cs index 0fd67b578..905131b79 100644 --- a/Capability/GameFrameX.Capability.Online.Competition/Tournament/OnlineTournamentService.cs +++ b/Capability/GameFrameX.Capability.Online.Competition/Tournament/OnlineTournamentService.cs @@ -236,7 +236,11 @@ public async Task> RegisterAsy var rank = 0; var score = 0L; - var eligibility = tournament.Eligibility ?? new OnlineTournamentEligibility(); + var eligibility = tournament.Eligibility; + if (eligibility == null) + { + eligibility = new OnlineTournamentEligibility(); + } if (eligibility.RequiresLeaderboardLookup()) { var rankResult = await _leaderboardService.GetPlayerRankAsync(scope, tournament.LeaderboardId, playerId, nowUnixMilliseconds, cancellationToken).ConfigureAwait(false); diff --git a/Capability/GameFrameX.Capability.Online.Foundation/OnlineFoundationHostingExtensions.cs b/Capability/GameFrameX.Capability.Online.Foundation/OnlineAuthorizedScopeProvider.cs similarity index 50% rename from Capability/GameFrameX.Capability.Online.Foundation/OnlineFoundationHostingExtensions.cs rename to Capability/GameFrameX.Capability.Online.Foundation/OnlineAuthorizedScopeProvider.cs index b46b6ef25..94afdebb5 100644 --- a/Capability/GameFrameX.Capability.Online.Foundation/OnlineFoundationHostingExtensions.cs +++ b/Capability/GameFrameX.Capability.Online.Foundation/OnlineAuthorizedScopeProvider.cs @@ -4,7 +4,7 @@ // 均受中华人民共和国及相关国际法律法规保护。 // are protected by the laws of the PRC and relevant international regulations. // 使用本项目须严格遵守相关法律法规与开源许可证之规定。 -// Usage of this project must strictly comply with applicable laws, regulations, and open-source licenses. +// 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 文件。 @@ -12,7 +12,7 @@ // 禁止利用本项目实施任何危害国家安全、破坏社会秩序、 // 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! +// or infringe upon the legal rights and interests of others as prohibited by laws and regulations! // 因基于本项目二次开发所产生的一切法律纠纷与责任, // Any legal disputes or liabilities arising from secondary development, // 本组织与贡献者概不承担任何责任。 @@ -22,46 +22,41 @@ // Gitee 仓库:https://gitee.com/GameFrameX // Gitee Repository: https://github.com/GameFrameX // CNB 仓库:https://www.cnb.cool/GameFrameX -// CNB Repository: https://www.cnb.cool/GameFrameX +// CNB Repository: https://github.com/GameFrameX // 官方文档:https://gameframex.doc.alianblank.com/ // Official Documentation: https://gameframex.doc.alianblank.com/ // ========================================================================================== -using GameFrameX.Capability.Hosting.Core; - namespace GameFrameX.Capability.Online.Foundation; +using GameFrameX.Capability.Online.Abstractions.Scope; + /// -/// 平台地基能力包的 hosting 扩展(C232 P6): 装配入口 -/// (回调式,注册自装配模块)。地基三 store 的替换缝经共享服务面预置(模块 Register 期探测命中, -/// 未命中构造 InMemory 默认),不另设包级语法糖。 +/// 宿主授权作用域桥实现(透出族基建预置的 三元组)。 /// -/// 维护约束:模块无参构造,显式链式与真扫描(组合根 发现)共用同一模块类; -/// 生产组合根(Launcher 的 AddOnlineCapabilities)经真扫描装配,不经本入口。 +/// 维护约束:实例持有的作用域来自族配置段(Tenant / App / Server),全宿主单例语义;本类只透出 +/// 不构造——消费方(Hotfix 协议薄适配器)TryAcquire 失败即显式 8002,不得回落自造默认作用域。 /// /// -/// -/// Hosting extensions of the foundation capability package (C232 P6): the -/// assembly entry (callback style, registering the -/// self-assembling module). The three foundation stores probe the shared service face at module -/// Register time with in-memory fallbacks; no package-level sugar is added for those seams. -/// The production composition root assembles through real scanning rather than this entry. -/// -public static class OnlineFoundationHostingExtensions +public sealed class OnlineAuthorizedScopeProvider : IOnlineAuthorizedScopeProvider { /// - /// 装配平台地基能力运行时(注册自装配模块;options => ... 回调承载包级编排参数)。 + /// 族基建预置的授权作用域。 /// - /// 能力装配器。 - /// 包级选项回调(可空 = 全默认)。 - /// 本装配器(链式)。 - public static CapabilityHostBuilder AddOnlineFoundationRuntime(this CapabilityHostBuilder builder, Action configure = null) + private readonly OnlineScope _authorizedScope; + + /// + /// 初始化 。 + /// + /// 族基建预置的授权作用域。 + public OnlineAuthorizedScopeProvider(OnlineScope authorizedScope) { - ArgumentNullException.ThrowIfNull(builder); + _authorizedScope = authorizedScope ?? throw new ArgumentNullException(nameof(authorizedScope)); + } - var options = new OnlineFoundationOptions(); - configure?.Invoke(options); - builder.UseService(options); - return builder.AddModule(new OnlineFoundationCapabilityModule()); + /// + public OnlineScope AuthorizedScope + { + get { return _authorizedScope; } } } diff --git a/Capability/GameFrameX.Capability.Online.Foundation/OnlineFoundationCapabilityModule.cs b/Capability/GameFrameX.Capability.Online.Foundation/OnlineFoundationCapabilityModule.cs index 58229a585..89b8d3cd0 100644 --- a/Capability/GameFrameX.Capability.Online.Foundation/OnlineFoundationCapabilityModule.cs +++ b/Capability/GameFrameX.Capability.Online.Foundation/OnlineFoundationCapabilityModule.cs @@ -6,13 +6,13 @@ // 使用本项目须严格遵守相关法律法规与开源许可证之规定。 // 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, +// This project is licensed under the Apache License 2.0, // 完整许可证文本请参见源代码根目录下的 LICENSE 文件。 // please refer to the LICENSE file at the root 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! +// or infringe upon the legal rights and interests of others as prohibited by laws and regulations! // 因基于本项目二次开发所产生的一切法律纠纷与责任, // Any legal disputes or liabilities arising from secondary development, // 本组织与贡献者概不承担任何责任。 @@ -22,7 +22,7 @@ // Gitee 仓库:https://gitee.com/GameFrameX // Gitee Repository: https://github.com/GameFrameX // CNB 仓库:https://www.cnb.cool/GameFrameX -// CNB Repository: https://www.cnb.cool/GameFrameX +// CNB Repository: https://github.com/GameFrameX // 官方文档:https://gameframex.doc.alianblank.com/ // Official Documentation: https://gameframex.doc.alianblank.com/ // ========================================================================================== @@ -42,35 +42,46 @@ namespace GameFrameX.Capability.Online.Foundation; /// /// 平台地基能力(Identity / Session / Token / Presence 四域的服务与清扫)的自装配模块 -/// (C232 P6 迁入,自 Runtime 壳过渡模块无参化)。 +/// (C232 P6 迁入;C294 改写为三段生命周期机制模块)。 /// /// 维护约束(服务共享协议):模块无参构造( 扫描可发现); -/// Register 自装配本域——三 store 经替换缝探测(命中用组合根注入实例,未命中构造 InMemory -/// 默认并注册回共享面),四个域服务(身份 / 会话管理 / 会话令牌 / 在线状态)构造后注册回共享面供 -/// 后序能力(Operations 总览 / 时间线)取用;两个清扫滴答 job 归属地基域;授权作用域与事件出口 -/// 经上下文解析(组合根预置)。玩家 KV 存储(Storage 域)为壳时代死装配(构造无消费),模块不装配, -/// 消费方出现时再随装配演进(notes 披露)。 +/// Register 自装配本域——三 store 以懒默认注册后取用(族 / 宿主 Override 自动胜出,模块不做 +/// 探测回退),四个域服务(身份 / 会话管理 / 会话令牌 / 在线状态)构造后注册回共享面供后序能力取用; +/// 两个 Presence 探针实现以默认注册供消费方 Activate 期枚举;三个清扫滴答 job 归属地基域。 +/// ActivateAsync 发布两座桥:授权作用域提供者(透出族预置 )与 +/// 会话恢复契约;DeactivateAsync 无自持资源,默认空实现。 +/// 玩家 KV 存储(Storage 域)为壳时代死装配(构造无消费),本模块仍不装配,消费方出现时再随装配演进 +/// ( 已披露其参数面)。 /// /// /// /// The self-assembling module of the platform foundation capability (identity / session / token / -/// presence domains and their sweeps; migrated by C232 P6 from the Runtime-shell transitional module, -/// made parameterless). The module has a parameterless constructor (discoverable by +/// presence domains and their sweeps; migrated by C232 P6, rewritten by C294 for the three-stage +/// lifecycle). The module has a parameterless constructor (discoverable by /// scanning); Register self-assembles the domain — the three -/// stores probe replacement seams (falling back to in-memory defaults registered back into the shared -/// face), the four domain services are constructed and registered back for later capabilities -/// (the Operations overview / timeline), and the two sweep tick jobs belong to the foundation domain. -/// The player key-value storage stays unassembled (a dead assembly in the shell era, disclosed in notes). +/// stores register lazy in-memory defaults and resolve through the shared face (family / host +/// overrides win automatically, no probing fallback), the four domain services are constructed and +/// registered back for later capabilities, the two presence probes register as defaults for +/// Activate-phase enumeration, and the three sweep tick jobs belong to the foundation domain. +/// ActivateAsync publishes two bridges: the authorized scope provider (exposing the +/// family-preset ) and the session reconnect contract; +/// DeactivateAsync holds no resources of its own. +/// The player key-value storage stays unassembled (a dead assembly in the shell era, disclosed via +/// ). /// public sealed class OnlineFoundationCapabilityModule : ICapabilityModule, IAdminActionContributor { /// - /// 平台地基能力清单(Id 冻结 = 终态包名;action 集合与幂等包裹位逐字迁移自壳过渡模块)。 + /// 平台地基能力清单(Id 冻结 = 终态包名;action 集合与幂等包裹位逐字迁移自壳过渡模块; + /// Options 根类型 = ,无依赖声明)。 /// public static readonly CapabilityManifest ManifestInstance = new( "online-foundation", "1.0.0", - new[] { "suspend_resume_player", }); + new[] { "suspend_resume_player", }, + null, + null, + typeof(OnlineFoundationOptions)); /// /// 会话令牌(Register 期构造;清扫滴答 job 与 admin 管制命令的消费面)。 @@ -83,7 +94,7 @@ public sealed class OnlineFoundationCapabilityModule : ICapabilityModule, IAdmin private OnlineSessionManager _sessions; /// - /// 在线状态(Register 期构造;清扫滴答 job 与 admin 管制命令的消费面)。 + /// 在线状态(Register 期构造;清扫滴答 job、admin 管制命令与 Presence 探针的消费面)。 /// private OnlinePresenceService _presence; @@ -99,13 +110,15 @@ public void Register(CapabilityRegistrationContext context) var eventPublisher = context.GetService(); var scope = context.GetService(); - // 三 store 经替换缝探测,未命中构造 InMemory 默认并注册回共享面(Operations 总览 / 时间线取用同一实例)。 - var identityStore = ResolveStore(context, static () => new InMemoryOnlineIdentityStore()); - var presenceStore = ResolveStore(context, static () => new InMemoryOnlinePresenceStore()); - var sessionStore = ResolveStore(context, static () => new InMemoryOnlineSessionStore()); + // 三 store:懒默认注册后经共享面取用(族 / 宿主 GameDb Override 自动胜出;Operations 总览 / 时间线取同一实例)。 + context.AddService(static _ => new InMemoryOnlineIdentityStore()); + context.AddService(static _ => new InMemoryOnlinePresenceStore()); + context.AddService(static _ => new InMemoryOnlineSessionStore()); + var identityStore = context.GetService(); + var presenceStore = context.GetService(); + var sessionStore = context.GetService(); - // 四域服务构造并注册回共享面(与壳时代集中 new 逐语句等价;壳未预置具型共享的 Identity / Sessions / - // Presence 此处一并注册——服务面按需解析,注册超集无行为影响)。 + // 四域服务构造并注册回共享面(与壳时代集中 new 逐语句等价)。 context.AddService(new OnlineIdentityService(identityStore)); _sessions = new OnlineSessionManager(sessionStore, eventPublisher); context.AddService(_sessions); @@ -114,10 +127,15 @@ public void Register(CapabilityRegistrationContext context) _presence = new OnlinePresenceService(presenceStore, eventPublisher); context.AddService(_presence); - // 会话恢复服务(C204):Core 物理连接会话与 Online 逻辑会话协同的契约面实现,注册回共享面供组合根发桥。 + // 会话恢复服务(C204):Core 物理连接会话与 Online 逻辑会话协同的契约面实现,注册后在 Activate 期发桥。 context.AddService(new OnlineSessionReconnectService(_tokens, _sessions, sessionStore)); - // 清扫滴答 job(归属地基域;作用域三元组经组合根预置的授权作用域解析——壳时代 host.AuthorizedScope 等价)。 + // Presence 探针(C294 契约迁入 Abstractions.Contracts):包装本域在线状态读取,默认注册供 + // Social / Matchmaking 等消费方 Activate 期 GetServices 枚举。 + context.AddService(new OnlineSocialPresenceProbe(_presence)); + context.AddService(new OnlinePartyPresenceProbe(_presence)); + + // 清扫滴答 job(归属地基域;作用域三元组经族预置的授权作用域解析——壳时代 host.AuthorizedScope 等价)。 context.AddTickJob("presence-timeout-sweep", async ct => { await _presence.SweepTimeoutsAsync(scope.TenantId, scope.AppId, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), ct).ConfigureAwait(false); @@ -129,28 +147,25 @@ public void Register(CapabilityRegistrationContext context) } /// - public void RegisterAdminActions(AdminActionRegistry registry) + public Task ActivateAsync(CapabilityActivationContext context, CancellationToken cancellationToken) { - OnlineAdminFoundationHandlers.RegisterSuspendResume(registry, _presence); + // 授权作用域桥:透出族基建预置的 OnlineScope 三元组(全宿主唯一发布者;Hotfix 侧 TryAcquire 失败即显式 8002)。 + context.Bridges.Publish(new OnlineAuthorizedScopeProvider(context.Services.GetService())); + + // 会话恢复桥:C204 契约面(Register 期注册的实现实例)。 + context.Bridges.Publish(context.Services.GetService()); + return Task.CompletedTask; } - /// - /// 解析存储(替换缝探测——命中用组合根注入实例,未命中构造 InMemory 默认并注册回共享面)。 - /// - /// 存储接口。 - /// 注册上下文。 - /// 默认构造。 - /// 存储实例。 - private static TStore ResolveStore(CapabilityRegistrationContext context, Func defaultFactory) - where TStore : class + /// + public Task DeactivateAsync(CancellationToken cancellationToken) { - if (context.TryGetService(out TStore store)) - { - return store; - } + return Task.CompletedTask; + } - store = defaultFactory(); - context.AddService(store); - return store; + /// + public void RegisterAdminActions(AdminActionRegistry registry) + { + OnlineAdminFoundationHandlers.RegisterSuspendResume(registry, _presence); } } diff --git a/Capability/GameFrameX.Capability.Online.Foundation/OnlineFoundationOptions.cs b/Capability/GameFrameX.Capability.Online.Foundation/OnlineFoundationOptions.cs index 6f17c1e11..ededd5dd5 100644 --- a/Capability/GameFrameX.Capability.Online.Foundation/OnlineFoundationOptions.cs +++ b/Capability/GameFrameX.Capability.Online.Foundation/OnlineFoundationOptions.cs @@ -4,7 +4,7 @@ // 均受中华人民共和国及相关国际法律法规保护。 // are protected by the laws of the PRC and relevant international regulations. // 使用本项目须严格遵守相关法律法规与开源许可证之规定。 -// Usage of this project must strictly comply with applicable laws, regulations, and open-source licenses. +// 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 文件。 @@ -12,7 +12,7 @@ // 禁止利用本项目实施任何危害国家安全、破坏社会秩序、 // 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! +// or infringe upon the legal rights and interests of others as prohibited by laws and regulations! // 因基于本项目二次开发所产生的一切法律纠纷与责任, // Any legal disputes or liabilities arising from secondary development, // 本组织与贡献者概不承担任何责任。 @@ -20,22 +20,45 @@ // GitHub 仓库:https://github.com/GameFrameX // GitHub Repository: https://github.com/GameFrameX // Gitee 仓库:https://gitee.com/GameFrameX -// Gitee Repository: https://gitee.com/GameFrameX +// Gitee Repository: https://github.com/GameFrameX // CNB 仓库:https://www.cnb.cool/GameFrameX -// CNB Repository: https://www.cnb.cool/GameFrameX +// CNB Repository: https://github.com/GameFrameX // 官方文档:https://gameframex.doc.alianblank.com/ // Official Documentation: https://gameframex.doc.alianblank.com/ // ========================================================================================== namespace GameFrameX.Capability.Online.Foundation; +using GameFrameX.Capability.Hosting.Abstractions; +using GameFrameX.Capability.Online.Foundation.Storage; + /// -/// 平台地基能力包级选项(C232 P6 迁入;当前无包级编排参数,占位对齐包族选项形态)。 +/// 平台地基能力包级选项(C294 充实:聚合玩家 KV 存储限制参数并参与机制三层合并)。 +/// +/// 维护约束:本类型是 online-foundation 能力唯一的 Options 根类型(默认值唯一来源 = 字段初始值, +/// 经机制配置段 Online.Settings.online-foundation 与代码 Configure 逐层覆盖后统一 +/// );玩家 KV 存储域服务本模块仍不装配(壳时代死装配),此处仅披露其参数面, +/// 消费方出现时随装配演进。 +/// /// /// -/// Package-level options of the foundation capability (migrated by C232 P6; currently a placeholder -/// with no orchestration parameters). +/// Package-level options of the foundation capability (enriched by C294: aggregates the player +/// key-value storage limits and joins the mechanism's three-layer options merge). This type is the +/// single options root of the capability (defaults originate solely from field initializers); the +/// player storage domain service stays unassembled — the parameters are disclosed here only. /// -public sealed class OnlineFoundationOptions +public sealed class OnlineFoundationOptions : ICapabilityOptions { + /// + /// 获取或设置玩家 KV 存储限制参数(Storage 域服务本模块不装配,参数面先行披露)。 + /// + public OnlinePlayerStorageOptions PlayerStorage { get; set; } = new OnlinePlayerStorageOptions(); + + /// + public void Validate(CapabilityOptionsValidation validation) + { + validation.RequirePositive("PlayerStorage.MaxPayloadBytes", PlayerStorage.MaxPayloadBytes); + validation.RequirePositive("PlayerStorage.MaxKeysPerCollection", PlayerStorage.MaxKeysPerCollection); + validation.RequirePositive("PlayerStorage.MaxPageSize", PlayerStorage.MaxPageSize); + } } diff --git a/Capability/GameFrameX.Capability.Online.Foundation/Presence/OnlinePartyPresenceProbe.cs b/Capability/GameFrameX.Capability.Online.Foundation/Presence/OnlinePartyPresenceProbe.cs new file mode 100644 index 000000000..1a4579fec --- /dev/null +++ b/Capability/GameFrameX.Capability.Online.Foundation/Presence/OnlinePartyPresenceProbe.cs @@ -0,0 +1,72 @@ +// ========================================================================================== +// GameFrameX 组织及其衍生项目的版权、商标、专利及其他相关权利 +// GameFrameX organization and its derivative projects' copyrights, trademarks, patents and related rights +// 均受中华人民共和国及相关国际法律法规保护。 +// are protected by the laws of the PRC 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 at the root 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 legal rights and interests of others as prohibited by laws and regulations! +// 因基于本项目二次开发所产生的一切法律纠纷与责任, +// Any legal disputes or liabilities arising from secondary development, +// 本组织与贡献者概不承担任何责任。 +// 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://github.com/GameFrameX +// CNB 仓库:https://www.cnb.cool/GameFrameX +// CNB Repository: https://github.com/GameFrameX +// 官方文档:https://gameframex.doc.alianblank.com/ +// Official Documentation: https://gameframex.doc.alianblank.com/ +// ========================================================================================== + +namespace GameFrameX.Capability.Online.Foundation.Presence; + +using System.Threading; +using System.Threading.Tasks; +using GameFrameX.Capability.Online.Abstractions.Contracts; +using GameFrameX.Capability.Online.Abstractions.Scope; + +/// +/// 队伍在线事实探针( 的地基域实现:把成员在线判定委托给 +/// 事实源,不镜像、不缓存)。 +/// +/// 维护约束(红线):Presence 是唯一在线事实源,本探针只读;在线判定口径 = 记录存在且状态非 +/// / +///(对齐 的统计口径);查询失败按离线处理, +/// 队伍侧不得据此直接清理成员(离线判定见契约降级约定)。 +/// +/// +public sealed class OnlinePartyPresenceProbe : IOnlinePartyPresenceProbe +{ + /// + /// 在线状态事实源。 + /// + private readonly OnlinePresenceService _presence; + + /// + /// 初始化 。 + /// + /// 在线状态事实源。 + public OnlinePartyPresenceProbe(OnlinePresenceService presence) + { + _presence = presence ?? throw new ArgumentNullException(nameof(presence)); + } + + /// + public async Task IsOnlineAsync(long tenantId, long appId, long playerId, CancellationToken cancellationToken = default) + { + var scope = new OnlineScope(tenantId, appId, 0, playerId); + var result = await _presence.GetAsync(scope, cancellationToken).ConfigureAwait(false); + return result.IsSuccess + && result.Data != OnlinePresenceState.Offline + && result.Data != OnlinePresenceState.Blocked; + } +} diff --git a/Capability/GameFrameX.Capability.Online.Foundation/Presence/OnlinePresenceEvents.cs b/Capability/GameFrameX.Capability.Online.Foundation/Presence/OnlinePresenceEvents.cs index 0b4e0a527..311324573 100644 --- a/Capability/GameFrameX.Capability.Online.Foundation/Presence/OnlinePresenceEvents.cs +++ b/Capability/GameFrameX.Capability.Online.Foundation/Presence/OnlinePresenceEvents.cs @@ -42,7 +42,7 @@ namespace GameFrameX.Capability.Online.Foundation.Presence; public static class OnlinePresenceEvents { /// 在线状态变更。 - public const string PresenceChanged = "Online.Presence.Changed"; + public const string PresenceChanged = "online.presence.changed"; /// 事件来源标识。 public const string Source = "online-presence"; diff --git a/Capability/GameFrameX.Capability.Online.Foundation/Presence/OnlineSocialPresenceProbe.cs b/Capability/GameFrameX.Capability.Online.Foundation/Presence/OnlineSocialPresenceProbe.cs new file mode 100644 index 000000000..ac66eff13 --- /dev/null +++ b/Capability/GameFrameX.Capability.Online.Foundation/Presence/OnlineSocialPresenceProbe.cs @@ -0,0 +1,71 @@ +// ========================================================================================== +// GameFrameX 组织及其衍生项目的版权、商标、专利及其他相关权利 +// GameFrameX organization and its derivative projects' copyrights, trademarks, patents and related rights +// 均受中华人民共和国及相关国际法律法规保护。 +// are protected by the laws of the PRC 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 at the root 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 legal rights and interests of others as prohibited by laws and regulations! +// 因基于本项目二次开发所产生的一切法律纠纷与责任, +// Any legal disputes or liabilities arising from secondary development, +// 本组织与贡献者概不承担任何责任。 +// 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://github.com/GameFrameX +// CNB 仓库:https://www.cnb.cool/GameFrameX +// CNB Repository: https://github.com/GameFrameX +// 官方文档:https://gameframex.doc.alianblank.com/ +// Official Documentation: https://gameframex.doc.alianblank.com/ +// ========================================================================================== + +namespace GameFrameX.Capability.Online.Foundation.Presence; + +using System.Threading; +using System.Threading.Tasks; +using GameFrameX.Capability.Online.Abstractions.Contracts; +using GameFrameX.Capability.Online.Abstractions.Scope; + +/// +/// 社交域在线事实探针( 的地基域实现:把在线判定委托给 +/// 事实源,不镜像、不缓存)。 +/// +/// 维护约束:在线判定口径 = Presence 记录存在且状态非 / +/// (对齐 +/// 的统计口径);查询失败按离线处理,消费方不得据此触发清理或投递失败判定。 +/// +/// +public sealed class OnlineSocialPresenceProbe : IOnlineSocialPresenceProbe +{ + /// + /// 在线状态事实源。 + /// + private readonly OnlinePresenceService _presence; + + /// + /// 初始化 。 + /// + /// 在线状态事实源。 + public OnlineSocialPresenceProbe(OnlinePresenceService presence) + { + _presence = presence ?? throw new ArgumentNullException(nameof(presence)); + } + + /// + public async Task IsOnlineAsync(long tenantId, long appId, long playerId, CancellationToken cancellationToken = default) + { + var scope = new OnlineScope(tenantId, appId, 0, playerId); + var result = await _presence.GetAsync(scope, cancellationToken).ConfigureAwait(false); + return result.IsSuccess + && result.Data != OnlinePresenceState.Offline + && result.Data != OnlinePresenceState.Blocked; + } +} diff --git a/Capability/GameFrameX.Capability.Online.Foundation/Session/OnlineSessionEvents.cs b/Capability/GameFrameX.Capability.Online.Foundation/Session/OnlineSessionEvents.cs index cd955c4d0..13ca92cb3 100644 --- a/Capability/GameFrameX.Capability.Online.Foundation/Session/OnlineSessionEvents.cs +++ b/Capability/GameFrameX.Capability.Online.Foundation/Session/OnlineSessionEvents.cs @@ -36,7 +36,7 @@ namespace GameFrameX.Capability.Online.Foundation.Session; /// /// 会话域事件工厂(Session 生命周期事件经统一事件信封供 Party/Friend/Admin 消费)。 /// -/// 维护约束:EventType 命名 Online.Session.{动作},新增事件类型必须同步 Admin 镜像侧登记; +/// 维护约束:EventType 命名 online.session.{动作}(全串小写,值 == 机制总线 topic),新增事件类型必须同步 Admin 镜像侧登记; /// Payload 为 UTF-8 JSON(SchemaVersion=1),审计字段(SessionId/PlayerId/Reason)经 /// 透出——踢下线审计必须含 SessionId 与原因, /// 操作者(Operator)由装配层写入 Reason 承载;事件是事实不是状态,消费端不得回写会话。 @@ -45,31 +45,31 @@ namespace GameFrameX.Capability.Online.Foundation.Session; public static class OnlineSessionEvents { /// 会话创建(Created)。 - public const string SessionCreated = "Online.Session.Created"; + public const string SessionCreated = "online.session.created"; /// 会话通过鉴权(Authenticated,Token 已签发)。 - public const string SessionAuthenticated = "Online.Session.Authenticated"; + public const string SessionAuthenticated = "online.session.authenticated"; /// 会话建立连接(Connected)。 - public const string SessionConnected = "Online.Session.Connected"; + public const string SessionConnected = "online.session.connected"; /// 会话进入活跃(Active)。 - public const string SessionActive = "Online.Session.Active"; + public const string SessionActive = "online.session.active"; /// 会话断线进入重连窗口(Reconnecting)。 - public const string SessionReconnecting = "Online.Session.Reconnecting"; + public const string SessionReconnecting = "online.session.reconnecting"; /// 会话正常关闭(Closed)。 - public const string SessionClosed = "Online.Session.Closed"; + public const string SessionClosed = "online.session.closed"; /// 会话被踢下线(Kicked;审计含原因与操作者上下文)。 - public const string SessionKicked = "Online.Session.Kicked"; + public const string SessionKicked = "online.session.kicked"; /// 会话过期(Expired)。 - public const string SessionExpired = "Online.Session.Expired"; + public const string SessionExpired = "online.session.expired"; /// Token 刷新轮换(TokenRefreshed;旧 Token 即刻失效)。 - public const string SessionTokenRefreshed = "Online.Session.TokenRefreshed"; + public const string SessionTokenRefreshed = "online.session.tokenrefreshed"; /// 事件来源标识。 public const string Source = "online-session"; diff --git a/Capability/GameFrameX.Capability.Online.Foundation/Storage/OnlinePlayerStorageService.cs b/Capability/GameFrameX.Capability.Online.Foundation/Storage/OnlinePlayerStorageService.cs index 7c6a7ee47..8680a8df3 100644 --- a/Capability/GameFrameX.Capability.Online.Foundation/Storage/OnlinePlayerStorageService.cs +++ b/Capability/GameFrameX.Capability.Online.Foundation/Storage/OnlinePlayerStorageService.cs @@ -57,11 +57,11 @@ public sealed class OnlinePlayerStorageService /// 初始化 。 /// /// 玩家云存储。 - /// 限制选项(可空 = 默认限制)。 - public OnlinePlayerStorageService(IOnlinePlayerStorageStore store, OnlinePlayerStorageOptions options = null) + /// 限制选项(必填;默认值唯一来源 = 字段初始值)。 + public OnlinePlayerStorageService(IOnlinePlayerStorageStore store, OnlinePlayerStorageOptions options) { _store = store ?? throw new ArgumentNullException(nameof(store)); - _options = options ?? new OnlinePlayerStorageOptions(); + _options = options ?? throw new ArgumentNullException(nameof(options)); } /// diff --git a/Capability/GameFrameX.Capability.Online.Match/GameFrameX.Capability.Online.Match.csproj b/Capability/GameFrameX.Capability.Online.Match/GameFrameX.Capability.Online.Match.csproj index 9b65b059c..0b1b72c3e 100644 --- a/Capability/GameFrameX.Capability.Online.Match/GameFrameX.Capability.Online.Match.csproj +++ b/Capability/GameFrameX.Capability.Online.Match/GameFrameX.Capability.Online.Match.csproj @@ -2,14 +2,18 @@ - GameFrameX.Capability.Online.Match,GFX Online 对局能力包:权威对局 Actor/同步协议/重连/幂等结算/样例玩法(InMemory 默认存储,UseOnlineMatchStore 替换缝)+ hosting 扩展(AddOnlineMatchRuntime)+ admin handlers(end_abnormal_match). Match capability package for the GFX Online family: authoritative match actor, sync protocol, reconnect, idempotent settlement and the sample game with in-memory default stores, the hosting extension and admin handlers. Documentation: https://gameframex.doc.alianblank.com + GameFrameX.Capability.Online.Match,GFX Online 对局能力包:权威对局 Actor/同步协议/重连/幂等结算/样例玩法与撮合成功通知推送(InMemory 默认存储,描述符服务面 Override 替换)+ admin handlers(end_abnormal_match). Match capability package for the GFX Online family: authoritative match actor, sync protocol, reconnect, idempotent settlement, the sample game and the match-made notification push with in-memory default stores and admin handlers. Documentation: https://gameframex.doc.alianblank.com GameFrameX,Capability,Online,Match,Authoritative,Settlement,Reconnect,GameServer - 10 + + + diff --git a/Capability/GameFrameX.Capability.Online.Match/OnlineMatchActor.cs b/Capability/GameFrameX.Capability.Online.Match/OnlineMatchActor.cs index 999cec77a..127eea0b1 100644 --- a/Capability/GameFrameX.Capability.Online.Match/OnlineMatchActor.cs +++ b/Capability/GameFrameX.Capability.Online.Match/OnlineMatchActor.cs @@ -79,8 +79,8 @@ public sealed class OnlineMatchActor /// 玩法实现。 /// 对局存储。 /// 事件发布器(可空,测试可传空实现)。 - /// 运行参数(可空,取默认值)。 - public OnlineMatchActor(OnlineMatch match, IOnlineMatchGame game, IOnlineMatchActorStore store, IOnlineEventPublisher eventPublisher, OnlineMatchRuntimeOptions options = null) + /// 运行参数(必填;由运行时统一传入机制合并后的 Options 终值)。 + public OnlineMatchActor(OnlineMatch match, IOnlineMatchGame game, IOnlineMatchActorStore store, IOnlineEventPublisher eventPublisher, OnlineMatchRuntimeOptions options) { if (match == null) { @@ -91,7 +91,7 @@ public OnlineMatchActor(OnlineMatch match, IOnlineMatchGame game, IOnlineMatchAc _game = game ?? throw new ArgumentNullException(nameof(game)); _store = store ?? throw new ArgumentNullException(nameof(store)); _eventPublisher = eventPublisher; - _options = options == null ? new OnlineMatchRuntimeOptions() : options; + _options = options ?? throw new ArgumentNullException(nameof(options)); } /// diff --git a/Capability/GameFrameX.Capability.Online.Match/OnlineMatchCapabilityModule.cs b/Capability/GameFrameX.Capability.Online.Match/OnlineMatchCapabilityModule.cs index bc21d92fa..ff20562e7 100644 --- a/Capability/GameFrameX.Capability.Online.Match/OnlineMatchCapabilityModule.cs +++ b/Capability/GameFrameX.Capability.Online.Match/OnlineMatchCapabilityModule.cs @@ -34,36 +34,51 @@ using GameFrameX.Capability.Online.Abstractions.Events; using GameFrameX.Capability.Online.Assets; using GameFrameX.Capability.Online.Match.AdminApi; +using GameFrameX.Core.Session; +using GameFrameX.Localization; +using GameFrameX.Proto.Proto; namespace GameFrameX.Capability.Online.Match; /// -/// 对局能力(权威对局 Actor / 重连 / 幂等结算 / 样例玩法)的自装配模块(C232 P4 迁入)。 +/// 对局能力(权威对局 Actor / 重连 / 幂等结算 / 样例玩法)的自装配模块(C232 P4 迁入,C294 改写三段生命周期)。 /// /// 维护约束(服务共享协议):模块无参构造(扫描可发现);Register 自装配本域——对局 Actor / 结果 -/// 两个 store 经 探测替换缝,未命中构造 -/// InMemory 默认;跨域硬依赖(事件出口)经上下文解析;对外产物(store 接口 + 对局运行时)注册回共享面 -/// (Timeline / Overview 消费 读取面)。 +/// 两个 store 走「懒默认注册 + 解析」替换缝(族 / 宿主 Override 恒胜出);Options 经 +/// 取机制合并终值(必填传入运行时构造); +/// 可选依赖(online-assets 的发奖、online-matchmaking 的撮合三件套)经 GetOptionalService 消费—— +/// 缺席 = 不装配对应链路且不报错(manifest 已声明可选缺席为合法态);对局桥 / 匹配域桥迁 Activate 期发布; +/// 撮合成功通知订阅迁 Register 期机制事件总线(域服务在场才订阅;订阅体异常由机制记日志继续,不再自吞)。 /// /// /// /// The self-assembling module of the match capability (authoritative match actor, reconnect, idempotent -/// settlement and the sample game; migrated by C232 P4). The module has a parameterless constructor -/// (discoverable by scanning); Register self-assembles the domain — the match actor / result stores -/// probe the replacement seams with in-memory fallbacks; the cross-domain hard dependency (the event outlet) -/// resolves through the context; outward products (store interfaces and the match runtime) are registered -/// back into the shared face (Timeline / Overview consume the read face). +/// settlement and the sample game; migrated by C232 P4, rewritten for the three-phase lifecycle in C294). +/// The module has a parameterless constructor (discoverable by scanning); Register self-assembles the +/// domain — the match actor / result stores ride the lazy-default-then-resolve seam; options come from the +/// mechanism merge; optional dependencies (the assets grant and the matchmaking trio) are consumed via +/// GetOptionalService — absence means the corresponding chain is simply not assembled; the game and +/// match-domain bridges publish during activation; the match-made notification subscription moved onto the +/// mechanism event bus during Register (only when the domain service is assembled). /// public sealed class OnlineMatchCapabilityModule : ICapabilityModule, IAdminActionContributor { /// - /// 对局能力清单(Id 冻结 = 终态包名;action 集合与幂等包裹位逐字迁移自壳过渡模块)。 + /// 对局能力清单(Id 冻结 = 终态包名;action 集合与幂等包裹位逐字迁移自壳过渡模块; + /// 无必选依赖;可选 online-assets / online-matchmaking,缺席按本地化键披露缺失功能; + /// Options 根类型 = )。 /// public static readonly CapabilityManifest ManifestInstance = new( "online-match", "1.0.0", - new[] { "end_abnormal_match" }, - new[] { "online-assets", "online-matchmaking" }); + new[] { "end_abnormal_match", "query_match_settlement" }, + null, + new[] + { + new CapabilityOptionalDependency("online-assets", Keys.Capability.Online.Match.OptionalAssetsAbsent), + new CapabilityOptionalDependency("online-matchmaking", Keys.Capability.Online.Match.OptionalMatchmakingAbsent), + }, + typeof(OnlineMatchOptions)); /// /// 对局运行时(Register 期构造;样例玩法随迁移注册,与壳时代装配语句逐语句等价)。 @@ -76,10 +91,16 @@ public sealed class OnlineMatchCapabilityModule : ICapabilityModule, IAdminActio private OnlineMatchSettlementPipeline _settlementPipeline; /// - /// 对局桥服务(Register 期构造;对 IOnlineMatchGameBridge 契约面的本域实现,C245)。 + /// 对局桥服务(Register 期构造;对 IOnlineMatchGameBridge 契约面的本域实现,Activate 期发布桥,C245)。 /// private OnlineMatchGameBridgeService _bridgeService; + /// + /// 匹配域桥服务(Register 期构造;对 IOnlineMatchDomain 契约面的本域实现,Activate 期发布桥,C246; + /// online-matchmaking 缺席时为 null——不装配不报错,420 协议面经桥得显式失败)。 + /// + private IOnlineMatchDomain _matchDomain; + /// public CapabilityManifest Manifest { @@ -89,18 +110,21 @@ public CapabilityManifest Manifest /// public void Register(CapabilityRegistrationContext context) { + var options = context.GetOptions(); var eventPublisher = context.GetService(); - var matchActorStore = ResolveStore(context, static () => new InMemoryOnlineMatchActorStore()); - var matchResultStore = ResolveStore(context, static () => new InMemoryOnlineMatchResultStore()); + context.AddService(static _ => new InMemoryOnlineMatchActorStore()); + var matchActorStore = context.GetService(); + context.AddService(static _ => new InMemoryOnlineMatchResultStore()); + var matchResultStore = context.GetService(); var rpsGame = new OnlineRockPaperScissorsGame(); - _matchRuntime = new OnlineMatchRuntime(matchActorStore, eventPublisher); + _matchRuntime = new OnlineMatchRuntime(matchActorStore, eventPublisher, options.Runtime); _matchRuntime.RegisterGame(rpsGame); context.AddService(_matchRuntime); context.AddTickJob("match-tick", ct => _matchRuntime.TickAllAsync(DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), ct)); // 结算编排(C245):settlement + 可空 dispatcher(assets 缺席仅结算)+ tick 重投;桥服务注册回共享面。 - var grantService = context.TryGetService(out OnlineGrantService grant) ? grant : null; + var grantService = context.GetOptionalService(); var settlementService = new OnlineMatchSettlementService(_matchRuntime, matchResultStore, eventPublisher); var dispatcher = grantService == null ? null : new OnlineMatchResultDispatcher(grantService); _settlementPipeline = new OnlineMatchSettlementPipeline(_matchRuntime, settlementService, dispatcher); @@ -110,14 +134,36 @@ public void Register(CapabilityRegistrationContext context) context.AddService(_bridgeService); // 匹配域桥服务(C246):桥接票据撮合域(入队 / 取消 / 状态 / 即时窗口)与对局运行时(入场 create-or-reuse)。 - // 票据服务 / 撮合协调器经上下文解析(manifest 依赖 online-matchmaking 保证拓扑先序在场); - // online-matchmaking 被过滤时不装配不报错(grant 缺席装配先例)——420 协议面经 holder 得显式失败。 - if (context.TryGetService(out OnlineMatchTicketService domainTickets) - && context.TryGetService(out OnlineMatchmakerCoordinator domainCoordinator) - && context.TryGetService(out IOnlineMatchTicketStore domainTicketStore)) + // 票据服务 / 撮合协调器 / 票据存储按可选依赖语义解析(manifest 已声明 online-matchmaking 可选); + // 在场才装配域服务并订阅撮合分配通知;缺席 = 不装配不报错——420 协议面经桥得显式失败。 + var domainTickets = context.GetOptionalService(); + var domainCoordinator = context.GetOptionalService(); + var domainTicketStore = context.GetOptionalService(); + if (domainTickets != null && domainCoordinator != null && domainTicketStore != null) + { + _matchDomain = new OnlineMatchDomainService(domainTicketStore, domainTickets, domainCoordinator, _matchRuntime, rpsGame); + context.AddService(_matchDomain); + context.Subscribe(OnlineMatchEvents.AssignmentCreated, onlineEvent => HandleMatchAssignmentEvent(onlineEvent, _matchDomain)); + } + } + + /// + public Task ActivateAsync(CapabilityActivationContext context, CancellationToken cancellationToken) + { + // 桥发布迁 Activate 期(C294):对局桥恒发布;匹配域桥仅在场发布(缺席态由桥显式失败承载)。 + context.Bridges.Publish(_bridgeService); + if (_matchDomain != null) { - context.AddService(new OnlineMatchDomainService(domainTicketStore, domainTickets, domainCoordinator, _matchRuntime, rpsGame)); + context.Bridges.Publish(_matchDomain); } + + return Task.CompletedTask; + } + + /// + public Task DeactivateAsync(CancellationToken cancellationToken) + { + return Task.CompletedTask; } /// @@ -128,22 +174,45 @@ public void RegisterAdminActions(AdminActionRegistry registry) } /// - /// 解析存储(替换缝探测——命中用组合根注入实例,未命中构造 InMemory 默认并注册回共享面)。 + /// 撮合分配事件回调体(原 Launcher OnlineMatchDomainBridgeWiring.HandleMatchAssignmentEvent 迁入,C294): + /// online.match.assignmentcreated → 域服务解析数值对局标识 → 向双方在线会话 best-effort 推送 + /// ;过滤 / 校验失败即静默返回;离线方无会话跳过(等待方经状态轮询兜底)。 + /// + /// 维护约束:同步订阅体(机制事件总线约定);订阅体异常由机制记日志后继续分发,此处不得再自吞 try/catch。 + /// /// - /// 存储接口。 - /// 注册上下文。 - /// 默认构造。 - /// 存储实例。 - private static TStore ResolveStore(CapabilityRegistrationContext context, Func defaultFactory) - where TStore : class + /// 机制事件载荷。 + /// 匹配域契约面。 + private static void HandleMatchAssignmentEvent(OnlineEvent onlineEvent, IOnlineMatchDomain domain) { - if (context.TryGetService(out TStore store)) + var payload = OnlineMatchEvents.ReadAssignmentCreated(onlineEvent); + if (payload == null || string.IsNullOrEmpty(payload.AssignmentId)) + { + return; + } + + var resolved = domain.ResolveAssignmentAsync(onlineEvent.TenantId, onlineEvent.AppId, payload.AssignmentId).GetAwaiter().GetResult(); + if (!resolved.IsSuccess || resolved.Data == null || resolved.Data.MatchId == 0) { - return store; + return; } - store = defaultFactory(); - context.AddService(store); - return store; + if (resolved.Data.PlayerIds == null) + { + return; + } + + var notify = new NotifyMatchMade { MatchId = resolved.Data.MatchId, Mode = resolved.Data.Mode }; + foreach (var playerId in resolved.Data.PlayerIds) + { + var session = PlayerSessionManager.Instance.GetByPlayerId(playerId); + if (session == null) + { + // 离线方静默跳过:best-effort 推送,等待方经状态轮询兜底。 + continue; + } + + session.WriteAsync(notify).GetAwaiter().GetResult(); + } } } diff --git a/Capability/GameFrameX.Capability.Online.Match/OnlineMatchHostingExtensions.cs b/Capability/GameFrameX.Capability.Online.Match/OnlineMatchHostingExtensions.cs deleted file mode 100644 index 984921207..000000000 --- a/Capability/GameFrameX.Capability.Online.Match/OnlineMatchHostingExtensions.cs +++ /dev/null @@ -1,93 +0,0 @@ -// ========================================================================================== -// GameFrameX 组织及其衍生项目的版权、商标、专利及其他相关权利 -// GameFrameX organization and its derivative projects' copyrights, trademarks, patents and related rights -// 均受中华人民共和国及相关国际法律法规保护。 -// are protected by the laws of the PRC 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 refer to the LICENSE file at the root 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, -// 本组织与贡献者概不承担任何责任。 -// 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://www.cnb.cool/GameFrameX -// CNB Repository: https://www.cnb.cool/GameFrameX -// 官方文档:https://gameframex.doc.alianblank.com/ -// Official Documentation: https://gameframex.doc.alianblank.com/ -// ========================================================================================== - -using GameFrameX.Capability.Hosting.Core; - -namespace GameFrameX.Capability.Online.Match; - -/// -/// 对局能力包的 hosting 扩展(C232 P4): 装配入口(回调式,注册自装配模块) -/// 与两个存储替换缝( / )。 -/// -/// 维护约束:options 经共享服务面预置(模块无参构造,显式链式与 P4 真扫描共用同一模块类); -/// 替换缝在模块 Register 期探测命中,未命中构造 InMemory 默认。 -/// -/// -/// -/// Hosting extensions of the match capability package (C232 P4): the -/// assembly entry (callback style, registering the self-assembling module) plus two store replacement seams. -/// Options are pre-seeded through the shared service face; the replacement seams are probed at module -/// Register time with in-memory fallbacks. -/// -public static class OnlineMatchHostingExtensions -{ - /// - /// 装配对局能力运行时(注册自装配模块;options => ... 回调承载包级编排参数)。 - /// - /// 能力装配器。 - /// 包级选项回调(可空 = 全默认)。 - /// 本装配器(链式)。 - public static CapabilityHostBuilder AddOnlineMatchRuntime(this CapabilityHostBuilder builder, Action configure = null) - { - ArgumentNullException.ThrowIfNull(builder); - - var options = new OnlineMatchOptions(); - configure?.Invoke(options); - builder.UseService(options); - return builder.AddModule(new OnlineMatchCapabilityModule()); - } - - /// - /// 替换对局 Actor 存储(默认 InMemory,生产持久化实现经本缝注入)。 - /// - /// 能力装配器。 - /// 存储实现。 - /// 本装配器(链式)。 - public static CapabilityHostBuilder UseOnlineMatchActorStore(this CapabilityHostBuilder builder, IOnlineMatchActorStore store) - { - ArgumentNullException.ThrowIfNull(builder); - - builder.UseService(store); - return builder; - } - - /// - /// 替换对局结果存储(默认 InMemory,生产持久化实现经本缝注入)。 - /// - /// 能力装配器。 - /// 存储实现。 - /// 本装配器(链式)。 - public static CapabilityHostBuilder UseOnlineMatchResultStore(this CapabilityHostBuilder builder, IOnlineMatchResultStore store) - { - ArgumentNullException.ThrowIfNull(builder); - - builder.UseService(store); - return builder; - } -} diff --git a/Capability/GameFrameX.Capability.Online.Match/OnlineMatchOptions.cs b/Capability/GameFrameX.Capability.Online.Match/OnlineMatchOptions.cs index 825215717..b52d403a5 100644 --- a/Capability/GameFrameX.Capability.Online.Match/OnlineMatchOptions.cs +++ b/Capability/GameFrameX.Capability.Online.Match/OnlineMatchOptions.cs @@ -27,25 +27,38 @@ // Official Documentation: https://gameframex.doc.alianblank.com/ // ========================================================================================== +using GameFrameX.Capability.Hosting.Abstractions; + namespace GameFrameX.Capability.Online.Match; /// -/// 对局能力包级装配选项( 回调承载面,C232 P4)。 +/// 对局能力 Options 根类型(机制三层合并的承载面:包内默认 → 配置段 Online.Settings.online-match → 代码 Configure)。 /// -/// 维护约束(能力族全局约定,C227/C229 裁定):编排参数一律经 Action<TOptions> 回调承载(.NET -/// AddXxx(options => ...) 惯例);P4 期无包级编排参数(对局运行时阈值走域内 -/// ,存储替换走 UseOnlineMatch*Store 缝族,配置段绑定归组合根), -/// 成员随真实需求落位;禁止回退平铺配置键。 +/// 维护约束:成员默认值唯一来源 = 字段初始值(子选项聚合为属性,子项默认收敛在子 Options 自身); +/// 只做合并终值的结构校验(正数阈值),不做业务裁决; +/// 新增编排参数一律落子 Options 属性,禁止回退平铺配置键。 /// /// /// -/// Package-level assembly options for the match capability (the callback face of -/// , C232 P4). Following the -/// capability-family global convention, orchestration parameters ride an Action<TOptions> callback; -/// P4 carries no package-level knobs (match runtime thresholds live on the domain -/// , store replacement goes through the seam family); members land -/// as real needs appear; flat configuration keys stay forbidden. +/// The options root of the match capability, consumed by the three-layer mechanism merge +/// (package defaults, the configuration section, then code Configure). Default values live only on +/// field initializers; Validate only checks the structural positivity of the merged thresholds. /// -public sealed class OnlineMatchOptions +public sealed class OnlineMatchOptions : ICapabilityOptions { + /// + /// 对局运行时阈值(等待 / 进行中超时、重连窗口、结算留存、服务端事件容量;默认值唯一来源 = + /// 字段初始值)。 + /// + public OnlineMatchRuntimeOptions Runtime { get; set; } = new OnlineMatchRuntimeOptions(); + + /// + public void Validate(CapabilityOptionsValidation validation) + { + validation.RequirePositive("Runtime.WaitingTimeoutSeconds", Runtime.WaitingTimeoutSeconds); + validation.RequirePositive("Runtime.RunningTimeoutSeconds", Runtime.RunningTimeoutSeconds); + validation.RequirePositive("Runtime.ReconnectWindowSeconds", Runtime.ReconnectWindowSeconds); + validation.RequirePositive("Runtime.EndedRetentionSeconds", Runtime.EndedRetentionSeconds); + validation.RequirePositive("Runtime.MaxServerEventsPerMatch", Runtime.MaxServerEventsPerMatch); + } } diff --git a/Capability/GameFrameX.Capability.Online.Match/OnlineMatchRuntime.cs b/Capability/GameFrameX.Capability.Online.Match/OnlineMatchRuntime.cs index 6faea7da5..24ade9c2c 100644 --- a/Capability/GameFrameX.Capability.Online.Match/OnlineMatchRuntime.cs +++ b/Capability/GameFrameX.Capability.Online.Match/OnlineMatchRuntime.cs @@ -79,12 +79,12 @@ public sealed class OnlineMatchRuntime /// /// 对局存储。 /// 事件发布器(可空)。 - /// 运行参数(可空,取默认值)。 - public OnlineMatchRuntime(IOnlineMatchActorStore store, IOnlineEventPublisher eventPublisher, OnlineMatchRuntimeOptions options = null) + /// 运行参数(必填;机制合并后的 Options 终值,不再兜底默认)。 + public OnlineMatchRuntime(IOnlineMatchActorStore store, IOnlineEventPublisher eventPublisher, OnlineMatchRuntimeOptions options) { _store = store ?? throw new ArgumentNullException(nameof(store)); _eventPublisher = eventPublisher; - _options = options == null ? new OnlineMatchRuntimeOptions() : options; + _options = options ?? throw new ArgumentNullException(nameof(options)); } /// diff --git a/Capability/GameFrameX.Capability.Online.Match/OnlineMatchRuntimeEvents.cs b/Capability/GameFrameX.Capability.Online.Match/OnlineMatchRuntimeEvents.cs index c17c55d00..4797f92ce 100644 --- a/Capability/GameFrameX.Capability.Online.Match/OnlineMatchRuntimeEvents.cs +++ b/Capability/GameFrameX.Capability.Online.Match/OnlineMatchRuntimeEvents.cs @@ -47,10 +47,10 @@ namespace GameFrameX.Capability.Online.Match; public static class OnlineMatchRuntimeEvents { /// 对局生命周期状态变更。 - public const string MatchStateChanged = "Online.Match.StateChanged"; + public const string MatchStateChanged = "online.match.statechanged"; /// 对局结算完成(可信结果事件)。 - public const string MatchSettled = "Online.Match.Settled"; + public const string MatchSettled = "online.match.settled"; /// 事件来源标识(与匹配域的 online-matchmaker 区分)。 public const string Source = "online-match"; diff --git a/Capability/GameFrameX.Capability.Online.Matchmaking/GameFrameX.Capability.Online.Matchmaking.csproj b/Capability/GameFrameX.Capability.Online.Matchmaking/GameFrameX.Capability.Online.Matchmaking.csproj index 20fc51ef9..7c40f0a91 100644 --- a/Capability/GameFrameX.Capability.Online.Matchmaking/GameFrameX.Capability.Online.Matchmaking.csproj +++ b/Capability/GameFrameX.Capability.Online.Matchmaking/GameFrameX.Capability.Online.Matchmaking.csproj @@ -2,7 +2,7 @@ - GameFrameX.Capability.Online.Matchmaking,GFX Online 匹配能力包(含 Party 域):大厅/票据/撮合协调器/队列观测 + 队伍域(InMemory 默认存储,UseOnlineMatchmakingStore 族替换缝)+ hosting 扩展(AddOnlineMatchmakingRuntime)+ admin handlers(队列观测/票据处置族). Matchmaking capability package for the GFX Online family, including the party domain: listings, tickets, the matchmaker coordinator and queue observation with in-memory default stores, the hosting extension and admin handlers. Documentation: https://gameframex.doc.alianblank.com + GameFrameX.Capability.Online.Matchmaking,GFX Online 匹配能力包(含 Party 域):大厅/票据/撮合协调器/队列观测 + 队伍域(InMemory 默认存储,描述符服务面 Override 替换)+ admin handlers(队列观测/票据处置族). Matchmaking capability package for the GFX Online family, including the party domain: listings, tickets, the matchmaker coordinator and queue observation with in-memory default stores and admin handlers. Documentation: https://gameframex.doc.alianblank.com GameFrameX,Capability,Online,Matchmaking,Party,Ticket,GameServer 10 diff --git a/Capability/GameFrameX.Capability.Online.Matchmaking/OnlineMatchEvents.cs b/Capability/GameFrameX.Capability.Online.Matchmaking/OnlineMatchEvents.cs index b053b4d38..143d44d3b 100644 --- a/Capability/GameFrameX.Capability.Online.Matchmaking/OnlineMatchEvents.cs +++ b/Capability/GameFrameX.Capability.Online.Matchmaking/OnlineMatchEvents.cs @@ -44,10 +44,10 @@ namespace GameFrameX.Capability.Online.Matchmaking; public static class OnlineMatchEvents { /// 票据状态变更。 - public const string TicketChanged = "Online.Match.TicketChanged"; + public const string TicketChanged = "online.match.ticketchanged"; /// 对局分配产生。 - public const string AssignmentCreated = "Online.Match.AssignmentCreated"; + public const string AssignmentCreated = "online.match.assignmentcreated"; /// 事件来源标识。 public const string Source = "online-matchmaker"; diff --git a/Capability/GameFrameX.Capability.Online.Matchmaking/OnlineMatchListingService.cs b/Capability/GameFrameX.Capability.Online.Matchmaking/OnlineMatchListingService.cs index 5a0ac6578..5361306b2 100644 --- a/Capability/GameFrameX.Capability.Online.Matchmaking/OnlineMatchListingService.cs +++ b/Capability/GameFrameX.Capability.Online.Matchmaking/OnlineMatchListingService.cs @@ -135,7 +135,7 @@ public async Task>> QueryAsync(On return OnlineResult>.Fail(OnlineErrorCode.ParameterInvalid, "缺少作用域"); } - var filter = query ?? new OnlineMatchListingQuery(); + var filter = query == null ? new OnlineMatchListingQuery() : query; var limit = filter.Limit > 0 ? filter.Limit : DefaultQueryLimit; var all = await _store.ListAsync(scope.TenantId, scope.AppId, cancellationToken); diff --git a/Capability/GameFrameX.Capability.Online.Matchmaking/OnlineMatchTicketService.cs b/Capability/GameFrameX.Capability.Online.Matchmaking/OnlineMatchTicketService.cs index 01cc6da86..a9552439c 100644 --- a/Capability/GameFrameX.Capability.Online.Matchmaking/OnlineMatchTicketService.cs +++ b/Capability/GameFrameX.Capability.Online.Matchmaking/OnlineMatchTicketService.cs @@ -65,12 +65,12 @@ public sealed class OnlineMatchTicketService /// /// 票据存储。 /// 事件发布器。 - /// 匹配与限流可配置项(null 取默认值)。 - public OnlineMatchTicketService(IOnlineMatchTicketStore store, IOnlineEventPublisher eventPublisher, OnlineMatchmakerOptions options = null) + /// 匹配与限流可配置项(必填;机制合并后的 Options 终值,不再兜底默认)。 + public OnlineMatchTicketService(IOnlineMatchTicketStore store, IOnlineEventPublisher eventPublisher, OnlineMatchmakerOptions options) { _store = store ?? throw new ArgumentNullException(nameof(store)); _eventPublisher = eventPublisher ?? throw new ArgumentNullException(nameof(eventPublisher)); - _options = options == null ? new OnlineMatchmakerOptions() : options.Copy(); + _options = (options ?? throw new ArgumentNullException(nameof(options))).Copy(); _rateLimiter = new OnlineMatchRateLimiter(_options.RateLimitMaxOperations, _options.RateLimitWindowSeconds); } diff --git a/Capability/GameFrameX.Capability.Online.Matchmaking/OnlineMatchmakerCoordinator.cs b/Capability/GameFrameX.Capability.Online.Matchmaking/OnlineMatchmakerCoordinator.cs index afad94478..bf5c4e8b0 100644 --- a/Capability/GameFrameX.Capability.Online.Matchmaking/OnlineMatchmakerCoordinator.cs +++ b/Capability/GameFrameX.Capability.Online.Matchmaking/OnlineMatchmakerCoordinator.cs @@ -72,15 +72,15 @@ public sealed class OnlineMatchmakerCoordinator /// 票据存储。 /// 票据服务。 /// 事件发布器。 - /// 匹配与限流可配置项(null 取默认值)。 + /// 匹配与限流可配置项(必填;机制合并后的 Options 终值,不再兜底默认)。 /// 跨域社交裁决入口(可空;由 OnlineSocialDecisionService 提供)。 - public OnlineMatchmakerCoordinator(IOnlineMatchTicketStore store, OnlineMatchTicketService ticketService, IOnlineEventPublisher eventPublisher, OnlineMatchmakerOptions options = null, IOnlineSocialGate socialGate = null) + public OnlineMatchmakerCoordinator(IOnlineMatchTicketStore store, OnlineMatchTicketService ticketService, IOnlineEventPublisher eventPublisher, OnlineMatchmakerOptions options, IOnlineSocialGate socialGate = null) { _store = store ?? throw new ArgumentNullException(nameof(store)); _ticketService = ticketService ?? throw new ArgumentNullException(nameof(ticketService)); _eventPublisher = eventPublisher ?? throw new ArgumentNullException(nameof(eventPublisher)); _socialGate = socialGate; - _rule = new OnlineMatchRule(options == null ? new OnlineMatchmakerOptions() : options); + _rule = new OnlineMatchRule(options ?? throw new ArgumentNullException(nameof(options))); } /// diff --git a/Capability/GameFrameX.Capability.Online.Matchmaking/OnlineMatchmakingCapabilityModule.cs b/Capability/GameFrameX.Capability.Online.Matchmaking/OnlineMatchmakingCapabilityModule.cs index 850bf35d9..5c9f1d385 100644 --- a/Capability/GameFrameX.Capability.Online.Matchmaking/OnlineMatchmakingCapabilityModule.cs +++ b/Capability/GameFrameX.Capability.Online.Matchmaking/OnlineMatchmakingCapabilityModule.cs @@ -38,36 +38,43 @@ namespace GameFrameX.Capability.Online.Matchmaking; /// -/// 匹配能力(大厅 / 票据 / 撮合 / 队伍——Matchmaking+Party 同簇同包,C227 蓝图裁定)的自装配模块(C231 P3 迁入)。 +/// 匹配能力(大厅 / 票据 / 撮合 / 队伍——Matchmaking+Party 同簇同包,C227 蓝图裁定)的自装配模块(C231 P3 迁入,C294 改写三段生命周期)。 /// /// 维护约束(服务共享协议):模块无参构造(扫描可发现);Register 自装配本域——票据 / 大厅 / 队伍三个 store -/// 经 探测替换缝,未命中构造 InMemory 默认; +/// 走「懒默认注册 + 解析」替换缝(族 / 宿主 Override 恒胜出,模块不再写探测-回退分支); +/// Options 经 取机制合并终值(必填传入服务构造); /// 跨域硬依赖(事件出口 / 作用域 / 社交裁决 gate 面)经上下文解析(拓扑序保证先序在场); -/// 对外产物(store 接口 + 票据服务 + 撮合协调器 + 队列观察器)注册回共享面。 +/// 对外产物(store 接口 + 票据服务 + 撮合协调器 + 队列观察器 + 队伍服务与命名成员资格探针)注册回共享面; +/// 队伍在线探针是非直接依赖扩展点,Activate 期经 GetServices 枚举后注入。 /// /// /// /// The self-assembling module of the matchmaking capability (listings / tickets / the matchmaker coordinator / -/// parties — the Matchmaking+Party same-cluster merge ruled by the C227 blueprint). The module is parameterless -/// (discoverable by scanning); Register self-assembles the domain — the ticket / listing / party stores -/// probe the replacement seams with in-memory fallbacks; cross-domain hard dependencies (the event outlet, the -/// scope, the social adjudication gate face) resolve through the context (topological ordering guarantees the -/// producers ran first); outward products (store interfaces, the ticket service, the matchmaker coordinator and -/// the queue observer) are registered back into the shared face. +/// parties — the Matchmaking+Party same-cluster merge ruled by the C227 blueprint), rewritten for the +/// three-phase lifecycle in C294. The module is parameterless (discoverable by scanning); +/// Register self-assembles the domain — the ticket / listing / party stores ride the +/// lazy-default-then-resolve seam (family / host overrides always win); options come from the mechanism merge; +/// cross-domain hard dependencies (the event outlet, the scope, the social adjudication gate face) resolve +/// through the context; outward products (store interfaces, the ticket service, the matchmaker coordinator, +/// the queue observer, the party service and its named membership probe) are registered back into the shared +/// face; the party presence probe is a non-direct extension point enumerated and injected during activation. /// public sealed class OnlineMatchmakingCapabilityModule : ICapabilityModule, IAdminActionContributor { /// - /// 匹配能力清单(Id 冻结 = 终态包名;action 集合与幂等包裹位逐字迁移自壳过渡模块)。 + /// 匹配能力清单(Id 冻结 = 终态包名;action 集合与幂等包裹位逐字迁移自壳过渡模块; + /// 必选依赖 online-social;Options 根类型 = )。 /// public static readonly CapabilityManifest ManifestInstance = new( "online-matchmaking", "1.0.0", new[] { "query_match_queue_overview", "query_match_ticket_failures", "query_match_abnormal_tickets", "cancel_match_ticket" }, - new[] { "online-social" }); + new[] { "online-social" }, + null, + typeof(OnlineMatchmakingOptions)); /// - /// 匹配票据存储(Register 期解析或构造)。 + /// 匹配票据存储(Register 期解析)。 /// private IOnlineMatchTicketStore _matchTicketStore; @@ -86,6 +93,11 @@ public sealed class OnlineMatchmakingCapabilityModule : ICapabilityModule, IAdmi /// private OnlineMatchQueueObserver _matchQueueObserver; + /// + /// 队伍服务(Register 期构造;Activate 期回填在线探针)。 + /// + private OnlinePartyService _partyService; + /// public CapabilityManifest Manifest { @@ -95,19 +107,29 @@ public CapabilityManifest Manifest /// public void Register(CapabilityRegistrationContext context) { + var options = context.GetOptions(); var eventPublisher = context.GetService(); var socialGate = context.GetService(); var scope = context.GetService(); - _matchTicketStore = ResolveStore(context, static () => new InMemoryOnlineMatchTicketStore()); - var matchListingStore = ResolveStore(context, static () => new InMemoryOnlineMatchListingStore()); - var partyStore = ResolveStore(context, static () => new InMemoryOnlinePartyStore()); + context.AddService(static _ => new InMemoryOnlineMatchTicketStore()); + _matchTicketStore = context.GetService(); + context.AddService(static _ => new InMemoryOnlineMatchListingStore()); + var matchListingStore = context.GetService(); + context.AddService(static _ => new InMemoryOnlinePartyStore()); + var partyStore = context.GetService(); - _matchTickets = new OnlineMatchTicketService(_matchTicketStore, eventPublisher); - _matchmaker = new OnlineMatchmakerCoordinator(_matchTicketStore, _matchTickets, eventPublisher, null, socialGate); + _matchTickets = new OnlineMatchTicketService(_matchTicketStore, eventPublisher, options.Matchmaker); + _matchmaker = new OnlineMatchmakerCoordinator(_matchTicketStore, _matchTickets, eventPublisher, options.Matchmaker, socialGate); _matchQueueObserver = new OnlineMatchQueueObserver(_matchTicketStore); context.AddService(_matchTickets); context.AddService(_matchmaker); context.AddService(_matchQueueObserver); + + // 队伍域装配(此前壳组合根未装配):构造期在线探针留空(Foundation 非直接依赖,Register 期不可越界解析), + // Activate 期经 GetServices 枚举扩展点后 AttachPresenceProbe 回填;命名 "party" 成员资格探针供聊天域队伍频道判定。 + _partyService = new OnlinePartyService(partyStore, eventPublisher, presenceProbe: null, socialGate: socialGate); + context.AddService(_partyService); + context.AddService("party", (IOnlineChannelMembershipProbe)_partyService); _ = matchListingStore; context.AddTickJob("matchmaker-run", async ct => @@ -121,28 +143,27 @@ public void Register(CapabilityRegistrationContext context) } /// - public void RegisterAdminActions(AdminActionRegistry registry) + public Task ActivateAsync(CapabilityActivationContext context, CancellationToken cancellationToken) { - new OnlineAdminMatchmakingHandlers(_matchQueueObserver, _matchTicketStore, _matchTickets).Register(registry); + // 在线事实探针扩展点注入:实现常由非直接依赖能力(online-foundation)提供,只能 Activate 期枚举。 + var presenceProbe = context.Services.GetServices(); + if (presenceProbe.Count > 0) + { + _partyService.AttachPresenceProbe(presenceProbe[0]); + } + + return Task.CompletedTask; } - /// - /// 解析存储(替换缝探测——命中用组合根注入实例,未命中构造 InMemory 默认并注册回共享面)。 - /// - /// 存储接口。 - /// 注册上下文。 - /// 默认构造。 - /// 存储实例。 - private static TStore ResolveStore(CapabilityRegistrationContext context, Func defaultFactory) - where TStore : class + /// + public Task DeactivateAsync(CancellationToken cancellationToken) { - if (context.TryGetService(out TStore store)) - { - return store; - } + return Task.CompletedTask; + } - store = defaultFactory(); - context.AddService(store); - return store; + /// + public void RegisterAdminActions(AdminActionRegistry registry) + { + new OnlineAdminMatchmakingHandlers(_matchQueueObserver, _matchTicketStore, _matchTickets).Register(registry); } } diff --git a/Capability/GameFrameX.Capability.Online.Matchmaking/OnlineMatchmakingHostingExtensions.cs b/Capability/GameFrameX.Capability.Online.Matchmaking/OnlineMatchmakingHostingExtensions.cs deleted file mode 100644 index 6fa9d6934..000000000 --- a/Capability/GameFrameX.Capability.Online.Matchmaking/OnlineMatchmakingHostingExtensions.cs +++ /dev/null @@ -1,106 +0,0 @@ -// ========================================================================================== -// GameFrameX 组织及其衍生项目的版权、商标、专利及其他相关权利 -// GameFrameX organization and its derivative projects' copyrights, trademarks, patents and other rights -// 均受中华人民共和国及相关国际法律法规保护。 -// are protected by the laws of the People's Republic of China and other international regulations. -// 使用本项目须严格遵守相关法律法规与开源许可证之规定。 -// Usage of this project must strictly comply with relevant laws, regulations, and open-source licenses. -// 本项目采用 Apache License 2.0 单协议分发, -// This project is licensed solely under the Apache License 2.0, -// 完整许可证文本请参见源代码根目录的 LICENSE 文件。 -// please refer to the full license text in the LICENSE file located at the root of the source code. -// 禁止利用本项目实施任何危害国家安全、破坏社会秩序、 -// It is prohibited to use this project to engage in any activity that endangers national security, disrupts social order, -// 侵犯公民合法权益等法律法规所禁止的行为! -// or infringe upon the legal rights and interests of citizens as prohibited by laws and regulations! -// 因基于本项目二次开发所产生的一切法律纠纷与责任, -// Any legal disputes or liabilities arising from secondary development, -// 本组织不承担任何责任,开发者应自行承担。 -// 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.Capability.Hosting.Core; -using GameFrameX.Capability.Online.Matchmaking.Party; - -namespace GameFrameX.Capability.Online.Matchmaking; - -/// -/// 匹配能力包的 hosting 扩展(C231 P3): 装配入口(回调式,注册自装配模块) -/// 与三个存储替换缝( / / )。 -/// -/// 维护约束:options 经共享服务面预置(模块无参构造,显式链式与 P4 真扫描共用同一模块类); -/// 替换缝在模块 Register 期探测命中,未命中构造 InMemory 默认。 -/// -/// -/// -/// Hosting extensions of the matchmaking capability package (C231 P3): the -/// assembly entry plus three store replacement seams. -/// Options are pre-seeded through the shared service face; the seams are probed at module -/// Register time with in-memory fallbacks. -/// -public static class OnlineMatchmakingHostingExtensions -{ - /// - /// 装配匹配能力运行时(注册自装配模块;options => ... 回调承载包级编排参数)。 - /// - /// 能力装配器。 - /// 包级选项回调(可空 = 全默认)。 - /// 本装配器(链式)。 - public static CapabilityHostBuilder AddOnlineMatchmakingRuntime(this CapabilityHostBuilder builder, Action configure = null) - { - ArgumentNullException.ThrowIfNull(builder); - - var options = new OnlineMatchmakingOptions(); - configure?.Invoke(options); - builder.UseService(options); - return builder.AddModule(new OnlineMatchmakingCapabilityModule()); - } - - /// - /// 替换匹配大厅存储(默认 InMemory,生产持久化实现经本缝注入)。 - /// - /// 能力装配器。 - /// 存储实现。 - /// 本装配器(链式)。 - public static CapabilityHostBuilder UseOnlineMatchListingStore(this CapabilityHostBuilder builder, IOnlineMatchListingStore store) - { - ArgumentNullException.ThrowIfNull(builder); - - builder.UseService(store); - return builder; - } - /// - /// 替换匹配票据存储(默认 InMemory,生产持久化实现经本缝注入)。 - /// - /// 能力装配器。 - /// 存储实现。 - /// 本装配器(链式)。 - public static CapabilityHostBuilder UseOnlineMatchTicketStore(this CapabilityHostBuilder builder, IOnlineMatchTicketStore store) - { - ArgumentNullException.ThrowIfNull(builder); - - builder.UseService(store); - return builder; - } - /// - /// 替换队伍存储(默认 InMemory,生产持久化实现经本缝注入)。 - /// - /// 能力装配器。 - /// 存储实现。 - /// 本装配器(链式)。 - public static CapabilityHostBuilder UseOnlinePartyStore(this CapabilityHostBuilder builder, IOnlinePartyStore store) - { - ArgumentNullException.ThrowIfNull(builder); - - builder.UseService(store); - return builder; - } -} diff --git a/Capability/GameFrameX.Capability.Online.Matchmaking/OnlineMatchmakingOptions.cs b/Capability/GameFrameX.Capability.Online.Matchmaking/OnlineMatchmakingOptions.cs index 2fcbf693e..96d3e2df9 100644 --- a/Capability/GameFrameX.Capability.Online.Matchmaking/OnlineMatchmakingOptions.cs +++ b/Capability/GameFrameX.Capability.Online.Matchmaking/OnlineMatchmakingOptions.cs @@ -27,23 +27,38 @@ // Official documentation: https://gameframex.doc.alianblank.com/ // ========================================================================================== +using GameFrameX.Capability.Hosting.Abstractions; + namespace GameFrameX.Capability.Online.Matchmaking; /// -/// 匹配能力包级装配选项( 回调承载面,C231)。 +/// 匹配能力 Options 根类型(机制三层合并的承载面:包内默认 → 配置段 Online.Settings.online-matchmaking → 代码 Configure)。 /// -/// 维护约束(能力族全局约定,C227/C229 裁定):编排参数一律经 Action<TOptions> 回调承载(.NET -/// AddXxx(options => ...) 惯例);P3 期无包级编排参数(存储替换走 UseOnline*Store 缝族, -/// 配置段绑定归组合根),成员随真实需求落位;禁止回退平铺配置键。 +/// 维护约束:成员默认值唯一来源 = 字段初始值(子选项聚合为属性,子项默认收敛在子 Options 自身); +/// 只做合并终值的结构校验(正数阈值),不做业务裁决; +/// 新增编排参数一律落子 Options 属性,禁止回退平铺配置键。 /// /// /// -/// Package-level assembly options for the matchmaking capability (the callback face of -/// , C231). Following the -/// capability-family global convention, orchestration parameters ride an Action<TOptions> callback; -/// P3 carries no package-level knobs (store replacement goes through the UseOnline*Store seam family); -/// members land as real needs appear; flat configuration keys stay forbidden. +/// The options root of the matchmaking capability, consumed by the three-layer mechanism merge +/// (package defaults, the configuration section, then code Configure). Default values live only on +/// field initializers; Validate only checks the structural positivity of the merged thresholds. /// -public sealed class OnlineMatchmakingOptions +public sealed class OnlineMatchmakingOptions : ICapabilityOptions { + /// + /// 撮合参数(票据存活 / 等待扩窗 / 频控阈值;默认值唯一来源 = 字段初始值)。 + /// + public OnlineMatchmakerOptions Matchmaker { get; set; } = new OnlineMatchmakerOptions(); + + /// + public void Validate(CapabilityOptionsValidation validation) + { + validation.RequirePositive("Matchmaker.TicketTimeToLiveSeconds", Matchmaker.TicketTimeToLiveSeconds); + validation.RequirePositive("Matchmaker.WaitExpansionThresholdSeconds", Matchmaker.WaitExpansionThresholdSeconds); + validation.RequirePositive("Matchmaker.ExpansionIntervalSeconds", Matchmaker.ExpansionIntervalSeconds); + validation.RequirePositive("Matchmaker.SkillExpansionPerInterval", Matchmaker.SkillExpansionPerInterval); + validation.RequirePositive("Matchmaker.RateLimitMaxOperations", Matchmaker.RateLimitMaxOperations); + validation.RequirePositive("Matchmaker.RateLimitWindowSeconds", Matchmaker.RateLimitWindowSeconds); + } } diff --git a/Capability/GameFrameX.Capability.Online.Matchmaking/Party/OnlinePartyEvents.cs b/Capability/GameFrameX.Capability.Online.Matchmaking/Party/OnlinePartyEvents.cs index b3c33cd47..827072cb7 100644 --- a/Capability/GameFrameX.Capability.Online.Matchmaking/Party/OnlinePartyEvents.cs +++ b/Capability/GameFrameX.Capability.Online.Matchmaking/Party/OnlinePartyEvents.cs @@ -43,10 +43,10 @@ namespace GameFrameX.Capability.Online.Matchmaking.Party; public static class OnlinePartyEvents { /// 队伍状态变更。 - public const string PartyChanged = "Online.Party.Changed"; + public const string PartyChanged = "online.party.changed"; /// 邀请状态变更。 - public const string PartyInviteChanged = "Online.Party.InviteChanged"; + public const string PartyInviteChanged = "online.party.invitechanged"; /// 事件来源标识。 public const string Source = "online-party"; diff --git a/Capability/GameFrameX.Capability.Online.Matchmaking/Party/OnlinePartyService.cs b/Capability/GameFrameX.Capability.Online.Matchmaking/Party/OnlinePartyService.cs index 9205bc9da..7296f0b96 100644 --- a/Capability/GameFrameX.Capability.Online.Matchmaking/Party/OnlinePartyService.cs +++ b/Capability/GameFrameX.Capability.Online.Matchmaking/Party/OnlinePartyService.cs @@ -59,7 +59,7 @@ public sealed class OnlinePartyService : IOnlineChannelMembershipProbe private readonly IOnlineEventPublisher _eventPublisher; /// 在线事实探针(可空:未装配时离线清理退化为「不执行」而非误判全员离线)。 - private readonly IOnlinePartyPresenceProbe _presenceProbe; + private IOnlinePartyPresenceProbe _presenceProbe; /// 跨域社交裁决(可空:null = 不启用屏蔽/处罚裁决,既有行为不变)。 private readonly IOnlineSocialGate _socialGate; @@ -107,6 +107,19 @@ public OnlinePartyService( _idleTimeToLiveSeconds = idleTimeToLiveSeconds; } + /// + /// 后注入在线事实探针(Activate 期由能力模块经 GetServices<IOnlinePartyPresenceProbe> 枚举扩展点后回填)。 + /// + /// 维护约束:探针实现常来自非直接依赖的能力包(如 online-foundation),Register 期不可越界解析, + /// 故构造期可空、激活期后注入;重复注入以后一次为准(覆盖),传 null 等价于不启用离线清理。 + /// + /// + /// 在线事实探针(可空)。 + public void AttachPresenceProbe(IOnlinePartyPresenceProbe presenceProbe) + { + _presenceProbe = presenceProbe; + } + /// /// 创建队伍(发起人即队长并立即成为首个成员,状态 )。 /// diff --git a/Capability/GameFrameX.Capability.Online.Operations/AdminApi/OnlineAdminConsoleHandlers.cs b/Capability/GameFrameX.Capability.Online.Operations/AdminApi/OnlineAdminConsoleHandlers.cs index c46632856..ee2fcfbba 100644 --- a/Capability/GameFrameX.Capability.Online.Operations/AdminApi/OnlineAdminConsoleHandlers.cs +++ b/Capability/GameFrameX.Capability.Online.Operations/AdminApi/OnlineAdminConsoleHandlers.cs @@ -26,7 +26,7 @@ using GameFrameX.Capability.Hosting.AdminApi; using GameFrameX.Capability.Online.Abstractions.Contracts; -using GameFrameX.Capability.Online.Operations.Audit; +using GameFrameX.Capability.Online.Abstractions.Audit; using GameFrameX.Capability.Online.Operations.Overview; using GameFrameX.Capability.Online.Operations.Timeline; using GameFrameX.Capability.Online.Abstractions.Scope; diff --git a/Capability/GameFrameX.Capability.Online.Operations/GameFrameX.Capability.Online.Operations.csproj b/Capability/GameFrameX.Capability.Online.Operations/GameFrameX.Capability.Online.Operations.csproj index 14a0992b2..574f4c246 100644 --- a/Capability/GameFrameX.Capability.Online.Operations/GameFrameX.Capability.Online.Operations.csproj +++ b/Capability/GameFrameX.Capability.Online.Operations/GameFrameX.Capability.Online.Operations.csproj @@ -2,9 +2,11 @@ - GameFrameX.Capability.Online.Operations,GFX Online 运营聚合能力包:统一审计/游戏事件/Hotfix 回滚/在线总览/玩家时间线 + 事件桥与审计桥装配(InMemory 默认存储,UseOnlineOperationsStore 族替换缝)+ hosting 扩展(AddOnlineOperationsRuntime)+ admin handlers(Console 三 action). Operations aggregate capability package for the GFX Online family: unified audit, game events, hotfix rollback, overview and player timeline plus the event/audit bridge wiring with in-memory default stores, the hosting extension and admin handlers. Documentation: https://gameframex.doc.alianblank.com + GameFrameX.Capability.Online.Operations,GFX Online 运营聚合能力包:统一审计/游戏事件/Hotfix 回滚/在线总览/玩家时间线 + 事件桥与审计桥装配(机制事件总线逐主题订阅)+ 自装配模块(OnlineOperationsCapabilityModule,Options=OnlineOperationsOptions)+ admin handlers(Console 三 action). Operations aggregate capability package for the GFX Online family: unified audit, game events, hotfix rollback, overview and player timeline plus the event/audit bridge wiring over the mechanism event bus, the self-assembling capability module and admin handlers. Documentation: https://gameframex.doc.alianblank.com GameFrameX,Capability,Online,Operations,Audit,Timeline,Overview,HotfixRollback,GameEvents,GameServer - 10 + diff --git a/Capability/GameFrameX.Capability.Online.Operations/HotfixRollback/OnlineHotfixArtifactFetcher.cs b/Capability/GameFrameX.Capability.Online.Operations/HotfixRollback/OnlineHotfixArtifactFetcher.cs index 38c3300ff..aef26447c 100644 --- a/Capability/GameFrameX.Capability.Online.Operations/HotfixRollback/OnlineHotfixArtifactFetcher.cs +++ b/Capability/GameFrameX.Capability.Online.Operations/HotfixRollback/OnlineHotfixArtifactFetcher.cs @@ -77,11 +77,11 @@ public sealed class OnlineHotfixArtifactFetcher : IOnlineHotfixArtifactFetcher /// /// 初始化 。 /// - /// 下载参数(null = 缺省值)。 + /// 下载参数(必填;默认值唯一来源 = 字段初始值)。 /// HTTP 传输(null = 缺省 SocketsHttpHandler;测试注入桩)。 - public OnlineHotfixArtifactFetcher(OnlineHotfixArtifactFetchOptions options = null, HttpMessageHandler handler = null) + public OnlineHotfixArtifactFetcher(OnlineHotfixArtifactFetchOptions options, HttpMessageHandler handler = null) { - _options = options ?? new OnlineHotfixArtifactFetchOptions(); + _options = options ?? throw new ArgumentNullException(nameof(options)); _httpClient = new HttpClient(handler ?? new SocketsHttpHandler(), true); } diff --git a/Capability/GameFrameX.Capability.Online.Operations/HotfixRollback/OnlineHotfixEvents.cs b/Capability/GameFrameX.Capability.Online.Operations/HotfixRollback/OnlineHotfixEvents.cs index e65b67c21..4ef55380f 100644 --- a/Capability/GameFrameX.Capability.Online.Operations/HotfixRollback/OnlineHotfixEvents.cs +++ b/Capability/GameFrameX.Capability.Online.Operations/HotfixRollback/OnlineHotfixEvents.cs @@ -47,10 +47,10 @@ namespace GameFrameX.Capability.Online.Operations.HotfixRollback; public static class OnlineHotfixEvents { /// 版本清单已登记(发布流水线簿记事实)。 - public const string VersionRegistered = "Online.Hotfix.VersionRegistered"; + public const string VersionRegistered = "online.hotfix.versionregistered"; /// Hotfix 已回滚(受控操作事实:切换成功 + 兼容检查摘要)。 - public const string RolledBack = "Online.Hotfix.RolledBack"; + public const string RolledBack = "online.hotfix.rolledback"; /// 事件来源标识。 public const string Source = "online-hotfix"; diff --git a/Capability/GameFrameX.Capability.Online.Operations/HotfixRollback/OnlineHotfixRollbackService.cs b/Capability/GameFrameX.Capability.Online.Operations/HotfixRollback/OnlineHotfixRollbackService.cs index 0f3b22ff0..a01bc7554 100644 --- a/Capability/GameFrameX.Capability.Online.Operations/HotfixRollback/OnlineHotfixRollbackService.cs +++ b/Capability/GameFrameX.Capability.Online.Operations/HotfixRollback/OnlineHotfixRollbackService.cs @@ -32,7 +32,7 @@ using System.Text; using System.Threading; using System.Threading.Tasks; -using GameFrameX.Capability.Online.Operations.Audit; +using GameFrameX.Capability.Online.Abstractions.Audit; using GameFrameX.Capability.Online.Abstractions.Contracts; using GameFrameX.Capability.Online.Abstractions.Events; using GameFrameX.Capability.Online.Abstractions.Idempotency; diff --git a/Capability/GameFrameX.Capability.Online.Operations/OnlineOperationsCapabilityModule.cs b/Capability/GameFrameX.Capability.Online.Operations/OnlineOperationsCapabilityModule.cs index 9cd5e1c1a..cb82c243f 100644 --- a/Capability/GameFrameX.Capability.Online.Operations/OnlineOperationsCapabilityModule.cs +++ b/Capability/GameFrameX.Capability.Online.Operations/OnlineOperationsCapabilityModule.cs @@ -4,7 +4,7 @@ // 均受中华人民共和国及相关国际法律法规保护。 // are protected by the laws of the PRC and relevant international regulations. // 使用本项目须严格遵守相关法律法规与开源许可证之规定。 -// Usage of this project must strictly comply with applicable laws, regulations, and open-source licenses. +// Usage of this project must strictly comply with relevant laws, regulations, and open-source licenses. // 本项目采用 Apache License 2.0 单协议分发, // This project is licensed solely under the Apache License 2.0, // 完整许可证文本请参见源代码根目录下的 LICENSE 文件。 @@ -12,10 +12,10 @@ // 禁止利用本项目实施任何危害国家安全、破坏社会秩序、 // 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! +// or infringe upon the legal rights and interests of others, as prohibited by laws and regulations! // 因基于本项目二次开发所产生的一切法律纠纷与责任, -// Any legal disputes or liabilities arising from secondary development, -// 本组织与贡献者概不承担任何责任。 +// 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 @@ -24,75 +24,74 @@ // CNB 仓库:https://www.cnb.cool/GameFrameX // CNB Repository: https://www.cnb.cool/GameFrameX // 官方文档:https://gameframex.doc.alianblank.com/ -// Official Documentation: https://gameframex.doc.alianblank.com/ +// Official documentation: https://gameframex.doc.alianblank.com/ // ========================================================================================== +using System; +using System.Threading; +using System.Threading.Tasks; using GameFrameX.Capability.Hosting.Abstractions; using GameFrameX.Capability.Hosting.AdminApi; using GameFrameX.Capability.Online.Assets; -using GameFrameX.Capability.Online.Abstractions.Events; using GameFrameX.Capability.Online.Foundation.Identity; using GameFrameX.Capability.Online.Foundation.Presence; using GameFrameX.Capability.Online.Foundation.Session; +using GameFrameX.Capability.Online.Abstractions.Audit; +using GameFrameX.Capability.Online.Abstractions.Contracts; +using GameFrameX.Capability.Online.Abstractions.Events; using GameFrameX.Capability.Online.Abstractions.Idempotency; using GameFrameX.Capability.Online.Match; using GameFrameX.Capability.Online.Matchmaking; +using GameFrameX.Capability.Online.Matchmaking.Party; using GameFrameX.Capability.Online.Operations.AdminApi; -using GameFrameX.Capability.Online.Abstractions.Contracts; -using GameFrameX.Capability.Online.Operations.Audit; using GameFrameX.Capability.Online.Operations.HotfixRollback; using GameFrameX.Capability.Online.Operations.Overview; using GameFrameX.Capability.Online.Operations.Timeline; -using GameFrameX.Capability.Online.Matchmaking.Party; using GameFrameX.Capability.Online.Social; using GameFrameX.Foundation.Idempotency; namespace GameFrameX.Capability.Online.Operations; /// -/// 运营聚合能力(审计 / 游戏事件 / Hotfix 回滚 / 总览 / 时间线 + C105 事件桥/审计桥)的自装配模块 -/// (C232 P5 迁入)。 +/// 运营聚合能力(审计 / 游戏事件 / Hotfix 回滚 / 总览 / 时间线 + 事件桥/审计桥)的自装配模块 +/// (C294 迁入新机制三段生命周期)。 /// -/// 维护约束(服务共享协议):模块无参构造(扫描可发现);Register 自装配本域——审计服务经具型探测缝 -/// (组合根预置命中共享同一实例——Assets 模块的审计留痕与桥的审计写入保持同源;未命中构造 InMemory 默认并回注 -/// 具型 + 读面);Hotfix 回滚依赖幂等基建(组合根预置),缺席不装配(壳时代 -/// 「下游置 null」的模块化等价);总览 / 时间线探测叶子产物(matchmaking / party / match / assets / social), -/// 缺席置 null(handler 不可达语义对齐壳时代);地基三 store(presence / session / identity)经替换缝探测, -/// 未命中构造 InMemory 默认。 +/// 维护约束(服务共享协议):模块无参构造(扫描可发现);Register 自装配本域——统一审计与幂等 +/// 为族基建(OnlineCapabilityFamily 预置,具型 / 读面同源),直接 GetService;Hotfix 回滚的版本切换 +/// 执行器是宿主扩展点( 声明, +/// 宿主经 Override 提供缺即装配失败),制品获取器为包内默认(Options 必填化构造);总览 / 时间线探测 +/// 可选叶子产物(assets / social / match / matchmaking),缺席置 null(handler 不可达语义对齐壳时代); +/// 地基三 store(presence / session / identity)来自必选 online-foundation 的默认注册,直接 GetService。 /// /// -/// 事件桥 / 审计桥(C105 语义不变红线):桥体逐语句迁移自壳 OnlineRuntimeHost.StartAsync—— -/// 订阅 Foundation 事件总线,信封解映射 + 去重后做聊天审计投影与统一审计转接,投影侧异常吞掉不阻断业务分发; -/// 订阅时点从启动期提前到装配期(机制无 Start 钩子,行为等价已裁定披露)。 +/// 事件桥 / 审计桥(C105 语义不变红线):对全部已知 Online 事件主题逐个经机制事件总线订阅 +/// (Register 期声明;单个订阅体异常由机制记日志后继续分发,业务侧不再自行吞异常),信封去重后做 +/// 聊天审计投影与统一审计转接,桥体逐语句迁移自壳 OnlineRuntimeHost.StartAsync。 /// /// -/// -/// The self-assembling module of the operations capability (audit, game events, hotfix rollback, overview, -/// timeline and the C105 event/audit bridges; migrated by C232 P5). The module has a parameterless -/// constructor (discoverable by scanning); Register self-assembles the domain — the audit service -/// resolves through a typed probe seam (sharing the composition-root instance so the Assets module's audit -/// trail and the bridge's audit ingestion stay on the same instance, with an in-memory fallback registered -/// back under both the concrete type and the read face); the hotfix -/// rollback service depends on the idempotency infrastructure and stays unassembled when absent (the -/// modular equivalent of the shell-era null downstream); the overview / timeline services probe leaf -/// products and stay null when absent; the three foundation stores probe replacement seams with -/// in-memory fallbacks. The event/audit bridges migrate statement-by-statement from the shell's -/// StartAsync with the subscription point moved from start-up to assembly time (adjudicated as -/// behavior-equivalent). -/// public sealed class OnlineOperationsCapabilityModule : ICapabilityModule, IAdminActionContributor { /// - /// 运营聚合能力清单(Id 冻结 = 终态包名;查询域 action 迁移自壳过渡模块,Hotfix 受控操作族为 C256 新增)。 + /// 运营聚合能力清单(Id 冻结 = 终态包名;查询域 action 迁移自壳过渡模块,Hotfix 受控操作族为 C256 新增; + /// 必选 online-foundation;可选 assets / social / match / matchmaking 经 + /// 声明,缺席时披露对应缺失功能名)。 /// public static readonly CapabilityManifest ManifestInstance = new( "online-operations", "1.0.0", new[] { "query_online_overview", "query_player_timeline", "query_online_audit_events", "register_hotfix_manifest", "activate_hotfix_version", "check_hotfix_compatibility", "rollback_hotfix_version" }, - new[] { "online-foundation", "online-assets", "online-social", "online-match", "online-matchmaking" }); + new[] { "online-foundation" }, + new[] + { + new CapabilityOptionalDependency("online-assets", Localization.Keys.Capability.Online.Operations.OptionalAssetsAbsent), + new CapabilityOptionalDependency("online-social", Localization.Keys.Capability.Online.Operations.OptionalSocialAbsent), + new CapabilityOptionalDependency("online-match", Localization.Keys.Capability.Online.Operations.OptionalMatchAbsent), + new CapabilityOptionalDependency("online-matchmaking", Localization.Keys.Capability.Online.Operations.OptionalMatchmakingAbsent), + }, + typeof(OnlineOperationsOptions)); /// - /// 统一审计服务(Register 期解析;admin 查询面与桥的写入面同源)。 + /// 统一审计服务(Register 期解析族基建;admin 查询面与桥的写入面同源)。 /// private OnlineAuditService _audit; @@ -107,7 +106,7 @@ public sealed class OnlineOperationsCapabilityModule : ICapabilityModule, IAdmin private OnlinePlayerTimelineService _timeline; /// - /// Hotfix 回滚受控服务(Register 期构造;幂等基建缺席时不装配 = null,admin action 不可达)。 + /// Hotfix 回滚受控服务(Register 期构造;幂等基建为族基建恒在)。 /// private OnlineHotfixRollbackService _hotfixRollback; @@ -120,62 +119,55 @@ public CapabilityManifest Manifest /// public void Register(CapabilityRegistrationContext context) { + var options = context.GetOptions(); var eventPublisher = context.GetService(); - // 审计(具型探测缝——探测与回注语义见 ResolveAuditService)。 - var audit = ResolveAuditService(context); + // 统一审计:族基建预置(具型 + IOnlineAuditReader 读面同源单例),直接解析,无探测回退。 + var audit = context.GetService(); _audit = audit; - // Hotfix 回滚(幂等基建缺席时不装配——壳「下游置 null」的模块化等价;执行器与制品获取器为可选注入位,C248)。 - if (context.TryGetService(out OnlineIdempotencyService idempotency)) - { - var hotfixVersionStore = ResolveStore(context, static () => new InMemoryOnlineHotfixVersionStore()); - var rollbackExecutor = context.TryGetService(out IOnlineHotfixRollbackExecutor hotfixRollbackExecutor) ? hotfixRollbackExecutor : null; - var artifactFetcher = context.TryGetService(out IOnlineHotfixArtifactFetcher hotfixArtifactFetcher) ? hotfixArtifactFetcher : null; - _hotfixRollback = new OnlineHotfixRollbackService(hotfixVersionStore, idempotency, audit, rollbackExecutor, artifactFetcher, eventPublisher); - context.AddService(_hotfixRollback); - } + // Hotfix 回滚:幂等为族基建恒在;版本切换执行器为宿主扩展点(缺宿主 Override = 装配期 fail-fast); + // 制品获取器为包内默认(下载参数必填自 Options);版本 store 懒默认描述符(族 / 宿主覆盖恒胜出)。 + context.AddService(static _ => new InMemoryOnlineHotfixVersionStore()); + var hotfixVersionStore = context.GetService(); + var idempotency = context.GetService(); + context.RequireHostService(); + var rollbackExecutor = context.GetService(); + context.AddService(resolver => new OnlineHotfixArtifactFetcher(options.ArtifactFetch)); + var artifactFetcher = context.GetService(); + _hotfixRollback = new OnlineHotfixRollbackService(hotfixVersionStore, idempotency, audit, rollbackExecutor, artifactFetcher, eventPublisher); + context.AddService(_hotfixRollback); - // 总览 / 时间线(叶子产物探测;地基三 store 经替换缝探测,未命中构造 InMemory 默认)。 - var hasAssetQuery = context.TryGetService(out OnlineAssetQueryService assetQuery); - var hasSocialGraphStore = context.TryGetService(out IOnlineSocialGraphStore socialGraphStore); - var hasMatchTicketStore = context.TryGetService(out IOnlineMatchTicketStore matchTicketStore); - var hasPartyStore = context.TryGetService(out IOnlinePartyStore partyStore); - var hasMatchActorStore = context.TryGetService(out IOnlineMatchActorStore matchActorStore); - var presenceStore = ResolveStore(context, static () => new InMemoryOnlinePresenceStore()); - var sessionStore = ResolveStore(context, static () => new InMemoryOnlineSessionStore()); - var identityStore = ResolveStore(context, static () => new InMemoryOnlineIdentityStore()); - _overview = hasMatchTicketStore && hasPartyStore && hasMatchActorStore + // 总览 / 时间线(可选叶子产物经 manifest 可选声明 + GetOptionalService,缺席 = null 不装配; + // 地基三 store 来自必选 online-foundation 的默认注册,直接 GetService,本包不再注册默认避免同键第二默认)。 + var assetQuery = context.GetOptionalService(); + var socialGraphStore = context.GetOptionalService(); + var matchTicketStore = context.GetOptionalService(); + var partyStore = context.GetOptionalService(); + var matchActorStore = context.GetOptionalService(); + var presenceStore = context.GetService(); + var sessionStore = context.GetService(); + var identityStore = context.GetService(); + _overview = matchTicketStore != null && partyStore != null && matchActorStore != null ? new OnlineOverviewService(presenceStore, sessionStore, partyStore, matchTicketStore, matchActorStore) : null; - _timeline = hasAssetQuery && hasSocialGraphStore && hasMatchActorStore + _timeline = assetQuery != null && socialGraphStore != null && matchActorStore != null ? new OnlinePlayerTimelineService(identityStore, sessionStore, assetQuery, matchActorStore, socialGraphStore) : null; - // 事件桥 / 审计桥(C105 语义不变红线;桥体逐语句迁移自壳 StartAsync,订阅时点装配期提前已裁定披露)。 - var eventBus = context.GetService(); - var chatAudit = context.TryGetService(out OnlineChatAuditProjection chatAuditProjection) ? chatAuditProjection : null; - var consumer = new OnlineEventConsumer(new InMemoryEventDeduplicator()); - var sanitizer = new OnlineEventSanitizer(); - eventBus.Subscribe(envelope => - { - try - { - var onlineEvent = OnlineEventEnvelopeMapper.FromEnvelope(envelope); - if (!consumer.TryConsume(onlineEvent)) - { - return; - } - - chatAudit?.OnEvent(onlineEvent); - IngestAuditFromEvent(onlineEvent, sanitizer, audit); - } - catch (Exception) - { - // 审计桥异常不得阻断业务事件分发(InMemoryEventPublisher 异常策略是向上传播, - // 这里吞掉投影侧异常以保证宿主写链路不受投影缺陷影响;投影片段丢失由审计查询面暴露)。 - } - }); + RegisterEventBridge(context); + } + + /// + public Task ActivateAsync(CapabilityActivationContext context, CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + /// + public Task DeactivateAsync(CancellationToken cancellationToken) + { + return Task.CompletedTask; } /// @@ -185,6 +177,88 @@ public void RegisterAdminActions(AdminActionRegistry registry) OnlineAdminHotfixHandlers.RegisterHotfixActions(registry, _hotfixRollback); } + /// + /// 注册事件桥 / 审计桥(C105 语义不变红线):对全部已知 Online 事件主题逐个经机制事件总线订阅; + /// 订阅体 = 信封去重(EventId)→ 聊天审计投影(可选社交产物,缺席跳过)→ 统一审计转接, + /// 逐语句迁移自壳 StartAsync;单个订阅体异常由机制记日志后继续分发(业务侧不再吞异常)。 + /// + /// 注册上下文。 + private void RegisterEventBridge(CapabilityRegistrationContext context) + { + var chatAudit = context.GetOptionalService(); + var consumer = new OnlineEventConsumer(new InMemoryEventDeduplicator()); + var sanitizer = new OnlineEventSanitizer(); + + void HandleBridgeEvent(OnlineEvent onlineEvent) + { + if (!consumer.TryConsume(onlineEvent)) + { + return; + } + + chatAudit?.OnEvent(onlineEvent); + IngestAuditFromEvent(onlineEvent, sanitizer, _audit); + } + + // 地基(online-foundation)。 + context.Subscribe(OnlinePresenceEvents.PresenceChanged, HandleBridgeEvent); + context.Subscribe(OnlineSessionEvents.SessionCreated, HandleBridgeEvent); + context.Subscribe(OnlineSessionEvents.SessionAuthenticated, HandleBridgeEvent); + context.Subscribe(OnlineSessionEvents.SessionConnected, HandleBridgeEvent); + context.Subscribe(OnlineSessionEvents.SessionActive, HandleBridgeEvent); + context.Subscribe(OnlineSessionEvents.SessionReconnecting, HandleBridgeEvent); + context.Subscribe(OnlineSessionEvents.SessionClosed, HandleBridgeEvent); + context.Subscribe(OnlineSessionEvents.SessionKicked, HandleBridgeEvent); + context.Subscribe(OnlineSessionEvents.SessionExpired, HandleBridgeEvent); + context.Subscribe(OnlineSessionEvents.SessionTokenRefreshed, HandleBridgeEvent); + + // 资产(online-assets)。 + context.Subscribe(OnlineAssetEvents.AssetGranted, HandleBridgeEvent); + context.Subscribe(OnlineAssetEvents.AssetDeducted, HandleBridgeEvent); + context.Subscribe(OnlineAssetEvents.AssetAdjusted, HandleBridgeEvent); + + // 社交(online-social)。 + context.Subscribe(OnlineChatEvents.MessageSent, HandleBridgeEvent); + context.Subscribe(OnlineChatEvents.MessageRecalled, HandleBridgeEvent); + context.Subscribe(OnlineChatEvents.ReadMarkUpdated, HandleBridgeEvent); + context.Subscribe(OnlineChatEvents.ModerationFailed, HandleBridgeEvent); + context.Subscribe(OnlineNotificationEvents.NotificationChanged, HandleBridgeEvent); + context.Subscribe(OnlineSocialEvents.FriendshipChanged, HandleBridgeEvent); + context.Subscribe(OnlineSocialEvents.BlockChanged, HandleBridgeEvent); + context.Subscribe(OnlineSocialEvents.MuteChanged, HandleBridgeEvent); + context.Subscribe(OnlineSocialEvents.ReportChanged, HandleBridgeEvent); + context.Subscribe(OnlineSocialEvents.PunishmentChanged, HandleBridgeEvent); + context.Subscribe(OnlineSocialEvents.GroupChanged, HandleBridgeEvent); + + // 匹配与队伍(online-matchmaking)。 + context.Subscribe(OnlineMatchEvents.TicketChanged, HandleBridgeEvent); + context.Subscribe(OnlineMatchEvents.AssignmentCreated, HandleBridgeEvent); + context.Subscribe(OnlinePartyEvents.PartyChanged, HandleBridgeEvent); + context.Subscribe(OnlinePartyEvents.PartyInviteChanged, HandleBridgeEvent); + + // 对局(online-match)。 + context.Subscribe(OnlineMatchRuntimeEvents.MatchStateChanged, HandleBridgeEvent); + context.Subscribe(OnlineMatchRuntimeEvents.MatchSettled, HandleBridgeEvent); + + // 竞技(online-competition,非依赖边——本包不引用其程序集,topic 为全族冻结命名约定字面量, + // 见各能力包 *Events 常量;字段变化须与其同步)。 + context.Subscribe("online.leaderboard.created", HandleBridgeEvent); + context.Subscribe("online.leaderboard.scoreupdated", HandleBridgeEvent); + context.Subscribe("online.season.created", HandleBridgeEvent); + context.Subscribe("online.season.started", HandleBridgeEvent); + context.Subscribe("online.season.ended", HandleBridgeEvent); + context.Subscribe("online.season.settled", HandleBridgeEvent); + context.Subscribe("online.tournament.created", HandleBridgeEvent); + context.Subscribe("online.tournament.started", HandleBridgeEvent); + context.Subscribe("online.tournament.registered", HandleBridgeEvent); + context.Subscribe("online.tournament.ended", HandleBridgeEvent); + context.Subscribe("online.tournament.settled", HandleBridgeEvent); + + // Hotfix(本包)。 + context.Subscribe(OnlineHotfixEvents.VersionRegistered, HandleBridgeEvent); + context.Subscribe(OnlineHotfixEvents.RolledBack, HandleBridgeEvent); + } + /// /// 审计桥:把 Online 事件自动转接为统一审计记录(admin 命令 → 审计留痕)——逐语句迁移自壳 /// OnlineRuntimeHost(OperatorId 来源标识逐字节冻结)。 @@ -212,43 +286,4 @@ private static void IngestAuditFromEvent(OnlineEvent onlineEvent, OnlineEventSan }; var _ = audit.IngestAsync(entry); } - - /// - /// 解析统一审计服务(具型探测缝——组合根预置命中共享同一实例;未命中构造 InMemory 默认并回注具型 + 读面)。 - /// - /// 注册上下文。 - /// 统一审计服务实例。 - private static OnlineAuditService ResolveAuditService(CapabilityRegistrationContext context) - { - if (context.TryGetService(out OnlineAuditService preloadedAudit)) - { - return preloadedAudit; - } - - var auditStore = ResolveStore(context, static () => new InMemoryOnlineAuditStore()); - var audit = new OnlineAuditService(auditStore); - context.AddService(audit); - context.AddService(audit); - return audit; - } - - /// - /// 解析存储(替换缝探测——命中用组合根注入实例,未命中构造 InMemory 默认并注册回共享面)。 - /// - /// 存储接口。 - /// 注册上下文。 - /// 默认构造。 - /// 存储实例。 - private static TStore ResolveStore(CapabilityRegistrationContext context, Func defaultFactory) - where TStore : class - { - if (context.TryGetService(out TStore store)) - { - return store; - } - - store = defaultFactory(); - context.AddService(store); - return store; - } } diff --git a/Capability/GameFrameX.Capability.Online.Operations/OnlineOperationsHostingExtensions.cs b/Capability/GameFrameX.Capability.Online.Operations/OnlineOperationsHostingExtensions.cs deleted file mode 100644 index 4975355bf..000000000 --- a/Capability/GameFrameX.Capability.Online.Operations/OnlineOperationsHostingExtensions.cs +++ /dev/null @@ -1,126 +0,0 @@ -// ========================================================================================== -// GameFrameX 组织及其衍生项目的版权、商标、专利及其他相关权利 -// GameFrameX organization and its derivative projects' copyrights, trademarks, patents and related rights -// 均受中华人民共和国及相关国际法律法规保护。 -// are protected by the laws of the PRC 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 refer to the LICENSE file at the root 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, -// 本组织与贡献者概不承担任何责任。 -// 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://www.cnb.cool/GameFrameX -// CNB Repository: https://www.cnb.cool/GameFrameX -// 官方文档:https://gameframex.doc.alianblank.com/ -// Official Documentation: https://gameframex.doc.alianblank.com/ -// ========================================================================================== - -using GameFrameX.Capability.Hosting.Core; -using GameFrameX.Capability.Online.Operations.Audit; -using GameFrameX.Capability.Online.Operations.HotfixRollback; - -namespace GameFrameX.Capability.Online.Operations; - -/// -/// 运营聚合能力包的 hosting 扩展(C232 P5): 装配入口(回调式, -/// 注册自装配模块)、三个存储替换缝( / / -/// )与登记期制品获取器缝(,C248)。 -/// -/// 维护约束:options 经共享服务面预置(模块无参构造,显式链式与 P4 真扫描共用同一模块类); -/// 替换缝在模块 Register 期探测命中,未命中构造 InMemory 默认。 -/// -/// -/// -/// Hosting extensions of the operations capability package (C232 P5): the -/// assembly entry (callback style, registering the self-assembling module) plus three replacement seams. -/// Options are pre-seeded through the shared service face; the replacement seams are probed at module -/// Register time with in-memory fallbacks. -/// -public static class OnlineOperationsHostingExtensions -{ - /// - /// 装配运营聚合能力运行时(注册自装配模块;options => ... 回调承载包级编排参数)。 - /// - /// 能力装配器。 - /// 包级选项回调(可空 = 全默认)。 - /// 本装配器(链式)。 - public static CapabilityHostBuilder AddOnlineOperationsRuntime(this CapabilityHostBuilder builder, Action configure = null) - { - ArgumentNullException.ThrowIfNull(builder); - - var options = new OnlineOperationsOptions(); - configure?.Invoke(options); - builder.UseService(options); - return builder.AddModule(new OnlineOperationsCapabilityModule()); - } - - /// - /// 替换统一审计存储(默认 InMemory,生产持久化实现经本缝注入)。 - /// - /// 能力装配器。 - /// 存储实现。 - /// 本装配器(链式)。 - public static CapabilityHostBuilder UseOnlineAuditStore(this CapabilityHostBuilder builder, IOnlineAuditStore store) - { - ArgumentNullException.ThrowIfNull(builder); - - builder.UseService(store); - return builder; - } - - /// - /// 替换 Hotfix 版本存储(默认 InMemory,生产持久化实现经本缝注入)。 - /// - /// 能力装配器。 - /// 存储实现。 - /// 本装配器(链式)。 - public static CapabilityHostBuilder UseOnlineHotfixVersionStore(this CapabilityHostBuilder builder, IOnlineHotfixVersionStore store) - { - ArgumentNullException.ThrowIfNull(builder); - - builder.UseService(store); - return builder; - } - - /// - /// 注入 Hotfix 回滚执行器(生产组合根经 C248 注入 的 - /// Launcher 侧实现 HotfixManagerRollbackExecutor;替换缝开放,测试注入替身)。 - /// - /// 能力装配器。 - /// 回滚执行器。 - /// 本装配器(链式)。 - public static CapabilityHostBuilder UseOnlineHotfixRollbackExecutor(this CapabilityHostBuilder builder, IOnlineHotfixRollbackExecutor executor) - { - ArgumentNullException.ThrowIfNull(builder); - - builder.UseService(executor); - return builder; - } - - /// - /// 注入登记期制品获取器(可选注入位;清单携带 ArtifactSourceUrl/ArtifactSha256 时 - /// 登记落簿前经其下载、校验并原子落盘,生产组合根注入 ,C248)。 - /// - /// 能力装配器。 - /// 制品获取器。 - /// 本装配器(链式)。 - public static CapabilityHostBuilder UseOnlineHotfixArtifactFetcher(this CapabilityHostBuilder builder, IOnlineHotfixArtifactFetcher fetcher) - { - ArgumentNullException.ThrowIfNull(builder); - - builder.UseService(fetcher); - return builder; - } -} diff --git a/Capability/GameFrameX.Capability.Online.Operations/OnlineOperationsOptions.cs b/Capability/GameFrameX.Capability.Online.Operations/OnlineOperationsOptions.cs index ead88fa2d..1b6efa593 100644 --- a/Capability/GameFrameX.Capability.Online.Operations/OnlineOperationsOptions.cs +++ b/Capability/GameFrameX.Capability.Online.Operations/OnlineOperationsOptions.cs @@ -4,7 +4,7 @@ // 均受中华人民共和国及相关国际法律法规保护。 // are protected by the laws of the PRC and relevant international regulations. // 使用本项目须严格遵守相关法律法规与开源许可证之规定。 -// Usage of this project must strictly comply with applicable laws, regulations, and open-source licenses. +// Usage of this project must strictly comply with relevant laws, regulations, and open-source licenses. // 本项目采用 Apache License 2.0 单协议分发, // This project is licensed solely under the Apache License 2.0, // 完整许可证文本请参见源代码根目录下的 LICENSE 文件。 @@ -12,10 +12,10 @@ // 禁止利用本项目实施任何危害国家安全、破坏社会秩序、 // 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! +// or infringe upon the legal rights and interests of others, as prohibited by laws and regulations! // 因基于本项目二次开发所产生的一切法律纠纷与责任, -// Any legal disputes or liabilities arising from secondary development, -// 本组织与贡献者概不承担任何责任。 +// 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 @@ -24,18 +24,38 @@ // CNB 仓库:https://www.cnb.cool/GameFrameX // CNB Repository: https://www.cnb.cool/GameFrameX // 官方文档:https://gameframex.doc.alianblank.com/ -// Official Documentation: https://gameframex.doc.alianblank.com/ +// Official documentation: https://gameframex.doc.alianblank.com/ // ========================================================================================== +using System; +using GameFrameX.Capability.Hosting.Abstractions; +using GameFrameX.Capability.Online.Operations.HotfixRollback; + namespace GameFrameX.Capability.Online.Operations; /// -/// 运营聚合能力包级选项(C232 P5 迁入;当前无包级编排参数,占位对齐包族选项形态)。 +/// 运营聚合能力包级 Options 根类型(C294:聚合 Hotfix 登记期制品获取参数;默认值唯一来源 = 字段初始值)。 /// -/// -/// Package-level options of the operations capability (migrated by C232 P5; currently a placeholder -/// with no orchestration parameters). -/// -public sealed class OnlineOperationsOptions +public sealed class OnlineOperationsOptions : ICapabilityOptions { + /// + /// 获取或设置登记期制品获取参数(单次超时 / 重试次数 / 大小上限;OnlineHotfixArtifactFetcher 必填入参)。 + /// + public OnlineHotfixArtifactFetchOptions ArtifactFetch { get; set; } = new OnlineHotfixArtifactFetchOptions(); + + /// + public void Validate(CapabilityOptionsValidation validation) + { + if (ArtifactFetch.AttemptTimeout <= TimeSpan.Zero) + { + validation.AddError("ArtifactFetch.AttemptTimeout", "must be a positive time span (actual: " + ArtifactFetch.AttemptTimeout + ")"); + } + + if (ArtifactFetch.MaxRetryCount < 0) + { + validation.AddError("ArtifactFetch.MaxRetryCount", "must not be negative (actual: " + ArtifactFetch.MaxRetryCount + ")"); + } + + validation.RequirePositive("ArtifactFetch.MaxArtifactBytes", ArtifactFetch.MaxArtifactBytes); + } } diff --git a/Capability/GameFrameX.Capability.Online.Operations/Timeline/OnlinePlayerTimelineService.cs b/Capability/GameFrameX.Capability.Online.Operations/Timeline/OnlinePlayerTimelineService.cs index 5bf7eb630..a4a103b27 100644 --- a/Capability/GameFrameX.Capability.Online.Operations/Timeline/OnlinePlayerTimelineService.cs +++ b/Capability/GameFrameX.Capability.Online.Operations/Timeline/OnlinePlayerTimelineService.cs @@ -157,7 +157,11 @@ public async Task> QueryAsync(OnlineScope return failure; } - var request = query ?? new OnlinePlayerTimelineQuery(); + var request = query; + if (request == null) + { + request = new OnlinePlayerTimelineQuery(); + } var pageSize = request.PageSize <= 0 ? DefaultPageSize : request.PageSize; if (pageSize > MaxPageSize) diff --git a/Capability/GameFrameX.Capability.Online.Persistence/GameFrameX.Capability.Online.Persistence.csproj b/Capability/GameFrameX.Capability.Online.Persistence/GameFrameX.Capability.Online.Persistence.csproj index 52e4de071..5510de235 100644 --- a/Capability/GameFrameX.Capability.Online.Persistence/GameFrameX.Capability.Online.Persistence.csproj +++ b/Capability/GameFrameX.Capability.Online.Persistence/GameFrameX.Capability.Online.Persistence.csproj @@ -2,9 +2,11 @@ - GameFrameX.Capability.Online.Persistence,GFX Online 分级持久化适配包(C249,D3=3A):Identity/PlayerStorage/资产账本三域一级 store 的 GameDb 适配(物化复合键唯一索引 + C247 条件写原语 + 序列接管),Session/Presence 保持易失;组合根经 UseOnlineGameDbStores 预置替换缝,InMemory 默认不变. Graded-persistence adapter package for the GFX Online family (C249, D3=3A): GameDb adapters for the three level-one stores (identity, player storage and the asset ledger) built on materialized composite-key unique indexes, the C247 conditional-write primitives and sequence take-over, while sessions and presence stay volatile; composition roots pre-seed the replacement seam via UseOnlineGameDbStores and the in-memory default is unchanged. Documentation: https://gameframex.doc.alianblank.com + GameFrameX.Capability.Online.Persistence,GFX Online 分级持久化适配包(C249,D3=3A):Identity/PlayerStorage/资产账本/资产交易/好友关系五域一级 store 的 GameDb 适配(物化复合键唯一索引 + C247 条件写原语 + 序列接管),Session/Presence 保持易失;宿主组合根经能力服务 Override 预置,InMemory 默认不变. Graded-persistence adapter package for the GFX Online family (C249, D3=3A): GameDb adapters for the five level-one stores (identity, player storage, the asset ledger, asset transactions and friendships) built on materialized composite-key unique indexes, the C247 conditional-write primitives and sequence take-over, while sessions and presence stay volatile; composition roots pre-seed them through capability service overrides and the in-memory default is unchanged. Documentation: https://gameframex.doc.alianblank.com GameFrameX,Capability,Online,Persistence,GameDb,SQLite,Mongo,PostgreSql,GameServer - 10 + $(NoWarn);GFX0001 diff --git a/Capability/GameFrameX.Capability.Online.Persistence/OnlinePersistenceHostingExtensions.cs b/Capability/GameFrameX.Capability.Online.Persistence/OnlinePersistenceHostingExtensions.cs deleted file mode 100644 index 223117a37..000000000 --- a/Capability/GameFrameX.Capability.Online.Persistence/OnlinePersistenceHostingExtensions.cs +++ /dev/null @@ -1,75 +0,0 @@ -// ========================================================================================== -// 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 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://www.cnb.cool/GameFrameX -// 官方文档:https://gameframex.doc.alianblank.com/ -// Official documentation: https://gameframex.doc.alianblank.com/ -// ========================================================================================== - -namespace GameFrameX.Capability.Online.Persistence; - -using GameFrameX.Capability.Hosting.Core; -using GameFrameX.Capability.Online.Assets; -using GameFrameX.Capability.Online.Foundation.Storage; -using GameFrameX.Capability.Online.Persistence.Assets; -using GameFrameX.Capability.Online.Persistence.Identity; -using GameFrameX.Capability.Online.Persistence.Storage; -using GameFrameX.Capability.Online.Foundation.Identity; -using GameFrameX.Capability.Online.Persistence.Social; -using GameFrameX.Capability.Online.Social; - -/// -/// Online 分级持久化适配包的 hosting 扩展(C249,D3 = 3A;C244 增补好友域): -/// 一次性预置五个一级 store 的 GameDb 实现进共享服务面。 -/// -/// 装配语义(对齐 C231 替换缝族):预置发生在能力模块 Register 之前——Foundation / -/// Assets / Social 模块探测共享面命中即采用,未命中仍构造 InMemory 默认(缺省路径零变化红线); -/// 预置的 store 实例无进程内状态缓存(读路径恒直达数据库,实例重建即等价进程重启)。 -/// 前置约束:调用前 静态门面必须已完成 Init -///(组合根先初始化数据库再装配 Online 能力;未 Init 时的首次读写按门面契约 fail fast)。 -/// Session / Presence 二级易失 store 不在本缝预置(保持 InMemory 默认,Redis 演进位见模块 spec)。 -/// -/// -public static class OnlinePersistenceHostingExtensions -{ - /// - /// 预置 Online 一级 store 的 GameDb 实现(Identity / PlayerStorage / 资产账本 / 资产交易 / 好友关系)进共享服务面。 - /// - /// 能力装配器。 - /// 本装配器(链式)。 - public static CapabilityHostBuilder UseOnlineGameDbStores(this CapabilityHostBuilder builder) - { - ArgumentNullException.ThrowIfNull(builder); - - // 接口键显式注册(C245 修复):服务键 = 调用点 TService 类型(无接口自动映射)—— - // 此前以具体类型注册导致模块 Register 期的 IOnline*Store 接口探测永不命中, - // GameDb 持久化在生产组合根下静默回落 InMemory(C249 spec「探测命中即采用」声明被背离)。 - builder.UseService(new GameDbOnlineIdentityStore()); - builder.UseService(new GameDbOnlinePlayerStorageStore()); - builder.UseService(new GameDbOnlineAssetStore()); - builder.UseService(new GameDbOnlineAssetTransactionStore()); - builder.UseService(new GameDbOnlineFriendStore()); - return builder; - } -} diff --git a/Capability/GameFrameX.Capability.Online.Social/ChatMessageStateTransition.cs b/Capability/GameFrameX.Capability.Online.Social/ChatMessageStateTransition.cs index 77d3966cd..96accda28 100644 --- a/Capability/GameFrameX.Capability.Online.Social/ChatMessageStateTransition.cs +++ b/Capability/GameFrameX.Capability.Online.Social/ChatMessageStateTransition.cs @@ -27,6 +27,8 @@ // Official documentation: https://gameframex.doc.alianblank.com/ // ========================================================================================== +using GameFrameX.Capability.Online.Abstractions; + namespace GameFrameX.Capability.Online.Social; /// diff --git a/Capability/GameFrameX.Capability.Online.Social/GameFrameX.Capability.Online.Social.csproj b/Capability/GameFrameX.Capability.Online.Social/GameFrameX.Capability.Online.Social.csproj index 90df5b97a..bc70d82ff 100644 --- a/Capability/GameFrameX.Capability.Online.Social/GameFrameX.Capability.Online.Social.csproj +++ b/Capability/GameFrameX.Capability.Online.Social/GameFrameX.Capability.Online.Social.csproj @@ -2,15 +2,19 @@ - GameFrameX.Capability.Online.Social,GFX Online 社交能力包:好友/群组/聊天/通知/处罚/举报裁决六域 + 聊天审计投影(InMemory 默认存储,UseOnlineSocialStore 族替换缝)+ hosting 扩展(AddOnlineSocialRuntime)+ admin handlers(kick/revoke/mute/查询族). Social capability package for the GFX Online family: friends, groups, chat, notifications, punishments and report adjudication plus the chat-audit projection with in-memory default stores, the hosting extension and admin handlers. Documentation: https://gameframex.doc.alianblank.com + GameFrameX.Capability.Online.Social,GFX Online 社交能力包:好友/群组/聊天/通知/处罚/举报裁决六域 + 聊天审计投影 + 旧好友数据一次性迁移(InMemory 默认存储,描述符服务面 Override 替换)+ admin handlers(kick/revoke/mute/查询族). Social capability package for the GFX Online family: friends, groups, chat, notifications, punishments and report adjudication plus the chat-audit projection and the one-shot legacy friend-data migration with in-memory default stores and admin handlers. Documentation: https://gameframex.doc.alianblank.com GameFrameX,Capability,Online,Social,Friend,Chat,Punishment,GameServer - 10 + + + diff --git a/Capability/GameFrameX.Capability.Online.Social/IOnlineChatStore.cs b/Capability/GameFrameX.Capability.Online.Social/IOnlineChatStore.cs index 777e4bcf1..aa8268ab0 100644 --- a/Capability/GameFrameX.Capability.Online.Social/IOnlineChatStore.cs +++ b/Capability/GameFrameX.Capability.Online.Social/IOnlineChatStore.cs @@ -27,6 +27,8 @@ // Official documentation: https://gameframex.doc.alianblank.com/ // ========================================================================================== +using GameFrameX.Capability.Online.Abstractions; + namespace GameFrameX.Capability.Online.Social; /// diff --git a/Capability/GameFrameX.Capability.Online.Social/InMemoryOnlineChatStore.cs b/Capability/GameFrameX.Capability.Online.Social/InMemoryOnlineChatStore.cs index 0b91e95cb..bf9242b6a 100644 --- a/Capability/GameFrameX.Capability.Online.Social/InMemoryOnlineChatStore.cs +++ b/Capability/GameFrameX.Capability.Online.Social/InMemoryOnlineChatStore.cs @@ -27,6 +27,8 @@ // Official documentation: https://gameframex.doc.alianblank.com/ // ========================================================================================== +using GameFrameX.Capability.Online.Abstractions; + namespace GameFrameX.Capability.Online.Social; /// diff --git a/Capability/GameFrameX.Capability.Online.Social/OnlineChatAuditProjection.cs b/Capability/GameFrameX.Capability.Online.Social/OnlineChatAuditProjection.cs index bf8a43c5e..42f82d45e 100644 --- a/Capability/GameFrameX.Capability.Online.Social/OnlineChatAuditProjection.cs +++ b/Capability/GameFrameX.Capability.Online.Social/OnlineChatAuditProjection.cs @@ -25,6 +25,7 @@ // ========================================================================================== using System.Text.Json; +using GameFrameX.Capability.Online.Abstractions; using GameFrameX.Capability.Online.Abstractions.Events; namespace GameFrameX.Capability.Online.Social; diff --git a/Capability/GameFrameX.Capability.Online.Social/OnlineChatEvents.cs b/Capability/GameFrameX.Capability.Online.Social/OnlineChatEvents.cs index d3e4d6473..929def519 100644 --- a/Capability/GameFrameX.Capability.Online.Social/OnlineChatEvents.cs +++ b/Capability/GameFrameX.Capability.Online.Social/OnlineChatEvents.cs @@ -48,16 +48,16 @@ namespace GameFrameX.Capability.Online.Social; public static class OnlineChatEvents { /// 消息发送。 - public const string MessageSent = "Online.Chat.MessageSent"; + public const string MessageSent = "online.chat.messagesent"; /// 消息撤回。 - public const string MessageRecalled = "Online.Chat.MessageRecalled"; + public const string MessageRecalled = "online.chat.messagerecalled"; /// 已读位点更新。 - public const string ReadMarkUpdated = "Online.Chat.ReadMarkUpdated"; + public const string ReadMarkUpdated = "online.chat.readmarkupdated"; /// 内容审核插件执行失败(放行留痕)。 - public const string ModerationFailed = "Online.Chat.ModerationFailed"; + public const string ModerationFailed = "online.chat.moderationfailed"; /// 事件来源标识。 public const string Source = "online-chat"; diff --git a/Capability/GameFrameX.Capability.Online.Social/OnlineChatHistoryPage.cs b/Capability/GameFrameX.Capability.Online.Social/OnlineChatHistoryPage.cs index 537d26332..454633f9e 100644 --- a/Capability/GameFrameX.Capability.Online.Social/OnlineChatHistoryPage.cs +++ b/Capability/GameFrameX.Capability.Online.Social/OnlineChatHistoryPage.cs @@ -30,6 +30,7 @@ namespace GameFrameX.Capability.Online.Social; using GameFrameX.Capability.Online.Abstractions.Contracts; +using GameFrameX.Capability.Online.Abstractions; /// /// 聊天历史分页结果(游标契约)。 diff --git a/Capability/GameFrameX.Capability.Online.Social/OnlineChatModerationAllowAllHook.cs b/Capability/GameFrameX.Capability.Online.Social/OnlineChatModerationAllowAllHook.cs new file mode 100644 index 000000000..7619754ea --- /dev/null +++ b/Capability/GameFrameX.Capability.Online.Social/OnlineChatModerationAllowAllHook.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.Capability.Online.Social; + +using System.Threading; +using GameFrameX.Capability.Online.Abstractions; +using System.Threading.Tasks; + +/// +/// 聊天内容审核钩子的默认放行策略(C294 机制钩子化: 经 +/// DeclareHook<IOnlineChatModerationHook> 声明本默认,热更侧未挂载审核插件时生效)。 +/// +/// 维护约束:默认策略**只放行**——审核是扩展能力不是正确性前提,未挂载即拒绝等于把「有没有接审核服务」 +/// 变成聊天能不能用的开关;真实审核实现由热更侧经 CapabilityHook<IOnlineChatModerationHook>.Attach +/// 原子替换,本实现不感知。放行不产生 事件—— +/// 该事件语义是「审核插件执行失败被放行」的留痕,默认放行不是失败。 +/// +/// +public sealed class OnlineChatModerationAllowAllHook : IOnlineChatModerationHook +{ + /// + /// 审核一条待发送消息(默认放行策略:恒放行,不做任何检查)。 + /// + /// 待发送消息。 + /// 取消令牌。 + /// 恒返回放行裁决。 + public Task CheckAsync(OnlineChatMessage message, CancellationToken cancellationToken = default) + { + return Task.FromResult(OnlineChatModerationVerdict.Allow()); + } +} diff --git a/Capability/GameFrameX.Capability.Online.Social/OnlineChatService.cs b/Capability/GameFrameX.Capability.Online.Social/OnlineChatService.cs index 11dc18cea..7682cfcd8 100644 --- a/Capability/GameFrameX.Capability.Online.Social/OnlineChatService.cs +++ b/Capability/GameFrameX.Capability.Online.Social/OnlineChatService.cs @@ -29,6 +29,8 @@ namespace GameFrameX.Capability.Online.Social; +using System.Collections.Generic; +using GameFrameX.Capability.Online.Abstractions; using System.Threading; using System.Threading.Tasks; using GameFrameX.Capability.Online.Abstractions.Contracts; @@ -75,11 +77,17 @@ public sealed class OnlineChatService /// 发送限流器。 private readonly OnlineChatRateLimiter _rateLimiter; - /// 频道成员资格探针(可空:未装配时 Party / Group 频道一律拒绝,fail closed)。 - private readonly IOnlineChannelMembershipProbe _membershipProbe; + /// + /// 频道成员资格探针集合(Activate 期注入归属域探针;空集合 = 未装配,Party / Group 频道一律拒绝,fail closed)。 + /// + private readonly List _membershipProbes; - /// 内容审核扩展点(可空 = 全部放行)。 - private readonly IOnlineChatModerationHook _moderationHook; + /// + /// 内容审核扩展点提供者(每次审核调用时读取当前生效实现)。 + /// 维护约束:禁止在构造期或字段中缓存钩子实例——热更侧 CapabilityHook.Attach 原子替换后, + /// 缓存的旧实例既让新审核逻辑不生效,又会钉住旧热更程序集导致无法卸载。 + /// + private readonly Func _moderationHookProvider; /// /// 初始化 。 @@ -87,24 +95,65 @@ public sealed class OnlineChatService /// 聊天存储。 /// 事件发布出口。 /// 社交裁决入口。 - /// 可配置项(可空 = 推荐默认值)。 - /// 频道成员资格探针(可空)。 - /// 内容审核扩展点(可空 = 放行)。 + /// 可配置项(必填;默认值唯一来源 = 字段初始值)。 + /// 频道成员资格探针集合(Register 期传空集合,Activate 期经 + /// 注入归属域探针)。 + /// 内容审核扩展点提供者(必填;逐调用读取当前实现,未挂载插件时返回机制钩子的默认放行策略)。 public OnlineChatService( IOnlineChatStore store, IOnlineEventPublisher eventPublisher, OnlineSocialDecisionService decisionService, - OnlineChatOptions options = null, - IOnlineChannelMembershipProbe membershipProbe = null, - IOnlineChatModerationHook moderationHook = null) + OnlineChatOptions options, + IEnumerable membershipProbes, + Func moderationHookProvider) { _store = store ?? throw new ArgumentNullException(nameof(store)); _eventPublisher = eventPublisher ?? throw new ArgumentNullException(nameof(eventPublisher)); _decisionService = decisionService ?? throw new ArgumentNullException(nameof(decisionService)); - _options = options ?? new OnlineChatOptions(); - _rateLimiter = new OnlineChatRateLimiter(_options.RateLimitMaxMessages, _options.RateLimitWindowSeconds); - _membershipProbe = membershipProbe; - _moderationHook = moderationHook; + if (options == null) + { + throw new ArgumentNullException(nameof(options)); + } + + if (membershipProbes == null) + { + throw new ArgumentNullException(nameof(membershipProbes)); + } + + if (moderationHookProvider == null) + { + throw new ArgumentNullException(nameof(moderationHookProvider)); + } + + _options = options; + _rateLimiter = new OnlineChatRateLimiter(options.RateLimitMaxMessages, options.RateLimitWindowSeconds); + _membershipProbes = new List(membershipProbes); + _moderationHookProvider = moderationHookProvider; + } + + /// + /// 追加频道成员资格探针(Activate 期注入:社交模块枚举 IOnlineChannelMembershipProbe 扩展点后调用, + /// 群组探针("group" 命名注册)与兄弟域探针(如 matchmaking 的 "party")一并注入)。 + /// + /// 维护约束:宿主激活序在流量进入之前,注入后不再变更(无并发写);判定语义 = 任一探针 affirm 即成员 + ///(探针按归属域只应答自己域的频道类型,不归自己域的返回 false)。 + /// + /// + /// 探针集合(null 项跳过)。 + public void AttachChannelMembershipProbes(IEnumerable probes) + { + if (probes == null) + { + return; + } + + foreach (var probe in probes) + { + if (probe != null) + { + _membershipProbes.Add(probe); + } + } } /// @@ -638,19 +687,22 @@ private async Task> CheckMembershipAsync(OnlineS return null; } - if (_membershipProbe == null) + if (_membershipProbes.Count == 0) { // 成员资格不可验证时必须拒绝:放行等于把频道内容广播给任意知道频道标识的人。 return OnlineResult.Fail(OnlineErrorCode.StateNotReady, "频道成员资格校验未装配"); } - var isMember = await _membershipProbe.IsChannelMemberAsync(scope.TenantId, scope.AppId, kind, boundId, scope.PlayerId, cancellationToken).ConfigureAwait(false); - if (!isMember) + foreach (var probe in _membershipProbes) { - return OnlineResult.Fail(OnlineErrorCode.ResourceNotFound, "频道不存在"); + var isMember = await probe.IsChannelMemberAsync(scope.TenantId, scope.AppId, kind, boundId, scope.PlayerId, cancellationToken).ConfigureAwait(false); + if (isMember) + { + return null; + } } - return null; + return OnlineResult.Fail(OnlineErrorCode.ResourceNotFound, "频道不存在"); } /// @@ -685,15 +737,11 @@ private async Task EvaluateSendAsync(OnlineScope scope, On /// 放行返回 null,拒绝返回应回传的错误结果。 private async Task> CheckModerationAsync(OnlineChatMessage draft, string correlationId, CancellationToken cancellationToken) { - if (_moderationHook == null) - { - return null; - } OnlineChatModerationVerdict verdict; try { - verdict = await _moderationHook.CheckAsync(draft, cancellationToken).ConfigureAwait(false); + verdict = await _moderationHookProvider().CheckAsync(draft, cancellationToken).ConfigureAwait(false); } catch (Exception exception) { diff --git a/Capability/GameFrameX.Capability.Online.Social/OnlineFriendLegacyMigration.cs b/Capability/GameFrameX.Capability.Online.Social/OnlineFriendLegacyMigration.cs new file mode 100644 index 000000000..f68a7693f --- /dev/null +++ b/Capability/GameFrameX.Capability.Online.Social/OnlineFriendLegacyMigration.cs @@ -0,0 +1,205 @@ +// ========================================================================================== +// 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.Generic; +using System.Threading; +using System.Threading.Tasks; +using GameFrameX.Apps.Player.Friend.Entity; +using GameFrameX.Apps.ServerRole.Friend.Entity; +using GameFrameX.Capability.Online.Abstractions.Contracts; +using GameFrameX.Capability.Online.Abstractions.Scope; + +namespace GameFrameX.Capability.Online.Social; + +/// +/// 旧好友数据一次性迁移(C244 T3 落地,C294 自组合根逐语句迁入社交能力包;开关 = +/// ,模块激活期显式授权触发): +/// 读取本进程 GameDb 的旧 (域 120 旧链,Status=0 行 = 已建立关系) +/// 与旧 660 档案文档(Outgoing = 待答申请),经 +/// 真实写路径( 编排面)幂等收敛为 Online 好友关系。 +/// +/// 维护约束(红线): +/// ① 只读单向复制——旧表 / 旧文档零改动,回退 = 重部署旧版本代码即回旧链旧数据; +/// ② 幂等——重复执行经 SaveIfAbsent / 幂等收敛不产生第二条关系(已建立优先于待答); +/// ③ 零静默丢失——单条失败计数并继续(containment,不中断整体迁移),失败明细以返回汇总披露 +///(能力包零 LogHelper 惯例,逐条 Error 日志不再随迁,失败数与总数对账即披露面); +/// ④ 分进程旧数据在 Social 库时,须在 GameDb 指向该库的进程上执行(AllInOne 天然满足,spec 披露); +/// ⑤ 软删行(Status != 0)不迁移 = 已删除语义。 +/// +/// +public static class OnlineFriendLegacyMigration +{ + /// + /// 执行旧好友数据迁移(社交能力模块在激活期、好友域桥发布后调用)。 + /// + /// 好友域服务(真实写路径的唯一属主)。 + /// 租户标识(宿主 OnlineScope 三元组)。 + /// App 标识。 + /// 区服标识。 + /// 取消令牌。 + /// 迁移汇总(已建立关系数 / 待答申请数 / 失败数)。 + public static async Task<(int Established, int Pending, int Failed)> MigrateAsync(OnlineFriendService friends, long tenantId, long appId, long serverId, CancellationToken cancellationToken = default) + { + var domain = new OnlineFriendDomainFace(friends); + + // ① 域 120 旧链:FriendRelationState Status=0 → 已建立关系(Establish = 申请 + 立即接受,幂等收敛)。 + var (established, relationFailed) = await MigrateEstablishedRelationsAsync(domain, tenantId, appId, serverId, cancellationToken).ConfigureAwait(false); + + // ② 域 660 旧链:FriendState 档案 Outgoing → 待答申请(对已建立的对幂等无效果)。 + var (pending, profileFailed) = await MigratePendingRequestsAsync(domain, tenantId, appId, serverId, cancellationToken).ConfigureAwait(false); + + var failed = relationFailed + profileFailed; + return (established, pending, failed); + } + + /// + /// 迁移域 120 旧链:读取 FriendRelationState Status=0 行,逐条经 Establish 收敛为已建立关系 + /// (Establish = 申请 + 立即接受,幂等收敛);单条失败计数并继续(containment)。 + /// + /// Online 好友域契约面。 + /// 租户标识。 + /// App 标识。 + /// 区服标识。 + /// 取消令牌。 + /// (已建立关系数,失败数)。 + private static async Task<(int Established, int Failed)> MigrateEstablishedRelationsAsync(OnlineFriendDomainFace domain, long tenantId, long appId, long serverId, CancellationToken cancellationToken) + { + var established = 0; + var failed = 0; + var relations = await GameFrameX.DataBase.GameDb.FindListAsync(state => state.Status == 0).ConfigureAwait(false); + foreach (var relation in relations) + { + var low = relation.PlayerIdA; + var high = relation.PlayerIdB; + if (low <= 0 || high <= 0 || low == high) + { + continue; + } + + var result = await domain.EstablishAsync(PlayerScope(tenantId, appId, serverId, low), high, cancellationToken).ConfigureAwait(false); + if (result.IsSuccess && result.Data.After == OnlineFriendRelationKind.Friend) + { + established++; + } + else + { + failed++; + } + } + + return (established, failed); + } + + /// + /// 迁移域 660 旧链:读取 FriendState 档案文档,逐档案的 Outgoing 待答申请经真实写路径收敛 + /// (对已建立的对幂等无效果)。 + /// + /// Online 好友域契约面。 + /// 租户标识。 + /// App 标识。 + /// 区服标识。 + /// 取消令牌。 + /// (待答申请数,失败数)。 + private static async Task<(int Pending, int Failed)> MigratePendingRequestsAsync(OnlineFriendDomainFace domain, long tenantId, long appId, long serverId, CancellationToken cancellationToken) + { + var pending = 0; + var failed = 0; + var profiles = await GameFrameX.DataBase.GameDb.FindListAsync(state => state.Id >= 0).ConfigureAwait(false); + foreach (var document in profiles) + { + if (document?.Profiles == null) + { + continue; + } + + foreach (var profile in document.Profiles.Values) + { + if (profile?.Outgoing == null) + { + continue; + } + + var (entryPending, entryFailed) = await MigrateOutgoingRequestsAsync(domain, tenantId, appId, serverId, profile, cancellationToken).ConfigureAwait(false); + pending += entryPending; + failed += entryFailed; + } + } + + return (pending, failed); + } + + /// + /// 迁移单个旧档案的 Outgoing 待答申请:逐目标玩家校验后经 Request 真实写路径请求, + /// 单条失败计数并继续(containment)。 + /// + /// Online 好友域契约面。 + /// 租户标识。 + /// App 标识。 + /// 区服标识。 + /// 旧 660 好友档案。 + /// 取消令牌。 + /// (待答申请数,失败数)。 + private static async Task<(int Pending, int Failed)> MigrateOutgoingRequestsAsync(OnlineFriendDomainFace domain, long tenantId, long appId, long serverId, FriendProfileState profile, CancellationToken cancellationToken) + { + var pending = 0; + var failed = 0; + foreach (var targetPlayerId in profile.Outgoing) + { + if (profile.PlayerId <= 0 || targetPlayerId <= 0 || targetPlayerId == profile.PlayerId) + { + continue; + } + + var result = await domain.RequestAsync(PlayerScope(tenantId, appId, serverId, profile.PlayerId), targetPlayerId, cancellationToken).ConfigureAwait(false); + if (result.IsSuccess) + { + pending++; + } + else + { + failed++; + } + } + + return (pending, failed); + } + + /// + /// 构造玩家作用域(三元组取宿主 OnlineScope 值,玩家位为迁移主体)。 + /// + /// 租户标识。 + /// App 标识。 + /// 区服标识。 + /// 玩家标识。 + /// 生效作用域。 + private static OnlineScope PlayerScope(long tenantId, long appId, long serverId, long playerId) + { + return new OnlineScope(tenantId, appId, serverId, playerId); + } +} diff --git a/Capability/GameFrameX.Capability.Online.Social/OnlineFriendOptions.cs b/Capability/GameFrameX.Capability.Online.Social/OnlineFriendOptions.cs new file mode 100644 index 000000000..207b542a7 --- /dev/null +++ b/Capability/GameFrameX.Capability.Online.Social/OnlineFriendOptions.cs @@ -0,0 +1,65 @@ +// ========================================================================================== +// 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.Capability.Online.Social; + +/// +/// 好友域可配置项(C294 自 构造参数默认值收编:默认值唯一来源 = 本类型字段初始值)。 +/// +/// 维护约束:服务构造收本类型必填参数,不再携带参数默认值或非正数回退——配置错误由 +/// 在装配期 fail-fast 报出,而不是运行期静默回退推荐值。 +/// +/// +public sealed class OnlineFriendOptions +{ + /// + /// 获取或设置待答复请求存活时长(秒;默认 604800 即 7 天,超期由清扫滴答 job 收敛为过期态)。 + /// + public long RequestTimeToLiveSeconds { get; set; } = 604800; + + /// + /// 获取或设置搜索返回条数上限(默认 20)。 + /// + public int SearchLimit { get; set; } = 20; + + /// + /// 获取或设置好友列表返回条数上限(默认 200;同时也是好友上限裁决依据)。 + /// + public int FriendLimit { get; set; } = 200; + + /// + /// 获取或设置是否在模块激活期执行旧好友数据一次性迁移(默认 false;C294 收编原族级 + /// Online.MigrateLegacyFriendData 开关,配置路径 Online.Settings.online-social.Friend.MigrateLegacyFriendData)。 + /// + /// 维护约束:迁移只读单向复制旧表(域 120 FriendRelationState / 域 660 FriendState),旧数据零改动; + /// 幂等可重复执行;详见 。 + /// + /// + public bool MigrateLegacyFriendData { get; set; } +} diff --git a/Capability/GameFrameX.Capability.Online.Social/OnlineFriendService.cs b/Capability/GameFrameX.Capability.Online.Social/OnlineFriendService.cs index 4d9fcbd24..c2a609970 100644 --- a/Capability/GameFrameX.Capability.Online.Social/OnlineFriendService.cs +++ b/Capability/GameFrameX.Capability.Online.Social/OnlineFriendService.cs @@ -79,27 +79,28 @@ public sealed class OnlineFriendService /// /// 关系存储。 /// 事件发布出口。 - /// 玩家目录(可空)。 - /// 在线事实探针(可空)。 - /// 待答复请求存活时长(秒;默认 604800 即 7 天)。 - /// 搜索返回条数上限(默认 20)。 - /// 好友列表返回条数上限(默认 200)。 + /// 好友域可配置项(必填;默认值唯一来源 = 字段初始值)。 + /// 玩家目录(可空:未装配时搜索返回空、展示名回退为标识字面量)。 + /// 在线事实探针(可空:未装配时在线状态恒为「未知」)。 public OnlineFriendService( IOnlineFriendStore store, IOnlineEventPublisher eventPublisher, + OnlineFriendOptions options, IOnlinePlayerDirectory directory = null, - IOnlineSocialPresenceProbe presenceProbe = null, - long requestTimeToLiveSeconds = 604800, - int searchLimit = 20, - int friendLimit = 200) + IOnlineSocialPresenceProbe presenceProbe = null) { _store = store ?? throw new ArgumentNullException(nameof(store)); _eventPublisher = eventPublisher ?? throw new ArgumentNullException(nameof(eventPublisher)); + if (options == null) + { + throw new ArgumentNullException(nameof(options)); + } + _directory = directory; _presenceProbe = presenceProbe; - _requestTimeToLiveSeconds = requestTimeToLiveSeconds > 0 ? requestTimeToLiveSeconds : 604800; - _searchLimit = searchLimit > 0 ? searchLimit : 20; - _friendLimit = friendLimit > 0 ? friendLimit : 200; + _requestTimeToLiveSeconds = options.RequestTimeToLiveSeconds; + _searchLimit = options.SearchLimit; + _friendLimit = options.FriendLimit; } /// diff --git a/Capability/GameFrameX.Capability.Online.Social/OnlineNotificationEvents.cs b/Capability/GameFrameX.Capability.Online.Social/OnlineNotificationEvents.cs index db0592faa..0a13455fc 100644 --- a/Capability/GameFrameX.Capability.Online.Social/OnlineNotificationEvents.cs +++ b/Capability/GameFrameX.Capability.Online.Social/OnlineNotificationEvents.cs @@ -44,7 +44,7 @@ namespace GameFrameX.Capability.Online.Social; public static class OnlineNotificationEvents { /// 通知状态变更。 - public const string NotificationChanged = "Online.Notification.Changed"; + public const string NotificationChanged = "online.notification.changed"; /// 事件来源标识。 public const string Source = "online-notification"; diff --git a/Capability/GameFrameX.Capability.Online.Social/OnlineSocialCapabilityModule.cs b/Capability/GameFrameX.Capability.Online.Social/OnlineSocialCapabilityModule.cs index 5f8f80208..fe6311fe3 100644 --- a/Capability/GameFrameX.Capability.Online.Social/OnlineSocialCapabilityModule.cs +++ b/Capability/GameFrameX.Capability.Online.Social/OnlineSocialCapabilityModule.cs @@ -4,15 +4,15 @@ // 均受中华人民共和国及相关国际法律法规保护。 // are protected by the laws of the People's Republic of China and other international regulations. // 使用本项目须严格遵守相关法律法规与开源许可证之规定。 -// Usage of this project must strictly comply with relevant laws, regulations, and open-source licenses. +// 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 文件。 +// 完整许可证文本请参见源代码根目录下的 LICENSE 文件。 // please refer to the full license text in the LICENSE file located at the root of the source code. // 禁止利用本项目实施任何危害国家安全、破坏社会秩序、 // It is prohibited to use this project to engage in any activity that endangers national security, disrupts social order, // 侵犯公民合法权益等法律法规所禁止的行为! -// or infringe upon the legal rights and interests of citizens as prohibited by laws and regulations! +// or infringe upon the legal rights and interests of others as prohibited by laws and regulations! // 因基于本项目二次开发所产生的一切法律纠纷与责任, // Any legal disputes or liabilities arising from secondary development, // 本组织不承担任何责任,开发者应自行承担。 @@ -24,13 +24,17 @@ // CNB 仓库:https://cnb.cool/GameFrameX // CNB Repository: https://cnb.cool/GameFrameX // 官方文档:https://gameframex.doc.alianblank.com/ -// Official documentation: https://gameframex.doc.alianblank.com/ +// Official Documentation: https://gameframex.doc.alianblank.com/ // ========================================================================================== +using System.Collections.Generic; +using GameFrameX.Capability.Online.Abstractions; +using System.Threading; +using System.Threading.Tasks; using GameFrameX.Capability.Hosting.Abstractions; using GameFrameX.Capability.Hosting.AdminApi; -using GameFrameX.Capability.Online.Abstractions.Contracts; using GameFrameX.Capability.Online.Abstractions.Events; +using GameFrameX.Capability.Online.Abstractions.Contracts; using GameFrameX.Capability.Online.Abstractions.Scope; using GameFrameX.Capability.Online.Foundation.Session; using GameFrameX.Capability.Online.Social.AdminApi; @@ -38,52 +42,53 @@ namespace GameFrameX.Capability.Online.Social; /// -/// 社交能力(好友 / 群组 / 聊天 / 通知 / 处罚 / 举报裁决 + 聊天审计投影)的自装配模块(C231 P4 前为 P3 T4 迁入)。 +/// 社交能力(好友 / 群组 / 聊天 / 通知 / 处罚 / 举报裁决 + 聊天审计投影)的自装配模块(C294 机制化改写)。 /// -/// 维护约束(服务共享协议):模块无参构造(扫描可发现);Register 自装配本域——六个 store 经 -/// 探测替换缝,未命中构造 InMemory 默认; -/// 跨域硬依赖(事件出口 / Foundation 会话面)经上下文解析;对外产物(store 接口 + 好友 / 裁决 / 处罚 / 通知服务 + -/// 裁决面 + 好友域桥契约面(C244 域桥缝, -/// 薄编排实现)+ 聊天审计投影)注册回共享面;两个过期清扫滴答 job -///(通知 + 好友请求)经组合根授权作用域锚定租户 / App。 +/// 维护约束(三段生命周期):模块无参构造(扫描可发现);Register 自装配本域——六个 store 经 +/// 懒默认注册 + GetService 解析(族 / 宿主 Override 恒胜出,模块不感知); +/// 玩家目录经 RequireHostService 声明宿主扩展点(缺失即装配期 +/// fail-fast); 在线探针经 GetService 解析(必选 +/// online-foundation 提供);跨域硬依赖(事件出口 / Foundation 会话面)经上下文解析;对外产物(store 接口 + +/// 好友 / 裁决 / 处罚 / 通知 / 聊天 / 群组服务 + 裁决面 + 聊天审计投影)注册回共享面; +/// 两个过期清扫滴答 job(通知 + 好友请求)经族基建 锚定租户 / App。 /// /// -/// 好友服务装配披露(C242): 玩家目录与 -/// 在线探针当前全仓无实现,经共享面通用缝探测、缺席为披露态—— -/// 搜索降级为空结果(空 ≠ 无匹配)、展示名回退标识字面量、在线状态恒 false,均不构成可用功能; -/// 好友存储默认 InMemory(进程重启即丢);GameDb 持久化经 UseOnlineGameDbStores 预置替换(C244 起随一级 store 族)。 +/// ActivateAsync:发布 好友域桥契约面( +/// 薄编排实现);枚举 扩展点注入聊天服务(群组探针以 "group" +/// 命名注册,兄弟域如 matchmaking 的 "party" 探针同面注入);按 +/// 触发旧好友数据一次性迁移()。 +/// +/// +/// 钩子声明: 聊天内容审核扩展点以默认放行策略声明 +///(),热更侧经 CapabilityHook<T>.Attach 原子替换。 /// /// /// /// The self-assembling module of the social capability (friends / groups / chat / notifications / punishments / -/// report adjudication plus the chat-audit projection). The module has a parameterless constructor (discoverable -/// by scanning); Register self-assembles the domain — the six stores probe the replacement seams via -/// with in-memory fallbacks; cross-domain -/// hard dependencies (the event outlet / the Foundation session face) resolve through the context; outward -/// products (store interfaces, the friend / adjudication / punishment / notification services, the -/// face, the friend bridge face and -/// the chat-audit projection) are registered back into the shared face; -/// the two expiry-sweep tick jobs (notifications and friend requests) anchor their tenant / app on the -/// composition root's authorized scope. -/// -/// Friend assembly disclosure (C242): no implementation of or -/// exists in the repository today; both are probed through the shared-face -/// generic seam and their absence is a disclosed state — search degrades to empty results (empty is not -/// "no match"), display names fall back to id literals and presence reads as offline; none of these constitute -/// working features. The friend store defaults to in-memory (lost on restart); the GameDb persistence -/// adapter is preset through UseOnlineGameDbStores (joining the level-one store family since C244). -/// +/// report adjudication plus the chat-audit projection), rewritten onto the three-phase capability mechanism (C294). +/// Register self-assembles the domain — the six stores go through lazy-default registration plus +/// GetService resolution (family / host overrides always win); the player directory is declared as a host +/// extension point via RequireHostService; the presence probe resolves through the required online-foundation +/// dependency; outward products are registered back into the shared face; two expiry-sweep tick jobs anchor their +/// tenant / app on the family infrastructure. ActivateAsync publishes the +/// bridge face, injects channel-membership probes into the chat service by +/// enumerating the extension point, and triggers the one-shot legacy friend-data migration when the option is on. +/// The chat moderation extension point is declared with the default allow-all strategy, replaced atomically by the +/// hot-update side through CapabilityHook<T>.Attach. /// public sealed class OnlineSocialCapabilityModule : ICapabilityModule, IAdminActionContributor { /// - /// 社交能力清单(Id 冻结 = 终态包名;action 集合与幂等包裹位逐字迁移自壳过渡模块,含 match_isolation)。 + /// 社交能力清单(Id 冻结 = 终态包名;必选 online-foundation;Options 根类型 ; + /// action 集合与幂等包裹位逐字迁移自壳过渡模块,含 match_isolation)。 /// public static readonly CapabilityManifest ManifestInstance = new( "online-social", "1.0.0", new[] { "kick_player", "revoke_token", "mute_player", "query_report_cases", "update_report_case", "send_admin_notification", "query_chat_messages", "match_isolation" }, - new[] { "online-foundation" }); + new[] { "online-foundation" }, + null, + typeof(OnlineSocialOptions)); /// /// 社交裁决(禁言 / 举报 / 拉黑)。 @@ -96,7 +101,7 @@ public sealed class OnlineSocialCapabilityModule : ICapabilityModule, IAdminActi private OnlinePunishmentService _punishments; /// - /// 举报存储(Register 期解析或构造;handler 查询面用)。 + /// 举报存储(Register 期解析;handler 查询面用)。 /// private IOnlineReportStore _reportStore; @@ -111,10 +116,15 @@ public sealed class OnlineSocialCapabilityModule : ICapabilityModule, IAdminActi private OnlineChatAuditProjection _chatAudit; /// - /// 好友服务(好友关系生命周期的唯一写者;C242 首个消费链装配)。 + /// 好友服务(好友关系生命周期的唯一写者;激活期执行旧数据迁移的真实写路径)。 /// private OnlineFriendService _friends; + /// + /// 聊天服务(四类频道 + 审核钩子 + 成员资格探针集合,激活期注入探针)。 + /// + private OnlineChatService _chat; + /// /// 会话存储(Foundation 面;kick / revoke 逐会话执行)。 /// @@ -125,6 +135,16 @@ public sealed class OnlineSocialCapabilityModule : ICapabilityModule, IAdminActi /// private OnlineSessionTokenService _tokens; + /// + /// 合并校验后的社交 Options(Register 期取用;激活期迁移开关读取面)。 + /// + private OnlineSocialOptions _options; + + /// + /// 族基建作用域(Register 期解析;迁移玩家作用域三元组来源)。 + /// + private OnlineScope _scope; + /// public CapabilityManifest Manifest { @@ -134,36 +154,57 @@ public CapabilityManifest Manifest /// public void Register(CapabilityRegistrationContext context) { + _options = context.GetOptions(); var eventPublisher = context.GetService(); + _scope = context.GetService(); _sessionStore = context.GetService(); _tokens = context.GetService(); - var chatStore = ResolveStore(context, static () => new InMemoryOnlineChatStore()); - var friendStore = ResolveStore(context, static () => new InMemoryOnlineFriendStore()); - var groupStore = ResolveStore(context, static () => new InMemoryOnlineGroupStore()); - var notificationStore = ResolveStore(context, static () => new InMemoryOnlineNotificationStore()); - _reportStore = ResolveStore(context, static () => new InMemoryOnlineReportStore()); - var socialGraphStore = ResolveStore(context, static () => new InMemoryOnlineSocialGraphStore()); - - // 好友服务(C242 首个消费链装配):玩家目录与在线探针当前全仓无实现,经共享面通用缝 - //(CapabilityHostBuilder.UseService<T>)探测,缺席为披露态——搜索降级为空结果、 - // 展示名回退标识字面量、在线状态恒 false,均不构成可用功能(注入实现即生效)。 - context.TryGetService(out IOnlinePlayerDirectory directory); - context.TryGetService(out IOnlineSocialPresenceProbe presenceProbe); - _friends = new OnlineFriendService(friendStore, eventPublisher, directory, presenceProbe); + + // 六 store 懒默认注册 + 解析:族 / 宿主 Override 恒胜出,本模块不写探测-回退分支。 + context.AddService(static _ => new InMemoryOnlineChatStore()); + context.AddService(static _ => new InMemoryOnlineFriendStore()); + context.AddService(static _ => new InMemoryOnlineGroupStore()); + context.AddService(static _ => new InMemoryOnlineNotificationStore()); + context.AddService(static _ => new InMemoryOnlineReportStore()); + context.AddService(static _ => new InMemoryOnlineSocialGraphStore()); + var chatStore = context.GetService(); + var friendStore = context.GetService(); + var groupStore = context.GetService(); + var notificationStore = context.GetService(); + _reportStore = context.GetService(); + var socialGraphStore = context.GetService(); + + // 好友服务:玩家目录是宿主扩展点(缺失即装配期 fail-fast);在线探针由必选 online-foundation 提供。 + context.RequireHostService(); + var directory = context.GetService(); + var presenceProbe = context.GetService(); + _friends = new OnlineFriendService(friendStore, eventPublisher, _options.Friend, directory, presenceProbe); _socialDecisions = new OnlineSocialDecisionService(socialGraphStore, _reportStore, eventPublisher); _punishments = new OnlinePunishmentService(socialGraphStore, eventPublisher); _notifications = new OnlineNotificationService(notificationStore, eventPublisher); _chatAudit = new OnlineChatAuditProjection(chatStore); + + // 聊天服务:审核钩子以默认放行策略声明(热更侧可原子替换);服务持有句柄并逐调用读取 Current, + // 不缓存实现实例(缓存会让热更挂载失效并钉住旧热更程序集)。成员资格探针 Register 期传空集合, + // Activate 期枚举扩展点注入(含本包 "group" 命名注册的群组探针与兄弟域探针)。 + var moderationHook = context.DeclareHook(new OnlineChatModerationAllowAllHook()); + _chat = new OnlineChatService(chatStore, eventPublisher, _socialDecisions, _options.Chat, new List(), () => moderationHook.Current); + + // 群组服务(首次纳入装配面):本身实现 IOnlineChannelMembershipProbe,以 "group" 命名注册进探针扩展点。 + var groups = new OnlineGroupService(groupStore, eventPublisher); + context.AddService(groups); + context.AddService("group", groups); + context.AddService(_friends); context.AddService(_socialDecisions); context.AddService(_punishments); context.AddService(_notifications); context.AddService(_chatAudit); - context.AddService(new OnlineFriendDomainFace(_friends)); + context.AddService(_chat); context.AddService(_socialDecisions); - var scope = context.GetService(); + var scope = _scope; context.AddTickJob("notification-expiry-sweep", async ct => { await _notifications.SweepExpiredAsync(scope.TenantId, scope.AppId, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), ct).ConfigureAwait(false); @@ -175,30 +216,32 @@ public void Register(CapabilityRegistrationContext context) } /// - public void RegisterAdminActions(AdminActionRegistry registry) + public async Task ActivateAsync(CapabilityActivationContext context, CancellationToken cancellationToken) { - var handlers = new OnlineAdminSocialHandlers(_sessionStore, _tokens, _punishments, _socialDecisions, _notifications, _reportStore, _chatAudit); - handlers.Register(registry); - handlers.RegisterMatchIsolation(registry); - } + // 好友域桥契约面发布(发布者身份 = 本能力;撤销由宿主停止期自动完成)。 + context.Bridges.Publish(new OnlineFriendDomainFace(_friends)); - /// - /// 解析存储(替换缝探测——命中用组合根注入实例,未命中构造 InMemory 默认并注册回共享面)。 - /// - /// 存储接口。 - /// 注册上下文。 - /// 默认构造。 - /// 存储实例。 - private static TStore ResolveStore(CapabilityRegistrationContext context, Func defaultFactory) - where TStore : class - { - if (context.TryGetService(out TStore store)) + // 频道成员资格探针注入(Activate 期才允许枚举扩展点;群组 "group" + 兄弟域 "party" 等一并注入)。 + _chat.AttachChannelMembershipProbes(context.Services.GetServices()); + + // 旧好友数据一次性迁移(显式开关,默认关闭;只读单向 + 幂等 + 逐条 containment + 汇总)。 + if (_options.Friend.MigrateLegacyFriendData) { - return store; + await OnlineFriendLegacyMigration.MigrateAsync(_friends, _scope.TenantId, _scope.AppId, _scope.ServerId, cancellationToken).ConfigureAwait(false); } + } - store = defaultFactory(); - context.AddService(store); - return store; + /// + public Task DeactivateAsync(CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + /// + public void RegisterAdminActions(AdminActionRegistry registry) + { + var handlers = new OnlineAdminSocialHandlers(_sessionStore, _tokens, _punishments, _socialDecisions, _notifications, _reportStore, _chatAudit); + handlers.Register(registry); + handlers.RegisterMatchIsolation(registry); } } diff --git a/Capability/GameFrameX.Capability.Online.Social/OnlineSocialEvents.cs b/Capability/GameFrameX.Capability.Online.Social/OnlineSocialEvents.cs index df93fcc35..73d225053 100644 --- a/Capability/GameFrameX.Capability.Online.Social/OnlineSocialEvents.cs +++ b/Capability/GameFrameX.Capability.Online.Social/OnlineSocialEvents.cs @@ -48,22 +48,22 @@ namespace GameFrameX.Capability.Online.Social; public static class OnlineSocialEvents { /// 好友关系变更。 - public const string FriendshipChanged = "Online.Social.FriendshipChanged"; + public const string FriendshipChanged = "online.social.friendshipchanged"; /// 屏蔽变更。 - public const string BlockChanged = "Online.Social.BlockChanged"; + public const string BlockChanged = "online.social.blockchanged"; /// 静音变更。 - public const string MuteChanged = "Online.Social.MuteChanged"; + public const string MuteChanged = "online.social.mutechanged"; /// 举报案件变更。 - public const string ReportChanged = "Online.Social.ReportChanged"; + public const string ReportChanged = "online.social.reportchanged"; /// 处罚变更。 - public const string PunishmentChanged = "Online.Social.PunishmentChanged"; + public const string PunishmentChanged = "online.social.punishmentchanged"; /// 群组变更。 - public const string GroupChanged = "Online.Social.GroupChanged"; + public const string GroupChanged = "online.social.groupchanged"; /// 事件来源标识。 public const string Source = "online-social"; diff --git a/Capability/GameFrameX.Capability.Online.Social/OnlineSocialHostingExtensions.cs b/Capability/GameFrameX.Capability.Online.Social/OnlineSocialHostingExtensions.cs deleted file mode 100644 index b8cd5130f..000000000 --- a/Capability/GameFrameX.Capability.Online.Social/OnlineSocialHostingExtensions.cs +++ /dev/null @@ -1,144 +0,0 @@ -// ========================================================================================== -// GameFrameX 组织及其衍生项目的版权、商标、专利及其他相关权利 -// GameFrameX organization and its derivative projects' copyrights, trademarks, patents and other rights -// 均受中华人民共和国及相关国际法律法规保护。 -// are protected by the laws of the People's Republic of China and other international regulations. -// 使用本项目须严格遵守相关法律法规与开源许可证之规定。 -// Usage of this project must strictly comply with relevant laws, regulations, and open-source licenses. -// 本项目采用 Apache License 2.0 单协议分发, -// This project is licensed solely under the Apache License 2.0, -// 完整许可证文本请参见源代码根目录的 LICENSE 文件。 -// please refer to the full license text in the LICENSE file located at the root of the source code. -// 禁止利用本项目实施任何危害国家安全、破坏社会秩序、 -// It is prohibited to use this project to engage in any activity that endangers national security, disrupts social order, -// 侵犯公民合法权益等法律法规所禁止的行为! -// or infringe upon the legal rights and interests of citizens as prohibited by laws and regulations! -// 因基于本项目二次开发所产生的一切法律纠纷与责任, -// Any legal disputes or liabilities arising from secondary development, -// 本组织不承担任何责任,开发者应自行承担。 -// 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.Capability.Hosting.Core; - -namespace GameFrameX.Capability.Online.Social; - -/// -/// 社交能力包的 hosting 扩展(C231 P3): 装配入口(回调式,注册自装配模块) -/// 与六个存储替换缝( 等 UseOnlineSocial*Store 缝族)。 -/// -/// 维护约束:options 经共享服务面预置(模块无参构造,显式链式与 P4 真扫描共用同一模块类); -/// 替换缝在模块 Register 期探测命中,未命中构造 InMemory 默认。 -/// -/// -/// -/// Hosting extensions of the social capability package (C231 P3): the -/// assembly entry (callback style, registering the self-assembling module) plus six store replacement seams -/// (the UseOnlineSocial*Store family). Options are pre-seeded through the shared service face; the -/// replacement seams are probed at module Register time with in-memory fallbacks. -/// -public static class OnlineSocialHostingExtensions -{ - /// - /// 装配社交能力运行时(注册自装配模块;options => ... 回调承载包级编排参数)。 - /// - /// 能力装配器。 - /// 包级选项回调(可空 = 全默认)。 - /// 本装配器(链式)。 - public static CapabilityHostBuilder AddOnlineSocialRuntime(this CapabilityHostBuilder builder, Action configure = null) - { - ArgumentNullException.ThrowIfNull(builder); - - var options = new OnlineSocialOptions(); - configure?.Invoke(options); - builder.UseService(options); - return builder.AddModule(new OnlineSocialCapabilityModule()); - } - - /// - /// 替换聊天存储(频道消息正文与参与者)(默认 InMemory,生产持久化实现经本缝注入)。 - /// - /// 能力装配器。 - /// 存储实现。 - /// 本装配器(链式)。 - public static CapabilityHostBuilder UseOnlineChatStore(this CapabilityHostBuilder builder, IOnlineChatStore store) - { - ArgumentNullException.ThrowIfNull(builder); - - builder.UseService(store); - return builder; - } - /// - /// 替换好友存储(默认 InMemory,生产持久化实现经本缝注入)。 - /// - /// 能力装配器。 - /// 存储实现。 - /// 本装配器(链式)。 - public static CapabilityHostBuilder UseOnlineFriendStore(this CapabilityHostBuilder builder, IOnlineFriendStore store) - { - ArgumentNullException.ThrowIfNull(builder); - - builder.UseService(store); - return builder; - } - /// - /// 替换群组存储(默认 InMemory,生产持久化实现经本缝注入)。 - /// - /// 能力装配器。 - /// 存储实现。 - /// 本装配器(链式)。 - public static CapabilityHostBuilder UseOnlineGroupStore(this CapabilityHostBuilder builder, IOnlineGroupStore store) - { - ArgumentNullException.ThrowIfNull(builder); - - builder.UseService(store); - return builder; - } - /// - /// 替换通知存储(默认 InMemory,生产持久化实现经本缝注入)。 - /// - /// 能力装配器。 - /// 存储实现。 - /// 本装配器(链式)。 - public static CapabilityHostBuilder UseOnlineNotificationStore(this CapabilityHostBuilder builder, IOnlineNotificationStore store) - { - ArgumentNullException.ThrowIfNull(builder); - - builder.UseService(store); - return builder; - } - /// - /// 替换举报存储(默认 InMemory,生产持久化实现经本缝注入)。 - /// - /// 能力装配器。 - /// 存储实现。 - /// 本装配器(链式)。 - public static CapabilityHostBuilder UseOnlineReportStore(this CapabilityHostBuilder builder, IOnlineReportStore store) - { - ArgumentNullException.ThrowIfNull(builder); - - builder.UseService(store); - return builder; - } - /// - /// 替换社交关系存储(处罚读取面)(默认 InMemory,生产持久化实现经本缝注入)。 - /// - /// 能力装配器。 - /// 存储实现。 - /// 本装配器(链式)。 - public static CapabilityHostBuilder UseOnlineSocialGraphStore(this CapabilityHostBuilder builder, IOnlineSocialGraphStore store) - { - ArgumentNullException.ThrowIfNull(builder); - - builder.UseService(store); - return builder; - } -} diff --git a/Capability/GameFrameX.Capability.Online.Social/OnlineSocialOptions.cs b/Capability/GameFrameX.Capability.Online.Social/OnlineSocialOptions.cs index 464bf3215..4b7884d6d 100644 --- a/Capability/GameFrameX.Capability.Online.Social/OnlineSocialOptions.cs +++ b/Capability/GameFrameX.Capability.Online.Social/OnlineSocialOptions.cs @@ -1,21 +1,21 @@ -// ========================================================================================== +// ========================================================================================== // GameFrameX 组织及其衍生项目的版权、商标、专利及其他相关权利 -// GameFrameX organization and its derivative projects' copyrights, trademarks, patents and other rights +// 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 other international regulations. -// 使用本项目须严格遵守相关法律法规与开源许可证之规定。 -// Usage of this project must strictly comply with relevant laws, regulations, and open-source licenses. +// 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 refer to the full license text in the LICENSE file located at the root of the source code. +// 完整许可证文本请参见源代码根目录下的 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 activity that endangers national security, disrupts social order, -// 侵犯公民合法权益等法律法规所禁止的行为! -// or infringe upon the legal rights and interests of citizens as prohibited by laws and regulations! +// 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, -// 本组织不承担任何责任,开发者应自行承担。 +// 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 @@ -29,22 +29,61 @@ namespace GameFrameX.Capability.Online.Social; +using GameFrameX.Capability.Hosting.Abstractions; + /// -/// 社交能力包级装配选项( 回调承载面,C231)。 +/// 社交能力包级 Options 根类型(C294 机制化:manifest 声明,合并序 = 包内默认 → 配置段 +/// Online.Social → 代码 Configure → ,此后无写入入口)。 /// -/// 维护约束(能力族全局约定,C227/C229 裁定):编排参数一律经 Action<TOptions> 回调承载(.NET -/// AddXxx(options => ...) 惯例);P3 期无包级编排参数(存储替换走 UseOnlineSocial*Store 缝族, -/// 配置段绑定归组合根),成员随真实需求落位;禁止回退平铺配置键。 +/// 维护约束:子域 Options 聚合为属性( / ),默认值唯一来源 = +/// 各字段初始值;服务构造收 Options 必填参数,不再自带参数默认值或 ?? new 兜底—— +/// 配置错误由 在装配期一次报全。 /// /// -/// -/// Package-level assembly options for the social capability (the callback face of -/// , C231). Following the capability-family -/// global convention (ruled by C227/C229), orchestration parameters ride an Action<TOptions> callback; -/// P3 carries no package-level knobs (store replacement goes through the UseOnlineSocial*Store seam family -/// and config-section binding belongs to composition roots); members land as real needs appear; flat -/// configuration keys stay forbidden. -/// -public sealed class OnlineSocialOptions +public sealed class OnlineSocialOptions : ICapabilityOptions { + /// + /// 获取或设置聊天域可配置项(长度 / 频率 / 每页条数 / 撤回窗口)。 + /// + public OnlineChatOptions Chat { get; set; } = new OnlineChatOptions(); + + /// + /// 获取或设置好友域可配置项(请求存活时长 / 搜索与好友上限 / 旧数据迁移开关)。 + /// + public OnlineFriendOptions Friend { get; set; } = new OnlineFriendOptions(); + + /// + /// 校验合并后的最终值(聊天域各阈值与好友域各上限须为正数;历史保留时长允许 0 = 不设上限)。 + /// + /// 校验问题收集器。 + public void Validate(CapabilityOptionsValidation validation) + { + if (Chat != null) + { + validation.RequirePositive("Chat.MaxContentLength", Chat.MaxContentLength); + validation.RequirePositive("Chat.MaxMessagesPerPage", Chat.MaxMessagesPerPage); + validation.RequirePositive("Chat.RecallWindowSeconds", Chat.RecallWindowSeconds); + validation.RequirePositive("Chat.RateLimitWindowSeconds", Chat.RateLimitWindowSeconds); + validation.RequirePositive("Chat.RateLimitMaxMessages", Chat.RateLimitMaxMessages); + if (Chat.HistoryRetentionSeconds < 0) + { + validation.AddError("Chat.HistoryRetentionSeconds", "must not be negative (actual: " + Chat.HistoryRetentionSeconds + ")"); + } + } + else + { + validation.AddError("Chat", "must not be null"); + } + + if (Friend != null) + { + validation.RequirePositive("Friend.RequestTimeToLiveSeconds", Friend.RequestTimeToLiveSeconds); + validation.RequirePositive("Friend.SearchLimit", Friend.SearchLimit); + validation.RequirePositive("Friend.FriendLimit", Friend.FriendLimit); + } + else + { + validation.AddError("Friend", "must not be null"); + } + } } From ed0bb68d1f53b7bd78fed7ca5241cad24b65b809 Mon Sep 17 00:00:00 2001 From: Blank Date: Sat, 24 Oct 2026 03:33:38 +0800 Subject: [PATCH 3/6] =?UTF-8?q?feat(server):=20=E5=AE=BF=E4=B8=BB=E4=BE=A7?= =?UTF-8?q?=E6=8E=A5=E5=85=A5=E8=83=BD=E5=8A=9B=E6=97=8F=E7=BB=84=E5=90=88?= =?UTF-8?q?=E6=A8=A1=E6=9D=BF=E6=AD=A5=E9=AA=A4=E4=B8=8E=20Online=20?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AppStartUpGameServerBase 模板序列新增 ComposeCapabilitiesAsync(热更装载后、网络 监听前,同族进程内单例,失败复位可重试,停机归组合者 Role);新增 OnlineCapabilityCompositionRoot(族组合根 + 宿主 Override 注入 + GameDb store 覆盖)与 LogHelperCapabilityAssemblyLog、HostPlayerDirectory,删除旧组合根与四个 桥 Wiring;AppStartUpGame 删除 Online 编排;IsEnableOnline 双闸并入 Online 配置段 (EnableRuntime/EnableAdminApi/EnableRoles/Settings);Hotfix 四桥适配器改 CapabilityBridge.TryAcquire,作用域不可得显式 8002,聊天审核钩子装载期挂载。 Linear: GFX-998 --- Configs/app_config.json | 8 +- GameFrameX.Hotfix/GameFrameX.Hotfix.csproj | 94 ++-- .../Lifecycle/HotfixModuleLifecycleBridge.cs | 23 + .../RockPaperScissorsMatchBridge.cs | 84 ++-- .../Logic/OnlineAuthorizedScopeReader.cs | 92 ++++ .../Logic/Player/Friend/FriendDomainBridge.cs | 106 +++-- .../Server/Chat/GameChatModerationHook.cs | 81 ++++ .../ServerRole/Match/MatchDomainBridge.cs | 84 ++-- .../SessionResumeDomainBridge.cs | 171 ++++++-- .../Online/HostPlayerDirectory.cs | 102 +++++ .../Online/HotfixManagerRollbackExecutor.cs | 2 +- .../Online/LogHelperCapabilityAssemblyLog.cs | 58 +++ .../Online/OnlineAdminApiComposition.cs | 211 --------- .../Online/OnlineCapabilityComposition.cs | 415 ------------------ .../Online/OnlineCapabilityCompositionRoot.cs | 301 +++++++++++++ .../Online/OnlineFriendDomainBridgeWiring.cs | 162 ------- .../Online/OnlineFriendLegacyMigration.cs | 216 --------- .../Online/OnlineMatchBridgeWiring.cs | 172 -------- .../Online/OnlineMatchDomainBridgeWiring.cs | 174 -------- .../Online/OnlineSessionDomainBridgeWiring.cs | 81 ---- GameFrameX.Launcher/StartUp/AppStartUpGame.cs | 227 +--------- .../StartUp/AppStartUpGameServerBase.cs | 37 +- .../Configuration/ConfigStartupValidator.cs | 15 +- GameFrameX.Utility/Setting/AppSetting.cs | 49 +-- .../Setting/OnlineCapabilityOptions.cs | 181 ++++---- build/deploy-game.sh | 3 +- docker-compose.yml | 2 +- 27 files changed, 1180 insertions(+), 1971 deletions(-) create mode 100644 GameFrameX.Hotfix/Logic/OnlineAuthorizedScopeReader.cs create mode 100644 GameFrameX.Hotfix/Logic/Server/Chat/GameChatModerationHook.cs create mode 100644 GameFrameX.Launcher/Online/HostPlayerDirectory.cs create mode 100644 GameFrameX.Launcher/Online/LogHelperCapabilityAssemblyLog.cs delete mode 100644 GameFrameX.Launcher/Online/OnlineAdminApiComposition.cs delete mode 100644 GameFrameX.Launcher/Online/OnlineCapabilityComposition.cs create mode 100644 GameFrameX.Launcher/Online/OnlineCapabilityCompositionRoot.cs delete mode 100644 GameFrameX.Launcher/Online/OnlineFriendDomainBridgeWiring.cs delete mode 100644 GameFrameX.Launcher/Online/OnlineFriendLegacyMigration.cs delete mode 100644 GameFrameX.Launcher/Online/OnlineMatchBridgeWiring.cs delete mode 100644 GameFrameX.Launcher/Online/OnlineMatchDomainBridgeWiring.cs delete mode 100644 GameFrameX.Launcher/Online/OnlineSessionDomainBridgeWiring.cs diff --git a/Configs/app_config.json b/Configs/app_config.json index ed5ad6a08..d95a74043 100644 --- a/Configs/app_config.json +++ b/Configs/app_config.json @@ -13,8 +13,12 @@ "IsEnableKcp": false, "OnlineAdminPort": 28090, "MetricsPort": 29090, - "IsEnableOnlineAdmin": true, - "IsEnableOnline": true, + "Online": { + "EnableRuntime": true, + "EnableAdminApi": true, + "EnableRoles": ["Game"], + "Settings": {} + }, "MinModuleId": 10, "MaxModuleId": 9999, "OnlineTenantId": 1, diff --git a/GameFrameX.Hotfix/GameFrameX.Hotfix.csproj b/GameFrameX.Hotfix/GameFrameX.Hotfix.csproj index f43b35850..0233361ad 100644 --- a/GameFrameX.Hotfix/GameFrameX.Hotfix.csproj +++ b/GameFrameX.Hotfix/GameFrameX.Hotfix.csproj @@ -1,47 +1,47 @@ - - - - - - false - - false - false - - - ..\bin\app_debug\hotfix - - - - ..\bin\app\hotfix - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + false + + false + false + + + ..\bin\app_debug\hotfix + + + + ..\bin\app\hotfix + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/GameFrameX.Hotfix/Lifecycle/HotfixModuleLifecycleBridge.cs b/GameFrameX.Hotfix/Lifecycle/HotfixModuleLifecycleBridge.cs index 46e00147f..c86a2ee68 100644 --- a/GameFrameX.Hotfix/Lifecycle/HotfixModuleLifecycleBridge.cs +++ b/GameFrameX.Hotfix/Lifecycle/HotfixModuleLifecycleBridge.cs @@ -29,9 +29,12 @@ using GameFrameX.Core.Session; +using GameFrameX.Capability.Online.Abstractions; +using GameFrameX.Capability.Hosting.Abstractions; using GameFrameX.Network.RemoteMessaging; using GameFrameX.Discovery.Routing; using GameFrameX.Network.RemoteMessaging.Routing; +using GameFrameX.Hotfix.Logic.Server.Chat; using GameFrameX.Hotfix.Logic.Server.Unified; using GameFrameX.Network.RemoteMessaging.Unified; @@ -56,6 +59,18 @@ namespace GameFrameX.Hotfix.Lifecycle; /// internal sealed class HotfixModuleLifecycleBridge : IHotfixBridge { + /// + /// 当前热更版本挂载的聊天审核钩子租约(装载成功挂载、停止 / 退役释放; + /// 新版本重挂时 + /// 原子替换并使本租约失效,能力侧不会钉住旧程序集)。 + /// + /// + /// The chat-moderation hook lease mounted by the current hotfix version (attached on load success, + /// released on stop / retirement; a re-attach by a newer version atomically replaces and invalidates + /// this lease so the capability side never pins the old assembly). + /// + private CapabilityHookLease _chatModerationHookLease; + /// /// 热更模块装载成功回调:纯业务生命周期激活(CurrentSetting 钉住 + 统一消息发送器装配 + /// 配置表 + 定时器 + 全局组件)。热更重载( = true)只清空代理缓存, @@ -78,12 +93,17 @@ internal sealed class HotfixModuleLifecycleBridge : IHotfixBridge /// 一个任务,表示异步操作的结果,并返回加载是否成功的布尔值 / A task that completes with whether the load succeeded public async Task OnLoadSuccessAsync(AppSetting setting, bool reload) { + // 聊天审核钩子挂载(每次装载成功都执行:新版本重挂原子替换旧挂载,旧租约自动失效)。 + _chatModerationHookLease?.Dispose(); + _chatModerationHookLease = CapabilityHook.Attach(new GameChatModerationHook()); + if (reload) { ActorManager.ClearAgent(); return true; } + // 多 Role 进程(--AllInOne)中钉住 CurrentSetting(C285 自 AppStartUpBase.Init 显式化): // Launcher 的创建循环先对全部 Role 逐个 Init,SetCurrentSetting 对 Role 级字段接受最新值, // 循环结束时 CurrentSetting 指向最后创建的 Role;热更首次装载由首个启动的 Role 触发, @@ -143,6 +163,9 @@ public async Task OnLoadSuccessAsync(AppSetting setting, bool reload) /// 一个任务,表示异步操作的结果 / A task that completes when the cleanup is done public async Task StopAsync(string message = "") { + // 释放本版本的聊天审核钩子挂载(摘下后能力侧回默认策略;防退役宽限期内的悬置挂载)。 + _chatModerationHookLease?.Dispose(); + _chatModerationHookLease = null; // 断开所有连接 await PlayerSessionManager.Instance.RemoveAllAsync(); // 取消所有未执行定时器 diff --git a/GameFrameX.Hotfix/Logic/Game/RockPaperScissors/RockPaperScissorsMatchBridge.cs b/GameFrameX.Hotfix/Logic/Game/RockPaperScissors/RockPaperScissorsMatchBridge.cs index e3bafde13..7521fd8f7 100644 --- a/GameFrameX.Hotfix/Logic/Game/RockPaperScissors/RockPaperScissorsMatchBridge.cs +++ b/GameFrameX.Hotfix/Logic/Game/RockPaperScissors/RockPaperScissorsMatchBridge.cs @@ -22,7 +22,7 @@ using System; using System.Collections.Generic; using System.Threading.Tasks; -using GameFrameX.Capability.Online.Abstractions.Bridges; +using GameFrameX.Capability.Hosting.Abstractions; using GameFrameX.Capability.Online.Abstractions.Contracts; using GameFrameX.Capability.Online.Abstractions.Scope; using GameFrameX.Foundation.Localization.Core; @@ -32,41 +32,45 @@ namespace GameFrameX.Hotfix.Logic.Game.RockPaperScissors; /// /// 对局样例桥薄适配器(C245,沿 C243 好友桥形态(该缝已随 C244 域桥演化)): -/// 显式开关开启时把石头剪刀布的房间制协议入口(开局 / 出拳 / 查询 / 再来一局)经 -/// 进程内转发到 Online 对局契约面;开关关闭(缺省)时不接管, +/// online-match 能力启用(对局桥由能力模块发布,C294 起能力过滤器是唯一开关)时把石头剪刀布的 +/// 房间制协议入口(开局 / 出拳 / 查询 / 再来一局)经 +///(契约 )进程内转发到 Online 对局契约面;能力缺席(缺省部署)时不接管, /// 旧链行为逐字节保持。 /// /// 维护约束(红线): -/// ① 开关开启时桥路径独占——桥不可用(宿主未启 / 已停止 / 租约撤销)与服务失败一律映射为 +/// ① 桥在场时桥路径独占——桥不可用(宿主未启 / 已停止 / 租约撤销)与服务失败一律映射为 /// -(int)OnlineErrorCode 段的显式错误码(≤ -1001,与旧链 -1..-99 段不重叠), -/// 绝不静默回落旧链;② 可信身份:作用域三元组只取 lease 携带的组合根授权值, -/// 玩家位只取请求主体 ActorId,请求体字段一律不可影响作用域;③ 无缓存:逐请求读取 holder -/// 当前租约,热更 / 宿主重启后旧引用不存活;④ 本适配器零玩法逻辑——只做参数转发、 +/// 绝不静默回落旧链;② 可信身份:作用域三元组只取能力侧授权作用域提供者发布的组合根授权值, +/// 玩家位只取请求主体 ActorId,请求体字段一律不可影响作用域;③ 无缓存:逐请求 +/// 取契约租约,热更 / 宿主重启后旧引用不存活; +/// ④ 本适配器零玩法逻辑——只做参数转发、 /// 字段级视图映射与 Room 状态映射决策,裁决与结算全部归属平台对局运行时。 /// /// /// /// The sample match thin bridge adapter (C245, following the C243 friend-bridge shape (since evolved by the C244 domain bridge)): when the -/// explicit switch is on it forwards the rock-paper-scissors room-shaped protocol entries (start / -/// submit / query / restart) in-process to the Online match contract face through -/// ; when off (default) it does not intercept and the legacy -/// chain stays byte-identical. With the switch on the bridge path is exclusive — unavailability and -/// service failures map to explicit -(int)OnlineErrorCode codes (≤ -1001, disjoint from the legacy -/// -1..-99 band), never silently falling back; the scope triple comes only from the lease's -/// composition-root authorized values and the player position only from the requesting actor id; -/// nothing is cached — each request reads the holder's current lease; and the adapter carries zero -/// gameplay logic — it only forwards parameters, maps views field-by-field and decides room-state -/// transitions, while adjudication and settlement belong to the platform match runtime. +/// online-match capability is enabled (the bridge published by the capability module — since C294 the +/// capability filter is the only switch) it forwards the rock-paper-scissors room-shaped protocol +/// entries (start / submit / query / restart) in-process to the Online match contract face through +/// (contract ); when the +/// capability is absent (default) it does not intercept and the legacy chain stays byte-identical. +/// With the bridge present the path is exclusive — unavailability and service failures map to explicit +/// -(int)OnlineErrorCode codes (≤ -1001, disjoint from the legacy -1..-99 band), never silently +/// falling back; the scope triple comes only from the capability-published authorized-scope provider +/// and the player position only from the requesting actor id; nothing is cached — each request +/// re-acquires the contract lease; and the adapter carries zero gameplay logic — it only forwards +/// parameters, maps views field-by-field and decides room-state transitions, while adjudication and +/// settlement belong to the platform match runtime. /// public static class RockPaperScissorsMatchBridge { /// - /// 获取对局桥开关是否开启(读取进程设置 Online.EnableMatchGameBridge; - /// 只应配置在 Game 角色段)。 + /// 获取对局桥是否在场接管(C294 起能力过滤器是唯一开关:online-match 启用即发布对局桥; + /// 旧 Online.EnableMatchGameBridge 配置键已删除)。 /// public static bool IsBridgeEnabled { - get { return GlobalSettings.CurrentSetting?.Online?.EnableMatchGameBridge == true; } + get { return TryGetContract() != null; } } /// @@ -275,10 +279,19 @@ public static async Task ApplyRoomStateAsync(RoomComponentAgent roomAgent, GameF /// 请求主体(玩家全局 actor)标识。 /// 契约调用体(接收 lease 与组装好的作用域)。 /// (错误码, 视图):错误码 0 = 成功(视图非空);非 0 为显式负值段错误码(视图为 null)。 - private static async Task<(int ErrorCode, OnlineMatchBridgeGameView View)> InvokeAsync(long playerId, Func>> invoke) + private static async Task<(int ErrorCode, OnlineMatchBridgeGameView View)> InvokeAsync(long playerId, Func>> invoke) { - var lease = OnlineMatchGameBridgeHolder.Current; - if (lease == null || !lease.IsActive) + var contract = TryGetContract(); + if (contract == null) + { + // Localization: Hotfix.Game.MatchBridgeUnavailable - 对局桥不可用(未发布或已撤销), ActorId: {0}, Code: {1} + LogHelper.Error(LocalizationService.GetString(Localization.Keys.Hotfix.Game.MatchBridgeUnavailable, playerId, OnlineErrorCode.DependencyUnavailable)); + return (-(int)OnlineErrorCode.DependencyUnavailable, null); + } + + // 授权作用域不可得 = 依赖不可用:与对局桥缺席同口径显式失败,不以空作用域调用契约、不降级。 + var scope = OnlineAuthorizedScopeReader.BuildRequestScope(playerId); + if (scope == null) { // Localization: Hotfix.Game.MatchBridgeUnavailable - 对局桥不可用(未发布或已撤销), ActorId: {0}, Code: {1} LogHelper.Error(LocalizationService.GetString(Localization.Keys.Hotfix.Game.MatchBridgeUnavailable, playerId, OnlineErrorCode.DependencyUnavailable)); @@ -287,8 +300,7 @@ public static async Task ApplyRoomStateAsync(RoomComponentAgent roomAgent, GameF try { - var scope = new OnlineScope(lease.AuthorizedScope.TenantId, lease.AuthorizedScope.AppId, lease.AuthorizedScope.ServerId, playerId); - var result = await invoke(lease, scope); + var result = await invoke(contract, scope); if (!result.IsSuccess) { // Localization: Hotfix.Game.MatchBridgeFailed - 对局桥返回失败, Code: {0}, Message: {1}, ActorId: {2} @@ -305,6 +317,28 @@ public static async Task ApplyRoomStateAsync(RoomComponentAgent roomAgent, GameF return (-2, null); } } + + /// + /// 读取对局桥契约(逐请求 ,不缓存; + /// 缺席 / 撤销竞态返回 null——缺席 = 能力未装配的合法部署形态,撤销按依赖不可用显式失败)。 + /// + /// 契约面;桥不可用返回 null。 + private static IOnlineMatchGameBridge TryGetContract() + { + if (CapabilityBridge.TryAcquire(out var lease)) + { + try + { + return lease.Contract; + } + catch (CapabilityBridgeUnavailableException) + { + // 获取与读取之间的撤销竞态:与缺席同语义(显式失败),不降级。 + } + } + + return null; + } } /// diff --git a/GameFrameX.Hotfix/Logic/OnlineAuthorizedScopeReader.cs b/GameFrameX.Hotfix/Logic/OnlineAuthorizedScopeReader.cs new file mode 100644 index 000000000..7e165f51f --- /dev/null +++ b/GameFrameX.Hotfix/Logic/OnlineAuthorizedScopeReader.cs @@ -0,0 +1,92 @@ +// ========================================================================================== +// 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 refer to 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, +// 侵犯他人合法权益等法律法规所禁止的行为! +// Any legal disputes and 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 +// CNB Repository: https://cnb.cool +// 官方文档:https://gameframex.doc.alianblank.com/ +// Official documentation: https://gameframex.doc.alianblank.com/ +// ========================================================================================== + + +using GameFrameX.Capability.Hosting.Abstractions; +using GameFrameX.Capability.Online.Abstractions.Contracts; +using GameFrameX.Capability.Online.Abstractions.Scope; +/// +/// Online 授权作用域读取器(C294 桥消费侧共享件):经 +/// 进程内读取 online-foundation 发布的 租约, +/// 把宿主组合根授权的作用域三元组与请求玩家位组装为生效作用域。 +/// +/// 维护约束(红线):① 可信身份——三元组只取能力侧发布的授权值(等价旧域桥租约的 +/// AuthorizedScope),玩家位只由调用方按会话绑定身份填充,请求体字段一律不可影响作用域; +/// ② 无缓存——逐请求读取桥租约,不缓存 实例,宿主停止 / 重启后旧引用不存活; +/// ③ 缺席 / 撤销不降级——提供者桥缺席或租约已撤销时返回 false,由各域薄适配器按 +/// 显式失败处理,绝不回落本地默认值。 +/// +/// +/// +/// The Online authorized-scope reader (the C294 bridge-consumer shared part): reads the +/// online-foundation-published lease in-process through +/// and combines the composition-root-authorized scope triple +/// with the requesting player position. The triple comes only from the capability-published authorized +/// values (equivalent to the old domain-lease AuthorizedScope); nothing is cached — every request +/// re-reads the bridge lease; absence or revocation returns false so each domain thin adapter fails +/// explicitly with and never falls back to defaults. +/// +internal static class OnlineAuthorizedScopeReader +{ + /// + /// 读取授权作用域三元组(玩家位为 0,由调用方按请求主体填充)。 + /// + /// 输出:授权作用域三元组(提供者桥缺席 / 已撤销时为 null)。 + /// 提供者在场且可达返回 true;缺席 / 撤销返回 false。 + internal static bool TryGetAuthorizedScope(out OnlineScope authorizedScope) + { + if (CapabilityBridge.TryAcquire(out var lease)) + { + try + { + authorizedScope = lease.Contract.AuthorizedScope; + if (authorizedScope != null) + { + return true; + } + } + catch (CapabilityBridgeUnavailableException) + { + // 获取与读取之间的撤销竞态:与缺席同语义(显式失败),不降级。 + } + } + + authorizedScope = null; + return false; + } + + /// + /// 组装生效作用域(授权三元组 + 请求玩家位)。 + /// + /// 请求主体玩家标识(会话绑定身份或 Token 解析身份)。 + /// 生效作用域;提供者桥缺席 / 已撤销时为 null。 + internal static OnlineScope BuildRequestScope(long playerId) + { + return TryGetAuthorizedScope(out var authorizedScope) + ? new OnlineScope(authorizedScope.TenantId, authorizedScope.AppId, authorizedScope.ServerId, playerId) + : null; + } +} diff --git a/GameFrameX.Hotfix/Logic/Player/Friend/FriendDomainBridge.cs b/GameFrameX.Hotfix/Logic/Player/Friend/FriendDomainBridge.cs index bef8bac9d..61f8f0553 100644 --- a/GameFrameX.Hotfix/Logic/Player/Friend/FriendDomainBridge.cs +++ b/GameFrameX.Hotfix/Logic/Player/Friend/FriendDomainBridge.cs @@ -29,7 +29,7 @@ using System.Collections.Generic; using System.Threading.Tasks; -using GameFrameX.Capability.Online.Abstractions.Bridges; +using GameFrameX.Capability.Hosting.Abstractions; using GameFrameX.Capability.Online.Abstractions.Contracts; using GameFrameX.Capability.Online.Abstractions.Scope; using GameFrameX.Foundation.Localization.Core; @@ -38,16 +38,16 @@ namespace GameFrameX.Hotfix.Logic.Player.Friend; /// /// 好友域桥薄适配器(C244,好友域 120 / 660 两族协议入口的唯一转发点):经 -/// 进程内租约把全部好友协议行为转发到 Online 好友域契约面, -/// 并按 T2 裁定完成两族错误码映射。 +/// (契约 )进程内租约把 +/// 全部好友协议行为转发到 Online 好友域契约面,并按 T2 裁定完成两族错误码映射。 /// /// 维护约束(红线): /// ① 域桥独占——桥不可用(宿主未启 / 已停止 / 租约撤销)与服务失败一律映射为 /// -(int)OnlineErrorCode 段的显式错误码(≤ -1001,与旧 120 链 -1..-104 段、660 段 660001..660005 不重叠), /// 绝不静默回落、绝不返回成功空列表掩盖错库 / 错进程(旧 120/660 链已随本切换退役,无回落目标); -/// ② 可信身份:作用域三元组只取 lease 携带的组合根授权值,玩家位只取会话绑定 playerId +/// ② 可信身份:作用域三元组只取能力侧授权作用域提供者发布的组合根授权值,玩家位只取会话绑定 playerId ///(ServerRoleHandlerHelper.GetCurrentPlayerId),请求体字段一律不可影响作用域; -/// ③ 无缓存:逐请求读取 holder 当前租约,热更 / 宿主重启后旧引用不存活; +/// ③ 无缓存:逐请求 取契约租约,热更 / 宿主重启后旧引用不存活; /// ④ 错误码映射表——120 族保留 -100/-101/-103/-104(-102 目标不存在已废弃披露), /// 660 族全保留 660001..660005(660004 同时覆盖「对方已先申请」的单记录收敛,裁定 R6); /// 其余(含好友上限 StateOperationForbidden)走 Online 显式段。 @@ -55,17 +55,17 @@ namespace GameFrameX.Hotfix.Logic.Player.Friend; /// /// /// The friend-domain thin bridge adapter (C244, the single forwarding point for both the domain-120 -/// and domain-660 friend protocol entries): forwards every friend protocol behaviour in-process to -/// the Online friend-domain contract face through , -/// mapping error codes per the T2 rulings. The bridge path is exclusive — unavailability (host not -/// started / stopped / lease revoked) and service failures map to explicit -/// -(int)OnlineErrorCode codes (≤ -1001, disjoint from the legacy -1..-104 and +/// and domain-660 friend protocol entries): forwards every friend protocol behaviour in-process to the +/// Online friend-domain contract face through (contract +/// ), mapping error codes per the T2 rulings. The bridge path is +/// exclusive — unavailability (host not started / stopped / lease revoked) and service failures map to +/// explicit -(int)OnlineErrorCode codes (≤ -1001, disjoint from the legacy -1..-104 and /// 660001..660005 bands) with no silent fallback and no successful empty lists masking a wrong /// store or wrong process (the legacy chains retired with this cutover — there is no fallback -/// target). The scope triple comes only from the lease's composition-root authorized values and the -/// player position only from the session-bound player id — request-body fields can never influence -/// the scope; nothing is cached — each request reads the holder's current lease so no stale -/// reference survives a hotfix swap or host restart. The mapping tables: the 120 family keeps +/// target). The scope triple comes only from the capability-published authorized-scope provider and +/// the player position only from the session-bound player id — request-body fields can never influence +/// the scope; nothing is cached — each request re-acquires the contract lease so no stale reference +/// survives a hotfix swap or host restart. The mapping tables: the 120 family keeps /// -100/-101/-103/-104 (-102 "target absent" is dropped and disclosed); the 660 family keeps all of /// 660001..660005 (660004 also covers the single-record convergence of "the other side requested /// first", ruling R6); everything else (including the friend-limit StateOperationForbidden) uses the @@ -95,7 +95,7 @@ public static class FriendDomainBridge /// 协议响应。 public static async Task QueryFriendListAsync(long playerId, RespFriendList response) { - var lease = RequireLease(playerId); + var lease = RequireLease(playerId, out var scope); if (lease == null) { response.Friends = new List(); @@ -105,7 +105,7 @@ public static async Task QueryFriendListAsync(long playerId, RespFriendList resp try { - var result = await lease.ListFriendsAsync(BuildScope(lease, playerId)); + var result = await lease.ListFriendsAsync(scope); if (!result.IsSuccess) { response.Friends = new List(); @@ -147,7 +147,7 @@ public static async Task AddFriendDirectAsync(long playerId, long targetPlayerId return; } - var lease = RequireLease(playerId); + var lease = RequireLease(playerId, out var scope); if (lease == null) { response.Success = false; @@ -157,7 +157,7 @@ public static async Task AddFriendDirectAsync(long playerId, long targetPlayerId try { - var result = await lease.EstablishAsync(BuildScope(lease, playerId), targetPlayerId); + var result = await lease.EstablishAsync(scope, targetPlayerId); if (!result.IsSuccess) { response.Success = false; @@ -200,7 +200,7 @@ public static async Task DeleteFriendAsync(long playerId, long targetPlayerId, R return; } - var lease = RequireLease(playerId); + var lease = RequireLease(playerId, out var scope); if (lease == null) { response.Success = false; @@ -210,7 +210,7 @@ public static async Task DeleteFriendAsync(long playerId, long targetPlayerId, R try { - var result = await lease.RemoveAsync(BuildScope(lease, playerId), targetPlayerId); + var result = await lease.RemoveAsync(scope, targetPlayerId); if (!result.IsSuccess) { response.Success = false; @@ -244,7 +244,7 @@ public static async Task SendRequestAsync(long playerId, long toPlayerId, RespFr return; } - var lease = RequireLease(playerId); + var lease = RequireLease(playerId, out var scope); if (lease == null) { response.ErrorCode = -(int)OnlineErrorCode.DependencyUnavailable; @@ -253,7 +253,7 @@ public static async Task SendRequestAsync(long playerId, long toPlayerId, RespFr try { - var result = await lease.RequestAsync(BuildScope(lease, playerId), toPlayerId); + var result = await lease.RequestAsync(scope, toPlayerId); if (!result.IsSuccess) { response.ErrorCode = MapFamilyCodeFailure(result.Code); @@ -291,7 +291,7 @@ public static async Task SendRequestAsync(long playerId, long toPlayerId, RespFr /// 协议响应。 public static async Task AcceptAsync(long playerId, long fromPlayerId, RespFriendAccept response) { - var lease = RequireLease(playerId); + var lease = RequireLease(playerId, out var scope); if (lease == null) { response.ErrorCode = -(int)OnlineErrorCode.DependencyUnavailable; @@ -300,7 +300,7 @@ public static async Task AcceptAsync(long playerId, long fromPlayerId, RespFrien try { - var result = await lease.AcceptAsync(BuildScope(lease, playerId), fromPlayerId); + var result = await lease.AcceptAsync(scope, fromPlayerId); response.ErrorCode = result.IsSuccess ? 0 : MapFamilyCodeFailure(result.Code); if (!result.IsSuccess) { @@ -322,7 +322,7 @@ public static async Task AcceptAsync(long playerId, long fromPlayerId, RespFrien /// 协议响应。 public static async Task RejectAsync(long playerId, long fromPlayerId, RespFriendReject response) { - var lease = RequireLease(playerId); + var lease = RequireLease(playerId, out var scope); if (lease == null) { response.ErrorCode = -(int)OnlineErrorCode.DependencyUnavailable; @@ -331,7 +331,7 @@ public static async Task RejectAsync(long playerId, long fromPlayerId, RespFrien try { - var result = await lease.RejectAsync(BuildScope(lease, playerId), fromPlayerId); + var result = await lease.RejectAsync(scope, fromPlayerId); response.ErrorCode = result.IsSuccess ? 0 : MapFamilyCodeFailure(result.Code); if (!result.IsSuccess) { @@ -359,7 +359,7 @@ public static async Task RemoveAsync(long playerId, long friendPlayerId, RespFri return; } - var lease = RequireLease(playerId); + var lease = RequireLease(playerId, out var scope); if (lease == null) { response.ErrorCode = -(int)OnlineErrorCode.DependencyUnavailable; @@ -368,7 +368,7 @@ public static async Task RemoveAsync(long playerId, long friendPlayerId, RespFri try { - var result = await lease.RemoveAsync(BuildScope(lease, playerId), friendPlayerId); + var result = await lease.RemoveAsync(scope, friendPlayerId); response.ErrorCode = result.IsSuccess ? 0 : MapFamilyCodeFailure(result.Code); if (!result.IsSuccess) { @@ -389,7 +389,7 @@ public static async Task RemoveAsync(long playerId, long friendPlayerId, RespFri /// 协议响应。 public static async Task ListRelationsAsync(long playerId, RespFriendRelationList response) { - var lease = RequireLease(playerId); + var lease = RequireLease(playerId, out var scope); if (lease == null) { response.ErrorCode = -(int)OnlineErrorCode.DependencyUnavailable; @@ -398,7 +398,7 @@ public static async Task ListRelationsAsync(long playerId, RespFriendRelationLis try { - var result = await lease.ListRelationsAsync(BuildScope(lease, playerId)); + var result = await lease.ListRelationsAsync(scope); if (!result.IsSuccess) { response.ErrorCode = -(int)result.Code; @@ -435,16 +435,37 @@ public static async Task ListRelationsAsync(long playerId, RespFriendRelationLis } /// - /// 读取当前域桥租约(不可用即显式失败记录;逐请求读取,不缓存)。 + /// 读取当前域桥契约与生效作用域(逐请求 ,不缓存; + /// 获取与读取之间的撤销竞态按缺席同语义显式失败)。授权作用域不可得(提供者桥缺席 / 撤销)同样视为依赖不可用: + /// 返回 null 走调用方既有的桥不可用失败分支,契约面不被触达,绝不以空作用域降级调用。 /// - /// 请求主体(日志披露用)。 - /// 活跃租约;不可用返回 null。 - private static OnlineFriendDomainBridgeLease RequireLease(long playerId) + /// 会话绑定玩家标识(作用域玩家位唯一来源,兼作日志披露)。 + /// 输出:授权三元组 + 会话玩家位组成的生效作用域;返回 null 时为 null。 + /// 契约面;契约桥或授权作用域任一不可得返回 null。 + private static IOnlineFriendDomain RequireLease(long playerId, out OnlineScope scope) { - var lease = OnlineFriendDomainBridgeHolder.Current; - if (lease != null && lease.IsActive) + scope = null; + IOnlineFriendDomain contract = null; + if (CapabilityBridge.TryAcquire(out var lease)) { - return lease; + try + { + contract = lease.Contract; + } + catch (CapabilityBridgeUnavailableException) + { + // 获取与读取之间的撤销竞态:与缺席同语义(显式失败),不降级。 + } + } + + if (contract != null) + { + // 授权作用域不可得 = 依赖不可用:与契约缺席同口径显式失败,不降级。 + scope = OnlineAuthorizedScopeReader.BuildRequestScope(playerId); + if (scope != null) + { + return contract; + } } // Localization: Hotfix.Friend.DomainBridgeUnavailable - 好友域桥不可用(未发布或已撤销), PlayerId: {0}, Code: {1} @@ -452,17 +473,6 @@ private static OnlineFriendDomainBridgeLease RequireLease(long playerId) return null; } - /// - /// 以租约授权三元组 + 会话玩家位构造作用域。 - /// - /// 域桥租约。 - /// 会话绑定玩家标识。 - /// 生效作用域。 - private static OnlineScope BuildScope(OnlineFriendDomainBridgeLease lease, long playerId) - { - return new OnlineScope(lease.AuthorizedScope.TenantId, lease.AuthorizedScope.AppId, lease.AuthorizedScope.ServerId, playerId); - } - /// /// 660 族失败映射:资源不存在(申请 / 关系前置不满足)→ 660005;其余 → Online 显式段。 /// diff --git a/GameFrameX.Hotfix/Logic/Server/Chat/GameChatModerationHook.cs b/GameFrameX.Hotfix/Logic/Server/Chat/GameChatModerationHook.cs new file mode 100644 index 000000000..3c1b8fabd --- /dev/null +++ b/GameFrameX.Hotfix/Logic/Server/Chat/GameChatModerationHook.cs @@ -0,0 +1,81 @@ +// ========================================================================================== +// 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 refer to 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, +// 侵犯他人合法权益等法律法规所禁止的行为! +// Any legal disputes and 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 +// CNB Repository: https://cnb.cool +// 官方文档:https://gameframex.doc.alianblank.com/ +// Official documentation: https://gameframex.doc.alianblank.com/ +// ========================================================================================== + + +using System.Threading; +using GameFrameX.Capability.Online.Abstractions; +using System.Threading.Tasks; + +namespace GameFrameX.Hotfix.Logic.Server.Chat; +/// +/// 聊天内容审核钩子示例实现(C294 热更侧钩子接线样例):对消息内容做本地长度与空白校验后放行—— +/// 演示「热更装载完成挂载、退役释放」的钩子生命周期,不承载真实第三方审核。 +/// +/// 维护约束:本实现按 契约的红线书写——纯本地同步裁决 +///(无外部 IO、无超时风险);拒绝理由只供审计不透出;真实审核服务接入时以新热更版本替换本挂载 +///( 原子替换,旧租约自动失效)。 +/// +/// +/// +/// The sample chat-moderation hook implementation (the C294 hotfix-side hook wiring sample): applies a +/// local length / blank check to the message content and allows everything else — it demonstrates the +/// hook lifecycle (attach on hotfix load completion, release on retirement) and carries no real +/// third-party moderation. The implementation honours the hook contract red lines — a pure local +/// synchronous verdict (no external IO, no timeout risk), reasons for audit only; a real moderation +/// service replaces this mount with a new hotfix version (the atomic Attach replaces the old lease). +/// +public sealed class GameChatModerationHook : IOnlineChatModerationHook +{ + /// + /// 消息内容最大长度(超出即拒绝;样例阈值,演示本地裁决形态)。 + /// + /// + /// The maximum message content length (rejections beyond it; a sample threshold demonstrating the + /// local-verdict shape). + /// + public const int MaximumContentLength = 500; + + /// + public Task CheckAsync(OnlineChatMessage message, CancellationToken cancellationToken = default) + { + if (message == null) + { + throw new ArgumentNullException(nameof(message)); + } + + if (string.IsNullOrWhiteSpace(message.Content)) + { + return Task.FromResult(OnlineChatModerationVerdict.Reject("空白消息")); + } + + if (message.Content.Length > MaximumContentLength) + { + return Task.FromResult(OnlineChatModerationVerdict.Reject("消息超过最大长度 " + MaximumContentLength)); + } + + return Task.FromResult(OnlineChatModerationVerdict.Allow()); + } +} diff --git a/GameFrameX.Hotfix/Logic/ServerRole/Match/MatchDomainBridge.cs b/GameFrameX.Hotfix/Logic/ServerRole/Match/MatchDomainBridge.cs index e7c4ad26b..8f0169cdd 100644 --- a/GameFrameX.Hotfix/Logic/ServerRole/Match/MatchDomainBridge.cs +++ b/GameFrameX.Hotfix/Logic/ServerRole/Match/MatchDomainBridge.cs @@ -30,7 +30,7 @@ // ========================================================================================== using System.Threading.Tasks; -using GameFrameX.Capability.Online.Abstractions.Bridges; +using GameFrameX.Capability.Hosting.Abstractions; using GameFrameX.Capability.Online.Abstractions.Contracts; using GameFrameX.Capability.Online.Abstractions.Scope; using GameFrameX.Foundation.Localization.Core; @@ -40,8 +40,9 @@ namespace GameFrameX.Hotfix.Logic.ServerRole.Match; /// -/// 匹配域桥薄适配器(C246,420 族协议入口的唯一转发点):经 -/// 进程内租约把入队 / 取消 / 状态 / 入场协议行为转发到 Online 匹配域契约面,并按 T2 裁定完成错误码映射。 +/// 匹配域桥薄适配器(C246,420 族协议入口的唯一转发点):经 +///(契约 )进程内租约把入队 / 取消 / 状态 / 入场协议行为转发到 +/// Online 匹配域契约面,并按 T2 裁定完成错误码映射。 /// /// 维护约束(红线): /// ① 域桥独占——桥不可用(宿主未启 / 已停止 / 租约撤销)与服务失败一律映射为 @@ -49,7 +50,7 @@ namespace GameFrameX.Hotfix.Logic.ServerRole.Match; /// 绝不静默回落(旧 FIFO 内存池链已随本切换退役,无回落目标); /// ② 可信身份:作用域三元组只取 lease 携带的组合根授权值,玩家位只取会话绑定 playerId ///(ServerRoleHandlerHelper.GetCurrentPlayerId),请求体字段一律不可影响作用域; -/// ③ 无缓存:逐请求读取 holder 当前租约,热更 / 宿主重启后旧引用不存活; +/// ③ 无缓存:逐请求 取契约租约,热更 / 宿主重启后旧引用不存活; /// ④ 错误码映射表——本地校验保留 420004(模式非法)/ 420005(评分非法), /// 入队查重保留 420002(已在排队),取消终态回执保留 420003(未在排队); /// 其余(含限流 RateLimitExceeded)走 Online 显式段;入场为新面,失败一律走 Online 显式段。 @@ -58,13 +59,14 @@ namespace GameFrameX.Hotfix.Logic.ServerRole.Match; /// /// The match-domain thin bridge adapter (C246, the single forwarding point for the domain-420 protocol /// entries): forwards enqueue / cancel / status / enter protocol behaviours in-process to the Online -/// match-domain contract face through , mapping error codes -/// per the T2 rulings. The bridge path is exclusive — unavailability and service failures map to -/// explicit -(int)OnlineErrorCode codes (≤ -1001, disjoint from the legacy 420001..420005 band) +/// match-domain contract face through (contract +/// ), mapping error codes per the T2 rulings. The bridge path is +/// exclusive — unavailability and service failures map to explicit -(int)OnlineErrorCode codes +/// (≤ -1001, disjoint from the legacy 420001..420005 band) /// with no silent fallback (the legacy in-memory FIFO pool chain retired with this cutover — there is -/// no fallback target). The scope triple comes only from the lease's composition-root authorized -/// values and the player position only from the session-bound player id — request-body fields can -/// never influence the scope; nothing is cached — each request reads the holder's current lease so no +/// no fallback target). The scope triple comes only from the capability-published authorized-scope +/// provider and the player position only from the session-bound player id — request-body fields can +/// never influence the scope; nothing is cached — each request re-acquires the contract lease so no /// stale reference survives a hotfix swap or host restart. The mapping tables: local validation keeps /// 420004 (mode invalid) / 420005 (rating invalid), enqueue duplication keeps 420002 (already /// queuing), the cancel terminal receipt keeps 420003 (not queuing); everything else (including the @@ -93,7 +95,7 @@ public static async Task EnqueueAsync(long playerId, ReqMatchEnqueue request, Re return; } - var lease = RequireLease(playerId); + var lease = RequireLease(playerId, out var scope); if (lease == null) { response.ErrorCode = -(int)OnlineErrorCode.DependencyUnavailable; @@ -102,7 +104,7 @@ public static async Task EnqueueAsync(long playerId, ReqMatchEnqueue request, Re try { - var result = await lease.EnqueueAsync(BuildScope(lease, playerId), (int)request.Mode, request.Rating); + var result = await lease.EnqueueAsync(scope, (int)request.Mode, request.Rating); if (!result.IsSuccess) { response.ErrorCode = MapEnqueueFailure(result.Code); @@ -128,7 +130,7 @@ public static async Task EnqueueAsync(long playerId, ReqMatchEnqueue request, Re /// 协议响应。 public static async Task CancelAsync(long playerId, RespMatchCancel response) { - var lease = RequireLease(playerId); + var lease = RequireLease(playerId, out var scope); if (lease == null) { response.ErrorCode = -(int)OnlineErrorCode.DependencyUnavailable; @@ -137,7 +139,7 @@ public static async Task CancelAsync(long playerId, RespMatchCancel response) try { - var result = await lease.CancelAsync(BuildScope(lease, playerId)); + var result = await lease.CancelAsync(scope); if (!result.IsSuccess) { response.ErrorCode = -(int)result.Code; @@ -163,7 +165,7 @@ public static async Task CancelAsync(long playerId, RespMatchCancel response) /// 协议响应。 public static async Task StatusAsync(long playerId, RespMatchStatus response) { - var lease = RequireLease(playerId); + var lease = RequireLease(playerId, out var scope); if (lease == null) { response.ErrorCode = -(int)OnlineErrorCode.DependencyUnavailable; @@ -172,7 +174,7 @@ public static async Task StatusAsync(long playerId, RespMatchStatus response) try { - var result = await lease.GetStatusAsync(BuildScope(lease, playerId)); + var result = await lease.GetStatusAsync(scope); if (!result.IsSuccess) { response.Queuing = false; @@ -203,7 +205,7 @@ public static async Task StatusAsync(long playerId, RespMatchStatus response) /// 协议响应。 public static async Task EnterAsync(long playerId, ReqMatchEnter request, RespMatchEnter response) { - var lease = RequireLease(playerId); + var lease = RequireLease(playerId, out var scope); if (lease == null) { response.ErrorCode = -(int)OnlineErrorCode.DependencyUnavailable; @@ -212,7 +214,7 @@ public static async Task EnterAsync(long playerId, ReqMatchEnter request, RespMa try { - var result = await lease.EnterAsync(BuildScope(lease, playerId), request.MatchId); + var result = await lease.EnterAsync(scope, request.MatchId); if (!result.IsSuccess) { response.ErrorCode = -(int)result.Code; @@ -234,16 +236,37 @@ public static async Task EnterAsync(long playerId, ReqMatchEnter request, RespMa } /// - /// 读取当前域桥租约(不可用即显式失败记录;逐请求读取,不缓存)。 + /// 读取当前域桥契约与生效作用域(逐请求 ,不缓存; + /// 获取与读取之间的撤销竞态按缺席同语义显式失败)。授权作用域不可得(提供者桥缺席 / 撤销)同样视为依赖不可用: + /// 返回 null 走调用方既有的桥不可用失败分支,契约面不被触达,绝不以空作用域降级调用。 /// - /// 请求主体(日志披露用)。 - /// 活跃租约;不可用返回 null。 - private static OnlineMatchDomainBridgeLease RequireLease(long playerId) + /// 会话绑定玩家标识(作用域玩家位唯一来源,兼作日志披露)。 + /// 输出:授权三元组 + 会话玩家位组成的生效作用域;返回 null 时为 null。 + /// 契约面;契约桥或授权作用域任一不可得返回 null。 + private static IOnlineMatchDomain RequireLease(long playerId, out OnlineScope scope) { - var lease = OnlineMatchDomainBridgeHolder.Current; - if (lease != null && lease.IsActive) + scope = null; + IOnlineMatchDomain contract = null; + if (CapabilityBridge.TryAcquire(out var lease)) { - return lease; + try + { + contract = lease.Contract; + } + catch (CapabilityBridgeUnavailableException) + { + // 获取与读取之间的撤销竞态:与缺席同语义(显式失败),不降级。 + } + } + + if (contract != null) + { + // 授权作用域不可得 = 依赖不可用:与契约缺席同口径显式失败,不降级。 + scope = OnlineAuthorizedScopeReader.BuildRequestScope(playerId); + if (scope != null) + { + return contract; + } } // Localization: Hotfix.Match.DomainBridgeUnavailable - 匹配域桥不可用(未发布或已撤销), PlayerId: {0}, Code: {1} @@ -251,17 +274,6 @@ private static OnlineMatchDomainBridgeLease RequireLease(long playerId) return null; } - /// - /// 以租约授权三元组 + 会话玩家位构造作用域。 - /// - /// 域桥租约。 - /// 会话绑定玩家标识。 - /// 生效作用域。 - private static OnlineScope BuildScope(OnlineMatchDomainBridgeLease lease, long playerId) - { - return new OnlineScope(lease.AuthorizedScope.TenantId, lease.AuthorizedScope.AppId, lease.AuthorizedScope.ServerId, playerId); - } - /// /// 入队失败映射:重复排队 → 旧 420002;其余 → Online 显式段。 /// diff --git a/GameFrameX.Hotfix/Logic/ServerRole/SessionResume/SessionResumeDomainBridge.cs b/GameFrameX.Hotfix/Logic/ServerRole/SessionResume/SessionResumeDomainBridge.cs index f3d18ae3e..062dd62d6 100644 --- a/GameFrameX.Hotfix/Logic/ServerRole/SessionResume/SessionResumeDomainBridge.cs +++ b/GameFrameX.Hotfix/Logic/ServerRole/SessionResume/SessionResumeDomainBridge.cs @@ -22,7 +22,7 @@ using System.Threading.Tasks; using GameFrameX.Apps.Common.EventData; -using GameFrameX.Capability.Online.Abstractions.Bridges; +using GameFrameX.Capability.Hosting.Abstractions; using GameFrameX.Capability.Online.Abstractions.Contracts; using GameFrameX.Capability.Online.Abstractions.Scope; using GameFrameX.Core.Events; @@ -67,15 +67,22 @@ internal static class SessionResumeDomainBridge /// Token 明文;桥不可用或签发失败返回 null(登录主流程不受影响,日志披露)。 public static async Task IssueLoginCredentialAsync(long playerId, string connectionId) { - var lease = OnlineSessionDomainBridgeHolder.Current; - if (lease == null || !lease.IsActive) + var lease = RequireSessionContract(playerId); + if (lease == null) + { + return null; + } + + // 授权作用域不可得 = 依赖不可用:与会话域桥缺席同形态返回空凭据并披露,不以空作用域调用契约。 + var issueScope = BuildScope(playerId); + if (issueScope == null) { // Localization: Hotfix.SessionResume.BridgeUnavailable - 会话域桥不可用(未发布或已撤销), PlayerId: {0}, Code: {1} LogHelper.Error(LocalizationService.GetString(Localization.Keys.Hotfix.SessionResume.BridgeUnavailable, playerId, OnlineErrorCode.DependencyUnavailable)); return null; } - var issued = await lease.IssueForLoginAsync(BuildScope(lease, playerId), connectionId); + var issued = await lease.IssueForLoginAsync(issueScope, connectionId); if (!issued.IsSuccess) { // Localization: Hotfix.SessionResume.IssueFailed - 登录签发会话恢复凭据失败, Code: {0}, PlayerId: {1} @@ -102,16 +109,22 @@ public static async Task ResumeAsync(INetworkChannel netwo return SessionResumeOutcome.Fail(OnlineErrorCode.ParameterInvalid, "连接会话标识缺失"); } - var lease = OnlineSessionDomainBridgeHolder.Current; - if (lease == null || !lease.IsActive) + var lease = RequireSessionContract(0); + if (lease == null) { - // Localization: Hotfix.SessionResume.BridgeUnavailable - 会话域桥不可用(未发布或已撤销), PlayerId: {0}, Code: {1} - LogHelper.Error(LocalizationService.GetString(Localization.Keys.Hotfix.SessionResume.BridgeUnavailable, 0L, OnlineErrorCode.DependencyUnavailable)); return SessionResumeOutcome.Fail(OnlineErrorCode.DependencyUnavailable, "会话域桥不可用(宿主未运行或已停止)"); } // 恢复主体由 Token 解析得出(玩家位 0 = 待解析;请求体字段不可影响作用域)。 - var resumeScope = new OnlineScope(lease.AuthorizedScope.TenantId, lease.AuthorizedScope.AppId, lease.AuthorizedScope.ServerId, 0); + var resumeScope = BuildScope(0); + if (resumeScope == null) + { + // 授权作用域不可得 = 依赖不可用:身份恢复显式失败,不以空作用域调用契约、不降级。 + // Localization: Hotfix.SessionResume.BridgeUnavailable - 会话域桥不可用(未发布或已撤销), PlayerId: {0}, Code: {1} + LogHelper.Error(LocalizationService.GetString(Localization.Keys.Hotfix.SessionResume.BridgeUnavailable, 0, OnlineErrorCode.DependencyUnavailable)); + return SessionResumeOutcome.Fail(OnlineErrorCode.DependencyUnavailable, "授权作用域不可得(作用域提供者桥未发布或已撤销)"); + } + var resumed = await lease.ResumeAsync(resumeScope, token, sessionId); if (!resumed.IsSuccess) { @@ -139,8 +152,8 @@ public static async Task ResumeAsync(INetworkChannel netwo OnlineMatchDomainResume matchResume = null; var matchFailureCode = OnlineErrorCode.None; var matchFailureMessage = string.Empty; - var matchLease = OnlineMatchDomainBridgeHolder.Current; - if (matchLease == null || !matchLease.IsActive) + var matchContract = TryGetMatchContract(); + if (matchContract == null) { // 匹配域桥缺席(online-match 被排除的部署形态):身份恢复仍成立,对局恢复面缺位披露。 matchFailureMessage = "匹配域桥不可用(宿主未运行或已停止)"; @@ -148,16 +161,27 @@ public static async Task ResumeAsync(INetworkChannel netwo } else { - var outcome = await matchLease.TryResumeForPlayerAsync(BuildScope(matchLease, playerId), lastAcknowledgedSequence); - if (outcome.IsSuccess) + var matchScope = BuildScope(playerId); + if (matchScope == null) { - matchResume = outcome.Data; + // 授权作用域不可得 = 依赖不可用:对局恢复按失败回显(身份恢复仍成立),不以空作用域调用契约。 + matchFailureCode = OnlineErrorCode.DependencyUnavailable; + matchFailureMessage = "授权作用域不可得(作用域提供者桥未发布或已撤销)"; + LogBridgeFailed(matchFailureCode, matchFailureMessage, playerId); } else { - matchFailureCode = outcome.Code; - matchFailureMessage = outcome.Message; - LogBridgeFailed(outcome.Code, outcome.Message, playerId); + var outcome = await matchContract.TryResumeForPlayerAsync(matchScope, lastAcknowledgedSequence); + if (outcome.IsSuccess) + { + matchResume = outcome.Data; + } + else + { + matchFailureCode = outcome.Code; + matchFailureMessage = outcome.Message; + LogBridgeFailed(outcome.Code, outcome.Message, playerId); + } } } @@ -182,21 +206,42 @@ public static async Task NotifyPlayerDisconnectedAsync(long playerId, string dis } var sessionOutcome = "skipped"; - var sessionLease = OnlineSessionDomainBridgeHolder.Current; - if (sessionLease != null && sessionLease.IsActive) + var sessionContract = TryGetSessionContract(); + if (sessionContract != null) { - var notified = await sessionLease.NotifyPlayerDisconnectedAsync(BuildScope(sessionLease, playerId), ReconnectWindowSeconds); - sessionOutcome = notified.IsSuccess ? "ok" : "failed:" + notified.Code; + var sessionScope = BuildScope(playerId); + if (sessionScope == null) + { + // 授权作用域不可得 = 依赖不可用:显式记失败并跳过开窗,不以空作用域调用契约。 + sessionOutcome = "failed:" + OnlineErrorCode.DependencyUnavailable; + // Localization: Hotfix.SessionResume.BridgeUnavailable - 会话域桥不可用(未发布或已撤销), PlayerId: {0}, Code: {1} + LogHelper.Error(LocalizationService.GetString(Localization.Keys.Hotfix.SessionResume.BridgeUnavailable, playerId, OnlineErrorCode.DependencyUnavailable)); + } + else + { + var notified = await sessionContract.NotifyPlayerDisconnectedAsync(sessionScope, ReconnectWindowSeconds); + sessionOutcome = notified.IsSuccess ? "ok" : "failed:" + notified.Code; + } } var matchOutcome = "skipped"; - var matchLease = OnlineMatchDomainBridgeHolder.Current; - if (matchLease != null && matchLease.IsActive) + var matchContract = TryGetMatchContract(); + if (matchContract != null) { - var notified = await matchLease.NotifyPlayerDisconnectedAsync(BuildScope(matchLease, playerId)); - matchOutcome = notified.IsSuccess - ? notified.Data != null && notified.Data.Marked ? "marked:" + notified.Data.MatchId : "no-match" - : "failed:" + notified.Code; + var matchScope = BuildScope(playerId); + if (matchScope == null) + { + // 授权作用域不可得 = 依赖不可用:显式记失败并跳过断线标记,不以空作用域调用契约。 + matchOutcome = "failed:" + OnlineErrorCode.DependencyUnavailable; + LogBridgeFailed(OnlineErrorCode.DependencyUnavailable, "授权作用域不可得(作用域提供者桥未发布或已撤销)", playerId); + } + else + { + var notified = await matchContract.NotifyPlayerDisconnectedAsync(matchScope); + matchOutcome = notified.IsSuccess + ? notified.Data != null && notified.Data.Marked ? "marked:" + notified.Data.MatchId : "no-match" + : "failed:" + notified.Code; + } } // Localization: Hotfix.SessionResume.DisconnectCoordination - 断线两层协同, PlayerId: {0}, 会话域结果: {1}, 对局域结果: {2} @@ -219,25 +264,73 @@ private static Task NotifyDuplicateLoginAsync(IPlayerSession oldSession) } /// - /// 以租约授权三元组 + 玩家位构造作用域。 + /// 读取会话域桥契约(不可用即显式失败记录;逐请求 ,不缓存)。 + /// + /// 请求主体(日志披露用)。 + /// 契约面;桥不可用返回 null。 + private static IOnlineSessionReconnectContract RequireSessionContract(long playerId) + { + var contract = TryGetSessionContract(); + if (contract == null) + { + // Localization: Hotfix.SessionResume.BridgeUnavailable - 会话域桥不可用(未发布或已撤销), PlayerId: {0}, Code: {1} + LogHelper.Error(LocalizationService.GetString(Localization.Keys.Hotfix.SessionResume.BridgeUnavailable, playerId, OnlineErrorCode.DependencyUnavailable)); + } + + return contract; + } + + /// + /// 读取会话域桥契约(缺席 / 撤销竞态返回 null,不打日志——缺席语义由调用方决定)。 + /// + /// 契约面;桥不可用返回 null。 + private static IOnlineSessionReconnectContract TryGetSessionContract() + { + if (CapabilityBridge.TryAcquire(out var lease)) + { + try + { + return lease.Contract; + } + catch (CapabilityBridgeUnavailableException) + { + // 获取与读取之间的撤销竞态:与缺席同语义,不降级。 + } + } + + return null; + } + + /// + /// 读取匹配域桥契约(缺席 / 撤销竞态返回 null——online-match 为可选能力,缺席为合法部署形态)。 /// - /// 会话域桥租约。 - /// 玩家标识。 - /// 生效作用域。 - private static OnlineScope BuildScope(OnlineSessionDomainBridgeLease lease, long playerId) + /// 契约面;桥不可用返回 null。 + private static IOnlineMatchDomain TryGetMatchContract() { - return new OnlineScope(lease.AuthorizedScope.TenantId, lease.AuthorizedScope.AppId, lease.AuthorizedScope.ServerId, playerId); + if (CapabilityBridge.TryAcquire(out var lease)) + { + try + { + return lease.Contract; + } + catch (CapabilityBridgeUnavailableException) + { + // 获取与读取之间的撤销竞态:与缺席同语义,不降级。 + } + } + + return null; } /// - /// 以租约授权三元组 + 玩家位构造作用域(匹配域桥形态)。 + /// 以授权三元组 + 玩家位构造作用域(三元组经 读取; + /// 提供者桥缺席 / 撤销时返回 null,调用方须按依赖不可用显式失败,不得把 null 传入契约)。 /// - /// 匹配域桥租约。 - /// 玩家标识。 - /// 生效作用域。 - private static OnlineScope BuildScope(OnlineMatchDomainBridgeLease lease, long playerId) + /// 玩家标识(恢复前为 0 = 待 Token 解析)。 + /// 生效作用域;授权作用域不可得时为 null。 + private static OnlineScope BuildScope(long playerId) { - return new OnlineScope(lease.AuthorizedScope.TenantId, lease.AuthorizedScope.AppId, lease.AuthorizedScope.ServerId, playerId); + return OnlineAuthorizedScopeReader.BuildRequestScope(playerId); } /// diff --git a/GameFrameX.Launcher/Online/HostPlayerDirectory.cs b/GameFrameX.Launcher/Online/HostPlayerDirectory.cs new file mode 100644 index 000000000..b78beb8b7 --- /dev/null +++ b/GameFrameX.Launcher/Online/HostPlayerDirectory.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 refer to 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, +// 侵犯他人合法权益等法律法规所禁止的行为! +// Any legal disputes and 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 +// CNB Repository: https://cnb.cool +// 官方文档:https://gameframex.doc.alianblank.com/ +// Official documentation: https://gameframex.doc.alianblank.com/ +// ========================================================================================== + + +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using GameFrameX.Apps.Player.Player.Entity; +using GameFrameX.Capability.Online.Abstractions.Contracts; +using GameFrameX.DataBase; +using GameFrameX.Utility.Setting; + +namespace GameFrameX.Launcher.Online; + +/// +/// 宿主侧玩家目录实现( 的数据源落地,C294): +/// 以本进程业务库的 (玩家展示名权威归属)为目录数据源—— +/// 按名字搜索 = 展示名包含匹配(稳定序 = 玩家标识升序),批量解析 = 按标识集合读取。 +/// +/// 维护约束:组合发生在业务库初始化之后(模板序列:数据库 → 热更装载 → 能力组合),数据源可达性由 +/// 模板顺序保证;查询直读业务库(无缓存)——目录只服务好友搜索与展示名解析,量级与索引以业务库现状为准; +/// 空白关键字不返回结果(契约约定); 为 null 的档案不出现在结果中。 +/// +/// +/// +/// The host-side player-directory implementation (the data-source landing of +/// , C294): the directory source is this process's business-database +/// (the authoritative home of the player display name) — name search is a +/// display-name contains-match (stable order = player id ascending) and batch resolution reads by the +/// id set. Composition happens after the business database is initialized (template order: databases → +/// hotfix load → capability composition), so the source is reachable by construction; queries read the +/// business database directly (no cache) — the directory only serves friend search and display-name +/// resolution; blank keywords return no results (per the contract); profiles with a null +/// never appear in the results. +/// +public sealed class HostPlayerDirectory : IOnlinePlayerDirectory +{ + /// + public async Task> SearchByNameAsync(long tenantId, long appId, string keyword, int limit, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(keyword) || limit <= 0) + { + return new List(); + } + + var trimmedKeyword = keyword.Trim(); + var states = await GameDb.FindListAsync(state => state.Name != null && state.Name.Contains(trimmedKeyword), cancellationToken); + return states.OrderBy(state => state.Id) + .Take(limit) + .Select(state => new OnlinePlayerDirectoryEntry + { + PlayerId = state.Id, + Name = state.Name, + ServerId = GlobalSettings.CurrentSetting == null ? 0 : GlobalSettings.CurrentSetting.ServerId, + }) + .ToList(); + } + + /// + public async Task> FindAsync(long tenantId, long appId, IReadOnlyList playerIds, CancellationToken cancellationToken = default) + { + if (playerIds == null || playerIds.Count == 0) + { + return new List(); + } + + var states = await GameDb.FindByIdsAsync(playerIds, cancellationToken); + return states.Where(state => state.Name != null) + .OrderBy(state => state.Id) + .Select(state => new OnlinePlayerDirectoryEntry + { + PlayerId = state.Id, + Name = state.Name, + ServerId = GlobalSettings.CurrentSetting == null ? 0 : GlobalSettings.CurrentSetting.ServerId, + }) + .ToList(); + } +} diff --git a/GameFrameX.Launcher/Online/HotfixManagerRollbackExecutor.cs b/GameFrameX.Launcher/Online/HotfixManagerRollbackExecutor.cs index f731b1aac..a2c8ce9c4 100644 --- a/GameFrameX.Launcher/Online/HotfixManagerRollbackExecutor.cs +++ b/GameFrameX.Launcher/Online/HotfixManagerRollbackExecutor.cs @@ -39,7 +39,7 @@ namespace GameFrameX.Launcher.Online; /// /// Hotfix 版本切换执行器(C248 生产实现,vault R-01 / D-3 拍板 = 补实现): /// 组合既有公共原语完成方向无关的本进程热更版本切换,供组合根经 -/// UseOnlineHotfixRollbackExecutor 注入(Operations 侧服务探测即得非 null,消除恒缺席 8002)。 +/// Override<IOnlineHotfixRollbackExecutor> 注入(Operations 侧 RequireHostService 即得非 null,消除恒缺席 8002)。 /// /// 切换时序不变量(红线): /// ① 单次装载入口——切换 = 单次 : diff --git a/GameFrameX.Launcher/Online/LogHelperCapabilityAssemblyLog.cs b/GameFrameX.Launcher/Online/LogHelperCapabilityAssemblyLog.cs new file mode 100644 index 000000000..4f16db9a5 --- /dev/null +++ b/GameFrameX.Launcher/Online/LogHelperCapabilityAssemblyLog.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 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 refer to 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, +// 侵犯他人合法权益等法律法规所禁止的行为! +// Any legal disputes and 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 +// CNB Repository: https://cnb.cool +// 官方文档:https://gameframex.doc.alianblank.com/ +// Official documentation: https://gameframex.doc.alianblank.com/ +// ========================================================================================== + + +using GameFrameX.Capability.Hosting.Abstractions; +using GameFrameX.Foundation.Logger; + +namespace GameFrameX.Launcher.Online; + +/// +/// 装配日志的 LogHelper 桥接( 的宿主实现,C294): +/// 机制的装配期日志(开始 / 排除 / 逐能力汇总 / 可选缺失 / 完成 = Info,必选缺失 / 环路 / 汇总失败 = Error) +/// 统一进 ,文案由机制层本地化后传入。 +/// +/// +/// The LogHelper bridge of the assembly log (the host implementation of +/// , C294): the mechanism's composition-time logs (start / +/// exclusion / per-capability summaries / optional absence / completion = Info, mandatory absence / +/// cycles / aggregate failure = Error) flow into with messages already +/// localized by the mechanism layer. +/// +public sealed class LogHelperCapabilityAssemblyLog : ICapabilityAssemblyLog +{ + /// + public void Info(string message) + { + LogHelper.Info(message); + } + + /// + public void Error(string message) + { + LogHelper.Error(message); + } +} diff --git a/GameFrameX.Launcher/Online/OnlineAdminApiComposition.cs b/GameFrameX.Launcher/Online/OnlineAdminApiComposition.cs deleted file mode 100644 index 4b2a74078..000000000 --- a/GameFrameX.Launcher/Online/OnlineAdminApiComposition.cs +++ /dev/null @@ -1,211 +0,0 @@ -// ========================================================================================== -// GameFrameX 组织及其衍生项目的版权、商标、专利及其他相关权利 -// GameFrameX organization and its derivative projects' copyrights, trademarks, patents and related rights -// 均受中华人民共和国及相关国际法律法规保护。 -// are protected by the laws of the PRC 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 refer to the LICENSE file at the root 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, -// 本组织与贡献者概不承担任何责任。 -// 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://github.com/GameFrameX -// CNB 仓库:https://www.cnb.cool/GameFrameX -// CNB Repository: https://www.cnb.cool/GameFrameX -// 官方文档:https://gameframex.doc.alianblank.com/ -// Official Documentation: https://gameframex.doc.alianblank.com/ -// ========================================================================================== - -using GameFrameX.Capability.Hosting.Abstractions; -using GameFrameX.Capability.Hosting.Core; -using GameFrameX.Capability.Hosting.AdminApi; -using GameFrameX.Capability.Online.Abstractions.Contracts; -using GameFrameX.Capability.Online.Abstractions.Idempotency; -using GameFrameX.Capability.Online.Abstractions.Scope; -using GameFrameX.Foundation.Localization.Core; - -namespace GameFrameX.Launcher.Online; - -/// -/// Online 管理面组合工厂(注册反转的组合点:能力模块贡献 action → 通用管道 + Online 适配器组装; -/// 业务与管理面可独立启停——admin-only 形态按能力目录全部「未装配」裁决)。 -/// -/// 维护约束:C229 起 Launcher 经 AddOnlineCapabilities() 单入口装配,本工厂由入口内部调用; -/// C232 P6 随 Runtime 壳退役迁入 Launcher——业务形态签名改为机制 (作用域 / -/// 幂等经宿主共享服务面解析),admin-only 目录由 扫描驱动 -/// (手工 Catalog 随壳退役;manifest 与来源程序集等价)。 -/// 组合期对账 fail-fast——启用能力的 manifest 声明 action 必须全部实际注册。 -/// -/// -/// -/// The Online admin-plane composition factory (the registration-inversion composition point: -/// capability modules contribute actions, the generic pipeline plus Online adapters assemble around -/// them; business and admin planes can start and stop independently — the admin-only shape rules -/// every catalog capability as not installed). Since C229 the Launcher assembles through the -/// AddOnlineCapabilities() single entry which invokes this factory; C232 P6 relocates it into -/// the Launcher along with the Runtime-shell retirement — the full-capability signature now takes the -/// mechanism (scope / idempotency resolve through the host's shared -/// service face), and the admin-only catalog is driven by -/// scanning (the hand-written catalog retired with the shell; manifests and source assemblies -/// equivalent). Composition-time reconciliation fails fast — every action declared by an enabled -/// manifest must actually be registered. -/// -public static class OnlineAdminApiComposition -{ - /// - /// 组装管理面调度器(业务运行时形态:全部能力经模块注册,报告取自宿主)。 - /// - /// 业务能力宿主(已装配;作用域与幂等经共享服务面解析)。 - /// 通用管理面调度器。 - public static AdminApiDispatcher CreateDispatcher(CapabilityHost host) - { - if (host == null) - { - throw new ArgumentNullException(nameof(host)); - } - - var registry = new AdminActionRegistry(); - foreach (var module in host.Modules) - { - if (module is IAdminActionContributor contributor) - { - contributor.RegisterAdminActions(registry); - } - } - - ReconcileDeclaredActions(registry, host.Report); - - return new AdminApiDispatcher( - registry, - OnlineAdminApiAdapters.ErrorCodes, - OnlineAdminApiAdapters.CreateScopeAdapter(host.Services.GetService()), - new AdminApiClaimedScope(host.TenantId, host.AppId, host.ServerId), - OnlineAdminApiAdapters.CreateIdempotencyAdapter(host.Services.GetService()), - host.Report); - } - - /// - /// 组装管理面调度器(admin-only 形态:零能力装配,action 一律按未安装裁决)。 - /// - /// 授权作用域选项(必须合法,否则抛 )。 - /// 能力装配过滤器(null = 目录全部 NotAssembled;传入时排除/白名单外条目如实反映配置排除,其余仍为未装配,C229)。 - /// 通用管理面调度器。 - public static AdminApiDispatcher CreateAdminOnlyDispatcher(CapabilityHostOptions scopeOptions, CapabilityAssemblyFilter filter = null) - { - if (scopeOptions == null) - { - throw new ArgumentNullException(nameof(scopeOptions)); - } - - if (!scopeOptions.IsValid()) - { - // Localization: Capability.Hosting.OptionsInvalid - 能力宿主选项非法(作用域三元组 / 调度间隔)。 - throw new InvalidOperationException(LocalizationService.GetString(Localization.Keys.Capability.Hosting.OptionsInvalid)); - } - - var entries = CapabilityAssembly.DiscoverModules(OnlineCapabilityComposition.LoadCapabilityAssemblies()) - .Select(module => new CapabilityReportEntry(module.Manifest, module.GetType().Assembly.GetName().Name, MapCatalogState(filter, module.Manifest))) - .ToArray(); - var report = new CapabilityReport(entries, Array.Empty()); - return new AdminApiDispatcher( - new AdminActionRegistry(), - OnlineAdminApiAdapters.ErrorCodes, - OnlineAdminApiAdapters.CreateScopeAdapter(new OnlineScope(scopeOptions.TenantId, scopeOptions.AppId, scopeOptions.ServerId)), - new AdminApiClaimedScope(scopeOptions.TenantId, scopeOptions.AppId, scopeOptions.ServerId), - null, - report); - } - - /// - /// 组装管理面 HTTP 服务(业务运行时形态)。 - /// - /// 业务能力宿主。 - /// 管理面监听选项。 - /// 管理面 HTTP 服务。 - public static AdminApiServer CreateServer(CapabilityHost host, AdminApiOptions options) - { - ValidateAdminOptions(options); - return new AdminApiServer(options, CreateDispatcher(host)); - } - - /// - /// 组装管理面 HTTP 服务(admin-only 形态)。 - /// - /// 授权作用域选项。 - /// 管理面监听选项。 - /// 能力装配过滤器(null = 目录全部 NotAssembled;传入时配置排除/白名单外条目如实反映,其余仍为未装配,C229)。 - /// 管理面 HTTP 服务。 - public static AdminApiServer CreateAdminOnlyServer(CapabilityHostOptions scopeOptions, AdminApiOptions options, CapabilityAssemblyFilter filter = null) - { - ValidateAdminOptions(options); - return new AdminApiServer(options, CreateAdminOnlyDispatcher(scopeOptions, filter)); - } - - /// - /// 校验管理面监听选项(端口范围与前缀;fail-fast,等价拆分前宿主选项校验的管理面半边)。 - /// - /// 管理面监听选项。 - private static void ValidateAdminOptions(AdminApiOptions options) - { - if (options == null || !options.IsValid()) - { - // Localization: Capability.Hosting.AdminApi.OptionsInvalid - 管理面监听选项非法(端口 / 前缀)。 - throw new InvalidOperationException(LocalizationService.GetString(Localization.Keys.Capability.Hosting.AdminApi.AdminApiOptionsInvalid)); - } - } - - /// - /// admin-only 目录状态映射:过滤器判定为排除/白名单外的条目如实反映配置排除; - /// 未被排除的条目仍为未装配(本进程未启动业务运行时,C229)。 - /// - /// 能力装配过滤器(可为 null)。 - /// 目录 manifest。 - /// 报告条目状态。 - private static CapabilityFilterState MapCatalogState(CapabilityAssemblyFilter filter, CapabilityManifest manifest) - { - var matched = filter?.Match(manifest) ?? CapabilityFilterState.NotAssembled; - return matched switch - { - CapabilityFilterState.ExcludedByDisableList => CapabilityFilterState.ExcludedByDisableList, - CapabilityFilterState.NotInEnableList => CapabilityFilterState.NotInEnableList, - _ => CapabilityFilterState.NotAssembled, - }; - } - - - /// - /// 组合期对账(声明 ⊆ 实注册;fail-fast)。 - /// - /// action 注册表。 - /// 能力报告。 - private static void ReconcileDeclaredActions(AdminActionRegistry registry, CapabilityReport report) - { - foreach (var entry in report.Entries) - { - if (entry.State != CapabilityFilterState.Enabled) - { - continue; - } - - foreach (var action in entry.Manifest.Actions) - { - if (registry.Find(action) == null) - { - // Localization: Capability.Hosting.AdminApi.ActionNotRegistered - 能力声明的 action 未注册。 - throw new InvalidOperationException(string.Format(LocalizationService.GetString(Localization.Keys.Capability.Hosting.AdminApi.ActionNotRegistered), action, entry.Manifest.Id)); - } - } - } - } -} diff --git a/GameFrameX.Launcher/Online/OnlineCapabilityComposition.cs b/GameFrameX.Launcher/Online/OnlineCapabilityComposition.cs deleted file mode 100644 index af430d102..000000000 --- a/GameFrameX.Launcher/Online/OnlineCapabilityComposition.cs +++ /dev/null @@ -1,415 +0,0 @@ -// ========================================================================================== -// GameFrameX 组织及其衍生项目的版权、商标、专利及其他相关权利 -// GameFrameX organization and its derivative projects' copyrights, trademarks, patents and related rights -// 均受中华人民共和国及相关国际法律法规保护。 -// are protected by the laws of the PRC 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 refer to the LICENSE file at the root 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, -// 本组织与贡献者概不承担任何责任。 -// 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://github.com/GameFrameX -// CNB 仓库:https://www.cnb.cool/GameFrameX -// CNB Repository: https://www.cnb.cool/GameFrameX -// 官方文档:https://gameframex.doc.alianblank.com/ -// Official Documentation: https://gameframex.doc.alianblank.com/ -// ========================================================================================== - -using System.IO; -using System.Reflection; -using GameFrameX.Capability.Hosting.Abstractions; -using GameFrameX.Capability.Hosting.Core; -using GameFrameX.Capability.Hosting.AdminApi; -using GameFrameX.Capability.Online.Abstractions.Contracts; -using GameFrameX.Capability.Online.Abstractions.Events; -using GameFrameX.Capability.Online.Abstractions.Idempotency; -using GameFrameX.Capability.Online.Competition.Leaderboard; -using GameFrameX.Capability.Online.Operations; -using GameFrameX.Capability.Online.Operations.HotfixRollback; -using GameFrameX.Capability.Online.Operations.Audit; -using GameFrameX.Capability.Online.Persistence; -using GameFrameX.Capability.Online.Abstractions.Scope; -using GameFrameX.Foundation.Idempotency; -using GameFrameX.Foundation.Localization.Core; - -namespace GameFrameX.Launcher.Online; - -/// -/// Online 能力组合编排选项( 回调承载面,C229; -/// C232 P6 编排随 Runtime 壳退役迁入 Launcher 组合根)。 -/// 作用域三元组、调度间隔、双闸(EnableRuntime / EnableAdminApi)、管理面监听与能力过滤器 -/// (Capabilities 白名单 / Disable 排除表)统一经回调写入(.NET AddXxx(options => ...) 惯例)。 -/// -/// -/// Orchestration options carried by the -/// callback (C229; relocated into the Launcher composition root by C232 P6 along with the Runtime-shell -/// retirement): the scope triple, scheduler interval, the two enable gates -/// ( / ), the admin listening options and the -/// capability filter ( whitelist / exclusion list) all -/// arrive through the callback, following the .NET AddXxx(options => ...) convention. -/// -public sealed class OnlineCapabilityCompositionOptions -{ - /// - /// 授权租户标识(作用域三元组成员;跨租户请求按 3002 拒绝) - /// - /// - /// Authorized tenant id (part of the scope triple; cross-tenant requests are rejected with 3002). - /// - public long TenantId { get; set; } - - /// - /// 授权应用标识(作用域三元组成员;跨 App 请求按 3003 拒绝) - /// - /// - /// Authorized app id (part of the scope triple; cross-app requests are rejected with 3003). - /// - public long AppId { get; set; } - - /// - /// 宿主服务器标识(作用域三元组成员;跨服请求按 3004 拒绝) - /// - /// - /// Host server id (part of the scope triple; cross-server requests are rejected with 3004). - /// - public long ServerId { get; set; } - - /// - /// 调度滴答间隔(毫秒,默认 1000;小于 100 由调度壳钳制为 100) - /// - /// - /// Scheduler tick interval in milliseconds (default 1000; values below 100 are clamped to 100 by the scheduler). - /// - public int SchedulerIntervalMilliseconds { get; set; } = 1000; - - /// - /// 是否装配业务运行时(对应 AppSetting.IsEnableOnline;false 时不构造业务能力宿主) - /// - /// - /// Whether to assemble the business runtime (mirrors AppSetting.IsEnableOnline); - /// when false no business capability host is constructed at all. - /// - public bool EnableRuntime { get; set; } - - /// - /// 是否装配管理面(对应 AppSetting.IsEnableOnlineAdmin;业务运行时在则全能力形态,不在则 admin-only 形态) - /// - /// - /// Whether to assemble the admin plane (mirrors AppSetting.IsEnableOnlineAdmin); - /// full-capability shape when the runtime is assembled, admin-only shape otherwise. - /// - public bool EnableAdminApi { get; set; } - - /// - /// 管理面监听选项(null = 缺省 28090 / online-admin) - /// - /// - /// Admin listening options (null = defaults 28090 / online-admin). - /// - public AdminApiOptions AdminApi { get; set; } - - /// - /// 能力白名单(能力 Id 列表,如 online-assets);null 或空 = 全装配;白名单未含依赖时组合期闭包校验 fail-fast - /// - /// - /// Capability enable whitelist (capability ids, e.g. online-assets); null or empty assembles - /// everything, and a whitelist that omits a required dependency fails fast at composition time. - /// - public IReadOnlyList Capabilities { get; set; } - - /// - /// 能力排除表(能力 Id 列表);null 或空 = 无排除 - /// - /// - /// Capability exclusion list (capability ids); null or empty excludes nothing. - /// - public IReadOnlyList Disable { get; set; } - - /// - /// 是否预置 Online 一级 store 的 GameDb 持久化实现(C249,D3 = 3A 分级混合) - /// true = Identity / 玩家云存储 / 资产账本与交易四 store 经 UseOnlineGameDbStores 预置进共享服务面(模块 Register 期探测命中);false(缺省)= InMemory 默认不变(零依赖起跑红线) - /// 前置约束:数据库须先于能力装配完成初始化(GameDb 门面 Init);Session / Presence 二级易失 store 不受本开关影响 - /// - /// - /// Whether to pre-seed the GameDb persistence adapters for the Online level-one stores (C249, - /// D3 = 3A): true pre-seeds the identity / player-storage / asset-ledger and asset-transaction - /// stores through UseOnlineGameDbStores into the shared service face (probed at module - /// Register time); false (default) keeps the in-memory defaults unchanged (the zero-dependency - /// boot guarantee). The database must be initialized before capability assembly; the volatile - /// level-two stores (sessions / presence) are unaffected by this switch. - /// - public bool UseGameDbStores { get; set; } - - /// - /// 对局结算写榜榜单标识(C245):非空 = 预置 OnlineLeaderboardProjectionOptions 共享服务, - /// online-competition 模块据此装配 Match.Settled 可信结果投影(要求 online-match / online-competition 能力在装配面); - /// 空(缺省)= 不预置,投影不装配。 - /// - /// - /// The leaderboard id that settled match results project onto (C245): non-empty pre-seeds the - /// OnlineLeaderboardProjectionOptions shared service from which the online-competition - /// module assembles the Match.Settled trusted-result projection (requires the online-match / - /// online-competition capabilities on the assembly face); empty (default) pre-seeds nothing and - /// the projection stays unassembled. - /// - public string MatchBridgeLeaderboardId { get; set; } -} - -/// -/// Online 能力组合产物(业务运行时与管理面一次组装的双面产物,C229)。 -/// 启动 / 停止由组合根按序编排:业务先启后停、管理面后启先停;本产物只承载装配结果。 -/// -/// -/// The composed product of a single call (C229): the business -/// runtime and the admin plane are assembled together, each face remaining null when its gate is off. -/// Start/stop orchestration stays with the composition root (business first to start and last to -/// stop); this type only carries the assembled result. -/// -public sealed class OnlineCapabilityComposition -{ - /// - /// 业务能力宿主( 为 false 时为 null; - /// C232 P6 起真扫描产物——模块发现经 ,启动 / 停止语义与壳时代一致)。 - /// - /// - /// The business capability host; null when - /// is false. Since C232 P6 it is the product of real scanning (module discovery via - /// ) with start/stop semantics unchanged from the shell era. - /// - public CapabilityHost Runtime { get; } - - /// - /// 管理面 HTTP 服务( 为 false 时为 null; - /// 业务运行时在则全能力形态,不在则 admin-only 形态)。 - /// - /// - /// The admin HTTP service; null when - /// is false. Full-capability shape when the runtime is assembled, admin-only shape otherwise. - /// - public AdminApiServer AdminApi { get; } - - /// - /// 初始化组合产物(仅经 构造)。 - /// - /// 业务能力宿主(可为 null)。 - /// 管理面 HTTP 服务(可为 null)。 - private OnlineCapabilityComposition(CapabilityHost runtime, AdminApiServer adminApi) - { - Runtime = runtime; - AdminApi = adminApi; - } - - /// - /// Online 能力单入口:一次回调编排装配业务运行时与管理面(C229;C232 P6 编排迁入 Launcher 且升级为 - /// 真扫描生产采用)。 - /// 入口内部经 装配:组合根基建预置(授权作用域 / 事件总线 / - /// 事件出口 / 幂等 / 统一审计)+ 扫描发现全部能力模块 - /// (扫描面 = 本组合根程序集引用面)+ 过滤器与拓扑闭包校验由机制 Build 承担。 - /// 能力过滤器与运行时报告、admin-only 目录共享同一份配置;未知能力 Id / 白名单闭包缺口 / 选项非法 - /// 均组合期 fail-fast。 - /// - /// - /// The single Online capability entry point: one callback orchestrates both the business runtime - /// and the admin plane (C229; relocated into the Launcher by C232 P6 and upgraded to real-scanning - /// production adoption). The entry assembles through : the - /// composition-root infrastructure pre-seed (authorized scope / event bus / event outlet / - /// idempotency / unified audit), module discovery via - /// over the composition-root assembly reference face, and filter plus topological closure validation - /// handled by the mechanism's Build. The filter is shared between the runtime report and the - /// admin-only catalog; unknown capability ids, whitelist closure gaps and invalid options all fail - /// fast at composition time. - /// - /// 编排回调(null 拒绝;至少启用一个面)。 - /// 双面组合产物(未启用的面为 null)。 - /// configure 为 null。 - /// 双闸均未启用,或选项 / 过滤器未通过组合期校验。 - public static OnlineCapabilityComposition AddOnlineCapabilities(Action configure) - { - ArgumentNullException.ThrowIfNull(configure); - - var options = new OnlineCapabilityCompositionOptions(); - configure(options); - - if (!options.EnableRuntime && !options.EnableAdminApi) - { - // Localization: Capability.Hosting.Composition.NoPlaneEnabled - 双闸均未启用(业务与管理面至少启用其一)。 - throw new InvalidOperationException(LocalizationService.GetString(Localization.Keys.Capability.Hosting.Composition.NoPlaneEnabled)); - } - - var hostOptions = new CapabilityHostOptions - { - TenantId = options.TenantId, - AppId = options.AppId, - ServerId = options.ServerId, - SchedulerIntervalMilliseconds = options.SchedulerIntervalMilliseconds, - }; - var filter = BuildFilter(options.Capabilities, options.Disable); - - var runtime = options.EnableRuntime ? BuildScannedRuntime(hostOptions, filter, options.UseGameDbStores, options.MatchBridgeLeaderboardId) : null; - var adminOptions = options.AdminApi ?? new AdminApiOptions(); - var adminApi = options.EnableAdminApi - ? (runtime != null - ? OnlineAdminApiComposition.CreateServer(runtime, adminOptions) - : OnlineAdminApiComposition.CreateAdminOnlyServer(hostOptions, adminOptions, filter)) - : null; - - return new OnlineCapabilityComposition(runtime, adminApi); - } - - /// - /// 真扫描装配业务能力宿主(C232 P6 生产采用收口):组合根基建预置 + 模块扫描发现 + 机制构建。 - /// 基建预置与壳时代 OnlineRuntimeHost 构造段逐语句等价——授权作用域 / 事件总线 / - /// 事件出口 / 幂等 / 统一审计(具型 + 读面);地基三 store 与会话令牌下沉 Foundation 模块自装配 - /// (共享面经 AddService 回注,后序能力探测同源);Hotfix 切换执行器与登记期制品获取器 - /// 为生产装配面(C248 注入 HotfixManagerRollbackExecutor / OnlineHotfixArtifactFetcher, - /// Operations 模块探测即得非 null,替换缝仍开放)。 - /// - /// - /// Builds the business capability host through real scanning (the C232 P6 production adoption): - /// composition-root infrastructure pre-seed, scanned module discovery, then the mechanism build. - /// The pre-seed is statement-by-statement equivalent to the shell-era OnlineRuntimeHost - /// constructor — the authorized scope, event bus, event outlet, idempotency and unified audit - /// (concrete type plus read face); the three foundation stores and the session-token service - /// sink into the Foundation module's self-assembly (registered back into the shared face so later - /// capabilities resolve the same instances); since C248 the hotfix switch executor and the - /// registration-time artifact fetcher are production-wired (HotfixManagerRollbackExecutor / - /// OnlineHotfixArtifactFetcher, probed non-null by the Operations module, replacement - /// seams still open). - /// - /// 宿主选项(已通过合法性校验)。 - /// 能力装配过滤器(null = 全装配)。 - /// 是否预置一级 store 的 GameDb 持久化实现(C249;false = InMemory 默认不变)。 - /// 对局结算写榜榜单标识(空白 = 不预置投影选项;C245)。 - /// 业务能力宿主。 - private static CapabilityHost BuildScannedRuntime(CapabilityHostOptions hostOptions, CapabilityAssemblyFilter filter, bool useGameDbStores, string matchBridgeLeaderboardId) - { - var authorizedScope = new OnlineScope(hostOptions.TenantId, hostOptions.AppId, hostOptions.ServerId); - var eventBus = new InMemoryEventPublisher(); - var eventPublisher = new OnlineEventPublisher(eventBus); - var coordinator = new IdempotencyCoordinator(new InMemoryIdempotencyStore(), SystemClock.Instance, - new IdempotencyOptions { FailedReplayPolicy = FailedReplayPolicy.ReExecute, }); - var idempotency = new OnlineIdempotencyService(coordinator); - var audit = new OnlineAuditService(new InMemoryOnlineAuditStore()); - - var builder = new CapabilityHostBuilder(); - builder.UseService(authorizedScope); - builder.UseService(eventBus); - builder.UseService(eventPublisher); - builder.UseService(idempotency); - builder.UseService(audit); - builder.UseService(audit); - // C248:Hotfix 版本切换执行器 + 登记期制品获取器生产装配(消除「生产路径恒缺席」)—— - // Operations 模块 Register 期探测命中,切换命令不再恒 8002;切换时刻零下载红线由执行器路径静态保证。 - builder.UseOnlineHotfixRollbackExecutor(new HotfixManagerRollbackExecutor()); - builder.UseOnlineHotfixArtifactFetcher(new OnlineHotfixArtifactFetcher()); - if (useGameDbStores) - { - // C249 一级 store 持久化预置(D3 = 3A):模块 Register 期探测共享面命中即采用;Session / Presence 保持 InMemory。 - builder.UseOnlineGameDbStores(); - } - - if (!string.IsNullOrWhiteSpace(matchBridgeLeaderboardId)) - { - // C245 对局结算写榜投影目标预置:online-competition 模块 Register 期探测命中即装配 Match.Settled 投影订阅。 - builder.UseService(new OnlineLeaderboardProjectionOptions { MatchSettledBoardId = matchBridgeLeaderboardId }); - } - foreach (var module in CapabilityAssembly.DiscoverModules(LoadCapabilityAssemblies())) - { - builder.AddModule(module); - } - - builder.SetFilter(filter); - return builder.Build(hostOptions); - } - - /// - /// 加载部署目录内符合能力包命名约定(GameFrameX.Capability.*.dll)的全部程序集并返回 - /// (真扫描数据源)。 - /// .NET 懒加载下未被代码触碰的能力包程序集不在 AppDomain 已加载集, - /// 而程序集引用表(AssemblyRef)只记代码实际引用的类型,反射装配的组合根两处都靠不住; - /// 按部署目录命名约定圈定扫描面——新增能力 = 部署产物落地即被发现,宿主零改动 - /// (机制开放红线的生产采用面)。 - /// - /// - /// Loads and returns every assembly in the deployment directory matching the capability-package - /// naming convention (GameFrameX.Capability.*.dll), serving as the real-scanning source. - /// Under .NET lazy loading, capability packages untouched by code stay out of the loaded set, - /// and an assembly's reference table (AssemblyRef) only records types actually referenced by - /// code — a reflection-based composition root can rely on neither. The scan surface is therefore - /// pinned by the deployment-directory naming convention: a new capability is discovered by simply - /// landing its build output, with zero host changes (the production adoption of the - /// mechanism-openness guarantee). - /// - /// 部署目录内可加载的能力包程序集集合。 - internal static IReadOnlyList LoadCapabilityAssemblies() - { - var assemblies = new List(); - foreach (var path in Directory.GetFiles(AppContext.BaseDirectory, "GameFrameX.Capability.*.dll")) - { - try - { - assemblies.Add(Assembly.Load(AssemblyName.GetAssemblyName(path))); - } - catch (Exception) - { - // 解析失败的文件(原生库 / 损坏产物)跳过——扫描面只关心能力包程序集。 - } - } - - return assemblies; - } - - /// - /// 由配置列表构建装配过滤器(null / 空 / 全空白 = 相应维度不过滤——缺省全装配红线)。 - /// - /// - /// Builds the assembly filter from the configured lists; null, empty or all-blank lists skip the - /// corresponding dimension (the empty-filter-assembles-all guarantee). Entries are trimmed and - /// blank entries dropped; unknown ids fail fast inside the builder. - /// - /// 能力白名单(可为 null)。 - /// 能力排除表(可为 null)。 - /// 过滤器;两个维度都不过滤时为 null。 - private static CapabilityAssemblyFilter BuildFilter(IReadOnlyList capabilities, IReadOnlyList disable) - { - var enabledIds = NormalizeIds(capabilities); - var disabledIds = NormalizeIds(disable) ?? Array.Empty(); - - return enabledIds == null && disabledIds.Length == 0 ? null : new CapabilityAssemblyFilter(enabledIds, disabledIds); - } - - /// - /// 规整能力 Id 列表(trim、丢弃空白项);无有效条目时返回 null(该维度不过滤)。 - /// - /// - /// Normalizes a capability id list (trim, drop blank entries); returns null when nothing - /// meaningful remains, meaning the dimension does not filter. - /// - /// 原始列表(可为 null)。 - /// 规整后的数组;无有效条目时为 null。 - private static string[] NormalizeIds(IReadOnlyList source) - { - if (source == null) - { - return null; - } - - var ids = source.Where(id => !string.IsNullOrWhiteSpace(id)) - .Select(id => id.Trim()) - .ToArray(); - - return ids.Length == 0 ? null : ids; - } -} diff --git a/GameFrameX.Launcher/Online/OnlineCapabilityCompositionRoot.cs b/GameFrameX.Launcher/Online/OnlineCapabilityCompositionRoot.cs new file mode 100644 index 000000000..a49c90ab4 --- /dev/null +++ b/GameFrameX.Launcher/Online/OnlineCapabilityCompositionRoot.cs @@ -0,0 +1,301 @@ +// ========================================================================================== +// 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 refer to 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, +// 侵犯他人合法权益等法律法规所禁止的行为! +// Any legal disputes and 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 +// CNB Repository: https://cnb.cool +// 官方文档:https://gameframex.doc.alianblank.com/ +// Official documentation: https://gameframex.doc.alianblank.com/ +// ========================================================================================== + + +using System.Collections.Generic; +using System.Text.Json; +using System.Threading.Tasks; +using GameFrameX.Capability.Hosting.Abstractions; +using GameFrameX.Capability.Hosting.AdminApi; +using GameFrameX.Capability.Hosting.Core; +using GameFrameX.Capability.Online.Abstractions; +using GameFrameX.Capability.Online.Abstractions.Contracts; +using GameFrameX.Capability.Online.Assets; +using GameFrameX.Capability.Online.Foundation.Identity; +using GameFrameX.Capability.Online.Foundation.Storage; +using GameFrameX.Capability.Online.Operations.HotfixRollback; +using GameFrameX.Capability.Online.Persistence.Assets; +using GameFrameX.Capability.Online.Persistence.Identity; +using GameFrameX.Capability.Online.Persistence.Social; +using GameFrameX.Capability.Online.Persistence.Storage; +using GameFrameX.Capability.Online.Social; +using GameFrameX.Foundation.Logger; +using GameFrameX.Utility.Setting; + +namespace GameFrameX.Launcher.Online; + +/// +/// Online 能力族组合根(C294,替代退役的旧 Launcher 单入口装配): +/// 模板序列的固定步骤经本类型组装 online 族——构造 +///(族配置段 JSON + LogHelper 装配日志 + 双闸 + 作用域三元组 + 能力过滤器 + 管理面监听), +/// 经 产出 并启动。 +/// +/// 维护约束(红线): +/// ① 同族进程内单例——--AllInOne 多 Role 进程中首个 EnableRoles 命中的 Role 完成组合, +/// 其余命中 Role 直接复用( 的互斥闸),停止由组合 Role 的停机步承担; +/// ② 宿主扩展点全部走 Override<T>——Hotfix 版本切换执行器、玩家目录、 +/// GameDb 一级 store 覆盖(Online.UseGameDbStores,五 store 适配器来自 Persistence 包); +/// ③ 双闸全关(EnableRuntime 与 EnableAdminApi 均 false)时零装配零日志(现状等价); +/// ④ 族配置段以原始 JSON 重建(族级键 + 机制使用的 Settings 节点),能力级参数由机制 Options +/// 绑定器逐字段合并、未知键 fail-fast。 +/// +/// +/// +/// The Online capability-family composition root (C294, replacing the retired Launcher single-entry +/// assembly): the fixed template-sequence step assembles the online family through this +/// type — building the (the family configuration-section +/// JSON, the LogHelper assembly log, the two gates, the scope triple, the capability filter and the +/// admin listening options), producing a via +/// and starting it. Red lines: one composition per +/// process for the family — under --AllInOne the first role named by EnableRoles +/// composes and the rest reuse it (the mutual-exclusion gate in ), +/// with the stop owned by the composing role's shutdown step; every host extension point rides +/// Override<T> — the hotfix rollback executor, the player directory and the GameDb +/// level-one store overrides (Online.UseGameDbStores, the five adapters from the Persistence +/// package); with both gates off nothing is assembled and no log fires; the family configuration +/// section is rebuilt as raw JSON (family keys plus the Settings node consumed by the mechanism), +/// capability-level parameters merged field-by-field by the mechanism options binder with unknown +/// keys failing fast. +/// +public static class OnlineCapabilityCompositionRoot +{ + /// + /// 进程内组合产物(同族单例;未组合为 null)。 + /// + /// + /// The in-process composition product (the per-family singleton; null before composition). + /// + private static CapabilityRuntime _runtime; + + /// + /// 组合互斥闸(0 = 未组合 / 组合失败已复位,1 = 有 Role 正在组合或已完成组合)。 + /// 维护约束:只在双闸判定通过、真正要组装时才抢闸;组装或启动失败必须复位为 0, + /// 否则后续命中 EnableRoles 的 Role 会被静默跳过。 + /// + /// + /// The composition mutual-exclusion gate (0 = not composed or reset after a failed composition, + /// 1 = a role is composing or has composed). Claimed only once the two gates pass and assembly + /// really happens; reset to 0 on any composition or start failure. + /// + private static int _composed; + + /// + /// 组合者 Role 名(ServerType;未组合为 null)。族运行时的停止只归该 Role 的停机步(设计 §4.6)。 + /// + /// + /// The composing role name (ServerType; null before composition). Only that role's shutdown + /// step stops the family runtime. + /// + private static string _composerServerType; + + /// + /// 停止单次闸(0 = 未停止,1 = 已进入停止;停机幂等护栏的族级对应物)。 + /// + /// + /// The stop-once gate (0 = running, 1 = stopping/stopped; the family-level twin of the role + /// stop guard). + /// + private static int _stopped; + + /// + /// 按配置段组合并启动 Online 能力族(模板序列固定步骤的承载入口,热更装载之后、网络监听之前调用)。 + /// 判定链:"Online" 配置段缺席 → 不组合;EnableRoles(缺省 ["Game"])不含本 Role 名 → + /// 不组合;双闸全关 → 不组合(零装配零日志,且不占用互斥闸);进程内已有组合 → 复用(本 Role 不再装配)。 + /// 其余路径完成组装并启动(启动序 = 业务宿主 → 管理面),并记录本 Role 为组合者。 + /// 失败语义:组装或启动抛异常时,已创建的运行时先尽力停止(停止失败只记日志、不覆盖原异常), + /// 再清空产物并复位互斥闸,原异常继续上抛。 + /// + /// 当前 Role 的应用配置。 + /// 表示组合与启动操作的异步任务。 + public static async Task ComposeIfEnabledAsync(AppSetting setting) + { + var online = setting.Online; + if (online == null) + { + return; + } + + if (!IsRoleEnabled(online, setting.ServerType)) + { + return; + } + + if (!online.EnableRuntime && !online.EnableAdminApi) + { + return; + } + + // 同族进程内单例:首个命中 Role 完成组合,后续 Role 复用(D6)。 + if (System.Threading.Interlocked.CompareExchange(ref _composed, 1, 0) != 0) + { + return; + } + + try + { + var options = new CapabilityCompositionOptions(BuildFamilyConfigurationSection(setting), new LogHelperCapabilityAssemblyLog()) + { + EnableRuntime = online.EnableRuntime, + HostOptions = new CapabilityHostOptions + { + TenantId = setting.OnlineTenantId, + AppId = setting.OnlineAppId, + ServerId = setting.ServerId, + }, + Filter = new CapabilityAssemblyFilter(online.Capabilities, online.Disable), + }; + + if (online.EnableAdminApi) + { + options.UseAdminApi(adminOptions => + { + adminOptions.AdminPort = setting.OnlineAdminPort; + adminOptions.AdminApiPrefix = setting.OnlineAdminApiPrefix; + }); + } + + // 宿主扩展点(Override 注入,来源 = 宿主组合根):Hotfix 版本切换执行器与玩家目录。 + // 类型实参必须显式写成契约接口:描述符表按类型键索引,省略时推断为具型,模块的 RequireHostService<契约> 找不到覆盖。 + options.Override(new HotfixManagerRollbackExecutor(setting)); + options.Override(new HostPlayerDirectory()); + + // GameDb 一级 store 覆盖(Online.UseGameDbStores=true 时预置五个持久化适配器;业务库已就绪由模板顺序保证)。 + if (online.UseGameDbStores) + { + options.Override(new GameDbOnlineIdentityStore()); + options.Override(new GameDbOnlinePlayerStorageStore()); + options.Override(new GameDbOnlineAssetStore()); + options.Override(new GameDbOnlineAssetTransactionStore()); + options.Override(new GameDbOnlineFriendStore()); + } + + _runtime = CapabilityComposition.Compose(new OnlineCapabilityFamily(), options); + _composerServerType = setting.ServerType; + await _runtime.StartAsync(); + } + catch + { + await StopFailedRuntimeAsync(); + _runtime = null; + _composerServerType = null; + System.Threading.Volatile.Write(ref _composed, 0); + throw; + } + } + + /// + /// 组合失败路径的尽力停止:撤回已启动部分(桥 / 调度 / 管理面),停止自身的异常只记日志, + /// 不得覆盖触发回滚的原异常。 + /// + /// 表示停止操作的异步任务。 + private static async Task StopFailedRuntimeAsync() + { + if (_runtime == null) + { + return; + } + + try + { + await _runtime.StopAsync(); + } + catch (System.Exception stopException) + { + LogHelper.Error(stopException.ToString()); + } + } + + /// + /// 停止进程内组合产物(停 admin → 停调度 → 逆序 Deactivate → 自动撤桥)。 + /// 只有组合者 Role 的停机步真正执行族停机;非组合 Role、未组合或已停止时为空操作。 + /// + /// 调用方 Role 名(ServerType)。 + /// 表示停止操作的异步任务。 + public static async Task StopIfComposedAsync(string serverType) + { + if (_runtime == null || !string.Equals(_composerServerType, serverType, System.StringComparison.Ordinal)) + { + return; + } + + if (System.Threading.Interlocked.Exchange(ref _stopped, 1) != 0) + { + return; + } + + try + { + await _runtime.StopAsync(); + } + finally + { + _runtime = null; + } + } + + /// + /// 判定 EnableRoles(缺省 ["Game"])是否命中本 Role。 + /// + /// Online 配置段。 + /// 本 Role 名(ServerType)。 + /// 命中返回 true。 + private static bool IsRoleEnabled(OnlineCapabilityOptions online, string serverType) + { + var enableRoles = online.GetEffectiveEnableRoles(); + for (var index = 0; index < enableRoles.Count; index++) + { + if (string.Equals(enableRoles[index], serverType, System.StringComparison.Ordinal)) + { + return true; + } + } + + return false; + } + + /// + /// 重建族配置段原始 JSON(族级键 + Settings 节点):族脊柱包读取 TenantId / AppId / ServerId 落授权作用域, + /// 机制 Options 绑定器读取 Settings.<能力Id> 做能力级参数覆盖;JsonElement 底层 = System.Text.Json + ///(GameFrameX.Foundation.Json 5.0.0 实证引用 System.Text.Json,配置装载同为 System.Text.Json 反序列化)。 + /// + /// 当前 Role 的应用配置。 + /// 族配置段 JSON 节点。 + private static JsonElement BuildFamilyConfigurationSection(AppSetting setting) + { + var online = setting.Online; + var section = new Dictionary + { + ["TenantId"] = setting.OnlineTenantId, + ["AppId"] = setting.OnlineAppId, + ["ServerId"] = setting.ServerId, + ["EnableRuntime"] = online.EnableRuntime, + ["EnableAdminApi"] = online.EnableAdminApi, + ["UseGameDbStores"] = online.UseGameDbStores, + ["Settings"] = online.Settings.ValueKind == JsonValueKind.Undefined || online.Settings.ValueKind == JsonValueKind.Null ? new Dictionary() : online.Settings, + }; + return JsonSerializer.SerializeToElement(section); + } +} diff --git a/GameFrameX.Launcher/Online/OnlineFriendDomainBridgeWiring.cs b/GameFrameX.Launcher/Online/OnlineFriendDomainBridgeWiring.cs deleted file mode 100644 index 13c14f42c..000000000 --- a/GameFrameX.Launcher/Online/OnlineFriendDomainBridgeWiring.cs +++ /dev/null @@ -1,162 +0,0 @@ -// ========================================================================================== -// 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 refer to 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 -// 本组织及其贡献者不承担任何责任,开发者应自行承担。 -// The organization and contributors assume no responsibility; developers shall bear the responsibility themselves. -// 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.Generic; -using System.Threading.Tasks; -using GameFrameX.Capability.Hosting.Core; -using GameFrameX.Capability.Online.Abstractions.Bridges; -using GameFrameX.Capability.Online.Abstractions.Contracts; -using GameFrameX.Capability.Online.Abstractions.Scope; -using GameFrameX.Capability.Online.Social; -using GameFrameX.Foundation.Localization.Core; - -namespace GameFrameX.Launcher.Online; - -/// -/// 好友域桥组合根接线(C244,接管 C243 查询桥并扩展为全家族):Game 型启动链的桥接生命周期编排—— -/// 发布(业务运行时启动后)/ 撤销(退出路径)/ 可选验证种子(经真实写路径预置非空好友状态)。 -/// -/// 维护约束:发布 / 撤销必须成对出现在同一组合根(AppStartUpGame);域桥无条件于运行时启动后发布 -///(旧 120/660 链已随本切换退役,无回落目标)——契约面缺席(online-social 被过滤器排除)按 Error -/// 披露后跳过发布,好友消息届时显式 失败, -/// 不降级、不空转;种子仅供部署验证,生产常开须知情(Warning 披露)。 -/// -/// -/// -/// The friend-domain bridge composition-root wiring (C244, taking over the C243 query bridge and -/// extending it to the full family): the bridge lifecycle orchestration for the Game-shape startup -/// chain — publish (after the business runtime starts) / retract (on the shutdown path) / the -/// optional verification seed (presetting non-empty friend state through the real write path). -/// Publish and retract must stay paired inside the same composition root (AppStartUpGame); the -/// domain bridge is published unconditionally after the runtime starts (the legacy domain-120/660 -/// chains retired with this cutover — there is no fallback target) — a missing contract face -/// (online-social excluded by filters) is disclosed with an Error log and skips publishing, after -/// which friend messages fail explicitly with ; -/// no degradation, no silent idle. The seed is for deployment verification only; keeping it on in -/// production requires informed consent (disclosed by a Warning). -/// -public static class OnlineFriendDomainBridgeWiring -{ - /// - /// 发布好友域桥租约(业务运行时启动后调用):从宿主共享面解析域契约面与授权作用域并发布; - /// 契约面或作用域缺席(online-social 被排除 / 未装配)按 Error 披露并返回 null(不抛出—— - /// Online 运行时是可选拓扑,缺席态由好友消息层显式失败承载)。 - /// - /// 业务能力宿主。 - /// 发布的租约;契约面缺席返回 null。 - public static OnlineFriendDomainBridgeLease PublishFriendDomainBridge(CapabilityHost runtime) - { - if (!runtime.Services.TryGetService(out var domain) || !runtime.Services.TryGetService(out var authorizedScope)) - { - // Localization: Launcher.OnlineFriendBridgeFaceMissing - Online 好友域桥契约面不可解析(online-social 未装配或被排除),好友消息将显式失败 - LogHelper.Error(LocalizationService.GetString(Localization.Keys.Launcher.OnlineFriendBridgeFaceMissing)); - return null; - } - var lease = OnlineFriendDomainBridgeHolder.Publish(domain, authorizedScope); - // Localization: Launcher.OnlineFriendBridgePublished - Online 好友查询桥已发布(租户 {0},应用 {1},区服 {2})... - LogHelper.Info(LocalizationService.GetString(Localization.Keys.Launcher.OnlineFriendBridgePublished, authorizedScope.TenantId, authorizedScope.AppId, authorizedScope.ServerId)); - return lease; - } - - /// - /// 撤销好友域桥租约(进程停止路径调用;未发布时为空操作)。 - /// - public static void RetractFriendDomainBridge() - { - if (!OnlineFriendDomainBridgeHolder.IsPublished) - { - return; - } - - OnlineFriendDomainBridgeHolder.Retract(); - // Localization: Launcher.OnlineFriendBridgeRetracted - Online 好友查询桥已撤销... - LogHelper.Info(LocalizationService.GetString(Localization.Keys.Launcher.OnlineFriendBridgeRetracted)); - } - - /// - /// 建立好友域桥验证种子(业务运行时启动后调用;空 / 单元素列表为空操作): - /// 列表首个标识为请求方、其余为好友,经真实 写路径两两建立 - ///(幂等收敛——已是好友返回既有关系),使真实协议入口可以该请求方身份读到预置非空好友状态。 - /// - /// 请求方取配置首 id 而非进程 actor 派生 id——玩家型组件的 actor id 为多实例生成 - ///(ActorTypePlayer < ActorTypeSeparator,逐次唯一),不存在可预置的确定性请求主体; - /// 验证入口(HTTP mode=online-bridge-friend)以显式 playerId 复核同一主体。 - /// - /// - /// 业务能力宿主。 - /// 种子标识列表(首元素 = 请求方,其余 = 好友)。 - public static async Task SeedFriendDomainBridgeVerificationAsync(CapabilityHost runtime, IReadOnlyList friendPlayerIds) - { - if (friendPlayerIds == null || friendPlayerIds.Count < 2) - { - return; - } - - if (!runtime.Services.TryGetService(out var friends)) - { - // Localization: Launcher.OnlineFriendBridgeFaceMissing - Online 好友域桥契约面不可解析(online-social 未装配或被排除),好友消息将显式失败 - LogHelper.Error(LocalizationService.GetString(Localization.Keys.Launcher.OnlineFriendBridgeFaceMissing)); - return; - } - - var requesterId = friendPlayerIds[0]; - var requesterScope = new OnlineScope(runtime.TenantId, runtime.AppId, runtime.ServerId, requesterId); - var established = 0; - for (var index = 1; index < friendPlayerIds.Count; index++) - { - var friendPlayerId = friendPlayerIds[index]; - if (friendPlayerId <= 0 || friendPlayerId == requesterId) - { - continue; - } - - var requested = await friends.RequestAsync(requesterScope, friendPlayerId).ConfigureAwait(false); - if (!requested.IsSuccess) - { - // Localization: Hotfix.Friend.DomainBridgeFailed - 好友域桥返回失败, Code: {0}, Message: {1}, PlayerId: {2} - LogHelper.Error(LocalizationService.GetString(Localization.Keys.Hotfix.Friend.DomainBridgeFailed, requested.Code, requested.Message, requesterId)); - continue; - } - - var accepted = await friends.AcceptAsync(new OnlineScope(runtime.TenantId, runtime.AppId, runtime.ServerId, friendPlayerId), requested.Data.FriendshipId).ConfigureAwait(false); - if (!accepted.IsSuccess && accepted.Code != OnlineErrorCode.StateOperationForbidden) - { - // 已建立(重复种子幂等命中既有关系)不算失败;其余失败按桥失败口径披露。 - // Localization: Hotfix.Friend.DomainBridgeFailed - 好友域桥返回失败, Code: {0}, Message: {1}, PlayerId: {2} - LogHelper.Error(LocalizationService.GetString(Localization.Keys.Hotfix.Friend.DomainBridgeFailed, accepted.Code, accepted.Message, friendPlayerId)); - continue; - } - - established++; - } - - // Localization: Launcher.OnlineFriendBridgeSeeded - Online 好友查询桥验证种子已建立(仅供部署验证):请求主体 {0},种子好友数 {1} - LogHelper.Warning(LocalizationService.GetString(Localization.Keys.Launcher.OnlineFriendBridgeSeeded, requesterId, established)); - } -} diff --git a/GameFrameX.Launcher/Online/OnlineFriendLegacyMigration.cs b/GameFrameX.Launcher/Online/OnlineFriendLegacyMigration.cs deleted file mode 100644 index d860503b2..000000000 --- a/GameFrameX.Launcher/Online/OnlineFriendLegacyMigration.cs +++ /dev/null @@ -1,216 +0,0 @@ -// ========================================================================================== -// 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 refer to 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 -// 本组织及其贡献者不承担任何责任,开发者应自行承担。 -// The organization and contributors assume no responsibility; developers shall bear the responsibility themselves. -// 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.Generic; -using System.Threading.Tasks; -using GameFrameX.Apps.Player.Friend.Entity; -using GameFrameX.Apps.ServerRole.Friend.Entity; -using GameFrameX.Capability.Hosting.Core; -using GameFrameX.Capability.Online.Abstractions.Contracts; -using GameFrameX.Capability.Online.Abstractions.Scope; -using GameFrameX.Foundation.Localization.Core; - -namespace GameFrameX.Launcher.Online; - -/// -/// 旧好友数据一次性迁移(C244 T3,开关 Online.MigrateLegacyFriendData 显式授权): -/// 读取本进程 GameDb 的旧 (域 120 旧链,Status=0 行 = 已建立关系) -/// 与旧 660 档案文档(Outgoing = 待答申请),经 Online 好友域契约面真实写路径 -/// 幂等收敛为 Online 好友关系。 -/// -/// 维护约束(红线): -/// ① 只读单向复制——旧表 / 旧文档零改动,回退 = 重部署旧版本代码即回旧链旧数据; -/// ② 幂等——重复执行经 SaveIfAbsent / 幂等收敛不产生第二条关系(已建立优先于待答); -/// ③ 零静默丢失——单条失败逐条 Error 披露并继续(containment),结束输出汇总; -/// ④ 分进程旧数据在 Social 库时,须在 GameDb 指向该库的进程上执行(AllInOne 天然满足,spec 披露); -/// ⑤ 软删行(Status != 0)不迁移 = 已删除语义。 -/// -/// -/// -/// The one-shot legacy friend-data migration (C244 T3, explicitly authorized by the -/// Online.MigrateLegacyFriendData switch): reads this process's GameDb — the legacy -/// rows (the domain-120 chain; Status=0 means established) and the -/// legacy domain-660 profile documents (Outgoing = pending requests) — and -/// converges them into Online friendships through the real Online friend-domain contract write path. -/// It is a read-only one-way copy (the legacy tables and documents are never modified; rollback = -/// redeploying the legacy version restores the legacy chain over the legacy data); repeated runs are -/// idempotent (SaveIfAbsent / idempotent convergence, never a second record — established wins over -/// pending); nothing is silently lost — per-row failures are disclosed with Error logs and contained -/// (the run continues) with a summary at the end; when split-process legacy data lives in the Social -/// database the migration must run on a process whose GameDb points there (AllInOne satisfies this -/// naturally — disclosed in the spec); soft-deleted rows (Status != 0) are not migrated (deleted -/// semantics). -/// -public static class OnlineFriendLegacyMigration -{ - /// - /// 执行旧好友数据迁移(组合根在业务运行时启动且域桥发布后调用)。 - /// - /// 业务能力宿主。 - /// 迁移汇总(已建立关系数 / 待答申请数 / 失败数)。 - public static async Task<(int Established, int Pending, int Failed)> MigrateAsync(CapabilityHost runtime) - { - if (!runtime.Services.TryGetService(out var domain)) - { - // Localization: Launcher.OnlineFriendBridgeFaceMissing - Online 好友域桥契约面不可解析(online-social 未装配或被排除),好友消息将显式失败 - LogHelper.Error(LocalizationService.GetString(Localization.Keys.Launcher.OnlineFriendBridgeFaceMissing)); - return (0, 0, 0); - } - - // ① 域 120 旧链:FriendRelationState Status=0 → 已建立关系(Establish = 申请 + 立即接受,幂等收敛)。 - var (established, relationFailed) = await MigrateEstablishedRelationsAsync(runtime, domain).ConfigureAwait(false); - - // ② 域 660 旧链:FriendState 档案 Outgoing → 待答申请(对已建立的对幂等无效果)。 - var (pending, profileFailed) = await MigratePendingRequestsAsync(runtime, domain).ConfigureAwait(false); - - var failed = relationFailed + profileFailed; - - // Localization: Launcher.OnlineFriendLegacyMigrationDone - 旧好友数据迁移完成:已建立 {0},待答申请 {1},失败 {2}(旧数据零改动) - LogHelper.Info(LocalizationService.GetString(Localization.Keys.Launcher.OnlineFriendLegacyMigrationDone, established, pending, failed)); - return (established, pending, failed); - } - - /// - /// 迁移域 120 旧链:读取 FriendRelationState Status=0 行,逐条经 Establish 收敛为已建立关系 - /// (Establish = 申请 + 立即接受,幂等收敛);单条失败逐条 Error 披露并继续(containment)。 - /// - /// 业务能力宿主。 - /// Online 好友域契约面。 - /// (已建立关系数,失败数)。 - private static async Task<(int Established, int Failed)> MigrateEstablishedRelationsAsync(CapabilityHost runtime, IOnlineFriendDomain domain) - { - var established = 0; - var failed = 0; - var relations = await GameFrameX.DataBase.GameDb.FindListAsync(state => state.Status == 0).ConfigureAwait(false); - foreach (var relation in relations) - { - var low = relation.PlayerIdA; - var high = relation.PlayerIdB; - if (low <= 0 || high <= 0 || low == high) - { - continue; - } - - var result = await domain.EstablishAsync(PlayerScope(runtime, low), high).ConfigureAwait(false); - if (result.IsSuccess && result.Data.After == OnlineFriendRelationKind.Friend) - { - established++; - } - else - { - failed++; - // Localization: Launcher.OnlineFriendLegacyMigrationEntryFailed - 旧好友关系迁移单条失败:{0} <-> {1}, Code: {2}, Message: {3} - LogHelper.Error(LocalizationService.GetString(Localization.Keys.Launcher.OnlineFriendLegacyMigrationEntryFailed, low, high, result.Code, result.Message)); - } - } - - return (established, failed); - } - - /// - /// 迁移域 660 旧链:读取 FriendState 档案文档,逐档案的 Outgoing 待答申请经真实写路径收敛 - /// (对已建立的对幂等无效果)。 - /// - /// 业务能力宿主。 - /// Online 好友域契约面。 - /// (待答申请数,失败数)。 - private static async Task<(int Pending, int Failed)> MigratePendingRequestsAsync(CapabilityHost runtime, IOnlineFriendDomain domain) - { - var pending = 0; - var failed = 0; - var profiles = await GameFrameX.DataBase.GameDb.FindListAsync(state => state.Id >= 0).ConfigureAwait(false); - foreach (var document in profiles) - { - if (document?.Profiles == null) - { - continue; - } - - foreach (var profile in document.Profiles.Values) - { - if (profile?.Outgoing == null) - { - continue; - } - - var (entryPending, entryFailed) = await MigrateOutgoingRequestsAsync(runtime, domain, profile).ConfigureAwait(false); - pending += entryPending; - failed += entryFailed; - } - } - - return (pending, failed); - } - - /// - /// 迁移单个旧档案的 Outgoing 待答申请:逐目标玩家校验后经 Request 真实写路径请求, - /// 单条失败逐条 Error 披露并继续(containment)。 - /// - /// 业务能力宿主。 - /// Online 好友域契约面。 - /// 旧 660 好友档案。 - /// (待答申请数,失败数)。 - private static async Task<(int Pending, int Failed)> MigrateOutgoingRequestsAsync(CapabilityHost runtime, IOnlineFriendDomain domain, FriendProfileState profile) - { - var pending = 0; - var failed = 0; - foreach (var targetPlayerId in profile.Outgoing) - { - if (profile.PlayerId <= 0 || targetPlayerId <= 0 || targetPlayerId == profile.PlayerId) - { - continue; - } - - var result = await domain.RequestAsync(PlayerScope(runtime, profile.PlayerId), targetPlayerId).ConfigureAwait(false); - if (result.IsSuccess) - { - pending++; - } - else - { - failed++; - // Localization: Launcher.OnlineFriendLegacyMigrationEntryFailed - 旧好友关系迁移单条失败:{0} <-> {1}, Code: {2}, Message: {3} - LogHelper.Error(LocalizationService.GetString(Localization.Keys.Launcher.OnlineFriendLegacyMigrationEntryFailed, profile.PlayerId, targetPlayerId, result.Code, result.Message)); - } - } - - return (pending, failed); - } - - /// - /// 构造玩家作用域(三元组取宿主授权值,玩家位为迁移主体)。 - /// - /// 业务能力宿主。 - /// 玩家标识。 - /// 生效作用域。 - private static OnlineScope PlayerScope(CapabilityHost runtime, long playerId) - { - return new OnlineScope(runtime.TenantId, runtime.AppId, runtime.ServerId, playerId); - } -} diff --git a/GameFrameX.Launcher/Online/OnlineMatchBridgeWiring.cs b/GameFrameX.Launcher/Online/OnlineMatchBridgeWiring.cs deleted file mode 100644 index b76d35868..000000000 --- a/GameFrameX.Launcher/Online/OnlineMatchBridgeWiring.cs +++ /dev/null @@ -1,172 +0,0 @@ -// ========================================================================================== -// 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 applicable 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 refer to the LICENSE file in the root 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 legal rights and interests of others as prohibited by laws and regulations! -// 因基于本项目二次开发所产生的一切纠纷与责任, -// Any legal disputes or liabilities arising from secondary development based on this project -// 本组织不承担任何责任,开发者应自行承担。 -// The organization and contributors shall not be responsible; developers shall bear the responsibility themselves. -// ========================================================================================== - -using System.Threading.Tasks; -using GameFrameX.Capability.Hosting.Core; -using GameFrameX.Capability.Online.Abstractions.Bridges; -using GameFrameX.Capability.Online.Abstractions.Contracts; -using GameFrameX.Capability.Online.Abstractions.Scope; -using GameFrameX.Capability.Online.Competition.Leaderboard; -using GameFrameX.Foundation.Localization.Core; -using GameFrameX.Utility.Setting; - -namespace GameFrameX.Launcher.Online; - -/// -/// 对局样例桥组合根接线(C245,D4 RPS 垂直切片;沿 C243 形态): -/// Game 型启动链的桥接生命周期编排——组合前 fail-fast、业务运行时启动后发布租约与确保写榜目标榜单、 -/// 进程停止路径撤销租约。 -/// -/// 维护约束:发布 / 撤销必须成对出现在同一组合根(AppStartUpGame);不支持拓扑(业务运行时关、作用域缺失、 -/// online-match 能力被过滤器排除)一律启动期显式失败,不降级、不空转;榜单确保经真实 -/// 写路径执行(已存在幂等跳过),非空配置但 online-competition 能力 -/// 被排除视为配置矛盾(启动终止)。 -/// -/// -/// -/// The sample match bridge composition-root wiring (C245, the D4 RPS vertical slice; following the -/// C243 shape): the bridge lifecycle orchestration for the -/// Game-shape startup chain — fail-fast before assembly, lease publication plus leaderboard-board -/// ensuring after the business runtime starts, and lease retraction on the shutdown path. Publish -/// and retract must stay paired inside the same composition root (AppStartUpGame); unsupported -/// topologies (runtime gate off, scope missing, online-match excluded by filters) fail explicitly -/// at startup — no degradation, no silent idle. The board ensure goes through the real -/// write path (idempotent skip when the board exists); -/// a non-empty board id with the online-competition capability excluded is a configuration -/// contradiction (startup aborted). -/// -public static class OnlineMatchBridgeWiring -{ - /// - /// 校验对局桥配置(组合前 fail-fast):开关开启要求业务运行时开关开启且授权作用域三元组完整; - /// 开关关闭(缺省)恒通过——旧行为边界不动。 - /// - /// 进程设置。 - /// 开关与拓扑矛盾时抛出。 - public static void EnsureMatchGameBridgeConfigured(AppSetting setting) - { - if (setting?.Online?.EnableMatchGameBridge != true) - { - return; - } - - if (!setting.IsEnableOnline) - { - throw new InvalidOperationException(LocalizationService.GetString(Localization.Keys.Launcher.OnlineMatchBridgeRuntimeGateMissing)); - } - - if (setting.OnlineTenantId <= 0 || setting.OnlineAppId <= 0 || setting.ServerId <= 0) - { - throw new InvalidOperationException(LocalizationService.GetString(Localization.Keys.Launcher.OnlineMatchBridgeScopeMissing)); - } - } - - /// - /// 发布对局桥租约(业务运行时启动后调用):从宿主共享面解析契约面与授权作用域并发布; - /// 契约面缺失(online-match 被排除 / 未装配)即启动期显式失败。 - /// - /// 业务能力宿主。 - /// 发布的租约。 - /// 契约面或授权作用域不可解析时抛出。 - public static OnlineMatchGameBridgeLease PublishMatchGameBridge(CapabilityHost runtime) - { - var bridge = runtime.Services.GetService(); - var authorizedScope = runtime.Services.GetService(); - if (bridge == null || authorizedScope == null) - { - throw new InvalidOperationException(LocalizationService.GetString(Localization.Keys.Launcher.OnlineMatchBridgeFaceMissing)); - } - - var lease = OnlineMatchGameBridgeHolder.Publish(bridge, authorizedScope); - // Localization: Launcher.OnlineMatchBridgePublished - Online 对局桥已发布(租户 {0},应用 {1},区服 {2})... - LogHelper.Info(LocalizationService.GetString(Localization.Keys.Launcher.OnlineMatchBridgePublished, authorizedScope.TenantId, authorizedScope.AppId, authorizedScope.ServerId)); - return lease; - } - - /// - /// 确保对局桥写榜目标榜单存在(业务运行时启动后调用;榜单标识空白为空操作):经真实 - /// 查询-创建路径执行——已存在幂等跳过,不存在则创建样例榜单 - ///(累计策略 = 胜局总和,降序)。榜单服务不可解析(online-competition 被排除 / 未装配)或创建失败 - /// 均为配置矛盾,启动期显式失败。 - /// - /// 业务能力宿主。 - /// 榜单标识(空白 = 不写榜,空操作)。 - /// 表示异步操作的任务 / A task representing the asynchronous operation - /// 榜单服务不可解析或确保失败时抛出。 - public static async Task EnsureMatchLeaderboardBoardAsync(CapabilityHost runtime, string leaderboardId) - { - if (string.IsNullOrWhiteSpace(leaderboardId)) - { - return; - } - - var leaderboards = runtime.Services.GetService(); - var authorizedScope = runtime.Services.GetService(); - if (leaderboards == null || authorizedScope == null) - { - throw new InvalidOperationException(LocalizationService.GetString(Localization.Keys.Launcher.OnlineMatchBridgeFaceMissing)); - } - - var probe = await leaderboards.GetTopAsync(authorizedScope, new OnlineLeaderboardTopQuery - { - LeaderboardId = leaderboardId, - Count = 1, - }).ConfigureAwait(false); - if (probe.IsSuccess) - { - return; - } - - if (probe.Code != OnlineErrorCode.ResourceNotFound) - { - throw new InvalidOperationException(LocalizationService.GetString(Localization.Keys.Launcher.OnlineMatchBridgeBoardEnsureFailed, leaderboardId, (int)probe.Code, probe.Message)); - } - - var created = await leaderboards.CreateAsync(authorizedScope, new OnlineLeaderboardCreateRequest - { - LeaderboardId = leaderboardId, - SortOrder = OnlineLeaderboardSortOrder.Descending, - ScoreUpdatePolicy = OnlineLeaderboardScoreUpdatePolicy.Sum, - }).ConfigureAwait(false); - if (!created.IsSuccess && created.Code != OnlineErrorCode.ParameterInvalid) - { - throw new InvalidOperationException(LocalizationService.GetString(Localization.Keys.Launcher.OnlineMatchBridgeBoardEnsureFailed, leaderboardId, (int)created.Code, created.Message)); - } - - // Localization: Launcher.OnlineMatchBridgeBoardEnsured - Online 对局桥榜单已确保存在(仅供样例验证):{0} - LogHelper.Warning(LocalizationService.GetString(Localization.Keys.Launcher.OnlineMatchBridgeBoardEnsured, leaderboardId)); - } - - /// - /// 撤销对局桥租约(进程停止路径调用;未发布时为空操作)。 - /// - public static void RetractMatchGameBridge() - { - if (!OnlineMatchGameBridgeHolder.IsPublished) - { - return; - } - - OnlineMatchGameBridgeHolder.Retract(); - // Localization: Launcher.OnlineMatchBridgeRetracted - Online 对局桥已撤销... - LogHelper.Info(LocalizationService.GetString(Localization.Keys.Launcher.OnlineMatchBridgeRetracted)); - } -} diff --git a/GameFrameX.Launcher/Online/OnlineMatchDomainBridgeWiring.cs b/GameFrameX.Launcher/Online/OnlineMatchDomainBridgeWiring.cs deleted file mode 100644 index e26c30a24..000000000 --- a/GameFrameX.Launcher/Online/OnlineMatchDomainBridgeWiring.cs +++ /dev/null @@ -1,174 +0,0 @@ -// ========================================================================================== -// 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 refer to the LICENSE file at the source code root 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 disputes and liabilities arising from secondary development, -// 本组织不承担任何责任,开发者须自行承担。 -// The organization shall not be responsible for any disputes; developers shall bear the responsibility. -// ========================================================================================== - -using System; -using System.Threading.Tasks; -using GameFrameX.Capability.Hosting.Core; -using GameFrameX.Capability.Online.Abstractions.Bridges; -using GameFrameX.Capability.Online.Abstractions.Contracts; -using GameFrameX.Capability.Online.Abstractions.Events; -using GameFrameX.Capability.Online.Abstractions.Scope; -using GameFrameX.Capability.Online.Matchmaking; -using GameFrameX.Core.Session; -using GameFrameX.Foundation.Localization.Core; -using GameFrameX.Proto.Proto; -using GameFrameX.Utility; - -namespace GameFrameX.Launcher.Online; - -/// -/// 匹配域桥组合根接线(C246,沿 C244 形态): -/// Game 型启动链的域桥生命周期编排——业务运行时启动后发布租约、订阅撮合分配事件做匹配成功通知推送, -/// 进程停止路径撤销租约。 -/// -/// 维护约束:发布 / 撤销必须成对出现在同一组合根(AppStartUpGame);契约面或作用域缺席 -///(online-match / online-matchmaking 被排除 / 未装配)按 Error 披露并返回 null(不抛出——缺席态由 -/// 420 匹配消息层显式失败承载,无回落目标);通知推送 best-effort——离线方无会话即静默跳过, -/// 经状态轮询兜底;订阅体异常吞掉不阻断事件分发(C245 排行投影订阅先例)。 -/// -/// -/// -/// The match-domain bridge composition-root wiring (C246, following the C244 friend-domain shape): -/// the bridge lifecycle orchestration for the Game-shape startup chain — lease publication after the -/// business runtime starts, an assignment-event subscription for the match-made notification push, and -/// lease retraction on the shutdown path. Publish and retract stay paired inside the same composition -/// root (AppStartUpGame); a missing contract face or scope (online-match / online-matchmaking excluded) -/// discloses an Error and returns null without throwing (the absence state is carried by explicit -/// domain-420 message failures — there is no fallback target); the notification push is best-effort — -/// offline players without sessions are skipped silently and fall back to status polling; subscriber -/// exceptions are swallowed so event dispatch never breaks (the C245 leaderboard-projection precedent). -/// -public static class OnlineMatchDomainBridgeWiring -{ - /// - /// 发布匹配域桥租约(业务运行时启动后调用):从宿主共享面解析域契约面与授权作用域并发布; - /// 契约面或作用域缺席按 Error 披露并返回 null(不抛出)。 - /// - /// 业务能力宿主。 - /// 发布的租约;契约面缺席返回 null。 - public static OnlineMatchDomainBridgeLease PublishMatchDomainBridge(CapabilityHost runtime) - { - if (!runtime.Services.TryGetService(out var domain) || !runtime.Services.TryGetService(out var authorizedScope)) - { - // Localization: Launcher.OnlineMatchDomainBridgeFaceMissing - Online 匹配域桥契约面不可解析(online-match/matchmaking 未装配或被排除),420 匹配消息将显式失败 - LogHelper.Error(LocalizationService.GetString(Localization.Keys.Launcher.OnlineMatchDomainBridgeFaceMissing)); - return null; - } - - var lease = OnlineMatchDomainBridgeHolder.Publish(domain, authorizedScope); - // Localization: Launcher.OnlineMatchDomainBridgePublished - Online 匹配域桥已发布(租户 {0},应用 {1},区服 {2})... - LogHelper.Info(LocalizationService.GetString(Localization.Keys.Launcher.OnlineMatchDomainBridgePublished, authorizedScope.TenantId, authorizedScope.AppId, authorizedScope.ServerId)); - return lease; - } - - /// - /// 订阅撮合分配事件并推送匹配成功通知(业务运行时启动后调用): - /// Online.Match.AssignmentCreated → 域服务解析数值对局标识 → 向双方在线会话 best-effort 推送 - /// 。事件总线或契约面缺席按 Error 披露并跳过(不抛出)。 - /// - /// 业务能力宿主。 - public static void SubscribeMatchMadeNotifications(CapabilityHost runtime) - { - var eventBus = runtime.Services.GetService(); - if (!runtime.Services.TryGetService(out var domain)) - { - eventBus = null; - } - - if (eventBus == null || domain == null) - { - // Localization: Launcher.OnlineMatchDomainBridgeNotifyMissing - Online 匹配成功通知未装配(事件总线或契约面不可解析),等待方经状态查询兜底 - LogHelper.Error(LocalizationService.GetString(Localization.Keys.Launcher.OnlineMatchDomainBridgeNotifyMissing)); - return; - } - - eventBus.Subscribe(envelope => HandleMatchAssignmentEvent(domain, envelope)); - } - - /// - /// 匹配分配事件回调体(订阅入口见 ): - /// Online.Match.AssignmentCreated → 域服务解析数值对局标识 → 向双方在线会话 best-effort 推送 - /// ;过滤/校验失败即静默跳过,订阅体异常吞掉不阻断事件分发。 - /// - /// 匹配域契约面。 - /// Foundation 事件信封。 - private static void HandleMatchAssignmentEvent(IOnlineMatchDomain domain, GameFrameX.Foundation.Idempotency.EventEnvelope envelope) - { - try - { - var onlineEvent = OnlineEventEnvelopeMapper.FromEnvelope(envelope); - if (onlineEvent == null || onlineEvent.EventType != OnlineMatchEvents.AssignmentCreated) - { - return; - } - - var payload = OnlineMatchEvents.ReadAssignmentCreated(onlineEvent); - if (payload == null || string.IsNullOrEmpty(payload.AssignmentId)) - { - return; - } - - var resolved = domain.ResolveAssignmentAsync(onlineEvent.TenantId, onlineEvent.AppId, payload.AssignmentId).GetAwaiter().GetResult(); - if (!resolved.IsSuccess || resolved.Data == null || resolved.Data.MatchId == 0) - { - return; - } - - var notify = new NotifyMatchMade { MatchId = resolved.Data.MatchId, Mode = resolved.Data.Mode }; - if (resolved.Data.PlayerIds == null) - { - return; - } - - foreach (var playerId in resolved.Data.PlayerIds) - { - var session = PlayerSessionManager.Instance.GetByPlayerId(playerId); - if (session == null) - { - // 离线方静默跳过:best-effort 推送,等待方经状态轮询兜底。 - continue; - } - - session.WriteAsync(notify).GetAwaiter().GetResult(); - } - } - catch (Exception) - { - // 订阅体异常不得阻断业务事件分发(InMemoryEventPublisher 异常策略向上传播,此处吞掉; - // 通知是 best-effort 增益路径,失败不影响撮合事实,等待方经状态查询兜底)。 - } - } - - /// - /// 撤销匹配域桥租约(进程停止路径调用;未发布时为空操作)。 - /// - public static void RetractMatchDomainBridge() - { - if (!OnlineMatchDomainBridgeHolder.IsPublished) - { - return; - } - - OnlineMatchDomainBridgeHolder.Retract(); - // Localization: Launcher.OnlineMatchDomainBridgeRetracted - Online 匹配域桥已撤销... - LogHelper.Info(LocalizationService.GetString(Localization.Keys.Launcher.OnlineMatchDomainBridgeRetracted)); - } -} diff --git a/GameFrameX.Launcher/Online/OnlineSessionDomainBridgeWiring.cs b/GameFrameX.Launcher/Online/OnlineSessionDomainBridgeWiring.cs deleted file mode 100644 index b7a93d8fe..000000000 --- a/GameFrameX.Launcher/Online/OnlineSessionDomainBridgeWiring.cs +++ /dev/null @@ -1,81 +0,0 @@ -// ========================================================================================== -// GameFrameX 组织及其衍生项目的版权、商标、专利及其他相关权利 -// GameFrameX organization and its derivative projects' copyrights, trademarks, patents, and other related rights -// 均受中华人民共和国及相关国际法律法规保护。 -// are protected by the laws and regulations of the People's Republic of China and 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 at 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. - -using System; -using GameFrameX.Capability.Hosting.Core; -using GameFrameX.Capability.Online.Abstractions.Bridges; -using GameFrameX.Capability.Online.Abstractions.Contracts; -using GameFrameX.Capability.Online.Abstractions.Scope; -using GameFrameX.Foundation.Localization.Core; -using GameFrameX.Foundation.Logger; - -namespace GameFrameX.Launcher.Online; - -/// -/// 会话域桥组合根接线(C204,沿 C246 形态): -/// Game 型启动链的会话域桥生命周期编排——业务运行时启动后发布租约,进程停止路径撤销租约。 -/// -/// 维护约束:发布 / 撤销必须成对出现在同一组合根(AppStartUpGame);契约面或作用域缺席 -///(online-foundation 被排除 / 未装配)按 Error 披露并返回 null(不抛出——缺席态由登录不下发凭据、 -/// 恢复请求显式失败承载,无回落目标)。 -/// -/// -/// -/// The session-domain bridge composition-root wiring (C204, following the C246 match-domain shape): -/// lease publication after the business runtime starts and retraction on the shutdown path. Publish and -/// retract stay paired inside the same composition root (AppStartUpGame); a missing contract face or -/// scope (online-foundation excluded) discloses an Error and returns null without throwing — the absence -/// state is carried by login issuing no credential and resume requests failing explicitly (there is no -/// fallback target). -/// -public static class OnlineSessionDomainBridgeWiring -{ - /// - /// 发布会话域桥租约(业务运行时启动后调用):从宿主共享面解析会话恢复契约面与授权作用域并发布; - /// 契约面或作用域缺席按 Error 披露并返回 null(不抛出)。 - /// - /// 业务能力宿主。 - /// 发布的租约;契约面缺席返回 null。 - public static OnlineSessionDomainBridgeLease PublishSessionDomainBridge(CapabilityHost runtime) - { - if (!runtime.Services.TryGetService(out var contract) || !runtime.Services.TryGetService(out var authorizedScope)) - { - // Localization: Launcher.OnlineSessionDomainBridgeFaceMissing - Online 会话域桥契约面不可解析(online-foundation 未装配或被排除),会话恢复不可用 - LogHelper.Error(LocalizationService.GetString(Localization.Keys.Launcher.OnlineSessionDomainBridgeFaceMissing)); - return null; - } - - var lease = OnlineSessionDomainBridgeHolder.Publish(contract, authorizedScope); - // Localization: Launcher.OnlineSessionDomainBridgePublished - Online 会话域桥已发布(租户 {0},应用 {1},区服 {2}) - LogHelper.Info(LocalizationService.GetString(Localization.Keys.Launcher.OnlineSessionDomainBridgePublished, authorizedScope.TenantId, authorizedScope.AppId, authorizedScope.ServerId)); - return lease; - } - - /// - /// 撤销会话域桥租约(进程停止路径调用;未发布时为空操作)。 - /// - public static void RetractSessionDomainBridge() - { - if (!OnlineSessionDomainBridgeHolder.IsPublished) - { - return; - } - - OnlineSessionDomainBridgeHolder.Retract(); - // Localization: Launcher.OnlineSessionDomainBridgeRetracted - Online 会话域桥已撤销 - LogHelper.Info(LocalizationService.GetString(Localization.Keys.Launcher.OnlineSessionDomainBridgeRetracted)); - } -} diff --git a/GameFrameX.Launcher/StartUp/AppStartUpGame.cs b/GameFrameX.Launcher/StartUp/AppStartUpGame.cs index 7cc717f65..26601b2c6 100644 --- a/GameFrameX.Launcher/StartUp/AppStartUpGame.cs +++ b/GameFrameX.Launcher/StartUp/AppStartUpGame.cs @@ -29,14 +29,11 @@ using System.Reflection; -using GameFrameX.Capability.Hosting.Core; -using GameFrameX.Capability.Hosting.AdminApi; using GameFrameX.Core.Events; using GameFrameX.Core.Session; using GameFrameX.Foundation.Localization.Core; using GameFrameX.Foundation.Utility; using GameFrameX.Network.Abstractions; -using GameFrameX.Launcher.Online; using GameFrameX.Utility.Runtime; namespace GameFrameX.Launcher.StartUp; @@ -45,90 +42,48 @@ namespace GameFrameX.Launcher.StartUp; /// 游戏服务器 /// /// -/// 组合根边界(C240 声明随 C243 交付):本类所在启动模板序列( +/// 组合根边界:本类所在启动模板序列( /// 模板钩子 + 各 Role 差异覆写)负责基础设施装配——网络监听、GameDb 数据库、发现层、热更模块装载与退出编排; -/// 平台业务域服务(好友 / 资产 / 对局等)的唯一组合根是 AddOnlineCapabilities 单入口装配的 -/// CapabilityHost(本类持有并按启停序编排)。两层边界经 C243 好友查询桥实证: -/// Hotfix 协议面薄适配器经 Online 契约面进程内消费平台服务,不经本地注册表绕行。 -/// Composition-root boundary (declared by C240, delivered with C243): the startup template sequence -/// (the template hooks plus per-role overrides) assembles the +/// 平台业务域服务(好友 / 资产 / 对局等)的组合自 C294 起由模板固定步骤 +/// 经 +/// CapabilityComposition.Compose 单入口承载("Online" 配置段声明启用 Role)。两层边界经 +/// 好友查询桥实证:Hotfix 协议面薄适配器经 Online 契约面进程内消费平台服务,不经本地注册表绕行。 +/// Composition-root boundary: the startup template sequence (the +/// template hooks plus per-role overrides) assembles the /// infrastructure — network listeners, the GameDb database, the discovery layer, hotfix module loading -/// and exit orchestration — while the sole composition root for platform domain services (friends, -/// assets, matches, ...) is the CapabilityHost assembled by the single -/// AddOnlineCapabilities entry (held and lifecycle-orchestrated by this class). The two-layer -/// boundary is proven by the C243 friend-query bridge: the Hotfix protocol thin adapter consumes the -/// platform services in-process through the Online contract face, never through the local registry. +/// and exit orchestration — while since C294 the composition of platform domain services (friends, +/// assets, matches, ...) is carried by the fixed template step +/// through the single +/// CapabilityComposition.Compose entry (enabled roles declared in the "Online" configuration +/// section). The two-layer boundary is proven by the friend-query bridge: the Hotfix protocol thin +/// adapter consumes the platform services in-process through the Online contract face, never through +/// the local registry. /// // 显式优先级修复与 Social 同为缺省 1000 的冲突——Game 为主服务先起(值越小优先级越高) [StartUpTag(GameServerConst.Game.Name, 100)] internal sealed class AppStartUpGame : AppStartUpGameServerBase { /// - /// Online Runtime 过渡组合根( 经 AddOnlineCapabilities 单入口装配的产物; - /// 业务闸关闭时为 null;进程退出时随主流程停止)。 + /// Game 型退出清理(两段逆序编排):宿主停机(护栏内含能力族停止: + /// 停 admin → 停调度 → 逆序 Deactivate → 自动撤桥)→ 热更模块业务清理。 /// /// - /// The Online runtime host — the runtime face of the AddOnlineCapabilities composition (null - /// when the business gate is off; stopped with the main flow at process exit). - /// - private CapabilityHost _onlineRuntime; - - /// - /// Online admin HTTP 服务(与协议端口独立的 Kestrel 监听;管理面闸开启时由单入口装配—— - /// 业务运行时在则全能力形态,不在则 admin-only 形态,全部能力 action 按未安装裁决)。 - /// - /// - /// The Online admin HTTP service (a Kestrel listener independent of the protocol ports), - /// composed by the single entry when the admin gate is on — full-capability shape with the - /// business runtime, admin-only shape without it (every capability action answered as not - /// installed). - /// - private AdminApiServer _onlineAdminApi; - - /// - /// Game 型 Role 服务:缺省统一形态(热更生命周期激活 + 宿主监听,经 base hook)+ Online 双闸附加启动 - ///(共享装配由模板序列承载)。 - /// - /// - /// Game-shape role services: the unified default (hotfix lifecycle activation plus the host - /// listener startup, both via the base hook) followed by the optional Online gates — the shared - /// wiring (control database, discovery layer, business database, components) is carried by the - /// template sequence. C284 起 Game 不再自拟启动序列:热更装载与监听均由缺省 hook 承载, - /// 本覆写只追加 Online 面(Online 属范围外,装配必须保留)。 - /// Since C284 Game no longer rolls its own startup sequence — hotfix loading and listener - /// ownership both live in the default hook; this override only appends the Online surface - /// (Online is out of scope and its assembly must be preserved). - /// - /// 表示 hook 操作的异步任务 / A task representing the hook operation - protected override async Task StartRoleServicesAsync() - { - await base.StartRoleServicesAsync(); - await StartOnlineIfEnabledAsync(); - await StartOnlineAdminIfEnabledAsync(); - } - - /// - /// Game 型退出清理(三段逆序编排):Online 撤桥并停 admin/runtime → 宿主停机(护栏内 - /// ServiceOffline 分发 + 网络监听停止)→ 热更模块业务清理。 - /// - /// - /// Game-shape exit cleanup (three reverse phases): Online bridge retraction with the admin and - /// runtime stops, then the host stop (the guarded ServiceOffline dispatch plus the - /// network listener shutdown), then the hotfix module's business cleanup. C284 起监听所有权 - /// 归宿主:宿主 StopAsync 停监听并翻转运行时状态,热更 Stop 只清 Actor/定时器/会话。 - /// Since C284 listener ownership stays with the host — the host StopAsync stops the - /// listeners and flips the runtime state while the hotfix Stop only clears actors, - /// timers and sessions. + /// Game-shape exit cleanup (two reverse phases): the host stop (with the capability-family stop + /// inside the guard: admin → scheduler → reverse Deactivate → automatic bridge retraction), then + /// the hotfix module's business cleanup. C284 起监听所有权归宿主;C294 起 Online 编排并入 + /// 模板序列(),本覆写只保留热更收尾。 + /// Since C284 listener ownership stays with the host; since C294 the Online orchestration lives in + /// the template sequence, and this override only keeps the hotfix tail cleanup. /// /// 退出原因(可能为 null——启动失败路径未获得退出消息)/ The exit reason (may be null on the startup-failure path) /// 表示 hook 操作的异步任务 / A task representing the hook operation protected override async Task ShutdownAsync(string exitMessage) { - await ShutdownOnlineAdminAsync(); await StopAsync(exitMessage ?? ""); await HotfixManager.StopAsync(exitMessage); } + /// /// 创建 Game 服务器的 KCP 会话首消息鉴权配置:放行登录流程消息,以角色登录为鉴权完成信号。 /// @@ -229,142 +184,6 @@ protected override ValueTask OnDisconnectedAsync(IAppSession appSession, CloseEv return ValueTask.CompletedTask; } - /// - /// 经单入口装配 Online 能力(双闸任一开启即组合; 关且 - /// 关时零装配零日志直接返回——现状等价)。 - /// 业务运行时与管理面一次回调编排装配(作用域三元组 / 调度间隔缺省 / 双闸 / 管理面监听 / - /// "Online" 配置段过滤器);本方法只启动业务面,管理面启动由 - /// 按闸独立决定。 - /// - /// C244 好友域接管:业务运行时启动后无条件发布好友域桥租约(旧 120/660 链已退役,无回落目标), - /// 按配置执行旧好友数据一次性迁移(Online.MigrateLegacyFriendData)与验证种子 - ///(Online.FriendBridgeSeedPlayerIds);撤销由退出路径 承担。 - /// - /// - /// - /// Composes the Online capabilities through the single entry point (composition runs when either - /// gate is on; when both and - /// are off nothing is assembled and no log fires — - /// behaviour-equivalent to the pre-C229 shape). The runtime and the admin plane are orchestrated - /// by one callback (scope triple / default scheduler interval / both gates / admin listening / - /// the "Online" config-section filter); this method only starts the business plane — whether the - /// admin plane starts is still decided independently by - /// . - /// - /// 表示异步操作的任务 / A task representing the asynchronous operation - private async Task StartOnlineIfEnabledAsync() - { - if (!Setting.IsEnableOnline && !Setting.IsEnableOnlineAdmin) - { - OnlineMatchBridgeWiring.EnsureMatchGameBridgeConfigured(Setting); - return; - } - - // C243 桥接 fail-fast:开关开启而拓扑不支持(业务运行时关/作用域缺失)在装配前终止。 - OnlineMatchBridgeWiring.EnsureMatchGameBridgeConfigured(Setting); - - var composition = OnlineCapabilityComposition.AddOnlineCapabilities(options => - { - options.TenantId = Setting.OnlineTenantId; - options.AppId = Setting.OnlineAppId; - options.ServerId = Setting.ServerId; - options.EnableRuntime = Setting.IsEnableOnline; - options.EnableAdminApi = Setting.IsEnableOnlineAdmin; - options.AdminApi = new AdminApiOptions - { - AdminPort = Setting.OnlineAdminPort, - AdminApiPrefix = Setting.OnlineAdminApiPrefix, - }; - options.Capabilities = Setting.Online?.Capabilities; - options.Disable = Setting.Online?.Disable; - options.UseGameDbStores = Setting.Online?.UseGameDbStores ?? false; - options.MatchBridgeLeaderboardId = Setting.Online?.MatchBridgeLeaderboardId; - }); - _onlineRuntime = composition.Runtime; - _onlineAdminApi = composition.AdminApi; - - if (_onlineRuntime != null) - { - await _onlineRuntime.StartAsync(); - - // C244 好友域接管:业务运行时启动后发布好友域桥租约(无回落目标),按配置执行旧数据迁移与验证种子。 - OnlineFriendDomainBridgeWiring.PublishFriendDomainBridge(_onlineRuntime); - if (Setting.Online?.MigrateLegacyFriendData == true) - { - await OnlineFriendLegacyMigration.MigrateAsync(_onlineRuntime); - } - - await OnlineFriendDomainBridgeWiring.SeedFriendDomainBridgeVerificationAsync(_onlineRuntime, Setting.Online?.FriendBridgeSeedPlayerIds); - - // C245 桥接:业务运行时启动后发布对局桥租约(开关开启时),并确保写榜目标榜单存在。 - if (Setting.Online?.EnableMatchGameBridge == true) - { - OnlineMatchBridgeWiring.PublishMatchGameBridge(_onlineRuntime); - await OnlineMatchBridgeWiring.EnsureMatchLeaderboardBoardAsync(_onlineRuntime, Setting.Online?.MatchBridgeLeaderboardId); - } - - // C246 匹配域接管:发布匹配域桥租约(420 族唯一转发面,无回落目标)并订阅撮合分配事件推送匹配成功通知。 - OnlineMatchDomainBridgeWiring.PublishMatchDomainBridge(_onlineRuntime); - OnlineMatchDomainBridgeWiring.SubscribeMatchMadeNotifications(_onlineRuntime); - - // C204 两层会话协同:发布会话域桥租约(登录凭据签发 / 会话恢复 / 断线开窗的唯一转发面)。 - OnlineSessionDomainBridgeWiring.PublishSessionDomainBridge(_onlineRuntime); - } - } - - /// - /// 可选启动 Online admin HTTP 服务( 为 true 时监听; - /// 业务运行时在则全能力形态,不在则 admin-only 形态——组合已由单入口完成,此处只负责启动与日志)。 - /// - /// - /// Starts the Online admin HTTP service optionally — listens when - /// is true; full-capability shape when the business - /// runtime is running, admin-only shape otherwise. The composition itself already happened in - /// the single entry call; this method only starts the listener and emits the boundary logs. - /// - /// 表示异步操作的任务 / A task representing the asynchronous operation - private async Task StartOnlineAdminIfEnabledAsync() - { - if (!Setting.IsEnableOnlineAdmin) - { - return; - } - - LogHelper.Info(LocalizationService.GetString(Localization.Keys.Launcher.OnlineAdminStartBegin, Setting.OnlineAdminPort)); - await _onlineAdminApi.StartAsync(); - LogHelper.Info(LocalizationService.GetString(Localization.Keys.Launcher.OnlineAdminStartEnd, Setting.OnlineTenantId, Setting.OnlineAppId, Setting.ServerId)); - } - - /// - /// 退出期按反向顺序停掉 Online admin HTTP 服务与 Online runtime 宿主; - /// 未启动( 为 false)时为空判跳过。 - /// - /// - /// Stops the Online admin HTTP service and the Online runtime host in reverse order at exit; - /// skipped by null checks when they were never started ( false). - /// - /// 表示异步操作的任务 / A task representing the asynchronous operation - private async Task ShutdownOnlineAdminAsync() - { - // C244 好友域接管:先撤销好友域桥租约(旧引用显式失效),再停 Online admin 与 runtime 宿主。 - // C245 桥接:先撤销对局桥租约(旧引用显式失效),顺序同上(撤桥 → 停 admin → 停 runtime)。 - OnlineFriendDomainBridgeWiring.RetractFriendDomainBridge(); - OnlineMatchBridgeWiring.RetractMatchGameBridge(); - OnlineMatchDomainBridgeWiring.RetractMatchDomainBridge(); - - // C204 两层会话协同:撤销会话域桥租约(恢复请求自此显式失败,不残留旧引用)。 - OnlineSessionDomainBridgeWiring.RetractSessionDomainBridge(); - - if (_onlineAdminApi != null) - { - await _onlineAdminApi.StopAsync(); - } - - if (_onlineRuntime != null) - { - await _onlineRuntime.StopAsync(); - } - } /// /// 创建 Game 服务器的缺省配置。 diff --git a/GameFrameX.Launcher/StartUp/AppStartUpGameServerBase.cs b/GameFrameX.Launcher/StartUp/AppStartUpGameServerBase.cs index 55da59398..60526cd20 100644 --- a/GameFrameX.Launcher/StartUp/AppStartUpGameServerBase.cs +++ b/GameFrameX.Launcher/StartUp/AppStartUpGameServerBase.cs @@ -148,7 +148,8 @@ protected virtual Task PrepareAsync() } /// - /// Role 服务启动 hook(缺省 = 统一形态):热更模块装载(激活热更业务生命周期)→ 网络监听启动。 + /// Role 服务启动 hook(缺省 = 统一形态):热更模块装载(激活热更业务生命周期)→ + /// 能力族组合( 固定步骤)→ 网络监听启动。 /// /// /// The role-services hook (default = the unified shape): hotfix module loading (which activates the @@ -169,9 +170,34 @@ protected virtual Task PrepareAsync() protected virtual async Task StartRoleServicesAsync() { await HotfixManager.LoadHotfixModuleAsync(Setting); + await ComposeCapabilitiesAsync(); await StartServerAsync(new DefaultMessageCompressHandler(), new DefaultMessageDecompressHandler(), HotfixManager.GetListHttpHandler(), HotfixManager.GetHttpHandler); } + /// + /// 能力族组合固定步骤(C294 D6):热更装载之后、网络监听之前执行——保证监听开放时桥已发布、 + /// 热更侧钩子挂载先于能力侧声明读取。当前承载 online 族(经 + /// ):"Online" 配置段的 + /// EnableRoles(缺省 ["Game"])命中本 Role 时组合并启动;同族进程内单例(--AllInOne 下 + /// 首个命中 Role 组合,后续复用),停止归本类 停机步(见 + /// OnStoppingAsync 前的组合停机调用)。 + /// + /// + /// The fixed capability-family composition step (C294 D6): runs after the hotfix load and before + /// the network listeners start — bridges are published by the time listening opens and the + /// hotfix-side hook mounts precede the capability-side declaration reads. It currently carries the + /// online family (through + /// ): when the "Online" + /// section's EnableRoles (default ["Game"]) names this role the family is composed and + /// started; one composition per process for the family (under --AllInOne the first named role + /// composes and the rest reuse it), with the stop owned by this class's stop step. + /// + /// 表示组合操作的异步任务 / A task representing the composition operation + protected virtual Task ComposeCapabilitiesAsync() + { + return GameFrameX.Launcher.Online.OnlineCapabilityCompositionRoot.ComposeIfEnabledAsync(Setting); + } + /// /// 启动完成 hook(缺省 = 统一形态,全 Role 生效):网络监听就绪后分发 ServiceOnline 事件 ///(唯一分发点——热更侧不分发,C284 单源化)。 @@ -219,6 +245,9 @@ public sealed override async Task StopAsync(string message = "") return; } + // 能力族停机(逆序于启动:停 admin → 停调度 → 逆序 Deactivate → 自动撤桥;仅组合者 Role 执行,其余 Role 与未组合为空操作)—— + // 先于本 Role 的停机流程与 ServiceOffline 分发,消费方自此收显式 8002,不残留旧桥。 + await GameFrameX.Launcher.Online.OnlineCapabilityCompositionRoot.StopIfComposedAsync(Setting.ServerType); await OnStoppingAsync(message); await base.StopAsync(message); } @@ -243,14 +272,14 @@ protected virtual Task OnStoppingAsync(string message) /// /// 退出清理 hook(缺省 = 统一形态):自停——护栏内的 , /// 宿主逆序停机先到时本调用被护栏吸收,ServiceOffline 仍恰好一次。 - /// Game 型覆写为「Online 撤桥停 admin → 宿主停机 → 热更模块业务清理」三段编排。 + /// Game 型覆写为「宿主停机(护栏内含能力族停止)→ 热更模块业务清理」两段编排。 /// /// /// The exit-cleanup hook (default = the unified shape): self-stop through the guarded /// — when the host's reverse-order stop wins /// the race, the guard absorbs this call and ServiceOffline still fires exactly once. - /// The Game shape overrides it with the three-phase orchestration: Online bridge retraction and - /// admin stop, the host stop, then the hotfix module's business cleanup. + /// The Game shape overrides it with the two-phase orchestration: the host stop (with the + /// capability-family stop inside the guard) then the hotfix module's business cleanup. /// /// 退出原因(可能为 null——启动失败路径未获得退出消息)/ The exit reason (may be null on the startup-failure path) /// 表示 hook 操作的异步任务 / A task representing the hook operation diff --git a/GameFrameX.StartUp/Configuration/ConfigStartupValidator.cs b/GameFrameX.StartUp/Configuration/ConfigStartupValidator.cs index 3d9ce7d3a..f5fbbf252 100644 --- a/GameFrameX.StartUp/Configuration/ConfigStartupValidator.cs +++ b/GameFrameX.StartUp/Configuration/ConfigStartupValidator.cs @@ -77,7 +77,8 @@ private static readonly (string PortFieldName, string[] EnableFieldNames, string (nameof(AppSetting.OuterPort), new[] { nameof(AppSetting.IsEnableTcp) }, TransportTcp), (nameof(AppSetting.HttpPort), new[] { nameof(AppSetting.IsEnableHttp) }, TransportTcp), (nameof(AppSetting.HttpsPort), new[] { nameof(AppSetting.IsEnableHttp) }, TransportTcp), - (nameof(AppSetting.OnlineAdminPort), new[] { nameof(AppSetting.IsEnableOnlineAdmin) }, TransportTcp), + // Online 管理面端口不在本表:其使能开关已并入 "Online" 配置段(Online.EnableAdminApi,嵌套属性), + // 与平铺字段开关不同构,由 ExpandEnabledEndpoints 内专项展开。 (nameof(AppSetting.MetricsPort), new[] { nameof(AppSetting.IsOpenTelemetry), nameof(AppSetting.IsOpenTelemetryMetrics) }, TransportTcp), (nameof(AppSetting.WsPort), new[] { nameof(AppSetting.IsEnableWebSocket) }, TransportTcp), (nameof(AppSetting.WssPort), new[] { nameof(AppSetting.IsEnableWebSocket) }, TransportTcp), @@ -275,6 +276,18 @@ private static void CollectPortConflicts(List conflicts, IR } } + // Online 管理面端点专项展开:使能开关为嵌套 "Online" 配置段的 EnableAdminApi(C294 并段), + // 未配置段 / 未开闸的 Role 不参与端口冲突分组(禁用方不掩盖其余 Role 的冲突)。 + foreach (var role in selectedSections) + { + if (role.Setting.Online == null || !role.Setting.Online.EnableAdminApi || role.Setting.OnlineAdminPort == 0) + { + continue; + } + + endpoints.Add((role.ServerType, nameof(AppSetting.OnlineAdminPort), TransportTcp, role.Setting.OnlineAdminPort)); + } + return endpoints; } diff --git a/GameFrameX.Utility/Setting/AppSetting.cs b/GameFrameX.Utility/Setting/AppSetting.cs index da2bfecf7..fee60ea35 100644 --- a/GameFrameX.Utility/Setting/AppSetting.cs +++ b/GameFrameX.Utility/Setting/AppSetting.cs @@ -662,44 +662,13 @@ public string ToFormatString() [SettingFieldLevel(SettingFieldLevel.ProcessLevel)] public string InterNodeAuthSecret { get; set; } = ""; - /// - /// 是否启用 Online 平台业务运行时 - /// 开启后 Game 服进程在本进程内装配 Online Runtime(InMemory 存储 + 各域服务 + 事件桥 + 幂等 + 后台调度) - /// 与管理面开关 IsEnableOnlineAdmin 正交(C228 拆分:业务与管理面可独立启停);默认值为 false - /// - /// - /// Whether to enable the Online business runtime. - /// When enabled, the Game process hosts the Online Runtime in-process (in-memory stores, domain - /// services, event bridge, idempotency and the background scheduler). - /// Orthogonal to the admin-plane switch IsEnableOnlineAdmin (C228 split: business and - /// admin planes can be toggled independently). Default value is false. - /// - [Option(nameof(IsEnableOnline), DefaultValue = false, Description = "是否启用 Online 平台业务运行时,默认值为 false;与管理面开关 IsEnableOnlineAdmin 正交")] - [SettingFieldLevel(SettingFieldLevel.RoleLevel)] - public bool IsEnableOnline { get; set; } - - /// - /// 是否启用 Online 平台管理面(admin API) - /// 开启后按 OnlineAdminPort 暴露 Admin 管理端点;业务运行时未启时以 admin-only 形态监听(全部能力 action 按未安装裁决) - /// 默认值为 false,存量部署零影响 - /// - /// - /// Whether to enable the Online platform admin API. - /// When enabled, admin endpoints are exposed on OnlineAdminPort; without the business runtime - /// it listens in admin-only shape (every capability action is answered as not installed). - /// Default value is false. - /// - [Option(nameof(IsEnableOnlineAdmin), DefaultValue = false, Description = "是否启用 Online 平台管理面(admin API),默认值为 false")] - [SettingFieldLevel(SettingFieldLevel.RoleLevel)] - public bool IsEnableOnlineAdmin { get; set; } - /// /// Online admin API 监听端口 /// /// /// Online admin API listening port. /// - [Option(nameof(OnlineAdminPort), DefaultValue = 28090, Description = "Online admin API 监听端口,默认值为 28090,只有 IsEnableOnlineAdmin 为 true 时才生效")] + [Option(nameof(OnlineAdminPort), DefaultValue = 28090, Description = "Online admin API 监听端口,默认值为 28090,只有 Online 配置段 EnableAdminApi 为 true 时才生效")] [SettingFieldLevel(SettingFieldLevel.RoleLevel)] public ushort OnlineAdminPort { get; set; } = 28090; @@ -734,15 +703,19 @@ public string ToFormatString() public long OnlineAppId { get; set; } /// - /// Online 能力配置段(角色配置内嵌套 "Online" 节点的强类型绑定,C229) - /// Capabilities 白名单 / Disable 排除表;null 或空 = 全装配(缺省全装配红线);白名单未含依赖时组合期闭包校验 fail-fast + /// Online 能力配置段(角色配置内嵌套 "Online" 节点的强类型绑定,C294 键集重定) + /// 双闸 EnableRuntime / EnableAdminApi、EnableRoles 名单、Capabilities 白名单 / Disable 排除表、 + /// UseGameDbStores 覆盖开关与 Settings 能力级参数节点;EnableRoles 命中的 Role 在进程内组合一次 + ///(缺省名单 ["Game"]);null 或空过滤器 = 全装配(缺省全装配红线) /// CLI 侧经 StartupOptions.OnlineCapabilities / OnlineDisable 平铺别名投影(仅无配置段 Role 的回落档生效,配置段优先链不变) /// /// - /// Strong-typed binding of the nested "Online" node inside this role's configuration section (C229): - /// Capabilities is the enable whitelist and Disable the exclusion list; null or empty assembles - /// everything (the empty-filter-assembles-all guarantee), and a whitelist that omits a required - /// dependency fails fast at composition time. The CLI surface is the flat alias pair + /// Strong-typed binding of the nested "Online" node inside this role's configuration section (the C294 + /// key-set redefinition): the EnableRuntime / EnableAdminApi gates, the EnableRoles list, the + /// Capabilities whitelist / Disable exclusion list, the UseGameDbStores override switch and the + /// Settings capability-parameter node. The first role named by EnableRoles composes the family once + /// per process (default list ["Game"]); null or empty filters assemble everything (the + /// empty-filter-assembles-all guarantee). The CLI surface is the flat alias pair /// StartupOptions.OnlineCapabilities / OnlineDisable, projected only on the launcher fallback /// path for roles without a config section (the config-section-first chain is unchanged). /// diff --git a/GameFrameX.Utility/Setting/OnlineCapabilityOptions.cs b/GameFrameX.Utility/Setting/OnlineCapabilityOptions.cs index c6b94bf0a..0a4db6369 100644 --- a/GameFrameX.Utility/Setting/OnlineCapabilityOptions.cs +++ b/GameFrameX.Utility/Setting/OnlineCapabilityOptions.cs @@ -2,154 +2,149 @@ // GameFrameX 组织及其衍生项目的版权、商标、专利及其他相关权利 // GameFrameX organization and its derivative projects' copyrights, trademarks, patents and related rights // 均受中华人民共和国及相关国际法律法规保护。 -// are protected by the laws of the PRC and relevant international regulations. -// 使用本项目须严格遵守相关法律法规与开源许可证之规定。 +// 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 refer to the LICENSE file at the root of the source code for the full license text. +// 完整许可证文本请参见源代码根目录下的 LICENSE 文件。 +// please refer to 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 legal rights and interests of others as prohibited by laws and regulations! -// 因基于本项目二次开发所产生的一切法律纠纷与责任, -// Any legal disputes or liabilities arising from secondary development, -// 本组织与贡献者概不承担任何责任。 +// Any legal disputes and 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 -// Gite Repository: https://gitee.com/GameFrameX -// CNB 仓库:https://www.cnb.cool/GameFrameX -// CNB Repository: https://www.cnb.cool/GameFrameX +// Gitee Repository: https://gitee.com/cnblanblank +// CNB 仓库:https://cnb.cool +// CNB Repository: https://cnb.cool // 官方文档:https://gameframex.doc.alianblank.com/ -// Official Documentation: https://gameframex.doc.alianblank.com/ +// Official documentation: https://gameframex.doc.alianblank.com/ // ========================================================================================== +using System.Text.Json; + namespace GameFrameX.Utility.Setting; /// -/// Online 能力配置段(角色配置内嵌套 "Online" 节点的强类型绑定,C229) -/// 白名单 Capabilities 与排除表 Disable 承载能力 Id 列表;null 或空 = 全装配 / 无排除(缺省全装配红线) +/// Online 能力配置段(角色配置内嵌套 "Online" 节点的强类型绑定,C294 键集重定) +/// 族级键:双闸( / )与 +/// (缺省 ["Game"],D6 Role↔Capability 声明式映射)、能力过滤器 +/// ( 白名单 / 排除表,null 或空 = 全装配 / 无排除)、 +/// (store 覆盖开关);能力级参数经 原始 JSON 节点承载 +/// (键 = 能力 Id,值 = 该能力 Options 根类型的字段覆盖源,装配期由机制 Options 绑定器逐字段合并) /// 本类型为配置只读数据:装载后不得变更(多 Role 回落档可能共享同一实例引用) /// /// -/// Strong-typed binding of the nested "Online" node inside a role configuration section (C229). -/// Capabilities is the enable whitelist and Disable the exclusion list, both carrying -/// capability ids; null or empty means "assemble everything" / "exclude nothing" (the -/// empty-filter-assembles-all guarantee). Treat instances as read-only after loading — multiple -/// roles on the launcher fallback path may share one instance reference. +/// Strong-typed binding of the nested "Online" node inside a role configuration section (the C294 +/// key-set redefinition). Family-level keys: the two enable gates +/// ( / ) plus +/// (default ["Game"], the D6 declarative role-to-capability mapping), the capability filter +/// ( whitelist / exclusion list; null or empty assembles +/// everything / excludes nothing) and the store-override switch; +/// capability-level parameters ride the raw JSON node (key = capability id, value = +/// the field-override source for that capability's options root, merged field-by-field at composition time +/// by the mechanism options binder). Treat instances as read-only after loading — multiple roles on the +/// launcher fallback path may share one instance reference. /// public sealed class OnlineCapabilityOptions { /// - /// 能力白名单(能力 Id 列表,如 online-assets);null 或空 = 全装配 + /// 缺省启用角色表( 为 null 时等效本值)。 /// /// - /// Capability enable whitelist (capability ids, e.g. online-assets); null or empty assembles everything. + /// The default enabled-role list (equivalent to this value when is null). /// - public IReadOnlyList Capabilities { get; set; } + public static readonly IReadOnlyList DefaultEnableRoles = new[] { "Game" }; /// - /// 能力排除表(能力 Id 列表);null 或空 = 无排除 + /// 是否装配业务运行时(并入配置段的旧双闸之一;缺省 false = 不装配) /// /// - /// Capability exclusion list (capability ids); null or empty excludes nothing. + /// Whether to assemble the business runtime (the in-section successor of the former flat + /// runtime gate; default false = not assembled). /// - public IReadOnlyList Disable { get; set; } + public bool EnableRuntime { get; set; } /// - /// 是否预置 Online 一级 store 的 GameDb 持久化实现(C249,D3 = 3A 分级混合) - /// true = Identity / 玩家云存储 / 资产账本与交易 / 好友关系五 store 预置进共享服务面(重启数据不丢,好友为 C244 增补);false(缺省)= InMemory 默认不变 - /// 前置约束:开启前数据库必须已完成初始化(GameDb 门面 Init);Session / Presence 保持易失(Redis 演进位) + /// 是否装配管理面(并入配置段的旧双闸之一;缺省 false = 不装配; + /// 业务运行时在则全能力形态,不在则 admin-only 形态) /// /// - /// Whether to pre-seed the GameDb persistence adapters for the Online level-one stores (C249, D3 = 3A): - /// true pre-seeds identity / player storage / asset ledger and transaction stores into the shared - /// service face (data survives restarts); false (default) keeps the in-memory defaults unchanged. - /// The database must be initialized (GameDb facade Init) before enabling; sessions and presence stay volatile. + /// Whether to assemble the admin plane (the in-section successor of the former flat + /// admin gate; default false = not assembled; full-capability shape when the + /// runtime is assembled, admin-only shape otherwise). /// - public bool UseGameDbStores { get; set; } + public bool EnableAdminApi { get; set; } /// - /// 好友域桥验证种子玩家标识列表(C243 引入、C244 域接管后沿用;元素 < 2 = 不种子) - /// 首元素为请求方、其余为好友:组合根在业务运行时启动后,经真实 OnlineFriendService 写路径 - /// 两两建立好友(幂等收敛),验证入口(HTTP mode=online-bridge-friend&playerId=首元素)据此读到 - /// 预置非空好友状态——仅供部署验证,生产常开须显式知情(Warning 日志披露) - /// 请求方取配置 id 而非进程 actor 派生 id:玩家型组件 actor id 为多实例逐次唯一生成, - /// 不存在可预置的确定性请求主体 + /// 启用本族的 Role 名单(D6 声明式映射;null 或空 = 缺省 ["Game"])—— + /// --AllInOne 多 Role 进程中首个名单命中的 Role 负责组合,后续 Role 复用同一进程内组合 /// /// - /// The friend-domain bridge verification seed player ids (introduced by C243, kept by the C244 - /// domain takeover; fewer than 2 entries = no seeding): the first entry is the requester and the - /// rest the friends — after the business runtime starts the composition root establishes the - /// friendships pairwise through the real OnlineFriendService write path (idempotent - /// convergence), so the verification entry (HTTP - /// mode=online-bridge-friend&playerId=first-id) can read preset non-empty friend state. - /// Deployment verification only; keeping it on in production requires informed consent (a Warning - /// log discloses every seed). The requester comes from configuration rather than a - /// process-derived actor id: player-type component actor ids are per-call unique multi ids with no - /// deterministic request subject to preset. + /// The role names that enable this family (the D6 declarative mapping; null or empty = the default + /// ["Game"]) — under --AllInOne the first role named by the list composes and the + /// remaining roles reuse the same in-process composition. /// - public IReadOnlyList FriendBridgeSeedPlayerIds { get; set; } + public IReadOnlyList EnableRoles { get; set; } /// - /// 是否在业务运行时启动后执行旧好友数据一次性迁移(C244,T3;缺省 false = 不迁移) - /// true = 组合根读取本进程 GameDb 的旧 FriendRelationState(域 120 旧链,Status=0)与 - /// 旧 660 FriendState 档案文档(Outgoing 待答申请),经 OnlineFriendService 真实写路径 - /// 幂等收敛为 Online 好友关系(已建立 / 待答);旧数据零改动(只读单向复制,回退 = 重部署旧版即回旧链旧数据) - /// 分进程旧数据在 Social 库时,须在 GameDb 指向该库的进程上执行(AllInOne 天然满足); - /// 重复执行幂等(SaveIfAbsent 收敛,不产生第二条关系) + /// 能力白名单(能力 Id 列表,如 online-assets);null 或空 = 全装配 /// /// - /// Whether to run the one-shot legacy friend-data migration after the business runtime starts - /// (C244, T3; default false = no migration): true makes the composition root read this process's - /// GameDb — the legacy FriendRelationState rows (the domain-120 chain, Status=0) and the - /// legacy domain-660 FriendState profile document (Outgoing pending requests) — and - /// converge them into Online friendships through the real OnlineFriendService write path - /// (established / pending). The legacy data is never modified (a read-only one-way copy; - /// rollback = redeploying the legacy version restores the legacy chain over the legacy data). - /// When split-process legacy data lives in the Social database, run the migration on a process - /// whose GameDb points there (AllInOne satisfies this naturally); repeated runs are idempotent - /// (SaveIfAbsent convergence, never a second record). + /// Capability enable whitelist (capability ids, e.g. online-assets); null or empty assembles everything. /// - public bool MigrateLegacyFriendData { get; set; } + public IReadOnlyList Capabilities { get; set; } + + /// + /// 能力排除表(能力 Id 列表);null 或空 = 无排除 + /// + /// + /// Capability exclusion list (capability ids); null or empty excludes nothing. + /// + public IReadOnlyList Disable { get; set; } + + /// + /// 是否预置 Online 一级 store 的 GameDb 持久化实现(C294 起语义为宿主组合根 Override 注入开关) + /// true = 宿主经 Override<T> 预置身份 / 玩家云存储 / 资产账本与交易 / 好友关系 store + /// (重启数据不丢);false(缺省)= 包内 InMemory 默认不变 + /// 前置约束:开启前数据库必须已完成初始化(GameDb 门面 Init);Session / Presence 保持易失(Redis 演进位) + /// + /// + /// Whether the host composition root pre-seeds the GameDb persistence adapters for the Online + /// level-one stores via Override<T> (C294 semantics): true overrides the identity / + /// player-storage / asset-ledger and transaction / friend stores (data survives restarts); false + /// (default) keeps the in-package in-memory defaults. The database must be initialized (GameDb + /// facade Init) before enabling; sessions and presence stay volatile. + /// + public bool UseGameDbStores { get; set; } /// - /// 对局样例桥开关(C245,D4 RPS 垂直切片):true = Game 进程的房间制开局 / 出拳 / 查询 / 再来一局 - /// 协议入口经 Online 对局桥转发平台对局运行时(进程内桥读写),出拳裁决 / 结算 / 发奖 / 排行由平台编排; - /// false(缺省)= 旧链行为逐字节保持(Apps 本地对局执行不变)。 - /// 前置约束:开启时进程必须同时开启 IsEnableOnline 且装配 online-match 能力(组合根启动期 - /// fail-fast);桥失败(宿主未启 / 已停 / 服务错误)按显式错误返回,绝不静默回落旧链;本开关只应配置在 - /// Game 角色段 + /// 能力级参数原始 JSON 节点(键 = 能力 Id(如 online-social),值 = 该能力 Options 根类型的字段覆盖源; + /// 未知能力 Id / 未知字段 / 类型不符在装配期 fail-fast)。未配置时为 缺省值 + ///(,等效空对象)。 /// /// - /// Whether the sample match bridge is enabled (C245, the D4 RPS vertical slice): true routes the - /// Game process's room-shaped start / gesture / query / restart protocol entries through the - /// Online match bridge into the platform match runtime (an in-process bridge), with gesture - /// adjudication, settlement, rewards and leaderboards orchestrated by the platform; false - /// (default) keeps the legacy chain byte-identical (the local Apps match execution unchanged). - /// Enabling requires IsEnableOnline and the online-match capability (composition-root - /// fail-fast at startup); bridge failures (host not started / stopped / service errors) answer - /// with explicit error codes and never fall back to the legacy chain; configure this switch on - /// the Game role section only. + /// The raw JSON node of capability-level parameters (key = capability id such as online-social, value = + /// the field-override source for that capability's options root; unknown capability ids / unknown + /// fields / type mismatches fail fast at composition time). Unconfigured leaves the + /// default (, equivalent to an empty object). /// - public bool EnableMatchGameBridge { get; set; } + public JsonElement Settings { get; set; } /// - /// 对局结算写榜榜单标识(C245):非空 = 订阅 Online.Match.Settled 的可信结果投影目标榜单 - ///(要求 online-competition 能力;组合根确保榜单存在);空(缺省)= 不投影(仅结算发奖)。 - /// 榜单存储当前为 InMemory(D3 现状)——进程重启后积分易失,样例能力边界 + /// 读取生效的启用角色表( 为 null 或空时返回缺省 ["Game"])。 /// /// - /// The leaderboard id that settled match results project onto (C245): non-empty = the target - /// board for trusted-result projection subscribed from Online.Match.Settled (requires the - /// online-competition capability; the composition root ensures the board exists); empty - /// (default) = no projection (settlement and rewards only). The board store is currently - /// in-memory (the D3 status quo) — scores are volatile across process restarts, a documented - /// sample boundary. + /// Reads the effective enabled-role list (returns the default ["Game"] when + /// is null or empty). /// - public string MatchBridgeLeaderboardId { get; set; } + /// 生效的启用角色名单。 + public IReadOnlyList GetEffectiveEnableRoles() + { + return EnableRoles == null || EnableRoles.Count == 0 ? DefaultEnableRoles : EnableRoles; + } } diff --git a/build/deploy-game.sh b/build/deploy-game.sh index 843d6f4a9..eff03148e 100755 --- a/build/deploy-game.sh +++ b/build/deploy-game.sh @@ -26,7 +26,8 @@ set -euo pipefail # --ServerType=Game --ServerId=1001 --ServerInstanceId=1 # --InnerPort=29100 --OuterPort=29100 --HttpPort=28080 --WsPort=29110 # --MetricsPort=29090 --MinModuleId=10 --MaxModuleId=9999 -# --IsEnableOnline=true --IsEnableOnlineAdmin=true --OnlineTenantId=1 --OnlineAppId=1 +# --OnlineTenantId=1 --OnlineAppId=1 +# (Online 能力开关在配置段 Online.EnableRuntime / EnableAdminApi,C294 起无 CLI 平铺开关) # --DataBaseUrl= --DataBaseName=gameframex) # ============================================================================= diff --git a/docker-compose.yml b/docker-compose.yml index 02d1e1f02..b4408306c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -23,7 +23,7 @@ services: mongo: condition: service_healthy environment: - GAMEFRAMEX_SERVER_ARGS: "--ServerType=Game --ServerId=1001 --ServerInstanceId=1 --InnerPort=29100 --OuterPort=29100 --HttpPort=28080 --WsPort=29110 --MetricsPort=29090 --MinModuleId=10 --MaxModuleId=9999 --IsEnableOnlineAdmin=true --IsEnableOnline=true --OnlineTenantId=1 --OnlineAppId=1 --DataBaseUrl=mongodb://mongo:27017/?authSource=admin --DataBaseName=gameframex" + GAMEFRAMEX_SERVER_ARGS: "--ServerType=Game --ServerId=1001 --ServerInstanceId=1 --InnerPort=29100 --OuterPort=29100 --HttpPort=28080 --WsPort=29110 --MetricsPort=29090 --MinModuleId=10 --MaxModuleId=9999 --OnlineTenantId=1 --OnlineAppId=1 --DataBaseUrl=mongodb://mongo:27017/?authSource=admin --DataBaseName=gameframex" ports: - "39100:29100" - "38080:28080" From c78fc77cb2f7aedb546c8d2e44e911cefe1cc730 Mon Sep 17 00:00:00 2001 From: Blank Date: Sat, 24 Oct 2026 03:33:38 +0800 Subject: [PATCH 4/6] =?UTF-8?q?feat(build):=20GFX0018=20=E6=94=B9=E6=B3=9B?= =?UTF-8?q?=E5=9E=8B=E7=AC=A6=E5=8F=B7=E5=88=A4=E5=AE=9A=E5=B9=B6=E4=BD=BF?= =?UTF-8?q?=20gfx-online=20=E6=A8=A1=E6=9D=BF=E8=B5=B0=20Compose?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GFX0018 桥/钩子/组合入口符号集按泛型定义判定,替代旧名字清单(新桥族自动受 保护);GFX0017 机制白名单与 IsMechanism 收敛同一集合;gfx-online 模板改 CapabilityComposition.Compose,族配置段带作用域三元组,宿主扩展点提供最小实现 (EmptyPlayerDirectory / UnsupportedHotfixRollbackExecutor)。 Linear: GFX-998 --- .../CapabilityAssemblyBoundaryAnalyzer.cs | 142 ++++++++++-------- ...pabilityImplementationReferenceAnalyzer.cs | 19 ++- GameFrameX.Templates/README.md | 17 ++- .../gfx-online/.template.config/template.json | 22 ++- .../{{Name}}/EmptyPlayerDirectory.cs | 60 ++++++++ .../templates/gfx-online/{{Name}}/Program.cs | 134 ++++++----------- .../templates/gfx-online/{{Name}}/README.md | 33 ++-- .../UnsupportedHotfixRollbackExecutor.cs | 54 +++++++ .../gfx-online/{{Name}}/{{Name}}.csproj | 6 +- scripts/test-capability-boundary.py | 44 +++--- 10 files changed, 336 insertions(+), 195 deletions(-) create mode 100644 GameFrameX.Templates/templates/gfx-online/{{Name}}/EmptyPlayerDirectory.cs create mode 100644 GameFrameX.Templates/templates/gfx-online/{{Name}}/UnsupportedHotfixRollbackExecutor.cs diff --git a/GameFrameX.Architecture.Analyzers/Analyzers/Architecture/CapabilityAssemblyBoundaryAnalyzer.cs b/GameFrameX.Architecture.Analyzers/Analyzers/Architecture/CapabilityAssemblyBoundaryAnalyzer.cs index e735fe31d..727509c2e 100644 --- a/GameFrameX.Architecture.Analyzers/Analyzers/Architecture/CapabilityAssemblyBoundaryAnalyzer.cs +++ b/GameFrameX.Architecture.Analyzers/Analyzers/Architecture/CapabilityAssemblyBoundaryAnalyzer.cs @@ -35,15 +35,26 @@ namespace GameFrameX.Architecture.Analyzers; /// -/// GFX0018:能力注册及桥发布必须位于对应模块、组合根或测试夹具。 +/// GFX0018:Capability 机制 API 必须在其生命周期阶段与所属程序集内调用。 /// +/// +/// 符号按泛型定义(OriginalDefinition)判定,任意新增桥契约 / 钩子契约自动受保护,不维护名字清单: +/// +/// Register 期 API(CapabilityRegistrationContext 的注册 / 解析 / GetOptions / DeclareHook / RequireHostService) +/// 只在能力包模块内、持有 CapabilityRegistrationContext 参数的 Register 及其辅助方法中合法; +/// Bridges.Publish<> 只在能力包模块内、持有激活期上下文的 ActivateAsync 及其辅助方法中合法; +/// CapabilityBridge<>.TryAcquire 只在 Hotfix / Apps 消费侧合法;CapabilityHook<>.Attach 只在 Hotfix 合法; +/// CapabilityCompositionOptions.Override<> / Configure<> / SetAdminApiPlaneFactory 只在宿主组合入口(Launcher)合法。 +/// +/// 维护约束:Hosting 机制包是这些 API 的定义方,内部转交不受限;测试工程(精确程序集名)可构造任意阶段夹具。 +/// [DiagnosticAnalyzer(LanguageNames.CSharp)] public sealed class CapabilityAssemblyBoundaryAnalyzer : DiagnosticAnalyzer { private static readonly DiagnosticDescriptor Rule = new( "GFX0018", - "Capability 装配或桥生命周期调用越界", - "'{0}' 不允许在程序集 '{1}' 的当前类型中调用;{2},Hotfix 请逐请求读取 holder.Current 并调用 lease 域契约", + "Capability 机制 API 调用越界", + "'{0}' 不允许在程序集 '{1}' 的当前位置调用;{2}", ArchitectureAnalyzerConstants.Category, DiagnosticSeverity.Error, true); @@ -60,89 +71,90 @@ public override void Initialize(AnalysisContext context) private static void RegisterChecks(CompilationStartAnalysisContext context) { var targets = new Dictionary(SymbolEqualityComparer.Default); - var registration = ResolveType(context.Compilation, CapabilityBoundaryFacts.HostingAbstractions, "CapabilityRegistrationContext"); - var services = ResolveType(context.Compilation, CapabilityBoundaryFacts.HostingAbstractions, "CapabilityServices"); - var builder = ResolveType(context.Compilation, CapabilityBoundaryFacts.HostingCore, "CapabilityHostBuilder"); - var assemblyLoader = ResolveType(context.Compilation, CapabilityBoundaryFacts.HostingCore, "CapabilityAssembly"); - var module = ResolveType(context.Compilation, CapabilityBoundaryFacts.HostingAbstractions, "ICapabilityModule"); - AddTargets(targets, registration, BoundaryKind.Registration, "AddService", "AddTickJob", "AddIntervalJob", "AddEventJob"); - AddTargets(targets, services, BoundaryKind.Registration, "AddService"); - AddTargets(targets, builder, BoundaryKind.Builder, "UseService", "AddModule", "Build"); - foreach (var name in new[] { "OnlineFriendDomainBridgeHolder", "OnlineMatchGameBridgeHolder", "OnlineMatchDomainBridgeHolder" }) - { - var holder = ResolveType(context.Compilation, CapabilityBoundaryFacts.OnlineAbstractions, "Bridges." + name); - AddTargets(targets, holder, BoundaryKind.Bridge, "Publish", "Retract"); - } - + var compilation = context.Compilation; + var registration = ResolveType(compilation, CapabilityBoundaryFacts.HostingAbstractions, "CapabilityRegistrationContext"); + var activation = ResolveType(compilation, CapabilityBoundaryFacts.HostingAbstractions, "CapabilityActivationContext"); + var publisher = ResolveType(compilation, CapabilityBoundaryFacts.HostingAbstractions, "CapabilityBridgePublisher"); + var bridge = ResolveType(compilation, CapabilityBoundaryFacts.HostingAbstractions, "CapabilityBridge`1"); + var hook = ResolveType(compilation, CapabilityBoundaryFacts.HostingAbstractions, "CapabilityHook`1"); + var compositionOptions = ResolveType(compilation, CapabilityBoundaryFacts.HostingCore, "CapabilityCompositionOptions"); + var module = ResolveType(compilation, CapabilityBoundaryFacts.HostingAbstractions, "ICapabilityModule"); + AddTargets(targets, registration, BoundaryKind.Registration, "AddService", "AddTickJob", "AddIntervalJob", "Subscribe", + "GetService", "GetOptionalService", "GetServices", "GetOptions", "DeclareHook", "RequireHostService"); + AddTargets(targets, publisher, BoundaryKind.BridgePublish, "Publish"); + AddTargets(targets, bridge, BoundaryKind.BridgeAcquire, "TryAcquire"); + AddTargets(targets, hook, BoundaryKind.HookAttach, "Attach"); + AddTargets(targets, compositionOptions, BoundaryKind.HostComposition, "Configure", "Override", "SetAdminApiPlaneFactory"); if (targets.Count == 0) { return; } + var registrationPhase = new[] { registration }; + var activationPhase = new[] { activation, publisher }; context.RegisterOperationAction(operationContext => { var method = operationContext.Operation is IInvocationOperation invocation ? invocation.TargetMethod : ((IMethodReferenceOperation)operationContext.Operation).Method; - if (!targets.TryGetValue(method.OriginalDefinition, out var kind) - || IsAllowed(operationContext.ContainingSymbol, kind, registration, builder, assemblyLoader, module)) + if (!targets.TryGetValue(method.OriginalDefinition, out var kind)) + { + return; + } + + var phase = kind == BoundaryKind.BridgePublish ? activationPhase : registrationPhase; + if (IsAllowed(operationContext.ContainingSymbol, kind, module, phase)) { return; } - var guidance = kind == BoundaryKind.Bridge - ? "请将 Publish/Retract 移至 Launcher 启停 Wiring(测试可使用独立夹具)" - : "请将注册移至 Capability 模块所属类型(含辅助方法),或将 builder 预置移至 Launcher / Capability hosting 扩展"; operationContext.ReportDiagnostic(Diagnostic.Create(Rule, operationContext.Operation.Syntax.GetLocation(), method.ToDisplayString(SymbolDisplayFormat.CSharpErrorMessageFormat), - operationContext.Compilation.AssemblyName, guidance)); + operationContext.Compilation.AssemblyName, Guidance(kind))); }, OperationKind.Invocation, OperationKind.MethodReference); } - private static bool IsAllowed(ISymbol caller, BoundaryKind kind, INamedTypeSymbol registration, - INamedTypeSymbol builder, INamedTypeSymbol assemblyLoader, INamedTypeSymbol module) + private static bool IsAllowed(ISymbol caller, BoundaryKind kind, INamedTypeSymbol module, INamedTypeSymbol[] phase) { var assembly = caller.ContainingAssembly.Identity.Name; - if (CapabilityBoundaryFacts.IsCompositionOrTest(assembly)) + if (CapabilityBoundaryFacts.IsTest(assembly) || CapabilityBoundaryFacts.IsMechanism(assembly)) { return true; } - if (kind == BoundaryKind.Bridge) + switch (kind) { - return false; + case BoundaryKind.BridgeAcquire: + return assembly == ArchitectureAnalyzerConstants.AppsAssembly || assembly == ArchitectureAnalyzerConstants.HotfixAssembly; + case BoundaryKind.HookAttach: + return assembly == ArchitectureAnalyzerConstants.HotfixAssembly; + case BoundaryKind.HostComposition: + return assembly == CapabilityBoundaryFacts.Launcher; + default: + if (!CapabilityBoundaryFacts.IsImplementation(assembly)) + { + return false; + } + + return IsModulePhaseCall(caller, module, phase); } - - var type = caller as INamedTypeSymbol ?? caller.ContainingType; - if (IsMechanismType(type, kind, registration, builder, assemblyLoader)) - { - return true; - } - - if (!CapabilityBoundaryFacts.IsImplementation(assembly)) - { - return false; - } - - if (ImplementsCapabilityModule(type, module)) - { - return true; - } - - // 组合根调用的能力包 hosting 扩展可预置 store/options;不把普通工具类一并豁免。 - return kind == BoundaryKind.Builder && IsBuilderHostExtension(caller, builder); } - // 机制类型内部转交服务注册;不可用同名命名空间伪装这个身份。 - private static bool IsMechanismType(INamedTypeSymbol type, BoundaryKind kind, INamedTypeSymbol registration, - INamedTypeSymbol builder, INamedTypeSymbol assemblyLoader) + // 模块阶段身份 = 调用点外层方法链上存在「持有该阶段上下文参数 + 所属类型实现 ICapabilityModule」的方法; + // lambda / 局部函数向上追溯到 Register / ActivateAsync 本体,把上下文缓存到字段后在其它方法调用则不具备阶段身份。 + private static bool IsModulePhaseCall(ISymbol caller, INamedTypeSymbol module, INamedTypeSymbol[] phase) { - if (SymbolEqualityComparer.Default.Equals(type, registration) || SymbolEqualityComparer.Default.Equals(type, builder)) + for (var current = caller; current != null && current is not INamedTypeSymbol; current = current.ContainingSymbol) { - return true; + if (current is IMethodSymbol method + && method.Parameters.Any(parameter => phase.Any(type => type != null && SymbolEqualityComparer.Default.Equals(parameter.Type, type))) + && ImplementsCapabilityModule(method.ContainingType, module)) + { + return true; + } } - return kind == BoundaryKind.Builder && SymbolEqualityComparer.Default.Equals(type, assemblyLoader); + return false; } private static bool ImplementsCapabilityModule(INamedTypeSymbol type, INamedTypeSymbol module) @@ -158,19 +170,21 @@ private static bool ImplementsCapabilityModule(INamedTypeSymbol type, INamedType return false; } - private static bool IsBuilderHostExtension(ISymbol caller, INamedTypeSymbol builder) + private static string Guidance(BoundaryKind kind) { - for (var current = caller; current != null; current = current.ContainingSymbol) + switch (kind) { - if (current is IMethodSymbol { IsExtensionMethod: true } extension - && extension.Parameters.Length > 0 - && SymbolEqualityComparer.Default.Equals(extension.Parameters[0].Type, builder)) - { - return true; - } + case BoundaryKind.BridgePublish: + return "桥发布仅限能力包模块的 ActivateAsync(持有 CapabilityActivationContext 的激活期方法)经 context.Bridges.Publish 调用,撤销由宿主停止期自动完成"; + case BoundaryKind.BridgeAcquire: + return "桥租约仅限 Hotfix / Apps 消费侧经 CapabilityBridge.TryAcquire 获取,能力包与宿主请经服务注册面取用"; + case BoundaryKind.HookAttach: + return "钩子挂载仅限 Hotfix 程序集经 CapabilityHook.Attach 调用"; + case BoundaryKind.HostComposition: + return "Override / Configure / SetAdminApiPlaneFactory 仅限宿主组合入口(Launcher)调用"; + default: + return "Register 期 API 仅限能力包模块(实现 ICapabilityModule)的 Register 及持有 CapabilityRegistrationContext 参数的模块内辅助方法调用"; } - - return false; } private static INamedTypeSymbol ResolveType(Compilation compilation, string assembly, string relativeName) @@ -193,5 +207,5 @@ private static void AddTargets(Dictionary targets, } } - private enum BoundaryKind { Registration, Builder, Bridge } + private enum BoundaryKind { Registration, BridgePublish, BridgeAcquire, HookAttach, HostComposition } } diff --git a/GameFrameX.Architecture.Analyzers/Analyzers/Architecture/CapabilityImplementationReferenceAnalyzer.cs b/GameFrameX.Architecture.Analyzers/Analyzers/Architecture/CapabilityImplementationReferenceAnalyzer.cs index ccff81a12..9ae9ae51b 100644 --- a/GameFrameX.Architecture.Analyzers/Analyzers/Architecture/CapabilityImplementationReferenceAnalyzer.cs +++ b/GameFrameX.Architecture.Analyzers/Analyzers/Architecture/CapabilityImplementationReferenceAnalyzer.cs @@ -107,16 +107,27 @@ internal static class CapabilityBoundaryFacts internal const string OnlineAbstractions = "GameFrameX.Capability.Online.Abstractions"; internal const string Launcher = "GameFrameX.Launcher"; + /// + /// 判定程序集是否为 Capability 实现包(GFX0017 拦截 Apps / Hotfix 经引用闭包触达的对象)。 + /// 维护约束:Hosting 三包为机制定义方(C294 族组合骨架后 Online.Abstractions 传递可达 Hosting.Core), + /// 不属于能力实现;白名单与 保持同一集合,新增机制包须两处同步。 + /// internal static bool IsImplementation(string name) { return name.StartsWith("GameFrameX.Capability.", StringComparison.Ordinal) && name != OnlineAbstractions - && name != HostingAbstractions - && name != "GameFrameX.Capability.Hosting.AdminApi"; + && !IsMechanism(name); } - internal static bool IsCompositionOrTest(string name) + /// 测试工程(精确程序集名):可构造任意生命周期阶段的夹具,不受 GFX0018 阶段边界约束。 + internal static bool IsTest(string name) { - return name == Launcher || name == "GameFrameX.Tests" || name == "GameFrameX.Hotfix.Tests"; + return name == "GameFrameX.Tests" || name == "GameFrameX.Hotfix.Tests"; + } + + /// Hosting 机制三包:机制 API 的定义方,内部转交不受 GFX0018 约束(且不得引用能力包,见 GFX0017 边界)。 + internal static bool IsMechanism(string name) + { + return name == HostingAbstractions || name == HostingCore || name == "GameFrameX.Capability.Hosting.AdminApi"; } } diff --git a/GameFrameX.Templates/README.md b/GameFrameX.Templates/README.md index dab1db311..c42e50e1c 100644 --- a/GameFrameX.Templates/README.md +++ b/GameFrameX.Templates/README.md @@ -54,10 +54,11 @@ Tests/GameFrameX.Tests/Proto/DemoMessageDomainTests.cs ← 域 ## 使用 gfx-online(最小组合根宿主,C230) -生成一个最小宿主工程:按开关只引所选 `GameFrameX.Capability.Online.*` 能力包,经各包 -`AddOnlineXxxRuntime` 回调式显式链式装配——**引用集 = 装配集 = `list_capabilities` 输出集** -(编译期保证)。与 `gfx-role` 正交(垂直切片 vs 组合根宿主);不引 `GameFrameX.Launcher` -(其直引 11 能力包全家桶,引用即全量 DLL 落盘,编译期「引啥装配啥」不成立)。 +生成一个最小宿主工程:按开关只引所选 `GameFrameX.Capability.Online.*` 能力包,经机制层唯一组合入口 +`CapabilityComposition.Compose(new OnlineCapabilityFamily(), options)` 按部署前缀扫描装配—— +**引用集 = 装配集 = `list_capabilities` 输出集**(编译期保证,C294 起无按包装配代码)。与 `gfx-role` +正交(垂直切片 vs 组合根宿主);不引 `GameFrameX.Launcher`(其直引 11 能力包全家桶,引用即全量 DLL +落盘,编译期「引啥装配啥」不成立)。 ```bash # 必须在 Server.Source 仓根执行;-N 宿主工程名(PascalCase),免 -o 直接落地 ./MyOnlineHost/ @@ -70,12 +71,12 @@ dotnet build MyOnlineHost && dotnet run --project MyOnlineHost # 独立构建 能力开关:`--Assets`(缺省 true)/ `--Social` / `--Matchmaking` / `--Match` / `--Competition` / `--Operations`(缺省 false);Foundation 为脊柱恒引、不设开关。生成物 = `.csproj` + -`Program.cs` + `README.md` 三件;增减能力须 csproj `ProjectReference` 与 `Program.cs` -`AddOnlineXxxRuntime` **成对**增减(闭包缺口 Build 期 fail-fast)。 +`Program.cs` + `README.md` 三件;增减能力 = 只增删 csproj `ProjectReference`(`Program.cs` 组合代码与 +能力集无关;依赖声明缺口在组合期 fail-fast 一次报全)。 管理面:`POST http://127.0.0.1:28091/online/admin/{action}`(body 带作用域三元组 `TenantId/AppId/ServerId`);`list_capabilities` 如实输出装配集;未装配能力的 action 不可执行 -(未部署 → `4002 UnknownAction`;已引用被 `SetFilter`/`Disable` 排除 → `4003 CapabilityNotInstalled` +(未部署 → `4002 UnknownAction`;已引用被 `Capabilities`/`Disable` 过滤排除 → `4003 CapabilityNotInstalled` 载荷带已装清单)。 ### 行为实测结论(C230 演练,2026-10-13) @@ -111,4 +112,4 @@ dotnet build MyOnlineHost && dotnet run --project MyOnlineHost # 独立构建 - **向既有切片追加消息对**:dotnet new 只能新建文件、不能编辑既有 `.proto`/Handlers——按 C197 约定手工追加(消息对 `Req`/`Resp`、Resp 带 `ErrorCode`(ProtoMember 2047)、handler 三段式样板见任意 `*Handlers.cs`)。 - 新 ServerRole 启动入口(`AppStartUpXxx`)、Inner 负域号消息、HTTP handler 模板、配置表(Luban):按需后补/既有流程。 -- NuGet 发布(模板包不发布,C221 约定);Launcher 轨组合根(走 `AddOnlineCapabilities` 单入口,C229)。 +- NuGet 发布(模板包不发布,C221 约定);Launcher 轨组合根(走 `CapabilityComposition.Compose` 单入口,C294)。 diff --git a/GameFrameX.Templates/templates/gfx-online/.template.config/template.json b/GameFrameX.Templates/templates/gfx-online/.template.config/template.json index ef4eb5579..9bccabfa6 100644 --- a/GameFrameX.Templates/templates/gfx-online/.template.config/template.json +++ b/GameFrameX.Templates/templates/gfx-online/.template.config/template.json @@ -1,7 +1,11 @@ { "$schema": "http://json.schemastore.org/template", "author": "GameFrameX", - "classifications": [ "GameFrameX", "Capability", "CompositionRoot" ], + "classifications": [ + "GameFrameX", + "Capability", + "CompositionRoot" + ], "identity": "GameFrameX.OnlineMinimalComposition", "name": "GameFrameX Online 最小组合根宿主", "shortName": "gfx-online", @@ -9,7 +13,7 @@ "language": "C#", "type": "project" }, - "description": "生成最小 Online 能力组合根宿主工程(C230):按开关只引所选 Capability.Online 能力包,经各包 AddOnlineXxxRuntime 回调式显式链式装配——引啥装配啥(编译期保证);未装配能力的 admin action 不可执行(未部署 → 4002 UnknownAction;已引用被 SetFilter/Disable 排除 → 4003 CapabilityNotInstalled)。必须在 Server.Source 仓根执行。", + "description": "生成最小 Online 能力组合根宿主工程(C230 骨架,C294 机制化组合):按开关只引所选 Capability.Online 能力包,经 CapabilityComposition.Compose 按部署前缀扫描装配——引啥装配啥(编译期保证);未装配能力的 admin action 不可执行(未部署 → 4002 UnknownAction;已引用被 Capabilities/Disable 过滤排除 → 4003 CapabilityNotInstalled)。必须在 Server.Source 仓根执行。", "symbols": { "Name": { "type": "parameter", @@ -55,5 +59,17 @@ "defaultValue": "false", "description": "引用并装配 online-operations 能力包(聚合审计/回滚/总览;闭包含 Foundation)" } - } + }, + "sources": [ + { + "modifiers": [ + { + "condition": "(!Operations)", + "exclude": [ + "{{Name}}/UnsupportedHotfixRollbackExecutor.cs" + ] + } + ] + } + ] } diff --git a/GameFrameX.Templates/templates/gfx-online/{{Name}}/EmptyPlayerDirectory.cs b/GameFrameX.Templates/templates/gfx-online/{{Name}}/EmptyPlayerDirectory.cs new file mode 100644 index 000000000..e92e12f41 --- /dev/null +++ b/GameFrameX.Templates/templates/gfx-online/{{Name}}/EmptyPlayerDirectory.cs @@ -0,0 +1,60 @@ +// ========================================================================================== +// GameFrameX 组织及其衍生项目的版权、商标、专利及其他相关权利 +// GameFrameX organization and its derivative projects' copyrights, trademarks, patents, and related rights +// 均受中华人民共和国及相关国际法律法规保护。 +// are protected by the laws and regulations 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. +// +// 本项目采用 MIT 许可证与 Apache License 2.0 双许可证分发, +// This project is dual-licensed under the MIT License and Apache License 2.0, +// 完整许可证文本请参见源代码根目录下的 LICENSE 文件。 +// please refer to the LICENSE file at the root 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 of 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 +// 官方文档:https://gameframex.doc.alianblank.com/ +// Official Documentation: https://gameframex.doc.alianblank.com/ +// ========================================================================================== + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using GameFrameX.Capability.Online.Abstractions.Contracts; + +namespace {{Name}}; + +/// +/// 最小宿主的玩家目录(满足 online-social 以 RequireHostService 声明的宿主扩展点)。 +/// +/// 维护约束:本宿主不接玩家库,搜索与查找恒返回空集合——好友搜索无结果、好友展示名为空, +/// 其余社交功能不受影响。接入真实玩家数据时替换为读取玩家库的实现,并在 Program.cs 的 Override 处更换实例。 +/// +/// +public sealed class EmptyPlayerDirectory : IOnlinePlayerDirectory +{ + /// + public Task> SearchByNameAsync(long tenantId, long appId, string keyword, int limit, CancellationToken cancellationToken = default) + { + return Task.FromResult>(new List()); + } + + /// + public Task> FindAsync(long tenantId, long appId, IReadOnlyList playerIds, CancellationToken cancellationToken = default) + { + return Task.FromResult>(new List()); + } +} diff --git a/GameFrameX.Templates/templates/gfx-online/{{Name}}/Program.cs b/GameFrameX.Templates/templates/gfx-online/{{Name}}/Program.cs index 0f8ff1fb4..da830f06f 100644 --- a/GameFrameX.Templates/templates/gfx-online/{{Name}}/Program.cs +++ b/GameFrameX.Templates/templates/gfx-online/{{Name}}/Program.cs @@ -2,7 +2,7 @@ // 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. +// are protected by the laws and regulations 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. @@ -16,8 +16,9 @@ // 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 +// Any legal disputes or liabilities arising from secondary development of this project // 本项目组织与贡献者概不承担。 // shall be borne solely by the developer; the project organization and contributors assume no responsibility. // @@ -29,38 +30,25 @@ // Official Documentation: https://gameframex.doc.alianblank.com/ // ========================================================================================== -using GameFrameX.Capability.Hosting.Abstractions; +using System; +using System.Linq; +using System.Text.Json; +using System.Threading; using GameFrameX.Capability.Hosting.AdminApi; using GameFrameX.Capability.Hosting.Core; +using GameFrameX.Capability.Online.Abstractions; using GameFrameX.Capability.Online.Abstractions.Contracts; -using GameFrameX.Capability.Online.Abstractions.Events; -using GameFrameX.Capability.Online.Abstractions.Idempotency; -using GameFrameX.Capability.Online.Abstractions.Scope; -using GameFrameX.Capability.Online.Foundation; -//#if (Assets) -using GameFrameX.Capability.Online.Assets; -//#endif -//#if (Social) -using GameFrameX.Capability.Online.Social; -//#endif -//#if (Matchmaking) -using GameFrameX.Capability.Online.Matchmaking; -//#endif -//#if (Match) -using GameFrameX.Capability.Online.Match; -//#endif -//#if (Competition) -using GameFrameX.Capability.Online.Competition; -//#endif //#if (Operations) -using GameFrameX.Capability.Online.Operations; +using GameFrameX.Capability.Online.Operations.HotfixRollback; //#endif -using GameFrameX.Foundation.Idempotency; +using {{Name}}; -// {{Name}} —— Online 能力最小组合根宿主(gfx-online 模板生成,C230)。 -// 引啥装配啥:只装配 csproj 实际引用的能力包(编译期保证);未装配能力的 admin action 不可执行 -// ——未部署(未引用)→ 4002 UnknownAction;已引用被 SetFilter/Disable 排除 → 4003 CapabilityNotInstalled。 -// 增减能力 = csproj ProjectReference 与 AddOnlineXxxRuntime 成对增减。 +// {{Name}} —— Online 能力最小组合根宿主(gfx-online 模板生成,C230 组合根骨架 + C294 机制化组合)。 +// 引啥装配啥:CapabilityComposition.Compose 按 Online 族部署前缀扫描本进程加载的能力包, +// csproj 引用了哪些能力包(DLL 随构建落盘)就装配哪些能力(编译期保证); +// 未装配能力的 admin action 不可执行——未部署(未引用)→ 4002 UnknownAction; +// 已引用但被过滤排除 → 4003 CapabilityNotInstalled(载荷带已装清单)。 +// 增减能力 = 只增删 csproj ProjectReference(引用集即装配集,无按包装配代码)。 // 授权作用域三元组(演示值)。接入配置管道时改走嵌套强类型段 "Online": { ... }(禁平铺键, // 能力族全局约定 2026-10-12)。 @@ -68,72 +56,49 @@ const long appId = 1; const long serverId = 1; -// 组合根基建预置(与生产组合根同形的最小四件:作用域 / 事件总线 / 事件出口 / 幂等)。 -var scope = new OnlineScope(tenantId, appId, serverId); -var eventBus = new InMemoryEventPublisher(); -var idempotency = new OnlineIdempotencyService(new IdempotencyCoordinator( - new InMemoryIdempotencyStore(), SystemClock.Instance, - new IdempotencyOptions { FailedReplayPolicy = FailedReplayPolicy.ReExecute })); - -var builder = new CapabilityHostBuilder(); -builder.UseService(scope); -builder.UseService(eventBus); -builder.UseService(new OnlineEventPublisher(eventBus)); -builder.UseService(idempotency); - -// 能力装配(回调式,AddXxx(options => ...) 惯例;闭包缺口的引用组合会在 Build 期 fail-fast)。 -builder.AddOnlineFoundationRuntime(options => { /* 地基包级选项(缺省) */ }); -//#if (Assets) -builder.AddOnlineAssetsRuntime(options => { /* 资产包级选项(缺省) */ }); -//#endif -//#if (Social) -builder.AddOnlineSocialRuntime(options => { /* 社交包级选项(缺省) */ }); -//#endif -//#if (Matchmaking) -builder.AddOnlineMatchmakingRuntime(options => { /* 撮合包级选项(缺省) */ }); -//#endif -//#if (Match) -builder.AddOnlineMatchRuntime(options => { /* 对局包级选项(缺省) */ }); -//#endif -//#if (Competition) -builder.AddOnlineCompetitionRuntime(options => { /* 竞技包级选项(缺省) */ }); -//#endif -//#if (Operations) -builder.AddOnlineOperationsRuntime(options => { /* 运营聚合包级选项(缺省) */ }); -//#endif - -var host = builder.Build(new CapabilityHostOptions +// 族配置段(演示值):族脊柱包从段内读取授权作用域三元组(TenantId / AppId / ServerId,缺失或非正数 +// = 装配期 fail-fast);机制只把其中的 Settings 子对象按字段绑定到各能力 Options(空 = 全部走包内默认参数, +// 未知能力 Id / 未知字段 / 类型不符 = fail-fast)。接入配置管道后把 "Online" 段整段读成 JsonElement 传入; +// 族级键(EnableRoles / Capabilities / Disable / UseGameDbStores 等)由宿主自行解析后落到 options(如 options.Filter)。 +var familyConfigurationSection = JsonSerializer.SerializeToElement(new { TenantId = tenantId, AppId = appId, ServerId = serverId, + Settings = new { }, }); -// 管理面:模块贡献 action → 注册表 → 调度器 → HTTP 服务(机制开放,组合根零域硬编码)。 -var registry = new AdminActionRegistry(); -foreach (var module in host.Modules) +var options = new CapabilityCompositionOptions(familyConfigurationSection, new ConsoleCapabilityAssemblyLog()); +options.HostOptions.TenantId = tenantId; +options.HostOptions.AppId = appId; +options.HostOptions.ServerId = serverId; + +// 管理面:UseAdminApi 挂 admin 组装(action 注册 + 双向对账 + 元端点 list_capability_services / +// list_capability_settings)。端口避开生产 Launcher 缺省 28090,便于同机并存。 +options.UseAdminApi(adminOptions => { - if (module is IAdminActionContributor contributor) - { - contributor.RegisterAdminActions(registry); - } -} + adminOptions.AdminPort = 28091; + adminOptions.AdminApiPrefix = "online/admin"; +}); -var dispatcher = new AdminApiDispatcher( - registry, - OnlineAdminApiAdapters.ErrorCodes, - OnlineAdminApiAdapters.CreateScopeAdapter(host.Services.GetService()), - new AdminApiClaimedScope(host.TenantId, host.AppId, host.ServerId), - OnlineAdminApiAdapters.CreateIdempotencyAdapter(host.Services.GetService()), - host.Report); +// 宿主扩展点:能力以 RequireHostService 声明、包内无默认实现,宿主必须 Override 提供(缺失 = 装配期 fail-fast)。 +// 玩家目录供 online-social 好友搜索 / 展示名使用;本最小宿主不接玩家库,提供空目录(未装配 online-social 时无人解析,覆盖无副作用)。 +options.Override(new EmptyPlayerDirectory()); +//#if (Operations) +// Hotfix 版本切换执行器供 online-operations 回滚 action 使用;本最小宿主不承载热更运行时,执行即显式失败。 +options.Override(new UnsupportedHotfixRollbackExecutor()); +//#endif -// 管理面监听(端口避开生产 Launcher 缺省 28090,便于同机并存)。 -var admin = new AdminApiServer(new AdminApiOptions { AdminPort = 28091, AdminApiPrefix = "online/admin" }, dispatcher); +// 代码层参数覆盖(可选示例;只改需要改的字段,合并优先级:包内默认 < 配置段 Settings < 代码 +// Configure)。替换带默认实现的扩展点同样走 options.Override(...)(如宿主 GameDb store)。 +// options.Configure(configured => configured.Friend.FriendLimit = 500); -await host.StartAsync(); -await admin.StartAsync(); +// 唯一组合入口:族骨架 + 机制装配(依赖检查、拓扑定序、Options 绑定、装配日志一次报全)。 +// 启停序由 CapabilityRuntime 内含:启动 Host → Admin;停止 Admin → Host(桥撤销随宿主停止自动完成)。 +var runtime = CapabilityComposition.Compose(new OnlineCapabilityFamily(), options); +await runtime.StartAsync(); -Console.WriteLine("[{{Name}}] Online 最小组合根已启动。已装配能力: {0}", string.Join(", ", host.Modules.Select(module => module.Manifest.Id))); +Console.WriteLine("[{{Name}}] Online 最小组合根已启动。已装配能力(拓扑序): {0}", string.Join(", ", runtime.Report.EnabledIdsInOrder)); Console.WriteLine("[{{Name}}] 管理面: http://127.0.0.1:28091/online/admin/{{action}}(未装配 action 不可执行:未部署 → 4002 / 已引被排除 → 4003)"); Console.WriteLine("[{{Name}}] 按 Ctrl+C 退出。"); @@ -145,6 +110,5 @@ }; shutdown.Wait(); -await admin.StopAsync(); -await host.StopAsync(); +await runtime.StopAsync(); Console.WriteLine("[{{Name}}] 已停止。"); diff --git a/GameFrameX.Templates/templates/gfx-online/{{Name}}/README.md b/GameFrameX.Templates/templates/gfx-online/{{Name}}/README.md index 36e2785d9..5a47dfcfe 100644 --- a/GameFrameX.Templates/templates/gfx-online/{{Name}}/README.md +++ b/GameFrameX.Templates/templates/gfx-online/{{Name}}/README.md @@ -1,6 +1,6 @@ # {{Name}} —— Online 能力最小组合根宿主 -由 `gfx-online` 模板生成(C230,P2b 模板轨)。核心不变量:**引用集 = 装配集 = `list_capabilities` 输出集**(三者一致,编译期保证)。 +由 `gfx-online` 模板生成(C230,P2b 模板轨;C294 改走机制化组合)。核心不变量:**引用集 = 装配集 = `list_capabilities` 输出集**(三者一致,编译期保证)。 ## 构建 / 运行 @@ -12,15 +12,23 @@ dotnet run --project {{Name}} # 启动宿主 + 管理面(127.0.0.1:28091) ## 已引用的能力包 -见 `{{Name}}.csproj` 的 `ProjectReference` 与 `Program.cs` 的 `AddOnlineXxxRuntime` 装配语句——**两者必须成对存在**。 +见 `{{Name}}.csproj` 的 `ProjectReference`——`CapabilityComposition.Compose` 按族部署前缀扫描已加载的能力包,**引用了哪些包就装配哪些能力**,无需按包书写装配代码。 ## 增减能力 1. csproj 增/删对应 `Capability/GameFrameX.Capability.Online.<包名>` 的 `ProjectReference`; -2. `Program.cs` 同步增/删 `builder.AddOnline<包名>Runtime(...)` 装配语句; -3. 未引用的能力不参与编译与装配,其 admin action 不可执行——**未部署 → `4002 UnknownAction`**;已引用但被 `SetFilter`/`Disable` 排除 → `4003 CapabilityNotInstalled`(载荷带已装清单)。 +2. 未引用的能力不参与编译与装配,其 admin action 不可执行——**未部署 → `4002 UnknownAction`**;已引用但被过滤排除 → `4003 CapabilityNotInstalled`(载荷带已装清单)。 -闭包缺口(如引 Social 不引 Foundation)会在 `Build` 期 fail-fast。 +依赖声明缺口(如引 Social 不引 Foundation)在组合期 fail-fast(一次报全)。 + +## 宿主扩展点 + +部分能力以 `RequireHostService` 声明宿主扩展点(包内无默认实现,宿主未 `Override` 即组合期 fail-fast)。本模板已在 `Program.cs` 提供最小实现: + +- `IOnlinePlayerDirectory`(online-social 好友搜索 / 展示名)→ `EmptyPlayerDirectory`:恒返回空集合,好友搜索无结果,其余社交功能不受影响; +- `IOnlineHotfixRollbackExecutor`(online-operations 回滚 action,仅 `--Operations` 时生成)→ `UnsupportedHotfixRollbackExecutor`:本宿主不承载热更运行时,回滚显式失败。 + +接入真实玩家库或热更运行时时,替换这两个实现并在 `Program.cs` 的 `options.Override(...)` 处更换实例。 ## 管理面调用 @@ -38,15 +46,22 @@ curl -X POST http://127.0.0.1:28091/online/admin/<未引用能力的action> -H " ## 配置升级路径 -本宿主为最小演示形态,作用域三元组等为 `Program.cs` 常量。接入配置管道时遵循能力族全局约定(2026-10-12):嵌套强类型段 + POCO 绑定,**禁止平铺键**: +本宿主为最小演示形态:作用域三元组与族配置段(空段 = 全部能力走包内默认参数)为 `Program.cs` 常量。接入配置管道时遵循能力族全局约定(2026-10-12):嵌套强类型段 + POCO 绑定,**禁止平铺键**: ```json { "Online": { - "Capabilities": [ "online-foundation", "online-assets" ], - "Disable": [] + "EnableRoles": ["Game"], + "Capabilities": [], + "Disable": [], + "UseGameDbStores": false, + "Settings": { + "online-social": { "Friend": { "SearchLimit": 50 } } + } } } ``` -装配代码保持回调式 `AddXxx(options => ...)` 形态,不生成裸数据对象传参。 +族级键(`EnableRoles` / `Capabilities` / `Disable` / `UseGameDbStores`)由宿主解析后落到 +`CapabilityCompositionOptions`(如 `Filter`);`Settings` 子对象由机制按字段绑定到各能力 +Options 根类型(未知能力 Id / 未知字段 / 类型不符 / `Validate` 失败 = fail-fast)。 diff --git a/GameFrameX.Templates/templates/gfx-online/{{Name}}/UnsupportedHotfixRollbackExecutor.cs b/GameFrameX.Templates/templates/gfx-online/{{Name}}/UnsupportedHotfixRollbackExecutor.cs new file mode 100644 index 000000000..3eddc5043 --- /dev/null +++ b/GameFrameX.Templates/templates/gfx-online/{{Name}}/UnsupportedHotfixRollbackExecutor.cs @@ -0,0 +1,54 @@ +// ========================================================================================== +// GameFrameX 组织及其衍生项目的版权、商标、专利及其他相关权利 +// GameFrameX organization and its derivative projects' copyrights, trademarks, patents, and related rights +// 均受中华人民共和国及相关国际法律法规保护。 +// are protected by the laws and regulations 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. +// +// 本项目采用 MIT 许可证与 Apache License 2.0 双许可证分发, +// This project is dual-licensed under the MIT License and Apache License 2.0, +// 完整许可证文本请参见源代码根目录下的 LICENSE 文件。 +// please refer to the LICENSE file at the root 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 of 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 +// 官方文档:https://gameframex.doc.alianblank.com/ +// Official Documentation: https://gameframex.doc.alianblank.com/ +// ========================================================================================== + +using System; +using System.Threading; +using System.Threading.Tasks; +using GameFrameX.Capability.Online.Operations.HotfixRollback; + +namespace {{Name}}; + +/// +/// 最小宿主的 Hotfix 版本切换执行器(满足 online-operations 以 RequireHostService 声明的宿主扩展点)。 +/// +/// 维护约束:本宿主不承载热更运行时,回滚请求一律显式失败(不静默成功、不伪造切换结果); +/// 承载热更的宿主须提供真实执行器(参照 Server 侧 Launcher 的 HotfixManagerRollbackExecutor)。 +/// +/// +public sealed class UnsupportedHotfixRollbackExecutor : IOnlineHotfixRollbackExecutor +{ + /// + public Task RollbackAsync(string targetVersion, CancellationToken cancellationToken = default) + { + throw new NotSupportedException("This minimal Online host does not run a hotfix runtime; rollback to version '" + targetVersion + "' is not supported."); + } +} diff --git a/GameFrameX.Templates/templates/gfx-online/{{Name}}/{{Name}}.csproj b/GameFrameX.Templates/templates/gfx-online/{{Name}}/{{Name}}.csproj index bffcd470f..f104a1c96 100644 --- a/GameFrameX.Templates/templates/gfx-online/{{Name}}/{{Name}}.csproj +++ b/GameFrameX.Templates/templates/gfx-online/{{Name}}/{{Name}}.csproj @@ -10,9 +10,9 @@ - + diff --git a/scripts/test-capability-boundary.py b/scripts/test-capability-boundary.py index 17b6699dd..72a336483 100644 --- a/scripts/test-capability-boundary.py +++ b/scripts/test-capability-boundary.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""用真实 MSBuild ProjectReference / 本地 NuGet 验证 GFX0017(不修改工作区项目)。""" +"""用真实 MSBuild ProjectReference / 本地 NuGet 验证 GFX0017 / GFX0018(不修改工作区项目)。""" import os from pathlib import Path import subprocess @@ -73,27 +73,33 @@ def main(): f'') run(['build', legal]) print('PASS Launcher: transitive implementation -> legal') - contracts = REPO / 'bin/app_debug/GameFrameX.Capability.Online.Abstractions.dll' - bridge_items = (f'{escape(str(contracts))}' - f'') - holders = ('OnlineFriendDomainBridgeHolder', 'OnlineMatchGameBridgeHolder', 'OnlineMatchDomainBridgeHolder') - prefix = 'GameFrameX.Capability.Online.Abstractions.Bridges.' - lifecycle = 'public class Wiring { public void Run() { ' + ''.join( - prefix + holder + '.Retract();' for holder in holders) + ' } }' - reads = 'public class Adapter { public void Run() { ' + ''.join( - '_ = ' + prefix + holder + '.Current.IsActive;' for holder in holders) + ' } }' + contracts = REPO / 'bin/app_debug/GameFrameX.Capability.Hosting.Abstractions.dll' + hosting = REPO / 'bin/app_debug/GameFrameX.Capability.Hosting.Core.dll' + hosting_items = (f'{escape(str(contracts))}' + f'') + publish = ('using GameFrameX.Capability.Hosting.Abstractions; public class Wiring {' + ' public void Run(CapabilityActivationContext context) { context.Bridges.Publish(null); } }') + acquire = ('using GameFrameX.Capability.Hosting.Abstractions; public class Adapter {' + ' public bool Run() { return CapabilityBridge.TryAcquire(out _); } }') for consumer in ('GameFrameX.Apps', 'GameFrameX.Hotfix'): - negative = project(root / f'{consumer}-bridge-publish', consumer, bridge_items, lifecycle) + negative = project(root / f'{consumer}-bridge-publish', consumer, hosting_items, publish) output = run(['build', negative], 'GFX0018') - for holder in holders: - assert holder + '.Retract()' in output, output - print(f'PASS {consumer}: three bridge lifecycle calls -> GFX0018') - positive = project(root / f'{consumer}-bridge-current', consumer, bridge_items, reads) + assert 'Publish' in output, output + print(f'PASS {consumer}: 非模块桥发布 -> GFX0018') + positive = project(root / f'{consumer}-bridge-acquire', consumer, hosting_items, acquire) run(['build', positive]) - print(f'PASS {consumer}: three holder.Current / lease reads -> legal') - wiring = project(root / 'legal-wiring', 'GameFrameX.Launcher', bridge_items, lifecycle) - run(['build', wiring]) - print('PASS Launcher: three bridge lifecycle calls -> legal') + print(f'PASS {consumer}: CapabilityBridge.TryAcquire 消费侧 -> legal') + composition_items = (f'{escape(str(hosting))}' + f'{escape(str(contracts))}' + f'') + overrides = ('using GameFrameX.Capability.Hosting.Core; public class Entry {' + ' public void Run(CapabilityCompositionOptions options) { options.Configure(configured => { }); } }') + legal_composition = project(root / 'legal-composition', 'GameFrameX.Launcher', composition_items, overrides) + run(['build', legal_composition]) + print('PASS Launcher: CapabilityCompositionOptions.Configure -> legal') + illegal_acquire = project(root / 'launcher-bridge-acquire', 'GameFrameX.Launcher', hosting_items, acquire) + run(['build', illegal_acquire], 'GFX0018') + print('PASS Launcher: CapabilityBridge.TryAcquire 宿主侧 -> GFX0018') print('PASS 14/14 MSBuild boundary fixtures') From 44f23441c2fe7604de160af7cdb8889440afb2c8 Mon Sep 17 00:00:00 2001 From: Blank Date: Sat, 24 Oct 2026 03:33:38 +0800 Subject: [PATCH 5/6] =?UTF-8?q?feat(tests):=20=E6=B5=8B=E8=AF=95=E6=8C=89?= =?UTF-8?q?=E6=96=B0=E6=B3=A8=E5=85=A5=E5=A5=91=E7=BA=A6=E9=87=8D=E5=86=99?= =?UTF-8?q?=E5=B9=B6=E8=A6=86=E7=9B=96=E4=BE=9D=E8=B5=96=E4=B8=8E=E5=9B=9E?= =?UTF-8?q?=E6=BB=9A=E8=AF=AD=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 删除钉 holder/lease、探测回退与 Options 兜底的用例;新增生命周期逆序回滚、撤桥 后 8002、同契约重复发布、依赖缺失一次报全与环路路径、打乱发现序结果一致、 Options 三层覆盖与来源、钩子挂载替换释放、admin 同名失败与冒名对账、调度器时钟 注入、同进程二次组合等回归;结构测试引用矩阵与事件 topic 按新形态重新冻结。 Linear: GFX-998 --- .../FriendDomainBridgeTests.cs | 426 ++++++---- .../GameFrameX.Hotfix.Tests.csproj | 7 + .../HotfixBridgeAssemblyLog.cs | 57 ++ .../HotfixBridgeHarness.cs | 107 +++ .../HotfixBridgeStubModule.cs | 93 +++ .../HotfixBridgeStubScopeProvider.cs | 58 ++ .../HotfixBridgeStubSlots.cs | 58 ++ .../HotfixBridgeTestCollection.cs | 43 + .../HotfixBridgeTestFamily.cs | 64 ++ .../MatchDomainBridgeTests.cs | 281 ++++--- .../CapabilityBoundaryAnalyzerTests.cs | 398 ++++++--- .../GameFrameX.Tests/GameFrameX.Tests.csproj | 7 + .../Online/CapabilityAdminCompositionTests.cs | 329 ++++++++ .../Online/CapabilityBindingTestOptions.cs | 69 ++ .../Online/CapabilityDependencyCheckTests.cs | 453 +++++++++++ .../CapabilityHostServiceAndHookTests.cs | 556 +++++++++++++ .../Online/CapabilityHostingTests.cs | 446 +++++----- .../CapabilityLifecycleAndBridgeTests.cs | 503 ++++++++++++ .../Online/CapabilityOptionsBindingTests.cs | 468 +++++++++++ .../Online/CapabilityPackageStructureTests.cs | 85 +- .../Online/CapabilitySchedulerTests.cs | 554 +++++++++++-- .../Online/CapabilityServiceSharingTests.cs | 584 +++++++++++--- .../HotfixManagerRollbackExecutorTests.cs | 2 +- .../Online/MechanismCapabilityScenario.cs | 343 ++++++++ .../Online/OnlineAdminApiDispatcherTests.cs | 357 ++++---- .../Online/OnlineAdminApiRateLimitTests.cs | 101 +-- .../Online/OnlineAdminApiServerTests.cs | 162 ++-- .../OnlineAdminHotfixDispatcherTests.cs | 243 +++--- .../OnlineAssetsCapabilityPackageTests.cs | 194 ++--- .../Online/OnlineAuditServiceTests.cs | 2 +- .../Online/OnlineCapabilityComposeTests.cs | 759 ++++++++++++++++++ .../Online/OnlineCapabilityTestHarness.cs | 297 +++++++ .../Online/OnlineChatServiceTests.cs | 26 +- .../OnlineCrossServerGrantRouterTests.cs | 4 +- .../Online/OnlineDependencyMatrixTests.cs | 290 +++++-- .../OnlineEventPublisherConsumerTests.cs | 89 +- ...lineFriendConsumptionChainAssemblyTests.cs | 389 ++++----- .../Online/OnlineFriendDomainBridgeTests.cs | 454 +++++------ .../OnlineFriendLegacyMigrationTests.cs | 221 +++-- .../Online/OnlineFriendServiceTests.cs | 2 +- .../OnlineHotfixRollbackServiceTests.cs | 2 +- .../OnlineInMemoryStoreConcurrencyTests.cs | 2 +- .../OnlineLeaderboardTrustedResultTests.cs | 2 +- .../Online/OnlineMatchDomainBridgeTests.cs | 564 ++++++------- .../Online/OnlineMatchDomainResumeTests.cs | 11 +- .../OnlineMatchGameBridgeServiceTests.cs | 2 +- .../Online/OnlineMatchQueueObserverTests.cs | 5 +- .../Online/OnlineMatchRuntimeTests.cs | 13 +- ...neMatchSettledLeaderboardProjectorTests.cs | 228 ++++-- .../OnlineMatchSettlementPipelineTests.cs | 2 +- .../Online/OnlineMatchSettlementTests.cs | 2 +- .../Online/OnlineMatchSyncProtocolTests.cs | 13 +- .../Online/OnlineMatchTicketServiceTests.cs | 17 +- .../OnlineMatchmakerCoordinatorTests.cs | 15 +- ...OnlineMatchmakingCapabilityPackageTests.cs | 189 +++-- .../OnlineOverviewTimelineTestHarness.cs | 7 +- .../Online/OnlinePlayerStorageServiceTests.cs | 8 +- .../OnlineSocialCapabilityPackageTests.cs | 251 ++++-- .../OnlineSocialGateIntegrationTests.cs | 5 +- .../RockPaperScissorsMatchBridgeTests.cs | 286 ++++--- .../StartUp/ConfigStartupValidatorTests.cs | 10 +- .../OnlineCapabilityConfigSectionTests.cs | 100 ++- 62 files changed, 8689 insertions(+), 2626 deletions(-) create mode 100644 Tests/GameFrameX.Hotfix.Tests/HotfixBridgeAssemblyLog.cs create mode 100644 Tests/GameFrameX.Hotfix.Tests/HotfixBridgeHarness.cs create mode 100644 Tests/GameFrameX.Hotfix.Tests/HotfixBridgeStubModule.cs create mode 100644 Tests/GameFrameX.Hotfix.Tests/HotfixBridgeStubScopeProvider.cs create mode 100644 Tests/GameFrameX.Hotfix.Tests/HotfixBridgeStubSlots.cs create mode 100644 Tests/GameFrameX.Hotfix.Tests/HotfixBridgeTestCollection.cs create mode 100644 Tests/GameFrameX.Hotfix.Tests/HotfixBridgeTestFamily.cs create mode 100644 Tests/GameFrameX.Tests/Online/CapabilityAdminCompositionTests.cs create mode 100644 Tests/GameFrameX.Tests/Online/CapabilityBindingTestOptions.cs create mode 100644 Tests/GameFrameX.Tests/Online/CapabilityDependencyCheckTests.cs create mode 100644 Tests/GameFrameX.Tests/Online/CapabilityHostServiceAndHookTests.cs create mode 100644 Tests/GameFrameX.Tests/Online/CapabilityLifecycleAndBridgeTests.cs create mode 100644 Tests/GameFrameX.Tests/Online/CapabilityOptionsBindingTests.cs create mode 100644 Tests/GameFrameX.Tests/Online/MechanismCapabilityScenario.cs create mode 100644 Tests/GameFrameX.Tests/Online/OnlineCapabilityComposeTests.cs create mode 100644 Tests/GameFrameX.Tests/Online/OnlineCapabilityTestHarness.cs diff --git a/Tests/GameFrameX.Hotfix.Tests/FriendDomainBridgeTests.cs b/Tests/GameFrameX.Hotfix.Tests/FriendDomainBridgeTests.cs index ba0d34ebd..acb3afdce 100644 --- a/Tests/GameFrameX.Hotfix.Tests/FriendDomainBridgeTests.cs +++ b/Tests/GameFrameX.Hotfix.Tests/FriendDomainBridgeTests.cs @@ -33,7 +33,6 @@ using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; -using GameFrameX.Capability.Online.Abstractions.Bridges; using GameFrameX.Capability.Online.Abstractions.Contracts; using GameFrameX.Capability.Online.Abstractions.Scope; using GameFrameX.Hotfix.Logic.Player.Friend; @@ -42,47 +41,26 @@ using FriendDomainBridge = GameFrameX.Hotfix.Logic.Player.Friend.FriendDomainBridge; namespace GameFrameX.Hotfix.Tests; + /// -/// 好友域桥薄适配器测试(C244):120 / 660 两族错误码映射表(T2 裁定 R2-R7)、 -/// 作用域玩家位来源、租约失效显式段与异常兜底。 +/// 好友域桥薄适配器测试(C244 立项,C294 改为能力桥契约):120 / 660 两族错误码映射表(T2 裁定 R2-R7)、 +/// 作用域三元组来自授权作用域提供者桥 + 玩家位来自会话、桥缺席 / 撤销显式段与异常兜底。 +/// 桥由桩能力模块在真实组合的激活期发布,宿主停机自动撤销()。 /// -public sealed class FriendDomainBridgeTests : IDisposable +[Collection(HotfixBridgeTestCollection.Name)] +public sealed class FriendDomainBridgeTests { - /// 测试用租户标识。 - private const long TenantId = 1; - - /// 测试用应用标识。 - private const long AppId = 1; - - /// 测试用区服标识。 - private const long ServerId = 1001; - /// 玩家一(请求方)。 private const long PlayerOne = 1001; /// 玩家二(被请求方)。 private const long PlayerTwo = 1002; - public FriendDomainBridgeTests() - { - OnlineFriendDomainBridgeHolder.Retract(); - } - - public void Dispose() - { - OnlineFriendDomainBridgeHolder.Retract(); - } - /// - /// 构造玩家作用域。 + /// 好友域桥未发布:列表查询按依赖不可用显式失败,好友列表为空。 /// - private static OnlineScope PlayerScope(long playerId) - { - return new OnlineScope(TenantId, AppId, ServerId, playerId); - } - [Fact] - public async Task QueryFriendList_NoLease_ReturnsDependencyUnavailable() + public async Task QueryFriendList_NoBridge_ReturnsDependencyUnavailable() { var response = new RespFriendList(); await FriendDomainBridge.QueryFriendListAsync(PlayerOne, response); @@ -91,145 +69,212 @@ public async Task QueryFriendList_NoLease_ReturnsDependencyUnavailable() Assert.Empty(response.Friends); } + /// + /// 好友域桥在场:条目逐字段映射;作用域三元组取授权提供者,玩家位取会话 playerId(R2)。 + /// [Fact] public async Task QueryFriendList_Published_MapsEntriesAndFillsSessionPlayerPosition() { var face = new StubFriendDomain(); face.Entries.Add(new OnlineFriendEntry { PlayerId = PlayerTwo, Name = "bob" }); - OnlineFriendDomainBridgeHolder.Publish(face, PlayerScope(0)); - - var response = new RespFriendList(); - await FriendDomainBridge.QueryFriendListAsync(PlayerOne, response); - - Assert.Equal(0, response.ErrorCode); - var friend = Assert.Single(response.Friends); - Assert.Equal(PlayerTwo, friend.PlayerId); - Assert.Equal("bob", friend.PlayerName); - // 作用域玩家位 = 会话 playerId(R2),请求体 / lease 值均不可覆盖。 - Assert.NotNull(face.LastListFriendsScope); - Assert.Equal(PlayerOne, face.LastListFriendsScope.PlayerId); - Assert.Equal(TenantId, face.LastListFriendsScope.TenantId); + await HotfixBridgeHarness.RunWithFriendDomainAsync(face, async runtime => + { + var response = new RespFriendList(); + await FriendDomainBridge.QueryFriendListAsync(PlayerOne, response); + + Assert.Equal(0, response.ErrorCode); + var friend = Assert.Single(response.Friends); + Assert.Equal(PlayerTwo, friend.PlayerId); + Assert.Equal("bob", friend.PlayerName); + // 作用域玩家位 = 会话 playerId(R2),请求体 / 授权提供者的玩家位均不可覆盖。 + Assert.NotNull(face.LastListFriendsScope); + Assert.Equal(PlayerOne, face.LastListFriendsScope.PlayerId); + Assert.Equal(HotfixBridgeStubScopeProvider.TenantId, face.LastListFriendsScope.TenantId); + Assert.Equal(HotfixBridgeStubScopeProvider.AppId, face.LastListFriendsScope.AppId); + Assert.Equal(HotfixBridgeStubScopeProvider.ServerId, face.LastListFriendsScope.ServerId); + }); } + /// + /// 域面失败码走 Online 显式段(取负)。 + /// [Fact] public async Task QueryFriendList_FaceFailure_MapsOnlineBand() { var face = new StubFriendDomain { FailWith = OnlineErrorCode.ScopeDenied }; - OnlineFriendDomainBridgeHolder.Publish(face, PlayerScope(0)); - - var response = new RespFriendList(); - await FriendDomainBridge.QueryFriendListAsync(PlayerOne, response); + await HotfixBridgeHarness.RunWithFriendDomainAsync(face, async runtime => + { + var response = new RespFriendList(); + await FriendDomainBridge.QueryFriendListAsync(PlayerOne, response); - Assert.Equal(-(int)OnlineErrorCode.ScopeDenied, response.ErrorCode); - Assert.Empty(response.Friends); + Assert.Equal(-(int)OnlineErrorCode.ScopeDenied, response.ErrorCode); + Assert.Empty(response.Friends); + }); } + /// + /// 宿主停机撤桥后再请求:按依赖不可用显式失败(旧「租约撤销」语义 = 宿主 StopAsync)。 + /// [Fact] - public async Task QueryFriendList_LeaseRetracted_ReturnsDependencyUnavailable() + public async Task QueryFriendList_BridgeRetractedByHostStop_ReturnsDependencyUnavailable() { var face = new StubFriendDomain(); - OnlineFriendDomainBridgeHolder.Publish(face, PlayerScope(0)); - OnlineFriendDomainBridgeHolder.Retract(); + await HotfixBridgeHarness.RunWithFriendDomainAsync(face, async runtime => + { + await runtime.StopAsync(); - var response = new RespFriendList(); - await FriendDomainBridge.QueryFriendListAsync(PlayerOne, response); + var response = new RespFriendList(); + await FriendDomainBridge.QueryFriendListAsync(PlayerOne, response); - Assert.Equal(-(int)OnlineErrorCode.DependencyUnavailable, response.ErrorCode); + Assert.Equal(-(int)OnlineErrorCode.DependencyUnavailable, response.ErrorCode); + Assert.Equal(0, face.CallCount); + }); } + /// + /// 好友域桥在场但授权作用域提供者桥缺席:作用域三元组不可得,按适配器契约 + /// (FriendDomainBridge.RequireLease / OnlineAuthorizedScopeReader 红线 ③)以依赖不可用显式失败,域面不被调用。 + /// [Fact] - public async Task AddFriendDirect_Self_ReturnsLegacySelfCodeWithoutBridge() + public async Task QueryFriendList_ScopeProviderAbsent_ReturnsDependencyUnavailableWithoutCallingFace() { var face = new StubFriendDomain(); - OnlineFriendDomainBridgeHolder.Publish(face, PlayerScope(0)); + face.Entries.Add(new OnlineFriendEntry { PlayerId = PlayerTwo, Name = "bob" }); + await HotfixBridgeHarness.RunWithFriendDomainAsync(face, async runtime => + { + var response = new RespFriendList(); + await FriendDomainBridge.QueryFriendListAsync(PlayerOne, response); - var response = new RespFriendByAdd(); - await FriendDomainBridge.AddFriendDirectAsync(PlayerOne, PlayerOne, response); + Assert.Equal(-(int)OnlineErrorCode.DependencyUnavailable, response.ErrorCode); + Assert.Empty(response.Friends); + Assert.Equal(0, face.CallCount); + }, publishScopeProvider: false); + } - Assert.False(response.Success); - Assert.Equal(-101, response.ErrorCode); - Assert.Null(face.LastRequestTarget); + /// + /// 直接添加自己:本地返回旧 -101,不触达域桥。 + /// + [Fact] + public async Task AddFriendDirect_Self_ReturnsLegacySelfCodeWithoutBridge() + { + var face = new StubFriendDomain(); + await HotfixBridgeHarness.RunWithFriendDomainAsync(face, async runtime => + { + var response = new RespFriendByAdd(); + await FriendDomainBridge.AddFriendDirectAsync(PlayerOne, PlayerOne, response); + + Assert.False(response.Success); + Assert.Equal(-101, response.ErrorCode); + Assert.Null(face.LastRequestTarget); + }); } + /// + /// 直接添加非法目标:本地返回旧参数错误 -100。 + /// [Fact] public async Task AddFriendDirect_InvalidTarget_ReturnsLegacyParameterCode() { - OnlineFriendDomainBridgeHolder.Publish(new StubFriendDomain(), PlayerScope(0)); - - var response = new RespFriendByAdd(); - await FriendDomainBridge.AddFriendDirectAsync(PlayerOne, 0, response); + await HotfixBridgeHarness.RunWithFriendDomainAsync(new StubFriendDomain(), async runtime => + { + var response = new RespFriendByAdd(); + await FriendDomainBridge.AddFriendDirectAsync(PlayerOne, 0, response); - Assert.False(response.Success); - Assert.Equal(-100, response.ErrorCode); + Assert.False(response.Success); + Assert.Equal(-100, response.ErrorCode); + }); } + /// + /// 直接添加建立成功:Success=true,目标与作用域玩家位如实传入域面(R3:Establish)。 + /// [Fact] public async Task AddFriendDirect_Established_ReturnsSuccess() { var face = new StubFriendDomain { NextChange = new OnlineFriendRelationChange { Before = OnlineFriendRelationKind.None, After = OnlineFriendRelationKind.Friend } }; - OnlineFriendDomainBridgeHolder.Publish(face, PlayerScope(0)); - - var response = new RespFriendByAdd(); - await FriendDomainBridge.AddFriendDirectAsync(PlayerOne, PlayerTwo, response); - - Assert.True(response.Success); - Assert.Equal(0, response.ErrorCode); - Assert.Equal(PlayerTwo, face.LastRequestTarget); - Assert.Equal(PlayerOne, face.LastRequestScope.PlayerId); + await HotfixBridgeHarness.RunWithFriendDomainAsync(face, async runtime => + { + var response = new RespFriendByAdd(); + await FriendDomainBridge.AddFriendDirectAsync(PlayerOne, PlayerTwo, response); + + Assert.True(response.Success); + Assert.Equal(0, response.ErrorCode); + Assert.Equal(PlayerTwo, face.LastRequestTarget); + Assert.Equal(PlayerOne, face.LastRequestScope.PlayerId); + }); } + /// + /// 直接添加已是好友:旧 -103(Success=false)。 + /// [Fact] public async Task AddFriendDirect_AlreadyFriend_ReturnsLegacyAlreadyFriendsCode() { var face = new StubFriendDomain { NextChange = new OnlineFriendRelationChange { Before = OnlineFriendRelationKind.Friend, After = OnlineFriendRelationKind.Friend } }; - OnlineFriendDomainBridgeHolder.Publish(face, PlayerScope(0)); - - var response = new RespFriendByAdd(); - await FriendDomainBridge.AddFriendDirectAsync(PlayerOne, PlayerTwo, response); + await HotfixBridgeHarness.RunWithFriendDomainAsync(face, async runtime => + { + var response = new RespFriendByAdd(); + await FriendDomainBridge.AddFriendDirectAsync(PlayerOne, PlayerTwo, response); - Assert.False(response.Success); - Assert.Equal(-103, response.ErrorCode); + Assert.False(response.Success); + Assert.Equal(-103, response.ErrorCode); + }); } + /// + /// 删除不存在的好友关系:旧 -104。 + /// [Fact] public async Task DeleteFriend_NotFound_ReturnsLegacyRelationMissingCode() { var face = new StubFriendDomain { FailWith = OnlineErrorCode.ResourceNotFound }; - OnlineFriendDomainBridgeHolder.Publish(face, PlayerScope(0)); - - var response = new RespDeleteFriend(); - await FriendDomainBridge.DeleteFriendAsync(PlayerOne, PlayerTwo, response); + await HotfixBridgeHarness.RunWithFriendDomainAsync(face, async runtime => + { + var response = new RespDeleteFriend(); + await FriendDomainBridge.DeleteFriendAsync(PlayerOne, PlayerTwo, response); - Assert.False(response.Success); - Assert.Equal(-104, response.ErrorCode); + Assert.False(response.Success); + Assert.Equal(-104, response.ErrorCode); + }); } + /// + /// 删除好友成功:Success=true。 + /// [Fact] public async Task DeleteFriend_Removed_ReturnsSuccess() { var face = new StubFriendDomain { NextChange = new OnlineFriendRelationChange { Before = OnlineFriendRelationKind.Friend, After = OnlineFriendRelationKind.None } }; - OnlineFriendDomainBridgeHolder.Publish(face, PlayerScope(0)); - - var response = new RespDeleteFriend(); - await FriendDomainBridge.DeleteFriendAsync(PlayerOne, PlayerTwo, response); + await HotfixBridgeHarness.RunWithFriendDomainAsync(face, async runtime => + { + var response = new RespDeleteFriend(); + await FriendDomainBridge.DeleteFriendAsync(PlayerOne, PlayerTwo, response); - Assert.True(response.Success); - Assert.Equal(0, response.ErrorCode); + Assert.True(response.Success); + Assert.Equal(0, response.ErrorCode); + }); } + /// + /// 向自己发申请:本地返回 660002,不触达域桥。 + /// [Fact] public async Task SendRequest_Self_ReturnsCannotSelfWithoutBridge() { var face = new StubFriendDomain(); - OnlineFriendDomainBridgeHolder.Publish(face, PlayerScope(0)); - - var response = new RespFriendSendRequest(); - await FriendDomainBridge.SendRequestAsync(PlayerOne, PlayerOne, response); + await HotfixBridgeHarness.RunWithFriendDomainAsync(face, async runtime => + { + var response = new RespFriendSendRequest(); + await FriendDomainBridge.SendRequestAsync(PlayerOne, PlayerOne, response); - Assert.Equal((int)FriendErrorCode.CannotSelf, response.ErrorCode); - Assert.Null(face.LastRequestTarget); + Assert.Equal((int)FriendErrorCode.CannotSelf, response.ErrorCode); + Assert.Null(face.LastRequestTarget); + }); } + /// + /// 发申请按操作前关系分类映射 660 族旧码(R5/R6:已是好友 660003,任一方向待答 660004)。 + /// [Theory] [InlineData(OnlineFriendRelationKind.None, 0)] [InlineData(OnlineFriendRelationKind.Friend, 660003)] @@ -238,85 +283,113 @@ public async Task SendRequest_Self_ReturnsCannotSelfWithoutBridge() public async Task SendRequest_MapsBeforeKindToLegacyCodes(OnlineFriendRelationKind before, int expectedCode) { var face = new StubFriendDomain { NextChange = new OnlineFriendRelationChange { Before = before, After = before == OnlineFriendRelationKind.None ? OnlineFriendRelationKind.OutgoingRequest : before } }; - OnlineFriendDomainBridgeHolder.Publish(face, PlayerScope(0)); - - var response = new RespFriendSendRequest(); - await FriendDomainBridge.SendRequestAsync(PlayerOne, PlayerTwo, response); + await HotfixBridgeHarness.RunWithFriendDomainAsync(face, async runtime => + { + var response = new RespFriendSendRequest(); + await FriendDomainBridge.SendRequestAsync(PlayerOne, PlayerTwo, response); - Assert.Equal(expectedCode, response.ErrorCode); + Assert.Equal(expectedCode, response.ErrorCode); + }); } + /// + /// 接受不存在的申请:660005(R7)。 + /// [Fact] public async Task Accept_PendingMissing_ReturnsRequestNotFound() { var face = new StubFriendDomain { FailWith = OnlineErrorCode.ResourceNotFound }; - OnlineFriendDomainBridgeHolder.Publish(face, PlayerScope(0)); - - var response = new RespFriendAccept(); - await FriendDomainBridge.AcceptAsync(PlayerOne, PlayerTwo, response); + await HotfixBridgeHarness.RunWithFriendDomainAsync(face, async runtime => + { + var response = new RespFriendAccept(); + await FriendDomainBridge.AcceptAsync(PlayerOne, PlayerTwo, response); - Assert.Equal((int)FriendErrorCode.RequestNotFound, response.ErrorCode); + Assert.Equal((int)FriendErrorCode.RequestNotFound, response.ErrorCode); + }); } + /// + /// 接受待答申请:成功码 0。 + /// [Fact] public async Task Accept_Pending_ReturnsZero() { var face = new StubFriendDomain { NextChange = new OnlineFriendRelationChange { Before = OnlineFriendRelationKind.IncomingRequest, After = OnlineFriendRelationKind.Friend } }; - OnlineFriendDomainBridgeHolder.Publish(face, PlayerScope(0)); - - var response = new RespFriendAccept(); - await FriendDomainBridge.AcceptAsync(PlayerOne, PlayerTwo, response); + await HotfixBridgeHarness.RunWithFriendDomainAsync(face, async runtime => + { + var response = new RespFriendAccept(); + await FriendDomainBridge.AcceptAsync(PlayerOne, PlayerTwo, response); - Assert.Equal(0, response.ErrorCode); + Assert.Equal(0, response.ErrorCode); + }); } + /// + /// 拒绝不存在的申请:660005。 + /// [Fact] public async Task Reject_PendingMissing_ReturnsRequestNotFound() { var face = new StubFriendDomain { FailWith = OnlineErrorCode.ResourceNotFound }; - OnlineFriendDomainBridgeHolder.Publish(face, PlayerScope(0)); - - var response = new RespFriendReject(); - await FriendDomainBridge.RejectAsync(PlayerOne, PlayerTwo, response); + await HotfixBridgeHarness.RunWithFriendDomainAsync(face, async runtime => + { + var response = new RespFriendReject(); + await FriendDomainBridge.RejectAsync(PlayerOne, PlayerTwo, response); - Assert.Equal((int)FriendErrorCode.RequestNotFound, response.ErrorCode); + Assert.Equal((int)FriendErrorCode.RequestNotFound, response.ErrorCode); + }); } + /// + /// 解除与自己的关系:本地返回 660002。 + /// [Fact] public async Task Remove_Self_ReturnsCannotSelf() { - OnlineFriendDomainBridgeHolder.Publish(new StubFriendDomain(), PlayerScope(0)); - - var response = new RespFriendRemove(); - await FriendDomainBridge.RemoveAsync(PlayerOne, PlayerOne, response); + await HotfixBridgeHarness.RunWithFriendDomainAsync(new StubFriendDomain(), async runtime => + { + var response = new RespFriendRemove(); + await FriendDomainBridge.RemoveAsync(PlayerOne, PlayerOne, response); - Assert.Equal((int)FriendErrorCode.CannotSelf, response.ErrorCode); + Assert.Equal((int)FriendErrorCode.CannotSelf, response.ErrorCode); + }); } + /// + /// 解除非好友关系:660005(R7)。 + /// [Fact] public async Task Remove_NotFriend_ReturnsRequestNotFound() { var face = new StubFriendDomain { FailWith = OnlineErrorCode.ResourceNotFound }; - OnlineFriendDomainBridgeHolder.Publish(face, PlayerScope(0)); - - var response = new RespFriendRemove(); - await FriendDomainBridge.RemoveAsync(PlayerOne, PlayerTwo, response); + await HotfixBridgeHarness.RunWithFriendDomainAsync(face, async runtime => + { + var response = new RespFriendRemove(); + await FriendDomainBridge.RemoveAsync(PlayerOne, PlayerTwo, response); - Assert.Equal((int)FriendErrorCode.RequestNotFound, response.ErrorCode); + Assert.Equal((int)FriendErrorCode.RequestNotFound, response.ErrorCode); + }); } + /// + /// 解除好友关系成功:成功码 0。 + /// [Fact] public async Task Remove_Friend_ReturnsZero() { var face = new StubFriendDomain { NextChange = new OnlineFriendRelationChange { Before = OnlineFriendRelationKind.Friend, After = OnlineFriendRelationKind.None } }; - OnlineFriendDomainBridgeHolder.Publish(face, PlayerScope(0)); - - var response = new RespFriendRemove(); - await FriendDomainBridge.RemoveAsync(PlayerOne, PlayerTwo, response); + await HotfixBridgeHarness.RunWithFriendDomainAsync(face, async runtime => + { + var response = new RespFriendRemove(); + await FriendDomainBridge.RemoveAsync(PlayerOne, PlayerTwo, response); - Assert.Equal(0, response.ErrorCode); + Assert.Equal(0, response.ErrorCode); + }); } + /// + /// 全量关系按分类投影为好友 / 收到申请 / 发出申请三列表。 + /// [Fact] public async Task ListRelations_ProjectsKindsIntoThreeLists() { @@ -324,71 +397,93 @@ public async Task ListRelations_ProjectsKindsIntoThreeLists() face.Relations.Add(new OnlineFriendRelation { OtherPlayerId = 2001, Kind = OnlineFriendRelationKind.Friend }); face.Relations.Add(new OnlineFriendRelation { OtherPlayerId = 2002, Kind = OnlineFriendRelationKind.IncomingRequest }); face.Relations.Add(new OnlineFriendRelation { OtherPlayerId = 2003, Kind = OnlineFriendRelationKind.OutgoingRequest }); - OnlineFriendDomainBridgeHolder.Publish(face, PlayerScope(0)); - - var response = new RespFriendRelationList(); - await FriendDomainBridge.ListRelationsAsync(PlayerOne, response); - - Assert.Equal(0, response.ErrorCode); - Assert.Equal(new List { 2001 }, response.Friends); - Assert.Equal(new List { 2002 }, response.IncomingRequests); - Assert.Equal(new List { 2003 }, response.OutgoingRequests); + await HotfixBridgeHarness.RunWithFriendDomainAsync(face, async runtime => + { + var response = new RespFriendRelationList(); + await FriendDomainBridge.ListRelationsAsync(PlayerOne, response); + + Assert.Equal(0, response.ErrorCode); + Assert.Equal(new List { 2001 }, response.Friends); + Assert.Equal(new List { 2002 }, response.IncomingRequests); + Assert.Equal(new List { 2003 }, response.OutgoingRequests); + }); } + /// + /// 全量关系查询域面抛异常:660 族按 InternalServerError 兜底。 + /// [Fact] public async Task ListRelations_FaceThrows_ReturnsInternalServerError() { var face = new StubFriendDomain { Throw = true }; - OnlineFriendDomainBridgeHolder.Publish(face, PlayerScope(0)); - - var response = new RespFriendRelationList(); - await FriendDomainBridge.ListRelationsAsync(PlayerOne, response); + await HotfixBridgeHarness.RunWithFriendDomainAsync(face, async runtime => + { + var response = new RespFriendRelationList(); + await FriendDomainBridge.ListRelationsAsync(PlayerOne, response); - Assert.Equal((int)OperationStatusCode.InternalServerError, response.ErrorCode); + Assert.Equal((int)OperationStatusCode.InternalServerError, response.ErrorCode); + }); } + /// + /// 好友列表查询域面抛异常:120 族按旧异常码 -2 兜底,列表为空。 + /// [Fact] public async Task QueryFriendList_FaceThrows_ReturnsLegacyExceptionCode() { var face = new StubFriendDomain { Throw = true }; - OnlineFriendDomainBridgeHolder.Publish(face, PlayerScope(0)); - - var response = new RespFriendList(); - await FriendDomainBridge.QueryFriendListAsync(PlayerOne, response); + await HotfixBridgeHarness.RunWithFriendDomainAsync(face, async runtime => + { + var response = new RespFriendList(); + await FriendDomainBridge.QueryFriendListAsync(PlayerOne, response); - Assert.Equal(-2, response.ErrorCode); - Assert.Empty(response.Friends); + Assert.Equal(-2, response.ErrorCode); + Assert.Empty(response.Friends); + }); } /// - /// 域契约桩(可配置成功变更 / 失败码 / 抛异常;记录作用域与目标)。 + /// 域契约桩(可配置成功变更 / 失败码 / 抛异常;记录作用域、目标与调用次数)。 /// private sealed class StubFriendDomain : IOnlineFriendDomain { + /// 变更类调用的成功回执。 public OnlineFriendRelationChange NextChange { get; set; } = new() { Before = OnlineFriendRelationKind.None, After = OnlineFriendRelationKind.None }; + /// 失败码(None = 成功)。 public OnlineErrorCode FailWith { get; set; } + /// 是否抛异常。 public bool Throw { get; set; } + /// 好友列表条目。 public List Entries { get; } = new(); + /// 全量关系条目。 public List Relations { get; } = new(); + /// 域面被调用的总次数。 + public int CallCount { get; private set; } + + /// 最近一次好友列表查询作用域。 public OnlineScope LastListFriendsScope { get; private set; } + /// 最近一次申请 / 建立作用域。 public OnlineScope LastRequestScope { get; private set; } + /// 最近一次申请 / 建立目标。 public long? LastRequestTarget { get; private set; } public Task>> ListFriendsAsync(OnlineScope scope, CancellationToken cancellationToken = default) { + CallCount++; LastListFriendsScope = scope; - return Result(scope); + return Result(); } public Task>> ListRelationsAsync(OnlineScope scope, CancellationToken cancellationToken = default) { + CallCount++; if (Throw) { throw new InvalidOperationException("stub"); @@ -404,32 +499,32 @@ public Task> RequestAsync(OnlineScope s { LastRequestScope = scope; LastRequestTarget = targetPlayerId; - return ChangeResult(scope); + return ChangeResult(); } public Task> AcceptAsync(OnlineScope scope, long fromPlayerId, CancellationToken cancellationToken = default) { - return ChangeResult(scope); + return ChangeResult(); } public Task> RejectAsync(OnlineScope scope, long fromPlayerId, CancellationToken cancellationToken = default) { - return ChangeResult(scope); + return ChangeResult(); } public Task> RemoveAsync(OnlineScope scope, long targetPlayerId, CancellationToken cancellationToken = default) { - return ChangeResult(scope); + return ChangeResult(); } public Task> EstablishAsync(OnlineScope scope, long targetPlayerId, CancellationToken cancellationToken = default) { LastRequestScope = scope; LastRequestTarget = targetPlayerId; - return ChangeResult(scope); + return ChangeResult(); } - private Task>> Result(OnlineScope scope) + private Task>> Result() { if (Throw) { @@ -442,8 +537,9 @@ private Task>> Result(OnlineScope : OnlineResult>.Fail(FailWith, "stub failure")); } - private Task> ChangeResult(OnlineScope scope) + private Task> ChangeResult() { + CallCount++; if (Throw) { throw new InvalidOperationException("stub"); diff --git a/Tests/GameFrameX.Hotfix.Tests/GameFrameX.Hotfix.Tests.csproj b/Tests/GameFrameX.Hotfix.Tests/GameFrameX.Hotfix.Tests.csproj index f07367118..c64c8c703 100644 --- a/Tests/GameFrameX.Hotfix.Tests/GameFrameX.Hotfix.Tests.csproj +++ b/Tests/GameFrameX.Hotfix.Tests/GameFrameX.Hotfix.Tests.csproj @@ -26,4 +26,11 @@ + + + + + diff --git a/Tests/GameFrameX.Hotfix.Tests/HotfixBridgeAssemblyLog.cs b/Tests/GameFrameX.Hotfix.Tests/HotfixBridgeAssemblyLog.cs new file mode 100644 index 000000000..44967a5f5 --- /dev/null +++ b/Tests/GameFrameX.Hotfix.Tests/HotfixBridgeAssemblyLog.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 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 refer to 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 to 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.Generic; +using GameFrameX.Capability.Hosting.Abstractions; + +namespace GameFrameX.Hotfix.Tests; + +/// +/// 装配日志收集器(组合选项的必填日志出口;装配失败时用例可从 读出原因)。 +/// +public sealed class HotfixBridgeAssemblyLog : ICapabilityAssemblyLog +{ + /// Info 级装配日志。 + public List Infos { get; } = new List(); + + /// Error 级装配日志。 + public List Errors { get; } = new List(); + + /// + public void Info(string message) + { + Infos.Add(message); + } + + /// + public void Error(string message) + { + Errors.Add(message); + } +} diff --git a/Tests/GameFrameX.Hotfix.Tests/HotfixBridgeHarness.cs b/Tests/GameFrameX.Hotfix.Tests/HotfixBridgeHarness.cs new file mode 100644 index 000000000..976863b69 --- /dev/null +++ b/Tests/GameFrameX.Hotfix.Tests/HotfixBridgeHarness.cs @@ -0,0 +1,107 @@ +// ========================================================================================== +// 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 refer to 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 to 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; +using System.Threading.Tasks; +using GameFrameX.Capability.Hosting.Abstractions; +using GameFrameX.Capability.Hosting.Core; +using GameFrameX.Capability.Online.Abstractions.Contracts; + +namespace GameFrameX.Hotfix.Tests; + +/// +/// 域桥发布辅助(C294:替代旧进程级 holder 的 Publish / Retract)——写桩槽位 → 真实 +/// → (桩模块激活期发布桥)→ +/// 执行用例 → finally (宿主停机自动撤桥)+ 槽位复位。 +/// 「租约已撤销」= 用例体内先 StopAsync 再发请求。 +/// +public static class HotfixBridgeHarness +{ + /// + /// 发布好友域桥(+ 可选授权作用域提供者桥)后执行用例。 + /// + /// 好友域桥契约桩。 + /// 用例体(入参为已启动的组合产物)。 + /// 是否同时发布授权作用域提供者桥。 + /// 异步任务。 + public static Task RunWithFriendDomainAsync(IOnlineFriendDomain friendDomain, Func body, bool publishScopeProvider = true) + { + HotfixBridgeStubSlots.FriendDomain = friendDomain; + HotfixBridgeStubSlots.PublishScopeProvider = publishScopeProvider; + return RunAsync(body); + } + + /// + /// 发布匹配域桥(+ 可选授权作用域提供者桥)后执行用例。 + /// + /// 匹配域桥契约桩。 + /// 用例体(入参为已启动的组合产物)。 + /// 是否同时发布授权作用域提供者桥。 + /// 异步任务。 + public static Task RunWithMatchDomainAsync(IOnlineMatchDomain matchDomain, Func body, bool publishScopeProvider = true) + { + HotfixBridgeStubSlots.MatchDomain = matchDomain; + HotfixBridgeStubSlots.PublishScopeProvider = publishScopeProvider; + return RunAsync(body); + } + + /// + /// 组合并启动桩族、执行用例,finally 停机撤桥并复位槽位(组合失败时槽位同样复位)。 + /// + /// 用例体。 + /// 异步任务。 + private static async Task RunAsync(Func body) + { + CapabilityRuntime runtime = null; + try + { + var options = new CapabilityCompositionOptions(default, new HotfixBridgeAssemblyLog()) + { + HostOptions = new CapabilityHostOptions + { + TenantId = HotfixBridgeStubScopeProvider.TenantId, + AppId = HotfixBridgeStubScopeProvider.AppId, + ServerId = HotfixBridgeStubScopeProvider.ServerId, + }, + }; + runtime = CapabilityComposition.Compose(new HotfixBridgeTestFamily(), options); + await runtime.StartAsync(); + await body(runtime); + } + finally + { + if (runtime != null) + { + await runtime.StopAsync(); + } + + HotfixBridgeStubSlots.Reset(); + } + } +} diff --git a/Tests/GameFrameX.Hotfix.Tests/HotfixBridgeStubModule.cs b/Tests/GameFrameX.Hotfix.Tests/HotfixBridgeStubModule.cs new file mode 100644 index 000000000..a97924078 --- /dev/null +++ b/Tests/GameFrameX.Hotfix.Tests/HotfixBridgeStubModule.cs @@ -0,0 +1,93 @@ +// ========================================================================================== +// 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 refer to 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 to 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; +using System.Threading.Tasks; +using GameFrameX.Capability.Hosting.Abstractions; +using GameFrameX.Capability.Online.Abstractions.Contracts; +using GameFrameX.Capability.Online.Abstractions.Scope; + +namespace GameFrameX.Hotfix.Tests; + +/// +/// 域桥桩能力模块(本测试程序集内唯一的能力模块):激活期按 +/// 发布好友 / 匹配域桥契约与授权作用域提供者桥,复刻生产「模块 Activate 发布 / 宿主停机自动撤桥」。 +/// +public sealed class HotfixBridgeStubModule : ICapabilityModule +{ + /// 桩能力标识。 + public const string CapabilityId = "hotfix-bridge-stub"; + + /// + /// 桩能力清单(无依赖、无 admin action、无 Options)。 + /// + private static readonly CapabilityManifest ManifestInstance = new CapabilityManifest(CapabilityId, "1.0.0"); + + /// + public CapabilityManifest Manifest + { + get { return ManifestInstance; } + } + + /// + public void Register(CapabilityRegistrationContext context) + { + // 桩模块无注册期声明:桥契约只能在激活期发布。 + } + + /// + public Task ActivateAsync(CapabilityActivationContext context, CancellationToken cancellationToken) + { + var friendDomain = HotfixBridgeStubSlots.FriendDomain; + if (friendDomain != null) + { + context.Bridges.Publish(friendDomain); + } + + var matchDomain = HotfixBridgeStubSlots.MatchDomain; + if (matchDomain != null) + { + context.Bridges.Publish(matchDomain); + } + + if (HotfixBridgeStubSlots.PublishScopeProvider) + { + context.Bridges.Publish(new HotfixBridgeStubScopeProvider()); + } + + return Task.CompletedTask; + } + + /// + public Task DeactivateAsync(CancellationToken cancellationToken) + { + // 桥由机制在停止期自动撤销,模块不得自行撤销。 + return Task.CompletedTask; + } +} diff --git a/Tests/GameFrameX.Hotfix.Tests/HotfixBridgeStubScopeProvider.cs b/Tests/GameFrameX.Hotfix.Tests/HotfixBridgeStubScopeProvider.cs new file mode 100644 index 000000000..ded12b788 --- /dev/null +++ b/Tests/GameFrameX.Hotfix.Tests/HotfixBridgeStubScopeProvider.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 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 refer to 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 to 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.Capability.Online.Abstractions.Scope; + +namespace GameFrameX.Hotfix.Tests; + +/// +/// 授权作用域提供者桩(等价 online-foundation 发布的宿主授权三元组:租户 1 / 应用 1 / 区服 1001,玩家位 0)。 +/// +public sealed class HotfixBridgeStubScopeProvider : IOnlineAuthorizedScopeProvider +{ + /// 桩授权租户标识。 + public const long TenantId = 1; + + /// 桩授权应用标识。 + public const long AppId = 1; + + /// 桩授权区服标识。 + public const long ServerId = 1001; + + /// + /// 授权作用域实例(全宿主单例语义)。 + /// + private readonly OnlineScope _authorizedScope = new OnlineScope(TenantId, AppId, ServerId); + + /// + public OnlineScope AuthorizedScope + { + get { return _authorizedScope; } + } +} diff --git a/Tests/GameFrameX.Hotfix.Tests/HotfixBridgeStubSlots.cs b/Tests/GameFrameX.Hotfix.Tests/HotfixBridgeStubSlots.cs new file mode 100644 index 000000000..53be13f0f --- /dev/null +++ b/Tests/GameFrameX.Hotfix.Tests/HotfixBridgeStubSlots.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 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 refer to 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 to 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.Capability.Online.Abstractions.Contracts; + +namespace GameFrameX.Hotfix.Tests; + +/// +/// 域桥桩模块的静态槽位(用例在组合前写入要发布的桥契约;全部用例须落 +/// 串行集合,避免槽位与进程级桥槽被并行用例交叉覆写)。 +/// +public static class HotfixBridgeStubSlots +{ + /// 要发布的好友域桥契约(null = 不发布)。 + public static IOnlineFriendDomain FriendDomain; + + /// 要发布的匹配域桥契约(null = 不发布)。 + public static IOnlineMatchDomain MatchDomain; + + /// 是否发布授权作用域提供者桥(缺省 true;false 用于锚定「域桥在场、作用域提供者缺席」)。 + public static bool PublishScopeProvider = true; + + /// + /// 复位全部槽位(组合辅助在 finally 中调用,保证缺省态不泄漏到后续用例)。 + /// + public static void Reset() + { + FriendDomain = null; + MatchDomain = null; + PublishScopeProvider = true; + } +} diff --git a/Tests/GameFrameX.Hotfix.Tests/HotfixBridgeTestCollection.cs b/Tests/GameFrameX.Hotfix.Tests/HotfixBridgeTestCollection.cs new file mode 100644 index 000000000..159ee4b97 --- /dev/null +++ b/Tests/GameFrameX.Hotfix.Tests/HotfixBridgeTestCollection.cs @@ -0,0 +1,43 @@ +// ========================================================================================== +// 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 refer to 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 to 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 Xunit; + +namespace GameFrameX.Hotfix.Tests; + +/// +/// 域桥适配器测试禁并行 Collection(桥契约槽位是进程级静态状态:同契约重复发布 = 组合失败, +/// 撤桥时机也必须由单个用例独占,好友 / 匹配域桥用例串行执行)。 +/// +[CollectionDefinition(Name, DisableParallelization = true)] +public sealed class HotfixBridgeTestCollection +{ + /// Collection 名。 + public const string Name = "HotfixBridgeTests"; +} diff --git a/Tests/GameFrameX.Hotfix.Tests/HotfixBridgeTestFamily.cs b/Tests/GameFrameX.Hotfix.Tests/HotfixBridgeTestFamily.cs new file mode 100644 index 000000000..5a5fcd0de --- /dev/null +++ b/Tests/GameFrameX.Hotfix.Tests/HotfixBridgeTestFamily.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 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 refer to 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 to 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.Capability.Hosting.Abstractions; + +namespace GameFrameX.Hotfix.Tests; + +/// +/// 域桥适配器测试族(C294):发现面 = 测试程序集的部署命名约定副本 +/// GameFrameX.Hotfix.Tests.BridgeStubs.dll(机制按 {前缀}.*.dll 扫描后解析回本测试程序集), +/// 让桩能力模块走真实「Compose → ActivateAsync 发布桥 → StopAsync 撤桥」路径;族基建为空。 +/// +public sealed class HotfixBridgeTestFamily : ICapabilityFamily +{ + /// + public string FamilyId + { + get { return "hotfix-bridge-tests"; } + } + + /// + public string ConfigurationSectionName + { + get { return "HotfixBridgeTests"; } + } + + /// + public string DeploymentAssemblyPrefix + { + get { return "GameFrameX.Hotfix.Tests"; } + } + + /// + public void RegisterInfrastructure(CapabilityFamilyContext context) + { + // 域桥测试不需要族基建:桥契约全部由桩模块在激活期发布。 + } +} diff --git a/Tests/GameFrameX.Hotfix.Tests/MatchDomainBridgeTests.cs b/Tests/GameFrameX.Hotfix.Tests/MatchDomainBridgeTests.cs index 9714141b2..6baa7cafb 100644 --- a/Tests/GameFrameX.Hotfix.Tests/MatchDomainBridgeTests.cs +++ b/Tests/GameFrameX.Hotfix.Tests/MatchDomainBridgeTests.cs @@ -2,7 +2,6 @@ using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; -using GameFrameX.Capability.Online.Abstractions.Bridges; using GameFrameX.Capability.Online.Abstractions.Contracts; using GameFrameX.Capability.Online.Abstractions.Scope; using GameFrameX.Hotfix.Logic.ServerRole.Match; @@ -12,43 +11,21 @@ namespace GameFrameX.Hotfix.Tests; /// -/// 匹配域桥薄适配器测试(C246):420 族错误码映射表(T2 裁定 R4/R8/R15/R16)、 -/// 作用域玩家位来源、租约失效显式段、本地校验(模式 / 评分)与入场新面映射。 +/// 匹配域桥薄适配器测试(C246 立项,C294 改为能力桥契约):420 族错误码映射表(T2 裁定 R4/R8/R15/R16)、 +/// 作用域三元组来自授权作用域提供者桥 + 玩家位来自会话、桥缺席 / 撤销显式段、本地校验(模式 / 评分)与入场新面映射。 +/// 桥由桩能力模块在真实组合的激活期发布,宿主停机自动撤销()。 /// -public sealed class MatchDomainBridgeTests : IDisposable +[Collection(HotfixBridgeTestCollection.Name)] +public sealed class MatchDomainBridgeTests { - /// 测试用租户标识。 - private const long TenantId = 1; - - /// 测试用应用标识。 - private const long AppId = 1; - - /// 测试用区服标识。 - private const long ServerId = 1001; - /// 玩家一。 private const long PlayerOne = 2001; - public MatchDomainBridgeTests() - { - OnlineMatchDomainBridgeHolder.Retract(); - } - - public void Dispose() - { - OnlineMatchDomainBridgeHolder.Retract(); - } - /// - /// 构造玩家作用域。 + /// 匹配域桥未发布:入队按依赖不可用显式失败,MatchId 为 0。 /// - private static OnlineScope PlayerScope(long playerId) - { - return new OnlineScope(TenantId, AppId, ServerId, playerId); - } - [Fact] - public async Task Enqueue_NoLease_ReturnsDependencyUnavailable() + public async Task Enqueue_NoBridge_ReturnsDependencyUnavailable() { var response = new RespMatchEnqueue(); await MatchDomainBridge.EnqueueAsync(PlayerOne, new ReqMatchEnqueue { Mode = MatchMode.OneVsOne, Rating = 1000 }, response); @@ -57,103 +34,175 @@ public async Task Enqueue_NoLease_ReturnsDependencyUnavailable() Assert.Equal(0, response.MatchId); } + /// + /// 本地校验:模式非法 420004、评分非法 420005,均不触达域桥。 + /// [Fact] public async Task Enqueue_LocalValidation_RejectsModeAndRating() { var face = new StubMatchDomain(); - OnlineMatchDomainBridgeHolder.Publish(face, PlayerScope(0)); - - var badMode = new RespMatchEnqueue(); - await MatchDomainBridge.EnqueueAsync(PlayerOne, new ReqMatchEnqueue { Mode = (MatchMode)9, Rating = 1000 }, badMode); - Assert.Equal((int)MatchErrorCode.ModeInvalid, badMode.ErrorCode); - Assert.Null(face.LastEnqueueScope); - - var badRating = new RespMatchEnqueue(); - await MatchDomainBridge.EnqueueAsync(PlayerOne, new ReqMatchEnqueue { Mode = MatchMode.OneVsOne, Rating = 0 }, badRating); - Assert.Equal((int)MatchErrorCode.RatingInvalid, badRating.ErrorCode); - Assert.Null(face.LastEnqueueScope); + await HotfixBridgeHarness.RunWithMatchDomainAsync(face, async runtime => + { + var badMode = new RespMatchEnqueue(); + await MatchDomainBridge.EnqueueAsync(PlayerOne, new ReqMatchEnqueue { Mode = (MatchMode)9, Rating = 1000 }, badMode); + Assert.Equal((int)MatchErrorCode.ModeInvalid, badMode.ErrorCode); + Assert.Null(face.LastEnqueueScope); + + var badRating = new RespMatchEnqueue(); + await MatchDomainBridge.EnqueueAsync(PlayerOne, new ReqMatchEnqueue { Mode = MatchMode.OneVsOne, Rating = 0 }, badRating); + Assert.Equal((int)MatchErrorCode.RatingInvalid, badRating.ErrorCode); + Assert.Null(face.LastEnqueueScope); + }); } + /// + /// 匹配域桥在场:作用域三元组取授权提供者、玩家位取会话 playerId(R2),入队参数与回执如实映射。 + /// [Fact] public async Task Enqueue_Published_FillsSessionPlayerPositionAndMapsResult() { var face = new StubMatchDomain { EnqueueMatchId = 77 }; - OnlineMatchDomainBridgeHolder.Publish(face, PlayerScope(0)); - - var response = new RespMatchEnqueue(); - await MatchDomainBridge.EnqueueAsync(PlayerOne, new ReqMatchEnqueue { Mode = MatchMode.OneVsOne, Rating = 1000 }, response); - - Assert.Equal(0, response.ErrorCode); - Assert.Equal(77, response.MatchId); - // 作用域玩家位 = 会话 playerId(R2),lease 值不可覆盖。 - Assert.NotNull(face.LastEnqueueScope); - Assert.Equal(PlayerOne, face.LastEnqueueScope.PlayerId); - Assert.Equal(TenantId, face.LastEnqueueScope.TenantId); - Assert.Equal((int)MatchMode.OneVsOne, face.LastEnqueueMode); - Assert.Equal(1000, face.LastEnqueueRating); + await HotfixBridgeHarness.RunWithMatchDomainAsync(face, async runtime => + { + var response = new RespMatchEnqueue(); + await MatchDomainBridge.EnqueueAsync(PlayerOne, new ReqMatchEnqueue { Mode = MatchMode.OneVsOne, Rating = 1000 }, response); + + Assert.Equal(0, response.ErrorCode); + Assert.Equal(77, response.MatchId); + // 作用域玩家位 = 会话 playerId(R2),授权提供者的玩家位不可覆盖。 + Assert.NotNull(face.LastEnqueueScope); + Assert.Equal(PlayerOne, face.LastEnqueueScope.PlayerId); + Assert.Equal(HotfixBridgeStubScopeProvider.TenantId, face.LastEnqueueScope.TenantId); + Assert.Equal(HotfixBridgeStubScopeProvider.AppId, face.LastEnqueueScope.AppId); + Assert.Equal(HotfixBridgeStubScopeProvider.ServerId, face.LastEnqueueScope.ServerId); + Assert.Equal((int)MatchMode.OneVsOne, face.LastEnqueueMode); + Assert.Equal(1000, face.LastEnqueueRating); + }); } + /// + /// 重复排队:映射旧 420002(已在排队)。 + /// [Fact] public async Task Enqueue_Duplicate_MapsLegacyAlreadyQueuing() { var face = new StubMatchDomain { EnqueueFailWith = OnlineErrorCode.DuplicateRequest }; - OnlineMatchDomainBridgeHolder.Publish(face, PlayerScope(0)); - - var response = new RespMatchEnqueue(); - await MatchDomainBridge.EnqueueAsync(PlayerOne, new ReqMatchEnqueue { Mode = MatchMode.OneVsOne, Rating = 1000 }, response); + await HotfixBridgeHarness.RunWithMatchDomainAsync(face, async runtime => + { + var response = new RespMatchEnqueue(); + await MatchDomainBridge.EnqueueAsync(PlayerOne, new ReqMatchEnqueue { Mode = MatchMode.OneVsOne, Rating = 1000 }, response); - Assert.Equal((int)MatchErrorCode.AlreadyQueuing, response.ErrorCode); + Assert.Equal((int)MatchErrorCode.AlreadyQueuing, response.ErrorCode); + }); } + /// + /// 入队其余失败(含限流)走 Online 显式段。 + /// [Fact] public async Task Enqueue_FaceFailure_MapsOnlineBand() { var face = new StubMatchDomain { EnqueueFailWith = OnlineErrorCode.RateLimitExceeded }; - OnlineMatchDomainBridgeHolder.Publish(face, PlayerScope(0)); + await HotfixBridgeHarness.RunWithMatchDomainAsync(face, async runtime => + { + var response = new RespMatchEnqueue(); + await MatchDomainBridge.EnqueueAsync(PlayerOne, new ReqMatchEnqueue { Mode = MatchMode.OneVsOne, Rating = 1000 }, response); - var response = new RespMatchEnqueue(); - await MatchDomainBridge.EnqueueAsync(PlayerOne, new ReqMatchEnqueue { Mode = MatchMode.OneVsOne, Rating = 1000 }, response); + Assert.Equal(-(int)OnlineErrorCode.RateLimitExceeded, response.ErrorCode); + }); + } + + /// + /// 宿主停机撤桥后再入队:按依赖不可用显式失败(旧「租约撤销」语义 = 宿主 StopAsync)。 + /// + [Fact] + public async Task Enqueue_BridgeRetractedByHostStop_ReturnsDependencyUnavailable() + { + var face = new StubMatchDomain { EnqueueMatchId = 77 }; + await HotfixBridgeHarness.RunWithMatchDomainAsync(face, async runtime => + { + await runtime.StopAsync(); - Assert.Equal(-(int)OnlineErrorCode.RateLimitExceeded, response.ErrorCode); + var response = new RespMatchEnqueue(); + await MatchDomainBridge.EnqueueAsync(PlayerOne, new ReqMatchEnqueue { Mode = MatchMode.OneVsOne, Rating = 1000 }, response); + + Assert.Equal(-(int)OnlineErrorCode.DependencyUnavailable, response.ErrorCode); + Assert.Equal(0, response.MatchId); + Assert.Null(face.LastEnqueueScope); + }); } + /// + /// 匹配域桥在场但授权作用域提供者桥缺席:作用域三元组不可得,按适配器契约 + /// (MatchDomainBridge.RequireLease / OnlineAuthorizedScopeReader 红线 ③)以依赖不可用显式失败,域面不被调用。 + /// + [Fact] + public async Task Enqueue_ScopeProviderAbsent_ReturnsDependencyUnavailableWithoutCallingFace() + { + var face = new StubMatchDomain { EnqueueMatchId = 77 }; + await HotfixBridgeHarness.RunWithMatchDomainAsync(face, async runtime => + { + var response = new RespMatchEnqueue(); + await MatchDomainBridge.EnqueueAsync(PlayerOne, new ReqMatchEnqueue { Mode = MatchMode.OneVsOne, Rating = 1000 }, response); + + Assert.Equal(-(int)OnlineErrorCode.DependencyUnavailable, response.ErrorCode); + Assert.Equal(0, response.MatchId); + Assert.Equal(0, face.CallCount); + }, publishScopeProvider: false); + } + + /// + /// 取消成功:成功码 0,作用域玩家位 = 会话 playerId。 + /// [Fact] public async Task Cancel_Cancelled_MapsSuccess() { var face = new StubMatchDomain { CancelCancelled = true }; - OnlineMatchDomainBridgeHolder.Publish(face, PlayerScope(0)); - - var response = new RespMatchCancel(); - await MatchDomainBridge.CancelAsync(PlayerOne, response); + await HotfixBridgeHarness.RunWithMatchDomainAsync(face, async runtime => + { + var response = new RespMatchCancel(); + await MatchDomainBridge.CancelAsync(PlayerOne, response); - Assert.Equal(0, response.ErrorCode); - Assert.Equal(PlayerOne, face.LastCancelScope.PlayerId); + Assert.Equal(0, response.ErrorCode); + Assert.Equal(PlayerOne, face.LastCancelScope.PlayerId); + }); } + /// + /// 取消遇终态回执:映射旧 420003(未在排队)。 + /// [Fact] public async Task Cancel_TerminalReceipt_MapsLegacyNotQueuing() { var face = new StubMatchDomain { CancelCancelled = false }; - OnlineMatchDomainBridgeHolder.Publish(face, PlayerScope(0)); - - var response = new RespMatchCancel(); - await MatchDomainBridge.CancelAsync(PlayerOne, response); + await HotfixBridgeHarness.RunWithMatchDomainAsync(face, async runtime => + { + var response = new RespMatchCancel(); + await MatchDomainBridge.CancelAsync(PlayerOne, response); - Assert.Equal((int)MatchErrorCode.NotQueuing, response.ErrorCode); + Assert.Equal((int)MatchErrorCode.NotQueuing, response.ErrorCode); + }); } + /// + /// 取消失败走 Online 显式段。 + /// [Fact] public async Task Cancel_FaceFailure_MapsOnlineBand() { var face = new StubMatchDomain { CancelFailWith = OnlineErrorCode.DependencyUnavailable }; - OnlineMatchDomainBridgeHolder.Publish(face, PlayerScope(0)); - - var response = new RespMatchCancel(); - await MatchDomainBridge.CancelAsync(PlayerOne, response); + await HotfixBridgeHarness.RunWithMatchDomainAsync(face, async runtime => + { + var response = new RespMatchCancel(); + await MatchDomainBridge.CancelAsync(PlayerOne, response); - Assert.Equal(-(int)OnlineErrorCode.DependencyUnavailable, response.ErrorCode); + Assert.Equal(-(int)OnlineErrorCode.DependencyUnavailable, response.ErrorCode); + }); } + /// + /// 状态查询:排队位与 MatchId 如实映射。 + /// [Fact] public async Task Status_Published_MapsQueuingAndMatchId() { @@ -163,19 +212,23 @@ public async Task Status_Published_MapsQueuingAndMatchId() StatusMatched = true, StatusMatchId = 88, }; - OnlineMatchDomainBridgeHolder.Publish(face, PlayerScope(0)); - - var response = new RespMatchStatus(); - await MatchDomainBridge.StatusAsync(PlayerOne, response); - - Assert.Equal(0, response.ErrorCode); - Assert.False(response.Queuing); - Assert.Equal(88, response.MatchId); - Assert.Equal(PlayerOne, face.LastStatusScope.PlayerId); + await HotfixBridgeHarness.RunWithMatchDomainAsync(face, async runtime => + { + var response = new RespMatchStatus(); + await MatchDomainBridge.StatusAsync(PlayerOne, response); + + Assert.Equal(0, response.ErrorCode); + Assert.False(response.Queuing); + Assert.Equal(88, response.MatchId); + Assert.Equal(PlayerOne, face.LastStatusScope.PlayerId); + }); } + /// + /// 匹配域桥未发布:状态查询按依赖不可用显式失败。 + /// [Fact] - public async Task Status_NoLease_ReturnsDependencyUnavailable() + public async Task Status_NoBridge_ReturnsDependencyUnavailable() { var response = new RespMatchStatus(); await MatchDomainBridge.StatusAsync(PlayerOne, response); @@ -183,6 +236,9 @@ public async Task Status_NoLease_ReturnsDependencyUnavailable() Assert.Equal(-(int)OnlineErrorCode.DependencyUnavailable, response.ErrorCode); } + /// + /// 入场新面:回执字段与入参如实映射。 + /// [Fact] public async Task Enter_Published_MapsEntryFields() { @@ -192,32 +248,37 @@ public async Task Enter_Published_MapsEntryFields() EntryStarted = true, EntryPlayerIds = new List { 2001, 2002 }, }; - OnlineMatchDomainBridgeHolder.Publish(face, PlayerScope(0)); - - var response = new RespMatchEnter(); - await MatchDomainBridge.EnterAsync(PlayerOne, new ReqMatchEnter { MatchId = 88 }, response); - - Assert.Equal(0, response.ErrorCode); - Assert.Equal(88, response.MatchId); - Assert.True(response.Started); - Assert.Equal(88, face.LastEnterMatchId); - Assert.Equal(PlayerOne, face.LastEnterScope.PlayerId); + await HotfixBridgeHarness.RunWithMatchDomainAsync(face, async runtime => + { + var response = new RespMatchEnter(); + await MatchDomainBridge.EnterAsync(PlayerOne, new ReqMatchEnter { MatchId = 88 }, response); + + Assert.Equal(0, response.ErrorCode); + Assert.Equal(88, response.MatchId); + Assert.True(response.Started); + Assert.Equal(88, face.LastEnterMatchId); + Assert.Equal(PlayerOne, face.LastEnterScope.PlayerId); + }); } + /// + /// 入场失败一律走 Online 显式段。 + /// [Fact] public async Task Enter_FaceFailure_MapsOnlineBand() { var face = new StubMatchDomain { EntryFailWith = OnlineErrorCode.ResourceNotFound }; - OnlineMatchDomainBridgeHolder.Publish(face, PlayerScope(0)); - - var response = new RespMatchEnter(); - await MatchDomainBridge.EnterAsync(PlayerOne, new ReqMatchEnter { MatchId = 404 }, response); + await HotfixBridgeHarness.RunWithMatchDomainAsync(face, async runtime => + { + var response = new RespMatchEnter(); + await MatchDomainBridge.EnterAsync(PlayerOne, new ReqMatchEnter { MatchId = 404 }, response); - Assert.Equal(-(int)OnlineErrorCode.ResourceNotFound, response.ErrorCode); + Assert.Equal(-(int)OnlineErrorCode.ResourceNotFound, response.ErrorCode); + }); } /// - /// 匹配域桩:记录最近一次调用与可配置结果。 + /// 匹配域桩:记录最近一次调用、调用次数与可配置结果。 /// private sealed class StubMatchDomain : IOnlineMatchDomain { @@ -254,6 +315,9 @@ private sealed class StubMatchDomain : IOnlineMatchDomain /// 入场失败码。 public OnlineErrorCode? EntryFailWith { get; set; } + /// 域面被调用的总次数。 + public int CallCount { get; private set; } + /// 最近一次入队作用域。 public OnlineScope LastEnqueueScope { get; private set; } @@ -277,6 +341,7 @@ private sealed class StubMatchDomain : IOnlineMatchDomain public Task> EnqueueAsync(OnlineScope scope, int mode, int rating, CancellationToken cancellationToken = default) { + CallCount++; LastEnqueueScope = scope; LastEnqueueMode = mode; LastEnqueueRating = rating; @@ -290,6 +355,7 @@ public Task> EnqueueAsync(OnlineScope sco public Task> CancelAsync(OnlineScope scope, CancellationToken cancellationToken = default) { + CallCount++; LastCancelScope = scope; if (CancelFailWith.HasValue) { @@ -301,6 +367,7 @@ public Task> CancelAsync(OnlineScope scope public Task> GetStatusAsync(OnlineScope scope, CancellationToken cancellationToken = default) { + CallCount++; LastStatusScope = scope; return Task.FromResult(OnlineResult.Ok(new OnlineMatchDomainStatus { @@ -312,6 +379,7 @@ public Task> GetStatusAsync(OnlineScope sc public Task> EnterAsync(OnlineScope scope, long matchId, CancellationToken cancellationToken = default) { + CallCount++; LastEnterScope = scope; LastEnterMatchId = matchId; if (EntryFailWith.HasValue) @@ -329,16 +397,19 @@ public Task> EnterAsync(OnlineScope scope, public Task> ResolveAssignmentAsync(long tenantId, long appId, string assignmentId, CancellationToken cancellationToken = default) { + CallCount++; return Task.FromResult(OnlineResult.Fail(OnlineErrorCode.ResourceNotFound, "stub")); } public Task> NotifyPlayerDisconnectedAsync(OnlineScope scope, CancellationToken cancellationToken = default) { + CallCount++; return Task.FromResult(OnlineResult.Ok(new OnlineMatchDomainDisconnect { Marked = false })); } public Task> TryResumeForPlayerAsync(OnlineScope scope, long lastAcknowledgedSequence, CancellationToken cancellationToken = default) { + CallCount++; return Task.FromResult(OnlineResult.Ok(new OnlineMatchDomainResume { Resumed = false })); } } diff --git a/Tests/GameFrameX.Tests/Architecture/CapabilityBoundaryAnalyzerTests.cs b/Tests/GameFrameX.Tests/Architecture/CapabilityBoundaryAnalyzerTests.cs index 64b2b059b..56e9da05b 100644 --- a/Tests/GameFrameX.Tests/Architecture/CapabilityBoundaryAnalyzerTests.cs +++ b/Tests/GameFrameX.Tests/Architecture/CapabilityBoundaryAnalyzerTests.cs @@ -31,7 +31,7 @@ using GameFrameX.Architecture.Analyzers; using GameFrameX.Capability.Hosting.Abstractions; using GameFrameX.Capability.Hosting.Core; -using GameFrameX.Capability.Online.Abstractions.Bridges; +using GameFrameX.Capability.Online.Abstractions.Scope; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.Diagnostics; @@ -47,16 +47,15 @@ public sealed class CapabilityBoundaryAnalyzerTests private static readonly MetadataReference[] ContractReferences = { - MetadataReference.CreateFromFile(typeof(OnlineFriendDomainBridgeHolder).Assembly.Location), + MetadataReference.CreateFromFile(typeof(OnlineScope).Assembly.Location), MetadataReference.CreateFromFile(typeof(CapabilityRegistrationContext).Assembly.Location), - MetadataReference.CreateFromFile(typeof(CapabilityHostBuilder).Assembly.Location) + MetadataReference.CreateFromFile(typeof(CapabilityCompositionOptions).Assembly.Location) }; [Theory] [InlineData("GameFrameX.Apps", "GameFrameX.Capability.Online.Social")] [InlineData("GameFrameX.Hotfix", "GameFrameX.Capability.Online.Match")] [InlineData("GameFrameX.Hotfix", "GameFrameX.Capability.Online.Matchmaking")] - [InlineData("GameFrameX.Hotfix", "GameFrameX.Capability.Hosting.Core")] [InlineData("GameFrameX.Apps", "GameFrameX.Capability.Other.Abstractions")] public async Task Unused_direct_implementation_reference_is_rejected(string consumer, string implementation) { @@ -100,10 +99,13 @@ public async Task Diamond_paths_report_each_implementation_once() [InlineData("GameFrameX.Hotfix")] public async Task Actual_online_contract_closure_is_allowed(string consumer) { + // C294:Online.Abstractions → Hosting.AdminApi → Hosting.Core 传递可达;Hosting 三包均为机制定义方(非能力实现), + // 与 CapabilityBoundaryFacts.IsImplementation / IsMechanism 同一集合,契约闭包含全部三包即合法。 var references = new[] { - MetadataReference.CreateFromFile(typeof(OnlineFriendDomainBridgeHolder).Assembly.Location), + MetadataReference.CreateFromFile(typeof(OnlineScope).Assembly.Location), MetadataReference.CreateFromFile(typeof(CapabilityRegistrationContext).Assembly.Location), + MetadataReference.CreateFromFile(typeof(CapabilityCompositionOptions).Assembly.Location), MetadataReference.CreateFromFile(typeof(GameFrameX.Capability.Hosting.AdminApi.AdminActionRegistry).Assembly.Location) }; Assert.Empty(await Analyze(Compile(consumer, "public class Consumer { }", references), new CapabilityImplementationReferenceAnalyzer())); @@ -129,159 +131,269 @@ public async Task Empty_compilation_still_reports_project_level_reference() } [Theory] - [InlineData("OnlineFriendDomainBridgeHolder")] - [InlineData("OnlineMatchGameBridgeHolder")] - [InlineData("OnlineMatchDomainBridgeHolder")] - public async Task Actual_three_families_block_hotfix_publish_retract_and_delegate(string holder) + [InlineData("GameFrameX.Hotfix")] + [InlineData("GameFrameX.Apps")] + [InlineData("GameFrameX.Launcher")] + [InlineData("GameFrameX.Capability.Online.Social")] + [InlineData("ThirdParty.Tool")] + public async Task Bridge_publish_outside_module_activation_is_rejected(string consumer) { - var source = $$""" + var source = """ using System; - using Bridge = GameFrameX.Capability.Online.Abstractions.Bridges.{{holder}}; - namespace GameFrameX.Launcher.Online + using GameFrameX.Capability.Hosting.Abstractions; + public class Wiring { - public class PretendWiring + public void Run(CapabilityActivationContext context) { - public void Run() - { - Bridge.Publish(null, null); - Bridge.Retract(); - Action later = Bridge.Retract; - } + context.Bridges.Publish(null); + Action publish = () => context.Bridges.Publish(null); } } + public interface IFixtureContract { } """; - var diagnostics = await Analyze(Compile("GameFrameX.Hotfix", source, ContractReferences), new CapabilityAssemblyBoundaryAnalyzer()); - Assert.Equal(3, diagnostics.Length); + var diagnostics = await Analyze(Compile(consumer, source, ContractReferences), new CapabilityAssemblyBoundaryAnalyzer()); + Assert.Equal(2, diagnostics.Length); Assert.All(diagnostics, diagnostic => { Assert.Equal("GFX0018", diagnostic.Id); - Assert.Contains("Launcher", diagnostic.GetMessage()); - Assert.Contains("holder.Current", diagnostic.GetMessage()); + Assert.Contains("ActivateAsync", diagnostic.GetMessage()); var text = diagnostic.Location.SourceTree.GetText().ToString(diagnostic.Location.SourceSpan); - Assert.StartsWith("Bridge.", text); + Assert.StartsWith("context.Bridges.Publish", text); }); } [Theory] - [InlineData("GameFrameX.Launcher")] - [InlineData("GameFrameX.Tests")] - [InlineData("GameFrameX.Hotfix.Tests")] - public async Task Actual_three_family_lifecycle_wiring_is_allowed(string consumer) + [InlineData("GameFrameX.Capability.Online.Social")] + [InlineData("GameFrameX.Capability.Online.Match")] + [InlineData("GameFrameX.Capability.Online.Matchmaking")] + public async Task Bridge_publish_inside_module_activation_is_allowed(string implementation) { var source = """ - using GameFrameX.Capability.Online.Abstractions.Bridges; - public class Wiring + using System; + using System.Threading; + using System.Threading.Tasks; + using GameFrameX.Capability.Hosting.Abstractions; + public sealed class Module : ICapabilityModule { - public void Run() + public CapabilityManifest Manifest => null; + public Task ActivateAsync(CapabilityActivationContext context, CancellationToken cancellationToken) { - OnlineFriendDomainBridgeHolder.Publish(null, null); - OnlineFriendDomainBridgeHolder.Retract(); - OnlineMatchGameBridgeHolder.Publish(null, null); - OnlineMatchGameBridgeHolder.Retract(); - OnlineMatchDomainBridgeHolder.Publish(null, null); - OnlineMatchDomainBridgeHolder.Retract(); + context.Bridges.Publish(null); + void Local() => context.Bridges.Publish(null); + Local(); + PublishLater(context.Bridges); + return Task.CompletedTask; } + public void Register(CapabilityRegistrationContext context) { } + public Task DeactivateAsync(CancellationToken cancellationToken) => Task.CompletedTask; + private static void PublishLater(CapabilityBridgePublisher publisher) => publisher.Publish(null); } + public interface IFixtureContract { } """; - Assert.Empty(await Analyze(Compile(consumer, source, ContractReferences), new CapabilityAssemblyBoundaryAnalyzer())); + Assert.Empty(await Analyze(Compile(implementation, source, ContractReferences), new CapabilityAssemblyBoundaryAnalyzer())); + } + + [Fact] + public async Task Bridge_publish_after_cached_activation_context_is_rejected() + { + var source = """ + using System.Threading; + using System.Threading.Tasks; + using GameFrameX.Capability.Hosting.Abstractions; + public sealed class Module : ICapabilityModule + { + private CapabilityActivationContext _cached; + public CapabilityManifest Manifest => null; + public Task ActivateAsync(CapabilityActivationContext context, CancellationToken cancellationToken) + { + _cached = context; + return Task.CompletedTask; + } + public void Register(CapabilityRegistrationContext context) { } + public Task DeactivateAsync(CancellationToken cancellationToken) + { + _cached.Bridges.Publish(null); + PublishWithoutParameter(); + return Task.CompletedTask; + } + private void PublishWithoutParameter() => _cached.Bridges.Publish(null); + } + public interface IFixtureContract { } + """; + var diagnostics = await Analyze(Compile("GameFrameX.Capability.Online.Social", source, ContractReferences), new CapabilityAssemblyBoundaryAnalyzer()); + Assert.Equal(2, diagnostics.Length); + Assert.All(diagnostics, diagnostic => Assert.Contains("ActivateAsync", diagnostic.GetMessage())); } [Theory] - [InlineData("GameFrameX.Apps")] - [InlineData("GameFrameX.Hotfix")] - public async Task Actual_hotfix_current_and_lease_domain_calls_are_allowed(string consumer) + [InlineData("GameFrameX.Apps", 0)] + [InlineData("GameFrameX.Hotfix", 0)] + [InlineData("GameFrameX.Launcher", 1)] + [InlineData("GameFrameX.Capability.Online.Social", 1)] + [InlineData("GameFrameX.Hotfix.Tests.Evil", 1)] + [InlineData("ThirdParty.Tool", 1)] + public async Task Bridge_acquire_is_consumer_side_only(string consumer, int diagnosticCount) { var source = """ - using GameFrameX.Capability.Online.Abstractions.Bridges; + using GameFrameX.Capability.Hosting.Abstractions; public class Adapter + { + public string Run() + { + return CapabilityBridge.TryAcquire(out var lease) ? lease.Contract.Name : "absent"; + } + } + public interface IFixtureContract { string Name { get; } } + """; + var diagnostics = await Analyze(Compile(consumer, source, ContractReferences), new CapabilityAssemblyBoundaryAnalyzer()); + Assert.Equal(diagnosticCount, diagnostics.Length); + Assert.All(diagnostics, diagnostic => Assert.Contains("Hotfix / Apps", diagnostic.GetMessage())); + } + + [Theory] + [InlineData("GameFrameX.Hotfix", 0)] + [InlineData("GameFrameX.Apps", 1)] + [InlineData("GameFrameX.Launcher", 1)] + [InlineData("GameFrameX.Capability.Online.Social", 1)] + public async Task Hook_attach_is_hotfix_only(string consumer, int diagnosticCount) + { + var source = """ + using GameFrameX.Capability.Hosting.Abstractions; + public class HotfixLoad { public void Run() { - var friend = OnlineFriendDomainBridgeHolder.Current; - var game = OnlineMatchGameBridgeHolder.Current; - var match = OnlineMatchDomainBridgeHolder.Current; - _ = friend.IsActive; - _ = friend.ListFriendsAsync(null); - _ = game.GetGameInfoAsync(null, 1, 1); - _ = match.GetStatusAsync(null); + var lease = CapabilityHook.Attach(new FixtureHook()); + lease.Dispose(); + } + } + public interface IFixtureHook { } + public sealed class FixtureHook : IFixtureHook { } + """; + var diagnostics = await Analyze(Compile(consumer, source, ContractReferences), new CapabilityAssemblyBoundaryAnalyzer()); + Assert.Equal(diagnosticCount, diagnostics.Length); + Assert.All(diagnostics, diagnostic => Assert.Contains("Hotfix", diagnostic.GetMessage())); + } + + [Theory] + [InlineData("GameFrameX.Launcher", 0)] + [InlineData("GameFrameX.Hotfix", 3)] + [InlineData("GameFrameX.Apps", 3)] + [InlineData("GameFrameX.Capability.Online.Social", 3)] + [InlineData("ThirdParty.Tool", 3)] + public async Task Host_composition_overrides_are_launcher_only(string consumer, int diagnosticCount) + { + var source = """ + using GameFrameX.Capability.Hosting.Core; + public class CompositionEntry + { + public void Run(CapabilityCompositionOptions options) + { + options.Configure(configured => { }); + options.Override(resolver => null); + options.SetAdminApiPlaneFactory(null); } } + public interface IFixtureContract { } + public sealed class FixtureOptions { } """; - Assert.Empty(await Analyze(Compile(consumer, source, ContractReferences), new CapabilityAssemblyBoundaryAnalyzer())); + var diagnostics = await Analyze(Compile(consumer, source, ContractReferences), new CapabilityAssemblyBoundaryAnalyzer()); + Assert.Equal(diagnosticCount, diagnostics.Length); + Assert.All(diagnostics, diagnostic => Assert.Contains("Launcher", diagnostic.GetMessage())); } [Theory] [InlineData("GameFrameX.Hotfix")] [InlineData("GameFrameX.Apps")] + [InlineData("GameFrameX.Launcher")] [InlineData("GameFrameX.Hotfix.Tests.Evil")] [InlineData("ThirdParty.Tool")] - public async Task Registration_and_builder_operations_require_authorized_location(string consumer) + public async Task Registration_surface_requires_module_register_phase(string consumer) { var compilation = Compile(consumer, RegistrationSource, ContractReferences); var diagnostics = await Analyze(compilation, new CapabilityAssemblyBoundaryAnalyzer()); - Assert.Equal(8, diagnostics.Length); + Assert.Equal(12, diagnostics.Length); Assert.All(diagnostics, diagnostic => { - Assert.Contains("注册", diagnostic.GetMessage()); + Assert.Contains("Register", diagnostic.GetMessage()); Assert.True(diagnostic.Location.IsInSource); }); } private const string RegistrationSource = """ using System; + using System.Threading; + using System.Threading.Tasks; using GameFrameX.Capability.Hosting.Abstractions; - using GameFrameX.Capability.Hosting.Core; public class Wiring { - public void Run(CapabilityRegistrationContext context, CapabilityServices services, CapabilityHostBuilder builder) + public void Run(CapabilityRegistrationContext context) { context.AddService(new object()); - services.AddService(new object()); - context.AddTickJob("tick", null); - context.AddIntervalJob("interval", TimeSpan.FromMilliseconds(10), null); - context.AddEventJob("event", null); - builder.UseService(new object()); - builder.AddModule(null); - builder.Build(null); + context.AddService(resolver => new object()); + context.AddService("name", new object()); + var service = context.GetService(); + var optional = context.GetOptionalService(); + var all = context.GetServices(); + var options = context.GetOptions(); + var handle = context.DeclareHook(new FixtureHook()); + context.RequireHostService(); + context.Subscribe("topic", received => { }); + context.AddTickJob("tick", cancellationToken => Task.CompletedTask); + context.AddIntervalJob("interval", TimeSpan.FromMilliseconds(10), cancellationToken => Task.CompletedTask); } } + public interface IFixtureHook { } + public interface IFixtureHostService { } + public sealed class FixtureHook : IFixtureHook { } + public sealed class FixtureOptions { } + public sealed class FixtureEvent { } """; - [Theory] - [InlineData("GameFrameX.Launcher")] - [InlineData("GameFrameX.Tests")] - [InlineData("GameFrameX.Hotfix.Tests")] - public async Task Composition_registration_is_allowed(string consumer) - { - Assert.Empty(await Analyze(Compile(consumer, RegistrationSource, ContractReferences), new CapabilityAssemblyBoundaryAnalyzer())); - } - [Theory] [InlineData("GameFrameX.Capability.Online.Social")] [InlineData("GameFrameX.Capability.Online.Match")] [InlineData("GameFrameX.Capability.Online.Matchmaking")] - public async Task Module_owned_register_helper_and_local_function_are_allowed(string implementation) + public async Task Module_register_surface_with_helpers_is_allowed(string implementation) { var source = """ + using System; + using System.Threading; + using System.Threading.Tasks; using GameFrameX.Capability.Hosting.Abstractions; public sealed class Module : ICapabilityModule { public CapabilityManifest Manifest => null; - public void Register(CapabilityRegistrationContext context) => ResolveStore(context); - private static void ResolveStore(CapabilityRegistrationContext context) + public void Register(CapabilityRegistrationContext context) { context.AddService(new object()); - void Local() => context.AddEventJob("test", null); + var service = context.GetService(); + var optional = context.GetOptionalService(); + var options = context.GetOptions(); + var handle = context.DeclareHook(new FixtureHook()); + context.RequireHostService(); + context.Subscribe("topic", received => { }); + context.AddTickJob("tick", cancellationToken => Task.CompletedTask); + ResolveStore(context); + void Local() => context.AddService(new object()); Local(); } + public Task ActivateAsync(CapabilityActivationContext context, CancellationToken cancellationToken) => Task.CompletedTask; + public Task DeactivateAsync(CancellationToken cancellationToken) => Task.CompletedTask; + private static void ResolveStore(CapabilityRegistrationContext context) => context.AddService("nested", new object()); private sealed class NestedHelper { public void Seed(CapabilityRegistrationContext context) => context.AddService(new object()); } } + public interface IFixtureHook { } + public interface IFixtureHostService { } + public sealed class FixtureHook : IFixtureHook { } + public sealed class FixtureOptions { } + public sealed class FixtureEvent { } """; Assert.Empty(await Analyze(Compile(implementation, source, ContractReferences), new CapabilityAssemblyBoundaryAnalyzer())); - // 仅伪装 Register 方法或命名空间不足以获得模块权限。 + // 仅实现 ICapabilityModule 的类型获得 Register 期权限;伪装 Register 方法或命名空间不足以获得。 var ordinary = """ using GameFrameX.Capability.Hosting.Abstractions; namespace GameFrameX.Launcher @@ -299,62 +411,91 @@ public class NotAModule public async Task Module_interface_does_not_allow_hotfix_to_self_assemble() { var source = """ + using System.Threading; + using System.Threading.Tasks; using GameFrameX.Capability.Hosting.Abstractions; public class Module : ICapabilityModule { public CapabilityManifest Manifest => null; public void Register(CapabilityRegistrationContext context) => context.AddService(new object()); + public Task ActivateAsync(CapabilityActivationContext context, CancellationToken cancellationToken) => Task.CompletedTask; + public Task DeactivateAsync(CancellationToken cancellationToken) => Task.CompletedTask; } """; Assert.Single(await Analyze(Compile("GameFrameX.Hotfix", source, ContractReferences), new CapabilityAssemblyBoundaryAnalyzer())); } [Fact] - public async Task Actual_capability_hosting_extension_can_preseed_builder() + public async Task Register_api_after_cached_context_is_rejected() { var source = """ - using GameFrameX.Capability.Hosting.Core; - public static class StoreExtensions + using System.Threading; + using System.Threading.Tasks; + using GameFrameX.Capability.Hosting.Abstractions; + public sealed class Module : ICapabilityModule { - public static CapabilityHostBuilder UseStores(this CapabilityHostBuilder builder) + private CapabilityRegistrationContext _cached; + public CapabilityManifest Manifest => null; + public void Register(CapabilityRegistrationContext context) { - builder.UseService(new object()); - void Local() => builder.UseService("options"); - Local(); - return builder; + _cached = context; + RegisterLater(); } + public Task ActivateAsync(CapabilityActivationContext context, CancellationToken cancellationToken) => Task.CompletedTask; + public Task DeactivateAsync(CancellationToken cancellationToken) => Task.CompletedTask; + private void RegisterLater() => _cached.AddService(new object()); } """; - Assert.Empty(await Analyze(Compile("GameFrameX.Capability.Online.Persistence", source, ContractReferences), new CapabilityAssemblyBoundaryAnalyzer())); - Assert.Equal(2, (await Analyze(Compile("GameFrameX.Hotfix", source, ContractReferences), new CapabilityAssemblyBoundaryAnalyzer())).Length); + Assert.Single(await Analyze(Compile("GameFrameX.Capability.Online.Social", source, ContractReferences), new CapabilityAssemblyBoundaryAnalyzer())); } [Theory] - [InlineData("CapabilityAssembly", 0)] - [InlineData("ArbitraryHelper", 2)] - public async Task Hosting_assembly_scan_entry_can_delegate_to_builder(string type, int diagnosticCount) + [InlineData("GameFrameX.Capability.Hosting.Core")] + [InlineData("GameFrameX.Capability.Hosting.AdminApi")] + public async Task Mechanism_assemblies_own_their_api_surface(string mechanism) { - var source = $$""" - namespace GameFrameX.Capability.Hosting.Core + var compilation = Compile(mechanism, RegistrationSource + BridgeConsumerSource, HostingAbstractionsReference); + Assert.Empty(await Analyze(compilation, new CapabilityAssemblyBoundaryAnalyzer())); + } + + [Theory] + [InlineData("GameFrameX.Tests")] + [InlineData("GameFrameX.Hotfix.Tests")] + public async Task Test_fixtures_may_exercise_every_phase(string consumer) + { + var compilation = Compile(consumer, RegistrationSource + BridgeConsumerSource + HostCompositionSource, ContractReferences); + Assert.Empty(await Analyze(compilation, new CapabilityAssemblyBoundaryAnalyzer())); + } + + private const string BridgeConsumerSource = """ + public class BridgeAdapter + { + public void Run(GameFrameX.Capability.Hosting.Abstractions.CapabilityActivationContext context) { - public class CapabilityHostBuilder - { - public void AddModule(object value) { } - public void Build(object value) { } - } - public class {{type}} - { - public void Run(CapabilityHostBuilder builder) - { - builder.AddModule(null); - builder.Build(null); - } - } + context.Bridges.Publish(null); + GameFrameX.Capability.Hosting.Abstractions.CapabilityBridge.TryAcquire(out var lease); + GameFrameX.Capability.Hosting.Abstractions.CapabilityHook.Attach(new FixtureHook()); } - """; - Assert.Equal(diagnosticCount, (await Analyze(Compile("GameFrameX.Capability.Hosting.Core", source), - new CapabilityAssemblyBoundaryAnalyzer())).Length); - } + } + public interface IFixtureContract { } + """; + + private const string HostCompositionSource = """ + public class CompositionEntry + { + public void Run(GameFrameX.Capability.Hosting.Core.CapabilityCompositionOptions options) + { + options.Configure(configured => { }); + options.Override(resolver => null); + options.SetAdminApiPlaneFactory(null); + } + } + """; + + private static readonly MetadataReference[] HostingAbstractionsReference = + { + MetadataReference.CreateFromFile(typeof(CapabilityRegistrationContext).Assembly.Location) + }; [Fact] public async Task Generic_registration_delegate_is_also_checked() @@ -364,11 +505,11 @@ public async Task Generic_registration_delegate_is_also_checked() using GameFrameX.Capability.Hosting.Abstractions; public class Wiring { - public Action Capture(CapabilityRegistrationContext context) => context.AddService; + public Func Capture(CapabilityRegistrationContext context) => context.GetService; } """; var diagnostic = Assert.Single(await Analyze(Compile("GameFrameX.Hotfix", source, ContractReferences), new CapabilityAssemblyBoundaryAnalyzer())); - Assert.Equal("context.AddService", diagnostic.Location.SourceTree.GetText().ToString(diagnostic.Location.SourceSpan)); + Assert.Equal("context.GetService", diagnostic.Location.SourceTree.GetText().ToString(diagnostic.Location.SourceSpan)); } [Fact] @@ -378,34 +519,45 @@ public async Task Generated_source_cannot_bypass_bridge_guard() // public class GeneratedAdapter { - public void Run() => GameFrameX.Capability.Online.Abstractions.Bridges.OnlineMatchDomainBridgeHolder.Retract(); + public void Run() => GameFrameX.Capability.Hosting.Abstractions.CapabilityBridge.TryAcquire(out _); } + public interface IFixtureContract { } """; - Assert.Single(await Analyze(Compile("GameFrameX.Hotfix", source, ContractReferences), new CapabilityAssemblyBoundaryAnalyzer())); + Assert.Single(await Analyze(Compile("GameFrameX.Launcher", source, ContractReferences), new CapabilityAssemblyBoundaryAnalyzer())); } [Fact] public async Task Same_named_methods_and_fake_framework_types_are_not_matched() { var source = """ - namespace GameFrameX.Capability.Online.Abstractions.Bridges - { - public static class OnlineFriendDomainBridgeHolder - { - public static void Publish() { } - public static void Retract() { } - } - } namespace GameFrameX.Capability.Hosting.Abstractions { public class CapabilityRegistrationContext { public void AddService(T value) { } } + public sealed class CapabilityBridgePublisher { public void Publish(TContract contract) { } } + public class CapabilityActivationContext { public CapabilityBridgePublisher Bridges { get; } } + public static class CapabilityBridge { public static bool TryAcquire(out object lease) { lease = null; return false; } } + public static class CapabilityHook { public static object Attach(TContract implementation) => null; } + } + namespace GameFrameX.Capability.Hosting.Core + { + public sealed class CapabilityCompositionOptions + { + public void Configure(System.Action configure) { } + public void Override(TService instance) { } + } } public class Normal { - public void Run() + public void Run(GameFrameX.Capability.Hosting.Abstractions.CapabilityActivationContext context) { - GameFrameX.Capability.Online.Abstractions.Bridges.OnlineFriendDomainBridgeHolder.Publish(); - new GameFrameX.Capability.Hosting.Abstractions.CapabilityRegistrationContext().AddService(1); + context.Bridges.Publish(null); + GameFrameX.Capability.Hosting.Abstractions.CapabilityRegistrationContext capture = null; + capture.AddService(1); + GameFrameX.Capability.Hosting.Abstractions.CapabilityBridge.TryAcquire(out _); + GameFrameX.Capability.Hosting.Abstractions.CapabilityHook.Attach(null); + GameFrameX.Capability.Hosting.Core.CapabilityCompositionOptions options = null; + options.Configure(configured => { }); + options.Override(new object()); } } """; diff --git a/Tests/GameFrameX.Tests/GameFrameX.Tests.csproj b/Tests/GameFrameX.Tests/GameFrameX.Tests.csproj index e7091212a..7e0586402 100644 --- a/Tests/GameFrameX.Tests/GameFrameX.Tests.csproj +++ b/Tests/GameFrameX.Tests/GameFrameX.Tests.csproj @@ -71,4 +71,11 @@ + + + + + diff --git a/Tests/GameFrameX.Tests/Online/CapabilityAdminCompositionTests.cs b/Tests/GameFrameX.Tests/Online/CapabilityAdminCompositionTests.cs new file mode 100644 index 000000000..33182b7ca --- /dev/null +++ b/Tests/GameFrameX.Tests/Online/CapabilityAdminCompositionTests.cs @@ -0,0 +1,329 @@ +// ========================================================================================== +// GameFrameX 组织及其衍生项目的版权、商标、专利及其他相关权利 +// GameFrameX organization and its derivative projects' copyrights, trademarks, patents and related rights +// 均受中华人民共和国及相关国际法律法规保护。 +// are protected by the laws of the PRC 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 refer to the LICENSE file at the root 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 the legal 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://www.cnb.cool/GameFrameX +// CNB Repository: https://www.cnb.cool/GameFrameX +// 官方文档:https://gameframex.doc.alianblank.com/ +// Official Docs: https://gameframex.doc.alianblank.com/ +// ========================================================================================== + +using System; +using System.Net; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; +using GameFrameX.Capability.Hosting.Abstractions; +using GameFrameX.Capability.Hosting.AdminApi; +using GameFrameX.Capability.Hosting.Core; +using GameFrameX.Foundation.Localization.Core; +using Xunit; + +namespace GameFrameX.Tests.Online; + +/// +/// 管理面组合机制测试(C294:UseAdminApi + 组装期—— +/// action 名跨能力唯一、manifest 声明与实注册双向对账一次报全、admin-only 形态、监听选项非法与无面启用 fail-fast; +/// 适配器由 族基建预置;组装期用例不启动监听)。 +/// +[Collection(CapabilityRuntimeCollection.Name)] +public class CapabilityAdminCompositionTests +{ + /// 管理面路由前缀(机制测试专用)。 + private const string AdminApiPrefix = "mech/admin"; + + /// + /// 探测 28290-28299 段内的空闲端口(本类用例不启动监听,端口只需合法且不与本机已占用端口冲突)。 + /// + /// 空闲端口;整段被占用时返回段首(不监听时仍为合法端口)。 + private static int FindFreeAdminPort() + { + for (var port = 28290; port <= 28299; port++) + { + TcpListener listener = null; + try + { + listener = new TcpListener(IPAddress.Loopback, port); + listener.Start(); + return port; + } + catch (SocketException) + { + // 端口被占用:试下一个。 + } + finally + { + listener?.Stop(); + } + } + + return 28290; + } + + /// + /// 构造启用管理面的组合选项(合法作用域 + 白名单 + UseAdminApi 合法监听参数)。 + /// + /// 参与组合的槽位能力 Id。 + /// 组合选项。 + private static CapabilityCompositionOptions CreateAdminOptions(params string[] enabledIds) + { + var port = FindFreeAdminPort(); + var options = new CapabilityCompositionOptions(default, new CollectingCapabilityAssemblyLog()) + { + HostOptions = new CapabilityHostOptions { TenantId = 1, AppId = 1, ServerId = 1 }, + Filter = new CapabilityAssemblyFilter(enabledIds), + }; + options.UseAdminApi(adminApiOptions => + { + adminApiOptions.AdminPort = port; + adminApiOptions.AdminApiPrefix = AdminApiPrefix; + }); + return options; + } + + /// + /// 组合机制测试族。 + /// + /// 组合选项。 + /// 组合产物。 + private static CapabilityRuntime Compose(CapabilityCompositionOptions options) + { + return CapabilityComposition.Compose(new MechanismTestFamily(), options); + } + + /// + /// 测试用 admin action 处理器(恒返回固定载荷;组装期用例不会真正调用)。 + /// + /// 请求包装。 + /// 已通过守卫的作用域。 + /// 取消令牌。 + /// 固定载荷。 + private static Task OkHandler(AdminApiRequest request, AdminApiClaimedScope scope, CancellationToken cancellationToken) + { + return Task.FromResult("ok"); + } + + /// + /// 停止组合产物(组合成功才有产物;管理面未启动时停止为空操作)。 + /// + /// 组合产物(可为 null)。 + /// 异步任务。 + private static async Task StopIfComposedAsync(CapabilityRuntime runtime) + { + if (runtime != null) + { + await runtime.StopAsync(); + } + } + + /// + /// action 名跨能力唯一:两个能力注册同名 action 即组装失败(不再后者静默覆盖前者), + /// 错误信息带 action 名与先注册者的能力 Id。 + /// + [Fact] + public async Task Compose_DuplicateActionAcrossCapabilities_FailsFastWithFirstOwner() + { + CapabilityRuntime runtime = null; + try + { + MechanismScenario.First.Actions = new[] { "mech_duplicate" }; + MechanismScenario.First.RegisterAdminActions = registry => registry.Register("mech_duplicate", OkHandler, false); + MechanismScenario.Second.Actions = new[] { "mech_duplicate" }; + MechanismScenario.Second.RegisterAdminActions = registry => registry.Register("mech_duplicate", OkHandler, false); + + var exception = Assert.Throws(() => runtime = Compose(CreateAdminOptions("mech-first", "mech-second"))); + + Assert.Contains("mech_duplicate", exception.Message); + Assert.Contains("mech-first", exception.Message); + Assert.DoesNotContain("mech-second", exception.Message); + } + finally + { + await StopIfComposedAsync(runtime); + MechanismScenario.Reset(); + } + } + + /// + /// 双向对账一次报全:manifest 声明但未注册的 action(附声明能力 Id)与注册但未被任何启用能力声明的 action + /// 同时出现在同一个异常里(问题数 = 2)。 + /// + [Fact] + public async Task Compose_ActionReconciliation_ReportsBothDirectionsInOneThrow() + { + CapabilityRuntime runtime = null; + try + { + MechanismScenario.First.Actions = new[] { "mech_declared_only" }; + MechanismScenario.Second.RegisterAdminActions = registry => registry.Register("mech_registered_only", OkHandler, false); + + var exception = Assert.Throws(() => runtime = Compose(CreateAdminOptions("mech-first", "mech-second"))); + + var expectedHeader = string.Format(LocalizationService.GetString(GameFrameX.Localization.Keys.Capability.Hosting.AdminApi.ReconcileFailed), 2); + Assert.StartsWith(expectedHeader, exception.Message); + Assert.Contains(string.Format(LocalizationService.GetString(GameFrameX.Localization.Keys.Capability.Hosting.AdminApi.ActionNotRegistered), "mech_declared_only", "mech-first"), exception.Message); + Assert.Contains(string.Format(LocalizationService.GetString(GameFrameX.Localization.Keys.Capability.Hosting.AdminApi.ActionNotDeclared), "mech_registered_only", "mech-second"), exception.Message); + } + finally + { + await StopIfComposedAsync(runtime); + MechanismScenario.Reset(); + } + } + + /// + /// 按能力分组对账(跨能力冒名):A 声明却未注册、B 注册却未声明同名 action,全局集合会互相抵消, + /// 分组对账必须装配失败并指出声明者与实际注册者。 + /// + [Fact] + public async Task Compose_ActionDeclaredByOneCapabilityRegisteredByAnother_FailsFast() + { + CapabilityRuntime runtime = null; + try + { + MechanismScenario.First.Actions = new[] { "mech_impersonated" }; + MechanismScenario.Second.RegisterAdminActions = registry => registry.Register("mech_impersonated", OkHandler, false); + + var exception = Assert.Throws(() => runtime = Compose(CreateAdminOptions("mech-first", "mech-second"))); + + var expectedHeader = string.Format(LocalizationService.GetString(GameFrameX.Localization.Keys.Capability.Hosting.AdminApi.ReconcileFailed), 1); + Assert.StartsWith(expectedHeader, exception.Message); + Assert.Contains(string.Format(LocalizationService.GetString(GameFrameX.Localization.Keys.Capability.Hosting.AdminApi.ActionOwnerMismatch), "mech_impersonated", "mech-first", "mech-second"), exception.Message); + } + finally + { + await StopIfComposedAsync(runtime); + MechanismScenario.Reset(); + } + } + + /// + /// 合法的声明 + 注册配对:组合成功,业务宿主与管理面同时在场(未启动监听)。 + /// + [Fact] + public async Task Compose_ValidDeclaredAndRegisteredAction_ComposesAdminPlane() + { + CapabilityRuntime runtime = null; + try + { + MechanismScenario.First.Actions = new[] { "mech_ping" }; + MechanismScenario.First.RegisterAdminActions = registry => registry.Register("mech_ping", OkHandler, false); + + runtime = Compose(CreateAdminOptions("mech-first")); + + Assert.NotNull(runtime.AdminApi); + Assert.NotNull(runtime.Host); + Assert.Equal(new[] { "mech-first" }, runtime.Report.EnabledIdsInOrder); + } + finally + { + await StopIfComposedAsync(runtime); + MechanismScenario.Reset(); + } + } + + /// + /// admin-only 形态(EnableRuntime=false):业务宿主不交付(Host 为 null),管理面在场且报告照常产出。 + /// + [Fact] + public async Task Compose_AdminOnly_HostNullAndAdminPlanePresent() + { + CapabilityRuntime runtime = null; + try + { + MechanismScenario.First.Actions = new[] { "mech_ping" }; + MechanismScenario.First.RegisterAdminActions = registry => registry.Register("mech_ping", OkHandler, false); + var options = CreateAdminOptions("mech-first"); + options.EnableRuntime = false; + + runtime = Compose(options); + + Assert.Null(runtime.Host); + Assert.NotNull(runtime.AdminApi); + Assert.NotNull(runtime.Report); + Assert.Equal(new[] { "mech-first" }, runtime.Report.EnabledIdsInOrder); + } + finally + { + await StopIfComposedAsync(runtime); + MechanismScenario.Reset(); + } + } + + /// + /// 管理面监听选项非法(端口 0):组装期 fail-fast(本地化「管理面监听选项非法」)。 + /// + [Fact] + public async Task Compose_InvalidAdminApiOptions_FailsFast() + { + CapabilityRuntime runtime = null; + try + { + var options = new CapabilityCompositionOptions(default, new CollectingCapabilityAssemblyLog()) + { + HostOptions = new CapabilityHostOptions { TenantId = 1, AppId = 1, ServerId = 1 }, + Filter = new CapabilityAssemblyFilter(new[] { "mech-first" }), + }; + options.UseAdminApi(adminApiOptions => + { + adminApiOptions.AdminPort = 0; + adminApiOptions.AdminApiPrefix = AdminApiPrefix; + }); + + var exception = Assert.Throws(() => runtime = Compose(options)); + + Assert.Equal(LocalizationService.GetString(GameFrameX.Localization.Keys.Capability.Hosting.AdminApi.AdminApiOptionsInvalid), exception.Message); + } + finally + { + await StopIfComposedAsync(runtime); + MechanismScenario.Reset(); + } + } + + /// + /// 业务面与管理面都未启用(EnableRuntime=false 且未 UseAdminApi):组合入口即 fail-fast(NoPlaneEnabled)。 + /// + [Fact] + public async Task Compose_NoPlaneEnabled_FailsFast() + { + CapabilityRuntime runtime = null; + try + { + var options = new CapabilityCompositionOptions(default, new CollectingCapabilityAssemblyLog()) + { + HostOptions = new CapabilityHostOptions { TenantId = 1, AppId = 1, ServerId = 1 }, + Filter = new CapabilityAssemblyFilter(new[] { "mech-first" }), + EnableRuntime = false, + }; + + var exception = Assert.Throws(() => runtime = Compose(options)); + + Assert.Equal(LocalizationService.GetString(GameFrameX.Localization.Keys.Capability.Hosting.Composition.NoPlaneEnabled), exception.Message); + } + finally + { + await StopIfComposedAsync(runtime); + MechanismScenario.Reset(); + } + } +} diff --git a/Tests/GameFrameX.Tests/Online/CapabilityBindingTestOptions.cs b/Tests/GameFrameX.Tests/Online/CapabilityBindingTestOptions.cs new file mode 100644 index 000000000..ba2caf313 --- /dev/null +++ b/Tests/GameFrameX.Tests/Online/CapabilityBindingTestOptions.cs @@ -0,0 +1,69 @@ +// ========================================================================================== +// GameFrameX 组织及其衍生项目的版权、商标、专利及其他相关权利 +// GameFrameX organization and its derivative projects' copyrights, trademarks, patents and related rights +// 均受中华人民共和国及相关国际法律法规保护。 +// are protected by the laws of the PRC 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 refer to the LICENSE file at the root 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 the legal 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://www.cnb.cool/GameFrameX +// CNB Repository: https://www.cnb.cool/GameFrameX +// 官方文档:https://gameframex.doc.alianblank.com/ +// Official Docs: https://gameframex.doc.alianblank.com/ +// ========================================================================================== + +using GameFrameX.Capability.Hosting.Abstractions; + +namespace GameFrameX.Tests.Online; + +/// +/// Options 绑定测试根类型(机制层测试槽位声明为 OptionsType:字段初始值即「包内默认」层, +/// 用例经族配置段 Settings.<能力Id> 与 Configure<T> 逐层覆盖,验证合并优先级、字段来源与 fail-fast)。 +/// +public sealed class CapabilityBindingTestOptions : ICapabilityOptions +{ + /// 获取或设置字段 Alpha(默认层验证位;默认 1,必须为正数)。 + public int Alpha { get; set; } = 1; + + /// 获取或设置字段 Beta(配置段层验证位;默认 2,必须为正数)。 + public int Beta { get; set; } = 2; + + /// 获取或设置字段 Gamma(代码层验证位;默认 3,必须为正数)。 + public int Gamma { get; set; } = 3; + + /// 获取或设置嵌套参数段(嵌套字段路径验证位)。 + public NestedSection Nested { get; set; } = new NestedSection(); + + /// + public void Validate(CapabilityOptionsValidation validation) + { + validation.RequirePositive("Alpha", Alpha); + validation.RequirePositive("Beta", Beta); + validation.RequirePositive("Gamma", Gamma); + validation.RequirePositive("Nested.Depth", Nested == null ? 0 : Nested.Depth); + } + + /// + /// 嵌套参数段(模拟能力 Options 的子对象,如 Social 的 Friend 段)。 + /// + public sealed class NestedSection + { + /// 获取或设置嵌套深度(默认 4,必须为正数)。 + public int Depth { get; set; } = 4; + } +} diff --git a/Tests/GameFrameX.Tests/Online/CapabilityDependencyCheckTests.cs b/Tests/GameFrameX.Tests/Online/CapabilityDependencyCheckTests.cs new file mode 100644 index 000000000..319e59c19 --- /dev/null +++ b/Tests/GameFrameX.Tests/Online/CapabilityDependencyCheckTests.cs @@ -0,0 +1,453 @@ +// ========================================================================================== +// GameFrameX 组织及其衍生项目的版权、商标、专利及其他相关权利 +// GameFrameX organization and its derivative projects' copyrights, trademarks, patents and related rights +// 均受中华人民共和国及相关国际法律法规保护。 +// are protected by the laws of the PRC 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 refer to the LICENSE file at the root 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 the legal 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://www.cnb.cool/GameFrameX +// CNB Repository: https://www.cnb.cool/GameFrameX +// 官方文档:https://gameframex.doc.alianblank.com/ +// Official Docs: https://gameframex.doc.alianblank.com/ +// ========================================================================================== + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using GameFrameX.Capability.Hosting.Abstractions; +using GameFrameX.Capability.Hosting.Core; +using GameFrameX.Foundation.Localization.Core; +using Xunit; +using LocalizationKeys = GameFrameX.Localization.Keys; + +namespace GameFrameX.Tests.Online; + +/// +/// 能力依赖检查测试(设计 §6.4:必选缺失一次报全 + 汇总日志、可选缺失的报告与日志披露、环路完整路径、 +/// 缺席原因三分、发现序无关的确定性定序、Register 期解析来源检查与 GetServices 阶段限制)。 +/// +[Collection(CapabilityRuntimeCollection.Name)] +public sealed class CapabilityDependencyCheckTests +{ + /// + /// 解析来源检查用的探针服务契约(由某个槽位能力注册,另一个槽位尝试解析)。 + /// + public interface IScopeProbeService + { + /// 探针名(区分实例)。 + string Name { get; } + } + + /// + /// 探针服务实现。 + /// + private sealed class ScopeProbeService : IScopeProbeService + { + /// + /// 初始化探针。 + /// + /// 探针名。 + public ScopeProbeService(string name) + { + Name = name; + } + + /// + public string Name { get; } + } + + /// + /// 构造机制组合选项(合法作用域三元组 + 白名单 / 排除表圈定参与面)。 + /// + /// 装配日志收集器。 + /// 白名单。 + /// 排除表(可空)。 + /// 组合选项。 + private static CapabilityCompositionOptions CreateOptions(CollectingCapabilityAssemblyLog log, string[] enabledIds, string[] disabledIds = null) + { + return new CapabilityCompositionOptions(default, log) + { + HostOptions = new CapabilityHostOptions { TenantId = 1, AppId = 1, ServerId = 1, }, + Filter = new CapabilityAssemblyFilter(enabledIds, disabledIds), + }; + } + + /// + /// 以机制测试族执行一次真实组合。 + /// + /// 组合选项。 + /// 组合产物。 + private static CapabilityRuntime Compose(CapabilityCompositionOptions options) + { + return CapabilityComposition.Compose(new MechanismTestFamily(), options); + } + + /// + /// 组合一次、取装配序后立即停止(确定性定序比较用)。 + /// + /// 白名单。 + /// 装配序。 + private static async Task ComposeOrderAsync(string[] enabledIds) + { + var runtime = Compose(CreateOptions(new CollectingCapabilityAssemblyLog(), enabledIds)); + try + { + return runtime.Report.EnabledIdsInOrder.ToArray(); + } + finally + { + await runtime.StopAsync(); + } + } + + /// + /// 必选依赖缺失问题文案(与机制拼接口径一致:依赖方 + 被依赖方 + 缺席原因)。 + /// + /// 依赖方能力 Id。 + /// 被依赖能力 Id。 + /// 缺席原因本地化键。 + /// 问题文案。 + private static string RequiredMissingProblem(string dependentId, string requiredId, string reasonKey) + { + return string.Format(LocalizationService.GetString(LocalizationKeys.Capability.Hosting.Assembly.AssemblyRequiredDependencyMissing), dependentId, requiredId, LocalizationService.GetString(reasonKey)); + } + + /// + /// 一次报全:同一组合内多个能力的多条必选缺失全部进入 Problems(含三种缺席原因), + /// 装配日志 Error 流 = 汇总行 + 每条问题一行(两空格缩进);依赖检查失败时任何模块 Register 都不执行。 + /// + [Fact] + public void Compose_SeveralMissingRequirements_ReportsAllProblemsAtOnce() + { + MechanismScenario.Reset(); + try + { + var registerInvoked = false; + MechanismScenario.First.Requires = new[] { "mech-second", "mech-third", "ghost-one", }; + MechanismScenario.First.Register = context => registerInvoked = true; + MechanismScenario.Fourth.Requires = new[] { "ghost-two", }; + MechanismScenario.Fourth.Register = context => registerInvoked = true; + var log = new CollectingCapabilityAssemblyLog(); + + var exception = Assert.Throws(() => Compose(CreateOptions(log, new[] { "mech-first", "mech-fourth", }, new[] { "mech-third", }))); + + // 问题序 = 启用模块的发现序(槽位类型全名 Ordinal:First < Fourth)× manifest 声明序。 + var expectedProblems = new[] + { + RequiredMissingProblem("mech-first", "mech-second", LocalizationKeys.Capability.Hosting.Assembly.AssemblyReasonNotInEnableList), + RequiredMissingProblem("mech-first", "mech-third", LocalizationKeys.Capability.Hosting.Assembly.AssemblyReasonExcludedByDisableList), + RequiredMissingProblem("mech-first", "ghost-one", LocalizationKeys.Capability.Hosting.Assembly.AssemblyReasonNotDiscovered), + RequiredMissingProblem("mech-fourth", "ghost-two", LocalizationKeys.Capability.Hosting.Assembly.AssemblyReasonNotDiscovered), + }; + Assert.Equal(expectedProblems, exception.Problems); + Assert.False(registerInvoked); + + var expectedErrors = new List + { + string.Format(LocalizationService.GetString(LocalizationKeys.Capability.Hosting.Assembly.AssemblyFailed), expectedProblems.Length), + }; + expectedErrors.AddRange(expectedProblems.Select(problem => " " + problem)); + Assert.Equal(expectedErrors, log.Errors); + } + finally + { + MechanismScenario.Reset(); + } + } + + /// + /// 可选依赖缺失是正常装配形态:模块照常 Register 并进入装配序;报告条目记录 Kind=Optional / State=NotAssembled + /// 与缺席原因,AbsentFeatures 为缺失功能的本地化文案;装配日志打一行固定格式的 Info,不打 Error。 + /// + [Fact] + public async Task Compose_MissingOptionalDependency_AssemblesAndDisclosesAbsentFeature() + { + MechanismScenario.Reset(); + CapabilityRuntime runtime = null; + try + { + var featureKey = LocalizationKeys.Capability.Online.Match.OptionalAssetsAbsent; + var registerInvoked = false; + MechanismScenario.First.Optional = new[] { new CapabilityOptionalDependency("ghost-optional", featureKey), }; + MechanismScenario.First.Register = context => registerInvoked = true; + var log = new CollectingCapabilityAssemblyLog(); + + runtime = Compose(CreateOptions(log, new[] { "mech-first", })); + + Assert.True(registerInvoked); + Assert.Contains("mech-first", runtime.Report.EnabledIdsInOrder); + var entry = runtime.Report.Entries.Single(candidate => candidate.Manifest.Id == "mech-first"); + Assert.Equal(CapabilityFilterState.Enabled, entry.State); + var dependency = Assert.Single(entry.Dependencies); + Assert.Equal("ghost-optional", dependency.CapabilityId); + Assert.Equal(CapabilityDependencyKind.Optional, dependency.Kind); + Assert.Equal(CapabilityDependencyAssemblyState.NotAssembled, dependency.State); + Assert.Equal(CapabilityDependencyAbsenceReason.NotDiscovered, dependency.AbsenceReason); + + var featureText = LocalizationService.GetString(featureKey); + Assert.NotEmpty(entry.AbsentFeatures); + Assert.Equal(new[] { featureText, }, entry.AbsentFeatures); + + var expectedInfo = string.Format(LocalizationService.GetString(LocalizationKeys.Capability.Hosting.Assembly.AssemblyOptionalDependencyMissing), "mech-first", "ghost-optional", LocalizationService.GetString(LocalizationKeys.Capability.Hosting.Assembly.AssemblyReasonNotDiscovered), featureText); + Assert.Single(log.Infos, line => line == expectedInfo); + Assert.Empty(log.Errors); + } + finally + { + if (runtime != null) + { + await runtime.StopAsync(); + } + + MechanismScenario.Reset(); + } + } + + /// + /// 缺席原因三分:白名单未收录(NotInEnableList)/ 排除表命中(ExcludedByDisableList)/ 部署目录未发现 + /// (NotDiscovered,依赖了没有任何槽位持有的 Id),报告数据面与 Info 日志逐项区分。 + /// + [Fact] + public async Task Compose_OptionalAbsenceReasons_DistinguishEnableListDisableListAndNotDiscovered() + { + MechanismScenario.Reset(); + CapabilityRuntime runtime = null; + try + { + var featureKey = LocalizationKeys.Capability.Online.Match.OptionalAssetsAbsent; + MechanismScenario.First.Optional = new[] + { + new CapabilityOptionalDependency("mech-second", featureKey), + new CapabilityOptionalDependency("mech-third", featureKey), + new CapabilityOptionalDependency("ghost-optional", featureKey), + }; + var log = new CollectingCapabilityAssemblyLog(); + + runtime = Compose(CreateOptions(log, new[] { "mech-first", }, new[] { "mech-third", })); + + Assert.Equal(new[] { "mech-first", }, runtime.Report.EnabledIdsInOrder); + Assert.Equal(CapabilityFilterState.NotInEnableList, runtime.Report.Entries.Single(candidate => candidate.Manifest.Id == "mech-second").State); + Assert.Equal(CapabilityFilterState.ExcludedByDisableList, runtime.Report.Entries.Single(candidate => candidate.Manifest.Id == "mech-third").State); + + var entry = runtime.Report.Entries.Single(candidate => candidate.Manifest.Id == "mech-first"); + Assert.Equal(3, entry.Dependencies.Count); + Assert.All(entry.Dependencies, dependency => Assert.Equal(CapabilityDependencyAssemblyState.NotAssembled, dependency.State)); + Assert.Equal(CapabilityDependencyAbsenceReason.NotInEnableList, entry.Dependencies.Single(dependency => dependency.CapabilityId == "mech-second").AbsenceReason); + Assert.Equal(CapabilityDependencyAbsenceReason.ExcludedByDisableList, entry.Dependencies.Single(dependency => dependency.CapabilityId == "mech-third").AbsenceReason); + Assert.Equal(CapabilityDependencyAbsenceReason.NotDiscovered, entry.Dependencies.Single(dependency => dependency.CapabilityId == "ghost-optional").AbsenceReason); + Assert.Equal(3, entry.AbsentFeatures.Count); + + var featureText = LocalizationService.GetString(featureKey); + var template = LocalizationService.GetString(LocalizationKeys.Capability.Hosting.Assembly.AssemblyOptionalDependencyMissing); + Assert.Contains(string.Format(template, "mech-first", "mech-second", LocalizationService.GetString(LocalizationKeys.Capability.Hosting.Assembly.AssemblyReasonNotInEnableList), featureText), log.Infos); + Assert.Contains(string.Format(template, "mech-first", "mech-third", LocalizationService.GetString(LocalizationKeys.Capability.Hosting.Assembly.AssemblyReasonExcludedByDisableList), featureText), log.Infos); + Assert.Contains(string.Format(template, "mech-first", "ghost-optional", LocalizationService.GetString(LocalizationKeys.Capability.Hosting.Assembly.AssemblyReasonNotDiscovered), featureText), log.Infos); + } + finally + { + if (runtime != null) + { + await runtime.StopAsync(); + } + + MechanismScenario.Reset(); + } + } + + /// + /// 环路:问题文案列出完整路径(cycle-a → cycle-b → cycle-c → cycle-a,箭头为机制拼接的 " → "), + /// 同一个环只报一次,并进入 Error 日志。 + /// + [Fact] + public void Compose_DependencyCycle_ReportsFullCyclePath() + { + MechanismScenario.Reset(); + try + { + MechanismScenario.First.Id = "cycle-a"; + MechanismScenario.First.Requires = new[] { "cycle-b", }; + MechanismScenario.Second.Id = "cycle-b"; + MechanismScenario.Second.Requires = new[] { "cycle-c", }; + MechanismScenario.Third.Id = "cycle-c"; + MechanismScenario.Third.Requires = new[] { "cycle-a", }; + var log = new CollectingCapabilityAssemblyLog(); + + var exception = Assert.Throws(() => Compose(CreateOptions(log, new[] { "cycle-a", "cycle-b", "cycle-c", }))); + + var problem = Assert.Single(exception.Problems); + Assert.Contains("cycle-a → cycle-b → cycle-c → cycle-a", problem); + Assert.Equal(string.Format(LocalizationService.GetString(LocalizationKeys.Capability.Hosting.Assembly.AssemblyDependencyCycle), "cycle-a → cycle-b → cycle-c → cycle-a"), problem); + Assert.Contains(" " + problem, log.Errors); + } + finally + { + MechanismScenario.Reset(); + } + } + + /// + /// 发现序无关:把能力 Id 在槽位间互换(发现序随之改变)后再组合,装配序完全一致 + /// (依赖边 + 同层 Id 序数决定,与扫描序 / 类型名无关)。 + /// + [Fact] + public async Task Compose_ShuffledDiscoveryOrder_ProducesIdenticalOrder() + { + MechanismScenario.Reset(); + try + { + var enabledIds = new[] { "shuffle-x", "shuffle-y", "shuffle-z", }; + MechanismScenario.First.Id = "shuffle-x"; + MechanismScenario.First.Requires = new[] { "shuffle-y", }; + MechanismScenario.Second.Id = "shuffle-y"; + MechanismScenario.Third.Id = "shuffle-z"; + var firstOrder = await ComposeOrderAsync(enabledIds); + + MechanismScenario.Reset(); + MechanismScenario.First.Id = "shuffle-z"; + MechanismScenario.Second.Id = "shuffle-x"; + MechanismScenario.Second.Requires = new[] { "shuffle-y", }; + MechanismScenario.Third.Id = "shuffle-y"; + var secondOrder = await ComposeOrderAsync(enabledIds); + + Assert.Equal(new[] { "shuffle-y", "shuffle-x", "shuffle-z", }, firstOrder); + Assert.Equal(firstOrder, secondOrder); + } + finally + { + MechanismScenario.Reset(); + } + } + + /// + /// Register 期越界解析 fail-fast:mech-second 未声明依赖 mech-first,却解析 mech-first 注册的服务 + /// (即使装配序上对方已注册)——拒绝靠装配顺序碰巧可用的未声明依赖。 + /// + [Fact] + public void Compose_RegisterResolvesServiceOutsideDeclaredDependencies_FailsFast() + { + MechanismScenario.Reset(); + try + { + MechanismScenario.First.Register = context => context.AddService(new ScopeProbeService("owned-by-first")); + MechanismScenario.Second.Register = context => context.GetService(); + + var exception = Assert.Throws(() => Compose(CreateOptions(new CollectingCapabilityAssemblyLog(), new[] { "mech-first", "mech-second", }))); + + Assert.Equal(string.Format(LocalizationService.GetString(LocalizationKeys.Capability.Hosting.ServiceResolutionForbidden), "mech-second", typeof(IScopeProbeService).FullName, "mech-first"), exception.Message); + } + finally + { + MechanismScenario.Reset(); + } + } + + /// + /// 对照:声明了必选依赖后,Register 期解析对方注册的服务合法且拿到同一实例。 + /// + [Fact] + public async Task Compose_RegisterResolvesServiceOfDeclaredRequirement_Succeeds() + { + MechanismScenario.Reset(); + CapabilityRuntime runtime = null; + try + { + var probe = new ScopeProbeService("owned-by-first"); + IScopeProbeService resolved = null; + MechanismScenario.First.Register = context => context.AddService(probe); + MechanismScenario.Second.Requires = new[] { "mech-first", }; + MechanismScenario.Second.Register = context => resolved = context.GetService(); + + runtime = Compose(CreateOptions(new CollectingCapabilityAssemblyLog(), new[] { "mech-first", "mech-second", })); + + Assert.Same(probe, resolved); + } + finally + { + if (runtime != null) + { + await runtime.StopAsync(); + } + + MechanismScenario.Reset(); + } + } + + /// + /// GetServices 在 Register 期恒抛(枚举结果不得随装配顺序变化)。 + /// + [Fact] + public void Compose_GetServicesDuringRegister_Throws() + { + MechanismScenario.Reset(); + try + { + MechanismScenario.First.Register = context => context.GetServices(); + + var exception = Assert.Throws(() => Compose(CreateOptions(new CollectingCapabilityAssemblyLog(), new[] { "mech-first", }))); + + Assert.Equal(string.Format(LocalizationService.GetString(LocalizationKeys.Capability.Hosting.ServicesEnumerationForbidden), "mech-first", typeof(IScopeProbeService).FullName), exception.Message); + } + finally + { + MechanismScenario.Reset(); + } + } + + /// + /// GetServices 阶段边界:宿主侧在 Activate 开始前同样拒绝枚举;Activate 期与之后枚举可用且列出已注册实现。 + /// + [Fact] + public async Task GetServices_AvailableFromActivationOnward() + { + MechanismScenario.Reset(); + CapabilityRuntime runtime = null; + try + { + var probe = new ScopeProbeService("enumerated"); + IReadOnlyList enumeratedDuringActivate = null; + MechanismScenario.First.Register = context => context.AddService(probe); + MechanismScenario.First.Activate = (context, cancellationToken) => + { + enumeratedDuringActivate = context.Services.GetServices(); + return Task.CompletedTask; + }; + + runtime = Compose(CreateOptions(new CollectingCapabilityAssemblyLog(), new[] { "mech-first", })); + + var beforeStart = Assert.Throws(() => runtime.Host.Services.GetServices()); + Assert.Equal(string.Format(LocalizationService.GetString(LocalizationKeys.Capability.Hosting.ServicesEnumerationForbidden), "host", typeof(IScopeProbeService).FullName), beforeStart.Message); + + await runtime.StartAsync(); + + Assert.NotNull(enumeratedDuringActivate); + Assert.Same(probe, Assert.Single(enumeratedDuringActivate)); + Assert.Same(probe, Assert.Single(runtime.Host.Services.GetServices())); + } + finally + { + if (runtime != null) + { + await runtime.StopAsync(); + } + + MechanismScenario.Reset(); + } + } +} diff --git a/Tests/GameFrameX.Tests/Online/CapabilityHostServiceAndHookTests.cs b/Tests/GameFrameX.Tests/Online/CapabilityHostServiceAndHookTests.cs new file mode 100644 index 000000000..b9ac4d50f --- /dev/null +++ b/Tests/GameFrameX.Tests/Online/CapabilityHostServiceAndHookTests.cs @@ -0,0 +1,556 @@ +// ========================================================================================== +// GameFrameX 组织及其衍生项目的版权、商标、专利及其他相关权利 +// GameFrameX organization and its derivative projects' copyrights, trademarks, patents and related rights +// 均受中华人民共和国及相关国际法律法规保护。 +// are protected by the laws of the PRC 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 refer to the LICENSE file at the root 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 the legal 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://www.cnb.cool/GameFrameX +// CNB Repository: https://www.cnb.cool/GameFrameX +// 官方文档:https://gameframex.doc.alianblank.com/ +// Official Docs: https://gameframex.doc.alianblank.com/ +// ========================================================================================== + +using System; +using System.Threading.Tasks; +using GameFrameX.Capability.Hosting.Abstractions; +using GameFrameX.Capability.Hosting.Core; +using GameFrameX.Foundation.Localization.Core; +using Xunit; +using LocalizationKeys = GameFrameX.Localization.Keys; + +namespace GameFrameX.Tests.Online; + +/// +/// 宿主扩展点与能力钩子测试(设计 §6.4:RequireHostService 缺失 fail-fast、宿主 Override 恒胜出模块默认、 +/// 钩子声明 / 挂载 / 原子替换 / 摘下回默认,以及钩子声明随宿主停止、激活失败、组合失败释放的回归锚点)。 +/// +[Collection(CapabilityRuntimeCollection.Name)] +public sealed class CapabilityHostServiceAndHookTests +{ + /// 宿主扩展点契约(模块声明 RequireHostService,宿主 Override 提供)。 + public interface IHostDirectoryService + { + /// 实现标记。 + string Name { get; } + } + + /// 可被宿主覆盖的模块默认服务契约。 + public interface IOverridableClockService + { + /// 实现标记。 + string Name { get; } + } + + /// 挂载 / 替换 / 摘下用例的钩子契约。 + public interface IAttachSwapHook + { + /// 实现标记。 + string Name { get; } + } + + /// 停止后再组合用例的钩子契约。 + public interface IRecomposeAfterStopHook + { + /// 实现标记。 + string Name { get; } + } + + /// 组合失败释放用例的钩子契约。 + public interface IRecomposeAfterFailedComposeHook + { + /// 实现标记。 + string Name { get; } + } + + /// 激活失败释放用例的钩子契约。 + public interface IRecomposeAfterFailedStartHook + { + /// 实现标记。 + string Name { get; } + } + + /// 同组合内重复声明用例的钩子契约。 + public interface IDuplicateDeclarationHook + { + /// 实现标记。 + string Name { get; } + } + + /// 声明计数用例的钩子契约(交错释放不得抹掉仍存活的声明)。 + public interface IInterleavedDeclarationHook + { + /// 实现标记。 + string Name { get; } + } + + /// + /// 全部测试契约的统一实现(按实现标记区分实例)。 + /// + private sealed class NamedImplementation : IHostDirectoryService, IOverridableClockService, IAttachSwapHook, IRecomposeAfterStopHook, IRecomposeAfterFailedComposeHook, IRecomposeAfterFailedStartHook, IDuplicateDeclarationHook, IInterleavedDeclarationHook + { + /// + /// 初始化实现。 + /// + /// 实现标记。 + public NamedImplementation(string name) + { + Name = name; + } + + /// + public string Name { get; } + } + + /// + /// 构造机制组合选项(合法作用域三元组 + 白名单圈定参与面)。 + /// + /// 白名单。 + /// 组合选项。 + private static CapabilityCompositionOptions CreateOptions(params string[] enabledIds) + { + return new CapabilityCompositionOptions(default, new CollectingCapabilityAssemblyLog()) + { + HostOptions = new CapabilityHostOptions { TenantId = 1, AppId = 1, ServerId = 1, }, + Filter = new CapabilityAssemblyFilter(enabledIds), + }; + } + + /// + /// 以机制测试族执行一次真实组合。 + /// + /// 组合选项。 + /// 组合产物。 + private static CapabilityRuntime Compose(CapabilityCompositionOptions options) + { + return CapabilityComposition.Compose(new MechanismTestFamily(), options); + } + + /// + /// 宿主扩展点缺失 fail-fast:模块 Register 声明 RequireHostService 而宿主未 Override,组合即失败(HostServiceMissing)。 + /// + [Fact] + public void Compose_RequireHostServiceWithoutOverride_FailsFast() + { + MechanismScenario.Reset(); + try + { + MechanismScenario.First.Register = context => context.RequireHostService(); + + var exception = Assert.Throws(() => Compose(CreateOptions("mech-first"))); + + Assert.Equal(string.Format(LocalizationService.GetString(LocalizationKeys.Capability.Hosting.HostServiceMissing), "mech-first", typeof(IHostDirectoryService).FullName), exception.Message); + } + finally + { + MechanismScenario.Reset(); + } + } + + /// + /// 宿主扩展点满足:宿主 Override 后组合成功,模块 Register 期可解析到宿主提供的实例(来源 = 族,不受来源检查拦截), + /// 宿主侧服务面解析到同一实例。 + /// + [Fact] + public async Task Compose_RequireHostServiceWithOverride_ComposesAndResolvesHostInstance() + { + MechanismScenario.Reset(); + CapabilityRuntime runtime = null; + try + { + var hostDirectory = new NamedImplementation("host-directory"); + IHostDirectoryService resolvedInRegister = null; + MechanismScenario.First.Register = context => + { + context.RequireHostService(); + resolvedInRegister = context.GetService(); + }; + var options = CreateOptions("mech-first"); + options.Override(hostDirectory); + + runtime = Compose(options); + + Assert.Same(hostDirectory, resolvedInRegister); + Assert.Same(hostDirectory, runtime.Host.Services.GetService()); + } + finally + { + if (runtime != null) + { + await runtime.StopAsync(); + } + + MechanismScenario.Reset(); + } + } + + /// + /// 宿主 Override 恒胜出模块默认(与注册先后无关):模块 Register 注册默认实现、宿主 Override 同键, + /// 模块侧、宿主侧单实例解析与 Activate 期枚举都只见覆盖实现(被覆盖的默认不列出)。 + /// + [Fact] + public async Task Compose_OverrideBeatsModuleDefault() + { + MechanismScenario.Reset(); + CapabilityRuntime runtime = null; + try + { + var moduleDefault = new NamedImplementation("module-default"); + var hostOverride = new NamedImplementation("host-override"); + IOverridableClockService resolvedInRegister = null; + MechanismScenario.First.Register = context => + { + context.AddService(moduleDefault); + resolvedInRegister = context.GetService(); + }; + var options = CreateOptions("mech-first"); + options.Override(hostOverride); + + runtime = Compose(options); + + Assert.Same(hostOverride, resolvedInRegister); + Assert.Same(hostOverride, runtime.Host.Services.GetService()); + + await runtime.StartAsync(); + + Assert.Same(hostOverride, Assert.Single(runtime.Host.Services.GetServices())); + } + finally + { + if (runtime != null) + { + await runtime.StopAsync(); + } + + MechanismScenario.Reset(); + } + } + + /// + /// 同键第二个宿主 Override = 组合失败(ServiceOverrideDuplicate)。 + /// + [Fact] + public void Compose_DuplicateOverrideOfSameKey_FailsFast() + { + MechanismScenario.Reset(); + try + { + var options = CreateOptions("mech-first"); + options.Override(new NamedImplementation("first-override")); + options.Override(new NamedImplementation("second-override")); + + var exception = Assert.Throws(() => Compose(options)); + + Assert.Equal(string.Format(LocalizationService.GetString(LocalizationKeys.Capability.Hosting.ServiceOverrideDuplicate), typeof(IOverridableClockService).FullName), exception.Message); + } + finally + { + MechanismScenario.Reset(); + } + } + + /// + /// 钩子声明 / 挂载 / 原子替换 / 摘下:组合后句柄读到默认策略;Attach(a) 后为 a;Attach(b) 原子替换为 b, + /// 被替换的旧租约 Dispose 为空操作(b 仍在位);摘下当前租约后回默认策略。 + /// + [Fact] + public async Task Hook_AttachReplaceAndDetach_FollowsLeaseSemantics() + { + MechanismScenario.Reset(); + CapabilityRuntime runtime = null; + CapabilityHookLease leaseA = null; + CapabilityHookLease leaseB = null; + try + { + var defaultHook = new NamedImplementation("default"); + var hookA = new NamedImplementation("a"); + var hookB = new NamedImplementation("b"); + CapabilityHookHandle handle = null; + MechanismScenario.First.Register = context => handle = context.DeclareHook(defaultHook); + + runtime = Compose(CreateOptions("mech-first")); + + Assert.NotNull(handle); + Assert.Same(defaultHook, handle.Current); + + leaseA = CapabilityHook.Attach(hookA); + Assert.Same(hookA, handle.Current); + + leaseB = CapabilityHook.Attach(hookB); + Assert.Same(hookB, handle.Current); + + leaseA.Dispose(); + Assert.Same(hookB, handle.Current); + + leaseB.Dispose(); + Assert.Same(defaultHook, handle.Current); + } + finally + { + leaseA?.Dispose(); + leaseB?.Dispose(); + if (runtime != null) + { + await runtime.StopAsync(); + } + + MechanismScenario.Reset(); + } + } + + /// + /// 同一组合内两个能力声明同一钩子 = 组合失败(HookDuplicateDeclaration,消息含先后两个声明者); + /// 失败组合已声明的项被释放,随后只留一个声明者再组合成功。 + /// + [Fact] + public async Task Compose_SameHookDeclaredTwice_FailsFastAndReleasesDeclaration() + { + MechanismScenario.Reset(); + CapabilityRuntime runtime = null; + try + { + var defaultHook = new NamedImplementation("default"); + MechanismScenario.First.Register = context => context.DeclareHook(defaultHook); + MechanismScenario.Second.Register = context => context.DeclareHook(defaultHook); + + var exception = Assert.Throws(() => Compose(CreateOptions("mech-first", "mech-second"))); + + Assert.Equal(string.Format(LocalizationService.GetString(LocalizationKeys.Capability.Hosting.HookDuplicateDeclaration), typeof(IDuplicateDeclarationHook).FullName, "mech-first", "mech-second"), exception.Message); + + CapabilityHookHandle handle = null; + MechanismScenario.Second.Register = null; + MechanismScenario.First.Register = context => handle = context.DeclareHook(defaultHook); + + runtime = Compose(CreateOptions("mech-first", "mech-second")); + + Assert.Same(defaultHook, handle.Current); + } + finally + { + if (runtime != null) + { + await runtime.StopAsync(); + } + + MechanismScenario.Reset(); + } + } + + /// + /// 钩子声明释放回归(停止路径):组合 → 启动 → 停止后同进程再次以同一 DeclareHook 组合成功; + /// 已组合未启动直接停止同样释放声明。修复前第二次组合抛 HookDuplicateDeclaration。 + /// + [Fact] + public async Task Compose_AfterStopAsync_SameHookCanBeDeclaredAgain() + { + MechanismScenario.Reset(); + CapabilityRuntime runtime = null; + try + { + var defaultHook = new NamedImplementation("default"); + CapabilityHookHandle handle = null; + MechanismScenario.First.Register = context => handle = context.DeclareHook(defaultHook); + + runtime = Compose(CreateOptions("mech-first")); + await runtime.StartAsync(); + await runtime.StopAsync(); + runtime = null; + + handle = null; + runtime = Compose(CreateOptions("mech-first")); + Assert.Same(defaultHook, handle.Current); + + // 已组合未启动:停止仍须释放 Register 期声明。 + await runtime.StopAsync(); + runtime = null; + + handle = null; + runtime = Compose(CreateOptions("mech-first")); + Assert.Same(defaultHook, handle.Current); + } + finally + { + if (runtime != null) + { + await runtime.StopAsync(); + } + + MechanismScenario.Reset(); + } + } + + /// + /// 钩子声明释放回归(组合失败路径):前序模块 Register 已声明钩子、后序模块 Register 抛出, + /// 组合失败后声明被释放,再次组合成功并读到默认策略。 + /// + [Fact] + public async Task Compose_AfterFailedComposition_SameHookCanBeDeclaredAgain() + { + MechanismScenario.Reset(); + CapabilityRuntime runtime = null; + try + { + var defaultHook = new NamedImplementation("default"); + CapabilityHookHandle handle = null; + var secondRegisterInvoked = false; + MechanismScenario.First.Register = context => handle = context.DeclareHook(defaultHook); + MechanismScenario.Second.Register = context => + { + secondRegisterInvoked = true; + throw new InvalidOperationException("mech-second register failed"); + }; + + var exception = Assert.Throws(() => Compose(CreateOptions("mech-first", "mech-second"))); + + Assert.Equal("mech-second register failed", exception.Message); + Assert.True(secondRegisterInvoked); + Assert.NotNull(handle); + + handle = null; + MechanismScenario.Second.Register = null; + runtime = Compose(CreateOptions("mech-first", "mech-second")); + + Assert.NotNull(handle); + Assert.Same(defaultHook, handle.Current); + } + finally + { + if (runtime != null) + { + await runtime.StopAsync(); + } + + MechanismScenario.Reset(); + } + } + + /// + /// 钩子声明释放回归(激活失败路径):模块 Register 声明钩子、Activate 抛出,StartAsync 回滚后声明被释放, + /// 再次组合成功并读到默认策略。 + /// + [Fact] + public async Task Compose_AfterFailedStart_SameHookCanBeDeclaredAgain() + { + MechanismScenario.Reset(); + CapabilityRuntime runtime = null; + try + { + var defaultHook = new NamedImplementation("default"); + CapabilityHookHandle handle = null; + MechanismScenario.First.Register = context => handle = context.DeclareHook(defaultHook); + MechanismScenario.First.Activate = (context, cancellationToken) => throw new InvalidOperationException("mech-first activation failed"); + + runtime = Compose(CreateOptions("mech-first")); + var exception = await Assert.ThrowsAsync(() => runtime.StartAsync()); + Assert.Equal("mech-first activation failed", exception.Message); + runtime = null; + + handle = null; + MechanismScenario.First.Activate = null; + runtime = Compose(CreateOptions("mech-first")); + + Assert.NotNull(handle); + Assert.Same(defaultHook, handle.Current); + } + finally + { + if (runtime != null) + { + await runtime.StopAsync(); + } + + MechanismScenario.Reset(); + } + } + + /// + /// 钩子声明按契约计数(交错释放回归):旧宿主释放声明前,另一宿主对同契约的新声明已登记(以机制内部登记入口模拟 + /// 该交错到达点);旧宿主停止后,新声明仍在册——随后组合时不得对已挂载契约误报「已挂载但无能力声明」Info; + /// 新声明也释放后,同一审计如实报出。 + /// + [Fact] + public async Task HookDeclaration_InterleavedRelease_DoesNotEraseLiveDeclaration() + { + MechanismScenario.Reset(); + CapabilityRuntime runtime = null; + CapabilityHookLease lease = null; + // 登记表是机制内部类型(测试程序集不在 InternalsVisibleTo 名单),经程序集按名取用以模拟交错到达的另一宿主声明。 + var slotsType = typeof(CapabilityHook<>).Assembly.GetType("GameFrameX.Capability.Hosting.Abstractions.CapabilityHookSlots", true); + var markDeclared = slotsType.GetMethod("MarkDeclared", System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic); + var markUndeclared = slotsType.GetMethod("MarkUndeclared", System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic); + var liveDeclarationRegistered = false; + try + { + var hookNotDeclared = string.Format(LocalizationService.GetString(LocalizationKeys.Capability.Hosting.Assembly.AssemblyHookNotDeclared), typeof(IInterleavedDeclarationHook).FullName); + lease = CapabilityHook.Attach(new NamedImplementation("attached")); + MechanismScenario.First.Register = context => context.DeclareHook(new NamedImplementation("default")); + + runtime = Compose(CreateOptions("mech-first")); + markDeclared.Invoke(null, new object[] { typeof(IInterleavedDeclarationHook), }); + liveDeclarationRegistered = true; + await runtime.StopAsync(); + runtime = null; + + MechanismScenario.First.Register = null; + var whileLiveLog = new CollectingCapabilityAssemblyLog(); + runtime = CapabilityComposition.Compose(new MechanismTestFamily(), CreateOptionsWithLog(whileLiveLog, "mech-first")); + await runtime.StopAsync(); + runtime = null; + + Assert.DoesNotContain(hookNotDeclared, whileLiveLog.Infos); + + markUndeclared.Invoke(null, new object[] { typeof(IInterleavedDeclarationHook), }); + liveDeclarationRegistered = false; + var afterReleaseLog = new CollectingCapabilityAssemblyLog(); + runtime = CapabilityComposition.Compose(new MechanismTestFamily(), CreateOptionsWithLog(afterReleaseLog, "mech-first")); + + Assert.Contains(hookNotDeclared, afterReleaseLog.Infos); + } + finally + { + if (liveDeclarationRegistered) + { + markUndeclared.Invoke(null, new object[] { typeof(IInterleavedDeclarationHook), }); + } + + lease?.Dispose(); + if (runtime != null) + { + await runtime.StopAsync(); + } + + MechanismScenario.Reset(); + } + } + + /// + /// 构造带指定装配日志收集器的机制组合选项。 + /// + /// 装配日志收集器。 + /// 白名单。 + /// 组合选项。 + private static CapabilityCompositionOptions CreateOptionsWithLog(CollectingCapabilityAssemblyLog log, params string[] enabledIds) + { + return new CapabilityCompositionOptions(default, log) + { + HostOptions = new CapabilityHostOptions { TenantId = 1, AppId = 1, ServerId = 1, }, + Filter = new CapabilityAssemblyFilter(enabledIds), + }; + } +} diff --git a/Tests/GameFrameX.Tests/Online/CapabilityHostingTests.cs b/Tests/GameFrameX.Tests/Online/CapabilityHostingTests.cs index 507f55ef9..d7e715d07 100644 --- a/Tests/GameFrameX.Tests/Online/CapabilityHostingTests.cs +++ b/Tests/GameFrameX.Tests/Online/CapabilityHostingTests.cs @@ -2,324 +2,294 @@ // 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. -// 使用本项目须严格遵守相应法律法规与开源许可证之规定。 +// are protected by the laws of the PRC 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 refer to the LICENSE file in the root directory of the source code for the full license text. +// 完整许可证文本请参见源代码根目录的 LICENSE 文件。 +// please refer to the LICENSE file at the root 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! +// or infringe the legal rights and interests of others as prohibited by laws and regulations! // 因基于本项目二次开发所产生的一切法律纠纷与责任, -// Any legal disputes or liabilities arising from secondary development based on this project -// 本组织与贡献者概不承担。 +// 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 +// 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 +// Gitee Repository: https://gitee.com/GameFrameX +// CNB 仓库:https://www.cnb.cool/GameFrameX +// CNB Repository: https://www.cnb.cool/GameFrameX // 官方文档:https://gameframex.doc.alianblank.com/ -// Official Documentation: https://gameframex.doc.alianblank.com/ +// Official Docs: https://gameframex.doc.alianblank.com/ // ========================================================================================== using System; using System.Linq; -using System.Text.Json; -using System.Threading; using System.Threading.Tasks; using GameFrameX.Capability.Hosting.Abstractions; using GameFrameX.Capability.Hosting.Core; -using GameFrameX.Capability.Hosting.AdminApi; -using GameFrameX.Capability.Online.Abstractions.Contracts; -using GameFrameX.Launcher.Online; +using GameFrameX.Foundation.Localization.Core; using Xunit; +using LocalizationKeys = GameFrameX.Localization.Keys; -namespace GameFrameX.Tests.Online +namespace GameFrameX.Tests.Online; + +/// +/// 能力机制层装配测试(C294:经唯一组合面 + 机制测试族槽位, +/// 锚定拓扑定序、必选闭包 fail-fast、同 Id fail-fast、过滤器语义、扫描发现、宿主选项校验与 job 全名防重复)。 +/// +[Collection(CapabilityRuntimeCollection.Name)] +public sealed class CapabilityHostingTests { /// - /// 能力机制层测试(拓扑定序稳定性 / 闭包校验 fail-fast / 装配过滤器 / 报告与扫描发现 / - /// Online 过渡装配的报告口径与 admin-only 未安装裁决)。 + /// 构造机制组合选项(合法作用域三元组 + 白名单 / 排除表圈定参与面)。 /// - public class CapabilityHostingTests + /// 装配日志收集器。 + /// 白名单(参与本次组合的能力 Id)。 + /// 排除表(可空)。 + /// 组合选项。 + private static CapabilityCompositionOptions CreateOptions(CollectingCapabilityAssemblyLog log, string[] enabledIds, string[] disabledIds = null) { - /// - /// 测试模块甲(无依赖)。 - /// - private sealed class AlphaModule : ICapabilityModule + return new CapabilityCompositionOptions(default, log) { - public CapabilityManifest Manifest { get; } = new("test-alpha", "1.0.0", new[] { "alpha_action" }); + HostOptions = new CapabilityHostOptions { TenantId = 1, AppId = 1, ServerId = 1, }, + Filter = new CapabilityAssemblyFilter(enabledIds, disabledIds), + }; + } - public void Register(CapabilityRegistrationContext context) - { - } - } + /// + /// 以机制测试族执行一次真实组合。 + /// + /// 组合选项。 + /// 组合产物。 + private static CapabilityRuntime Compose(CapabilityCompositionOptions options) + { + return CapabilityComposition.Compose(new MechanismTestFamily(), options); + } - /// - /// 测试模块乙(依赖甲)。 - /// - private sealed class BetaModule : ICapabilityModule + /// + /// 拓扑定序:依赖方排在被依赖方之后;同层按能力 Id 序数定序(不再按发现序 / 加入序)—— + /// 无依赖的 topo-gamma 虽与 topo-alpha 同处首层,仍排在序数更小的 topo-beta 之后。 + /// + [Fact] + public async Task Compose_OrdersDependentAfterDependencyAndSameLevelByIdOrdinal() + { + MechanismScenario.Reset(); + CapabilityRuntime runtime = null; + try { - public CapabilityManifest Manifest { get; } = new("test-beta", "1.0.0", Array.Empty(), new[] { "test-alpha" }); + MechanismScenario.First.Id = "topo-beta"; + MechanismScenario.First.Requires = new[] { "topo-alpha", }; + MechanismScenario.Second.Id = "topo-alpha"; + MechanismScenario.Third.Id = "topo-gamma"; - public void Register(CapabilityRegistrationContext context) - { - } - } + runtime = Compose(CreateOptions(new CollectingCapabilityAssemblyLog(), new[] { "topo-beta", "topo-alpha", "topo-gamma", })); - /// - /// 构造合法选项。 - /// - /// 选项。 - private static CapabilityHostOptions ValidOptions() - { - return new CapabilityHostOptions { TenantId = 1, AppId = 1, ServerId = 1 }; + Assert.Equal(new[] { "topo-alpha", "topo-beta", "topo-gamma", }, runtime.Report.EnabledIdsInOrder); } - - /// - /// 构造 Online 单入口组合产物(业务运行时形态;授权三元组 1/1/1001,C232 P6 起真扫描装配)。 - /// - /// 组合产物。 - private static OnlineCapabilityComposition CreateComposition() + finally { - return OnlineCapabilityComposition.AddOnlineCapabilities(options => + if (runtime != null) { - options.TenantId = 1; - options.AppId = 1; - options.ServerId = 1001; - options.EnableRuntime = true; - }); + await runtime.StopAsync(); + } + + MechanismScenario.Reset(); } + } - /// - /// 拓扑定序:依赖方排在被依赖方之后,同层保持加入序。 - /// - [Fact] - public void Build_OrdersByRequiresAndKeepsInsertionOrderWithinLevel() + /// + /// 必选闭包 fail-fast:Requires 指向未发现的能力即抛装配汇总异常(C294 起为 )。 + /// + [Fact] + public void Compose_MissingRequirement_FailsFastWithAssemblyException() + { + MechanismScenario.Reset(); + try { - var builder = new CapabilityHostBuilder(); - builder.AddModule(new BetaModule()); - builder.AddModule(new AlphaModule()); + MechanismScenario.First.Requires = new[] { "missing-capability", }; - var host = builder.Build(ValidOptions()); + var exception = Assert.Throws(() => Compose(CreateOptions(new CollectingCapabilityAssemblyLog(), new[] { "mech-first", }))); - Assert.Equal(new[] { "test-alpha", "test-beta" }, host.Report.EnabledIdsInOrder); + Assert.Single(exception.Problems); } - - /// - /// 闭包校验 fail-fast:Requires 指向未装配能力即抛。 - /// - [Fact] - public void Build_MissingRequirement_FailsFast() + finally { - var builder = new CapabilityHostBuilder(); - builder.AddModule(new BetaModule()); - - Assert.Throws(() => builder.Build(ValidOptions())); + MechanismScenario.Reset(); } + } - /// - /// manifest Id 重复即装配失败。 - /// - [Fact] - public void Build_DuplicateCapabilityId_FailsFast() + /// + /// manifest Id 重复即装配失败:两个槽位同 Id 时部署目录扫描直接 fail-fast(不再静默取首)。 + /// + [Fact] + public void Compose_DuplicateCapabilityId_FailsFast() + { + MechanismScenario.Reset(); + try { - var builder = new CapabilityHostBuilder(); - builder.AddModule(new AlphaModule()); - builder.AddModule(new AlphaModule()); - - Assert.Throws(() => builder.Build(ValidOptions())); - } + MechanismScenario.First.Id = "dup-capability"; + MechanismScenario.Second.Id = "dup-capability"; - /// - /// 过滤器引用未发现的能力 Id 即装配失败。 - /// - [Fact] - public void Build_FilterTargetMissing_FailsFast() - { - var builder = new CapabilityHostBuilder(); - builder.AddModule(new AlphaModule()); - builder.SetFilter(new CapabilityAssemblyFilter(disabledIds: new[] { "no-such-capability" })); + var exception = Assert.Throws(() => Compose(CreateOptions(new CollectingCapabilityAssemblyLog(), new[] { "dup-capability", }))); + Assert.Contains("dup-capability", exception.Message); - Assert.Throws(() => builder.Build(ValidOptions())); + Assert.Throws(() => CapabilityAssembly.DiscoverFromDeploymentDirectory(new MechanismTestFamily().DeploymentAssemblyPrefix)); } - - /// - /// 排除表与清单段语义:Disable 命中不装配;Capabilities 白名单未收录不装配; - /// 被排除能力的依赖方也因闭包校验失败(本例乙无甲即 fail-fast)。 - /// - [Fact] - public void Build_DisabledCapability_ExcludedFromReportAndEnabledOrder() + finally { - var builder = new CapabilityHostBuilder(); - builder.AddModule(new AlphaModule()); - builder.SetFilter(new CapabilityAssemblyFilter(disabledIds: new[] { "test-alpha" })); - - var host = builder.Build(ValidOptions()); - - Assert.Empty(host.Report.EnabledIdsInOrder); - var entry = Assert.Single(host.Report.Entries); - Assert.Equal(CapabilityFilterState.ExcludedByDisableList, entry.State); + MechanismScenario.Reset(); } + } - /// - /// 白名单形态:清单段未收录的能力标记 NotInEnableList,收录的照常装配。 - /// - [Fact] - public void Build_EnableList_FiltersUnlistedCapabilities() + /// + /// 过滤器引用未发现的能力 Id 即装配失败(排除表与白名单两种形态)。 + /// + [Fact] + public void Compose_FilterTargetMissing_FailsFast() + { + MechanismScenario.Reset(); + try { - var builder = new CapabilityHostBuilder(); - builder.AddModule(new AlphaModule()); - builder.AddModule(new BetaModule()); - builder.SetFilter(new CapabilityAssemblyFilter(enabledIds: new[] { "test-alpha" })); + var expectedMessage = string.Format(LocalizationService.GetString(LocalizationKeys.Capability.Hosting.FilterTargetMissing), "no-such-capability"); - var host = builder.Build(ValidOptions()); + var disabled = Assert.Throws(() => Compose(CreateOptions(new CollectingCapabilityAssemblyLog(), new[] { "mech-first", }, new[] { "no-such-capability", }))); + Assert.Equal(expectedMessage, disabled.Message); - Assert.Equal(new[] { "test-alpha" }, host.Report.EnabledIdsInOrder); - Assert.Equal(CapabilityFilterState.NotInEnableList, host.Report.Entries.Single(e => e.Manifest.Id == "test-beta").State); + var enabled = Assert.Throws(() => Compose(CreateOptions(new CollectingCapabilityAssemblyLog(), new[] { "mech-first", "no-such-capability", }))); + Assert.Equal(expectedMessage, enabled.Message); } - - /// - /// 扫描发现:程序集扫描能发现测试程序集内的 ICapabilityModule 实现(机制开放性——无硬编码清单)。 - /// - [Fact] - public void DiscoverModules_FindsModulesFromAssembly() + finally { - var modules = CapabilityAssembly.DiscoverModules(new[] { typeof(CapabilityHostingTests).Assembly }); - - Assert.Contains(modules, m => m.Manifest.Id == "test-alpha"); + MechanismScenario.Reset(); } + } - /// - /// 选项非法(作用域三元组非正)即构造失败。 - /// - [Fact] - public void CapabilityHost_InvalidOptions_Throws() + /// + /// 排除表语义:排除表命中的能力不装配,报告判定为 ExcludedByDisableList(排除表优先于白名单)。 + /// + [Fact] + public async Task Compose_DisabledCapability_ExcludedFromEnabledOrder() + { + MechanismScenario.Reset(); + CapabilityRuntime runtime = null; + try { - Assert.Throws(() => new CapabilityHostBuilder().Build(new CapabilityHostOptions { TenantId = 0, AppId = 1, ServerId = 1 })); - } + runtime = Compose(CreateOptions(new CollectingCapabilityAssemblyLog(), new[] { "mech-first", "mech-second", }, new[] { "mech-second", })); - /// - /// Online 真扫描装配报告:7 个终态包 Id 全部启用,拓扑序满足 Requires 方向(依赖方后置; - /// C232 P6 起经 Launcher 单入口扫描装配,装配序 = 扫描发现序 + Requires 拓扑)。 - /// - [Fact] - public void OnlineHost_ReportEnablesAllSevenCapabilitiesInTopologicalOrder() + Assert.Equal(new[] { "mech-first", }, runtime.Report.EnabledIdsInOrder); + Assert.Equal(CapabilityFilterState.ExcludedByDisableList, runtime.Report.Entries.Single(entry => entry.Manifest.Id == "mech-second").State); + } + finally { - var composition = OnlineCapabilityComposition.AddOnlineCapabilities(options => + if (runtime != null) { - options.TenantId = 1; - options.AppId = 1; - options.ServerId = 1001; - options.EnableRuntime = true; - }); + await runtime.StopAsync(); + } - var order = composition.Runtime.Report.EnabledIdsInOrder; - Assert.Equal(new[] - { - "online-assets", "online-foundation", "online-social", "online-matchmaking", - "online-match", "online-competition", "online-operations", - }, order); + MechanismScenario.Reset(); } + } - /// - /// admin-only 形态:零能力装配,全部业务 action 按未安装(4003)裁决且载荷带已装清单(空), - /// list_capabilities 如实返回全目录(NotAssembled)。 - /// - [Fact] - public async Task AdminOnlyDispatcher_AnswersNotInstalledAndListsCatalogAsync() + /// + /// 白名单形态:清单段未收录的能力标记 NotInEnableList,收录的照常装配。 + /// + [Fact] + public async Task Compose_EnableList_FiltersUnlistedCapabilities() + { + MechanismScenario.Reset(); + CapabilityRuntime runtime = null; + try { - var dispatcher = OnlineAdminApiComposition.CreateAdminOnlyDispatcher(ValidOptions()); + runtime = Compose(CreateOptions(new CollectingCapabilityAssemblyLog(), new[] { "mech-first", })); - var notInstalled = await dispatcher.DispatchAsync("grant_asset", "{\"TenantId\":1,\"AppId\":1,\"ServerId\":1,\"RequestId\":\"r1\"}"); - using (var outer = JsonDocument.Parse(notInstalled)) + Assert.Equal(new[] { "mech-first", }, runtime.Report.EnabledIdsInOrder); + Assert.Equal(CapabilityFilterState.Enabled, runtime.Report.Entries.Single(entry => entry.Manifest.Id == "mech-first").State); + Assert.Equal(CapabilityFilterState.NotInEnableList, runtime.Report.Entries.Single(entry => entry.Manifest.Id == "mech-second").State); + } + finally + { + if (runtime != null) { - var innerJson = outer.RootElement.GetProperty("data").GetString(); - using (var inner = JsonDocument.Parse(innerJson)) - { - Assert.Equal((int)OnlineErrorCode.CapabilityNotInstalled, inner.RootElement.GetProperty("Code").GetInt32()); - Assert.Equal("Online.Error.Param.CapabilityNotInstalled", inner.RootElement.GetProperty("MessageKey").GetString()); - Assert.Equal("r1", inner.RootElement.GetProperty("RequestId").GetString()); - using (var payload = JsonDocument.Parse(inner.RootElement.GetProperty("Data").GetRawText())) - { - Assert.Equal("online-assets", payload.RootElement.GetProperty("NotInstalledCapability").GetString()); - Assert.Empty(payload.RootElement.GetProperty("InstalledCapabilities").EnumerateArray()); - } - } + await runtime.StopAsync(); } - var listing = await dispatcher.DispatchAsync("list_capabilities", "{\"TenantId\":1,\"AppId\":1,\"ServerId\":1}"); - using (var outer = JsonDocument.Parse(listing)) - { - var innerJson = outer.RootElement.GetProperty("data").GetString(); - using (var inner = JsonDocument.Parse(innerJson)) - { - var capabilities = inner.RootElement.GetProperty("Data").GetProperty("Capabilities"); - Assert.Equal(7, capabilities.GetArrayLength()); - Assert.All(capabilities.EnumerateArray(), entry => Assert.Equal("NotAssembled", entry.GetProperty("State").GetString())); - } - } + MechanismScenario.Reset(); } + } - /// - /// 未安装裁决与未知 action 的区分:目录外的 action 仍按 4002 未知处理。 - /// - [Fact] - public async Task AdminOnlyDispatcher_UnknownActionOutsideCatalog_Answers4002Async() + /// + /// 扫描发现:程序集扫描能发现测试程序集内的槽位模块(机制开放性——无硬编码清单)。 + /// + [Fact] + public void DiscoverModules_FindsSlotModulesFromAssembly() + { + MechanismScenario.Reset(); + try { - var dispatcher = OnlineAdminApiComposition.CreateAdminOnlyDispatcher(ValidOptions()); - - var response = await dispatcher.DispatchAsync("no_such_action", "{\"TenantId\":1,\"AppId\":1,\"ServerId\":1}"); - using (var outer = JsonDocument.Parse(response)) - { - var innerJson = outer.RootElement.GetProperty("data").GetString(); - using (var inner = JsonDocument.Parse(innerJson)) - { - Assert.Equal((int)OnlineErrorCode.ResourceNotFound, inner.RootElement.GetProperty("Code").GetInt32()); - } - } + var modules = CapabilityAssembly.DiscoverModules(new[] { typeof(MechanismFirstSlotModule).Assembly, }); + var ids = modules.Select(module => module.Manifest.Id).ToArray(); + + Assert.Contains("mech-first", ids); + Assert.Contains("mech-second", ids); + Assert.Contains("mech-third", ids); + Assert.Contains("mech-fourth", ids); + Assert.Contains(modules, module => module is MechanismFirstSlotModule); } + finally + { + MechanismScenario.Reset(); + } + } - /// - /// 业务形态的 list_capabilities:29 个业务 action 之外返回全部启用能力与装配顺序。 - /// - [Fact] - public async Task FullComposition_ListCapabilities_ReportsEnabledOrderAsync() + /// + /// 宿主选项非法(作用域三元组非正)即组合失败。 + /// + [Fact] + public void Compose_InvalidHostOptions_FailsFast() + { + MechanismScenario.Reset(); + try { - var dispatcher = OnlineAdminApiComposition.CreateDispatcher(CreateComposition().Runtime); + var options = CreateOptions(new CollectingCapabilityAssemblyLog(), new[] { "mech-first", }); + options.HostOptions = new CapabilityHostOptions { TenantId = 0, AppId = 1, ServerId = 1, }; - var response = await dispatcher.DispatchAsync("list_capabilities", "{\"TenantId\":1,\"AppId\":1,\"ServerId\":1001}"); - using (var outer = JsonDocument.Parse(response)) - { - var innerJson = outer.RootElement.GetProperty("data").GetString(); - using (var inner = JsonDocument.Parse(innerJson)) - { - var data = inner.RootElement.GetProperty("Data"); - Assert.Equal(7, data.GetProperty("Capabilities").GetArrayLength()); - Assert.Equal(new[] - { - "online-assets", "online-foundation", "online-social", "online-matchmaking", - "online-match", "online-competition", "online-operations", - }, data.GetProperty("EnabledIdsInOrder").EnumerateArray().Select(e => e.GetString()).ToArray()); - } - } + var exception = Assert.Throws(() => Compose(options)); + + Assert.Equal(LocalizationService.GetString(LocalizationKeys.Capability.Hosting.OptionsInvalid), exception.Message); + } + finally + { + MechanismScenario.Reset(); } + } - /// - /// job 全名防重复:同能力内重复 jobId 即注册失败。 - /// - [Fact] - public void RegistrationContext_DuplicateJobName_FailsFast() + /// + /// job 全名防重复:同能力 Register 内重复 jobId 即组合失败(全名 = 能力Id/jobId)。 + /// + [Fact] + public void Compose_DuplicateJobIdWithinCapability_FailsFast() + { + MechanismScenario.Reset(); + try { - var context = new CapabilityRegistrationContext(new CapabilityManifest("dup", "1.0.0")); + MechanismScenario.First.Register = context => + { + context.AddTickJob("sweep", cancellationToken => Task.CompletedTask); + context.AddTickJob("sweep", cancellationToken => Task.CompletedTask); + }; - context.AddTickJob("sweep", _ => Task.CompletedTask); + var exception = Assert.Throws(() => Compose(CreateOptions(new CollectingCapabilityAssemblyLog(), new[] { "mech-first", }))); - Assert.Throws(() => context.AddTickJob("sweep", _ => Task.CompletedTask)); + Assert.Contains("mech-first/sweep", exception.Message); + } + finally + { + MechanismScenario.Reset(); } } } diff --git a/Tests/GameFrameX.Tests/Online/CapabilityLifecycleAndBridgeTests.cs b/Tests/GameFrameX.Tests/Online/CapabilityLifecycleAndBridgeTests.cs new file mode 100644 index 000000000..c52cea390 --- /dev/null +++ b/Tests/GameFrameX.Tests/Online/CapabilityLifecycleAndBridgeTests.cs @@ -0,0 +1,503 @@ +// ========================================================================================== +// GameFrameX 组织及其衍生项目的版权、商标、专利及其他相关权利 +// GameFrameX organization and its derivative projects' copyrights, trademarks, patents and related rights +// 均受中华人民共和国及相关国际法律法规保护。 +// are protected by the laws of the PRC 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 refer to the LICENSE file at the root 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 the legal 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://www.cnb.cool/GameFrameX +// CNB Repository: https://www.cnb.cool/GameFrameX +// 官方文档:https://gameframex.doc.alianblank.com/ +// Official Docs: https://gameframex.doc.alianblank.com/ +// ========================================================================================== + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using GameFrameX.Capability.Hosting.Abstractions; +using GameFrameX.Capability.Hosting.Core; +using GameFrameX.Foundation.Localization.Core; +using Xunit; +using LocalizationKeys = GameFrameX.Localization.Keys; + +namespace GameFrameX.Tests.Online; + +/// +/// 能力宿主生命周期与能力桥测试(设计 §6.4:激活中途失败逆序回滚并撤桥、停止期逆序停用并自动撤桥、 +/// 撤桥后旧租约显式失败、同契约重复发布 fail-fast)。 +/// +[Collection(CapabilityRuntimeCollection.Name)] +public sealed class CapabilityLifecycleAndBridgeTests +{ + /// 回滚用例:槽位一发布的桥契约。 + public interface IRollbackFirstBridge + { + /// 发布者标记。 + string Owner { get; } + } + + /// 回滚用例:槽位二发布的桥契约。 + public interface IRollbackSecondBridge + { + /// 发布者标记。 + string Owner { get; } + } + + /// 回滚用例:激活失败的槽位三在抛出前发布的桥契约。 + public interface IRollbackThirdBridge + { + /// 发布者标记。 + string Owner { get; } + } + + /// 撤桥用例:停止后旧租约显式失败的桥契约。 + public interface IRetractedLeaseBridge + { + /// 发布者标记。 + string Owner { get; } + } + + /// 停止用例:槽位一发布的桥契约。 + public interface IStopFirstBridge + { + /// 发布者标记。 + string Owner { get; } + } + + /// 停止用例:槽位二发布的桥契约。 + public interface IStopSecondBridge + { + /// 发布者标记。 + string Owner { get; } + } + + /// 重复发布用例:两个槽位争抢发布的同一桥契约。 + public interface IDuplicatePublishBridge + { + /// 发布者标记。 + string Owner { get; } + } + + /// 串行化用例:启动进行中被停止的模块发布的桥契约。 + public interface ISerializedStopBridge + { + /// 发布者标记。 + string Owner { get; } + } + + /// 调度器启动失败回滚用例:已激活模块发布的桥契约。 + public interface ISchedulerRollbackBridge + { + /// 发布者标记。 + string Owner { get; } + } + + /// + /// 桥契约测试实现(同时实现全部测试契约,按发布者标记区分实例)。 + /// + private sealed class BridgeImplementation : IRollbackFirstBridge, IRollbackSecondBridge, IRollbackThirdBridge, IRetractedLeaseBridge, IStopFirstBridge, IStopSecondBridge, IDuplicatePublishBridge, ISerializedStopBridge, ISchedulerRollbackBridge + { + /// + /// 初始化桥实现。 + /// + /// 发布者标记。 + public BridgeImplementation(string owner) + { + Owner = owner; + } + + /// + public string Owner { get; } + } + + /// + /// 构造机制组合选项(合法作用域三元组 + 白名单圈定参与面)。 + /// + /// 装配日志收集器。 + /// 白名单。 + /// 组合选项。 + private static CapabilityCompositionOptions CreateOptions(CollectingCapabilityAssemblyLog log, string[] enabledIds) + { + return new CapabilityCompositionOptions(default, log) + { + HostOptions = new CapabilityHostOptions { TenantId = 1, AppId = 1, ServerId = 1, }, + Filter = new CapabilityAssemblyFilter(enabledIds), + }; + } + + /// + /// 以机制测试族执行一次真实组合。 + /// + /// 组合选项。 + /// 组合产物。 + private static CapabilityRuntime Compose(CapabilityCompositionOptions options) + { + return CapabilityComposition.Compose(new MechanismTestFamily(), options); + } + + /// + /// 激活中途失败:First ← Second ← Third 链式依赖,Third 在发布自己的桥后抛出—— + /// 失败的 Third 自身先 Deactivate 一次,已激活的 Second、First 再按逆序 Deactivate,三个模块发布的桥全部撤销 + /// (TryAcquire 为 false),StartAsync 重抛原异常,并记一条激活失败回滚 Error。 + /// + [Fact] + public async Task StartAsync_ActivationFailsMidway_RollsBackInReverseAndRetractsBridges() + { + MechanismScenario.Reset(); + CapabilityRuntime runtime = null; + try + { + var deactivated = new List(); + var activated = new List(); + MechanismScenario.First.Activate = (context, cancellationToken) => + { + activated.Add("mech-first"); + context.Bridges.Publish(new BridgeImplementation("mech-first")); + return Task.CompletedTask; + }; + MechanismScenario.First.Deactivate = cancellationToken => deactivated.Add("mech-first"); + MechanismScenario.Second.Requires = new[] { "mech-first", }; + MechanismScenario.Second.Activate = (context, cancellationToken) => + { + activated.Add("mech-second"); + context.Bridges.Publish(new BridgeImplementation("mech-second")); + return Task.CompletedTask; + }; + MechanismScenario.Second.Deactivate = cancellationToken => deactivated.Add("mech-second"); + MechanismScenario.Third.Requires = new[] { "mech-second", }; + MechanismScenario.Third.Activate = (context, cancellationToken) => + { + activated.Add("mech-third"); + context.Bridges.Publish(new BridgeImplementation("mech-third")); + throw new InvalidOperationException("mech-third activation failed"); + }; + MechanismScenario.Third.Deactivate = cancellationToken => deactivated.Add("mech-third"); + var log = new CollectingCapabilityAssemblyLog(); + + runtime = Compose(CreateOptions(log, new[] { "mech-first", "mech-second", "mech-third", })); + Assert.Equal(new[] { "mech-first", "mech-second", "mech-third", }, runtime.Report.EnabledIdsInOrder); + + var exception = await Assert.ThrowsAsync(() => runtime.StartAsync()); + + Assert.Equal("mech-third activation failed", exception.Message); + Assert.Equal(new[] { "mech-first", "mech-second", "mech-third", }, activated); + Assert.Equal(new[] { "mech-third", "mech-second", "mech-first", }, deactivated); + Assert.False(CapabilityBridge.TryAcquire(out _)); + Assert.False(CapabilityBridge.TryAcquire(out _)); + Assert.False(CapabilityBridge.TryAcquire(out _)); + Assert.Contains(string.Format(LocalizationService.GetString(LocalizationKeys.Capability.Hosting.ActivationFailed), "mech-third", 2), log.Errors); + } + finally + { + if (runtime != null) + { + await runtime.StopAsync(); + } + + MechanismScenario.Reset(); + } + } + + /// + /// 撤桥后旧租约显式失败:启动后取得的租约可正常访问契约;宿主停止后同一租约访问 + /// 抛 + /// (不回旧数据、不降级),重新 TryAcquire 返回 false。 + /// + [Fact] + public async Task Lease_AfterStopAsync_ContractThrowsBridgeUnavailable() + { + MechanismScenario.Reset(); + CapabilityRuntime runtime = null; + try + { + var implementation = new BridgeImplementation("mech-first"); + MechanismScenario.First.Activate = (context, cancellationToken) => + { + context.Bridges.Publish(implementation); + return Task.CompletedTask; + }; + + runtime = Compose(CreateOptions(new CollectingCapabilityAssemblyLog(), new[] { "mech-first", })); + Assert.False(CapabilityBridge.TryAcquire(out _)); + + await runtime.StartAsync(); + + Assert.True(CapabilityBridge.TryAcquire(out var lease)); + Assert.Same(implementation, lease.Contract); + + await runtime.StopAsync(); + + var exception = Assert.Throws(() => lease.Contract); + Assert.Equal(typeof(IRetractedLeaseBridge).FullName, exception.ContractTypeName); + Assert.False(CapabilityBridge.TryAcquire(out var afterStop)); + Assert.Null(afterStop); + } + finally + { + if (runtime != null) + { + await runtime.StopAsync(); + } + + MechanismScenario.Reset(); + } + } + + /// + /// 停止期编排:逆拓扑序 Deactivate,机制自动撤销全部模块发布的桥(模块与组合根无撤销入口)。 + /// + [Fact] + public async Task StopAsync_DeactivatesInReverseOrderAndRetractsAllBridges() + { + MechanismScenario.Reset(); + CapabilityRuntime runtime = null; + try + { + var deactivated = new List(); + MechanismScenario.First.Activate = (context, cancellationToken) => + { + context.Bridges.Publish(new BridgeImplementation("mech-first")); + return Task.CompletedTask; + }; + MechanismScenario.First.Deactivate = cancellationToken => deactivated.Add("mech-first"); + MechanismScenario.Second.Requires = new[] { "mech-first", }; + MechanismScenario.Second.Activate = (context, cancellationToken) => + { + context.Bridges.Publish(new BridgeImplementation("mech-second")); + return Task.CompletedTask; + }; + MechanismScenario.Second.Deactivate = cancellationToken => deactivated.Add("mech-second"); + + runtime = Compose(CreateOptions(new CollectingCapabilityAssemblyLog(), new[] { "mech-first", "mech-second", })); + await runtime.StartAsync(); + + Assert.True(CapabilityBridge.TryAcquire(out var firstLease)); + Assert.Equal("mech-first", firstLease.Contract.Owner); + Assert.True(CapabilityBridge.TryAcquire(out var secondLease)); + Assert.Equal("mech-second", secondLease.Contract.Owner); + Assert.Empty(deactivated); + + await runtime.StopAsync(); + + Assert.Equal(new[] { "mech-second", "mech-first", }, deactivated); + Assert.False(CapabilityBridge.TryAcquire(out _)); + Assert.False(CapabilityBridge.TryAcquire(out _)); + Assert.Throws(() => firstLease.Contract); + Assert.Throws(() => secondLease.Contract); + } + finally + { + if (runtime != null) + { + await runtime.StopAsync(); + } + + MechanismScenario.Reset(); + } + } + + /// + /// 同契约重复发布 fail-fast:两个槽位在 Activate 中发布同一契约,StartAsync 抛 InvalidOperationException + /// (消息含契约类型与前后两个发布者);失败的后发布者自身先 Deactivate,先发布者随激活回滚 Deactivate,其桥一并撤销。 + /// + [Fact] + public async Task StartAsync_SameContractPublishedTwice_FailsFast() + { + MechanismScenario.Reset(); + CapabilityRuntime runtime = null; + try + { + var deactivated = new List(); + MechanismScenario.First.Activate = (context, cancellationToken) => + { + context.Bridges.Publish(new BridgeImplementation("mech-first")); + return Task.CompletedTask; + }; + MechanismScenario.First.Deactivate = cancellationToken => deactivated.Add("mech-first"); + MechanismScenario.Second.Requires = new[] { "mech-first", }; + MechanismScenario.Second.Activate = (context, cancellationToken) => + { + context.Bridges.Publish(new BridgeImplementation("mech-second")); + return Task.CompletedTask; + }; + MechanismScenario.Second.Deactivate = cancellationToken => deactivated.Add("mech-second"); + + runtime = Compose(CreateOptions(new CollectingCapabilityAssemblyLog(), new[] { "mech-first", "mech-second", })); + + var exception = await Assert.ThrowsAsync(() => runtime.StartAsync()); + + Assert.Equal(string.Format(LocalizationService.GetString(LocalizationKeys.Capability.Hosting.BridgeDuplicatePublish), typeof(IDuplicatePublishBridge).FullName, "mech-first", "mech-second"), exception.Message); + Assert.Equal(new[] { "mech-second", "mech-first", }, deactivated); + Assert.False(CapabilityBridge.TryAcquire(out _)); + } + finally + { + if (runtime != null) + { + await runtime.StopAsync(); + } + + MechanismScenario.Reset(); + } + } + + /// + /// 激活失败且失败模块自身的 Deactivate 也抛出:停用异常只记 DeactivationFailed Error, + /// StartAsync 重抛的仍是原激活异常,已激活模块照常逆序回滚。 + /// + [Fact] + public async Task StartAsync_FailedModuleDeactivateThrows_OriginalExceptionPreserved() + { + MechanismScenario.Reset(); + CapabilityRuntime runtime = null; + try + { + var deactivated = new List(); + MechanismScenario.First.Deactivate = cancellationToken => deactivated.Add("mech-first"); + MechanismScenario.Second.Requires = new[] { "mech-first", }; + MechanismScenario.Second.Activate = (context, cancellationToken) => throw new InvalidOperationException("mech-second activation failed"); + MechanismScenario.Second.Deactivate = cancellationToken => + { + deactivated.Add("mech-second"); + throw new InvalidOperationException("mech-second deactivation failed"); + }; + var log = new CollectingCapabilityAssemblyLog(); + + runtime = Compose(CreateOptions(log, new[] { "mech-first", "mech-second", })); + + var exception = await Assert.ThrowsAsync(() => runtime.StartAsync()); + + Assert.Equal("mech-second activation failed", exception.Message); + Assert.Equal(new[] { "mech-second", "mech-first", }, deactivated); + Assert.Contains(string.Format(LocalizationService.GetString(LocalizationKeys.Capability.Hosting.DeactivationFailed), "mech-second", "mech-second deactivation failed"), log.Errors); + } + finally + { + if (runtime != null) + { + await runtime.StopAsync(); + } + + MechanismScenario.Reset(); + } + } + + /// + /// 启动与停止串行化:激活进行中调用 StopAsync,停止等待启动结束后才执行—— + /// 激活未放行前停止不完成;放行后模块先完成激活、再被 Deactivate,激活期发布的桥最终全部撤销(无泄漏桥)。 + /// + [Fact] + public async Task StopAsync_DuringStartAsync_WaitsForStartThenStops() + { + MechanismScenario.Reset(); + CapabilityRuntime runtime = null; + try + { + var events = new List(); + var activationEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var releaseActivation = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + MechanismScenario.First.Activate = async (context, cancellationToken) => + { + context.Bridges.Publish(new BridgeImplementation("mech-first")); + activationEntered.SetResult(true); + await releaseActivation.Task; + lock (events) + { + events.Add("activated"); + } + }; + MechanismScenario.First.Deactivate = cancellationToken => + { + lock (events) + { + events.Add("deactivated"); + } + }; + + runtime = Compose(CreateOptions(new CollectingCapabilityAssemblyLog(), new[] { "mech-first", })); + var startTask = runtime.StartAsync(); + await activationEntered.Task; + + var stopTask = runtime.StopAsync(); + await Task.Delay(200); + + Assert.False(stopTask.IsCompleted); + Assert.Empty(events); + + releaseActivation.SetResult(true); + await startTask; + await stopTask; + + Assert.Equal(new[] { "activated", "deactivated", }, events); + Assert.False(CapabilityBridge.TryAcquire(out _)); + } + finally + { + if (runtime != null) + { + await runtime.StopAsync(); + } + + MechanismScenario.Reset(); + } + } + + /// + /// 调度器启动失败纳入回滚:全部模块激活成功后调度器启动抛出(调度器已被停止)—— + /// 已激活模块逆序 Deactivate、桥全部撤销、记一条调度器启动失败回滚 Error,StartAsync 重抛原异常。 + /// + [Fact] + public async Task StartAsync_SchedulerStartFails_RollsBackActivatedModules() + { + MechanismScenario.Reset(); + CapabilityRuntime runtime = null; + try + { + var deactivated = new List(); + MechanismScenario.First.Activate = (context, cancellationToken) => + { + context.Bridges.Publish(new BridgeImplementation("mech-first")); + return Task.CompletedTask; + }; + MechanismScenario.First.Deactivate = cancellationToken => deactivated.Add("mech-first"); + MechanismScenario.Second.Requires = new[] { "mech-first", }; + MechanismScenario.Second.Deactivate = cancellationToken => deactivated.Add("mech-second"); + var log = new CollectingCapabilityAssemblyLog(); + + runtime = Compose(CreateOptions(log, new[] { "mech-first", "mech-second", })); + await runtime.Host.Scheduler.StopAsync(); + + var exception = await Assert.ThrowsAsync(() => runtime.StartAsync()); + + Assert.Equal(LocalizationService.GetString(LocalizationKeys.Capability.Hosting.SchedulerRestartAfterStop), exception.Message); + Assert.Equal(new[] { "mech-second", "mech-first", }, deactivated); + Assert.False(CapabilityBridge.TryAcquire(out _)); + Assert.Contains(string.Format(LocalizationService.GetString(LocalizationKeys.Capability.Hosting.SchedulerStartFailed), 2), log.Errors); + } + finally + { + if (runtime != null) + { + await runtime.StopAsync(); + } + + MechanismScenario.Reset(); + } + } +} diff --git a/Tests/GameFrameX.Tests/Online/CapabilityOptionsBindingTests.cs b/Tests/GameFrameX.Tests/Online/CapabilityOptionsBindingTests.cs new file mode 100644 index 000000000..133388a03 --- /dev/null +++ b/Tests/GameFrameX.Tests/Online/CapabilityOptionsBindingTests.cs @@ -0,0 +1,468 @@ +// ========================================================================================== +// GameFrameX 组织及其衍生项目的版权、商标、专利及其他相关权利 +// GameFrameX organization and its derivative projects' copyrights, trademarks, patents and related rights +// 均受中华人民共和国及相关国际法律法规保护。 +// are protected by the laws of the PRC 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 refer to the LICENSE file at the root 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 the legal 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://www.cnb.cool/GameFrameX +// CNB Repository: https://www.cnb.cool/GameFrameX +// 官方文档:https://gameframex.doc.alianblank.com/ +// Official Docs: https://gameframex.doc.alianblank.com/ +// ========================================================================================== + +using System; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using GameFrameX.Capability.Hosting.Abstractions; +using GameFrameX.Capability.Hosting.Core; +using Xunit; + +namespace GameFrameX.Tests.Online; + +/// +/// 能力 Options 绑定机制测试(C294:三层合并「包内默认 < 族配置段 Settings.<能力Id> < 代码 Configure<T>」、 +/// 报告逐字段来源、装配期 fail-fast(未发现能力 / 未知字段 / 类型不符 / 嵌套路径 / Validate / Configure 目标缺失)、 +/// 被过滤能力的设置段忽略并标注)。 +/// +[Collection(CapabilityRuntimeCollection.Name)] +public class CapabilityOptionsBindingTests +{ + /// 参与绑定的槽位能力 Id(槽位一)。 + private const string BoundCapabilityId = "mech-first"; + + /// 被过滤槽位能力 Id(槽位二)。 + private const string FilteredCapabilityId = "mech-second"; + + /// 错误信息中的配置段路径前缀(族配置段名 + Settings + 能力 Id)。 + private const string ErrorPathPrefix = "MechanismTests.Settings.mech-first"; + + /// 槽位 Register 期捕获的合并校验后 Options 实例。 + private static CapabilityBindingTestOptions _capturedOptions; + + /// + /// 解析族配置段 JSON(作为 构造首参)。 + /// + /// 族配置段 JSON。 + /// 独立于文档生命周期的配置段节点。 + private static JsonElement FamilySection(string json) + { + using (var document = JsonDocument.Parse(json)) + { + return document.RootElement.Clone(); + } + } + + /// + /// 构造组合选项(族配置段 + 收集器日志 + 合法作用域 + 白名单)。 + /// + /// 族配置段 JSON(null = 无配置段)。 + /// 参与组合的槽位能力 Id。 + /// 组合选项。 + private static CapabilityCompositionOptions CreateOptions(string familySectionJson, params string[] enabledIds) + { + var section = familySectionJson == null ? default : FamilySection(familySectionJson); + return new CapabilityCompositionOptions(section, new CollectingCapabilityAssemblyLog()) + { + HostOptions = new CapabilityHostOptions { TenantId = 1, AppId = 1, ServerId = 1 }, + Filter = new CapabilityAssemblyFilter(enabledIds), + }; + } + + /// + /// 槽位一声明测试 Options 根类型,并在 Register 期把合并结果写入静态捕获位。 + /// + private static void DeclareBoundOptionsOnFirstSlot() + { + MechanismScenario.First.OptionsType = typeof(CapabilityBindingTestOptions); + MechanismScenario.First.Register = context => _capturedOptions = context.GetOptions(); + } + + /// + /// 组合机制测试族。 + /// + /// 组合选项。 + /// 组合产物。 + private static CapabilityRuntime Compose(CapabilityCompositionOptions options) + { + return CapabilityComposition.Compose(new MechanismTestFamily(), options); + } + + /// + /// 复位用例静态状态(槽位 + 捕获位)。 + /// + private static void ResetScenario() + { + MechanismScenario.Reset(); + _capturedOptions = null; + } + + /// + /// 查找报告条目。 + /// + /// 组合产物。 + /// 能力 Id。 + /// 报告条目。 + private static CapabilityReportEntry FindEntry(CapabilityRuntime runtime, string capabilityId) + { + return runtime.Report.Entries.Single(entry => entry.Manifest.Id == capabilityId); + } + + /// + /// 三层合并优先级:未写字段保持包内默认(Alpha);配置段覆盖默认(Beta);代码 Configure 覆盖默认(Gamma 之外) + /// 且在同一字段上胜过配置段(Gamma:配置段 25 → 代码 30);嵌套段未写字段保持默认。 + /// + [Fact] + public async Task Compose_ThreeLayerPrecedence_DefaultThenSectionThenCode() + { + CapabilityRuntime runtime = null; + try + { + DeclareBoundOptionsOnFirstSlot(); + var options = CreateOptions("{\"Settings\":{\"mech-first\":{\"Beta\":20,\"Gamma\":25}}}", BoundCapabilityId); + options.Configure(bound => bound.Gamma = 30); + + runtime = Compose(options); + + Assert.NotNull(_capturedOptions); + Assert.Equal(1, _capturedOptions.Alpha); + Assert.Equal(20, _capturedOptions.Beta); + Assert.Equal(30, _capturedOptions.Gamma); + Assert.Equal(4, _capturedOptions.Nested.Depth); + } + finally + { + if (runtime != null) + { + await runtime.StopAsync(); + } + + ResetScenario(); + } + } + + /// + /// 报告逐字段来源:未改写 = Default、配置段改写(含嵌套字段)= ConfigurationSection、代码改写 = Code; + /// 生效值为合并校验后的最终值。 + /// + [Fact] + public async Task Compose_ReportSettingsFields_CarryPerFieldSource() + { + CapabilityRuntime runtime = null; + try + { + DeclareBoundOptionsOnFirstSlot(); + var options = CreateOptions("{\"Settings\":{\"mech-first\":{\"Beta\":20,\"Nested\":{\"Depth\":8}}}}", BoundCapabilityId); + options.Configure(bound => bound.Gamma = 30); + + runtime = Compose(options); + + var fields = FindEntry(runtime, BoundCapabilityId).SettingsFields.ToDictionary(field => field.FieldPath, StringComparer.Ordinal); + Assert.Equal(4, fields.Count); + + Assert.Equal(CapabilitySettingsFieldSource.Default, fields["Alpha"].Source); + Assert.Equal((object)1, fields["Alpha"].EffectiveValue); + + Assert.Equal(CapabilitySettingsFieldSource.ConfigurationSection, fields["Beta"].Source); + Assert.Equal((object)20, fields["Beta"].EffectiveValue); + + Assert.Equal(CapabilitySettingsFieldSource.Code, fields["Gamma"].Source); + Assert.Equal((object)30, fields["Gamma"].EffectiveValue); + + Assert.Equal(CapabilitySettingsFieldSource.ConfigurationSection, fields["Nested.Depth"].Source); + Assert.Equal((object)8, fields["Nested.Depth"].EffectiveValue); + } + finally + { + if (runtime != null) + { + await runtime.StopAsync(); + } + + ResetScenario(); + } + } + + /// + /// Settings 出现未发现的能力 Id:装配期 fail-fast,错误信息带该 Id(拼错能力名不会被静默忽略)。 + /// + [Fact] + public void Compose_SettingsForUndiscoveredCapability_FailsFast() + { + try + { + DeclareBoundOptionsOnFirstSlot(); + var options = CreateOptions("{\"Settings\":{\"mech-undiscovered\":{\"Beta\":20}}}", BoundCapabilityId); + + var exception = Assert.Throws(() => Compose(options)); + + Assert.Contains("mech-undiscovered", exception.Message); + Assert.Null(_capturedOptions); + } + finally + { + ResetScenario(); + } + } + + /// + /// Settings 写了 Options 根类型不存在的字段:装配期 fail-fast,错误信息带完整配置段路径。 + /// + [Fact] + public void Compose_SettingsUnknownField_FailsFastWithFullPath() + { + try + { + DeclareBoundOptionsOnFirstSlot(); + var options = CreateOptions("{\"Settings\":{\"mech-first\":{\"Delta\":1}}}", BoundCapabilityId); + + var exception = Assert.Throws(() => Compose(options)); + + Assert.Contains(ErrorPathPrefix + ".Delta", exception.Message); + Assert.Null(_capturedOptions); + } + finally + { + ResetScenario(); + } + } + + /// + /// Settings 字段类型不符(字符串写入 int 字段):装配期 fail-fast,错误信息带完整路径与期望类型。 + /// + [Fact] + public void Compose_SettingsTypeMismatch_FailsFastWithFullPath() + { + try + { + DeclareBoundOptionsOnFirstSlot(); + var options = CreateOptions("{\"Settings\":{\"mech-first\":{\"Alpha\":\"one\"}}}", BoundCapabilityId); + + var exception = Assert.Throws(() => Compose(options)); + + Assert.Contains(ErrorPathPrefix + ".Alpha", exception.Message); + Assert.Contains(typeof(int).FullName, exception.Message); + Assert.Null(_capturedOptions); + } + finally + { + ResetScenario(); + } + } + + /// + /// 嵌套对象路径:嵌套段内未知字段与类型不符的错误信息都带「配置段前缀 + 父字段 + 子字段」完整路径。 + /// + [Fact] + public void Compose_SettingsNestedObjectErrors_CarryNestedPath() + { + try + { + DeclareBoundOptionsOnFirstSlot(); + var unknownNestedField = CreateOptions("{\"Settings\":{\"mech-first\":{\"Nested\":{\"Depht\":5}}}}", BoundCapabilityId); + + var unknownNestedFieldError = Assert.Throws(() => Compose(unknownNestedField)); + + Assert.Contains(ErrorPathPrefix + ".Nested.Depht", unknownNestedFieldError.Message); + + var nestedTypeMismatch = CreateOptions("{\"Settings\":{\"mech-first\":{\"Nested\":{\"Depth\":\"deep\"}}}}", BoundCapabilityId); + + var nestedTypeMismatchError = Assert.Throws(() => Compose(nestedTypeMismatch)); + + Assert.Contains(ErrorPathPrefix + ".Nested.Depth", nestedTypeMismatchError.Message); + Assert.Null(_capturedOptions); + } + finally + { + ResetScenario(); + } + } + + /// + /// Validate 不通过(RequirePositive):校验的是合并后的最终值(配置段写 0 + 代码写负数), + /// 装配期 fail-fast 且全部问题一次报全(带能力 Id 与字段路径)。 + /// + [Fact] + public void Compose_ValidateFailure_ReportsAllProblemsAtOnce() + { + try + { + DeclareBoundOptionsOnFirstSlot(); + var options = CreateOptions("{\"Settings\":{\"mech-first\":{\"Beta\":0}}}", BoundCapabilityId); + options.Configure(bound => bound.Gamma = -5); + + var exception = Assert.Throws(() => Compose(options)); + + Assert.Contains(BoundCapabilityId, exception.Message); + Assert.Contains("Beta: must be positive (actual: 0)", exception.Message); + Assert.Contains("Gamma: must be positive (actual: -5)", exception.Message); + Assert.DoesNotContain("Alpha:", exception.Message); + Assert.Null(_capturedOptions); + } + finally + { + ResetScenario(); + } + } + + /// + /// Configure<T> 指向没有任何已发现能力声明的 Options 类型:装配期 fail-fast,错误信息带类型全名。 + /// + [Fact] + public void Compose_ConfigureForUndeclaredOptionsType_FailsFast() + { + try + { + var options = CreateOptions(null, BoundCapabilityId); + options.Configure(bound => bound.Alpha = 10); + + var exception = Assert.Throws(() => Compose(options)); + + Assert.Contains(typeof(CapabilityBindingTestOptions).FullName, exception.Message); + } + finally + { + ResetScenario(); + } + } + + /// + /// 被过滤能力的 Settings 与 Configure:设置段整体忽略(即便含未知字段也不校验)、Configure 回调不执行, + /// 报告条目标注 SettingsConfiguredButNotAssembled 且无字段条目;启用能力不受影响且不标注。 + /// + [Fact] + public async Task Compose_SettingsForFilteredCapability_IgnoredAndMarked() + { + CapabilityRuntime runtime = null; + try + { + var configureInvoked = false; + MechanismScenario.Second.OptionsType = typeof(CapabilityBindingTestOptions); + MechanismScenario.Second.Register = context => _capturedOptions = context.GetOptions(); + var options = CreateOptions("{\"Settings\":{\"mech-second\":{\"Beta\":99,\"NoSuchField\":1}}}", BoundCapabilityId); + options.Configure(bound => configureInvoked = true); + + runtime = Compose(options); + + Assert.False(configureInvoked); + Assert.Null(_capturedOptions); + + var filteredEntry = FindEntry(runtime, FilteredCapabilityId); + Assert.Equal(CapabilityFilterState.NotInEnableList, filteredEntry.State); + Assert.True(filteredEntry.SettingsConfiguredButNotAssembled); + Assert.Empty(filteredEntry.SettingsFields); + + var enabledEntry = FindEntry(runtime, BoundCapabilityId); + Assert.Equal(CapabilityFilterState.Enabled, enabledEntry.State); + Assert.False(enabledEntry.SettingsConfiguredButNotAssembled); + } + finally + { + if (runtime != null) + { + await runtime.StopAsync(); + } + + ResetScenario(); + } + } + + /// + /// 嵌套 Options 子对象写 JSON null:按类型不符装配期 fail-fast(带完整路径与实际类型 null), + /// 不再把子对象置空后让 Validate / 模块解引用到空引用。 + /// + [Fact] + public void Compose_SettingsNullForNestedObject_FailsFastWithTypeMismatch() + { + try + { + DeclareBoundOptionsOnFirstSlot(); + var options = CreateOptions("{\"Settings\":{\"mech-first\":{\"Nested\":null}}}", BoundCapabilityId); + + var exception = Assert.Throws(() => Compose(options)); + + Assert.Contains(ErrorPathPrefix + ".Nested", exception.Message); + Assert.Contains(typeof(CapabilityBindingTestOptions.NestedSection).FullName, exception.Message); + Assert.Contains("null", exception.Message); + Assert.Null(_capturedOptions); + } + finally + { + ResetScenario(); + } + } + + /// + /// 能力未声明 Options 根类型却写了带字段的配置段:装配期 fail-fast,错误信息带「前缀.首个字段名」; + /// 空对象 {} 视为未配置,组合照常成功。 + /// + [Fact] + public async Task Compose_SettingsForCapabilityWithoutOptionsType_FailsFastUnlessEmpty() + { + CapabilityRuntime runtime = null; + try + { + var withField = CreateOptions("{\"Settings\":{\"mech-first\":{\"SomeKey\":1}}}", BoundCapabilityId); + + var exception = Assert.Throws(() => Compose(withField)); + + Assert.Contains(ErrorPathPrefix + ".SomeKey", exception.Message); + + runtime = Compose(CreateOptions("{\"Settings\":{\"mech-first\":{}}}", BoundCapabilityId)); + + Assert.Empty(FindEntry(runtime, BoundCapabilityId).SettingsFields); + } + finally + { + if (runtime != null) + { + await runtime.StopAsync(); + } + + ResetScenario(); + } + } + + /// + /// 族配置段的 Settings 节点不是对象(null / 数组):装配期按类型不符 fail-fast,错误信息带「族段名.Settings」路径, + /// 不再抛出无路径的 JSON 元素类型异常。 + /// + [Fact] + public void Compose_SettingsNodeNotObject_FailsFastWithSectionPath() + { + try + { + DeclareBoundOptionsOnFirstSlot(); + + var nullSettings = Assert.Throws(() => Compose(CreateOptions("{\"Settings\":null}", BoundCapabilityId))); + var arraySettings = Assert.Throws(() => Compose(CreateOptions("{\"Settings\":[]}", BoundCapabilityId))); + + Assert.Contains("MechanismTests.Settings", nullSettings.Message); + Assert.Contains("Null", nullSettings.Message); + Assert.Contains("MechanismTests.Settings", arraySettings.Message); + Assert.Contains("Array", arraySettings.Message); + Assert.Null(_capturedOptions); + } + finally + { + ResetScenario(); + } + } +} diff --git a/Tests/GameFrameX.Tests/Online/CapabilityPackageStructureTests.cs b/Tests/GameFrameX.Tests/Online/CapabilityPackageStructureTests.cs index d7f7c8a8a..cf9b85b1b 100644 --- a/Tests/GameFrameX.Tests/Online/CapabilityPackageStructureTests.cs +++ b/Tests/GameFrameX.Tests/Online/CapabilityPackageStructureTests.cs @@ -33,23 +33,26 @@ namespace GameFrameX.Tests.Online { /// - /// 能力包结构门禁测试(C228 P1:C223 目录矩阵的包级升维)——包间 ProjectReference 方向断言 - /// (精确引用集合 = 无多引无漏引)+ Hosting 三包对能力包引用恒零(机制开放红线)+ + /// 能力包结构门禁测试(C228 P1:C223 目录矩阵的包级升维;C294 能力注入契约终态重冻)——包间 ProjectReference 方向断言 + /// (精确引用集合 = 无多引无漏引)+ 能力包间引用 ⊆ manifest 声明依赖 + Hosting 三包对能力包引用恒零(机制开放红线)+ /// 脊柱包零上行引用 + 工程引用图无环。 /// public class CapabilityPackageStructureTests { /// - /// 期望的 ProjectReference 精确集合(工程 → 允许引用的工程名集合;C228 P1 终态 + C231 P3 增量:三叶子包 + 脊柱 admin 线缆契约边 + 单体/壳叶子引用)。 + /// 期望的 ProjectReference 精确集合(工程 → 允许引用的工程名集合;C294 终态,逐条取自 Capability/*/ *.csproj: + /// Hosting.Abstractions → Localization;Hosting.AdminApi → Abstractions + Localization + Hosting.Core; + /// Online.Abstractions → Localization + Hosting.AdminApi;Social → Apps + DataBase;Match → Core + Proto)。 + /// private static readonly Dictionary ExpectedProjectReferences = new() { - ["GameFrameX.Capability.Hosting.Abstractions"] = Array.Empty(), + ["GameFrameX.Capability.Hosting.Abstractions"] = new[] { "GameFrameX.Localization" }, ["GameFrameX.Capability.Hosting.Core"] = new[] { "GameFrameX.Capability.Hosting.Abstractions", "GameFrameX.Localization" }, - ["GameFrameX.Capability.Hosting.AdminApi"] = new[] { "GameFrameX.Capability.Hosting.Abstractions", "GameFrameX.Localization" }, + ["GameFrameX.Capability.Hosting.AdminApi"] = new[] { "GameFrameX.Capability.Hosting.Abstractions", "GameFrameX.Localization", "GameFrameX.Capability.Hosting.Core" }, ["GameFrameX.Capability.Online.Assets"] = new[] { "GameFrameX.Capability.Online.Abstractions", "GameFrameX.Capability.Hosting.Core" }, - ["GameFrameX.Capability.Online.Social"] = new[] { "GameFrameX.Capability.Online.Abstractions", "GameFrameX.Capability.Online.Foundation", "GameFrameX.Capability.Hosting.Core" }, + ["GameFrameX.Capability.Online.Social"] = new[] { "GameFrameX.Capability.Online.Abstractions", "GameFrameX.Capability.Online.Foundation", "GameFrameX.Capability.Hosting.Core", "GameFrameX.DataBase", "GameFrameX.Apps" }, ["GameFrameX.Capability.Online.Matchmaking"] = new[] { "GameFrameX.Capability.Online.Abstractions", "GameFrameX.Capability.Hosting.Core" }, - ["GameFrameX.Capability.Online.Match"] = new[] { "GameFrameX.Capability.Online.Abstractions", "GameFrameX.Capability.Online.Assets", "GameFrameX.Capability.Online.Matchmaking", "GameFrameX.Capability.Hosting.Core" }, + ["GameFrameX.Capability.Online.Match"] = new[] { "GameFrameX.Capability.Online.Abstractions", "GameFrameX.Capability.Online.Assets", "GameFrameX.Core", "GameFrameX.Proto", "GameFrameX.Capability.Online.Matchmaking", "GameFrameX.Capability.Hosting.Core" }, ["GameFrameX.Capability.Online.Competition"] = new[] { "GameFrameX.Capability.Online.Abstractions", "GameFrameX.Capability.Online.Assets", "GameFrameX.Capability.Online.Match", "GameFrameX.Capability.Hosting.Core" }, ["GameFrameX.Capability.Online.Operations"] = new[] { "GameFrameX.Capability.Online.Abstractions", "GameFrameX.Capability.Online.Foundation", "GameFrameX.Capability.Online.Assets", "GameFrameX.Capability.Online.Social", "GameFrameX.Capability.Online.Matchmaking", "GameFrameX.Capability.Online.Match", "GameFrameX.Capability.Hosting.Core" }, ["GameFrameX.Capability.Online.Abstractions"] = new[] { "GameFrameX.Localization", "GameFrameX.Capability.Hosting.AdminApi" }, @@ -57,6 +60,21 @@ public class CapabilityPackageStructureTests ["GameFrameX.Capability.Online.Persistence"] = new[] { "GameFrameX.Capability.Online.Abstractions", "GameFrameX.Capability.Online.Foundation", "GameFrameX.Capability.Online.Assets", "GameFrameX.Capability.Online.Social", "GameFrameX.Capability.Hosting.Core", "GameFrameX.DataBase" }, }; + /// + /// 七个 Online 能力包与其能力清单(包名 → 清单实例;清单依赖声明运行期取自各模块 ManifestInstance, + /// 只冻结能力 Id ↔ 包名映射)。Abstractions / Persistence / Hosting 不是能力包,不在此表。 + /// + private static readonly (string PackageName, string CapabilityId, GameFrameX.Capability.Hosting.Abstractions.CapabilityManifest Manifest)[] CapabilityPackages = + { + ("GameFrameX.Capability.Online.Foundation", "online-foundation", GameFrameX.Capability.Online.Foundation.OnlineFoundationCapabilityModule.ManifestInstance), + ("GameFrameX.Capability.Online.Assets", "online-assets", GameFrameX.Capability.Online.Assets.OnlineAssetsCapabilityModule.ManifestInstance), + ("GameFrameX.Capability.Online.Social", "online-social", GameFrameX.Capability.Online.Social.OnlineSocialCapabilityModule.ManifestInstance), + ("GameFrameX.Capability.Online.Matchmaking", "online-matchmaking", GameFrameX.Capability.Online.Matchmaking.OnlineMatchmakingCapabilityModule.ManifestInstance), + ("GameFrameX.Capability.Online.Match", "online-match", GameFrameX.Capability.Online.Match.OnlineMatchCapabilityModule.ManifestInstance), + ("GameFrameX.Capability.Online.Competition", "online-competition", GameFrameX.Capability.Online.Competition.OnlineCompetitionCapabilityModule.ManifestInstance), + ("GameFrameX.Capability.Online.Operations", "online-operations", GameFrameX.Capability.Online.Operations.OnlineOperationsCapabilityModule.ManifestInstance), + }; + /// /// Hosting 机制包名集合(对能力包引用恒零的红线主体)。 /// @@ -103,6 +121,47 @@ public void ProjectReferences_MatchDeclaredPackageGraph() Assert.True(violations.Count == 0, "包引用图偏离声明拓扑:" + Environment.NewLine + string.Join(Environment.NewLine, violations)); } + /// + /// 断言每个 Online 能力包对其它能力包的 ProjectReference ⊆ 其 manifest 的必选 ∪ 可选依赖 + /// (编译期引用面不得超出装配期声明的依赖面——否则可选依赖缺席时仍有硬编译耦合,依赖检查形同虚设)。 + /// 能力 Id ↔ 包名映射冻结;依赖集合运行期读 ManifestInstance,不保留硬编码副本。 + /// + [Fact] + public void CapabilityPackageReferences_AreSubsetOfManifestDependencies() + { + var packageByCapabilityId = new Dictionary(StringComparer.Ordinal); + foreach (var (packageName, capabilityId, manifest) in CapabilityPackages) + { + Assert.Equal(capabilityId, manifest.Id); + packageByCapabilityId.Add(manifest.Id, packageName); + } + + var capabilityPackageNames = new HashSet(packageByCapabilityId.Values, StringComparer.Ordinal); + var violations = new List(); + foreach (var (packageName, _, manifest) in CapabilityPackages) + { + var declaredIds = manifest.Requires.Concat(manifest.Optional.Select(dependency => dependency.CapabilityId)).ToList(); + var unmappedIds = declaredIds.Where(id => !packageByCapabilityId.ContainsKey(id)).ToList(); + if (unmappedIds.Count > 0) + { + violations.Add($"{packageName} 的 manifest 声明了未映射到能力包的依赖:{string.Join(", ", unmappedIds)}"); + } + + var declaredPackages = new HashSet(declaredIds.Where(packageByCapabilityId.ContainsKey).Select(id => packageByCapabilityId[id]), StringComparer.Ordinal); + var undeclared = ReadProjectReferences(packageName) + .Where(capabilityPackageNames.Contains) + .Where(reference => !declaredPackages.Contains(reference)) + .OrderBy(reference => reference, StringComparer.Ordinal) + .ToList(); + if (undeclared.Count > 0) + { + violations.Add($"{packageName}({manifest.Id})引用了未在 requires ∪ optional 声明的能力包:{string.Join(", ", undeclared)}"); + } + } + + Assert.True(violations.Count == 0, "能力包编译期引用超出 manifest 依赖声明:" + Environment.NewLine + string.Join(Environment.NewLine, violations)); + } + /// /// 断言组合根(Launcher)直引全部 Online 能力包(C232 P6 终态:单体与 Runtime 壳退役, /// 编排回归 Launcher 组合根 + BuildHost 真扫描生产采用——扫描面 = Launcher 程序集引用面)。 @@ -238,14 +297,14 @@ public void ProjectReferenceGraph_IsAcyclic() } /// - /// 断言脊柱包(Capability.Online.Abstractions)对地基/域命名空间零引用 - /// (包内方向规则:只允许脊柱内部向下;地基与肢体命名空间出现在 Abstractions 源码即违规, - /// 含 using 之外的限定名形态——C223 Events 零域引用断言的包级升维)。 + /// 断言脊柱包(Capability.Online.Abstractions)对地基/能力包/持久化命名空间零引用 + /// (包内方向规则:只允许脊柱内部向下;地基、七个能力包与持久化包的命名空间出现在 Abstractions 源码即违规, + /// 含 using 之外的限定名形态——C223 Events 零域引用断言的包级升维;旧 GameFrameX.Online.* 命名空间保留为退役守卫)。 /// [Fact] public void SpinePackage_HasNoUpwardOrDomainTypeReferences() { - var upwardPattern = new Regex(@"GameFrameX\.Capability\.Online\.Foundation\.(Identity|Session|Presence|Storage)\.[A-Z]|GameFrameX\.Online\.(Assets|Audit|GameEvents|HotfixRollback|Leaderboard|Match|Matchmaking|Overview|Party|Season|Social|Timeline|Tournament)\.[A-Z]"); + var upwardPattern = new Regex(@"GameFrameX\.Capability\.Online\.(Foundation|Assets|Social|Matchmaking|Match|Competition|Operations|Persistence)\b|GameFrameX\.Online\.[A-Z]"); var violations = new List(); foreach (var file in EnumeratePackageSourceFiles("GameFrameX.Capability.Online.Abstractions")) { @@ -261,7 +320,8 @@ public void SpinePackage_HasNoUpwardOrDomainTypeReferences() /// /// 断言地基包(Capability.Online.Foundation)内目录依赖为簇内向下 - /// (Session→Identity 允许;Identity/Presence/Storage 互引与对 Session 引用恒零)。 + /// (Session→Identity 允许;Identity/Presence/Storage 互引与对 Session 引用恒零; + /// AdminApi 是地基 admin handler 顶层,可向下引用四个域目录,任何域目录不得反向引用 AdminApi)。 /// [Fact] public void FoundationPackage_IntraDirectoryEdges_RemainWithinClusterRules() @@ -274,6 +334,7 @@ public void FoundationPackage_IntraDirectoryEdges_RemainWithinClusterRules() ["Session"] = new[] { "Identity" }, ["Presence"] = Array.Empty(), ["Storage"] = Array.Empty(), + ["AdminApi"] = new[] { "Identity", "Session", "Presence", "Storage" }, }; var violations = new List(); foreach (var file in EnumeratePackageSourceFiles("GameFrameX.Capability.Online.Foundation")) diff --git a/Tests/GameFrameX.Tests/Online/CapabilitySchedulerTests.cs b/Tests/GameFrameX.Tests/Online/CapabilitySchedulerTests.cs index 2e080b5d7..a23f55ad9 100644 --- a/Tests/GameFrameX.Tests/Online/CapabilitySchedulerTests.cs +++ b/Tests/GameFrameX.Tests/Online/CapabilitySchedulerTests.cs @@ -2,154 +2,538 @@ // 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. -// 使用本项目须严格遵守相应法律法规与开源许可证之规定。 +// are protected by the laws of the PRC 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 refer to the LICENSE file in the root directory of the source code for the full license text. +// 完整许可证文本请参见源代码根目录的 LICENSE 文件。 +// please refer to the LICENSE file at the root 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! +// or infringe the legal rights and interests of others as prohibited by laws and regulations! // 因基于本项目二次开发所产生的一切法律纠纷与责任, -// Any legal disputes or liabilities arising from secondary development based on this project -// 本组织与贡献者概不承担。 +// 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 +// 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 +// Gitee Repository: https://gitee.com/GameFrameX +// CNB 仓库:https://www.cnb.cool/GameFrameX +// CNB Repository: https://www.cnb.cool/GameFrameX // 官方文档:https://gameframex.doc.alianblank.com/ -// Official Documentation: https://gameframex.doc.alianblank.com/ +// Official Docs: https://gameframex.doc.alianblank.com/ // ========================================================================================== using System; +using System.Collections.Generic; +using System.Linq; using System.Threading; using System.Threading.Tasks; using GameFrameX.Capability.Hosting.Abstractions; using GameFrameX.Capability.Hosting.Core; -using GameFrameX.Launcher.Online; using Xunit; -namespace GameFrameX.Tests.Online +namespace GameFrameX.Tests.Online; + +/// +/// 能力调度壳测试(C294:一次性生命周期与停机路径幂等、逐滴答 job 注册序执行与 per-job 隔离、 +/// 可注入时钟驱动间隔 job 到期、观测出口上报失败与每轮指标;组合面 SchedulerClock / SchedulerObserver 注入生效)。 +/// +[Collection(CapabilityRuntimeCollection.Name)] +public class CapabilitySchedulerTests { + /// 调度驱动间隔(机制下限 100ms)。 + private const int LoopIntervalMilliseconds = 100; + + /// 轮询等待上限(后台循环异步推进的断言超时)。 + private static readonly TimeSpan PollTimeout = TimeSpan.FromSeconds(5); + + /// 虚拟时钟起点。 + private static readonly DateTimeOffset StartInstant = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); + /// - /// 能力调度壳测试(一次性生命周期与停机路径幂等——S2930 修复行为锚点的机制层移植; - /// 三类 job 注册点:逐滴答顺序执行 / 事件驱动爆发口与异常隔离)。 + /// 手动推进的调度时钟(测试注入:间隔 job 到期只由用例推进决定,不依赖真实等待)。 /// - public class CapabilitySchedulerTests + private sealed class ManualSchedulerClock : ISchedulerClock { + /// 时刻读写锁(后台调度循环与用例线程并发访问)。 + private readonly object _sync = new(); + + /// 当前虚拟时刻。 + private DateTimeOffset _utcNow; + /// - /// 构造测试用调度器(Online 单入口组合根承载,最小驱动间隔)。 + /// 初始化手动时钟。 /// - /// 待测调度器。 - private static CapabilityScheduler CreateScheduler() + /// 起始时刻。 + public ManualSchedulerClock(DateTimeOffset start) { - var composition = OnlineCapabilityComposition.AddOnlineCapabilities(options => + _utcNow = start; + } + + /// + public DateTimeOffset UtcNow + { + get { - options.TenantId = 1; - options.AppId = 1; - options.ServerId = 1; - options.SchedulerIntervalMilliseconds = 100; - options.EnableRuntime = true; - }); - return composition.Runtime.Scheduler; + lock (_sync) + { + return _utcNow; + } + } } /// - /// 生产停机路径:启动后停止应正常返回(循环被取消打断并退出,令牌就地释放)。 + /// 推进虚拟时刻。 /// - [Fact] - public async Task StartThenStopAsync_CompletesWithoutThrowing() + /// 推进量。 + public void Advance(TimeSpan delta) { - var scheduler = CreateScheduler(); + lock (_sync) + { + _utcNow += delta; + } + } + } - scheduler.Start(); - await scheduler.StopAsync(); + /// + /// job 失败观测记录(种类 + 全名 + 异常)。 + /// + private sealed class JobFailureRecord + { + /// + /// 初始化失败记录。 + /// + /// job 种类。 + /// job 全名。 + /// job 异常。 + public JobFailureRecord(CapabilitySchedulerJobKind kind, string jobName, Exception exception) + { + Kind = kind; + JobName = jobName; + Exception = exception; + } - scheduler.Dispose(); + /// 获取 job 种类。 + public CapabilitySchedulerJobKind Kind { get; } - // 停机后调度器仍可驱动一次 tick:TickAsync 不依赖运行中循环,仅驱动注册 job, - // 调用成功即锚定「启动→停止→释放」整段链未抛、job 集合完整。 - Assert.NotNull(scheduler); - await scheduler.TickAsync(); - } + /// 获取 job 全名。 + public string JobName { get; } + + /// 获取 job 异常。 + public Exception Exception { get; } + } + /// + /// 每轮 tick 指标记录(执行数 + 失败数)。 + /// + private sealed class TickRoundRecord + { /// - /// 停止幂等:StopAsync 重复调用与后续 Dispose 均不得抛异常(已释放令牌上的重入防护)。 + /// 初始化每轮记录。 /// - [Fact] - public async Task StopAsync_RepeatedAfterStop_DoesNotThrow() + /// 执行 job 数。 + /// 失败 job 数。 + public TickRoundRecord(int executedJobCount, int failedJobCount) { - var scheduler = CreateScheduler(); + ExecutedJobCount = executedJobCount; + FailedJobCount = failedJobCount; + } - scheduler.Start(); - await scheduler.StopAsync(); - await scheduler.StopAsync(); + /// 获取执行 job 数。 + public int ExecutedJobCount { get; } - scheduler.Dispose(); + /// 获取失败 job 数。 + public int FailedJobCount { get; } + } - // 重复停止后调度器仍可驱动一次 tick:锚定 StopAsync 重入防护未破坏 job 路径。 - Assert.NotNull(scheduler); - await scheduler.TickAsync(); + /// + /// 记录型调度观测出口(运维指标管道的测试替身:收集 job 失败与每轮指标)。 + /// + private sealed class RecordingSchedulerObserver : ICapabilitySchedulerObserver + { + /// 记录读写锁(后台调度循环与用例线程并发访问)。 + private readonly object _sync = new(); + + /// 失败记录。 + private readonly List _failures = new(); + + /// 每轮记录。 + private readonly List _tickRounds = new(); + + /// 获取失败记录快照。 + public JobFailureRecord[] Failures + { + get + { + lock (_sync) + { + return _failures.ToArray(); + } + } } - /// - /// 从未启动即停止:StopAsync 与 Dispose 直接走释放路径,不得抛异常。 - /// - [Fact] - public async Task StopAsync_WithoutStart_DisposesWithoutThrowing() + /// 获取每轮记录快照。 + public TickRoundRecord[] TickRounds { - var scheduler = CreateScheduler(); + get + { + lock (_sync) + { + return _tickRounds.ToArray(); + } + } + } - await scheduler.StopAsync(); + /// + public void OnJobFailed(CapabilitySchedulerJobKind jobKind, string jobName, Exception exception) + { + lock (_sync) + { + _failures.Add(new JobFailureRecord(jobKind, jobName, exception)); + } + } - scheduler.Dispose(); + /// + public void OnTickRoundCompleted(TimeSpan elapsed, int executedJobCount, int failedJobCount) + { + lock (_sync) + { + _tickRounds.Add(new TickRoundRecord(executedJobCount, failedJobCount)); + } + } + } - // 从未启动即停止后调度器仍可驱动一次 tick:锚定释放路径未抛、job 可达。 - Assert.NotNull(scheduler); - await scheduler.TickAsync(); + /// + /// 线程安全的调用计数器(job 执行次数断言用)。 + /// + private sealed class InvocationCounter + { + /// 计数值。 + private int _count; + + /// 获取当前计数。 + public int Count + { + get { return Volatile.Read(ref _count); } } /// - /// 逐滴答 job 按注册序顺序执行(装配序即执行序不变量)。 + /// 计数加一。 /// - [Fact] - public async Task TickAsync_RunsTickJobsInRegistrationOrder() + public void Increment() { - var order = new System.Collections.Generic.List(); - var scheduler = new CapabilityScheduler(1000, new[] + Interlocked.Increment(ref _count); + } + } + + /// + /// 构造只含一个计数 tick job 的调度器(最小驱动间隔)。 + /// + /// tick job 执行计数器。 + /// 待测调度器。 + private static CapabilityScheduler CreateCountingScheduler(InvocationCounter counter) + { + return new CapabilityScheduler(LoopIntervalMilliseconds, new[] + { + new CapabilityTickJob("a/count", _ => { - new CapabilityTickJob("a/first", _ => { lock (order) { order.Add("first"); } return Task.CompletedTask; }), - new CapabilityTickJob("a/second", _ => { lock (order) { order.Add("second"); } return Task.CompletedTask; }), - new CapabilityTickJob("a/third", _ => { lock (order) { order.Add("third"); } return Task.CompletedTask; }), - }); + counter.Increment(); + return Task.CompletedTask; + }), + }); + } - await scheduler.TickAsync(); + /// + /// 轮询等待条件成立(后台调度循环异步推进;超时返回 false)。 + /// + /// 条件。 + /// 等待上限。 + /// 条件在上限内成立返回 true。 + private static async Task WaitUntilAsync(Func condition, TimeSpan timeout) + { + var deadline = DateTime.UtcNow + timeout; + while (DateTime.UtcNow < deadline) + { + if (condition()) + { + return true; + } - Assert.Equal(new[] { "first", "second", "third" }, order); + await Task.Delay(20); } - /// - /// 事件驱动 job:宿主爆发口同步分发,单 job 异常不阻断其它 job 也不向来源传播。 - /// - [Fact] - public void RaiseEvent_DrivesEventJobsAndIsolatesExceptions() + return condition(); + } + + /// + /// 生产停机路径:启动后停止应正常返回(循环被取消打断并退出,令牌就地释放); + /// 停机并释放后 TickAsync 仍能驱动注册 job(不依赖运行中循环)。 + /// + [Fact] + public async Task StartThenStopAsync_CompletesWithoutThrowing() + { + var counter = new InvocationCounter(); + var scheduler = CreateCountingScheduler(counter); + + scheduler.Start(); + await scheduler.StopAsync(); + scheduler.Dispose(); + + var before = counter.Count; + await scheduler.TickAsync(); + + Assert.Equal(before + 1, counter.Count); + } + + /// + /// 停止幂等:StopAsync 重复调用与后续 Dispose 均不得抛异常(已释放令牌上的重入防护);job 路径完好。 + /// + [Fact] + public async Task StopAsync_RepeatedAfterStop_DoesNotThrow() + { + var counter = new InvocationCounter(); + var scheduler = CreateCountingScheduler(counter); + + scheduler.Start(); + await scheduler.StopAsync(); + await scheduler.StopAsync(); + scheduler.Dispose(); + + var before = counter.Count; + await scheduler.TickAsync(); + + Assert.Equal(before + 1, counter.Count); + } + + /// + /// 从未启动即停止:StopAsync 与 Dispose 直接走释放路径,不得抛异常;job 仍可经 TickAsync 驱动。 + /// + [Fact] + public async Task StopAsync_WithoutStart_DisposesWithoutThrowing() + { + var counter = new InvocationCounter(); + var scheduler = CreateCountingScheduler(counter); + + await scheduler.StopAsync(); + scheduler.Dispose(); + await scheduler.TickAsync(); + + Assert.Equal(1, counter.Count); + } + + /// + /// 一次性生命周期:停止后再 Start 抛 (调度器不可复活)。 + /// + [Fact] + public async Task Start_AfterStopAsync_Throws() + { + var scheduler = CreateCountingScheduler(new InvocationCounter()); + + scheduler.Start(); + await scheduler.StopAsync(); + + Assert.Throws(() => scheduler.Start()); + } + + /// + /// 逐滴答 job 按注册序顺序执行(装配序即执行序不变量)。 + /// + [Fact] + public async Task TickAsync_RunsTickJobsInRegistrationOrder() + { + var order = new List(); + var scheduler = new CapabilityScheduler(LoopIntervalMilliseconds, new[] + { + new CapabilityTickJob("a/first", _ => + { + order.Add("first"); + return Task.CompletedTask; + }), + new CapabilityTickJob("a/second", _ => + { + order.Add("second"); + return Task.CompletedTask; + }), + new CapabilityTickJob("a/third", _ => + { + order.Add("third"); + return Task.CompletedTask; + }), + }); + + await scheduler.TickAsync(); + + Assert.Equal(new[] { "first", "second", "third" }, order); + } + + /// + /// per-job 隔离:一个 tick job 抛异常不阻断当轮剩余 job;观测出口收到 Tick 种类 + job 全名的失败, + /// 每轮指标执行数含失败 job(3 执行 / 1 失败);异常不向 TickAsync 调用方传播。 + /// + [Fact] + public async Task TickAsync_ThrowingJob_DoesNotStopRemainingJobsAndIsReported() + { + var order = new List(); + var observer = new RecordingSchedulerObserver(); + var scheduler = new CapabilityScheduler(LoopIntervalMilliseconds, new[] + { + new CapabilityTickJob("a/first", _ => + { + order.Add("first"); + return Task.CompletedTask; + }), + new CapabilityTickJob("a/boom", _ => throw new InvalidOperationException("tick boom")), + new CapabilityTickJob("a/third", _ => + { + order.Add("third"); + return Task.CompletedTask; + }), + }, null, null, observer); + + await scheduler.TickAsync(); + + Assert.Equal(new[] { "first", "third" }, order); + var failure = Assert.Single(observer.Failures); + Assert.Equal(CapabilitySchedulerJobKind.Tick, failure.Kind); + Assert.Equal("a/boom", failure.JobName); + Assert.Equal("tick boom", failure.Exception.Message); + var round = Assert.Single(observer.TickRounds); + Assert.Equal(3, round.ExecutedJobCount); + Assert.Equal(1, round.FailedJobCount); + } + + /// + /// 时钟注入:1 分钟间隔 job 在虚拟时钟未越过到期点前不执行(后台循环照常推进); + /// 时钟推进越过到期点后下一轮执行且只执行一次(下一租期顺延一个周期)。 + /// + [Fact] + public async Task IntervalJob_RunsOnlyAfterInjectedClockPassesDueTime() + { + var clock = new ManualSchedulerClock(StartInstant); + var observer = new RecordingSchedulerObserver(); + var runs = new InvocationCounter(); + var scheduler = new CapabilityScheduler(LoopIntervalMilliseconds, Array.Empty(), new[] { - var handled = new System.Collections.Generic.List(); - var scheduler = new CapabilityScheduler(1000, Array.Empty(), null, new[] + new CapabilityIntervalJob("a/minute", TimeSpan.FromMinutes(1), _ => { - new CapabilityEventJob("a/boom", _ => throw new InvalidOperationException("boom")), - new CapabilityEventJob("a/ok", notification => { lock (handled) { handled.Add(notification.Type); } }), - }); + runs.Increment(); + return Task.CompletedTask; + }), + }, clock, observer); + try + { + scheduler.Start(); - scheduler.RaiseEvent(new CapabilityEventNotification("t.moved", null, 1)); + Assert.True(await WaitUntilAsync(() => observer.TickRounds.Length >= 3, PollTimeout)); + Assert.Equal(0, runs.Count); + + clock.Advance(TimeSpan.FromSeconds(61)); + + Assert.True(await WaitUntilAsync(() => runs.Count >= 1, PollTimeout)); + var roundsAfterRun = observer.TickRounds.Length; + Assert.True(await WaitUntilAsync(() => observer.TickRounds.Length >= roundsAfterRun + 3, PollTimeout)); + Assert.Equal(1, runs.Count); + } + finally + { + await scheduler.StopAsync(); + } + } + + /// + /// 间隔 job 异常只影响自身本轮:观测出口收到 Interval 种类 + job 全名的失败,同轮其它到期间隔 job 照常执行。 + /// + [Fact] + public async Task IntervalJob_Failure_ReportedAsIntervalKind() + { + var clock = new ManualSchedulerClock(StartInstant); + var observer = new RecordingSchedulerObserver(); + var healthyRuns = new InvocationCounter(); + var scheduler = new CapabilityScheduler(LoopIntervalMilliseconds, Array.Empty(), new[] + { + new CapabilityIntervalJob("a/faulty", TimeSpan.FromMinutes(1), _ => throw new InvalidOperationException("interval boom")), + new CapabilityIntervalJob("a/healthy", TimeSpan.FromMinutes(1), _ => + { + healthyRuns.Increment(); + return Task.CompletedTask; + }), + }, clock, observer); + try + { + scheduler.Start(); + clock.Advance(TimeSpan.FromSeconds(61)); + + Assert.True(await WaitUntilAsync(() => observer.Failures.Length >= 1 && healthyRuns.Count >= 1, PollTimeout)); + var failure = Assert.Single(observer.Failures); + Assert.Equal(CapabilitySchedulerJobKind.Interval, failure.Kind); + Assert.Equal("a/faulty", failure.JobName); + Assert.Equal("interval boom", failure.Exception.Message); + Assert.Equal(1, healthyRuns.Count); + } + finally + { + await scheduler.StopAsync(); + } + } + + /// + /// 组合面注入: / + /// 被宿主调度器采用——槽位 AddIntervalJob 在 StartAsync 后、虚拟时钟推进前不执行,推进后执行; + /// 注入的观测出口收到每轮指标与能力限定全名(mech-first/faulty)的 Interval 失败。 + /// + [Fact] + public async Task Compose_SchedulerClockAndObserver_AreHonored() + { + CapabilityRuntime runtime = null; + try + { + var clock = new ManualSchedulerClock(StartInstant); + var observer = new RecordingSchedulerObserver(); + var pulses = new InvocationCounter(); + MechanismScenario.First.Register = context => + { + context.AddIntervalJob("pulse", TimeSpan.FromMinutes(1), _ => + { + pulses.Increment(); + return Task.CompletedTask; + }); + context.AddIntervalJob("faulty", TimeSpan.FromMinutes(1), _ => throw new InvalidOperationException("slot interval boom")); + }; + var options = new CapabilityCompositionOptions(default, new CollectingCapabilityAssemblyLog()) + { + HostOptions = new CapabilityHostOptions { TenantId = 1, AppId = 1, ServerId = 1, SchedulerIntervalMilliseconds = LoopIntervalMilliseconds }, + Filter = new CapabilityAssemblyFilter(new[] { "mech-first" }), + SchedulerClock = clock, + SchedulerObserver = observer, + }; + + runtime = CapabilityComposition.Compose(new MechanismTestFamily(), options); + await runtime.StartAsync(); + + Assert.True(await WaitUntilAsync(() => observer.TickRounds.Length >= 3, PollTimeout)); + Assert.Equal(0, pulses.Count); + Assert.Empty(observer.Failures); + + clock.Advance(TimeSpan.FromSeconds(61)); + + Assert.True(await WaitUntilAsync(() => pulses.Count >= 1 && observer.Failures.Length >= 1, PollTimeout)); + var failure = observer.Failures.Single(); + Assert.Equal(CapabilitySchedulerJobKind.Interval, failure.Kind); + Assert.Equal("mech-first/faulty", failure.JobName); + Assert.Equal(1, pulses.Count); + } + finally + { + if (runtime != null) + { + await runtime.StopAsync(); + } - Assert.Equal(new[] { "t.moved" }, handled); + MechanismScenario.Reset(); } } } diff --git a/Tests/GameFrameX.Tests/Online/CapabilityServiceSharingTests.cs b/Tests/GameFrameX.Tests/Online/CapabilityServiceSharingTests.cs index c80007446..8201268f1 100644 --- a/Tests/GameFrameX.Tests/Online/CapabilityServiceSharingTests.cs +++ b/Tests/GameFrameX.Tests/Online/CapabilityServiceSharingTests.cs @@ -1,189 +1,521 @@ +// ========================================================================================== +// GameFrameX 组织及其衍生项目的版权、商标、专利及其他相关权利 +// GameFrameX organization and its derivative projects' copyrights, trademarks, patents and related rights +// 均受中华人民共和国及相关国际法律法规保护。 +// are protected by the laws of the PRC 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 refer to the LICENSE file at the root 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 the legal 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://www.cnb.cool/GameFrameX +// CNB Repository: https://www.cnb.cool/GameFrameX +// 官方文档:https://gameframex.doc.alianblank.com/ +// Official Docs: https://gameframex.doc.alianblank.com/ +// ========================================================================================== + using System; +using System.Collections.Generic; using System.Linq; -using System.Threading; using System.Threading.Tasks; using GameFrameX.Capability.Hosting.Abstractions; using GameFrameX.Capability.Hosting.Core; using Xunit; -namespace GameFrameX.Tests.Online +namespace GameFrameX.Tests.Online; + +/// +/// 能力服务共享协议机制测试(C294:描述符表形态——模块默认注册 / 宿主覆盖恒胜出 / 命名寻址 / +/// 懒工厂单例 / Activate 期枚举;生产者与消费者全部经 槽位 + +/// 真实装配面驱动)。 +/// +[Collection(CapabilityRuntimeCollection.Name)] +public class CapabilityServiceSharingTests { /// - /// 能力服务共享协议机制测试(C231 P3):注册表 CRUD 语义 / 组合根预置(UseService)/ - /// 模块间拓扑序共享解析 / 替换缝命中 / 宿主 Services 与 Modules 暴露面。 + /// 信号服务(测试载荷:生产槽位注册、消费槽位解析)。 + /// + private sealed class SignalService + { + /// 获取或设置标记值。 + public string Marker { get; set; } + } + + /// + /// 从未注册的服务类型(缺失语义断言用)。 + /// + private sealed class UnregisteredService + { + } + + /// + /// 循环解析用例:懒工厂解析 的服务。 + /// + private sealed class CycleAlphaService + { + } + + /// + /// 循环解析用例:懒工厂反向解析 的服务。 + /// + private sealed class CycleBetaService + { + } + + /// + /// 构造机制组合选项(无族配置段;作用域三元组合法;白名单圈定参与槽位)。 /// - public class CapabilityServiceSharingTests + /// 参与组合的槽位能力 Id。 + /// 组合选项。 + private static CapabilityCompositionOptions CreateOptions(params string[] enabledIds) { - /// - /// 信号服务(测试载荷)。 - /// - private sealed class SignalService + return new CapabilityCompositionOptions(default, new CollectingCapabilityAssemblyLog()) { - /// 获取标记值。 - public string Marker { get; set; } - } + HostOptions = new CapabilityHostOptions { TenantId = 1, AppId = 1, ServerId = 1 }, + Filter = new CapabilityAssemblyFilter(enabledIds), + }; + } + + /// + /// 组合机制测试族。 + /// + /// 组合选项。 + /// 组合产物。 + private static CapabilityRuntime Compose(CapabilityCompositionOptions options) + { + return CapabilityComposition.Compose(new MechanismTestFamily(), options); + } - /// - /// 生产模块(注册服务产物,无依赖)。 - /// - private sealed class ProducerModule : ICapabilityModule + /// + /// 模块间共享:消费者声明必选依赖后在 Register 期解析到生产者注册的同一实例; + /// 组合完成后宿主侧 host.Services 取到的也是同一实例。 + /// + [Fact] + public async Task Compose_ProducerService_ResolvedByRequiringConsumerAndHost() + { + CapabilityRuntime runtime = null; + try { - /// 生产模块注册的产物(断言用)。 - public SignalService Produced { get; private set; } + var produced = new SignalService { Marker = "produced", }; + SignalService resolvedByConsumer = null; + MechanismScenario.First.Register = context => context.AddService(produced); + MechanismScenario.Second.Requires = new[] { "mech-first" }; + MechanismScenario.Second.Register = context => resolvedByConsumer = context.GetService(); - /// - public CapabilityManifest Manifest { get; } = new("share-producer", "1.0.0", Array.Empty()); + runtime = Compose(CreateOptions("mech-first", "mech-second")); - /// - public void Register(CapabilityRegistrationContext context) + Assert.Same(produced, resolvedByConsumer); + Assert.Same(produced, runtime.Host.Services.GetService()); + } + finally + { + if (runtime != null) { - if (context.TryGetService(out SignalService existing)) - { - Produced = existing; - return; - } + await runtime.StopAsync(); + } + + MechanismScenario.Reset(); + } + } + + /// + /// 缺失语义:未注册类型宿主侧 GetService 抛出、GetOptionalService 返回 null(替代旧 TryGet 缺失分支); + /// 模块注册 null 实例被拒绝。 + /// + [Fact] + public async Task Compose_MissingService_GetThrowsAndOptionalReturnsNull() + { + CapabilityRuntime runtime = null; + try + { + Exception nullInstanceError = null; + MechanismScenario.First.Register = context => nullInstanceError = Record.Exception(() => context.AddService((SignalService)null)); + + runtime = Compose(CreateOptions("mech-first")); - Produced = new SignalService { Marker = "produced", }; - context.AddService(Produced); + Assert.IsType(nullInstanceError); + Assert.Throws(() => runtime.Host.Services.GetService()); + Assert.Null(runtime.Host.Services.GetOptionalService()); + Assert.Null(runtime.Host.Services.GetOptionalService()); + } + finally + { + if (runtime != null) + { + await runtime.StopAsync(); } + + MechanismScenario.Reset(); + } + } + + /// + /// 同键第二个默认注册即装配失败(两个能力各注册同类型默认实现,错误信息带服务类型键)。 + /// + [Fact] + public void Compose_SecondDefaultRegistrationOfSameKey_FailsFast() + { + try + { + MechanismScenario.First.Register = context => context.AddService(new SignalService { Marker = "first", }); + MechanismScenario.Second.Register = context => context.AddService(new SignalService { Marker = "second", }); + + var exception = Assert.Throws(() => Compose(CreateOptions("mech-first", "mech-second"))); + + Assert.Contains(typeof(SignalService).FullName, exception.Message); + } + finally + { + MechanismScenario.Reset(); } + } - /// - /// 消费模块(依赖生产模块,解析其产物)。 - /// - private sealed class ConsumerModule : ICapabilityModule + /// + /// 宿主覆盖恒胜出模块默认(替代旧组合根预置服务形态):宿主侧与依赖它的消费者都解析到覆盖实例; + /// 被覆盖的模块默认仍留在审计描述符表中。 + /// + [Fact] + public async Task Compose_Override_WinsOverModuleDefault() + { + CapabilityRuntime runtime = null; + try { - /// 消费模块解析到的产物(断言用)。 - public SignalService Resolved { get; private set; } + var replacement = new SignalService { Marker = "replaced", }; + SignalService resolvedByConsumer = null; + MechanismScenario.First.Register = context => context.AddService(new SignalService { Marker = "module-default", }); + MechanismScenario.Second.Requires = new[] { "mech-first" }; + MechanismScenario.Second.Register = context => resolvedByConsumer = context.GetService(); + var options = CreateOptions("mech-first", "mech-second"); + options.Override(replacement); - /// - public CapabilityManifest Manifest { get; } = new("share-consumer", "1.0.0", Array.Empty(), new[] { "share-producer" }); + runtime = Compose(options); - /// - public void Register(CapabilityRegistrationContext context) + Assert.Same(replacement, runtime.Host.Services.GetService()); + Assert.Same(replacement, resolvedByConsumer); + var registrations = runtime.Host.Services.Registrations.Where(registration => registration.ServiceType == typeof(SignalService)).ToList(); + Assert.Equal(2, registrations.Count); + Assert.Contains(registrations, registration => registration.IsOverride && registration.OwnerCapabilityId == "mechanism-tests"); + Assert.Contains(registrations, registration => !registration.IsOverride && registration.OwnerCapabilityId == "mech-first"); + } + finally + { + if (runtime != null) { - Resolved = context.GetService(); + await runtime.StopAsync(); } + + MechanismScenario.Reset(); } + } + + /// + /// 同键第二个宿主覆盖即装配失败(覆盖面与模块注册面共用同一描述符表与口径)。 + /// + [Fact] + public void Compose_SecondOverrideOfSameKey_FailsFast() + { + try + { + var options = CreateOptions("mech-first"); + options.Override(new SignalService { Marker = "one", }); + options.Override(new SignalService { Marker = "two", }); + + var exception = Assert.Throws(() => Compose(options)); - /// - /// 构造合法选项。 - /// - /// 选项。 - private static CapabilityHostOptions ValidOptions() + Assert.Contains(typeof(SignalService).FullName, exception.Message); + } + finally { - return new CapabilityHostOptions { TenantId = 1, AppId = 1, ServerId = 1 }; + MechanismScenario.Reset(); } + } + + /// + /// 命名注册:宿主侧 GetService<T>(name) 按命名键寻址;消费者 Activate 期经解析器按名取用; + /// 只有命名注册时默认键仍为缺失。 + /// + [Fact] + public async Task Compose_NamedRegistrations_ResolveByName() + { + CapabilityRuntime runtime = null; + try + { + var alpha = new SignalService { Marker = "alpha", }; + var beta = new SignalService { Marker = "beta", }; + SignalService betaResolvedInActivate = null; + MechanismScenario.First.Register = context => + { + context.AddService("alpha", alpha); + context.AddService("beta", beta); + }; + MechanismScenario.Second.Requires = new[] { "mech-first" }; + MechanismScenario.Second.Activate = (context, cancellationToken) => + { + betaResolvedInActivate = context.Services.GetService("beta"); + return Task.CompletedTask; + }; + + runtime = Compose(CreateOptions("mech-first", "mech-second")); - /// - /// 注册表 CRUD:Add 后 Get/TryGet 命中同实例;未注册类型 Get 抛 / TryGet 缺失;null 实例拒绝。 - /// - [Fact] - public void Registry_AddGetTry_RoundTripAndMissingSemantics() + Assert.Same(alpha, runtime.Host.Services.GetService("alpha")); + Assert.Same(beta, runtime.Host.Services.GetService("beta")); + Assert.Throws(() => runtime.Host.Services.GetService()); + Assert.Null(runtime.Host.Services.GetOptionalService()); + + await runtime.StartAsync(); + + Assert.Same(beta, betaResolvedInActivate); + } + finally { - var services = new CapabilityServices(); - var instance = new SignalService { Marker = "a", }; - services.AddService(instance); + if (runtime != null) + { + await runtime.StopAsync(); + } - Assert.Same(instance, services.GetService()); - Assert.True(services.TryGetService(out SignalService found)); - Assert.Same(instance, found); - Assert.Throws(() => services.GetService()); - Assert.False(services.TryGetService(out object _)); - Assert.Throws(() => services.AddService(null)); + MechanismScenario.Reset(); } + } + + /// + /// 多实现枚举:Register 期与宿主启动前 GetServices 恒抛出;Activate 期与启动后按注册序列出默认 + 命名实现。 + /// + [Fact] + public async Task Compose_GetServices_EnumeratesDefaultAndNamedOnlyFromActivation() + { + CapabilityRuntime runtime = null; + try + { + var defaultInstance = new SignalService { Marker = "default", }; + var alphaInstance = new SignalService { Marker = "alpha", }; + Exception registerEnumerationError = null; + IReadOnlyList enumeratedInActivate = null; + MechanismScenario.First.Register = context => + { + context.AddService(defaultInstance); + context.AddService("alpha", alphaInstance); + registerEnumerationError = Record.Exception(() => context.GetServices()); + }; + MechanismScenario.Second.Requires = new[] { "mech-first" }; + MechanismScenario.Second.Activate = (context, cancellationToken) => + { + enumeratedInActivate = context.Services.GetServices(); + return Task.CompletedTask; + }; + + runtime = Compose(CreateOptions("mech-first", "mech-second")); + + Assert.IsType(registerEnumerationError); + Assert.Throws(() => runtime.Host.Services.GetServices()); - /// - /// 同类型重复注册即装配失败(对齐 manifest Id 唯一 / job 全名唯一口径)。 - /// - [Fact] - public void Registry_DuplicateRegistration_FailsFast() + await runtime.StartAsync(); + + Assert.Equal(new[] { defaultInstance, alphaInstance }, enumeratedInActivate); + Assert.Equal(new[] { defaultInstance, alphaInstance }, runtime.Host.Services.GetServices()); + } + finally { - var services = new CapabilityServices(); - services.AddService(new SignalService()); + if (runtime != null) + { + await runtime.StopAsync(); + } - Assert.Throws(() => services.AddService(new SignalService())); + MechanismScenario.Reset(); } + } + + /// + /// 懒工厂:组合完成时尚未构造;首次解析才执行一次,之后重复解析命中同一实例(宿主内单例)。 + /// + [Fact] + public async Task Compose_LazyFactory_RunsOnceOnFirstResolve() + { + CapabilityRuntime runtime = null; + try + { + var factoryRuns = 0; + MechanismScenario.First.Register = context => context.AddService(resolver => + { + factoryRuns++; + return new SignalService { Marker = "lazy", }; + }); + + runtime = Compose(CreateOptions("mech-first")); + + Assert.Equal(0, factoryRuns); - /// - /// 独立注册表形态(单参构造)下上下文服务面可用(缺省自建注册表)。 - /// - [Fact] - public void Context_StandaloneConstructor_OwnsPrivateRegistry() + var first = runtime.Host.Services.GetService(); + var second = runtime.Host.Services.GetService(); + + Assert.Equal(1, factoryRuns); + Assert.Same(first, second); + Assert.Equal("lazy", first.Marker); + } + finally { - var context = new CapabilityRegistrationContext(new CapabilityManifest("solo", "1.0.0", Array.Empty())); - var instance = new SignalService { Marker = "solo", }; - context.AddService(instance); + if (runtime != null) + { + await runtime.StopAsync(); + } - Assert.Same(instance, context.GetService()); - Assert.Same(instance, context.Services.GetService()); + MechanismScenario.Reset(); } + } - /// - /// 模块间共享:拓扑序后模块解析先序模块产物(同一实例);Build 后组合根经 host.Services 取用。 - /// - [Fact] - public void Build_ModuleProducts_SharedAcrossModulesAndExposedToRoot() + /// + /// 宿主 Modules 面:只暴露启用模块的拓扑序实例(与 Register 共用同一槽位模块实例);被过滤槽位缺席且报告标注未收录。 + /// + [Fact] + public async Task Compose_HostModules_ExposesEnabledInstancesOnly() + { + CapabilityRuntime runtime = null; + try { - var producer = new ProducerModule(); - var consumer = new ConsumerModule(); - var builder = new CapabilityHostBuilder(); - builder.AddModule(producer); - builder.AddModule(consumer); + MechanismScenario.Second.Requires = new[] { "mech-first" }; + + runtime = Compose(CreateOptions("mech-first", "mech-second")); - var host = builder.Build(ValidOptions()); + Assert.Equal(new[] { "mech-first", "mech-second" }, runtime.Host.Modules.Select(module => module.Manifest.Id).ToArray()); + Assert.IsType(runtime.Host.Modules[0]); + Assert.IsType(runtime.Host.Modules[1]); + Assert.DoesNotContain(runtime.Host.Modules, module => module is MechanismThirdSlotModule || module is MechanismFourthSlotModule); + Assert.Equal(new[] { "mech-first", "mech-second" }, runtime.Host.Report.EnabledIdsInOrder); + Assert.Equal(CapabilityFilterState.NotInEnableList, runtime.Report.Entries.Single(entry => entry.Manifest.Id == "mech-third").State); + Assert.Equal(CapabilityFilterState.NotInEnableList, runtime.Report.Entries.Single(entry => entry.Manifest.Id == "mech-fourth").State); + } + finally + { + if (runtime != null) + { + await runtime.StopAsync(); + } - Assert.NotNull(producer.Produced); - Assert.Same(producer.Produced, consumer.Resolved); - Assert.Same(producer.Produced, host.Services.GetService()); + MechanismScenario.Reset(); } + } - /// - /// 组合根预置(UseService):模块 TryGetService 命中预置实例(替换缝语义),且未命中时可用缺省。 - /// - [Fact] - public void Builder_UseService_PreSeedWinsOverModuleDefault() + /// + /// 懒工厂循环依赖(A 的工厂解析 B、B 的工厂解析 A):首次解析即抛 InvalidOperationException 并列出完整解析链 + /// (A -> B -> A),不再无限递归导致不可捕获的栈溢出。 + /// + [Fact] + public void Compose_LazyFactoryCycle_FailsFastWithResolutionChain() + { + try { - var replacement = new SignalService { Marker = "replaced", }; - var builder = new CapabilityHostBuilder(); - builder.UseService(replacement); - builder.AddModule(new ProducerModule()); + MechanismScenario.First.Register = context => + { + context.AddService(resolver => + { + resolver.GetService(); + return new CycleAlphaService(); + }); + context.AddService(resolver => + { + resolver.GetService(); + return new CycleBetaService(); + }); + context.GetService(); + }; - var host = builder.Build(ValidOptions()); + var exception = Assert.Throws(() => Compose(CreateOptions("mech-first"))); - Assert.Same(replacement, host.Services.GetService()); + var chain = typeof(CycleAlphaService).FullName + " -> " + typeof(CycleBetaService).FullName + " -> " + typeof(CycleAlphaService).FullName; + Assert.Equal(string.Format(GameFrameX.Foundation.Localization.Core.LocalizationService.GetString(GameFrameX.Localization.Keys.Capability.Hosting.ServiceResolutionCycle), chain), exception.Message); + } + finally + { + MechanismScenario.Reset(); } + } + + /// + /// 模块作用域可选解析的合法缺席:消费者声明的可选依赖未装配时,Register 期与 Activate 期 GetOptionalService 未命中均返回 null。 + /// + [Fact] + public async Task GetOptionalService_DeclaredOptionalDependencyAbsent_ReturnsNull() + { + CapabilityRuntime runtime = null; + try + { + var registerResolved = new SignalService { Marker = "sentinel", }; + var activateResolved = new SignalService { Marker = "sentinel", }; + MechanismScenario.First.Optional = new[] { new CapabilityOptionalDependency("mech-second", GameFrameX.Localization.Keys.Capability.Online.Match.OptionalAssetsAbsent), }; + MechanismScenario.First.Register = context => registerResolved = context.GetOptionalService(); + MechanismScenario.First.Activate = (context, cancellationToken) => + { + activateResolved = context.Services.GetOptionalService(); + return Task.CompletedTask; + }; - /// - /// UseService 同类型重复预置即装配失败(预置面与模块注册面共用同一注册表与口径)。 - /// - [Fact] - public void Builder_UseService_DuplicatePreSeed_FailsFast() + runtime = Compose(CreateOptions("mech-first")); + await runtime.StartAsync(); + + Assert.Null(registerResolved); + Assert.Null(activateResolved); + } + finally { - var builder = new CapabilityHostBuilder(); - builder.UseService(new SignalService()); + if (runtime != null) + { + await runtime.StopAsync(); + } - Assert.Throws(() => builder.UseService(new SignalService())); + MechanismScenario.Reset(); } + } - /// - /// 宿主 Modules 面:暴露启用模块的拓扑序实例(与 Register 共用同一实例);被过滤模块缺席。 - /// - [Fact] - public void Host_Modules_ExposesEnabledTopologicalInstances() + /// + /// 模块作用域可选解析的 fail-fast(设计 §4.9.1):声明的可选依赖已装配却没注册该服务 → Register 期抛 InvalidOperationException; + /// 消费者未声明任何可选依赖时 Activate 期未命中同样抛出(能力在场但服务未注册,不得静默返回 null)。 + /// + [Fact] + public async Task GetOptionalService_NoAbsentOptionalDependency_FailsFast() + { + CapabilityRuntime runtime = null; + try { - var producer = new ProducerModule(); - var consumer = new ConsumerModule(); - var builder = new CapabilityHostBuilder(); - builder.AddModule(producer); - builder.AddModule(consumer); - builder.SetFilter(new CapabilityAssemblyFilter(new[] { "share-producer" }, Array.Empty())); + MechanismScenario.First.Optional = new[] { new CapabilityOptionalDependency("mech-second", GameFrameX.Localization.Keys.Capability.Online.Match.OptionalAssetsAbsent), }; + MechanismScenario.First.Register = context => context.GetOptionalService(); + + var registerError = Assert.Throws(() => Compose(CreateOptions("mech-first", "mech-second"))); + + Assert.Equal(string.Format(GameFrameX.Foundation.Localization.Core.LocalizationService.GetString(GameFrameX.Localization.Keys.Capability.Hosting.OptionalServiceNotRegistered), "mech-first", typeof(SignalService).FullName), registerError.Message); + + MechanismScenario.Reset(); + MechanismScenario.First.Activate = (context, cancellationToken) => + { + context.Services.GetOptionalService(); + return Task.CompletedTask; + }; - var host = builder.Build(ValidOptions()); + runtime = Compose(CreateOptions("mech-first")); + var activateError = await Assert.ThrowsAsync(() => runtime.StartAsync()); + + Assert.Equal(string.Format(GameFrameX.Foundation.Localization.Core.LocalizationService.GetString(GameFrameX.Localization.Keys.Capability.Hosting.OptionalServiceNotRegistered), "mech-first", typeof(SignalService).FullName), activateError.Message); + Assert.Null(runtime.Host.Services.GetOptionalService()); + } + finally + { + if (runtime != null) + { + await runtime.StopAsync(); + } - var module = Assert.Single(host.Modules); - Assert.Same(producer, module); - Assert.Equal(new[] { "share-producer" }, host.Report.EnabledIdsInOrder); + MechanismScenario.Reset(); } } } diff --git a/Tests/GameFrameX.Tests/Online/HotfixManagerRollbackExecutorTests.cs b/Tests/GameFrameX.Tests/Online/HotfixManagerRollbackExecutorTests.cs index c1e6d9202..02aa5198f 100644 --- a/Tests/GameFrameX.Tests/Online/HotfixManagerRollbackExecutorTests.cs +++ b/Tests/GameFrameX.Tests/Online/HotfixManagerRollbackExecutorTests.cs @@ -37,7 +37,7 @@ using System.Threading.Tasks; using GameFrameX.Core.Hotfix; using GameFrameX.Foundation.Idempotency; -using GameFrameX.Capability.Online.Operations.Audit; +using GameFrameX.Capability.Online.Abstractions.Audit; using GameFrameX.Capability.Online.Abstractions.Contracts; using GameFrameX.Capability.Online.Operations.HotfixRollback; using GameFrameX.Capability.Online.Abstractions.Idempotency; diff --git a/Tests/GameFrameX.Tests/Online/MechanismCapabilityScenario.cs b/Tests/GameFrameX.Tests/Online/MechanismCapabilityScenario.cs new file mode 100644 index 000000000..366b742f1 --- /dev/null +++ b/Tests/GameFrameX.Tests/Online/MechanismCapabilityScenario.cs @@ -0,0 +1,343 @@ +// ========================================================================================== +// GameFrameX 组织及其衍生项目的版权、商标、专利及其他相关权利 +// GameFrameX organization and its derivative projects' copyrights, trademarks, patents and related rights +// 均受中华人民共和国及相关国际法律法规保护。 +// are protected by the laws of the PRC 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 refer to the LICENSE file at the root 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 the legal 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://www.cnb.cool/GameFrameX +// CNB Repository: https://www.cnb.cool/GameFrameX +// 官方文档:https://gameframex.doc.alianblank.com/ +// Official Docs: https://gameframex.doc.alianblank.com/ +// ========================================================================================== + +using System; +using System.Threading; +using System.Threading.Tasks; +using GameFrameX.Capability.Hosting.Abstractions; + +using GameFrameX.Capability.Hosting.AdminApi; +using GameFrameX.Capability.Hosting.Core; + +namespace GameFrameX.Tests.Online; + +/// +/// 机制层测试场景槽位(C294:模块只能经部署目录扫描发现,桩清单 / 依赖 / 行为全部由用例在 +/// 组合前写入静态槽位——同一测试程序集内的全部槽位模块对每次组合同时可见,用例以 +/// 白名单圈定参与面;槽位缺省态为无依赖的惰性模块)。 +/// +public sealed class MechanismScenarioSlot +{ + /// 初始化槽位。 + /// 缺省能力 Id(用例可覆写)。 + public MechanismScenarioSlot(string defaultId) + { + DefaultId = defaultId; + } + + /// 缺省能力 Id。 + public string DefaultId { get; } + + /// 用例覆写的能力 Id(null = 缺省)。 + public string Id; + + /// 必选依赖(缺省空)。 + public string[] Requires = Array.Empty(); + + /// 可选依赖声明(缺省空)。 + public CapabilityOptionalDependency[] Optional = Array.Empty(); + + /// Options 根类型(缺省 null = 不声明)。 + public Type OptionsType; + + /// manifest 声明的 admin action(缺省空)。 + public string[] Actions = Array.Empty(); + + /// Register 期行为(缺省空操作)。 + public Action Register; + + /// Activate 期行为(缺省空操作)。 + public Func Activate; + + /// Deactivate 期行为(缺省空操作)。 + public Action Deactivate; + + /// admin action 注册期行为(缺省空操作)。 + public Action RegisterAdminActions; + + /// + /// 构造当前槽位清单。 + /// + /// 能力清单。 + public CapabilityManifest ToManifest() + { + return new CapabilityManifest(Id ?? DefaultId, "1.0.0", Actions, Requires, Optional, OptionsType); + } +} + +/// +/// 机制层测试场景静态状态(四个槽位;全部机制用例须落 +/// 串行集合,避免静态槽位被并行用例交叉覆写)。 +/// +public static class MechanismScenario +{ + /// 槽位一(类型名定序最先被发现)。 + public static readonly MechanismScenarioSlot First = new("mech-first"); + + /// 槽位二。 + public static readonly MechanismScenarioSlot Second = new("mech-second"); + + /// 槽位三。 + public static readonly MechanismScenarioSlot Third = new("mech-third"); + + /// 槽位四。 + public static readonly MechanismScenarioSlot Fourth = new("mech-fourth"); + + /// + /// 复位全部槽位(用例 finally 调用,保证槽位缺省态不泄漏到后续用例)。 + /// + public static void Reset() + { + Reset(First); + Reset(Second); + Reset(Third); + Reset(Fourth); + MechanismTestFamily.LastEventPublisher = null; + } + + /// + /// 复位单个槽位。 + /// + /// 槽位。 + private static void Reset(MechanismScenarioSlot slot) + { + slot.Id = null; + slot.Requires = Array.Empty(); + slot.Optional = Array.Empty(); + slot.OptionsType = null; + slot.Actions = Array.Empty(); + slot.Register = null; + slot.Activate = null; + slot.Deactivate = null; + slot.RegisterAdminActions = null; + } +} + +/// +/// 机制层测试族(发现面 = 测试程序集的部署命名约定副本 GameFrameX.Tests.CapabilityStubs.dll; +/// 族基建只预置管理面测试适配器—— 的解析面)。 +/// +public sealed class MechanismTestFamily : ICapabilityFamily +{ + /// + public string FamilyId + { + get { return "mechanism-tests"; } + } + + /// + public string ConfigurationSectionName + { + get { return "MechanismTests"; } + } + + /// + public string DeploymentAssemblyPrefix + { + get { return "GameFrameX.Tests"; } + } + + /// 最近一次组合的事件发布口捕获(事件总线行为用例经它发布)。 + public static ICapabilityEventPublisher LastEventPublisher; + + /// + public void RegisterInfrastructure(CapabilityFamilyContext context) + { + // 管理面测试适配器:五个机制用错误码位 + 恒放行作用域(机制口径,不绑 Online 语义)。 + var code = new AdminApiErrorCode(0, "mechanism.test"); + context.AddService(new AdminApiErrorCodes(code, code, code, code, code)); + context.AddService(new MechanismTestScopeAdapter()); + LastEventPublisher = context.EventPublisher; + } +} + +/// +/// 机制测试作用域适配器(从请求体读三元组 + 恒放行——作用域守卫语义另有 Online 侧用例锚定)。 +/// +public sealed class MechanismTestScopeAdapter : IAdminApiScopeAdapter +{ + /// + public bool TryReadClaimedScope(AdminApiRequest request, out AdminApiClaimedScope claimedScope) + { + if (request.TryReadClaimedScope(out var tenantId, out var appId, out var serverId)) + { + claimedScope = new AdminApiClaimedScope(tenantId, appId, serverId); + return true; + } + + claimedScope = null; + return false; + } + + /// + public AdminApiErrorCode ValidateClaimed(AdminApiClaimedScope claimed, AdminApiClaimedScope authorized) + { + return null; + } +} + +/// 槽位一模块(发现序最先)。 +public sealed class MechanismFirstSlotModule : ICapabilityModule, IAdminActionContributor +{ + /// + public CapabilityManifest Manifest + { + get { return MechanismScenario.First.ToManifest(); } + } + + /// + public void Register(CapabilityRegistrationContext context) + { + MechanismScenario.First.Register?.Invoke(context); + } + + /// + public Task ActivateAsync(CapabilityActivationContext context, CancellationToken cancellationToken) + { + return MechanismScenario.First.Activate?.Invoke(context, cancellationToken) ?? Task.CompletedTask; + } + + /// + public Task DeactivateAsync(CancellationToken cancellationToken) + { + MechanismScenario.First.Deactivate?.Invoke(cancellationToken); + return Task.CompletedTask; + } + + /// + public void RegisterAdminActions(AdminActionRegistry registry) + { + MechanismScenario.First.RegisterAdminActions?.Invoke(registry); + } +} + +/// 槽位二模块。 +public sealed class MechanismSecondSlotModule : ICapabilityModule, IAdminActionContributor +{ + /// + public CapabilityManifest Manifest + { + get { return MechanismScenario.Second.ToManifest(); } + } + + /// + public void Register(CapabilityRegistrationContext context) + { + MechanismScenario.Second.Register?.Invoke(context); + } + + /// + public Task ActivateAsync(CapabilityActivationContext context, CancellationToken cancellationToken) + { + return MechanismScenario.Second.Activate?.Invoke(context, cancellationToken) ?? Task.CompletedTask; + } + + /// + public Task DeactivateAsync(CancellationToken cancellationToken) + { + MechanismScenario.Second.Deactivate?.Invoke(cancellationToken); + return Task.CompletedTask; + } + + /// + public void RegisterAdminActions(AdminActionRegistry registry) + { + MechanismScenario.Second.RegisterAdminActions?.Invoke(registry); + } +} + +/// 槽位三模块。 +public sealed class MechanismThirdSlotModule : ICapabilityModule, IAdminActionContributor +{ + /// + public CapabilityManifest Manifest + { + get { return MechanismScenario.Third.ToManifest(); } + } + + /// + public void Register(CapabilityRegistrationContext context) + { + MechanismScenario.Third.Register?.Invoke(context); + } + + /// + public Task ActivateAsync(CapabilityActivationContext context, CancellationToken cancellationToken) + { + return MechanismScenario.Third.Activate?.Invoke(context, cancellationToken) ?? Task.CompletedTask; + } + + /// + public Task DeactivateAsync(CancellationToken cancellationToken) + { + MechanismScenario.Third.Deactivate?.Invoke(cancellationToken); + return Task.CompletedTask; + } + + /// + public void RegisterAdminActions(AdminActionRegistry registry) + { + MechanismScenario.Third.RegisterAdminActions?.Invoke(registry); + } +} + +/// 槽位四模块。 +public sealed class MechanismFourthSlotModule : ICapabilityModule, IAdminActionContributor +{ + /// + public CapabilityManifest Manifest + { + get { return MechanismScenario.Fourth.ToManifest(); } + } + + /// + public void Register(CapabilityRegistrationContext context) + { + MechanismScenario.Fourth.Register?.Invoke(context); + } + + /// + public Task ActivateAsync(CapabilityActivationContext context, CancellationToken cancellationToken) + { + return MechanismScenario.Fourth.Activate?.Invoke(context, cancellationToken) ?? Task.CompletedTask; + } + + /// + public Task DeactivateAsync(CancellationToken cancellationToken) + { + MechanismScenario.Fourth.Deactivate?.Invoke(cancellationToken); + return Task.CompletedTask; + } + + /// + public void RegisterAdminActions(AdminActionRegistry registry) + { + MechanismScenario.Fourth.RegisterAdminActions?.Invoke(registry); + } +} diff --git a/Tests/GameFrameX.Tests/Online/OnlineAdminApiDispatcherTests.cs b/Tests/GameFrameX.Tests/Online/OnlineAdminApiDispatcherTests.cs index 03f91e8f4..b9cf47cea 100644 --- a/Tests/GameFrameX.Tests/Online/OnlineAdminApiDispatcherTests.cs +++ b/Tests/GameFrameX.Tests/Online/OnlineAdminApiDispatcherTests.cs @@ -24,43 +24,39 @@ // Official Documentation: https://gameframex.doc.alianblank.com/ // ========================================================================================== +using System; +using System.Linq; using System.Text.Json; +using System.Threading.Tasks; using GameFrameX.Capability.Hosting.AdminApi; +using GameFrameX.Capability.Hosting.Core; using GameFrameX.Capability.Online.Abstractions.Contracts; -using GameFrameX.Launcher.Online; +using Xunit; namespace GameFrameX.Tests.Online { /// /// Online admin API 调度器契约测试(双层信封、协议码、作用域守卫、幂等 Replay 透传与发放→账本→明细往返; - /// 对齐 Admin 侧 OnlineServerClient 线缆契约)。 + /// 对齐 Admin 侧 OnlineServerClient 线缆契约)。C294 起调度器经真实族组合() + /// 后由 按机制同口径组装;业务面先启动(事件总线 Activate 前拒绝发布)。 /// + [Collection(CapabilityRuntimeCollection.Name)] public class OnlineAdminApiDispatcherTests { /// - /// 构造授权作用域为 租户 1 / 应用 1 / 区服 1001 的组合产物(C232 P6 起真扫描装配)。 + /// 组合并启动 Online 族(授权作用域 租户 1 / 应用 1 / 区服 1001),以调度器执行用例主体,结束后恒停止。 /// - /// 组合产物。 - private static OnlineCapabilityComposition CreateComposition() + /// 用例主体(入参为调度器)。 + /// 异步任务。 + private static Task WithDispatcherAsync(Func body) { - return OnlineCapabilityComposition.AddOnlineCapabilities(options => + return OnlineCapabilityTestHarness.WithRuntimeAsync(async runtime => { - options.TenantId = 1; - options.AppId = 1; - options.ServerId = 1001; - options.EnableRuntime = true; + await runtime.Host.StartAsync(); + await body(OnlineCapabilityTestHarness.CreateDispatcher(runtime.Host)); }); } - /// - /// 构造经注册反转组装的调度器(29 action + list_capabilities 元端点)。 - /// - /// 调度器。 - private static AdminApiDispatcher CreateDispatcher() - { - return OnlineAdminApiComposition.CreateDispatcher(CreateComposition().Runtime); - } - /// /// 解析外层信封(Foundation camelCase 形态),取内层 JSON 字符串(data 字段)。 /// @@ -91,23 +87,42 @@ private static void AssertInnerCode(string innerJson, OnlineErrorCode expectedCo } /// - /// 验证 35 个业务 action(C256 后 = 29 + hotfix 受控操作族 4 + C245 结算 / 榜单查询 2)全部经注册反转注册,且 list_capabilities 元端点在列。 + /// 验证注册表 = 七能力 manifest 声明的全部业务 action + 三个机制元端点(双向对账后两边严格相等)。 /// [Fact] - public void ActionNames_ShouldRegisterAllThirtyThreeActionsPlusMeta() + public async Task ActionNames_ShouldRegisterAllThirtyFiveDeclaredActionsPlusThreeMetaEndpoints() { - var dispatcher = CreateDispatcher(); - Assert.Equal(36, dispatcher.ActionNames.Count); - Assert.Contains("query_match_settlement", dispatcher.ActionNames); - Assert.Contains("query_leaderboard", dispatcher.ActionNames); - Assert.Contains("grant_asset", dispatcher.ActionNames); - Assert.Contains("rollbackConfigRollout", dispatcher.ActionNames); - Assert.Contains("query_online_audit_events", dispatcher.ActionNames); - Assert.Contains("register_hotfix_manifest", dispatcher.ActionNames); - Assert.Contains("activate_hotfix_version", dispatcher.ActionNames); - Assert.Contains("check_hotfix_compatibility", dispatcher.ActionNames); - Assert.Contains("rollback_hotfix_version", dispatcher.ActionNames); - Assert.Contains("list_capabilities", dispatcher.ActionNames); + await OnlineCapabilityTestHarness.WithRuntimeAsync(runtime => + { + var dispatcher = OnlineCapabilityTestHarness.CreateDispatcher(runtime.Host); + + // 业务 action 35 = assets 5(grant_asset / revoke_asset / query_wallet_ledger / query_asset_audit / query_transaction_detail) + // + foundation 1(suspend_resume_player) + // + social 8(kick_player / revoke_token / mute_player / query_report_cases / update_report_case / + // send_admin_notification / query_chat_messages / match_isolation) + // + matchmaking 4(query_match_queue_overview / query_match_ticket_failures / query_match_abnormal_tickets / cancel_match_ticket) + // + match 2(end_abnormal_match / query_match_settlement) + // + competition 8(LiveOps 7 + query_leaderboard) + // + operations 7(query_online_overview / query_player_timeline / query_online_audit_events + hotfix 受控操作族 4); + // 机制元端点 3:list_capabilities + list_capability_settings(调度器持有报告)+ list_capability_services(调度器持有服务面)。 + var declaredActions = runtime.Report.Entries.SelectMany(entry => entry.Manifest.Actions).ToList(); + Assert.Equal(35, declaredActions.Count); + Assert.Equal(declaredActions.Count + 3, dispatcher.ActionNames.Count); + Assert.Equal(38, dispatcher.ActionNames.Count); + foreach (var action in declaredActions) + { + Assert.Contains(action, dispatcher.ActionNames); + } + + Assert.Contains("list_capabilities", dispatcher.ActionNames); + Assert.Contains("list_capability_settings", dispatcher.ActionNames); + Assert.Contains("list_capability_services", dispatcher.ActionNames); + Assert.Contains("query_match_settlement", dispatcher.ActionNames); + Assert.Contains("query_leaderboard", dispatcher.ActionNames); + Assert.Contains("rollbackConfigRollout", dispatcher.ActionNames); + Assert.Contains("rollback_hotfix_version", dispatcher.ActionNames); + return Task.CompletedTask; + }); } /// @@ -116,9 +131,11 @@ public void ActionNames_ShouldRegisterAllThirtyThreeActionsPlusMeta() [Fact] public async Task Dispatch_UnknownAction_ShouldReturnInner4002() { - var dispatcher = CreateDispatcher(); - var envelope = await dispatcher.DispatchAsync("definitely_not_an_action", "{\"TenantId\":1,\"AppId\":1,\"ServerId\":1001}"); - AssertInnerCode(ReadInnerJson(envelope), OnlineErrorCode.ResourceNotFound); + await WithDispatcherAsync(async dispatcher => + { + var envelope = await dispatcher.DispatchAsync("definitely_not_an_action", "{\"TenantId\":1,\"AppId\":1,\"ServerId\":1001}"); + AssertInnerCode(ReadInnerJson(envelope), OnlineErrorCode.ResourceNotFound); + }); } /// @@ -127,9 +144,11 @@ public async Task Dispatch_UnknownAction_ShouldReturnInner4002() [Fact] public async Task Dispatch_MalformedJson_ShouldReturnInner4001() { - var dispatcher = CreateDispatcher(); - var envelope = await dispatcher.DispatchAsync("grant_asset", "{\"GrantItems\": [oops"); - AssertInnerCode(ReadInnerJson(envelope), OnlineErrorCode.ParameterInvalid); + await WithDispatcherAsync(async dispatcher => + { + var envelope = await dispatcher.DispatchAsync("grant_asset", "{\"GrantItems\": [oops"); + AssertInnerCode(ReadInnerJson(envelope), OnlineErrorCode.ParameterInvalid); + }); } /// @@ -138,9 +157,11 @@ public async Task Dispatch_MalformedJson_ShouldReturnInner4001() [Fact] public async Task Dispatch_MissingScope_ShouldReturnInner3005() { - var dispatcher = CreateDispatcher(); - var envelope = await dispatcher.DispatchAsync("query_online_overview", "{}"); - AssertInnerCode(ReadInnerJson(envelope), OnlineErrorCode.ScopeMissing); + await WithDispatcherAsync(async dispatcher => + { + var envelope = await dispatcher.DispatchAsync("query_online_overview", "{}"); + AssertInnerCode(ReadInnerJson(envelope), OnlineErrorCode.ScopeMissing); + }); } /// @@ -154,9 +175,11 @@ public async Task Dispatch_MissingScope_ShouldReturnInner3005() [InlineData("{\"TenantId\":1,\"AppId\":1,\"ServerId\":2002}", OnlineErrorCode.ServerScopeDenied)] public async Task Dispatch_ScopeMismatch_ShouldReturnGuardCode(string body, OnlineErrorCode expectedCode) { - var dispatcher = CreateDispatcher(); - var envelope = await dispatcher.DispatchAsync("query_online_overview", body); - AssertInnerCode(ReadInnerJson(envelope), expectedCode); + await WithDispatcherAsync(async dispatcher => + { + var envelope = await dispatcher.DispatchAsync("query_online_overview", body); + AssertInnerCode(ReadInnerJson(envelope), expectedCode); + }); } /// @@ -165,45 +188,47 @@ public async Task Dispatch_ScopeMismatch_ShouldReturnGuardCode(string body, Onli [Fact] public async Task Dispatch_GrantAsset_ShouldRoundTripThroughLedgerAndDetail() { - var dispatcher = CreateDispatcher(); - var grantEnvelope = await dispatcher.DispatchAsync("grant_asset", - "{\"TenantId\":1,\"AppId\":1,\"ServerId\":1001,\"PlayerId\":\"9001\",\"Reason\":\"admin-test\",\"IdempotencyKey\":\"GFX-GRANT-test-1\",\"GrantItems\":[{\"ItemOrCurrencyType\":\"Coin\",\"Quantity\":100}]}"); - string transactionId; - using (var document = JsonDocument.Parse(ReadInnerJson(grantEnvelope))) + await WithDispatcherAsync(async dispatcher => { - var root = document.RootElement; - Assert.Equal(0, root.GetProperty("Code").GetInt32()); - var data = root.GetProperty("Data"); - Assert.Equal(JsonValueKind.Object, data.ValueKind); - transactionId = data.GetProperty("TransactionId").GetString(); - Assert.False(string.IsNullOrEmpty(transactionId)); - Assert.Equal("GFX-GRANT-test-1", data.GetProperty("IdempotencyKey").GetString()); - Assert.Equal("Succeeded", data.GetProperty("Status").GetString()); - } + var grantEnvelope = await dispatcher.DispatchAsync("grant_asset", + "{\"TenantId\":1,\"AppId\":1,\"ServerId\":1001,\"PlayerId\":\"9001\",\"Reason\":\"admin-test\",\"IdempotencyKey\":\"GFX-GRANT-test-1\",\"GrantItems\":[{\"ItemOrCurrencyType\":\"Coin\",\"Quantity\":100}]}"); + string transactionId; + using (var document = JsonDocument.Parse(ReadInnerJson(grantEnvelope))) + { + var root = document.RootElement; + Assert.Equal(0, root.GetProperty("Code").GetInt32()); + var data = root.GetProperty("Data"); + Assert.Equal(JsonValueKind.Object, data.ValueKind); + transactionId = data.GetProperty("TransactionId").GetString(); + Assert.False(string.IsNullOrEmpty(transactionId)); + Assert.Equal("GFX-GRANT-test-1", data.GetProperty("IdempotencyKey").GetString()); + Assert.Equal("Succeeded", data.GetProperty("Status").GetString()); + } - var ledgerEnvelope = await dispatcher.DispatchAsync("query_wallet_ledger", - "{\"TenantId\":1,\"AppId\":1,\"ServerId\":1001,\"PlayerId\":\"9001\",\"PageSize\":20}"); - using (var ledgerDocument = JsonDocument.Parse(ReadInnerJson(ledgerEnvelope))) - { - var items = ledgerDocument.RootElement.GetProperty("Data").GetProperty("Items"); - Assert.Equal(JsonValueKind.Array, items.ValueKind); - Assert.True(items.GetArrayLength() > 0); - Assert.Equal(transactionId, items[0].GetProperty("TransactionId").GetString()); - Assert.Equal("9001", items[0].GetProperty("PlayerId").GetString()); - Assert.Equal("Coin", items[0].GetProperty("CurrencyOrItemType").GetString()); - } + var ledgerEnvelope = await dispatcher.DispatchAsync("query_wallet_ledger", + "{\"TenantId\":1,\"AppId\":1,\"ServerId\":1001,\"PlayerId\":\"9001\",\"PageSize\":20}"); + using (var ledgerDocument = JsonDocument.Parse(ReadInnerJson(ledgerEnvelope))) + { + var items = ledgerDocument.RootElement.GetProperty("Data").GetProperty("Items"); + Assert.Equal(JsonValueKind.Array, items.ValueKind); + Assert.True(items.GetArrayLength() > 0); + Assert.Equal(transactionId, items[0].GetProperty("TransactionId").GetString()); + Assert.Equal("9001", items[0].GetProperty("PlayerId").GetString()); + Assert.Equal("Coin", items[0].GetProperty("CurrencyOrItemType").GetString()); + } - var detailEnvelope = await dispatcher.DispatchAsync("query_transaction_detail", - "{\"TenantId\":1,\"AppId\":1,\"ServerId\":1001,\"TransactionId\":\"" + transactionId + "\"}"); - using (var detailDocument = JsonDocument.Parse(ReadInnerJson(detailEnvelope))) - { - var data = detailDocument.RootElement.GetProperty("Data"); - Assert.Equal("GFX-GRANT-test-1", data.GetProperty("IdempotencyKey").GetString()); - var grantItems = data.GetProperty("GrantItems"); - Assert.Equal(1, grantItems.GetArrayLength()); - Assert.Equal("Coin", grantItems[0].GetProperty("ItemOrCurrencyType").GetString()); - Assert.Equal(100, grantItems[0].GetProperty("Quantity").GetInt64()); - } + var detailEnvelope = await dispatcher.DispatchAsync("query_transaction_detail", + "{\"TenantId\":1,\"AppId\":1,\"ServerId\":1001,\"TransactionId\":\"" + transactionId + "\"}"); + using (var detailDocument = JsonDocument.Parse(ReadInnerJson(detailEnvelope))) + { + var data = detailDocument.RootElement.GetProperty("Data"); + Assert.Equal("GFX-GRANT-test-1", data.GetProperty("IdempotencyKey").GetString()); + var grantItems = data.GetProperty("GrantItems"); + Assert.Equal(1, grantItems.GetArrayLength()); + Assert.Equal("Coin", grantItems[0].GetProperty("ItemOrCurrencyType").GetString()); + Assert.Equal(100, grantItems[0].GetProperty("Quantity").GetInt64()); + } + }); } /// @@ -212,10 +237,12 @@ public async Task Dispatch_GrantAsset_ShouldRoundTripThroughLedgerAndDetail() [Fact] public async Task Dispatch_GrantAsset_WithoutPlayer_ShouldReturnInner4001() { - var dispatcher = CreateDispatcher(); - var envelope = await dispatcher.DispatchAsync("grant_asset", - "{\"TenantId\":1,\"AppId\":1,\"ServerId\":1001,\"Reason\":\"admin-test\",\"GrantItems\":[{\"ItemOrCurrencyType\":\"Coin\",\"Quantity\":100}]}"); - AssertInnerCode(ReadInnerJson(envelope), OnlineErrorCode.ParameterInvalid); + await WithDispatcherAsync(async dispatcher => + { + var envelope = await dispatcher.DispatchAsync("grant_asset", + "{\"TenantId\":1,\"AppId\":1,\"ServerId\":1001,\"Reason\":\"admin-test\",\"GrantItems\":[{\"ItemOrCurrencyType\":\"Coin\",\"Quantity\":100}]}"); + AssertInnerCode(ReadInnerJson(envelope), OnlineErrorCode.ParameterInvalid); + }); } /// @@ -224,17 +251,19 @@ public async Task Dispatch_GrantAsset_WithoutPlayer_ShouldReturnInner4001() [Fact] public async Task Dispatch_MutePlayer_TwiceWithSameKey_ShouldReplayFirstResponse() { - var dispatcher = CreateDispatcher(); - const string body = "{\"TenantId\":1,\"AppId\":1,\"ServerId\":1001,\"PlayerId\":\"9002\",\"PenaltyId\":77,\"IdempotencyKey\":\"penalty-77-mute-1001\"}"; - var first = await dispatcher.DispatchAsync("mute_player", body); - var second = await dispatcher.DispatchAsync("mute_player", body); - Assert.Equal(first, second); - using (var document = JsonDocument.Parse(ReadInnerJson(first))) + await WithDispatcherAsync(async dispatcher => { - var root = document.RootElement; - Assert.Equal(0, root.GetProperty("Code").GetInt32()); - Assert.True(root.GetProperty("Data").GetProperty("TokenRevokedConfirmed").GetBoolean()); - } + const string body = "{\"TenantId\":1,\"AppId\":1,\"ServerId\":1001,\"PlayerId\":\"9002\",\"PenaltyId\":77,\"IdempotencyKey\":\"penalty-77-mute-1001\"}"; + var first = await dispatcher.DispatchAsync("mute_player", body); + var second = await dispatcher.DispatchAsync("mute_player", body); + Assert.Equal(first, second); + using (var document = JsonDocument.Parse(ReadInnerJson(first))) + { + var root = document.RootElement; + Assert.Equal(0, root.GetProperty("Code").GetInt32()); + Assert.True(root.GetProperty("Data").GetProperty("TokenRevokedConfirmed").GetBoolean()); + } + }); } /// @@ -243,15 +272,17 @@ public async Task Dispatch_MutePlayer_TwiceWithSameKey_ShouldReplayFirstResponse [Fact] public async Task Dispatch_KickPlayer_WithoutSessions_ShouldConfirm() { - var dispatcher = CreateDispatcher(); - var envelope = await dispatcher.DispatchAsync("kick_player", - "{\"TenantId\":1,\"AppId\":1,\"ServerId\":1001,\"PlayerId\":\"9003\",\"PenaltyId\":78,\"IdempotencyKey\":\"penalty-78-kick-1001\"}"); - using (var document = JsonDocument.Parse(ReadInnerJson(envelope))) + await WithDispatcherAsync(async dispatcher => { - var root = document.RootElement; - Assert.Equal(0, root.GetProperty("Code").GetInt32()); - Assert.True(root.GetProperty("Data").GetProperty("TokenRevokedConfirmed").GetBoolean()); - } + var envelope = await dispatcher.DispatchAsync("kick_player", + "{\"TenantId\":1,\"AppId\":1,\"ServerId\":1001,\"PlayerId\":\"9003\",\"PenaltyId\":78,\"IdempotencyKey\":\"penalty-78-kick-1001\"}"); + using (var document = JsonDocument.Parse(ReadInnerJson(envelope))) + { + var root = document.RootElement; + Assert.Equal(0, root.GetProperty("Code").GetInt32()); + Assert.True(root.GetProperty("Data").GetProperty("TokenRevokedConfirmed").GetBoolean()); + } + }); } /// @@ -260,20 +291,22 @@ public async Task Dispatch_KickPlayer_WithoutSessions_ShouldConfirm() [Fact] public async Task Dispatch_QueryOnlineOverview_ShouldReturnCounters() { - var dispatcher = CreateDispatcher(); - var envelope = await dispatcher.DispatchAsync("query_online_overview", "{\"TenantId\":1,\"AppId\":1,\"ServerId\":1001}"); - using (var document = JsonDocument.Parse(ReadInnerJson(envelope))) + await WithDispatcherAsync(async dispatcher => { - var root = document.RootElement; - Assert.Equal(0, root.GetProperty("Code").GetInt32()); - var data = root.GetProperty("Data"); - Assert.Equal(JsonValueKind.Object, data.ValueKind); - Assert.True(data.GetProperty("OnlinePlayerCount").GetInt64() >= 0); - Assert.True(data.GetProperty("SessionCount").GetInt64() >= 0); - Assert.True(data.GetProperty("MatchCount").GetInt64() >= 0); - Assert.Equal(JsonValueKind.Array, data.GetProperty("QueueSummaries").ValueKind); - Assert.True(data.GetProperty("ServerTime").GetInt64() > 0); - } + var envelope = await dispatcher.DispatchAsync("query_online_overview", "{\"TenantId\":1,\"AppId\":1,\"ServerId\":1001}"); + using (var document = JsonDocument.Parse(ReadInnerJson(envelope))) + { + var root = document.RootElement; + Assert.Equal(0, root.GetProperty("Code").GetInt32()); + var data = root.GetProperty("Data"); + Assert.Equal(JsonValueKind.Object, data.ValueKind); + Assert.True(data.GetProperty("OnlinePlayerCount").GetInt64() >= 0); + Assert.True(data.GetProperty("SessionCount").GetInt64() >= 0); + Assert.True(data.GetProperty("MatchCount").GetInt64() >= 0); + Assert.Equal(JsonValueKind.Array, data.GetProperty("QueueSummaries").ValueKind); + Assert.True(data.GetProperty("ServerTime").GetInt64() > 0); + } + }); } /// @@ -282,10 +315,12 @@ public async Task Dispatch_QueryOnlineOverview_ShouldReturnCounters() [Fact] public async Task Dispatch_EndAbnormalMatch_ShouldReturnInner5003() { - var dispatcher = CreateDispatcher(); - var envelope = await dispatcher.DispatchAsync("end_abnormal_match", - "{\"TenantId\":1,\"AppId\":1,\"ServerId\":1001,\"MatchId\":\"m-1\",\"Reason\":\"test\",\"ReviewerOperatorId\":\"admin\",\"IdempotencyKey\":\"onlineControlled-end_abnormal_match-m-1-1001\"}"); - AssertInnerCode(ReadInnerJson(envelope), OnlineErrorCode.StateOperationForbidden); + await WithDispatcherAsync(async dispatcher => + { + var envelope = await dispatcher.DispatchAsync("end_abnormal_match", + "{\"TenantId\":1,\"AppId\":1,\"ServerId\":1001,\"MatchId\":\"m-1\",\"Reason\":\"test\",\"ReviewerOperatorId\":\"admin\",\"IdempotencyKey\":\"onlineControlled-end_abnormal_match-m-1-1001\"}"); + AssertInnerCode(ReadInnerJson(envelope), OnlineErrorCode.StateOperationForbidden); + }); } /// @@ -294,15 +329,17 @@ public async Task Dispatch_EndAbnormalMatch_ShouldReturnInner5003() [Fact] public async Task Dispatch_QueryPlayerTimeline_Empty_ShouldReturnEmptyEvents() { - var dispatcher = CreateDispatcher(); - var envelope = await dispatcher.DispatchAsync("query_player_timeline", - "{\"TenantId\":1,\"AppId\":1,\"ServerId\":1001,\"PlayerId\":\"9004\"}"); - using (var document = JsonDocument.Parse(ReadInnerJson(envelope))) + await WithDispatcherAsync(async dispatcher => { - var data = document.RootElement.GetProperty("Data"); - Assert.Equal(0, data.GetProperty("Events").GetArrayLength()); - Assert.False(data.GetProperty("HasMore").GetBoolean()); - } + var envelope = await dispatcher.DispatchAsync("query_player_timeline", + "{\"TenantId\":1,\"AppId\":1,\"ServerId\":1001,\"PlayerId\":\"9004\"}"); + using (var document = JsonDocument.Parse(ReadInnerJson(envelope))) + { + var data = document.RootElement.GetProperty("Data"); + Assert.Equal(0, data.GetProperty("Events").GetArrayLength()); + Assert.False(data.GetProperty("HasMore").GetBoolean()); + } + }); } /// @@ -311,17 +348,19 @@ public async Task Dispatch_QueryPlayerTimeline_Empty_ShouldReturnEmptyEvents() [Fact] public async Task Dispatch_PublishRemoteConfig_ShouldReturnConfirmVersion() { - var dispatcher = CreateDispatcher(); - var envelope = await dispatcher.DispatchAsync("publishRemoteConfig", - "{\"TenantId\":1,\"AppId\":1,\"ServerId\":1001,\"ConfigId\":5001,\"Reason\":\"admin-test\"}"); - using (var document = JsonDocument.Parse(ReadInnerJson(envelope))) + await WithDispatcherAsync(async dispatcher => { - var root = document.RootElement; - Assert.Equal(0, root.GetProperty("Code").GetInt32()); - var data = root.GetProperty("Data"); - Assert.False(string.IsNullOrEmpty(data.GetProperty("ConfirmVersion").GetString())); - Assert.True(data.GetProperty("ServerTime").GetInt64() > 0); - } + var envelope = await dispatcher.DispatchAsync("publishRemoteConfig", + "{\"TenantId\":1,\"AppId\":1,\"ServerId\":1001,\"ConfigId\":5001,\"Reason\":\"admin-test\"}"); + using (var document = JsonDocument.Parse(ReadInnerJson(envelope))) + { + var root = document.RootElement; + Assert.Equal(0, root.GetProperty("Code").GetInt32()); + var data = root.GetProperty("Data"); + Assert.False(string.IsNullOrEmpty(data.GetProperty("ConfirmVersion").GetString())); + Assert.True(data.GetProperty("ServerTime").GetInt64() > 0); + } + }); } /// @@ -330,10 +369,12 @@ public async Task Dispatch_PublishRemoteConfig_ShouldReturnConfirmVersion() [Fact] public async Task Dispatch_SuspendResumePlayer_WithoutDirection_ShouldReturnInner4001() { - var dispatcher = CreateDispatcher(); - var envelope = await dispatcher.DispatchAsync("suspend_resume_player", - "{\"TenantId\":1,\"AppId\":1,\"ServerId\":1001,\"PlayerId\":\"9005\",\"Reason\":\"test\",\"ReviewerOperatorId\":\"admin\",\"IdempotencyKey\":\"onlineControlled-suspend_resume_player-9005-1001\"}"); - AssertInnerCode(ReadInnerJson(envelope), OnlineErrorCode.ParameterInvalid); + await WithDispatcherAsync(async dispatcher => + { + var envelope = await dispatcher.DispatchAsync("suspend_resume_player", + "{\"TenantId\":1,\"AppId\":1,\"ServerId\":1001,\"PlayerId\":\"9005\",\"Reason\":\"test\",\"ReviewerOperatorId\":\"admin\",\"IdempotencyKey\":\"onlineControlled-suspend_resume_player-9005-1001\"}"); + AssertInnerCode(ReadInnerJson(envelope), OnlineErrorCode.ParameterInvalid); + }); } /// @@ -342,14 +383,16 @@ public async Task Dispatch_SuspendResumePlayer_WithoutDirection_ShouldReturnInne [Fact] public async Task Dispatch_QueryReportCases_Empty_ShouldReturnEmptyItems() { - var dispatcher = CreateDispatcher(); - var envelope = await dispatcher.DispatchAsync("query_report_cases", "{\"TenantId\":1,\"AppId\":1,\"ServerId\":1001}"); - using (var document = JsonDocument.Parse(ReadInnerJson(envelope))) + await WithDispatcherAsync(async dispatcher => { - var data = document.RootElement.GetProperty("Data"); - Assert.Equal(0, data.GetProperty("Items").GetArrayLength()); - Assert.False(data.GetProperty("HasMore").GetBoolean()); - } + var envelope = await dispatcher.DispatchAsync("query_report_cases", "{\"TenantId\":1,\"AppId\":1,\"ServerId\":1001}"); + using (var document = JsonDocument.Parse(ReadInnerJson(envelope))) + { + var data = document.RootElement.GetProperty("Data"); + Assert.Equal(0, data.GetProperty("Items").GetArrayLength()); + Assert.False(data.GetProperty("HasMore").GetBoolean()); + } + }); } /// @@ -358,14 +401,16 @@ public async Task Dispatch_QueryReportCases_Empty_ShouldReturnEmptyItems() [Fact] public async Task Dispatch_QueryMatchQueueOverview_ShouldReturnQueuesArray() { - var dispatcher = CreateDispatcher(); - var envelope = await dispatcher.DispatchAsync("query_match_queue_overview", "{\"TenantId\":1,\"AppId\":1,\"ServerId\":1001}"); - using (var document = JsonDocument.Parse(ReadInnerJson(envelope))) + await WithDispatcherAsync(async dispatcher => { - var data = document.RootElement.GetProperty("Data"); - Assert.Equal(JsonValueKind.Array, data.GetProperty("Queues").ValueKind); - Assert.True(data.GetProperty("ServerTime").GetInt64() > 0); - } + var envelope = await dispatcher.DispatchAsync("query_match_queue_overview", "{\"TenantId\":1,\"AppId\":1,\"ServerId\":1001}"); + using (var document = JsonDocument.Parse(ReadInnerJson(envelope))) + { + var data = document.RootElement.GetProperty("Data"); + Assert.Equal(JsonValueKind.Array, data.GetProperty("Queues").ValueKind); + Assert.True(data.GetProperty("ServerTime").GetInt64() > 0); + } + }); } } } diff --git a/Tests/GameFrameX.Tests/Online/OnlineAdminApiRateLimitTests.cs b/Tests/GameFrameX.Tests/Online/OnlineAdminApiRateLimitTests.cs index afe20e7cd..8fbca888d 100644 --- a/Tests/GameFrameX.Tests/Online/OnlineAdminApiRateLimitTests.cs +++ b/Tests/GameFrameX.Tests/Online/OnlineAdminApiRateLimitTests.cs @@ -27,10 +27,13 @@ // Official Documentation: https://gameframex.doc.alianblank.com/ // ========================================================================================== +using System; using System.Net; +using System.Net.Http; using System.Text; +using System.Threading.Tasks; using GameFrameX.Capability.Hosting.AdminApi; -using GameFrameX.Launcher.Online; +using GameFrameX.Capability.Hosting.Core; using Xunit; namespace GameFrameX.Tests.Online; @@ -41,8 +44,10 @@ namespace GameFrameX.Tests.Online; /// /// 真实 Kestrel 监听:默认启用形态下同 IP 超过窗口许可数得 429(登录爆破/重放/洪泛闸门); /// PermitLimit <= 0 显式关闭时行为与未引入限流前等价(全部 200)。端口 28191 为测试专用。 +/// C294 起管理面经 UseAdminApi 组合进运行时(限流参数同一回调写入)。 /// -public class OnlineAdminApiRateLimitTests : IDisposable +[Collection(CapabilityRuntimeCollection.Name)] +public class OnlineAdminApiRateLimitTests { /// /// HTTP 客户端(共享连接池)。 @@ -54,28 +59,22 @@ public class OnlineAdminApiRateLimitTests : IDisposable /// private const int TestPort = 28191; - /// - /// 当前测试的 HTTP 服务实例。 - /// - private AdminApiServer _server; - /// /// 默认限流形态:PermitLimit=2 时第 3 个请求(同窗口同 IP)应得 429。 /// [Fact] public async Task StartAsync_RateLimitExceeded_ShouldReturn429() { - _server = OnlineAdminApiComposition.CreateServer(CreateComposition().Runtime, BuildAdminOptions(2, 60)); - await _server.StartAsync(); - - var content = new StringContent("{\"TenantId\":1,\"AppId\":1,\"ServerId\":1001}", Encoding.UTF8, "application/json"); - var first = await SharedHttpClient.PostAsync(Url, content); - var second = await SharedHttpClient.PostAsync(Url, content); - var third = await SharedHttpClient.PostAsync(Url, content); + await WithStartedAdminAsync(2, 60, async () => + { + var first = await PostAsync(); + var second = await PostAsync(); + var third = await PostAsync(); - Assert.Equal(HttpStatusCode.OK, first.StatusCode); - Assert.Equal(HttpStatusCode.OK, second.StatusCode); - Assert.Equal(HttpStatusCode.TooManyRequests, third.StatusCode); + Assert.Equal(HttpStatusCode.OK, first.StatusCode); + Assert.Equal(HttpStatusCode.OK, second.StatusCode); + Assert.Equal(HttpStatusCode.TooManyRequests, third.StatusCode); + }); } /// @@ -84,15 +83,14 @@ public async Task StartAsync_RateLimitExceeded_ShouldReturn429() [Fact] public async Task StartAsync_RateLimitDisabled_ShouldServeAllRequests() { - _server = OnlineAdminApiComposition.CreateServer(CreateComposition().Runtime, BuildAdminOptions(0, 10)); - await _server.StartAsync(); - - for (var i = 0; i < 5; i++) + await WithStartedAdminAsync(0, 10, async () => { - var content = new StringContent("{\"TenantId\":1,\"AppId\":1,\"ServerId\":1001}", Encoding.UTF8, "application/json"); - var response = await SharedHttpClient.PostAsync(Url, content); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - } + for (var i = 0; i < 5; i++) + { + var response = await PostAsync(); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + }); } /// @@ -117,22 +115,44 @@ private static string Url } /// - /// 构造 Online 单入口组合产物(业务运行时形态;授权三元组 1/1/1001)。 + /// 向测试端口 POST 一次(作用域三元组合法)。 + /// + /// HTTP 响应。 + private static Task PostAsync() + { + var content = new StringContent("{\"TenantId\":1,\"AppId\":1,\"ServerId\":1001}", Encoding.UTF8, "application/json"); + return SharedHttpClient.PostAsync(Url, content); + } + + /// + /// 组合 Online 族并启用管理面(测试端口 28191 + 限流参数),启动后执行用例主体,结束后恒停止。 /// - /// 组合产物。 - private static OnlineCapabilityComposition CreateComposition() + /// 窗口许可数。 + /// 窗口秒数。 + /// 用例主体。 + /// 异步任务。 + private static async Task WithStartedAdminAsync(int permitLimit, int windowSeconds, Func body) { - return OnlineCapabilityComposition.AddOnlineCapabilities(options => + var runtime = OnlineCapabilityTestHarness.Compose(null, options => options.UseAdminApi(adminOptions => { - options.TenantId = 1; - options.AppId = 1; - options.ServerId = 1001; - options.EnableRuntime = true; - }); + adminOptions.AdminPort = TestPort; + adminOptions.AdminApiPrefix = "online/admin"; + adminOptions.RateLimitPermitLimit = permitLimit; + adminOptions.RateLimitWindowSeconds = windowSeconds; + })); + try + { + await runtime.StartAsync(); + await body(); + } + finally + { + await runtime.StopAsync(); + } } /// - /// 构造管理面选项(测试端口 28191 + 限流参数)。 + /// 构造管理面选项(测试端口 28191 + 限流参数;仅供选项校验用例)。 /// /// 窗口许可数。 /// 窗口秒数。 @@ -147,15 +167,4 @@ private static AdminApiOptions BuildAdminOptions(int permitLimit, int windowSeco RateLimitWindowSeconds = windowSeconds, }; } - - /// - /// 释放测试服务(优雅停机)。 - /// - public void Dispose() - { - if (_server != null) - { - _server.StopAsync().GetAwaiter().GetResult(); - } - } } diff --git a/Tests/GameFrameX.Tests/Online/OnlineAdminApiServerTests.cs b/Tests/GameFrameX.Tests/Online/OnlineAdminApiServerTests.cs index 53b88b7b7..947b45980 100644 --- a/Tests/GameFrameX.Tests/Online/OnlineAdminApiServerTests.cs +++ b/Tests/GameFrameX.Tests/Online/OnlineAdminApiServerTests.cs @@ -24,22 +24,27 @@ // Official Documentation: https://gameframex.doc.alianblank.com/ // ========================================================================================== +using System; using System.Net; using System.Net.Http; using System.Text; using System.Text.Json; +using System.Threading.Tasks; using GameFrameX.Capability.Hosting.AdminApi; +using GameFrameX.Capability.Hosting.Core; using GameFrameX.Capability.Online.Abstractions.Contracts; -using GameFrameX.Launcher.Online; +using Xunit; namespace GameFrameX.Tests.Online { /// /// Online admin HTTP 服务冒烟测试(真实 Kestrel 监听:路由前缀、POST 往返、双层信封与优雅停机; /// Admin 侧 IOnlineServerClient 寻址 {HttpManageUrl}/online/admin/{action})。 + /// C294 起管理面经 UseAdminApi 组合进运行时, 先启业务面再开监听。 /// 端口 28190 为测试专用(避开默认 28090,防本机 Game 服干扰)。 /// - public class OnlineAdminApiServerTests : IDisposable + [Collection(CapabilityRuntimeCollection.Name)] + public class OnlineAdminApiServerTests { /// /// HTTP 客户端(共享连接池)。 @@ -52,120 +57,117 @@ public class OnlineAdminApiServerTests : IDisposable private const int TestPort = 28190; /// - /// 当前测试的 HTTP 服务实例。 + /// 组合 Online 族并启用管理面(业务面 + 管理面;授权三元组 1/1/1001,监听测试端口)。 /// - private AdminApiServer _server; - - /// - /// 验证真实 HTTP 往返:POST {prefix}/{action} 返回 200 + 双层信封(外层 0 / 内层 0 / Data 非 null)。 - /// - [Fact] - public async Task StartAsync_RealHttpPost_ShouldReturnDoubleEnvelope() + /// 组合产物(未启动)。 + private static CapabilityRuntime ComposeWithAdmin() { - await StartServerAsync(); - var content = new StringContent("{\"TenantId\":1,\"AppId\":1,\"ServerId\":1001}", Encoding.UTF8, "application/json"); - var response = await SharedHttpClient.PostAsync("http://127.0.0.1:" + TestPort + "/online/admin/query_online_overview", content); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var body = await response.Content.ReadAsStringAsync(); - using (var document = JsonDocument.Parse(body)) + return OnlineCapabilityTestHarness.Compose(null, options => options.UseAdminApi(adminOptions => { - Assert.Equal(0, document.RootElement.GetProperty("code").GetInt32()); - var innerJson = document.RootElement.GetProperty("data").GetString(); - Assert.False(string.IsNullOrEmpty(innerJson)); - using (var inner = JsonDocument.Parse(innerJson)) - { - Assert.Equal(0, inner.RootElement.GetProperty("Code").GetInt32()); - Assert.Equal(JsonValueKind.Object, inner.RootElement.GetProperty("Data").ValueKind); - } - } + adminOptions.AdminPort = TestPort; + adminOptions.AdminApiPrefix = "online/admin"; + })); } /// - /// 验证真实 HTTP 上未知 action 同样返回 200(业务错误只进内层 4002)。 + /// 组合并启动双面运行时,执行用例主体后恒停止(停监听 → 停业务面 → 撤桥 + 释放钩子声明)。 /// - [Fact] - public async Task StartAsync_UnknownAction_ShouldReturnInner4002OverHttp() + /// 用例主体。 + /// 异步任务。 + private static async Task WithStartedAdminAsync(Func body) { - await StartServerAsync(); - var content = new StringContent("{\"TenantId\":1,\"AppId\":1,\"ServerId\":1001}", Encoding.UTF8, "application/json"); - var response = await SharedHttpClient.PostAsync("http://127.0.0.1:" + TestPort + "/online/admin/no_such_action", content); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var body = await response.Content.ReadAsStringAsync(); - using (var document = JsonDocument.Parse(body)) + var runtime = ComposeWithAdmin(); + try { - Assert.Equal(0, document.RootElement.GetProperty("code").GetInt32()); - var innerJson = document.RootElement.GetProperty("data").GetString(); - using (var inner = JsonDocument.Parse(innerJson)) - { - Assert.Equal((int)OnlineErrorCode.ResourceNotFound, inner.RootElement.GetProperty("Code").GetInt32()); - } + await runtime.StartAsync(); + await body(); + } + finally + { + await runtime.StopAsync(); } } /// - /// 验证停机后端口释放(停机 → 再次启停同端口成功,重启后仍能服务真实 HTTP 请求)。 + /// 向测试端口 POST 一个 action。 /// - [Fact] - public async Task StopAsync_ThenRestart_ShouldReleasePort() + /// action 名。 + /// HTTP 响应。 + private static Task PostAsync(string action) { - var first = OnlineAdminApiComposition.CreateServer(CreateComposition().Runtime, BuildAdminOptions()); - await first.StartAsync(); - await first.StopAsync(); - var second = OnlineAdminApiComposition.CreateServer(CreateComposition().Runtime, BuildAdminOptions()); - await second.StartAsync(); var content = new StringContent("{\"TenantId\":1,\"AppId\":1,\"ServerId\":1001}", Encoding.UTF8, "application/json"); - var response = await SharedHttpClient.PostAsync("http://127.0.0.1:" + TestPort + "/online/admin/no_such_action", content); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - await second.StopAsync(); + return SharedHttpClient.PostAsync("http://127.0.0.1:" + TestPort + "/online/admin/" + action, content); } /// - /// 启动测试服务(懒构造,保证各用例独立实例)。 - /// - /// 异步任务。 - private async Task StartServerAsync() - { - _server = OnlineAdminApiComposition.CreateServer(CreateComposition().Runtime, BuildAdminOptions()); - await _server.StartAsync(); - } - - /// - /// 构造 Online 单入口组合产物(业务运行时形态;授权三元组 1/1/1001,C232 P6 起真扫描装配)。 + /// 验证真实 HTTP 往返:POST {prefix}/{action} 返回 200 + 双层信封(外层 0 / 内层 0 / Data 非 null)。 /// - /// 组合产物。 - private static OnlineCapabilityComposition CreateComposition() + [Fact] + public async Task StartAsync_RealHttpPost_ShouldReturnDoubleEnvelope() { - return OnlineCapabilityComposition.AddOnlineCapabilities(options => + await WithStartedAdminAsync(async () => { - options.TenantId = 1; - options.AppId = 1; - options.ServerId = 1001; - options.EnableRuntime = true; + var response = await PostAsync("query_online_overview"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + using (var document = JsonDocument.Parse(body)) + { + Assert.Equal(0, document.RootElement.GetProperty("code").GetInt32()); + var innerJson = document.RootElement.GetProperty("data").GetString(); + Assert.False(string.IsNullOrEmpty(innerJson)); + using (var inner = JsonDocument.Parse(innerJson)) + { + Assert.Equal(0, inner.RootElement.GetProperty("Code").GetInt32()); + Assert.Equal(JsonValueKind.Object, inner.RootElement.GetProperty("Data").ValueKind); + } + } }); } /// - /// 构造管理面选项(测试端口 28190)。 + /// 验证真实 HTTP 上未知 action 同样返回 200(业务错误只进内层 4002)。 /// - /// 管理面选项。 - private static AdminApiOptions BuildAdminOptions() + [Fact] + public async Task StartAsync_UnknownAction_ShouldReturnInner4002OverHttp() { - return new AdminApiOptions + await WithStartedAdminAsync(async () => { - AdminPort = TestPort, - AdminApiPrefix = "online/admin", - }; + var response = await PostAsync("no_such_action"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + using (var document = JsonDocument.Parse(body)) + { + Assert.Equal(0, document.RootElement.GetProperty("code").GetInt32()); + var innerJson = document.RootElement.GetProperty("data").GetString(); + using (var inner = JsonDocument.Parse(innerJson)) + { + Assert.Equal((int)OnlineErrorCode.ResourceNotFound, inner.RootElement.GetProperty("Code").GetInt32()); + } + } + }); } /// - /// 释放测试服务(优雅停机)。 + /// 验证停机后端口释放(第一次组合启停 → 第二次组合同端口启动成功,且仍能服务真实 HTTP 请求)。 /// - public void Dispose() + [Fact] + public async Task StopAsync_ThenRestart_ShouldReleasePort() { - if (_server != null) + var first = ComposeWithAdmin(); + try { - _server.StopAsync().GetAwaiter().GetResult(); + await first.StartAsync(); } + finally + { + await first.StopAsync(); + } + + await WithStartedAdminAsync(async () => + { + var response = await PostAsync("no_such_action"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + }); } } } diff --git a/Tests/GameFrameX.Tests/Online/OnlineAdminHotfixDispatcherTests.cs b/Tests/GameFrameX.Tests/Online/OnlineAdminHotfixDispatcherTests.cs index 8dc97563d..2811a9efe 100644 --- a/Tests/GameFrameX.Tests/Online/OnlineAdminHotfixDispatcherTests.cs +++ b/Tests/GameFrameX.Tests/Online/OnlineAdminHotfixDispatcherTests.cs @@ -24,9 +24,16 @@ // Official Documentation: https://gameframex.doc.alianblank.com/ // ========================================================================================== +using System; +using System.Collections.Generic; using System.IO; using System.Text.Json; using System.Threading.Tasks; +using GameFrameX.Capability.Hosting.Abstractions; +using GameFrameX.Capability.Hosting.AdminApi; +using GameFrameX.Capability.Hosting.Core; +using GameFrameX.Capability.Online.Abstractions; +using GameFrameX.Capability.Online.Abstractions.Contracts; using GameFrameX.Capability.Online.Operations.HotfixRollback; using GameFrameX.Core.Hotfix; using GameFrameX.Launcher.Online; @@ -40,34 +47,63 @@ namespace GameFrameX.Tests.Online; /// rollback_hotfix_version)经 dispatcher 可达且与直调 行为一致 ///(rollback 全链断言审计 / 幂等回放 / 活跃指针 / 实际装载路径)+ 错误码面经 admin 面透传。 /// +/// C294:组合选项自建(不用挂具的空操作执行器)——宿主扩展点 +/// 以生产 Override 注入,玩家目录用空目录桩;业务面先启动 +///(热更登记 / 切换发布事件,事件总线 Activate 前拒绝发布),用例结束恒停止。 +/// +/// /// 载体 = Operations 程序集本体(不引 Core ⇒ 无热更注册面类型,ConvertHotfixAssembly 零副作用); /// 版本目录以 GUID 命名落测试 bin 目录;与 同 Collection -/// 串行(共享 进程级状态)。 +/// 串行(共享 进程级状态;禁并行集合彼此独占执行,组合产物的桥 / 钩子槽位不与其它集合交叠)。 /// /// [Collection(HotfixManagerStaticStateCollection.Name)] -public sealed class OnlineAdminHotfixDispatcherTests : System.IDisposable +public sealed class OnlineAdminHotfixDispatcherTests : IDisposable { /// 测试用作用域三元组(与组合产物授权作用域一致)。 private const string ScopeBody = "\"TenantId\":1,\"AppId\":1,\"ServerId\":1001"; /// 本实例创建的版本目录(Dispose 统一最佳努力清理)。 - private readonly System.Collections.Generic.List _createdVersions = new System.Collections.Generic.List(); + private readonly List _createdVersions = new List(); /// - /// 构造经注册反转组装的调度器(组合产物装配真执行器 + 真制品获取器,C248 生产接线)。 + /// 构造生产接线的组合选项(族配置段 + 空玩家目录 + 生产热更切换执行器;制品获取器走 Operations 包内默认)。 /// - /// 调度器。 - private static GameFrameX.Capability.Hosting.AdminApi.AdminApiDispatcher CreateDispatcher() + /// 组合选项。 + private static CapabilityCompositionOptions CreateProductionExecutorOptions() { - var composition = OnlineCapabilityComposition.AddOnlineCapabilities(options => + var options = new CapabilityCompositionOptions(OnlineCapabilityTestHarness.FamilySection(), new CollectingCapabilityAssemblyLog()) { - options.TenantId = 1; - options.AppId = 1; - options.ServerId = 1001; - options.EnableRuntime = true; - }); - return OnlineAdminApiComposition.CreateDispatcher(composition.Runtime); + HostOptions = new CapabilityHostOptions + { + TenantId = OnlineCapabilityTestHarness.TenantId, + AppId = OnlineCapabilityTestHarness.AppId, + ServerId = OnlineCapabilityTestHarness.ServerId, + }, + }; + options.Override(new OnlineCapabilityTestHarness.EmptyPlayerDirectory()); + options.Override(new HotfixManagerRollbackExecutor()); + return options; + } + + /// + /// 组合并启动 Online 族(生产执行器接线),以调度器执行用例主体,结束后恒停止(撤桥 + 释放钩子声明)。 + /// + /// 用例主体(入参为调度器)。 + /// 异步任务。 + private static async Task WithDispatcherAsync(Func body) + { + var runtime = CapabilityComposition.Compose(new OnlineCapabilityFamily(), CreateProductionExecutorOptions()); + try + { + Assert.IsType(runtime.Host.Services.GetService()); + await runtime.Host.StartAsync(); + await body(OnlineCapabilityTestHarness.CreateDispatcher(runtime.Host)); + } + finally + { + await runtime.StopAsync(); + } } /// @@ -91,7 +127,7 @@ private static string ReadInnerJson(string envelopeJson) /// /// 内层 JSON。 /// 期望业务码。 - private static void AssertInnerCode(string innerJson, GameFrameX.Capability.Online.Abstractions.Contracts.OnlineErrorCode expectedCode) + private static void AssertInnerCode(string innerJson, OnlineErrorCode expectedCode) { using (var document = JsonDocument.Parse(innerJson)) { @@ -106,7 +142,7 @@ private static void AssertInnerCode(string innerJson, GameFrameX.Capability.Onli /// 制品完整路径。 private static string ArtifactPath(string version) { - return Path.Combine(System.Environment.CurrentDirectory, version, "GameFrameX.Hotfix.dll"); + return Path.Combine(Environment.CurrentDirectory, version, "GameFrameX.Hotfix.dll"); } /// @@ -115,8 +151,8 @@ private static string ArtifactPath(string version) /// 版本目录名。 private string NewLoadableVersion() { - var version = "c256-admin-" + System.Guid.NewGuid().ToString("N"); - Directory.CreateDirectory(Path.Combine(System.Environment.CurrentDirectory, version)); + var version = "c256-admin-" + Guid.NewGuid().ToString("N"); + Directory.CreateDirectory(Path.Combine(Environment.CurrentDirectory, version)); File.Copy(typeof(OnlineHotfixProtocolManifest).Assembly.Location, ArtifactPath(version)); _createdVersions.Add(version); return version; @@ -126,6 +162,7 @@ private string NewLoadableVersion() /// 真实装载指定版本(镜像生产启动装载)。 /// /// 版本目录名。 + /// 异步任务。 private static async Task LoadVersion(string version) { Assert.True(await HotfixManager.LoadHotfixModuleAsync(null, new HotfixLoadOptions { Version = version, })); @@ -135,91 +172,98 @@ private static async Task LoadVersion(string version) /// 调度器级全链:register×2(dispatcher)→ activate v1 → 真装载 v1 → rollback 切到 v2—— /// 活跃指针(幂等回放证活跃已切)与实际装载路径双断言 + 审计经 admin 查询面可查。 /// + /// 异步任务。 [Fact] public async Task Dispatch_RollbackHotfixVersion_FullChain_AuditPointerAndLoadPathAsserted() { - var dispatcher = CreateDispatcher(); var v1 = NewLoadableVersion(); var v2 = NewLoadableVersion(); const string idempotencyKey = "c256-admin-full-switch"; - foreach (var version in new[] { v1, v2 }) + await WithDispatcherAsync(async dispatcher => { - var registerEnvelope = await dispatcher.DispatchAsync("register_hotfix_manifest", - "{" + ScopeBody + ",\"Version\":\"" + version + "\"}"); - using (var document = JsonDocument.Parse(ReadInnerJson(registerEnvelope))) + foreach (var version in new[] { v1, v2 }) { - Assert.Equal(0, document.RootElement.GetProperty("Code").GetInt32()); - Assert.True(document.RootElement.GetProperty("Data").GetProperty("IsNewRegistered").GetBoolean()); + var registerEnvelope = await dispatcher.DispatchAsync("register_hotfix_manifest", + "{" + ScopeBody + ",\"Version\":\"" + version + "\"}"); + using (var document = JsonDocument.Parse(ReadInnerJson(registerEnvelope))) + { + Assert.Equal(0, document.RootElement.GetProperty("Code").GetInt32()); + Assert.True(document.RootElement.GetProperty("Data").GetProperty("IsNewRegistered").GetBoolean()); + } } - } - var activateEnvelope = await dispatcher.DispatchAsync("activate_hotfix_version", - "{" + ScopeBody + ",\"Version\":\"" + v1 + "\"}"); - using (var activateDocument = JsonDocument.Parse(ReadInnerJson(activateEnvelope))) - { - Assert.Equal(0, activateDocument.RootElement.GetProperty("Code").GetInt32()); - Assert.True(activateDocument.RootElement.GetProperty("Data").GetProperty("Activated").GetBoolean()); - } + var activateEnvelope = await dispatcher.DispatchAsync("activate_hotfix_version", + "{" + ScopeBody + ",\"Version\":\"" + v1 + "\"}"); + using (var activateDocument = JsonDocument.Parse(ReadInnerJson(activateEnvelope))) + { + Assert.Equal(0, activateDocument.RootElement.GetProperty("Code").GetInt32()); + Assert.True(activateDocument.RootElement.GetProperty("Data").GetProperty("Activated").GetBoolean()); + } - await LoadVersion(v1); + await LoadVersion(v1); - var rollbackEnvelope = await dispatcher.DispatchAsync("rollback_hotfix_version", - "{" + ScopeBody + ",\"TargetVersion\":\"" + v2 + "\",\"OperatorId\":\"admin-001\",\"Reason\":\"c256 全链\",\"IdempotencyKey\":\"" + idempotencyKey + "\"}"); - using (var rollbackDocument = JsonDocument.Parse(ReadInnerJson(rollbackEnvelope))) - { - var data = rollbackDocument.RootElement.GetProperty("Data"); - Assert.Equal(v2, data.GetProperty("TargetVersion").GetString()); - Assert.Equal(v1, data.GetProperty("PreviousVersion").GetString()); - Assert.False(data.GetProperty("IsReplay").GetBoolean()); - } + var rollbackEnvelope = await dispatcher.DispatchAsync("rollback_hotfix_version", + "{" + ScopeBody + ",\"TargetVersion\":\"" + v2 + "\",\"OperatorId\":\"admin-001\",\"Reason\":\"c256 全链\",\"IdempotencyKey\":\"" + idempotencyKey + "\"}"); + using (var rollbackDocument = JsonDocument.Parse(ReadInnerJson(rollbackEnvelope))) + { + var data = rollbackDocument.RootElement.GetProperty("Data"); + Assert.Equal(v2, data.GetProperty("TargetVersion").GetString()); + Assert.Equal(v1, data.GetProperty("PreviousVersion").GetString()); + Assert.False(data.GetProperty("IsReplay").GetBoolean()); + } - // 活跃指针断言:同键重放透传首次结果(IsReplay = true)证明切换命令已落定;实际装载路径断言:真执行器已装载 v2。 - var replayEnvelope = await dispatcher.DispatchAsync("rollback_hotfix_version", - "{" + ScopeBody + ",\"TargetVersion\":\"" + v2 + "\",\"OperatorId\":\"admin-001\",\"Reason\":\"c256 全链重放\",\"IdempotencyKey\":\"" + idempotencyKey + "\"}"); - using (var replayDocument = JsonDocument.Parse(ReadInnerJson(replayEnvelope))) - { - var data = replayDocument.RootElement.GetProperty("Data"); - Assert.True(data.GetProperty("IsReplay").GetBoolean()); - Assert.Equal(v1, data.GetProperty("PreviousVersion").GetString()); - } + // 活跃指针断言:同键重放透传首次结果(IsReplay = true)证明切换命令已落定;实际装载路径断言:真执行器已装载 v2。 + var replayEnvelope = await dispatcher.DispatchAsync("rollback_hotfix_version", + "{" + ScopeBody + ",\"TargetVersion\":\"" + v2 + "\",\"OperatorId\":\"admin-001\",\"Reason\":\"c256 全链重放\",\"IdempotencyKey\":\"" + idempotencyKey + "\"}"); + using (var replayDocument = JsonDocument.Parse(ReadInnerJson(replayEnvelope))) + { + var data = replayDocument.RootElement.GetProperty("Data"); + Assert.True(data.GetProperty("IsReplay").GetBoolean()); + Assert.Equal(v1, data.GetProperty("PreviousVersion").GetString()); + } - Assert.Equal(ArtifactPath(v2), HotfixManager.HotfixAssembly.Location); + Assert.Equal(ArtifactPath(v2), HotfixManager.HotfixAssembly.Location); - // 审计断言:回滚审计先行留痕,经统一审计查询面按事件类型可查。 - var auditEnvelope = await dispatcher.DispatchAsync("query_online_audit_events", - "{" + ScopeBody + ",\"CommandKind\":\"" + OnlineHotfixEvents.RolledBack + "\"}"); - using (var auditDocument = JsonDocument.Parse(ReadInnerJson(auditEnvelope))) - { - var items = auditDocument.RootElement.GetProperty("Data").GetProperty("Items"); - Assert.True(items.GetArrayLength() >= 1); - } + // 审计断言:回滚审计先行留痕,经统一审计查询面按事件类型可查。 + var auditEnvelope = await dispatcher.DispatchAsync("query_online_audit_events", + "{" + ScopeBody + ",\"CommandKind\":\"" + OnlineHotfixEvents.RolledBack + "\"}"); + using (var auditDocument = JsonDocument.Parse(ReadInnerJson(auditEnvelope))) + { + var items = auditDocument.RootElement.GetProperty("Data").GetProperty("Items"); + Assert.True(items.GetArrayLength() >= 1); + } + }); } /// /// check_hotfix_compatibility:只读预检返回兼容报告(切换前后均可演练)。 /// + /// 异步任务。 [Fact] public async Task Dispatch_CheckHotfixCompatibility_ShouldReturnReport() { - var dispatcher = CreateDispatcher(); var v1 = NewLoadableVersion(); var v2 = NewLoadableVersion(); - await dispatcher.DispatchAsync("register_hotfix_manifest", "{" + ScopeBody + ",\"Version\":\"" + v1 + "\"}"); - await dispatcher.DispatchAsync("register_hotfix_manifest", "{" + ScopeBody + ",\"Version\":\"" + v2 + "\"}"); - await dispatcher.DispatchAsync("activate_hotfix_version", "{" + ScopeBody + ",\"Version\":\"" + v1 + "\"}"); - - var envelope = await dispatcher.DispatchAsync("check_hotfix_compatibility", - "{" + ScopeBody + ",\"TargetVersion\":\"" + v2 + "\"}"); - using (var document = JsonDocument.Parse(ReadInnerJson(envelope))) + await WithDispatcherAsync(async dispatcher => { - Assert.Equal(0, document.RootElement.GetProperty("Code").GetInt32()); - var data = document.RootElement.GetProperty("Data"); - Assert.True(data.GetProperty("IsCompatible").GetBoolean()); - Assert.Equal(0, data.GetProperty("RemovedCount").GetInt32()); - Assert.Equal(0, data.GetProperty("ChangedCount").GetInt32()); - } + await dispatcher.DispatchAsync("register_hotfix_manifest", "{" + ScopeBody + ",\"Version\":\"" + v1 + "\"}"); + await dispatcher.DispatchAsync("register_hotfix_manifest", "{" + ScopeBody + ",\"Version\":\"" + v2 + "\"}"); + await dispatcher.DispatchAsync("activate_hotfix_version", "{" + ScopeBody + ",\"Version\":\"" + v1 + "\"}"); + + var envelope = await dispatcher.DispatchAsync("check_hotfix_compatibility", + "{" + ScopeBody + ",\"TargetVersion\":\"" + v2 + "\"}"); + + using (var document = JsonDocument.Parse(ReadInnerJson(envelope))) + { + Assert.Equal(0, document.RootElement.GetProperty("Code").GetInt32()); + var data = document.RootElement.GetProperty("Data"); + Assert.True(data.GetProperty("IsCompatible").GetBoolean()); + Assert.Equal(0, data.GetProperty("RemovedCount").GetInt32()); + Assert.Equal(0, data.GetProperty("ChangedCount").GetInt32()); + } + }); } /// @@ -228,56 +272,67 @@ public async Task Dispatch_CheckHotfixCompatibility_ShouldReturnReport() /// action 名。 /// 请求体。 /// 期望业务码。 + /// 异步任务。 [Theory] - [InlineData("rollback_hotfix_version", "{" + ScopeBody + ",\"TargetVersion\":\"c256-ghost\",\"OperatorId\":\"admin\",\"Reason\":\"c256\",\"IdempotencyKey\":\"c256-ghost-key\"}", GameFrameX.Capability.Online.Abstractions.Contracts.OnlineErrorCode.ResourceNotFound)] - [InlineData("activate_hotfix_version", "{" + ScopeBody + ",\"Version\":\"c256-ghost\"}", GameFrameX.Capability.Online.Abstractions.Contracts.OnlineErrorCode.ResourceNotFound)] - [InlineData("rollback_hotfix_version", "{" + ScopeBody + ",\"TargetVersion\":\"c256-x\",\"OperatorId\":\"admin\",\"Reason\":\"c256\"}", GameFrameX.Capability.Online.Abstractions.Contracts.OnlineErrorCode.ParameterInvalid)] - [InlineData("register_hotfix_manifest", "{" + ScopeBody + "}", GameFrameX.Capability.Online.Abstractions.Contracts.OnlineErrorCode.ParameterInvalid)] - [InlineData("check_hotfix_compatibility", "{" + ScopeBody + ",\"TargetVersion\":\"c256-x\"}", GameFrameX.Capability.Online.Abstractions.Contracts.OnlineErrorCode.ResourceNotFound)] - public async Task Dispatch_HotfixActions_ErrorCodesTransparent(string action, string body, GameFrameX.Capability.Online.Abstractions.Contracts.OnlineErrorCode expectedCode) + [InlineData("rollback_hotfix_version", "{" + ScopeBody + ",\"TargetVersion\":\"c256-ghost\",\"OperatorId\":\"admin\",\"Reason\":\"c256\",\"IdempotencyKey\":\"c256-ghost-key\"}", OnlineErrorCode.ResourceNotFound)] + [InlineData("activate_hotfix_version", "{" + ScopeBody + ",\"Version\":\"c256-ghost\"}", OnlineErrorCode.ResourceNotFound)] + [InlineData("rollback_hotfix_version", "{" + ScopeBody + ",\"TargetVersion\":\"c256-x\",\"OperatorId\":\"admin\",\"Reason\":\"c256\"}", OnlineErrorCode.ParameterInvalid)] + [InlineData("register_hotfix_manifest", "{" + ScopeBody + "}", OnlineErrorCode.ParameterInvalid)] + [InlineData("check_hotfix_compatibility", "{" + ScopeBody + ",\"TargetVersion\":\"c256-x\"}", OnlineErrorCode.ResourceNotFound)] + public async Task Dispatch_HotfixActions_ErrorCodesTransparent(string action, string body, OnlineErrorCode expectedCode) { - var dispatcher = CreateDispatcher(); - var envelope = await dispatcher.DispatchAsync(action, body); - AssertInnerCode(ReadInnerJson(envelope), expectedCode); + await WithDispatcherAsync(async dispatcher => + { + var envelope = await dispatcher.DispatchAsync(action, body); + AssertInnerCode(ReadInnerJson(envelope), expectedCode); + }); } /// /// check_hotfix_compatibility 无活跃版本(已登记目标但未激活基线):5001 透传。 /// + /// 异步任务。 [Fact] public async Task Dispatch_CheckHotfixCompatibility_WithoutActiveVersion_ShouldReturn5001() { - var dispatcher = CreateDispatcher(); var v1 = NewLoadableVersion(); - await dispatcher.DispatchAsync("register_hotfix_manifest", "{" + ScopeBody + ",\"Version\":\"" + v1 + "\"}"); - var envelope = await dispatcher.DispatchAsync("check_hotfix_compatibility", - "{" + ScopeBody + ",\"TargetVersion\":\"" + v1 + "\"}"); + await WithDispatcherAsync(async dispatcher => + { + await dispatcher.DispatchAsync("register_hotfix_manifest", "{" + ScopeBody + ",\"Version\":\"" + v1 + "\"}"); + + var envelope = await dispatcher.DispatchAsync("check_hotfix_compatibility", + "{" + ScopeBody + ",\"TargetVersion\":\"" + v1 + "\"}"); - AssertInnerCode(ReadInnerJson(envelope), GameFrameX.Capability.Online.Abstractions.Contracts.OnlineErrorCode.StateNotReady); + AssertInnerCode(ReadInnerJson(envelope), OnlineErrorCode.StateNotReady); + }); } + /// /// 登记携带制品字段但制品源不可达:获取失败 8002 透传且登记未生效(「已登记」与「已落盘」同时成立红线)。 /// + /// 异步任务。 [Fact] public async Task Dispatch_RegisterHotfixManifest_UnreachableArtifact_ShouldReturn8002() { - var dispatcher = CreateDispatcher(); - var envelope = await dispatcher.DispatchAsync("register_hotfix_manifest", - "{" + ScopeBody + ",\"Version\":\"c256-broken-artifact\",\"ArtifactSourceUrl\":\"http://127.0.0.1:1/GameFrameX.Hotfix.dll\",\"ArtifactSha256\":\"" + new string('a', 64) + "\"}"); - AssertInnerCode(ReadInnerJson(envelope), GameFrameX.Capability.Online.Abstractions.Contracts.OnlineErrorCode.DependencyUnavailable); + await WithDispatcherAsync(async dispatcher => + { + var envelope = await dispatcher.DispatchAsync("register_hotfix_manifest", + "{" + ScopeBody + ",\"Version\":\"c256-broken-artifact\",\"ArtifactSourceUrl\":\"http://127.0.0.1:1/GameFrameX.Hotfix.dll\",\"ArtifactSha256\":\"" + new string('a', 64) + "\"}"); + AssertInnerCode(ReadInnerJson(envelope), OnlineErrorCode.DependencyUnavailable); + }); } /// /// 最佳努力清理本实例创建的版本目录(collectible ALC 退役宽限期内文件仍被映射,删除失败静默——目录名带 GUID 不复用)。 /// - void System.IDisposable.Dispose() + void IDisposable.Dispose() { foreach (var version in _createdVersions) { try { - Directory.Delete(Path.Combine(System.Environment.CurrentDirectory, version), true); + Directory.Delete(Path.Combine(Environment.CurrentDirectory, version), true); } catch (IOException) { diff --git a/Tests/GameFrameX.Tests/Online/OnlineAssetsCapabilityPackageTests.cs b/Tests/GameFrameX.Tests/Online/OnlineAssetsCapabilityPackageTests.cs index 76201dba7..23b4de2ea 100644 --- a/Tests/GameFrameX.Tests/Online/OnlineAssetsCapabilityPackageTests.cs +++ b/Tests/GameFrameX.Tests/Online/OnlineAssetsCapabilityPackageTests.cs @@ -1,167 +1,121 @@ -using System; -using System.Linq; -using System.Reflection; +// ========================================================================================== +// GameFrameX 组织及其衍生项目的版权、商标、专利及其他相关权利 +// GameFrameX organization and its derivative projects' copyrights, trademarks, patents and related rights +// 均受中华人民共和国及相关国际法律法规保护。 +// are protected by the laws of the PRC 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 refer to the LICENSE file at the root 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 the legal 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://www.cnb.cool/GameFrameX +// CNB Repository: https://www.cnb.cool/GameFrameX +// 官方文档:https://gameframex.doc.alianblank.com/ +// Official Docs: https://gameframex.doc.alianblank.com/ +// ========================================================================================== + +using System.Threading.Tasks; using GameFrameX.Capability.Hosting.Abstractions; using GameFrameX.Capability.Hosting.Core; -using GameFrameX.Capability.Online.Abstractions.Contracts; using GameFrameX.Capability.Online.Assets; -using GameFrameX.Capability.Online.Abstractions.Idempotency; using Xunit; namespace GameFrameX.Tests.Online { /// - /// 资产能力包(C231 P3)验收测试:无参模块扫描可发现 / AddOnlineAssetsRuntime 最小组合 / - /// UseOnlineAssetStore 替换缝生效 / 与壳全能力形态的装配等价锚点。 + /// 资产能力包验收测试(C294 机制化):无参模块扫描可发现 / 真实族白名单单独装配资产能力、产物经宿主共享服务面取用 / + /// 宿主 Override 替换缝恒胜出包内默认存储 / 资产能力不声明 Options 根类型(旧 OnlineAssetsOptions 已删除)。 /// + [Collection(CapabilityRuntimeCollection.Name)] public class OnlineAssetsCapabilityPackageTests { /// - /// 替换缝替换实例(独立 InMemory 实例——断言模块取用的是组合根注入的实例而非包内默认构造)。 + /// 白名单圈定只装配资产能力(资产能力无必选依赖,单独即构成闭包)。 /// - private sealed class ProbeAssetStore : GameFrameX.Capability.Online.Assets.IOnlineAssetStore + /// 组合选项。 + private static void AssetsOnly(CapabilityCompositionOptions options) { - /// - public Task FindWalletAsync(long tenantId, long appId, long playerId, string currencyId, CancellationToken cancellationToken = default) - { - return Task.FromResult(null); - } - - /// - public Task FindInventoryAsync(long tenantId, long appId, long playerId, string itemId, CancellationToken cancellationToken = default) - { - return Task.FromResult(null); - } - - /// - public Task> ListWalletAccountsAsync(long tenantId, long appId, long playerId, CancellationToken cancellationToken = default) - { - return Task.FromResult>(Array.Empty()); - } - - /// - public Task> ListInventoryStacksAsync(long tenantId, long appId, long playerId, CancellationToken cancellationToken = default) - { - return Task.FromResult>(Array.Empty()); - } - - /// - public Task ApplyAsync(GameFrameX.Capability.Online.Assets.OnlineAssetChangeBatch batch, CancellationToken cancellationToken = default) - { - var entries = new List(); - return Task.FromResult(new GameFrameX.Capability.Online.Assets.OnlineAssetApplyResult(true, GameFrameX.Capability.Online.Abstractions.Contracts.OnlineErrorCode.None, string.Empty, entries)); - } - - /// - public Task> ListLedgerEntriesAsync(GameFrameX.Capability.Online.Assets.OnlineLedgerPageQuery query, CancellationToken cancellationToken = default) - { - return Task.FromResult>(Array.Empty()); - } - - /// - public Task> FindLedgerEntriesByTransactionIdAsync(string transactionId, CancellationToken cancellationToken = default) - { - return Task.FromResult>(Array.Empty()); - } - - /// - public Task> SumLedgerDeltasByAssetAsync(long tenantId, long appId, long playerId, CancellationToken cancellationToken = default) - { - return Task.FromResult>(new Dictionary()); - } - - /// - public Task> ListPlayerIdsAsync(long tenantId, long appId, CancellationToken cancellationToken = default) - { - return Task.FromResult>(Array.Empty()); - } + options.Filter = new CapabilityAssemblyFilter(new[] { "online-assets" }); } /// - /// 构造最小组合根所需的脊柱预置(作用域 + 事件出口 + 幂等)。 - /// - /// 装配器(已预置脊柱服务)。 - private static CapabilityHostBuilder MinimalRoot() - { - var builder = new CapabilityHostBuilder(); - builder.UseService(new GameFrameX.Capability.Online.Abstractions.Scope.OnlineScope(1, 1, 1)); - builder.UseService(new GameFrameX.Capability.Online.Abstractions.Events.OnlineEventPublisher(new GameFrameX.Foundation.Idempotency.InMemoryEventPublisher())); - builder.UseService(new OnlineIdempotencyService(new GameFrameX.Foundation.Idempotency.IdempotencyCoordinator( - new GameFrameX.Foundation.Idempotency.InMemoryIdempotencyStore(), - GameFrameX.Foundation.Idempotency.SystemClock.Instance, - new GameFrameX.Foundation.Idempotency.IdempotencyOptions()))); - return builder; - } - - /// - /// 构造合法选项。 - /// - /// 选项。 - private static CapabilityHostOptions ValidOptions() - { - return new CapabilityHostOptions { TenantId = 1, AppId = 1, ServerId = 1 }; - } - - /// - /// 无参模块扫描可发现(P4 前置):资产包程序集扫描产出 online-assets 模块。 + /// 无参模块扫描可发现:资产包程序集扫描产出唯一的 online-assets 模块。 /// [Fact] public void DiscoverModules_FindsAssetsCapabilityModule() { - var modules = CapabilityAssembly.DiscoverModules(new[] { typeof(GameFrameX.Capability.Online.Assets.OnlineAssetsCapabilityModule).Assembly }); + var modules = CapabilityAssembly.DiscoverModules(new[] { typeof(OnlineAssetsCapabilityModule).Assembly }); var module = Assert.Single(modules); Assert.Equal("online-assets", module.Manifest.Id); } /// - /// AddOnlineAssetsRuntime 最小组合:仅脊柱预置即可装配,产物经共享面可取用(grant / query / store 接口)。 + /// 白名单单独装配资产能力:发放 / 查询服务与两个存储接口经宿主共享服务面可取用,装配清单只含 online-assets。 /// [Fact] - public void AddOnlineAssetsRuntime_MinimalRoot_ExposesProductsThroughSharedFace() + public async Task Compose_AssetsAlone_ExposesProductsThroughHostServices() { - var builder = MinimalRoot(); - builder.AddOnlineAssetsRuntime(options => { }); - - var host = builder.Build(ValidOptions()); - - Assert.NotNull(host.Services.GetService()); - Assert.NotNull(host.Services.GetService()); - Assert.NotNull(host.Services.GetService()); - Assert.NotNull(host.Services.GetService()); - Assert.Equal("online-assets", Assert.Single(host.Report.EnabledIdsInOrder)); + await OnlineCapabilityTestHarness.WithRuntimeAsync(runtime => + { + var services = runtime.Host.Services; + Assert.NotNull(services.GetService()); + Assert.NotNull(services.GetService()); + Assert.NotNull(services.GetService()); + Assert.NotNull(services.GetService()); + Assert.Equal(new[] { "online-assets" }, runtime.Report.EnabledIdsInOrder); + return Task.CompletedTask; + }, configure: AssetsOnly); } /// - /// UseOnlineAssetStore 替换缝生效:模块取用组合根注入的存储实例(生产持久化替换位)。 + /// 存储替换缝:宿主 Override 的资产存储恒胜出包内 InMemory 默认(生产持久化替换位)。 /// [Fact] - public void UseOnlineAssetStore_PreSeedWinsOverInMemoryDefault() + public async Task OverrideAssetStore_WinsOverInMemoryDefault() { - var probe = new ProbeAssetStore(); - var builder = MinimalRoot(); - builder.UseOnlineAssetStore(probe); - builder.AddOnlineAssetsRuntime(); - - var host = builder.Build(ValidOptions()); + var replacement = new InMemoryOnlineAssetStore(); - Assert.Same(probe, host.Services.GetService()); + await OnlineCapabilityTestHarness.WithRuntimeAsync(runtime => + { + Assert.Same(replacement, runtime.Host.Services.GetService()); + return Task.CompletedTask; + }, configure: options => + { + AssetsOnly(options); + options.Override(replacement); + }); } /// - /// 包级 options 经回调承载并进共享面(能力族 AddXxx(options => ...) 惯例锚点)。 + /// 资产能力是无参数能力:清单不声明 Options 根类型,报告条目无 Options 字段生效值。 /// [Fact] - public void AddOnlineAssetsRuntime_OptionsCarriedThroughCallback() + public async Task Manifest_DeclaresNoOptionsType_AndReportCarriesNoSettingsFields() { - var builder = MinimalRoot(); - var seen = false; - builder.AddOnlineAssetsRuntime(options => seen = true); + Assert.Null(OnlineAssetsCapabilityModule.ManifestInstance.OptionsType); - var host = builder.Build(ValidOptions()); - - Assert.True(seen); - Assert.NotNull(host.Services.GetService()); + await OnlineCapabilityTestHarness.WithRuntimeAsync(runtime => + { + var entry = Assert.Single(runtime.Report.Entries, candidate => candidate.Manifest.Id == "online-assets"); + Assert.Equal(CapabilityFilterState.Enabled, entry.State); + Assert.Null(entry.Manifest.OptionsType); + Assert.Empty(entry.SettingsFields); + return Task.CompletedTask; + }, configure: AssetsOnly); } } } diff --git a/Tests/GameFrameX.Tests/Online/OnlineAuditServiceTests.cs b/Tests/GameFrameX.Tests/Online/OnlineAuditServiceTests.cs index 3c51a411a..01ea0c639 100644 --- a/Tests/GameFrameX.Tests/Online/OnlineAuditServiceTests.cs +++ b/Tests/GameFrameX.Tests/Online/OnlineAuditServiceTests.cs @@ -30,7 +30,7 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; -using GameFrameX.Capability.Online.Operations.Audit; +using GameFrameX.Capability.Online.Abstractions.Audit; using GameFrameX.Capability.Online.Abstractions.Contracts; using GameFrameX.Capability.Online.Abstractions.Events; using GameFrameX.Capability.Online.Abstractions.Scope; diff --git a/Tests/GameFrameX.Tests/Online/OnlineCapabilityComposeTests.cs b/Tests/GameFrameX.Tests/Online/OnlineCapabilityComposeTests.cs new file mode 100644 index 000000000..0b3becd6a --- /dev/null +++ b/Tests/GameFrameX.Tests/Online/OnlineCapabilityComposeTests.cs @@ -0,0 +1,759 @@ +// ========================================================================================== +// GameFrameX 组织及其衍生项目的版权、商标、专利及其他相关权利 +// GameFrameX organization and its derivative projects' copyrights, trademarks, patents and related rights +// 均受中华人民共和国及相关国际法律法规保护。 +// are protected by the laws of the PRC 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 refer to the LICENSE file at the root 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 the legal 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://www.cnb.cool/GameFrameX +// CNB Repository: https://www.cnb.cool/GameFrameX +// 官方文档:https://gameframex.doc.alianblank.com/ +// Official Docs: https://gameframex.doc.alianblank.com/ +// ========================================================================================== + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using GameFrameX.Capability.Hosting.Abstractions; +using GameFrameX.Capability.Hosting.AdminApi; +using GameFrameX.Capability.Hosting.Core; +using GameFrameX.Capability.Online.Abstractions; +using GameFrameX.Capability.Online.Abstractions.Contracts; +using GameFrameX.Capability.Online.Operations.HotfixRollback; +using GameFrameX.Capability.Online.Social; +using GameFrameX.Foundation.Localization.Core; +using Xunit; + +namespace GameFrameX.Tests.Online; + +/// +/// Online 能力族单入口组合测试(C294: + ): +/// 双面形态(业务面 / 管理面 / 双面)、过滤器形态与规整(空列表 / 空白条目 / 首尾空白)、组合期 fail-fast +///(未知 Id / 闭包缺口 / 无面启用 / 作用域与选项非法 / 宿主扩展点缺失)、钩子声明释放回归、 +/// 机制元端点(list_capabilities / list_capability_services / list_capability_settings)、可选依赖缺失披露与 Options 来源。 +/// +[Collection(CapabilityRuntimeCollection.Name)] +public class OnlineCapabilityComposeTests +{ + /// + /// 合法作用域三元组请求体(与族配置段授权作用域一致)。 + /// + private const string ScopeBody = "{\"TenantId\":1,\"AppId\":1,\"ServerId\":1001}"; + + /// + /// 管理面测试专用端口(避开 28190 / 28191 的 Server / RateLimit 用例与默认 28090)。 + /// + private const int AdminTestPort = 28196; + + /// + /// 好友 Options 默认搜索上限( 包内默认值)。 + /// + private const int DefaultFriendSearchLimit = 20; + + /// + /// 好友 Options 默认好友上限( 包内默认值)。 + /// + private const int DefaultFriendLimit = 200; + + /// + /// HTTP 客户端(共享连接池)。 + /// + private static readonly HttpClient SharedHttpClient = new HttpClient(); + + /// + /// 七能力全装配的拓扑序(依赖边:social→foundation、matchmaking→social、match 可选 assets/matchmaking、 + /// competition 可选 assets/match、operations→foundation 可选 assets/social/match/matchmaking;同层按能力 Id Ordinal)。 + /// + private static readonly string[] ExpectedTopologicalOrder = + { + "online-assets", "online-foundation", "online-social", "online-matchmaking", + "online-match", "online-competition", "online-operations", + }; + + /// + /// 构造不带任何宿主 Override 的组合选项(宿主扩展点缺失 / 自定义 Override 用例自行补齐)。 + /// + /// 族配置段节点。 + /// 组合选项。 + private static CapabilityCompositionOptions CreateBareOptions(JsonElement familySection) + { + return new CapabilityCompositionOptions(familySection, new CollectingCapabilityAssemblyLog()) + { + HostOptions = new CapabilityHostOptions + { + TenantId = OnlineCapabilityTestHarness.TenantId, + AppId = OnlineCapabilityTestHarness.AppId, + ServerId = OnlineCapabilityTestHarness.ServerId, + }, + }; + } + + /// + /// 以给定选项组合 Online 族并在主体结束后恒停止(撤桥 + 释放钩子声明)。 + /// + /// 组合选项。 + /// 用例主体。 + /// 异步任务。 + private static async Task WithComposedAsync(CapabilityCompositionOptions options, Func body) + { + var runtime = CapabilityComposition.Compose(new OnlineCapabilityFamily(), options); + try + { + await body(runtime); + } + finally + { + await runtime.StopAsync(); + } + } + + /// + /// 解析外层信封(HTTP 状态恒 200、外层 code 恒 0),返回内层业务文档(调用方负责释放)。 + /// + /// 响应体原文。 + /// 内层业务 JSON 文档。 + private static JsonDocument ParseInner(string envelopeJson) + { + using (var outer = JsonDocument.Parse(envelopeJson)) + { + Assert.Equal(0, outer.RootElement.GetProperty("code").GetInt32()); + return JsonDocument.Parse(outer.RootElement.GetProperty("data").GetString()); + } + } + + /// + /// 读取 JSON 字符串数组。 + /// + /// JSON 数组节点。 + /// 字符串数组。 + private static string[] ReadStrings(JsonElement array) + { + return array.EnumerateArray().Select(item => item.GetString()).ToArray(); + } + + /// + /// 按指定键把 JSON 对象数组索引成字典。 + /// + /// JSON 对象数组。 + /// 键属性名。 + /// 键 → 条目字典。 + private static Dictionary IndexBy(JsonElement array, string keyProperty) + { + return array.EnumerateArray().ToDictionary(item => item.GetProperty(keyProperty).GetString()); + } + + /// + /// 取报告中指定能力的条目。 + /// + /// 能力报告。 + /// 能力 Id。 + /// 报告条目。 + private static CapabilityReportEntry FindEntry(CapabilityReport report, string capabilityId) + { + return report.Entries.Single(entry => entry.Manifest.Id == capabilityId); + } + + /// + /// 经真实 HTTP 向管理面 POST 一个 action(断言 HTTP 200,返回响应体原文)。 + /// + /// action 名。 + /// 请求体。 + /// 响应体原文。 + private static async Task PostAsync(string action, string body) + { + using (var content = new StringContent(body, Encoding.UTF8, "application/json")) + { + var response = await SharedHttpClient.PostAsync("http://127.0.0.1:" + AdminTestPort + "/online/admin/" + action, content); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + return await response.Content.ReadAsStringAsync(); + } + } + + /// + /// 业务面单开:产出业务宿主、无管理面;七能力全部启用,装配序 = 拓扑序(依赖恒先于依赖方),宿主模块表同序。 + /// + [Fact] + public async Task Compose_RuntimeOnly_ComposesHostWithoutAdminInTopologicalOrder() + { + await OnlineCapabilityTestHarness.WithRuntimeAsync(runtime => + { + Assert.NotNull(runtime.Host); + Assert.Null(runtime.AdminApi); + Assert.Equal(ExpectedTopologicalOrder, runtime.Report.EnabledIdsInOrder); + Assert.Equal(7, runtime.Report.Entries.Count(entry => entry.State == CapabilityFilterState.Enabled)); + Assert.Equal(ExpectedTopologicalOrder, runtime.Host.Modules.Select(module => module.Manifest.Id).ToArray()); + + var order = runtime.Report.EnabledIdsInOrder.ToList(); + foreach (var entry in runtime.Report.Entries) + { + foreach (var dependency in entry.Dependencies) + { + if (dependency.State == CapabilityDependencyAssemblyState.Assembled) + { + Assert.True(order.IndexOf(dependency.CapabilityId) < order.IndexOf(entry.Manifest.Id), + dependency.CapabilityId + " must be ordered before " + entry.Manifest.Id); + } + } + } + + return Task.CompletedTask; + }); + } + + /// + /// 管理面单开(EnableRuntime=false + UseAdminApi):业务宿主不交付(Host 为 null),管理面产出; + /// 新设计下每个模块的 Register 仍执行,报告完整(七能力全部启用、拓扑序不变)。 + /// + [Fact] + public async Task Compose_AdminOnly_HostNullAdminPresentReportComplete() + { + var options = OnlineCapabilityTestHarness.CreateOptions(); + options.EnableRuntime = false; + options.UseAdminApi(adminOptions => + { + adminOptions.AdminPort = AdminTestPort; + adminOptions.AdminApiPrefix = "online/admin"; + }); + + await WithComposedAsync(options, runtime => + { + Assert.Null(runtime.Host); + Assert.IsType(runtime.AdminApi); + Assert.Equal(ExpectedTopologicalOrder, runtime.Report.EnabledIdsInOrder); + Assert.All(runtime.Report.Entries, entry => Assert.Equal(CapabilityFilterState.Enabled, entry.State)); + return Task.CompletedTask; + }); + } + + /// + /// 双面齐开:一次组合同时产出业务宿主与管理面(管理面复用同一次组合的模块与报告)。 + /// + [Fact] + public async Task Compose_BothPlanes_ComposesHostAndAdmin() + { + await OnlineCapabilityTestHarness.WithRuntimeAsync(runtime => + { + Assert.NotNull(runtime.Host); + Assert.IsType(runtime.AdminApi); + Assert.Same(runtime.Host.Report, runtime.Report); + return Task.CompletedTask; + }, null, options => options.UseAdminApi(adminOptions => + { + adminOptions.AdminPort = AdminTestPort; + adminOptions.AdminApiPrefix = "online/admin"; + })); + } + + /// + /// 空列表 = 全装配红线:白名单与排除表均为空数组时不过滤,七能力全部启用。 + /// + [Fact] + public async Task Compose_EmptyFilterLists_AssembleAll() + { + await OnlineCapabilityTestHarness.WithRuntimeAsync(runtime => + { + Assert.Equal(7, runtime.Report.Entries.Count(entry => entry.State == CapabilityFilterState.Enabled)); + Assert.Equal(ExpectedTopologicalOrder, runtime.Report.EnabledIdsInOrder); + return Task.CompletedTask; + }, null, options => options.Filter = new CapabilityAssemblyFilter(Array.Empty(), Array.Empty())); + } + + /// + /// 空白条目规整:两张表都只含空白条目时视为未过滤(机制层过滤器丢弃空白条目,不误报为未发现能力)。 + /// + [Fact] + public async Task Compose_BlankEntries_TreatedAsUnfiltered() + { + await OnlineCapabilityTestHarness.WithRuntimeAsync(runtime => + { + Assert.Equal(7, runtime.Report.Entries.Count(entry => entry.State == CapabilityFilterState.Enabled)); + return Task.CompletedTask; + }, null, options => options.Filter = new CapabilityAssemblyFilter(new[] { string.Empty, " " }, new[] { string.Empty, " " })); + } + + /// + /// 首尾空白条目规整:白名单条目 Trim 后精确命中(只装配该闭包,其余为清单段未收录)。 + /// + [Fact] + public async Task Compose_PaddedEnableListEntries_TrimmedToExactWhitelist() + { + await OnlineCapabilityTestHarness.WithRuntimeAsync(runtime => + { + Assert.Equal(new[] { "online-foundation", "online-social" }, runtime.Report.EnabledIdsInOrder); + Assert.Equal(CapabilityFilterState.NotInEnableList, FindEntry(runtime.Report, "online-assets").State); + Assert.Equal(CapabilityFilterState.NotInEnableList, FindEntry(runtime.Report, "online-operations").State); + return Task.CompletedTask; + }, null, options => options.Filter = new CapabilityAssemblyFilter(new[] { " online-foundation ", "online-social" })); + } + + /// + /// 排除表生效:叶子能力 online-operations(无下游必选依赖)为 ExcludedByDisableList,其余六能力保持装配。 + /// + [Fact] + public async Task Compose_DisableList_ExcludesCapabilityFromReport() + { + await OnlineCapabilityTestHarness.WithRuntimeAsync(runtime => + { + Assert.Equal(CapabilityFilterState.ExcludedByDisableList, FindEntry(runtime.Report, "online-operations").State); + Assert.Equal(6, runtime.Report.EnabledIdsInOrder.Count); + Assert.DoesNotContain("online-operations", runtime.Report.EnabledIdsInOrder); + return Task.CompletedTask; + }, null, options => options.Filter = new CapabilityAssemblyFilter(null, new[] { "online-operations" })); + } + + /// + /// 被排除能力的 admin action 按未安装(4003)裁决:载荷指向该能力,已装能力清单不含它。 + /// + [Fact] + public async Task Dispatch_DisabledCapabilityAction_AnswersNotInstalled() + { + await OnlineCapabilityTestHarness.WithRuntimeAsync(async runtime => + { + var dispatcher = OnlineCapabilityTestHarness.CreateDispatcher(runtime.Host); + var envelope = await dispatcher.DispatchAsync("query_online_overview", "{\"TenantId\":1,\"AppId\":1,\"ServerId\":1001,\"RequestId\":\"r1\"}"); + using var inner = ParseInner(envelope); + Assert.Equal((int)OnlineErrorCode.CapabilityNotInstalled, inner.RootElement.GetProperty("Code").GetInt32()); + var payload = inner.RootElement.GetProperty("Data"); + Assert.Equal("online-operations", payload.GetProperty("NotInstalledCapability").GetString()); + var installed = ReadStrings(payload.GetProperty("InstalledCapabilities")); + Assert.Equal(6, installed.Length); + Assert.DoesNotContain("online-operations", installed); + }, null, options => options.Filter = new CapabilityAssemblyFilter(null, new[] { "online-operations" })); + } + + /// + /// admin-only 形态经真实 HTTP:list_capabilities 如实反映排除表(online-assets 排除、其余因 Register 照常执行而为 Enabled—— + /// 旧设计下为 NotAssembled),被排除能力的 action 答 4003。 + /// + [Fact] + public async Task AdminOnly_OverHttp_ListCapabilitiesReflectsFilterAndDisabledActionAnswersNotInstalled() + { + var options = OnlineCapabilityTestHarness.CreateOptions(); + options.EnableRuntime = false; + options.Filter = new CapabilityAssemblyFilter(null, new[] { "online-assets" }); + options.UseAdminApi(adminOptions => + { + adminOptions.AdminPort = AdminTestPort; + adminOptions.AdminApiPrefix = "online/admin"; + }); + + await WithComposedAsync(options, async runtime => + { + Assert.Null(runtime.Host); + await runtime.StartAsync(); + + using (var inner = ParseInner(await PostAsync("list_capabilities", ScopeBody))) + { + Assert.Equal(0, inner.RootElement.GetProperty("Code").GetInt32()); + var data = inner.RootElement.GetProperty("Data"); + var entries = IndexBy(data.GetProperty("Capabilities"), "Id"); + Assert.Equal(7, entries.Count); + Assert.Equal("ExcludedByDisableList", entries["online-assets"].GetProperty("State").GetString()); + Assert.Equal("Enabled", entries["online-foundation"].GetProperty("State").GetString()); + Assert.Equal(6, data.GetProperty("EnabledIdsInOrder").GetArrayLength()); + } + + using (var inner = ParseInner(await PostAsync("grant_asset", "{\"TenantId\":1,\"AppId\":1,\"ServerId\":1001,\"RequestId\":\"r2\"}"))) + { + Assert.Equal((int)OnlineErrorCode.CapabilityNotInstalled, inner.RootElement.GetProperty("Code").GetInt32()); + Assert.Equal("online-assets", inner.RootElement.GetProperty("Data").GetProperty("NotInstalledCapability").GetString()); + } + }); + } + + /// + /// 白名单闭包缺口:白名单含 online-matchmaking 而未含其必选依赖 online-social——依赖检查一次报全并 fail-fast,装配日志记 Error。 + /// + [Fact] + public void Compose_EnableListClosureGap_ThrowsAssemblyException() + { + var options = OnlineCapabilityTestHarness.CreateOptions(); + options.Filter = new CapabilityAssemblyFilter(new[] { "online-matchmaking" }); + + var exception = Assert.Throws(() => CapabilityComposition.Compose(new OnlineCapabilityFamily(), options)); + + Assert.Contains(exception.Problems, problem => problem.Contains("online-matchmaking") && problem.Contains("online-social")); + Assert.NotEmpty(((CollectingCapabilityAssemblyLog)options.AssemblyLog).Errors); + } + + /// + /// 白名单闭包完整:含全部必选依赖链时按白名单装配(拓扑序)。 + /// + [Fact] + public async Task Compose_EnableListWithClosure_AssemblesExactlyThatList() + { + await OnlineCapabilityTestHarness.WithRuntimeAsync(runtime => + { + Assert.Equal(new[] { "online-foundation", "online-social", "online-matchmaking" }, runtime.Report.EnabledIdsInOrder); + return Task.CompletedTask; + }, null, options => options.Filter = new CapabilityAssemblyFilter(new[] { "online-foundation", "online-social", "online-matchmaking" })); + } + + /// + /// 未知能力 Id(非空白、未被部署扫描发现):白名单与排除表引用都在组合期 fail-fast,消息带该 Id。 + /// + [Fact] + public void Compose_UnknownCapabilityId_FailsFast() + { + var enableOptions = OnlineCapabilityTestHarness.CreateOptions(); + enableOptions.Filter = new CapabilityAssemblyFilter(new[] { "online-ghost" }); + var enableException = Assert.Throws(() => CapabilityComposition.Compose(new OnlineCapabilityFamily(), enableOptions)); + Assert.Contains("online-ghost", enableException.Message); + + var disableOptions = OnlineCapabilityTestHarness.CreateOptions(); + disableOptions.Filter = new CapabilityAssemblyFilter(null, new[] { "online-ghost" }); + var disableException = Assert.Throws(() => CapabilityComposition.Compose(new OnlineCapabilityFamily(), disableOptions)); + Assert.Contains("online-ghost", disableException.Message); + } + + /// + /// 双面均未启用:组合期 fail-fast,消息为 Capability.Hosting.Composition.NoPlaneEnabled 本地化文案。 + /// + [Fact] + public void Compose_NoPlaneEnabled_FailsFastWithKey() + { + var options = OnlineCapabilityTestHarness.CreateOptions(); + options.EnableRuntime = false; + + var exception = Assert.Throws(() => CapabilityComposition.Compose(new OnlineCapabilityFamily(), options)); + + Assert.Equal(LocalizationService.GetString(GameFrameX.Localization.Keys.Capability.Hosting.Composition.NoPlaneEnabled), exception.Message); + } + + /// + /// 组合选项缺失:参数校验直接拒绝。 + /// + [Fact] + public void Compose_NullOptions_ThrowsArgumentNull() + { + Assert.Throws(() => CapabilityComposition.Compose(new OnlineCapabilityFamily(), null)); + } + + /// + /// 族配置段作用域非法(缺 TenantId / TenantId=0):族基建读取三元组即 fail-fast,消息带配置路径。 + /// + /// 族配置段原文。 + [Theory] + [InlineData("{\"AppId\":1,\"ServerId\":1001}")] + [InlineData("{\"TenantId\":0,\"AppId\":1,\"ServerId\":1001}")] + public void Compose_FamilySectionTenantInvalid_FailsFast(string familySectionJson) + { + var options = CreateBareOptions(JsonDocument.Parse(familySectionJson).RootElement.Clone()); + options.Override(new OnlineCapabilityTestHarness.EmptyPlayerDirectory()); + options.Override(new OnlineCapabilityTestHarness.NoopHotfixRollbackExecutor()); + + var exception = Assert.Throws(() => CapabilityComposition.Compose(new OnlineCapabilityFamily(), options)); + + Assert.Contains("Online.TenantId", exception.Message); + } + + /// + /// 宿主选项作用域非法(TenantId=0):机制校验宿主选项 fail-fast(Capability.Hosting.OptionsInvalid)。 + /// + [Fact] + public void Compose_HostOptionsInvalid_FailsFast() + { + var options = OnlineCapabilityTestHarness.CreateOptions(); + options.HostOptions.TenantId = 0; + + var exception = Assert.Throws(() => CapabilityComposition.Compose(new OnlineCapabilityFamily(), options)); + + Assert.Equal(LocalizationService.GetString(GameFrameX.Localization.Keys.Capability.Hosting.OptionsInvalid), exception.Message); + } + + /// + /// 管理面监听选项非法(端口 0):管理面组装 fail-fast;失败组合已就地释放钩子声明,随后同进程再次组合成功。 + /// + [Fact] + public async Task Compose_InvalidAdminOptions_FailsFastAndReleasesHook() + { + var options = OnlineCapabilityTestHarness.CreateOptions(); + options.UseAdminApi(adminOptions => + { + adminOptions.AdminPort = 0; + adminOptions.AdminApiPrefix = "online/admin"; + }); + + var exception = Assert.Throws(() => CapabilityComposition.Compose(new OnlineCapabilityFamily(), options)); + Assert.Equal(LocalizationService.GetString(GameFrameX.Localization.Keys.Capability.Hosting.AdminApi.AdminApiOptionsInvalid), exception.Message); + + await OnlineCapabilityTestHarness.WithRuntimeAsync(runtime => + { + Assert.Equal(ExpectedTopologicalOrder, runtime.Report.EnabledIdsInOrder); + return Task.CompletedTask; + }); + } + + /// + /// 宿主扩展点缺失:Social 的 RequireHostService<IOnlinePlayerDirectory> 无宿主 Override 即 fail-fast(HostServiceMissing)。 + /// + [Fact] + public void Compose_PlayerDirectoryMissing_FailsFast() + { + var options = CreateBareOptions(OnlineCapabilityTestHarness.FamilySection()); + options.Override(new OnlineCapabilityTestHarness.NoopHotfixRollbackExecutor()); + + var exception = Assert.Throws(() => CapabilityComposition.Compose(new OnlineCapabilityFamily(), options)); + + Assert.Contains("online-social", exception.Message); + Assert.Contains(typeof(IOnlinePlayerDirectory).FullName, exception.Message); + } + + /// + /// 宿主扩展点缺失:Operations 的 RequireHostService<IOnlineHotfixRollbackExecutor> 无宿主 Override 即 fail-fast(HostServiceMissing)。 + /// + [Fact] + public void Compose_HotfixRollbackExecutorMissing_FailsFast() + { + var options = CreateBareOptions(OnlineCapabilityTestHarness.FamilySection()); + options.Override(new OnlineCapabilityTestHarness.EmptyPlayerDirectory()); + + var exception = Assert.Throws(() => CapabilityComposition.Compose(new OnlineCapabilityFamily(), options)); + + Assert.Contains("online-operations", exception.Message); + Assert.Contains(typeof(IOnlineHotfixRollbackExecutor).FullName, exception.Message); + } + + /// + /// 宿主扩展点满足:热更回滚执行器解析为宿主 Override 实例;制品获取器解析为 Operations 包内默认实现 + ///(生产路径不再恒缺席 8002)。 + /// + [Fact] + public async Task Compose_HostOverrides_ExecutorResolvesToOverrideAndFetcherToPackageDefault() + { + var executor = new OnlineCapabilityTestHarness.NoopHotfixRollbackExecutor(); + var options = CreateBareOptions(OnlineCapabilityTestHarness.FamilySection()); + options.Override(new OnlineCapabilityTestHarness.EmptyPlayerDirectory()); + options.Override(executor); + + await WithComposedAsync(options, runtime => + { + Assert.Same(executor, runtime.Host.Services.GetService()); + Assert.IsType(runtime.Host.Services.GetService()); + return Task.CompletedTask; + }); + } + + /// + /// 钩子声明释放回归(真实族):Social 在 Register 期声明 IOnlineChatModerationHook——组合存活期间再次组合 fail-fast + ///(HookDuplicateDeclaration);StopAsync 后再次组合成功(启动过 / 从未启动两条停止路径都释放声明)。 + /// + [Fact] + public async Task Compose_StopThenComposeAgain_ReleasesHookDeclaration() + { + var first = OnlineCapabilityTestHarness.Compose(); + try + { + var duplicate = Assert.Throws(() => OnlineCapabilityTestHarness.Compose()); + Assert.Contains(typeof(IOnlineChatModerationHook).FullName, duplicate.Message); + await first.Host.StartAsync(); + } + finally + { + await first.StopAsync(); + } + + var neverStarted = OnlineCapabilityTestHarness.Compose(); + await neverStarted.StopAsync(); + + await OnlineCapabilityTestHarness.WithRuntimeAsync(async runtime => + { + Assert.Equal(ExpectedTopologicalOrder, runtime.Report.EnabledIdsInOrder); + await runtime.Host.StartAsync(); + }); + } + + /// + /// list_capabilities 元端点:启用序 = 报告拓扑序;条目携带必选 / 可选依赖状态与可选依赖 Id 集合(全装配时无缺失功能)。 + /// + [Fact] + public async Task Dispatch_ListCapabilities_ReportsOrderDependenciesAndOptionalIds() + { + await OnlineCapabilityTestHarness.WithRuntimeAsync(async runtime => + { + var dispatcher = OnlineCapabilityTestHarness.CreateDispatcher(runtime.Host); + using var inner = ParseInner(await dispatcher.DispatchAsync("list_capabilities", ScopeBody)); + Assert.Equal(0, inner.RootElement.GetProperty("Code").GetInt32()); + var data = inner.RootElement.GetProperty("Data"); + Assert.Equal(ExpectedTopologicalOrder, ReadStrings(data.GetProperty("EnabledIdsInOrder"))); + + var entries = IndexBy(data.GetProperty("Capabilities"), "Id"); + Assert.Equal(7, entries.Count); + + var match = entries["online-match"]; + Assert.Equal(new[] { "online-assets", "online-matchmaking" }, ReadStrings(match.GetProperty("OptionalCapabilityIds"))); + var matchDependencies = IndexBy(match.GetProperty("Dependencies"), "CapabilityId"); + Assert.Equal("Optional", matchDependencies["online-assets"].GetProperty("Kind").GetString()); + Assert.Equal("Assembled", matchDependencies["online-assets"].GetProperty("State").GetString()); + Assert.Equal("None", matchDependencies["online-assets"].GetProperty("AbsenceReason").GetString()); + Assert.Equal(0, match.GetProperty("AbsentFeatures").GetArrayLength()); + + var social = entries["online-social"]; + Assert.Contains("online-foundation", ReadStrings(social.GetProperty("Requires"))); + var socialDependencies = IndexBy(social.GetProperty("Dependencies"), "CapabilityId"); + Assert.Equal("Required", socialDependencies["online-foundation"].GetProperty("Kind").GetString()); + Assert.Equal("Assembled", socialDependencies["online-foundation"].GetProperty("State").GetString()); + }); + } + + /// + /// list_capability_services 元端点:宿主扩展点以覆盖身份登记(来源 = 族 Id),模块默认 store 以本能力来源登记且未被覆盖。 + /// + [Fact] + public async Task Dispatch_ListCapabilityServices_ReportsOwnersAndOverrideFlags() + { + await OnlineCapabilityTestHarness.WithRuntimeAsync(async runtime => + { + var dispatcher = OnlineCapabilityTestHarness.CreateDispatcher(runtime.Host); + using var inner = ParseInner(await dispatcher.DispatchAsync("list_capability_services", ScopeBody)); + Assert.Equal(0, inner.RootElement.GetProperty("Code").GetInt32()); + var services = inner.RootElement.GetProperty("Data").GetProperty("Services").EnumerateArray().ToList(); + + var directory = services.Single(service => service.GetProperty("ServiceType").GetString() == typeof(IOnlinePlayerDirectory).FullName); + Assert.True(directory.GetProperty("IsOverride").GetBoolean()); + Assert.Equal("online", directory.GetProperty("OwnerCapabilityId").GetString()); + + var friendStore = services.Single(service => service.GetProperty("ServiceType").GetString() == typeof(IOnlineFriendStore).FullName); + Assert.Equal("online-social", friendStore.GetProperty("OwnerCapabilityId").GetString()); + Assert.False(friendStore.GetProperty("IsOverride").GetBoolean()); + Assert.False(friendStore.GetProperty("IsOverridden").GetBoolean()); + }); + } + + /// + /// list_capability_services 元端点:宿主 Override 一个模块默认 store 时两条登记并列——默认实现标注 IsOverridden,覆盖实现标注 IsOverride, + /// 宿主侧解析命中覆盖实例。 + /// + [Fact] + public async Task Dispatch_ListCapabilityServices_OverrideShadowsModuleDefault() + { + var overrideStore = new InMemoryOnlineFriendStore(); + await OnlineCapabilityTestHarness.WithRuntimeAsync(async runtime => + { + Assert.Same(overrideStore, runtime.Host.Services.GetService()); + var dispatcher = OnlineCapabilityTestHarness.CreateDispatcher(runtime.Host); + using var inner = ParseInner(await dispatcher.DispatchAsync("list_capability_services", ScopeBody)); + var friendStores = inner.RootElement.GetProperty("Data").GetProperty("Services").EnumerateArray() + .Where(service => service.GetProperty("ServiceType").GetString() == typeof(IOnlineFriendStore).FullName) + .ToList(); + + Assert.Equal(2, friendStores.Count); + var moduleDefault = friendStores.Single(service => !service.GetProperty("IsOverride").GetBoolean()); + Assert.Equal("online-social", moduleDefault.GetProperty("OwnerCapabilityId").GetString()); + Assert.True(moduleDefault.GetProperty("IsOverridden").GetBoolean()); + var hostOverride = friendStores.Single(service => service.GetProperty("IsOverride").GetBoolean()); + Assert.Equal("online", hostOverride.GetProperty("OwnerCapabilityId").GetString()); + }, null, options => options.Override(overrideStore)); + } + + /// + /// 配置段覆盖 Options 字段:Friend.SearchLimit=5 生效且来源为 ConfigurationSection,未配置字段保持 Default; + /// 报告与 list_capability_settings 元端点同源一致。 + /// + [Fact] + public async Task Settings_SectionOverride_ReportsConfigurationSectionSource() + { + await OnlineCapabilityTestHarness.WithRuntimeAsync(async runtime => + { + var social = FindEntry(runtime.Report, "online-social"); + var searchLimit = social.SettingsFields.Single(field => field.FieldPath == "Friend.SearchLimit"); + Assert.Equal(5, (int)searchLimit.EffectiveValue); + Assert.Equal(CapabilitySettingsFieldSource.ConfigurationSection, searchLimit.Source); + var friendLimit = social.SettingsFields.Single(field => field.FieldPath == "Friend.FriendLimit"); + Assert.Equal(DefaultFriendLimit, (int)friendLimit.EffectiveValue); + Assert.Equal(CapabilitySettingsFieldSource.Default, friendLimit.Source); + + var dispatcher = OnlineCapabilityTestHarness.CreateDispatcher(runtime.Host); + using var inner = ParseInner(await dispatcher.DispatchAsync("list_capability_settings", ScopeBody)); + Assert.Equal(0, inner.RootElement.GetProperty("Code").GetInt32()); + var socialEntry = IndexBy(inner.RootElement.GetProperty("Data").GetProperty("Capabilities"), "Id")["online-social"]; + var fields = IndexBy(socialEntry.GetProperty("SettingsFields"), "FieldPath"); + Assert.Equal(5, fields["Friend.SearchLimit"].GetProperty("Value").GetInt32()); + Assert.Equal("ConfigurationSection", fields["Friend.SearchLimit"].GetProperty("Source").GetString()); + Assert.Equal(DefaultFriendLimit, fields["Friend.FriendLimit"].GetProperty("Value").GetInt32()); + Assert.Equal("Default", fields["Friend.FriendLimit"].GetProperty("Source").GetString()); + }, "{\"online-social\":{\"Friend\":{\"SearchLimit\":5}}}"); + } + + /// + /// 代码 Configure 胜出配置段:配置段 SearchLimit=5、Configure 改为 7——生效值 7、来源 Code(合并优先级 默认 < 配置段 < 代码)。 + /// + [Fact] + public async Task Settings_ConfigureBeatsSection_ReportsCodeSource() + { + await OnlineCapabilityTestHarness.WithRuntimeAsync(runtime => + { + var searchLimit = FindEntry(runtime.Report, "online-social").SettingsFields.Single(field => field.FieldPath == "Friend.SearchLimit"); + Assert.Equal(7, (int)searchLimit.EffectiveValue); + Assert.Equal(CapabilitySettingsFieldSource.Code, searchLimit.Source); + Assert.NotEqual(DefaultFriendSearchLimit, (int)searchLimit.EffectiveValue); + return Task.CompletedTask; + }, "{\"online-social\":{\"Friend\":{\"SearchLimit\":5}}}", options => options.Configure(social => social.Friend.SearchLimit = 7)); + } + + /// + /// 可选依赖缺失(排除 online-assets 与 online-matchmaking):online-match 照常装配并可激活,报告标注两条可选依赖 + /// NotAssembled / ExcludedByDisableList,缺失功能文案为对应本地化键文本。 + /// + [Fact] + public async Task Compose_OptionalDependenciesMissing_ComposesMatchWithAbsentFeatures() + { + await OnlineCapabilityTestHarness.WithRuntimeAsync(async runtime => + { + var match = FindEntry(runtime.Report, "online-match"); + Assert.Equal(CapabilityFilterState.Enabled, match.State); + Assert.Contains("online-match", runtime.Report.EnabledIdsInOrder); + Assert.Equal(5, runtime.Report.EnabledIdsInOrder.Count); + + foreach (var absentId in new[] { "online-assets", "online-matchmaking" }) + { + var dependency = match.Dependencies.Single(item => item.CapabilityId == absentId); + Assert.Equal(CapabilityDependencyKind.Optional, dependency.Kind); + Assert.Equal(CapabilityDependencyAssemblyState.NotAssembled, dependency.State); + Assert.Equal(CapabilityDependencyAbsenceReason.ExcludedByDisableList, dependency.AbsenceReason); + } + + Assert.Equal(2, match.AbsentFeatures.Count); + Assert.Contains(LocalizationService.GetString(GameFrameX.Localization.Keys.Capability.Online.Match.OptionalAssetsAbsent), match.AbsentFeatures); + Assert.Contains(LocalizationService.GetString(GameFrameX.Localization.Keys.Capability.Online.Match.OptionalMatchmakingAbsent), match.AbsentFeatures); + + Assert.Contains(runtime.Host.Modules, module => module.Manifest.Id == "online-match"); + await runtime.Host.StartAsync(); + }, null, options => options.Filter = new CapabilityAssemblyFilter(null, new[] { "online-assets", "online-matchmaking" })); + } + + /// + /// 目录外未知 action:既非已注册 action、也不属于任何被禁用能力——答 4002(HTTP 恒 200,外层恒 0)。 + /// + [Fact] + public async Task Dispatch_UnknownActionOutsideCatalog_AnswersResourceNotFound() + { + await OnlineCapabilityTestHarness.WithRuntimeAsync(async runtime => + { + var dispatcher = OnlineCapabilityTestHarness.CreateDispatcher(runtime.Host); + using var inner = ParseInner(await dispatcher.DispatchAsync("definitely_not_an_action", ScopeBody)); + Assert.Equal((int)OnlineErrorCode.ResourceNotFound, inner.RootElement.GetProperty("Code").GetInt32()); + }); + } +} diff --git a/Tests/GameFrameX.Tests/Online/OnlineCapabilityTestHarness.cs b/Tests/GameFrameX.Tests/Online/OnlineCapabilityTestHarness.cs new file mode 100644 index 000000000..2d5f0d647 --- /dev/null +++ b/Tests/GameFrameX.Tests/Online/OnlineCapabilityTestHarness.cs @@ -0,0 +1,297 @@ +// ========================================================================================== +// GameFrameX 组织及其衍生项目的版权、商标、专利及其他相关权利 +// GameFrameX organization and its derivative projects' copyrights, trademarks, patents and related rights +// 均受中华人民共和国及相关国际法律法规保护。 +// are protected by the laws of the PRC 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 refer to the LICENSE file at the root 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 the legal 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://www.cnb.cool/GameFrameX +// CNB Repository: https://www.cnb.cool/GameFrameX +// 官方文档:https://gameframex.doc.alianblank.com/ +// Official Docs: https://gameframex.doc.alianblank.com/ +// ========================================================================================== + +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using GameFrameX.Capability.Hosting.Abstractions; +using GameFrameX.Capability.Hosting.AdminApi; +using GameFrameX.Capability.Hosting.Core; +using GameFrameX.Capability.Online.Abstractions; +using GameFrameX.Capability.Online.Abstractions.Contracts; +using GameFrameX.Capability.Online.Abstractions.Idempotency; +using GameFrameX.Capability.Online.Abstractions.Scope; +using GameFrameX.Capability.Online.Operations.HotfixRollback; +using GameFrameX.Capability.Online.Social; +using Xunit; + +namespace GameFrameX.Tests.Online; + +/// +/// 测试用装配日志收集器(C294 起 是组合必填出口; +/// 本类在进程内收集 Info / Error 两条流,供用例断言装配日志内容)。 +/// +public sealed class CollectingCapabilityAssemblyLog : ICapabilityAssemblyLog +{ + /// Info 级日志流。 + public readonly List Infos = new(); + + /// Error 级日志流。 + public readonly List Errors = new(); + + /// + public void Info(string message) + { + Infos.Add(message); + } + + /// + public void Error(string message) + { + Errors.Add(message); + } +} + +/// +/// Online 能力族测试组合挂具(C294:测试与宿主共用同一装配面 +/// ——族配置段 JSON、宿主 Override、过滤器与 +/// 管理面适配器全部经 注入;admin 调度器按 +/// AdminApiComposition.CreateAdminApiPlane 同口径组装但直接暴露 +/// ,供行为用例内网调用)。 +/// +public static class OnlineCapabilityTestHarness +{ + /// 测试用租户标识(与族配置段一致)。 + public const long TenantId = 1; + + /// 测试用应用标识(与族配置段一致)。 + public const long AppId = 1; + + /// 测试用区服标识(与族配置段一致)。 + public const long ServerId = 1001; + + /// + /// 构造族配置段 JSON(作用域三元组 + 可选 Settings 子对象;缺失键由族 fail-fast,因此恒写入)。 + /// + /// Settings 子对象原文(null = 不写 Settings 键)。 + /// 族配置段节点。 + public static JsonElement FamilySection(string settingsJson = null) + { + var settings = string.IsNullOrEmpty(settingsJson) ? string.Empty : ",\"Settings\":" + settingsJson; + return JsonDocument.Parse("{\"TenantId\":1,\"AppId\":1,\"ServerId\":1001" + settings + "}").RootElement.Clone(); + } + + /// + /// 构造组合选项(收集器日志 + 合法宿主选项 + 两个宿主扩展点 Override)。 + /// + /// 管理面适配器(错误码表 / 作用域守卫 / 幂等包裹)由族基建 注册, + /// 测试不得 Override 绕行——UseAdminApi 形态必须走真实族基建解析路径。 + /// + /// + /// Settings 子对象原文(null = 不写 Settings 键)。 + /// 组合选项。 + public static CapabilityCompositionOptions CreateOptions(string settingsJson = null) + { + var options = new CapabilityCompositionOptions(FamilySection(settingsJson), new CollectingCapabilityAssemblyLog()) + { + HostOptions = new CapabilityHostOptions { TenantId = TenantId, AppId = AppId, ServerId = ServerId, }, + }; + + // 宿主扩展点:Social 的玩家目录与 Operations 的热更回滚执行器(两者均 RequireHostService,缺失即装配 fail-fast)。 + options.Override(new EmptyPlayerDirectory()); + options.Override(new NoopHotfixRollbackExecutor()); + return options; + } + + /// + /// 组合 Online 能力族(全装配;过滤器 / Settings / Configure 由用例经 追加)。 + /// + /// Settings 子对象原文(null = 不写 Settings 键)。 + /// 用例追加的编排动作。 + /// 组合产物。 + public static CapabilityRuntime Compose(string settingsJson = null, Action configure = null) + { + var options = CreateOptions(settingsJson); + configure?.Invoke(options); + return CapabilityComposition.Compose(new OnlineCapabilityFamily(), options); + } + + /// + /// 按机制同口径组装 admin 调度器(遍历模块贡献 action;错误码表 / 作用域守卫 / 幂等包裹一律从 + /// 族基建注册的宿主共享服务面解析——与 AdminApiComposition.CreateAdminApiPlane 同源,测试不另造适配器)。 + /// + /// 已装配能力宿主。 + /// admin 调度器。 + public static AdminApiDispatcher CreateDispatcher(CapabilityHost host) + { + var registry = new AdminActionRegistry(); + foreach (var module in host.Modules) + { + if (module is IAdminActionContributor contributor) + { + contributor.RegisterAdminActions(registry); + } + } + + return new AdminApiDispatcher( + registry, + host.Services.GetService(), + host.Services.GetService(), + new AdminApiClaimedScope(host.TenantId, host.AppId, host.ServerId), + host.Services.GetOptionalService(), + host.Report, + host.Services); + } + + /// + /// 组合并启动业务面(Activate 桥发布完成后返回;用例须在结束时 StopAsync 以撤桥)。 + /// + /// Settings 子对象原文。 + /// 用例追加的编排动作。 + /// 已启动的组合产物。 + public static async Task ComposeStartedAsync(string settingsJson = null, Action configure = null) + { + var runtime = Compose(settingsJson, configure); + await runtime.Host.StartAsync(); + return runtime; + } + + /// + /// 组合并在 结束后恒停止(finally StopAsync:撤桥 + 释放钩子声明—— + /// 同进程下一次组合才能重新声明 IOnlineChatModerationHook)。 + /// + /// 用例主体(入参为已组合、未启动的产物)。 + /// Settings 子对象原文。 + /// 用例追加的编排动作。 + /// 异步任务。 + public static async Task WithRuntimeAsync(Func body, string settingsJson = null, Action configure = null) + { + var runtime = Compose(settingsJson, configure); + try + { + await body(runtime); + } + finally + { + await runtime.StopAsync(); + } + } + + /// + /// 空玩家目录桩(Social 的 RequireHostService<IOnlinePlayerDirectory> 最小满足; + /// 搜索 / 解析均返回空集合 = 部署未接目录的披露态,不是伪数据)。 + /// + public sealed class EmptyPlayerDirectory : IOnlinePlayerDirectory + { + /// + public Task> SearchByNameAsync(long tenantId, long appId, string keyword, int limit, CancellationToken cancellationToken = default) + { + return Task.FromResult>(Array.Empty()); + } + + /// + public Task> FindAsync(long tenantId, long appId, IReadOnlyList playerIds, CancellationToken cancellationToken = default) + { + return Task.FromResult>(Array.Empty()); + } + } + + /// + /// 空操作热更回滚执行器桩(Operations 的 RequireHostService<IOnlineHotfixRollbackExecutor> 最小满足)。 + /// + public sealed class NoopHotfixRollbackExecutor : IOnlineHotfixRollbackExecutor + { + /// + public Task RollbackAsync(string targetVersion, CancellationToken cancellationToken = default) + { + return Task.CompletedTask; + } + } +} + +/// +/// 好友域桥验证种子(C294 §4.6:生产配置键 FriendBridgeSeedPlayerIds 删除后,种子语义挪进测试夹具; +/// 语句级迁移自旧 OnlineFriendDomainBridgeWiring.SeedFriendDomainBridgeVerificationAsync—— +/// 首元素为请求方、其余为好友,经真实 写路径两两建立,幂等收敛)。 +/// +public static class FriendBridgeVerificationSeed +{ + /// + /// 经真实写路径两两建立种子好友关系(空 / 单元素列表为空操作;重复种子幂等命中既有关系)。 + /// + /// 已装配能力宿主。 + /// 种子标识列表(首元素 = 请求方,其余 = 好友)。 + /// 成功建立的关系数。 + public static async Task SeedAsync(CapabilityHost host, IReadOnlyList friendPlayerIds) + { + Assert.NotNull(host); + if (friendPlayerIds == null || friendPlayerIds.Count < 2) + { + return 0; + } + + var friends = host.Services.GetOptionalService(); + if (friends == null) + { + return 0; + } + + var requesterId = friendPlayerIds[0]; + var established = 0; + for (var index = 1; index < friendPlayerIds.Count; index++) + { + var friendPlayerId = friendPlayerIds[index]; + if (friendPlayerId <= 0 || friendPlayerId == requesterId) + { + continue; + } + + var requested = await friends.RequestAsync(new OnlineScope(host.TenantId, host.AppId, host.ServerId, requesterId), friendPlayerId); + if (!requested.IsSuccess) + { + continue; + } + + var accepted = await friends.AcceptAsync(new OnlineScope(host.TenantId, host.AppId, host.ServerId, friendPlayerId), requested.Data.FriendshipId); + if (!accepted.IsSuccess && accepted.Code != OnlineErrorCode.StateOperationForbidden) + { + continue; + } + + established++; + } + + return established; + } +} + +/// +/// 能力运行时串行集合(C294:桥槽位与钩子声明槽位均为进程级静态——同契约重复发布 / 重复声明 fail-fast, +/// 钩子声明在 Compose 的 Register 期即落位、StopAsync 才释放;机制场景槽位亦为静态。 +/// 因此本测试程序集内凡调用 CapabilityComposition.Compose、发布或消费桥契约的用例, +/// 一律落本集合且禁并行——禁并行集合在 xUnit 中独占执行,不与其它任何集合交叠)。 +/// +[CollectionDefinition(Name, DisableParallelization = true)] +public sealed class CapabilityRuntimeCollection +{ + /// 集合名。 + public const string Name = "CapabilityRuntimeCollection"; +} diff --git a/Tests/GameFrameX.Tests/Online/OnlineChatServiceTests.cs b/Tests/GameFrameX.Tests/Online/OnlineChatServiceTests.cs index 8c64a7954..078abddba 100644 --- a/Tests/GameFrameX.Tests/Online/OnlineChatServiceTests.cs +++ b/Tests/GameFrameX.Tests/Online/OnlineChatServiceTests.cs @@ -28,6 +28,7 @@ // ========================================================================================== using System; +using GameFrameX.Capability.Online.Abstractions; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; @@ -862,15 +863,29 @@ private static OnlineScope CreatePlayerScope(long playerId) } /// - /// 创建被测聊天服务(并把内部件暴露给断言用)。 + /// 以包内默认审核插件(放行)创建被测聊天服务。 /// /// 事件记录桩。 /// 频控窗口内允许的条数(默认取上限避免干扰非频控用例)。 /// 撤回窗口(秒)。 /// 频道成员资格探针(可空)。 - /// 内容审核插件(可空)。 /// 聊天服务实例(已把存储、裁决服务挂在测试夹具上)。 - private static TestChatFixture CreateService(out OnlineEventRecorder recorder, int rateLimitMaxMessages = 1000, int recallWindowSeconds = 120, IOnlineChannelMembershipProbe membershipProbe = null, IOnlineChatModerationHook moderationHook = null) + private static TestChatFixture CreateService(out OnlineEventRecorder recorder, int rateLimitMaxMessages = 1000, int recallWindowSeconds = 120, IOnlineChannelMembershipProbe membershipProbe = null) + { + // 未给审核插件 = 包内默认放行实现(与 Social 声明的钩子默认一致)。 + return CreateService(out recorder, new OnlineChatModerationAllowAllHook(), rateLimitMaxMessages, recallWindowSeconds, membershipProbe); + } + + /// + /// 以指定审核插件创建被测聊天服务(并把内部件暴露给断言用)。 + /// + /// 事件记录桩。 + /// 内容审核插件。 + /// 频控窗口内允许的条数(默认取上限避免干扰非频控用例)。 + /// 撤回窗口(秒)。 + /// 频道成员资格探针(可空)。 + /// 聊天服务实例(已把存储、裁决服务挂在测试夹具上)。 + private static TestChatFixture CreateService(out OnlineEventRecorder recorder, IOnlineChatModerationHook moderationHook, int rateLimitMaxMessages = 1000, int recallWindowSeconds = 120, IOnlineChannelMembershipProbe membershipProbe = null) { recorder = new OnlineEventRecorder(); var store = new InMemoryOnlineChatStore(); @@ -884,7 +899,10 @@ private static TestChatFixture CreateService(out OnlineEventRecorder recorder, i RateLimitMaxMessages = rateLimitMaxMessages, HistoryRetentionSeconds = 0, }; - var service = new OnlineChatService(store, recorder, decisionService, options, membershipProbe, moderationHook); + // C294:构造面改为探针集合 + 审核钩子提供器(逐调用读取,生产经 CapabilityHookHandle.Current 供值); + // 未给探针 = 空集合(无归属域)。 + var membershipProbes = membershipProbe == null ? new List() : new List { membershipProbe }; + var service = new OnlineChatService(store, recorder, decisionService, options, membershipProbes, () => moderationHook); return new TestChatFixture(service, store, decisionService); } diff --git a/Tests/GameFrameX.Tests/Online/OnlineCrossServerGrantRouterTests.cs b/Tests/GameFrameX.Tests/Online/OnlineCrossServerGrantRouterTests.cs index a68382c93..500ad510d 100644 --- a/Tests/GameFrameX.Tests/Online/OnlineCrossServerGrantRouterTests.cs +++ b/Tests/GameFrameX.Tests/Online/OnlineCrossServerGrantRouterTests.cs @@ -186,9 +186,9 @@ public OnlineGrantService CreateGrantService(InMemoryOnlineAssetStore assetStore return new OnlineGrantService(assetStore, new InMemoryOnlineAssetTransactionStore(), idempotencyService, new NullPublisher(), AlertSink); } - public OnlineCrossServerGrantRouter CreateRouter(OnlineGrantService localService, IOnlineCrossServerGrantTransport transport, OnlineGrantCompensationQueue queue = null) + public OnlineCrossServerGrantRouter CreateRouter(OnlineGrantService localService, IOnlineCrossServerGrantTransport transport) { - return new OnlineCrossServerGrantRouter(100, localService, transport, queue ?? new OnlineGrantCompensationQueue(transport, AlertSink)); + return new OnlineCrossServerGrantRouter(100, localService, transport, new OnlineGrantCompensationQueue(transport, AlertSink)); } } diff --git a/Tests/GameFrameX.Tests/Online/OnlineDependencyMatrixTests.cs b/Tests/GameFrameX.Tests/Online/OnlineDependencyMatrixTests.cs index c1be4119a..4e669d74e 100644 --- a/Tests/GameFrameX.Tests/Online/OnlineDependencyMatrixTests.cs +++ b/Tests/GameFrameX.Tests/Online/OnlineDependencyMatrixTests.cs @@ -1,87 +1,247 @@ +using System.Text.RegularExpressions; +using GameFrameX.Capability.Online.Assets; +using GameFrameX.Capability.Online.Competition.Leaderboard; +using GameFrameX.Capability.Online.Competition.Season; +using GameFrameX.Capability.Online.Competition.Tournament; +using GameFrameX.Capability.Online.Foundation.Presence; +using GameFrameX.Capability.Online.Foundation.Session; +using GameFrameX.Capability.Online.Matchmaking; +using GameFrameX.Capability.Online.Matchmaking.Party; +using GameFrameX.Capability.Online.Operations.HotfixRollback; +using GameFrameX.Capability.Online.Social; using Xunit; namespace GameFrameX.Tests.Online { /// - /// Online 事件 topic 冻结门禁测试(C223 立项,C228 升维,C232 P6 收口):全部 Online 事件的 - /// topic 常量与来源标识逐字节冻结(零行为变更红线)。 + /// Online 事件 topic 冻结门禁测试(C223 立项,C228 升维,C232 P6 收口,C294 重冻为全小写 + /// online.<域>.<事件> 命名约定):全部 Online 事件的 topic 常量与来源标识逐字节冻结, + /// 且运营审计桥订阅面与冻结表一一对应(竞技域 topic 以字面量订阅,须与竞技包常量同值)。 /// - /// 维护约束:目录级依赖矩阵已随双工程退役终结(C232 P5 停摆、P6 删除 GameFrameX.Online - /// 残量目录后正式退役)——包级方向断言由 全量接管, - /// 本测试只保留 topic 冻结面。 + /// 维护约束:目录级依赖矩阵已随双工程退役终结——包级方向断言由 + /// 全量接管,本测试只保留 topic 冻结面。 /// /// public class OnlineDependencyMatrixTests { /// - /// 断言事件 topic 常量与来源标识逐字节冻结(C223 零行为变更红线;值 = 2026-10-12 迁移前基线)。 + /// topic 命名约定:online.<域>.<事件>,三段全小写字母(EventType == topic)。 + /// + private static readonly Regex TopicConvention = new Regex(@"^online\.[a-z]+\.[a-z]+$"); + + /// + /// 冻结 topic 表(Member = 常量的「类型名.成员名」,用于把审计桥源码中的常量订阅解析回值; + /// Actual = 生产常量;Frozen = C294 冻结值)。 + /// + private static readonly (string Member, string Actual, string Frozen)[] FrozenTopics = + { + (nameof(OnlineAssetEvents) + "." + nameof(OnlineAssetEvents.AssetGranted), OnlineAssetEvents.AssetGranted, "online.asset.granted"), + (nameof(OnlineAssetEvents) + "." + nameof(OnlineAssetEvents.AssetDeducted), OnlineAssetEvents.AssetDeducted, "online.asset.deducted"), + (nameof(OnlineAssetEvents) + "." + nameof(OnlineAssetEvents.AssetAdjusted), OnlineAssetEvents.AssetAdjusted, "online.asset.adjusted"), + (nameof(OnlineChatEvents) + "." + nameof(OnlineChatEvents.MessageSent), OnlineChatEvents.MessageSent, "online.chat.messagesent"), + (nameof(OnlineChatEvents) + "." + nameof(OnlineChatEvents.MessageRecalled), OnlineChatEvents.MessageRecalled, "online.chat.messagerecalled"), + (nameof(OnlineChatEvents) + "." + nameof(OnlineChatEvents.ReadMarkUpdated), OnlineChatEvents.ReadMarkUpdated, "online.chat.readmarkupdated"), + (nameof(OnlineChatEvents) + "." + nameof(OnlineChatEvents.ModerationFailed), OnlineChatEvents.ModerationFailed, "online.chat.moderationfailed"), + (nameof(OnlineHotfixEvents) + "." + nameof(OnlineHotfixEvents.VersionRegistered), OnlineHotfixEvents.VersionRegistered, "online.hotfix.versionregistered"), + (nameof(OnlineHotfixEvents) + "." + nameof(OnlineHotfixEvents.RolledBack), OnlineHotfixEvents.RolledBack, "online.hotfix.rolledback"), + (nameof(OnlineLeaderboardEvents) + "." + nameof(OnlineLeaderboardEvents.LeaderboardCreated), OnlineLeaderboardEvents.LeaderboardCreated, "online.leaderboard.created"), + (nameof(OnlineLeaderboardEvents) + "." + nameof(OnlineLeaderboardEvents.ScoreUpdated), OnlineLeaderboardEvents.ScoreUpdated, "online.leaderboard.scoreupdated"), + (nameof(OnlineMatchEvents) + "." + nameof(OnlineMatchEvents.TicketChanged), OnlineMatchEvents.TicketChanged, "online.match.ticketchanged"), + (nameof(OnlineMatchEvents) + "." + nameof(OnlineMatchEvents.AssignmentCreated), OnlineMatchEvents.AssignmentCreated, "online.match.assignmentcreated"), + (nameof(GameFrameX.Capability.Online.Match.OnlineMatchRuntimeEvents) + "." + nameof(GameFrameX.Capability.Online.Match.OnlineMatchRuntimeEvents.MatchStateChanged), GameFrameX.Capability.Online.Match.OnlineMatchRuntimeEvents.MatchStateChanged, "online.match.statechanged"), + (nameof(GameFrameX.Capability.Online.Match.OnlineMatchRuntimeEvents) + "." + nameof(GameFrameX.Capability.Online.Match.OnlineMatchRuntimeEvents.MatchSettled), GameFrameX.Capability.Online.Match.OnlineMatchRuntimeEvents.MatchSettled, "online.match.settled"), + (nameof(OnlineNotificationEvents) + "." + nameof(OnlineNotificationEvents.NotificationChanged), OnlineNotificationEvents.NotificationChanged, "online.notification.changed"), + (nameof(OnlinePartyEvents) + "." + nameof(OnlinePartyEvents.PartyChanged), OnlinePartyEvents.PartyChanged, "online.party.changed"), + (nameof(OnlinePartyEvents) + "." + nameof(OnlinePartyEvents.PartyInviteChanged), OnlinePartyEvents.PartyInviteChanged, "online.party.invitechanged"), + (nameof(OnlinePresenceEvents) + "." + nameof(OnlinePresenceEvents.PresenceChanged), OnlinePresenceEvents.PresenceChanged, "online.presence.changed"), + (nameof(OnlineSeasonEvents) + "." + nameof(OnlineSeasonEvents.SeasonCreated), OnlineSeasonEvents.SeasonCreated, "online.season.created"), + (nameof(OnlineSeasonEvents) + "." + nameof(OnlineSeasonEvents.SeasonStarted), OnlineSeasonEvents.SeasonStarted, "online.season.started"), + (nameof(OnlineSeasonEvents) + "." + nameof(OnlineSeasonEvents.SeasonEnded), OnlineSeasonEvents.SeasonEnded, "online.season.ended"), + (nameof(OnlineSeasonEvents) + "." + nameof(OnlineSeasonEvents.SeasonSettled), OnlineSeasonEvents.SeasonSettled, "online.season.settled"), + (nameof(OnlineSessionEvents) + "." + nameof(OnlineSessionEvents.SessionCreated), OnlineSessionEvents.SessionCreated, "online.session.created"), + (nameof(OnlineSessionEvents) + "." + nameof(OnlineSessionEvents.SessionAuthenticated), OnlineSessionEvents.SessionAuthenticated, "online.session.authenticated"), + (nameof(OnlineSessionEvents) + "." + nameof(OnlineSessionEvents.SessionConnected), OnlineSessionEvents.SessionConnected, "online.session.connected"), + (nameof(OnlineSessionEvents) + "." + nameof(OnlineSessionEvents.SessionActive), OnlineSessionEvents.SessionActive, "online.session.active"), + (nameof(OnlineSessionEvents) + "." + nameof(OnlineSessionEvents.SessionReconnecting), OnlineSessionEvents.SessionReconnecting, "online.session.reconnecting"), + (nameof(OnlineSessionEvents) + "." + nameof(OnlineSessionEvents.SessionClosed), OnlineSessionEvents.SessionClosed, "online.session.closed"), + (nameof(OnlineSessionEvents) + "." + nameof(OnlineSessionEvents.SessionKicked), OnlineSessionEvents.SessionKicked, "online.session.kicked"), + (nameof(OnlineSessionEvents) + "." + nameof(OnlineSessionEvents.SessionExpired), OnlineSessionEvents.SessionExpired, "online.session.expired"), + (nameof(OnlineSessionEvents) + "." + nameof(OnlineSessionEvents.SessionTokenRefreshed), OnlineSessionEvents.SessionTokenRefreshed, "online.session.tokenrefreshed"), + (nameof(OnlineSocialEvents) + "." + nameof(OnlineSocialEvents.FriendshipChanged), OnlineSocialEvents.FriendshipChanged, "online.social.friendshipchanged"), + (nameof(OnlineSocialEvents) + "." + nameof(OnlineSocialEvents.BlockChanged), OnlineSocialEvents.BlockChanged, "online.social.blockchanged"), + (nameof(OnlineSocialEvents) + "." + nameof(OnlineSocialEvents.MuteChanged), OnlineSocialEvents.MuteChanged, "online.social.mutechanged"), + (nameof(OnlineSocialEvents) + "." + nameof(OnlineSocialEvents.ReportChanged), OnlineSocialEvents.ReportChanged, "online.social.reportchanged"), + (nameof(OnlineSocialEvents) + "." + nameof(OnlineSocialEvents.PunishmentChanged), OnlineSocialEvents.PunishmentChanged, "online.social.punishmentchanged"), + (nameof(OnlineSocialEvents) + "." + nameof(OnlineSocialEvents.GroupChanged), OnlineSocialEvents.GroupChanged, "online.social.groupchanged"), + (nameof(OnlineTournamentEvents) + "." + nameof(OnlineTournamentEvents.TournamentCreated), OnlineTournamentEvents.TournamentCreated, "online.tournament.created"), + (nameof(OnlineTournamentEvents) + "." + nameof(OnlineTournamentEvents.TournamentStarted), OnlineTournamentEvents.TournamentStarted, "online.tournament.started"), + (nameof(OnlineTournamentEvents) + "." + nameof(OnlineTournamentEvents.TournamentRegistered), OnlineTournamentEvents.TournamentRegistered, "online.tournament.registered"), + (nameof(OnlineTournamentEvents) + "." + nameof(OnlineTournamentEvents.TournamentEnded), OnlineTournamentEvents.TournamentEnded, "online.tournament.ended"), + (nameof(OnlineTournamentEvents) + "." + nameof(OnlineTournamentEvents.TournamentSettled), OnlineTournamentEvents.TournamentSettled, "online.tournament.settled"), + }; + + /// + /// 冻结来源标识表(Actual = 生产常量;Frozen = 冻结值;来源标识不随 topic 重命名变化)。 + /// + private static readonly (string Actual, string Frozen)[] FrozenSources = + { + (OnlineAssetEvents.Source, "online-assets"), + (OnlineChatEvents.Source, "online-chat"), + (OnlineHotfixEvents.Source, "online-hotfix"), + (OnlineLeaderboardEvents.Source, "online-leaderboard"), + (OnlineMatchEvents.Source, "online-matchmaker"), + (GameFrameX.Capability.Online.Match.OnlineMatchRuntimeEvents.Source, "online-match"), + (OnlineNotificationEvents.Source, "online-notification"), + (OnlinePartyEvents.Source, "online-party"), + (OnlinePresenceEvents.Source, "online-presence"), + (OnlineSeasonEvents.Source, "online-season"), + (OnlineSessionEvents.Source, "online-session"), + (OnlineSocialEvents.Source, "online-social"), + (OnlineTournamentEvents.Source, "online-tournament"), + }; + + /// + /// 断言事件 topic 常量与来源标识逐字节冻结(C294 重冻基线:topic 全小写三段,来源标识沿用迁移前基线)。 /// [Fact] public void EventTopicConstants_RemainByteFrozen() { - var expected = new (string Actual, string Frozen)[] + foreach (var (member, actual, frozen) in FrozenTopics) { - (GameFrameX.Capability.Online.Assets.OnlineAssetEvents.AssetGranted, "Online.Asset.Granted"), - (GameFrameX.Capability.Online.Assets.OnlineAssetEvents.AssetDeducted, "Online.Asset.Deducted"), - (GameFrameX.Capability.Online.Assets.OnlineAssetEvents.AssetAdjusted, "Online.Asset.Adjusted"), - (GameFrameX.Capability.Online.Assets.OnlineAssetEvents.Source, "online-assets"), - (GameFrameX.Capability.Online.Social.OnlineChatEvents.MessageSent, "Online.Chat.MessageSent"), - (GameFrameX.Capability.Online.Social.OnlineChatEvents.MessageRecalled, "Online.Chat.MessageRecalled"), - (GameFrameX.Capability.Online.Social.OnlineChatEvents.ReadMarkUpdated, "Online.Chat.ReadMarkUpdated"), - (GameFrameX.Capability.Online.Social.OnlineChatEvents.ModerationFailed, "Online.Chat.ModerationFailed"), - (GameFrameX.Capability.Online.Social.OnlineChatEvents.Source, "online-chat"), - (GameFrameX.Capability.Online.Operations.HotfixRollback.OnlineHotfixEvents.VersionRegistered, "Online.Hotfix.VersionRegistered"), - (GameFrameX.Capability.Online.Operations.HotfixRollback.OnlineHotfixEvents.RolledBack, "Online.Hotfix.RolledBack"), - (GameFrameX.Capability.Online.Operations.HotfixRollback.OnlineHotfixEvents.Source, "online-hotfix"), - (GameFrameX.Capability.Online.Competition.Leaderboard.OnlineLeaderboardEvents.LeaderboardCreated, "Online.Leaderboard.Created"), - (GameFrameX.Capability.Online.Competition.Leaderboard.OnlineLeaderboardEvents.ScoreUpdated, "Online.Leaderboard.ScoreUpdated"), - (GameFrameX.Capability.Online.Competition.Leaderboard.OnlineLeaderboardEvents.Source, "online-leaderboard"), - (GameFrameX.Capability.Online.Matchmaking.OnlineMatchEvents.TicketChanged, "Online.Match.TicketChanged"), - (GameFrameX.Capability.Online.Matchmaking.OnlineMatchEvents.AssignmentCreated, "Online.Match.AssignmentCreated"), - (GameFrameX.Capability.Online.Matchmaking.OnlineMatchEvents.Source, "online-matchmaker"), - (GameFrameX.Capability.Online.Match.OnlineMatchRuntimeEvents.MatchStateChanged, "Online.Match.StateChanged"), - (GameFrameX.Capability.Online.Match.OnlineMatchRuntimeEvents.MatchSettled, "Online.Match.Settled"), - (GameFrameX.Capability.Online.Match.OnlineMatchRuntimeEvents.Source, "online-match"), - (GameFrameX.Capability.Online.Social.OnlineNotificationEvents.NotificationChanged, "Online.Notification.Changed"), - (GameFrameX.Capability.Online.Social.OnlineNotificationEvents.Source, "online-notification"), - (GameFrameX.Capability.Online.Matchmaking.Party.OnlinePartyEvents.PartyChanged, "Online.Party.Changed"), - (GameFrameX.Capability.Online.Matchmaking.Party.OnlinePartyEvents.PartyInviteChanged, "Online.Party.InviteChanged"), - (GameFrameX.Capability.Online.Matchmaking.Party.OnlinePartyEvents.Source, "online-party"), - (GameFrameX.Capability.Online.Foundation.Presence.OnlinePresenceEvents.PresenceChanged, "Online.Presence.Changed"), - (GameFrameX.Capability.Online.Foundation.Presence.OnlinePresenceEvents.Source, "online-presence"), - (GameFrameX.Capability.Online.Competition.Season.OnlineSeasonEvents.SeasonCreated, "Online.Season.Created"), - (GameFrameX.Capability.Online.Competition.Season.OnlineSeasonEvents.SeasonStarted, "Online.Season.Started"), - (GameFrameX.Capability.Online.Competition.Season.OnlineSeasonEvents.SeasonEnded, "Online.Season.Ended"), - (GameFrameX.Capability.Online.Competition.Season.OnlineSeasonEvents.SeasonSettled, "Online.Season.Settled"), - (GameFrameX.Capability.Online.Competition.Season.OnlineSeasonEvents.Source, "online-season"), - (GameFrameX.Capability.Online.Foundation.Session.OnlineSessionEvents.SessionCreated, "Online.Session.Created"), - (GameFrameX.Capability.Online.Foundation.Session.OnlineSessionEvents.SessionAuthenticated, "Online.Session.Authenticated"), - (GameFrameX.Capability.Online.Foundation.Session.OnlineSessionEvents.SessionConnected, "Online.Session.Connected"), - (GameFrameX.Capability.Online.Foundation.Session.OnlineSessionEvents.SessionActive, "Online.Session.Active"), - (GameFrameX.Capability.Online.Foundation.Session.OnlineSessionEvents.SessionReconnecting, "Online.Session.Reconnecting"), - (GameFrameX.Capability.Online.Foundation.Session.OnlineSessionEvents.SessionClosed, "Online.Session.Closed"), - (GameFrameX.Capability.Online.Foundation.Session.OnlineSessionEvents.SessionKicked, "Online.Session.Kicked"), - (GameFrameX.Capability.Online.Foundation.Session.OnlineSessionEvents.SessionExpired, "Online.Session.Expired"), - (GameFrameX.Capability.Online.Foundation.Session.OnlineSessionEvents.SessionTokenRefreshed, "Online.Session.TokenRefreshed"), - (GameFrameX.Capability.Online.Foundation.Session.OnlineSessionEvents.Source, "online-session"), - (GameFrameX.Capability.Online.Social.OnlineSocialEvents.FriendshipChanged, "Online.Social.FriendshipChanged"), - (GameFrameX.Capability.Online.Social.OnlineSocialEvents.BlockChanged, "Online.Social.BlockChanged"), - (GameFrameX.Capability.Online.Social.OnlineSocialEvents.MuteChanged, "Online.Social.MuteChanged"), - (GameFrameX.Capability.Online.Social.OnlineSocialEvents.ReportChanged, "Online.Social.ReportChanged"), - (GameFrameX.Capability.Online.Social.OnlineSocialEvents.PunishmentChanged, "Online.Social.PunishmentChanged"), - (GameFrameX.Capability.Online.Social.OnlineSocialEvents.GroupChanged, "Online.Social.GroupChanged"), - (GameFrameX.Capability.Online.Social.OnlineSocialEvents.Source, "online-social"), - (GameFrameX.Capability.Online.Competition.Tournament.OnlineTournamentEvents.TournamentCreated, "Online.Tournament.Created"), - (GameFrameX.Capability.Online.Competition.Tournament.OnlineTournamentEvents.TournamentStarted, "Online.Tournament.Started"), - (GameFrameX.Capability.Online.Competition.Tournament.OnlineTournamentEvents.TournamentRegistered, "Online.Tournament.Registered"), - (GameFrameX.Capability.Online.Competition.Tournament.OnlineTournamentEvents.TournamentEnded, "Online.Tournament.Ended"), - (GameFrameX.Capability.Online.Competition.Tournament.OnlineTournamentEvents.TournamentSettled, "Online.Tournament.Settled"), - (GameFrameX.Capability.Online.Competition.Tournament.OnlineTournamentEvents.Source, "online-tournament"), - }; - foreach (var (actual, frozen) in expected) + Assert.True(string.Equals(frozen, actual, StringComparison.Ordinal), $"{member} 偏离冻结值:期望 {frozen},实际 {actual}"); + } + + foreach (var (actual, frozen) in FrozenSources) { Assert.Equal(frozen, actual); } } + + /// + /// 断言每个冻结 topic 符合 online.<域>.<事件> 全小写命名约定,且 topic 之间无重复 + /// (订阅方按 topic 精确匹配,大小写或重名漂移会让订阅静默失效)。 + /// + [Fact] + public void FrozenTopics_FollowLowercaseThreeSegmentConvention() + { + var violations = FrozenTopics.Where(topic => !TopicConvention.IsMatch(topic.Frozen)).Select(topic => topic.Member + " = " + topic.Frozen).ToList(); + Assert.True(violations.Count == 0, "topic 不符合 online.<域>.<事件> 全小写约定:" + Environment.NewLine + string.Join(Environment.NewLine, violations)); + + var duplicates = FrozenTopics.GroupBy(topic => topic.Frozen, StringComparer.Ordinal).Where(group => group.Count() > 1).Select(group => group.Key).ToList(); + Assert.True(duplicates.Count == 0, "topic 重复:" + string.Join(", ", duplicates)); + } + + /// + /// 断言运营审计桥的竞技域字面量订阅与竞技包常量同值(Operations 不引用 Competition 程序集, + /// 只能以字面量订阅;常量改名而字面量未同步 = 竞技事件静默漏审计)。 + /// + [Fact] + public void OperationsAuditBridge_CompetitionLiterals_EqualCompetitionConstants() + { + var subscriptions = ReadOperationsAuditSubscriptions(); + var literals = subscriptions.Where(subscription => subscription.Literal != null) + .Select(subscription => subscription.Literal) + .OrderBy(topic => topic, StringComparer.Ordinal) + .ToArray(); + var competitionConstants = new[] + { + OnlineLeaderboardEvents.LeaderboardCreated, + OnlineLeaderboardEvents.ScoreUpdated, + OnlineSeasonEvents.SeasonCreated, + OnlineSeasonEvents.SeasonStarted, + OnlineSeasonEvents.SeasonEnded, + OnlineSeasonEvents.SeasonSettled, + OnlineTournamentEvents.TournamentCreated, + OnlineTournamentEvents.TournamentStarted, + OnlineTournamentEvents.TournamentRegistered, + OnlineTournamentEvents.TournamentEnded, + OnlineTournamentEvents.TournamentSettled, + } + .OrderBy(topic => topic, StringComparer.Ordinal) + .ToArray(); + + Assert.Equal(competitionConstants, literals); + } + + /// + /// 断言运营审计桥的订阅面恰好覆盖全部冻结 topic(每个 topic 一次,无漏订无多订; + /// 常量订阅按「类型名.成员名」解析回冻结值,字面量订阅直接取值)。 + /// + [Fact] + public void OperationsAuditBridge_SubscribesEveryFrozenTopicExactlyOnce() + { + var valueByMember = FrozenTopics.ToDictionary(topic => topic.Member, topic => topic.Actual, StringComparer.Ordinal); + var subscribed = new List(); + var unresolved = new List(); + foreach (var subscription in ReadOperationsAuditSubscriptions()) + { + if (subscription.Literal != null) + { + subscribed.Add(subscription.Literal); + } + else if (valueByMember.TryGetValue(subscription.Member, out var value)) + { + subscribed.Add(value); + } + else + { + unresolved.Add(subscription.Member); + } + } + + Assert.True(unresolved.Count == 0, "审计桥订阅了冻结表之外的 topic 常量:" + string.Join(", ", unresolved)); + Assert.Equal( + FrozenTopics.Select(topic => topic.Frozen).OrderBy(topic => topic, StringComparer.Ordinal).ToArray(), + subscribed.OrderBy(topic => topic, StringComparer.Ordinal).ToArray()); + } + + /// + /// 扫描运营能力模块源码中的审计桥订阅(context.Subscribe<OnlineEvent>(topic, ...)), + /// 返回每个订阅的字面量 topic 或常量「类型名.成员名」。 + /// + /// 订阅集合(Literal 非 null = 字面量订阅;否则 Member 为常量引用)。 + private static List<(string Literal, string Member)> ReadOperationsAuditSubscriptions() + { + var moduleFile = Path.Combine(FindRepositoryRoot(), "Capability", "GameFrameX.Capability.Online.Operations", "OnlineOperationsCapabilityModule.cs"); + Assert.True(File.Exists(moduleFile), "未找到运营能力模块源码:" + moduleFile); + var code = new Regex(@"//[^\n]*").Replace(File.ReadAllText(moduleFile), string.Empty); + var subscribePattern = new Regex(@"context\.Subscribe\(\s*(?:""([^""]*)""|([A-Za-z_]\w*)\.([A-Za-z_]\w*))\s*,"); + var subscriptions = new List<(string Literal, string Member)>(); + foreach (var match in subscribePattern.Matches(code).Cast()) + { + if (match.Groups[1].Success) + { + subscriptions.Add((match.Groups[1].Value, null)); + } + else + { + subscriptions.Add((null, match.Groups[2].Value + "." + match.Groups[3].Value)); + } + } + + Assert.NotEmpty(subscriptions); + return subscriptions; + } + + /// + /// 从测试程序集位置向上定位仓库根(含 Server.slnx 的目录)。 + /// + /// 仓库根路径。 + private static string FindRepositoryRoot() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory != null) + { + if (File.Exists(Path.Combine(directory.FullName, "Server.slnx"))) + { + return directory.FullName; + } + + directory = directory.Parent; + } + + Assert.Fail("未定位到仓库根(测试须在仓库内运行)"); + return string.Empty; + } } } diff --git a/Tests/GameFrameX.Tests/Online/OnlineEventPublisherConsumerTests.cs b/Tests/GameFrameX.Tests/Online/OnlineEventPublisherConsumerTests.cs index bdef8561f..fa8fa068d 100644 --- a/Tests/GameFrameX.Tests/Online/OnlineEventPublisherConsumerTests.cs +++ b/Tests/GameFrameX.Tests/Online/OnlineEventPublisherConsumerTests.cs @@ -27,7 +27,9 @@ // Official documentation: https://gameframex.doc.alianblank.com/ // ========================================================================================== +using System; using System.Collections.Generic; +using GameFrameX.Capability.Hosting.Abstractions; using GameFrameX.Foundation.Idempotency; using GameFrameX.Capability.Online.Abstractions.Events; using Xunit; @@ -35,11 +37,30 @@ namespace GameFrameX.Tests.Online { /// - /// OnlineEventPublisher / OnlineEventConsumer 事件发布与消费去重基座测试(Foundation InMemory 传输; - /// 同一 EventId 投递两次只处理一次)。 + /// OnlineEventPublisher / OnlineEventConsumer 事件发布与消费去重基座测试(C294:发布基座委托机制事件发布口 + /// ,主题 = EventType、载荷 = OnlineEvent 原件;同一 EventId 投递两次只处理一次)。 /// public class OnlineEventPublisherConsumerTests { + /// + /// 机制事件发布口记录桩(按发布序记录主题与载荷,供断言委托口径;不做分发)。 + /// + private sealed class RecordingCapabilityEventPublisher : ICapabilityEventPublisher + { + /// 已发布主题(发布序)。 + public readonly List Topics = new(); + + /// 已发布载荷(发布序,装箱原件)。 + public readonly List Payloads = new(); + + /// + public void Publish(string topic, TEvent eventPayload) + { + Topics.Add(topic); + Payloads.Add(eventPayload); + } + } + /// /// 构造完整 Online 事件样本。 /// @@ -62,31 +83,63 @@ private static OnlineEvent CreateSampleEvent() } /// - /// 验证经发布契约发布的事件以 Foundation 信封形态到达订阅方,作用域经稳定键承载。 + /// 验证经发布契约发布的事件原样委托机制发布口:主题 = 事件类型字符串,载荷 = 同一 OnlineEvent 实例(作用域字段随载荷承载)。 /// [Fact] - public void PublishAsync_ShouldDeliverEnvelopeWithScopeAttributes() + public void PublishAsync_ShouldDelegateToCapabilityPublisherWithEventTypeAsTopic() { // Arrange - var delivered = new List(); - var transport = new InMemoryEventPublisher(); - transport.Subscribe(delegate (EventEnvelope envelope) - { - delivered.Add(envelope); - }); + var transport = new RecordingCapabilityEventPublisher(); IOnlineEventPublisher publisher = new OnlineEventPublisher(transport); + var onlineEvent = CreateSampleEvent(); // Act - publisher.PublishAsync(CreateSampleEvent()).GetAwaiter().GetResult(); + publisher.PublishAsync(onlineEvent).GetAwaiter().GetResult(); // Assert - var envelope = Assert.Single(delivered); - Assert.Equal("evt-0004", envelope.EventId); - Assert.Equal("req-0004", envelope.CorrelationId); - Assert.Equal("1", envelope.Attributes[OnlineEventEnvelopeMapper.TenantAttributeKey]); - Assert.Equal("10", envelope.Attributes[OnlineEventEnvelopeMapper.AppAttributeKey]); - Assert.Equal("100", envelope.Attributes[OnlineEventEnvelopeMapper.ServerAttributeKey]); - Assert.Equal("10001", envelope.Attributes[OnlineEventEnvelopeMapper.PlayerAttributeKey]); + var topic = Assert.Single(transport.Topics); + Assert.Equal("online.session.kicked", topic); + Assert.Equal(onlineEvent.EventType, topic); + var payload = Assert.IsType(Assert.Single(transport.Payloads)); + Assert.Same(onlineEvent, payload); + Assert.Equal("evt-0004", payload.EventId); + Assert.Equal("req-0004", payload.CorrelationId); + Assert.Equal(1, payload.TenantId); + Assert.Equal(10, payload.AppId); + Assert.Equal(100, payload.ServerId); + Assert.Equal(10001, payload.PlayerId); + } + + /// + /// 验证事件类型(即总线主题)为空时拒绝发布且不触达机制发布口(主题是订阅匹配键,空主题无法投递)。 + /// + [Fact] + public void PublishAsync_WithEmptyEventType_ShouldThrowWithoutPublishing() + { + // Arrange + var transport = new RecordingCapabilityEventPublisher(); + IOnlineEventPublisher publisher = new OnlineEventPublisher(transport); + var onlineEvent = CreateSampleEvent(); + onlineEvent.EventType = " "; + + // Act / Assert + Assert.Throws(() => publisher.PublishAsync(onlineEvent).GetAwaiter().GetResult()); + Assert.Empty(transport.Topics); + } + + /// + /// 验证空事件拒绝发布(业务方漏传事件属编程错误,显式失败)。 + /// + [Fact] + public void PublishAsync_WithNullEvent_ShouldThrow() + { + // Arrange + var transport = new RecordingCapabilityEventPublisher(); + IOnlineEventPublisher publisher = new OnlineEventPublisher(transport); + + // Act / Assert + Assert.Throws(() => publisher.PublishAsync(null).GetAwaiter().GetResult()); + Assert.Empty(transport.Topics); } /// diff --git a/Tests/GameFrameX.Tests/Online/OnlineFriendConsumptionChainAssemblyTests.cs b/Tests/GameFrameX.Tests/Online/OnlineFriendConsumptionChainAssemblyTests.cs index 4dfa72c65..814aa3980 100644 --- a/Tests/GameFrameX.Tests/Online/OnlineFriendConsumptionChainAssemblyTests.cs +++ b/Tests/GameFrameX.Tests/Online/OnlineFriendConsumptionChainAssemblyTests.cs @@ -33,31 +33,32 @@ using System.Threading.Tasks; using GameFrameX.Capability.Hosting.Abstractions; using GameFrameX.Capability.Hosting.Core; -using GameFrameX.Capability.Online.Abstractions.Events; -using GameFrameX.Capability.Online.Abstractions.Idempotency; +using GameFrameX.Capability.Online.Abstractions; +using GameFrameX.Capability.Online.Abstractions.Contracts; using GameFrameX.Capability.Online.Abstractions.Scope; -using GameFrameX.Capability.Online.Foundation.Session; +using GameFrameX.Capability.Online.Operations.HotfixRollback; using GameFrameX.Capability.Online.Social; -using GameFrameX.Launcher.Online; using Xunit; namespace GameFrameX.Tests.Online { /// - /// 好友最小消费链装配测试(C242):OnlineFriendService 经模块自装配注册回共享面、 - /// store 替换缝与 directory / presence 通用缝生效、好友请求过期清扫滴答 job 与查询共用同一状态来源、 - /// 真实组合根(AddOnlineCapabilities)可解析、宿主启停驱动清扫、清扫失败 containment 与错误配置行为。 + /// 好友最小消费链装配测试(C242,C294 改写为真实族组合):OnlineFriendService 经 Social 模块自装配注册回共享面、 + /// store 替换缝(宿主 Override)与 directory / presence 扩展点生效、好友请求过期清扫滴答 job 与查询共用同一状态来源、 + /// 宿主启停驱动清扫、清扫失败 containment 与白名单排除 Social 的缺席行为。 + /// 好友请求 / 接受 / 过期均发布事件——事件总线 Activate 前拒绝发布,故涉及写路径的用例先启动业务面。 /// + [Collection(CapabilityRuntimeCollection.Name)] public class OnlineFriendConsumptionChainAssemblyTests { - /// 测试用租户标识(与组合根授权作用域一致)。 - private const long TenantId = 1; + /// 测试用租户标识(与族配置段授权作用域一致)。 + private const long TenantId = OnlineCapabilityTestHarness.TenantId; /// 测试用应用标识。 - private const long AppId = 1; + private const long AppId = OnlineCapabilityTestHarness.AppId; - /// 测试用区服标识(与组合根授权作用域一致)。 - private const long ServerId = 1001; + /// 测试用区服标识(与族配置段授权作用域一致)。 + private const long ServerId = OnlineCapabilityTestHarness.ServerId; /// 测试用玩家一标识(请求发起方)。 private const long PlayerOne = 1001; @@ -66,61 +67,30 @@ public class OnlineFriendConsumptionChainAssemblyTests private const long PlayerTwo = 1002; /// - /// 最小组合根(AddOnlineSocialRuntime 装配路径所需的预置:作用域 + 事件出口 + 幂等 + Foundation 会话面)。 - /// - /// 装配器。 - private static CapabilityHostBuilder MinimalRoot() - { - var builder = new CapabilityHostBuilder(); - builder.UseService(new OnlineScope(TenantId, AppId, ServerId)); - var eventPublisher = new OnlineEventPublisher(new GameFrameX.Foundation.Idempotency.InMemoryEventPublisher()); - builder.UseService(eventPublisher); - builder.UseService(new OnlineIdempotencyService(new GameFrameX.Foundation.Idempotency.IdempotencyCoordinator( - new GameFrameX.Foundation.Idempotency.InMemoryIdempotencyStore(), - GameFrameX.Foundation.Idempotency.SystemClock.Instance, - new GameFrameX.Foundation.Idempotency.IdempotencyOptions()))); - var sessionStore = new InMemoryOnlineSessionStore(); - builder.UseService(sessionStore); - builder.UseService(new OnlineSessionTokenService(sessionStore, eventPublisher)); - builder.AddModule(new FoundationStubModule()); - return builder; - } - - /// - /// 构造合法宿主选项(作用域与测试常量一致)。 + /// 构造玩家作用域(作用域三元组与测试常量一致)。 /// - /// 调度滴答间隔。 - /// 宿主选项。 - private static CapabilityHostOptions ValidOptions(int schedulerIntervalMilliseconds = 1000) + /// 玩家标识。 + /// 作用域。 + private static OnlineScope CreatePlayerScope(long playerId) { - return new CapabilityHostOptions { TenantId = TenantId, AppId = AppId, ServerId = ServerId, SchedulerIntervalMilliseconds = schedulerIntervalMilliseconds }; + return new OnlineScope(TenantId, AppId, ServerId, playerId); } /// - /// 构造真实组合根编排回调(业务闸单开;间隔与过滤器由用例覆写)。 + /// 构造自定义玩家目录的组合选项(不经挂具 CreateOptions——同键第二个 Override 即装配失败; + /// 热更回滚执行器仍以空操作桩满足 Operations 的宿主扩展点)。 /// - /// 用例追加的编排动作。 - /// 组合回调。 - private static Action Compose(Action configure = null) + /// 玩家目录宿主实现。 + /// 组合选项。 + private static CapabilityCompositionOptions CreateOptionsWithDirectory(IOnlinePlayerDirectory directory) { - return options => + var options = new CapabilityCompositionOptions(OnlineCapabilityTestHarness.FamilySection(), new CollectingCapabilityAssemblyLog()) { - options.TenantId = TenantId; - options.AppId = AppId; - options.ServerId = ServerId; - options.EnableRuntime = true; - configure?.Invoke(options); + HostOptions = new CapabilityHostOptions { TenantId = TenantId, AppId = AppId, ServerId = ServerId, }, }; - } - - /// - /// 构造玩家作用域(作用域三元组与测试常量一致)。 - /// - /// 玩家标识。 - /// 作用域。 - private static OnlineScope CreatePlayerScope(long playerId) - { - return new OnlineScope(TenantId, AppId, ServerId, playerId); + options.Override(directory); + options.Override(new OnlineCapabilityTestHarness.NoopHotfixRollbackExecutor()); + return options; } /// @@ -176,178 +146,171 @@ private static async Task UntilAsync(Func> condition, int timeo } /// - /// 模块自装配把 OnlineFriendService 注册回共享面,且与共享面的好友存储同源 - ///(服务写入的关系经共享面存储可见 = 查询与清扫同一状态来源)。 + /// 真实族组合:共享面可解析 OnlineFriendService,服务写入与过期清扫共用同一存储实例 + ///(服务写入 → 共享面存储可见 → 单轮 TickAsync 终结超期请求、未超期请求保持待答复)。 /// [Fact] - public async Task MinimalRoot_RegistersFriendServiceIntoSharedFaceSharingStore() + public async Task Compose_ExposesFriendServiceWithSweepOnSameStore() { - var builder = MinimalRoot(); - builder.AddOnlineSocialRuntime(); - var host = builder.Build(ValidOptions()); - - var service = host.Services.GetService(); - var store = host.Services.GetService(); - Assert.NotNull(service); - Assert.NotNull(store); - - var request = await service.RequestAsync(CreatePlayerScope(PlayerOne), PlayerTwo, "corr-1"); - Assert.True(request.IsSuccess); - Assert.NotNull(await store.FindAsync(TenantId, AppId, PlayerOne, PlayerTwo)); + await OnlineCapabilityTestHarness.WithRuntimeAsync(async runtime => + { + var service = runtime.Host.Services.GetService(); + var store = runtime.Host.Services.GetService(); + Assert.NotNull(service); + Assert.IsType(store); + await runtime.Host.StartAsync(); + + var request = await service.RequestAsync(CreatePlayerScope(PlayerOne), PlayerTwo, "corr-2"); + Assert.True(request.IsSuccess); + Assert.NotNull(await store.FindAsync(TenantId, AppId, PlayerOne, PlayerTwo)); + + await SeedExpiredRequestAsync(store, TenantId, AppId, PlayerTwo, PlayerOne + 10); + await runtime.Host.Scheduler.TickAsync(); + + var expired = await store.FindAsync(TenantId, AppId, PlayerTwo, PlayerOne + 10); + Assert.Equal(OnlineFriendshipState.Expired, expired.State); + var live = await store.FindAsync(TenantId, AppId, PlayerOne, PlayerTwo); + Assert.Equal(OnlineFriendshipState.Requested, live.State); + }); } /// - /// UseOnlineFriendStore 替换缝:组合根预置实例被模块采用(共享面同实例), + /// 好友存储替换缝(宿主 Override):预置实例胜出 Social 包内默认(共享面同实例), /// 过期清扫滴答 job 与服务使用同一条预置存储(单轮 TickAsync 即终结超期请求)。 /// [Fact] - public async Task UseOnlineFriendStore_PreSeed_DrivesServiceAndSweepOnSameStateSource() + public async Task OverrideFriendStore_PreSeed_DrivesServiceAndSweepOnSameStateSource() { var preSeeded = new InMemoryOnlineFriendStore(); await SeedExpiredRequestAsync(preSeeded, TenantId, AppId, PlayerOne, PlayerTwo); - var builder = MinimalRoot(); - builder.UseOnlineFriendStore(preSeeded); - builder.AddOnlineSocialRuntime(); - var host = builder.Build(ValidOptions()); - - Assert.Same(preSeeded, host.Services.GetService()); + await OnlineCapabilityTestHarness.WithRuntimeAsync(async runtime => + { + Assert.Same(preSeeded, runtime.Host.Services.GetService()); + await runtime.Host.StartAsync(); - var service = host.Services.GetService(); - var request = await service.RequestAsync(CreatePlayerScope(PlayerTwo), PlayerOne + 10); - Assert.True(request.IsSuccess); + var service = runtime.Host.Services.GetService(); + var request = await service.RequestAsync(CreatePlayerScope(PlayerTwo), PlayerOne + 10); + Assert.True(request.IsSuccess); + Assert.NotNull(await preSeeded.FindAsync(TenantId, AppId, PlayerTwo, PlayerOne + 10)); - await host.Scheduler.TickAsync(); + await runtime.Host.Scheduler.TickAsync(); - var expired = await preSeeded.FindAsync(TenantId, AppId, PlayerOne, PlayerTwo); - Assert.Equal(OnlineFriendshipState.Expired, expired.State); + var expired = await preSeeded.FindAsync(TenantId, AppId, PlayerOne, PlayerTwo); + Assert.Equal(OnlineFriendshipState.Expired, expired.State); + }, null, options => options.Override(preSeeded)); } /// - /// 未预置玩家目录(当前全仓无实现):好友搜索降级为空结果——这是装配披露的边界 + /// 空玩家目录(宿主扩展点以空实现满足):好友搜索降级为空结果——这是装配披露的边界 ///(空结果 ≠ 无匹配玩家,搜索能力缺席),不是可用的搜索功能。 /// [Fact] - public async Task MinimalRoot_WithoutDirectory_SearchDegradesToEmpty() + public async Task EmptyDirectory_SearchDegradesToEmpty() { - var builder = MinimalRoot(); - builder.AddOnlineSocialRuntime(); - var host = builder.Build(ValidOptions()); - - var service = host.Services.GetService(); - var outcome = await service.SearchAsync(CreatePlayerScope(PlayerOne), "alice"); + await OnlineCapabilityTestHarness.WithRuntimeAsync(async runtime => + { + var service = runtime.Host.Services.GetService(); + var outcome = await service.SearchAsync(CreatePlayerScope(PlayerOne), "alice"); - Assert.True(outcome.IsSuccess); - Assert.Empty(outcome.Data); + Assert.True(outcome.IsSuccess); + Assert.Empty(outcome.Data); + }); } /// - /// 玩家目录与在线探针经既有 UseService 通用缝预置即被模块采用: + /// 玩家目录(宿主 Override)与在线探针(宿主 Override 胜出 Foundation 默认探针)被 Social 采用: /// 搜索返回目录条目、好友列表带展示名与在线事实——证明缺席是可注入的披露态而非隐藏失败。 /// [Fact] - public async Task PreSeededDirectoryAndPresenceProbe_FeedFriendService() + public async Task OverriddenDirectoryAndPresenceProbe_FeedFriendService() { var directory = new StubPlayerDirectory(); directory.Add(PlayerTwo, "alice"); var probe = new StubPresenceProbe(); probe.SetOnline(PlayerTwo); + var options = CreateOptionsWithDirectory(directory); + options.Override(probe); - var builder = MinimalRoot(); - builder.UseService(directory); - builder.UseService(probe); - builder.AddOnlineSocialRuntime(); - var host = builder.Build(ValidOptions()); - - var service = host.Services.GetService(); - var search = await service.SearchAsync(CreatePlayerScope(PlayerOne), "ali"); - Assert.True(search.IsSuccess); - var entry = Assert.Single(search.Data); - Assert.Equal(PlayerTwo, entry.PlayerId); - - var request = await service.RequestAsync(CreatePlayerScope(PlayerOne), PlayerTwo); - Assert.True(request.IsSuccess); - var accepted = await service.AcceptAsync(CreatePlayerScope(PlayerTwo), request.Data.FriendshipId); - Assert.True(accepted.IsSuccess); - - var friends = await service.ListFriendsAsync(CreatePlayerScope(PlayerOne)); - Assert.True(friends.IsSuccess); - var summary = Assert.Single(friends.Data); - Assert.Equal("alice", summary.Name); - Assert.True(summary.Online); + var runtime = CapabilityComposition.Compose(new OnlineCapabilityFamily(), options); + try + { + Assert.Same(probe, runtime.Host.Services.GetService()); + Assert.Same(directory, runtime.Host.Services.GetService()); + await runtime.Host.StartAsync(); + + var service = runtime.Host.Services.GetService(); + var search = await service.SearchAsync(CreatePlayerScope(PlayerOne), "ali"); + Assert.True(search.IsSuccess); + var entry = Assert.Single(search.Data); + Assert.Equal(PlayerTwo, entry.PlayerId); + + var request = await service.RequestAsync(CreatePlayerScope(PlayerOne), PlayerTwo); + Assert.True(request.IsSuccess); + var accepted = await service.AcceptAsync(CreatePlayerScope(PlayerTwo), request.Data.FriendshipId); + Assert.True(accepted.IsSuccess); + + var friends = await service.ListFriendsAsync(CreatePlayerScope(PlayerOne)); + Assert.True(friends.IsSuccess); + var summary = Assert.Single(friends.Data); + Assert.Equal("alice", summary.Name); + Assert.True(summary.Online); + } + finally + { + await runtime.StopAsync(); + } } /// - /// 清扫存储输入持续抛异常:当轮滴答失败被调度壳吞掉、循环继续, + /// 清扫存储输入持续抛异常:当轮滴答失败被调度壳逐 job 吞掉、循环继续, /// 宿主停机干净、服务查询面仍可用——异常 containment 的行为级可观测契约。 /// [Fact] public async Task ThrowingFriendStore_SweepFailureContained_HostStaysRunnable() { var throwing = new ThrowingSweepFriendStore(); - var builder = MinimalRoot(); - builder.UseOnlineFriendStore(throwing); - builder.AddOnlineSocialRuntime(); - var host = builder.Build(ValidOptions(100)); - - await host.StartAsync(); - var observedThrow = await UntilAsync(() => Task.FromResult(throwing.SweepInputFailures > 0), 5000); - await host.StopAsync(); - - Assert.True(observedThrow); - Assert.True(throwing.SweepInputFailures >= 1); - - var service = host.Services.GetService(); - var friends = await service.ListFriendsAsync(CreatePlayerScope(PlayerOne)); - Assert.True(friends.IsSuccess); - Assert.Empty(friends.Data); - } - - /// - /// 真实组合根(AddOnlineCapabilities 业务闸):Runtime 共享面可解析 OnlineFriendService, - /// 服务查询与过期清扫共用同一存储实例(服务写入 → 共享面存储可见 → 单轮 TickAsync 终结超期请求)。 - /// - [Fact] - public async Task RealCompositionRoot_ExposesFriendServiceWithSweepOnSameStore() - { - var composition = OnlineCapabilityComposition.AddOnlineCapabilities(Compose()); - - var service = composition.Runtime.Services.GetService(); - var store = composition.Runtime.Services.GetService(); - Assert.NotNull(service); - Assert.NotNull(store); - - var request = await service.RequestAsync(CreatePlayerScope(PlayerOne), PlayerTwo, "corr-2"); - Assert.True(request.IsSuccess); - Assert.NotNull(await store.FindAsync(TenantId, AppId, PlayerOne, PlayerTwo)); - - await SeedExpiredRequestAsync(store, TenantId, AppId, PlayerTwo, PlayerOne + 10); - await composition.Runtime.Scheduler.TickAsync(); - - var expired = await store.FindAsync(TenantId, AppId, PlayerTwo, PlayerOne + 10); - Assert.Equal(OnlineFriendshipState.Expired, expired.State); - var live = await store.FindAsync(TenantId, AppId, PlayerOne, PlayerTwo); - Assert.Equal(OnlineFriendshipState.Requested, live.State); + await OnlineCapabilityTestHarness.WithRuntimeAsync(async runtime => + { + await runtime.Host.StartAsync(); + var observedThrow = await UntilAsync(() => Task.FromResult(throwing.SweepInputFailures > 1), 5000); + await runtime.Host.StopAsync(); + + Assert.True(observedThrow); + Assert.True(throwing.SweepInputFailures >= 2); + + var service = runtime.Host.Services.GetService(); + var friends = await service.ListFriendsAsync(CreatePlayerScope(PlayerOne)); + Assert.True(friends.IsSuccess); + Assert.Empty(friends.Data); + }, null, options => + { + options.HostOptions.SchedulerIntervalMilliseconds = 100; + options.Override(throwing); + }); } /// - /// 清扫的作用域隔离:滴答 job 经组合根授权作用域锚定租户 / App—— + /// 清扫的作用域隔离:滴答 job 经族基建授权作用域锚定租户 / App—— /// 其余租户或 App 的超期请求不被本宿主触碰。 /// [Fact] - public async Task RealCompositionRoot_SweepScopedToAuthorizedTenantAndApp() + public async Task Sweep_ScopedToAuthorizedTenantAndApp() { - var composition = OnlineCapabilityComposition.AddOnlineCapabilities(Compose()); - var store = composition.Runtime.Services.GetService(); - await SeedExpiredRequestAsync(store, TenantId, AppId, PlayerOne, PlayerTwo); - await SeedExpiredRequestAsync(store, TenantId + 1, AppId, PlayerOne, PlayerTwo); - await SeedExpiredRequestAsync(store, TenantId, AppId + 1, PlayerOne, PlayerTwo); + await OnlineCapabilityTestHarness.WithRuntimeAsync(async runtime => + { + var store = runtime.Host.Services.GetService(); + await SeedExpiredRequestAsync(store, TenantId, AppId, PlayerOne, PlayerTwo); + await SeedExpiredRequestAsync(store, TenantId + 1, AppId, PlayerOne, PlayerTwo); + await SeedExpiredRequestAsync(store, TenantId, AppId + 1, PlayerOne, PlayerTwo); + await runtime.Host.StartAsync(); - await composition.Runtime.Scheduler.TickAsync(); + await runtime.Host.Scheduler.TickAsync(); - Assert.Equal(OnlineFriendshipState.Expired, (await store.FindAsync(TenantId, AppId, PlayerOne, PlayerTwo)).State); - Assert.Equal(OnlineFriendshipState.Requested, (await store.FindAsync(TenantId + 1, AppId, PlayerOne, PlayerTwo)).State); - Assert.Equal(OnlineFriendshipState.Requested, (await store.FindAsync(TenantId, AppId + 1, PlayerOne, PlayerTwo)).State); + Assert.Equal(OnlineFriendshipState.Expired, (await store.FindAsync(TenantId, AppId, PlayerOne, PlayerTwo)).State); + Assert.Equal(OnlineFriendshipState.Requested, (await store.FindAsync(TenantId + 1, AppId, PlayerOne, PlayerTwo)).State); + Assert.Equal(OnlineFriendshipState.Requested, (await store.FindAsync(TenantId, AppId + 1, PlayerOne, PlayerTwo)).State); + }); } /// @@ -355,52 +318,42 @@ public async Task RealCompositionRoot_SweepScopedToAuthorizedTenantAndApp() /// StopAsync 后循环退出,新落的超期请求不再被终结。 /// [Fact] - public async Task RealCompositionRoot_HostStartDrivesSweepAndStopHaltsIt() + public async Task HostStartDrivesSweepAndStopHaltsIt() { - var composition = OnlineCapabilityComposition.AddOnlineCapabilities(Compose(options => options.SchedulerIntervalMilliseconds = 100)); - var store = composition.Runtime.Services.GetService(); - var host = composition.Runtime; - - await host.StartAsync(); - await SeedExpiredRequestAsync(store, TenantId, AppId, PlayerOne, PlayerTwo); - var swept = await UntilAsync(async () => (await store.FindAsync(TenantId, AppId, PlayerOne, PlayerTwo))?.State == OnlineFriendshipState.Expired, 5000); - Assert.True(swept); - - await host.StopAsync(); - await SeedExpiredRequestAsync(store, TenantId, AppId, PlayerTwo, PlayerOne + 10); - await Task.Delay(600); - Assert.Equal(OnlineFriendshipState.Requested, (await store.FindAsync(TenantId, AppId, PlayerTwo, PlayerOne + 10)).State); + await OnlineCapabilityTestHarness.WithRuntimeAsync(async runtime => + { + var store = runtime.Host.Services.GetService(); + + await runtime.Host.StartAsync(); + await SeedExpiredRequestAsync(store, TenantId, AppId, PlayerOne, PlayerTwo); + var swept = await UntilAsync(async () => (await store.FindAsync(TenantId, AppId, PlayerOne, PlayerTwo))?.State == OnlineFriendshipState.Expired, 5000); + Assert.True(swept); + + await runtime.Host.StopAsync(); + await SeedExpiredRequestAsync(store, TenantId, AppId, PlayerTwo, PlayerOne + 10); + await Task.Delay(600); + Assert.Equal(OnlineFriendshipState.Requested, (await store.FindAsync(TenantId, AppId, PlayerTwo, PlayerOne + 10)).State); + }, null, options => options.HostOptions.SchedulerIntervalMilliseconds = 100); } /// - /// 错误配置行为:白名单未含 online-social(闭包完整,合法装配)时社交能力整体缺席, - /// 好友服务在共享面不可解析(GetService fail-fast / TryGetService miss),不返回伪实例。 + /// 白名单仅含 online-foundation(闭包完整,合法装配):社交能力整体缺席, + /// 好友服务在共享面不可解析(GetOptionalService 返回 null / GetService fail-fast),不返回伪实例。 /// [Fact] - public void WhitelistExcludingSocial_FriendServiceAbsentFromSharedFace() - { - var composition = OnlineCapabilityComposition.AddOnlineCapabilities(Compose(options => options.Capabilities = new[] { "online-foundation" })); - - Assert.False(composition.Runtime.Services.TryGetService(out OnlineFriendService _)); - Assert.Throws(() => composition.Runtime.Services.GetService()); - } - - /// - /// 地基占位模块(online-foundation 清单;Social 包闭包校验要求其在场)。 - /// - private sealed class FoundationStubModule : ICapabilityModule + public async Task WhitelistExcludingSocial_FriendServiceAbsentFromSharedFace() { - /// - public CapabilityManifest Manifest { get; } = new("online-foundation", "1.0.0", Array.Empty()); - - /// - public void Register(CapabilityRegistrationContext context) + await OnlineCapabilityTestHarness.WithRuntimeAsync(runtime => { - } + Assert.Equal(new[] { "online-foundation" }, runtime.Report.EnabledIdsInOrder); + Assert.Null(runtime.Host.Services.GetOptionalService()); + Assert.Throws(() => runtime.Host.Services.GetService()); + return Task.CompletedTask; + }, null, options => options.Filter = new CapabilityAssemblyFilter(new[] { "online-foundation" })); } /// - /// 玩家目录桩:按名称子串匹配。 + /// 玩家目录桩:按名称子串匹配(宿主扩展点 的测试实现)。 /// private sealed class StubPlayerDirectory : IOnlinePlayerDirectory { @@ -465,7 +418,7 @@ public Task> FindAsync(long tenantId, } /// - /// 在线事实探针桩:白名单内玩家在线。 + /// 在线事实探针桩:白名单内玩家在线(宿主 Override 胜出 Foundation 默认探针)。 /// private sealed class StubPresenceProbe : IOnlineSocialPresenceProbe { @@ -495,17 +448,23 @@ public Task IsOnlineAsync(long tenantId, long appId, long playerId, Cancel /// /// 清扫输入持续抛异常的好友存储(其余面委托内层 InMemory): - /// 驱动好友过期清扫失败路径的 containment 断言。 + /// 驱动好友过期清扫失败路径的 containment 断言(失败计数跨调度线程可见)。 /// private sealed class ThrowingSweepFriendStore : IOnlineFriendStore { /// 内层正常存储。 private readonly InMemoryOnlineFriendStore _inner = new InMemoryOnlineFriendStore(); + /// 清扫输入失败次数(调度线程写、用例线程读)。 + private int _sweepInputFailures; + /// /// 获取清扫输入(ListByStateAsync)失败次数。 /// - public int SweepInputFailures { get; private set; } + public int SweepInputFailures + { + get { return Volatile.Read(ref _sweepInputFailures); } + } /// 委托内层。 /// 待创建的关系。 @@ -569,7 +528,7 @@ public Task> ListByPlayerAsync(long tenantId, lo /// 不返回(恒抛)。 public Task> ListByStateAsync(long tenantId, long appId, OnlineFriendshipState state, CancellationToken cancellationToken = default) { - SweepInputFailures++; + Interlocked.Increment(ref _sweepInputFailures); throw new InvalidOperationException("friend sweep input failure (test)"); } } diff --git a/Tests/GameFrameX.Tests/Online/OnlineFriendDomainBridgeTests.cs b/Tests/GameFrameX.Tests/Online/OnlineFriendDomainBridgeTests.cs index e1dcb4a66..84188d690 100644 --- a/Tests/GameFrameX.Tests/Online/OnlineFriendDomainBridgeTests.cs +++ b/Tests/GameFrameX.Tests/Online/OnlineFriendDomainBridgeTests.cs @@ -28,37 +28,23 @@ // ========================================================================================== using System; -using System.Collections.Generic; using System.Threading.Tasks; using GameFrameX.Capability.Hosting.Abstractions; using GameFrameX.Capability.Hosting.Core; -using GameFrameX.Capability.Online.Abstractions.Bridges; using GameFrameX.Capability.Online.Abstractions.Contracts; -using GameFrameX.Capability.Online.Abstractions.Events; -using GameFrameX.Capability.Online.Abstractions.Idempotency; using GameFrameX.Capability.Online.Abstractions.Scope; -using GameFrameX.Capability.Online.Foundation.Session; -using GameFrameX.Capability.Online.Social; -using GameFrameX.Launcher.Online; using Xunit; namespace GameFrameX.Tests.Online { /// - /// 好友域桥接缝测试(C244):域契约面装配、域面语义(T2 裁定 R3/R5/R6/R7 的实现行为)、 - /// lease 失效语义与 holder 生命周期、组合根接线发布 / 契约面缺席披露与验证种子。 + /// 好友域桥接缝测试(C244 → C294 机制桥):好友域契约面由 online-social 在 Activate 期经机制桥发布、 + /// 域面语义(T2 裁定 R3/R5/R6/R7 的实现行为)经租约访问、桥生命周期(启动前缺席 / 停止后撤销且旧租约显式失败)、 + /// 契约面缺席披露(未装配 social 即无桥)与验证种子(§4.6 测试夹具 )。 /// - public class OnlineFriendDomainBridgeTests : IDisposable + [Collection(CapabilityRuntimeCollection.Name)] + public class OnlineFriendDomainBridgeTests { - /// 测试用租户标识。 - private const long TenantId = 1; - - /// 测试用应用标识。 - private const long AppId = 1; - - /// 测试用区服标识。 - private const long ServerId = 1001; - /// 玩家一。 private const long PlayerOne = 1001; @@ -68,85 +54,99 @@ public class OnlineFriendDomainBridgeTests : IDisposable /// 玩家三。 private const long PlayerThree = 1003; - public OnlineFriendDomainBridgeTests() - { - OnlineFriendDomainBridgeHolder.Retract(); - } - - public void Dispose() - { - OnlineFriendDomainBridgeHolder.Retract(); - } - /// - /// 最小组合根(Social 运行时装配所需预置:作用域 + 事件出口 + 幂等 + Foundation 会话面)。 + /// 白名单圈定 foundation + social(好友域桥发布者 = online-social)。 /// - private static CapabilityHostBuilder MinimalRoot() + /// 组合选项。 + private static void SocialClosure(CapabilityCompositionOptions options) { - var builder = new CapabilityHostBuilder(); - builder.UseService(new OnlineScope(TenantId, AppId, ServerId)); - var eventPublisher = new OnlineEventPublisher(new GameFrameX.Foundation.Idempotency.InMemoryEventPublisher()); - builder.UseService(eventPublisher); - builder.UseService(new OnlineIdempotencyService(new GameFrameX.Foundation.Idempotency.IdempotencyCoordinator( - new GameFrameX.Foundation.Idempotency.InMemoryIdempotencyStore(), - GameFrameX.Foundation.Idempotency.SystemClock.Instance, - new GameFrameX.Foundation.Idempotency.IdempotencyOptions()))); - var sessionStore = new InMemoryOnlineSessionStore(); - builder.UseService(sessionStore); - builder.UseService(new OnlineSessionTokenService(sessionStore, eventPublisher)); - builder.AddModule(new FoundationStubModule()); - return builder; + options.Filter = new CapabilityAssemblyFilter(new[] { "online-foundation", "online-social" }); } /// - /// Foundation 桩模块(满足 online-social 的 Requires 拓扑)。 + /// 构造玩家作用域(三元组与族配置段一致)。 /// - private sealed class FoundationStubModule : ICapabilityModule + /// 玩家标识。 + /// 玩家作用域。 + private static OnlineScope PlayerScope(long playerId) { - /// - public CapabilityManifest Manifest { get; } = new("online-foundation", "1.0.0", Array.Empty()); - - /// - public void Register(CapabilityRegistrationContext context) - { - } + return new OnlineScope(OnlineCapabilityTestHarness.TenantId, OnlineCapabilityTestHarness.AppId, OnlineCapabilityTestHarness.ServerId, playerId); } /// - /// 构造合法宿主选项。 + /// 组合 foundation + social 并启动,经机制桥取好友域契约面执行用例主体(结束恒停止撤桥)。 /// - private static CapabilityHostOptions ValidOptions() + /// 用例主体(入参:好友域契约面、已启动的组合产物)。 + /// 异步任务。 + private static Task WithStartedFriendDomainAsync(Func body) { - return new CapabilityHostOptions { TenantId = TenantId, AppId = AppId, ServerId = ServerId }; + return OnlineCapabilityTestHarness.WithRuntimeAsync(async runtime => + { + await runtime.Host.StartAsync(); + Assert.True(CapabilityBridge.TryAcquire(out var lease)); + await body(lease.Contract, runtime); + }, configure: SocialClosure); } /// - /// 构造玩家作用域。 + /// 桥生命周期:装配未启动时好友域桥缺席;启动后 Activate 发布好友域桥,租约可取用契约面。 /// - private static OnlineScope PlayerScope(long playerId) + [Fact] + public async Task Bridge_PublishedOnlyAfterStart() { - return new OnlineScope(TenantId, AppId, ServerId, playerId); + await OnlineCapabilityTestHarness.WithRuntimeAsync(async runtime => + { + Assert.False(CapabilityBridge.TryAcquire(out _)); + + await runtime.Host.StartAsync(); + + Assert.True(CapabilityBridge.TryAcquire(out var lease)); + Assert.NotNull(lease.Contract); + }, configure: SocialClosure); } /// - /// 构造已装配 social 运行时的宿主(不自启:仅验证装配与域面行为)。 + /// 桥生命周期:宿主停止自动撤桥——再取租约失败,停止前取得的旧租约访问契约面显式抛 + /// ;同进程重新组合后发布新桥,旧租约仍失效、新租约可用。 /// - private static CapabilityHost BuildSocialHost() + [Fact] + public async Task Bridge_AfterStop_RetractsAndOldLeaseFailsExplicitly() { - var builder = MinimalRoot(); - builder.AddOnlineSocialRuntime(); - return builder.Build(ValidOptions()); + CapabilityBridgeLease staleLease = null; + await OnlineCapabilityTestHarness.WithRuntimeAsync(async runtime => + { + await runtime.Host.StartAsync(); + Assert.True(CapabilityBridge.TryAcquire(out staleLease)); + var before = await staleLease.Contract.ListFriendsAsync(PlayerScope(PlayerOne)); + Assert.True(before.IsSuccess); + + await runtime.StopAsync(); + + Assert.False(CapabilityBridge.TryAcquire(out _)); + Assert.Throws(() => staleLease.Contract); + }, configure: SocialClosure); + + await WithStartedFriendDomainAsync(async (domain, runtime) => + { + Assert.Throws(() => staleLease.Contract); + var fresh = await domain.ListFriendsAsync(PlayerScope(PlayerOne)); + Assert.True(fresh.IsSuccess); + }); } /// - /// 模块自装配把好友域契约面注册回共享面。 + /// 契约面缺席披露:白名单不含 online-social 时启动后好友域桥不发布(消费方按依赖不可用显式失败)。 /// [Fact] - public void Module_RegistersFriendDomainFace() + public async Task Bridge_WithoutSocial_IsNotPublished() { - var host = BuildSocialHost(); + await OnlineCapabilityTestHarness.WithRuntimeAsync(async runtime => + { + await runtime.Host.StartAsync(); - Assert.NotNull(host.Services.GetService()); + Assert.DoesNotContain("online-social", runtime.Report.EnabledIdsInOrder); + Assert.False(CapabilityBridge.TryAcquire(out _)); + }, configure: options => options.Filter = new CapabilityAssemblyFilter(new[] { "online-foundation" })); } /// @@ -155,16 +155,16 @@ public void Module_RegistersFriendDomainFace() [Fact] public async Task Face_Establish_FreshPair_ConvergesToFriend() { - var host = BuildSocialHost(); - var domain = host.Services.GetService(); - - var result = await domain.EstablishAsync(PlayerScope(PlayerOne), PlayerTwo); - - Assert.True(result.IsSuccess); - Assert.Equal(OnlineFriendRelationKind.None, result.Data.Before); - Assert.Equal(OnlineFriendRelationKind.Friend, result.Data.After); - var listed = await domain.ListFriendsAsync(PlayerScope(PlayerTwo)); - Assert.Contains(listed.Data, entry => entry.PlayerId == PlayerOne); + await WithStartedFriendDomainAsync(async (domain, runtime) => + { + var result = await domain.EstablishAsync(PlayerScope(PlayerOne), PlayerTwo); + + Assert.True(result.IsSuccess); + Assert.Equal(OnlineFriendRelationKind.None, result.Data.Before); + Assert.Equal(OnlineFriendRelationKind.Friend, result.Data.After); + var listed = await domain.ListFriendsAsync(PlayerScope(PlayerTwo)); + Assert.Contains(listed.Data, entry => entry.PlayerId == PlayerOne); + }); } /// @@ -173,15 +173,15 @@ public async Task Face_Establish_FreshPair_ConvergesToFriend() [Fact] public async Task Face_Establish_ReversePending_ActsAsAccept() { - var host = BuildSocialHost(); - var domain = host.Services.GetService(); - - await domain.RequestAsync(PlayerScope(PlayerTwo), PlayerOne); - var result = await domain.EstablishAsync(PlayerScope(PlayerOne), PlayerTwo); + await WithStartedFriendDomainAsync(async (domain, runtime) => + { + await domain.RequestAsync(PlayerScope(PlayerTwo), PlayerOne); + var result = await domain.EstablishAsync(PlayerScope(PlayerOne), PlayerTwo); - Assert.True(result.IsSuccess); - Assert.Equal(OnlineFriendRelationKind.IncomingRequest, result.Data.Before); - Assert.Equal(OnlineFriendRelationKind.Friend, result.Data.After); + Assert.True(result.IsSuccess); + Assert.Equal(OnlineFriendRelationKind.IncomingRequest, result.Data.Before); + Assert.Equal(OnlineFriendRelationKind.Friend, result.Data.After); + }); } /// @@ -190,15 +190,15 @@ public async Task Face_Establish_ReversePending_ActsAsAccept() [Fact] public async Task Face_Establish_OwnPending_AutoAccepts() { - var host = BuildSocialHost(); - var domain = host.Services.GetService(); - - await domain.RequestAsync(PlayerScope(PlayerOne), PlayerTwo); - var result = await domain.EstablishAsync(PlayerScope(PlayerOne), PlayerTwo); + await WithStartedFriendDomainAsync(async (domain, runtime) => + { + await domain.RequestAsync(PlayerScope(PlayerOne), PlayerTwo); + var result = await domain.EstablishAsync(PlayerScope(PlayerOne), PlayerTwo); - Assert.True(result.IsSuccess); - Assert.Equal(OnlineFriendRelationKind.OutgoingRequest, result.Data.Before); - Assert.Equal(OnlineFriendRelationKind.Friend, result.Data.After); + Assert.True(result.IsSuccess); + Assert.Equal(OnlineFriendRelationKind.OutgoingRequest, result.Data.Before); + Assert.Equal(OnlineFriendRelationKind.Friend, result.Data.After); + }); } /// @@ -207,15 +207,15 @@ public async Task Face_Establish_OwnPending_AutoAccepts() [Fact] public async Task Face_Establish_AlreadyFriend_Idempotent() { - var host = BuildSocialHost(); - var domain = host.Services.GetService(); - - await domain.EstablishAsync(PlayerScope(PlayerOne), PlayerTwo); - var result = await domain.EstablishAsync(PlayerScope(PlayerOne), PlayerTwo); + await WithStartedFriendDomainAsync(async (domain, runtime) => + { + await domain.EstablishAsync(PlayerScope(PlayerOne), PlayerTwo); + var result = await domain.EstablishAsync(PlayerScope(PlayerOne), PlayerTwo); - Assert.True(result.IsSuccess); - Assert.Equal(OnlineFriendRelationKind.Friend, result.Data.Before); - Assert.Equal(OnlineFriendRelationKind.Friend, result.Data.After); + Assert.True(result.IsSuccess); + Assert.Equal(OnlineFriendRelationKind.Friend, result.Data.Before); + Assert.Equal(OnlineFriendRelationKind.Friend, result.Data.After); + }); } /// @@ -224,26 +224,26 @@ public async Task Face_Establish_AlreadyFriend_Idempotent() [Fact] public async Task Face_Request_ConvergesAndClassifiesDirection() { - var host = BuildSocialHost(); - var domain = host.Services.GetService(); - - var fresh = await domain.RequestAsync(PlayerScope(PlayerOne), PlayerTwo); - Assert.True(fresh.IsSuccess); - Assert.Equal(OnlineFriendRelationKind.None, fresh.Data.Before); - Assert.Equal(OnlineFriendRelationKind.OutgoingRequest, fresh.Data.After); - - var repeat = await domain.RequestAsync(PlayerScope(PlayerOne), PlayerTwo); - Assert.True(repeat.IsSuccess); - Assert.Equal(OnlineFriendRelationKind.OutgoingRequest, repeat.Data.Before); - - var reverse = await domain.RequestAsync(PlayerScope(PlayerTwo), PlayerOne); - Assert.True(reverse.IsSuccess); - Assert.Equal(OnlineFriendRelationKind.IncomingRequest, reverse.Data.Before); - - // 玩家三的新鲜申请不受影响。 - var third = await domain.RequestAsync(PlayerScope(PlayerThree), PlayerOne); - Assert.True(third.IsSuccess); - Assert.Equal(OnlineFriendRelationKind.None, third.Data.Before); + await WithStartedFriendDomainAsync(async (domain, runtime) => + { + var fresh = await domain.RequestAsync(PlayerScope(PlayerOne), PlayerTwo); + Assert.True(fresh.IsSuccess); + Assert.Equal(OnlineFriendRelationKind.None, fresh.Data.Before); + Assert.Equal(OnlineFriendRelationKind.OutgoingRequest, fresh.Data.After); + + var repeat = await domain.RequestAsync(PlayerScope(PlayerOne), PlayerTwo); + Assert.True(repeat.IsSuccess); + Assert.Equal(OnlineFriendRelationKind.OutgoingRequest, repeat.Data.Before); + + var reverse = await domain.RequestAsync(PlayerScope(PlayerTwo), PlayerOne); + Assert.True(reverse.IsSuccess); + Assert.Equal(OnlineFriendRelationKind.IncomingRequest, reverse.Data.Before); + + // 玩家三的新鲜申请不受影响。 + var third = await domain.RequestAsync(PlayerScope(PlayerThree), PlayerOne); + Assert.True(third.IsSuccess); + Assert.Equal(OnlineFriendRelationKind.None, third.Data.Before); + }); } /// @@ -252,27 +252,27 @@ public async Task Face_Request_ConvergesAndClassifiesDirection() [Fact] public async Task Face_Accept_WithoutAnswerablePending_ReturnsResourceNotFound() { - var host = BuildSocialHost(); - var domain = host.Services.GetService(); - - var noRecord = await domain.AcceptAsync(PlayerScope(PlayerOne), PlayerTwo); - Assert.False(noRecord.IsSuccess); - Assert.Equal(OnlineErrorCode.ResourceNotFound, noRecord.Code); - - // 发起方本人不是被请求方:看不到自己发出的申请。 - await domain.RequestAsync(PlayerScope(PlayerOne), PlayerTwo); - var selfAnswer = await domain.AcceptAsync(PlayerScope(PlayerOne), PlayerTwo); - Assert.False(selfAnswer.IsSuccess); - Assert.Equal(OnlineErrorCode.ResourceNotFound, selfAnswer.Code); - - // 首次接受成功,重放(Incoming 已消费)→ ResourceNotFound(R7:重复答复同旧协议「申请不存在」)。 - var accepted = await domain.AcceptAsync(PlayerScope(PlayerTwo), PlayerOne); - Assert.True(accepted.IsSuccess); - Assert.Equal(OnlineFriendRelationKind.IncomingRequest, accepted.Data.Before); - Assert.Equal(OnlineFriendRelationKind.Friend, accepted.Data.After); - var replay = await domain.AcceptAsync(PlayerScope(PlayerTwo), PlayerOne); - Assert.False(replay.IsSuccess); - Assert.Equal(OnlineErrorCode.ResourceNotFound, replay.Code); + await WithStartedFriendDomainAsync(async (domain, runtime) => + { + var noRecord = await domain.AcceptAsync(PlayerScope(PlayerOne), PlayerTwo); + Assert.False(noRecord.IsSuccess); + Assert.Equal(OnlineErrorCode.ResourceNotFound, noRecord.Code); + + // 发起方本人不是被请求方:看不到自己发出的申请。 + await domain.RequestAsync(PlayerScope(PlayerOne), PlayerTwo); + var selfAnswer = await domain.AcceptAsync(PlayerScope(PlayerOne), PlayerTwo); + Assert.False(selfAnswer.IsSuccess); + Assert.Equal(OnlineErrorCode.ResourceNotFound, selfAnswer.Code); + + // 首次接受成功,重放(Incoming 已消费)→ ResourceNotFound(R7:重复答复同旧协议「申请不存在」)。 + var accepted = await domain.AcceptAsync(PlayerScope(PlayerTwo), PlayerOne); + Assert.True(accepted.IsSuccess); + Assert.Equal(OnlineFriendRelationKind.IncomingRequest, accepted.Data.Before); + Assert.Equal(OnlineFriendRelationKind.Friend, accepted.Data.After); + var replay = await domain.AcceptAsync(PlayerScope(PlayerTwo), PlayerOne); + Assert.False(replay.IsSuccess); + Assert.Equal(OnlineErrorCode.ResourceNotFound, replay.Code); + }); } /// @@ -281,19 +281,19 @@ public async Task Face_Accept_WithoutAnswerablePending_ReturnsResourceNotFound() [Fact] public async Task Face_Reject_ThenRenew_FlipsDirection() { - var host = BuildSocialHost(); - var domain = host.Services.GetService(); - - await domain.RequestAsync(PlayerScope(PlayerOne), PlayerTwo); - var rejected = await domain.RejectAsync(PlayerScope(PlayerTwo), PlayerOne); - Assert.True(rejected.IsSuccess); - Assert.Equal(OnlineFriendRelationKind.IncomingRequest, rejected.Data.Before); - Assert.Equal(OnlineFriendRelationKind.None, rejected.Data.After); - - var renewed = await domain.RequestAsync(PlayerScope(PlayerTwo), PlayerOne); - Assert.True(renewed.IsSuccess); - Assert.Equal(OnlineFriendRelationKind.None, renewed.Data.Before); - Assert.Equal(OnlineFriendRelationKind.OutgoingRequest, renewed.Data.After); + await WithStartedFriendDomainAsync(async (domain, runtime) => + { + await domain.RequestAsync(PlayerScope(PlayerOne), PlayerTwo); + var rejected = await domain.RejectAsync(PlayerScope(PlayerTwo), PlayerOne); + Assert.True(rejected.IsSuccess); + Assert.Equal(OnlineFriendRelationKind.IncomingRequest, rejected.Data.Before); + Assert.Equal(OnlineFriendRelationKind.None, rejected.Data.After); + + var renewed = await domain.RequestAsync(PlayerScope(PlayerTwo), PlayerOne); + Assert.True(renewed.IsSuccess); + Assert.Equal(OnlineFriendRelationKind.None, renewed.Data.Before); + Assert.Equal(OnlineFriendRelationKind.OutgoingRequest, renewed.Data.After); + }); } /// @@ -302,22 +302,22 @@ public async Task Face_Reject_ThenRenew_FlipsDirection() [Fact] public async Task Face_Remove_RequiresEstablishedFriendship() { - var host = BuildSocialHost(); - var domain = host.Services.GetService(); - - var notFriend = await domain.RemoveAsync(PlayerScope(PlayerOne), PlayerTwo); - Assert.False(notFriend.IsSuccess); - Assert.Equal(OnlineErrorCode.ResourceNotFound, notFriend.Code); - - await domain.EstablishAsync(PlayerScope(PlayerOne), PlayerTwo); - var removed = await domain.RemoveAsync(PlayerScope(PlayerOne), PlayerTwo); - Assert.True(removed.IsSuccess); - Assert.Equal(OnlineFriendRelationKind.Friend, removed.Data.Before); - Assert.Equal(OnlineFriendRelationKind.None, removed.Data.After); - - var replay = await domain.RemoveAsync(PlayerScope(PlayerOne), PlayerTwo); - Assert.False(replay.IsSuccess); - Assert.Equal(OnlineErrorCode.ResourceNotFound, replay.Code); + await WithStartedFriendDomainAsync(async (domain, runtime) => + { + var notFriend = await domain.RemoveAsync(PlayerScope(PlayerOne), PlayerTwo); + Assert.False(notFriend.IsSuccess); + Assert.Equal(OnlineErrorCode.ResourceNotFound, notFriend.Code); + + await domain.EstablishAsync(PlayerScope(PlayerOne), PlayerTwo); + var removed = await domain.RemoveAsync(PlayerScope(PlayerOne), PlayerTwo); + Assert.True(removed.IsSuccess); + Assert.Equal(OnlineFriendRelationKind.Friend, removed.Data.Before); + Assert.Equal(OnlineFriendRelationKind.None, removed.Data.After); + + var replay = await domain.RemoveAsync(PlayerScope(PlayerOne), PlayerTwo); + Assert.False(replay.IsSuccess); + Assert.Equal(OnlineErrorCode.ResourceNotFound, replay.Code); + }); } /// @@ -326,84 +326,76 @@ public async Task Face_Remove_RequiresEstablishedFriendship() [Fact] public async Task Face_ListRelations_ProjectsKinds_And_ListFriends_OnlyEstablished() { - var host = BuildSocialHost(); - var domain = host.Services.GetService(); - - await domain.EstablishAsync(PlayerScope(PlayerOne), PlayerTwo); - await domain.RequestAsync(PlayerScope(PlayerThree), PlayerOne); - - var relations = await domain.ListRelationsAsync(PlayerScope(PlayerOne)); - Assert.True(relations.IsSuccess); - Assert.Contains(relations.Data, r => r.OtherPlayerId == PlayerTwo && r.Kind == OnlineFriendRelationKind.Friend); - Assert.Contains(relations.Data, r => r.OtherPlayerId == PlayerThree && r.Kind == OnlineFriendRelationKind.IncomingRequest); - Assert.Equal(2, relations.Data.Count); - - var friends = await domain.ListFriendsAsync(PlayerScope(PlayerOne)); - var only = Assert.Single(friends.Data); - Assert.Equal(PlayerTwo, only.PlayerId); + await WithStartedFriendDomainAsync(async (domain, runtime) => + { + await domain.EstablishAsync(PlayerScope(PlayerOne), PlayerTwo); + await domain.RequestAsync(PlayerScope(PlayerThree), PlayerOne); + + var relations = await domain.ListRelationsAsync(PlayerScope(PlayerOne)); + Assert.True(relations.IsSuccess); + Assert.Contains(relations.Data, r => r.OtherPlayerId == PlayerTwo && r.Kind == OnlineFriendRelationKind.Friend); + Assert.Contains(relations.Data, r => r.OtherPlayerId == PlayerThree && r.Kind == OnlineFriendRelationKind.IncomingRequest); + Assert.Equal(2, relations.Data.Count); + + var friends = await domain.ListFriendsAsync(PlayerScope(PlayerOne)); + var only = Assert.Single(friends.Data); + Assert.Equal(PlayerTwo, only.PlayerId); + }); } /// - /// 租约撤销后旧引用一律显式 DependencyUnavailable;再发布替换旧租约。 + /// 验证种子经真实写路径建立非空好友,请求方经好友域桥可见;幂等重放不产生第二条关系、不报错。 /// [Fact] - public async Task Lease_RetractAndRepublish_FailExplicitly() + public async Task Seed_EstablishesFriendshipsThroughRealWritePath() { - var host = BuildSocialHost(); - var domain = host.Services.GetService(); - - var first = OnlineFriendDomainBridgeHolder.Publish(domain, PlayerScope(0)); - var before = await first.ListFriendsAsync(PlayerScope(PlayerOne)); - Assert.True(before.IsSuccess); - - var second = OnlineFriendDomainBridgeHolder.Publish(domain, PlayerScope(0)); - var staleFirst = await first.ListFriendsAsync(PlayerScope(PlayerOne)); - Assert.False(staleFirst.IsSuccess); - Assert.Equal(OnlineErrorCode.DependencyUnavailable, staleFirst.Code); - - OnlineFriendDomainBridgeHolder.Retract(); - Assert.Null(OnlineFriendDomainBridgeHolder.Current); - var staleSecond = await second.ListFriendsAsync(PlayerScope(PlayerOne)); - Assert.False(staleSecond.IsSuccess); - Assert.Equal(OnlineErrorCode.DependencyUnavailable, staleSecond.Code); + await WithStartedFriendDomainAsync(async (domain, runtime) => + { + var established = await FriendBridgeVerificationSeed.SeedAsync(runtime.Host, new[] { PlayerOne, PlayerTwo }); + Assert.Equal(1, established); + await FriendBridgeVerificationSeed.SeedAsync(runtime.Host, new[] { PlayerOne, PlayerTwo }); + + var friends = await domain.ListFriendsAsync(PlayerScope(PlayerOne)); + var only = Assert.Single(friends.Data); + Assert.Equal(PlayerTwo, only.PlayerId); + var reverse = await domain.ListFriendsAsync(PlayerScope(PlayerTwo)); + Assert.Contains(reverse.Data, entry => entry.PlayerId == PlayerOne); + }); } /// - /// 组合根接线:完整宿主发布域桥;契约面缺席(online-social 未装配)披露 Error 且不发布、不抛出。 + /// 验证种子多好友:首元素为请求方、其余逐一成友,请求方经好友域桥看到全部种子好友。 /// [Fact] - public void Wiring_PublishesWithFullHost_And_DisclosesMissingFace() + public async Task Seed_MultipleFriends_AllVisibleToRequester() { - var host = BuildSocialHost(); - - var lease = OnlineFriendDomainBridgeWiring.PublishFriendDomainBridge(host); - Assert.NotNull(lease); - Assert.Same(lease, OnlineFriendDomainBridgeHolder.Current); - OnlineFriendDomainBridgeWiring.RetractFriendDomainBridge(); - Assert.False(OnlineFriendDomainBridgeHolder.IsPublished); - - var bareBuilder = new CapabilityHostBuilder(); - bareBuilder.UseService(new OnlineScope(TenantId, AppId, ServerId)); - var bareHost = bareBuilder.Build(ValidOptions()); - Assert.Null(OnlineFriendDomainBridgeWiring.PublishFriendDomainBridge(bareHost)); - Assert.False(OnlineFriendDomainBridgeHolder.IsPublished); + await WithStartedFriendDomainAsync(async (domain, runtime) => + { + var established = await FriendBridgeVerificationSeed.SeedAsync(runtime.Host, new[] { PlayerOne, PlayerTwo, PlayerThree }); + Assert.Equal(2, established); + + var friends = await domain.ListFriendsAsync(PlayerScope(PlayerOne)); + Assert.Equal(2, friends.Data.Count); + Assert.Contains(friends.Data, entry => entry.PlayerId == PlayerTwo); + Assert.Contains(friends.Data, entry => entry.PlayerId == PlayerThree); + }); } /// - /// 验证种子经真实写路径建立非空好友(幂等重放不报错)。 + /// 验证种子空列表 / 单元素列表为空操作:不建立任何关系。 /// [Fact] - public async Task Seed_EstablishesFriendshipsThroughRealWritePath() + public async Task Seed_EmptyOrSingleList_IsNoOp() { - var host = BuildSocialHost(); - - await OnlineFriendDomainBridgeWiring.SeedFriendDomainBridgeVerificationAsync(host, new[] { PlayerOne, PlayerTwo }); - await OnlineFriendDomainBridgeWiring.SeedFriendDomainBridgeVerificationAsync(host, new[] { PlayerOne, PlayerTwo }); + await WithStartedFriendDomainAsync(async (domain, runtime) => + { + Assert.Equal(0, await FriendBridgeVerificationSeed.SeedAsync(runtime.Host, Array.Empty())); + Assert.Equal(0, await FriendBridgeVerificationSeed.SeedAsync(runtime.Host, new[] { PlayerOne })); - var domain = host.Services.GetService(); - var friends = await domain.ListFriendsAsync(PlayerScope(PlayerOne)); - var only = Assert.Single(friends.Data); - Assert.Equal(PlayerTwo, only.PlayerId); + var relations = await domain.ListRelationsAsync(PlayerScope(PlayerOne)); + Assert.True(relations.IsSuccess); + Assert.Empty(relations.Data); + }); } } } diff --git a/Tests/GameFrameX.Tests/Online/OnlineFriendLegacyMigrationTests.cs b/Tests/GameFrameX.Tests/Online/OnlineFriendLegacyMigrationTests.cs index f3d5007f7..a454e2d1f 100644 --- a/Tests/GameFrameX.Tests/Online/OnlineFriendLegacyMigrationTests.cs +++ b/Tests/GameFrameX.Tests/Online/OnlineFriendLegacyMigrationTests.cs @@ -29,34 +29,35 @@ using System; using System.Collections.Generic; +using System.Text.Json; using System.Threading.Tasks; using GameFrameX.Apps.Player.Friend.Entity; using GameFrameX.Apps.ServerRole.Friend.Entity; using GameFrameX.Capability.Hosting.Abstractions; using GameFrameX.Capability.Hosting.Core; +using GameFrameX.Capability.Online.Abstractions; using GameFrameX.Capability.Online.Abstractions.Contracts; -using GameFrameX.Capability.Online.Abstractions.Events; -using GameFrameX.Capability.Online.Abstractions.Idempotency; using GameFrameX.Capability.Online.Abstractions.Scope; -using GameFrameX.Capability.Online.Foundation.Session; +using GameFrameX.Capability.Online.Operations.HotfixRollback; using GameFrameX.Capability.Online.Social; using GameFrameX.DataBase; using GameFrameX.DataBase.Abstractions; using GameFrameX.DataBase.Sqlite; -using GameFrameX.Launcher.Online; using Xunit; namespace GameFrameX.Tests.Online; /// -/// 旧好友数据一次性迁移测试(C244 T3):域 120 旧链 FriendRelationState 与域 660 旧 FriendState -/// 档案经真实写路径收敛为 Online 好友关系;只读单向复制(旧数据零改动)、幂等重放、软删行不迁移。 +/// 旧好友数据一次性迁移测试(C244 T3 → C294 机制化):域 120 旧链 FriendRelationState 与域 660 旧 FriendState +/// 档案经真实写路径收敛为 Online 好友关系;只读单向复制(旧数据零改动)、幂等重放、软删行不迁移; +/// 配置开关 online-social.Friend.MigrateLegacyFriendData 打开时社交能力在 Activate 期自动执行迁移。 /// /// -/// Tests for the one-shot legacy friend-data migration (C244 T3): the legacy domain-120 +/// Tests for the one-shot legacy friend-data migration (C244 T3, mechanism rewrite C294): the legacy domain-120 /// FriendRelationState rows and the legacy domain-660 FriendState profiles converge into Online /// friendships through the real write path; the migration is a read-only one-way copy (legacy data -/// untouched), idempotent on replay, and skips soft-deleted rows. +/// untouched), idempotent on replay, skips soft-deleted rows, and runs automatically during activation +/// when the social option switch is on. /// [Collection("GameDbStaticStateCollection")] public sealed class OnlineFriendLegacyMigrationTests @@ -93,38 +94,44 @@ private static async Task WithSqliteFacadeAsync(Func action) } /// - /// 构造已装配 social 运行时的宿主(好友 store 保持 InMemory 默认,迁移写路径与生产一致)。 + /// 组合 foundation + social(族配置段作用域 9/4/1001;好友 store 保持 InMemory 默认,迁移写路径与生产一致)并启动, + /// 执行用例主体后恒停止撤桥。 /// - private static CapabilityHost BuildSocialHost() + /// 用例主体(入参:已启动的组合产物)。 + /// Settings 子对象原文(null = 不写 Settings 键)。 + /// 异步任务。 + private static async Task WithStartedSocialRuntimeAsync(Func body, string settingsJson = null) { - var builder = new CapabilityHostBuilder(); - builder.UseService(new OnlineScope(TenantId, AppId, ServerId)); - var eventPublisher = new OnlineEventPublisher(new GameFrameX.Foundation.Idempotency.InMemoryEventPublisher()); - builder.UseService(eventPublisher); - builder.UseService(new OnlineIdempotencyService(new GameFrameX.Foundation.Idempotency.IdempotencyCoordinator( - new GameFrameX.Foundation.Idempotency.InMemoryIdempotencyStore(), - GameFrameX.Foundation.Idempotency.SystemClock.Instance, - new GameFrameX.Foundation.Idempotency.IdempotencyOptions()))); - var sessionStore = new InMemoryOnlineSessionStore(); - builder.UseService(sessionStore); - builder.UseService(new OnlineSessionTokenService(sessionStore, eventPublisher)); - builder.AddModule(new FoundationStubModule()); - builder.AddOnlineSocialRuntime(); - return builder.Build(new CapabilityHostOptions { TenantId = TenantId, AppId = AppId, ServerId = ServerId }); + var settings = string.IsNullOrEmpty(settingsJson) ? string.Empty : ",\"Settings\":" + settingsJson; + var section = JsonDocument.Parse("{\"TenantId\":9,\"AppId\":4,\"ServerId\":1001" + settings + "}").RootElement.Clone(); + var options = new CapabilityCompositionOptions(section, new CollectingCapabilityAssemblyLog()) + { + HostOptions = new CapabilityHostOptions { TenantId = TenantId, AppId = AppId, ServerId = ServerId, }, + Filter = new CapabilityAssemblyFilter(new[] { "online-foundation", "online-social" }), + }; + options.Override(new OnlineCapabilityTestHarness.EmptyPlayerDirectory()); + options.Override(new OnlineCapabilityTestHarness.NoopHotfixRollbackExecutor()); + + var runtime = CapabilityComposition.Compose(new OnlineCapabilityFamily(), options); + try + { + await runtime.Host.StartAsync(); + await body(runtime); + } + finally + { + await runtime.StopAsync(); + } } /// - /// Foundation 桩模块(满足 online-social 的 Requires 拓扑)。 + /// 构造迁移作用域内的玩家作用域。 /// - private sealed class FoundationStubModule : ICapabilityModule + /// 玩家标识。 + /// 玩家作用域。 + private static OnlineScope PlayerScope(long playerId) { - /// - public CapabilityManifest Manifest { get; } = new("online-foundation", "1.0.0", Array.Empty()); - - /// - public void Register(CapabilityRegistrationContext context) - { - } + return new OnlineScope(TenantId, AppId, ServerId, playerId); } /// @@ -142,6 +149,26 @@ private static async Task SeedLegacyDataAsync() await GameDb.AddOrUpdateAsync(legacyProfiles); } + /// + /// 断言迁移收敛结果:11-22 已成友;55-66 待答;33-44(软删)无关系。 + /// + /// 好友服务。 + /// 异步任务。 + private static async Task AssertConvergedRelationsAsync(OnlineFriendService friends) + { + var friendPair = await friends.FindAsync(PlayerScope(11), 22); + Assert.True(friendPair.IsSuccess); + Assert.NotNull(friendPair.Data); + Assert.Equal(OnlineFriendshipState.Accepted, friendPair.Data.State); + var pendingPair = await friends.FindAsync(PlayerScope(55), 66); + Assert.True(pendingPair.IsSuccess); + Assert.NotNull(pendingPair.Data); + Assert.Equal(OnlineFriendshipState.Requested, pendingPair.Data.State); + var deletedPair = await friends.FindAsync(PlayerScope(33), 44); + Assert.True(deletedPair.IsSuccess); + Assert.Null(deletedPair.Data); + } + /// /// 迁移收敛:已建立对成友、待答申请入列、软删行不迁移、旧数据零改动。 /// @@ -151,43 +178,32 @@ public async Task Migrate_ConvergesLegacyData_WithoutTouchingLegacyRows() await WithSqliteFacadeAsync(async () => { await SeedLegacyDataAsync(); - var host = BuildSocialHost(); - var domain = host.Services.GetService(); - var service = host.Services.GetService(); - - var (established, pending, failed) = await OnlineFriendLegacyMigration.MigrateAsync(host); - - Assert.Equal(1, established); - Assert.Equal(1, pending); - Assert.Equal(0, failed); - - // 11-22 已成友;55-66 待答;33-44(软删)无关系。 - var friendPair = await service.FindAsync(new OnlineScope(TenantId, AppId, ServerId, 11), 22); - Assert.True(friendPair.IsSuccess); - Assert.NotNull(friendPair.Data); - Assert.Equal(OnlineFriendshipState.Accepted, friendPair.Data.State); - var pendingPair = await service.FindAsync(new OnlineScope(TenantId, AppId, ServerId, 55), 66); - Assert.True(pendingPair.IsSuccess); - Assert.NotNull(pendingPair.Data); - Assert.Equal(OnlineFriendshipState.Requested, pendingPair.Data.State); - var deletedPair = await service.FindAsync(new OnlineScope(TenantId, AppId, ServerId, 33), 44); - Assert.True(deletedPair.IsSuccess); - Assert.Null(deletedPair.Data); - - // 旧数据零改动:行数、状态、档案内容原样。 - var relations = await GameDb.FindListAsync(state => true); - Assert.Equal(2, relations.Count); - Assert.Contains(relations, r => r.PlayerIdA == 11 && r.PlayerIdB == 22 && r.Status == 0); - Assert.Contains(relations, r => r.PlayerIdA == 33 && r.PlayerIdB == 44 && r.Status == 1); - var profiles = await GameDb.FindListAsync(state => state.Id >= 0); - var profile = Assert.Single(profiles); - Assert.NotNull(profile.Profiles.TryGetValue(55, out var legacy)); - Assert.Equal(new List { 66 }, legacy.Outgoing); + await WithStartedSocialRuntimeAsync(async runtime => + { + var friends = runtime.Host.Services.GetService(); + + var (established, pending, failed) = await OnlineFriendLegacyMigration.MigrateAsync(friends, TenantId, AppId, ServerId); + + Assert.Equal(1, established); + Assert.Equal(1, pending); + Assert.Equal(0, failed); + await AssertConvergedRelationsAsync(friends); + + // 旧数据零改动:行数、状态、档案内容原样。 + var relations = await GameDb.FindListAsync(state => true); + Assert.Equal(2, relations.Count); + Assert.Contains(relations, r => r.PlayerIdA == 11 && r.PlayerIdB == 22 && r.Status == 0); + Assert.Contains(relations, r => r.PlayerIdA == 33 && r.PlayerIdB == 44 && r.Status == 1); + var profiles = await GameDb.FindListAsync(state => state.Id >= 0); + var profile = Assert.Single(profiles); + Assert.True(profile.Profiles.TryGetValue(55, out var legacy)); + Assert.Equal(new List { 66 }, legacy.Outgoing); + }); }); } /// - /// 迁移幂等:重放不产生第二条关系、不重复计数失败(SaveIfAbsent / 幂等收敛)。 + /// 迁移幂等:重放不产生第二条关系、不重复计数失败(SaveIfAbsent / 幂等收敛);好友域桥视角关系唯一。 /// [Fact] public async Task Migrate_Replay_IsIdempotent() @@ -195,22 +211,63 @@ public async Task Migrate_Replay_IsIdempotent() await WithSqliteFacadeAsync(async () => { await SeedLegacyDataAsync(); - var host = BuildSocialHost(); - var domain = host.Services.GetService(); - - await OnlineFriendLegacyMigration.MigrateAsync(host); - var (established, pending, failed) = await OnlineFriendLegacyMigration.MigrateAsync(host); - - Assert.Equal(1, established); - Assert.Equal(1, pending); - Assert.Equal(0, failed); - - var relations = await domain.ListRelationsAsync(new OnlineScope(TenantId, AppId, ServerId, 11)); - Assert.Single(relations.Data); - var outgoing = await domain.ListRelationsAsync(new OnlineScope(TenantId, AppId, ServerId, 55)); - var only = Assert.Single(outgoing.Data); - Assert.Equal(66, only.OtherPlayerId); - Assert.Equal(OnlineFriendRelationKind.OutgoingRequest, only.Kind); + await WithStartedSocialRuntimeAsync(async runtime => + { + var friends = runtime.Host.Services.GetService(); + + await OnlineFriendLegacyMigration.MigrateAsync(friends, TenantId, AppId, ServerId); + var (established, pending, failed) = await OnlineFriendLegacyMigration.MigrateAsync(friends, TenantId, AppId, ServerId); + + Assert.Equal(1, established); + Assert.Equal(1, pending); + Assert.Equal(0, failed); + + Assert.True(CapabilityBridge.TryAcquire(out var lease)); + var domain = lease.Contract; + var relations = await domain.ListRelationsAsync(PlayerScope(11)); + Assert.Single(relations.Data); + var outgoing = await domain.ListRelationsAsync(PlayerScope(55)); + var only = Assert.Single(outgoing.Data); + Assert.Equal(66, only.OtherPlayerId); + Assert.Equal(OnlineFriendRelationKind.OutgoingRequest, only.Kind); + }); + }); + } + + /// + /// 配置开关打开(online-social.Friend.MigrateLegacyFriendData = true):社交能力在 Activate 期自动执行迁移, + /// 启动完成即可观测到收敛后的关系。 + /// + [Fact] + public async Task Activate_WithMigrateLegacyFriendDataEnabled_MigratesAutomatically() + { + await WithSqliteFacadeAsync(async () => + { + await SeedLegacyDataAsync(); + await WithStartedSocialRuntimeAsync(async runtime => + { + var friends = runtime.Host.Services.GetService(); + await AssertConvergedRelationsAsync(friends); + }, "{\"online-social\":{\"Friend\":{\"MigrateLegacyFriendData\":true}}}"); + }); + } + + /// + /// 配置开关缺省关闭:启动不触发迁移(旧数据在场,Online 侧仍无关系)。 + /// + [Fact] + public async Task Activate_WithMigrateLegacyFriendDataDefault_DoesNotMigrate() + { + await WithSqliteFacadeAsync(async () => + { + await SeedLegacyDataAsync(); + await WithStartedSocialRuntimeAsync(async runtime => + { + var friends = runtime.Host.Services.GetService(); + var friendPair = await friends.FindAsync(PlayerScope(11), 22); + Assert.True(friendPair.IsSuccess); + Assert.Null(friendPair.Data); + }); }); } } diff --git a/Tests/GameFrameX.Tests/Online/OnlineFriendServiceTests.cs b/Tests/GameFrameX.Tests/Online/OnlineFriendServiceTests.cs index 6b768d0a3..3b748450d 100644 --- a/Tests/GameFrameX.Tests/Online/OnlineFriendServiceTests.cs +++ b/Tests/GameFrameX.Tests/Online/OnlineFriendServiceTests.cs @@ -651,7 +651,7 @@ private static OnlineFriendService CreateService( { recorder = new OnlineEventRecorder(); store = new InMemoryOnlineFriendStore(); - return new OnlineFriendService(store, recorder, directory, presenceProbe, 604800, searchLimit, friendLimit); + return new OnlineFriendService(store, recorder, new OnlineFriendOptions { RequestTimeToLiveSeconds = 604800, SearchLimit = searchLimit, FriendLimit = friendLimit }, directory, presenceProbe); } /// diff --git a/Tests/GameFrameX.Tests/Online/OnlineHotfixRollbackServiceTests.cs b/Tests/GameFrameX.Tests/Online/OnlineHotfixRollbackServiceTests.cs index 2a039307b..a3a898106 100644 --- a/Tests/GameFrameX.Tests/Online/OnlineHotfixRollbackServiceTests.cs +++ b/Tests/GameFrameX.Tests/Online/OnlineHotfixRollbackServiceTests.cs @@ -33,7 +33,7 @@ using System.Threading; using System.Threading.Tasks; using GameFrameX.Foundation.Idempotency; -using GameFrameX.Capability.Online.Operations.Audit; +using GameFrameX.Capability.Online.Abstractions.Audit; using GameFrameX.Capability.Online.Abstractions.Contracts; using GameFrameX.Capability.Online.Abstractions.Events; using GameFrameX.Capability.Online.Operations.HotfixRollback; diff --git a/Tests/GameFrameX.Tests/Online/OnlineInMemoryStoreConcurrencyTests.cs b/Tests/GameFrameX.Tests/Online/OnlineInMemoryStoreConcurrencyTests.cs index 204a89d32..e41c98061 100644 --- a/Tests/GameFrameX.Tests/Online/OnlineInMemoryStoreConcurrencyTests.cs +++ b/Tests/GameFrameX.Tests/Online/OnlineInMemoryStoreConcurrencyTests.cs @@ -103,7 +103,7 @@ public async Task ConcurrentCreateSameKey_ShouldAllowExactlyOneWinner() { // Arrange var store = new InMemoryOnlinePlayerStorageStore(); - var service = new OnlinePlayerStorageService(store); + var service = new OnlinePlayerStorageService(store, new OnlinePlayerStorageOptions()); var scope = new OnlineScope(TenantId, AppId, ServerId, PlayerId); // Act diff --git a/Tests/GameFrameX.Tests/Online/OnlineLeaderboardTrustedResultTests.cs b/Tests/GameFrameX.Tests/Online/OnlineLeaderboardTrustedResultTests.cs index dabb1b30f..0ad685798 100644 --- a/Tests/GameFrameX.Tests/Online/OnlineLeaderboardTrustedResultTests.cs +++ b/Tests/GameFrameX.Tests/Online/OnlineLeaderboardTrustedResultTests.cs @@ -421,7 +421,7 @@ public async Task CommitResultAsync(string matchId, string matchResultId, params public async Task PlayToSettlingAsync() { var matchStore = new InMemoryOnlineMatchActorStore(); - var runtime = new OnlineMatchRuntime(matchStore, Recorder); + var runtime = new OnlineMatchRuntime(matchStore, Recorder, new OnlineMatchRuntimeOptions()); runtime.RegisterGame(new OnlineRockPaperScissorsGame()); Settlement = new OnlineMatchSettlementService(runtime, ResultStore, Recorder); diff --git a/Tests/GameFrameX.Tests/Online/OnlineMatchDomainBridgeTests.cs b/Tests/GameFrameX.Tests/Online/OnlineMatchDomainBridgeTests.cs index 94d85db77..a223e39bb 100644 --- a/Tests/GameFrameX.Tests/Online/OnlineMatchDomainBridgeTests.cs +++ b/Tests/GameFrameX.Tests/Online/OnlineMatchDomainBridgeTests.cs @@ -1,13 +1,38 @@ +// ========================================================================================== +// GameFrameX 组织及其衍生项目的版权、商标、专利及其他相关权利 +// GameFrameX organization and its derivative projects' copyrights, trademarks, patents and related rights +// 均受中华人民共和国及相关国际法律法规保护。 +// are protected by the laws of the PRC 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 refer to the LICENSE file at the root 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 the legal 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://www.cnb.cool/GameFrameX +// CNB Repository: https://www.cnb.cool/GameFrameX +// 官方文档:https://gameframex.doc.alianblank.com/ +// Official Docs: https://gameframex.doc.alianblank.com/ +// ========================================================================================== + using System; -using System.Collections.Generic; using System.Linq; -using System.Threading; using System.Threading.Tasks; using GameFrameX.Capability.Hosting.Abstractions; using GameFrameX.Capability.Hosting.Core; -using GameFrameX.Capability.Online.Abstractions.Bridges; using GameFrameX.Capability.Online.Abstractions.Contracts; -using GameFrameX.Capability.Online.Abstractions.Events; using GameFrameX.Capability.Online.Abstractions.Scope; using GameFrameX.Capability.Online.Match; using GameFrameX.Capability.Online.Matchmaking; @@ -16,19 +41,18 @@ namespace GameFrameX.Tests.Online { /// - /// 匹配域桥接缝测试(C246):域契约面装配、T2 裁定 R4-R13 的域面语义(即时窗口 / 取消终态回执 / - /// 状态与票据映射 / 超时 / 入场到达同一对局)、holder 生命周期与模块拓扑序。 + /// 匹配域桥接缝测试(C246 → C294 机制桥):匹配域契约面由 online-match 在 online-matchmaking 在场时于 Activate 期经机制桥发布; + /// T2 裁定 R4-R13 的域面语义(即时窗口 / 取消终态回执 / 状态与票据映射 / 超时 / 入场到达同一对局)经租约访问; + /// 桥生命周期(启动前缺席 / 停止后撤销且旧租约显式失败)与匹配能力缺席时的披露(域桥不发布、对局桥照常发布)。 /// - public class OnlineMatchDomainBridgeTests : IDisposable + [Collection(CapabilityRuntimeCollection.Name)] + public class OnlineMatchDomainBridgeTests { - /// 测试用租户标识。 - private const long TenantId = 1; - - /// 测试用应用标识。 - private const long AppId = 1; - - /// 测试用区服标识。 - private const long ServerId = 1001; + /// + /// 静默调度间隔(毫秒):tick job 一律由用例显式驱动(如撮合 RunOnceAsync),后台循环在用例时长内不触发, + /// 保持「撮合 / 超时只在用例推进时刻时发生」的确定性。 + /// + private const int QuietSchedulerIntervalMilliseconds = 3600000; /// 玩家一。 private const long PlayerOne = 2001; @@ -39,106 +63,103 @@ public class OnlineMatchDomainBridgeTests : IDisposable /// 玩家三(非成员)。 private const long PlayerThree = 2003; - public OnlineMatchDomainBridgeTests() - { - OnlineMatchDomainBridgeHolder.Retract(); - } - - public void Dispose() - { - OnlineMatchDomainBridgeHolder.Retract(); - } - /// - /// 最小组合根(matchmaking + match 两运行时装配所需预置:作用域 + 事件出口 + 社交 gate 面 + 拓扑桩)。 + /// 构造玩家作用域(三元组与族配置段一致)。 /// - private static CapabilityHostBuilder MinimalRoot() + /// 玩家标识。 + /// 玩家作用域。 + private static OnlineScope PlayerScope(long playerId) { - var builder = new CapabilityHostBuilder(); - builder.UseService(new OnlineScope(TenantId, AppId, ServerId)); - builder.UseService(new OnlineEventPublisher(new GameFrameX.Foundation.Idempotency.InMemoryEventPublisher())); - builder.UseService(new AllowAllSocialGate()); - builder.AddModule(new SocialStubModule()); - builder.AddModule(new AssetsStubModule()); - return builder; + return new OnlineScope(OnlineCapabilityTestHarness.TenantId, OnlineCapabilityTestHarness.AppId, OnlineCapabilityTestHarness.ServerId, playerId); } /// - /// 社交 gate 放行桩(满足 online-matchmaking 对 online-social 的依赖面)。 + /// 静默后台调度(用例显式驱动 tick 语义)。 /// - private sealed class AllowAllSocialGate : IOnlineSocialGate + /// 组合选项。 + private static void QuietScheduler(CapabilityCompositionOptions options) { - /// - public Task EvaluateAsync(long tenantId, long appId, long fromPlayerId, long toPlayerId, OnlineSocialInteractionPurpose purpose, CancellationToken cancellationToken = default) - { - return Task.FromResult(OnlineSocialDecision.Allow()); - } + options.HostOptions.SchedulerIntervalMilliseconds = QuietSchedulerIntervalMilliseconds; } /// - /// online-social 拓扑桩(满足 Requires)。 + /// 全族组合并启动,经机制桥取匹配域契约面执行用例主体(结束恒停止撤桥)。 /// - private sealed class SocialStubModule : ICapabilityModule + /// 用例主体(入参:匹配域契约面、已启动的组合产物)。 + /// 异步任务。 + private static Task WithStartedMatchDomainAsync(Func body) { - /// - public CapabilityManifest Manifest { get; } = new("online-social", "1.0.0", Array.Empty()); - - public void Register(CapabilityRegistrationContext context) + return OnlineCapabilityTestHarness.WithRuntimeAsync(async runtime => { - } + await runtime.Host.StartAsync(); + Assert.True(CapabilityBridge.TryAcquire(out var lease)); + await body(lease.Contract, runtime); + }, configure: QuietScheduler); } /// - /// online-assets 拓扑桩(满足 online-match 的 Requires)。 + /// 匹配域桥发布:online-matchmaking 先于 online-match 装配(拓扑序),启动后桥契约面与共享面注册的域服务为同一实例。 /// - private sealed class AssetsStubModule : ICapabilityModule + [Fact] + public async Task Bridge_PublishesMatchDomainFace_AfterMatchmakingAssembly() { - /// - public CapabilityManifest Manifest { get; } = new("online-assets", "1.0.0", Array.Empty()); - - public void Register(CapabilityRegistrationContext context) + await WithStartedMatchDomainAsync((domain, runtime) => { - } + var order = runtime.Report.EnabledIdsInOrder.ToList(); + Assert.True(order.IndexOf("online-matchmaking") < order.IndexOf("online-match"), "online-matchmaking 必须先于 online-match 装配"); + Assert.Same(runtime.Host.Services.GetService(), domain); + return Task.CompletedTask; + }); } /// - /// 构造合法宿主选项。 + /// 桥生命周期:装配未启动时匹配域桥缺席;宿主停止自动撤桥——再取租约失败,旧租约访问契约面显式抛 + /// 。 /// - private static CapabilityHostOptions ValidOptions() + [Fact] + public async Task Bridge_LifecycleFollowsHostStartAndStop() { - return new CapabilityHostOptions { TenantId = TenantId, AppId = AppId, ServerId = ServerId }; - } + await OnlineCapabilityTestHarness.WithRuntimeAsync(async runtime => + { + Assert.False(CapabilityBridge.TryAcquire(out _)); - /// - /// 构造玩家作用域。 - /// - private static OnlineScope PlayerScope(long playerId) - { - return new OnlineScope(TenantId, AppId, ServerId, playerId); - } + await runtime.Host.StartAsync(); + Assert.True(CapabilityBridge.TryAcquire(out var lease)); + var through = await lease.Contract.GetStatusAsync(PlayerScope(PlayerOne)); + Assert.True(through.IsSuccess); - /// - /// 构造已装配 matchmaking + match 运行时的宿主(不自启:仅验证装配与域面行为)。 - /// - private static CapabilityHost BuildHost() - { - var builder = MinimalRoot(); - builder.AddOnlineMatchmakingRuntime(); - builder.AddOnlineMatchRuntime(); - return builder.Build(ValidOptions()); + await runtime.StopAsync(); + + Assert.False(CapabilityBridge.TryAcquire(out _)); + Assert.Throws(() => lease.Contract); + }, configure: QuietScheduler); } /// - /// 模块自装配把匹配域契约面注册回共享面,且 matchmaking 先于 match 装配(拓扑序)。 + /// 匹配能力缺席披露:排除 online-matchmaking 后匹配域服务不装配、匹配域桥不发布(不报错), + /// 对局桥照常发布;对局能力报告条目披露缺失功能。 /// [Fact] - public void Module_RegistersMatchDomainFace_AfterMatchmaking() + public async Task Bridge_WithoutMatchmaking_PublishesGameBridgeButNotMatchDomain() { - var host = BuildHost(); - - Assert.NotNull(host.Services.GetService()); - var order = host.Report.EnabledIdsInOrder.ToList(); - Assert.True(order.IndexOf("online-matchmaking") < order.IndexOf("online-match"), "online-matchmaking 必须先于 online-match 装配"); + await OnlineCapabilityTestHarness.WithRuntimeAsync(async runtime => + { + await runtime.Host.StartAsync(); + + Assert.DoesNotContain("online-matchmaking", runtime.Report.EnabledIdsInOrder); + Assert.Contains("online-match", runtime.Report.EnabledIdsInOrder); + Assert.Null(runtime.Host.Services.GetOptionalService()); + Assert.False(CapabilityBridge.TryAcquire(out _)); + Assert.True(CapabilityBridge.TryAcquire(out var gameLease)); + Assert.NotNull(gameLease.Contract); + + var matchEntry = Assert.Single(runtime.Report.Entries, candidate => candidate.Manifest.Id == "online-match"); + Assert.NotEmpty(matchEntry.AbsentFeatures); + }, configure: options => + { + QuietScheduler(options); + options.Filter = new CapabilityAssemblyFilter(disabledIds: new[] { "online-matchmaking" }); + }); } /// @@ -147,17 +168,17 @@ public void Module_RegistersMatchDomainFace_AfterMatchmaking() [Fact] public async Task Enqueue_SoloPlayer_QueuesWithZeroMatchId() { - var host = BuildHost(); - var domain = host.Services.GetService(); - - var enqueue = await domain.EnqueueAsync(PlayerScope(PlayerOne), 1, 1000); - - Assert.True(enqueue.IsSuccess); - Assert.Equal(0, enqueue.Data.MatchId); - var status = await domain.GetStatusAsync(PlayerScope(PlayerOne)); - Assert.True(status.Data.Queuing); - Assert.False(status.Data.Matched); - Assert.Equal(0, status.Data.MatchId); + await WithStartedMatchDomainAsync(async (domain, runtime) => + { + var enqueue = await domain.EnqueueAsync(PlayerScope(PlayerOne), 1, 1000); + + Assert.True(enqueue.IsSuccess); + Assert.Equal(0, enqueue.Data.MatchId); + var status = await domain.GetStatusAsync(PlayerScope(PlayerOne)); + Assert.True(status.Data.Queuing); + Assert.False(status.Data.Matched); + Assert.Equal(0, status.Data.MatchId); + }); } /// @@ -166,18 +187,18 @@ public async Task Enqueue_SoloPlayer_QueuesWithZeroMatchId() [Fact] public async Task Enqueue_SecondPlayer_InstantWindowPairsBoth() { - var host = BuildHost(); - var domain = host.Services.GetService(); - - await domain.EnqueueAsync(PlayerScope(PlayerOne), 1, 1000); - var second = await domain.EnqueueAsync(PlayerScope(PlayerTwo), 1, 1050); - - Assert.True(second.IsSuccess); - Assert.NotEqual(0, second.Data.MatchId); - var firstStatus = await domain.GetStatusAsync(PlayerScope(PlayerOne)); - Assert.False(firstStatus.Data.Queuing); - Assert.True(firstStatus.Data.Matched); - Assert.Equal(second.Data.MatchId, firstStatus.Data.MatchId); + await WithStartedMatchDomainAsync(async (domain, runtime) => + { + await domain.EnqueueAsync(PlayerScope(PlayerOne), 1, 1000); + var second = await domain.EnqueueAsync(PlayerScope(PlayerTwo), 1, 1050); + + Assert.True(second.IsSuccess); + Assert.NotEqual(0, second.Data.MatchId); + var firstStatus = await domain.GetStatusAsync(PlayerScope(PlayerOne)); + Assert.False(firstStatus.Data.Queuing); + Assert.True(firstStatus.Data.Matched); + Assert.Equal(second.Data.MatchId, firstStatus.Data.MatchId); + }); } /// @@ -186,14 +207,14 @@ public async Task Enqueue_SecondPlayer_InstantWindowPairsBoth() [Fact] public async Task Enqueue_DuplicatePlayer_Rejected() { - var host = BuildHost(); - var domain = host.Services.GetService(); - - await domain.EnqueueAsync(PlayerScope(PlayerOne), 1, 1000); - var duplicate = await domain.EnqueueAsync(PlayerScope(PlayerOne), 1, 1000); + await WithStartedMatchDomainAsync(async (domain, runtime) => + { + await domain.EnqueueAsync(PlayerScope(PlayerOne), 1, 1000); + var duplicate = await domain.EnqueueAsync(PlayerScope(PlayerOne), 1, 1000); - Assert.False(duplicate.IsSuccess); - Assert.Equal(OnlineErrorCode.DuplicateRequest, duplicate.Code); + Assert.False(duplicate.IsSuccess); + Assert.Equal(OnlineErrorCode.DuplicateRequest, duplicate.Code); + }); } /// @@ -202,15 +223,15 @@ public async Task Enqueue_DuplicatePlayer_Rejected() [Fact] public async Task Enqueue_ModePartitionsAndRatingWindow_PartitionPools() { - var host = BuildHost(); - var domain = host.Services.GetService(); - - await domain.EnqueueAsync(PlayerScope(PlayerOne), 1, 1000); - var otherMode = await domain.EnqueueAsync(PlayerScope(PlayerTwo), 2, 1050); - Assert.Equal(0, otherMode.Data.MatchId); + await WithStartedMatchDomainAsync(async (domain, runtime) => + { + await domain.EnqueueAsync(PlayerScope(PlayerOne), 1, 1000); + var otherMode = await domain.EnqueueAsync(PlayerScope(PlayerTwo), 2, 1050); + Assert.Equal(0, otherMode.Data.MatchId); - var third = await domain.EnqueueAsync(PlayerScope(PlayerThree), 1, 5000); - Assert.Equal(0, third.Data.MatchId); + var third = await domain.EnqueueAsync(PlayerScope(PlayerThree), 1, 5000); + Assert.Equal(0, third.Data.MatchId); + }); } /// @@ -219,16 +240,16 @@ public async Task Enqueue_ModePartitionsAndRatingWindow_PartitionPools() [Fact] public async Task Enqueue_InvalidModeOrRating_Rejected() { - var host = BuildHost(); - var domain = host.Services.GetService(); - - var badMode = await domain.EnqueueAsync(PlayerScope(PlayerOne), 9, 1000); - Assert.False(badMode.IsSuccess); - Assert.Equal(OnlineErrorCode.ParameterInvalid, badMode.Code); - - var badRating = await domain.EnqueueAsync(PlayerScope(PlayerOne), 1, 0); - Assert.False(badRating.IsSuccess); - Assert.Equal(OnlineErrorCode.ParameterInvalid, badRating.Code); + await WithStartedMatchDomainAsync(async (domain, runtime) => + { + var badMode = await domain.EnqueueAsync(PlayerScope(PlayerOne), 9, 1000); + Assert.False(badMode.IsSuccess); + Assert.Equal(OnlineErrorCode.ParameterInvalid, badMode.Code); + + var badRating = await domain.EnqueueAsync(PlayerScope(PlayerOne), 1, 0); + Assert.False(badRating.IsSuccess); + Assert.Equal(OnlineErrorCode.ParameterInvalid, badRating.Code); + }); } /// @@ -237,17 +258,17 @@ public async Task Enqueue_InvalidModeOrRating_Rejected() [Fact] public async Task Cancel_WhileQueued_Succeeds_AndWithoutTicket_ComesBackNotCancelled() { - var host = BuildHost(); - var domain = host.Services.GetService(); - - await domain.EnqueueAsync(PlayerScope(PlayerOne), 1, 1000); - var cancelled = await domain.CancelAsync(PlayerScope(PlayerOne)); - Assert.True(cancelled.IsSuccess); - Assert.True(cancelled.Data.Cancelled); - - var noTicket = await domain.CancelAsync(PlayerScope(PlayerTwo)); - Assert.True(noTicket.IsSuccess); - Assert.False(noTicket.Data.Cancelled); + await WithStartedMatchDomainAsync(async (domain, runtime) => + { + await domain.EnqueueAsync(PlayerScope(PlayerOne), 1, 1000); + var cancelled = await domain.CancelAsync(PlayerScope(PlayerOne)); + Assert.True(cancelled.IsSuccess); + Assert.True(cancelled.Data.Cancelled); + + var noTicket = await domain.CancelAsync(PlayerScope(PlayerTwo)); + Assert.True(noTicket.IsSuccess); + Assert.False(noTicket.Data.Cancelled); + }); } /// @@ -256,38 +277,39 @@ public async Task Cancel_WhileQueued_Succeeds_AndWithoutTicket_ComesBackNotCance [Fact] public async Task Cancel_AfterMatch_ComesBackTerminalReceipt() { - var host = BuildHost(); - var domain = host.Services.GetService(); - - await domain.EnqueueAsync(PlayerScope(PlayerOne), 1, 1000); - await domain.EnqueueAsync(PlayerScope(PlayerTwo), 1, 1050); - var lateCancel = await domain.CancelAsync(PlayerScope(PlayerOne)); + await WithStartedMatchDomainAsync(async (domain, runtime) => + { + await domain.EnqueueAsync(PlayerScope(PlayerOne), 1, 1000); + await domain.EnqueueAsync(PlayerScope(PlayerTwo), 1, 1050); + var lateCancel = await domain.CancelAsync(PlayerScope(PlayerOne)); - Assert.True(lateCancel.IsSuccess); - Assert.False(lateCancel.Data.Cancelled); + Assert.True(lateCancel.IsSuccess); + Assert.False(lateCancel.Data.Cancelled); + }); } /// - /// R10:票据 TTL 过期(注入时刻推进扫描)→ Status=(false,0),取消回未取消回执。 + /// R10:票据 TTL 过期(注入时刻推进撮合扫描)→ Status=(false,0),取消回未取消回执。 /// [Fact] public async Task Status_AfterTicketExpiry_NotQueuingZeroMatch() { - var host = BuildHost(); - var domain = host.Services.GetService(); - var coordinator = host.Services.GetService(); + await WithStartedMatchDomainAsync(async (domain, runtime) => + { + var coordinator = runtime.Host.Services.GetService(); - await domain.EnqueueAsync(PlayerScope(PlayerOne), 1, 1000); - var later = DateTimeOffset.UtcNow.AddSeconds(400).ToUnixTimeMilliseconds(); - await coordinator.RunOnceAsync(TenantId, AppId, later); + await domain.EnqueueAsync(PlayerScope(PlayerOne), 1, 1000); + var later = DateTimeOffset.UtcNow.AddSeconds(400).ToUnixTimeMilliseconds(); + await coordinator.RunOnceAsync(OnlineCapabilityTestHarness.TenantId, OnlineCapabilityTestHarness.AppId, later); - var status = await domain.GetStatusAsync(PlayerScope(PlayerOne)); - Assert.False(status.Data.Queuing); - Assert.False(status.Data.Matched); - Assert.Equal(0, status.Data.MatchId); + var status = await domain.GetStatusAsync(PlayerScope(PlayerOne)); + Assert.False(status.Data.Queuing); + Assert.False(status.Data.Matched); + Assert.Equal(0, status.Data.MatchId); - var cancel = await domain.CancelAsync(PlayerScope(PlayerOne)); - Assert.False(cancel.Data.Cancelled); + var cancel = await domain.CancelAsync(PlayerScope(PlayerOne)); + Assert.False(cancel.Data.Cancelled); + }); } /// @@ -296,31 +318,31 @@ public async Task Status_AfterTicketExpiry_NotQueuingZeroMatch() [Fact] public async Task Enter_BothPlayers_ReachSameRunningMatch() { - var host = BuildHost(); - var domain = host.Services.GetService(); - var runtime = host.Services.GetService(); - - await domain.EnqueueAsync(PlayerScope(PlayerOne), 1, 1000); - var second = await domain.EnqueueAsync(PlayerScope(PlayerTwo), 1, 1050); - var matchId = second.Data.MatchId; - - var firstEntry = await domain.EnterAsync(PlayerScope(PlayerOne), matchId); - var secondEntry = await domain.EnterAsync(PlayerScope(PlayerTwo), matchId); - - Assert.True(firstEntry.IsSuccess, firstEntry.Message); - Assert.True(secondEntry.IsSuccess, secondEntry.Message); - Assert.Equal(matchId, firstEntry.Data.MatchId); - Assert.Equal(matchId, secondEntry.Data.MatchId); - Assert.True(firstEntry.Data.Started); - Assert.True(secondEntry.Data.Started); - Assert.Contains(PlayerOne, firstEntry.Data.PlayerIds); - Assert.Contains(PlayerTwo, secondEntry.Data.PlayerIds); - - var actors = runtime.SnapshotActors(); - var actor = Assert.Single(actors); - var snapshot = actor.GetSnapshot(); - Assert.Equal(OnlineMatchState.Running, snapshot.State); - Assert.Equal(2, snapshot.Members.Count(member => member != null)); + await WithStartedMatchDomainAsync(async (domain, runtime) => + { + var matchRuntime = runtime.Host.Services.GetService(); + + await domain.EnqueueAsync(PlayerScope(PlayerOne), 1, 1000); + var second = await domain.EnqueueAsync(PlayerScope(PlayerTwo), 1, 1050); + var matchId = second.Data.MatchId; + + var firstEntry = await domain.EnterAsync(PlayerScope(PlayerOne), matchId); + var secondEntry = await domain.EnterAsync(PlayerScope(PlayerTwo), matchId); + + Assert.True(firstEntry.IsSuccess, firstEntry.Message); + Assert.True(secondEntry.IsSuccess, secondEntry.Message); + Assert.Equal(matchId, firstEntry.Data.MatchId); + Assert.Equal(matchId, secondEntry.Data.MatchId); + Assert.True(firstEntry.Data.Started); + Assert.True(secondEntry.Data.Started); + Assert.Contains(PlayerOne, firstEntry.Data.PlayerIds); + Assert.Contains(PlayerTwo, secondEntry.Data.PlayerIds); + + var actor = Assert.Single(matchRuntime.SnapshotActors()); + var snapshot = actor.GetSnapshot(); + Assert.Equal(OnlineMatchState.Running, snapshot.State); + Assert.Equal(2, snapshot.Members.Count(member => member != null)); + }); } /// @@ -329,17 +351,18 @@ public async Task Enter_BothPlayers_ReachSameRunningMatch() [Fact] public async Task Enter_Repeatedly_ReusesSameMatch() { - var host = BuildHost(); - var domain = host.Services.GetService(); - var runtime = host.Services.GetService(); + await WithStartedMatchDomainAsync(async (domain, runtime) => + { + var matchRuntime = runtime.Host.Services.GetService(); - await domain.EnqueueAsync(PlayerScope(PlayerOne), 1, 1000); - var second = await domain.EnqueueAsync(PlayerScope(PlayerTwo), 1, 1050); + await domain.EnqueueAsync(PlayerScope(PlayerOne), 1, 1000); + var second = await domain.EnqueueAsync(PlayerScope(PlayerTwo), 1, 1050); - await domain.EnterAsync(PlayerScope(PlayerOne), second.Data.MatchId); - await domain.EnterAsync(PlayerScope(PlayerOne), second.Data.MatchId); + await domain.EnterAsync(PlayerScope(PlayerOne), second.Data.MatchId); + await domain.EnterAsync(PlayerScope(PlayerOne), second.Data.MatchId); - Assert.Single(runtime.SnapshotActors()); + Assert.Single(matchRuntime.SnapshotActors()); + }); } /// @@ -348,18 +371,18 @@ public async Task Enter_Repeatedly_ReusesSameMatch() [Fact] public async Task Enter_UnknownOrForeign_Rejected() { - var host = BuildHost(); - var domain = host.Services.GetService(); - - var unknown = await domain.EnterAsync(PlayerScope(PlayerOne), 424242); - Assert.False(unknown.IsSuccess); - Assert.Equal(OnlineErrorCode.ResourceNotFound, unknown.Code); - - await domain.EnqueueAsync(PlayerScope(PlayerOne), 1, 1000); - var second = await domain.EnqueueAsync(PlayerScope(PlayerTwo), 1, 1050); - var foreign = await domain.EnterAsync(PlayerScope(PlayerThree), second.Data.MatchId); - Assert.False(foreign.IsSuccess); - Assert.Equal(OnlineErrorCode.ScopeDenied, foreign.Code); + await WithStartedMatchDomainAsync(async (domain, runtime) => + { + var unknown = await domain.EnterAsync(PlayerScope(PlayerOne), 424242); + Assert.False(unknown.IsSuccess); + Assert.Equal(OnlineErrorCode.ResourceNotFound, unknown.Code); + + await domain.EnqueueAsync(PlayerScope(PlayerOne), 1, 1000); + var second = await domain.EnqueueAsync(PlayerScope(PlayerTwo), 1, 1050); + var foreign = await domain.EnterAsync(PlayerScope(PlayerThree), second.Data.MatchId); + Assert.False(foreign.IsSuccess); + Assert.Equal(OnlineErrorCode.ScopeDenied, foreign.Code); + }); } /// @@ -368,24 +391,25 @@ public async Task Enter_UnknownOrForeign_Rejected() [Fact] public async Task ResolveAssignment_AfterMatch_ReturnsNumericAndPlayers_Idempotently() { - var host = BuildHost(); - var domain = host.Services.GetService(); - var store = host.Services.GetService(); - - await domain.EnqueueAsync(PlayerScope(PlayerOne), 1, 1000); - var second = await domain.EnqueueAsync(PlayerScope(PlayerTwo), 1, 1050); - var ticket = await store.FindLatestByPlayerAsync(TenantId, AppId, PlayerOne); - Assert.NotNull(ticket); - Assert.False(string.IsNullOrEmpty(ticket.AssignmentId)); - - var resolved = await domain.ResolveAssignmentAsync(TenantId, AppId, ticket.AssignmentId); - Assert.True(resolved.IsSuccess, resolved.Message); - Assert.Equal(second.Data.MatchId, resolved.Data.MatchId); - Assert.Contains(PlayerOne, resolved.Data.PlayerIds); - Assert.Contains(PlayerTwo, resolved.Data.PlayerIds); - - var again = await domain.ResolveAssignmentAsync(TenantId, AppId, ticket.AssignmentId); - Assert.Equal(resolved.Data.MatchId, again.Data.MatchId); + await WithStartedMatchDomainAsync(async (domain, runtime) => + { + var store = runtime.Host.Services.GetService(); + + await domain.EnqueueAsync(PlayerScope(PlayerOne), 1, 1000); + var second = await domain.EnqueueAsync(PlayerScope(PlayerTwo), 1, 1050); + var ticket = await store.FindLatestByPlayerAsync(OnlineCapabilityTestHarness.TenantId, OnlineCapabilityTestHarness.AppId, PlayerOne); + Assert.NotNull(ticket); + Assert.False(string.IsNullOrEmpty(ticket.AssignmentId)); + + var resolved = await domain.ResolveAssignmentAsync(OnlineCapabilityTestHarness.TenantId, OnlineCapabilityTestHarness.AppId, ticket.AssignmentId); + Assert.True(resolved.IsSuccess, resolved.Message); + Assert.Equal(second.Data.MatchId, resolved.Data.MatchId); + Assert.Contains(PlayerOne, resolved.Data.PlayerIds); + Assert.Contains(PlayerTwo, resolved.Data.PlayerIds); + + var again = await domain.ResolveAssignmentAsync(OnlineCapabilityTestHarness.TenantId, OnlineCapabilityTestHarness.AppId, ticket.AssignmentId); + Assert.Equal(resolved.Data.MatchId, again.Data.MatchId); + }); } /// @@ -394,43 +418,17 @@ public async Task ResolveAssignment_AfterMatch_ReturnsNumericAndPlayers_Idempote [Fact] public async Task Store_FindLatestByPlayer_ReturnsMatchedTicket() { - var host = BuildHost(); - var domain = host.Services.GetService(); - var store = host.Services.GetService(); + await WithStartedMatchDomainAsync(async (domain, runtime) => + { + var store = runtime.Host.Services.GetService(); - await domain.EnqueueAsync(PlayerScope(PlayerOne), 1, 1000); - await domain.EnqueueAsync(PlayerScope(PlayerTwo), 1, 1050); + await domain.EnqueueAsync(PlayerScope(PlayerOne), 1, 1000); + await domain.EnqueueAsync(PlayerScope(PlayerTwo), 1, 1050); - var ticket = await store.FindLatestByPlayerAsync(TenantId, AppId, PlayerTwo); - Assert.NotNull(ticket); - Assert.Equal(OnlineMatchTicketState.Matched, ticket.State); - } - - /// - /// holder 生命周期:发布生成有效租约(可作状态查询);再发布吊销旧租约;撤销后 Current 为空且旧租约显式失败。 - /// - [Fact] - public async Task Holder_PublishReplaceRetract_LeaseLifecycle() - { - var host = BuildHost(); - var domain = host.Services.GetService(); - var scope = new OnlineScope(TenantId, AppId, ServerId); - - var first = OnlineMatchDomainBridgeHolder.Publish(domain, scope); - var through = await first.GetStatusAsync(PlayerScope(PlayerOne)); - Assert.True(through.IsSuccess); - - var second = OnlineMatchDomainBridgeHolder.Publish(domain, scope); - var staleFirst = await first.GetStatusAsync(PlayerScope(PlayerOne)); - Assert.False(staleFirst.IsSuccess); - Assert.Equal(OnlineErrorCode.DependencyUnavailable, staleFirst.Code); - - OnlineMatchDomainBridgeHolder.Retract(); - Assert.Null(OnlineMatchDomainBridgeHolder.Current); - var staleSecond = await second.GetStatusAsync(PlayerScope(PlayerOne)); - Assert.False(staleSecond.IsSuccess); - Assert.Equal(OnlineErrorCode.DependencyUnavailable, staleSecond.Code); - Assert.False(OnlineMatchDomainBridgeHolder.IsPublished); + var ticket = await store.FindLatestByPlayerAsync(OnlineCapabilityTestHarness.TenantId, OnlineCapabilityTestHarness.AppId, PlayerTwo); + Assert.NotNull(ticket); + Assert.Equal(OnlineMatchTicketState.Matched, ticket.State); + }); } /// @@ -439,23 +437,23 @@ public async Task Holder_PublishReplaceRetract_LeaseLifecycle() [Fact] public async Task Enter_ConcurrentlyFromBothPlayers_BothSucceed() { - var host = BuildHost(); - var domain = host.Services.GetService(); - - await domain.EnqueueAsync(PlayerScope(PlayerOne), 1, 1000); - var second = await domain.EnqueueAsync(PlayerScope(PlayerTwo), 1, 1050); - var matchId = second.Data.MatchId; - - var firstEnter = Task.Run(() => domain.EnterAsync(PlayerScope(PlayerOne), matchId)); - var secondEnter = Task.Run(() => domain.EnterAsync(PlayerScope(PlayerTwo), matchId)); - var results = await Task.WhenAll(firstEnter, secondEnter); - - Assert.True(results[0].IsSuccess, results[0].Message); - Assert.True(results[1].IsSuccess, results[1].Message); - Assert.True(results[0].Data.Started); - Assert.True(results[1].Data.Started); - Assert.Equal(matchId, results[0].Data.MatchId); - Assert.Equal(matchId, results[1].Data.MatchId); + await WithStartedMatchDomainAsync(async (domain, runtime) => + { + await domain.EnqueueAsync(PlayerScope(PlayerOne), 1, 1000); + var second = await domain.EnqueueAsync(PlayerScope(PlayerTwo), 1, 1050); + var matchId = second.Data.MatchId; + + var firstEnter = Task.Run(() => domain.EnterAsync(PlayerScope(PlayerOne), matchId)); + var secondEnter = Task.Run(() => domain.EnterAsync(PlayerScope(PlayerTwo), matchId)); + var results = await Task.WhenAll(firstEnter, secondEnter); + + Assert.True(results[0].IsSuccess, results[0].Message); + Assert.True(results[1].IsSuccess, results[1].Message); + Assert.True(results[0].Data.Started); + Assert.True(results[1].Data.Started); + Assert.Equal(matchId, results[0].Data.MatchId); + Assert.Equal(matchId, results[1].Data.MatchId); + }); } /// @@ -464,11 +462,13 @@ public async Task Enter_ConcurrentlyFromBothPlayers_BothSucceed() [Fact] public async Task Enqueue_WithoutPlayerPosition_Rejected() { - var host = BuildHost(); - var domain = host.Services.GetService(); - var rejected = await domain.EnqueueAsync(new OnlineScope(TenantId, AppId, ServerId), 1, 1000); - Assert.False(rejected.IsSuccess); - Assert.Equal(OnlineErrorCode.ParameterInvalid, rejected.Code); + await WithStartedMatchDomainAsync(async (domain, runtime) => + { + var scope = new OnlineScope(OnlineCapabilityTestHarness.TenantId, OnlineCapabilityTestHarness.AppId, OnlineCapabilityTestHarness.ServerId); + var rejected = await domain.EnqueueAsync(scope, 1, 1000); + Assert.False(rejected.IsSuccess); + Assert.Equal(OnlineErrorCode.ParameterInvalid, rejected.Code); + }); } } } diff --git a/Tests/GameFrameX.Tests/Online/OnlineMatchDomainResumeTests.cs b/Tests/GameFrameX.Tests/Online/OnlineMatchDomainResumeTests.cs index feaa624ad..cdae4e9b4 100644 --- a/Tests/GameFrameX.Tests/Online/OnlineMatchDomainResumeTests.cs +++ b/Tests/GameFrameX.Tests/Online/OnlineMatchDomainResumeTests.cs @@ -141,12 +141,17 @@ public async Task TryResume_EndedMatch_ShouldFailWithStateEnded() /// private sealed class Harness { - public Harness(OnlineMatchRuntimeOptions options = null) + public Harness() : this(new OnlineMatchRuntimeOptions()) + { + } + + public Harness(OnlineMatchRuntimeOptions options) { Publisher = new OnlineEventRecorder(); TicketStore = new InMemoryOnlineMatchTicketStore(); - Tickets = new OnlineMatchTicketService(TicketStore, Publisher); - Coordinator = new OnlineMatchmakerCoordinator(TicketStore, Tickets, Publisher); + var matchmakerOptions = new OnlineMatchmakerOptions(); + Tickets = new OnlineMatchTicketService(TicketStore, Publisher, matchmakerOptions); + Coordinator = new OnlineMatchmakerCoordinator(TicketStore, Tickets, Publisher, matchmakerOptions); Runtime = new OnlineMatchRuntime(new InMemoryOnlineMatchActorStore(), Publisher, options); var game = new OnlineRockPaperScissorsGame(); Runtime.RegisterGame(game); diff --git a/Tests/GameFrameX.Tests/Online/OnlineMatchGameBridgeServiceTests.cs b/Tests/GameFrameX.Tests/Online/OnlineMatchGameBridgeServiceTests.cs index fbaa20ef3..a4dafca21 100644 --- a/Tests/GameFrameX.Tests/Online/OnlineMatchGameBridgeServiceTests.cs +++ b/Tests/GameFrameX.Tests/Online/OnlineMatchGameBridgeServiceTests.cs @@ -268,7 +268,7 @@ private sealed class Harness public Harness() { Publisher = new OnlineEventRecorder(); - Runtime = new OnlineMatchRuntime(new InMemoryOnlineMatchActorStore(), Publisher); + Runtime = new OnlineMatchRuntime(new InMemoryOnlineMatchActorStore(), Publisher, new OnlineMatchRuntimeOptions()); Game = new OnlineRockPaperScissorsGame(); Runtime.RegisterGame(Game); Settlement = new OnlineMatchSettlementService(Runtime, new InMemoryOnlineMatchResultStore(), Publisher); diff --git a/Tests/GameFrameX.Tests/Online/OnlineMatchQueueObserverTests.cs b/Tests/GameFrameX.Tests/Online/OnlineMatchQueueObserverTests.cs index 0a9679c48..0d3d7de6b 100644 --- a/Tests/GameFrameX.Tests/Online/OnlineMatchQueueObserverTests.cs +++ b/Tests/GameFrameX.Tests/Online/OnlineMatchQueueObserverTests.cs @@ -158,8 +158,9 @@ private static Fixture CreateFixture() { var store = new InMemoryOnlineMatchTicketStore(); var recorder = new OnlineEventRecorder(); - var tickets = new OnlineMatchTicketService(store, recorder); - var coordinator = new OnlineMatchmakerCoordinator(store, tickets, recorder); + var matchmakerOptions = new OnlineMatchmakerOptions(); + var tickets = new OnlineMatchTicketService(store, recorder, matchmakerOptions); + var coordinator = new OnlineMatchmakerCoordinator(store, tickets, recorder, matchmakerOptions); return new Fixture(new OnlineMatchQueueObserver(store), tickets, coordinator); } diff --git a/Tests/GameFrameX.Tests/Online/OnlineMatchRuntimeTests.cs b/Tests/GameFrameX.Tests/Online/OnlineMatchRuntimeTests.cs index f6175bc5b..6a7fbb77a 100644 --- a/Tests/GameFrameX.Tests/Online/OnlineMatchRuntimeTests.cs +++ b/Tests/GameFrameX.Tests/Online/OnlineMatchRuntimeTests.cs @@ -247,10 +247,17 @@ private static OnlineMatchAssignment BuildAssignment(params long[] playerIds) private sealed class Harness { /// - /// 初始化测试基座。 + /// 以包内默认运行参数初始化测试基座。 /// - /// 运行参数(可空)。 - public Harness(OnlineMatchRuntimeOptions options = null) + public Harness() : this(new OnlineMatchRuntimeOptions()) + { + } + + /// + /// 以指定运行参数初始化测试基座。 + /// + /// 运行参数。 + public Harness(OnlineMatchRuntimeOptions options) { Store = new InMemoryOnlineMatchActorStore(); Publisher = new OnlineEventRecorder(); diff --git a/Tests/GameFrameX.Tests/Online/OnlineMatchSettledLeaderboardProjectorTests.cs b/Tests/GameFrameX.Tests/Online/OnlineMatchSettledLeaderboardProjectorTests.cs index 77d1f24e8..86af1283f 100644 --- a/Tests/GameFrameX.Tests/Online/OnlineMatchSettledLeaderboardProjectorTests.cs +++ b/Tests/GameFrameX.Tests/Online/OnlineMatchSettledLeaderboardProjectorTests.cs @@ -4,56 +4,63 @@ // 均受中华人民共和国及相关国际法律法规保护。 // are protected by the laws of the PRC and relevant international regulations. // 使用本项目须严格遵守相关法律法规与开源许可证之规定。 -// Usage of this project must strictly comply with relevant laws, regulations, and open-source licenses. +// 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 文件。 +// 完整许可证文本请参见源代码根目录的 LICENSE 文件。 // please refer to the LICENSE file at the root 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 legal rights and interests of others, as prohibited by laws and regulations! +// or infringe the legal rights and interests of others as prohibited by laws and regulations! // 因基于本项目二次开发所产生的一切法律纠纷与责任, -// Any legal disputes or liabilities arising from secondary development based on this project -// 本项目组织与贡献者概不承担。 +// 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 +// GitHub Repository: https://github.com/GameFrameX // Gitee 仓库:https://gitee.com/GameFrameX -// Gitee Repository: https://gitee.com/GameFrameX +// Gitee Repository: https://gitee.com/GameFrameX // CNB 仓库:https://www.cnb.cool/GameFrameX -// CNB Repository: https://www.cnb.cool/GameFrameX +// CNB Repository: https://www.cnb.cool/GameFrameX // 官方文档:https://gameframex.doc.alianblank.com/ -// Official documentation: https://gameframex.doc.alianblank.com/ +// Official Docs: https://gameframex.doc.alianblank.com/ // ========================================================================================== + +using System; +using System.Collections.Generic; +using System.Threading; using System.Threading.Tasks; -using GameFrameX.Foundation.Idempotency; using GameFrameX.Capability.Hosting.Abstractions; -using GameFrameX.Capability.Online.Abstractions.Contracts; +using GameFrameX.Capability.Hosting.Core; using GameFrameX.Capability.Online.Abstractions.Events; using GameFrameX.Capability.Online.Abstractions.Idempotency; +using GameFrameX.Capability.Online.Abstractions.Scope; +using GameFrameX.Capability.Online.Assets; using GameFrameX.Capability.Online.Competition; using GameFrameX.Capability.Online.Competition.Leaderboard; using GameFrameX.Capability.Online.Match; -using GameFrameX.Capability.Online.Abstractions.Scope; -using GameFrameX.Capability.Online.Assets; +using GameFrameX.Foundation.Idempotency; using Xunit; namespace GameFrameX.Tests.Online { /// - /// Match.Settled → 排行榜投影生产接线测试(C245 Batch B): - /// 订阅消费(EventId 去重 + 幂等键回放双保险)、失败待重试 / 重投补齐、结果不存在终态移除、模块装配探测。 + /// Match.Settled → 排行榜投影生产接线测试(C245 Batch B → C294 机制化): + /// 投影器直接构造验证订阅消费(EventId 去重 + 幂等键回放双保险)、失败待重试 / 重投补齐、结果不存在终态移除; + /// 真实族组合验证 Competition 按 Settings 投影榜 id 装配投影器、Activate 预建目标榜单、 + /// 结算事件经机制事件总线(主题 online.match.settled)订阅链真实落榜,以及投影缺席形态与依赖声明。 /// + [Collection(CapabilityRuntimeCollection.Name)] public class OnlineMatchSettledLeaderboardProjectorTests { - /// 租户标识。 + /// 租户标识(直接构造基座用)。 private const long TenantId = 1; - /// App 标识。 + /// App 标识(直接构造基座用)。 private const long AppId = 10; - /// 区服标识。 + /// 区服标识(直接构造基座用)。 private const long ServerId = 100; /// 玩家一。 @@ -68,6 +75,12 @@ public class OnlineMatchSettledLeaderboardProjectorTests /// 目标榜单标识。 private const string BoardId = "board-match-settled"; + /// 静默调度间隔(毫秒):tick 一律由用例显式驱动,后台循环在用例时长内不触发。 + private const int QuietSchedulerIntervalMilliseconds = 3600000; + + /// Competition 投影配置段(Settings 子对象:目标榜单 = )。 + private const string ProjectionSettings = "{\"online-competition\":{\"LeaderboardProjection\":{\"MatchSettledBoardId\":\"" + BoardId + "\"}}}"; + /// /// 验证订阅半边:结算事件驱动投影落榜(Top 查询可见分数),同 EventId 重复投递被去重。 /// @@ -174,78 +187,135 @@ public async Task HandleEventAsync_MalformedPayload_ShouldSwallowWithoutThrowing } /// - /// 验证模块装配半边一:组合根预置投影选项 + 结果存储 + 幂等服务 + 事件总线 → 投影器装配, - /// 结算事件经总线订阅链真实落榜,重试 tick job 注册。 + /// 验证模块装配半边一(真实族组合 match + competition):Settings 配置投影榜 id → 投影器装配; + /// Activate 预建目标榜单(启动后 Top 查询即成功);结算事件经机制事件总线订阅链(主题 online.match.settled)真实落榜。 /// [Fact] - public async Task ModuleRegister_WithProjectionOptions_ShouldAssembleProjectorAndSubscribe() + public async Task Compose_WithProjectionBoard_PreCreatesBoardAndProjectsSettledEventThroughBus() { - var eventBus = new InMemoryEventPublisher(); - var resultStore = new InMemoryOnlineMatchResultStore(); - var coordinator = new IdempotencyCoordinator(new InMemoryIdempotencyStore(), new Harness.StepClock(), new IdempotencyOptions { ConcurrentWaitTimeoutMilliseconds = 1 }); - var idempotency = new OnlineIdempotencyService(coordinator, null); - var context = new CapabilityRegistrationContext(new CapabilityManifest("test-competition", "1.0.0", new string[] { }, new string[] { })); - context.AddService(eventBus); - context.AddService(new OnlineEventPublisher(eventBus)); - context.AddService(idempotency); - context.AddService(resultStore); - context.AddService(new OnlineLeaderboardProjectionOptions { MatchSettledBoardId = BoardId }); - - new OnlineCompetitionCapabilityModule().Register(context); - - var projector = context.Services.GetService(); - Assert.NotNull(projector); - Assert.Contains(context.TickJobs, job => job.Name.EndsWith("leaderboard-projection-retry", System.StringComparison.Ordinal)); - - var leaderboards = context.Services.GetService(); - var created = await leaderboards.CreateAsync(new OnlineScope(TenantId, AppId, ServerId), new OnlineLeaderboardCreateRequest { LeaderboardId = BoardId, ScoreUpdatePolicy = OnlineLeaderboardScoreUpdatePolicy.Sum }, Now); - Assert.True(created.IsSuccess, created.Message); - - await CommitResultAsync(resultStore, "match-mod", "mrs-mod", (PlayerOne, 42)); - var settledEvent = OnlineMatchRuntimeEvents.CreateMatchSettled(TenantId, AppId, ServerId, "mrs-mod", "match-mod", OnlineRockPaperScissorsGame.MatchMode, 1, OnlineMatchState.Completed.ToString(), Now, new (long PlayerId, int Rank, bool IsWinner, long Score, int RewardCount)[] { (PlayerOne, 1, true, 42, 0) }); - await eventBus.PublishAsync(OnlineEventEnvelopeMapper.ToEnvelope(settledEvent)); - - var entry = await context.Services.GetService().FindEntryAsync(TenantId, AppId, BoardId, PlayerOne); - Assert.NotNull(entry); - Assert.Equal(42, entry.Score); + await OnlineCapabilityTestHarness.WithRuntimeAsync(async runtime => + { + await runtime.Host.StartAsync(); + var services = runtime.Host.Services; + var projector = services.GetService(); + Assert.NotNull(projector); + + var hostScope = new OnlineScope(OnlineCapabilityTestHarness.TenantId, OnlineCapabilityTestHarness.AppId, OnlineCapabilityTestHarness.ServerId); + var probe = await services.GetService().GetTopAsync(hostScope, new OnlineLeaderboardTopQuery { LeaderboardId = BoardId, Count = 1, }); + Assert.True(probe.IsSuccess, probe.Message); + + await CommitResultAsync(services.GetService(), OnlineCapabilityTestHarness.TenantId, OnlineCapabilityTestHarness.AppId, OnlineCapabilityTestHarness.ServerId, "match-mod", "mrs-mod", (PlayerOne, 42)); + var settledEvent = OnlineMatchRuntimeEvents.CreateMatchSettled(OnlineCapabilityTestHarness.TenantId, OnlineCapabilityTestHarness.AppId, OnlineCapabilityTestHarness.ServerId, "mrs-mod", "match-mod", OnlineRockPaperScissorsGame.MatchMode, 1, OnlineMatchState.Completed.ToString(), Now, new (long PlayerId, int Rank, bool IsWinner, long Score, int RewardCount)[] { (PlayerOne, 1, true, 42, 0) }); + Assert.Equal("online.match.settled", settledEvent.EventType); + + await services.GetService().PublishAsync(settledEvent); + + var entry = await services.GetService().FindEntryAsync(OnlineCapabilityTestHarness.TenantId, OnlineCapabilityTestHarness.AppId, BoardId, PlayerOne); + Assert.NotNull(entry); + Assert.Equal(42, entry.Score); + Assert.Empty(projector.QueryStatus()); + }, ProjectionSettings, options => ConfigureComposition(options, null, "online-match", "online-competition")); } /// - /// 验证模块装配半边二:未预置投影选项(或 board id 空白)→ 不装配投影器也不报错。 + /// 验证模块装配半边二:未配置投影榜 id 或 id 空白 → 不装配投影器、不注册重投 tick job(一轮 tick 比投影形态少执行一个 job),也不报错。 /// [Fact] - public void ModuleRegister_WithoutProjectionOptions_ShouldSkipProjector() + public async Task Compose_WithoutProjectionBoard_SkipsProjectorAndRetryJob() { - var context = new CapabilityRegistrationContext(new CapabilityManifest("test-competition", "1.0.0", new string[] { }, new string[] { })); - context.AddService(new InMemoryEventPublisher()); - context.AddService(new InMemoryOnlineMatchResultStore()); + var withProjection = await RunOneTickRoundAsync(ProjectionSettings, true); + var withoutSettings = await RunOneTickRoundAsync(null, false); + var blankBoard = await RunOneTickRoundAsync("{\"online-competition\":{\"LeaderboardProjection\":{\"MatchSettledBoardId\":\" \"}}}", false); - context.AddService(new OnlineEventPublisher(new InMemoryEventPublisher())); - new OnlineCompetitionCapabilityModule().Register(context); + Assert.Equal(withProjection - 1, withoutSettings); + Assert.Equal(withProjection - 1, blankBoard); + } - Assert.False(context.Services.TryGetService(out OnlineMatchSettledLeaderboardProjector _)); - Assert.DoesNotContain(context.TickJobs, job => job.Name.EndsWith("leaderboard-projection-retry", System.StringComparison.Ordinal)); + /// + /// 验证可选依赖缺席:配置了投影榜 id 但 online-match 未装配(对局结果存储缺席)→ 不装配投影器、不报错。 + /// + [Fact] + public async Task Compose_WithoutMatch_SkipsProjectorEvenWithProjectionBoard() + { + await OnlineCapabilityTestHarness.WithRuntimeAsync(async runtime => + { + await runtime.Host.StartAsync(); + + Assert.DoesNotContain("online-match", runtime.Report.EnabledIdsInOrder); + Assert.Null(runtime.Host.Services.GetOptionalService()); + }, ProjectionSettings, options => ConfigureComposition(options, null, "online-competition")); } /// - /// 验证依赖声明:online-competition 声明依赖 online-match,而 online-match 不依赖 online-competition(拓扑无环)。 + /// 验证依赖声明(C294:竞技对对局为可选依赖):online-competition 以可选方式声明 online-match 且无必选依赖, + /// online-match 不以任何方式依赖 online-competition(拓扑无环)。 /// [Fact] public void ManifestDependency_ShouldBeAcyclic() { - Assert.Contains("online-match", OnlineCompetitionCapabilityModule.ManifestInstance.Requires); - Assert.DoesNotContain("online-competition", OnlineMatchCapabilityModule.ManifestInstance.Requires); + var competition = OnlineCompetitionCapabilityModule.ManifestInstance; + var match = OnlineMatchCapabilityModule.ManifestInstance; + + Assert.Contains(competition.Optional, dependency => dependency.CapabilityId == "online-match"); + Assert.DoesNotContain("online-match", competition.Requires); + Assert.DoesNotContain("online-competition", match.Requires); + Assert.DoesNotContain(match.Optional, dependency => dependency.CapabilityId == "online-competition"); + } + + /// + /// 组合编排:白名单圈定参与能力、静默后台调度、可选注入调度观测出口。 + /// + /// 组合选项。 + /// 调度观测出口(null = 缺省)。 + /// 白名单能力 Id。 + private static void ConfigureComposition(CapabilityCompositionOptions options, ICapabilitySchedulerObserver observer, params string[] enabledIds) + { + options.Filter = new CapabilityAssemblyFilter(enabledIds); + options.HostOptions.SchedulerIntervalMilliseconds = QuietSchedulerIntervalMilliseconds; + if (observer != null) + { + options.SchedulerObserver = observer; + } + } + + /// + /// 组合 match + competition 并启动,断言投影器在场性后显式驱动一轮 tick,返回本轮执行的 job 数(无 job 失败)。 + /// + /// Settings 子对象原文(null = 不写 Settings 键)。 + /// 是否期望投影器装配。 + /// 本轮执行的 tick job 数。 + private static async Task RunOneTickRoundAsync(string settingsJson, bool expectProjector) + { + var recorder = new TickRoundRecorder(); + var executed = -1; + await OnlineCapabilityTestHarness.WithRuntimeAsync(async runtime => + { + await runtime.Host.StartAsync(); + var projector = runtime.Host.Services.GetOptionalService(); + Assert.Equal(expectProjector, projector != null); + + await runtime.Host.Scheduler.TickAsync(); + + Assert.Empty(recorder.FailedJobNames); + executed = recorder.LastExecutedJobCount; + }, settingsJson, options => ConfigureComposition(options, recorder, "online-match", "online-competition")); + + Assert.True(executed > 0); + return executed; } /// /// 直接向结算存储落一份服务端形态的结果(模拟已结算事实)。 /// /// 结算结果存储。 + /// 租户标识。 + /// App 标识。 + /// 区服标识。 /// 对局标识。 /// 结果标识。 /// 条目(玩家, 分数)序列。 /// 完成通知。 - private static async Task CommitResultAsync(InMemoryOnlineMatchResultStore resultStore, string matchId, string matchResultId, params (long PlayerId, long Score)[] entries) + private static async Task CommitResultAsync(IOnlineMatchResultStore resultStore, long tenantId, long appId, long serverId, string matchId, string matchResultId, params (long PlayerId, long Score)[] entries) { var resultEntries = new List(); foreach (var item in entries) @@ -257,9 +327,9 @@ private static async Task CommitResultAsync(InMemoryOnlineMatchResultStore resul { MatchResultId = matchResultId, MatchId = matchId, - TenantId = TenantId, - AppId = AppId, - ServerId = ServerId, + TenantId = tenantId, + AppId = appId, + ServerId = serverId, Mode = OnlineRockPaperScissorsGame.MatchMode, Region = 1, Outcome = OnlineMatchState.Completed, @@ -270,7 +340,31 @@ private static async Task CommitResultAsync(InMemoryOnlineMatchResultStore resul } /// - /// 测试基座:结算存储 + 排行榜全栈 + 真实幂等协调器 + 被测投影器。 + /// 调度观测记录器(记录最近一轮 tick 的执行 job 数与失败 job 名,用于断言重投 job 是否注册)。 + /// + private sealed class TickRoundRecorder : ICapabilitySchedulerObserver + { + /// 失败 job 名(发生序)。 + public readonly List FailedJobNames = new(); + + /// 最近一轮 tick 执行的 job 数(-1 = 尚未完成任何一轮)。 + public int LastExecutedJobCount = -1; + + /// + public void OnJobFailed(CapabilitySchedulerJobKind jobKind, string jobName, Exception exception) + { + FailedJobNames.Add(jobName); + } + + /// + public void OnTickRoundCompleted(TimeSpan elapsed, int executedJobCount, int failedJobCount) + { + LastExecutedJobCount = executedJobCount; + } + } + + /// + /// 测试基座:结算存储 + 排行榜全栈 + 真实幂等协调器 + 被测投影器(直接构造,不经组合)。 /// private sealed class Harness { @@ -336,7 +430,7 @@ public async Task CreateBoardAsync() /// 完成通知。 public async Task CommitResultAsync(string matchId, string matchResultId, params (long PlayerId, long Score)[] entries) { - await OnlineMatchSettledLeaderboardProjectorTests.CommitResultAsync(ResultStore, matchId, matchResultId, entries); + await OnlineMatchSettledLeaderboardProjectorTests.CommitResultAsync(ResultStore, TenantId, AppId, ServerId, matchId, matchResultId, entries); } /// diff --git a/Tests/GameFrameX.Tests/Online/OnlineMatchSettlementPipelineTests.cs b/Tests/GameFrameX.Tests/Online/OnlineMatchSettlementPipelineTests.cs index 727fdac04..501982f60 100644 --- a/Tests/GameFrameX.Tests/Online/OnlineMatchSettlementPipelineTests.cs +++ b/Tests/GameFrameX.Tests/Online/OnlineMatchSettlementPipelineTests.cs @@ -180,7 +180,7 @@ public Harness(bool withFailingStore = false, bool withDispatcher = true) Publisher = new OnlineEventRecorder(); MatchStore = new InMemoryOnlineMatchActorStore(); ResultStore = new InMemoryOnlineMatchResultStore(); - Runtime = new OnlineMatchRuntime(MatchStore, Publisher); + Runtime = new OnlineMatchRuntime(MatchStore, Publisher, new OnlineMatchRuntimeOptions()); Runtime.RegisterGame(new OnlineRockPaperScissorsGame()); Settlement = new OnlineMatchSettlementService(Runtime, ResultStore, Publisher); diff --git a/Tests/GameFrameX.Tests/Online/OnlineMatchSettlementTests.cs b/Tests/GameFrameX.Tests/Online/OnlineMatchSettlementTests.cs index ad57f93c2..1b1704641 100644 --- a/Tests/GameFrameX.Tests/Online/OnlineMatchSettlementTests.cs +++ b/Tests/GameFrameX.Tests/Online/OnlineMatchSettlementTests.cs @@ -236,7 +236,7 @@ public Harness() Publisher = new OnlineEventRecorder(); MatchStore = new InMemoryOnlineMatchActorStore(); ResultStore = new InMemoryOnlineMatchResultStore(); - Runtime = new OnlineMatchRuntime(MatchStore, Publisher); + Runtime = new OnlineMatchRuntime(MatchStore, Publisher, new OnlineMatchRuntimeOptions()); Runtime.RegisterGame(new OnlineRockPaperScissorsGame()); var options = new IdempotencyOptions { ConcurrentWaitTimeoutMilliseconds = 1 }; diff --git a/Tests/GameFrameX.Tests/Online/OnlineMatchSyncProtocolTests.cs b/Tests/GameFrameX.Tests/Online/OnlineMatchSyncProtocolTests.cs index a29d0d156..2f0707683 100644 --- a/Tests/GameFrameX.Tests/Online/OnlineMatchSyncProtocolTests.cs +++ b/Tests/GameFrameX.Tests/Online/OnlineMatchSyncProtocolTests.cs @@ -415,10 +415,17 @@ private sealed class Harness public const string MatchId = "match-1"; /// - /// 初始化测试基座。 + /// 以包内默认运行参数初始化测试基座。 /// - /// 运行参数(可空)。 - public Harness(OnlineMatchRuntimeOptions options = null) + public Harness() : this(new OnlineMatchRuntimeOptions()) + { + } + + /// + /// 以指定运行参数初始化测试基座。 + /// + /// 运行参数。 + public Harness(OnlineMatchRuntimeOptions options) { Store = new InMemoryOnlineMatchActorStore(); Runtime = new OnlineMatchRuntime(Store, new OnlineEventRecorder(), options); diff --git a/Tests/GameFrameX.Tests/Online/OnlineMatchTicketServiceTests.cs b/Tests/GameFrameX.Tests/Online/OnlineMatchTicketServiceTests.cs index b4816e011..f84e25e4e 100644 --- a/Tests/GameFrameX.Tests/Online/OnlineMatchTicketServiceTests.cs +++ b/Tests/GameFrameX.Tests/Online/OnlineMatchTicketServiceTests.cs @@ -277,13 +277,24 @@ public async Task EnqueueAsync_AfterTicketExpired_ShouldBeAllowed() } /// - /// 创建被测服务。 + /// 以包内默认匹配参数创建被测服务。 /// /// 票据存储。 /// 事件记录桩。 - /// 匹配与限流可配置项(可空)。 /// 票据服务实例。 - private static OnlineMatchTicketService CreateService(out InMemoryOnlineMatchTicketStore store, out OnlineEventRecorder recorder, OnlineMatchmakerOptions options = null) + private static OnlineMatchTicketService CreateService(out InMemoryOnlineMatchTicketStore store, out OnlineEventRecorder recorder) + { + return CreateService(out store, out recorder, new OnlineMatchmakerOptions()); + } + + /// + /// 以指定匹配参数创建被测服务。 + /// + /// 票据存储。 + /// 事件记录桩。 + /// 匹配与限流可配置项。 + /// 票据服务实例。 + private static OnlineMatchTicketService CreateService(out InMemoryOnlineMatchTicketStore store, out OnlineEventRecorder recorder, OnlineMatchmakerOptions options) { store = new InMemoryOnlineMatchTicketStore(); recorder = new OnlineEventRecorder(); diff --git a/Tests/GameFrameX.Tests/Online/OnlineMatchmakerCoordinatorTests.cs b/Tests/GameFrameX.Tests/Online/OnlineMatchmakerCoordinatorTests.cs index bb08cccd3..c9f4712fb 100644 --- a/Tests/GameFrameX.Tests/Online/OnlineMatchmakerCoordinatorTests.cs +++ b/Tests/GameFrameX.Tests/Online/OnlineMatchmakerCoordinatorTests.cs @@ -250,11 +250,20 @@ public async Task RunOnceAsync_WithOverdueTicket_ShouldExpireItFirst() } /// - /// 创建一套测试夹具(内存存储 + 票据服务 + 协调器)。 + /// 以包内默认匹配参数创建一套测试夹具。 /// - /// 匹配与限流可配置项(可空)。 /// 测试夹具。 - private static Fixture CreateFixture(OnlineMatchmakerOptions options = null) + private static Fixture CreateFixture() + { + return CreateFixture(new OnlineMatchmakerOptions()); + } + + /// + /// 以指定匹配参数创建一套测试夹具(内存存储 + 票据服务 + 协调器)。 + /// + /// 匹配与限流可配置项。 + /// 测试夹具。 + private static Fixture CreateFixture(OnlineMatchmakerOptions options) { var store = new InMemoryOnlineMatchTicketStore(); var recorder = new OnlineEventRecorder(); diff --git a/Tests/GameFrameX.Tests/Online/OnlineMatchmakingCapabilityPackageTests.cs b/Tests/GameFrameX.Tests/Online/OnlineMatchmakingCapabilityPackageTests.cs index 77b3c2593..02bccc433 100644 --- a/Tests/GameFrameX.Tests/Online/OnlineMatchmakingCapabilityPackageTests.cs +++ b/Tests/GameFrameX.Tests/Online/OnlineMatchmakingCapabilityPackageTests.cs @@ -1,76 +1,62 @@ -using System; +// ========================================================================================== +// GameFrameX 组织及其衍生项目的版权、商标、专利及其他相关权利 +// GameFrameX organization and its derivative projects' copyrights, trademarks, patents and related rights +// 均受中华人民共和国及相关国际法律法规保护。 +// are protected by the laws of the PRC 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 refer to the LICENSE file at the root 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 the legal 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://www.cnb.cool/GameFrameX +// CNB Repository: https://www.cnb.cool/GameFrameX +// 官方文档:https://gameframex.doc.alianblank.com/ +// Official Docs: https://gameframex.doc.alianblank.com/ +// ========================================================================================== + +using System.Threading.Tasks; using GameFrameX.Capability.Hosting.Abstractions; using GameFrameX.Capability.Hosting.Core; using GameFrameX.Capability.Online.Abstractions.Contracts; -using GameFrameX.Capability.Online.Abstractions.Events; -using GameFrameX.Capability.Online.Abstractions.Idempotency; -using GameFrameX.Capability.Online.Abstractions.Scope; -using GameFrameX.Capability.Online.Foundation.Session; -using GameFrameX.Capability.Online.Social; -using Xunit; - +using GameFrameX.Capability.Online.Foundation.Presence; using GameFrameX.Capability.Online.Matchmaking; +using GameFrameX.Capability.Online.Matchmaking.Party; using Xunit; namespace GameFrameX.Tests.Online { /// - /// 匹配能力包(C231 P3,含 Party 域)验收测试:无参模块扫描可发现 / AddOnlineMatchmakingRuntime 最小组合 - /// (social gate 面经共享面解析)/ UseOnlineMatchTicketStore 替换缝生效 / Party store 产物注册回共享面。 + /// 匹配能力包(含 Party 域)验收测试(C294 机制化):无参模块扫描可发现 / 真实族白名单 foundation+social+matchmaking 装配、 + /// 票据 / 撮合 / 观察器 / 队伍产物经宿主共享服务面取用 / 宿主 Override 替换缝恒胜出包内默认票据存储 / + /// Configure 与配置段 Settings 承载 Options / 队伍以 "party" 命名注册成员资格探针、Activate 期枚举注入在线探针。 /// + [Collection(CapabilityRuntimeCollection.Name)] public class OnlineMatchmakingCapabilityPackageTests { /// - /// 社交占位模块(online-social 清单;Matchmaking 包闭包校验要求其在场——真实 gate 面经共享面预置)。 - /// - private sealed class SocialStubModule : ICapabilityModule - { - /// - public CapabilityManifest Manifest { get; } = new("online-social", "1.0.0", Array.Empty()); - - /// - public void Register(CapabilityRegistrationContext context) - { - } - } - - /// - /// 社交裁决 gate 面探针(实现 IOnlineSocialGate 的最小实例——匹配模块的跨域硬依赖)。 - /// - private sealed class StubSocialGate : IOnlineSocialGate - { - /// - public Task EvaluateAsync(long tenantId, long appId, long fromPlayerId, long toPlayerId, OnlineSocialInteractionPurpose purpose, CancellationToken cancellationToken = default) - { - return Task.FromResult(OnlineSocialDecision.Allow()); - } - } - - /// - /// 构造最小组合根所需的预置(作用域 + 事件出口 + 社交 gate 面)。 - /// - /// 装配器(已预置跨域硬依赖)。 - private static CapabilityHostBuilder MinimalRoot() - { - var builder = new CapabilityHostBuilder(); - builder.UseService(new OnlineScope(1, 1, 1)); - builder.UseService(new OnlineEventPublisher(new GameFrameX.Foundation.Idempotency.InMemoryEventPublisher())); - builder.UseService(new StubSocialGate()); - builder.AddModule(new SocialStubModule()); - return builder; - } - - /// - /// 构造合法选项。 + /// 白名单圈定 foundation + social + matchmaking(matchmaking 必选 social、social 必选 foundation)。 /// - /// 选项。 - private static CapabilityHostOptions ValidOptions() + /// 组合选项。 + private static void MatchmakingClosure(CapabilityCompositionOptions options) { - return new CapabilityHostOptions { TenantId = 1, AppId = 1, ServerId = 1 }; + options.Filter = new CapabilityAssemblyFilter(new[] { "online-foundation", "online-social", "online-matchmaking" }); } /// - /// 无参模块扫描可发现(P4 前置):匹配包程序集扫描产出 online-matchmaking 模块。 + /// 无参模块扫描可发现:匹配包程序集扫描产出唯一的 online-matchmaking 模块。 /// [Fact] public void DiscoverModules_FindsMatchmakingCapabilityModule() @@ -82,54 +68,93 @@ public void DiscoverModules_FindsMatchmakingCapabilityModule() } /// - /// AddOnlineMatchmakingRuntime 最小组合:gate 面预置齐备即可装配,产物(票据 / 撮合 / 观察器 / Party store)经共享面可取用。 + /// 白名单闭包装配:票据服务 / 撮合协调器 / 队列观察器 / 票据与队伍存储 / 队伍服务经宿主共享服务面可取用, + /// 装配序 foundation → social → matchmaking。 /// [Fact] - public void AddOnlineMatchmakingRuntime_MinimalRoot_ExposesProductsIncludingPartyStore() + public async Task Compose_MatchmakingClosure_ExposesProductsIncludingPartyService() { - var builder = MinimalRoot(); - builder.AddOnlineMatchmakingRuntime(); + await OnlineCapabilityTestHarness.WithRuntimeAsync(runtime => + { + var services = runtime.Host.Services; + Assert.NotNull(services.GetService()); + Assert.NotNull(services.GetService()); + Assert.NotNull(services.GetService()); + Assert.NotNull(services.GetService()); + Assert.NotNull(services.GetService()); + Assert.NotNull(services.GetService()); + Assert.Equal(new[] { "online-foundation", "online-social", "online-matchmaking" }, runtime.Report.EnabledIdsInOrder); + return Task.CompletedTask; + }, configure: MatchmakingClosure); + } - var host = builder.Build(ValidOptions()); + /// + /// 存储替换缝:宿主 Override 的票据存储恒胜出包内 InMemory 默认。 + /// + [Fact] + public async Task OverrideMatchTicketStore_WinsOverInMemoryDefault() + { + var replacement = new InMemoryOnlineMatchTicketStore(); - Assert.NotNull(host.Services.GetService()); - Assert.NotNull(host.Services.GetService()); - Assert.NotNull(host.Services.GetService()); - Assert.NotNull(host.Services.GetService()); - Assert.NotNull(host.Services.GetService()); - Assert.Equal(new[] { "online-social", "online-matchmaking" }, host.Report.EnabledIdsInOrder); + await OnlineCapabilityTestHarness.WithRuntimeAsync(runtime => + { + Assert.Same(replacement, runtime.Host.Services.GetService()); + return Task.CompletedTask; + }, configure: options => + { + MatchmakingClosure(options); + options.Override(replacement); + }); } /// - /// UseOnlineMatchTicketStore 替换缝生效:模块取用组合根注入的存储实例。 + /// 包级 Options 承载:Configure 改写字段报告来源 Code;配置段 Settings 写入的字段生效值为配置值;未触碰字段保持默认。 /// [Fact] - public void UseOnlineMatchTicketStore_PreSeedWinsOverInMemoryDefault() + public async Task MatchmakingOptions_CarriedThroughSettingsAndConfigure() { - var replacement = new InMemoryOnlineMatchTicketStore(); - var builder = MinimalRoot(); - builder.UseOnlineMatchTicketStore(replacement); - builder.AddOnlineMatchmakingRuntime(); + await OnlineCapabilityTestHarness.WithRuntimeAsync(runtime => + { + var entry = Assert.Single(runtime.Report.Entries, candidate => candidate.Manifest.Id == "online-matchmaking"); + Assert.Equal(typeof(OnlineMatchmakingOptions), entry.Manifest.OptionsType); + + var configured = Assert.Single(entry.SettingsFields, candidate => candidate.FieldPath == "Matchmaker.TicketTimeToLiveSeconds"); + Assert.Equal(120, (int)configured.EffectiveValue); + Assert.Equal(CapabilitySettingsFieldSource.Code, configured.Source); - var host = builder.Build(ValidOptions()); + var fromSection = Assert.Single(entry.SettingsFields, candidate => candidate.FieldPath == "Matchmaker.RateLimitMaxOperations"); + Assert.Equal(4, (int)fromSection.EffectiveValue); - Assert.Same(replacement, host.Services.GetService()); + var untouched = Assert.Single(entry.SettingsFields, candidate => candidate.FieldPath == "Matchmaker.WaitExpansionThresholdSeconds"); + Assert.Equal(30, (int)untouched.EffectiveValue); + Assert.Equal(CapabilitySettingsFieldSource.Default, untouched.Source); + return Task.CompletedTask; + }, "{\"online-matchmaking\":{\"Matchmaker\":{\"RateLimitMaxOperations\":4}}}", options => + { + MatchmakingClosure(options); + options.Configure(matchmaking => matchmaking.Matchmaker.TicketTimeToLiveSeconds = 120); + }); } /// - /// 包级 options 经回调承载并进共享面(能力族 AddXxx(options => ...) 惯例锚点)。 + /// 队伍探针:队伍服务以 "party" 命名注册成员资格探针(聊天队伍频道判定面); + /// 启动后在线探针扩展点可枚举到 foundation 提供的队伍在线探针(Activate 期注入队伍服务)。 /// [Fact] - public void AddOnlineMatchmakingRuntime_OptionsCarriedThroughCallback() + public async Task Start_ExposesPartyProbes() { - var builder = MinimalRoot(); - var seen = false; - builder.AddOnlineMatchmakingRuntime(options => seen = true); + await OnlineCapabilityTestHarness.WithRuntimeAsync(async runtime => + { + await runtime.Host.StartAsync(); - var host = builder.Build(ValidOptions()); + var services = runtime.Host.Services; + var parties = services.GetService(); + Assert.Same(parties, services.GetService("party")); + Assert.Contains(parties, services.GetServices()); - Assert.True(seen); - Assert.NotNull(host.Services.GetService()); + var presenceProbe = Assert.Single(services.GetServices()); + Assert.IsType(presenceProbe); + }, configure: MatchmakingClosure); } } } diff --git a/Tests/GameFrameX.Tests/Online/OnlineOverviewTimelineTestHarness.cs b/Tests/GameFrameX.Tests/Online/OnlineOverviewTimelineTestHarness.cs index a878c442f..ad6107e62 100644 --- a/Tests/GameFrameX.Tests/Online/OnlineOverviewTimelineTestHarness.cs +++ b/Tests/GameFrameX.Tests/Online/OnlineOverviewTimelineTestHarness.cs @@ -273,7 +273,7 @@ internal async Task EnqueueAsync(long serverId, long playerId CustomProperties = new Dictionary(), }; - var outcome = await new OnlineMatchTicketService(TicketStore, Recorder).EnqueueAsync(CreateScope(serverId, playerId), request); + var outcome = await new OnlineMatchTicketService(TicketStore, Recorder, new OnlineMatchmakerOptions()).EnqueueAsync(CreateScope(serverId, playerId), request); Assert.True(outcome.IsSuccess); return outcome.Data; } @@ -285,8 +285,9 @@ internal async Task EnqueueAsync(long serverId, long playerId /// 异步任务。 internal async Task RunMatchmakerAsync(long serverId) { - var tickets = new OnlineMatchTicketService(TicketStore, Recorder); - var coordinator = new OnlineMatchmakerCoordinator(TicketStore, tickets, Recorder); + var matchmakerOptions = new OnlineMatchmakerOptions(); + var tickets = new OnlineMatchTicketService(TicketStore, Recorder, matchmakerOptions); + var coordinator = new OnlineMatchmakerCoordinator(TicketStore, tickets, Recorder, matchmakerOptions); var outcome = await coordinator.RunOnceAsync(TenantId, AppId); Assert.True(outcome.IsSuccess); } diff --git a/Tests/GameFrameX.Tests/Online/OnlinePlayerStorageServiceTests.cs b/Tests/GameFrameX.Tests/Online/OnlinePlayerStorageServiceTests.cs index 3e5a27b80..f936c59cc 100644 --- a/Tests/GameFrameX.Tests/Online/OnlinePlayerStorageServiceTests.cs +++ b/Tests/GameFrameX.Tests/Online/OnlinePlayerStorageServiceTests.cs @@ -275,13 +275,13 @@ public async Task WriteAsync_WithoutPlayerScope_ShouldBeRejected() } /// - /// 构造被测服务。 + /// 以包内默认限制选项构造被测服务。 /// - /// 限制选项(可空 = 默认)。 /// 玩家云存储服务实例。 - private static OnlinePlayerStorageService CreateService(OnlinePlayerStorageOptions options = null) + private static OnlinePlayerStorageService CreateService() { - return new OnlinePlayerStorageService(new InMemoryOnlinePlayerStorageStore(), options); + // C294:服务构造面 options 改为必填(包内默认由 Foundation 的 Options 绑定提供),测试显式构造包内默认实例。 + return new OnlinePlayerStorageService(new InMemoryOnlinePlayerStorageStore(), new OnlinePlayerStorageOptions()); } /// diff --git a/Tests/GameFrameX.Tests/Online/OnlineSocialCapabilityPackageTests.cs b/Tests/GameFrameX.Tests/Online/OnlineSocialCapabilityPackageTests.cs index c490387d4..01e0bf8a2 100644 --- a/Tests/GameFrameX.Tests/Online/OnlineSocialCapabilityPackageTests.cs +++ b/Tests/GameFrameX.Tests/Online/OnlineSocialCapabilityPackageTests.cs @@ -1,66 +1,77 @@ +// ========================================================================================== +// GameFrameX 组织及其衍生项目的版权、商标、专利及其他相关权利 +// GameFrameX organization and its derivative projects' copyrights, trademarks, patents and related rights +// 均受中华人民共和国及相关国际法律法规保护。 +// are protected by the laws of the PRC 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 refer to the LICENSE file at the root 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 the legal 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://www.cnb.cool/GameFrameX +// CNB Repository: https://www.cnb.cool/GameFrameX +// 官方文档:https://gameframex.doc.alianblank.com/ +// Official Docs: https://gameframex.doc.alianblank.com/ +// ========================================================================================== + using System; +using System.Threading; +using System.Threading.Tasks; using GameFrameX.Capability.Hosting.Abstractions; using GameFrameX.Capability.Hosting.Core; +using GameFrameX.Capability.Online.Abstractions; using GameFrameX.Capability.Online.Abstractions.Contracts; -using GameFrameX.Capability.Online.Abstractions.Events; -using GameFrameX.Capability.Online.Abstractions.Idempotency; using GameFrameX.Capability.Online.Abstractions.Scope; -using GameFrameX.Capability.Online.Foundation.Session; using GameFrameX.Capability.Online.Social; using Xunit; namespace GameFrameX.Tests.Online { /// - /// 社交能力包(C231 P3)验收测试:无参模块扫描可发现 / AddOnlineSocialRuntime 最小组合(Foundation 预置)/ - /// UseOnlineChatStore 替换缝生效 / gate 面注册回共享面。 + /// 社交能力包验收测试(C294 机制化):无参模块扫描可发现 / 真实族白名单 foundation+social 装配、产物与 gate 面经宿主共享服务面取用 / + /// 宿主 Override 替换缝恒胜出包内默认聊天存储 / Configure 代码层覆盖 Options 字段 / 群组探针 Activate 期注入扩展点 / + /// 聊天审核钩子声明默认放行、热更侧 Attach 原子替换、Dispose 回默认。 /// + [Collection(CapabilityRuntimeCollection.Name)] public class OnlineSocialCapabilityPackageTests { + /// 发言玩家。 + private const long PlayerOne = 2101; + /// - /// 地基占位模块(online-foundation 清单;Social 包闭包校验要求其在场——Foundation 包 hosting 扩展归 P4)。 + /// 白名单圈定 foundation + social(social 必选 foundation,二者构成闭包)。 /// - private sealed class FoundationStubModule : ICapabilityModule + /// 组合选项。 + private static void SocialClosure(CapabilityCompositionOptions options) { - /// - public CapabilityManifest Manifest { get; } = new("online-foundation", "1.0.0", Array.Empty()); - - /// - public void Register(CapabilityRegistrationContext context) - { - } + options.Filter = new CapabilityAssemblyFilter(new[] { "online-foundation", "online-social" }); } - /// 构造最小组合根所需的预置(作用域 + 事件出口 + Foundation 会话面)。 - /// - /// 装配器(已预置平台与地基服务)。 - private static CapabilityHostBuilder MinimalRoot() - { - var builder = new CapabilityHostBuilder(); - builder.UseService(new OnlineScope(1, 1, 1)); - var eventPublisher = new OnlineEventPublisher(new GameFrameX.Foundation.Idempotency.InMemoryEventPublisher()); - builder.UseService(eventPublisher); - builder.UseService(new OnlineIdempotencyService(new GameFrameX.Foundation.Idempotency.IdempotencyCoordinator( - new GameFrameX.Foundation.Idempotency.InMemoryIdempotencyStore(), - GameFrameX.Foundation.Idempotency.SystemClock.Instance, - new GameFrameX.Foundation.Idempotency.IdempotencyOptions()))); - var sessionStore = new InMemoryOnlineSessionStore(); - builder.UseService(sessionStore); - builder.UseService(new OnlineSessionTokenService(sessionStore, eventPublisher)); - builder.AddModule(new FoundationStubModule()); - return builder; - } /// - /// 构造合法选项。 + /// 构造玩家作用域(三元组与族配置段一致)。 /// - /// 选项。 - private static CapabilityHostOptions ValidOptions() + /// 玩家标识。 + /// 玩家作用域。 + private static OnlineScope PlayerScope(long playerId) { - return new CapabilityHostOptions { TenantId = 1, AppId = 1, ServerId = 1 }; + return new OnlineScope(OnlineCapabilityTestHarness.TenantId, OnlineCapabilityTestHarness.AppId, OnlineCapabilityTestHarness.ServerId, playerId); } /// - /// 无参模块扫描可发现(P4 前置):社交包程序集扫描产出 online-social 模块。 + /// 无参模块扫描可发现:社交包程序集扫描产出唯一的 online-social 模块。 /// [Fact] public void DiscoverModules_FindsSocialCapabilityModule() @@ -72,54 +83,164 @@ public void DiscoverModules_FindsSocialCapabilityModule() } /// - /// AddOnlineSocialRuntime 最小组合:Foundation 预置齐备即可装配,产物与 gate 面经共享面可取用。 + /// 白名单 foundation+social 装配:好友 / 裁决 / 处罚 / 通知 / 聊天审计 / 聊天 / 群组服务与 gate 面经宿主共享服务面可取用, + /// gate 面与裁决服务同一实例,装配序 foundation 先于 social。 /// [Fact] - public void AddOnlineSocialRuntime_MinimalRoot_ExposesProductsAndGate() + public async Task Compose_FoundationAndSocial_ExposesProductsAndGate() { - var builder = MinimalRoot(); - builder.AddOnlineSocialRuntime(); + await OnlineCapabilityTestHarness.WithRuntimeAsync(runtime => + { + var services = runtime.Host.Services; + Assert.NotNull(services.GetService()); + Assert.NotNull(services.GetService()); + Assert.NotNull(services.GetService()); + Assert.NotNull(services.GetService()); + Assert.NotNull(services.GetService()); + Assert.NotNull(services.GetService()); + var decisions = services.GetService(); + Assert.NotNull(decisions); + Assert.Same(decisions, services.GetService()); + Assert.Equal(new[] { "online-foundation", "online-social" }, runtime.Report.EnabledIdsInOrder); + return Task.CompletedTask; + }, configure: SocialClosure); + } - var host = builder.Build(ValidOptions()); + /// + /// 存储替换缝:宿主 Override 的聊天存储恒胜出包内 InMemory 默认。 + /// + [Fact] + public async Task OverrideChatStore_WinsOverInMemoryDefault() + { + var replacement = new InMemoryOnlineChatStore(); - Assert.NotNull(host.Services.GetService()); - Assert.NotNull(host.Services.GetService()); - Assert.NotNull(host.Services.GetService()); - Assert.NotNull(host.Services.GetService()); - Assert.NotNull(host.Services.GetService()); - Assert.Equal(new[] { "online-foundation", "online-social" }, host.Report.EnabledIdsInOrder); + await OnlineCapabilityTestHarness.WithRuntimeAsync(runtime => + { + Assert.Same(replacement, runtime.Host.Services.GetService()); + return Task.CompletedTask; + }, configure: options => + { + SocialClosure(options); + options.Override(replacement); + }); } /// - /// UseOnlineChatStore 替换缝生效:模块取用组合根注入的存储实例。 + /// 包级 Options 经 Configure 代码层覆盖:被改写字段报告来源 Code 且生效值为改写值,未改写字段保持包内默认。 /// [Fact] - public void UseOnlineChatStore_PreSeedWinsOverInMemoryDefault() + public async Task ConfigureSocialOptions_AppliesCodeOverrideVisibleInReport() { - var replacement = new InMemoryOnlineChatStore(); - var builder = MinimalRoot(); - builder.UseOnlineChatStore(replacement); - builder.AddOnlineSocialRuntime(); + await OnlineCapabilityTestHarness.WithRuntimeAsync(runtime => + { + var entry = Assert.Single(runtime.Report.Entries, candidate => candidate.Manifest.Id == "online-social"); + Assert.Equal(typeof(OnlineSocialOptions), entry.Manifest.OptionsType); - var host = builder.Build(ValidOptions()); + var configured = Assert.Single(entry.SettingsFields, candidate => candidate.FieldPath == "Friend.SearchLimit"); + Assert.Equal(7, (int)configured.EffectiveValue); + Assert.Equal(CapabilitySettingsFieldSource.Code, configured.Source); - Assert.Same(replacement, host.Services.GetService()); + var untouched = Assert.Single(entry.SettingsFields, candidate => candidate.FieldPath == "Friend.FriendLimit"); + Assert.Equal(200, (int)untouched.EffectiveValue); + Assert.Equal(CapabilitySettingsFieldSource.Default, untouched.Source); + return Task.CompletedTask; + }, configure: options => + { + SocialClosure(options); + options.Configure(social => social.Friend.SearchLimit = 7); + }); } /// - /// 包级 options 经回调承载并进共享面(能力族 AddXxx(options => ...) 惯例锚点)。 + /// 群组成员资格探针:以 "group" 命名注册;扩展点枚举只在 Activate 开始后可用,启动后枚举结果包含群组服务。 /// [Fact] - public void AddOnlineSocialRuntime_OptionsCarriedThroughCallback() + public async Task Start_ExposesGroupMembershipProbeThroughExtensionPoint() { - var builder = MinimalRoot(); - var seen = false; - builder.AddOnlineSocialRuntime(options => seen = true); + await OnlineCapabilityTestHarness.WithRuntimeAsync(async runtime => + { + var services = runtime.Host.Services; + Assert.Throws(() => services.GetServices()); - var host = builder.Build(ValidOptions()); + await runtime.Host.StartAsync(); - Assert.True(seen); - Assert.NotNull(host.Services.GetService()); + var groups = services.GetService(); + Assert.Same(groups, services.GetService("group")); + Assert.Contains(groups, services.GetServices()); + }, configure: SocialClosure); + } + + /// + /// 聊天审核钩子:默认放行;Attach 后逐调用读到挂载实现(拒绝);再次 Attach 原子替换且旧租约 Dispose 为空操作; + /// 当前租约 Dispose 后回默认放行策略。 + /// + [Fact] + public async Task ModerationHook_AttachReplaceDispose_ChangesChatBehavior() + { + await OnlineCapabilityTestHarness.WithRuntimeAsync(async runtime => + { + await runtime.Host.StartAsync(); + var chat = runtime.Host.Services.GetService(); + var channel = await chat.OpenGlobalChannelAsync(PlayerScope(PlayerOne)); + Assert.True(channel.IsSuccess, channel.Message); + var channelId = channel.Data.ChannelId; + + var allowedByDefault = await chat.SendAsync(PlayerScope(PlayerOne), channelId, "默认放行"); + Assert.True(allowedByDefault.IsSuccess, allowedByDefault.Message); + + CapabilityHookLease firstLease = null; + CapabilityHookLease secondLease = null; + try + { + firstLease = CapabilityHook.Attach(new RejectingModerationHook("first")); + secondLease = CapabilityHook.Attach(new RejectingModerationHook("second")); + + var rejected = await chat.SendAsync(PlayerScope(PlayerOne), channelId, "被替换后的审核拒绝"); + Assert.False(rejected.IsSuccess); + Assert.Equal(OnlineErrorCode.RiskControlRejected, rejected.Code); + Assert.Contains("second", rejected.Message); + + // 已被替换的旧租约 Dispose 为空操作:当前挂载仍生效。 + firstLease.Dispose(); + var stillRejected = await chat.SendAsync(PlayerScope(PlayerOne), channelId, "旧租约摘下无效"); + Assert.False(stillRejected.IsSuccess); + Assert.Equal(OnlineErrorCode.RiskControlRejected, stillRejected.Code); + + // 当前租约 Dispose:回声明的默认放行策略。 + secondLease.Dispose(); + var allowedAgain = await chat.SendAsync(PlayerScope(PlayerOne), channelId, "回默认放行"); + Assert.True(allowedAgain.IsSuccess, allowedAgain.Message); + } + finally + { + firstLease?.Dispose(); + secondLease?.Dispose(); + } + }, configure: SocialClosure); + } + + /// + /// 恒拒绝审核钩子桩(模拟热更侧挂载的审核插件;拒绝理由用于区分哪一个挂载生效)。 + /// + private sealed class RejectingModerationHook : IOnlineChatModerationHook + { + /// 拒绝理由。 + private readonly string _reason; + + /// + /// 初始化恒拒绝审核钩子桩。 + /// + /// 拒绝理由。 + public RejectingModerationHook(string reason) + { + _reason = reason; + } + + /// + public Task CheckAsync(OnlineChatMessage message, CancellationToken cancellationToken = default) + { + return Task.FromResult(OnlineChatModerationVerdict.Reject(_reason)); + } } } } diff --git a/Tests/GameFrameX.Tests/Online/OnlineSocialGateIntegrationTests.cs b/Tests/GameFrameX.Tests/Online/OnlineSocialGateIntegrationTests.cs index 229da4145..356b017b0 100644 --- a/Tests/GameFrameX.Tests/Online/OnlineSocialGateIntegrationTests.cs +++ b/Tests/GameFrameX.Tests/Online/OnlineSocialGateIntegrationTests.cs @@ -409,8 +409,9 @@ private static MatchFixture CreateMatchFixture(bool withGate) var graph = new InMemoryOnlineSocialGraphStore(); var decision = new OnlineSocialDecisionService(graph, new InMemoryOnlineReportStore(), recorder); var punishments = new OnlinePunishmentService(graph, recorder); - var tickets = new OnlineMatchTicketService(store, recorder); - var coordinator = new OnlineMatchmakerCoordinator(store, tickets, recorder, null, withGate ? decision : null); + var matchmakerOptions = new OnlineMatchmakerOptions(); + var tickets = new OnlineMatchTicketService(store, recorder, matchmakerOptions); + var coordinator = new OnlineMatchmakerCoordinator(store, tickets, recorder, matchmakerOptions, withGate ? decision : null); return new MatchFixture(store, tickets, coordinator, decision, punishments); } diff --git a/Tests/GameFrameX.Tests/Online/RockPaperScissorsMatchBridgeTests.cs b/Tests/GameFrameX.Tests/Online/RockPaperScissorsMatchBridgeTests.cs index 81aaab8de..7146dc4cc 100644 --- a/Tests/GameFrameX.Tests/Online/RockPaperScissorsMatchBridgeTests.cs +++ b/Tests/GameFrameX.Tests/Online/RockPaperScissorsMatchBridgeTests.cs @@ -23,30 +23,32 @@ using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; -using GameFrameX.Capability.Online.Abstractions.Bridges; +using GameFrameX.Capability.Hosting.Abstractions; +using GameFrameX.Capability.Hosting.Core; using GameFrameX.Capability.Online.Abstractions.Contracts; using GameFrameX.Capability.Online.Abstractions.Scope; using GameFrameX.Hotfix.Logic.Game.RockPaperScissors; using GameFrameX.Proto.Proto; -using GameFrameX.Utility.Setting; using Xunit; namespace GameFrameX.Tests.Online { /// - /// 对局样例桥 Hotfix 薄适配器测试(C245):开关关闭不接管、lease 失效显式错误码、 - /// 契约失败码映射、异常兜底、作用域与参数透传、视图字段级映射(出拳保密 / Wins 不外发) - /// 与 Room 状态映射决策矩阵。 + /// 对局样例桥 Hotfix 薄适配器测试(C245 → C294 机制桥):对局桥缺席 / 已撤销即不接管(能力过滤器是唯一开关)、 + /// 桥在场而授权作用域提供者缺席显式 DependencyUnavailable、契约失败码映射、异常兜底、作用域与参数透传、 + /// 视图字段级映射(出拳保密 / Wins 不外发)与 Room 状态映射决策矩阵。 + /// 桥契约桩经机制测试族槽位 在 Activate 期发布。 /// - public class RockPaperScissorsMatchBridgeTests : IDisposable + [Collection(CapabilityRuntimeCollection.Name)] + public class RockPaperScissorsMatchBridgeTests { - /// 测试用租户标识。 + /// 授权作用域租户标识。 private const long TenantId = 1; - /// 测试用应用标识。 + /// 授权作用域应用标识。 private const long AppId = 1; - /// 测试用区服标识。 + /// 授权作用域区服标识。 private const long ServerId = 1001; /// 请求主体(玩家位唯一来源)。 @@ -58,26 +60,48 @@ public class RockPaperScissorsMatchBridgeTests : IDisposable /// 测试用局号。 private const int Round = 2; - public RockPaperScissorsMatchBridgeTests() - { - OnlineMatchGameBridgeHolder.Retract(); - } - - public void Dispose() - { - OnlineMatchGameBridgeHolder.Retract(); - EnableBridge(false); - } - /// - /// 设置对局桥开关(经进程设置真实写路径;进程级字段保持缺省,避免与既有设置冲突)。 + /// 经机制测试族发布对局桥桩(及可选的授权作用域提供者桩),启动后执行用例主体;结束恒停止撤桥并复位槽位。 /// - private static void EnableBridge(bool enabled) + /// 对局桥契约桩。 + /// 是否同时发布授权作用域提供者桩(三元组 1/1/1001)。 + /// 用例主体(入参:已启动的组合产物)。 + /// 异步任务。 + private static async Task WithPublishedBridgeAsync(StubMatchBridge stub, bool publishScopeProvider, Func body) { - GlobalSettings.SetCurrentSetting(new AppSetting + try { - Online = new OnlineCapabilityOptions { EnableMatchGameBridge = enabled }, - }); + MechanismScenario.First.Activate = (context, cancellationToken) => + { + context.Bridges.Publish(stub); + if (publishScopeProvider) + { + context.Bridges.Publish(new StubScopeProvider(new OnlineScope(TenantId, AppId, ServerId))); + } + + return Task.CompletedTask; + }; + + var options = new CapabilityCompositionOptions(default, new CollectingCapabilityAssemblyLog()) + { + HostOptions = new CapabilityHostOptions { TenantId = 1, AppId = 1, ServerId = 1, }, + Filter = new CapabilityAssemblyFilter(new[] { MechanismScenario.First.DefaultId }), + }; + var runtime = CapabilityComposition.Compose(new MechanismTestFamily(), options); + try + { + await runtime.Host.StartAsync(); + await body(runtime); + } + finally + { + await runtime.StopAsync(); + } + } + finally + { + MechanismScenario.Reset(); + } } /// @@ -102,12 +126,12 @@ private static OnlineMatchBridgeGameView SecretView() } /// - /// 开关关闭:四个 Try* 一律返回未接管(Handled=false / false),响应对象不被触碰。 + /// 断言四个 Try* 一律返回未接管(Handled=false / false),响应对象不被触碰。 /// - [Fact] - public async Task BridgeDisabled_AllTryMethods_ReturnNotHandledAndLeaveResponseUntouched() + /// 异步任务。 + private static async Task AssertAllTryMethodsNotHandledAsync() { - EnableBridge(false); + Assert.False(RockPaperScissorsMatchBridge.IsBridgeEnabled); var startResponse = new RespStartRoomGame(); var startHandled = await RockPaperScissorsMatchBridge.TryStartRoomGameAsync(ActorId, RoomId, Round, new[] { ActorId, 2002L }, startResponse); @@ -138,60 +162,65 @@ public async Task BridgeDisabled_AllTryMethods_ReturnNotHandledAndLeaveResponseU } /// - /// 开关开启而 lease 未发布:显式 -(int)DependencyUnavailable,不静默成功、不抛异常。 + /// 对局桥缺席(online-match 未装配 = 缺省部署):四个 Try* 一律未接管,响应对象不被触碰——旧链行为逐字节保持。 /// [Fact] - public async Task BridgeEnabled_WithoutLease_ReturnsExplicitDependencyUnavailable() + public async Task BridgeAbsent_AllTryMethods_ReturnNotHandledAndLeaveResponseUntouched() { - EnableBridge(true); + Assert.False(CapabilityBridge.TryAcquire(out _)); - var startResponse = new RespStartRoomGame(); - var startHandled = await RockPaperScissorsMatchBridge.TryStartRoomGameAsync(ActorId, RoomId, Round, new[] { ActorId, 2002L }, startResponse); - Assert.True(startHandled); - Assert.Equal(-(int)OnlineErrorCode.DependencyUnavailable, startResponse.ErrorCode); - - var submitResponse = new RespSubmitRockPaperScissorsGesture(); - var submit = await RockPaperScissorsMatchBridge.TrySubmitGestureAsync(ActorId, RoomId, Round, 1, submitResponse); - Assert.True(submit.Handled); - Assert.Null(submit.View); - Assert.Equal(-(int)OnlineErrorCode.DependencyUnavailable, submitResponse.ErrorCode); - Assert.Null(submitResponse.GameInfo); - - var infoResponse = new RespRockPaperScissorsGameInfo(); - var info = await RockPaperScissorsMatchBridge.TryGetGameInfoAsync(ActorId, RoomId, Round, infoResponse); - Assert.True(info.Handled); - Assert.Null(info.View); - Assert.Equal(-(int)OnlineErrorCode.DependencyUnavailable, infoResponse.ErrorCode); - Assert.Null(infoResponse.GameInfo); + await AssertAllTryMethodsNotHandledAsync(); } /// - /// 开关开启且 lease 已撤销(holder 当前位清空):同样显式 DependencyUnavailable—— - /// 逐请求读取 holder,撤销后旧引用不存活。 + /// 对局桥已撤销(宿主停止自动撤桥):与缺席同语义——未接管、契约面不被触达(逐请求取租约,撤销后旧引用不存活)。 /// [Fact] - public async Task BridgeEnabled_AfterRetract_ReturnsExplicitDependencyUnavailable() + public async Task BridgeRetractedByStop_AllTryMethods_ReturnNotHandledWithoutTouchingContract() { - EnableBridge(true); var stub = new StubMatchBridge(); - OnlineMatchGameBridgeHolder.Publish(stub, new OnlineScope(TenantId, AppId, ServerId)); - OnlineMatchGameBridgeHolder.Retract(); - Assert.False(OnlineMatchGameBridgeHolder.IsPublished); + await WithPublishedBridgeAsync(stub, true, async runtime => + { + Assert.True(RockPaperScissorsMatchBridge.IsBridgeEnabled); - var handled = await RockPaperScissorsMatchBridge.TryStartRoomGameAsync(ActorId, RoomId, Round, new[] { ActorId, 2002L }, new RespStartRoomGame()); - Assert.True(handled); - Assert.Equal(0, stub.StartCallCount); // 未触达契约面 + await runtime.StopAsync(); - var submitResponse = new RespSubmitRockPaperScissorsGesture(); - var submit = await RockPaperScissorsMatchBridge.TrySubmitGestureAsync(ActorId, RoomId, Round, 1, submitResponse); - Assert.True(submit.Handled); - Assert.Equal(-(int)OnlineErrorCode.DependencyUnavailable, submitResponse.ErrorCode); - Assert.Null(submitResponse.GameInfo); + await AssertAllTryMethodsNotHandledAsync(); + Assert.Equal(0, stub.StartCallCount); + Assert.Equal(0, stub.SubmitCallCount); + }); } /// - /// 契约失败码映射:ParameterInvalid / ScopeDenied / StateEnded / DuplicateRequest - /// 逐一映射为对应负值段(≤ -1001,与旧链 -1..-99 不重叠)。 + /// 对局桥在场而授权作用域提供者缺席:桥路径独占且显式 -(int)DependencyUnavailable,契约面不被触达(作用域不降级为空)。 + /// + [Fact] + public async Task BridgePresent_WithoutScopeProvider_ReturnsExplicitDependencyUnavailable() + { + var stub = new StubMatchBridge(); + await WithPublishedBridgeAsync(stub, false, async runtime => + { + Assert.False(CapabilityBridge.TryAcquire(out _)); + + var startResponse = new RespStartRoomGame(); + var startHandled = await RockPaperScissorsMatchBridge.TryStartRoomGameAsync(ActorId, RoomId, Round, new[] { ActorId, 2002L }, startResponse); + Assert.True(startHandled); + Assert.Equal(-(int)OnlineErrorCode.DependencyUnavailable, startResponse.ErrorCode); + + var submitResponse = new RespSubmitRockPaperScissorsGesture(); + var submit = await RockPaperScissorsMatchBridge.TrySubmitGestureAsync(ActorId, RoomId, Round, 1, submitResponse); + Assert.True(submit.Handled); + Assert.Null(submit.View); + Assert.Equal(-(int)OnlineErrorCode.DependencyUnavailable, submitResponse.ErrorCode); + Assert.Null(submitResponse.GameInfo); + + Assert.Equal(0, stub.StartCallCount); + Assert.Equal(0, stub.SubmitCallCount); + }); + } + + /// + /// 契约失败码映射:ScopeDenied / StateEnded / DuplicateRequest 逐一映射为对应负值段(≤ -1001,与旧链 -1..-99 不重叠)。 /// [Theory] [InlineData(OnlineErrorCode.ScopeDenied)] @@ -199,17 +228,17 @@ public async Task BridgeEnabled_AfterRetract_ReturnsExplicitDependencyUnavailabl [InlineData(OnlineErrorCode.DuplicateRequest)] public async Task ContractFailure_MapsToExplicitNegativeCode(OnlineErrorCode code) { - EnableBridge(true); var stub = new StubMatchBridge { SubmitResult = () => OnlineResult.Fail(code, "契约面失败") }; - OnlineMatchGameBridgeHolder.Publish(stub, new OnlineScope(TenantId, AppId, ServerId)); - - var response = new RespSubmitRockPaperScissorsGesture(); - var result = await RockPaperScissorsMatchBridge.TrySubmitGestureAsync(ActorId, RoomId, Round, 1, response); - Assert.True(result.Handled); - Assert.Null(result.View); - Assert.Equal(-(int)code, response.ErrorCode); - Assert.True(response.ErrorCode <= -1001); - Assert.Null(response.GameInfo); + await WithPublishedBridgeAsync(stub, true, async runtime => + { + var response = new RespSubmitRockPaperScissorsGesture(); + var result = await RockPaperScissorsMatchBridge.TrySubmitGestureAsync(ActorId, RoomId, Round, 1, response); + Assert.True(result.Handled); + Assert.Null(result.View); + Assert.Equal(-(int)code, response.ErrorCode); + Assert.True(response.ErrorCode <= -1001); + Assert.Null(response.GameInfo); + }); } /// @@ -218,52 +247,55 @@ public async Task ContractFailure_MapsToExplicitNegativeCode(OnlineErrorCode cod [Fact] public async Task ContractThrows_ReturnsInternalFallbackCode() { - EnableBridge(true); var stub = new StubMatchBridge { SubmitResult = () => throw new InvalidOperationException("boom") }; - OnlineMatchGameBridgeHolder.Publish(stub, new OnlineScope(TenantId, AppId, ServerId)); - - var response = new RespSubmitRockPaperScissorsGesture(); - var result = await RockPaperScissorsMatchBridge.TrySubmitGestureAsync(ActorId, RoomId, Round, 1, response); - Assert.True(result.Handled); - Assert.Null(result.View); - Assert.Equal(-2, response.ErrorCode); - Assert.Null(response.GameInfo); + await WithPublishedBridgeAsync(stub, true, async runtime => + { + var response = new RespSubmitRockPaperScissorsGesture(); + var result = await RockPaperScissorsMatchBridge.TrySubmitGestureAsync(ActorId, RoomId, Round, 1, response); + Assert.True(result.Handled); + Assert.Null(result.View); + Assert.Equal(-2, response.ErrorCode); + Assert.Null(response.GameInfo); + }); } /// - /// 可信身份与参数透传:作用域三元组只取 lease 授权值、玩家位只取请求主体 ActorId; + /// 可信身份与参数透传:作用域三元组只取授权作用域提供者发布值、玩家位只取请求主体 ActorId; /// roomId / round / gesture / playerIds 原样到达契约面。 /// [Fact] public async Task SubmitGesture_PassesAuthorizedScopeTripleAndSessionSubject() { - EnableBridge(true); var stub = new StubMatchBridge(); - OnlineMatchGameBridgeHolder.Publish(stub, new OnlineScope(TenantId, AppId, ServerId)); - var playerIds = new[] { ActorId, 2002L }; - var startResponse = new RespStartRoomGame(); - var startHandled = await RockPaperScissorsMatchBridge.TryStartRoomGameAsync(ActorId, RoomId, Round, playerIds, startResponse); - Assert.True(startHandled); - Assert.Equal(0, startResponse.ErrorCode); - Assert.Equal(1, stub.StartCallCount); - Assert.Equal(TenantId, stub.LastStartScope.TenantId); - Assert.Equal(AppId, stub.LastStartScope.AppId); - Assert.Equal(ServerId, stub.LastStartScope.ServerId); - Assert.Equal(ActorId, stub.LastStartScope.PlayerId); - Assert.Equal(RoomId, stub.LastStartRoomId); - Assert.Equal(Round, stub.LastStartRound); - Assert.Equal(playerIds, stub.LastStartPlayerIds); - - var submitResponse = new RespSubmitRockPaperScissorsGesture(); - var submit = await RockPaperScissorsMatchBridge.TrySubmitGestureAsync(ActorId, RoomId, Round, 2, submitResponse); - Assert.True(submit.Handled); - Assert.NotNull(submit.View); - Assert.Equal(0, submitResponse.ErrorCode); - Assert.NotNull(submitResponse.GameInfo); - Assert.Equal(ActorId, stub.LastSubmitScope.PlayerId); - Assert.Equal(RoomId, stub.LastSubmitRoomId); - Assert.Equal(Round, stub.LastSubmitRound); - Assert.Equal(2, stub.LastSubmitGesture); + await WithPublishedBridgeAsync(stub, true, async runtime => + { + var playerIds = new[] { ActorId, 2002L }; + var startResponse = new RespStartRoomGame(); + var startHandled = await RockPaperScissorsMatchBridge.TryStartRoomGameAsync(ActorId, RoomId, Round, playerIds, startResponse); + Assert.True(startHandled); + Assert.Equal(0, startResponse.ErrorCode); + Assert.Equal(1, stub.StartCallCount); + Assert.Equal(TenantId, stub.LastStartScope.TenantId); + Assert.Equal(AppId, stub.LastStartScope.AppId); + Assert.Equal(ServerId, stub.LastStartScope.ServerId); + Assert.Equal(ActorId, stub.LastStartScope.PlayerId); + Assert.Equal(RoomId, stub.LastStartRoomId); + Assert.Equal(Round, stub.LastStartRound); + Assert.Equal(playerIds, stub.LastStartPlayerIds); + + var submitResponse = new RespSubmitRockPaperScissorsGesture(); + var submit = await RockPaperScissorsMatchBridge.TrySubmitGestureAsync(ActorId, RoomId, Round, 2, submitResponse); + Assert.True(submit.Handled); + Assert.NotNull(submit.View); + Assert.Equal(0, submitResponse.ErrorCode); + Assert.NotNull(submitResponse.GameInfo); + Assert.Equal(ActorId, stub.LastSubmitScope.PlayerId); + Assert.Equal(TenantId, stub.LastSubmitScope.TenantId); + Assert.Equal(ServerId, stub.LastSubmitScope.ServerId); + Assert.Equal(RoomId, stub.LastSubmitRoomId); + Assert.Equal(Round, stub.LastSubmitRound); + Assert.Equal(2, stub.LastSubmitGesture); + }); } /// @@ -328,6 +360,30 @@ public void ComputeRoomAction_DecisionMatrix(OnlineMatchBridgeGameState viewStat Assert.Equal(expected, RockPaperScissorsMatchBridge.ComputeRoomAction(viewState, roomStatus)); } + /// + /// 授权作用域提供者桩(模拟 online-foundation 发布的组合根授权三元组)。 + /// + private sealed class StubScopeProvider : IOnlineAuthorizedScopeProvider + { + /// 授权作用域。 + private readonly OnlineScope _scope; + + /// + /// 初始化授权作用域提供者桩。 + /// + /// 授权作用域三元组。 + public StubScopeProvider(OnlineScope scope) + { + _scope = scope; + } + + /// + public OnlineScope AuthorizedScope + { + get { return _scope; } + } + } + /// /// 契约面桩:记录调用参数,可注入结果工厂或异常。 /// @@ -336,6 +392,9 @@ private sealed class StubMatchBridge : IOnlineMatchGameBridge /// 开局调用计数。 public int StartCallCount; + /// 提交调用计数。 + public int SubmitCallCount; + /// 提交结果工厂(缺省返回保密视图)。 public Func> SubmitResult = () => OnlineResult.Ok(SecretView()); @@ -377,6 +436,7 @@ public Task> StartRoomGameAsync(OnlineSc /// public Task> SubmitGestureAsync(OnlineScope scope, long roomId, int round, int gesture, CancellationToken cancellationToken = default) { + SubmitCallCount++; LastSubmitScope = scope; LastSubmitRoomId = roomId; LastSubmitRound = round; diff --git a/Tests/GameFrameX.Tests/StartUp/ConfigStartupValidatorTests.cs b/Tests/GameFrameX.Tests/StartUp/ConfigStartupValidatorTests.cs index 3544f86dd..b1944269c 100644 --- a/Tests/GameFrameX.Tests/StartUp/ConfigStartupValidatorTests.cs +++ b/Tests/GameFrameX.Tests/StartUp/ConfigStartupValidatorTests.cs @@ -227,8 +227,8 @@ public void Validate_DisabledListenerRole_DoesNotMaskPortConflict() { var sections = new List { - new AppSetting { ServerType = "Game", ServerId = 1001, InnerPort = 29100, OuterPort = 29101, HttpPort = 28080, IsEnableOnlineAdmin = true, OnlineAdminPort = 28090 }, - new AppSetting { ServerType = "Social", ServerId = 5531, InnerPort = 29400, OuterPort = 29401, HttpPort = 28081, IsEnableOnlineAdmin = true, OnlineAdminPort = 28090 }, + new AppSetting { ServerType = "Game", ServerId = 1001, InnerPort = 29100, OuterPort = 29101, HttpPort = 28080, Online = new OnlineCapabilityOptions { EnableAdminApi = true }, OnlineAdminPort = 28090 }, + new AppSetting { ServerType = "Social", ServerId = 5531, InnerPort = 29400, OuterPort = 29401, HttpPort = 28081, Online = new OnlineCapabilityOptions { EnableAdminApi = true }, OnlineAdminPort = 28090 }, new AppSetting { ServerType = "Chat", ServerId = 6611, InnerPort = 29700, OuterPort = 29701, HttpPort = 28082 }, }; @@ -263,15 +263,15 @@ public void Validate_InnerAndOuterPortSameValueWithinOneRole_Passes() } /// - /// 使能开关开启后的同端口才构成冲突:两段 OnlineAdminPort 同值且 IsEnableOnlineAdmin=true → fail fast。 + /// 使能开关开启后的同端口才构成冲突:两段 OnlineAdminPort 同值且 Online 配置段 EnableAdminApi=true → fail fast。 /// [Fact] public void Validate_EnabledSameOnlineAdminPort_ThrowsPortConflict() { var sections = new List { - new AppSetting { ServerType = "Game", ServerId = 1001, InnerPort = 29100, OuterPort = 29100, HttpPort = 28080, IsEnableOnlineAdmin = true, OnlineAdminPort = 28090 }, - new AppSetting { ServerType = "Social", ServerId = 5531, InnerPort = 29400, OuterPort = 29400, HttpPort = 28081, IsEnableOnlineAdmin = true, OnlineAdminPort = 28090 }, + new AppSetting { ServerType = "Game", ServerId = 1001, InnerPort = 29100, OuterPort = 29100, HttpPort = 28080, Online = new OnlineCapabilityOptions { EnableAdminApi = true }, OnlineAdminPort = 28090 }, + new AppSetting { ServerType = "Social", ServerId = 5531, InnerPort = 29400, OuterPort = 29400, HttpPort = 28081, Online = new OnlineCapabilityOptions { EnableAdminApi = true }, OnlineAdminPort = 28090 }, }; var exception = Assert.Throws(() => ConfigStartupValidator.Validate( diff --git a/Tests/GameFrameX.Tests/Utility/OnlineCapabilityConfigSectionTests.cs b/Tests/GameFrameX.Tests/Utility/OnlineCapabilityConfigSectionTests.cs index 2876f8534..96b440abc 100644 --- a/Tests/GameFrameX.Tests/Utility/OnlineCapabilityConfigSectionTests.cs +++ b/Tests/GameFrameX.Tests/Utility/OnlineCapabilityConfigSectionTests.cs @@ -29,6 +29,7 @@ using System; using System.Linq; +using System.Text.Json; using GameFrameX.Foundation.Json; using GameFrameX.Utility.Setting; using Xunit; @@ -36,9 +37,10 @@ namespace GameFrameX.Tests.Utility; /// -/// Online 能力配置段绑定测试(C229):角色配置 json 的嵌套 "Online" 节点经 +/// Online 能力配置段绑定测试(C229 立项,C294 键集重定):角色配置 json 的嵌套 "Online" 节点经 /// GlobalSettings.Load 的反序列化通道(List<AppSetting>)捕获为强类型 POCO; -/// 节点缺失 = null(全装配语义)、空数组如实绑定(由组合入口归一为不过滤)。 +/// 节点缺失 = null(全装配语义)、空数组如实绑定(由组合入口归一为不过滤); +/// 双闸(EnableRuntime / EnableAdminApi)并入配置段,能力级参数经 Settings 原始 JSON 节点承载。 /// public class OnlineCapabilityConfigSectionTests { @@ -80,8 +82,7 @@ public void Deserialize_WithoutOnlineSection_LeavesNull() [ { "ServerType": "Game", - "ServerId": 1001, - "IsEnableOnline": true + "ServerId": 1001 } ] """; @@ -121,21 +122,24 @@ public void Deserialize_EmptyArrays_BindEmptyLists() } /// - /// 既有配置面零破坏:Online 平铺开关与嵌套段并存绑定互不干扰。 + /// 双闸并入配置段:EnableRuntime / EnableAdminApi / EnableRoles / UseGameDbStores 在 "Online" 节点内绑定, + /// 与平铺的作用域字段(OnlineTenantId / OnlineAppId)并存互不干扰;未配置的过滤表保持 null。 /// [Fact] - public void Deserialize_FlatSwitchesAndNestedSection_Coexist() + public void Deserialize_SectionGatesAndFlatScopeFields_Coexist() { const string json = """ [ { "ServerType": "Game", "ServerId": 1001, - "IsEnableOnline": true, - "IsEnableOnlineAdmin": true, "OnlineTenantId": 1, "OnlineAppId": 1, "Online": { + "EnableRuntime": true, + "EnableAdminApi": true, + "EnableRoles": ["Game", "Social"], + "UseGameDbStores": true, "Disable": ["online-operations"] } } @@ -146,12 +150,88 @@ public void Deserialize_FlatSwitchesAndNestedSection_Coexist() Assert.NotNull(settings); var setting = Assert.Single(settings); - Assert.True(setting.IsEnableOnline); - Assert.True(setting.IsEnableOnlineAdmin); Assert.Equal(1, setting.OnlineTenantId); Assert.Equal(1, setting.OnlineAppId); Assert.NotNull(setting.Online); + Assert.True(setting.Online.EnableRuntime); + Assert.True(setting.Online.EnableAdminApi); + Assert.True(setting.Online.UseGameDbStores); + Assert.Equal(new[] { "Game", "Social" }, setting.Online.GetEffectiveEnableRoles().ToArray()); Assert.Null(setting.Online.Capabilities); Assert.Equal(new[] { "online-operations" }, setting.Online.Disable.ToArray()); } + + /// + /// 段内开关缺省:未写双闸 = 业务面与管理面都不装配;未写 EnableRoles = 生效角色表回落 ["Game"]。 + /// + [Fact] + public void Deserialize_SectionWithoutGates_DefaultsToDisabledAndGameRole() + { + const string json = """ + [ + { + "ServerType": "Game", + "ServerId": 1001, + "Online": { + "Capabilities": ["online-assets"] + } + } + ] + """; + + var settings = JsonHelper.Deserialize>(json); + + Assert.NotNull(settings); + var section = Assert.Single(settings).Online; + Assert.NotNull(section); + Assert.False(section.EnableRuntime); + Assert.False(section.EnableAdminApi); + Assert.False(section.UseGameDbStores); + Assert.Null(section.EnableRoles); + Assert.Equal(new[] { "Game" }, section.GetEffectiveEnableRoles().ToArray()); + } + + /// + /// 能力级参数节点:Settings 按原始 JsonElement 绑定(键 = 能力 Id,值原样保留供装配期 Options 绑定器逐字段合并); + /// 节点缺失时为默认 JsonElement(ValueKind = Undefined,机制按「无覆盖」处理)。 + /// + [Fact] + public void Deserialize_Settings_BindsRawJsonElement_AndIsUndefinedWhenAbsent() + { + const string json = """ + [ + { + "ServerType": "Game", + "ServerId": 1001, + "Online": { + "Settings": { + "online-social": { + "Friend": { "FriendLimit": 50 } + } + } + } + }, + { + "ServerType": "Social", + "ServerId": 5531, + "Online": { + "EnableRuntime": true + } + } + ] + """; + + var settings = JsonHelper.Deserialize>(json); + + Assert.NotNull(settings); + Assert.Equal(2, settings.Count); + + var configured = settings[0].Online.Settings; + Assert.Equal(JsonValueKind.Object, configured.ValueKind); + var social = configured.GetProperty("online-social"); + Assert.Equal(JsonValueKind.Object, social.ValueKind); + Assert.Equal(50, social.GetProperty("Friend").GetProperty("FriendLimit").GetInt32()); + + Assert.Equal(JsonValueKind.Undefined, settings[1].Online.Settings.ValueKind); + } } From 943fc688a87f62c18a2a191d8dd54e3e2da6a000 Mon Sep 17 00:00:00 2001 From: Blank Date: Sat, 24 Oct 2026 03:33:38 +0800 Subject: [PATCH 6/6] =?UTF-8?q?doc(readme):=20=E5=90=8C=E6=AD=A5=E8=83=BD?= =?UTF-8?q?=E5=8A=9B=E6=B3=A8=E5=85=A5=E9=87=8D=E8=AE=BE=E8=AE=A1=E4=B8=8E?= =?UTF-8?q?=20Online=20=E9=85=8D=E7=BD=AE=E6=AE=B5=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 五语言 README 更新 Online 配置段键集、管理面开关与元端点(action 共 35); CHANGELOG 记录 C294 破坏性切换全部条目与迁移写法。 Linear: GFX-998 --- CHANGELOG.md | 12 ++++++++++++ README.ja.md | 6 +++--- README.ko.md | 4 ++-- README.md | 6 +++--- README.zh-CN.md | 6 +++--- README.zh-TW.md | 4 ++-- 6 files changed, 25 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9eb8c0b81..db5f6fba2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,6 +68,18 @@ ### Breaking +* Capability 能力注入机制重设计(C294,GFX-998,破坏性一次性切换,能力未上线无兼容层): + - 模块契约改为三段生命周期:`ICapabilityModule` 新增 `ActivateAsync(CapabilityActivationContext, CancellationToken)` / `DeactivateAsync(CancellationToken)`;激活按拓扑序执行、中途失败逆序回滚,停止为停调度 → 逆序 Deactivate → 机制自动撤销全部桥。四桥发布、榜单预建、撮合通知订阅、旧好友数据迁移迁入所属能力模块。 + - 桥机制化:泛型 `CapabilityBridge.TryAcquire` / `CapabilityBridgeLease`(撤销 / 替换后抛 `CapabilityBridgeUnavailableException`,族层映射 8002),发布只经 `CapabilityActivationContext.Bridges.Publish`,同契约重复发布 fail-fast;删除 `Online.Abstractions/Bridges/` 四对 holder/lease 与 Launcher 四个 `*BridgeWiring`。 + - 服务注册改为描述符表(类型键 + 命名键 + 懒工厂 + 来源),替换实现走显式 `CapabilityCompositionOptions.Override`;删除 `TryGetService` / `AddService(旧)` / `UseService` 与全部探测回退,可选依赖改 `GetOptionalService`(仅 manifest 声明的可选能力缺席时返回 null),扩展点枚举 `GetServices` 仅 Activate 期可用。 + - 族组合骨架:`CapabilityComposition.Compose(ICapabilityFamily, CapabilityCompositionOptions)` 取代 `AddOnlineCapabilities` / `OnlineCapabilityComposition(Options)` / `OnlineAdminApiComposition`;Online 族实现 `OnlineCapabilityFamily`(族基建含管理面适配器);admin 组装上移 `Hosting.AdminApi`,同名 action 直接失败,声明 / 实注册双向对账;新增元端点 `list_capability_services` / `list_capability_settings`,`list_capabilities` 增依赖状态与缺失功能。manifest 补声明 `query_leaderboard`(online-competition)、`query_match_settlement`(online-match),admin action 共 35 个。 + - 依赖分必选 / 可选,任何 `Register` 前统一检查:必选缺失或成环打 Error 并抛 `CapabilityAssemblyException`(一次报全、环路列完整路径),可选缺失打 Info「缺失功能「…」,不影响 … 其余功能运行」;同层按能力 Id 序数定序。依赖边:match → assets / matchmaking、competition → assets / match、operations → assets / social / match / matchmaking 由必选改为可选。 + - 参数:默认值只在能力包 Options 根类型定义(新增 `OnlineFriendOptions`,删除 `OnlineAssetsOptions` 与全部 `?? new XxxOptions()` 兜底),装配按「包内默认 → `Online.Settings.<能力Id>` → 代码 `Configure`」逐字段覆盖后 `Validate`,未知能力 Id / 未知字段 / 类型不符 fail-fast。回调:宿主扩展点 `RequireHostService`、Hotfix 钩子 `DeclareHook` + `CapabilityHook.Attach`(逐调用读取当前实现,聊天审核钩子已接线)。 + - 事件:删除 `CapabilityEventJob` / `CapabilityEventNotification` / `RaiseEvent`,业务事件统一机制层 `CapabilityEventBus`(Register 期订阅、Activate 前拒绝发布),topic 重整为 `<族>.<域>.<事件>`。调度器支持 `ISchedulerClock` 注入、tick job 逐个隔离并记录日志与指标,停止后再 `Start` 抛 `InvalidOperationException`。 + - 启动:`AppStartUpGameServerBase` 模板新增 `ComposeCapabilitiesAsync`(热更装载之后、网络监听之前,同族进程内单例);`AppStartUpGame` 删除三个 Online 编排方法与两个字段。 + - 配置:`AppSetting.IsEnableOnline` / `IsEnableOnlineAdmin` 删除,并入 "Online" 段 `EnableRuntime` / `EnableAdminApi`,新增 `EnableRoles`(缺省 `["Game"]`)与 `Settings`;删除键 `EnableMatchGameBridge` / `FriendBridgeSeedPlayerIds` / `MatchBridgeLeaderboardId`(迁入 `Settings.online-competition.LeaderboardProjection`)。迁移写法:CLI `--IsEnableOnline` / `--IsEnableOnlineAdmin` 改为 `Configs/app_config.json` 对应 Role 段 `"Online": { "EnableRuntime": true, "EnableAdminApi": true }`。 + - 护栏:GFX0018 改为按泛型符号判定(替代名字清单),覆盖 `Bridges.Publish<>` / `TryAcquire` / `Attach` / `Override<>` / `Configure<>` / Register 期专用 API;GFX0017 机制白名单与 `IsMechanism` 收敛为同一集合(`Hosting.Core` 属机制包);`IOnlineChatModerationHook` 及消息 / 裁决类型上移 `Online.Abstractions`,Hotfix 不再引用 Social 实现包。退役 22 个 `Launcher.Online*` 本地化键。 + * 全解决方案 Task/ValueTask 成员方法统一 Async 后缀(C290,204 处、15 工程,零行为变更):返回 `Task` / `ValueTask` 的接口与类成员方法(任意可见性,含 `Task` / `ValueTask` 任意泛型深度与元组形态)一律 `Async` 后缀,接口 / 虚链整链一致改名——`IActor` / `IComponentAgent` / `IEventListener` / `ITimerHandler` / `IMessageHandler` / `IHotfixBridge` / `IDatabaseService` / `IHttpHandler` / `IPlayerSessionManager` / `IWorkerActor` 及其全部实现链(如 `SaveAll→SaveAllAsync`、`TimerSave→TimerSaveAsync`、`InnerAction→InnerActionAsync`、`Enqueue→EnqueueAsync`、`IDatabaseService.Open/Close→OpenAsync/CloseAsync`、`GameDb.Init→InitAsync`、`UpdateSession→UpdateSessionAsync`、`HttpHandler.HandleRequest→HandleRequestAsync`、`IHttpHandler.Action/ActionMessageObject→ActionAsync/ActionMessageObjectAsync`,权威映射 = gfx-doc archived C290 rename-map.md)。撞名配对按 `InitCore`/`CloseCoreAsync` 先例以 `CoreAsync` 消解:`QuartzTimer` 私有 `StopAsync→StopCoreAsync` 让位公开 `Stop→StopAsync`、`AppStartUpByServer.StartServer→StartServerCoreAsync`、Base Handler 既有 `InnerActionAsync→InnerActionCoreAsync` 让位公开编排入口。豁免:外部契约实现(Quartz `IJob.Execute` / TouchSocket `MiddlewareBase.RegisterSession`/`UnRegisterSession`)、名已含 `Async` 记号、局部函数、测试方法、`GameFrameX.ProtoBuf.Net`(fork,用户裁定不处理)。对外破坏性(公开 API 批量更名):受影响 API 全部在解决方案内(含 Tests)闭环消费、无外部消费者;XML doc / cref、本地化值内嵌方法名(键名不动,en + zh-CN 双 resx)、WrapperGenerator 发射模板与跳过表、测试反射字符串随迁;验证 = 构建 0 错 0 新增警告 + 全量测试与基线零漂移 + 语义级复扫生产代码归零 + 605 hunk diff 逐 hunk 纯改名审计(0 控制流丢失)。 * 组件代理事件派发扩展下沉 Core 并退役服务器段广播(C289,GFX-991):`EventDispatcherExtensions`(`agent.Dispatch(eventArgs)`)自 `GameFrameX.Hotfix/Common/Events/` `git mv` 至 `GameFrameX.Core/Events/`(命名空间 `GameFrameX.Hotfix.Common.Events → GameFrameX.Core.Events`),删除生产零调用的服务器段广播分支(原 `IServerScopeEvent` 载荷经 `ServerComponentAgent.OnlineRoleForeach` 遍历在线玩家派发),自处理与静态 `EventDispatcher` 共用同一监听器循环 `DispatchToActorAsync`(形参 `Actor → IActor`、`private → internal`),日志键统一 `Core.EventDispatcher.*`(退役 `Hotfix.Event.SelfHandle*`)。对外破坏性(公开 API 删除):`IServerScopeEvent`、`ServerComponent.OnlineSet`、`ServerComponentAgent.AddOnlinePlayer` / `RemoveOnlinePlayer` / `IsOnline` / `OnlineRoleForeach`、扩展命名空间变化;迁移写法——在线判定改 `PlayerSessionManager.Instance.GetByPlayerId(playerId) != null`(在线人数 / 列表 `Count()` / `GetPageList()`,跨服路由 `TryGetPlayerRoute`),扩展引用改 `using GameFrameX.Core.Events;`(组件代理侧调用写法 `this.Dispatch(...)` 零变化),`IServerScopeEvent` 无替代(组件代理侧不再广播,需要遍历在线玩家时以会话层为在线事实源自行派发);`ServiceOnlineEventArgs` / `ServiceOfflineEventArgs` 去掉该接口实现后静态派发行为不变。 diff --git a/README.ja.md b/README.ja.md index 48b29efbb..e8a2825cb 100644 --- a/README.ja.md +++ b/README.ja.md @@ -202,7 +202,7 @@ GameFrameX はコマンドライン引数(`--Key=Value`)で設定を行い | `HttpSignSecret` | HTTP 署名シークレット(HMAC-SHA256)、署名検証有効時は必須。空の場合はすべて拒否(fail-closed) | なし | `a-strong-secret` | | `HttpUrl` | API ルートパス | `/game/api/` | `/game/api/` | | `HttpIsDevelopment` | HTTP 開発モード(Swagger を有効化) | `false` | `true` | -| `IsEnableOnlineAdmin` | Online Runtime + 管理 API をプロセス内でホスト | `false` | `true` | +| `Online` | Online 機能ファミリーの設定セクション:`EnableRuntime` / `EnableAdminApi`(既定 `false`)、`EnableRoles`(既定 `["Game"]`)、`Capabilities` / `Disable` フィルター、`UseGameDbStores`、機能 Id ごとの `Settings` | なし | `Configs/app_config.json` を参照 | | `OnlineAdminPort` | Online 管理 API リッスンポート | `28090` | `28090` | | `OnlineAdminApiPrefix` | Online 管理 API ルートプレフィックス | `online/admin` | `online/admin` | | `OnlineTenantId` | Online Runtime 認可テナント ID(スコープトリプル) | `0` | `1` | @@ -538,9 +538,9 @@ docker compose down #### Online プラットフォーム管理 API -`IsEnableOnlineAdmin=true` の場合、Game プロセスは Online Runtime(インメモリストア上のアセット / セッション / マッチング / ソーシャル / LiveOps サービスを含むプロセス内機能ライブラリ)を組み立て、管理用 HTTP API を独立した Kestrel リスナーで公開します: +`Online.EnableAdminApi=true`(かつ現在の Role が `Online.EnableRoles` に含まれる)の場合、プロセスは `CapabilityComposition.Compose` で Online 機能ファミリーを組み立て(既定ではインメモリストア上のアセット / セッション / マッチング / ソーシャル / LiveOps サービスを含むプロセス内機能ライブラリ)、管理用 HTTP API を独立した Kestrel リスナーで公開します: -- エンドポイント: `POST http://:28090/online/admin/{action}`(全 33 の管理アクション + `list_capabilities` メタエンドポイント。HTTP ステータスは常に 200、業務結果は内部エンベロープの `Code` で返却) +- エンドポイント: `POST http://:28090/online/admin/{action}`(全 35 の管理アクション + `list_capabilities` / `list_capability_services` / `list_capability_settings` メタエンドポイント。HTTP ステータスは常に 200、業務結果は内部エンベロープの `Code` で返却) - スコープトリプル: 各リクエストは `TenantId` / `AppId` / `ServerId` を携行し、認可された `OnlineTenantId` / `OnlineAppId` / `ServerId` と不一致の場合は 3002/3003/3004、トリプル欠落の場合は 3005 で拒否されます - GameFrameX Admin コンソールへのサーバー登録: エリアの `HttpManageUrl` を `http://:28090` に設定します(`online/admin` プレフィックスとアクション名は Admin クライアント側のワイヤ契約に従って付加されます) diff --git a/README.ko.md b/README.ko.md index ccad3e523..ab8cdba5f 100644 --- a/README.ko.md +++ b/README.ko.md @@ -537,9 +537,9 @@ docker compose down #### Online 플랫폼 관리 API -`IsEnableOnlineAdmin=true`이면 Game 프로세스가 Online Runtime(인메모리 저장소 기반의 에셋 / 세션 / 매치메이킹 / 소셜 / LiveOps 서비스를 갖춘 인프로세스 역량 라이브러리)을 구성하고, 관리 HTTP API를 독립된 Kestrel 리스너에서 노출합니다: +`Online.EnableAdminApi=true`(그리고 현재 역할이 `Online.EnableRoles`에 포함)이면 프로세스가 `CapabilityComposition.Compose`로 Online 역량 패밀리를 구성하고(기본값은 인메모리 저장소 기반의 에셋 / 세션 / 매치메이킹 / 소셜 / LiveOps 서비스를 갖춘 인프로세스 역량 라이브러리), 관리 HTTP API를 독립된 Kestrel 리스너에서 노출합니다: -- 엔드포인트: `POST http://:28090/online/admin/{action}`(관리 액션 33개 전체 + `list_capabilities` 메타 엔드포인트, HTTP 상태는 항상 200. 비즈니스 결과는 내부 엔벨로프 `Code`로 전달) +- 엔드포인트: `POST http://:28090/online/admin/{action}`(관리 액션 35개 전체 + `list_capabilities` / `list_capability_services` / `list_capability_settings` 메타 엔드포인트, HTTP 상태는 항상 200. 비즈니스 결과는 내부 엔벨로프 `Code`로 전달) - 스코프 삼중항: 각 요청은 `TenantId` / `AppId` / `ServerId`를 전달하며, 인가된 `OnlineTenantId` / `OnlineAppId` / `ServerId`와 불일치하면 3002/3003/3004, 삼중항 누락 시 3005로 거부 - GameFrameX Admin 콘솔 서버 등록: 대상 영역의 `HttpManageUrl`을 `http://:28090`로 설정(`online/admin` 접두사와 액션 이름은 Admin 클라이언트가 와이어 계약에 따라 추가) diff --git a/README.md b/README.md index a0f56569e..96771ecd5 100644 --- a/README.md +++ b/README.md @@ -229,7 +229,7 @@ GameFrameX uses command-line arguments (`--Key=Value`) for configuration. All co | `HttpSignSecret` | HMAC-SHA256 secret for HTTP signature validation (required once signing is enabled; blank = fail-closed) | None | `a-strong-secret` | | `HttpUrl` | API root path | `/game/api/` | `/game/api/` | | `HttpIsDevelopment` | HTTP development mode (enables Swagger) | `false` | `true` | -| `IsEnableOnlineAdmin` | Host the Online Runtime + admin API in-process | `false` | `true` | +| `Online` | Online capability family section: `EnableRuntime` / `EnableAdminApi` (default `false`), `EnableRoles` (default `["Game"]`), `Capabilities` / `Disable` filters, `UseGameDbStores`, per-capability `Settings` | None | see `Configs/app_config.json` | | `OnlineAdminPort` | Online admin API listen port | `28090` | `28090` | | `OnlineAdminApiPrefix` | Online admin API route prefix | `online/admin` | `online/admin` | | `OnlineTenantId` | Online runtime authorized tenant id (scope triple) | `0` | `1` | @@ -565,9 +565,9 @@ Service port mapping: #### Online Platform Admin API -When `IsEnableOnlineAdmin=true`, the Game process assembles the Online Runtime (in-process capability library: asset / session / match / social / LiveOps services on in-memory stores) and exposes the admin HTTP API on an independent Kestrel listener: +When `Online.EnableAdminApi=true` (and the current Role is listed in `Online.EnableRoles`), the process composes the Online capability family via `CapabilityComposition.Compose` (in-process capability library: asset / session / match / social / LiveOps services on in-memory stores by default) and exposes the admin HTTP API on an independent Kestrel listener: -- Endpoint: `POST http://:28090/online/admin/{action}` (all 33 admin actions plus the `list_capabilities` meta endpoint, HTTP status is always 200; business results travel in the inner envelope `Code`) +- Endpoint: `POST http://:28090/online/admin/{action}` (all 35 admin actions plus the `list_capabilities` / `list_capability_services` / `list_capability_settings` meta endpoints, HTTP status is always 200; business results travel in the inner envelope `Code`) - Scope triple: each request carries `TenantId` / `AppId` / `ServerId`; requests that do not match the authorized `OnlineTenantId` / `OnlineAppId` / `ServerId` are rejected with 3002/3003/3004, missing triple with 3005 - Registering the server in the GameFrameX Admin console: set the area's `HttpManageUrl` to `http://:28090` (the `online/admin` prefix and the action name are appended by the Admin client, per its wire contract) diff --git a/README.zh-CN.md b/README.zh-CN.md index a1676c650..c05cdb706 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -205,7 +205,7 @@ GameFrameX 使用命令行参数 (`--Key=Value`) 进行配置,所有配置项 | `HttpSignSecret` | HTTP 签名密钥(HMAC-SHA256),开启签名校验后必填;为空时一律拒绝(fail-closed) | 无 | `a-strong-secret` | | `HttpUrl` | API 接口根路径 | `/game/api/` | `/game/api/` | | `HttpIsDevelopment` | HTTP 开发模式(启用 Swagger) | `false` | `true` | -| `IsEnableOnlineAdmin` | 是否在进程内托管 Online Runtime 与管理 API | `false` | `true` | +| `Online` | Online 能力族配置段:`EnableRuntime` / `EnableAdminApi`(缺省 `false`)、`EnableRoles`(缺省 `["Game"]`)、`Capabilities` / `Disable` 过滤、`UseGameDbStores`、按能力 Id 寻址的 `Settings` | 无 | 见 `Configs/app_config.json` | | `OnlineAdminPort` | Online 管理 API 监听端口 | `28090` | `28090` | | `OnlineAdminApiPrefix` | Online 管理 API 路由前缀 | `online/admin` | `online/admin` | | `OnlineTenantId` | Online Runtime 授权租户 ID(作用域三元组) | `0` | `1` | @@ -538,9 +538,9 @@ docker compose down #### Online 平台管理 API -当 `IsEnableOnlineAdmin=true` 时,Game 进程会组装 Online Runtime(进程内能力库:基于内存存储的资产 / 会话 / 匹配 / 社交 / LiveOps 服务),并在独立的 Kestrel 监听器上暴露管理 HTTP API: +当 `Online.EnableAdminApi=true`(且当前 Role 在 `Online.EnableRoles` 内)时,进程经 `CapabilityComposition.Compose` 组合 Online 能力族(进程内能力库:缺省基于内存存储的资产 / 会话 / 匹配 / 社交 / LiveOps 服务),并在独立的 Kestrel 监听器上暴露管理 HTTP API: -- 端点:`POST http://:28090/online/admin/{action}`(全部 33 个管理动作 + `list_capabilities` 元端点,HTTP 状态码恒为 200;业务结果通过内层信封 `Code` 返回) +- 端点:`POST http://:28090/online/admin/{action}`(全部 35 个管理动作 + `list_capabilities` / `list_capability_services` / `list_capability_settings` 元端点,HTTP 状态码恒为 200;业务结果通过内层信封 `Code` 返回) - 作用域三元组:每个请求携带 `TenantId` / `AppId` / `ServerId`;与授权的 `OnlineTenantId` / `OnlineAppId` / `ServerId` 不匹配的请求分别以 3002/3003/3004 拒绝,缺失三元组则以 3005 拒绝 - 在 GameFrameX Admin 控制台注册服务器:将大区的 `HttpManageUrl` 指向 `http://:28090`(`online/admin` 前缀与动作名由 Admin 客户端按其通信协议自动追加) diff --git a/README.zh-TW.md b/README.zh-TW.md index ecd2e6cc2..bcf6d5867 100644 --- a/README.zh-TW.md +++ b/README.zh-TW.md @@ -534,9 +534,9 @@ docker compose down #### Online 平台管理 API -當 `IsEnableOnlineAdmin=true` 時,Game 程序會組裝 Online Runtime(程序內能力庫:基於記憶體儲存的資產 / 工作階段 / 配對 / 社交 / LiveOps 服務),並在獨立的 Kestrel 監聽器上暴露管理 HTTP API: +當 `Online.EnableAdminApi=true`(且目前 Role 在 `Online.EnableRoles` 內)時,程序經 `CapabilityComposition.Compose` 組合 Online 能力族(程序內能力庫:預設基於記憶體儲存的資產 / 工作階段 / 配對 / 社交 / LiveOps 服務),並在獨立的 Kestrel 監聽器上暴露管理 HTTP API: -- 端點:`POST http://:28090/online/admin/{action}`(全部 33 個管理動作 + `list_capabilities` 元端點,HTTP 狀態碼恆為 200;業務結果透過內層封套 `Code` 傳回) +- 端點:`POST http://:28090/online/admin/{action}`(全部 35 個管理動作 + `list_capabilities` / `list_capability_services` / `list_capability_settings` 元端點,HTTP 狀態碼恆為 200;業務結果透過內層封套 `Code` 傳回) - 作用域三元組:每個請求攜帶 `TenantId` / `AppId` / `ServerId`;與授權的 `OnlineTenantId` / `OnlineAppId` / `ServerId` 不匹配的請求分別以 3002/3003/3004 拒絕,缺少三元組則以 3005 拒絕 - 在 GameFrameX Admin 控制檯註冊伺服器:將區服的 `HttpManageUrl` 指向 `http://:28090`(`online/admin` 前綴與動作名由 Admin 客戶端依其線路約定附加)