docs(plan): 配置/渠道子系统 framework/config 实施计划

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-28 16:23:19 +08:00
co-authored by Claude Opus 4.8
parent bc16d43a91
commit f66771b6b1
@@ -0,0 +1,837 @@
# 配置 / 渠道子系统(framework/config)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/` 子系统——把「渠道身份解析(含原生同步读取)+ 调试 profiles + 远程配置抓取与分层取参 + 服务器地址解析」收敛为可注入、可在 Node 下测试的纯逻辑 + IO 分离模块,最终由 `resolveBootstrap()` 产出可直接喂给 `NetClient` 的 `{ identity, servers }`。
**Architecture:** 严格分纯逻辑(`resolveIdentity`/`getParam`/`resolveServers`/`profiles`)与异步 IO(`ConfigFetcher`),原生读取收敛为单一 source。依赖方向 `config → core`。身份按优先级合并:`defaults < (native|query) < profile < 显式 URL query`。服务器优先用 profile 显式 `server`,否则远程配置 `getParam` → `resolveServers`。外部契约字符串(协议字段名 / 原生接口名 / 远程配置键名)逐字不变,内部命名现代化。
**Tech Stack:** TypeScript(Cocos 3.8.8);测试用 Node 20 + `tsx` 跑 `node:test`,置于 `framework-tests/`(Cocos `assets/` 之外)。沿用 Plan 2 已建的 `npm run test:framework` / `npm run typecheck:framework`。
> 依据 spec `docs/superpowers/specs/2026-06-28-config-channel-design.md`。原工程事实依据:`projects/Game_Surface_3/js/00_Surface/12_Logic.js`(`get_paravalue` 1345 / `getConfig_Succ` 1392 / `get_config` 1332 / `setAgentId` 1234 / `setChannelId` 1217)、`js/00_Surface/05_Func.js`(`getothername` 2467 / `getchannelName` 1961 / `getmarketname` 2429)、`version.js`(构建期默认)、`js/01_SubGame/00_SubGame_Config.js:11`(`gameserver`)。
**所有 npm 命令工作目录为 `cocoscreator_projects/`(monorepo 根);git 命令工作目录为 `G:/Works/YouleGamesCocosCreator`。**
---
## 文件结构(本计划建立)
```
cocoscreator_projects/
├─ framework-tests/config/
│ ├─ identity.test.ts
│ ├─ sources/query-string.test.ts
│ ├─ sources/native-settings.test.ts
│ ├─ profiles.test.ts
│ ├─ remote-config.test.ts
│ └─ bootstrap.test.ts
└─ YouleNexus/assets/framework/config/
├─ identity.ts # ChannelIdentity + IdentitySource + resolveIdentity()
├─ profiles.ts # DebugProfile + PROFILES + resolveActiveProfile()
├─ remote-config.ts # RemoteConfig 类型 + ConfigFetcher + getParam() + resolveServers() + 错误类型
├─ remote-config-fetcher.ts # 生产用 HttpConfigFetcher(typecheck-only)
├─ bootstrap.ts # resolveBootstrap()
└─ sources/
├─ defaults.ts # BUILD_IDENTITY 构建期默认
├─ query-string.ts # queryStringSource()
└─ native-settings.ts # nativeSettingsSource()
```
---
### Task 1: identity.ts + sources/defaults.ts — 身份类型与纯合并
**Files:**
- Create: `cocoscreator_projects/YouleNexus/assets/framework/config/identity.ts`
- Create: `cocoscreator_projects/YouleNexus/assets/framework/config/sources/defaults.ts`
- Create: `cocoscreator_projects/framework-tests/config/identity.test.ts`
- [ ] **Step 1: 写失败测试**
创建 `framework-tests/config/identity.test.ts`:
```ts
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { resolveIdentity } from '../../YouleNexus/assets/framework/config/identity.ts';
import { BUILD_IDENTITY } from '../../YouleNexus/assets/framework/config/sources/defaults.ts';
test('BUILD_IDENTITY 含全部字段且 gameid 为固定 token', () => {
assert.ok(BUILD_IDENTITY.gameid.length > 0);
assert.ok('agentid' in BUILD_IDENTITY);
assert.ok('channelid' in BUILD_IDENTITY);
assert.ok('marketid' in BUILD_IDENTITY);
assert.equal(typeof BUILD_IDENTITY.version, 'string');
assert.equal(typeof BUILD_IDENTITY.versionCode, 'number');
});
test('resolveIdentity:后面的 source 覆盖前面的', () => {
const id = resolveIdentity([
() => BUILD_IDENTITY,
() => ({ agentid: 'A2' }),
() => ({ agentid: 'A3', channelid: 'C3' }),
]);
assert.equal(id.agentid, 'A3');
assert.equal(id.channelid, 'C3');
assert.equal(id.gameid, BUILD_IDENTITY.gameid); // 未被覆盖
});
test('resolveIdentity:undefined / null / 空串 不覆盖', () => {
const id = resolveIdentity([
() => BUILD_IDENTITY,
() => ({ agentid: undefined as any, channelid: '' , marketid: null as any }),
]);
assert.equal(id.agentid, BUILD_IDENTITY.agentid);
assert.equal(id.channelid, BUILD_IDENTITY.channelid);
assert.equal(id.marketid, BUILD_IDENTITY.marketid);
});
test('resolveIdentity:marketid=0 是有效值,应覆盖', () => {
const id = resolveIdentity([() => BUILD_IDENTITY, () => ({ marketid: 0 })]);
assert.equal(id.marketid, 0);
});
```
- [ ] **Step 2: 跑测试确认失败**
Run: `npm run test:framework`
Expected: FAIL — 找不到 identity.ts / defaults.ts
- [ ] **Step 3: 实现 identity.ts**
创建 `config/identity.ts`:
```ts
/** 渠道身份(属性名沿用协议字段,序列化进 player_login,逐字不变)。 */
export interface ChannelIdentity {
agentid: string | number;
channelid: string | number;
gameid: string;
marketid: string | number;
version: string; // 旧 GameData.Version
versionCode: number; // 旧 GameData.versionCode
}
/** 身份来源:返回部分字段覆盖。 */
export type IdentitySource = () => Partial<ChannelIdentity>;
function hasValue(v: unknown): boolean {
return v !== undefined && v !== null && v !== '';
}
/**
* 按数组顺序合并身份,后者覆盖前者(仅覆盖有效值字段)。
* 约定首个 source 为完整 defaults,故返回视为完整 ChannelIdentity。
*/
export function resolveIdentity(sources: IdentitySource[]): ChannelIdentity {
const out: Partial<ChannelIdentity> = {};
for (const src of sources) {
const part = src();
for (const k of Object.keys(part) as (keyof ChannelIdentity)[]) {
const v = part[k];
if (hasValue(v)) (out as Record<string, unknown>)[k] = v;
}
}
return out as ChannelIdentity;
}
```
- [ ] **Step 4: 实现 sources/defaults.ts**
创建 `config/sources/defaults.ts`(默认值取自 `projects/Game_Surface_3/version.js` 模板默认 + H5 marketid 默认 4):
```ts
import type { ChannelIdentity } from '../identity.ts';
/**
* 构建期默认渠道身份(旧 version.js 等价物)。
* gameid 每子游戏固定写死;agentid/channelid/marketid/version 为默认,运行期由 query/native/profile 覆盖。
*/
export const BUILD_IDENTITY: ChannelIdentity = {
agentid: '00bA05haB0d9ZC0fwGD09Q2OA30insbQ',
channelid: 'frdt0C1GG0t91P0McFo0rbA1he5yurbS',
gameid: '8x4l0rGjf026f60c48h0mbUAhK5vV16f',
marketid: 4,
version: '1.1',
versionCode: 10000,
};
```
- [ ] **Step 5: 跑测试 + 类型检查**
Run: `npm run test:framework`
Expected: PASS(含 identity 4 用例)
Run: `npm run typecheck:framework`
Expected: exit 0
- [ ] **Step 6: Commit**
```bash
cd G:/Works/YouleGamesCocosCreator
git add cocoscreator_projects/YouleNexus/assets/framework/config/identity.ts cocoscreator_projects/YouleNexus/assets/framework/config/sources/defaults.ts cocoscreator_projects/framework-tests/config/identity.test.ts
git commit -m "feat(framework): config 身份类型 + 纯合并 resolveIdentity + 构建期默认
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
### Task 2: sources/query-string.ts — H5 URL query 身份来源
**Files:**
- Create: `cocoscreator_projects/YouleNexus/assets/framework/config/sources/query-string.ts`
- Create: `cocoscreator_projects/framework-tests/config/sources/query-string.test.ts`
> 对应原 `Logic.setChannelId`/`setAgentId` 的 H5 分支(`fGetQuery`)。gameid 固定不从 query 取。
- [ ] **Step 1: 写失败测试**
创建 `framework-tests/config/sources/query-string.test.ts`:
```ts
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { queryStringSource } from '../../../YouleNexus/assets/framework/config/sources/query-string.ts';
test('解析 agentid/channelid/marketid/version', () => {
const src = queryStringSource('?agentid=A1&channelid=C1&marketid=2&version=9.9');
assert.deepEqual(src(), { agentid: 'A1', channelid: 'C1', marketid: '2', version: '9.9' });
});
test('缺省键不出现在结果里', () => {
const src = queryStringSource('?agentid=A1');
assert.deepEqual(src(), { agentid: 'A1' });
});
test('空 query → 空对象', () => {
assert.deepEqual(queryStringSource('')(), {});
});
test('不含前导 ? 也能解析', () => {
assert.deepEqual(queryStringSource('agentid=A1')(), { agentid: 'A1' });
});
```
- [ ] **Step 2: 跑测试确认失败** — `npm run test:framework` → FAIL(找不到 query-string.ts)
- [ ] **Step 3: 实现 query-string.ts**
创建 `config/sources/query-string.ts`:
```ts
import type { ChannelIdentity, IdentitySource } from '../identity.ts';
/** H5 模式:从 location.search 解析渠道身份覆盖。URLSearchParams 在浏览器/Cocos/Node 均可用。 */
export function queryStringSource(search: string): IdentitySource {
return () => {
const q = new URLSearchParams(search);
const out: Partial<ChannelIdentity> = {};
const agentid = q.get('agentid'); if (agentid) out.agentid = agentid;
const channelid = q.get('channelid'); if (channelid) out.channelid = channelid;
const marketid = q.get('marketid'); if (marketid) out.marketid = marketid;
const version = q.get('version'); if (version) out.version = version;
return out;
};
}
```
- [ ] **Step 4: 跑测试确认通过** — `npm run test:framework`(含 query-string 4 用例)+ `npm run typecheck:framework`
- [ ] **Step 5: Commit**
```bash
cd G:/Works/YouleGamesCocosCreator
git add cocoscreator_projects/YouleNexus/assets/framework/config/sources/query-string.ts cocoscreator_projects/framework-tests/config/sources/query-string.test.ts
git commit -m "feat(framework): config H5 URL query 身份来源
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
### Task 3: sources/native-settings.ts — 原生同步身份来源
**Files:**
- Create: `cocoscreator_projects/YouleNexus/assets/framework/config/sources/native-settings.ts`
- Create: `cocoscreator_projects/framework-tests/config/sources/native-settings.test.ts`
> 对应原 `Func.getothername("agent")`(→`window.settings.getothername` 或全局 `app_agent`)、`Func.getchannelName()`(→`window.settings.getchannelName`)、`Func.getmarketname()`(→`window.settings.getmarketname` 或全局 `app_market`)。接口名/全局名逐字一致(红线)。
- [ ] **Step 1: 写失败测试**
创建 `framework-tests/config/sources/native-settings.test.ts`:
```ts
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { nativeSettingsSource } from '../../../YouleNexus/assets/framework/config/sources/native-settings.ts';
test('优先从 window.settings 读取 agentid/channelid/marketid', () => {
const win = {
settings: {
getothername: (name: string) => (name === 'agent' ? 'NA' : ''),
getchannelName: () => 'NC',
getmarketname: () => 7,
},
};
assert.deepEqual(nativeSettingsSource(win)(), { agentid: 'NA', channelid: 'NC', marketid: 7 });
});
test('settings 抛错时回退到全局 app_agent / app_market', () => {
const win: any = {
settings: { getothername: () => { throw new Error('no bridge'); },
getchannelName: () => { throw new Error('no bridge'); },
getmarketname: () => { throw new Error('no bridge'); } },
app_agent: 'GA',
app_market: 5,
};
assert.deepEqual(nativeSettingsSource(win)(), { agentid: 'GA', marketid: 5 });
});
test('完全无原生注入 → 空对象', () => {
assert.deepEqual(nativeSettingsSource({})(), {});
});
```
- [ ] **Step 2: 跑测试确认失败** — `npm run test:framework` → FAIL(找不到 native-settings.ts)
- [ ] **Step 3: 实现 native-settings.ts**
创建 `config/sources/native-settings.ts`:
```ts
import type { ChannelIdentity, IdentitySource } from '../identity.ts';
/** 原生注入宿主(≈ window)。settings 由原生注入;全局 app_* 为 uAgent_3 路径回退。 */
export interface NativeHost {
settings?: {
getothername?(name: string): string | number;
getchannelName?(): string | number;
getmarketname?(): string | number;
};
[global: string]: unknown; // app_agent / app_market 等
}
function tryRead(primary: () => unknown, fallback: () => unknown): unknown {
try {
const v = primary();
if (v !== undefined && v !== null && v !== '') return v;
} catch { /* 原生桥不存在,走回退 */ }
try { return fallback(); } catch { return undefined; }
}
function has(v: unknown): boolean { return v !== undefined && v !== null && v !== ''; }
/**
* 原生模式:同步读取渠道身份。逐字对齐原 05_Func.js:
* agent → window.settings.getothername('agent') | window.app_agent
* channel → window.settings.getchannelName()
* market → window.settings.getmarketname() | window.app_market
*/
export function nativeSettingsSource(win: NativeHost): IdentitySource {
return () => {
const out: Partial<ChannelIdentity> = {};
const agentid = tryRead(() => win.settings?.getothername?.('agent'), () => win['app_agent']);
if (has(agentid)) out.agentid = agentid as string | number;
const channelid = tryRead(() => win.settings?.getchannelName?.(), () => undefined);
if (has(channelid)) out.channelid = channelid as string | number;
const marketid = tryRead(() => win.settings?.getmarketname?.(), () => win['app_market']);
if (has(marketid)) out.marketid = marketid as string | number;
return out;
};
}
```
- [ ] **Step 4: 跑测试确认通过** — `npm run test:framework`(含 native-settings 3 用例)+ `npm run typecheck:framework`
- [ ] **Step 5: Commit**
```bash
cd G:/Works/YouleGamesCocosCreator
git add cocoscreator_projects/YouleNexus/assets/framework/config/sources/native-settings.ts cocoscreator_projects/framework-tests/config/sources/native-settings.test.ts
git commit -m "feat(framework): config 原生同步身份来源(window.settings/app_* 逐字对齐)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
### Task 4: profiles.ts — 调试 profiles + URL 切换
**Files:**
- Create: `cocoscreator_projects/YouleNexus/assets/framework/config/profiles.ts`
- Create: `cocoscreator_projects/framework-tests/config/profiles.test.ts`
> 替代原 `Game_Config.Debugger`。`prod` 等价正式发布;`local`/`staging` 提供调试服地址与 gameserver。`ACTIVE_PROFILE` 发布只需保持 `prod`,`?profile=<name>` 可临时切换。地址取自原工程真实值(`00_SubGame_Config.js:11` 正式 gameserver、注释里的测试 gameserver、`12_Logic.js:573` 本地 `127.0.0.1:3088`)。
- [ ] **Step 1: 写失败测试**
创建 `framework-tests/config/profiles.test.ts`:
```ts
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { resolveActiveProfile, PROFILES, ACTIVE_PROFILE } from '../../YouleNexus/assets/framework/config/profiles.ts';
test('默认返回 ACTIVE_PROFILE', () => {
const p = resolveActiveProfile('');
assert.equal(p.name, ACTIVE_PROFILE);
});
test('prod profile 无调试覆盖、isDebugger=false', () => {
const prod = PROFILES['prod'];
assert.equal(prod.isDebugger, false);
assert.equal(prod.server, undefined);
assert.equal(prod.identity, undefined);
assert.ok(prod.gameserver && prod.gameserver.length > 0);
});
test('?profile=local 切到 local,含调试服地址', () => {
const p = resolveActiveProfile('?profile=local');
assert.equal(p.name, 'local');
assert.ok(p.server && p.server.startsWith('ws://'));
});
test('未知 profile 回退到 ACTIVE_PROFILE', () => {
const p = resolveActiveProfile('?profile=nope');
assert.equal(p.name, ACTIVE_PROFILE);
});
```
- [ ] **Step 2: 跑测试确认失败** — `npm run test:framework` → FAIL(找不到 profiles.ts)
- [ ] **Step 3: 实现 profiles.ts**
创建 `config/profiles.ts`:
```ts
import type { ChannelIdentity } from './identity.ts';
export interface DebugProfile {
name: string;
server?: string; // 显式 ws 调试服地址(给定则跳过远程抓取)
gameserver?: string; // 远程配置 txt URL(不含防缓存串)
identity?: Partial<ChannelIdentity>; // 调试用渠道身份覆盖
isDebugger?: boolean; // 收发包日志开关(替代旧 Game_Config.Debugger.isDebugger)
}
/** 正式 gameserver:原 00_SubGame_Config.js:11。 */
export const DEFAULT_GAMESERVER = 'http://ylyxservice1.0791ts.cn/config/update_json.txt';
/** 测试 gameserver:原 00_SubGame_Config.js:9 注释。 */
const STAGING_GAMESERVER = 'http://testgame.youlehdyx.com/update_json/ceshi_json.txt';
export const PROFILES: Record<string, DebugProfile> = {
prod: { name: 'prod', gameserver: DEFAULT_GAMESERVER, isDebugger: false },
staging: { name: 'staging', gameserver: STAGING_GAMESERVER, isDebugger: true },
local: { name: 'local', server: 'ws://127.0.0.1:3088', isDebugger: true },
};
/** 发布只需保持 'prod'。 */
export const ACTIVE_PROFILE = 'prod';
/** 解析当前 profile:ACTIVE_PROFILE 为基线,?profile=<name> 临时切换;未知名回退 ACTIVE_PROFILE。 */
export function resolveActiveProfile(search: string): DebugProfile {
const name = new URLSearchParams(search).get('profile') || ACTIVE_PROFILE;
return PROFILES[name] ?? PROFILES[ACTIVE_PROFILE];
}
```
- [ ] **Step 4: 跑测试确认通过** — `npm run test:framework`(含 profiles 4 用例)+ `npm run typecheck:framework`
- [ ] **Step 5: Commit**
```bash
cd G:/Works/YouleGamesCocosCreator
git add cocoscreator_projects/YouleNexus/assets/framework/config/profiles.ts cocoscreator_projects/framework-tests/config/profiles.test.ts
git commit -m "feat(framework): config 调试 profiles + ?profile= 切换
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
### Task 5: remote-config.ts — 分层取参 getParam + 服务器解析 resolveServers
**Files:**
- Create: `cocoscreator_projects/YouleNexus/assets/framework/config/remote-config.ts`
- Create: `cocoscreator_projects/framework-tests/config/remote-config.test.ts`
> `getParam` 忠实原 `get_paravalue`(12_Logic.js:1345):`data[key]` 顶层默认,逐层进入匹配的 `agentlist→gamelist→channellist→marketlist`,越具体越优先;匹配键宽松比较 `==`(id 可能 string/number)。连接键名(`*_server_tcp`)逐字一致(getConfig_Succ 1404-1409)。
- [ ] **Step 1: 写失败测试**
创建 `framework-tests/config/remote-config.test.ts`:
```ts
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { getParam, resolveServers, type RemoteConfig } from '../../YouleNexus/assets/framework/config/remote-config.ts';
import type { ChannelIdentity } from '../../YouleNexus/assets/framework/config/identity.ts';
const ID: ChannelIdentity = { agentid: 'A', channelid: 'C', gameid: 'G', marketid: 2, version: '1', versionCode: 1 };
const CONFIG: RemoteConfig = {
data: {
player_server_tcp: '1.1.1.1:1000', // 顶层默认
agentlist: [{
agentid: 'A',
player_server_tcp: '2.2.2.2:2000', // agent 级覆盖
gamelist: [{
gameid: 'G',
channellist: [{
channelid: 'C',
player_server_tcp: '3.3.3.3:3000', // channel 级覆盖(最具体)
visitor_server_tcp: '4.4.4.4:4000',
marketlist: [{ marketid: 2 }],
}],
}],
}],
},
};
test('getParam:最具体层级胜出', () => {
assert.equal(getParam(CONFIG, 'player_server_tcp', ID), '3.3.3.3:3000');
});
test('getParam:channel 未匹配时回退到 agent 级值', () => {
const id2 = { ...ID, channelid: 'OTHER' };
assert.equal(getParam(CONFIG, 'player_server_tcp', id2), '2.2.2.2:2000');
});
test('getParam:agent 未匹配时回退到顶层默认', () => {
const id3 = { ...ID, agentid: 'NONE' };
assert.equal(getParam(CONFIG, 'player_server_tcp', id3), '1.1.1.1:1000');
});
test('getParam:键不存在 → null', () => {
assert.equal(getParam(CONFIG, 'not_a_key', ID), null);
});
test('getParam:无 data → null', () => {
assert.equal(getParam({}, 'player_server_tcp', ID), null);
});
test('resolveServers:player 优先、visitor 回退,前缀 ws://', () => {
assert.deepEqual(resolveServers(CONFIG, ID), ['ws://3.3.3.3:3000', 'ws://4.4.4.4:4000']);
});
test('resolveServers:已带 ws:// 不重复前缀;无地址 → 空数组', () => {
const cfg: RemoteConfig = { data: { player_server_tcp: 'ws://9.9.9.9:9' } };
assert.deepEqual(resolveServers(cfg, ID), ['ws://9.9.9.9:9']);
assert.deepEqual(resolveServers({ data: {} }, ID), []);
});
```
- [ ] **Step 2: 跑测试确认失败** — `npm run test:framework` → FAIL(找不到 remote-config.ts)
- [ ] **Step 3: 实现 remote-config.ts**
创建 `config/remote-config.ts`:
```ts
import type { ChannelIdentity } from './identity.ts';
/** 远程配置内层数据(键名逐字一致原 get_paravalue / getConfig_Succ)。 */
export interface MarketEntry { marketid: string | number; [k: string]: unknown; }
export interface ChannelEntry { channelid: string | number; marketlist?: MarketEntry[]; [k: string]: unknown; }
export interface GameEntry { gameid: string | number; channellist?: ChannelEntry[]; [k: string]: unknown; }
export interface AgentEntry { agentid: string | number; gamelist?: GameEntry[]; [k: string]: unknown; }
export interface RemoteConfigData { agentlist?: AgentEntry[]; [k: string]: unknown; }
export interface RemoteConfig { data?: RemoteConfigData; }
/** 远程配置抓取抽象(生产注入 HttpConfigFetcher,测试注入 fake)。 */
export interface ConfigFetcher { fetch(url: string): Promise<RemoteConfig>; }
export class ConfigFetchError extends Error {}
export class ConfigParseError extends Error {}
/**
* 分层取参,忠实原 get_paravalue:顶层 data[key] 为默认,逐层进入匹配项;
* 某层有该 key(truthy)则更新返回值;匹配链中断则返回当前最具体值。
*/
export function getParam(config: RemoteConfig, key: string, id: ChannelIdentity): unknown {
const data = config.data;
if (!data) return null;
let value: unknown = data[key] ? data[key] : null;
const descend = <T extends Record<string, unknown>>(
list: T[] | undefined, idKey: string, idVal: unknown,
): T | null => {
if (!list) return null;
for (const item of list) {
if (item[idKey] == idVal) { // 宽松比较,id 可能 string/number
if (item[key]) value = item[key];
return item;
}
}
return null;
};
const agent = descend(data.agentlist, 'agentid', id.agentid);
if (!agent) return value;
const game = descend(agent.gamelist, 'gameid', id.gameid);
if (!game) return value;
const channel = descend(game.channellist, 'channelid', id.channelid);
if (!channel) return value;
descend(channel.marketlist, 'marketid', id.marketid);
return value;
}
/** 连接相关参数键(逐字一致)。优先级即数组顺序。 */
const SERVER_KEYS = ['player_server_tcp', 'visitor_server_tcp', 'game_server_tcp'] as const;
function toWs(addr: string): string {
return (addr.startsWith('ws://') || addr.startsWith('wss://')) ? addr : 'ws://' + addr;
}
/** 解析 ws 候选服务器列表(按 SERVER_KEYS 优先级,去重)。 */
export function resolveServers(config: RemoteConfig, id: ChannelIdentity): string[] {
const out: string[] = [];
for (const key of SERVER_KEYS) {
const v = getParam(config, key, id);
if (typeof v === 'string' && v) {
const ws = toWs(v);
if (!out.includes(ws)) out.push(ws);
}
}
return out;
}
```
- [ ] **Step 4: 跑测试确认通过** — `npm run test:framework`(含 remote-config 8 用例)+ `npm run typecheck:framework`
- [ ] **Step 5: Commit**
```bash
cd G:/Works/YouleGamesCocosCreator
git add cocoscreator_projects/YouleNexus/assets/framework/config/remote-config.ts cocoscreator_projects/framework-tests/config/remote-config.test.ts
git commit -m "feat(framework): config 远程配置分层取参 getParam + 服务器解析 resolveServers
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
### Task 6: bootstrap.ts — 启动编排 resolveBootstrap
**Files:**
- Create: `cocoscreator_projects/YouleNexus/assets/framework/config/bootstrap.ts`
- Create: `cocoscreator_projects/framework-tests/config/bootstrap.test.ts`
> 编排身份合并 + 服务器决策(profile 直连捷径 / 远程配置 / 失败降级),产出可直接喂 NetClient 的 `{ identity, servers }`。优先级:`defaults < (native|query) < profile.identity < 显式 query 覆盖`。
- [ ] **Step 1: 写失败测试**
创建 `framework-tests/config/bootstrap.test.ts`:
```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('profile=local:用 profile.server 直连,不抓远程', async () => {
const fetcher = fetcherReturning(REMOTE);
const r = await resolveBootstrap({ 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('prod + H5:抓远程配置产出 servers,identity 来自 defaults', async () => {
const fetcher = fetcherReturning(REMOTE);
const r = await resolveBootstrap({ 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('H5 显式 URL query 覆盖身份(最高优先级)', async () => {
const fetcher = fetcherReturning(REMOTE);
const r = await resolveBootstrap({ win: {}, search: '?agentid=999&channelid=C9', fetcher, isNative: false });
assert.equal(r.identity.agentid, '999');
assert.equal(r.identity.channelid, 'C9');
});
test('原生模式:identity 来自 window.settings', async () => {
const fetcher = fetcherReturning(REMOTE);
const win = { settings: { getothername: () => 'NA', getchannelName: () => 'NC', getmarketname: () => 3 } };
const r = await resolveBootstrap({ win, search: '', fetcher, isNative: true });
assert.equal(r.identity.agentid, 'NA');
assert.equal(r.identity.channelid, 'NC');
assert.equal(r.identity.marketid, 3);
});
test('远程失败 + 有 fallbackServers → 降级使用', async () => {
const r = await resolveBootstrap({ win: {}, search: '', fetcher: throwingFetcher, isNative: false, fallbackServers: ['ws://fb:1'] });
assert.deepEqual(r.servers, ['ws://fb:1']);
});
test('远程失败 + 无 fallback → 抛 ConfigFetchError', async () => {
await assert.rejects(
resolveBootstrap({ win: {}, search: '', fetcher: throwingFetcher, isNative: false }),
/远程配置抓取失败/,
);
});
```
- [ ] **Step 2: 跑测试确认失败** — `npm run test:framework` → FAIL(找不到 bootstrap.ts)
- [ ] **Step 3: 实现 bootstrap.ts**
创建 `config/bootstrap.ts`:
```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, DEFAULT_GAMESERVER, type DebugProfile } from './profiles.ts';
import { resolveServers, type ConfigFetcher, type RemoteConfig, ConfigFetchError } from './remote-config.ts';
export interface BootstrapOptions {
win: NativeHost; // 原生注入宿主(≈ window)
search: string; // location.search
fetcher: ConfigFetcher; // 远程配置抓取
isNative: boolean; // 环境判定(URL 含 index.html → true,由调用方算)
fallbackServers?: string[]; // 远程失败/无地址时降级
cacheBust?: () => string; // 防缓存串注入(默认 Date.now)
}
export interface BootstrapResult {
identity: ChannelIdentity;
servers: string[];
rawConfig: RemoteConfig | null;
profile: DebugProfile;
}
/** 启动编排:解析身份 + 决定服务器,产出可喂 NetClient 的结果。 */
export async function resolveBootstrap(opts: BootstrapOptions): Promise<BootstrapResult> {
const profile = resolveActiveProfile(opts.search);
const runtime = opts.isNative ? nativeSettingsSource(opts.win) : queryStringSource(opts.search);
const identity = resolveIdentity([
() => BUILD_IDENTITY,
runtime,
() => profile.identity ?? {},
queryStringSource(opts.search), // 显式 URL 覆盖(最高)
]);
// 服务器决策:profile 显式 server 直连捷径
if (profile.server) {
return { identity, servers: [profile.server], rawConfig: null, profile };
}
// 否则远程配置
const base = profile.gameserver ?? DEFAULT_GAMESERVER;
const bust = opts.cacheBust ? opts.cacheBust() : String(Date.now());
const url = base + '?' + bust; // 防缓存,等价原 min_timestamp/ifast_random
let config: RemoteConfig;
try {
config = await opts.fetcher.fetch(url);
} catch {
if (opts.fallbackServers && opts.fallbackServers.length) {
return { identity, servers: opts.fallbackServers, rawConfig: null, profile };
}
throw new ConfigFetchError(`远程配置抓取失败: ${url}`);
}
let servers = resolveServers(config, identity);
if (!servers.length && opts.fallbackServers && opts.fallbackServers.length) {
servers = opts.fallbackServers;
}
return { identity, servers, rawConfig: config, profile };
}
```
- [ ] **Step 4: 跑测试确认通过** — `npm run test:framework`(含 bootstrap 6 用例)+ `npm run typecheck:framework`
- [ ] **Step 5: Commit**
```bash
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(身份合并+服务器决策+降级)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
### Task 7: remote-config-fetcher.ts — 生产用 HTTP 抓取(typecheck-only)
**Files:**
- Create: `cocoscreator_projects/YouleNexus/assets/framework/config/remote-config-fetcher.ts`
> 生产实现,依赖运行时全局 `fetch`(浏览器/Cocos 均提供)。无单测(同 `cocos-transport.ts` 处理),仅 typecheck。方法用 GET 抓取静态配置 txt(原 `get_config` 用 POST 抓静态文件;静态文件服务器 GET/POST 均返回同一文件,此处用语义更正确的 GET,不需要服务器改动)。
- [ ] **Step 1: 实现 remote-config-fetcher.ts**
创建 `config/remote-config-fetcher.ts`:
```ts
import type { ConfigFetcher, RemoteConfig } from './remote-config.ts';
import { ConfigParseError } from './remote-config.ts';
/** 生产用 ConfigFetcher:全局 fetch 抓取远程配置 txt 并 JSON.parse。 */
export class HttpConfigFetcher implements ConfigFetcher {
async fetch(url: string): Promise<RemoteConfig> {
const resp = await fetch(url, { method: 'GET' });
const text = await resp.text();
try {
return JSON.parse(text) as RemoteConfig;
} catch {
throw new ConfigParseError(`远程配置 JSON 解析失败: ${url}`);
}
}
}
```
- [ ] **Step 2: 类型检查**
Run: `npm run typecheck:framework`
Expected: exit 0(`fetch`/`Response` 由 tsconfig `lib:["DOM"]` 提供)
- [ ] **Step 3: 跑全部框架测试(回归)**
Run: `npm run test:framework`
Expected: PASS(全部用例,含本计划新增 config 用例 + Plan 2 原有用例)
- [ ] **Step 4: Commit**
```bash
cd G:/Works/YouleGamesCocosCreator
git add cocoscreator_projects/YouleNexus/assets/framework/config/remote-config-fetcher.ts
git commit -m "feat(framework): config 生产用 HttpConfigFetcher(全局 fetch)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## 验收标准(完成定义)
1. `npm run test:framework` 全绿;`npm run typecheck:framework` exit 0。
2. config 层:身份类型 + 纯合并、构建期默认、H5/原生两个身份来源、调试 profiles + URL 切换、远程配置 `getParam`(忠实 get_paravalue)+ `resolveServers`、启动编排 `resolveBootstrap`、生产 fetcher 齐备。
3. `resolveBootstrap` 在 mock(fake fetcher + fake 环境)下,能产出可直接喂给 `NetClient` 的 `{ identity, servers }`,覆盖:profile 直连捷径 / 远程配置 / 失败降级 / 身份四级优先级 / 原生与 H5 两模式。
4. 外部契约字符串(协议字段名、原生接口名 `getothername/getchannelName/getmarketname`、远程配置键 `agentlist/.../player_server_tcp` 等)逐字保留。
## 后续衔接
- platform Plan:复用同一 `getParam` 提取平台/UI 类远程参数(`menunotice`/`scrollmsg`/`service_*`/`logimage`/`rankList`…)。
- 端到端联调(Plan 2 Task 12 Step 4-5):`resolveBootstrap` + `HttpConfigFetcher` 接真实配置服与 `NetClient`,跑真实 login 往返。
- sdk Plan:完整异步 WVJB 桥(分享/视频/语音等),本计划只做了身份相关的同步原生读取。
```