Files
youle_cocos/docs/superpowers/plans/2026-06-28-runtime-mode.md
2026-06-28 17:59:00 +08:00

23 KiB
Raw Permalink Blame History

debug/release 一键运行模式 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: 给 framework/config 增加一个一键运行模式开关:自动跟随 Cocos 构建类型(debug/release)、可选手动覆盖;release 强制 prod 并完全锁死 URL 覆盖;并把 isDebugger 接到 NetClient 的收发包日志,让 debug 模式真正可观测。

Architecture: 新增纯函数 resolveRuntimeMode(构建标志 + 可选覆盖 → 'debug'|'release')。resolveBootstrap 新增必填 mode:release 分支只用 [BUILD_IDENTITY, nativeSettings] 身份来源且强制 PROFILES.prod,从源头不构造 URL 来源;debug 分支维持现状。NetClient 增可选 debug/logger,在统一的发送/接收切面打印帧。

Tech Stack: TypeScript(Cocos 3.8.8);测试 Node 20 + tsx 跑 node:test,沿用 npm run test:framework / npm run typecheck:framework(cwd cocoscreator_projects/)。

依据 spec docs/superpowers/specs/2026-06-28-runtime-mode-design.md。当前分支 feat/runtime-mode。 命名红线:内部命名现代化,外部契约字符串(协议字段/原生接口名/远程配置键)逐字不变。

所有 npm 命令工作目录 cocoscreator_projects/;git 命令工作目录 G:/Works/YouleGamesCocosCreator。导入必须带 .ts 后缀(tsconfig allowImportingTsExtensions)。git commit 的 CRLF 警告可忽略,用 git log --oneline -1 确认。


文件结构(本计划涉及)

cocoscreator_projects/
├─ YouleNexus/assets/framework/
│  ├─ config/runtime-mode.ts          # 新增:RuntimeMode + MODE_OVERRIDE + resolveRuntimeMode
│  ├─ config/bootstrap.ts             # 修改:BootstrapOptions/Result 加 mode/isDebugger + release 分支
│  └─ net/net-client.ts               # 修改:NetClientOptions 加 debug/logger + 收发日志切面
├─ framework-tests/config/
│  ├─ runtime-mode.test.ts            # 新增
│  └─ bootstrap.test.ts               # 修改:现有 6 用例补 mode + 新增 release 用例
└─ framework-tests/net/net-client.test.ts  # 修改:追加 debug 日志 2 用例
docs/guides/配置与调试指南.md          # 修改:新增模式切换章节 + 更新示例/清单/速查表

Task 1: config/runtime-mode.ts — 运行模式解析

Files:

  • Create: cocoscreator_projects/YouleNexus/assets/framework/config/runtime-mode.ts

  • Create: cocoscreator_projects/framework-tests/config/runtime-mode.test.ts

  • Step 1: 写失败测试

创建 framework-tests/config/runtime-mode.test.ts:

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { resolveRuntimeMode } from '../../YouleNexus/assets/framework/config/runtime-mode.ts';

test('override 优先于构建类型', () => {
  assert.equal(resolveRuntimeMode(true, 'release'), 'release');
  assert.equal(resolveRuntimeMode(false, 'debug'), 'debug');
});

test('无 override:debug 构建 → debug', () => {
  assert.equal(resolveRuntimeMode(true, null), 'debug');
});

test('无 override:release 构建 → release', () => {
  assert.equal(resolveRuntimeMode(false, null), 'release');
});
  • Step 2: 跑测试确认失败 — npm run test:framework → FAIL(找不到 runtime-mode.ts)

  • Step 3: 实现 runtime-mode.ts

创建 config/runtime-mode.ts:

export type RuntimeMode = 'debug' | 'release';

/**
 * 手动覆盖运行模式。
 * null  = 自动跟随构建类型(推荐,发布前保持 null)。
 * 'debug' / 'release' = 强制该模式(release 包里临时调试,或编辑器预览里验证正式行为)。
 */
export const MODE_OVERRIDE: RuntimeMode | null = null;

/**
 * 解析运行模式:override 优先,否则由是否 debug 构建决定。
 * isDebugBuild 由调用方读 Cocos 全局 DEBUG 传入(typeof DEBUG !== 'undefined' ? DEBUG : true)。
 */
export function resolveRuntimeMode(
  isDebugBuild: boolean,
  override: RuntimeMode | null = MODE_OVERRIDE,
): RuntimeMode {
  if (override) return override;
  return isDebugBuild ? 'debug' : 'release';
}
  • Step 4: 跑测试 + 类型检查 — npm run test:framework(含 runtime-mode 3 用例 PASS)+ npm run typecheck:framework(exit 0)

  • Step 5: Commit

cd G:/Works/YouleGamesCocosCreator
git add cocoscreator_projects/YouleNexus/assets/framework/config/runtime-mode.ts cocoscreator_projects/framework-tests/config/runtime-mode.test.ts
git commit -m "feat(framework): config 运行模式解析 resolveRuntimeMode(自动+手动覆盖)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"

Task 2: bootstrap.ts — 接入 mode + release 锁定

Files:

  • Modify: cocoscreator_projects/YouleNexus/assets/framework/config/bootstrap.ts(全文替换为下方内容)
  • Modify: cocoscreator_projects/framework-tests/config/bootstrap.test.ts(全文替换为下方内容)

mode 设为必填。release 分支只用 [BUILD_IDENTITY, nativeSettings] 身份来源、强制 PROFILES.prod,从源头不构造 queryStringSource,杜绝 URL 覆盖。

  • Step 1: 改测试(先让其失败)—— 全文替换 framework-tests/config/bootstrap.test.ts
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { resolveBootstrap } from '../../YouleNexus/assets/framework/config/bootstrap.ts';
import { BUILD_IDENTITY } from '../../YouleNexus/assets/framework/config/sources/defaults.ts';
import type { ConfigFetcher, RemoteConfig } from '../../YouleNexus/assets/framework/config/remote-config.ts';

function fetcherReturning(config: RemoteConfig): ConfigFetcher & { calls: string[] } {
  const calls: string[] = [];
  return { calls, fetch: async (url: string) => { calls.push(url); return config; } };
}
const throwingFetcher: ConfigFetcher = {
  fetch: async () => { throw new Error('net down'); },
};

const REMOTE: RemoteConfig = { data: { player_server_tcp: '5.5.5.5:5000' } };

test('debug + profile=local:用 profile.server 直连,不抓远程', async () => {
  const fetcher = fetcherReturning(REMOTE);
  const r = await resolveBootstrap({ mode: 'debug', win: {}, search: '?profile=local', fetcher, isNative: false });
  assert.deepEqual(r.servers, ['ws://127.0.0.1:3088']);
  assert.equal(r.rawConfig, null);
  assert.equal(fetcher.calls.length, 0);
});

test('debug + prod + H5:抓远程配置产出 servers,identity 来自 defaults', async () => {
  const fetcher = fetcherReturning(REMOTE);
  const r = await resolveBootstrap({ mode: 'debug', win: {}, search: '', fetcher, isNative: false });
  assert.deepEqual(r.servers, ['ws://5.5.5.5:5000']);
  assert.equal(r.identity.agentid, BUILD_IDENTITY.agentid);
  assert.equal(r.identity.gameid, BUILD_IDENTITY.gameid);
  assert.equal(fetcher.calls.length, 1);
});

test('debug + H5 显式 URL query 覆盖身份(最高优先级)', async () => {
  const fetcher = fetcherReturning(REMOTE);
  const r = await resolveBootstrap({ mode: 'debug', win: {}, search: '?agentid=999&channelid=C9', fetcher, isNative: false });
  assert.equal(r.identity.agentid, '999');
  assert.equal(r.identity.channelid, 'C9');
});

test('debug + 原生模式:identity 来自 window.settings', async () => {
  const fetcher = fetcherReturning(REMOTE);
  const win = { settings: { getothername: () => 'NA', getchannelName: () => 'NC', getmarketname: () => 3 } };
  const r = await resolveBootstrap({ mode: 'debug', win, search: '', fetcher, isNative: true });
  assert.equal(r.identity.agentid, 'NA');
  assert.equal(r.identity.channelid, 'NC');
  assert.equal(r.identity.marketid, 3);
});

test('debug + 远程失败 + 有 fallbackServers → 降级使用', async () => {
  const r = await resolveBootstrap({ mode: 'debug', win: {}, search: '', fetcher: throwingFetcher, isNative: false, fallbackServers: ['ws://fb:1'] });
  assert.deepEqual(r.servers, ['ws://fb:1']);
});

test('debug + 远程失败 + 无 fallback → 抛 ConfigFetchError', async () => {
  await assert.rejects(
    resolveBootstrap({ mode: 'debug', win: {}, search: '', fetcher: throwingFetcher, isNative: false }),
    /远程配置抓取失败/,
  );
});

test('debug 结果含 mode/isDebugger', async () => {
  const fetcher = fetcherReturning(REMOTE);
  const r = await resolveBootstrap({ mode: 'debug', win: {}, search: '', fetcher, isNative: false });
  assert.equal(r.mode, 'debug');
  assert.equal(r.isDebugger, true);
});

test('release:忽略所有 URL 覆盖,强制 prod,identity 不被 URL 改', async () => {
  const fetcher = fetcherReturning(REMOTE);
  const r = await resolveBootstrap({ mode: 'release', win: {}, search: '?profile=local&agentid=999', fetcher, isNative: false });
  assert.equal(r.profile.name, 'prod');
  assert.deepEqual(r.servers, ['ws://5.5.5.5:5000']); // 走远程,不是 local 直连
  assert.equal(r.identity.agentid, BUILD_IDENTITY.agentid); // 999 无效
  assert.equal(r.isDebugger, false);
  assert.equal(r.mode, 'release');
  assert.equal(fetcher.calls.length, 1);
});

test('release:原生身份仍生效(URL 的 999 无效)', async () => {
  const fetcher = fetcherReturning(REMOTE);
  const win = { settings: { getothername: () => 'NA', getchannelName: () => 'NC', getmarketname: () => 3 } };
  const r = await resolveBootstrap({ mode: 'release', win, search: '?agentid=999', fetcher, isNative: true });
  assert.equal(r.identity.agentid, 'NA');
  assert.equal(r.identity.channelid, 'NC');
});
  • Step 2: 跑测试确认失败 — npm run test:framework → FAIL(mode 类型缺失 / release 行为未实现 / 结果缺 mode·isDebugger)

  • Step 3: 实现 —— 全文替换 YouleNexus/assets/framework/config/bootstrap.ts

import type { ChannelIdentity } from './identity.ts';
import { resolveIdentity } from './identity.ts';
import { BUILD_IDENTITY } from './sources/defaults.ts';
import { queryStringSource } from './sources/query-string.ts';
import { nativeSettingsSource, type NativeHost } from './sources/native-settings.ts';
import { resolveActiveProfile, PROFILES, DEFAULT_GAMESERVER, type DebugProfile } from './profiles.ts';
import { resolveServers, type ConfigFetcher, type RemoteConfig, ConfigFetchError } from './remote-config.ts';
import type { RuntimeMode } from './runtime-mode.ts';

export interface BootstrapOptions {
  mode: RuntimeMode;               // 运行模式(由 resolveRuntimeMode 得到)
  win: NativeHost;                 // 原生注入宿主(≈ window)
  search: string;                  // location.search
  fetcher: ConfigFetcher;          // 远程配置抓取
  isNative: boolean;               // 环境判定(URL 含 index.html → true,由调用方算)
  fallbackServers?: string[];      // 远程失败/无地址时降级
  cacheBust?: () => string;        // 防缓存串注入(默认 Date.now)
}

export interface BootstrapResult {
  mode: RuntimeMode;
  isDebugger: boolean;             // = (mode === 'debug')
  identity: ChannelIdentity;
  servers: string[];
  rawConfig: RemoteConfig | null;
  profile: DebugProfile;
}

/** 启动编排:按运行模式解析身份 + 决定服务器,产出可喂 NetClient 的结果。 */
export async function resolveBootstrap(opts: BootstrapOptions): Promise<BootstrapResult> {
  const isDebug = opts.mode === 'debug';

  // 模式决定 profile 与身份来源:release 强制 prod,且身份只走原生(绝不读 URL)。
  const profile = isDebug ? resolveActiveProfile(opts.search) : PROFILES.prod;
  const identity = isDebug
    ? resolveIdentity([
        () => BUILD_IDENTITY,
        opts.isNative ? nativeSettingsSource(opts.win) : queryStringSource(opts.search),
        () => profile.identity ?? {},
        queryStringSource(opts.search),   // 显式 URL 覆盖(最高)
      ])
    : resolveIdentity([
        () => BUILD_IDENTITY,
        nativeSettingsSource(opts.win),   // release:身份只取原生,URL 一律无效
      ]);

  const resultBase = { mode: opts.mode, isDebugger: isDebug, identity, profile };

  // 服务器决策:profile 显式 server 直连捷径(仅 debug 的 local/lab 等档位会有)
  if (profile.server) {
    return { ...resultBase, servers: [profile.server], rawConfig: null };
  }

  // 否则远程配置
  const gameserver = profile.gameserver ?? DEFAULT_GAMESERVER;
  const bust = opts.cacheBust ? opts.cacheBust() : String(Date.now());
  const url = gameserver + '?' + bust;     // 防缓存,等价原 min_timestamp/ifast_random

  let config: RemoteConfig;
  try {
    config = await opts.fetcher.fetch(url);
  } catch {
    if (opts.fallbackServers && opts.fallbackServers.length) {
      return { ...resultBase, servers: opts.fallbackServers, rawConfig: null };
    }
    throw new ConfigFetchError(`远程配置抓取失败: ${url}`);
  }

  let servers = resolveServers(config, identity);
  if (!servers.length && opts.fallbackServers && opts.fallbackServers.length) {
    servers = opts.fallbackServers;
  }
  return { ...resultBase, servers, rawConfig: config };
}
  • Step 4: 跑测试确认通过 — npm run test:framework(bootstrap 9 用例全 PASS)+ npm run typecheck:framework(exit 0)

  • Step 5: Commit

cd G:/Works/YouleGamesCocosCreator
git add cocoscreator_projects/YouleNexus/assets/framework/config/bootstrap.ts cocoscreator_projects/framework-tests/config/bootstrap.test.ts
git commit -m "feat(framework): config resolveBootstrap 接入 mode + release 锁死 URL 覆盖

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"

Task 3: net-client.ts — debug 收发包日志

Files:

  • Modify: cocoscreator_projects/YouleNexus/assets/framework/net/net-client.ts
  • Modify: cocoscreator_projects/framework-tests/net/net-client.test.ts(在文件末尾追加 2 个用例)

给 NetClient 加可选 debug/logger;统一经 sendFrame() 发送切面与 onMessage 入口打印帧。默认关闭,不影响现有行为/测试。

  • Step 1: 追加失败测试 —— 在 framework-tests/net/net-client.test.ts 末尾追加
test('debug=true 记录 send 与 recv(注入 logger)', async () => {
  const t = new FakeTransport();
  const { clock } = fakeClock();
  const logs: unknown[][] = [];
  const client = new NetClient({
    servers: 'ws://a',
    transportFactory: () => t,
    clock,
    debug: true,
    logger: (...a: unknown[]) => logs.push(a),
  });
  client.setIdentity(IDENTITY);
  client.start();
  await t.flush();
  assert.ok(logs.some((l) => l[0] === '[net] →'));   // 发出 player_login 被记录
  t.serverPush(JSON.stringify({ data: JSON.stringify({ route: 'agent', rpc: 'player_login', data: { state: 0, playerid: 9 } }) }));
  await t.flush();
  assert.ok(logs.some((l) => l[0] === '[net] ←'));   // 收包被记录
});

test('debug 默认关闭:logger 不被调用', async () => {
  const t = new FakeTransport();
  const { clock } = fakeClock();
  const logs: unknown[][] = [];
  const client = new NetClient({
    servers: 'ws://a',
    transportFactory: () => t,
    clock,
    logger: (...a: unknown[]) => logs.push(a),
  });
  client.setIdentity(IDENTITY);
  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();
  assert.equal(logs.length, 0);
});
  • Step 2: 跑测试确认失败 — npm run test:framework → FAIL(debug/logger 选项不存在,logger 未被调用)

  • Step 3: 改 net-client.ts —— 共 5 处编辑

3a. NetClientOptions 接口加两个可选字段(替换整个接口):

export interface NetClientOptions {
  servers: string | string[];
  transportFactory: () => Transport;
  clock?: Clock;
  bus?: EventBus<NetClientEvents>;
  debug?: boolean;                          // 为真时打印每帧 send/recv
  logger?: (...args: unknown[]) => void;    // 日志函数,默认 console.log(便于测试注入)
}

3b. 在字段声明区(private stopped = false; 这一行后面)新增两个字段:

  private stopped = false;
  private debug: boolean;
  private logger: (...args: unknown[]) => void;

3c. 构造函数体末尾(this.watchdog = ... 那一行后)新增两行:

    this.watchdog = new HeartbeatWatchdog(RECV_TIMEOUT_MS, () => this.onRecvTimeout(), this.clock);
    this.debug = opts.debug ?? false;
    this.logger = opts.logger ?? console.log;

3d. 新增私有发送切面,并把 send() 改为走它。把现有:

  /** 发业务包(单层信封)。 */
  send(route: string, rpc: string, data: unknown): void {
    this.transport?.send(encodeOutbound(route, rpc, data));
  }

替换为:

  /** 发业务包(单层信封)。 */
  send(route: string, rpc: string, data: unknown): void {
    this.sendFrame(encodeOutbound(route, rpc, data));
  }

  /** 统一发送切面:debug 时打印出站帧。 */
  private sendFrame(frame: string): void {
    if (this.debug) this.logger('[net] →', frame);
    this.transport?.send(frame);
  }

3e. sendLogin() 内的直接发送改走切面。把:

    this.isSendLoginState = true;
    this.transport?.send(encodeOutbound(Route.agent, 'player_login', this.identity));

替换为:

    this.isSendLoginState = true;
    this.sendFrame(encodeOutbound(Route.agent, 'player_login', this.identity));

3f. onMessage(frame) 入口打印入站帧。把:

  private onMessage(frame: string): void {
    const r = decodeFrame(frame);

替换为:

  private onMessage(frame: string): void {
    if (this.debug) this.logger('[net] ←', frame);
    const r = decodeFrame(frame);
  • Step 4: 跑测试确认通过 — npm run test:framework(net-client 含新 2 用例全 PASS,原有用例不受影响)+ npm run typecheck:framework(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 NetClient 可选 debug 收发包日志

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"

Task 4: 文档 — 更新配置与调试指南

Files:

  • Modify: docs/guides/配置与调试指南.md

纯文档任务,无测试。改完跑一次全量回归确认未误改代码。所有改动在 docs/ 下。

  • Step 1: 新增「一键切换 debug/release 模式」小节

在 ## 4. 调试 Profiles 这一节之前(即紧接 ## 3. 渠道身份 ChannelIdentity 小节末尾之后)插入新的一节,并把后续小节序号顺延(4→5、5→6…;目录也同步顺延)。新节内容:

## 4. 一键切换 debug / release 模式

运行模式是比 profile 更高一层的总开关,由 `config/runtime-mode.ts` 提供。

| 模式 | 怎么进入 | profile | URL 覆盖(?profile=/?agentid=…) | 身份来源 | 收发包日志 |
| --- | --- | --- | --- | --- | --- |
| **debug** | debug/预览构建自动进入;或 `MODE_OVERRIDE='debug'` | `?profile=` 可切(默认 prod) | **全部生效** | 原生或 URL(按 `isNative`) | 开(`isDebugger=true`) |
| **release** | release 构建自动进入;或 `MODE_OVERRIDE='release'` | **强制 prod** | **全部忽略** | **只走原生 `window.settings`** | 关 |

### 三种切换姿势

1. **什么都不动**:发 release 包自动 release,编辑器预览自动 debug。模式由 Cocos 全局 `DEBUG` 决定。
2. **release 包里临时调试**:把 `config/runtime-mode.ts` 的 `MODE_OVERRIDE` 设为 `'debug'`。
3. **预览里验证正式行为**:把 `MODE_OVERRIDE` 设为 `'release'`。

> release 之所以能放心锁死 URL:正式包是 Cocos 原生(jsb),渠道身份走 `window.settings`/`app_*`,本就不依赖 URL。

### 接入

```ts
import { resolveRuntimeMode } from 'db://assets/framework/config/runtime-mode';

const isDebugBuild = typeof DEBUG !== 'undefined' ? DEBUG : true; // Cocos 全局
const mode = resolveRuntimeMode(isDebugBuild);                    // 'debug' | 'release'
// 传给 resolveBootstrap({ mode, ... }),并把 result.isDebugger 传给 NetClient 的 debug 选项

- [ ] **Step 2: 更新 `resolveBootstrap` 接入示例**

找到原「启动编排 resolveBootstrap 与接入 NetClient」一节里的接入示例代码块,做两处改动:①`resolveBootstrap({...})` 的入参补上 `mode`;②`new NetClient({...})` 补上 `debug`。改后关键两处应为:

```ts
  const isDebugBuild = typeof DEBUG !== 'undefined' ? DEBUG : true;
  const mode = resolveRuntimeMode(isDebugBuild);

  const result = await resolveBootstrap({
    mode,
    win: globalThis as any,
    search: location.search,
    fetcher: new HttpConfigFetcher(),
    isNative: href.indexOf('index.html') > -1,
    fallbackServers: [],
  });

  const net = new NetClient({
    servers: result.servers,
    transportFactory: () => new CocosWebSocketTransport(),
    debug: result.isDebugger,
  });

并在该示例的 import 区补一行 import { resolveRuntimeMode } from 'db://assets/framework/config/runtime-mode';。

  • Step 3: 更新发布前检查清单

在「发布前检查清单」一节的清单最前面加一项:

- [ ] `config/runtime-mode.ts` 的 `MODE_OVERRIDE` 为 `null`(发布走自动模式,由 release 构建决定)。
  • Step 4: 更新 API 速查表

在 API 速查表里,于 resolveBootstrap 行之上新增一行:

| `resolveRuntimeMode` | `config/runtime-mode.ts` | `(isDebugBuild, override?) => 'debug' | 'release'` 运行模式解析 |

并在 resolveBootstrap 行的说明里追加「(入参含 mode,结果含 mode/isDebugger)」。

  • Step 5: 全量回归 + Commit

Run: npm run test:framework(应仍全绿,文档改动不影响) Run: npm run typecheck:framework(exit 0)

cd G:/Works/YouleGamesCocosCreator
git add docs/guides/配置与调试指南.md
git commit -m "docs(guide): 配置与调试指南补充 debug/release 一键模式

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"

验收标准(完成定义)

  1. npm run test:framework 全绿;npm run typecheck:framework exit 0。
  2. resolveRuntimeMode:override 优先、否则跟随构建标志。
  3. resolveBootstrap 接入必填 mode:release 强制 prod、忽略所有 URL 覆盖、身份只走原生、isDebugger=false;debug 维持原有全部覆盖行为;结果含 mode/isDebugger。
  4. NetClient 可选 debug/logger:debug 时记录每帧 send/recv,默认关闭不影响现有行为。
  5. docs/guides/配置与调试指南.md 含「一键切换 debug/release」章节、更新后的接入示例、发布前清单与 API 速查表。

后续衔接

  • 启动场景(platform/Plan 3)接入:resolveRuntimeMode → resolveBootstrap({mode}) → NetClient({debug})。
  • 远程下发 isdebugger(原 get_paravalue('isdebugger'))若需要,归 platform 层,可与本地 mode 取或。