15 KiB
YouleNexus 平台纵向链路逻辑迁移设计
状态:已确认设计方向,待用户审阅本文。
适用工程:
cocoscreator_projects/YouleNexus。契约清单:
docs/superpowers/specs/2026-09-04-platform-vertical-slice-contract-inventory.md。
1. 决策
第一批逻辑迁移采用“平台纵向链路优先”,只交付一个能独立验证的闭环:
远程配置 / 原生身份
→ WebSocket 连接
→ player_login
→ 大厅
→ self_join_room
→ 房间玩家与准备状态
→ 断线、重登、deskinfo 透传重连
本批不按 Layer 编号逐个补事件,也不一次性迁移全部平台功能。这样可以先固定协议边界、状态归属和运行时编排,后续 Layer 控制器只消费稳定接口。
2. 不可妥协的约束
- 服务器零改动:信封、route、rpc、字段名、字段类型、时序与旧客户端一致。
- 唯一数据源:配置、协议映射、玩家实体和房间座位各有唯一权威来源。
- 下游不兜底:边界数据缺失或非法时显式报错;Store、控制器和视图不得猜默认值。
- roomtype 不解析:平台只保存和透传具体子游戏的嵌套数组。
- deskinfo 不解析:平台只按“响应中存在 deskinfo”判断并原样传给
IGameModule.onReconnect(deskinfo);不得改用isbattle作为触发条件。 - Cocos 资源只经编辑器 MCP 修改:本设计涉及 prefab 挂载或场景操作时,执行阶段必须使用 funplay-cocos MCP。
- 旧引擎机制不复刻:渲染循环、命中检测、对象表和逐精灵定时器交给 Cocos;只迁移业务状态机和协议行为。
权威来源按以下顺序裁决冲突:
docs/protocol/与其引用的原工程源码行;projects/Game_Surface_3/js/00_Surface/实际行为;- 本设计与契约清单;
- 现有 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 唯一运行时入口
新增一个平台运行时组合根,负责按固定顺序创建并持有:
PlatformRuntime
├── RuntimeConfigResolver
├── NetClient
├── Router
├── PlatformHandlers
├── PlatformState
├── ActiveGame
└── ScenePort
LaunchFlow 和 LoginFlow 不再各自构造网络、身份和 Store。Cocos 组件只调用组合根的命令并订阅只读状态。
组合根的职责仅是装配和生命周期管理,不包含具体 RPC 业务分支。
5.2 配置边界
RuntimeConfigResolver 在网络连接前一次性产出不可变结果:
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 由一个契约对象定义:
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:
NetClient.message
→ Router
├── platform / agent / room → PlatformHandlers
└── activeGame.route → IGameModule.onReceive
RoomRPCBus 的独立监听职责被移除。不得出现第二个消费者再次筛选 route === 'room'。
player_login、kick_server 和切服指令仍可在 NetClient 的连接状态机中作为控制包识别,但成功解析后的业务数据必须进入统一会话入口,不能由不同模块重复落 Store。
5.5 状态模型
第一批采用三个逻辑域,但玩家实体只保存一份:
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。
控制器遵循同一规则:
用户事件 → Runtime command → RPC contract → NetClient
Store action → selector → 控制器 render → Cocos 节点
视图不能直接访问 WebSocket、Router 或可写 Store。prefab 节点引用与 Button 事件必须在执行阶段通过 Cocos MCP 添加。
5.7 子游戏边界
第一批只要求一个测试替身实现以下接口:
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 首次启动
- 组合根同时启动资源加载、最短展示计时和配置解析。
- 配置解析成功后构造 NetClient,设置完整登录身份并连接
urlserver。 - WebSocket open 只表示连接门闩完成;是否立即发 login 由登录身份是否已具备决定。
- 资源、配置、open、计时四门闩全部完成后,显示登录/授权入口。
- 任一必需门闩失败,进入明确错误状态;不得切到 Login 后再使用 mock 数据继续。
6.2 登录
player_login请求由契约构造器注入所有条件字段。- 等待响应期间只接受
player_login和kick_server;其它业务包按旧协议丢弃。 state !== 0进入登录失败状态,不写玩家或房间数据。state === 0时一次 action 提交 A 组状态。- 无
roomcode:清理房间域并显示大厅。 - 有
roomcode:一次 action 提交 B 组状态,进入房间壳。 - 响应存在
deskinfo:进入房间后调用一次onReconnect(deskinfo);没有则不调用。
6.3 主动加入房间
- JoinRoom 控制器维护最多六位的本地输入状态。
- 输入完成并确认后构造
self_join_room请求,字段严格来自登录态和输入值。 - 失败响应只更新命令结果和提示,不污染 RoomState。
- 成功响应原子提交玩家目录和房间座位状态。
deskwar为真时调用onStartWar;否则若存在deskinfo,调用DeskInfo等价入口;普通进房不调用对局恢复。
6.4 准备和房内推送
- 点击准备发送无业务字段的
player_preparedata,通用信封字段由协议层补齐。 - 收到
player_prepare后更新对应玩家的准备状态并通知 UI/子游戏。 deskwar为真时只触发一次开战入口。- 玩家加入、退出、离线、上线都通过原子 action 更新目录或座位状态。
6.5 断线重连
- 连接关闭或收包超时后进入 reconnecting,显示 Layer 615。
- 按旧策略延时并轮换候选服务器。
- open 后用同一份登录身份重发
player_login。 - 登录成功后用服务器完整快照覆盖房间态,不在旧 RoomState 上打补丁。
- 响应存在
deskinfo时原样调用onReconnect。 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 纵向集成测试
用固定消息序列验证:
bootstrap → open → login(no room) → join → other join
→ prepare → close → reconnect → login(with room + deskinfo)
断言每一步的出站包、phase、Store、场景命令和 IGameModule 调用。
8.5 真服验证
单元与集成测试通过后,使用真实测试账号和原服务器完成一次抓包验证。测试账号和服务器地址只写入调试 profile,不写入控制器或测试快照。
9. 交付与验收
本批完成必须同时满足:
- 不修改服务器和原生 App;
- release 启动不存在 mock、localhost 或捕获错误后的回退;
- 所有业务消息只经过一个 Router;
- 玩家实体只有一个权威存储;
- 第一批 RPC 均有请求/响应解析器和黄金样本;
- 四门闩、登录、大厅、进房、准备和断线重连集成测试通过;
deskinfo仅以“字段存在”为触发条件并原样传给游戏模块;- Layer 控制器只依赖 command 与只读 selector;
- TypeScript 类型检查与全部 framework tests 通过;
- 真实服务器抓包与原工程信封、字段和关键时序一致。
10. 后续子项目边界
本批验收后按以下顺序单独设计:
- 创建房间与目标子游戏
roomtype; - 房间解散、换桌及完整房间壳;
- 大厅资产、战绩、任务、仓库和排行;
- 分享、语音、电话、定位、支付等原生能力;
- 指定子游戏的对局协议和
deskinfo恢复。
每个子项目继续使用同一套黄金报文与新旧行为差分验收方式。