From f66771b6b1869365f912805716b9e6ce616e150b Mon Sep 17 00:00:00 2001 From: Joywayer Date: Sun, 28 Jun 2026 16:23:19 +0800 Subject: [PATCH] =?UTF-8?q?docs(plan):=20=E9=85=8D=E7=BD=AE/=E6=B8=A0?= =?UTF-8?q?=E9=81=93=E5=AD=90=E7=B3=BB=E7=BB=9F=20framework/config=20?= =?UTF-8?q?=E5=AE=9E=E6=96=BD=E8=AE=A1=E5=88=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plans/2026-06-28-config-channel.md | 837 ++++++++++++++++++ 1 file changed, 837 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-28-config-channel.md diff --git a/docs/superpowers/plans/2026-06-28-config-channel.md b/docs/superpowers/plans/2026-06-28-config-channel.md new file mode 100644 index 0000000..afa8108 --- /dev/null +++ b/docs/superpowers/plans/2026-06-28-config-channel.md @@ -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; + +function hasValue(v: unknown): boolean { + return v !== undefined && v !== null && v !== ''; +} + +/** + * 按数组顺序合并身份,后者覆盖前者(仅覆盖有效值字段)。 + * 约定首个 source 为完整 defaults,故返回视为完整 ChannelIdentity。 + */ +export function resolveIdentity(sources: IdentitySource[]): ChannelIdentity { + const out: Partial = {}; + 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)[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) " +``` + +--- + +### 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 = {}; + 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) " +``` + +--- + +### 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 = {}; + 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) " +``` + +--- + +### 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=` 可临时切换。地址取自原工程真实值(`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; // 调试用渠道身份覆盖 + 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 = { + 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= 临时切换;未知名回退 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) " +``` + +--- + +### 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; } + +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 = >( + 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) " +``` + +--- + +### 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 { + 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) " +``` + +--- + +### 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 { + 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) " +``` + +--- + +## 验收标准(完成定义) + +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 桥(分享/视频/语音等),本计划只做了身份相关的同步原生读取。 +```