From 3a9c5d6fdbe5802d6f78466e22ead12e9bd05569 Mon Sep 17 00:00:00 2001 From: Joywayer Date: Sat, 5 Sep 2026 09:13:00 +0800 Subject: [PATCH] feat(platform): add single-entry platform runtime --- .../assets/framework/platform/ready-gate.ts | 25 + .../assets/framework/platform/runtime.ts | 504 ++++++++++++++ .../assets/framework/platform/scene-port.ts | 10 + .../architecture/import-boundaries.test.mjs | 31 + .../platform/ready-gate.test.ts | 36 + .../framework-tests/platform/runtime.test.ts | 615 ++++++++++++++++++ 6 files changed, 1221 insertions(+) create mode 100644 cocoscreator_projects/YouleNexus/assets/framework/platform/ready-gate.ts create mode 100644 cocoscreator_projects/YouleNexus/assets/framework/platform/runtime.ts create mode 100644 cocoscreator_projects/YouleNexus/assets/framework/platform/scene-port.ts create mode 100644 cocoscreator_projects/framework-tests/platform/ready-gate.test.ts create mode 100644 cocoscreator_projects/framework-tests/platform/runtime.test.ts diff --git a/cocoscreator_projects/YouleNexus/assets/framework/platform/ready-gate.ts b/cocoscreator_projects/YouleNexus/assets/framework/platform/ready-gate.ts new file mode 100644 index 0000000..b17933d --- /dev/null +++ b/cocoscreator_projects/YouleNexus/assets/framework/platform/ready-gate.ts @@ -0,0 +1,25 @@ +export type ReadyGateName = + | 'resources' + | 'config' + | 'socket' + | 'minimum-display'; + +const REQUIRED_GATES: readonly ReadyGateName[] = Object.freeze([ + 'resources', + 'config', + 'socket', + 'minimum-display', +]); + +/** One-way startup latch: readiness is reached only after all four signals arrive. */ +export class ReadyGate { + private readonly marked = new Set(); + + get ready(): boolean { + return REQUIRED_GATES.every((name) => this.marked.has(name)); + } + + mark(name: ReadyGateName): void { + this.marked.add(name); + } +} diff --git a/cocoscreator_projects/YouleNexus/assets/framework/platform/runtime.ts b/cocoscreator_projects/YouleNexus/assets/framework/platform/runtime.ts new file mode 100644 index 0000000..0e0f503 --- /dev/null +++ b/cocoscreator_projects/YouleNexus/assets/framework/platform/runtime.ts @@ -0,0 +1,504 @@ +import type { RuntimeConfig } from '../config/runtime-config.ts'; +import { LOGIN_GUARD_MS } from '../core/constants.ts'; +import type { OutboundEnvelope } from '../core/types/envelope.ts'; +import { realClock, type Clock } from '../net/heartbeat.ts'; +import type { WireClient, WireEvent } from '../net/wire-client.ts'; +import { + buildLoginRequest, + type LoginAccountIdentity, + type LoginDeviceSnapshot, +} from '../protocol/contracts/index.ts'; +import { PlatformHandlers } from '../protocol/platform-handlers.ts'; +import { Router } from '../protocol/router.ts'; +import type { GameEntry, GameHost } from '../sdk/contracts/index.ts'; +import { GameSessionHost } from '../sdk/runtime/game-session-host.ts'; +import { PlatformCommands, type JoinRoomCommand } from './commands.ts'; +import type { ConnectionIntent } from './connection-intent.ts'; +import { + createGameHostAdapter, + type GameHostLease, +} from './game-host-adapter.ts'; +import { RuntimeSession } from './runtime-session.ts'; +import type { ScenePort } from './scene-port.ts'; +import { PlatformStore } from './stores/platform-store.ts'; +import type { PlatformState } from './stores/platform-types.ts'; +import { ReadyGate, type ReadyGateName } from './ready-gate.ts'; + +export type PlatformWireClient = Pick< + WireClient, + 'subscribe' | 'start' | 'send' | 'switchServer' | 'reconnectCurrent' | 'stop' +>; + +export interface PlatformRuntimeOptions { + readonly gameEntry: GameEntry; + readonly resolveRuntimeConfig: () => Promise; + readonly createWireClient: (config: RuntimeConfig) => PlatformWireClient; + readonly scene: ScenePort; + readonly loadResources: () => Promise; + readonly waitForMinimumDisplay: () => Promise; + readonly getLoginDeviceSnapshot: () => LoginDeviceSnapshot; + readonly clock?: Clock; +} + +type RuntimeLifecycle = 'idle' | 'starting' | 'running' | 'kicked' | 'fatal' | 'stopped'; + +/** Keeps the scene transition between room activation and optional snapshot restore. */ +class SceneOrderedGameSessionHost extends GameSessionHost { + private roomSceneShown = false; + + constructor(entry: GameEntry, private readonly scene: ScenePort) { + super(entry); + } + + open(host: GameHost): void { + this.roomSceneShown = false; + super.open(host); + } + + restore(deskinfo: unknown): void { + this.showRoomAfterOpen(); + super.restore(deskinfo); + } + + showRoomAfterOpen(): void { + if (this.roomSceneShown) return; + if (this.state !== 'active') { + throw new Error('Cannot show the room without an active game session'); + } + this.roomSceneShown = true; + this.scene.showRoom(); + } + + close(): void { + try { + super.close(); + } finally { + this.roomSceneShown = false; + } + } + + dispose(): void { + try { + super.dispose(); + } finally { + this.roomSceneShown = false; + } + } +} + +class PlatformRuntimeCleanupError extends Error { + readonly errors: readonly unknown[]; + + constructor(operation: string, errors: readonly unknown[]) { + super(`PlatformRuntime cleanup failed during ${operation}`); + this.name = 'PlatformRuntimeCleanupError'; + this.errors = Object.freeze([...errors]); + } +} + +/** Owns the sole headless config, wire, router, store, and game-session composition. */ +export class PlatformRuntime { + private readonly store = new PlatformStore(); + private readonly gate = new ReadyGate(); + private readonly clock: Clock; + private lifecycle: RuntimeLifecycle = 'idle'; + private config: RuntimeConfig | null = null; + private wire: PlatformWireClient | null = null; + private unsubscribeWire: (() => void) | null = null; + private session: RuntimeSession | null = null; + private router: Router | null = null; + private gameSession: SceneOrderedGameSessionHost | null = null; + private commands: PlatformCommands | null = null; + private activeGameHostLease: GameHostLease | null = null; + private connectionIntent: ConnectionIntent = { type: 'none' }; + private loginEnvelope: OutboundEnvelope | null = null; + private loginGuard: unknown = null; + private wireIsOpen = false; + private loginSceneShown = false; + private fatalError: Error | null = null; + private wireStopped = false; + + constructor(private readonly options: PlatformRuntimeOptions) { + this.clock = options.clock ?? realClock; + } + + get state(): PlatformState { + return this.store.getState(); + } + + get ready(): boolean { + return this.gate.ready; + } + + async start(): Promise { + if (this.lifecycle !== 'idle') { + throw new Error(`PlatformRuntime cannot start while ${this.lifecycle}`); + } + this.lifecycle = 'starting'; + this.options.scene.showLoading(); + const resourcesReady = this.beginReadyTask('resources', this.options.loadResources); + const minimumDisplayReady = this.beginReadyTask( + 'minimum-display', + this.options.waitForMinimumDisplay, + ); + + try { + const config = await this.options.resolveRuntimeConfig(); + if (this.fatalError !== null) throw this.fatalError; + if (config.identity.gameid !== this.options.gameEntry.gameId) { + throw new Error( + 'RuntimeConfig identity.gameid must strictly equal GameEntry.gameId: ' + + `config=${String(config.identity.gameid)} (${typeof config.identity.gameid}), ` + + `entry=${String(this.options.gameEntry.gameId)} (${typeof this.options.gameEntry.gameId})`, + ); + } + this.config = config; + + const gameSession = new SceneOrderedGameSessionHost( + this.options.gameEntry, + this.options.scene, + ); + const send = Object.freeze({ + send: (envelope: OutboundEnvelope): void => { + this.requireOpenWire().send(envelope); + }, + }); + const commands = new PlatformCommands({ + runtimeConfig: config, + store: this.store, + send, + }); + const session = new RuntimeSession({ + store: this.store, + gameSession, + createGameHostLease: () => this.createGameHostLease(commands, send), + }); + const handlers = new PlatformHandlers({ + store: this.store, + gameSession, + session, + connection: { + switchServer: (target, resend) => { this.beginServerSwitch(target, resend); }, + stopForKick: (data) => { this.stopForKick(data); }, + }, + }); + const router = new Router({ session, platform: handlers, gameSession }); + const wire = this.options.createWireClient(config); + + this.gameSession = gameSession; + this.commands = commands; + this.session = session; + this.router = router; + this.wire = wire; + this.unsubscribeWire = wire.subscribe((event) => { this.onWireEvent(event); }); + this.markReady('config'); + this.lifecycle = 'running'; + wire.start(); + await Promise.all([resourcesReady, minimumDisplayReady]); + if (this.fatalError !== null) throw this.fatalError; + } catch (error) { + const fatal = this.showFatalOnce(error); + throw fatal; + } + } + + login(account: LoginAccountIdentity): void { + const config = this.requireConfig(); + const wire = this.requireOpenWire(); + const session = this.requireSession(); + const envelope = buildLoginRequest( + config, + account, + this.options.getLoginDeviceSnapshot(), + ); + + wire.send(envelope); + this.loginEnvelope = envelope; + this.connectionIntent = { type: 'login', envelope }; + session.markLoginPending(); + this.startLoginGuard(); + } + + joinRoom(command: JoinRoomCommand): void { + this.requireCommands().joinRoom(command); + } + + prepare(): void { + this.requireCommands().prepare(); + } + + exitRoom(): void { + this.requireCommands().exitRoom(); + } + + stop(): void { + if (this.lifecycle === 'stopped') return; + this.lifecycle = 'stopped'; + this.clearLoginGuard(); + this.connectionIntent = { type: 'none' }; + this.wireIsOpen = false; + + const errors: unknown[] = []; + const unsubscribe = this.unsubscribeWire; + this.unsubscribeWire = null; + if (unsubscribe !== null) this.captureCleanupError(errors, unsubscribe); + + const gameSession = this.gameSession; + if (gameSession !== null) { + this.captureCleanupError(errors, () => { gameSession.dispose(); }); + } + + const lease = this.activeGameHostLease; + this.activeGameHostLease = null; + if (lease !== null) { + this.captureCleanupError(errors, () => { lease.invalidate(); }); + } + + this.captureCleanupError(errors, () => { this.stopWireOnce(); }); + this.throwCleanupErrors('stop', errors); + } + + private beginReadyTask( + name: ReadyGateName, + task: () => Promise, + ): Promise { + return Promise.resolve() + .then(task) + .then( + () => { this.markReady(name); }, + (error: unknown) => { this.showFatalOnce(error); }, + ); + } + + private markReady(name: ReadyGateName): void { + if ( + this.lifecycle === 'fatal' + || this.lifecycle === 'kicked' + || this.lifecycle === 'stopped' + ) return; + this.gate.mark(name); + if (this.gate.ready && !this.loginSceneShown) { + this.loginSceneShown = true; + this.options.scene.showLogin(); + } + } + + private onWireEvent(event: WireEvent): void { + if ( + this.lifecycle === 'fatal' + || this.lifecycle === 'kicked' + || this.lifecycle === 'stopped' + ) return; + try { + switch (event.type) { + case 'open': + this.wireIsOpen = true; + this.markReady('socket'); + if (this.connectionIntent.type === 'switch') { + const loginEnvelope = this.loginEnvelope; + if (loginEnvelope === null) { + throw new Error('server switch requires a previously sent player_login envelope'); + } + this.requireOpenWire().send(this.connectionIntent.resend); + this.connectionIntent = { type: 'login', envelope: loginEnvelope }; + } else if (this.connectionIntent.type === 'login') { + this.sendLoginEnvelope(this.connectionIntent.envelope); + } + break; + case 'message': + if (event.message.route === 'agent' && event.message.rpc === 'player_login') { + this.clearLoginGuard(); + } + this.requireRouter().dispatch(event.message); + this.updateSceneAfterMessage(event.message.route, event.message.rpc); + break; + case 'slow': + case 'reconnecting': + this.wireIsOpen = false; + this.options.scene.showReconnect(); + break; + case 'close': + this.wireIsOpen = false; + break; + } + } catch (error) { + this.showFatalOnce(error); + } + } + + private sendLoginEnvelope(envelope: OutboundEnvelope): void { + const wire = this.requireOpenWire(); + const session = this.requireSession(); + wire.send(envelope); + session.markLoginPending(); + this.startLoginGuard(); + } + + private beginServerSwitch(target: string, resend: OutboundEnvelope): void { + if (this.loginEnvelope === null) { + throw new Error('server switch requires a previously sent player_login envelope'); + } + const wire = this.requireWire(); + this.clearLoginGuard(); + this.connectionIntent = { type: 'switch', target, resend }; + this.wireIsOpen = false; + wire.switchServer(target); + } + + private createGameHostLease( + commands: PlatformCommands, + send: { send(envelope: OutboundEnvelope): void }, + ): GameHostLease { + const lease = createGameHostAdapter({ + gameEntry: this.options.gameEntry, + store: this.store, + commands, + send, + }); + let invalidated = false; + const trackedLease: GameHostLease = Object.freeze({ + host: lease.host, + invalidate: (): void => { + if (invalidated) return; + invalidated = true; + try { + lease.invalidate(); + } finally { + if (this.activeGameHostLease === trackedLease) { + this.activeGameHostLease = null; + } + } + }, + }); + this.activeGameHostLease = trackedLease; + return trackedLease; + } + + private updateSceneAfterMessage(route: string, rpc: string): void { + if (route !== 'platform' && route !== 'agent' && route !== 'room') return; + if (rpc === 'player_login') { + if (this.store.getState().room.kind === 'outside') { + this.options.scene.showLobby(); + } else { + this.requireGameSession().showRoomAfterOpen(); + } + return; + } + if (rpc === 'self_join_room') { + this.requireGameSession().showRoomAfterOpen(); + return; + } + if (rpc === 'self_exit_room') this.options.scene.showLobby(); + } + + private stopForKick(data: unknown): void { + this.lifecycle = 'kicked'; + this.clearLoginGuard(); + this.connectionIntent = { type: 'none' }; + this.wireIsOpen = false; + const errors: unknown[] = []; + this.captureCleanupError(errors, () => { this.options.scene.showKicked(data); }); + this.captureCleanupError(errors, () => { this.stopWireOnce(); }); + this.throwCleanupErrors('kick', errors); + } + + private stopWireOnce(): void { + if (this.wireStopped) return; + const wire = this.wire; + if (wire === null) return; + this.wireStopped = true; + wire.stop(); + } + + private captureCleanupError(errors: unknown[], operation: () => void): void { + try { + operation(); + } catch (error) { + errors.push(error); + } + } + + private throwCleanupErrors(operation: string, errors: readonly unknown[]): void { + if (errors.length === 1) throw errors[0]; + if (errors.length > 1) throw new PlatformRuntimeCleanupError(operation, errors); + } + + private startLoginGuard(): void { + this.clearLoginGuard(); + let handle: unknown = null; + handle = this.clock.setTimeout(() => { + if (this.loginGuard !== handle) return; + this.loginGuard = null; + try { + this.requireWire().reconnectCurrent(); + } catch (error) { + this.showFatalOnce(error); + } + }, LOGIN_GUARD_MS); + this.loginGuard = handle; + } + + private clearLoginGuard(): void { + if (this.loginGuard === null) return; + this.clock.clearTimeout(this.loginGuard); + this.loginGuard = null; + } + + private requireConfig(): RuntimeConfig { + this.requireRunning(); + if (this.config === null) throw new Error('PlatformRuntime has no RuntimeConfig'); + return this.config; + } + + private requireOpenWire(): PlatformWireClient { + const wire = this.requireWire(); + if (!this.wireIsOpen) throw new Error('PlatformRuntime connection is not open'); + return wire; + } + + private requireWire(): PlatformWireClient { + this.requireRunning(); + if (this.wire === null) throw new Error('PlatformRuntime has no WireClient'); + return this.wire; + } + + private requireSession(): RuntimeSession { + this.requireRunning(); + if (this.session === null) throw new Error('PlatformRuntime has no RuntimeSession'); + return this.session; + } + + private requireRouter(): Router { + this.requireRunning(); + if (this.router === null) throw new Error('PlatformRuntime has no Router'); + return this.router; + } + + private requireGameSession(): SceneOrderedGameSessionHost { + this.requireRunning(); + if (this.gameSession === null) throw new Error('PlatformRuntime has no game session'); + return this.gameSession; + } + + private requireCommands(): PlatformCommands { + this.requireRunning(); + if (this.commands === null) throw new Error('PlatformRuntime has no platform commands'); + return this.commands; + } + + private requireRunning(): void { + if (this.lifecycle !== 'running') { + throw this.fatalError ?? new Error(`PlatformRuntime is ${this.lifecycle}`); + } + } + + private showFatalOnce(value: unknown): Error { + const error = value instanceof Error ? value : new Error(String(value)); + if (this.fatalError !== null) return this.fatalError; + this.fatalError = error; + this.lifecycle = 'fatal'; + this.clearLoginGuard(); + this.options.scene.showFatal(error); + return error; + } +} diff --git a/cocoscreator_projects/YouleNexus/assets/framework/platform/scene-port.ts b/cocoscreator_projects/YouleNexus/assets/framework/platform/scene-port.ts new file mode 100644 index 0000000..7e72276 --- /dev/null +++ b/cocoscreator_projects/YouleNexus/assets/framework/platform/scene-port.ts @@ -0,0 +1,10 @@ +/** Semantic scene intents; the headless runtime never imports Cocos. */ +export interface ScenePort { + showLoading(): void; + showLogin(): void; + showLobby(): void; + showRoom(): void; + showReconnect(): void; + showKicked(data: unknown): void; + showFatal(error: Error): void; +} diff --git a/cocoscreator_projects/framework-tests/architecture/import-boundaries.test.mjs b/cocoscreator_projects/framework-tests/architecture/import-boundaries.test.mjs index 1da578c..bfde9ae 100644 --- a/cocoscreator_projects/framework-tests/architecture/import-boundaries.test.mjs +++ b/cocoscreator_projects/framework-tests/architecture/import-boundaries.test.mjs @@ -220,6 +220,37 @@ test('compatibility graph applies the same isolation to a future platform/runtim assert.equal(found?.path.length, 3); }); +test('platform/runtime.ts cannot import any former startup owner directly', async () => { + const cases = [ + { file: 'framework/platform/startup.ts', specifier: './startup.ts' }, + { file: 'framework/net/net-client.ts', specifier: '../net/net-client.ts' }, + { file: 'framework/platform/session.ts', specifier: './session.ts' }, + { file: 'framework/platform/room-rpc-bus.ts', specifier: './room-rpc-bus.ts' }, + ]; + + for (const item of cases) { + const root = await createFixtureRoot(); + await writeFixture(root, item.file, 'export const legacy = true\n'); + await writeFixture( + root, + 'framework/platform/runtime.ts', + `import '${item.specifier}'\n`, + ); + + const found = findCompatibilityDependency({ + entry: join(root, 'framework/platform/runtime.ts'), + forbiddenFiles: [join(root, item.file)], + traversalRoot: join(root, 'framework'), + }); + assert.equal(found?.kind, 'forbidden', item.file); + assert.deepEqual( + found?.path.map((file) => file.replaceAll('\\', '/').split('/framework/')[1]), + ['platform/runtime.ts', item.file.slice('framework/'.length)], + item.file, + ); + } +}); + test('scanner rejects sdk contracts importing a framework platform path', async () => { const root = await createFixtureRoot(); await writeFixture(root, 'framework/sdk/contracts/bad.ts', "import '../platform/session.ts'\n"); diff --git a/cocoscreator_projects/framework-tests/platform/ready-gate.test.ts b/cocoscreator_projects/framework-tests/platform/ready-gate.test.ts new file mode 100644 index 0000000..bc054f1 --- /dev/null +++ b/cocoscreator_projects/framework-tests/platform/ready-gate.test.ts @@ -0,0 +1,36 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { + ReadyGate, + type ReadyGateName, +} from '../../YouleNexus/assets/framework/platform/ready-gate.ts'; + +const GATES = [ + 'resources', + 'config', + 'socket', + 'minimum-display', +] as const satisfies readonly ReadyGateName[]; + +for (let subset = 0; subset < 16; subset += 1) { + test(`ReadyGate is ${subset === 15 ? '' : 'not '}ready for subset ${subset.toString(2).padStart(4, '0')}`, () => { + const gate = new ReadyGate(); + + GATES.forEach((name, index) => { + if ((subset & (1 << index)) !== 0) gate.mark(name); + }); + + assert.equal(gate.ready, subset === 15); + }); +} + +test('ReadyGate duplicate marks are idempotent', () => { + const gate = new ReadyGate(); + + for (const name of GATES) { + gate.mark(name); + gate.mark(name); + } + + assert.equal(gate.ready, true); +}); diff --git a/cocoscreator_projects/framework-tests/platform/runtime.test.ts b/cocoscreator_projects/framework-tests/platform/runtime.test.ts new file mode 100644 index 0000000..2f90cce --- /dev/null +++ b/cocoscreator_projects/framework-tests/platform/runtime.test.ts @@ -0,0 +1,615 @@ +import { readFileSync } from 'node:fs'; +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import type { RuntimeConfig } from '../../YouleNexus/assets/framework/config/runtime-config.ts'; +import type { OutboundEnvelope } from '../../YouleNexus/assets/framework/core/types/envelope.ts'; +import type { Clock } from '../../YouleNexus/assets/framework/net/heartbeat.ts'; +import type { WireEvent } from '../../YouleNexus/assets/framework/net/wire-client.ts'; +import type { + GameEntry, + GameHost, + GameModule, + PlatformToGameEvent, +} from '../../YouleNexus/assets/framework/sdk/contracts/index.ts'; +import { + PlatformRuntime, + type PlatformWireClient, +} from '../../YouleNexus/assets/framework/platform/runtime.ts'; +import type { ScenePort } from '../../YouleNexus/assets/framework/platform/scene-port.ts'; + +function fixture(name: string): Record { + return JSON.parse( + readFileSync(new URL(`../fixtures/contracts/${name}`, import.meta.url), 'utf8'), + ).data; +} + +const ACCOUNT = Object.freeze({ + openid: 'o1', + nickname: '测试号', + avatar: 'http://a', + sex: 0, + province: '江西', + city: '南昌', + unionid: 'test_unionid', +}); + +const DEVICE = Object.freeze({ + ip: '127.0.0.1', + location: Object.freeze({ latitude: 1, longitude: 2 }), + machineid: 'machine-1', + machineroom: 'room-install-1', + deviceLogin: Object.freeze({ enabled: false as const }), + loginPlayerId: Object.freeze({ enabled: false as const }), +}); + +function runtimeConfig(gameid: string | number = '41'): RuntimeConfig { + return Object.freeze({ + mode: 'debug', + isDebugger: true, + identity: Object.freeze({ + agentid: 'veRa0qrBf', + channelid: 'FtJf073aa', + gameid, + marketid: 4, + version: 10000, + }), + servers: Object.freeze(['ws://agent']), + source: 'direct', + gameserver: null, + rawConfig: null, + }) as RuntimeConfig; +} + +class ManualClock implements Clock { + private nextHandle = 1; + readonly timers = new Map void; readonly ms: number }>(); + + setTimeout(fn: () => void, ms: number): unknown { + const handle = this.nextHandle; + this.nextHandle += 1; + this.timers.set(handle, { fn, ms }); + return handle; + } + + clearTimeout(handle: unknown): void { + this.timers.delete(handle as number); + } + + runOnlyTimer(): void { + assert.equal(this.timers.size, 1); + const [handle, timer] = [...this.timers.entries()][0]!; + this.timers.delete(handle); + timer.fn(); + } +} + +class RecordingWireClient implements PlatformWireClient { + readonly sent: OutboundEnvelope[] = []; + readonly switchCalls: string[] = []; + readonly lifecycle: string[] = []; + reconnectCalls = 0; + stopCalls = 0; + onAction: (name: string) => void = () => {}; + private readonly listeners = new Set<(event: WireEvent) => void>(); + + subscribe(listener: (event: WireEvent) => void): () => void { + this.lifecycle.push('subscribe'); + this.onAction('subscribe'); + this.listeners.add(listener); + return () => { + this.lifecycle.push('unsubscribe'); + this.onAction('unsubscribe'); + this.listeners.delete(listener); + }; + } + + start(): void { + this.lifecycle.push('start'); + this.onAction('start'); + } + + send(envelope: OutboundEnvelope): void { + this.sent.push(envelope); + } + + switchServer(target: string): void { + this.switchCalls.push(target); + } + + reconnectCurrent(): void { + this.reconnectCalls += 1; + } + + stop(): void { + this.stopCalls += 1; + this.lifecycle.push('stop'); + this.onAction('stop'); + } + + emit(event: WireEvent): void { + for (const listener of [...this.listeners]) listener(event); + } +} + +class RecordingScene implements ScenePort { + readonly calls: Array<{ readonly name: string; readonly value?: unknown }> = []; + onCall: (name: string, value?: unknown) => void = () => {}; + private record(name: string, value?: unknown): void { + this.calls.push(value === undefined ? { name } : { name, value }); + this.onCall(name, value); + } + showLoading(): void { this.record('loading'); } + showLogin(): void { this.record('login'); } + showLobby(): void { this.record('lobby'); } + showRoom(): void { this.record('room'); } + showReconnect(): void { this.record('reconnect'); } + showKicked(data: unknown): void { this.record('kicked', data); } + showFatal(error: Error): void { this.record('fatal', error); } +} + +function makeGameEntry(overrides: Partial = {}): GameEntry { + return { + key: 'fixture', + gameId: '41', + route: 'fixture-game', + resolveSeatCount: () => 4, + createModule: (): GameModule => ({ + attach() {}, + handlePlatformEvent() {}, + handleGameMessage() {}, + restore() {}, + dispose() {}, + }), + ...overrides, + }; +} + +function makeRuntime(config: RuntimeConfig = runtimeConfig(), entry = makeGameEntry()) { + const wire = new RecordingWireClient(); + const scene = new RecordingScene(); + const clock = new ManualClock(); + const factoryConfigs: RuntimeConfig[] = []; + const runtime = new PlatformRuntime({ + gameEntry: entry, + resolveRuntimeConfig: async () => config, + createWireClient(resolved) { + factoryConfigs.push(resolved); + return wire; + }, + scene, + loadResources: async () => {}, + waitForMinimumDisplay: async () => {}, + getLoginDeviceSnapshot: () => DEVICE, + clock, + }); + return { runtime, wire, scene, clock, factoryConfigs, config }; +} + +function makeLoggingEntry(log: string[], restored: unknown[], attached: GameHost[]): GameEntry { + let recording = false; + const entry = makeGameEntry({ + createModule: (): GameModule => { + let host: GameHost | null = null; + return { + attach(value) { + host = value; + if (!recording) return; + attached.push(value); + log.push(`attach:${value.getSnapshot().room.roomcode}`); + }, + handlePlatformEvent(event: PlatformToGameEvent) { + if (recording) log.push(`event:${event.type}`); + }, + handleGameMessage() {}, + restore(deskinfo) { + if (!recording) return; + assert.ok(host); + restored.push(deskinfo); + log.push('restore'); + }, + dispose() { + if (!recording) return; + assert.ok(host); + host.getSnapshot(); + log.push('dispose'); + }, + }; + }, + }); + Object.defineProperty(entry, 'beginRecording', { + value: () => { recording = true; }, + }); + return entry; +} + +function beginEntryRecording(entry: GameEntry): void { + (entry as GameEntry & { beginRecording(): void }).beginRecording(); +} + +test('start resolves config, subscribes before connect, and never logs in without an account', async () => { + const setup = makeRuntime(); + + await setup.runtime.start(); + setup.wire.emit({ type: 'open', server: 'ws://agent' }); + + assert.equal(setup.factoryConfigs.length, 1); + assert.equal(setup.factoryConfigs[0], setup.config); + assert.deepEqual(setup.wire.lifecycle, ['subscribe', 'start']); + assert.deepEqual(setup.wire.sent, []); + assert.equal(setup.runtime.ready, true); + assert.deepEqual(setup.scene.calls.map((call) => call.name), ['loading', 'login']); +}); + +test('login sends one exact request and an ordinary reopen resends the same data with a four-second guard', async () => { + const setup = makeRuntime(); + await setup.runtime.start(); + setup.wire.emit({ type: 'open', server: 'ws://agent' }); + + setup.runtime.login(ACCOUNT); + + assert.deepEqual(setup.wire.sent[0], { + app: 'youle', + route: 'agent', + rpc: 'player_login', + data: { + agentid: 'veRa0qrBf', gameid: '41', openid: 'o1', nickname: '测试号', + avatar: 'http://a', sex: 0, province: '江西', city: '南昌', + unionid: 'test_unionid', version: 10000, channelid: 'FtJf073aa', + marketid: 4, ip: '127.0.0.1', location: DEVICE.location, + machineid: 'machine-1', machineroom: 'room-install-1', + }, + }); + assert.deepEqual([...setup.clock.timers.values()].map((timer) => timer.ms), [4000]); + + setup.wire.emit({ + type: 'message', + message: { route: 'agent', rpc: 'player_login', data: fixture('player-login-success.json') }, + }); + assert.equal(setup.clock.timers.size, 0); + + setup.wire.emit({ type: 'open', server: 'ws://agent' }); + + assert.equal(setup.wire.sent.length, 2); + assert.equal(setup.wire.sent[1]?.rpc, 'player_login'); + assert.equal(setup.wire.sent[1]?.data, setup.wire.sent[0]?.data); + assert.deepEqual([...setup.clock.timers.values()].map((timer) => timer.ms), [4000]); + setup.clock.runOnlyTimer(); + assert.equal(setup.wire.reconnectCalls, 1); +}); + +for (const mismatch of [ + { label: 'different value', configGameId: '42', entryGameId: '41' }, + { label: 'different type', configGameId: '41', entryGameId: 41 }, +] as const) { + test(`gameid ${mismatch.label} fails fatally before WireClient creation`, async () => { + const setup = makeRuntime( + runtimeConfig(mismatch.configGameId), + makeGameEntry({ gameId: mismatch.entryGameId }), + ); + + await assert.rejects(() => setup.runtime.start(), /gameid/i); + await assert.rejects(() => setup.runtime.start(), /fatal/i); + + assert.equal(setup.factoryConfigs.length, 0); + assert.deepEqual(setup.wire.lifecycle, []); + const fatalCalls = setup.scene.calls.filter((call) => call.name === 'fatal'); + assert.equal(fatalCalls.length, 1); + assert.ok(fatalCalls[0]?.value instanceof Error); + }); +} + +for (const switchCase of [ + { + label: 'room', + route: 'room', + rpc: 'connect_roomserver', + target: 'ws://room.example:3088', + data: { roomserver: 'ws://room.example:3088', epoch: 7, nested: { trace: 'r' } }, + }, + { + label: 'agent', + route: 'agent', + rpc: 'connect_agentserver', + target: 'wss://agent.example/socket', + data: { + agentserver: 'wss://agent.example/socket', + opt: 'free_room', + nested: { trace: 'a' }, + }, + }, +] as const) { + test(`${switchCase.label} switch resends the exact switch envelope before returning to steady login`, async () => { + const setup = makeRuntime(); + await setup.runtime.start(); + setup.wire.emit({ type: 'open', server: 'ws://agent' }); + setup.runtime.login(ACCOUNT); + const loginEnvelope = setup.wire.sent[0]!; + setup.wire.emit({ + type: 'message', + message: { route: 'agent', rpc: 'player_login', data: fixture('player-login-success.json') }, + }); + setup.wire.sent.length = 0; + + setup.wire.emit({ + type: 'message', + message: { route: switchCase.route, rpc: switchCase.rpc, data: switchCase.data }, + }); + + assert.deepEqual(setup.wire.switchCalls, [switchCase.target]); + assert.equal(setup.wire.sent.length, 0); + + setup.wire.emit({ type: 'open', server: switchCase.target }); + + assert.equal(setup.wire.sent.length, 1); + assert.equal(setup.wire.sent[0]?.rpc, switchCase.rpc); + assert.deepEqual(setup.wire.sent[0], { + app: 'youle', + route: switchCase.route, + rpc: switchCase.rpc, + data: switchCase.data, + }); + assert.equal(setup.wire.sent[0]?.data, switchCase.data); + assert.equal(setup.clock.timers.size, 0, 'switch resend must not start the login guard'); + + setup.wire.emit({ type: 'open', server: switchCase.target }); + + assert.equal(setup.wire.sent.length, 2); + assert.equal(setup.wire.sent[1]?.rpc, 'player_login'); + assert.equal(setup.wire.sent[1]?.data, loginEnvelope.data); + assert.deepEqual([...setup.clock.timers.values()].map((timer) => timer.ms), [4000]); + }); +} + +test('successful login without a room shows the lobby', async () => { + const setup = makeRuntime(); + await setup.runtime.start(); + setup.wire.emit({ type: 'open', server: 'ws://agent' }); + setup.runtime.login(ACCOUNT); + + setup.wire.emit({ + type: 'message', + message: { route: 'agent', rpc: 'player_login', data: fixture('player-login-success.json') }, + }); + + assert.equal(setup.runtime.state.room.kind, 'outside'); + assert.equal(setup.scene.calls[setup.scene.calls.length - 1]?.name, 'lobby'); +}); + +test('room login commits and opens before showRoom, then restores the exact deskinfo', async () => { + const log: string[] = []; + const restored: unknown[] = []; + const attached: GameHost[] = []; + const entry = makeLoggingEntry(log, restored, attached); + const setup = makeRuntime(runtimeConfig(), entry); + setup.scene.onCall = (name) => { if (name === 'room') log.push('scene:room'); }; + await setup.runtime.start(); + beginEntryRecording(entry); + setup.wire.emit({ type: 'open', server: 'ws://agent' }); + setup.runtime.login(ACCOUNT); + const roomLogin = fixture('player-login-room.json'); + + setup.wire.emit({ + type: 'message', + message: { route: 'agent', rpc: 'player_login', data: roomLogin }, + }); + + assert.equal(setup.runtime.state.room.kind, 'inside'); + assert.deepEqual(log, [ + 'attach:100001', + 'event:room.entered', + 'scene:room', + 'restore', + ]); + assert.deepEqual(restored, [roomLogin.deskinfo]); + assert.equal(restored[0], roomLogin.deskinfo); + assert.equal(attached.length, 1); +}); + +for (const withDeskinfo of [false, true]) { + test(`self join opens and shows the room ${withDeskinfo ? 'before restoring deskinfo' : 'without restoring absent deskinfo'}`, async () => { + const log: string[] = []; + const restored: unknown[] = []; + const attached: GameHost[] = []; + const entry = makeLoggingEntry(log, restored, attached); + const setup = makeRuntime(runtimeConfig(), entry); + setup.scene.onCall = (name) => { if (name === 'room') log.push('scene:room'); }; + await setup.runtime.start(); + beginEntryRecording(entry); + setup.wire.emit({ type: 'open', server: 'ws://agent' }); + setup.runtime.login(ACCOUNT); + setup.wire.emit({ + type: 'message', + message: { route: 'agent', rpc: 'player_login', data: fixture('player-login-success.json') }, + }); + log.length = 0; + const join = fixture('self-join-room.json'); + const deskinfo = join.deskinfo; + if (!withDeskinfo) delete join.deskinfo; + + setup.wire.emit({ + type: 'message', + message: { route: 'agent', rpc: 'self_join_room', data: join }, + }); + + assert.deepEqual(log, withDeskinfo + ? ['attach:100001', 'event:room.entered', 'scene:room', 'restore'] + : ['attach:100001', 'event:room.entered', 'scene:room']); + assert.deepEqual(restored, withDeskinfo ? [deskinfo] : []); + if (withDeskinfo) assert.equal(restored[0], deskinfo); + }); +} + +test('slow and reconnecting show reconnect without clearing or replacing room state', async () => { + const setup = makeRuntime(); + await setup.runtime.start(); + setup.wire.emit({ type: 'open', server: 'ws://agent' }); + setup.runtime.login(ACCOUNT); + setup.wire.emit({ + type: 'message', + message: { route: 'agent', rpc: 'player_login', data: fixture('player-login-room.json') }, + }); + const roomState = setup.runtime.state; + const callsBefore = setup.scene.calls.length; + + setup.wire.emit({ type: 'slow' }); + setup.wire.emit({ type: 'reconnecting', server: 'ws://agent' }); + + assert.equal(setup.runtime.state, roomState); + assert.equal(setup.runtime.state.room.kind, 'inside'); + assert.deepEqual( + setup.scene.calls.slice(callsBefore).map((call) => call.name), + ['reconnect', 'reconnect'], + ); +}); + +test('kick closes the game before showing kicked and stopping the wire', async () => { + const log: string[] = []; + const restored: unknown[] = []; + const attached: GameHost[] = []; + const entry = makeLoggingEntry(log, restored, attached); + const setup = makeRuntime(runtimeConfig(), entry); + setup.scene.onCall = (name) => { if (name === 'kicked') log.push('scene:kicked'); }; + setup.wire.onAction = (name) => { if (name === 'stop') log.push('wire:stop'); }; + await setup.runtime.start(); + beginEntryRecording(entry); + setup.wire.emit({ type: 'open', server: 'ws://agent' }); + setup.runtime.login(ACCOUNT); + setup.wire.emit({ + type: 'message', + message: { route: 'agent', rpc: 'player_login', data: fixture('player-login-room.json') }, + }); + log.length = 0; + const kick = { msg: '检查到新版本', code: 1 }; + + setup.wire.emit({ + type: 'message', + message: { route: 'agent', rpc: 'kick_server', data: kick }, + }); + + assert.deepEqual(log, ['dispose', 'scene:kicked', 'wire:stop']); + assert.equal(setup.wire.stopCalls, 1); + assert.equal(setup.scene.calls[setup.scene.calls.length - 1]?.value, kick); + + const sceneCalls = setup.scene.calls.length; + setup.wire.emit({ type: 'reconnecting', server: 'ws://agent' }); + setup.wire.emit({ type: 'open', server: 'ws://agent' }); + assert.equal(setup.scene.calls.length, sceneCalls, 'late wire events stay silent after kick'); + assert.equal(setup.wire.sent.length, 1, 'late open never revives login after kick'); +}); + +test('a game rpc named like a platform rpc cannot drive platform scenes', async () => { + const setup = makeRuntime(); + await setup.runtime.start(); + setup.wire.emit({ type: 'open', server: 'ws://agent' }); + setup.runtime.login(ACCOUNT); + setup.wire.emit({ + type: 'message', + message: { route: 'agent', rpc: 'player_login', data: fixture('player-login-room.json') }, + }); + const sceneCalls = setup.scene.calls.length; + + setup.wire.emit({ + type: 'message', + message: { route: 'fixture-game', rpc: 'self_exit_room', data: { reason: 'game-owned' } }, + }); + + assert.equal(setup.runtime.state.room.kind, 'inside'); + assert.equal(setup.scene.calls.length, sceneCalls); +}); + +test('joinRoom, prepare, and exitRoom delegate through the authoritative platform commands', async () => { + const setup = makeRuntime(); + await setup.runtime.start(); + setup.wire.emit({ type: 'open', server: 'ws://agent' }); + setup.runtime.login(ACCOUNT); + setup.wire.emit({ + type: 'message', + message: { route: 'agent', rpc: 'player_login', data: fixture('player-login-success.json') }, + }); + setup.wire.sent.length = 0; + const location = { latitude: 28.7, longitude: 115.8 }; + + setup.runtime.joinRoom({ + roomcode: '100001', + location, + ip: '203.0.113.7', + vipMatch: 1, + match_id: 'match-9', + }); + + assert.deepEqual(setup.wire.sent, [{ + app: 'youle', + route: 'agent', + rpc: 'self_join_room', + data: { + agentid: 'veRa0qrBf', + playerid: 430511, + gameid: '41', + roomcode: '100001', + location, + ip: '203.0.113.7', + vipMatch: 1, + match_id: 'match-9', + }, + }]); + assert.equal((setup.wire.sent[0]!.data as { location: unknown }).location, location); + + const joinedRoom = fixture('self-join-room.json'); + delete joinedRoom.deskinfo; + joinedRoom.deskwar = 0; + setup.wire.emit({ + type: 'message', + message: { route: 'agent', rpc: 'self_join_room', data: joinedRoom }, + }); + setup.wire.sent.length = 0; + setup.runtime.prepare(); + setup.runtime.exitRoom(); + + assert.deepEqual(setup.wire.sent, [ + { + app: 'youle', + route: 'room', + rpc: 'player_prepare', + data: { + agentid: 'veRa0qrBf', playerid: 430511, gameid: '41', roomcode: '100001', + }, + }, + { + app: 'youle', + route: 'room', + rpc: 'self_exit_room', + data: { + agentid: 'veRa0qrBf', playerid: 430511, gameid: '41', roomcode: '100001', + }, + }, + ]); +}); + +test('stop unsubscribes, disposes while the host is live, invalidates it, then stops the wire', async () => { + const log: string[] = []; + const restored: unknown[] = []; + const attached: GameHost[] = []; + const entry = makeLoggingEntry(log, restored, attached); + const setup = makeRuntime(runtimeConfig(), entry); + setup.wire.onAction = (name) => { + if (name === 'unsubscribe' || name === 'stop') log.push(`wire:${name}`); + }; + await setup.runtime.start(); + beginEntryRecording(entry); + setup.wire.emit({ type: 'open', server: 'ws://agent' }); + setup.runtime.login(ACCOUNT); + setup.wire.emit({ + type: 'message', + message: { route: 'agent', rpc: 'player_login', data: fixture('player-login-room.json') }, + }); + const activeHost = attached[0]!; + log.length = 0; + + setup.runtime.stop(); + + assert.deepEqual(log, ['wire:unsubscribe', 'dispose', 'wire:stop']); + assert.throws(() => activeHost.getSnapshot(), /disposed/i); + setup.runtime.stop(); + assert.deepEqual(log, ['wire:unsubscribe', 'dispose', 'wire:stop']); + assert.equal(setup.wire.stopCalls, 1); +});