517 lines
18 KiB
TypeScript
517 lines
18 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, resolver presence, 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: undefined as never }), /resolveSeatCount/);
|
|
assert.throws(() => assertGameEntry({ ...entry, createModule: () => undefined as never }), /module/);
|
|
});
|
|
|
|
test('GameEntry preflight does not call a resolver before its game-owned roomtype exists', () => {
|
|
const calls: Array<readonly unknown[]> = [];
|
|
const entry = {
|
|
...makeEntry(() => makeModule([])),
|
|
resolveSeatCount(roomtype: readonly unknown[]) {
|
|
calls.push(roomtype);
|
|
if (roomtype[1] !== 4) throw new Error('Game roomtype requires four seats at index 1');
|
|
return 4;
|
|
},
|
|
};
|
|
assert.doesNotThrow(() => new GameSessionHost(entry));
|
|
assert.deepEqual(calls, []);
|
|
});
|
|
|
|
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');
|
|
});
|
|
|
|
for (const callback of ['attach', 'restore'] as const) {
|
|
for (const throwsAfterStop of [false, true]) {
|
|
test(`terminal dispose during ${callback} survives callback completion (throws=${throwsAfterStop})`, () => {
|
|
let sessions: GameSessionHost;
|
|
let disposals = 0;
|
|
const failure = new Error('callback failed after disposal');
|
|
sessions = new GameSessionHost(makeEntry(() => ({
|
|
...makeModule([]),
|
|
[callback]() {
|
|
sessions.dispose();
|
|
sessions.dispose();
|
|
assert.equal(sessions.state, 'disposed');
|
|
if (throwsAfterStop) throw failure;
|
|
},
|
|
dispose() { disposals += 1; },
|
|
})));
|
|
disposals = 0;
|
|
const invoke = () => {
|
|
sessions.open(makeHost());
|
|
if (callback === 'restore') sessions.restore({ round: 1 });
|
|
};
|
|
if (throwsAfterStop) assert.equal(caughtError(invoke), failure);
|
|
else assert.doesNotThrow(invoke);
|
|
assert.equal(disposals, 1);
|
|
assert.equal(sessions.state, 'disposed');
|
|
sessions.dispose();
|
|
assert.equal(disposals, 1);
|
|
assert.throws(() => sessions.open(makeHost()), /disposed/);
|
|
assert.throws(() => sessions.restore({}), /disposed/);
|
|
});
|
|
}
|
|
}
|
|
|
|
for (const release of ['close', 'dispose'] as const) {
|
|
test(`${release} tolerates module.dispose reentering terminal disposal`, () => {
|
|
let sessions: GameSessionHost | undefined;
|
|
let disposals = 0;
|
|
sessions = new GameSessionHost(makeEntry(() => ({
|
|
...makeModule([]),
|
|
dispose() {
|
|
if (sessions === undefined) return;
|
|
disposals += 1;
|
|
sessions.dispose();
|
|
},
|
|
})));
|
|
sessions.open(makeHost());
|
|
assert.doesNotThrow(() => sessions![release]());
|
|
assert.equal(sessions.state, 'disposed');
|
|
assert.equal(disposals, 1);
|
|
sessions.dispose();
|
|
assert.equal(disposals, 1);
|
|
});
|
|
}
|
|
|
|
test('terminal disposal during module creation cleans the returned module without attaching it', () => {
|
|
let sessions: GameSessionHost | undefined;
|
|
let attaches = 0;
|
|
let disposals = 0;
|
|
sessions = new GameSessionHost(makeEntry(() => {
|
|
if (sessions !== undefined) sessions.dispose();
|
|
return {
|
|
...makeModule([]),
|
|
attach() { attaches += 1; },
|
|
dispose() { disposals += 1; },
|
|
};
|
|
}));
|
|
disposals = 0;
|
|
assert.doesNotThrow(() => sessions!.open(makeHost()));
|
|
assert.equal(disposals, 1);
|
|
assert.equal(attaches, 0);
|
|
assert.equal(sessions.state, 'disposed');
|
|
});
|