import { readFileSync } from 'node:fs'; import assert from 'node:assert/strict'; import { test } from 'node:test'; import type { GameEntry, GameHost, GameModule, PlatformGameSnapshot, PlatformToGameEvent, } from '../../YouleNexus/assets/framework/sdk/contracts/index.ts'; import { GameSessionHost } 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 { 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(); const errors = Object.fromEntries([ 'lease-factory', 'module-create', 'attach', 'event', 'restore', 'dispose', 'invalidate', ].map((point) => [point, new Error(`failure:${point}`)])) as Record; 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], ); });