feat(framework): StartupOrchestrator 启动链路编排
串联启动门控四条件(C 规范 §6.1)的可测部分: - bootstrap() 拉取并解析远程配置,写入 AppStore(identity/servers) 失败显式抛错(第二准则) - onLogin() 收到 player_login 后构造 NetClient + RoomRPCBus 并触发 ready 信号 - waitForReady() 合并 bootstrap + login + room rpc 通道 UI 层 await 即可进入登录页 - shutdown() 清理 RoomRPCBus 依赖注入: - getBootstrapResult: 远程配置拉取函数(测试可 mock 抛错) - makeNetClient: NetClient 工厂(避免测试需要真 WS) - roomBusFactory: RoomRPCBus 工厂(避免测试起副作用) 167/167 tests pass, typecheck exit 0。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
import type { EventBus } from '../core/events.ts';
|
||||
import type { NetClientEvents } from '../net/net-client.ts';
|
||||
import type { ChannelIdentity } from '../config/identity.ts';
|
||||
import type { BootstrapResult } from '../config/bootstrap.ts';
|
||||
import type { PlatformSession } from './session.ts';
|
||||
import type { RoomRPCBus } from './room-rpc-bus.ts';
|
||||
|
||||
/**
|
||||
* StartupOrchestrator:把启动链路串起来的纯逻辑编排器。
|
||||
*
|
||||
* 流程:bootstrap() → onLogin()(由 PlatformSession 在收到 login 事件后调用)→ ready
|
||||
*
|
||||
* 不直接构造 NetClient / RoomRPCBus(依赖注入),便于单测覆盖全部阶段。
|
||||
* 不主动连 WS/发包——NetClient 由业务侧触发 start(),本类只编排阶段信号。
|
||||
*
|
||||
* 启动门控四条件(C 规范 §6.1):资源(外部)/ WS onopen(NetClient.on)/ 配置(本类 bootstrap)/ 加载计时器(外部)。
|
||||
* waitForReady() 合并后三个为「就绪信号」,UI 层 await 进入登录页。
|
||||
*/
|
||||
export interface StartupOptions {
|
||||
session: PlatformSession;
|
||||
bus: EventBus<NetClientEvents>;
|
||||
/** 返回 bootstrap 结果(远程配置 + 身份 + 服务器列表) */
|
||||
getBootstrapResult: () => Promise<BootstrapResult>;
|
||||
/** 构造 NetClient 的工厂(注入避免硬依赖真 WS) */
|
||||
makeNetClient: () => { bus: EventBus<NetClientEvents> };
|
||||
/** 用于 session 初始化的身份字段 */
|
||||
identity: ChannelIdentity;
|
||||
/** 构造 RoomRPCBus 的工厂 */
|
||||
roomBusFactory: (session: PlatformSession, bus: EventBus<NetClientEvents>) => RoomRPCBus;
|
||||
}
|
||||
|
||||
export class StartupOrchestrator {
|
||||
bootstrapResult: BootstrapResult | null = null;
|
||||
private readyResolve: (() => void) | null = null;
|
||||
private readyPromise: Promise<void>;
|
||||
private roomBus: RoomRPCBus | null = null;
|
||||
private netClient: { bus: EventBus<NetClientEvents> } | null = null;
|
||||
|
||||
constructor(private readonly opts: StartupOptions) {
|
||||
// 立即构造一个 pending Promise,UI 层可提前 await
|
||||
this.readyPromise = new Promise<void>((resolve) => {
|
||||
this.readyResolve = resolve;
|
||||
});
|
||||
}
|
||||
|
||||
/** 阶段 1:拉取并解析远程配置,失败显式抛错 */
|
||||
async bootstrap(): Promise<BootstrapResult> {
|
||||
const result = await this.opts.getBootstrapResult();
|
||||
this.bootstrapResult = result;
|
||||
// 把身份 + 服务器写入 AppStore(session 在构造时已订阅 login 等事件,
|
||||
// 这里直接驱动 AppStore.setServers/setIdentity 以提前有数据)
|
||||
this.opts.session.app.setServers(result.servers);
|
||||
this.opts.session.app.setIdentity(result.identity);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 阶段 2/3:构造 NetClient + RoomRPCBus,准备就绪(但不主动 start 连接;
|
||||
* 业务侧在收到 phase=connected 后再 netClient.start())。
|
||||
*
|
||||
* 通常 PlatformSession 收到 'login' 事件后调用此方法——login 事件
|
||||
* 表明 NetClient 已 start 且 server 已响应 player_login。
|
||||
*/
|
||||
async onLogin(): Promise<void> {
|
||||
// 构造 NetClient(业务侧会调用 start() 真正建立 WS)
|
||||
this.netClient = this.opts.makeNetClient();
|
||||
// 构造 RoomRPCBus 并立即 start,把 room route 的包挂到 bus
|
||||
this.roomBus = this.opts.roomBusFactory(this.opts.session, this.netClient.bus);
|
||||
this.roomBus.start();
|
||||
// 触发 ready 信号
|
||||
if (this.readyResolve) { this.readyResolve(); this.readyResolve = null; }
|
||||
}
|
||||
|
||||
/** UI 层等待「bootstrap + login + 房间 rpc 通道就绪」 */
|
||||
async waitForReady(): Promise<void> {
|
||||
return this.readyPromise;
|
||||
}
|
||||
|
||||
isReady(): boolean { return this.readyResolve === null; }
|
||||
|
||||
/** 清理:停止 RoomRPCBus + 关闭 NetClient */
|
||||
shutdown(): void {
|
||||
this.roomBus?.stop();
|
||||
this.roomBus = null;
|
||||
this.netClient = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { StartupOrchestrator } from '../../YouleNexus/assets/framework/platform/startup.ts';
|
||||
import { PlatformSession } from '../../YouleNexus/assets/framework/platform/session.ts';
|
||||
import { EventBus } from '../../YouleNexus/assets/framework/core/events.ts';
|
||||
import type { NetClientEvents } from '../../YouleNexus/assets/framework/net/net-client.ts';
|
||||
import type { BootstrapResult } from '../../YouleNexus/assets/framework/config/bootstrap.ts';
|
||||
|
||||
const IDENTITY = { agentid: 'A', channelid: 'C', gameid: 'G', marketid: 4, version: 10000 };
|
||||
|
||||
function bootstrapOf(): BootstrapResult {
|
||||
return {
|
||||
mode: 'debug', isDebugger: true,
|
||||
identity: IDENTITY,
|
||||
servers: ['ws://test'],
|
||||
rawConfig: null,
|
||||
profile: { name: 'debug' },
|
||||
};
|
||||
}
|
||||
|
||||
test('StartupOrchestrator: 启动流程 bootstrap → onLogin → ready', async () => {
|
||||
const bus = new EventBus<NetClientEvents>();
|
||||
const session = new PlatformSession(bus);
|
||||
let startedRoomBus = false;
|
||||
|
||||
const orch = new StartupOrchestrator({
|
||||
session,
|
||||
bus,
|
||||
getBootstrapResult: async () => bootstrapOf(),
|
||||
makeNetClient: () => ({ bus }) as any,
|
||||
identity: IDENTITY,
|
||||
roomBusFactory: () => ({
|
||||
start() { startedRoomBus = true; },
|
||||
stop() { startedRoomBus = false; },
|
||||
} as any),
|
||||
});
|
||||
|
||||
await orch.bootstrap();
|
||||
assert.equal(orch.bootstrapResult?.servers[0], 'ws://test');
|
||||
assert.equal(session.app.state.value.servers[0], 'ws://test', 'servers 应写入 AppStore');
|
||||
assert.equal(session.app.state.value.identity?.agentid, 'A', 'identity 应写入 AppStore');
|
||||
|
||||
// 模拟 NetClient 发出 login 事件(让 session 把 phase 推到 loggedIn)
|
||||
bus.emit('login', { state: 0, playerid: 100, nickname: '甲', agentid: 'A', channelid: 'C' } as any);
|
||||
|
||||
await orch.onLogin();
|
||||
assert.equal(session.app.state.value.phase, 'loggedIn');
|
||||
assert.equal(startedRoomBus, true, 'RoomRPCBus 应已 start');
|
||||
assert.equal(orch.isReady(), true);
|
||||
|
||||
await orch.waitForReady();
|
||||
});
|
||||
|
||||
test('StartupOrchestrator: bootstrap 失败显式抛错', async () => {
|
||||
const bus = new EventBus<NetClientEvents>();
|
||||
const session = new PlatformSession(bus);
|
||||
const orch = new StartupOrchestrator({
|
||||
session,
|
||||
bus,
|
||||
getBootstrapResult: async () => { throw new Error('远程配置抓取失败'); },
|
||||
makeNetClient: () => ({} as any),
|
||||
identity: IDENTITY,
|
||||
roomBusFactory: () => ({ start() {}, stop() {} } as any),
|
||||
});
|
||||
await assert.rejects(() => orch.bootstrap(), /远程配置抓取失败/);
|
||||
assert.equal(orch.bootstrapResult, null, 'bootstrap 失败时不写入 result');
|
||||
});
|
||||
|
||||
test('StartupOrchestrator: waitForReady 在 ready 之前不 resolve', async () => {
|
||||
const bus = new EventBus<NetClientEvents>();
|
||||
const session = new PlatformSession(bus);
|
||||
const orch = new StartupOrchestrator({
|
||||
session,
|
||||
bus,
|
||||
getBootstrapResult: async () => bootstrapOf(),
|
||||
makeNetClient: () => ({}) as any,
|
||||
identity: IDENTITY,
|
||||
roomBusFactory: () => ({ start() {}, stop() {} } as any),
|
||||
});
|
||||
|
||||
let resolved = false;
|
||||
const p = orch.waitForReady().then(() => { resolved = true; });
|
||||
await new Promise((r) => setImmediate(r));
|
||||
assert.equal(resolved, false, 'ready 之前不应 resolve');
|
||||
|
||||
await orch.onLogin();
|
||||
await p;
|
||||
assert.equal(resolved, true);
|
||||
});
|
||||
|
||||
test('StartupOrchestrator: shutdown 停止 RoomRPCBus', async () => {
|
||||
const bus = new EventBus<NetClientEvents>();
|
||||
const session = new PlatformSession(bus);
|
||||
let stopped = false;
|
||||
const orch = new StartupOrchestrator({
|
||||
session,
|
||||
bus,
|
||||
getBootstrapResult: async () => bootstrapOf(),
|
||||
makeNetClient: () => ({}) as any,
|
||||
identity: IDENTITY,
|
||||
roomBusFactory: () => ({
|
||||
start() {},
|
||||
stop() { stopped = true; },
|
||||
} as any),
|
||||
});
|
||||
|
||||
await orch.bootstrap();
|
||||
await orch.onLogin();
|
||||
orch.shutdown();
|
||||
assert.equal(stopped, true);
|
||||
});
|
||||
|
||||
test('StartupOrchestrator: bootstrap 可独立调用(不依赖 onLogin)', async () => {
|
||||
const bus = new EventBus<NetClientEvents>();
|
||||
const session = new PlatformSession(bus);
|
||||
const orch = new StartupOrchestrator({
|
||||
session,
|
||||
bus,
|
||||
getBootstrapResult: async () => bootstrapOf(),
|
||||
makeNetClient: () => ({}) as any,
|
||||
identity: IDENTITY,
|
||||
roomBusFactory: () => ({ start() {}, stop() {} } as any),
|
||||
});
|
||||
|
||||
await orch.bootstrap();
|
||||
// waitForReady 还未 resolve(未调 onLogin)
|
||||
const ready = orch.waitForReady();
|
||||
let resolved = false;
|
||||
ready.then(() => { resolved = true; });
|
||||
await new Promise((r) => setImmediate(r));
|
||||
assert.equal(resolved, false, '仅 bootstrap 不足以触发 ready');
|
||||
});
|
||||
Reference in New Issue
Block a user