Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
23 KiB
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>"
验收标准(完成定义)
npm run test:framework全绿;npm run typecheck:frameworkexit 0。resolveRuntimeMode:override 优先、否则跟随构建标志。resolveBootstrap接入必填mode:release 强制 prod、忽略所有 URL 覆盖、身份只走原生、isDebugger=false;debug 维持原有全部覆盖行为;结果含mode/isDebugger。NetClient可选debug/logger:debug 时记录每帧 send/recv,默认关闭不影响现有行为。docs/guides/配置与调试指南.md含「一键切换 debug/release」章节、更新后的接入示例、发布前清单与 API 速查表。
后续衔接
- 启动场景(platform/Plan 3)接入:
resolveRuntimeMode→resolveBootstrap({mode})→NetClient({debug})。 - 远程下发
isdebugger(原get_paravalue('isdebugger'))若需要,归 platform 层,可与本地 mode 取或。