Files
youle_cocos/cocoscreator_projects/framework-tests/platform/runtime-session.test.ts
T

521 lines
18 KiB
TypeScript

import { readFileSync } from 'node:fs';
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { runInNewContext } from 'node:vm';
import type {
GameEntry,
GameHost,
GameModule,
PlatformGameSnapshot,
PlatformToGameEvent,
} from '../../YouleNexus/assets/framework/sdk/contracts/index.ts';
import {
GameSessionHost,
GameSessionOpenFault,
} from '../../YouleNexus/assets/framework/sdk/runtime/game-session-host.ts';
import { RuntimeSession } from '../../YouleNexus/assets/framework/platform/runtime-session.ts';
import { PlatformStore } from '../../YouleNexus/assets/framework/platform/stores/platform-store.ts';
import { selectGameSnapshot } from '../../YouleNexus/assets/framework/platform/stores/selectors.ts';
function fixture(name: string): Record<string, unknown> {
return JSON.parse(readFileSync(new URL(`../fixtures/contracts/${name}`, import.meta.url), 'utf8')).data;
}
function makeSession() {
const store = new PlatformStore();
const log: string[] = [];
const restored: unknown[] = [];
const events: Array<{ event: PlatformToGameEvent; snapshot: PlatformGameSnapshot }> = [];
let host: GameHost;
const entry: GameEntry = {
key: 'fixture',
gameId: 41,
route: 'fixture-game',
resolveSeatCount: () => 4,
createModule: (): GameModule => ({
attach(attachedHost) {
host = attachedHost;
attachedHost.getSnapshot();
log.push('attach');
},
handlePlatformEvent(event) {
events.push({ event, snapshot: host.getSnapshot() });
log.push(`event:${event.type}`);
},
handleGameMessage() {},
restore(deskinfo) {
restored.push(deskinfo);
log.push('restore');
},
dispose() { log.push('dispose'); },
}),
};
const gameSession = new GameSessionHost(entry);
log.length = 0;
const session = new RuntimeSession({
store,
gameSession,
createGameHostLease: () => ({
host: {
seat: { toView: (seat) => seat, toServer: (seat) => seat },
getSnapshot: () => selectGameSnapshot(store.getState()),
subscribe: () => () => {},
sendGameMessage() {},
execute() {},
},
invalidate() {},
}),
});
return { store, gameSession, session, log, restored, events };
}
test('RuntimeSession commits a room login before opening, publishing, and restoring deskinfo', () => {
const setup = makeSession();
const data = fixture('player-login-room.json');
setup.store.subscribe(() => { setup.log.push('store'); });
setup.session.markLoginPending();
setup.session.handleLogin(data);
assert.equal(setup.session.loginPending, false);
assert.equal(setup.store.getState().room.kind, 'inside');
assert.equal('deskinfo' in setup.store.getState().room, false);
assert.equal(setup.restored.length, 1);
assert.equal(setup.restored[0], data.deskinfo);
assert.deepEqual(setup.log, ['store', 'attach', 'event:room.entered', 'restore']);
assert.equal(setup.events[0]?.snapshot.room.roomcode, '100001');
assert.equal(setup.events[0]?.event.type, 'room.entered');
if (setup.events[0]?.event.type === 'room.entered') {
assert.equal(setup.events[0].event.roomtype, setup.events[0].snapshot.room.roomtype);
}
});
test('RuntimeSession never treats isbattle as a restore signal without truthy deskinfo', () => {
for (const deskinfo of [undefined, null, false, 0, '']) {
const setup = makeSession();
const data = fixture('player-login-room.json');
if (deskinfo === undefined) delete data.deskinfo;
else data.deskinfo = deskinfo;
data.isbattle = 1;
setup.session.handleLogin(data);
assert.deepEqual(setup.restored, [], `deskinfo=${String(deskinfo)}`);
assert.equal(setup.store.getState().room.kind, 'inside');
}
});
test('RuntimeSession self join commits, opens a fresh session, and restores the same snapshot once', () => {
const setup = makeSession();
setup.session.handleLogin(fixture('player-login-success.json'));
setup.log.length = 0;
const join = fixture('self-join-room.json');
setup.store.subscribe(() => { setup.log.push('store'); });
setup.session.handleSelfJoin(join);
assert.equal(setup.store.getState().room.kind, 'inside');
assert.equal(setup.restored.length, 1);
assert.equal(setup.restored[0], join.deskinfo);
assert.deepEqual(setup.log, ['store', 'attach', 'event:room.entered', 'restore']);
});
test('RuntimeSession self join honors the parser-owned deskwar and deskinfo matrix', () => {
const cases = [
{ deskwar: 'missing', deskinfo: false, expectedStage: 0 },
{ deskwar: 0, deskinfo: false, expectedStage: 0 },
{ deskwar: 1, deskinfo: false, expectedStage: 1 },
{ deskwar: 'missing', deskinfo: true, expectedStage: 1 },
{ deskwar: 0, deskinfo: true, expectedStage: 1 },
{ deskwar: 1, deskinfo: true, expectedStage: 1 },
] as const;
for (const row of cases) {
const setup = makeSession();
setup.session.handleLogin(fixture('player-login-success.json'));
const data = fixture('self-join-room.json');
if (row.deskwar === 'missing') delete data.deskwar;
else data.deskwar = row.deskwar;
const deskinfo = { round: 7 };
if (row.deskinfo) data.deskinfo = deskinfo;
else delete data.deskinfo;
data.isbattle = 1;
setup.session.handleSelfJoin(data);
const state = setup.store.getState();
assert.equal(state.room.kind, 'inside');
if (state.room.kind === 'inside') {
assert.equal(state.room.stage, row.expectedStage, JSON.stringify(row));
}
assert.equal(setup.restored.length, row.deskinfo ? 1 : 0, JSON.stringify(row));
if (row.deskinfo) assert.equal(setup.restored[0], deskinfo);
}
});
type LeaseFailurePoint =
| 'lease-factory'
| 'module-create'
| 'attach'
| 'event'
| 'restore'
| 'dispose'
| 'invalidate';
interface LeaseRecord {
readonly host: GameHost;
invalidations: number;
}
function makeLeaseSession() {
const store = new PlatformStore();
const leases: LeaseRecord[] = [];
const attachedHosts: GameHost[] = [];
const failures = new Set<LeaseFailurePoint>();
const errors = Object.fromEntries([
'lease-factory', 'module-create', 'attach', 'event', 'restore', 'dispose', 'invalidate',
].map((point) => [point, new Error(`failure:${point}`)])) as Record<LeaseFailurePoint, Error>;
let validatingEntry = true;
let moduleDisposals = 0;
function host(): GameHost {
return {
seat: { toView: (seat) => seat, toServer: (seat) => seat },
getSnapshot: () => selectGameSnapshot(store.getState()),
subscribe: () => () => {},
sendGameMessage() {},
execute() {},
};
}
const entry: GameEntry = {
key: 'lease-fixture',
gameId: 41,
route: 'fixture-game',
resolveSeatCount: () => 4,
createModule: (): GameModule => {
if (!validatingEntry && failures.has('module-create')) throw errors['module-create'];
return {
attach(attachedHost) {
if (!validatingEntry) attachedHosts.push(attachedHost);
if (!validatingEntry && failures.has('attach')) throw errors.attach;
},
handlePlatformEvent() {
if (!validatingEntry && failures.has('event')) throw errors.event;
},
handleGameMessage() {},
restore() {
if (!validatingEntry && failures.has('restore')) throw errors.restore;
},
dispose() {
if (validatingEntry) return;
moduleDisposals += 1;
if (failures.has('dispose')) throw errors.dispose;
},
};
},
};
const gameSession = new GameSessionHost(entry);
validatingEntry = false;
const sessionOptions = {
store,
gameSession,
createGameHostLease() {
if (failures.has('lease-factory')) throw errors['lease-factory'];
const record: LeaseRecord = { host: host(), invalidations: 0 };
leases.push(record);
return {
host: record.host,
invalidate() {
record.invalidations += 1;
if (failures.has('invalidate')) throw errors.invalidate;
},
};
},
};
const session = new RuntimeSession(sessionOptions);
return {
store,
gameSession,
session,
leases,
attachedHosts,
errors,
setFailures(...points: LeaseFailurePoint[]) {
failures.clear();
for (const point of points) failures.add(point);
},
getModuleDisposals: () => moduleDisposals,
};
}
function caughtError(operation: () => void): Error {
try {
operation();
} catch (error) {
assert.ok(error instanceof Error);
return error;
}
assert.fail('expected operation to throw');
}
test('RuntimeSession creates a fresh host lease per login/self-join replacement and invalidates on self exit', () => {
const setup = makeLeaseSession();
setup.session.handleLogin(fixture('player-login-room.json'));
setup.session.handleSelfJoin(fixture('self-join-room.json'));
setup.session.handleLogin(fixture('player-login-room.json'));
assert.equal(setup.leases.length, 3);
assert.equal(new Set(setup.attachedHosts).size, 3);
assert.deepEqual(setup.leases.map((lease) => lease.invalidations), [1, 1, 0]);
assert.equal(setup.attachedHosts[0], setup.leases[0]?.host);
assert.equal(setup.attachedHosts[1], setup.leases[1]?.host);
assert.equal(setup.attachedHosts[2], setup.leases[2]?.host);
setup.session.handleSelfExit({ raw: {} });
assert.deepEqual(setup.leases.map((lease) => lease.invalidations), [1, 1, 1]);
assert.equal(setup.gameSession.state, 'idle');
assert.equal(setup.session.state, 'operational');
});
test('RuntimeSession invalidates the active lease on a successful server switch', () => {
const setup = makeLeaseSession();
setup.session.handleLogin(fixture('player-login-room.json'));
setup.session.handleServerSwitch(() => {});
assert.equal(setup.leases[0]?.invalidations, 1);
assert.equal(setup.gameSession.state, 'idle');
assert.equal(setup.session.state, 'operational');
});
for (const failure of [
'lease-factory', 'module-create', 'attach', 'event', 'restore',
] as const) {
test(`RuntimeSession cleans a failed room open and enters terminal fault at ${failure}`, () => {
const setup = makeLeaseSession();
setup.setFailures(failure);
const error = caughtError(() => setup.session.handleLogin(fixture('player-login-room.json')));
assert.equal(setup.session.state, 'faulted');
assert.equal(setup.session.fault, error);
assert.equal(setup.gameSession.state, 'idle');
if (failure === 'lease-factory') {
assert.equal(setup.leases.length, 0);
} else {
assert.equal(setup.leases.length, 1);
assert.equal(setup.leases[0]?.invalidations, 1);
}
assert.throws(
() => setup.session.handleLogin(fixture('player-login-success.json')),
/fault/i,
);
});
}
for (const replacement of ['login', 'self-join'] as const) {
test(`RuntimeSession invalidates the old lease and faults when ${replacement} replacement disposal fails`, () => {
const setup = makeLeaseSession();
setup.session.handleLogin(fixture('player-login-room.json'));
setup.setFailures('dispose');
const error = caughtError(() => {
if (replacement === 'login') setup.session.handleLogin(fixture('player-login-room.json'));
else setup.session.handleSelfJoin(fixture('self-join-room.json'));
});
assert.equal(setup.session.state, 'faulted');
assert.equal(setup.session.fault, error);
assert.equal(setup.gameSession.state, 'idle');
assert.equal(setup.leases.length, 1, 'a failed old-session cleanup must not create a new lease');
assert.equal(setup.leases[0]?.invalidations, 1);
assert.equal(setup.getModuleDisposals(), 1);
});
}
for (const operation of ['self-exit', 'switch'] as const) {
test(`RuntimeSession invalidates once and faults when ${operation} disposal fails`, () => {
const setup = makeLeaseSession();
setup.session.handleLogin(fixture('player-login-room.json'));
setup.setFailures('dispose');
const error = caughtError(() => {
if (operation === 'self-exit') setup.session.handleSelfExit({ raw: {} });
else setup.session.handleServerSwitch(() => {});
});
assert.equal(setup.session.state, 'faulted');
assert.equal(setup.session.fault, error);
assert.equal(setup.gameSession.state, 'idle');
assert.equal(setup.leases[0]?.invalidations, 1);
assert.equal(setup.getModuleDisposals(), 1);
});
}
test('RuntimeSession aggregates event, dispose, and invalidation failures after completing cleanup', () => {
const setup = makeLeaseSession();
setup.setFailures('event', 'dispose', 'invalidate');
const error = caughtError(() => setup.session.handleLogin(fixture('player-login-room.json')));
assert.equal(setup.session.state, 'faulted');
assert.equal(setup.session.fault, error);
assert.equal(setup.gameSession.state, 'idle');
assert.equal(setup.leases[0]?.invalidations, 1);
assert.deepEqual(
(error as Error & { errors?: readonly unknown[] }).errors,
[setup.errors.event, setup.errors.dispose, setup.errors.invalidate],
);
});
test('RuntimeSession flattens attach cleanup failures before appending lease invalidation', () => {
const setup = makeLeaseSession();
setup.setFailures('attach', 'dispose', 'invalidate');
const error = caughtError(() => setup.session.handleLogin(fixture('player-login-room.json')));
assert.equal(setup.session.state, 'faulted');
assert.equal(setup.session.fault, error);
assert.equal(setup.gameSession.state, 'idle');
assert.equal(setup.leases[0]?.invalidations, 1);
assert.equal(setup.getModuleDisposals(), 1);
assert.deepEqual(
(error as Error & { errors?: readonly unknown[] }).errors,
[setup.errors.attach, setup.errors.dispose, setup.errors.invalidate],
);
});
test('RuntimeSession flattens a foreign-realm branded open fault before lease invalidation', () => {
const setup = makeLeaseSession();
const primary = new Error('foreign primary');
const cleanup = new Error('foreign cleanup');
const foreignFault = runInNewContext(`(() => {
const fault = new Error('foreign open fault');
Object.defineProperties(fault, {
[Symbol.for('youle.framework.sdk.GameSessionOpenFault')]: { value: true },
errors: { value: [primary, cleanup] },
});
return fault;
})()`, { primary, cleanup });
assert.equal(foreignFault instanceof GameSessionOpenFault, false);
setup.setFailures('invalidate');
setup.gameSession.open = () => { throw foreignFault; };
const error = caughtError(() => setup.session.handleLogin(fixture('player-login-room.json')));
assert.equal(setup.session.state, 'faulted');
assert.equal(setup.session.fault, error);
assert.equal(setup.gameSession.state, 'idle');
assert.equal(setup.leases[0]?.invalidations, 1);
assert.deepEqual(
(error as Error & { errors?: readonly unknown[] }).errors,
[primary, cleanup, setup.errors.invalidate],
);
});
test('RuntimeSession snapshots branded open errors without invoking their hostile iterator', () => {
const setup = makeLeaseSession();
const iteratorReplacement = new Error('iterator replacement');
let iteratorReads = 0;
const sourceErrors = [setup.errors.attach, setup.errors.dispose];
Object.defineProperty(sourceErrors, Symbol.iterator, {
configurable: true,
get() {
iteratorReads += 1;
sourceErrors[0] = iteratorReplacement;
throw new Error('source iterator must not run');
},
});
const brandedFault = new Error('hostile branded open fault');
Object.defineProperties(brandedFault, {
[Symbol.for('youle.framework.sdk.GameSessionOpenFault')]: { value: true },
errors: { value: sourceErrors },
});
setup.setFailures('invalidate');
setup.gameSession.open = () => { throw brandedFault; };
const error = caughtError(() => setup.session.handleLogin(fixture('player-login-room.json')));
assert.equal(setup.session.state, 'faulted');
assert.equal(setup.session.fault, error);
assert.equal(setup.gameSession.state, 'idle');
assert.equal(setup.leases[0]?.invalidations, 1);
assert.equal(iteratorReads, 0);
assert.equal(sourceErrors[0], setup.errors.attach);
assert.deepEqual(
(error as Error & { errors?: readonly unknown[] }).errors,
[setup.errors.attach, setup.errors.dispose, setup.errors.invalidate],
);
});
test('RuntimeSession keeps ordinary aggregates and unbranded or accessor spoofs as single errors', () => {
const primary = new Error('user primary');
const cleanup = new Error('user 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('youle.framework.sdk.GameSessionOpenFault')]: { 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('youle.framework.sdk.GameSessionOpenFault')]: {
get() {
accessorReads += 1;
throw new Error('brand getter must not run');
},
},
errors: { value: [primary, cleanup] },
});
const descriptorTarget = new Error('descriptor failure');
Object.defineProperties(descriptorTarget, {
[Symbol.for('youle.framework.sdk.GameSessionOpenFault')]: { 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);
},
});
for (const candidate of [
ordinaryAggregate,
unbranded,
accessorSpoof,
brandAccessorSpoof,
descriptorFailure,
]) {
const setup = makeLeaseSession();
setup.setFailures('invalidate');
setup.gameSession.open = () => { throw candidate; };
const error = caughtError(() => setup.session.handleLogin(fixture('player-login-room.json')));
const errors = (error as Error & { errors?: readonly unknown[] }).errors;
assert.equal(errors?.length, 2);
assert.equal(errors?.[0], candidate);
assert.equal(errors?.[1], setup.errors.invalidate);
assert.equal(setup.session.state, 'faulted');
assert.equal(setup.gameSession.state, 'idle');
assert.equal(setup.leases[0]?.invalidations, 1);
}
assert.equal(accessorReads, 0);
assert.equal(descriptorReads, 2);
});