Files
youle_cocos/cocoscreator_projects/framework-tests/sdk/game-session-host.test.ts
T

431 lines
15 KiB
TypeScript

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<string, unknown>;
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');
});