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 { Router } from '../../YouleNexus/assets/framework/protocol/router.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'; import { PlatformStore } from '../../YouleNexus/assets/framework/platform/stores/platform-store.ts'; import { PlatformUiController } from '../../YouleNexus/assets/framework/presentation/platform-ui-controller.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; } interface Deferred { readonly promise: Promise; resolve(value: T): void; reject(error: unknown): void; } function deferred(): Deferred { let resolvePromise!: (value: T) => void; let rejectPromise!: (error: unknown) => void; const promise = new Promise((resolve, reject) => { resolvePromise = resolve; rejectPromise = reject; }); return { promise, resolve: resolvePromise, reject: rejectPromise }; } function rejectedValue(promise: Promise): Promise { return promise.then( () => { throw new Error('Expected promise to reject'); }, (error: unknown) => error, ); } function thrownValue(operation: () => void): unknown { let didThrow = false; let thrown: unknown; try { operation(); } catch (error) { didThrow = true; thrown = error; } assert.equal(didThrow, true, 'Expected operation to throw'); return thrown; } 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 ControlledClearClock extends ManualClock { private failureEnabled = false; private failure: unknown; onFailingClear: () => void = () => {}; enableClearFailure(error: unknown): void { this.failure = error; this.failureEnabled = true; } clearTimeout(handle: unknown): void { super.clearTimeout(handle); if (!this.failureEnabled) return; this.onFailingClear(); throw this.failure; } } 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 SynchronousSubscribeWireClient implements PlatformWireClient { readonly sent: OutboundEnvelope[] = []; readonly switchCalls: string[] = []; startCalls = 0; stopCalls = 0; unsubscribeCalls = 0; private listener: ((event: WireEvent) => void) | null = null; constructor( private readonly subscribeEvent: WireEvent, private readonly unsubscribeFailure?: { readonly value: unknown }, ) {} subscribe(listener: (event: WireEvent) => void): () => void { this.listener = listener; listener(this.subscribeEvent); return () => { this.unsubscribeCalls += 1; this.listener = null; if (this.unsubscribeFailure !== undefined) throw this.unsubscribeFailure.value; }; } start(): void { this.startCalls += 1; } send(envelope: OutboundEnvelope): void { this.sent.push(envelope); } switchServer(target: string): void { this.switchCalls.push(target); } reconnectCurrent(): void {} stop(): void { this.stopCalls += 1; } emit(event: WireEvent): void { this.listener?.(event); } get listenerAttached(): boolean { return this.listener !== null; } } class InvalidSubscribeWireClient extends RecordingWireClient { constructor(private readonly invalidUnsubscribe: unknown) { super(); } override subscribe(_listener: (event: WireEvent) => void): () => void { this.lifecycle.push('subscribe'); this.onAction('subscribe'); return this.invalidUnsubscribe as () => void; } } 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); } showServerDenial(outcome: unknown): void { this.record('denied', outcome); } 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 }; } test('UI observes canonical commits and unsubscribe is idempotent', async () => { const { runtime, wire } = makeRuntime(); const seen: unknown[] = []; const off = runtime.subscribeState( state => { seen.push(state); }, error => { throw error; }, ); assert.equal(seen.length, 0); await runtime.start(); wire.emit({ type: 'open', server: 'ws://agent' }); assert.equal(seen[seen.length - 1], runtime.state); off(); off(); const count = seen.length; runtime.login(ACCOUNT); wire.emit({ type: 'message', message: { route: 'agent', rpc: 'player_login', data: fixture('player-login-success.json'), } }); assert.equal(seen.length, count); runtime.stop(); assert.throws(() => runtime.subscribeState(() => {}, () => {}), /stopped/); }); test('a failing UI observer reports its original error once while other observers continue', async () => { const { runtime, wire } = makeRuntime(); const failure = { source: 'render' }; const reported: unknown[] = []; let failingCalls = 0; const healthyStates: unknown[] = []; runtime.subscribeState( () => { failingCalls += 1; throw failure; }, error => { reported.push(error); }, ); runtime.subscribeState( state => { healthyStates.push(state); }, error => { throw error; }, ); await runtime.start(); wire.emit({ type: 'open', server: 'ws://agent' }); assert.equal(failingCalls, 1); assert.deepEqual(reported, [failure]); assert.equal(reported[0], failure); assert.equal(healthyStates[0], runtime.state); runtime.login(ACCOUNT); wire.emit({ type: 'message', message: { route: 'agent', rpc: 'player_login', data: fixture('player-login-success.json'), } }); assert.equal(failingCalls, 1); assert.equal(healthyStates[healthyStates.length - 1], runtime.state); runtime.stop(); }); test('an onError failure enters fatal with both UI error identities and cancels observers', async () => { const { runtime, wire, scene } = makeRuntime(); const listenerFailure = { source: 'listener' }; const onErrorFailure = { source: 'onError' }; let laterObserverCalls = 0; runtime.subscribeState( () => { throw listenerFailure; }, () => { throw onErrorFailure; }, ); runtime.subscribeState( () => { laterObserverCalls += 1; }, error => { throw error; }, ); await runtime.start(); wire.emit({ type: 'open', server: 'ws://agent' }); assert.equal(laterObserverCalls, 0); assert.equal(wire.stopCalls, 1); const fatal = scene.calls.find(call => call.name === 'fatal')?.value; assert.ok(fatal instanceof Error); const errors = (fatal as Error & { readonly errors: readonly unknown[] }).errors; assert.equal(errors.length, 2); assert.equal(errors[0], listenerFailure); assert.equal(errors[1], onErrorFailure); assert.throws(() => runtime.subscribeState(() => {}, () => {}), /fatal/); }); for (const terminal of ['stop', 'fatal', 'kick'] as const) { test(`${terminal} invalidates a late Store delivery and rejects new UI observers`, async () => { const originalSubscribe = PlatformStore.prototype.subscribe; let retainedListener: Parameters[0] | null = null; PlatformStore.prototype.subscribe = function retainFirstListener(listener) { retainedListener ??= listener; return originalSubscribe.call(this, listener); }; try { const setup = makeRuntime(); let calls = 0; setup.runtime.subscribeState( () => { calls += 1; }, error => { throw error; }, ); await setup.runtime.start(); setup.wire.emit({ type: 'open', server: 'ws://agent' }); const callsBeforeTermination = calls; setup.wire.onAction = (name) => { if (name !== 'stop') return; const listener = retainedListener; assert.ok(listener); listener(setup.runtime.state, setup.runtime.state); }; if (terminal === 'stop') { setup.runtime.stop(); } else if (terminal === 'fatal') { setup.scene.onCall = (name) => { if (name === 'reconnect') throw new Error('reconnect render failed'); }; setup.wire.emit({ type: 'slow' }); } else { setup.runtime.login(ACCOUNT); setup.wire.emit({ type: 'message', message: { route: 'agent', rpc: 'kick_server', data: { code: 1, msg: 'terminal' }, } }); } assert.equal( calls, callsBeforeTermination + (terminal === 'fatal' ? 1 : 0), 'observers must be inactive before terminal cleanup callbacks', ); const callsAtTermination = calls; const lateListener = retainedListener as Parameters[0] | null; assert.ok(lateListener); lateListener(setup.runtime.state, setup.runtime.state); assert.equal(calls, callsAtTermination); assert.throws( () => setup.runtime.subscribeState(() => {}, () => {}), new RegExp(terminal === 'kick' ? 'kicked' : terminal), ); } finally { PlatformStore.prototype.subscribe = originalSubscribe; } }); } test('stop during a Store commit cancels later observers and prevents scene revival', async () => { const setup = makeRuntime(); await setup.runtime.start(); setup.wire.emit({ type: 'open', server: 'ws://agent' }); let laterObserverCalls = 0; let resubscribeFailure: unknown; setup.runtime.subscribeState( () => { setup.runtime.stop(); resubscribeFailure = thrownValue( () => setup.runtime.subscribeState(() => {}, () => {}), ); }, error => { throw error; }, ); setup.runtime.subscribeState( () => { laterObserverCalls += 1; }, error => { throw error; }, ); const sceneCallsBefore = setup.scene.calls.length; setup.wire.emit({ type: 'slow' }); assert.equal(laterObserverCalls, 0); assert.match(String(resubscribeFailure), /stopped/); assert.equal(setup.scene.calls.length, sceneCallsBefore); assert.equal(setup.wire.stopCalls, 1); }); test('listener and onError failures after stopping escape the Store boundary without reviving fatal', async () => { const setup = makeRuntime(); await setup.runtime.start(); setup.wire.emit({ type: 'open', server: 'ws://agent' }); const listenerFailure = { source: 'listener-after-stop' }; const onErrorFailure = { source: 'onError-after-stop' }; const reported: unknown[] = []; setup.runtime.subscribeState( () => { setup.runtime.stop(); throw listenerFailure; }, error => { reported.push(error); setup.runtime.stop(); throw onErrorFailure; }, ); const sceneCallsBefore = setup.scene.calls.length; const thrown = thrownValue(() => { setup.wire.emit({ type: 'slow' }); }); assert.deepEqual(reported, [listenerFailure]); assert.ok(thrown instanceof Error); const errors = (thrown as Error & { readonly errors: readonly unknown[] }).errors; assert.equal(errors.length, 2); assert.equal(errors[0], listenerFailure); assert.equal(errors[1], onErrorFailure); assert.equal(setup.scene.calls.length, sceneCallsBefore); assert.equal(setup.scene.calls.filter(call => call.name === 'fatal').length, 0); assert.equal(setup.wire.stopCalls, 1); assert.throws(() => setup.runtime.subscribeState(() => {}, () => {}), /stopped/); }); test('stopped observer errors survive a later active-room publish failure', async () => { const setup = makeRuntime(); await setup.runtime.start(); setup.wire.emit({ type: 'open', server: 'ws://agent' }); setup.runtime.login(ACCOUNT); const roomLogin = fixture('player-login-room.json'); (roomLogin.players as unknown[])[2] = { playerid: 430512, nickname: '同桌玩家', avatar: 'http://b', sex: 0, bean: 10, isprepare: 0, onstate: 0, }; setup.wire.emit({ type: 'message', message: { route: 'agent', rpc: 'player_login', data: roomLogin, } }); const listenerFailure = { source: 'room-listener-after-stop' }; const onErrorFailure = { source: 'room-onError-after-stop' }; setup.runtime.subscribeState( () => { setup.runtime.stop(); throw listenerFailure; }, () => { throw onErrorFailure; }, ); const thrown = thrownValue(() => { setup.wire.emit({ type: 'message', message: { route: 'room', rpc: 'player_prepare', data: { seat: 2 }, } }); }); assert.ok(thrown instanceof Error); const errors = (thrown as Error & { readonly errors: readonly unknown[] }).errors; assert.equal(errors.length, 2); const observerFailure = errors[0]; assert.ok(observerFailure instanceof Error); const observerErrors = ( observerFailure as Error & { readonly errors: readonly unknown[] } ).errors; assert.equal(observerErrors[0], listenerFailure); assert.equal(observerErrors[1], onErrorFailure); assert.match(String(errors[1]), /disposed game session/i); assert.equal(setup.scene.calls.filter(call => call.name === 'fatal').length, 0); assert.throws(() => setup.runtime.subscribeState(() => {}, () => {}), /stopped/); }); test('UI observers receive each committed snapshot even when a listener commits reentrantly', async () => { const setup = makeRuntime(); const delivered: unknown[] = []; let firstCommit: unknown; let latestDuringFirstCommit: unknown; let reentered = false; setup.runtime.subscribeState( state => { if (reentered) return; reentered = true; firstCommit = state; setup.wire.emit({ type: 'slow' }); latestDuringFirstCommit = setup.runtime.state; }, error => { throw error; }, ); setup.runtime.subscribeState( state => { delivered.push(state); }, error => { throw error; }, ); await setup.runtime.start(); setup.wire.emit({ type: 'open', server: 'ws://agent' }); assert.notEqual(firstCommit, latestDuringFirstCommit); assert.equal(delivered[0], firstCommit); assert.equal(delivered[1], latestDuringFirstCommit); assert.deepEqual( delivered.map(state => (state as { app: { phase: string } }).app.phase), ['connected', 'slow'], ); setup.runtime.stop(); }); test('cancelling and subscribing observers during a commit cannot alter that delivery snapshot', async () => { const setup = makeRuntime(); let cancelledCalls = 0; const replacementPhases: string[] = []; let changedObservers = false; let offCancelled = (): void => {}; setup.runtime.subscribeState( () => { if (changedObservers) return; changedObservers = true; offCancelled(); setup.runtime.subscribeState( state => { replacementPhases.push(state.app.phase); }, error => { throw error; }, ); }, error => { throw error; }, ); offCancelled = setup.runtime.subscribeState( () => { cancelledCalls += 1; }, error => { throw error; }, ); await setup.runtime.start(); setup.wire.emit({ type: 'open', server: 'ws://agent' }); assert.equal(cancelledCalls, 0); assert.deepEqual(replacementPhases, []); setup.wire.emit({ type: 'slow' }); assert.equal(cancelledCalls, 0); assert.deepEqual(replacementPhases, ['slow']); setup.runtime.stop(); }); test('a denied join retains the lobby root, reports the raw outcome and permits a valid retry', async () => { const { runtime, wire, scene } = makeRuntime(); await runtime.start(); wire.emit({ type: 'open', server: 'ws://agent' }); runtime.login(ACCOUNT); wire.emit({ type: 'message', message: { route: 'agent', rpc: 'player_login', data: fixture('player-login-success.json'), } }); const lobby = runtime.state; const denial = { state: 99, roomcode: '999999', showerror: 1, error: 'Room does not exist' }; runtime.joinRoom({ roomcode: '999999', location: null, ip: '127.0.0.1' }); wire.emit({ type: 'message', message: { route: 'agent', rpc: 'self_join_room', data: denial } }); assert.equal(runtime.state, lobby); assert.equal(wire.stopCalls, 0); assert.deepEqual(scene.calls.filter(call => call.name === 'fatal'), []); const outcome = scene.calls.find(call => call.name === 'denied')?.value; assert.deepEqual(outcome, { rpc: 'self_join_room', state: 99, data: denial }); assert.equal((outcome as { data: unknown }).data, denial); runtime.joinRoom({ roomcode: '100001', location: null, ip: '127.0.0.1' }); wire.emit({ type: 'message', message: { route: 'agent', rpc: 'self_join_room', data: fixture('self-join-room.json'), } }); assert.equal(runtime.state.room.kind, 'inside'); assert.equal(scene.calls[scene.calls.length - 1]?.name, 'room'); runtime.stop(); }); test('runtime resolves seats only from the actual game-owned server roomtype at room entry', async () => { const roomtypes: Array = []; const attached: GameHost[] = []; const setup = makeRuntime(runtimeConfig(), makeGameEntry({ resolveSeatCount(roomtype) { roomtypes.push(roomtype); if (roomtype.length !== 7 || roomtype[1] !== 4 || !Array.isArray(roomtype[5])) { throw new Error('Game roomtype requires the full nested server configuration'); } return 4; }, createModule: () => ({ attach(host) { attached.push(host); }, handlePlatformEvent() {}, handleGameMessage() {}, restore() {}, dispose() {}, }), })); await setup.runtime.start(); assert.deepEqual(roomtypes, []); setup.wire.emit({ type: 'open', server: 'ws://agent' }); setup.wire.emit({ type: 'message', message: { route: 'agent', rpc: 'player_login', data: fixture('player-login-room.json'), } }); assert.equal(roomtypes.length, 1); assert.equal(attached.length, 1); assert.equal(roomtypes[0], attached[0]!.getSnapshot().room.roomtype); assert.equal(attached[0]!.seat.toView(1), 0); assert.deepEqual(setup.scene.calls.filter(call => call.name === 'fatal'), []); setup.runtime.stop(); }); for (const count of [0, 2.5, 2]) { test(`runtime rejects invalid game count ${count} at the real room boundary`, async () => { let attaches = 0; const setup = makeRuntime(runtimeConfig(), makeGameEntry({ resolveSeatCount: () => count, createModule: () => ({ attach() { attaches += 1; }, handlePlatformEvent() {}, handleGameMessage() {}, restore() {}, dispose() {}, }), })); await setup.runtime.start(); setup.wire.emit({ type: 'open', server: 'ws://agent' }); setup.wire.emit({ type: 'message', message: { route: 'agent', rpc: 'player_login', data: fixture('player-login-room.json'), } }); assert.equal(attaches, 0); assert.equal(setup.wire.stopCalls, 1); const failure = setup.scene.calls.find(call => call.name === 'fatal')?.value; assert.match(String((failure as { errors: unknown[] }).errors[0]), /seat count/); setup.runtime.stop(); }); } test('a nonzero login outcome clears its guard, retains state and permits account retry', async () => { const { runtime, wire, scene, clock } = makeRuntime(); await runtime.start(); wire.emit({ type: 'open', server: 'ws://agent' }); const before = runtime.state; runtime.login(ACCOUNT); const denial = { state: 7, roomcode: 'unavailable', showerror: 1, error: 'Login denied' }; wire.emit({ type: 'message', message: { route: 'agent', rpc: 'player_login', data: denial } }); assert.equal(runtime.state, before); assert.equal(wire.stopCalls, 0); assert.equal(clock.timers.size, 0); const outcome = scene.calls.find(call => call.name === 'denied')?.value; assert.deepEqual(outcome, { rpc: 'player_login', state: 7, data: denial }); assert.equal((outcome as { data: unknown }).data, denial); assert.deepEqual(scene.calls.filter(call => call.name === 'fatal'), []); runtime.login({ ...ACCOUNT, openid: 'retry-account' }); assert.equal((wire.sent[wire.sent.length - 1]!.data as Record).openid, 'retry-account'); wire.emit({ type: 'message', message: { route: 'agent', rpc: 'player_login', data: fixture('player-login-success.json'), } }); assert.equal(runtime.state.app.phase, 'logged-in'); runtime.stop(); }); for (const interruption of ['attach', 'restore', 'room-scene', 'room-event'] as const) { test(`stop from ${interruption} releases the actual module and host without reviving runtime`, async () => { let runtime: PlatformRuntime; let attachedHost: GameHost | undefined; let moduleNumber = 0; const disposed: number[] = []; const events: string[] = []; const entry = makeGameEntry({ createModule() { const id = moduleNumber++; return { attach(host) { attachedHost = host; host.subscribe(() => { events.push('snapshot'); }); if (interruption === 'attach') runtime.stop(); }, handlePlatformEvent() { events.push('room-event'); if (interruption === 'room-event') runtime.stop(); }, handleGameMessage() { events.push('game-message'); }, restore() { events.push('restore'); if (interruption === 'restore') runtime.stop(); }, dispose() { disposed.push(id); }, }; }, }); const setup = makeRuntime(runtimeConfig(), entry); runtime = setup.runtime; setup.scene.onCall = name => { if (name === 'room' && interruption === 'room-scene') runtime.stop(); }; await runtime.start(); setup.wire.emit({ type: 'open', server: 'ws://agent' }); runtime.login(ACCOUNT); setup.wire.emit({ type: 'message', message: { route: 'agent', rpc: 'player_login', data: fixture('player-login-room.json'), } }); assert.deepEqual(disposed, [0, 1], 'validation and actual room modules each dispose once'); assert.ok(attachedHost); const stoppedHost = attachedHost; assert.throws(() => stoppedHost.getSnapshot(), /disposed/); assert.throws(() => stoppedHost.subscribe(() => {}), /disposed/); assert.equal(setup.clock.timers.size, 0); assert.deepEqual(setup.scene.calls.filter(call => call.name === 'fatal'), []); assert.equal(setup.wire.stopCalls, 1); assert.deepEqual(setup.wire.lifecycle, ['subscribe', 'start', 'unsubscribe', 'stop']); if (interruption === 'attach') assert.deepEqual(events, ['snapshot']); if (interruption === 'room-event' || interruption === 'room-scene') { assert.deepEqual(events, ['snapshot', 'room-event']); } const stoppedState = runtime.state; const stoppedEvents = [...events]; setup.wire.emit({ type: 'open', server: 'ws://agent' }); setup.wire.emit({ type: 'message', message: { route: 'fixture-game', rpc: 'late', data: {}, } }); assert.equal(runtime.state, stoppedState); assert.deepEqual(events, stoppedEvents); assert.throws(() => runtime.login(ACCOUNT), /stopped/); await assert.rejects(runtime.start(), /stopped/); runtime.stop(); assert.deepEqual(disposed, [0, 1]); assert.equal(setup.wire.stopCalls, 1); }); } test('stop reentered by an old module disposal prevents replacement room activation', async () => { let runtime: PlatformRuntime; const attached: GameHost[] = []; let disposals = 0; const setup = makeRuntime(runtimeConfig(), makeGameEntry({ createModule() { let host: GameHost | undefined; return { attach(value) { host = value; attached.push(value); }, handlePlatformEvent() {}, handleGameMessage() {}, restore() {}, dispose() { if (host === undefined) return; disposals += 1; runtime.stop(); }, }; }, })); runtime = setup.runtime; await runtime.start(); setup.wire.emit({ type: 'open', server: 'ws://agent' }); setup.wire.emit({ type: 'message', message: { route: 'agent', rpc: 'player_login', data: fixture('player-login-room.json'), } }); setup.wire.emit({ type: 'message', message: { route: 'agent', rpc: 'player_login', data: fixture('player-login-room.json'), } }); assert.equal(disposals, 1); assert.equal(attached.length, 1); assert.throws(() => attached[0]!.getSnapshot(), /disposed/); assert.deepEqual(setup.scene.calls.filter(call => call.name === 'fatal'), []); assert.equal(setup.wire.stopCalls, 1); assert.throws(() => runtime.login(ACCOUNT), /stopped/); runtime.stop(); }); test('an actual callback error after stop remains explicit without changing the stopped lifecycle', async () => { let runtime: PlatformRuntime; const failure = new Error('restore callback failed after stop'); const setup = makeRuntime(runtimeConfig(), makeGameEntry({ createModule: () => ({ attach() {}, handlePlatformEvent() {}, handleGameMessage() {}, dispose() {}, restore() { runtime.stop(); throw failure; }, }), })); runtime = setup.runtime; await runtime.start(); setup.wire.emit({ type: 'open', server: 'ws://agent' }); const thrown = thrownValue(() => setup.wire.emit({ type: 'message', message: { route: 'agent', rpc: 'player_login', data: fixture('player-login-room.json'), } })); assert.deepEqual((thrown as { errors: unknown[] }).errors, [failure]); assert.deepEqual(setup.scene.calls.filter(call => call.name === 'fatal'), []); assert.throws(() => runtime.login(ACCOUNT), /stopped/); runtime.stop(); assert.equal(setup.wire.stopCalls, 1); }); for (const rpc of ['player_login', 'self_join_room']) { test(`malformed successful ${rpc} still terminates explicitly`, async () => { const { runtime, wire, scene } = makeRuntime(); await runtime.start(); wire.emit({ type: 'open', server: 'ws://agent' }); wire.emit({ type: 'message', message: { route: 'agent', rpc, data: { state: 0 } } }); assert.equal(wire.stopCalls, 1); assert.equal(scene.calls.filter(call => call.name === 'fatal').length, 1); assert.deepEqual(scene.calls.filter(call => call.name === 'denied'), []); runtime.stop(); }); } 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(); } interface CapturedGameHost { host: GameHost | null; } function makeCleanupFailureEntry( log: string[], disposeFailure: unknown, captured: CapturedGameHost, ): GameEntry { let moduleNumber = 0; return makeGameEntry({ createModule(): GameModule { moduleNumber += 1; const validationModule = moduleNumber === 1; return { attach(host) { if (validationModule) return; captured.host = host; host.subscribe(() => {}); }, handlePlatformEvent() {}, handleGameMessage() {}, restore() {}, dispose() { if (validationModule) return; log.push('game:dispose'); throw disposeFailure; }, }; }, }); } 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('showLoading failure enters terminal fatal before any startup dependency runs', async () => { const primary = new Error('loading scene failed'); const scene = new RecordingScene(); const wire = new RecordingWireClient(); let resourcesStarted = 0; let minimumDisplayStarted = 0; let configsResolved = 0; let wireFactories = 0; scene.onCall = (name) => { if (name === 'loading') throw primary; }; const runtime = new PlatformRuntime({ gameEntry: makeGameEntry(), resolveRuntimeConfig: async () => { configsResolved += 1; return runtimeConfig(); }, createWireClient: () => { wireFactories += 1; return wire; }, scene, loadResources: async () => { resourcesStarted += 1; }, waitForMinimumDisplay: async () => { minimumDisplayStarted += 1; }, getLoginDeviceSnapshot: () => DEVICE, }); const thrown = await rejectedValue(runtime.start()); assert.equal(thrown, primary); assert.deepEqual(scene.calls.map((call) => call.name), ['loading', 'fatal']); assert.equal(scene.calls[1]?.value, primary); assert.equal(resourcesStarted, 0); assert.equal(minimumDisplayStarted, 0); assert.equal(configsResolved, 0); assert.equal(wireFactories, 0); assert.deepEqual(wire.lifecycle, []); assert.equal(runtime.ready, false); await assert.rejects(() => runtime.start(), /fatal/i); assert.equal(scene.calls.filter((call) => call.name === 'fatal').length, 1); }); test('showLoading and showFatal failures preserve both error identities without starting dependencies', async () => { const primary = new Error('loading scene failed'); const fatalSceneFailure = { step: 'scene.showFatal' }; const scene = new RecordingScene(); let dependencyCalls = 0; scene.onCall = (name) => { if (name === 'loading') throw primary; if (name === 'fatal') throw fatalSceneFailure; }; const runtime = new PlatformRuntime({ gameEntry: makeGameEntry(), resolveRuntimeConfig: async () => { dependencyCalls += 1; return runtimeConfig(); }, createWireClient: () => { dependencyCalls += 1; return new RecordingWireClient(); }, scene, loadResources: async () => { dependencyCalls += 1; }, waitForMinimumDisplay: async () => { dependencyCalls += 1; }, getLoginDeviceSnapshot: () => DEVICE, }); const thrown = await rejectedValue(runtime.start()); assert.ok(thrown instanceof Error); const errors = (thrown as Error & { readonly errors: readonly unknown[] }).errors; assert.equal(errors[0], primary); assert.equal(errors[1], fatalSceneFailure); assert.equal(dependencyCalls, 0); assert.deepEqual(scene.calls.map((call) => call.name), ['loading', 'fatal']); await assert.rejects(() => runtime.start(), /fatal/i); assert.equal(scene.calls.filter((call) => call.name === 'fatal').length, 1); }); test('stop while config is pending prevents every late startup continuation from reviving runtime', async () => { const configReady = deferred(); const resourcesReady = deferred(); const minimumDisplayReady = deferred(); const wire = new RecordingWireClient(); const scene = new RecordingScene(); let resourcesStarted = 0; let minimumDisplayStarted = 0; let wireFactories = 0; const runtime = new PlatformRuntime({ gameEntry: makeGameEntry(), resolveRuntimeConfig: () => configReady.promise, createWireClient() { wireFactories += 1; return wire; }, scene, loadResources() { resourcesStarted += 1; return resourcesReady.promise; }, waitForMinimumDisplay() { minimumDisplayStarted += 1; return minimumDisplayReady.promise; }, getLoginDeviceSnapshot: () => DEVICE, }); const starting = runtime.start(); await Promise.resolve(); assert.equal(resourcesStarted, 1); assert.equal(minimumDisplayStarted, 1); runtime.stop(); configReady.resolve(runtimeConfig()); resourcesReady.resolve(undefined); minimumDisplayReady.resolve(undefined); await assert.rejects(starting, /stopped/i); await assert.rejects(() => runtime.start(), /stopped/i); assert.equal(wireFactories, 0); assert.deepEqual(wire.lifecycle, []); assert.equal(runtime.ready, false); assert.deepEqual(scene.calls.map((call) => call.name), ['loading']); }); test('a synchronous subscribe callback that stops runtime hands the returned unsubscribe to teardown exactly once', async () => { const unsubscribeFailure = { step: 'late-unsubscribe' }; const wire = new SynchronousSubscribeWireClient( { type: 'slow' }, { value: unsubscribeFailure }, ); const scene = new RecordingScene(); let runtime!: PlatformRuntime; runtime = new PlatformRuntime({ gameEntry: makeGameEntry(), resolveRuntimeConfig: async () => runtimeConfig(), createWireClient: () => wire, scene, loadResources: async () => {}, waitForMinimumDisplay: async () => {}, getLoginDeviceSnapshot: () => DEVICE, }); scene.onCall = (name) => { if (name === 'reconnect') runtime.stop(); }; const thrown = await rejectedValue(runtime.start()); assert.ok(thrown instanceof Error); const errors = (thrown as Error & { readonly errors: readonly unknown[] }).errors; assert.match(String(errors[0]), /stopped/i); assert.equal(errors[1], unsubscribeFailure); assert.equal(wire.unsubscribeCalls, 1); assert.equal(wire.listenerAttached, false); assert.equal(wire.stopCalls, 1); assert.equal(wire.startCalls, 0); const sceneCallCount = scene.calls.length; wire.emit({ type: 'open', server: 'ws://agent' }); runtime.stop(); assert.equal(scene.calls.length, sceneCallCount); assert.equal(wire.unsubscribeCalls, 1); assert.equal(wire.stopCalls, 1); }); test('a fatal synchronous subscribe callback still adopts unsubscribe and ignores late events', async () => { const primary = new Error('synchronous reconnect scene failed'); const wire = new SynchronousSubscribeWireClient({ type: 'slow' }); const scene = new RecordingScene(); scene.onCall = (name) => { if (name === 'reconnect') throw primary; }; const runtime = new PlatformRuntime({ gameEntry: makeGameEntry(), resolveRuntimeConfig: async () => runtimeConfig(), createWireClient: () => wire, scene, loadResources: async () => {}, waitForMinimumDisplay: async () => {}, getLoginDeviceSnapshot: () => DEVICE, }); const thrown = await rejectedValue(runtime.start()); assert.equal(thrown, primary); assert.equal(wire.unsubscribeCalls, 1); assert.equal(wire.listenerAttached, false); assert.equal(wire.stopCalls, 1); assert.equal(wire.startCalls, 0); assert.deepEqual( scene.calls.filter((call) => call.name === 'fatal').map((call) => call.value), [primary], ); const sceneCallCount = scene.calls.length; wire.emit({ type: 'reconnecting', server: 'ws://agent' }); assert.equal(scene.calls.length, sceneCallCount); }); for (const invalidUnsubscribe of [7, null, undefined] as const) { test(`subscribe returning ${String(invalidUnsubscribe)} is fatal before Wire start`, async () => { const wire = new InvalidSubscribeWireClient(invalidUnsubscribe); const scene = new RecordingScene(); const runtime = new PlatformRuntime({ gameEntry: makeGameEntry(), resolveRuntimeConfig: async () => runtimeConfig(), createWireClient: () => wire, scene, loadResources: async () => {}, waitForMinimumDisplay: async () => {}, getLoginDeviceSnapshot: () => DEVICE, }); const thrown = await rejectedValue(runtime.start()); assert.ok(thrown instanceof TypeError); assert.match(thrown.message, /subscribe.*unsubscribe.*function/i); assert.deepEqual(wire.lifecycle, ['subscribe', 'stop']); assert.equal(wire.stopCalls, 1); assert.deepEqual( scene.calls.filter((call) => call.name === 'fatal').map((call) => call.value), [thrown], ); await assert.rejects(() => runtime.start(), /fatal/i); assert.equal(scene.calls.filter((call) => call.name === 'fatal').length, 1); }); } test('subscribe startup failure enters fatal once and stops the created wire without starting it', async () => { const setup = makeRuntime(); const primary = new Error('subscribe failed'); setup.wire.onAction = (name) => { if (name === 'subscribe') throw primary; }; const thrown = await rejectedValue(setup.runtime.start()); assert.equal(thrown, primary); assert.deepEqual(setup.wire.lifecycle, ['subscribe', 'stop']); assert.equal(setup.wire.stopCalls, 1); assert.deepEqual( setup.scene.calls.filter((call) => call.name === 'fatal').map((call) => call.value), [primary], ); }); test('wire start failure cleans every established layer and preserves cleanup error order', async () => { const primary = new Error('wire start failed'); const unsubscribeFailure = { step: 'unsubscribe' }; const stopFailure = { step: 'wire.stop' }; const cleanupLog: string[] = []; let moduleNumber = 0; let activeHost: GameHost | null = null; const entry = makeGameEntry({ createModule(): GameModule { moduleNumber += 1; const currentModule = moduleNumber; return { attach(host) { if (currentModule === 1) return; activeHost = host; host.subscribe(() => {}); }, handlePlatformEvent() {}, handleGameMessage() {}, restore() {}, dispose() { if (currentModule === 1) return; cleanupLog.push('game:dispose'); throw undefined; }, }; }, }); const setup = makeRuntime(runtimeConfig(), entry); setup.wire.onAction = (name) => { if (name === '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'), }, }); throw primary; } if (name === 'unsubscribe') { cleanupLog.push('wire:unsubscribe'); throw unsubscribeFailure; } if (name === 'stop') { cleanupLog.push('wire:stop'); const host = activeHost; assert.ok(host); assert.throws(() => host.getSnapshot(), /disposed/i); throw stopFailure; } }; const thrown = await rejectedValue(setup.runtime.start()); assert.ok(thrown instanceof Error); const errors = (thrown as Error & { readonly errors: readonly unknown[] }).errors; assert.deepEqual(cleanupLog, ['wire:unsubscribe', 'game:dispose', 'wire:stop']); assert.equal(errors.length, 4); assert.equal(errors[0], primary); assert.equal(errors[1], unsubscribeFailure); assert.equal(errors[2], undefined); assert.equal(errors[3], stopFailure); assert.equal(setup.scene.calls.filter((call) => call.name === 'fatal').length, 1); assert.equal( setup.scene.calls.find((call) => call.name === 'fatal')?.value, thrown, ); 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); assert.equal(setup.wire.stopCalls, 1); }); for (const failedGate of ['resources', 'minimum-display'] as const) { test(`${failedGate} failure after Wire start tears down once and stays fatal`, async () => { const resourcesReady = deferred(); const minimumDisplayReady = deferred(); const wireStarted = deferred(); const primary = new Error(`${failedGate} failed`); const setup = makeRuntime(); const runtime = new PlatformRuntime({ gameEntry: makeGameEntry(), resolveRuntimeConfig: async () => runtimeConfig(), createWireClient: () => setup.wire, scene: setup.scene, loadResources: () => resourcesReady.promise, waitForMinimumDisplay: () => minimumDisplayReady.promise, getLoginDeviceSnapshot: () => DEVICE, clock: setup.clock, }); setup.wire.onAction = (name) => { if (name === 'start') wireStarted.resolve(undefined); }; const starting = runtime.start(); await wireStarted.promise; setup.wire.emit({ type: 'open', server: 'ws://agent' }); if (failedGate === 'resources') { resourcesReady.reject(primary); minimumDisplayReady.resolve(undefined); } else { resourcesReady.resolve(undefined); minimumDisplayReady.reject(primary); } const thrown = await rejectedValue(starting); assert.equal(thrown, primary); assert.deepEqual(setup.wire.lifecycle, ['subscribe', 'start', 'unsubscribe', 'stop']); assert.equal(setup.wire.stopCalls, 1); assert.deepEqual( setup.scene.calls.filter((call) => call.name === 'fatal').map((call) => call.value), [primary], ); 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); }); } 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' }); assert.equal(setup.runtime.state.app.phase, 'slow'); setup.wire.emit({ type: 'reconnecting', server: 'ws://agent' }); assert.equal(setup.runtime.state.app.phase, 'reconnecting'); assert.equal(setup.runtime.state.room, roomState.room); assert.equal(setup.runtime.state.players, roomState.players); 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('login gate routes each ignored room message once with zero runtime side effects', async () => { const originalDispatch = Router.prototype.dispatch; let routerDispatches = 0; Router.prototype.dispatch = function dispatchWithCount(message) { routerDispatches += 1; return originalDispatch.call(this, message); }; try { const log: string[] = []; const entry = makeLoggingEntry(log, [], []); const setup = makeRuntime(runtimeConfig(), entry); await setup.runtime.start(); beginEntryRecording(entry); setup.wire.emit({ type: 'open', server: 'ws://agent' }); setup.runtime.login(ACCOUNT); const stateBefore = setup.runtime.state; const sceneCallsBefore = setup.scene.calls.length; const lifecycleBefore = [...setup.wire.lifecycle]; setup.wire.emit({ type: 'message', message: { route: 'room', rpc: 'self_exit_room', data: { seat: 1 } }, }); setup.wire.emit({ type: 'message', message: { route: 'agent', rpc: 'self_join_room', data: fixture('self-join-room.json') }, }); assert.equal(routerDispatches, 2); assert.equal(setup.runtime.state, stateBefore); assert.deepEqual(log, []); assert.equal(setup.scene.calls.length, sceneCallsBefore); assert.equal(setup.scene.calls.filter((call) => call.name === 'fatal').length, 0); assert.deepEqual(setup.wire.lifecycle, lifecycleBefore); assert.equal(setup.wire.stopCalls, 0); } finally { Router.prototype.dispatch = originalDispatch; } }); test('a handled no-op room message cannot drive a scene without a state transition', 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') }, }); const stateBefore = setup.runtime.state; const sceneCallsBefore = setup.scene.calls.length; setup.wire.emit({ type: 'message', message: { route: 'room', rpc: 'self_exit_room', data: {} }, }); assert.equal(setup.runtime.state, stateBefore); assert.equal(setup.scene.calls.length, sceneCallsBefore); assert.equal(setup.scene.calls.filter((call) => call.name === 'fatal').length, 0); }); 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); }); test('stop continues after clearTimeout and every later cleanup failure in exact order', async () => { const clearFailure = { step: 'clock.clearTimeout' }; const disposeFailure = { step: 'game.dispose' }; const leaseFailure = { step: 'lease.invalidate' }; const stopFailure = { step: 'wire.stop' }; const log: string[] = []; const captured: CapturedGameHost = { host: null }; const clock = new ControlledClearClock(); const wire = new RecordingWireClient(); const scene = new RecordingScene(); const originalStoreSubscribe = PlatformStore.prototype.subscribe; PlatformStore.prototype.subscribe = function subscribeWithFailingRelease(listener) { const release = originalStoreSubscribe.call(this, listener); return () => { release(); log.push('lease:invalidate'); throw leaseFailure; }; }; try { const runtime = new PlatformRuntime({ gameEntry: makeCleanupFailureEntry(log, disposeFailure, captured), resolveRuntimeConfig: async () => runtimeConfig(), createWireClient: () => wire, scene, loadResources: async () => {}, waitForMinimumDisplay: async () => {}, getLoginDeviceSnapshot: () => DEVICE, clock, }); clock.onFailingClear = () => { log.push('clock:clear'); }; wire.onAction = (name) => { if (name === 'unsubscribe') { log.push('wire:unsubscribe'); throw undefined; } if (name === 'stop') { log.push('wire:stop'); const host = captured.host; assert.ok(host); assert.throws(() => host.getSnapshot(), /disposed/i); throw stopFailure; } }; await runtime.start(); wire.emit({ type: 'open', server: 'ws://agent' }); runtime.login(ACCOUNT); wire.emit({ type: 'message', message: { route: 'agent', rpc: 'player_login', data: fixture('player-login-room.json') }, }); wire.emit({ type: 'open', server: 'ws://agent' }); assert.equal(clock.timers.size, 1); clock.enableClearFailure(clearFailure); log.length = 0; const thrown = thrownValue(() => { runtime.stop(); }); assert.ok(thrown instanceof Error); const errors = (thrown as Error & { readonly errors: readonly unknown[] }).errors; assert.deepEqual(log, [ 'clock:clear', 'wire:unsubscribe', 'game:dispose', 'lease:invalidate', 'wire:stop', ]); assert.equal(errors.length, 5); assert.equal(errors[0], clearFailure); assert.equal(errors[1], undefined); assert.equal(errors[2], disposeFailure); assert.equal(errors[3], leaseFailure); assert.equal(errors[4], stopFailure); assert.equal(scene.calls.filter((call) => call.name === 'fatal').length, 0); const completedLog = [...log]; assert.doesNotThrow(() => { runtime.stop(); }); assert.deepEqual(log, completedLog); assert.equal(wire.stopCalls, 1); } finally { PlatformStore.prototype.subscribe = originalStoreSubscribe; } }); test('kick completes close, invalidate, clear, kicked scene, and wire stop despite all failures', async () => { const disposeFailure = { step: 'game.dispose' }; const leaseFailure = { step: 'lease.invalidate' }; const clearFailure = { step: 'clock.clearTimeout' }; const stopFailure = { step: 'wire.stop' }; const log: string[] = []; const captured: CapturedGameHost = { host: null }; const clock = new ControlledClearClock(); const wire = new RecordingWireClient(); const scene = new RecordingScene(); const originalStoreSubscribe = PlatformStore.prototype.subscribe; PlatformStore.prototype.subscribe = function subscribeWithFailingRelease(listener) { const release = originalStoreSubscribe.call(this, listener); return () => { release(); log.push('lease:invalidate'); throw leaseFailure; }; }; try { const runtime = new PlatformRuntime({ gameEntry: makeCleanupFailureEntry(log, disposeFailure, captured), resolveRuntimeConfig: async () => runtimeConfig(), createWireClient: () => wire, scene, loadResources: async () => {}, waitForMinimumDisplay: async () => {}, getLoginDeviceSnapshot: () => DEVICE, clock, }); clock.onFailingClear = () => { log.push('clock:clear'); }; scene.onCall = (name) => { if (name === 'kicked') { log.push('scene:kicked'); throw undefined; } if (name === 'fatal') log.push('scene:fatal'); }; wire.onAction = (name) => { if (name === 'unsubscribe') log.push('wire:unsubscribe'); if (name === 'stop') { log.push('wire:stop'); const host = captured.host; assert.ok(host); assert.throws(() => host.getSnapshot(), /disposed/i); throw stopFailure; } }; await runtime.start(); wire.emit({ type: 'open', server: 'ws://agent' }); runtime.login(ACCOUNT); wire.emit({ type: 'message', message: { route: 'agent', rpc: 'player_login', data: fixture('player-login-room.json') }, }); wire.emit({ type: 'open', server: 'ws://agent' }); assert.equal(clock.timers.size, 1); clock.enableClearFailure(clearFailure); log.length = 0; const kick = { msg: 'terminal cleanup test', code: 1 }; wire.emit({ type: 'message', message: { route: 'agent', rpc: 'kick_server', data: kick }, }); assert.deepEqual(log, [ 'game:dispose', 'lease:invalidate', 'clock:clear', 'scene:kicked', 'wire:stop', 'wire:unsubscribe', 'scene:fatal', ]); assert.equal(wire.stopCalls, 1); assert.equal(scene.calls.filter((call) => call.name === 'kicked').length, 1); assert.equal(scene.calls.find((call) => call.name === 'kicked')?.value, kick); const fatal = scene.calls.find((call) => call.name === 'fatal')?.value; assert.ok(fatal instanceof Error); const sessionErrors = (fatal as Error & { readonly errors: readonly unknown[] }).errors; assert.equal(sessionErrors.length, 3); assert.equal(sessionErrors[0], disposeFailure); assert.equal(sessionErrors[1], leaseFailure); const kickCleanup = sessionErrors[2]; assert.ok(kickCleanup instanceof Error); const kickErrors = ( kickCleanup as Error & { readonly errors: readonly unknown[] } ).errors; assert.equal(kickErrors.length, 3); assert.equal(kickErrors[0], clearFailure); assert.equal(kickErrors[1], undefined); assert.equal(kickErrors[2], stopFailure); const completedLog = [...log]; assert.doesNotThrow(() => { runtime.stop(); }); assert.doesNotThrow(() => { runtime.stop(); }); assert.deepEqual(log, completedLog); assert.equal(wire.stopCalls, 1); } finally { PlatformStore.prototype.subscribe = originalStoreSubscribe; } }); test('real runtime renders the room before restoring canonical deskinfo', async () => { const events: string[] = []; const wire = new RecordingWireClient(); const controller = new PlatformUiController({ render: model => { events.push(`render:${model.page}`); }, activate: page => { events.push(`activate:${page}`); }, showOverlay: model => { events.push(`overlay:${model.kind}`); }, }, { request: () => () => {} }, error => { throw error; }); const deskinfo = { fixture: 'opaque' }; const entry = makeGameEntry({ createModule: () => ({ attach() { events.push('attach'); }, handlePlatformEvent() {}, handleGameMessage() {}, dispose() {}, restore(value) { assert.equal(value, deskinfo); events.push('restore'); }, }) }); const runtime = new PlatformRuntime({ gameEntry: entry, resolveRuntimeConfig: async () => runtimeConfig(), createWireClient: () => wire, scene: controller, loadResources: async () => {}, waitForMinimumDisplay: async () => {}, getLoginDeviceSnapshot: () => DEVICE, clock: new ManualClock(), }); controller.connect(runtime); await runtime.start(); wire.emit({ type: 'open', server: 'ws://agent' }); controller.login(ACCOUNT); wire.emit({ type: 'message', message: { route: 'agent', rpc: 'player_login', data: fixture('player-login-success.json') } }); controller.joinRoom({ roomcode: '100001', location: null, ip: '127.0.0.1' }); wire.emit({ type: 'message', message: { route: 'agent', rpc: 'self_join_room', data: { ...fixture('self-join-room.json'), deskinfo } } }); assert.ok(events.includes('render:lobby')); assert.ok(events.indexOf('attach') < events.indexOf('render:room')); assert.ok(events.indexOf('render:room') < events.indexOf('activate:room')); assert.ok(events.indexOf('activate:room') < events.indexOf('restore')); controller.dispose(); runtime.stop(); assert.equal(wire.stopCalls, 1); }); test('real runtime keeps canonical roots and raw denials while login and join retries succeed', async () => { const wire = new RecordingWireClient(); const overlays: unknown[] = []; const controller = new PlatformUiController({ render: () => {}, activate: () => {}, showOverlay: model => { overlays.push(model); }, }, { request: () => () => {} }, error => { throw error; }); const runtime = new PlatformRuntime({ gameEntry: makeGameEntry(), resolveRuntimeConfig: async () => runtimeConfig(), createWireClient: () => wire, scene: controller, loadResources: async () => {}, waitForMinimumDisplay: async () => {}, getLoginDeviceSnapshot: () => DEVICE, clock: new ManualClock(), }); controller.connect(runtime); await runtime.start(); wire.emit({ type: 'open', server: 'ws://agent' }); const preLoginRoot = runtime.state; controller.login(ACCOUNT); const loginDenial = { state: 7, showerror: 1, error: 'Login denied' }; wire.emit({ type: 'message', message: { route: 'agent', rpc: 'player_login', data: loginDenial, } }); assert.equal(runtime.state, preLoginRoot); const loginOverlay = overlays[overlays.length - 1] as { kind: string; outcome: { data: unknown } }; assert.equal(loginOverlay.kind, 'denial'); assert.equal(loginOverlay.outcome.data, loginDenial); const retryAccount = { ...ACCOUNT, openid: 'retry-account' }; controller.login(retryAccount); assert.equal((wire.sent[wire.sent.length - 1]!.data as Record).openid, 'retry-account'); wire.emit({ type: 'message', message: { route: 'agent', rpc: 'player_login', data: fixture('player-login-success.json'), } }); const lobbyRoot = runtime.state; const firstJoin = { roomcode: '999999', location: null, ip: '127.0.0.1' }; controller.joinRoom(firstJoin); const joinDenial = { state: 99, roomcode: '999999', showerror: 1, error: 'No room' }; wire.emit({ type: 'message', message: { route: 'agent', rpc: 'self_join_room', data: joinDenial, } }); assert.equal(runtime.state, lobbyRoot); const joinOverlay = overlays[overlays.length - 1] as { kind: string; outcome: { data: unknown } }; assert.equal(joinOverlay.kind, 'denial'); assert.equal(joinOverlay.outcome.data, joinDenial); controller.joinRoom({ roomcode: '100001', location: null, ip: '127.0.0.1' }); wire.emit({ type: 'message', message: { route: 'agent', rpc: 'self_join_room', data: fixture('self-join-room.json'), } }); assert.equal(runtime.state.room.kind, 'inside'); assert.equal((overlays[overlays.length - 1] as { kind: string }).kind, 'none'); controller.dispose(); runtime.stop(); }); test('real runtime restores reconnect overlay and silences UI after kick or fatal', async () => { for (const terminal of ['kick', 'fatal'] as const) { const wire = new RecordingWireClient(); const events: string[] = []; const controller = new PlatformUiController({ render: model => { events.push(`render:${model.page}`); }, activate: page => { events.push(`activate:${page}`); }, showOverlay: model => { events.push(`overlay:${model.kind}`); }, }, { request: () => () => {} }, error => { throw error; }); const runtime = new PlatformRuntime({ gameEntry: makeGameEntry(), resolveRuntimeConfig: async () => runtimeConfig(), createWireClient: () => wire, scene: controller, loadResources: async () => {}, waitForMinimumDisplay: async () => {}, getLoginDeviceSnapshot: () => DEVICE, clock: new ManualClock(), }); controller.connect(runtime); await runtime.start(); wire.emit({ type: 'open', server: 'ws://agent' }); controller.login(ACCOUNT); wire.emit({ type: 'message', message: { route: 'agent', rpc: 'player_login', data: fixture('player-login-success.json'), } }); wire.emit({ type: 'slow' }); assert.equal(events[events.length - 1], 'overlay:reconnect'); const activations = events.filter(event => event.startsWith('activate:')).length; wire.emit({ type: 'open', server: 'ws://agent' }); assert.equal(events[events.length - 1], 'overlay:none'); assert.equal(events.filter(event => event.startsWith('activate:')).length, activations); if (terminal === 'kick') { wire.emit({ type: 'message', message: { route: 'agent', rpc: 'kick_server', data: { code: 1, msg: 'terminal' }, } }); assert.equal(events[events.length - 1], 'overlay:kicked'); } else { wire.emit({ type: 'message', message: { route: 'agent', rpc: 'player_login', data: { state: 0 }, } }); assert.equal(events[events.length - 1], 'overlay:fatal'); } const terminalEvents = [...events]; assert.throws(() => controller.login(ACCOUNT), /terminal/); wire.emit({ type: 'slow' }); wire.emit({ type: 'open', server: 'ws://agent' }); assert.deepEqual(events, terminalEvents); assert.equal(wire.stopCalls, 1); controller.dispose(); runtime.stop(); } }); test('real runtime alone governs login readiness while resources are pending', async () => { const resourcesReady = deferred(); const wire = new RecordingWireClient(); const controller = new PlatformUiController({ render: () => {}, activate: () => {}, showOverlay: () => {}, }, { request: () => () => {} }, error => { throw error; }); const runtime = new PlatformRuntime({ gameEntry: makeGameEntry(), resolveRuntimeConfig: async () => runtimeConfig(), createWireClient: () => wire, scene: controller, loadResources: () => resourcesReady.promise, waitForMinimumDisplay: async () => {}, getLoginDeviceSnapshot: () => DEVICE, clock: new ManualClock(), }); controller.connect(runtime); const starting = runtime.start(); await Promise.resolve(); await Promise.resolve(); assert.deepEqual(wire.lifecycle, ['subscribe', 'start']); wire.emit({ type: 'open', server: 'ws://agent' }); assert.equal(runtime.ready, false); assert.throws(() => controller.login(ACCOUNT), /login page/); assert.equal(wire.sent.length, 0); resourcesReady.resolve(undefined); await starting; assert.equal(runtime.ready, true); controller.login(ACCOUNT); assert.equal(wire.sent.length, 1); controller.dispose(); runtime.stop(); }); for (const phase of ['render', 'activate'] as const) { test(`real room ${phase} disposal interrupts restore and terminates the runtime`, async () => { const wire = new RecordingWireClient(); let armed = false; let restores = 0; let controller!: PlatformUiController; controller = new PlatformUiController({ render: model => { if (armed && phase === 'render' && model.page === 'room') controller.dispose(); }, activate: page => { if (armed && phase === 'activate' && page === 'room') controller.dispose(); }, showOverlay: () => {}, }, { request: () => () => {} }, error => { throw error; }); const runtime = new PlatformRuntime({ gameEntry: makeGameEntry({ createModule: () => ({ attach() {}, handlePlatformEvent() {}, handleGameMessage() {}, dispose() {}, restore() { restores += 1; }, }) }), resolveRuntimeConfig: async () => runtimeConfig(), createWireClient: () => wire, scene: controller, loadResources: async () => {}, waitForMinimumDisplay: async () => {}, getLoginDeviceSnapshot: () => DEVICE, clock: new ManualClock(), }); controller.connect(runtime); await runtime.start(); wire.emit({ type: 'open', server: 'ws://agent' }); controller.login(ACCOUNT); wire.emit({ type: 'message', message: { route: 'agent', rpc: 'player_login', data: fixture('player-login-success.json'), } }); armed = true; controller.joinRoom({ roomcode: '100001', location: null, ip: '127.0.0.1' }); wire.emit({ type: 'message', message: { route: 'agent', rpc: 'self_join_room', data: fixture('self-join-room.json'), } }); assert.equal(restores, 0); assert.equal(wire.stopCalls, 1); assert.throws(() => runtime.login(ACCOUNT), error => { assert.ok(error instanceof Error); const errors = (error as Error & { readonly errors: readonly unknown[] }).errors; assert.match(String(errors[0]), /lifecycle interrupted/); return true; }); runtime.stop(); }); } test('application overlay teardown disposes Presenter, stops Runtime, and invalidates a late frame', async () => { const wire = new RecordingWireClient(); const frames: Array<{ callback: () => void; cancelled: boolean }> = []; const renders: string[] = []; let armed = false; let runtime!: PlatformRuntime; let controller!: PlatformUiController; controller = new PlatformUiController({ render: model => { renders.push(model.page); }, activate: () => {}, showOverlay: model => { if (armed && model.kind === 'reconnect') { controller.dispose(); runtime.stop(); } }, }, { request: callback => { const frame = { callback, cancelled: false }; frames.push(frame); return () => { frame.cancelled = true; }; } }, error => { throw error; }); runtime = new PlatformRuntime({ gameEntry: makeGameEntry(), resolveRuntimeConfig: async () => runtimeConfig(), createWireClient: () => wire, scene: controller, loadResources: async () => {}, waitForMinimumDisplay: async () => {}, getLoginDeviceSnapshot: () => DEVICE, clock: new ManualClock(), }); controller.connect(runtime); await runtime.start(); wire.emit({ type: 'open', server: 'ws://agent' }); controller.login(ACCOUNT); wire.emit({ type: 'message', message: { route: 'agent', rpc: 'player_login', data: fixture('player-login-success.json'), } }); armed = true; const framesBeforeStop = frames.length; wire.emit({ type: 'slow' }); assert.equal(frames.length, framesBeforeStop + 1); const lateFrame = frames[frames.length - 1]!; assert.equal(lateFrame.cancelled, true); assert.equal(wire.stopCalls, 1); const rendered = [...renders]; lateFrame.callback(); wire.emit({ type: 'open', server: 'ws://agent' }); assert.deepEqual(renders, rendered); assert.throws(() => runtime.login(ACCOUNT), /stopped/); runtime.stop(); });