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); });