import assert from 'node:assert/strict'; import { test } from 'node:test'; import type { GameEntry, GameHost, GameModule, GameServerMessage, PlatformGameSnapshot, PlatformToGameEvent, } from '../../YouleNexus/assets/framework/sdk/contracts/index.ts'; import { assertGameEntry, GameSessionHost, } from '../../YouleNexus/assets/framework/sdk/runtime/game-session-host.ts'; function makeHost(): GameHost { const snapshot: PlatformGameSnapshot = { connection: { phase: 'connected' }, self: { playerId: 1, seat: 0 }, room: { roomcode: 'fixture', roomtype: [], stage: 0, needprepare: 0, infinite: 0 }, seats: [], }; return { seat: { toView: (seat) => seat, toServer: (seat) => seat }, getSnapshot: () => snapshot, subscribe: () => () => {}, sendGameMessage: () => {}, execute: () => {}, }; } function makeEntry(createModule: () => GameModule): GameEntry { return { key: 'fixture', gameId: 41, route: 'fixture-route', resolveSeatCount: () => 4, createModule, }; } function makeModule(log: string[], onRestore?: (deskinfo: unknown) => void): GameModule { return { attach: () => log.push('attach'), handlePlatformEvent: (event) => log.push(`event:${event.type}`), handleGameMessage: (message) => log.push(`message:${message.rpc}`), restore: (deskinfo) => { log.push('restore'); onRestore?.(deskinfo); }, dispose: () => log.push('dispose'), }; } test('assertGameEntry rejects invalid identity, routes, seat counts, and module factories', () => { const entry = makeEntry(() => makeModule([])); assert.throws(() => assertGameEntry({ ...entry, key: '' }), /key/); assert.throws(() => assertGameEntry({ ...entry, gameId: '' }), /gameId/); for (const route of ['platform', 'agent', 'room']) { assert.throws(() => assertGameEntry({ ...entry, route }), /reserved.*route/); } assert.throws(() => assertGameEntry({ ...entry, resolveSeatCount: () => 0 }), /seat count/); assert.throws(() => assertGameEntry({ ...entry, resolveSeatCount: () => 2.5 }), /seat count/); assert.throws(() => assertGameEntry({ ...entry, createModule: () => undefined as never }), /module/); }); test('GameSessionHost delivers room events and deskinfo to one active module in order', () => { const log: string[] = []; const deskinfo = { turn: 7 }; let restoredValue: unknown; const sessions = new GameSessionHost(makeEntry(() => makeModule(log, (value) => { restoredValue = value; }))); sessions.open(makeHost()); sessions.publish({ type: 'room.entered', roomtype: ['raw', { players: 4 }] }); sessions.restore(deskinfo); sessions.close(); assert.deepEqual(log, ['attach', 'event:room.entered', 'restore', 'dispose']); assert.equal(restoredValue, deskinfo); assert.equal(sessions.state, 'idle'); }); test('GameSessionHost preserves raw platform events and game messages by identity', () => { let receivedEvent: PlatformToGameEvent | undefined; let receivedMessage: GameServerMessage | undefined; const sessions = new GameSessionHost(makeEntry(() => ({ attach() {}, handlePlatformEvent(event) { receivedEvent = event; }, handleGameMessage(message) { receivedMessage = message; }, restore() {}, dispose() {}, }))); const rawRoomtype = ['raw', { players: 4 }]; const rawData = { cards: [3, 7] }; const message: GameServerMessage = { rpc: 'draw', data: rawData }; const event: PlatformToGameEvent = { type: 'room.entered', roomtype: rawRoomtype }; sessions.open(makeHost()); sessions.publish(event); sessions.dispatchGameMessage('fixture-route', message); assert.equal(receivedEvent, event); assert.equal(receivedEvent?.roomtype, rawRoomtype); assert.equal(receivedMessage, message); assert.equal(receivedMessage?.data, rawData); }); test('GameSessionHost rejects illegal state transitions and mismatched game routes', () => { const log: string[] = []; const sessions = new GameSessionHost(makeEntry(() => makeModule(log))); const message: GameServerMessage = { rpc: 'draw', data: { card: 3 } }; assert.throws(() => sessions.publish({ type: 'room.dissolved' }), /active/); assert.throws(() => sessions.restore({}), /active/); sessions.open(makeHost()); assert.throws(() => sessions.open(makeHost()), /open/); assert.throws(() => sessions.dispatchGameMessage('other-route', message), /route/); sessions.dispatchGameMessage('fixture-route', message); assert.deepEqual(log, ['attach', 'message:draw']); }); test('GameSessionHost disposes a module exactly once and becomes terminal after dispose', () => { const log: string[] = []; const sessions = new GameSessionHost(makeEntry(() => makeModule(log))); sessions.open(makeHost()); sessions.dispose(); sessions.dispose(); assert.deepEqual(log, ['attach', 'dispose']); assert.equal(sessions.state, 'disposed'); assert.throws(() => sessions.open(makeHost()), /disposed/); assert.throws(() => sessions.publish({ type: 'room.dissolved' }), /disposed/); }); test('GameSessionHost creates a fresh module after close', () => { const log: string[] = []; const modules: GameModule[] = []; const sessions = new GameSessionHost(makeEntry(() => { const module = makeModule(log); modules.push(module); return module; })); sessions.open(makeHost()); sessions.close(); sessions.open(makeHost()); sessions.close(); assert.notEqual(modules[modules.length - 1], modules[modules.length - 2]); assert.deepEqual(log, ['attach', 'dispose', 'attach', 'dispose']); }); test('GameSessionHost returns to active after a restore handler throws', () => { const log: string[] = []; const sessions = new GameSessionHost(makeEntry(() => ({ ...makeModule(log), restore: () => { throw new Error('restore failed'); }, }))); sessions.open(makeHost()); assert.throws(() => sessions.restore({}), /restore failed/); assert.equal(sessions.state, 'active'); });