diff --git a/cocoscreator_projects/YouleNexus/assets/framework/platform/startup.ts b/cocoscreator_projects/YouleNexus/assets/framework/platform/startup.ts new file mode 100644 index 0000000..66519a3 --- /dev/null +++ b/cocoscreator_projects/YouleNexus/assets/framework/platform/startup.ts @@ -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; + /** 返回 bootstrap 结果(远程配置 + 身份 + 服务器列表) */ + getBootstrapResult: () => Promise; + /** 构造 NetClient 的工厂(注入避免硬依赖真 WS) */ + makeNetClient: () => { bus: EventBus }; + /** 用于 session 初始化的身份字段 */ + identity: ChannelIdentity; + /** 构造 RoomRPCBus 的工厂 */ + roomBusFactory: (session: PlatformSession, bus: EventBus) => RoomRPCBus; +} + +export class StartupOrchestrator { + bootstrapResult: BootstrapResult | null = null; + private readyResolve: (() => void) | null = null; + private readyPromise: Promise; + private roomBus: RoomRPCBus | null = null; + private netClient: { bus: EventBus } | null = null; + + constructor(private readonly opts: StartupOptions) { + // 立即构造一个 pending Promise,UI 层可提前 await + this.readyPromise = new Promise((resolve) => { + this.readyResolve = resolve; + }); + } + + /** 阶段 1:拉取并解析远程配置,失败显式抛错 */ + async bootstrap(): Promise { + 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 { + // 构造 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 { + return this.readyPromise; + } + + isReady(): boolean { return this.readyResolve === null; } + + /** 清理:停止 RoomRPCBus + 关闭 NetClient */ + shutdown(): void { + this.roomBus?.stop(); + this.roomBus = null; + this.netClient = null; + } +} \ No newline at end of file diff --git a/cocoscreator_projects/framework-tests/platform/startup.test.ts b/cocoscreator_projects/framework-tests/platform/startup.test.ts new file mode 100644 index 0000000..1c57888 --- /dev/null +++ b/cocoscreator_projects/framework-tests/platform/startup.test.ts @@ -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(); + 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(); + 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(); + 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(); + 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(); + 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'); +}); \ No newline at end of file