554 lines
24 KiB
Markdown
554 lines
24 KiB
Markdown
# YouleNexus 平台纵向链路逻辑迁移设计
|
||
|
||
> 状态:已废止。本文包含运行时游戏注册及工程布局的旧假设,不得继续作为实施依据。
|
||
>
|
||
> 替代规范:`docs/superpowers/specs/2026-09-04-framework-subgame-zero-coupling-migration-design.md`。
|
||
>
|
||
> 适用工程:`cocoscreator_projects/YouleNexus`。
|
||
>
|
||
> 契约清单:`docs/superpowers/specs/2026-09-04-platform-vertical-slice-contract-inventory.md`。
|
||
|
||
## 1. 决策
|
||
|
||
第一批逻辑迁移采用“平台纵向链路优先”,只交付一个能独立验证的闭环:
|
||
|
||
```text
|
||
远程配置 / 原生身份
|
||
→ WebSocket 连接
|
||
→ player_login
|
||
→ 大厅
|
||
→ self_join_room
|
||
→ 房间玩家与准备状态
|
||
→ 断线、重登、deskinfo 透传重连
|
||
```
|
||
|
||
本批不按 Layer 编号逐个补事件,也不一次性迁移全部平台功能。这样可以先固定协议边界、状态归属和运行时编排,后续 Layer 控制器只消费稳定接口。
|
||
|
||
平台与子游戏采用端口/适配器结构:平台核心只认识稳定 SDK 契约,不导入任何具体子游戏;子游戏只依赖 SDK 契约,不导入平台 Store、Router、NetClient 或平台 UI。具体子游戏由应用组合根注册,接入新游戏不修改平台核心。
|
||
|
||
## 2. 不可妥协的约束
|
||
|
||
1. **服务器零改动**:信封、route、rpc、字段名、字段类型、时序与旧客户端一致。
|
||
2. **唯一数据源**:配置、协议映射、玩家实体和房间座位各有唯一权威来源。
|
||
3. **下游不兜底**:边界数据缺失或非法时显式报错;Store、控制器和视图不得猜默认值。
|
||
4. **roomtype 不解析**:平台只保存和透传具体子游戏的嵌套数组。
|
||
5. **deskinfo 不解析**:平台严格复刻原工程 `if (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 发送权。
|
||
|
||
权威来源按以下顺序裁决冲突:
|
||
|
||
1. `docs/protocol/` 与其引用的原工程源码行;
|
||
2. `projects/Game_Surface_3/js/00_Surface/` 实际行为;
|
||
3. 本设计与契约清单;
|
||
4. 现有 YouleNexus 实现和测试。
|
||
|
||
现有测试若与前三级冲突,修改测试,不保留错误兼容。
|
||
|
||
## 3. 范围
|
||
|
||
### 3.1 本批包含
|
||
|
||
- release/debug 运行模式判定;
|
||
- H5 查询参数与原生 `window.settings` 身份读取;
|
||
- `gameconfig` 覆盖、`gameserver` 远程配置抓取与 `data.urlserver` 解析;
|
||
- 启动四门闩:资源完成、配置完成、WebSocket open、最短展示时间到达;
|
||
- WebSocket 信封、握手、心跳、收包超时、登录守护和服务器切换;
|
||
- `player_login` 请求、响应和登录期间收包门控;
|
||
- 登录后进入大厅或恢复房间;
|
||
- `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、准备、玩家座位、重连和踢下线界面的最小控制器接线;
|
||
- 单元测试、协议黄金测试和纵向集成测试。
|
||
|
||
### 3.2 本批不包含
|
||
|
||
- 创建房间选项与具体 `roomtype` 生成;
|
||
- 解散投票、换桌、战绩、任务、仓库、排行、支付;
|
||
- 分享、语音、电话、通讯录、电量、网络、定位、摇一摇等完整 WVJB 功能;
|
||
- 任何具体子游戏的出牌、结算、`roomtype` 位含义和 `deskinfo` 内部结构;
|
||
- 为了迁移而改变服务器或原生 App。
|
||
|
||
这些能力后续按独立子项目设计和实施,不能扩入本批。
|
||
|
||
## 4. 当前基线与必须纠正的偏差
|
||
|
||
现有框架不是推倒重写对象。`NetClient`、信封编解码、心跳、重连策略、事件总线和响应式原语可以保留,但以下偏差必须在本批纠正:
|
||
|
||
- `LaunchFlow.ts` 用一秒定时器模拟加载,未实现四门闩。
|
||
- `LoginFlow.ts` 固定 debug/local、mock 身份,并在 bootstrap 失败时回退本地地址。
|
||
- `StartupOrchestrator` 在 login 之后才构造网络相关对象,生命周期倒置。
|
||
- `remote-config.ts` 从 `*_server_tcp` 推导连接地址,未复刻原工程 `ServerUrl_Succ` 直接读取 `data.urlserver` 的契约。
|
||
- `profiles.ts` 的默认 gameserver 与 `Game_Surface_3/js/01_SubGame/00_SubGame_Config.js:11` 不一致。
|
||
- `NetClient.sendFrame` 在 transport 缺失时静默不发送,`sendLogin` 在 identity 缺失时静默返回。
|
||
- `Router` 与 `RoomRPCBus` 并行承担消息分发,所有权不唯一。
|
||
- `roomHandlers` 直接修改 `signal.value` 内部对象,不能保证订阅者收到更新;部分 handler 是空实现。
|
||
- `PlayerStore` 和 `RoomStore.players` 同时保存自己的玩家信息,存在状态镜像。
|
||
- `PlayerStore`、`RoomStore` 和多个 handler 用 `??` 构造服务器未提供的数据。
|
||
- `parseLoginResponse.hasBattle` 同时判断 `isbattle` 和 `deskinfo`,与源码“`if (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. 目标结构
|
||
|
||
### 5.1 唯一运行时入口
|
||
|
||
新增一个平台运行时组合根,负责按固定顺序创建并持有:
|
||
|
||
```text
|
||
PlatformRuntime
|
||
├── RuntimeConfigResolver
|
||
├── NetClient
|
||
├── Router
|
||
├── PlatformHandlers
|
||
├── PlatformState
|
||
├── GameRegistry
|
||
├── GameSessionHost
|
||
└── ScenePort
|
||
```
|
||
|
||
`LaunchFlow` 和 `LoginFlow` 不再各自构造网络、身份和 Store。Cocos 组件只调用组合根的命令并订阅只读状态。
|
||
|
||
组合根的职责仅是装配和生命周期管理,不包含具体 RPC 业务分支。
|
||
|
||
### 5.2 配置边界
|
||
|
||
`RuntimeConfigResolver` 在网络连接前一次性产出不可变结果:
|
||
|
||
```ts
|
||
interface RuntimeConfig {
|
||
mode: 'debug' | 'release';
|
||
identity: ChannelIdentity;
|
||
loginIdentity: LoginIdentity;
|
||
servers: readonly string[];
|
||
debugLogging: boolean;
|
||
}
|
||
```
|
||
|
||
- release 的默认 `gameserver` 必须来自唯一 profile,并与原工程目标版本一致;
|
||
- 原生环境的 `gameconfig` 可按原算法把 `-` 还原为 `/`、`#` 还原为 `:`,再构造 `http://<value>.txt`;
|
||
- 远程配置成功后,WebSocket 地址只取 `data.urlserver`;值可为单个地址或候选数组;
|
||
- debug 直连必须由显式 profile 定义,不允许捕获错误后临时改用 localhost;
|
||
- 缺少必要身份、登录身份、gameserver 或 urlserver 时,解析器抛出带字段路径的错误。
|
||
|
||
### 5.3 唯一协议注册表
|
||
|
||
生产代码提供一份完整的 `RPC_ROUTE` 常量,覆盖 `routes.ts` 中的所有 `RpcName`。第一批只实现本设计范围内的请求/响应类型和 handler,但其它已知平台 RPC 收到时必须明确报告“未实现”,不能被子游戏接走。
|
||
|
||
每个已实现 RPC 由一个契约对象定义:
|
||
|
||
```ts
|
||
interface RpcContract<Request, Response> {
|
||
readonly route: RouteName;
|
||
readonly rpc: RpcName;
|
||
parseRequest(input: unknown): Request;
|
||
parseResponse(input: unknown): Response;
|
||
}
|
||
```
|
||
|
||
发包和收包都经过同一契约。控制器不得直接写 route/rpc 字符串,也不得绕过解析器调用 `NetClient.send`。
|
||
|
||
### 5.4 单一路由
|
||
|
||
`NetClient` 只负责传输与连接状态,业务包全部交给一个 `Router`:
|
||
|
||
```text
|
||
NetClient.message
|
||
→ Router
|
||
├── platform / agent / room → PlatformHandlers
|
||
└── activeGame.route → GameModule.handleGameMessage
|
||
```
|
||
|
||
`RoomRPCBus` 的独立监听职责被移除。不得出现第二个消费者再次筛选 `route === 'room'`。
|
||
|
||
`player_login`、`kick_server` 和切服指令仍可在 NetClient 的连接状态机中作为控制包识别,但成功解析后的业务数据必须进入统一会话入口,不能由不同模块重复落 Store。
|
||
|
||
### 5.5 状态模型
|
||
|
||
第一批采用三个逻辑域,但玩家实体只保存一份:
|
||
|
||
```ts
|
||
interface PlatformState {
|
||
app: AppState;
|
||
players: PlayerDirectoryState;
|
||
room: RoomState;
|
||
}
|
||
|
||
interface PlayerDirectoryState {
|
||
selfPlayerId: number | null;
|
||
entities: Readonly<Record<number, PlayerState>>;
|
||
}
|
||
|
||
interface RoomState {
|
||
inRoom: boolean;
|
||
roomcode: string | null;
|
||
seatPlayerIds: readonly (number | null)[];
|
||
selfSeat: number | null;
|
||
roomtype: unknown[] | null;
|
||
// 其余字段按 protocol/04 的 B 组显式定义
|
||
}
|
||
```
|
||
|
||
- 登录 A 组写入 `players.entities[playerid]`,同时设置 `selfPlayerId`;
|
||
- 登录 B 组和进房响应先写玩家实体,再写座位到玩家 ID 的映射;
|
||
- 房间 UI 通过 selector 组合座位与玩家实体,不保存玩家副本;
|
||
- `deskinfo` 不进入 PlatformState;解析成功后由会话入口一次性原样派发给激活子游戏;
|
||
- Store 只提供原子 action,每次 action 替换新的 state 值并通知订阅者;
|
||
- 初始空状态可以有明确语义值,但服务器响应缺失字段不能用初始值补齐。
|
||
|
||
### 5.6 UI 边界
|
||
|
||
第一批只建立下列控制器:
|
||
|
||
- 启动/加载控制器:Layer 1、Layer 614、Layer 615、Layer 616;
|
||
- 登录控制器:`Login_Layer.prefab`;
|
||
- 大厅控制器:Layer 4;
|
||
- 加入房间控制器:Layer 15;
|
||
- 房间壳控制器:Layer 50、Layer 403、Layer 411、Layer 202、Layer 416。
|
||
|
||
控制器遵循同一规则:
|
||
|
||
```text
|
||
用户事件 → Runtime command → RPC contract → NetClient
|
||
Store action → selector → 控制器 render → Cocos 节点
|
||
```
|
||
|
||
视图不能直接访问 WebSocket、Router 或可写 Store。prefab 节点引用与 Button 事件必须在执行阶段通过 Cocos MCP 添加。
|
||
|
||
### 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
|
||
type PlatformGameApiVersion = 1;
|
||
|
||
interface GameDescriptor {
|
||
readonly apiVersion: PlatformGameApiVersion;
|
||
readonly gameId: string | number;
|
||
readonly route: string;
|
||
readonly key: string;
|
||
resolveSeatCount(roomtype: readonly unknown[]): number;
|
||
create(): GameModule;
|
||
}
|
||
```
|
||
|
||
`GameRegistry.register(descriptor)` 必须检查:
|
||
|
||
- `key`、`gameId`、`route` 均非空且分别唯一;
|
||
- `apiVersion` 与平台支持版本完全相等;
|
||
- `create()` 每次返回新的 GameModule 实例;
|
||
- `resolveSeatCount(roomtype)` 是平台获得该游戏座位总数的唯一入口,结果必须是正整数;具体 `roomtype` 索引含义只存在于游戏实现;
|
||
- 重复注册或版本不匹配显式抛错,不选择“最接近版本”。
|
||
|
||
进入房间时依据权威 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` 值为真,与原工程 `if (deskinfo)` 完全一致;缺失、`null`、`false`、`0` 或空字符串均不触发;
|
||
- 对同一会话的状态提交、PlatformToGameEvent 和游戏消息严格串行派发;平台先提交 Store,再发对应事件,游戏读取 snapshot 时一定看到事件后的权威状态;
|
||
- snapshot 表达“现在是什么”,event 表达“刚发生什么”,不得用 event 重建平台状态,也不得用 snapshot 猜测一次性动画事件;
|
||
- 平台不得吞掉 GameModule 抛出的契约错误;运行时转入明确 failed 状态并停止本局继续派发。
|
||
|
||
### 5.10 子游戏 → 平台
|
||
|
||
`GameHost` 是子游戏获得平台能力的唯一入口:
|
||
|
||
```ts
|
||
interface GameHost {
|
||
readonly apiVersion: PlatformGameApiVersion;
|
||
readonly seat: GameSeatMapper;
|
||
getSnapshot(): PlatformGameSnapshot;
|
||
subscribe(listener: (snapshot: PlatformGameSnapshot) => void): Unsubscribe;
|
||
sendGameMessage(rpc: string, data: unknown): void;
|
||
execute(command: GameHostCommand): void;
|
||
}
|
||
|
||
type Unsubscribe = () => void;
|
||
|
||
interface GameSeatMapper {
|
||
toView(serverSeat: number): number;
|
||
toServer(viewSeat: number): number;
|
||
}
|
||
|
||
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 实现;`roomtype` 在入 Store 边界仅做通用递归冻结、不解释任何位置,并以同一权威引用透传到 snapshot。
|
||
- `GameSeatMapper` 在会话建立时绑定当前 `self.seat` 和 descriptor 解析出的 `seatCount`;公式分别为 `(serverSeat-selfSeat+seatCount)%seatCount` 与 `(viewSeat+selfSeat)%seatCount`,子游戏不导入平台 `core/seat`,也不重复保存自己的换算参数。
|
||
- `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. 核心时序
|
||
|
||
### 6.1 首次启动
|
||
|
||
1. 组合根同时启动资源加载、最短展示计时和配置解析。
|
||
2. 配置解析成功后构造 NetClient,设置完整登录身份并连接 `urlserver`。
|
||
3. WebSocket open 只表示连接门闩完成;是否立即发 login 由登录身份是否已具备决定。
|
||
4. 资源、配置、open、计时四门闩全部完成后,显示登录/授权入口。
|
||
5. 任一必需门闩失败,进入明确错误状态;不得切到 Login 后再使用 mock 数据继续。
|
||
|
||
### 6.2 登录
|
||
|
||
1. `player_login` 请求由契约构造器注入所有条件字段。
|
||
2. 等待响应期间只接受 `player_login` 和 `kick_server`;其它业务包按旧协议丢弃。
|
||
3. `state !== 0` 进入登录失败状态,不写玩家或房间数据。
|
||
4. `state === 0` 时一次 action 提交 A 组状态。
|
||
5. 无 `roomcode`:清理房间域并显示大厅。
|
||
6. 有 `roomcode`:一次 action 提交 B 组状态,进入房间壳。
|
||
7. 响应 `deskinfo` 值为真:进入房间后调用一次 `onReconnect(deskinfo)`;缺失或假值不调用。
|
||
|
||
### 6.3 主动加入房间
|
||
|
||
1. JoinRoom 控制器维护最多六位的本地输入状态。
|
||
2. 输入完成并确认后构造 `self_join_room` 请求,字段严格来自登录态和输入值。
|
||
3. 失败响应只更新命令结果和提示,不污染 RoomState。
|
||
4. 成功响应原子提交玩家目录和房间座位状态。
|
||
5. `deskwar` 为真时调用 `onStartWar`;否则若 `deskinfo` 值为真,调用 `DeskInfo` 等价入口;普通进房不调用对局恢复。
|
||
|
||
### 6.4 准备和房内推送
|
||
|
||
1. 点击准备发送无业务字段的 `player_prepare` data,通用信封字段由协议层补齐。
|
||
2. 收到 `player_prepare` 后更新对应玩家的准备状态并通知 UI/子游戏。
|
||
3. `deskwar` 为真时只触发一次开战入口。
|
||
4. 玩家加入、退出、离线、上线都通过原子 action 更新目录或座位状态。
|
||
|
||
### 6.5 断线重连
|
||
|
||
1. 连接关闭或收包超时后进入 reconnecting,显示 Layer 615。
|
||
2. 按旧策略延时并轮换候选服务器。
|
||
3. open 后用同一份登录身份重发 `player_login`。
|
||
4. 登录成功后用服务器完整快照覆盖房间态,不在旧 RoomState 上打补丁。
|
||
5. 响应 `deskinfo` 值为真时原样调用 `onReconnect`。
|
||
6. `kick_server` 停止重连并显示 Layer 616。
|
||
|
||
## 7. 错误处理
|
||
|
||
错误分为三类:
|
||
|
||
- **契约错误**:字段缺失、类型错误、未知 route/rpc。抛出包含 route、rpc 和字段路径的错误,测试必须失败。
|
||
- **可恢复运行错误**:连接关闭、超时、服务器切换。进入明确 phase,并由 NetClient 状态机处理。
|
||
- **业务失败**:RPC `state !== 0`。保留原服务端错误语义,转换为命令结果供 UI 展示,不修改成功态 Store。
|
||
|
||
禁止:空 catch、`as any` 穿透边界、静默 return、localhost 回退、从旧 Store 猜缺失字段。
|
||
|
||
## 8. 测试设计
|
||
|
||
### 8.1 契约黄金测试
|
||
|
||
为每个第一批 RPC 保存最小成功、失败和可选字段样本。断言:
|
||
|
||
- 编码后的 JSON 信封字段和值完全一致;
|
||
- 解码后字段类型不改变;
|
||
- 缺失必需字段时解析失败;
|
||
- `roomtype` 和 `deskinfo` 保持引用内容不变。
|
||
|
||
### 8.2 状态迁移测试
|
||
|
||
每个 handler 同时断言最终 state 和订阅通知次数,防止再次出现原地修改不通知 UI。
|
||
|
||
### 8.3 时序测试
|
||
|
||
使用 fake clock 和 fake transport 覆盖:四门闩排列组合、四秒登录守护、十秒重连、候选服务器轮换、踢下线停止重连、重登覆盖房间快照。
|
||
|
||
### 8.4 纵向集成测试
|
||
|
||
用固定消息序列验证:
|
||
|
||
```text
|
||
bootstrap → open → login(no room) → join → other join
|
||
→ prepare → close → reconnect → login(with room + deskinfo)
|
||
```
|
||
|
||
断言每一步的出站包、phase、Store、场景命令和 `GameModule` 事件记录。
|
||
|
||
### 8.5 真服验证
|
||
|
||
单元与集成测试通过后,使用真实测试账号和原服务器完成一次抓包验证。测试账号和服务器地址只写入调试 profile,不写入控制器或测试快照。
|
||
|
||
### 8.6 子游戏接入一致性测试
|
||
|
||
`game-contract-harness` 对任意 descriptor 运行同一组测试:
|
||
|
||
- descriptor 字段、route 与 API 版本有效;
|
||
- 两次 `create()` 返回不同实例;
|
||
- 生命周期顺序非法时显式失败;
|
||
- 游戏消息只能发往自身 route;
|
||
- snapshot 只读且不泄露 Store/Reactive/EventBus;
|
||
- dispose 自动释放订阅并使旧 Host 失效;
|
||
- 两个测试游戏顺序进入时,状态、订阅和消息不串局;
|
||
- 2、4、10 人座位的双向换算可逆,且服务端座位到自己的视图位置恒为 0;
|
||
- `deskinfo` 以同一引用和值交给 `restore`,平台不解析。
|
||
|
||
## 9. 交付与验收
|
||
|
||
本批完成必须同时满足:
|
||
|
||
1. 不修改服务器和原生 App;
|
||
2. release 启动不存在 mock、localhost 或捕获错误后的回退;
|
||
3. 所有业务消息只经过一个 Router;
|
||
4. 玩家实体只有一个权威存储;
|
||
5. 第一批 RPC 均有请求/响应解析器和黄金样本;
|
||
6. 四门闩、登录、大厅、进房、准备和断线重连集成测试通过;
|
||
7. `deskinfo` 仅以原工程 `if (deskinfo)` 的真值语义触发,并原样传给游戏模块;
|
||
8. Layer 控制器只依赖 command 与只读 selector;
|
||
9. TypeScript 类型检查与全部 framework tests 通过;
|
||
10. 新子游戏只需提供 descriptor/module 并在应用组合根注册,不修改 framework 核心;
|
||
11. 子游戏源码没有导入 platform/net/protocol/ui 内部模块;
|
||
12. game-contract-harness 与 import-boundary 测试通过;
|
||
13. 真实服务器抓包与原工程信封、字段和关键时序一致。
|
||
|
||
## 10. 后续子项目边界
|
||
|
||
本批验收后按以下顺序单独设计:
|
||
|
||
1. 创建房间与目标子游戏 `roomtype`;
|
||
2. 房间解散、换桌及完整房间壳;
|
||
3. 大厅资产、战绩、任务、仓库和排行;
|
||
4. 分享、语音、电话、定位、支付等原生能力;
|
||
5. 指定子游戏的对局协议和 `deskinfo` 恢复。
|
||
|
||
每个子项目继续使用同一套黄金报文与新旧行为差分验收方式。
|