Files
youle_cocos/docs/superpowers/plans/2026-06-28-framework-core-net-protocol.md
joywayerandClaude Opus 5 02dc5d51ca chore(spec): 综合清理 + legacy-layer 迁移 spec/plan/data
主要改动:
- 切到 funplay-cocos-mcp v0.5.1 (用户级配置, 项目级 .mcp.json 删除)
- 仓库文档/CLAUDE.md/.gitignore 等清理过时 cocos-mcp-server 引用
- memory 文件同步: cocos-mcp-setup/path/blocker/spriteframe-uuid/prefab-persist 等加 funplay 实测警告
- memory 新建 funplay-cocos-mcp-pending-verification.md (后已被实测覆盖)
- spec/plan/data:
  - docs/superpowers/specs/2026-09-02-legacy-layer-migration-design.md
  - docs/superpowers/plans/2026-09-02-legacy-layer-migration.md
  - docs/superpowers/data/layer-spirit-summary.json
- YouleNexus: profiles.ts / defaults.ts / PlayerInfoView.prefab / scene 改动

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 07:36:54 +08:00

55 KiB
Raw Permalink Blame History

框架内核 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):

    "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:

{
  "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:

/**
 * 框架常量。协议取值参见 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:

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
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) <noreply@anthropic.com>"

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:

import type { RouteName } from '../constants.ts';

/** 客户端→服务器 单层信封。来源 docs/protocol/01 §3.1。 */
export interface OutboundEnvelope<T = unknown> {
  app: 'youle';
  route: RouteName | string; // 游戏内 route 为子游戏名
  rpc: string;
  data: T;
}

/** 服务器→客户端 解包后的内层。来源 docs/protocol/01 §3.2。 */
export interface InboundMessage<T = unknown> {
  route: string;
  rpc: string;
  data: T;
}
  • Step 2: 定义 login 类型(最小集)

创建 core/types/login.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 内):

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<LoginRequestData> = {
  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<LoginResponseData> = { 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
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) <noreply@anthropic.com>"

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:

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:

type Handler = (...args: any[]) => void;

/** 轻量类型化事件总线。订阅者抛错被隔离,不影响其它订阅者(故障隔离)。 */
export class EventBus<Events extends Record<string, any[]> = Record<string, any[]>> {
  private map = new Map<string, Set<Handler>>();

  on<K extends keyof Events & string>(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<K extends keyof Events & string>(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<K extends keyof Events & string>(type: K, fn: (...args: Events[K]) => void): void {
    this.map.get(type)?.delete(fn as Handler);
  }

  emit<K extends keyof Events & string>(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
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) <noreply@anthropic.com>"

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:

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:

/** 绝对座位 → 以 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
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) <noreply@anthropic.com>"

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:

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:

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
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) <noreply@anthropic.com>"

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:

/**
 * 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:

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:

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<void> {
    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
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) <noreply@anthropic.com>"

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:

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<number, { fn: () => 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
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

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) <noreply@anthropic.com>"

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:

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

/** 候选服务器轮询。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

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) <noreply@anthropic.com>"

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:

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<number, { fn: () => 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

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<string, any[]> {
  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<NetClientEvents>;
}

/** 网络客户端:编排 transport/codec/heartbeat/reconnect,事件驱动,忠实 docs/protocol/01。 */
export class NetClient {
  readonly bus: EventBus<NetClientEvents>;
  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<NetClientEvents>();
    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 = <K extends keyof NetClientEvents & string>(t: K, fn: (...a: NetClientEvents[K]) => void) => this.bus.on(t, fn);
  off = <K extends keyof NetClientEvents & string>(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
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) <noreply@anthropic.com>"

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:

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:

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:

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

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) <noreply@anthropic.com>"

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:

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<typeof parseLoginResponse> | 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
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) <noreply@anthropic.com>"

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 原生与浏览器均提供):

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
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) <noreply@anthropic.com>"
  • 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} 的部分。