diff --git a/cocoscreator_projects/YouleNexus/assets/framework/config/local-startup.ts b/cocoscreator_projects/YouleNexus/assets/framework/config/local-startup.ts index 5dfa233..4e77a4b 100644 --- a/cocoscreator_projects/YouleNexus/assets/framework/config/local-startup.ts +++ b/cocoscreator_projects/YouleNexus/assets/framework/config/local-startup.ts @@ -89,9 +89,10 @@ export function assertLocalStartupContext(context: LocalStartupContext): void { const { config } = context; if (config.mode !== 'debug') fail('mode', 'must be debug'); if (config.isDebugger !== true) fail('isDebugger', 'must be true'); - if (config.source !== 'direct') fail('source', 'must be direct'); - if (config.gameserver !== null) fail('gameserver', 'must be null'); - if (config.rawConfig !== null) fail('rawConfig', 'must be null'); + if (config.source !== 'remote') fail('source', 'must be remote'); + if (config.gameserver !== PROFILES.local!.gameserver) fail('gameserver', 'must equal local profile remote entry'); + if (!config.remoteConfig || typeof config.remoteConfig.getValue !== 'function') fail('remoteConfig', 'must provide memory view'); + if (!config.rawConfig || config.rawConfig !== config.remoteConfig.raw) fail('rawConfig', 'must share remoteConfig.raw'); try { resolveIdentity([() => config.identity]); diff --git a/cocoscreator_projects/YouleNexus/assets/framework/config/profiles.ts b/cocoscreator_projects/YouleNexus/assets/framework/config/profiles.ts index b11b3e7..0be0355 100644 --- a/cocoscreator_projects/YouleNexus/assets/framework/config/profiles.ts +++ b/cocoscreator_projects/YouleNexus/assets/framework/config/profiles.ts @@ -9,12 +9,12 @@ export interface DebugAccount { unionid?: string | number; } -export type ProfileSource = 'remote' | 'direct'; +export type ProfileSource = 'remote'; export interface DebugProfile { readonly name: string; readonly source: ProfileSource; - readonly gameserver?: string; + readonly gameserver: string; readonly servers?: readonly string[]; readonly account?: DebugAccount; } @@ -27,7 +27,8 @@ export const PROFILES: Readonly> = Object.freeze({ staging: Object.freeze({ name: 'staging', source: 'remote', gameserver: DEFAULT_GAMESERVER }), local: Object.freeze({ name: 'local', - source: 'direct', + source: 'remote', + gameserver: DEFAULT_GAMESERVER, servers: Object.freeze(['ws://127.0.0.1:3088']), }), }); diff --git a/cocoscreator_projects/YouleNexus/assets/framework/config/remote-config.ts b/cocoscreator_projects/YouleNexus/assets/framework/config/remote-config.ts index cb70717..0e7e18f 100644 --- a/cocoscreator_projects/YouleNexus/assets/framework/config/remote-config.ts +++ b/cocoscreator_projects/YouleNexus/assets/framework/config/remote-config.ts @@ -1,12 +1,11 @@ -/** 原配置服务的唯一服务器字段,逐字对齐 ServerUrl_Succ 的 data.urlserver。 */ +import type { ChannelIdentity } from './identity.ts'; + +/** One in-memory view over the JSON root received by legacy getConfig_Succ. */ export interface RemoteConfig { - readonly data: { - readonly urlserver: string | readonly string[]; - readonly [key: string]: unknown; - }; + readonly raw: Readonly>; + readonly getValue: (name: string) => unknown; } -/** 远程配置抓取抽象;cache bust 在 fetcher 内形成且只形成一次。 */ export interface ConfigFetcher { fetch(gameserver: string, cacheBust?: () => string): Promise; } @@ -14,33 +13,76 @@ export interface ConfigFetcher { export class ConfigFetchError extends Error {} export class ConfigParseError extends Error {} -function normalizeWebSocketUrl(value: string): string { - if (value.startsWith('ws://') || value.startsWith('wss://')) return value; - const scheme = /^([A-Za-z][A-Za-z0-9+.-]*):\/\//.exec(value); - if (scheme) { - throw new ConfigParseError(`远程配置 data.urlserver 不支持 scheme: ${scheme[1]}`); +const SCOPES = [ + ['agentlist', 'agentid'], ['gamelist', 'gameid'], + ['channellist', 'channelid'], ['marketlist', 'marketid'], +] as const; + +type ConfigObject = Record; +function requireObject(value: unknown, path: string): ConfigObject { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new ConfigParseError(`Invalid remote config ${path}: expected object`); } - return `ws://${value}`; + return value as ConfigObject; } -/** - * 只接受 data.urlserver;不读取旧的 *_server_tcp,也不去重或改变配置顺序。 - */ -export function parseUrlServers(input: unknown): readonly string[] { - if (typeof input !== 'object' || input === null || !('data' in input)) { - throw new ConfigParseError('远程配置缺少 data.urlserver'); - } - const data = (input as { data?: unknown }).data; - if (typeof data !== 'object' || data === null || !('urlserver' in data)) { - throw new ConfigParseError('远程配置缺少 data.urlserver'); - } +function validateHierarchy(scope: ConfigObject, depth: number): void { + if (depth === SCOPES.length) return; + const [listName] = SCOPES[depth]; + if (!Object.prototype.hasOwnProperty.call(scope, listName)) return; + const list = scope[listName]; + if (!Array.isArray(list)) throw new ConfigParseError(`Invalid remote config ${listName}: expected array`); + for (const entry of list) validateHierarchy(requireObject(entry, listName), depth + 1); +} - const raw = (data as { urlserver?: unknown }).urlserver; +/** Preserve first loose identity match, truthy overrides and whole-object replacement. */ +export function createRemoteConfig(input: unknown, identity: Readonly): RemoteConfig { + const raw = requireObject(input, 'root'); + validateHierarchy(raw, 0); + const scopes = [raw]; + let scope = raw; + for (const [listName, key] of SCOPES) { + const list = scope[listName] as ConfigObject[] | undefined; + // Legacy do_get_para skips any falsy identity, including numeric marketid=0. + if (!list || !identity[key]) break; + const match = list.find(entry => entry[key] == identity[key]); + if (!match) break; + scopes.push(match); + scope = match; + } + return Object.freeze({ + raw, + getValue(name: string): unknown { + let value: unknown = null; + for (const current of scopes) { + if (Object.prototype.hasOwnProperty.call(current, name) && current[name]) value = current[name]; + } + return value; + }, + }); +} + +function normalizeWebSocketUrl(value: string): string { + const url = value.startsWith('ws://') || value.startsWith('wss://') ? value : `ws://${value}`; + if (/^[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(value) && !/^wss?:\/\//.test(value)) { + throw new ConfigParseError('Invalid remote server: unsupported scheme'); + } + try { + const parsed = new URL(url); + if (!value || /\s/.test(value) || !parsed.hostname || parsed.username || parsed.password + || (parsed.protocol !== 'ws:' && parsed.protocol !== 'wss:')) throw new Error('invalid'); + } catch { + throw new ConfigParseError('Invalid remote server: malformed WebSocket URL'); + } + return url; +} + +/** Legacy getConfig_Succ selects game_server_tcp when truthy, otherwise player_server_tcp. */ +export function parseUrlServers(config: RemoteConfig): readonly string[] { + const raw = config.getValue('game_server_tcp') || config.getValue('player_server_tcp'); 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 必须是非空字符串或非空字符串数组'); + if (!Array.isArray(values) || values.length === 0 || values.some(value => typeof value !== 'string' || !value)) { + throw new ConfigParseError('Remote game_server_tcp/player_server_tcp must be a nonempty string or string array'); } - 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 index 0db44c4..f2e5c8e 100644 --- a/cocoscreator_projects/YouleNexus/assets/framework/config/runtime-config.ts +++ b/cocoscreator_projects/YouleNexus/assets/framework/config/runtime-config.ts @@ -8,6 +8,8 @@ import { } from './profiles.ts'; import { ConfigFetchError, + ConfigParseError, + createRemoteConfig, parseUrlServers, type ConfigFetcher, type RemoteConfig, @@ -29,9 +31,10 @@ export interface RuntimeConfig { readonly isDebugger: boolean; readonly identity: Readonly; readonly servers: readonly string[]; - readonly source: 'remote' | 'direct'; - readonly gameserver: string | null; - readonly rawConfig: RemoteConfig | null; + readonly source: 'remote'; + readonly gameserver: string; + readonly rawConfig: RemoteConfig['raw']; + readonly remoteConfig: RemoteConfig; } export interface ResolveRuntimeConfigOptions { @@ -78,24 +81,6 @@ export async function resolveRuntimeConfig( 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`); } @@ -108,10 +93,19 @@ export async function resolveRuntimeConfig( try { raw = await options.fetcher.fetch(gameserver, options.cacheBust); } catch (error) { - if (error instanceof ConfigFetchError) throw error; + if (error instanceof ConfigFetchError || error instanceof ConfigParseError) throw error; throw new ConfigFetchError(`远程配置抓取失败: ${gameserver}`); } - const servers = parseUrlServers(raw); + const remoteConfig = createRemoteConfig(raw, identity); + let servers: readonly string[]; + if (profile.name === 'local') { + if (!profile.servers || profile.servers.length === 0) { + throw new ConfigFetchError('Local profile must define servers'); + } + servers = Object.freeze([...profile.servers]); + } else { + servers = parseUrlServers(remoteConfig); + } return freezeResult({ mode: options.mode, @@ -120,6 +114,7 @@ export async function resolveRuntimeConfig( servers, source: 'remote', gameserver, - rawConfig: raw as RemoteConfig, + rawConfig: remoteConfig.raw, + remoteConfig, }); } diff --git a/cocoscreator_projects/YouleNexus/assets/scripts/local-platform-login/local-login-host.ts b/cocoscreator_projects/YouleNexus/assets/scripts/local-platform-login/local-login-host.ts index f7d1cf9..7ed15b0 100644 --- a/cocoscreator_projects/YouleNexus/assets/scripts/local-platform-login/local-login-host.ts +++ b/cocoscreator_projects/YouleNexus/assets/scripts/local-platform-login/local-login-host.ts @@ -114,7 +114,8 @@ export function createLocalLoginHost(options: LocalLoginHostOptions): LocalLogin requireActive(); const context = await resolveLocalStartup(options.configOptions); requireActive(); - options.record({ kind: 'startup-config', data: context.config }); + const { mode, isDebugger, identity, servers, source, gameserver } = context.config; + options.record({ kind: 'startup-config', data: { mode, isDebugger, identity, servers, source, gameserver } }); requireActive(); const entry = createDiagnosticGameEntry(context.config.identity.gameid, options.record); sources = createLocalLoginSources({ context, storage: options.storage, random: options.random, diff --git a/cocoscreator_projects/framework-tests/adapters/local/login-sources.test.ts b/cocoscreator_projects/framework-tests/adapters/local/login-sources.test.ts index 9d116ca..effb8d7 100644 --- a/cocoscreator_projects/framework-tests/adapters/local/login-sources.test.ts +++ b/cocoscreator_projects/framework-tests/adapters/local/login-sources.test.ts @@ -1,3 +1,4 @@ +import { remoteFixture } from '../../fixtures/remote-config.ts'; import { test } from 'node:test'; import assert from 'node:assert/strict'; import { BUILD_IDENTITY } from '../../../YouleNexus/assets/framework/config/sources/defaults.ts'; @@ -7,7 +8,7 @@ import { LOCAL_MACHINE_KEY, localAccountKey, type LocalStoragePort } from '../.. import { createLocalLoginSources, type LocalHostSnapshot } from '../../../YouleNexus/assets/framework/adapters/local/login-sources.ts'; const context: LocalStartupContext = { hostKind: 'h5', profile: 'local', config: { - mode: 'debug', isDebugger: true, source: 'direct', gameserver: null, rawConfig: null, + mode: 'debug', isDebugger: true, source: 'remote', gameserver: PROFILES.local!.gameserver, ...remoteFixture(), servers: PROFILES.local!.servers!, identity: BUILD_IDENTITY, } }; class MapStorage implements LocalStoragePort { diff --git a/cocoscreator_projects/framework-tests/adapters/local/visitor-account.test.ts b/cocoscreator_projects/framework-tests/adapters/local/visitor-account.test.ts index 9a42819..f8a1ccd 100644 --- a/cocoscreator_projects/framework-tests/adapters/local/visitor-account.test.ts +++ b/cocoscreator_projects/framework-tests/adapters/local/visitor-account.test.ts @@ -1,3 +1,4 @@ +import { remoteFixture } from '../../fixtures/remote-config.ts'; import { test } from 'node:test'; import assert from 'node:assert/strict'; import { BUILD_IDENTITY } from '../../../YouleNexus/assets/framework/config/sources/defaults.ts'; @@ -15,7 +16,7 @@ class MapStorage implements LocalStoragePort { function context(overrides: Partial = {}): LocalStartupContext { return { hostKind: 'h5', profile: 'local', config: { - mode: 'debug', isDebugger: true, source: 'direct', gameserver: null, rawConfig: null, + mode: 'debug', isDebugger: true, source: 'remote', gameserver: PROFILES.local!.gameserver, ...remoteFixture(), servers: PROFILES.local!.servers!, identity: { ...BUILD_IDENTITY, ...overrides }, } }; } diff --git a/cocoscreator_projects/framework-tests/config/bootstrap.test.ts b/cocoscreator_projects/framework-tests/config/bootstrap.test.ts index 2d542a5..358a2e5 100644 --- a/cocoscreator_projects/framework-tests/config/bootstrap.test.ts +++ b/cocoscreator_projects/framework-tests/config/bootstrap.test.ts @@ -5,12 +5,12 @@ import type { ConfigFetcher } from '../../YouleNexus/assets/framework/config/rem test('resolveBootstrap:显式 hostKind 透传严格 RuntimeConfig,并保留 profile 元数据', async () => { const fetcher: ConfigFetcher = { - async fetch() { return { data: { urlserver: '5.5.5.5:5000' } }; }, + async fetch() { return { player_server_tcp: '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.source, 'remote'); assert.equal(result.profile.name, 'local'); assert.deepEqual(result.servers, ['ws://127.0.0.1:3088']); }); diff --git a/cocoscreator_projects/framework-tests/config/local-startup.test.ts b/cocoscreator_projects/framework-tests/config/local-startup.test.ts index 3026c73..542089d 100644 --- a/cocoscreator_projects/framework-tests/config/local-startup.test.ts +++ b/cocoscreator_projects/framework-tests/config/local-startup.test.ts @@ -1,3 +1,4 @@ +import { remoteFixture } from '../fixtures/remote-config.ts'; import { test } from 'node:test'; import assert from 'node:assert/strict'; import { @@ -16,15 +17,15 @@ function options(search = '?profile=local'): ResolveRuntimeConfigOptions & { } { const fetcher = { calls: 0, - async fetch(): Promise { + async fetch() { this.calls += 1; - throw new Error('forbidden fetch'); + return { player_server_tcp: 'remote.example:3088' }; }, }; return { mode: 'debug', hostKind: 'h5', win: {}, search, fetcher }; } -test('local debug resolves the authoritative direct config without fetching', async () => { +test('local debug fetches remote config before choosing authoritative local servers', async () => { const input = options(); const context = await resolveLocalStartup(input); @@ -32,7 +33,8 @@ test('local debug resolves the authoritative direct config without fetching', as assert.equal(context.profile, 'local'); assert.equal(context.config.identity.gameid, BUILD_IDENTITY.gameid); assert.deepEqual(context.config.servers, PROFILES.local!.servers); - assert.equal(input.fetcher.calls, 0); + assert.equal(input.fetcher.calls, 1); + assert.equal(context.config.source, 'remote'); assert.equal(Object.isFrozen(context), true); }); @@ -107,9 +109,9 @@ function validContext(): LocalStartupContext { isDebugger: true, identity: BUILD_IDENTITY, servers: PROFILES.local!.servers!, - source: 'direct', - gameserver: null, - rawConfig: null, + source: 'remote', + gameserver: PROFILES.local!.gameserver, + ...remoteFixture(BUILD_IDENTITY), }, }; } @@ -119,7 +121,7 @@ test('context validator rejects forged config fields and malformed identity', () const configCases: Array<[keyof RuntimeConfig, unknown]> = [ ['mode', 'release'], ['isDebugger', false], - ['source', 'remote'], + ['source', 'direct'], ['gameserver', 'https://example.com/config.txt'], ['rawConfig', {}], ['servers', ['ws://127.0.0.1:9999']], diff --git a/cocoscreator_projects/framework-tests/config/profiles.test.ts b/cocoscreator_projects/framework-tests/config/profiles.test.ts index cb1e8cd..fbd5869 100644 --- a/cocoscreator_projects/framework-tests/config/profiles.test.ts +++ b/cocoscreator_projects/framework-tests/config/profiles.test.ts @@ -23,10 +23,11 @@ test('resolveGameServer:空 gameconfig 使用配置源,非空值使用解码 ); }); -test('默认 profile 是 ACTIVE_PROFILE,local 是显式 direct 源', () => { +test('默认 profile 是 ACTIVE_PROFILE,local 保持 remote 源与独立连接目标', () => { assert.equal(resolveActiveProfile('').name, ACTIVE_PROFILE); const local = resolveActiveProfile('?profile=local'); - assert.equal(local.source, 'direct'); + assert.equal(local.source, 'remote'); + assert.ok(local.gameserver); assert.deepEqual(local.servers, ['ws://127.0.0.1:3088']); }); diff --git a/cocoscreator_projects/framework-tests/config/remote-config-fetcher.test.ts b/cocoscreator_projects/framework-tests/config/remote-config-fetcher.test.ts index 6add1d1..1a892e6 100644 --- a/cocoscreator_projects/framework-tests/config/remote-config-fetcher.test.ts +++ b/cocoscreator_projects/framework-tests/config/remote-config-fetcher.test.ts @@ -8,7 +8,7 @@ test('HttpConfigFetcher:POST 空 body,并无条件以 ? 拼接 cache bust', 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"}}'); + return new Response('{"player_server_tcp":"10.0.0.1:3088"}'); }); await fetcher.fetch('https://config.example/file.txt?existing=1', () => '123'); @@ -22,7 +22,7 @@ test('resolveRuntimeConfig:把 cacheBust 注入唯一的 HTTP 请求构造点' 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"}}'); + return new Response('{"player_server_tcp":"10.0.0.1:3088"}'); }); await resolveRuntimeConfig({ @@ -49,7 +49,7 @@ test('HttpConfigFetcher:非法 JSON 显式抛 ConfigParseError', async () => { test('HttpConfigFetcher:非 200 响应即使 body 是合法 JSON 也拒绝', async () => { const fetcher = new HttpConfigFetcher(async () => new Response( - '{"data":{"urlserver":"10.0.0.1:3088"}}', + '{"player_server_tcp":"10.0.0.1:3088"}', { status: 503 }, )); await assert.rejects( diff --git a/cocoscreator_projects/framework-tests/config/remote-config.test.ts b/cocoscreator_projects/framework-tests/config/remote-config.test.ts index dc44c5c..2e998f4 100644 --- a/cocoscreator_projects/framework-tests/config/remote-config.test.ts +++ b/cocoscreator_projects/framework-tests/config/remote-config.test.ts @@ -1,54 +1,68 @@ import { readFileSync } from 'node:fs'; +import { runInNewContext } from 'node:vm'; import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { parseUrlServers } from '../../YouleNexus/assets/framework/config/remote-config.ts'; - -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; - -test('parseUrlServers:读取 data.urlserver 字符串并补 ws://', () => { - assert.deepEqual(parseUrlServers(single), ['ws://10.0.0.1:3088']); +import * as api from '../../YouleNexus/assets/framework/config/remote-config.ts'; +const identity = { agentid: '11', gameid: '22', channelid: '33', marketid: 44, version: 1 }; +const source = readFileSync(new URL('../../../projects/Game_Surface_3/js/00_Surface/12_Logic.js', import.meta.url), 'utf8'); +const start = source.indexOf('function get_paravalue(o_config, paraname){'); +const legacySource = source.slice(start, source.indexOf('function getConfig_Succ', start)); +function view(raw: unknown, selected = identity) { + assert.equal(typeof api.createRemoteConfig, 'function', 'remote memory view must be exported'); + return api.createRemoteConfig(raw, selected); +} +function legacy(raw: unknown, name: string, selected = identity) { + return runInNewContext(`${legacySource}; get_paravalue(wrapper, name)`, { + wrapper: { data: raw }, name, + GameData: { AgentId: selected.agentid, GameId: selected.gameid, ChannelId: selected.channelid, marketID: selected.marketid }, + }); +} +test('memory view agrees with actual legacy function across all five scopes and preserves raw objects', () => { + const scopes: Record[] = [ + { hall_config: { root: true }, rootOnly: 'root', falsy: 'inherited' }, + { agentid: 11, hall_config: { agent: true }, agentOnly: 'agent', falsy: 0 }, + { gameid: 22, hall_config: { game: true }, gameOnly: 'game', falsy: false }, + { channelid: 33, hall_config: { channel: true }, channelOnly: 'channel', falsy: '' }, + { marketid: '44', hall_config: ['market'], marketOnly: 'market', falsy: null }, + ]; + const lists = ['agentlist', 'gamelist', 'channellist', 'marketlist']; + for (let depth = 0; depth < scopes.length; depth++) { + if (depth > 0) scopes[depth - 1][lists[depth - 1]] = [scopes[depth], { ...scopes[depth], hall_config: 'duplicate must lose' }]; + const result = view(scopes[0]); + assert.strictEqual(result.raw, scopes[0]); + for (const name of ['hall_config', 'rootOnly', 'agentOnly', 'gameOnly', 'channelOnly', 'marketOnly', 'falsy', 'missing']) { + assert.deepEqual(result.getValue(name), legacy(scopes[0], name)); + } + assert.strictEqual(result.getValue('hall_config'), scopes[depth].hall_config); + assert.equal(result.getValue('falsy'), 'inherited'); + } + assert.equal(view(scopes[0], { ...identity, marketid: 0 }).getValue('marketOnly'), null); + assert.deepEqual(view(scopes[0], { ...identity, agentid: 'missing' }).getValue('hall_config'), { root: true }); }); - -test('parseUrlServers:读取 data.urlserver 数组并保留已有 wss://', () => { - assert.deepEqual(parseUrlServers(array), ['ws://10.0.0.1:3088', 'wss://backup.example/ws']); +test('falsy root values remain null and missing identity levels stop inheritance', () => { + for (const value of [0, false, '', null]) assert.equal(view({ x: value }).getValue('x'), null); + assert.equal(view({ x: 'root', agentlist: [{ agentid: 'other', x: 'wrong' }] }).getValue('x'), 'root'); }); - -test('parseUrlServers:拒绝旧的 *_server_tcp 字段', () => { - assert.throws( - () => parseUrlServers({ data: { player_server_tcp: 'wrong:1' } }), - /data\.urlserver/, - ); +test('invalid root and present hierarchy fail explicitly', () => { + for (const raw of [null, [], 'json', { agentlist: {} }, { agentlist: [null] }, + { agentlist: [{ agentid: 11, gamelist: {} }] }, { agentlist: [{ agentid: 11, gamelist: [{ gameid: 22, channellist: [false] }] }] }]) { + assert.throws(() => view(raw), api.ConfigParseError); + } }); - -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('server selection prefers scoped game then player and preserves order and duplicates', () => { + for (const [raw, expected] of [ + [{ player_server_tcp: 'remote.example:3088' }, ['ws://remote.example:3088']], + [{ player_server_tcp: 'unused', game_server_tcp: ['same:1', 'same:1', 'wss://last/ws'] }, ['ws://same:1', 'ws://same:1', 'wss://last/ws']], + [{ game_server_tcp: '', player_server_tcp: 'player:1' }, ['ws://player:1']], + [{ game_server_tcp: 'global:1', agentlist: [{ agentid: 11, game_server_tcp: 'agent:2' }] }, ['ws://agent:2']], + ] as const) { + const servers = api.parseUrlServers(view(raw)); + assert.deepEqual(servers, expected); assert.ok(Object.isFrozen(servers)); + } }); - -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/); -}); - -test('parseUrlServers:显式拒绝已有的非 WebSocket scheme', () => { - for (const value of [ - 'http://config.example/ws', - 'https://config.example/ws', - 'ftp://config.example/ws', - ]) { - assert.throws( - () => parseUrlServers({ data: { urlserver: value } }), - /不支持.*scheme/i, - ); +test('missing or invalid chosen servers never fall back to obsolete envelope or player candidates', () => { + for (const raw of [{ data: { urlserver: 'old:1' } }, {}, + ...[[], ['ok:1', ''], ['ok:1', 2], true, ' ', 'http://wrong/ws', 'ws://', 'ws://user:pass@host:1', 'host:bad'].map(game_server_tcp => ({ game_server_tcp, player_server_tcp: 'valid:1' }))]) { + assert.throws(() => api.parseUrlServers(view(raw)), api.ConfigParseError); } }); diff --git a/cocoscreator_projects/framework-tests/config/runtime-config.test.ts b/cocoscreator_projects/framework-tests/config/runtime-config.test.ts index e27970c..58e03fe 100644 --- a/cocoscreator_projects/framework-tests/config/runtime-config.test.ts +++ b/cocoscreator_projects/framework-tests/config/runtime-config.test.ts @@ -12,7 +12,7 @@ function returning(value: unknown): ConfigFetcher & { calls: string[] } { }; } -const REMOTE = { data: { urlserver: ['5.5.5.5:5000', '5.5.5.5:5000'] } }; +const REMOTE = { player_server_tcp: ['5.5.5.5:5000', '5.5.5.5:5000'] }; test('h5:只读 query 身份,不访问原生宿主,并生成严格 remote 配置', async () => { const fetcher = returning(REMOTE); @@ -133,16 +133,17 @@ test('uAgent_3:拒绝宿主提供的非法身份字段类型', async () => { ); }); -test('显式 local debug:direct 字段不靠 null 推断,完整服务器来自 profile', async () => { +test('显式 local debug:远程配置保留内存,连接服务器来自 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.equal(result.source, 'remote'); + assert.ok(result.gameserver); + assert.equal(result.rawConfig, REMOTE); + assert.strictEqual(result.remoteConfig.raw, result.rawConfig); assert.deepEqual(result.servers, ['ws://127.0.0.1:3088']); - assert.deepEqual(fetcher.calls, []); + assert.equal(fetcher.calls.length, 1); }); test('远程抓取失败显式报错,不降级 direct', async () => { @@ -152,3 +153,39 @@ test('远程抓取失败显式报错,不降级 direct', async () => { /远程配置抓取失败/, ); }); + + +test('release ignores local profile but still fetches and selects remote servers', async () => { + const fetcher = returning(REMOTE); + const result = await resolveRuntimeConfig({ mode: 'release', hostKind: 'h5', win: {}, search: '?profile=local', fetcher }); + assert.equal(fetcher.calls.length, 1); + assert.deepEqual(result.servers, ['ws://5.5.5.5:5000', 'ws://5.5.5.5:5000']); + assert.equal(result.isDebugger, false); +}); + + +test('JSON parse errors retain their classification through the runtime boundary', async () => { + const { ConfigParseError } = await import('../../YouleNexus/assets/framework/config/remote-config.ts'); + const error = new ConfigParseError('invalid JSON'); + const fetcher = { async fetch() { throw error; } }; + await assert.rejects(resolveRuntimeConfig({ mode: 'debug', hostKind: 'h5', win: {}, search: '?profile=local', fetcher }), + actual => actual === error); +}); + +test('local consumes explicit loopback targets after valid remote JSON even when remote candidates are absent', async () => { + const raw = { visitor_server_tcp: 'unused.example:1234', notice: 'kept' }; + const fetcher = returning(raw); + const result = await resolveRuntimeConfig({ mode: 'debug', hostKind: 'h5', win: {}, search: '?profile=local', fetcher }); + assert.equal(fetcher.calls.length, 1); + assert.deepEqual(result.servers, ['ws://127.0.0.1:3088']); + assert.strictEqual(result.rawConfig, raw); + assert.equal(result.remoteConfig.getValue('notice'), 'kept'); +}); + +test('remote profiles reject missing game/player addresses without selecting visitor', async () => { + for (const mode of ['debug', 'release'] as const) { + const fetcher = returning({ visitor_server_tcp: 'unused.example:1234' }); + await assert.rejects(resolveRuntimeConfig({ mode, hostKind: 'h5', win: {}, search: '', fetcher }), /game_server_tcp\/player_server_tcp/); + assert.equal(fetcher.calls.length, 1); + } +}); diff --git a/cocoscreator_projects/framework-tests/fixtures/contracts/remote-config-array.json b/cocoscreator_projects/framework-tests/fixtures/contracts/remote-config-array.json index e00b44e..83b00f4 100644 --- a/cocoscreator_projects/framework-tests/fixtures/contracts/remote-config-array.json +++ b/cocoscreator_projects/framework-tests/fixtures/contracts/remote-config-array.json @@ -1 +1 @@ -{"data":{"urlserver":["10.0.0.1:3088","wss://backup.example/ws"]}} +{"player_server_tcp":["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 index 0d6e3d0..f08738d 100644 --- a/cocoscreator_projects/framework-tests/fixtures/contracts/remote-config-single.json +++ b/cocoscreator_projects/framework-tests/fixtures/contracts/remote-config-single.json @@ -1 +1 @@ -{"data":{"urlserver":"10.0.0.1:3088"}} +{"player_server_tcp":"10.0.0.1:3088"} diff --git a/cocoscreator_projects/framework-tests/fixtures/remote-config.ts b/cocoscreator_projects/framework-tests/fixtures/remote-config.ts new file mode 100644 index 0000000..85c6b7a --- /dev/null +++ b/cocoscreator_projects/framework-tests/fixtures/remote-config.ts @@ -0,0 +1,9 @@ +import { BUILD_IDENTITY } from '../../YouleNexus/assets/framework/config/sources/defaults.ts'; +import { createRemoteConfig } from '../../YouleNexus/assets/framework/config/remote-config.ts'; +import type { ChannelIdentity } from '../../YouleNexus/assets/framework/config/identity.ts'; + +/** Test-only memory configuration shared by typed RuntimeConfig fixtures. */ +export function remoteFixture(identity: Readonly = BUILD_IDENTITY) { + const remoteConfig = createRemoteConfig({ player_server_tcp: 'fixture.example:3088' }, identity); + return { remoteConfig, rawConfig: remoteConfig.raw }; +} diff --git a/cocoscreator_projects/framework-tests/integration/local-login-sources.test.ts b/cocoscreator_projects/framework-tests/integration/local-login-sources.test.ts index 7cc20b7..40203b3 100644 --- a/cocoscreator_projects/framework-tests/integration/local-login-sources.test.ts +++ b/cocoscreator_projects/framework-tests/integration/local-login-sources.test.ts @@ -14,7 +14,7 @@ class MapStorage implements LocalStoragePort { function resolve(search = '?profile=local') { return resolveLocalStartup({ mode: 'debug', hostKind: 'h5', win: {}, search, - fetcher: { async fetch(): Promise { throw new Error('forbidden fetch'); } } }); + fetcher: { async fetch() { return { player_server_tcp: 'remote.example:3088' }; } } }); } function sourcesFor(context: Awaited>, storage: LocalStoragePort, diff --git a/cocoscreator_projects/framework-tests/integration/login-flow.test.ts b/cocoscreator_projects/framework-tests/integration/login-flow.test.ts index 8abc880..43f0b2b 100644 --- a/cocoscreator_projects/framework-tests/integration/login-flow.test.ts +++ b/cocoscreator_projects/framework-tests/integration/login-flow.test.ts @@ -1,3 +1,4 @@ +import { remoteFixture } from '../fixtures/remote-config.ts'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { test } from 'node:test'; @@ -13,7 +14,7 @@ import { FakeTransport } from '../helpers/fake-transport.ts'; const CONFIG: RuntimeConfig = { mode: 'debug', isDebugger: true, identity: { agentid: 'A', channelid: 'C', gameid: '41', version: 1, marketid: 4 }, - source: 'direct', servers: ['ws://agent'], gameserver: null, rawConfig: null, + source: 'remote', servers: ['ws://agent'], gameserver: 'https://fixture/config', ...remoteFixture(), }; const ACCOUNT = { openid: 'o', nickname: 'n', avatar: 'a', sex: 0, province: '', city: '', unionid: 'u' }; const LOGIN = { diff --git a/cocoscreator_projects/framework-tests/integration/platform-vertical-slice.test.ts b/cocoscreator_projects/framework-tests/integration/platform-vertical-slice.test.ts index 2ac7c42..38d283f 100644 --- a/cocoscreator_projects/framework-tests/integration/platform-vertical-slice.test.ts +++ b/cocoscreator_projects/framework-tests/integration/platform-vertical-slice.test.ts @@ -137,7 +137,7 @@ test('real modern vertical replay preserves wire contracts, room ordering and te configRequests.push({ url: String(url), init }); return new Response(JSON.stringify({ urlserver: 'wrong-root.example:1', - data: { urlserver: ['agent.example:3088', 'backup.example:3088'] }, + player_server_tcp: ['agent.example:3088', 'backup.example:3088'], }), { status: 200 }); }), }), diff --git a/cocoscreator_projects/framework-tests/local-platform-login/host.test.ts b/cocoscreator_projects/framework-tests/local-platform-login/host.test.ts index c3fcea9..754a066 100644 --- a/cocoscreator_projects/framework-tests/local-platform-login/host.test.ts +++ b/cocoscreator_projects/framework-tests/local-platform-login/host.test.ts @@ -40,7 +40,7 @@ async function setup(overrides: { config?: Partial; let fetches = 0; const host = api.createLocalLoginHost({ configOptions: { mode: 'debug', hostKind: 'h5', win: {}, search: '?profile=local&version=731', - fetcher: { async fetch(): Promise { fetches++; throw new Error('unexpected fetch'); } }, ...overrides.config }, + fetcher: { async fetch() { fetches++; return { player_server_tcp: 'remote.example:3088' }; } }, ...overrides.config }, storage: { getItem: key => values.get(key) ?? null, setItem: (key, value) => { values.set(key, value); } }, random: () => .5, now: () => 123456, sampleHost: () => ({ location: null }), view: { render: model => { overrides.render?.(model); pages.push(model); }, @@ -70,7 +70,7 @@ for (const missing of ['resources', 'minimum', 'socket'] as const) test(`login w assert.equal(h.values.size, 0); assert.deepEqual(h.sockets[0]!.sent, []); h.resources.resolve(); h.minimum.resolve(); if (missing === 'socket') h.sockets[0]!.open(); await starting; - assert.equal(h.host.getSnapshot().ready, true); assert.equal(h.fetches(), 0); + assert.equal(h.host.getSnapshot().ready, true); assert.equal(h.fetches(), 1); assert.equal(h.sockets.length, 1); assert.equal(h.evidence.filter(event => event.kind === 'startup-config').length, 1); await h.host.login(); @@ -210,3 +210,35 @@ test('fatal overlay observer failure retains the original Runtime error object', assert.strictEqual(fault.errors[0], primary); assert.strictEqual(fault.errors[1], observer); assert.equal(h.host.getSnapshot().ready, false); assert.equal(h.sockets[0]!.closes, 1); }); + +test('remote fetch and parse failures leave local sockets and account storage untouched', async () => { + for (const fetch of [async () => { throw new Error('network down'); }, async () => ({ agentlist: {} }), async () => null]) { + const h = await setup({ config: { fetcher: { fetch } } }); + const result = h.host.start().catch(error => error); + await flush(); + assert.equal(h.sockets.length, 0); + assert.equal(h.values.size, 0); + assert.ok(await result instanceof Error); + assert.equal(h.failures.length, 1); + } +}); + +test('stop while remote fetch is pending prevents late socket creation', async () => { + const pending = deferred(); + let fetched = false; + const h = await setup({ config: { fetcher: { async fetch() { + fetched = true; await pending.promise; return { player_server_tcp: 'remote.example:3088' }; + } } } }); + const result = h.host.start().catch(error => error); + await flush(); assert.equal(fetched, true); assert.equal(h.sockets.length, 0); + h.host.stop(); pending.resolve(); await result; + assert.equal(h.sockets.length, 0); assert.equal(h.values.size, 0); +}); + +test('startup diagnostics exclude raw remote fields', async t => { + const h = await setup({ config: { fetcher: { async fetch() { + return { player_server_tcp: 'remote.example:3088', privateConfig: 'DO_NOT_RECORD_REMOTE_SECRET' }; + } } } }); + t.after(() => h.host.stop()); await h.ready(); + assert.equal(JSON.stringify(h.evidence).includes('DO_NOT_RECORD_REMOTE_SECRET'), false); +}); diff --git a/cocoscreator_projects/framework-tests/local-platform-login/transport.test.ts b/cocoscreator_projects/framework-tests/local-platform-login/transport.test.ts index 320dda4..0630ce5 100644 --- a/cocoscreator_projects/framework-tests/local-platform-login/transport.test.ts +++ b/cocoscreator_projects/framework-tests/local-platform-login/transport.test.ts @@ -37,7 +37,7 @@ async function setup(overrides: { .catch((error: unknown) => assert.fail(`LocalLoginTransport implementation must exist: ${String(error)}`)); const context = await resolveLocalStartup({ mode: 'debug', hostKind: 'h5', win: {}, search: '?profile=local', - fetcher: { async fetch(): Promise { throw new Error('unexpected fetch'); } }, + fetcher: { async fetch() { return { player_server_tcp: 'remote.example:3088' }; } }, }); const sockets: FakeSocket[] = []; const urls: string[] = []; diff --git a/cocoscreator_projects/framework-tests/local-platform-login/wire.test.ts b/cocoscreator_projects/framework-tests/local-platform-login/wire.test.ts index 080e640..bd47479 100644 --- a/cocoscreator_projects/framework-tests/local-platform-login/wire.test.ts +++ b/cocoscreator_projects/framework-tests/local-platform-login/wire.test.ts @@ -15,7 +15,7 @@ async function setup(recordHook?: (event: { kind: string; data: unknown }) => vo const api = await import('../../YouleNexus/assets/scripts/local-platform-login/local-login-wire.ts') .catch(error => assert.fail(`Local login Wire implementation must exist: ${String(error)}`)); const context = await resolveLocalStartup({ mode: 'debug', hostKind: 'h5', win: {}, search: '?profile=local', - fetcher: { async fetch(): Promise { throw new Error('unexpected fetch'); } } }); + fetcher: { async fetch() { return { player_server_tcp: 'remote.example:3088' }; } } }); const transports: FakeTransport[] = []; const events: WireEvent[] = []; const evidence: { kind: string; data: unknown }[] = []; diff --git a/cocoscreator_projects/framework-tests/platform/commands.test.ts b/cocoscreator_projects/framework-tests/platform/commands.test.ts index 49a96b6..f9a07c8 100644 --- a/cocoscreator_projects/framework-tests/platform/commands.test.ts +++ b/cocoscreator_projects/framework-tests/platform/commands.test.ts @@ -1,3 +1,4 @@ +import { remoteFixture } from '../fixtures/remote-config.ts'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { test } from 'node:test'; @@ -22,7 +23,7 @@ const RUNTIME_CONFIG: RuntimeConfig = Object.freeze({ servers: Object.freeze(['ws://fixture']), source: 'remote', gameserver: 'https://fixture/config', - rawConfig: Object.freeze({ data: Object.freeze({ urlserver: 'fixture' }) }), + ...remoteFixture(), }); function fixture(name: string): { data: Record } { diff --git a/cocoscreator_projects/framework-tests/platform/game-host-adapter.test.ts b/cocoscreator_projects/framework-tests/platform/game-host-adapter.test.ts index bd22b1e..961ebfe 100644 --- a/cocoscreator_projects/framework-tests/platform/game-host-adapter.test.ts +++ b/cocoscreator_projects/framework-tests/platform/game-host-adapter.test.ts @@ -1,3 +1,4 @@ +import { remoteFixture } from '../fixtures/remote-config.ts'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { test } from 'node:test'; @@ -41,7 +42,7 @@ const RUNTIME_CONFIG: RuntimeConfig = Object.freeze({ servers: Object.freeze(['ws://fixture']), source: 'remote', gameserver: 'https://fixture/config', - rawConfig: Object.freeze({ data: Object.freeze({ urlserver: 'fixture' }) }), + ...remoteFixture(), }); function fixture(name: string): { data: Record } { diff --git a/cocoscreator_projects/framework-tests/platform/runtime.test.ts b/cocoscreator_projects/framework-tests/platform/runtime.test.ts index a39c23d..b29b45b 100644 --- a/cocoscreator_projects/framework-tests/platform/runtime.test.ts +++ b/cocoscreator_projects/framework-tests/platform/runtime.test.ts @@ -1,3 +1,4 @@ +import { remoteFixture } from '../fixtures/remote-config.ts'; import { readFileSync } from 'node:fs'; import assert from 'node:assert/strict'; import { test } from 'node:test'; @@ -57,9 +58,9 @@ function runtimeConfig(gameid: string | number = '41'): RuntimeConfig { version: 10000, }), servers: Object.freeze(['ws://agent']), - source: 'direct', - gameserver: null, - rawConfig: null, + source: 'remote', + gameserver: 'https://fixture/config', + ...remoteFixture(), }) as RuntimeConfig; } diff --git a/cocoscreator_projects/framework-tests/platform/startup.test.ts b/cocoscreator_projects/framework-tests/platform/startup.test.ts index 1c57888..93525e1 100644 --- a/cocoscreator_projects/framework-tests/platform/startup.test.ts +++ b/cocoscreator_projects/framework-tests/platform/startup.test.ts @@ -1,3 +1,4 @@ +import { remoteFixture } from '../fixtures/remote-config.ts'; import { test } from 'node:test'; import assert from 'node:assert/strict'; import { StartupOrchestrator } from '../../YouleNexus/assets/framework/platform/startup.ts'; @@ -13,7 +14,7 @@ function bootstrapOf(): BootstrapResult { mode: 'debug', isDebugger: true, identity: IDENTITY, servers: ['ws://test'], - rawConfig: null, + ...remoteFixture(), profile: { name: 'debug' }, }; } diff --git a/cocoscreator_projects/framework-tests/sdk/game-contract-harness.test.ts b/cocoscreator_projects/framework-tests/sdk/game-contract-harness.test.ts index 1b88668..61d9076 100644 --- a/cocoscreator_projects/framework-tests/sdk/game-contract-harness.test.ts +++ b/cocoscreator_projects/framework-tests/sdk/game-contract-harness.test.ts @@ -1,3 +1,4 @@ +import { remoteFixture } from '../fixtures/remote-config.ts'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { test } from 'node:test'; @@ -83,7 +84,7 @@ const RUNTIME_CONFIG: RuntimeConfig = Object.freeze({ servers: Object.freeze(['ws://fixture']), source: 'remote', gameserver: 'https://fixture/config', - rawConfig: Object.freeze({ data: Object.freeze({ urlserver: 'fixture' }) }), + ...remoteFixture(), }); function makeModule(id: number, onRestore: (deskinfo: unknown) => void): GameModule {