import { remoteFixture } from '../fixtures/remote-config.ts'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { test } from 'node:test'; import type { RuntimeConfig } from '../../YouleNexus/assets/framework/config/runtime-config.ts'; import type { Clock } from '../../YouleNexus/assets/framework/net/heartbeat.ts'; import { WireClient } from '../../YouleNexus/assets/framework/net/wire-client.ts'; import { PlatformRuntime } from '../../YouleNexus/assets/framework/platform/runtime.ts'; import { Router } from '../../YouleNexus/assets/framework/protocol/router.ts'; import type { InboundMessage } from '../../YouleNexus/assets/framework/core/types/envelope.ts'; import type { GameHost } from '../../YouleNexus/assets/framework/sdk/index.ts'; import { FakeTransport } from '../helpers/fake-transport.ts'; const CONFIG: RuntimeConfig = { mode: 'debug', isDebugger: true, identity: { agentid: 'A', channelid: 'C', gameid: '41', version: 1, marketid: 4 }, source: 'remote', servers: ['ws://agent'], gameserver: 'https://fixture/config', ...remoteFixture(), }; const ACCOUNT = { openid: 'o', nickname: 'n', avatar: 'a', sex: 0, province: '', city: '', unionid: 'u' }; const LOGIN = { app: 'youle', route: 'agent', rpc: 'player_login', data: { agentid: 'A', gameid: '41', openid: 'o', nickname: 'n', avatar: 'a', sex: 0, province: '', city: '', unionid: 'u', version: 1, channelid: 'C', marketid: 4, location: null, machineid: 'machine', machineroom: 'install', }, }; function fixture(name: string): InboundMessage & { data: Record } { return JSON.parse(readFileSync(new URL(`../fixtures/contracts/${name}.json`, import.meta.url), 'utf8')); } function setup() { const timers = new Map void; ms: number }>(); const clock: Clock = { setTimeout(fn, ms) { const handle = {}; timers.set(handle, { fn, ms }); return handle; }, clearTimeout(handle) { timers.delete(handle as object); }, }; const transports: FakeTransport[] = []; const hosts: GameHost[] = []; const restores: unknown[] = []; const scenes: string[] = []; const fatals: Error[] = []; const gameMessages: unknown[] = []; const runtime = new PlatformRuntime({ gameEntry: { key: 'fixture', gameId: '41', route: 'fixture-game', resolveSeatCount: () => 4, createModule: () => ({ attach(host) { hosts.push(host); }, handlePlatformEvent() {}, handleGameMessage(message) { gameMessages.push(message); }, restore(deskinfo) { restores.push(deskinfo); }, dispose() {}, }), }, resolveRuntimeConfig: async () => CONFIG, createWireClient(config) { return new WireClient({ servers: config.servers, clock, transportFactory() { const transport = new FakeTransport(); transports.push(transport); return transport; } }); }, scene: { showLoading() { scenes.push('loading'); }, showLogin() { scenes.push('login'); }, showLobby() { scenes.push('lobby'); }, showRoom() { scenes.push('room'); }, showReconnect() { scenes.push('reconnect'); }, showKicked() { scenes.push('kicked'); }, showServerDenial() { assert.fail('Unexpected server denial'); }, showFatal(error) { fatals.push(error); }, }, loadResources: async () => {}, waitForMinimumDisplay: async () => {}, clock, getLoginDeviceSnapshot: () => ({ location: null, machineid: 'machine', machineroom: 'install', deviceLogin: { enabled: false }, loginPlayerId: { enabled: false }, }), }); return { runtime, transports, hosts, restores, scenes, fatals, gameMessages, timers, fire(ms: number) { const entry = [...timers].find(([, timer]) => timer.ms === ms); assert.ok(entry, `missing ${ms}ms timer`); timers.delete(entry[0]); entry[1].fn(); }, }; } test('modern login gates business packets and retries exact login after the four-second guard', async (t) => { const s = setup(); t.after(() => s.runtime.stop()); const routed: InboundMessage[] = []; const dispatch = Router.prototype.dispatch; t.mock.method(Router.prototype, 'dispatch', function (this: Router, message: InboundMessage) { routed.push(message); return dispatch.call(this, message); }); await s.runtime.start(); const first = s.transports[0]!; await first.flush(); assert.deepEqual(first.sent, []); s.runtime.login(ACCOUNT); assert.deepEqual(first.sent.map(frame => JSON.parse(frame)), [LOGIN]); first.serverPush(JSON.stringify(fixture('self-join-room'))); await first.flush(); assert.equal(routed.length, 1); assert.equal(s.runtime.state.room.kind, 'outside'); assert.equal(s.hosts.length, 0); s.fire(4000); assert.equal(first.closeCalls, 1); assert.equal(s.transports.length, 1); s.fire(10000); const second = s.transports[1]!; await second.flush(); assert.deepEqual(second.sent.map(frame => JSON.parse(frame)), [LOGIN]); // The obsolete connection cannot reach Router or consume the new login gate. first.serverPush(JSON.stringify(fixture('player-login-room'))); await first.flush(); assert.equal(routed.length, 1); second.serverPush(JSON.stringify(fixture('player-login-success'))); await second.flush(); assert.equal(routed.length, 2); assert.equal(s.scenes[s.scenes.length - 1], 'lobby'); assert.equal(s.runtime.state.app.phase, 'logged-in'); assert.equal([...s.timers.values()].some(timer => timer.ms === 4000), false); second.serverPush(JSON.stringify({ com: '@serverheartbeat' })); await second.flush(); assert.equal(routed.length, 2); assert.deepEqual(s.fatals, []); assert.deepEqual(s.transports.flatMap(transport => transport.sent.map(frame => JSON.parse(frame))), [LOGIN, LOGIN]); }); for (const deskinfo of [undefined, null, false, 0, '']) { test(`modern room login does not restore falsey deskinfo: ${String(deskinfo)}`, async (t) => { const s = setup(); t.after(() => s.runtime.stop()); await s.runtime.start(); const transport = s.transports[0]!; await transport.flush(); s.runtime.login(ACCOUNT); const login = fixture('player-login-room'); if (deskinfo === undefined) delete login.data.deskinfo; else login.data.deskinfo = deskinfo; transport.serverPush(JSON.stringify(login)); await transport.flush(); assert.equal(s.hosts.length, 1); assert.equal(s.scenes[s.scenes.length - 1], 'room'); assert.deepEqual(s.restores, []); assert.deepEqual(s.fatals, []); assert.deepEqual(transport.sent.map(frame => JSON.parse(frame)), [LOGIN]); }); } test('another game route throws in the sole Router dispatch and becomes one terminal runtime error', async (t) => { const s = setup(); t.after(() => s.runtime.stop()); const thrown: unknown[] = []; const routed: InboundMessage[] = []; const dispatch = Router.prototype.dispatch; t.mock.method(Router.prototype, 'dispatch', function (this: Router, message: InboundMessage) { routed.push(message); try { return dispatch.call(this, message); } catch (error) { thrown.push(error); throw error; } }); await s.runtime.start(); const transport = s.transports[0]!; await transport.flush(); s.runtime.login(ACCOUNT); transport.serverPush(JSON.stringify(fixture('player-login-room'))); await transport.flush(); transport.serverPush(JSON.stringify({ route: 'another-game', rpc: 'deal', data: { cards: [1] } })); await transport.flush(); assert.equal(routed.length, 2); assert.equal(thrown.length, 1); assert.match(String(thrown[0]), /route.*mismatch|another-game/i); assert.equal(s.fatals.length, 1); assert.equal(s.fatals[0], thrown[0]); assert.deepEqual(s.gameMessages, []); assert.equal(transport.closeCalls, 1); assert.equal(s.timers.size, 0); assert.deepEqual(transport.sent.map(frame => JSON.parse(frame)), [LOGIN]); });