From d1ad63983be03f36d32e962e2e5018bcdc5de427 Mon Sep 17 00:00:00 2001 From: Joywayer Date: Sat, 5 Sep 2026 01:25:50 +0800 Subject: [PATCH] fix(config): restore legacy gameserver and urlserver contract --- .../assets/framework/config/bootstrap.ts | 118 ++++++----------- .../assets/framework/config/identity.ts | 24 +++- .../assets/framework/config/profiles.ts | 75 +++++++---- .../framework/config/remote-config-fetcher.ts | 17 ++- .../assets/framework/config/remote-config.ts | 90 +++++-------- .../assets/framework/config/runtime-config.ts | 125 ++++++++++++++++++ .../config/sources/native-settings.ts | 68 +++++++--- .../framework-tests/config/bootstrap.test.ts | 122 ++--------------- .../framework-tests/config/identity.test.ts | 19 +++ .../framework-tests/config/profiles.test.ts | 47 ++++--- .../config/remote-config-fetcher.test.ts | 47 +++++++ .../config/remote-config.test.ts | 72 ++++------ .../config/runtime-config.test.ts | 111 ++++++++++++++++ .../config/sources/native-settings.test.ts | 68 +++++++--- .../contracts/remote-config-array.json | 1 + .../contracts/remote-config-single.json | 1 + 16 files changed, 622 insertions(+), 383 deletions(-) create mode 100644 cocoscreator_projects/YouleNexus/assets/framework/config/runtime-config.ts create mode 100644 cocoscreator_projects/framework-tests/config/remote-config-fetcher.test.ts create mode 100644 cocoscreator_projects/framework-tests/config/runtime-config.test.ts create mode 100644 cocoscreator_projects/framework-tests/fixtures/contracts/remote-config-array.json create mode 100644 cocoscreator_projects/framework-tests/fixtures/contracts/remote-config-single.json diff --git a/cocoscreator_projects/YouleNexus/assets/framework/config/bootstrap.ts b/cocoscreator_projects/YouleNexus/assets/framework/config/bootstrap.ts index 7882db3..6623c00 100644 --- a/cocoscreator_projects/YouleNexus/assets/framework/config/bootstrap.ts +++ b/cocoscreator_projects/YouleNexus/assets/framework/config/bootstrap.ts @@ -1,82 +1,52 @@ -import type { ChannelIdentity } from './identity.ts'; -import { resolveIdentity } from './identity.ts'; -import { BUILD_IDENTITY } from './sources/defaults.ts'; -import { queryStringSource } from './sources/query-string.ts'; -import { nativeSettingsSource, type NativeHost } from './sources/native-settings.ts'; -import { resolveActiveProfile, PROFILES, type DebugProfile, type DebugAccount } from './profiles.ts'; -import { resolveServers, type ConfigFetcher, type RemoteConfig, ConfigFetchError } from './remote-config.ts'; -import type { RuntimeMode } from './runtime-mode.ts'; +import type { DebugAccount, DebugProfile } from './profiles.ts'; +import { PROFILES, resolveActiveProfile } from './profiles.ts'; +import { + resolveRuntimeConfig, + type HostKind, + type ResolveRuntimeConfigOptions, + type RuntimeConfig, +} from './runtime-config.ts'; -export interface BootstrapOptions { - mode: RuntimeMode; // 运行模式(由 resolveRuntimeMode 得到) - win: NativeHost; // 原生注入宿主(≈ window) - search: string; // location.search - fetcher: ConfigFetcher; // 远程配置抓取 - isNative: boolean; // 环境判定(URL 含 index.html → true,由调用方算) - cacheBust?: () => string; // 防缓存串注入(默认 Date.now) -} +type RuntimeOptionsWithoutHost = Omit; -export interface BootstrapResult { - mode: RuntimeMode; - isDebugger: boolean; // = (mode === 'debug') - identity: ChannelIdentity; - account?: DebugAccount; // 调试登录账号(仅 debug 模式有值;release 恒 undefined) - servers: string[]; - rawConfig: RemoteConfig | null; - profile: DebugProfile; -} +/** 新调用方显式给 hostKind;isNative 分支仅保留现有 LoginFlow 的兼容入口。 */ +export type BootstrapOptions = RuntimeOptionsWithoutHost & ( + | { readonly hostKind: HostKind; readonly isNative?: never } + | { readonly hostKind?: never; readonly isNative: boolean } +); /** - * 启动编排:按运行模式解析身份 + 决定服务器,产出可喂 NetClient 的结果。 - * 数据源权威唯一、下游不兜底(CLAUDE.md 第二准则):服务器地址只来自 profile.server(直连) - * 或远程配置解析;取不到一律显式抛错暴露,不猜默认、不降级。 + * 旧 StartupOrchestrator 的过渡外观。严格的新代码使用 RuntimeConfig;这里保留 + * 可写 servers 类型以及可选的新判别字段,直到旧启动编排迁移完成。 */ -export async function resolveBootstrap(opts: BootstrapOptions): Promise { - const isDebug = opts.mode === 'debug'; +export type BootstrapResult = Omit & { + readonly servers: string[]; + readonly source?: RuntimeConfig['source']; + readonly gameserver?: RuntimeConfig['gameserver']; + readonly account?: DebugAccount; + readonly profile: Pick; +}; - // 模式决定 profile 与身份来源:release 强制 prod,且身份只走原生(绝不读 URL)。 - const profile = isDebug ? resolveActiveProfile(opts.search) : PROFILES.prod; - const identity = isDebug - ? resolveIdentity([ - () => BUILD_IDENTITY, - opts.isNative ? nativeSettingsSource(opts.win) : queryStringSource(opts.search), - () => profile.identity ?? {}, - queryStringSource(opts.search), // 显式 URL 覆盖(最高) - ]) - : resolveIdentity([ - () => BUILD_IDENTITY, - nativeSettingsSource(opts.win), // release:身份只取原生,URL 一律无效 - ]); +function bootstrapHostKind(options: BootstrapOptions): HostKind { + if (options.hostKind) return options.hostKind; + return options.isNative ? 'native-settings' : 'h5'; +} - // 调试账号只在 debug 模式从 profile 取;release 不读调试配置账号。 - const account = isDebug ? profile.account : undefined; - const resultBase = { mode: opts.mode, isDebugger: isDebug, identity, account, profile }; - - // 服务器决策:profile 显式 server 直连捷径 - if (profile.server) { - return { ...resultBase, servers: [profile.server], rawConfig: null }; - } - - // profile 是服务器地址的权威来源:无 server 则必须有 gameserver,缺则配置错误,显式暴露。 - if (!profile.gameserver) { - throw new ConfigFetchError(`profile '${profile.name}' 未配置 server 或 gameserver`); - } - - const bust = opts.cacheBust ? opts.cacheBust() : String(Date.now()); - const url = profile.gameserver + '?' + bust; // 防缓存,等价原 min_timestamp/ifast_random - - let config: RemoteConfig; - try { - config = await opts.fetcher.fetch(url); - } catch { - throw new ConfigFetchError(`远程配置抓取失败: ${url}`); - } - - const resolved = resolveServers(config, identity); - // debug 可把连接地址显式覆盖为本地测试服(仍走完整远程配置链路);release 一律用远程解析结果。 - const servers = isDebug && profile.serverOverride ? [profile.serverOverride] : resolved; - if (!servers.length) { - throw new ConfigFetchError(`远程配置未给出可用服务器地址: ${url}`); - } - return { ...resultBase, servers, rawConfig: config }; +/** 兼容启动编排外观;所有运行契约均由唯一的 RuntimeConfig 边界产出。 */ +export async function resolveBootstrap(options: BootstrapOptions): Promise { + const profile = options.mode === 'release' ? PROFILES.prod! : resolveActiveProfile(options.search); + const runtime = await resolveRuntimeConfig({ + mode: options.mode, + hostKind: bootstrapHostKind(options), + win: options.win, + search: options.search, + fetcher: options.fetcher, + cacheBust: options.cacheBust, + }); + return Object.freeze({ + ...runtime, + servers: [...runtime.servers], + account: profile.account, + profile, + }); } diff --git a/cocoscreator_projects/YouleNexus/assets/framework/config/identity.ts b/cocoscreator_projects/YouleNexus/assets/framework/config/identity.ts index f416033..06132d7 100644 --- a/cocoscreator_projects/YouleNexus/assets/framework/config/identity.ts +++ b/cocoscreator_projects/YouleNexus/assets/framework/config/identity.ts @@ -14,9 +14,29 @@ function hasValue(v: unknown): boolean { return v !== undefined && v !== null && v !== ''; } +function validateIdentity(identity: Partial): ChannelIdentity { + const missing = (['agentid', 'channelid', 'gameid', 'marketid', 'version'] as const) + .filter((key) => !hasValue(identity[key])); + if (missing.length > 0) { + throw new Error(`Invalid identity: missing ${missing.join(', ')}`); + } + for (const key of ['agentid', 'channelid', 'marketid'] as const) { + if (typeof identity[key] !== 'string' && typeof identity[key] !== 'number') { + throw new Error(`Invalid identity: ${key} type`); + } + } + if (typeof identity.gameid !== 'string') { + throw new Error('Invalid identity: gameid type'); + } + if (typeof identity.version !== 'number' || !Number.isFinite(identity.version)) { + throw new Error('Invalid identity: version type'); + } + return identity as ChannelIdentity; +} + /** * 按数组顺序合并身份,后者覆盖前者(仅覆盖有效值字段)。 - * 约定首个 source 为完整 defaults,故返回视为完整 ChannelIdentity。 + * 合并后逐字段校验;来源缺失必须在边界显式暴露,禁止不完整类型断言。 */ export function resolveIdentity(sources: IdentitySource[]): ChannelIdentity { const out: Partial = {}; @@ -27,5 +47,5 @@ export function resolveIdentity(sources: IdentitySource[]): ChannelIdentity { if (hasValue(v)) (out as Record)[k] = v; } } - return out as ChannelIdentity; + return Object.freeze(validateIdentity(out)); } diff --git a/cocoscreator_projects/YouleNexus/assets/framework/config/profiles.ts b/cocoscreator_projects/YouleNexus/assets/framework/config/profiles.ts index b3ee5a4..b11b3e7 100644 --- a/cocoscreator_projects/YouleNexus/assets/framework/config/profiles.ts +++ b/cocoscreator_projects/YouleNexus/assets/framework/config/profiles.ts @@ -1,8 +1,6 @@ -import type { ChannelIdentity } from './identity.ts'; - -/** 调试用登录玩家档案(player_login 中除渠道身份外的字段)。仅 debug 模式生效。 */ +/** 调试用登录玩家档案;值只能由测试或本地启动配置显式注入。 */ export interface DebugAccount { - openid: string; // 服务器据此标识玩家(必填) + openid: string; nickname?: string; avatar?: string; sex?: number; @@ -11,39 +9,58 @@ export interface DebugAccount { unionid?: string | number; } +export type ProfileSource = 'remote' | 'direct'; + export interface DebugProfile { - name: string; - server?: string; // 显式 ws 调试服地址(给定则跳过远程抓取,纯直连) - gameserver?: string; // 远程配置 txt URL(不含防缓存串) - serverOverride?: string; // debug:抓远程配置走全链路后,把连接地址覆盖为此(联调本地游戏服用) - identity?: Partial; // 调试用渠道身份覆盖 - account?: DebugAccount; // 调试用登录账号(仅 debug 模式生效) - isDebugger?: boolean; // 收发包日志开关(替代旧 Game_Config.Debugger.isDebugger) + readonly name: string; + readonly source: ProfileSource; + readonly gameserver?: string; + readonly servers?: readonly string[]; + readonly account?: DebugAccount; } /** 正式 gameserver:原 00_SubGame_Config.js:11。 */ export const DEFAULT_GAMESERVER = 'https://tsgames.daoqi88.cn/config/update_jsonv2.txt'; -/** 测试 gameserver:原 00_SubGame_Config.js:9 注释。 */ -const STAGING_GAMESERVER = 'https://tsgames.daoqi88.cn/config/update_jsonv2.txt'; -export const PROFILES: Record = { - prod: { name: 'prod', gameserver: DEFAULT_GAMESERVER, isDebugger: false }, - staging: { - name: 'staging', - gameserver: STAGING_GAMESERVER, - serverOverride: 'ws://127.0.0.1:3088', // 走 staging 配置全链路,但游戏服连本地测试服 - isDebugger: true, - // 调试登录账号:联调前替换为真实测试账号(openid 必填)。仅 debug 模式生效。 - account: { openid: 'REPLACE_ME_test_openid', nickname: '测试号', avatar: 'https://gips3.baidu.com/it/u=4028841722,3177937756&fm=3074&app=3074&f=PNG?w=2048&h=2048', sex: 0, province: 'testprovince', city: 'testcity', unionid: 'test_unionid' }, - }, - local: { name: 'local', server: 'ws://127.0.0.1:3088', isDebugger: true }, -}; +export const PROFILES: Readonly> = Object.freeze({ + prod: Object.freeze({ name: 'prod', source: 'remote', gameserver: DEFAULT_GAMESERVER }), + staging: Object.freeze({ name: 'staging', source: 'remote', gameserver: DEFAULT_GAMESERVER }), + local: Object.freeze({ + name: 'local', + source: 'direct', + servers: Object.freeze(['ws://127.0.0.1:3088']), + }), +}); -/** 发布只需保持 '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]; + const requested = new URLSearchParams(search).get('profile'); + const name = requested || ACTIVE_PROFILE; + const profile = PROFILES[name]; + if (!profile) throw new Error(`未知 profile: ${name}`); + return profile; +} + +/** 忠实旧 min_replaceAll 链:- → /,# → :,再包成 http://...txt。 */ +export function decodeGameConfig(value: string): string { + if (!value) throw new Error('gameconfig 必须是非空字符串'); + const decoded = value.split('-').join('/').split('#').join(':'); + return `http://${decoded}.txt`; +} + +export interface ResolveGameServerOptions { + readonly configured: string; + readonly injectedGameConfig: unknown; +} + +/** 注入的 gameconfig 非空时是唯一覆盖源;否则只使用 profile 配置源。 */ +export function resolveGameServer(options: ResolveGameServerOptions): string { + const injected = options.injectedGameConfig; + if (injected === '' || injected === undefined || injected === null) { + if (!options.configured) throw new Error('profile 未配置 gameserver'); + return options.configured; + } + if (typeof injected !== 'string') throw new Error('gameconfig 必须是字符串'); + return decodeGameConfig(injected); } diff --git a/cocoscreator_projects/YouleNexus/assets/framework/config/remote-config-fetcher.ts b/cocoscreator_projects/YouleNexus/assets/framework/config/remote-config-fetcher.ts index 6dea9a3..8c1b883 100644 --- a/cocoscreator_projects/YouleNexus/assets/framework/config/remote-config-fetcher.ts +++ b/cocoscreator_projects/YouleNexus/assets/framework/config/remote-config-fetcher.ts @@ -1,13 +1,18 @@ -import type { ConfigFetcher, RemoteConfig } from './remote-config.ts'; +import type { ConfigFetcher } from './remote-config.ts'; import { ConfigParseError } from './remote-config.ts'; -/** 生产用 ConfigFetcher:全局 fetch 抓取远程配置 txt 并 JSON.parse。 */ +type FetchImplementation = (input: RequestInfo | URL, init?: RequestInit) => Promise; + +/** 生产 fetcher:逐字复刻 get_config 的 POST、空 body、无条件 ? cache bust。 */ export class HttpConfigFetcher implements ConfigFetcher { - async fetch(url: string): Promise { - const resp = await fetch(url, { method: 'GET' }); - const text = await resp.text(); + constructor(private readonly fetchImpl: FetchImplementation = globalThis.fetch.bind(globalThis)) {} + + async fetch(gameserver: string, cacheBust: () => string = () => String(Date.now())): Promise { + const url = `${gameserver}?${cacheBust()}`; + const response = await this.fetchImpl(url, { method: 'POST', body: '' }); + const text = await response.text(); try { - return JSON.parse(text) as RemoteConfig; + return JSON.parse(text) as unknown; } catch { throw new ConfigParseError(`远程配置 JSON 解析失败: ${url}`); } diff --git a/cocoscreator_projects/YouleNexus/assets/framework/config/remote-config.ts b/cocoscreator_projects/YouleNexus/assets/framework/config/remote-config.ts index 8d2ed49..55dcc75 100644 --- a/cocoscreator_projects/YouleNexus/assets/framework/config/remote-config.ts +++ b/cocoscreator_projects/YouleNexus/assets/framework/config/remote-config.ts @@ -1,71 +1,41 @@ -import type { ChannelIdentity } from './identity.ts'; +/** 原配置服务的唯一服务器字段,逐字对齐 ServerUrl_Succ 的 data.urlserver。 */ +export interface RemoteConfig { + readonly data: { + readonly urlserver: string | readonly string[]; + readonly [key: string]: unknown; + }; +} -/** 远程配置内层数据(键名逐字一致原 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; } +/** 远程配置抓取抽象;cache bust 在 fetcher 内形成且只形成一次。 */ +export interface ConfigFetcher { + fetch(gameserver: string, cacheBust?: () => 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; +function normalizeWebSocketUrl(value: string): string { + return value.startsWith('ws://') || value.startsWith('wss://') ? value : `ws://${value}`; } /** - * 连接相关参数键(键名逐字一致原 getConfig_Succ)。优先级即数组顺序。 - * 仅取 *_server_tcp:YouleNexus 为 WebSocket-only(原 netType==0 分支); - * 原工程的 *_server_http 属 HTTP 传输模式(netType==1),本期不在范围,故有意省略。 + * 只接受 data.urlserver;不读取旧的 *_server_tcp,也不去重或改变配置顺序。 */ -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); - } +export function parseUrlServers(input: unknown): readonly string[] { + if (typeof input !== 'object' || input === null || !('data' in input)) { + throw new ConfigParseError('远程配置缺少 data.urlserver'); } - return out; + const data = (input as { data?: unknown }).data; + if (typeof data !== 'object' || data === null || !('urlserver' in data)) { + throw new ConfigParseError('远程配置缺少 data.urlserver'); + } + + const raw = (data as { urlserver?: unknown }).urlserver; + const values = typeof raw === 'string' ? [raw] : raw; + if (!Array.isArray(values) || values.length === 0 + || values.some((value) => typeof value !== 'string' || value.length === 0)) { + throw new ConfigParseError('远程配置 data.urlserver 必须是非空字符串或非空字符串数组'); + } + + return Object.freeze(values.map(normalizeWebSocketUrl)); } diff --git a/cocoscreator_projects/YouleNexus/assets/framework/config/runtime-config.ts b/cocoscreator_projects/YouleNexus/assets/framework/config/runtime-config.ts new file mode 100644 index 0000000..0db44c4 --- /dev/null +++ b/cocoscreator_projects/YouleNexus/assets/framework/config/runtime-config.ts @@ -0,0 +1,125 @@ +import type { ChannelIdentity, IdentitySource } from './identity.ts'; +import { resolveIdentity } from './identity.ts'; +import { + PROFILES, + resolveActiveProfile, + resolveGameServer, + type DebugProfile, +} from './profiles.ts'; +import { + ConfigFetchError, + parseUrlServers, + type ConfigFetcher, + type RemoteConfig, +} from './remote-config.ts'; +import type { RuntimeMode } from './runtime-mode.ts'; +import { BUILD_IDENTITY } from './sources/defaults.ts'; +import { + getothername, + nativeIdentitySource, + type NativeHost, + type NativeHostKind, +} from './sources/native-settings.ts'; +import { queryStringSource } from './sources/query-string.ts'; + +export type HostKind = 'h5' | NativeHostKind; + +export interface RuntimeConfig { + readonly mode: RuntimeMode; + readonly isDebugger: boolean; + readonly identity: Readonly; + readonly servers: readonly string[]; + readonly source: 'remote' | 'direct'; + readonly gameserver: string | null; + readonly rawConfig: RemoteConfig | null; +} + +export interface ResolveRuntimeConfigOptions { + readonly mode: RuntimeMode; + readonly hostKind: HostKind; + readonly win: NativeHost; + readonly search: string; + readonly fetcher: ConfigFetcher; + readonly cacheBust?: () => string; +} + +function buildIdentitySource(): IdentitySource { + return () => ({ gameid: BUILD_IDENTITY.gameid, version: BUILD_IDENTITY.version }); +} + +function resolveHostIdentity(options: ResolveRuntimeConfigOptions): Readonly { + if (options.hostKind === 'h5') { + return resolveIdentity([() => BUILD_IDENTITY, queryStringSource(options.search)]); + } + return resolveIdentity([ + buildIdentitySource(), + nativeIdentitySource(options.win, options.hostKind), + ]); +} + +function resolveInjectedGameConfig(options: ResolveRuntimeConfigOptions): unknown { + if (options.hostKind === 'h5') { + return new URLSearchParams(options.search).get('gameconfig') ?? ''; + } + return getothername(options.win, options.hostKind, 'gameconfig'); +} + +function selectedProfile(mode: RuntimeMode, search: string): DebugProfile { + return mode === 'release' ? PROFILES.prod! : resolveActiveProfile(search); +} + +function freezeResult(result: RuntimeConfig): RuntimeConfig { + return Object.freeze(result); +} + +export async function resolveRuntimeConfig( + options: ResolveRuntimeConfigOptions, +): Promise { + const profile = selectedProfile(options.mode, options.search); + const identity = resolveHostIdentity(options); + + if (profile.source === 'direct') { + if (options.mode !== 'debug' || profile.name !== 'local') { + throw new ConfigFetchError(`profile '${profile.name}' 不是允许的 local debug direct 模式`); + } + if (!profile.servers || profile.servers.length === 0) { + throw new ConfigFetchError(`profile '${profile.name}' 未配置完整 servers`); + } + return freezeResult({ + mode: options.mode, + isDebugger: true, + identity, + servers: Object.freeze([...profile.servers]), + source: 'direct', + gameserver: null, + rawConfig: null, + }); + } + + if (!profile.gameserver) { + throw new ConfigFetchError(`profile '${profile.name}' 未配置 gameserver`); + } + const gameserver = resolveGameServer({ + configured: profile.gameserver, + injectedGameConfig: resolveInjectedGameConfig(options), + }); + + let raw: unknown; + try { + raw = await options.fetcher.fetch(gameserver, options.cacheBust); + } catch (error) { + if (error instanceof ConfigFetchError) throw error; + throw new ConfigFetchError(`远程配置抓取失败: ${gameserver}`); + } + const servers = parseUrlServers(raw); + + return freezeResult({ + mode: options.mode, + isDebugger: options.mode === 'debug', + identity, + servers, + source: 'remote', + gameserver, + rawConfig: raw as RemoteConfig, + }); +} diff --git a/cocoscreator_projects/YouleNexus/assets/framework/config/sources/native-settings.ts b/cocoscreator_projects/YouleNexus/assets/framework/config/sources/native-settings.ts index cb37491..f134411 100644 --- a/cocoscreator_projects/YouleNexus/assets/framework/config/sources/native-settings.ts +++ b/cocoscreator_projects/YouleNexus/assets/framework/config/sources/native-settings.ts @@ -1,40 +1,66 @@ -import type { ChannelIdentity, IdentitySource } from '../identity.ts'; +import type { IdentitySource } from '../identity.ts'; -/** 原生注入宿主(≈ window)。settings 由原生注入;全局 app_* 为 uAgent_3 路径回退。 */ +export type NativeHostKind = 'native-settings' | 'uAgent_3'; + +/** window.settings 与 app_* 均由原生注入,名称不得改写。 */ export interface NativeHost { settings?: { getothername?(name: string): string | number; getchannelName?(): string | number; getmarketname?(): string | number; }; - [global: string]: unknown; // app_agent / app_market 等 + [global: string]: unknown; } -function tryRead(primary: () => unknown, fallback: () => unknown): unknown { +function hasValue(value: unknown): boolean { + return value !== undefined && value !== null && value !== ''; +} + +/** 忠实 Func.getothername:非 uAgent_3 的调用异常在源边界变成空串。 */ +export function getothername(win: NativeHost, hostKind: NativeHostKind, name: string): unknown { + if (hostKind === 'uAgent_3') return win[`app_${name}`]; try { - const v = primary(); - if (v !== undefined && v !== null && v !== '') return v; - } catch { /* 原生桥不存在,走回退 */ } - try { return fallback(); } catch { return undefined; } + return win.settings!.getothername!(name); + } catch { + return ''; + } } -function has(v: unknown): boolean { return v !== undefined && v !== null && v !== ''; } +/** 原生 getchannelName 的既有缺省语义只存在于此适配器。 */ +export function getchannelName(win: NativeHost, hostKind: NativeHostKind): unknown { + try { + return win.settings!.getchannelName!() || ''; + } catch { + return hostKind === 'uAgent_3' ? win.app_channel : ''; + } +} + +/** 原生 getmarketname 的既有缺省语义只存在于此适配器。 */ +export function getmarketname(win: NativeHost, hostKind: NativeHostKind): unknown { + try { + return win.settings!.getmarketname!(); + } catch { + return hostKind === 'uAgent_3' ? win.app_market : '4'; + } +} /** - * 原生模式:同步读取渠道身份。逐字对齐原 05_Func.js: - * agent → window.settings.getothername('agent') | window.app_agent - * channel → window.settings.getchannelName() - * market → window.settings.getmarketname() | window.app_market + * 只产出宿主负责的 agent/channel/market;game/version 由构建身份源负责。 + * uAgent_3 全部走 app_,不触碰 settings。 */ -export function nativeSettingsSource(win: NativeHost): IdentitySource { +export function nativeIdentitySource(win: NativeHost, hostKind: NativeHostKind): 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; + const agentid = getothername(win, hostKind, 'agent'); + const channelid = hostKind === 'uAgent_3' + ? getothername(win, hostKind, 'channel') + : getchannelName(win, hostKind); + const marketid = hostKind === 'uAgent_3' + ? getothername(win, hostKind, 'market') + : getmarketname(win, hostKind); + const out: Record = {}; + if (hasValue(agentid)) out.agentid = agentid; + if (hasValue(channelid)) out.channelid = channelid; + if (hasValue(marketid)) out.marketid = marketid; return out; }; } diff --git a/cocoscreator_projects/framework-tests/config/bootstrap.test.ts b/cocoscreator_projects/framework-tests/config/bootstrap.test.ts index 6415e2c..2d542a5 100644 --- a/cocoscreator_projects/framework-tests/config/bootstrap.test.ts +++ b/cocoscreator_projects/framework-tests/config/bootstrap.test.ts @@ -1,116 +1,16 @@ 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'; -import { PROFILES } from '../../YouleNexus/assets/framework/config/profiles.ts'; +import type { ConfigFetcher } from '../../YouleNexus/assets/framework/config/remote-config.ts'; -function fetcherReturning(config: RemoteConfig): ConfigFetcher & { calls: string[] } { - const calls: string[] = []; - return { calls, fetch: async (url: string) => { calls.push(url); return config; } }; -} -const throwingFetcher: ConfigFetcher = { - fetch: async () => { throw new Error('net down'); }, -}; - -const REMOTE: RemoteConfig = { data: { player_server_tcp: '5.5.5.5:5000' } }; - -test('debug + profile=local:用 profile.server 直连,不抓远程', async () => { - const fetcher = fetcherReturning(REMOTE); - const r = await resolveBootstrap({ mode: 'debug', win: {}, search: '?profile=local', fetcher, isNative: false }); - assert.deepEqual(r.servers, ['ws://127.0.0.1:3088']); - assert.equal(r.rawConfig, null); - assert.equal(fetcher.calls.length, 0); -}); - -test('debug + prod + H5:抓远程配置产出 servers,identity 来自 defaults', async () => { - const fetcher = fetcherReturning(REMOTE); - const r = await resolveBootstrap({ mode: 'debug', win: {}, search: '', fetcher, isNative: false }); - assert.deepEqual(r.servers, ['ws://5.5.5.5:5000']); - assert.equal(r.identity.agentid, BUILD_IDENTITY.agentid); - assert.equal(r.identity.gameid, BUILD_IDENTITY.gameid); - assert.equal(fetcher.calls.length, 1); -}); - -test('debug + H5 显式 URL query 覆盖身份(最高优先级)', async () => { - const fetcher = fetcherReturning(REMOTE); - const r = await resolveBootstrap({ mode: 'debug', win: {}, search: '?agentid=999&channelid=C9', fetcher, isNative: false }); - assert.equal(r.identity.agentid, '999'); - assert.equal(r.identity.channelid, 'C9'); -}); - -test('debug + 原生模式:identity 来自 window.settings', async () => { - const fetcher = fetcherReturning(REMOTE); - const win = { settings: { getothername: () => 'NA', getchannelName: () => 'NC', getmarketname: () => 3 } }; - const r = await resolveBootstrap({ mode: 'debug', win, search: '', fetcher, isNative: true }); - assert.equal(r.identity.agentid, 'NA'); - assert.equal(r.identity.channelid, 'NC'); - assert.equal(r.identity.marketid, 3); -}); - -test('远程抓取失败 → 抛 ConfigFetchError(不兜底)', async () => { - await assert.rejects( - resolveBootstrap({ mode: 'debug', win: {}, search: '', fetcher: throwingFetcher, isNative: false }), - /远程配置抓取失败/, - ); -}); - -test('远程配置未给出可用服务器地址 → 抛错(不兜底)', async () => { - const emptyFetcher: ConfigFetcher = { fetch: async () => ({ data: {} }) }; - await assert.rejects( - resolveBootstrap({ mode: 'debug', win: {}, search: '', fetcher: emptyFetcher, isNative: false }), - /未给出可用服务器/, - ); -}); - -test('debug 结果含 mode/isDebugger', async () => { - const fetcher = fetcherReturning(REMOTE); - const r = await resolveBootstrap({ mode: 'debug', win: {}, search: '', fetcher, isNative: false }); - assert.equal(r.mode, 'debug'); - assert.equal(r.isDebugger, true); -}); - -test('release:忽略所有 URL 覆盖,强制 prod,identity 不被 URL 改', async () => { - const fetcher = fetcherReturning(REMOTE); - const r = await resolveBootstrap({ mode: 'release', win: {}, search: '?profile=local&agentid=999', fetcher, isNative: false }); - assert.equal(r.profile.name, 'prod'); - assert.deepEqual(r.servers, ['ws://5.5.5.5:5000']); // 走远程,不是 local 直连 - assert.equal(r.identity.agentid, BUILD_IDENTITY.agentid); // 999 无效 - assert.equal(r.isDebugger, false); - assert.equal(r.mode, 'release'); - assert.equal(fetcher.calls.length, 1); -}); - -test('release:原生身份仍生效(URL 的 999 无效)', async () => { - const fetcher = fetcherReturning(REMOTE); - const win = { settings: { getothername: () => 'NA', getchannelName: () => 'NC', getmarketname: () => 3 } }; - const r = await resolveBootstrap({ mode: 'release', win, search: '?agentid=999', fetcher, isNative: true }); - assert.equal(r.identity.agentid, 'NA'); - assert.equal(r.identity.channelid, 'NC'); -}); - -test('debug + serverOverride:走远程全链路但连接地址覆盖为本地', async () => { - const fetcher = fetcherReturning(REMOTE); // REMOTE.player_server_tcp = 5.5.5.5:5000 - const r = await resolveBootstrap({ mode: 'debug', win: {}, search: '?profile=staging', fetcher, isNative: false }); - assert.deepEqual(r.servers, [PROFILES.staging.serverOverride]); // 覆盖为 profile.serverOverride - assert.ok(r.rawConfig); // 远程配置仍抓取(全链路) - assert.equal(fetcher.calls.length, 1); -}); - -test('release:serverOverride 不生效(强制 prod,用远程解析地址)', async () => { - const fetcher = fetcherReturning(REMOTE); - const r = await resolveBootstrap({ mode: 'release', win: {}, search: '?profile=staging', fetcher, isNative: true }); - assert.deepEqual(r.servers, ['ws://5.5.5.5:5000']); // prod 无 override,用远程解析 -}); - -test('debug:account 来自当前 profile', async () => { - const fetcher = fetcherReturning(REMOTE); - const r = await resolveBootstrap({ mode: 'debug', win: {}, search: '?profile=staging', fetcher, isNative: false }); - assert.deepEqual(r.account, PROFILES.staging.account); -}); - -test('release:account 恒为 undefined(不从调试配置泄漏账号)', async () => { - const fetcher = fetcherReturning(REMOTE); - const r = await resolveBootstrap({ mode: 'release', win: {}, search: '?profile=staging', fetcher, isNative: true }); - assert.equal(r.account, undefined); +test('resolveBootstrap:显式 hostKind 透传严格 RuntimeConfig,并保留 profile 元数据', async () => { + const fetcher: ConfigFetcher = { + async fetch() { return { data: { urlserver: '5.5.5.5:5000' } }; }, + }; + const result = await resolveBootstrap({ + mode: 'debug', hostKind: 'h5', win: {}, search: '?profile=local', fetcher, + }); + assert.equal(result.source, 'direct'); + assert.equal(result.profile.name, 'local'); + assert.deepEqual(result.servers, ['ws://127.0.0.1:3088']); }); diff --git a/cocoscreator_projects/framework-tests/config/identity.test.ts b/cocoscreator_projects/framework-tests/config/identity.test.ts index 63f9378..d3523e8 100644 --- a/cocoscreator_projects/framework-tests/config/identity.test.ts +++ b/cocoscreator_projects/framework-tests/config/identity.test.ts @@ -36,3 +36,22 @@ test('resolveIdentity:marketid=0 是有效值,应覆盖', () => { const id = resolveIdentity([() => BUILD_IDENTITY, () => ({ marketid: 0 })]); assert.equal(id.marketid, 0); }); + +test('resolveIdentity:不完整身份显式拒绝,完整结果被冻结', () => { + assert.throws( + () => resolveIdentity([() => ({ gameid: 'G', version: 1 })]), + /identity.*agentid.*channelid.*marketid/i, + ); + assert.equal(Object.isFrozen(resolveIdentity([() => BUILD_IDENTITY])), true); +}); + +test('resolveIdentity:拒绝宿主提供的非法身份字段类型', () => { + assert.throws( + () => resolveIdentity([() => ({ ...BUILD_IDENTITY, agentid: true as any })]), + /identity.*agentid.*type/i, + ); + assert.throws( + () => resolveIdentity([() => ({ ...BUILD_IDENTITY, version: Number.NaN })]), + /identity.*version.*type/i, + ); +}); diff --git a/cocoscreator_projects/framework-tests/config/profiles.test.ts b/cocoscreator_projects/framework-tests/config/profiles.test.ts index 884f53c..cb1e8cd 100644 --- a/cocoscreator_projects/framework-tests/config/profiles.test.ts +++ b/cocoscreator_projects/framework-tests/config/profiles.test.ts @@ -1,27 +1,40 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { resolveActiveProfile, PROFILES, ACTIVE_PROFILE } from '../../YouleNexus/assets/framework/config/profiles.ts'; +import { + ACTIVE_PROFILE, + PROFILES, + decodeGameConfig, + resolveActiveProfile, + resolveGameServer, +} from '../../YouleNexus/assets/framework/config/profiles.ts'; -test('默认返回 ACTIVE_PROFILE', () => { - const p = resolveActiveProfile(''); - assert.equal(p.name, ACTIVE_PROFILE); +test('decodeGameConfig:按旧协议把 -/# 解码为远程 txt URL', () => { + assert.equal(decodeGameConfig('host-path#8080-config'), 'http://host/path:8080/config.txt'); }); -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('resolveGameServer:空 gameconfig 使用配置源,非空值使用解码后的注入源', () => { + assert.equal( + resolveGameServer({ configured: 'https://default/config.txt', injectedGameConfig: '' }), + 'https://default/config.txt', + ); + assert.equal( + resolveGameServer({ configured: 'https://default/config.txt', injectedGameConfig: 'host-path#80-a' }), + 'http://host/path:80/a.txt', + ); }); -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,local 是显式 direct 源', () => { + assert.equal(resolveActiveProfile('').name, ACTIVE_PROFILE); + const local = resolveActiveProfile('?profile=local'); + assert.equal(local.source, 'direct'); + assert.deepEqual(local.servers, ['ws://127.0.0.1:3088']); }); -test('未知 profile 回退到 ACTIVE_PROFILE', () => { - const p = resolveActiveProfile('?profile=nope'); - assert.equal(p.name, ACTIVE_PROFILE); +test('未知 profile 显式报错,不回退到 ACTIVE_PROFILE', () => { + assert.throws(() => resolveActiveProfile('?profile=nope'), /未知 profile/); +}); + +test('正常 build 可达 profile 不含占位调试账号', () => { + assert.equal(JSON.stringify(PROFILES).includes('REPLACE_ME'), false); + assert.equal(PROFILES.staging.account, undefined); }); diff --git a/cocoscreator_projects/framework-tests/config/remote-config-fetcher.test.ts b/cocoscreator_projects/framework-tests/config/remote-config-fetcher.test.ts new file mode 100644 index 0000000..c773b38 --- /dev/null +++ b/cocoscreator_projects/framework-tests/config/remote-config-fetcher.test.ts @@ -0,0 +1,47 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { HttpConfigFetcher } from '../../YouleNexus/assets/framework/config/remote-config-fetcher.ts'; +import { resolveRuntimeConfig } from '../../YouleNexus/assets/framework/config/runtime-config.ts'; + +test('HttpConfigFetcher:POST 空 body,并无条件以 ? 拼接 cache bust', async () => { + const calls: Array<{ input: string; init?: RequestInit }> = []; + const fetcher = new HttpConfigFetcher(async (input, init) => { + calls.push({ input: String(input), init }); + return new Response('{"data":{"urlserver":"10.0.0.1:3088"}}'); + }); + + await fetcher.fetch('https://config.example/file.txt?existing=1', () => '123'); + + assert.equal(calls.length, 1); + assert.equal(calls[0]?.input, 'https://config.example/file.txt?existing=1?123'); + assert.deepEqual(calls[0]?.init, { method: 'POST', body: '' }); +}); + +test('resolveRuntimeConfig:把 cacheBust 注入唯一的 HTTP 请求构造点', async () => { + const calls: Array<{ input: string; init?: RequestInit }> = []; + const fetcher = new HttpConfigFetcher(async (input, init) => { + calls.push({ input: String(input), init }); + return new Response('{"data":{"urlserver":"10.0.0.1:3088"}}'); + }); + + await resolveRuntimeConfig({ + mode: 'release', + hostKind: 'h5', + win: {}, + search: '?agentid=A&channelid=C', + fetcher, + cacheBust: () => '123', + }); + + assert.equal(calls.length, 1); + assert.equal(calls[0]?.input, 'https://tsgames.daoqi88.cn/config/update_jsonv2.txt?123'); + assert.deepEqual(calls[0]?.init, { method: 'POST', body: '' }); +}); + +test('HttpConfigFetcher:非法 JSON 显式抛 ConfigParseError', async () => { + const fetcher = new HttpConfigFetcher(async () => new Response('not-json')); + await assert.rejects( + fetcher.fetch('https://config.example/file.txt', () => '123'), + /远程配置 JSON 解析失败/, + ); +}); diff --git a/cocoscreator_projects/framework-tests/config/remote-config.test.ts b/cocoscreator_projects/framework-tests/config/remote-config.test.ts index 35e70e8..3eb16f1 100644 --- a/cocoscreator_projects/framework-tests/config/remote-config.test.ts +++ b/cocoscreator_projects/framework-tests/config/remote-config.test.ts @@ -1,57 +1,41 @@ +import { readFileSync } from 'node:fs'; 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'; +import { parseUrlServers } from '../../YouleNexus/assets/framework/config/remote-config.ts'; -const ID: ChannelIdentity = { agentid: 'A', channelid: 'C', gameid: 'G', marketid: 2, version: 1 }; +const single = JSON.parse(readFileSync( + new URL('../fixtures/contracts/remote-config-single.json', import.meta.url), + 'utf8', +)) as unknown; +const array = JSON.parse(readFileSync( + new URL('../fixtures/contracts/remote-config-array.json', import.meta.url), + 'utf8', +)) as unknown; -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('parseUrlServers:读取 data.urlserver 字符串并补 ws://', () => { + assert.deepEqual(parseUrlServers(single), ['ws://10.0.0.1:3088']); }); -test('getParam:channel 未匹配时回退到 agent 级值', () => { - const id2 = { ...ID, channelid: 'OTHER' }; - assert.equal(getParam(CONFIG, 'player_server_tcp', id2), '2.2.2.2:2000'); +test('parseUrlServers:读取 data.urlserver 数组并保留已有 wss://', () => { + assert.deepEqual(parseUrlServers(array), ['ws://10.0.0.1:3088', 'wss://backup.example/ws']); }); -test('getParam:agent 未匹配时回退到顶层默认', () => { - const id3 = { ...ID, agentid: 'NONE' }; - assert.equal(getParam(CONFIG, 'player_server_tcp', id3), '1.1.1.1:1000'); +test('parseUrlServers:拒绝旧的 *_server_tcp 字段', () => { + assert.throws( + () => parseUrlServers({ data: { player_server_tcp: 'wrong:1' } }), + /data\.urlserver/, + ); }); -test('getParam:键不存在 → null', () => { - assert.equal(getParam(CONFIG, 'not_a_key', ID), null); +test('parseUrlServers:保留原顺序和重复项,并冻结结果', () => { + const servers = parseUrlServers({ data: { urlserver: ['same:1', 'same:1', 'ws://last:2'] } }); + assert.deepEqual(servers, ['ws://same:1', 'ws://same:1', 'ws://last:2']); + assert.equal(Object.isFrozen(servers), true); }); -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), []); +test('parseUrlServers:拒绝空字符串、空数组和非字符串数组项', () => { + assert.throws(() => parseUrlServers({ data: { urlserver: '' } }), /data\.urlserver/); + assert.throws(() => parseUrlServers({ data: { urlserver: [] } }), /data\.urlserver/); + assert.throws(() => parseUrlServers({ data: { urlserver: ['ok:1', ''] } }), /data\.urlserver/); + assert.throws(() => parseUrlServers({ data: { urlserver: ['ok:1', 2] } }), /data\.urlserver/); }); diff --git a/cocoscreator_projects/framework-tests/config/runtime-config.test.ts b/cocoscreator_projects/framework-tests/config/runtime-config.test.ts new file mode 100644 index 0000000..318a793 --- /dev/null +++ b/cocoscreator_projects/framework-tests/config/runtime-config.test.ts @@ -0,0 +1,111 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { BUILD_IDENTITY } from '../../YouleNexus/assets/framework/config/sources/defaults.ts'; +import { resolveRuntimeConfig } from '../../YouleNexus/assets/framework/config/runtime-config.ts'; +import type { ConfigFetcher } from '../../YouleNexus/assets/framework/config/remote-config.ts'; + +function returning(value: unknown): ConfigFetcher & { calls: string[] } { + const calls: string[] = []; + return { + calls, + async fetch(gameserver: string) { calls.push(gameserver); return value; }, + }; +} + +const REMOTE = { data: { urlserver: ['5.5.5.5:5000', '5.5.5.5:5000'] } }; + +test('h5:只读 query 身份,不访问原生宿主,并生成严格 remote 配置', async () => { + const fetcher = returning(REMOTE); + const throwingSettings = new Proxy({}, { get() { throw new Error('native access forbidden'); } }); + const result = await resolveRuntimeConfig({ + mode: 'release', + hostKind: 'h5', + win: { settings: throwingSettings }, + search: '?agentid=HA&channelid=HC', + fetcher, + cacheBust: () => '123', + }); + + assert.deepEqual(result.identity, { ...BUILD_IDENTITY, agentid: 'HA', channelid: 'HC' }); + assert.deepEqual(result.servers, ['ws://5.5.5.5:5000', 'ws://5.5.5.5:5000']); + assert.equal(result.source, 'remote'); + assert.equal(result.gameserver, 'https://tsgames.daoqi88.cn/config/update_jsonv2.txt'); + assert.equal(result.rawConfig, REMOTE); + assert.deepEqual(fetcher.calls, ['https://tsgames.daoqi88.cn/config/update_jsonv2.txt']); + assert.equal(Object.isFrozen(result), true); + assert.equal(Object.isFrozen(result.identity), true); + assert.equal(Object.isFrozen(result.servers), true); +}); + +test('native-settings:使用四个原生方法,gameconfig 是唯一 gameserver 注入名', async () => { + const calls: string[] = []; + const fetcher = returning(REMOTE); + const result = await resolveRuntimeConfig({ + mode: 'release', + hostKind: 'native-settings', + win: { + settings: { + getothername(name: string) { calls.push(`getothername:${name}`); return name === 'agent' ? 'NA' : 'host-path#80-a'; }, + getchannelName() { calls.push('getchannelName'); return 'NC'; }, + getmarketname() { calls.push('getmarketname'); return 7; }, + }, + }, + search: '', + fetcher, + }); + + assert.deepEqual(calls, ['getothername:agent', 'getchannelName', 'getmarketname', 'getothername:gameconfig']); + assert.deepEqual(result.identity, { + agentid: 'NA', channelid: 'NC', gameid: BUILD_IDENTITY.gameid, marketid: 7, version: BUILD_IDENTITY.version, + }); + assert.equal(result.gameserver, 'http://host/path:80/a.txt'); + assert.deepEqual(fetcher.calls, ['http://host/path:80/a.txt']); +}); + +test('uAgent_3:只读 app_,settings 不参与身份或 gameserver', async () => { + const fetcher = returning(REMOTE); + const result = await resolveRuntimeConfig({ + mode: 'release', + hostKind: 'uAgent_3', + win: { + settings: new Proxy({}, { get() { throw new Error('settings forbidden'); } }), + app_agent: 'UA', app_channel: 'UC', app_market: 9, app_gameconfig: 'u-host#80-cfg', + }, + search: '', + fetcher, + }); + + assert.deepEqual(result.identity, { + agentid: 'UA', channelid: 'UC', gameid: BUILD_IDENTITY.gameid, marketid: 9, version: BUILD_IDENTITY.version, + }); + assert.equal(result.gameserver, 'http://u/host:80/cfg.txt'); +}); + +test('native-settings:不完整最终身份显式拒绝,不拿 build identity 兜底', async () => { + await assert.rejects( + resolveRuntimeConfig({ + mode: 'release', hostKind: 'native-settings', win: {}, search: '', fetcher: returning(REMOTE), + }), + /identity.*agentid.*channelid/i, + ); +}); + +test('显式 local debug:direct 字段不靠 null 推断,完整服务器来自 profile', async () => { + const fetcher = returning(REMOTE); + const result = await resolveRuntimeConfig({ + mode: 'debug', hostKind: 'h5', win: {}, search: '?profile=local', fetcher, + }); + assert.equal(result.source, 'direct'); + assert.equal(result.gameserver, null); + assert.equal(result.rawConfig, null); + assert.deepEqual(result.servers, ['ws://127.0.0.1:3088']); + assert.deepEqual(fetcher.calls, []); +}); + +test('远程抓取失败显式报错,不降级 direct', async () => { + const fetcher: ConfigFetcher = { async fetch() { throw new Error('net down'); } }; + await assert.rejects( + resolveRuntimeConfig({ mode: 'release', hostKind: 'h5', win: {}, search: '', fetcher }), + /远程配置抓取失败/, + ); +}); diff --git a/cocoscreator_projects/framework-tests/config/sources/native-settings.test.ts b/cocoscreator_projects/framework-tests/config/sources/native-settings.test.ts index 432c773..a887dce 100644 --- a/cocoscreator_projects/framework-tests/config/sources/native-settings.test.ts +++ b/cocoscreator_projects/framework-tests/config/sources/native-settings.test.ts @@ -1,29 +1,59 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { nativeSettingsSource } from '../../../YouleNexus/assets/framework/config/sources/native-settings.ts'; +import { + getchannelName, + getmarketname, + getothername, + nativeIdentitySource, +} from '../../../YouleNexus/assets/framework/config/sources/native-settings.ts'; -test('优先从 window.settings 读取 agentid/channelid/marketid', () => { +test('非 uAgent_3 getothername 调 settings,异常只在源边界转换为空串', () => { + const names: string[] = []; + const win = { settings: { getothername(name: string) { names.push(name); throw new Error('bridge'); } } }; + assert.equal(getothername(win, 'native-settings', 'gameconfig'), ''); + assert.deepEqual(names, ['gameconfig']); +}); + +test('getchannelName 返回 truthy settings 结果,否则按宿主执行旧缺省语义', () => { + assert.equal(getchannelName({ settings: { getchannelName: () => 'C1' } }, 'native-settings'), 'C1'); + assert.equal(getchannelName({ settings: { getchannelName: () => 0 } }, 'native-settings'), ''); + assert.equal(getchannelName({ settings: { getchannelName: () => { throw new Error('bridge'); } } }, 'native-settings'), ''); + assert.equal(getchannelName({ + settings: { getchannelName: () => { throw new Error('bridge'); } }, + app_channel: 'APP-C', + }, 'uAgent_3'), 'APP-C'); +}); + +test('getmarketname 返回 settings 结果;异常按宿主执行旧缺省语义', () => { + assert.equal(getmarketname({ settings: { getmarketname: () => 0 } }, 'native-settings'), 0); + assert.equal(getmarketname({ settings: { getmarketname: () => { throw new Error('bridge'); } } }, 'native-settings'), '4'); + assert.equal(getmarketname({ + settings: { getmarketname: () => { throw new Error('bridge'); } }, + app_market: 8, + }, 'uAgent_3'), 8); +}); + +test('uAgent_3 getothername 只读 app_,不调用 settings', () => { + let settingsCalls = 0; + const win = { + settings: { getothername: () => { settingsCalls += 1; return 'WRONG'; } }, + app_agent: 'APP-A', + app_gameconfig: 'host-path#80-a', + }; + assert.equal(getothername(win, 'uAgent_3', 'agent'), 'APP-A'); + assert.equal(getothername(win, 'uAgent_3', 'gameconfig'), 'host-path#80-a'); + assert.equal(settingsCalls, 0); +}); + +test('nativeIdentitySource 仅返回所选宿主提供的原生身份字段', () => { const win = { settings: { - getothername: (name: string) => (name === 'agent' ? 'NA' : ''), + 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({})(), {}); + assert.deepEqual(nativeIdentitySource(win, 'native-settings')(), { + agentid: 'NA', channelid: 'NC', marketid: 7, + }); }); diff --git a/cocoscreator_projects/framework-tests/fixtures/contracts/remote-config-array.json b/cocoscreator_projects/framework-tests/fixtures/contracts/remote-config-array.json new file mode 100644 index 0000000..e00b44e --- /dev/null +++ b/cocoscreator_projects/framework-tests/fixtures/contracts/remote-config-array.json @@ -0,0 +1 @@ +{"data":{"urlserver":["10.0.0.1:3088","wss://backup.example/ws"]}} diff --git a/cocoscreator_projects/framework-tests/fixtures/contracts/remote-config-single.json b/cocoscreator_projects/framework-tests/fixtures/contracts/remote-config-single.json new file mode 100644 index 0000000..0d6e3d0 --- /dev/null +++ b/cocoscreator_projects/framework-tests/fixtures/contracts/remote-config-single.json @@ -0,0 +1 @@ +{"data":{"urlserver":"10.0.0.1:3088"}}