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 new file mode 100644 index 0000000..9b38f36 --- /dev/null +++ b/docs/superpowers/specs/2026-09-04-platform-vertical-slice-contract-inventory.md @@ -0,0 +1,222 @@ +# 平台纵向链路契约清单 + +> 本清单只覆盖 `2026-09-04-platform-vertical-slice-design.md` 的第一批范围。 +> +> 完整 84 项平台 RPC 的唯一清单仍是 `docs/protocol/README.md`,字段权威仍在 `docs/protocol/01-05`。本文不复制完整协议,只记录第一批实现门槛、源码锚点和当前偏差。 + +## 1. 权威证据 + +| 领域 | 权威文件 | +|---|---| +| 传输、信封、心跳、登录门控、切服 | `docs/protocol/01-传输层与架构.md` | +| agent RPC | `docs/protocol/02-协议-agent路由.md` | +| room RPC | `docs/protocol/03-协议-room路由.md` | +| 登录、玩家、房间、roomtype 数据 | `docs/protocol/04-数据结构.md` | +| 游戏 route、deskwar、deskinfo | `docs/protocol/05-游戏内协议与桥接.md` | +| 旧连接与启动流程 | `projects/Game_Surface_3/js/00_Surface/12_Logic.js` | +| 旧发包与收包入口 | `projects/Game_Surface_3/js/00_Surface/09_Net.js` | +| 旧房间状态机 | `projects/Game_Surface_3/js/00_Surface/07_Desk.js` | +| 原生配置与 WVJB | `projects/Game_Surface_3/js/00_Surface/05_Func.js` | +| 默认 gameserver | `projects/Game_Surface_3/js/01_SubGame/00_SubGame_Config.js:11` | + +## 2. 启动与配置契约 + +| 编号 | 契约 | 原工程锚点 | 当前 YouleNexus 偏差 | 第一批门槛 | +|---|---|---|---|---| +| CFG-01 | 默认 `gameserver` 来自子游戏配置唯一常量 | `00_SubGame_Config.js:11` | `profiles.ts` 使用不同 URL | profile 与目标原工程值一致 | +| CFG-02 | 原生 `gameconfig` 非空时:`-`→`/`、`#`→`:`,构造 `http://.txt` | `12_Logic.js:1268-1279` | 未完整复刻 | 黄金输入输出测试 | +| CFG-03 | H5 渠道取 query `channelid`、`agentid`;原生渠道走原方法 | `12_Logic.js:1215-1246` | source 合并策略可能改变分支 | mode/userAgent 分支显式建模 | +| CFG-04 | 原生 agent 使用 `window.settings.getothername('agent')`;仅 uAgent_3 使用 `window.app_agent` | `05_Func.js:2451-2468`, `12_Logic.js:1232-1246` | 捕获任意错误后都会尝试全局回退 | 只有来源明确定义的分支可回退 | +| CFG-05 | `get_config(gameserver)` 成功后解析 JSON 并取 `_msg.data.urlserver` | `12_Logic.js:521,533-550` | 当前解析 `*_server_tcp` | WebSocket 地址只取 `urlserver` | +| CFG-06 | `urlserver` 可为单地址或候选数组 | `12_Logic.js:826-899` | target 支持 servers 数组,但上游来源不同 | 保持顺序并逐项规范为 ws/wss URL | +| CFG-07 | 配置失败不产生替代服务器 | 仓库第二准则 | `LoginFlow.ts` 回退 localhost | release/debug 都显式失败 | +| CFG-08 | debug 直连是 profile 的显式语义 | `profiles.ts` 目标设计 | 已具备,但入口硬编码 local | 仅显式选择 profile 时生效 | + +启动就绪条件固定为四项: + +```text +resourcesReady && configReady && socketOpen && minimumDisplayElapsed +``` + +任一项失败都不能把其它项当作成功。`LaunchFlow.ts` 的固定一秒模拟不属于契约实现。 + +## 3. 传输契约 + +| 编号 | 契约 | 精确要求 | +|---|---|---| +| NET-01 | 出站信封 | `{app:"youle", route, rpc, data}`,单层 JSON | +| NET-02 | 入站载荷 | 浏览器 `MessageEvent.data` 解出协议单层信封;允许旧服务实际出现的字符串化形态,但不能虚构第二层业务包装 | +| NET-03 | route 分界 | `platform/agent/room` 进入平台;其它 route 仅在等于 active game route 时进入子游戏 | +| NET-04 | 登录门控 | 发出 login 后,只处理 `player_login` 与 `kick_server`,其它业务包丢弃 | +| NET-05 | 登录守护 | 子游戏模式四秒未收到 login 响应时触发重连 | +| NET-06 | 收包超时 | 有效帧重置 watchdog;超时进入 slow 并重连 | +| NET-07 | 踢下线 | `kick_server` 停止自动重连并进入 kicked | +| NET-08 | 切服 | `connect_roomserver.data.roomserver`、`connect_agentserver.data.agentserver` 更新当前服务器并重连重登 | +| NET-09 | 发送前置条件 | transport 或完整 login identity 缺失时显式抛错,不静默 return | +| NET-10 | 唯一消费者 | 每个业务信封只交给一个 Router 一次 | + +## 4. 第一批 RPC 契约 + +### 4.1 agent route + +| RPC | 方向 | 第一批用途 | 请求/响应权威 | 必须验证的分支 | +|---|---|---|---|---| +| `player_login` | 双向 | 首登、重登、恢复房间 | protocol/02 §登录;protocol/04 §登录响应 | state 失败、无房、大厅、在房、含 deskinfo | +| `self_join_room` | 双向 | 房号加入房间 | protocol/02 §房间创建/进入;protocol/04 §公共字段 | state=0、state=99、一般失败、deskwar、deskinfo、普通进房 | +| `connect_agentserver` | S→C | 切换 agent server | protocol/01 §7.4 | 地址存在、地址缺失报错 | +| `connect_roomserver` | S→C | 切换 room server | protocol/01 §7.4 | 地址存在、地址缺失报错 | +| `kick_server` | S→C | 强制下线 | protocol/01 §3.2 | 停止重连、显示踢下线态 | + +`player_login.data.version` 按已验证协议使用数字 versionCode。请求的必需字段是: + +```text +agentid, gameid, openid, nickname, avatar, sex, +province, city, unionid, version, channelid, marketid, +machineid, machineroom +``` + +条件字段严格按来源条件加入:`ip`、`location`、`telphone`、`telphoneAuto`、缓存 `playerid`。条件不成立时字段应省略,不用空值伪装已提供。 + +`self_join_room` 基础请求字段是: + +```text +agentid, playerid, gameid, roomcode +``` + +`location` 与 `ip` 按原发送器注入;`vipMatch`、`match_id` 不属于第一批普通房号加入入口。 + +### 4.2 room route + +| RPC | 方向 | 必需 data | Store/钩子结果 | +|---|---|---|---| +| `other_join_room` | S→C | `seat` + 完整玩家座位对象 | upsert 玩家实体,写 seatPlayerIds;`deskwar` 时开战 | +| `self_exit_room` | 双向 | 响应可含 `seat/isowner/roomcode` | 成功后清房间;保留登录玩家实体 | +| `other_exit_room` | S→C | `seat` | 清座位映射;玩家实体无其它引用时移除 | +| `player_prepare` | 双向/推送 | `seat`,可选 `deskwar` | 更新 ready,调用 onPlayerReady;deskwar 时开战 | +| `other_offline` | S→C | `seat` | 对应玩家 `onstate=1`,调用 onPlayerOffline | +| `other_online` | S→C | `seat`, `ip` | 对应玩家 `onstate=0` 并更新 ip,调用 onPlayerOnline | + +上表的座位值以服务器协议为准。转换为数组索引必须集中在一个座位转换函数中;控制器和 handler 不允许各自执行 `seat - 1`。 + +## 5. 登录响应字段组 + +### 5.1 A 组:玩家与代理状态 + +第一批 parser 至少显式声明并校验 protocol/04 已验证字段: + +```text +state, playerid, agentid, channelid, +nickname, avatar, openid, sex, unionid, +roomcard, bean, score, invitecode, advanced, taskstate, +ip, bankpower, bank, sign, tel, initCard, initBean, bankpwd, +agentname, agentmode, gameversion +``` + +字段是否可选以 protocol/04 的条件说明为准。可选性必须写在 parser 类型中;不得用 `?? 0` 或 `?? ''` 把缺失变成有效数据。 + +### 5.2 B 组:房间恢复状态 + +存在 `roomcode` 时,parser 至少处理: + +```text +roomcode, roomtype, asetcount, isbattle, makewar, seat, isowner, +players, roommode, beanlimit, needprepare, infinite, +rebateNumber, rebateMode, rebateType, sign, ownerNotice, +videoConfig, shortcode, match, matchid, agreefree, isbet, deskinfo +``` + +关键判定: + +- `roomcode` 存在决定是否恢复房间; +- `isbattle` 只写房间 stage; +- `deskinfo` 字段存在决定是否调用 `onReconnect(deskinfo)`; +- `deskinfo` 值不被平台复制、补字段或解析; +- `roomtype` 保留完整嵌套数组结构。 + +## 6. 状态所有权 + +| 数据 | 唯一来源 | 禁止的第二来源 | +|---|---|---| +| runtime mode | runtime-mode resolver | Cocos 控制器硬编码 | +| gameserver | active profile,经原生 gameconfig 合法覆盖 | LoginFlow 常量 | +| WebSocket servers | remote `data.urlserver` 或显式 debug direct profile | Store/NetClient 自行推导 | +| channel identity | identity resolver | 发包器补默认 ID | +| login identity | 登录/授权来源 | NetClient mock identity | +| rpc→route | 生产 `RPC_ROUTE` | 控制器字符串 | +| 玩家实体 | PlayerDirectory `entities` | RoomStore 中复制 PlayerState | +| 自己身份 | `selfPlayerId` 指向玩家实体 | 独立 C_Player 镜像 | +| 房间座位 | RoomState `seatPlayerIds` | UI 局部玩家数组 | +| 当前场景 | AppState scene/phase | 反查某个节点 active | +| roomtype | RoomState 原样值 | UI 重新生成或规范化 | +| deskinfo | 当前入站响应中的原样值,完成一次性派发后由子游戏接管 | PlatformState 或平台拆解后的派生对象 | + +## 7. 首批 UI 对应关系 + +| 行为 | prefab/Layer | 控制器职责 | +|---|---|---| +| 启动背景和资源进度 | Layer 1 | 只显示加载状态 | +| 网络请求遮罩 | Layer 614 | 订阅 pending command 数量 | +| 断线重连 | Layer 615 | 订阅 reconnecting/slow | +| 强制下线 | Layer 616 | 订阅 kicked 并阻止继续操作 | +| 登录/授权入口 | `Login_Layer.prefab` | 产生 login identity 或触发已有身份登录 | +| 大厅 | Layer 4 | 展示 self player selector;打开 JoinRoom | +| 输入房号 | Layer 15 | 本地六位输入;确认时调用 joinRoom command | +| 房间壳 | Layer 50、411 | 展示房号、时钟、网络态和退出入口 | +| 准备按钮 | Layer 403 | 调用 prepare command,状态由服务器推送确认 | +| 玩家座位 | Layer 202、416 | 从 seat selector 渲染,不保存玩家副本 | + +对 prefab 的节点路径、按钮绑定与组件挂载,以当前 Cocos 资源实际结构为准,并在实施时通过 funplay-cocos MCP 查询和修改。 + +## 8. 原生接口范围 + +第一批只实现启动必需的同步读取: + +```text +window.settings.getothername('agent') +window.settings.getothername('gameconfig') +window.settings.getothername('servertype') +window.settings.getchannelName() +window.settings.getmarketname() +``` + +uAgent_3 的 `window['app_' + name]` 是原工程明确定义的独立分支,不是任意异常的通用回退。 + +完整 WVJB 作为后续独立子项目。现阶段已确认的审计事实: + +- 原工程注册 21 个唯一 handler 名; +- 源码文本出现 41 个唯一 `callHandler` 名,其中 `getcompareCode`、`getothername`、`getmarketname` 仅出现在注释调用; +- 入站与出站名称集合不同,且存在大小写敏感名称,例如 `getphoneinfo` 与 `getphoneInfo`; +- 初始化必须保留 `window.WVJBCallbacks`、`WebViewJavascriptBridgeReady` 和 `wvjbscheme://__BRIDGE_LOADED__`。 + +因此现有 14 项共用白名单不能作为完整桥契约继续扩展。 + +## 9. 黄金样本清单 + +实施前必须把以下样本固化到 `cocoscreator_projects/framework-tests/fixtures/protocol/`: + +1. 远程配置:单 `urlserver`、数组 `urlserver`、缺失 `urlserver`; +2. `player_login` 请求:普通身份、带 ip/location、设备手机号、缓存 playerid; +3. `player_login` 响应:失败、成功无房、成功在房无 deskinfo、成功在房含 deskinfo; +4. `self_join_room`:成功普通、state=99、一般失败、deskwar、deskinfo; +5. `other_join_room`:完整玩家和准备条件; +6. `player_prepare`:普通准备与 deskwar; +7. `other_exit_room`、`other_offline`、`other_online`; +8. `connect_agentserver`、`connect_roomserver`、`kick_server`; +9. 一段纵向序列:登录大厅→进房→准备→掉线→重登恢复。 + +若仓库中没有真实样本,先从原客户端或测试服务器抓取并脱敏。没有证据的字段不能凭名称推断。 + +## 10. 完成判据 + +每条契约须具备: + +- 一个权威源码/协议锚点; +- 一个具名 TypeScript 类型; +- 一个运行时 parser; +- 至少一个成功黄金样本; +- 必需字段缺失的失败测试; +- 对应 Store action 或运行时状态迁移测试; +- 涉及 UI 时的控制器测试; +- 涉及服务器时的出站信封断言。 + +缺少上述任一项,该契约仍视为未迁移。 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 new file mode 100644 index 0000000..52638b8 --- /dev/null +++ b/docs/superpowers/specs/2026-09-04-platform-vertical-slice-design.md @@ -0,0 +1,345 @@ +# YouleNexus 平台纵向链路逻辑迁移设计 + +> 状态:已确认设计方向,待用户审阅本文。 +> +> 适用工程:`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 控制器只消费稳定接口。 + +## 2. 不可妥协的约束 + +1. **服务器零改动**:信封、route、rpc、字段名、字段类型、时序与旧客户端一致。 +2. **唯一数据源**:配置、协议映射、玩家实体和房间座位各有唯一权威来源。 +3. **下游不兜底**:边界数据缺失或非法时显式报错;Store、控制器和视图不得猜默认值。 +4. **roomtype 不解析**:平台只保存和透传具体子游戏的嵌套数组。 +5. **deskinfo 不解析**:平台只按“响应中存在 deskinfo”判断并原样传给 `IGameModule.onReconnect(deskinfo)`;不得改用 `isbattle` 作为触发条件。 +6. **Cocos 资源只经编辑器 MCP 修改**:本设计涉及 prefab 挂载或场景操作时,执行阶段必须使用 funplay-cocos MCP。 +7. **旧引擎机制不复刻**:渲染循环、命中检测、对象表和逐精灵定时器交给 Cocos;只迁移业务状态机和协议行为。 + +权威来源按以下顺序裁决冲突: + +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` 触发恢复钩子; +- 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`,与源码“deskinfo 存在才调用 Reconnect”不一致。 +- `native-bridge.ts` 把入站注册名和出站调用名合并成同一份 14 项白名单;原工程两者并不相同。 + +## 5. 目标结构 + +### 5.1 唯一运行时入口 + +新增一个平台运行时组合根,负责按固定顺序创建并持有: + +```text +PlatformRuntime +├── RuntimeConfigResolver +├── NetClient +├── Router +├── PlatformHandlers +├── PlatformState +├── ActiveGame +└── 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://.txt`; +- 远程配置成功后,WebSocket 地址只取 `data.urlserver`;值可为单个地址或候选数组; +- debug 直连必须由显式 profile 定义,不允许捕获错误后临时改用 localhost; +- 缺少必要身份、登录身份、gameserver 或 urlserver 时,解析器抛出带字段路径的错误。 + +### 5.3 唯一协议注册表 + +生产代码提供一份完整的 `RPC_ROUTE` 常量,覆盖 `routes.ts` 中的所有 `RpcName`。第一批只实现本设计范围内的请求/响应类型和 handler,但其它已知平台 RPC 收到时必须明确报告“未实现”,不能被子游戏接走。 + +每个已实现 RPC 由一个契约对象定义: + +```ts +interface RpcContract { + 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 → IGameModule.onReceive +``` + +`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>; +} + +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 子游戏边界 + +第一批只要求一个测试替身实现以下接口: + +```ts +interface IGameModule { + 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; +} +``` + +平台不得查看 `deskinfo` 内部字段。未指定真实子游戏前,只验证调用时机、参数引用和调用次数,不声称玩法重连完成。 + +## 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、场景命令和 `IGameModule` 调用。 + +### 8.5 真服验证 + +单元与集成测试通过后,使用真实测试账号和原服务器完成一次抓包验证。测试账号和服务器地址只写入调试 profile,不写入控制器或测试快照。 + +## 9. 交付与验收 + +本批完成必须同时满足: + +1. 不修改服务器和原生 App; +2. release 启动不存在 mock、localhost 或捕获错误后的回退; +3. 所有业务消息只经过一个 Router; +4. 玩家实体只有一个权威存储; +5. 第一批 RPC 均有请求/响应解析器和黄金样本; +6. 四门闩、登录、大厅、进房、准备和断线重连集成测试通过; +7. `deskinfo` 仅以“字段存在”为触发条件并原样传给游戏模块; +8. Layer 控制器只依赖 command 与只读 selector; +9. TypeScript 类型检查与全部 framework tests 通过; +10. 真实服务器抓包与原工程信封、字段和关键时序一致。 + +## 10. 后续子项目边界 + +本批验收后按以下顺序单独设计: + +1. 创建房间与目标子游戏 `roomtype`; +2. 房间解散、换桌及完整房间壳; +3. 大厅资产、战绩、任务、仓库和排行; +4. 分享、语音、电话、定位、支付等原生能力; +5. 指定子游戏的对局协议和 `deskinfo` 恢复。 + +每个子项目继续使用同一套黄金报文与新旧行为差分验收方式。