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 {}
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<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 `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<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 {
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));
}
@@ -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,