# 框架内核 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. 用 funplay-cocos-mcp 的 `search_project_logs` / `capture_preview_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} 的部分。