import { readFileSync } from 'node:fs'; import assert from 'node:assert/strict'; import { test } from 'node:test'; import type { OutboundEnvelope } from '../../YouleNexus/assets/framework/core/types/envelope.ts'; import type { GameEntry, GameHost, GameModule, PlatformGameSnapshot, PlatformToGameEvent, } from '../../YouleNexus/assets/framework/sdk/contracts/index.ts'; import { GameSessionHost } from '../../YouleNexus/assets/framework/sdk/runtime/game-session-host.ts'; import type { ConnectionControlPort } from '../../YouleNexus/assets/framework/platform/connection-intent.ts'; import { RuntimeSession } from '../../YouleNexus/assets/framework/platform/runtime-session.ts'; import { PlatformStore } from '../../YouleNexus/assets/framework/platform/stores/platform-store.ts'; import { selectGameSnapshot } from '../../YouleNexus/assets/framework/platform/stores/selectors.ts'; import { PlatformHandlers } from '../../YouleNexus/assets/framework/protocol/platform-handlers.ts'; import { Router } from '../../YouleNexus/assets/framework/protocol/router.ts'; function fixture(name: string): Record { return JSON.parse(readFileSync(new URL(`../fixtures/contracts/${name}`, import.meta.url), 'utf8')).data; } class ConnectionFixture implements ConnectionControlPort { readonly switchCalls: Array<{ target: string; resend: OutboundEnvelope }> = []; readonly stopCalls: unknown[] = []; switchServer(target: string, resend: OutboundEnvelope): void { this.switchCalls.push({ target, resend }); } stopForKick(data: unknown): void { this.stopCalls.push(data); } } function makeHandlers() { const store = new PlatformStore(); let disposals = 0; let eventObserver: ( event: PlatformToGameEvent, snapshot: PlatformGameSnapshot, ) => void = () => {}; let disposalObserver: () => void = () => {}; const entry: GameEntry = { key: 'fixture', gameId: 41, route: 'fixture-game', resolveSeatCount: () => 4, createModule: (): GameModule => ({ attach() {}, handlePlatformEvent(event) { eventObserver(event, selectGameSnapshot(store.getState())); }, handleGameMessage() {}, restore() {}, dispose() { disposals += 1; disposalObserver(); }, }), }; const gameSession = new GameSessionHost(entry); disposals = 0; const gameHost: GameHost = { seat: { toView: (seat) => seat, toServer: (seat) => seat }, getSnapshot: () => selectGameSnapshot(store.getState()), subscribe: () => () => {}, sendGameMessage() {}, execute() {}, }; const session = new RuntimeSession({ store, gameSession, gameHost }); const connection = new ConnectionFixture(); const handlers = new PlatformHandlers({ store, gameSession, session, connection }); const router = new Router({ session, platform: handlers, gameSession }); return { store, gameSession, session, connection, handlers, router, getDisposals: () => disposals, observeEvent(observer: typeof eventObserver) { eventObserver = observer; }, observeDisposal(observer: () => void) { disposalObserver = observer; }, }; } test('PlatformHandlers commits every room event before one typed game event reads the snapshot', () => { const setup = makeHandlers(); setup.handlers.dispatch({ route: 'agent', rpc: 'player_login', data: fixture('player-login-success.json'), }); function dispatchWithEvent( message: { route: string; rpc: string; data: Record }, expectedType: PlatformToGameEvent['type'], assertSnapshot: (snapshot: PlatformGameSnapshot) => void, ): void { const order: string[] = []; let received = 0; const unsubscribe = setup.store.subscribe(() => { order.push('store'); }); setup.observeEvent((event, snapshot) => { received += 1; order.push('game'); assert.equal(event.type, expectedType); assertSnapshot(snapshot); }); assert.equal(setup.handlers.dispatch(message), 'handled-platform'); unsubscribe(); assert.equal(received, 1); assert.deepEqual(order, ['store', 'game']); } const join = fixture('self-join-room.json'); delete join.deskinfo; join.deskwar = 0; dispatchWithEvent( { route: 'agent', rpc: 'self_join_room', data: join }, 'room.entered', (snapshot) => { assert.equal(snapshot.room.roomcode, '100001'); }, ); dispatchWithEvent( { route: 'room', rpc: 'other_join_room', data: { seat: 2, playerid: 77, nickname: '对家', avatar: 'http://b', sex: 1, ip: '127.0.0.2', onstate: 0, bean: 90, isprepare: 0, }, }, 'room.player-joined', (snapshot) => { assert.equal(snapshot.seats.find((seat) => seat.seat === 2)?.playerId, 77); }, ); dispatchWithEvent( { route: 'room', rpc: 'player_prepare', data: { seat: 2, deskwar: 1 } }, 'room.player-ready', (snapshot) => { assert.equal(snapshot.seats.find((seat) => seat.seat === 2)?.ready, true); assert.equal(snapshot.room.stage, 1); }, ); dispatchWithEvent( { route: 'room', rpc: 'other_offline', data: { seat: 2 } }, 'room.player-offline', (snapshot) => { assert.equal(snapshot.seats.find((seat) => seat.seat === 2)?.online, false); }, ); dispatchWithEvent( { route: 'room', rpc: 'other_online', data: { seat: 2, ip: '127.0.0.3' } }, 'room.player-online', (snapshot) => { assert.equal(snapshot.seats.find((seat) => seat.seat === 2)?.online, true); }, ); dispatchWithEvent( { route: 'room', rpc: 'other_exit_room', data: { seat: 2 } }, 'room.player-left', (snapshot) => { assert.equal(snapshot.seats.some((seat) => seat.seat === 2), false); }, ); }); test('self_exit_room commits the outside snapshot before disposing the game session', () => { const setup = makeHandlers(); setup.handlers.dispatch({ route: 'agent', rpc: 'player_login', data: fixture('player-login-room.json'), }); const order: string[] = []; setup.store.subscribe(() => { order.push('store'); }); setup.observeDisposal(() => { order.push('dispose'); assert.equal(setup.store.getState().room.kind, 'outside'); }); setup.handlers.dispatch({ route: 'room', rpc: 'self_exit_room', data: { seat: 1 } }); assert.deepEqual(order, ['store', 'dispose']); assert.equal(setup.gameSession.state, 'idle'); }); test('PlatformHandlers preserves complete switch payloads and builds the exact resend envelopes', () => { const setup = makeHandlers(); const roomData = { roomserver: '10.0.0.2:3088', epoch: 7 }; const agentData = { agentserver: 'secure.example:443/path', opt: 'free_room', trace: { id: 'x' }, }; assert.equal(setup.router.dispatch({ route: 'room', rpc: 'connect_roomserver', data: roomData, }), 'handled-platform'); assert.equal(setup.router.dispatch({ route: 'agent', rpc: 'connect_agentserver', data: agentData, }), 'handled-platform'); assert.deepEqual(setup.connection.switchCalls, [ { target: 'ws://10.0.0.2:3088', resend: { app: 'youle', route: 'room', rpc: 'connect_roomserver', data: roomData }, }, { target: 'ws://secure.example:443/path', resend: { app: 'youle', route: 'agent', rpc: 'connect_agentserver', data: agentData }, }, ]); assert.equal(setup.connection.switchCalls[0]?.resend.data, roomData); assert.equal(setup.connection.switchCalls[1]?.resend.data, agentData); }); test('PlatformHandlers rejects unknown, route-mismatched, missing, and invalid switch targets without effects', () => { const setup = makeHandlers(); const before = setup.store.getState(); assert.throws( () => setup.handlers.dispatch({ route: 'agent', rpc: 'update_bean', data: { bean: 1 } }), /update_bean/, ); assert.throws( () => setup.handlers.dispatch({ route: 'agent', rpc: 'connect_roomserver', data: { roomserver: 'x:1' } }), /route/i, ); assert.throws( () => setup.handlers.dispatch({ route: 'room', rpc: 'connect_roomserver', data: {} }), /roomserver/, ); assert.throws( () => setup.handlers.dispatch({ route: 'room', rpc: 'connect_roomserver', data: { roomserver: 'http://invalid.example' }, }), /roomserver|WebSocket/i, ); assert.equal(setup.store.getState(), before); assert.deepEqual(setup.connection.switchCalls, []); }); test('kick_server closes the game, marks the runtime session, and stops the connection once', () => { const setup = makeHandlers(); setup.handlers.dispatch({ route: 'agent', rpc: 'player_login', data: fixture('player-login-room.json') }); const kickData = { msg: '检查到新版本', code: 1 }; setup.session.markLoginPending(); setup.router.dispatch({ route: 'agent', rpc: 'kick_server', data: kickData }); assert.equal(setup.gameSession.state, 'idle'); assert.equal(setup.session.kicked, true); assert.equal(setup.session.loginPending, false); assert.equal(setup.getDisposals(), 1); assert.deepEqual(setup.connection.stopCalls, [kickData]); assert.equal(setup.connection.stopCalls[0], kickData); });