fix(config): resolve remote hierarchy in memory for every profile

This commit is contained in:
2026-09-05 21:21:24 +08:00
parent 62328b6fd6
commit 053302c5df
27 changed files with 286 additions and 143 deletions
@@ -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]);
@@ -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<Record<string, DebugProfile>> = 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']),
}),
});
@@ -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<Record<string, unknown>>;
readonly getValue: (name: string) => unknown;
}
/** 远程配置抓取抽象;cache bust 在 fetcher 内形成且只形成一次。 */
export interface ConfigFetcher {
fetch(gameserver: string, cacheBust?: () => string): Promise<unknown>;
}
@@ -14,33 +13,76 @@ export interface ConfigFetcher {
export class ConfigFetchError extends Error {}
export class ConfigParseError extends Error {}
const SCOPES = [
['agentlist', 'agentid'], ['gamelist', 'gameid'],
['channellist', 'channelid'], ['marketlist', 'marketid'],
] as const;
type ConfigObject = Record<string, unknown>;
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 value as ConfigObject;
}
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);
}
/** Preserve first loose identity match, truthy overrides and whole-object replacement. */
export function createRemoteConfig(input: unknown, identity: Readonly<ChannelIdentity>): 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 {
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 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');
}
return `ws://${value}`;
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;
}
/**
* 只接受 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');
}
const raw = (data as { urlserver?: unknown }).urlserver;
/** 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));
}
@@ -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<ChannelIdentity>;
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,
});
}
@@ -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,
@@ -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 {
@@ -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<typeof BUILD_IDENTITY> = {}): 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 },
} };
}
@@ -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']);
});
@@ -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<never> {
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']],
@@ -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']);
});
@@ -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(
@@ -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('parseUrlServers:读取 data.urlserver 数组并保留已有 wss://', () => {
assert.deepEqual(parseUrlServers(array), ['ws://10.0.0.1:3088', 'wss://backup.example/ws']);
}
test('memory view agrees with actual legacy function across all five scopes and preserves raw objects', () => {
const scopes: Record<string, unknown>[] = [
{ 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:拒绝旧的 *_server_tcp 字段', () => {
assert.throws(
() => parseUrlServers({ data: { player_server_tcp: 'wrong:1' } }),
/data\.urlserver/,
);
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:保留原顺序和重复项,并冻结结果', () => {
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('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:拒绝空字符串、空数组和非字符串数组项', () => {
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('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:显式拒绝已有的非 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);
}
});
@@ -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);
}
});
@@ -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"]}
@@ -1 +1 @@
{"data":{"urlserver":"10.0.0.1:3088"}}
{"player_server_tcp":"10.0.0.1:3088"}
@@ -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<ChannelIdentity> = BUILD_IDENTITY) {
const remoteConfig = createRemoteConfig({ player_server_tcp: 'fixture.example:3088' }, identity);
return { remoteConfig, rawConfig: remoteConfig.raw };
}
@@ -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<never> { throw new Error('forbidden fetch'); } } });
fetcher: { async fetch() { return { player_server_tcp: 'remote.example:3088' }; } } });
}
function sourcesFor(context: Awaited<ReturnType<typeof resolve>>, storage: LocalStoragePort,
@@ -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 = {
@@ -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 });
}),
}),
@@ -40,7 +40,7 @@ async function setup(overrides: { config?: Partial<ResolveRuntimeConfigOptions>;
let fetches = 0;
const host = api.createLocalLoginHost({
configOptions: { mode: 'debug', hostKind: 'h5', win: {}, search: '?profile=local&version=731',
fetcher: { async fetch(): Promise<never> { 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);
});
@@ -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<never> { throw new Error('unexpected fetch'); } },
fetcher: { async fetch() { return { player_server_tcp: 'remote.example:3088' }; } },
});
const sockets: FakeSocket[] = [];
const urls: string[] = [];
@@ -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<never> { 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 }[] = [];
@@ -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<string, unknown> } {
@@ -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<string, unknown> } {
@@ -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;
}
@@ -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' },
};
}
@@ -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 {