import assert from 'node:assert/strict'; import { test } from 'node:test'; import { runInNewContext } from 'node:vm'; import type { GameEntry, GameHost, GameModule, GameServerMessage, PlatformGameSnapshot, PlatformToGameEvent, } from '../../YouleNexus/assets/framework/sdk/contracts/index.ts'; import { assertGameEntry, GameSessionHost, GameSessionOpenFault, } from '../../YouleNexus/assets/framework/sdk/runtime/game-session-host.ts'; import * as gameSessionRuntime from '../../YouleNexus/assets/framework/sdk/runtime/game-session-host.ts'; const OPEN_FAULT_BRAND_KEY = 'youle.framework.sdk.GameSessionOpenFault'; 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 caughtError(operation: () => void): Error { try { operation(); } catch (error) { assert.ok(error instanceof Error); return error; } assert.fail('expected operation to throw'); } function makeModule(log: string[], onRestore?: (deskinfo: unknown) => void): GameModule { let attached = false; return { attach: () => { attached = true; 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: () => { if (attached) 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('assertGameEntry disposes its validation module exactly once', () => { let disposals = 0; const entry = makeEntry(() => ({ ...makeModule([]), dispose: () => { disposals++; }, })); assert.doesNotThrow(() => assertGameEntry(entry)); assert.equal(disposals, 1); }); 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 when attachment throws and returns to idle', () => { let disposals = 0; const sessions = new GameSessionHost(makeEntry(() => ({ ...makeModule([]), attach: () => { throw new Error('attach failed'); }, dispose: () => { disposals++; }, }))); disposals = 0; assert.throws(() => sessions.open(makeHost()), /attach failed/); assert.equal(disposals, 1); assert.equal(sessions.state, 'idle'); }); test('GameSessionHost preserves create and contract failures when open cleanup succeeds', () => { const createError = new Error('create failed'); let createCalls = 0; const createSessions = new GameSessionHost(makeEntry(() => { createCalls += 1; if (createCalls === 1) return makeModule([]); throw createError; })); assert.equal(caughtError(() => createSessions.open(makeHost())), createError); assert.equal(createSessions.state, 'idle'); let contractCalls = 0; let contractDisposals = 0; const contractSessions = new GameSessionHost(makeEntry(() => { contractCalls += 1; if (contractCalls === 1) return makeModule([]); return { handlePlatformEvent() {}, handleGameMessage() {}, restore() {}, dispose() { contractDisposals += 1; }, } as unknown as GameModule; })); assert.match(caughtError(() => contractSessions.open(makeHost())).message, /implement attach/); assert.equal(contractDisposals, 1); assert.equal(contractSessions.state, 'idle'); }); test('GameSessionHost preserves attach then dispose failures by identity and occurrence order', () => { const attachError = new Error('attach primary'); const disposeError = new Error('open cleanup'); let createCalls = 0; let disposals = 0; const sessions = new GameSessionHost(makeEntry(() => { createCalls += 1; if (createCalls === 1) return makeModule([]); return { ...makeModule([]), attach() { throw attachError; }, dispose() { disposals += 1; throw disposeError; }, }; })); const error = caughtError(() => sessions.open(makeHost())); assert.deepEqual( (error as Error & { errors?: readonly unknown[] }).errors, [attachError, disposeError], ); assert.equal(disposals, 1); assert.equal(sessions.state, 'idle'); }); test('open fault guard recognizes the global brand across realms without reading accessors', () => { type OpenFaultShape = { readonly errors: readonly unknown[] }; type OpenFaultGuard = (value: unknown) => value is OpenFaultShape; const exported = (gameSessionRuntime as unknown as Record) .isGameSessionOpenFault; assert.equal(typeof exported, 'function'); const isGameSessionOpenFault = exported as OpenFaultGuard; const primary = new Error('primary'); const cleanup = new Error('cleanup'); const sameRealm = new GameSessionOpenFault(primary, cleanup); const foreignRealm = runInNewContext(`(() => { const fault = new Error('foreign open fault'); Object.defineProperties(fault, { [Symbol.for(${JSON.stringify(OPEN_FAULT_BRAND_KEY)})]: { value: true }, errors: { value: [primary, cleanup] }, }); return fault; })()`, { primary, cleanup }); const ordinaryAggregate = runInNewContext( 'new AggregateError([primary, cleanup], "user aggregate")', { primary, cleanup }, ); const unbranded = { errors: [primary, cleanup] }; let accessorReads = 0; const accessorSpoof = new Error('accessor spoof'); Object.defineProperties(accessorSpoof, { [Symbol.for(OPEN_FAULT_BRAND_KEY)]: { value: true }, errors: { get() { accessorReads += 1; throw new Error('errors getter must not run'); }, }, }); const brandAccessorSpoof = new Error('brand accessor spoof'); Object.defineProperties(brandAccessorSpoof, { [Symbol.for(OPEN_FAULT_BRAND_KEY)]: { get() { accessorReads += 1; throw new Error('brand getter must not run'); }, }, errors: { value: [primary, cleanup] }, }); assert.equal(foreignRealm instanceof GameSessionOpenFault, false); assert.equal(isGameSessionOpenFault(sameRealm), true); assert.equal(isGameSessionOpenFault(foreignRealm), true); assert.equal(isGameSessionOpenFault(ordinaryAggregate), false); assert.equal(isGameSessionOpenFault(unbranded), false); assert.equal(isGameSessionOpenFault(accessorSpoof), false); assert.equal(isGameSessionOpenFault(brandAccessorSpoof), false); assert.equal(accessorReads, 0); }); 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'); });