From 905c134017a5839c0963db6c35230dae934c7b30 Mon Sep 17 00:00:00 2001 From: Joywayer Date: Sun, 28 Jun 2026 14:09:47 +0800 Subject: [PATCH] =?UTF-8?q?docs(plan):=20Plan=202=20=E6=A1=86=E6=9E=B6?= =?UTF-8?q?=E5=86=85=E6=A0=B8=20core+net+protocol=20=E5=AE=9E=E6=96=BD?= =?UTF-8?q?=E8=AE=A1=E5=88=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 12 个 TDD 任务:TS 测试基建(tsx+node:test)、core(EventBus/座位/最小集类型/常量)、 net(信封编解码/看门狗/重连策略/可注入Transport/NetClient编排)、protocol(routes/login)、 login 全流程集成测试(FakeTransport)、Cocos WebSocket adapter + 真实服务器联调。 事件驱动剥离 UI 耦合(D1)、依赖注入 Transport(D2)、只实现 WS 剥离旧 quirk(D3)、 忠实 docs/protocol/01(D4)、最小集类型(D6)。 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-28-framework-core-net-protocol.md | 1490 +++++++++++++++++ 1 file changed, 1490 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-28-framework-core-net-protocol.md diff --git a/docs/superpowers/plans/2026-06-28-framework-core-net-protocol.md b/docs/superpowers/plans/2026-06-28-framework-core-net-protocol.md new file mode 100644 index 0000000..19427c1 --- /dev/null +++ b/docs/superpowers/plans/2026-06-28-framework-core-net-protocol.md @@ -0,0 +1,1490 @@ +# 框架内核 core + net + protocol Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 实现框架网络内核——一个可注入、事件驱动的 `NetClient`,对 mock server 跑通完整协议流程(连接→条件发 login→双层拆包→握手/心跳过滤→login 往返→心跳超时→断线重连→服务器切换),并提供 Cocos 真实 WebSocket adapter 供端到端联调。 + +**Architecture:** 严格分 core / net / protocol 三层,依赖单向(protocol→net→core)。net 层把旧框架的 UI 耦合(GameUI.OpenTips 等)剥离为 EventBus 事件(D1),WebSocket 经依赖注入的 `Transport` 抽象(D2,测试注入内存 FakeTransport、生产注入 Cocos adapter),只实现 WebSocket 通道、固定子游戏模式(D3)。所有协议交互行为忠实 `docs/protocol/01`(D4)。协议类型只落最小集(D6)。 + +**Tech Stack:** TypeScript(Cocos 3.8.8);纯逻辑层用全局 `setTimeout`/`clearTimeout`,不依赖 cc 运行时;测试用 Node 20 + `tsx` 跑 `node:test`,测试文件置于 Cocos `assets/` 之外。 + +> 遵循 spec `docs/superpowers/specs/2026-06-28-cocos-framework-design.md` §0.1(协议 SSOT:类型与逻辑只引用 `docs/protocol`,不内联复制)、§0.2(不继承旧 bug)、§3/§4(分层与数据流)。 +> 协议权威来源:`docs/protocol/01-传输层与架构.md`(传输/握手/心跳/重连/切换)、`docs/protocol/04-数据结构.md`(login 字段)。 + +--- + +## 文件结构(本计划建立) + +``` +cocoscreator_projects/ +├─ package.json # 加 devDeps: tsx, typescript;scripts: test:framework / typecheck:framework +├─ tsconfig.framework.json # 仅供 Node 侧测试/类型检查 framework 纯逻辑层 +├─ framework-tests/ # 框架纯逻辑层测试(在 Cocos assets 之外,不被 Cocos 编译) +│ ├─ core/{events,seat}.test.ts +│ ├─ net/{envelope-codec,heartbeat,reconnect,net-client}.test.ts +│ ├─ protocol/login.test.ts +│ ├─ integration/login-flow.test.ts +│ └─ helpers/fake-transport.ts # 测试用内存 Transport(模拟 server) +└─ YouleNexus/assets/framework/ + ├─ core/ + │ ├─ events.ts # EventBus + │ ├─ seat.ts # 座位↔视图转换 + │ ├─ constants.ts # APP/路由/心跳超时等常量 + │ └─ types/{envelope,login}.ts # 最小集协议类型 + ├─ net/ + │ ├─ transport.ts # Transport 接口 + │ ├─ envelope-codec.ts # 编单层 / 解双层 + 三道过滤 + 握手/心跳识别 + │ ├─ heartbeat.ts # 30s 收包超时看门狗 + │ ├─ reconnect.ts # 候选服务器轮询 + 计数策略 + │ ├─ connection-state.ts # TcpID / isSendLoginState / isLogin / ConnectType + │ ├─ net-client.ts # 编排 + 收发 + 事件 + login 流程 + │ └─ cocos-transport.ts # 生产用 Cocos/浏览器 WebSocket adapter + └─ protocol/ + ├─ routes.ts # route/rpc 常量 + └─ login.ts # player_login 发送构造 + 响应解析骨架 +``` + +**所有命令工作目录为 `cocoscreator_projects/`(下称 monorepo 根),除非另注。** + +--- + +### Task 1: 框架 TS 测试基建 + +**Files:** +- Modify: `cocoscreator_projects/package.json`(加 devDeps + scripts) +- Create: `cocoscreator_projects/tsconfig.framework.json` +- Create: `cocoscreator_projects/YouleNexus/assets/framework/core/constants.ts` +- Create: `cocoscreator_projects/framework-tests/core/constants.test.ts` + +- [ ] **Step 1: 安装测试依赖** + +Run: `cd G:/Works/YouleGamesCocosCreator/cocoscreator_projects && npm install -D tsx typescript` +Expected: 安装成功,`package.json` 出现 `devDependencies.tsx` 与 `.typescript` + +- [ ] **Step 2: 加 npm scripts** + +编辑 `cocoscreator_projects/package.json`,在 `scripts` 中**增加**两条(保留原有 test/setup-links/new-game/check-cocos/bump-cocos): + +```json + "test:framework": "node --import tsx --test \"framework-tests/**/*.test.ts\"", + "typecheck:framework": "tsc -p tsconfig.framework.json --noEmit" +``` + +- [ ] **Step 3: 建 tsconfig.framework.json** + +创建 `cocoscreator_projects/tsconfig.framework.json`: + +```json +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "types": ["node"], + "lib": ["ES2020", "DOM"] + }, + "include": [ + "YouleNexus/assets/framework/**/*.ts", + "framework-tests/**/*.ts" + ] +} +``` + +- [ ] **Step 4: 写第一个实现 + 失败测试(验证基建打通)** + +创建 `YouleNexus/assets/framework/core/constants.ts`: + +```ts +/** + * 框架常量。协议取值参见 docs/protocol/01、02(spec §0.1:只引用、不内联协议细节)。 + */ +export const APP = 'youle'; + +export const Route = { + platform: 'platform', + agent: 'agent', + room: 'room', +} as const; +export type RouteName = (typeof Route)[keyof typeof Route]; + +/** 收包超时阈值(ms)。来源 docs/protocol/01 §4(ConstVal.Max.heartbeat=30000)。 */ +export const RECV_TIMEOUT_MS = 30000; + +/** 重连定时器间隔(ms)。来源 docs/protocol/01 §7.2(GameData.timer=10000)。 */ +export const RECONNECT_INTERVAL_MS = 10000; + +/** 发登录后等待响应的守护超时(ms)。来源 docs/protocol/01 §6.2(4000)。 */ +export const LOGIN_GUARD_MS = 4000; + +/** 失败累计到该次数轮换一个候选服务器。来源 docs/protocol/01 §7.3(tryReconnectTimes%3)。 */ +export const SERVER_ROTATE_EVERY = 3; +``` + +创建 `framework-tests/core/constants.test.ts`: + +```ts +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { APP, Route, RECV_TIMEOUT_MS, RECONNECT_INTERVAL_MS, LOGIN_GUARD_MS, SERVER_ROTATE_EVERY } from '../../YouleNexus/assets/framework/core/constants.ts'; + +test('框架常量与 docs/protocol 对齐', () => { + assert.equal(APP, 'youle'); + assert.equal(Route.agent, 'agent'); + assert.equal(Route.room, 'room'); + assert.equal(Route.platform, 'platform'); + assert.equal(RECV_TIMEOUT_MS, 30000); + assert.equal(RECONNECT_INTERVAL_MS, 10000); + assert.equal(LOGIN_GUARD_MS, 4000); + assert.equal(SERVER_ROTATE_EVERY, 3); +}); +``` + +- [ ] **Step 5: 跑测试 + 类型检查** + +Run: `npm run test:framework` +Expected: PASS — 1 test passed +Run: `npm run typecheck:framework` +Expected: 无类型错误(exit 0) + +- [ ] **Step 6: Commit** + +```bash +cd G:/Works/YouleGamesCocosCreator +git add cocoscreator_projects/package.json cocoscreator_projects/package-lock.json cocoscreator_projects/tsconfig.framework.json cocoscreator_projects/YouleNexus/assets/framework/core/constants.ts cocoscreator_projects/framework-tests/core/constants.test.ts +git commit -m "chore(framework): TS 测试基建(tsx+node:test) + core 常量 + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +### Task 2: 最小集协议类型 core/types + +**Files:** +- Create: `cocoscreator_projects/YouleNexus/assets/framework/core/types/envelope.ts` +- Create: `cocoscreator_projects/YouleNexus/assets/framework/core/types/login.ts` + +> 类型层无运行时行为,本任务以 `typecheck:framework` 通过为验收(TDD 的"测试"= 类型检查 + 一个编译期用例文件)。 + +- [ ] **Step 1: 定义信封类型** + +创建 `core/types/envelope.ts`: + +```ts +import type { RouteName } from '../constants.ts'; + +/** 客户端→服务器 单层信封。来源 docs/protocol/01 §3.1。 */ +export interface OutboundEnvelope { + app: 'youle'; + route: RouteName | string; // 游戏内 route 为子游戏名 + rpc: string; + data: T; +} + +/** 服务器→客户端 解包后的内层。来源 docs/protocol/01 §3.2。 */ +export interface InboundMessage { + route: string; + rpc: string; + data: T; +} +``` + +- [ ] **Step 2: 定义 login 类型(最小集)** + +创建 `core/types/login.ts`: + +```ts +/** + * player_login 请求/响应(最小集)。完整字段以 docs/protocol/01 §6、02、04 为准(spec §0.1)。 + * 用索引签名容纳本最小集未显式列出的协议字段,避免在此内联复制全部协议字段。 + */ +export interface LoginRequestData { + agentid: number | string; + gameid: number | string; + openid: string; + nickname: string; + avatar: string; + sex: number; + province: string; + city: string; + unionid: string | number; + version: string; + channelid: number | string; + marketid: number | string; + [extra: string]: unknown; // location/ip/machineid/telphone 等由 Send_login 注入,见 docs/protocol/01 §6 +} + +/** 登录响应 A 组账号资产(核心字段)。完整见 docs/protocol/04。 */ +export interface LoginResponseData { + state: number; // 0=成功 + playerid: number; + [extra: string]: unknown; // bean/roomcard/charm/... 及 B 组房间恢复字段 roomcode/isbattle/deskinfo +} +``` + +- [ ] **Step 3: 编译期用例** + +创建 `framework-tests/core/types.typetest.ts`(被 tsconfig include,仅供 typecheck,不在 test glob 内): + +```ts +import type { OutboundEnvelope, InboundMessage } from '../../YouleNexus/assets/framework/core/types/envelope.ts'; +import type { LoginRequestData, LoginResponseData } from '../../YouleNexus/assets/framework/core/types/login.ts'; + +const out: OutboundEnvelope = { + app: 'youle', route: 'agent', rpc: 'player_login', + data: { agentid: 1, gameid: 2, openid: 'o', nickname: 'n', avatar: 'a', sex: 0, province: '', city: '', unionid: 'u', version: '1', channelid: 0, marketid: 0 }, +}; +const inb: InboundMessage = { route: 'agent', rpc: 'player_login', data: { state: 0, playerid: 9 } }; +void out; void inb; +``` + +- [ ] **Step 4: 类型检查通过** + +Run: `npm run typecheck:framework` +Expected: exit 0,无类型错误 + +- [ ] **Step 5: Commit** + +```bash +cd G:/Works/YouleGamesCocosCreator +git add cocoscreator_projects/YouleNexus/assets/framework/core/types cocoscreator_projects/framework-tests/core/types.typetest.ts +git commit -m "feat(framework): core 最小集协议类型(envelope/login) + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +### Task 3: core/events.ts — EventBus + +**Files:** +- Create: `cocoscreator_projects/framework-tests/core/events.test.ts` +- Create: `cocoscreator_projects/YouleNexus/assets/framework/core/events.ts` + +- [ ] **Step 1: 写失败测试** + +创建 `framework-tests/core/events.test.ts`: + +```ts +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { EventBus } from '../../YouleNexus/assets/framework/core/events.ts'; + +test('on/emit 传递参数', () => { + const bus = new EventBus<{ hi: [number, string] }>(); + let got: [number, string] | null = null; + bus.on('hi', (n, s) => { got = [n, s]; }); + bus.emit('hi', 7, 'x'); + assert.deepEqual(got, [7, 'x']); +}); + +test('off 取消订阅', () => { + const bus = new EventBus<{ ping: [] }>(); + let count = 0; + const fn = () => { count++; }; + bus.on('ping', fn); + bus.emit('ping'); + bus.off('ping', fn); + bus.emit('ping'); + assert.equal(count, 1); +}); + +test('once 只触发一次', () => { + const bus = new EventBus<{ ping: [] }>(); + let count = 0; + bus.once('ping', () => { count++; }); + bus.emit('ping'); + bus.emit('ping'); + assert.equal(count, 1); +}); + +test('一个事件订阅者抛错不影响其它订阅者', () => { + const bus = new EventBus<{ ping: [] }>(); + let reached = false; + bus.on('ping', () => { throw new Error('boom'); }); + bus.on('ping', () => { reached = true; }); + bus.emit('ping'); + assert.equal(reached, true); +}); +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `npm run test:framework` +Expected: FAIL — 找不到模块 events.ts + +- [ ] **Step 3: 实现 events.ts** + +创建 `core/events.ts`: + +```ts +type Handler = (...args: any[]) => void; + +/** 轻量类型化事件总线。订阅者抛错被隔离,不影响其它订阅者(故障隔离)。 */ +export class EventBus = Record> { + private map = new Map>(); + + on(type: K, fn: (...args: Events[K]) => void): void { + let set = this.map.get(type); + if (!set) { set = new Set(); this.map.set(type, set); } + set.add(fn as Handler); + } + + once(type: K, fn: (...args: Events[K]) => void): void { + const wrap = (...args: Events[K]) => { this.off(type, wrap as any); (fn as any)(...args); }; + this.on(type, wrap as any); + } + + off(type: K, fn: (...args: Events[K]) => void): void { + this.map.get(type)?.delete(fn as Handler); + } + + emit(type: K, ...args: Events[K]): void { + const set = this.map.get(type); + if (!set) return; + for (const fn of [...set]) { + try { fn(...args); } catch (e) { console.error('[EventBus] handler error:', e); } + } + } +} +``` + +- [ ] **Step 4: 跑测试确认通过** + +Run: `npm run test:framework` +Expected: PASS(含 events 4 用例) +Run: `npm run typecheck:framework` +Expected: exit 0 + +- [ ] **Step 5: Commit** + +```bash +cd G:/Works/YouleGamesCocosCreator +git add cocoscreator_projects/YouleNexus/assets/framework/core/events.ts cocoscreator_projects/framework-tests/core/events.test.ts +git commit -m "feat(framework): core EventBus(类型化+故障隔离) + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +### Task 4: core/seat.ts — 座位↔视图转换 + +**Files:** +- Create: `cocoscreator_projects/framework-tests/core/seat.test.ts` +- Create: `cocoscreator_projects/YouleNexus/assets/framework/core/seat.ts` + +> 替代旧 `Logic.ChangeToStatus`:把服务器绝对座位转成"以我为视角"的相对位置(自己永远在视图 0 号位)。 + +- [ ] **Step 1: 写失败测试** + +创建 `framework-tests/core/seat.test.ts`: + +```ts +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { toView, fromView } from '../../YouleNexus/assets/framework/core/seat.ts'; + +test('toView:自己永远是视图 0 号位', () => { + assert.equal(toView(2, 2, 4), 0); // mySeat=2, target=2 → 0 +}); + +test('toView:他人按环形相对自己排布', () => { + // 4 人桌,我在 2 号位:座位 3→视图1,座位 0→视图2,座位 1→视图3 + assert.equal(toView(2, 3, 4), 1); + assert.equal(toView(2, 0, 4), 2); + assert.equal(toView(2, 1, 4), 3); +}); + +test('fromView 是 toView 的逆运算', () => { + const seatCount = 4, mySeat = 2; + for (let s = 0; s < seatCount; s++) { + assert.equal(fromView(mySeat, toView(mySeat, s, seatCount), seatCount), s); + } +}); +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `npm run test:framework` +Expected: FAIL — 找不到 seat.ts + +- [ ] **Step 3: 实现 seat.ts** + +创建 `core/seat.ts`: + +```ts +/** 绝对座位 → 以 mySeat 为视角的视图位(自己=0,其余环形顺延)。 */ +export function toView(mySeat: number, targetSeat: number, seatCount: number): number { + return ((targetSeat - mySeat) % seatCount + seatCount) % seatCount; +} + +/** 视图位 → 绝对座位(toView 的逆运算)。 */ +export function fromView(mySeat: number, viewSeat: number, seatCount: number): number { + return ((viewSeat + mySeat) % seatCount + seatCount) % seatCount; +} +``` + +- [ ] **Step 4: 跑测试确认通过** + +Run: `npm run test:framework` +Expected: PASS(含 seat 3 用例) + +- [ ] **Step 5: Commit** + +```bash +cd G:/Works/YouleGamesCocosCreator +git add cocoscreator_projects/YouleNexus/assets/framework/core/seat.ts cocoscreator_projects/framework-tests/core/seat.test.ts +git commit -m "feat(framework): core 座位↔视图转换 + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +### Task 5: net/envelope-codec.ts — 编解码 + 三道过滤 + +**Files:** +- Create: `cocoscreator_projects/framework-tests/net/envelope-codec.test.ts` +- Create: `cocoscreator_projects/YouleNexus/assets/framework/net/envelope-codec.ts` + +> 忠实 docs/protocol/01 §3.1(发送单层)/§3.2(接收双层 + 握手/心跳识别)。本模块**纯函数**,不含定时器/连接状态(那些在 net-client)。三道连接级过滤(TcpID/submit_error/isSendLoginState)由 net-client 处理,本模块只负责"一帧原始字符串 → 分类结果"。 + +- [ ] **Step 1: 写失败测试** + +创建 `framework-tests/net/envelope-codec.test.ts`: + +```ts +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { encodeOutbound, decodeFrame } from '../../YouleNexus/assets/framework/net/envelope-codec.ts'; + +test('encodeOutbound 产出单层信封字符串', () => { + const s = encodeOutbound('agent', 'player_login', { openid: 'o' }); + assert.deepEqual(JSON.parse(s), { app: 'youle', route: 'agent', rpc: 'player_login', data: { openid: 'o' } }); +}); + +test('decodeFrame:握手包 @toconcon 识别为 handshake', () => { + const frame = JSON.stringify({ data: '@toconconXYZ...' }); + assert.deepEqual(decodeFrame(frame), { kind: 'handshake' }); +}); + +test('decodeFrame:心跳包 @serverheartbeat 识别为 heartbeat', () => { + const frame = JSON.stringify({ data: JSON.stringify({ com: '@serverheartbeat' }) }); + assert.deepEqual(decodeFrame(frame), { kind: 'heartbeat' }); +}); + +test('decodeFrame:特殊错误包 webserve-服务器未工作', () => { + const frame = JSON.stringify({ data: 'webserve-服务器未工作' }); + assert.deepEqual(decodeFrame(frame), { kind: 'serverDown' }); +}); + +test('decodeFrame:正常业务包解出内层 {route,rpc,data}', () => { + const inner = { route: 'agent', rpc: 'player_login', data: { state: 0, playerid: 9 } }; + const frame = JSON.stringify({ data: JSON.stringify(inner) }); + assert.deepEqual(decodeFrame(frame), { kind: 'message', message: inner }); +}); + +test('decodeFrame:内层为对象(非字符串)也能解', () => { + const inner = { route: 'room', rpc: 'other_join_room', data: { seat: 1 } }; + const frame = JSON.stringify({ data: inner }); + assert.deepEqual(decodeFrame(frame), { kind: 'message', message: inner }); +}); +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `npm run test:framework` +Expected: FAIL — 找不到 envelope-codec.ts + +- [ ] **Step 3: 实现 envelope-codec.ts** + +创建 `net/envelope-codec.ts`: + +```ts +import { APP } from '../core/constants.ts'; +import type { InboundMessage } from '../core/types/envelope.ts'; + +/** 组装单层出站信封字符串。docs/protocol/01 §3.1。 */ +export function encodeOutbound(route: string, rpc: string, data: unknown): string { + return JSON.stringify({ app: APP, route, rpc, data }); +} + +export type DecodeResult = + | { kind: 'handshake' } // @toconcon,忽略 + | { kind: 'heartbeat' } // @serverheartbeat,忽略不回包 + | { kind: 'serverDown' } // webserve-服务器未工作 + | { kind: 'message'; message: InboundMessage } // 正常业务内层 + | { kind: 'ignore' }; // 无法解析,安全忽略 + +function parseMaybe(v: unknown): unknown { + return typeof v === 'string' ? JSON.parse(v) : v; +} + +/** + * 解一帧原始字符串为分类结果。docs/protocol/01 §3.2 步骤 3–8。 + * 不做 TcpID/isSendLoginState/submit_error 过滤(那是连接级,见 net-client)。 + */ +export function decodeFrame(frame: string): DecodeResult { + let outer: any; + try { outer = JSON.parse(frame); } catch { return { kind: 'ignore' }; } + let data = outer?.data; + + if (data === 'webserve-服务器未工作') return { kind: 'serverDown' }; + + if (typeof data === 'string') { + if (data.substr(0, 9) === '@toconcon') return { kind: 'handshake' }; + try { data = JSON.parse(data); } catch { return { kind: 'ignore' }; } + } + if (data && typeof data === 'object' && (data as any).com === '@serverheartbeat') { + return { kind: 'heartbeat' }; + } + const inner = parseMaybe(data) as InboundMessage | undefined; + if (inner && typeof inner === 'object' && 'route' in inner && 'rpc' in inner) { + return { kind: 'message', message: inner }; + } + return { kind: 'ignore' }; +} +``` + +- [ ] **Step 4: 跑测试确认通过** + +Run: `npm run test:framework` +Expected: PASS(含 envelope-codec 6 用例) +Run: `npm run typecheck:framework` +Expected: exit 0 + +- [ ] **Step 5: Commit** + +```bash +cd G:/Works/YouleGamesCocosCreator +git add cocoscreator_projects/YouleNexus/assets/framework/net/envelope-codec.ts cocoscreator_projects/framework-tests/net/envelope-codec.test.ts +git commit -m "feat(framework): net 信封编解码 + 握手/心跳/错误包识别 + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +### Task 6: net/transport.ts + 测试用 FakeTransport + +**Files:** +- Create: `cocoscreator_projects/YouleNexus/assets/framework/net/transport.ts` +- Create: `cocoscreator_projects/framework-tests/helpers/fake-transport.ts` +- Create: `cocoscreator_projects/framework-tests/net/transport.test.ts` + +- [ ] **Step 1: 定义 Transport 接口** + +创建 `net/transport.ts`: + +```ts +/** + * WebSocket 抽象(依赖注入点,D2)。 + * 生产:Cocos/浏览器 WebSocket adapter(见 cocos-transport.ts)。 + * 测试:内存 FakeTransport(模拟 server)。 + */ +export interface Transport { + send(frame: string): void; + close(): void; + onOpen(cb: () => void): void; + onMessage(cb: (frame: string) => void): void; + onClose(cb: () => void): void; + /** 发起连接(建立后应触发 onOpen)。 */ + connect(url: string): void; +} +``` + +- [ ] **Step 2: 实现测试用 FakeTransport(先写它的测试)** + +创建 `framework-tests/net/transport.test.ts`: + +```ts +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { FakeTransport } from '../helpers/fake-transport.ts'; + +test('FakeTransport:connect 后异步触发 onOpen', async () => { + const t = new FakeTransport(); + let opened = false; + t.onOpen(() => { opened = true; }); + t.connect('ws://x'); + await t.flush(); + assert.equal(opened, true); +}); + +test('FakeTransport:客户端 send 进入 sent 队列;serverPush 触发 onMessage', async () => { + const t = new FakeTransport(); + const got: string[] = []; + t.onMessage((f) => got.push(f)); + t.connect('ws://x'); + await t.flush(); + t.send('hello'); + assert.deepEqual(t.sent, ['hello']); + t.serverPush('world'); + await t.flush(); + assert.deepEqual(got, ['world']); +}); + +test('FakeTransport:close 触发 onClose', async () => { + const t = new FakeTransport(); + let closed = false; + t.onClose(() => { closed = true; }); + t.connect('ws://x'); + await t.flush(); + t.close(); + await t.flush(); + assert.equal(closed, true); +}); +``` + +创建 `framework-tests/helpers/fake-transport.ts`: + +```ts +import type { Transport } from '../../YouleNexus/assets/framework/net/transport.ts'; + +/** 内存 Transport:模拟服务器。用 microtask 队列异步触发回调,贴近真实 WS 时序。 */ +export class FakeTransport implements Transport { + sent: string[] = []; + url = ''; + private openCb?: () => void; + private msgCb?: (f: string) => void; + private closeCb?: () => void; + private pending: Array<() => void> = []; + private closed = false; + + connect(url: string): void { this.url = url; this.pending.push(() => this.openCb?.()); } + send(frame: string): void { if (!this.closed) this.sent.push(frame); } + close(): void { if (this.closed) return; this.closed = true; this.pending.push(() => this.closeCb?.()); } + onOpen(cb: () => void): void { this.openCb = cb; } + onMessage(cb: (f: string) => void): void { this.msgCb = cb; } + onClose(cb: () => void): void { this.closeCb = cb; } + + /** 测试侧:模拟服务器推一帧给客户端。 */ + serverPush(frame: string): void { if (!this.closed) this.pending.push(() => this.msgCb?.(frame)); } + + /** 冲刷所有挂起回调(解析微任务)。 */ + async flush(): Promise { + while (this.pending.length) { const fn = this.pending.shift()!; fn(); await Promise.resolve(); } + } +} +``` + +- [ ] **Step 3: 跑测试确认 FakeTransport 通过** + +Run: `npm run test:framework` +Expected: PASS(含 transport 3 用例) + +- [ ] **Step 4: Commit** + +```bash +cd G:/Works/YouleGamesCocosCreator +git add cocoscreator_projects/YouleNexus/assets/framework/net/transport.ts cocoscreator_projects/framework-tests/helpers/fake-transport.ts cocoscreator_projects/framework-tests/net/transport.test.ts +git commit -m "feat(framework): net Transport 接口 + 测试用 FakeTransport + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +### Task 7: net/heartbeat.ts — 收包超时看门狗 + +**Files:** +- Create: `cocoscreator_projects/framework-tests/net/heartbeat.test.ts` +- Create: `cocoscreator_projects/YouleNexus/assets/framework/net/heartbeat.ts` + +> 忠实 docs/protocol/01 §4.1:每收任意包 `feed()` 重置;超时回调由 net-client 决定降级动作(剥离旧 UI 耦合)。注入 `Clock` 便于测试。 + +- [ ] **Step 1: 写失败测试** + +创建 `framework-tests/net/heartbeat.test.ts`: + +```ts +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { HeartbeatWatchdog, type Clock } from '../../YouleNexus/assets/framework/net/heartbeat.ts'; + +function fakeClock() { + let seq = 0; + const timers = new Map void; at: number }>(); + let now = 0; + const clock: Clock = { + setTimeout: (fn, ms) => { const id = ++seq; timers.set(id, { fn, at: now + ms }); return id; }, + clearTimeout: (id: any) => { timers.delete(id); }, + }; + const advance = (ms: number) => { + now += ms; + for (const [id, t] of [...timers]) if (t.at <= now) { timers.delete(id); t.fn(); } + }; + return { clock, advance }; +} + +test('超时未 feed 触发回调', () => { + const { clock, advance } = fakeClock(); + let fired = 0; + const wd = new HeartbeatWatchdog(30000, () => { fired++; }, clock); + wd.feed(); + advance(30000); + assert.equal(fired, 1); +}); + +test('feed 重置计时,未到阈值不触发', () => { + const { clock, advance } = fakeClock(); + let fired = 0; + const wd = new HeartbeatWatchdog(30000, () => { fired++; }, clock); + wd.feed(); + advance(20000); + wd.feed(); // 重置 + advance(20000); // 距上次 feed 仅 20s + assert.equal(fired, 0); +}); + +test('stop 后不再触发', () => { + const { clock, advance } = fakeClock(); + let fired = 0; + const wd = new HeartbeatWatchdog(30000, () => { fired++; }, clock); + wd.feed(); + wd.stop(); + advance(30000); + assert.equal(fired, 0); +}); +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `npm run test:framework` → FAIL(找不到 heartbeat.ts) + +- [ ] **Step 3: 实现 heartbeat.ts** + +```ts +export interface Clock { + setTimeout(fn: () => void, ms: number): unknown; + clearTimeout(handle: unknown): void; +} + +export const realClock: Clock = { + setTimeout: (fn, ms) => setTimeout(fn, ms), + clearTimeout: (h) => clearTimeout(h as any), +}; + +/** 收包超时看门狗。docs/protocol/01 §4.1:每收一包 feed() 重置;超时调 onTimeout。 */ +export class HeartbeatWatchdog { + private handle: unknown = null; + constructor(private ms: number, private onTimeout: () => void, private clock: Clock = realClock) {} + + feed(): void { + this.stop(); + this.handle = this.clock.setTimeout(() => { this.handle = null; this.onTimeout(); }, this.ms); + } + stop(): void { + if (this.handle != null) { this.clock.clearTimeout(this.handle); this.handle = null; } + } +} +``` + +- [ ] **Step 4: 跑测试确认通过** — `npm run test:framework`(含 heartbeat 3 用例)+ `npm run typecheck:framework` + +- [ ] **Step 5: Commit** + +```bash +cd G:/Works/YouleGamesCocosCreator +git add cocoscreator_projects/YouleNexus/assets/framework/net/heartbeat.ts cocoscreator_projects/framework-tests/net/heartbeat.test.ts +git commit -m "feat(framework): net 收包超时看门狗(注入 Clock 可测) + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +### Task 8: net/reconnect.ts — 候选服务器轮询策略 + +**Files:** +- Create: `cocoscreator_projects/framework-tests/net/reconnect.test.ts` +- Create: `cocoscreator_projects/YouleNexus/assets/framework/net/reconnect.ts` + +> 忠实 docs/protocol/01 §7.3:已登录后断线 `tryReconnectTimes % 3 == 0` 轮换候选;单服务器不轮换。纯逻辑、无定时器。 + +- [ ] **Step 1: 写失败测试** + +创建 `framework-tests/net/reconnect.test.ts`: + +```ts +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { ReconnectPolicy } from '../../YouleNexus/assets/framework/net/reconnect.ts'; + +test('单服务器:失败永不轮换', () => { + const p = new ReconnectPolicy('ws://a', 3); + assert.equal(p.current(), 'ws://a'); + for (let i = 0; i < 10; i++) assert.equal(p.onFailure(), 'ws://a'); +}); + +test('多服务器:每 3 次失败轮换下一个', () => { + const p = new ReconnectPolicy(['ws://a', 'ws://b', 'ws://c'], 3); + assert.equal(p.onFailure(), 'ws://a'); // 1 + assert.equal(p.onFailure(), 'ws://a'); // 2 + assert.equal(p.onFailure(), 'ws://b'); // 3 → 轮换 + assert.equal(p.onFailure(), 'ws://b'); // 4 + assert.equal(p.onFailure(), 'ws://b'); // 5 + assert.equal(p.onFailure(), 'ws://c'); // 6 → 轮换 +}); + +test('reset 清零失败计数', () => { + const p = new ReconnectPolicy(['ws://a', 'ws://b'], 3); + p.onFailure(); p.onFailure(); + p.reset(); + assert.equal(p.onFailure(), 'ws://a'); // 计数从 1 起,未到 3,不轮换 +}); + +test('setCurrent 切换到指定服务器(服务器切换指令)', () => { + const p = new ReconnectPolicy(['ws://a', 'ws://b'], 3); + p.setCurrent('ws://room1'); + assert.equal(p.current(), 'ws://room1'); +}); +``` + +- [ ] **Step 2: 跑测试确认失败** — `npm run test:framework` → FAIL + +- [ ] **Step 3: 实现 reconnect.ts** + +```ts +/** 候选服务器轮询。docs/protocol/01 §7.3。 */ +export class ReconnectPolicy { + private servers: string[]; + private index = 0; + private failCount = 0; + constructor(servers: string | string[], private rotateEvery: number) { + this.servers = Array.isArray(servers) ? servers.slice() : [servers]; + } + current(): string { return this.servers[this.index]; } + /** 记一次连接失败,按 rotateEvery 轮换候选,返回新的 current。 */ + onFailure(): string { + this.failCount++; + if (this.servers.length > 1 && this.failCount % this.rotateEvery === 0) { + this.index = (this.index + 1) % this.servers.length; + } + return this.current(); + } + reset(): void { this.failCount = 0; } + /** 服务器切换指令:改用指定地址。docs/protocol/01 §7.4。 */ + setCurrent(server: string): void { this.servers = [server]; this.index = 0; this.failCount = 0; } +} +``` + +- [ ] **Step 4: 跑测试确认通过** — `npm run test:framework`(含 reconnect 4 用例) + +- [ ] **Step 5: Commit** + +```bash +cd G:/Works/YouleGamesCocosCreator +git add cocoscreator_projects/YouleNexus/assets/framework/net/reconnect.ts cocoscreator_projects/framework-tests/net/reconnect.test.ts +git commit -m "feat(framework): net 候选服务器轮询策略 + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +### Task 9: net/net-client.ts — 编排 + login 流程 + 事件 + +**Files:** +- Create: `cocoscreator_projects/framework-tests/net/net-client.test.ts` +- Create: `cocoscreator_projects/YouleNexus/assets/framework/net/net-client.ts` + +> 核心编排,忠实 docs/protocol/01:onOpen 条件发 login(§6.1)、`isSendLoginState` 门控(§3.2-10)、TcpID 去重(§3.2-1)、4s 守护(§6.2)、心跳 feed/超时降级(§4.1)、onClose 重连(§7)、服务器切换(§7.4)。所有外显行为以**事件**呈现(D1),不碰 UI。连接状态标志内联本文件(即结构中的 connection-state 概念)。 + +- [ ] **Step 1: 写失败测试** + +创建 `framework-tests/net/net-client.test.ts`: + +```ts +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { NetClient } from '../../YouleNexus/assets/framework/net/net-client.ts'; +import { FakeTransport } from '../helpers/fake-transport.ts'; +import type { Clock } from '../../YouleNexus/assets/framework/net/heartbeat.ts'; + +function fakeClock() { + let seq = 0, now = 0; + const timers = new Map void; at: number }>(); + const clock: Clock = { + setTimeout: (fn, ms) => { const id = ++seq; timers.set(id, { fn, at: now + ms }); return id; }, + clearTimeout: (id: any) => { timers.delete(id); }, + }; + const advance = (ms: number) => { now += ms; for (const [id, t] of [...timers]) if (t.at <= now) { timers.delete(id); t.fn(); } }; + return { clock, advance }; +} + +const IDENTITY = { agentid: 1, gameid: 2, openid: 'o', nickname: 'n', avatar: 'a', sex: 0, province: '', city: '', unionid: 'u', version: '1', channelid: 0, marketid: 0 }; + +function makeClient(transports: FakeTransport[], clock: Clock) { + let i = 0; + const client = new NetClient({ + servers: 'ws://a', + transportFactory: () => transports[i++] ?? transports[transports.length - 1], + clock, + }); + client.setIdentity(IDENTITY); + return client; +} + +test('onOpen 后自动发 player_login(单层信封)', async () => { + const t = new FakeTransport(); + const { clock } = fakeClock(); + const client = makeClient([t], clock); + client.start(); + await t.flush(); + assert.equal(t.sent.length, 1); + const env = JSON.parse(t.sent[0]); + assert.equal(env.app, 'youle'); + assert.equal(env.route, 'agent'); + assert.equal(env.rpc, 'player_login'); + assert.equal(env.data.openid, 'o'); +}); + +test('isSendLoginState 门控:login 响应前的其它包被丢弃,login 响应放行并 emit login', async () => { + const t = new FakeTransport(); + const { clock } = fakeClock(); + const client = makeClient([t], clock); + const messages: any[] = []; + let loginResp: any = null; + client.on('message', (m: any) => messages.push(m)); + client.on('login', (d: any) => { loginResp = d; }); + client.start(); + await t.flush(); + // 登录响应前推一个业务包 → 应被门控丢弃 + t.serverPush(JSON.stringify({ data: JSON.stringify({ route: 'agent', rpc: 'update_bean', data: { bean: 5 } }) })); + await t.flush(); + assert.equal(messages.length, 0); + // 推 login 响应 → 放行,emit login,清门控 + t.serverPush(JSON.stringify({ data: JSON.stringify({ route: 'agent', rpc: 'player_login', data: { state: 0, playerid: 9 } }) })); + await t.flush(); + assert.equal(loginResp.playerid, 9); + // 门控已清,后续业务包放行 + t.serverPush(JSON.stringify({ data: JSON.stringify({ route: 'agent', rpc: 'update_bean', data: { bean: 5 } }) })); + await t.flush(); + assert.equal(messages.length, 1); + assert.equal(messages[0].rpc, 'update_bean'); +}); + +test('握手包与心跳包被忽略且不进 message', async () => { + const t = new FakeTransport(); + const { clock } = fakeClock(); + const client = makeClient([t], clock); + const messages: any[] = []; + client.on('message', (m: any) => messages.push(m)); + client.start(); + await t.flush(); + // 先完成登录以清门控 + t.serverPush(JSON.stringify({ data: JSON.stringify({ route: 'agent', rpc: 'player_login', data: { state: 0, playerid: 9 } }) })); + await t.flush(); + t.serverPush(JSON.stringify({ data: '@toconconABC' })); // 握手 + t.serverPush(JSON.stringify({ data: JSON.stringify({ com: '@serverheartbeat' }) })); // 心跳 + await t.flush(); + assert.equal(messages.length, 0); +}); + +test('收包超时 emit slow 并触发重连(onClose→新连接重发 login)', async () => { + const t1 = new FakeTransport(), t2 = new FakeTransport(); + const { clock, advance } = fakeClock(); + const client = makeClient([t1, t2], clock); + let slow = 0; + client.on('slow', () => { slow++; }); + client.start(); + await t1.flush(); + // 先完成登录:清 4s 守护、feed 收包看门狗(否则 advance 会先撞上 loginGuard 而非 watchdog) + t1.serverPush(JSON.stringify({ data: JSON.stringify({ route: 'agent', rpc: 'player_login', data: { state: 0, playerid: 9 } }) })); + await t1.flush(); + // 30s 无收包 → watchdog → slow + 关闭当前连接 + advance(30000); + await t1.flush(); + assert.equal(slow, 1); + // 重连定时器到点 → 新连接 t2 建立并重发 login + advance(10000); + await t2.flush(); + assert.equal(t2.sent.length, 1); + assert.equal(JSON.parse(t2.sent[0]).rpc, 'player_login'); +}); + +test('TcpID 去重:旧连接的残留包被丢弃', async () => { + const t1 = new FakeTransport(), t2 = new FakeTransport(); + const { clock, advance } = fakeClock(); + const client = makeClient([t1, t2], clock); + const messages: any[] = []; + client.on('message', (m: any) => messages.push(m)); + client.start(); + await t1.flush(); + t1.serverPush(JSON.stringify({ data: JSON.stringify({ route: 'agent', rpc: 'player_login', data: { state: 0, playerid: 9 } }) })); + await t1.flush(); + // 触发重连切到 t2 + advance(30000); await t1.flush(); advance(10000); await t2.flush(); + // 旧连接 t1 仍推包 → 应被 TcpID 去重丢弃 + t1.serverPush(JSON.stringify({ data: JSON.stringify({ route: 'agent', rpc: 'update_bean', data: { bean: 1 } }) })); + await t1.flush(); + assert.equal(messages.length, 0); +}); +``` + +- [ ] **Step 2: 跑测试确认失败** — `npm run test:framework` → FAIL(找不到 net-client.ts) + +- [ ] **Step 3: 实现 net-client.ts** + +```ts +import { EventBus } from '../core/events.ts'; +import { Route, RECV_TIMEOUT_MS, RECONNECT_INTERVAL_MS, LOGIN_GUARD_MS, SERVER_ROTATE_EVERY } from '../core/constants.ts'; +import type { Transport } from './transport.ts'; +import { encodeOutbound, decodeFrame } from './envelope-codec.ts'; +import { HeartbeatWatchdog, realClock, type Clock } from './heartbeat.ts'; +import { ReconnectPolicy } from './reconnect.ts'; +import type { InboundMessage } from '../core/types/envelope.ts'; +import type { LoginRequestData } from '../core/types/login.ts'; + +export interface NetClientEvents extends Record { + open: []; + login: [unknown]; // player_login 响应 data + message: [InboundMessage]; + slow: []; + reconnecting: [string]; // 目标 server + serverSwitch: [string]; // 新 server 地址 + kicked: [unknown]; + close: []; +} + +export interface NetClientOptions { + servers: string | string[]; + transportFactory: () => Transport; + clock?: Clock; + bus?: EventBus; +} + +/** 网络客户端:编排 transport/codec/heartbeat/reconnect,事件驱动,忠实 docs/protocol/01。 */ +export class NetClient { + readonly bus: EventBus; + private transportFactory: () => Transport; + private clock: Clock; + private policy: ReconnectPolicy; + private watchdog: HeartbeatWatchdog; + + private transport: Transport | null = null; + private tcpId = 0; // 每次连接自增;onMessage 闭包捕获本次 id 做去重 + private identity: LoginRequestData | null = null; + private isSendLoginState = false; + private isLogin = false; + private loginGuard: unknown = null; + private reconnectTimer: unknown = null; + private stopped = false; + + constructor(opts: NetClientOptions) { + this.bus = opts.bus ?? new EventBus(); + this.transportFactory = opts.transportFactory; + this.clock = opts.clock ?? realClock; + this.policy = new ReconnectPolicy(opts.servers, SERVER_ROTATE_EVERY); + this.watchdog = new HeartbeatWatchdog(RECV_TIMEOUT_MS, () => this.onRecvTimeout(), this.clock); + } + + setIdentity(identity: LoginRequestData): void { this.identity = identity; } + on = (t: K, fn: (...a: NetClientEvents[K]) => void) => this.bus.on(t, fn); + off = (t: K, fn: (...a: NetClientEvents[K]) => void) => this.bus.off(t, fn); + + start(): void { this.stopped = false; this.connect(this.policy.current()); } + + stop(): void { + this.stopped = true; + this.watchdog.stop(); + if (this.reconnectTimer != null) { this.clock.clearTimeout(this.reconnectTimer); this.reconnectTimer = null; } + if (this.loginGuard != null) { this.clock.clearTimeout(this.loginGuard); this.loginGuard = null; } + this.transport?.close(); + } + + /** 发业务包(单层信封)。 */ + send(route: string, rpc: string, data: unknown): void { + this.transport?.send(encodeOutbound(route, rpc, data)); + } + + private connect(url: string): void { + const id = ++this.tcpId; + const t = this.transportFactory(); + this.transport = t; + t.onOpen(() => { if (id === this.tcpId) this.onOpen(); }); + t.onMessage((frame) => { if (id === this.tcpId) this.onMessage(frame); }); + t.onClose(() => { if (id === this.tcpId) this.onClose(); }); + t.connect(url); + } + + private onOpen(): void { + this.bus.emit('open'); + this.sendLogin(); + } + + private sendLogin(): void { + if (!this.identity) return; // 无身份不发(docs §6.1:首登需身份) + this.isSendLoginState = true; + this.transport?.send(encodeOutbound(Route.agent, 'player_login', this.identity)); + if (this.loginGuard != null) this.clock.clearTimeout(this.loginGuard); + this.loginGuard = this.clock.setTimeout(() => { this.loginGuard = null; this.onLoginGuardTimeout(); }, LOGIN_GUARD_MS); // docs §6.2 + } + + private onMessage(frame: string): void { + const r = decodeFrame(frame); + if (r.kind === 'ignore') return; + this.watchdog.feed(); // 收任意有效帧重置看门狗 docs §4.1 + if (r.kind === 'handshake' || r.kind === 'heartbeat') return; // 忽略,心跳不回包 + if (r.kind === 'serverDown') { this.sendLogin(); return; } // docs §4.2(剥离 UI 判定,直接重发登录) + + const msg = r.message; + if (msg.rpc === 'submit_error') return; // docs §3.2-9 + if (this.isSendLoginState) { // docs §3.2-10 登录态门控 + if (msg.rpc === 'player_login') { + this.isSendLoginState = false; this.isLogin = true; + if (this.loginGuard != null) { this.clock.clearTimeout(this.loginGuard); this.loginGuard = null; } + this.policy.reset(); + this.bus.emit('login', msg.data); + } else if (msg.rpc === 'kick_server') { + this.bus.emit('kicked', msg.data); + } + return; // 其它一律丢弃 + } + // 服务器切换指令 docs §7.4 + if (msg.rpc === 'connect_roomserver' && (msg.data as any)?.roomserver) { + this.switchServer((msg.data as any).roomserver); return; + } + if (msg.rpc === 'connect_agentserver' && (msg.data as any)?.agentserver) { + this.switchServer((msg.data as any).agentserver); return; + } + this.bus.emit('message', msg); + } + + private switchServer(addr: string): void { + this.policy.setCurrent(addr); + this.bus.emit('serverSwitch', addr); + this.reconnectNow(); // 关闭当前→重连到新地址→重登 + } + + private onRecvTimeout(): void { this.bus.emit('slow'); this.reconnectNow(); } // docs §4.1 + private onLoginGuardTimeout(): void { this.reconnectNow(); } // docs §6.2 + + private onClose(): void { + this.watchdog.stop(); + if (this.stopped) return; + const next = this.policy.onFailure(); + this.bus.emit('reconnecting', next); + if (this.reconnectTimer != null) this.clock.clearTimeout(this.reconnectTimer); + this.reconnectTimer = this.clock.setTimeout(() => { this.reconnectTimer = null; this.connect(next); }, RECONNECT_INTERVAL_MS); // docs §7.2 间隔 10s + } + + /** 主动关闭当前连接,交由 onClose 走重连。 */ + private reconnectNow(): void { + this.isSendLoginState = false; + if (this.loginGuard != null) { this.clock.clearTimeout(this.loginGuard); this.loginGuard = null; } + this.transport?.close(); + } +} +``` + +- [ ] **Step 4: 跑测试确认通过** + +Run: `npm run test:framework` +Expected: PASS(含 net-client 5 用例) +Run: `npm run typecheck:framework` +Expected: exit 0 + +- [ ] **Step 5: Commit** + +```bash +cd G:/Works/YouleGamesCocosCreator +git add cocoscreator_projects/YouleNexus/assets/framework/net/net-client.ts cocoscreator_projects/framework-tests/net/net-client.test.ts +git commit -m "feat(framework): net-client 编排(login流程/门控/TcpID/超时重连/服务器切换/事件) + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +### Task 10: protocol/routes.ts + protocol/login.ts + +**Files:** +- Create: `cocoscreator_projects/framework-tests/protocol/login.test.ts` +- Create: `cocoscreator_projects/YouleNexus/assets/framework/protocol/routes.ts` +- Create: `cocoscreator_projects/YouleNexus/assets/framework/protocol/login.ts` + +> 平台层协议骨架:route/rpc 常量 + login 请求构造 / 响应解析。字段以 docs/protocol/02、04 为准(spec §0.1)。 + +- [ ] **Step 1: 写失败测试** + +创建 `framework-tests/protocol/login.test.ts`: + +```ts +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { buildLoginRequest, parseLoginResponse } from '../../YouleNexus/assets/framework/protocol/login.ts'; + +const IDENTITY = { agentid: 1, gameid: 2, openid: 'o', nickname: 'n', avatar: 'a', sex: 1, province: 'p', city: 'c', unionid: 'u', version: '1.0', channelid: 7, marketid: 9 }; + +test('buildLoginRequest 透传必备身份字段', () => { + const d = buildLoginRequest(IDENTITY); + assert.equal(d.agentid, 1); + assert.equal(d.openid, 'o'); + assert.equal(d.marketid, 9); +}); + +test('parseLoginResponse 提取核心字段 + 房间恢复标志', () => { + const r = parseLoginResponse({ state: 0, playerid: 9, bean: 100, roomcode: 'ABCD', isbattle: 1, deskinfo: { x: 1 } }); + assert.equal(r.ok, true); + assert.equal(r.playerid, 9); + assert.equal(r.inRoom, true); + assert.equal(r.hasBattle, true); + assert.deepEqual(r.deskinfo, { x: 1 }); +}); + +test('parseLoginResponse:无 roomcode 时不在房间', () => { + const r = parseLoginResponse({ state: 0, playerid: 9 }); + assert.equal(r.inRoom, false); + assert.equal(r.hasBattle, false); +}); + +test('parseLoginResponse:state!=0 视为失败', () => { + const r = parseLoginResponse({ state: 1, playerid: -1 }); + assert.equal(r.ok, false); +}); +``` + +- [ ] **Step 2: 跑测试确认失败** — `npm run test:framework` → FAIL + +- [ ] **Step 3: 实现 routes.ts + login.ts** + +创建 `protocol/routes.ts`: + +```ts +import { Route } from '../core/constants.ts'; +export { Route }; +/** 平台层会用到的 rpc 名(最小集)。完整清单见 docs/protocol/02、03。 */ +export const Rpc = { + player_login: 'player_login', + kick_server: 'kick_server', + connect_roomserver: 'connect_roomserver', + connect_agentserver: 'connect_agentserver', +} as const; +``` + +创建 `protocol/login.ts`: + +```ts +import type { LoginRequestData, LoginResponseData } from '../core/types/login.ts'; + +/** 构造 player_login 请求 data。字段以 docs/protocol/01 §6、02 为准。 */ +export function buildLoginRequest(identity: LoginRequestData): LoginRequestData { + return { ...identity }; +} + +export interface ParsedLogin { + ok: boolean; // state===0 + playerid: number; + inRoom: boolean; // 含 roomcode → 需恢复房间 + hasBattle: boolean; // isbattle===1 或含 deskinfo → 需重连对局 + deskinfo: unknown; // 子游戏对局快照(原样透传,见 docs/protocol/05) + raw: LoginResponseData; +} + +/** 解析 player_login 响应(最小集)。完整字段见 docs/protocol/04。 */ +export function parseLoginResponse(data: LoginResponseData): ParsedLogin { + const roomcode = (data as any).roomcode; + const deskinfo = (data as any).deskinfo; + return { + ok: data.state === 0, + playerid: data.playerid, + inRoom: roomcode != null && roomcode !== '', + hasBattle: (data as any).isbattle === 1 || deskinfo != null, + deskinfo, + raw: data, + }; +} +``` + +- [ ] **Step 4: 跑测试确认通过** — `npm run test:framework`(含 login 4 用例)+ `npm run typecheck:framework` + +- [ ] **Step 5: Commit** + +```bash +cd G:/Works/YouleGamesCocosCreator +git add cocoscreator_projects/YouleNexus/assets/framework/protocol cocoscreator_projects/framework-tests/protocol/login.test.ts +git commit -m "feat(framework): protocol routes 常量 + login 请求构造/响应解析 + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +### Task 11: 集成测试 — login 全流程 over FakeTransport + +**Files:** +- Create: `cocoscreator_projects/framework-tests/integration/login-flow.test.ts` + +> 端到端(mock):NetClient + FakeTransport 模拟服务器,覆盖「连接→@toconcon→发 login→login 响应→进房恢复标志→心跳→正常业务包」完整链路。 + +- [ ] **Step 1: 写集成测试** + +创建 `framework-tests/integration/login-flow.test.ts`: + +```ts +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { NetClient } from '../../YouleNexus/assets/framework/net/net-client.ts'; +import { FakeTransport } from '../helpers/fake-transport.ts'; +import { parseLoginResponse } from '../../YouleNexus/assets/framework/protocol/login.ts'; +import type { Clock } from '../../YouleNexus/assets/framework/net/heartbeat.ts'; + +const realishClock: Clock = { setTimeout: (fn, ms) => setTimeout(fn, ms), clearTimeout: (h: any) => clearTimeout(h) }; +const IDENTITY = { agentid: 1, gameid: 2, openid: 'o', nickname: 'n', avatar: 'a', sex: 0, province: '', city: '', unionid: 'u', version: '1', channelid: 0, marketid: 0 }; + +test('login 全流程:握手→登录→业务包,且 parseLoginResponse 识别房间恢复', async () => { + const t = new FakeTransport(); + const client = new NetClient({ servers: 'ws://srv', transportFactory: () => t, clock: realishClock }); + client.setIdentity(IDENTITY); + + let parsed: ReturnType | null = null; + const business: any[] = []; + client.on('login', (d: any) => { parsed = parseLoginResponse(d); }); + client.on('message', (m: any) => business.push(m)); + + client.start(); + await t.flush(); + + // 服务器先发握手包(应被忽略) + t.serverPush(JSON.stringify({ data: '@toconconHELLO' })); + await t.flush(); + + // 客户端应已发出 player_login + assert.equal(JSON.parse(t.sent[0]).rpc, 'player_login'); + + // 服务器回 login 响应(带房间恢复 + 对局) + t.serverPush(JSON.stringify({ data: JSON.stringify({ + route: 'agent', rpc: 'player_login', + data: { state: 0, playerid: 42, bean: 500, roomcode: 'ROOM1', isbattle: 1, deskinfo: { round: 3 } }, + }) })); + await t.flush(); + + assert.ok(parsed); + assert.equal(parsed!.ok, true); + assert.equal(parsed!.playerid, 42); + assert.equal(parsed!.inRoom, true); + assert.equal(parsed!.hasBattle, true); + assert.deepEqual(parsed!.deskinfo, { round: 3 }); + + // 心跳包(忽略,不进 business) + t.serverPush(JSON.stringify({ data: JSON.stringify({ com: '@serverheartbeat' }) })); + // 正常业务推送(门控已清,放行) + t.serverPush(JSON.stringify({ data: JSON.stringify({ route: 'room', rpc: 'other_join_room', data: { seat: 2 } }) })); + await t.flush(); + + assert.equal(business.length, 1); + assert.equal(business[0].rpc, 'other_join_room'); + assert.equal(business[0].data.seat, 2); + + client.stop(); +}); +``` + +- [ ] **Step 2: 跑全部框架测试** + +Run: `npm run test:framework` +Expected: PASS(全部用例,含集成 1 用例) +Run: `npm run typecheck:framework` +Expected: exit 0 + +- [ ] **Step 3: Commit** + +```bash +cd G:/Works/YouleGamesCocosCreator +git add cocoscreator_projects/framework-tests/integration/login-flow.test.ts +git commit -m "test(framework): login 全流程集成测试(over FakeTransport) + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +### Task 12: Cocos WebSocket adapter + 真实服务器端到端验收 + +**Files:** +- Create: `cocoscreator_projects/YouleNexus/assets/framework/net/cocos-transport.ts` + +> ⚠️ 本任务的「真实服务器端到端」步骤**需要外部输入**:可联调的服务器地址 + 测试账号身份字段。adapter 代码可独立完成并 typecheck;真实联调在 Cocos 运行时执行。 + +- [ ] **Step 1: 实现 Cocos/浏览器 WebSocket adapter** + +创建 `net/cocos-transport.ts`(实现 `Transport`,用运行时全局 `WebSocket`,Cocos 原生与浏览器均提供): + +```ts +import type { Transport } from './transport.ts'; + +/** 生产用 Transport:包装运行时全局 WebSocket(Cocos 原生/浏览器)。docs/protocol/01 §2。 */ +export class CocosWebSocketTransport implements Transport { + private ws: WebSocket | null = null; + private openCb?: () => void; + private msgCb?: (frame: string) => void; + private closeCb?: () => void; + + connect(url: string): void { + const ws = new WebSocket(url); // url 形如 ws://ip:port + this.ws = ws; + ws.onopen = () => this.openCb?.(); + ws.onmessage = (ev: MessageEvent) => this.msgCb?.(typeof ev.data === 'string' ? ev.data : String(ev.data)); + ws.onclose = () => this.closeCb?.(); + ws.onerror = () => { try { ws.close(); } catch { /* ignore */ } }; + } + send(frame: string): void { this.ws?.send(frame); } + close(): void { try { this.ws?.close(); } catch { /* ignore */ } } + onOpen(cb: () => void): void { this.openCb = cb; } + onMessage(cb: (frame: string) => void): void { this.msgCb = cb; } + onClose(cb: () => void): void { this.closeCb = cb; } +} +``` + +- [ ] **Step 2: 类型检查** + +Run: `npm run typecheck:framework` +Expected: exit 0(`WebSocket`/`MessageEvent` 由 tsconfig 的 `lib: ["DOM"]` 提供) + +- [ ] **Step 3: Commit adapter** + +```bash +cd G:/Works/YouleGamesCocosCreator +git add cocoscreator_projects/YouleNexus/assets/framework/net/cocos-transport.ts +git commit -m "feat(framework): Cocos/浏览器 WebSocket Transport adapter + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +- [ ] **Step 4: 真实服务器端到端验收(需外部输入)** + +> 执行前向用户索取:① 可联调服务器地址(或配置服务 URL);② 一组可登录的身份字段(agentid/gameid/openid/...)。 + +在 Cocos 工程内写一个临时调试入口(一个挂载脚本或 `debug_execute_script`),用 `CocosWebSocketTransport` + `NetClient`: +1. `client.setIdentity(<真实身份>)`;监听 `open`/`login`/`message`/`slow`/`reconnecting`。 +2. `client.start()`,连真实服务器。 +3. 断言/观察:收到 `@toconcon` 不报错;发出 `player_login`;收到 `player_login` 响应且 `parseLoginResponse(...).ok === true`;console 打印 playerid/资产。 +4. 用 cocos-creator-mcp 的 `debug_get_console_logs` / `debug_screenshot` 留存联调证据。 + +验收标准:真实服务器返回的 `player_login` 被正确解析、`isSendLoginState` 门控正确放行、心跳不被误当业务包、断网后能重连重登。 + +- [ ] **Step 5: 记录联调结果** + +把端到端联调结论(成功/发现的协议偏差)记入 `docs/protocol/` 对应章节的 ⚠️待服务器确认 项(如心跳周期、connect_agentserver.opt 值域等本次能确认的)。 + +--- + +## 验收标准(Plan 2 完成定义) + +1. `npm run test:framework` 全绿;`npm run typecheck:framework` exit 0。 +2. core:EventBus(类型化+故障隔离)、座位转换、最小集类型、常量齐备。 +3. net:信封编解码 + 三类特殊包识别、看门狗、候选轮询、NetClient(login 流程/门控/TcpID/超时重连/服务器切换,全事件驱动、零 UI 耦合)。 +4. protocol:routes 常量 + login 构造/解析。 +5. 集成测试覆盖 login 全流程(mock)。 +6. Cocos adapter 就绪;真实服务器端到端联调通过(需外部输入)。 + +## 后续衔接(Plan 3) +- Plan 3(platform 业务层)开工前需定**响应式 Store 选型**(spec §9)。 +- Plan 3 将基于 NetClient 的 `message`/`login` 事件,构建 `RoomStore`(Desk)/`PlayerStore`(C_Player)/`AppStore`(GameData),并把 docs/protocol/04 的完整数据结构落为类型(本计划只落了最小集)。 +- 游戏内 route 的分发(`Game_Modify._ReceiveData` 等价物)属 sdk 边界(Plan 5),将消费 NetClient 的 `message` 事件中 route∉{platform,agent,room} 的部分。