Files
youle_cocos/docs/superpowers/plans/2026-06-28-config-channel.md
T
2026-06-28 16:23:19 +08:00

838 lines
34 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 配置 / 渠道子系统(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 桥(分享/视频/语音等),本计划只做了身份相关的同步原生读取。
```