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

34 KiB
Raw Blame History

配置 / 渠道子系统(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:

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:

/** 渠道身份(属性名沿用协议字段,序列化进 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):

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
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:

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:

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

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:

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:

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

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:

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:

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

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:

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:

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

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:

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:

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

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:

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
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 桥(分享/视频/语音等),本计划只做了身份相关的同步原生读取。