diff --git a/docs/superpowers/specs/2026-09-04-platform-vertical-slice-contract-inventory.md b/docs/superpowers/specs/2026-09-04-platform-vertical-slice-contract-inventory.md index 9b38f36..83d5959 100644 --- a/docs/superpowers/specs/2026-09-04-platform-vertical-slice-contract-inventory.md +++ b/docs/superpowers/specs/2026-09-04-platform-vertical-slice-contract-inventory.md @@ -150,7 +150,61 @@ videoConfig, shortcode, match, matchid, agreefree, isbet, deskinfo | roomtype | RoomState 原样值 | UI 重新生成或规范化 | | deskinfo | 当前入站响应中的原样值,完成一次性派发后由子游戏接管 | PlatformState 或平台拆解后的派生对象 | -## 7. 首批 UI 对应关系 +## 7. 子游戏解耦契约 + +### 7.1 公开边界 + +| 编号 | 契约 | 验收方式 | +|---|---|---| +| GAME-01 | 平台核心只依赖 `sdk/contracts`,不导入具体游戏 | import-boundary test | +| GAME-02 | 具体游戏只依赖 `sdk/contracts` 与自身目录,不导入 platform/net/protocol/ui 内部 | import-boundary test | +| GAME-03 | SDK contracts 不依赖 Store、Reactive、EventBus、WebSocket 或 `cc` | import-boundary test | +| GAME-04 | 每个游戏以 `GameDescriptor` 注册,key/gameId/route 唯一 | registry unit test | +| GAME-05 | `apiVersion` 必须与平台完全匹配 | mismatch failure test | +| GAME-06 | descriptor factory 每局产生新 GameModule | identity assertion | +| GAME-07 | 平台→游戏只走生命周期和 `PlatformToGameEvent` | fake module event log | +| GAME-08 | 游戏 route 包只走 `handleGameMessage` | router integration test | +| GAME-09 | deskinfo 只走 `restore`,平台不保存、不解析 | reference/value assertion | +| GAME-10 | 游戏→平台只走受限 `GameHost` | public type and import scan | +| GAME-11 | `sendGameMessage` 自动绑定本游戏 route | encoded envelope assertion | +| GAME-12 | platform command 使用判别联合,未知命令显式失败 | exhaustive parser test | +| GAME-13 | snapshot 是冻结 DTO,不暴露内部 Store 类型 | mutation failure/type test | +| GAME-14 | dispose 后 Host 失效并释放全部订阅 | lifecycle/leak test | +| GAME-15 | 不同游戏、不同房间会话之间无状态泄漏 | two-game isolation test | + +第一版 `GameHostCommand` 只包含: + +```text +room.prepare → 发送第一批 player_prepare +room.exit → 仅未开局时发送 self_exit_room;已开局显式拒绝 +``` + +平台先提交权威 Store,再串行通知子游戏 event。`subscribe` 初次立即发 snapshot,后续仅在公开 snapshot 变化时通知;snapshot 不能替代需要恰好处理一次的 event。 + +### 7.2 现有 SDK 必须移除的耦合 + +| 现状 | 原因 | 目标 | +|---|---|---| +| `sdk/index.ts` import `platform/readonly.ts` 和 Store types | SDK 类型随平台内部重构而变化 | 使用独立 `PlatformGameSnapshot` DTO | +| `GameContext.events` 暴露内部 EventBus | 子游戏依赖平台事件实现且可产生无约束事件 | 删除;子游戏自行管理内部事件 | +| `GameNet.send(route,rpc,data)` | 子游戏可伪造平台 route | 改为自动绑定 route 的 `sendGameMessage` | +| 大量可选 hook | 未实现时静默跳过,接口持续膨胀 | 改为判别联合 `PlatformToGameEvent` | +| `IGameModule.serialize?()` | 无客户端上传快照的协议证据 | 删除;重连快照只接受服务器 deskinfo | +| `ActiveGame.set/clear` | 无注册、版本和释放校验 | 用 GameRegistry + GameSessionHost 替代 | + +### 7.3 子游戏接入步骤 + +接入一个新子游戏只允许以下步骤: + +1. 在 `assets/games//` 创建游戏自身模块、状态、组件和资源; +2. 实现 `GameModule` 与唯一 `GameDescriptor`; +3. 使用 `game-contract-harness` 跑通标准契约测试; +4. 在应用组合根注册 descriptor; +5. 增加该游戏自己的 route/rpc、roomtype 和 deskinfo 测试。 + +上述步骤不包含修改 framework Router、NetClient、PlatformState 或平台 UI。若接入必须修改这些核心模块,说明 SDK 契约缺失,应先作为平台 API 变更单独设计和升级版本。 + +## 8. 首批 UI 对应关系 | 行为 | prefab/Layer | 控制器职责 | |---|---|---| @@ -167,7 +221,7 @@ videoConfig, shortcode, match, matchid, agreefree, isbet, deskinfo 对 prefab 的节点路径、按钮绑定与组件挂载,以当前 Cocos 资源实际结构为准,并在实施时通过 funplay-cocos MCP 查询和修改。 -## 8. 原生接口范围 +## 9. 原生接口范围 第一批只实现启动必需的同步读取: @@ -190,7 +244,7 @@ uAgent_3 的 `window['app_' + name]` 是原工程明确定义的独立分支, 因此现有 14 项共用白名单不能作为完整桥契约继续扩展。 -## 9. 黄金样本清单 +## 10. 黄金样本清单 实施前必须把以下样本固化到 `cocoscreator_projects/framework-tests/fixtures/protocol/`: @@ -206,7 +260,7 @@ uAgent_3 的 `window['app_' + name]` 是原工程明确定义的独立分支, 若仓库中没有真实样本,先从原客户端或测试服务器抓取并脱敏。没有证据的字段不能凭名称推断。 -## 10. 完成判据 +## 11. 完成判据 每条契约须具备: @@ -219,4 +273,6 @@ uAgent_3 的 `window['app_' + name]` 是原工程明确定义的独立分支, - 涉及 UI 时的控制器测试; - 涉及服务器时的出站信封断言。 +每个子游戏 descriptor 还必须通过 `game-contract-harness` 和 import-boundary 测试。 + 缺少上述任一项,该契约仍视为未迁移。 diff --git a/docs/superpowers/specs/2026-09-04-platform-vertical-slice-design.md b/docs/superpowers/specs/2026-09-04-platform-vertical-slice-design.md index 52638b8..96f8800 100644 --- a/docs/superpowers/specs/2026-09-04-platform-vertical-slice-design.md +++ b/docs/superpowers/specs/2026-09-04-platform-vertical-slice-design.md @@ -1,6 +1,6 @@ # YouleNexus 平台纵向链路逻辑迁移设计 -> 状态:已确认设计方向,待用户审阅本文。 +> 状态:已确认平台纵向链路方向;已纳入子游戏解耦架构,待用户复审。 > > 适用工程:`cocoscreator_projects/YouleNexus`。 > @@ -22,15 +22,19 @@ 本批不按 Layer 编号逐个补事件,也不一次性迁移全部平台功能。这样可以先固定协议边界、状态归属和运行时编排,后续 Layer 控制器只消费稳定接口。 +平台与子游戏采用端口/适配器结构:平台核心只认识稳定 SDK 契约,不导入任何具体子游戏;子游戏只依赖 SDK 契约,不导入平台 Store、Router、NetClient 或平台 UI。具体子游戏由应用组合根注册,接入新游戏不修改平台核心。 + ## 2. 不可妥协的约束 1. **服务器零改动**:信封、route、rpc、字段名、字段类型、时序与旧客户端一致。 2. **唯一数据源**:配置、协议映射、玩家实体和房间座位各有唯一权威来源。 3. **下游不兜底**:边界数据缺失或非法时显式报错;Store、控制器和视图不得猜默认值。 4. **roomtype 不解析**:平台只保存和透传具体子游戏的嵌套数组。 -5. **deskinfo 不解析**:平台只按“响应中存在 deskinfo”判断并原样传给 `IGameModule.onReconnect(deskinfo)`;不得改用 `isbattle` 作为触发条件。 +5. **deskinfo 不解析**:平台只按“响应中存在 deskinfo”判断并原样传给 `GameModule.restore(deskinfo)`;不得改用 `isbattle` 作为触发条件。 6. **Cocos 资源只经编辑器 MCP 修改**:本设计涉及 prefab 挂载或场景操作时,执行阶段必须使用 funplay-cocos MCP。 7. **旧引擎机制不复刻**:渲染循环、命中检测、对象表和逐精灵定时器交给 Cocos;只迁移业务状态机和协议行为。 +8. **依赖方向单向**:`game implementation → sdk contracts ← platform adapters`;SDK contracts 不反向依赖 platform/net/ui 实现。 +9. **能力最小化**:子游戏只能调用显式开放的 Host API;不得获得原始 EventBus、可写 Store、WebSocket 或任意 platform RPC 发送权。 权威来源按以下顺序裁决冲突: @@ -55,6 +59,7 @@ - `self_join_room`、`other_join_room`、`self_exit_room`、`other_exit_room`; - `player_prepare`、`other_offline`、`other_online`; - `deskwar` 触发开战钩子,`deskinfo` 触发恢复钩子; +- 稳定的子游戏 SDK 契约、游戏注册表、会话隔离器与接入一致性测试工具; - Loading、Login、MainMenu、JoinRoom、MainScene、准备、玩家座位、重连和踢下线界面的最小控制器接线; - 单元测试、协议黄金测试和纵向集成测试。 @@ -84,6 +89,10 @@ - `PlayerStore`、`RoomStore` 和多个 handler 用 `??` 构造服务器未提供的数据。 - `parseLoginResponse.hasBattle` 同时判断 `isbattle` 和 `deskinfo`,与源码“deskinfo 存在才调用 Reconnect”不一致。 - `native-bridge.ts` 把入站注册名和出站调用名合并成同一份 14 项白名单;原工程两者并不相同。 +- 现有 `sdk/index.ts` 直接导入 platform Store 类型和内部 EventBus,使 SDK 与平台实现耦合。 +- 现有 `GameNet.send(route,rpc,data)` 允许子游戏任意指定 route,能够绕过平台协议边界。 +- 现有 `IGameModule.serialize()` 没有对应的客户端上送协议证据;服务器重连快照来自登录/进房响应,不能保留这个推测性 API。 +- 现有 `ActiveGame.set()` 只是可变单槽,没有描述符校验、注册机制、会话释放和 API 版本检查。 ## 5. 目标结构 @@ -98,7 +107,8 @@ PlatformRuntime ├── Router ├── PlatformHandlers ├── PlatformState -├── ActiveGame +├── GameRegistry +├── GameSessionHost └── ScenePort ``` @@ -151,7 +161,7 @@ interface RpcContract { NetClient.message → Router ├── platform / agent / room → PlatformHandlers - └── activeGame.route → IGameModule.onReceive + └── activeGame.route → GameModule.handleGameMessage ``` `RoomRPCBus` 的独立监听职责被移除。不得出现第二个消费者再次筛选 `route === 'room'`。 @@ -210,24 +220,194 @@ Store action → selector → 控制器 render → Cocos 节点 视图不能直接访问 WebSocket、Router 或可写 Store。prefab 节点引用与 Button 事件必须在执行阶段通过 Cocos MCP 添加。 -### 5.7 子游戏边界 +### 5.7 子游戏 SDK 的物理边界 -第一批只要求一个测试替身实现以下接口: +SDK 分为纯契约、平台适配器和测试工具,目录职责固定: + +```text +assets/framework/sdk/ +├── contracts/ +│ ├── game-descriptor.ts # 游戏身份、route、API 版本、factory +│ ├── game-module.ts # 子游戏生命周期入口 +│ ├── game-host.ts # 子游戏可调用的平台能力 +│ ├── platform-events.ts # 平台→子游戏的判别联合事件 +│ └── snapshots.ts # 只读平台快照 DTO +├── runtime/ +│ ├── game-registry.ts # 注册、唯一性与版本校验 +│ └── game-session-host.ts # 单局 attach/enter/leave/dispose +└── testing/ + └── game-contract-harness.ts # 子游戏接入一致性测试 +``` + +`sdk/contracts` 只能依赖同目录的类型和 TypeScript 标准类型。它不能导入 `platform/`、`net/`、`protocol/`、`ui/`、Cocos `cc` 或具体游戏。 + +具体游戏统一放在: + +```text +assets/games// +├── game-descriptor.ts +├── game-module.ts +├── protocol/ +├── state/ +├── components/ +└── prefabs/ +``` + +平台核心不扫描目录、不动态猜模块;应用组合根显式导入 descriptor 并注册,保证构建期可追踪。 + +### 5.8 注册契约 + +每个子游戏只向应用层暴露一个描述符: ```ts -interface IGameModule { +type PlatformGameApiVersion = 1; + +interface GameDescriptor { + readonly apiVersion: PlatformGameApiVersion; + readonly gameId: string | number; readonly route: string; - onReceive(rpc: string, data: unknown): void; - onEnterRoom(roomtype: unknown[]): void; - onStartWar(data: unknown): void; - onReconnect(deskinfo: unknown): void; - onPlayerReady(seat: number): void; - onPlayerOffline(seat: number): void; - onPlayerOnline(seat: number, ip: string): void; + readonly key: string; + create(): GameModule; } ``` -平台不得查看 `deskinfo` 内部字段。未指定真实子游戏前,只验证调用时机、参数引用和调用次数,不声称玩法重连完成。 +`GameRegistry.register(descriptor)` 必须检查: + +- `key`、`gameId`、`route` 均非空且分别唯一; +- `apiVersion` 与平台支持版本完全相等; +- `create()` 每次返回新的 GameModule 实例; +- 重复注册或版本不匹配显式抛错,不选择“最接近版本”。 + +进入房间时依据权威 gameId/route 激活 descriptor。`GameSessionHost` 使用 `detached → attached → entered → disposed|failed` 状态机;非法次序显式抛错。离房或切换游戏时先 dispose 当前会话,下一次进房创建新实例,禁止把上一桌状态带入下一桌。每个 Host 带内部 generation,旧会话的异步回调在新会话建立后继续发包时必须失败。 + +### 5.9 平台 → 子游戏 + +平台不直接调用不断增长的可选方法集合,而是通过稳定生命周期和判别联合事件: + +```ts +type PlatformToGameEvent = + | { type: 'room-entered'; roomtype: readonly unknown[] } + | { type: 'self-joined'; seat: number } + | { type: 'player-joined'; seat: number } + | { type: 'player-left'; seat: number } + | { type: 'player-ready'; seat: number } + | { type: 'player-offline'; seat: number } + | { type: 'player-online'; seat: number; ip: string } + | { type: 'war-started'; payload: unknown } + | { type: 'room-left'; reason: RoomLeaveReason }; + +type RoomLeaveReason = + | 'self-exit' + | 'room-broken' + | 'kicked' + | 'session-replaced'; + +interface GameServerMessage { + readonly rpc: string; + readonly data: unknown; +} + +interface GameModule { + attach(host: GameHost): void; + handlePlatformEvent(event: PlatformToGameEvent): void; + handleGameMessage(message: GameServerMessage): void; + restore(deskinfo: unknown): void; + dispose(): void; +} +``` + +约束: + +- `attach`、每个房间进入事件、`restore` 和 `dispose` 的调用次数有状态机校验; +- 游戏 route 的服务端消息只形成 `GameServerMessage`,平台不解析 data; +- `restore` 的唯一触发条件是响应对象拥有 `deskinfo` 字段; +- 对同一会话的状态提交、PlatformToGameEvent 和游戏消息严格串行派发;平台先提交 Store,再发对应事件,游戏读取 snapshot 时一定看到事件后的权威状态; +- snapshot 表达“现在是什么”,event 表达“刚发生什么”,不得用 event 重建平台状态,也不得用 snapshot 猜测一次性动画事件; +- 平台不得吞掉 GameModule 抛出的契约错误;运行时转入明确 failed 状态并停止本局继续派发。 + +### 5.10 子游戏 → 平台 + +`GameHost` 是子游戏获得平台能力的唯一入口: + +```ts +interface GameHost { + readonly apiVersion: PlatformGameApiVersion; + getSnapshot(): PlatformGameSnapshot; + subscribe(listener: (snapshot: PlatformGameSnapshot) => void): Unsubscribe; + sendGameMessage(rpc: string, data: unknown): void; + execute(command: GameHostCommand): void; +} + +type Unsubscribe = () => void; + +type PublicConnectionPhase = + | 'connected' + | 'logged-in' + | 'reconnecting' + | 'slow' + | 'kicked'; + +interface PlatformGameSnapshot { + readonly connection: { readonly phase: PublicConnectionPhase }; + readonly self: { + readonly playerId: number; + readonly seat: number; + }; + readonly room: { + readonly roomcode: string; + readonly roomtype: readonly unknown[]; + readonly stage: number; + readonly needprepare: number; + readonly infinite: number; + }; + readonly seats: readonly GameSeatSnapshot[]; +} + +interface GameSeatSnapshot { + readonly seat: number; + readonly playerId: number; + readonly nickname: string; + readonly avatar: string; + readonly online: boolean; + readonly ready: boolean; +} + +type GameHostCommand = + | { readonly type: 'room.prepare' } + | { readonly type: 'room.exit' }; +``` + +- `PlatformGameSnapshot` 是冻结的只读 DTO,只包含公开身份、房间、座位和连接状态,不暴露 Store 类型或 reactive 实现。 +- `sendGameMessage` 自动绑定 descriptor 的 `route` 与固定 app,子游戏不能指定 platform/agent/room route。 +- `GameHostCommand` 是受控判别联合。第一批只开放 `room.prepare` 和 `room.exit`;命令只负责发起协议动作,成功状态由服务器响应/推送驱动。增加命令必须同步契约、适配器和一致性测试。 +- `room.exit` 在第一批仅允许未开局房间;已开局时显式抛出 unsupported command,不能误发 `self_exit_room` 代替尚未迁移的开局退出流程。 +- `subscribe` 返回明确的取消函数;会话 dispose 时 Host 自动取消全部订阅。 +- `subscribe` 注册成功后立即收到一次当前 snapshot,后续只在公开 DTO 发生变化时通知。 +- dispose 后继续调用 Host 必须抛出 `GameSessionDisposedError`。 +- 子游戏内部事件、状态和 Cocos 组件由子游戏自行管理,不借用平台 EventBus。 + +第一批用测试 GameModule 验证完整契约。未指定真实子游戏前,只验证调用时机、隔离性、参数引用和生命周期,不声称玩法重连完成。 + +### 5.11 依赖规则 + +允许的依赖方向: + +```text +app composition → platform runtime → sdk contracts +app composition → concrete game → sdk contracts +platform adapters → sdk contracts +``` + +禁止: + +```text +platform runtime → concrete game +concrete game → platform/net/protocol/ui/core internals +sdk contracts → platform/net/protocol/ui/cc +one game → another game +``` + +通过自动化 import-boundary 测试扫描源码导入路径。违反依赖方向时测试失败,而不是依赖评审人员记忆。 ## 6. 核心时序 @@ -311,12 +491,25 @@ bootstrap → open → login(no room) → join → other join → prepare → close → reconnect → login(with room + deskinfo) ``` -断言每一步的出站包、phase、Store、场景命令和 `IGameModule` 调用。 +断言每一步的出站包、phase、Store、场景命令和 `GameModule` 事件记录。 ### 8.5 真服验证 单元与集成测试通过后,使用真实测试账号和原服务器完成一次抓包验证。测试账号和服务器地址只写入调试 profile,不写入控制器或测试快照。 +### 8.6 子游戏接入一致性测试 + +`game-contract-harness` 对任意 descriptor 运行同一组测试: + +- descriptor 字段、route 与 API 版本有效; +- 两次 `create()` 返回不同实例; +- 生命周期顺序非法时显式失败; +- 游戏消息只能发往自身 route; +- snapshot 只读且不泄露 Store/Reactive/EventBus; +- dispose 自动释放订阅并使旧 Host 失效; +- 两个测试游戏顺序进入时,状态、订阅和消息不串局; +- `deskinfo` 以同一引用和值交给 `restore`,平台不解析。 + ## 9. 交付与验收 本批完成必须同时满足: @@ -330,7 +523,10 @@ bootstrap → open → login(no room) → join → other join 7. `deskinfo` 仅以“字段存在”为触发条件并原样传给游戏模块; 8. Layer 控制器只依赖 command 与只读 selector; 9. TypeScript 类型检查与全部 framework tests 通过; -10. 真实服务器抓包与原工程信封、字段和关键时序一致。 +10. 新子游戏只需提供 descriptor/module 并在应用组合根注册,不修改 framework 核心; +11. 子游戏源码没有导入 platform/net/protocol/ui 内部模块; +12. game-contract-harness 与 import-boundary 测试通过; +13. 真实服务器抓包与原工程信封、字段和关键时序一致。 ## 10. 后续子项目边界