docs(spec): decouple platform and subgames
This commit is contained in:
@@ -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<Request, Response> {
|
||||
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-key>/
|
||||
├── 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. 后续子项目边界
|
||||
|
||||
|
||||
Reference in New Issue
Block a user