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, RuntimeSessionFault, } 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; } function caughtError(operation: () => void): Error { try { operation(); } catch (error) { assert.ok(error instanceof Error); return error; } assert.fail('expected operation to throw'); } class ConnectionFixture implements ConnectionControlPort { readonly switchCalls: Array<{ target: string; resend: OutboundEnvelope }> = []; readonly stopCalls: unknown[] = []; switchFailure: Error | undefined; switchObserver: () => void = () => {}; stopFailure: Error | undefined; stopObserver: () => void = () => {}; switchServer(target: string, resend: OutboundEnvelope): void { this.switchCalls.push({ target, resend }); this.switchObserver(); if (this.switchFailure) throw this.switchFailure; } stopForKick(data: unknown): void { this.stopCalls.push(data); this.stopObserver(); if (this.stopFailure) throw this.stopFailure; } } function makeHandlers() { const store = new PlatformStore(); let disposals = 0; let eventObserver: ( event: PlatformToGameEvent, snapshot: PlatformGameSnapshot, ) => void = () => {}; let disposalObserver: () => void = () => {}; let invalidationObserver: () => void = () => {}; let invalidations = 0; 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 session = new RuntimeSession({ store, gameSession, createGameHostLease: () => ({ host: { seat: { toView: (seat) => seat, toServer: (seat) => seat }, getSnapshot: () => selectGameSnapshot(store.getState()), subscribe: () => () => {}, sendGameMessage() {}, execute() {}, }, invalidate() { invalidations += 1; invalidationObserver(); }, }), }); 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, getInvalidations: () => invalidations, observeEvent(observer: typeof eventObserver) { eventObserver = observer; }, observeDisposal(observer: () => void) { disposalObserver = observer; }, observeInvalidation(observer: () => void) { invalidationObserver = observer; }, }; } function makeReentrantRouterFixture(log: string[]) { const store = new PlatformStore(); let nextModuleId = 0; const restores: Array<{ moduleId: number; deskinfo: unknown }> = []; const entry: GameEntry = { key: 'reentrant-fixture', gameId: 41, route: 'fixture-game', resolveSeatCount: () => 4, createModule: (): GameModule => { const moduleId = ++nextModuleId; let host: GameHost | undefined; return { attach(attachedHost) { host = attachedHost; log.push(`attach:${moduleId}:${attachedHost.getSnapshot().room.roomcode}`); }, handlePlatformEvent(event) { if (!host) throw new Error('fixture module has no host'); const snapshot = host.getSnapshot(); if (event.type === 'room.entered') { log.push(`event:${moduleId}:room.entered:${snapshot.room.roomcode}`); return; } if (event.type === 'room.player-offline' || event.type === 'room.player-online') { const online = snapshot.seats.find((seat) => seat.seat === event.seat)?.online; log.push(`event:${moduleId}:${event.type}:${String(online)}`); } }, handleGameMessage() {}, restore(deskinfo) { restores.push({ moduleId, deskinfo }); log.push(`restore:${moduleId}`); }, dispose() { log.push(`dispose:${moduleId}`); }, }; }, }; const gameSession = new GameSessionHost(entry); nextModuleId = 0; log.length = 0; const session = new RuntimeSession({ store, gameSession, createGameHostLease: () => ({ host: { seat: { toView: (seat) => seat, toServer: (seat) => seat }, getSnapshot: () => selectGameSnapshot(store.getState()), subscribe: () => () => {}, sendGameMessage() {}, execute() {}, }, invalidate() {}, }), }); const connection = new ConnectionFixture(); const handlers = new PlatformHandlers({ store, gameSession, session, connection }); const router = new Router({ session, platform: handlers, gameSession }); return { store, session, gameSession, router, restores }; } test('Router drains reentrant room entries FIFO so each module receives its own committed room and deskinfo', () => { const log: string[] = []; const setup = makeReentrantRouterFixture(log); setup.router.dispatch({ route: 'agent', rpc: 'player_login', data: fixture('player-login-success.json'), }); const roomA = fixture('self-join-room.json'); const roomB = fixture('self-join-room.json'); roomA.roomcode = 'room-A'; roomB.roomcode = 'room-B'; roomA.deskinfo = { round: 'A' }; roomB.deskinfo = { round: 'B' }; let queuedResult: ReturnType | undefined; setup.store.subscribe((state) => { if (state.room.kind === 'inside' && state.room.roomcode === 'room-A') { log.push('store:A'); queuedResult = setup.router.dispatch({ route: 'agent', rpc: 'self_join_room', data: roomB, }); } else if (state.room.kind === 'inside' && state.room.roomcode === 'room-B') { log.push('store:B'); } }); const result = setup.router.dispatch({ route: 'agent', rpc: 'self_join_room', data: roomA, }); assert.equal(result, 'handled-platform'); assert.equal(queuedResult, 'handled-platform'); assert.deepEqual(log, [ 'store:A', 'attach:1:room-A', 'event:1:room.entered:room-A', 'restore:1', 'store:B', 'dispose:1', 'attach:2:room-B', 'event:2:room.entered:room-B', 'restore:2', ]); assert.deepEqual(setup.restores, [ { moduleId: 1, deskinfo: roomA.deskinfo }, { moduleId: 2, deskinfo: roomB.deskinfo }, ]); assert.equal(setup.restores[0]?.deskinfo, roomA.deskinfo); assert.equal(setup.restores[1]?.deskinfo, roomB.deskinfo); }); test('Router finishes an offline state-first event before draining a reentrant online message', () => { const log: string[] = []; const setup = makeReentrantRouterFixture(log); setup.router.dispatch({ route: 'agent', rpc: 'player_login', data: fixture('player-login-room.json'), }); setup.router.dispatch({ 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, }, }); log.length = 0; let queuedResult: ReturnType | undefined; setup.store.subscribe((state, previous) => { if (state.room.kind !== 'inside' || previous.room.kind !== 'inside') return; const wasOffline = previous.room.offlineSeats.includes(2); const isOffline = state.room.offlineSeats.includes(2); if (!wasOffline && isOffline) { log.push('store:offline'); queuedResult = setup.router.dispatch({ route: 'room', rpc: 'other_online', data: { seat: 2, ip: '127.0.0.3' }, }); } else if (wasOffline && !isOffline) { log.push('store:online'); } }); const result = setup.router.dispatch({ route: 'room', rpc: 'other_offline', data: { seat: 2 }, }); assert.equal(result, 'handled-platform'); assert.equal(queuedResult, 'handled-platform'); assert.deepEqual(log, [ 'store:offline', 'event:1:room.player-offline:false', 'store:online', 'event:1:room.player-online:true', ]); }); 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('self_exit_room accepts omitted seat, validates a present seat, and is idempotent outside', () => { const setup = makeHandlers(); setup.router.dispatch({ route: 'agent', rpc: 'player_login', data: fixture('player-login-room.json'), }); const inside = setup.store.getState(); const disposalsBefore = setup.getDisposals(); assert.throws( () => setup.router.dispatch({ route: 'room', rpc: 'self_exit_room', data: { seat: 0 } }), /self_exit_room.*seat.*expected 1.*received 0/i, ); assert.equal(setup.store.getState(), inside); assert.equal(setup.gameSession.state, 'active'); assert.equal(setup.getDisposals(), disposalsBefore); let notifications = 0; setup.store.subscribe(() => { notifications += 1; }); assert.equal( setup.router.dispatch({ route: 'room', rpc: 'self_exit_room', data: { reason: 'done' } }), 'handled-platform', ); assert.equal(setup.store.getState().room.kind, 'outside'); assert.equal(setup.gameSession.state, 'idle'); assert.equal(notifications, 1); assert.equal(setup.getDisposals(), disposalsBefore + 1); assert.equal( setup.router.dispatch({ route: 'room', rpc: 'self_exit_room', data: { reason: 'duplicate' } }), 'handled-platform', ); assert.equal(notifications, 1); assert.equal(setup.getDisposals(), disposalsBefore + 1); }); 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('unknown and every route-mismatched platform rpc leave all observable state unchanged', () => { const setup = makeHandlers(); setup.router.dispatch({ route: 'agent', rpc: 'player_login', data: fixture('player-login-room.json'), }); const before = setup.store.getState(); let notifications = 0; let gameEvents = 0; setup.store.subscribe(() => { notifications += 1; }); setup.observeEvent(() => { gameEvents += 1; }); const rpcsByRoute = [ ['player_login', 'agent'], ['self_join_room', 'agent'], ['player_prepare', 'room'], ['self_exit_room', 'room'], ['other_join_room', 'room'], ['other_exit_room', 'room'], ['other_offline', 'room'], ['other_online', 'room'], ['connect_roomserver', 'room'], ['connect_agentserver', 'agent'], ['kick_server', 'agent'], ] as const; for (const route of ['platform', 'agent', 'room'] as const) { assert.throws( () => setup.router.dispatch({ route, rpc: 'unknown_rpc', data: {} }), /unsupported.*unknown_rpc/i, `${route}:unknown_rpc`, ); } for (const [rpc, expectedRoute] of rpcsByRoute) { for (const route of ['platform', 'agent', 'room'] as const) { if (route === expectedRoute) continue; assert.throws( () => setup.router.dispatch({ route, rpc, data: {} }), /route mismatch/i, `${route}:${rpc}`, ); } } assert.equal(setup.store.getState(), before); assert.equal(setup.gameSession.state, 'active'); assert.equal(setup.session.state, 'operational'); assert.equal(notifications, 0); assert.equal(gameEvents, 0); assert.equal(setup.getDisposals(), 0); assert.equal(setup.getInvalidations(), 0); assert.deepEqual(setup.connection.switchCalls, []); assert.deepEqual(setup.connection.stopCalls, []); }); test('PlatformHandlers validates every switch URL before closing the active game', () => { const invalidTargets = [ 'http://invalid.example', 'ws://user@example.com', 'ws://example.com/#fragment', 'ws://example.com/#', 'ws://example.com#', 'ws://@example.com', 'ws://:@example.com', 'ws:///path', 'ws://example.com:99999', 'ws://example.com:abc', 'ws://', ]; for (const roomserver of invalidTargets) { const setup = makeHandlers(); setup.router.dispatch({ route: 'agent', rpc: 'player_login', data: fixture('player-login-room.json'), }); const before = setup.store.getState(); assert.throws( () => setup.router.dispatch({ route: 'room', rpc: 'connect_roomserver', data: { roomserver }, }), /roomserver|WebSocket/i, roomserver, ); assert.equal(setup.store.getState(), before, roomserver); assert.equal(setup.gameSession.state, 'active', roomserver); assert.equal(setup.session.state, 'operational', roomserver); assert.equal(setup.getDisposals(), 0, roomserver); assert.equal(setup.getInvalidations(), 0, roomserver); assert.deepEqual(setup.connection.switchCalls, [], roomserver); } }); test('PlatformHandlers accepts case-insensitive WS/WSS schemes and preserves full payload identity', () => { const setup = makeHandlers(); const roomData = { roomserver: 'WS://EXAMPLE.com:80/path/%23anchor?x=1', extra: { trace: 1 }, }; const agentData = { agentserver: 'WSS://secure.example:443/socket', opt: 'free_room', extra: { trace: 2 }, }; setup.router.dispatch({ route: 'room', rpc: 'connect_roomserver', data: roomData }); setup.router.dispatch({ route: 'agent', rpc: 'connect_agentserver', data: agentData }); assert.equal(setup.connection.switchCalls[0]?.target, roomData.roomserver); assert.equal(setup.connection.switchCalls[0]?.resend.data, roomData); assert.equal(setup.connection.switchCalls[1]?.target, agentData.agentserver); assert.equal(setup.connection.switchCalls[1]?.resend.data, agentData); assert.equal( (setup.connection.switchCalls[1]?.resend.data as typeof agentData).extra, agentData.extra, ); }); test('PlatformHandlers attempts a validated switch after cleanup errors and aggregates all failures', () => { const setup = makeHandlers(); setup.router.dispatch({ route: 'agent', rpc: 'player_login', data: fixture('player-login-room.json'), }); const data = { roomserver: 'wss://switch.example/socket', epoch: 9 }; const disposeError = new Error('switch:dispose'); const invalidateError = new Error('switch:invalidate'); const switchError = new Error('switch:connection'); setup.observeDisposal(() => { throw disposeError; }); setup.observeInvalidation(() => { throw invalidateError; }); setup.connection.switchFailure = switchError; const error = caughtError(() => setup.router.dispatch({ route: 'room', rpc: 'connect_roomserver', data, })); assert.ok(error instanceof RuntimeSessionFault); assert.deepEqual(error.errors, [disposeError, invalidateError, switchError]); assert.equal(setup.gameSession.state, 'idle'); assert.equal(setup.session.state, 'faulted'); assert.equal(setup.getDisposals(), 1); assert.equal(setup.getInvalidations(), 1); assert.equal(setup.connection.switchCalls.length, 1); assert.equal(setup.connection.switchCalls[0]?.target, data.roomserver); assert.equal(setup.connection.switchCalls[0]?.resend.data, data); }); 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); }); test('kick_server latches before cleanup and makes a reentrant duplicate a no-op', () => { const setup = makeHandlers(); setup.router.dispatch({ route: 'agent', rpc: 'player_login', data: fixture('player-login-room.json') }); const kickData = { msg: 'same-reference' }; let reentrantResult: ReturnType | undefined; setup.session.markLoginPending(); setup.observeDisposal(() => { assert.equal(setup.session.kicked, true); assert.equal(setup.session.loginPending, false); reentrantResult = setup.router.dispatch({ route: 'agent', rpc: 'kick_server', data: kickData }); }); assert.equal( setup.router.dispatch({ route: 'agent', rpc: 'kick_server', data: kickData }), 'handled-platform', ); assert.equal(reentrantResult, 'handled-platform'); assert.equal(setup.getDisposals(), 1); assert.equal(setup.getInvalidations(), 1); assert.equal(setup.connection.stopCalls.length, 1); assert.equal(setup.connection.stopCalls[0], kickData); assert.equal(setup.session.state, 'kicked'); }); test('kick_server attempts dispose, invalidate, mark, and stop once then aggregates failures', () => { const setup = makeHandlers(); setup.router.dispatch({ route: 'agent', rpc: 'player_login', data: fixture('player-login-room.json') }); const kickData = { msg: 'cleanup-failures' }; const disposeError = new Error('kick:dispose'); const invalidateError = new Error('kick:invalidate'); const stopError = new Error('kick:stop'); setup.observeDisposal(() => { throw disposeError; }); setup.observeInvalidation(() => { throw invalidateError; }); setup.connection.stopFailure = stopError; setup.session.markLoginPending(); const error = caughtError( () => setup.router.dispatch({ route: 'agent', rpc: 'kick_server', data: kickData }), ); assert.ok(error instanceof RuntimeSessionFault); assert.deepEqual(error.errors, [disposeError, invalidateError, stopError]); assert.equal(setup.gameSession.state, 'idle'); assert.equal(setup.session.kicked, true); assert.equal(setup.session.state, 'kicked'); assert.equal(setup.session.loginPending, false); assert.equal(setup.getDisposals(), 1); assert.equal(setup.getInvalidations(), 1); assert.equal(setup.connection.stopCalls.length, 1); assert.equal(setup.connection.stopCalls[0], kickData); assert.equal( setup.router.dispatch({ route: 'agent', rpc: 'kick_server', data: kickData }), 'handled-platform', ); assert.equal(setup.getDisposals(), 1); assert.equal(setup.getInvalidations(), 1); assert.equal(setup.connection.stopCalls.length, 1); });