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('GameSessionOpenFault locks its brand and frozen error tuple against later mutation', () => { const primary = new Error('primary'); const cleanup = new Error('cleanup'); const replacement = new Error('replacement'); const fault = new GameSessionOpenFault(primary, cleanup); const brand = Symbol.for(OPEN_FAULT_BRAND_KEY); const originalErrors = fault.errors; assert.deepEqual(Object.getOwnPropertyDescriptor(fault, brand), { value: true, writable: false, enumerable: false, configurable: false, }); assert.deepEqual(Object.getOwnPropertyDescriptor(fault, 'errors'), { value: originalErrors, writable: false, enumerable: true, configurable: false, }); assert.equal(Object.isFrozen(originalErrors), true); assert.throws(() => { (fault as unknown as { errors: unknown[] }).errors = [replacement, cleanup]; }, TypeError); assert.throws(() => { (originalErrors as unknown as unknown[])[0] = replacement; }, TypeError); assert.throws(() => { Object.defineProperty(originalErrors, Symbol.iterator, { value: function* hostileIterator() { yield replacement; }, }); }, TypeError); assert.throws(() => { (fault as unknown as { [key: symbol]: unknown })[brand] = false; }, TypeError); assert.equal(fault.errors, originalErrors); assert.equal(fault.errors[0], primary); assert.equal(fault.errors[1], cleanup); assert.equal((fault as unknown as { [key: symbol]: unknown })[brand], true); }); test('open fault extractor snapshots branded faults across realms without reading hostile hooks', () => { type OpenFaultShape = { readonly errors: readonly unknown[] }; type OpenFaultGuard = (value: unknown) => value is OpenFaultShape; type OpenFaultExtractor = (value: unknown) => readonly [unknown, unknown] | null; const runtimeExports = gameSessionRuntime as unknown as Record; const guardExport = runtimeExports.isGameSessionOpenFault; const extractorExport = runtimeExports.extractGameSessionOpenFaultErrors; assert.equal(typeof guardExport, 'function'); assert.equal(typeof extractorExport, 'function'); const isGameSessionOpenFault = guardExport as OpenFaultGuard; const extractGameSessionOpenFaultErrors = extractorExport as OpenFaultExtractor; const primary = new Error('primary'); const cleanup = new Error('cleanup'); const sameRealm = new GameSessionOpenFault(primary, cleanup); class OpenFaultSubclass extends GameSessionOpenFault {} const subclass = new OpenFaultSubclass(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 duplicateModuleLike = new Error('duplicate module open fault'); Object.defineProperties(duplicateModuleLike, { [Symbol.for(OPEN_FAULT_BRAND_KEY)]: { value: true }, errors: { value: [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] }, }); const iteratorReplacement = new Error('iterator replacement'); let iteratorReads = 0; const hostileErrors = [primary, cleanup]; Object.defineProperty(hostileErrors, Symbol.iterator, { configurable: true, get() { iteratorReads += 1; hostileErrors[0] = iteratorReplacement; throw new Error('source iterator must not run'); }, }); const hostileIteratorFault = new Error('hostile iterator fault'); Object.defineProperties(hostileIteratorFault, { [Symbol.for(OPEN_FAULT_BRAND_KEY)]: { value: true }, errors: { value: hostileErrors }, }); const descriptorTarget = new Error('descriptor failure'); Object.defineProperties(descriptorTarget, { [Symbol.for(OPEN_FAULT_BRAND_KEY)]: { value: true }, errors: { value: [primary, cleanup] }, }); let descriptorReads = 0; const descriptorFailure = new Proxy(descriptorTarget, { getOwnPropertyDescriptor(target, key) { descriptorReads += 1; if (key === 'errors') throw new Error('descriptor trap failed'); return Reflect.getOwnPropertyDescriptor(target, key); }, }); assert.equal(foreignRealm instanceof GameSessionOpenFault, false); for (const candidate of [sameRealm, subclass, foreignRealm, duplicateModuleLike]) { const snapshot = extractGameSessionOpenFaultErrors(candidate); assert.notEqual(snapshot, null); assert.equal(Object.isFrozen(snapshot), true); assert.equal(snapshot?.[0], primary); assert.equal(snapshot?.[1], cleanup); assert.equal(isGameSessionOpenFault(candidate), true); } const hostileSnapshot = extractGameSessionOpenFaultErrors(hostileIteratorFault); assert.notEqual(hostileSnapshot, null); assert.equal(hostileSnapshot?.[0], primary); assert.equal(hostileSnapshot?.[1], cleanup); assert.equal(Object.isFrozen(hostileSnapshot), true); assert.equal(iteratorReads, 0); assert.equal(hostileErrors[0], primary); for (const candidate of [ ordinaryAggregate, unbranded, accessorSpoof, brandAccessorSpoof, ]) { assert.equal(extractGameSessionOpenFaultErrors(candidate), null); assert.equal(isGameSessionOpenFault(candidate), false); } assert.equal(extractGameSessionOpenFaultErrors(descriptorFailure), null); assert.equal(descriptorReads, 2); 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'); });