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 {OwnedRoomHistory} from '../../YouleNexus/assets/framework/adapters/native/owned-room-history.ts'; import { RuntimeSession, RuntimeSessionFault } 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 { return JSON.parse(readFileSync(new URL(`../fixtures/contracts/${name}`, import.meta.url), 'utf8')).data; } test('session fault message exposes the original cause and preserves error references',()=>{ const cause=new RangeError('GameEntry seat count 3 does not match room seat count 4'); const fault=new RuntimeSessionFault('login room open',[cause]); assert.match(fault.message,/login room open.*RangeError: GameEntry seat count 3 does not match room seat count 4/); assert.equal(fault.errors[0],cause); assert.ok(Object.isFrozen(fault.errors)); }); function makeSession(roomHistory?:OwnedRoomHistory) { 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({ roomHistory, 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:Record = {...fixture('self-join-room.json'),deskwar:0}; 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 && row.deskwar !== 1 ? 1 : 0, JSON.stringify(row)); if (row.deskinfo && row.deskwar !== 1) 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(); const errors = Object.fromEntries([ 'lease-factory', 'module-create', 'attach', 'event', 'restore', 'dispose', 'invalidate', ].map((point) => [point, new Error(`failure:${point}`)])) as Record; 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 (failures.has('module-create')) throw errors['module-create']; return { attach(attachedHost) { attachedHosts.push(attachedHost); if (failures.has('attach')) throw errors.attach; }, handlePlatformEvent() { if (failures.has('event')) throw errors.event; }, handleGameMessage() {}, restore() { if (failures.has('restore')) throw errors.restore; }, dispose() { moduleDisposals += 1; if (failures.has('dispose')) throw errors.dispose; }, }; }, }; const gameSession = new GameSessionHost(entry); 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); }); test('server room outcomes update owned room cache at the session boundary',()=>{ const values=new Map();const history=new OwnedRoomHistory({getItem:k=>values.get(k)??null,setItem:(k,v)=>{values.set(k,v);}},'g','a'); const setup=makeSession(history);setup.session.handleLogin({...fixture('player-login-room.json'),infinite:0}); const room=setup.store.getState().room;if(room.kind!=='inside')throw Error('fixture room'); setup.session.handleSelfExit({seat:room.selfSeat,raw:{isowner:1,roomcode:room.roomcode}}); assert.deepEqual(history.read(),[Number(room.roomcode)]); setup.session.handleSelfJoin({state:99,roomcode:room.roomcode});assert.deepEqual(history.read(),[]); }); test('create success uses login profile and entry seats without wire players, opens once and exits', () => { const setup=makeSession();setup.session.handleLogin(fixture('player-login-success.json')); const data={state:0,roomcode:'789123',seat:2,roomtype:[1,4],makewar:0,asetcount:8}; setup.store.subscribe(()=>setup.log.push('store')); setup.session.handleCreateRoom(data); const state=setup.store.getState(); assert.equal(state.room.kind,'inside'); if(state.room.kind!=='inside')throw Error('missing room'); assert.deepEqual(state.room.seatPlayerIds,[null,null,430511,null]); assert.equal(state.room.selfSeat,2);assert.equal(state.room.stage,0); assert.equal(state.room.needprepare,0);assert.equal(state.room.infinite,0); assert.deepEqual(state.room.readySeats,[]);assert.deepEqual(state.room.offlineSeats,[]); assert.equal(state.players.entities[430511].isprepare,0); assert.equal('players' in data,false); assert.deepEqual(setup.log,['store','attach','event:room.entered']); setup.session.handleCreateRoom({...data}); assert.equal(setup.store.getState(),state); assert.deepEqual(setup.log,['store','attach','event:room.entered']); setup.session.handleSelfExit({raw:{}}); assert.equal(setup.store.getState().room.kind,'outside'); assert.equal(setup.gameSession.state,'idle');assert.ok(setup.log.includes('dispose')); }); test('create denial never commits or instantiates a game; invalid seat fails before state mutation',()=>{ const setup=makeSession();setup.session.handleLogin(fixture('player-login-success.json')); const before=setup.store.getState(), denial={state:2,showerror:1,error:'denied'}; setup.session.handleCreateRoom(denial); assert.equal(setup.store.getState(),before);assert.deepEqual(setup.log,[]); assert.deepEqual(setup.denials,[{rpc:'create_room',state:2,data:denial}]); assert.throws(()=>setup.session.handleCreateRoom({state:0,roomcode:'123',seat:4,roomtype:[1]}),/seat/); assert.equal(setup.store.getState(),before); }); test('duplicate successful self join responses do not replace module or restore twice',()=>{ const setup=makeSession();setup.session.handleLogin(fixture('player-login-success.json')); const data={...fixture('self-join-room.json'),deskwar:0};setup.session.handleSelfJoin(data); const before=setup.store.getState(),log=[...setup.log]; setup.session.handleSelfJoin({...data}); assert.equal(setup.store.getState(),before);assert.deepEqual(setup.log,log); assert.equal(setup.restored.length,1); }); for (const mutate of [ (roomtype: readonly unknown[]) => { (roomtype as unknown[])[0] = 999; }, (roomtype: readonly unknown[]) => { (roomtype[1] as {count: number}).count = 999; }, ]) test('create seat resolver cannot mutate raw roomtype or partially commit a room', () => { const setup = makeSession(); setup.session.handleLogin(fixture('player-login-success.json')); const before = setup.store.getState(); const data = {state: 0, roomcode: '123456', seat: 0, roomtype: [1, {count: 4}]}; setup.gameSession.entry.resolveSeatCount = roomtype => { assert.ok(Array.isArray(roomtype)); mutate(roomtype); return 4; }; assert.throws(() => setup.session.handleCreateRoom(data), TypeError); assert.deepEqual(data.roomtype, [1, {count: 4}]); assert.equal(Object.isFrozen(data.roomtype), false); assert.equal(Object.isFrozen(data.roomtype[1]), false); assert.equal(setup.store.getState(), before); assert.equal(setup.gameSession.state, 'idle'); assert.deepEqual(setup.log, []); }); test('entry inputs preserve create/login/join sources and deskwar takes priority over deskinfo',()=>{ for(const kind of ['login-waiting','login-restored','joined-waiting','joined-snapshot','joined-started'] as const){ const setup=makeSession(); const login=kind.startsWith('login');const raw=fixture(login?'player-login-room.json':'self-join-room.json'); const snapshot={unknownField:[1,'x']}; if(kind==='login-restored'||kind==='joined-snapshot'||kind==='joined-started')raw.deskinfo=snapshot;else delete raw.deskinfo; raw.deskwar=kind==='joined-started'?1:0; if(login)setup.session.handleLogin(raw);else{setup.session.handleLogin(fixture('player-login-success.json'));setup.session.handleSelfJoin(raw);} const event=setup.events[0].event;assert.equal(event.type,'room.entered');if(event.type!=='room.entered')throw Error('entry'); assert.equal(event.entry?.kind,kind);assert.equal(event.entry?.raw,raw); assert.equal(setup.restored.length,kind==='login-restored'||kind==='joined-snapshot'?1:0); assert.equal(setup.events.filter(x=>x.event.type==='room.started').length,kind==='joined-started'?1:0); } const setup=makeSession();setup.session.handleLogin(fixture('player-login-success.json')); const raw={state:0,roomcode:'123456',seat:0,roomtype:{mode:[true,null,3]},infinite:0};setup.session.handleCreateRoom(raw); const event=setup.events[0].event;if(event.type!=='room.entered')throw Error('entry');assert.equal(event.entry?.kind,'created');assert.equal(event.entry?.raw,raw); });