545 lines
19 KiB
TypeScript
545 lines
19 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 denials: 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,
|
|
onServerDenial: outcome => { denials.push(outcome); },
|
|
createGameHostLease: () => ({
|
|
host: {
|
|
seat: { toView: (seat) => seat, toServer: (seat) => seat },
|
|
getSnapshot: () => selectGameSnapshot(store.getState()),
|
|
subscribe: () => () => {},
|
|
sendGameMessage() {},
|
|
execute() {},
|
|
},
|
|
invalidate() {},
|
|
}),
|
|
});
|
|
return { store, gameSession, session, log, restored, events, denials };
|
|
}
|
|
|
|
test('server denials preserve an active room and module without committing a success action', () => {
|
|
const setup = makeSession();
|
|
setup.session.handleLogin(fixture('player-login-room.json'));
|
|
const before = setup.store.getState();
|
|
setup.log.length = 0;
|
|
const joinDenial = { state: 99, roomcode: '999999', showerror: 1, error: 'Room does not exist' };
|
|
const loginDenial = { state: 1, error: 'Login denied' };
|
|
setup.session.handleSelfJoin(joinDenial);
|
|
setup.session.markLoginPending();
|
|
setup.session.handleLogin(loginDenial);
|
|
assert.equal(setup.store.getState(), before);
|
|
assert.equal(setup.session.loginPending, false);
|
|
assert.equal(setup.session.state, 'operational');
|
|
assert.equal(setup.gameSession.state, 'active');
|
|
assert.deepEqual(setup.log, []);
|
|
assert.deepEqual(setup.denials, [
|
|
{ rpc: 'self_join_room', state: 99, data: joinDenial },
|
|
{ rpc: 'player_login', state: 1, data: loginDenial },
|
|
]);
|
|
});
|
|
|
|
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,
|
|
onServerDenial() { assert.fail('Unexpected server denial'); },
|
|
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);
|
|
});
|