feat(config): unify startup mode and server selection

This commit is contained in:
2026-09-05 22:52:14 +08:00
parent 239af0aa39
commit facceeae9b
26 changed files with 204 additions and 240 deletions
@@ -1,5 +1,4 @@
import type { DebugAccount, DebugProfile } from './profiles.ts';
import { PROFILES, resolveActiveProfile } from './profiles.ts';
import type { DebugAccount } from './profiles.ts';
import {
resolveRuntimeConfig,
type HostKind,
@@ -24,7 +23,7 @@ export type BootstrapResult = Omit<RuntimeConfig, 'servers' | 'source' | 'gamese
readonly source?: RuntimeConfig['source'];
readonly gameserver?: RuntimeConfig['gameserver'];
readonly account?: DebugAccount;
readonly profile: Pick<DebugProfile, 'name'>;
readonly profile: Readonly<{ name: 'local' | 'prod' }>;
};
function bootstrapHostKind(options: BootstrapOptions): HostKind {
@@ -34,9 +33,8 @@ function bootstrapHostKind(options: BootstrapOptions): HostKind {
/** 兼容启动编排外观;所有运行契约均由唯一的 RuntimeConfig 边界产出。 */
export async function resolveBootstrap(options: BootstrapOptions): Promise<BootstrapResult> {
const profile = options.mode === 'release' ? PROFILES.prod! : resolveActiveProfile(options.search);
const runtime = await resolveRuntimeConfig({
mode: options.mode,
startupConfig: options.startupConfig,
hostKind: bootstrapHostKind(options),
win: options.win,
search: options.search,
@@ -46,7 +44,6 @@ export async function resolveBootstrap(options: BootstrapOptions): Promise<Boots
return Object.freeze({
...runtime,
servers: [...runtime.servers],
account: profile.account,
profile,
profile: Object.freeze({ name: runtime.startupConfig.useLocalServer ? 'local' as const : 'prod' as const }),
});
}
@@ -1,5 +1,5 @@
import { resolveIdentity } from './identity.ts';
import { PROFILES } from './profiles.ts';
import { STARTUP_CONFIG, validateStartupConfig, validateLocalServer } from './profiles.ts';
import {
resolveRuntimeConfig,
type ResolveRuntimeConfigOptions,
@@ -13,8 +13,6 @@ export interface LocalStartupContext {
}
const QUERY_CONTROLS = Object.freeze([
'profile',
'mode',
'gameconfig',
'agentid',
'channelid',
@@ -27,41 +25,12 @@ function fail(field: string, reason: string): never {
throw new Error(`Invalid local startup ${field}: ${reason}`);
}
function validateLocalUrl(url: string, field: string): void {
let parsed: URL;
try {
parsed = new URL(url);
} catch {
fail(field, 'url must be absolute');
}
if (parsed.protocol !== 'ws:' && parsed.protocol !== 'wss:') {
fail(field, 'url protocol must be ws: or wss:');
}
if (parsed.username !== '' || parsed.password !== '') {
fail(field, 'url credentials are forbidden');
}
const hostname = parsed.hostname.toLowerCase();
if (
hostname !== 'localhost'
&& hostname !== '[::1]'
&& !/^127(?:\.[0-9]{1,3}){3}$/.test(hostname)
) {
fail(field, 'url hostname must be loopback');
}
}
function assertLocalQuery(search: string): void {
const query = new URLSearchParams(search);
for (const field of QUERY_CONTROLS) {
if (query.getAll(field).length > 1) fail(field, 'duplicate query control');
}
const profiles = query.getAll('profile');
if (profiles.length !== 1 || profiles[0] !== 'local') {
fail('profile', 'must occur exactly once with value local');
}
const modes = query.getAll('mode');
if (modes.length === 1 && modes[0] !== 'debug') fail('mode', 'must be debug');
const gameconfigs = query.getAll('gameconfig');
if (gameconfigs.length === 1 && gameconfigs[0] !== '') {
fail('gameconfig', 'must be empty');
@@ -72,11 +41,13 @@ function assertLocalQuery(search: string): void {
export async function resolveLocalStartup(
options: ResolveRuntimeConfigOptions,
): Promise<LocalStartupContext> {
if (options.mode !== 'debug') fail('mode', 'must be debug');
const startupConfig = validateStartupConfig(options.startupConfig === undefined ? STARTUP_CONFIG : options.startupConfig);
if (startupConfig.mode !== 'debug') fail('mode', 'must be debug');
if (!startupConfig.useLocalServer) fail('useLocalServer', 'must be true');
if (options.hostKind !== 'h5') fail('hostKind', 'must be h5');
assertLocalQuery(options.search);
const config = await resolveRuntimeConfig(options);
const config = await resolveRuntimeConfig({...options, startupConfig});
const context = Object.freeze({ hostKind: 'h5' as const, profile: 'local' as const, config });
assertLocalStartupContext(context);
return context;
@@ -87,10 +58,13 @@ export function assertLocalStartupContext(context: LocalStartupContext): void {
if (context.profile !== 'local') fail('profile', 'must be local');
const { config } = context;
const startupConfig = validateStartupConfig(config.startupConfig);
if (startupConfig.mode !== 'debug') fail('mode', 'source must be debug');
if (!startupConfig.useLocalServer) fail('useLocalServer', 'source must be true');
if (config.mode !== 'debug') fail('mode', 'must be debug');
if (config.isDebugger !== true) fail('isDebugger', 'must be true');
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.gameserver !== startupConfig.gameserver) fail('gameserver', 'must equal startupConfig 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');
@@ -105,26 +79,23 @@ export function assertLocalStartupContext(context: LocalStartupContext): void {
throw wrapped;
}
const authoritative = PROFILES.local?.servers;
if (!authoritative || authoritative.length === 0) {
fail('servers', 'PROFILES.local must define servers');
}
const authoritative = startupConfig.localServers;
if (config.servers.length !== authoritative.length) {
fail('servers', 'must equal PROFILES.local.servers');
fail('servers', 'must equal startupConfig.localServers');
}
for (let index = 0; index < authoritative.length; index += 1) {
const expected = authoritative[index];
validateLocalUrl(expected, `servers[${index}]`);
validateLocalServer(expected, `servers[${index}]`);
if (config.servers[index] !== expected) {
fail('servers', 'must equal PROFILES.local.servers');
fail('servers', 'must equal startupConfig.localServers');
}
}
}
export function assertLocalConnectionTarget(context: LocalStartupContext, url: string): void {
assertLocalStartupContext(context);
validateLocalUrl(url, 'url');
if (!PROFILES.local?.servers?.includes(url)) {
fail('url', 'target must occur in PROFILES.local.servers');
validateLocalServer(url, 'url');
if (!context.config.startupConfig.localServers.includes(url)) {
fail('url', 'target must occur in startupConfig.localServers');
}
}
@@ -1,3 +1,5 @@
import { resolveRuntimeMode, type RuntimeMode } from './runtime-mode.ts';
/** 调试用登录玩家档案;值只能由测试或本地启动配置显式注入。 */
export interface DebugAccount {
openid: string;
@@ -9,38 +11,49 @@ export interface DebugAccount {
unionid?: string | number;
}
export type ProfileSource = 'remote';
export interface DebugProfile {
readonly name: string;
readonly source: ProfileSource;
export interface StartupConfig {
readonly mode: RuntimeMode;
readonly gameserver: string;
readonly servers?: readonly string[];
readonly account?: DebugAccount;
readonly useLocalServer: boolean;
readonly localServers: readonly string[];
}
/** 正式 gameserver:原 00_SubGame_Config.js:11。 */
export const DEFAULT_GAMESERVER = 'https://tsgames.daoqi88.cn/config/update_jsonv2.txt';
export const PROFILES: Readonly<Record<string, DebugProfile>> = 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: 'remote',
gameserver: DEFAULT_GAMESERVER,
servers: Object.freeze(['ws://127.0.0.1:3088']),
}),
/** 唯一发布配置入口;mode 与服务器选择互相独立。 */
export const STARTUP_CONFIG: Readonly<StartupConfig> = Object.freeze({
mode: 'debug',
gameserver: 'https://tsgames.daoqi88.cn/config/update_jsonv2.txt',
useLocalServer: true,
localServers: Object.freeze(['ws://127.0.0.1:3088']),
});
export const ACTIVE_PROFILE = 'prod';
function invalid(field: string, reason: string): never {
throw new Error('Invalid startup config ' + field + ': ' + reason);
}
export function resolveActiveProfile(search: string): DebugProfile {
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;
export function validateLocalServer(value: string, field = 'localServers'): void {
if (typeof value !== 'string' || value.trim() !== value) invalid(field, 'must be an absolute URL');
let url: URL;
try { url = new URL(value); } catch { invalid(field, 'must be an absolute URL'); }
if (url.protocol !== 'ws:' && url.protocol !== 'wss:') invalid(field, 'protocol must be ws: or wss:');
if (url.username || url.password || value.includes('#')) invalid(field, 'credentials and fragments are forbidden');
const host = url.hostname.toLowerCase();
if (host !== 'localhost' && host !== '[::1]' && !/^127(?:\.[0-9]{1,3}){3}$/.test(host)) {
invalid(field, 'hostname must be loopback');
}
}
/** 验证后复制为不可变快照,异步抓取期间调用方不能改变配置。 */
export function validateStartupConfig(source: Readonly<StartupConfig>): Readonly<StartupConfig> {
if (!source || typeof source !== 'object') invalid('source', 'must be an object');
const mode = resolveRuntimeMode(source.mode);
if (typeof source.useLocalServer !== 'boolean') invalid('useLocalServer', 'must be boolean');
if (typeof source.gameserver !== 'string' || source.gameserver.trim() !== source.gameserver) invalid('gameserver', 'must be http(s) URL');
let url: URL;
try { url = new URL(source.gameserver); } catch { invalid('gameserver', 'must be http(s) URL'); }
if (url.protocol !== 'http:' && url.protocol !== 'https:') invalid('gameserver', 'must be http(s) URL');
if (!Array.isArray(source.localServers) || source.localServers.length === 0) invalid('localServers', 'must be a nonempty array');
for (let index = 0; index < source.localServers.length; index++) validateLocalServer(source.localServers[index], 'localServers[' + index + ']');
return Object.freeze({mode, gameserver:source.gameserver, useLocalServer:source.useLocalServer, localServers:Object.freeze([...source.localServers])});
}
/** 忠实旧 min_replaceAll 链:- → /,# → :,再包成 http://...txt。 */
@@ -55,11 +68,11 @@ export interface ResolveGameServerOptions {
readonly injectedGameConfig: unknown;
}
/** 注入的 gameconfig 非空时是唯一覆盖源;否则只使用 profile 配置源。 */
/** 注入的 gameconfig 非空时是唯一覆盖源;否则只使用 启动配置源。 */
export function resolveGameServer(options: ResolveGameServerOptions): string {
const injected = options.injectedGameConfig;
if (injected === '' || injected === undefined || injected === null) {
if (!options.configured) throw new Error('profile 未配置 gameserver');
if (!options.configured) throw new Error('启动配置未配置 gameserver');
return options.configured;
}
if (typeof injected !== 'string') throw new Error('gameconfig 必须是字符串');
@@ -1,10 +1,10 @@
import type { ChannelIdentity, IdentitySource } from './identity.ts';
import { resolveIdentity } from './identity.ts';
import {
PROFILES,
resolveActiveProfile,
STARTUP_CONFIG,
validateStartupConfig,
resolveGameServer,
type DebugProfile,
type StartupConfig,
} from './profiles.ts';
import {
ConfigFetchError,
@@ -27,6 +27,7 @@ import { queryStringSource } from './sources/query-string.ts';
export type HostKind = 'h5' | NativeHostKind;
export interface RuntimeConfig {
readonly startupConfig: Readonly<StartupConfig>;
readonly mode: RuntimeMode;
readonly isDebugger: boolean;
readonly identity: Readonly<ChannelIdentity>;
@@ -38,7 +39,7 @@ export interface RuntimeConfig {
}
export interface ResolveRuntimeConfigOptions {
readonly mode: RuntimeMode;
readonly startupConfig?: Readonly<StartupConfig>;
readonly hostKind: HostKind;
readonly win: NativeHost;
readonly search: string;
@@ -67,10 +68,6 @@ function resolveInjectedGameConfig(options: ResolveRuntimeConfigOptions): unknow
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);
}
@@ -78,14 +75,11 @@ function freezeResult(result: RuntimeConfig): RuntimeConfig {
export async function resolveRuntimeConfig(
options: ResolveRuntimeConfigOptions,
): Promise<RuntimeConfig> {
const profile = selectedProfile(options.mode, options.search);
const startupConfig = validateStartupConfig(options.startupConfig === undefined ? STARTUP_CONFIG : options.startupConfig);
const identity = resolveHostIdentity(options);
if (!profile.gameserver) {
throw new ConfigFetchError(`profile '${profile.name}' 未配置 gameserver`);
}
const gameserver = resolveGameServer({
configured: profile.gameserver,
configured: startupConfig.gameserver,
injectedGameConfig: resolveInjectedGameConfig(options),
});
@@ -97,19 +91,12 @@ export async function resolveRuntimeConfig(
throw new ConfigFetchError(`远程配置抓取失败: ${gameserver}`);
}
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);
}
const servers = startupConfig.useLocalServer ? startupConfig.localServers : parseUrlServers(remoteConfig);
return freezeResult({
mode: options.mode,
isDebugger: options.mode === 'debug',
startupConfig,
mode: startupConfig.mode,
isDebugger: startupConfig.mode === 'debug',
identity,
servers,
source: 'remote',
@@ -1,32 +1,7 @@
// 1. 先 export type
export type RuntimeMode = 'debug' | 'release';
declare const DEBUG: boolean | undefined;
// 2. 函数引用 RuntimeMode(此时 type 已 export)
function readModeFromUrl(): RuntimeMode | null {
if (typeof window === 'undefined' || !window.location) return null;
const m = new URLSearchParams(window.location.search).get('mode');
if (m === 'debug' || m === 'release') return m;
return null;
}
function detectDebugBuild(): boolean {
if (typeof DEBUG !== 'undefined') return DEBUG;
return true; // Cocos 编辑器内 dev workflow 默认;生产 build 改 false
}
// 3. 导出常量
export const MODE_OVERRIDE: RuntimeMode | null =
readModeFromUrl() ?? (detectDebugBuild() ? 'debug' : 'release');
/**
* 解析运行模式:override 优先,否则由是否 debug 构建决定。
*/
export function resolveRuntimeMode(
isDebugBuild: boolean,
override: RuntimeMode | null = MODE_OVERRIDE,
): RuntimeMode {
if (override) return override;
return isDebugBuild ? 'debug' : 'release';
/** 应用模式仅来自显式配置,不读取 URL 或 Creator 编译标志。 */
export function resolveRuntimeMode(mode: RuntimeMode): RuntimeMode {
if (mode !== 'debug' && mode !== 'release') throw new Error('Invalid startup config mode: must be debug or release');
return mode;
}