import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { test } from 'node:test'; import { HttpConfigFetcher } from '../../YouleNexus/assets/framework/config/remote-config-fetcher.ts'; import { resolveRuntimeConfig } from '../../YouleNexus/assets/framework/config/runtime-config.ts'; import type { InboundMessage } from '../../YouleNexus/assets/framework/core/types/envelope.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 { GameEntry, GameHost, GameModule } from '../../YouleNexus/assets/framework/sdk/index.ts'; import { FakeTransport } from '../helpers/fake-transport.ts'; // Independent wire expectations: do not build these with production envelope helpers. const LOGIN = { app: 'youle', route: 'agent', rpc: 'player_login', data: { agentid: 'veRa0qrBf', gameid: 'J9r4022t80pxlb01zOK76DN1WD4QewFl', openid: 'o1', nickname: '测试号', avatar: 'http://a', sex: 0, province: '江西', city: '南昌', unionid: 'test_unionid', version: 1, channelid: 'FtJf073aa', marketid: 4, ip: '127.0.0.1', location: { latitude: 1, longitude: 2 }, machineid: 'machine-1', machineroom: 'room-install-1', }, }; const JOIN = { app: 'youle', route: 'agent', rpc: 'self_join_room', data: { agentid: 'veRa0qrBf', playerid: 430511, gameid: 'J9r4022t80pxlb01zOK76DN1WD4QewFl', roomcode: '100001', location: { latitude: 1, longitude: 2 }, ip: '127.0.0.1', }, }; const SWITCH = { app: 'youle', route: 'room', rpc: 'connect_roomserver', data: { roomserver: 'room.example:3089', token: 'original-token', roomcode: '100001' }, }; const PREPARE = { app: 'youle', route: 'room', rpc: 'player_prepare', data: { agentid: 'veRa0qrBf', playerid: 430511, gameid: 'J9r4022t80pxlb01zOK76DN1WD4QewFl', roomcode: '100001', }, }; class ReplayClock implements Clock { private now = 0; private next = 0; readonly timers = new Map void }>(); setTimeout(fn: () => void, ms: number): unknown { const handle = ++this.next; this.timers.set(handle, { at: this.now + ms, fn }); return handle; } clearTimeout(handle: unknown): void { this.timers.delete(handle as number); } advance(ms: number): void { const end = this.now + ms; for (;;) { const next = [...this.timers].sort((a, b) => a[1].at - b[1].at)[0]; if (!next || next[1].at > end) break; this.now = next[1].at; this.timers.delete(next[0]); next[1].fn(); } this.now = end; } } function fixture(name: string): InboundMessage & { data: Record } { return JSON.parse(readFileSync(new URL(`../fixtures/contracts/${name}.json`, import.meta.url), 'utf8')); } test('real modern vertical replay preserves wire contracts, room ordering and terminal kick', async (t) => { const clock = new ReplayClock(); const transports: FakeTransport[] = []; const nativeCalls: string[] = []; const configRequests: Array<{ url: string; init: RequestInit | undefined }> = []; const scenes: string[] = []; const fatals: Error[] = []; const denials: Array<{ readonly rpc: string; readonly state: number; readonly data: unknown }> = []; const observations: string[] = []; const attached: GameHost[] = []; const restored: unknown[] = []; const disposed: GameHost[] = []; const routed: InboundMessage[] = []; const decoded: 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); }); const entry: GameEntry = { key: 'fixture', gameId: 'J9r4022t80pxlb01zOK76DN1WD4QewFl', route: 'fixture-game', resolveSeatCount: () => 4, createModule(): GameModule { let host: GameHost | undefined; return { attach(value) { host = value; attached.push(value); observations.push(`attach:${value.getSnapshot().room.roomcode}`); value.subscribe(snapshot => { observations.push(`snapshot:${snapshot.seats.find(seat => seat.seat === 1)?.ready}`); }); }, handlePlatformEvent(event) { if (event.type === 'room.player-ready') { observations.push(`ready:${host!.getSnapshot().seats.find(seat => seat.seat === event.seat)?.ready}`); } }, handleGameMessage(message) { observations.push(`game:${message.rpc}`); }, restore(value) { assert.equal(host!.getSnapshot().room.roomcode, '100001'); assert.equal(scenes[scenes.length - 1], 'room'); restored.push(value); }, dispose() { // GameEntry validation also constructs/disposes a module without attaching. if (host) disposed.push(host); }, }; }, }; const runtime = new PlatformRuntime({ gameEntry: entry, resolveRuntimeConfig: () => resolveRuntimeConfig({ mode: 'release', hostKind: 'native-settings', search: '', win: { settings: { getothername(name: string) { nativeCalls.push(`getothername:${name}`); if (name === 'agent') return 'veRa0qrBf'; assert.equal(name, 'gameconfig'); return 'config.example-config'; }, getchannelName() { nativeCalls.push('getchannelName'); return 'FtJf073aa'; }, getmarketname() { nativeCalls.push('getmarketname'); return 4; }, } }, cacheBust: () => 'fixed-cache-bust', fetcher: new HttpConfigFetcher(async (url, init) => { configRequests.push({ url: String(url), init }); return new Response(JSON.stringify({ urlserver: 'wrong-root.example:1', player_server_tcp: ['agent.example:3088', 'backup.example:3088'], }), { status: 200 }); }), }), createWireClient(config) { assert.deepEqual(config.servers, ['ws://agent.example:3088', 'ws://backup.example:3088']); const wire = new WireClient({ servers: config.servers, clock, transportFactory() { const transport = new FakeTransport(); transports.push(transport); return transport; } }); wire.subscribe(event => { if (event.type === 'message') decoded.push(event.message); }); return wire; }, 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(outcome) { denials.push(outcome); }, showFatal(error) { fatals.push(error); }, }, loadResources: async () => {}, waitForMinimumDisplay: async () => {}, clock, getLoginDeviceSnapshot: () => ({ ip: '127.0.0.1', location: { latitude: 1, longitude: 2 }, machineid: 'machine-1', machineroom: 'room-install-1', deviceLogin: { enabled: false }, loginPlayerId: { enabled: false }, }), }); t.after(() => runtime.stop()); const expectedFrames: unknown[] = []; function verifyFrames(): void { assert.deepEqual(transports.flatMap(transport => transport.sent.map(frame => JSON.parse(frame))), expectedFrames); assert.deepEqual(fatals, []); } async function push(transport: FakeTransport, message: InboundMessage): Promise { const before = routed.length; transport.serverPush(JSON.stringify(message)); await transport.flush(); assert.equal(routed.length, before + 1, `${message.rpc} must enter Router once`); assert.equal(routed[routed.length - 1], decoded[decoded.length - 1], 'Router receives the decoded object itself'); verifyFrames(); return routed[routed.length - 1]!; } await runtime.start(); assert.deepEqual(nativeCalls, ['getothername:agent', 'getchannelName', 'getmarketname', 'getothername:gameconfig']); assert.deepEqual(configRequests, [{ url: 'http://config.example/config.txt?fixed-cache-bust', init: { method: 'POST', body: '' }, }]); const agent = transports[0]!; assert.equal(agent.url, 'ws://agent.example:3088'); await agent.flush(); verifyFrames(); assert.deepEqual(scenes, ['loading', 'login']); assert.equal(runtime.ready, true); assert.equal(runtime.state.app.phase, 'connected'); agent.serverPush('@toconconHELLO'); agent.serverPush(JSON.stringify({ com: '@serverheartbeat' })); await agent.flush(); assert.equal(routed.length, 0); runtime.login({ openid: 'o1', nickname: '测试号', avatar: 'http://a', sex: 0, province: '江西', city: '南昌', unionid: 'test_unionid', }); expectedFrames.push(LOGIN); verifyFrames(); await push(agent, fixture('player-login-success')); assert.equal(scenes[scenes.length - 1], 'lobby'); assert.equal(runtime.state.room.kind, 'outside'); const lobby = runtime.state; runtime.joinRoom({ roomcode: '999999', location: { latitude: 1, longitude: 2 }, ip: '127.0.0.1' }); expectedFrames.push({ ...JOIN, data: { ...JOIN.data, roomcode: '999999' } }); const denied = await push(agent, { route: 'agent', rpc: 'self_join_room', data: { state: 99, roomcode: '999999', showerror: 1, error: 'Room does not exist' }, }); assert.equal(runtime.state, lobby); assert.equal(scenes[scenes.length - 1], 'lobby'); assert.equal(denials.length, 1); assert.deepEqual(denials[0], { rpc: 'self_join_room', state: 99, data: denied.data }); assert.equal(denials[0]?.data, denied.data); runtime.joinRoom({ roomcode: '100001', location: { latitude: 1, longitude: 2 }, ip: '127.0.0.1' }); expectedFrames.push(JOIN); verifyFrames(); await push(agent, SWITCH); assert.equal(agent.closeCalls, 1); clock.advance(9999); assert.equal(transports.length, 1); clock.advance(1); const room = transports[1]!; assert.equal(room.url, 'ws://room.example:3089'); assert.deepEqual(room.sent, []); expectedFrames.push(SWITCH); await room.flush(); verifyFrames(); const join = fixture('self-join-room'); delete join.data.deskinfo; join.data.deskwar = 0; join.data.players[1].isprepare = 0; await push(room, join); assert.equal(attached.length, 1); assert.equal(runtime.state.room.kind, 'inside'); assert.equal(scenes[scenes.length - 1], 'room'); assert.equal(attached[0]!.getSnapshot().connection.phase, 'logged-in'); assert.deepEqual(restored, []); assert.deepEqual(observations, ['attach:100001', 'snapshot:false']); runtime.prepare(); expectedFrames.push(PREPARE); verifyFrames(); await push(room, { route: 'room', rpc: 'player_prepare', data: { seat: 1 } }); assert.deepEqual(observations.slice(-2), ['snapshot:true', 'ready:true']); await push(room, { route: 'fixture-game', rpc: 'deal', data: { cards: [7, 8] } }); assert.equal(observations[observations.length - 1], 'game:deal'); const priorRoom = runtime.state.room; room.serverClose(); await room.flush(); assert.equal(scenes[scenes.length - 1], 'reconnect'); assert.equal(attached[0]!.getSnapshot().connection.phase, 'reconnecting'); clock.advance(9999); assert.equal(transports.length, 2); clock.advance(1); const reconnected = transports[2]!; assert.equal(reconnected.url, 'ws://room.example:3089'); expectedFrames.push(LOGIN); await reconnected.flush(); verifyFrames(); const restoredEnvelope = await push(reconnected, fixture('player-login-room')); assert.notEqual(runtime.state.room, priorRoom); assert.equal(attached.length, 2); assert.notEqual(attached[0], attached[1]); assert.deepEqual(disposed, [attached[0]]); assert.throws(() => attached[0]!.getSnapshot(), /invalid|inactive|disposed/i); assert.equal(restored.length, 1); assert.equal(restored[0], (restoredEnvelope.data as Record).deskinfo); assert.equal(Object.prototype.hasOwnProperty.call(runtime.state.room, 'deskinfo'), false); assert.equal(attached[1]!.getSnapshot().connection.phase, 'logged-in'); await push(reconnected, { route: 'agent', rpc: 'kick_server', data: { msg: 'elsewhere' } }); assert.equal(scenes[scenes.length - 1], 'kicked'); assert.equal(runtime.state.app.phase, 'kicked'); assert.deepEqual(disposed, attached); assert.equal(reconnected.closeCalls, 1); assert.throws(() => attached[1]!.getSnapshot(), /invalid|inactive|disposed/i); assert.equal(clock.timers.size, 0); clock.advance(60000); assert.equal(transports.length, 3); assert.deepEqual(routed, decoded); verifyFrames(); });