From 7e5a950cc7137c11ebd263004324d42cf9a76e92 Mon Sep 17 00:00:00 2001 From: Joywayer Date: Sat, 5 Sep 2026 06:17:04 +0800 Subject: [PATCH] fix(platform): serialize runtime session transitions --- .../framework/platform/runtime-session.ts | 189 ++++++-- .../platform/stores/platform-store.ts | 22 +- .../framework/protocol/contracts/index.ts | 2 + .../protocol/contracts/room-contracts.ts | 23 +- .../framework/protocol/platform-handlers.ts | 40 +- .../assets/framework/protocol/router.ts | 39 ++ .../architecture/import-boundaries.test.mjs | 159 +++++-- .../platform/platform-store.test.ts | 48 +- .../platform/runtime-session.test.ts | 273 ++++++++++- .../protocol/contracts.test.ts | 50 ++ .../protocol/platform-handlers.test.ts | 440 +++++++++++++++++- .../framework-tests/protocol/router.test.ts | 36 ++ .../scripts/lib/import-boundaries.mjs | 133 +++++- 13 files changed, 1297 insertions(+), 157 deletions(-) diff --git a/cocoscreator_projects/YouleNexus/assets/framework/platform/runtime-session.ts b/cocoscreator_projects/YouleNexus/assets/framework/platform/runtime-session.ts index c360ab1..a5ac193 100644 --- a/cocoscreator_projects/YouleNexus/assets/framework/platform/runtime-session.ts +++ b/cocoscreator_projects/YouleNexus/assets/framework/platform/runtime-session.ts @@ -3,21 +3,43 @@ import { GameSessionHost } from '../sdk/runtime/game-session-host.ts'; import { parseLoginResponse, parseSelfJoinRoomResponse, - type ParsedSeatPayload, + type ParsedSelfExitPayload, type ReconnectSnapshot, } from '../protocol/contracts/index.ts'; import { PlatformStore } from './stores/platform-store.ts'; +import type { PlatformState } from './stores/platform-types.ts'; + +export interface GameHostLeasePort { + readonly host: GameHost; + invalidate(): void; +} + +export type GameHostLeaseFactory = () => GameHostLeasePort; export interface RuntimeSessionOptions { readonly store: PlatformStore; readonly gameSession: GameSessionHost; - readonly gameHost: GameHost; + readonly createGameHostLease: GameHostLeaseFactory; +} + +export type RuntimeSessionState = 'operational' | 'faulted' | 'kicked'; + +export class RuntimeSessionFault extends Error { + readonly errors: readonly unknown[]; + + constructor(operation: string, errors: readonly unknown[]) { + super(`RuntimeSession fault during ${operation}`); + this.name = 'RuntimeSessionFault'; + this.errors = Object.freeze([...errors]); + } } /** Coordinates the canonical platform snapshot with the one active game session. */ export class RuntimeSession { private loginIsPending = false; private wasKicked = false; + private terminalFault: RuntimeSessionFault | null = null; + private activeLease: GameHostLeasePort | null = null; constructor(private readonly options: RuntimeSessionOptions) {} @@ -25,83 +47,166 @@ export class RuntimeSession { get kicked(): boolean { return this.wasKicked; } + get state(): RuntimeSessionState { + if (this.wasKicked) return 'kicked'; + return this.terminalFault !== null ? 'faulted' : 'operational'; + } + + get fault(): Error | null { return this.terminalFault; } + markLoginPending(): void { - this.requireNotKicked('mark login pending'); + this.requireOperational(); this.loginIsPending = true; } handleLogin(data: unknown): void { - this.requireNotKicked('handle login'); + this.requireOperational(); this.loginIsPending = false; const parsed = parseLoginResponse(data); - this.options.store.applyLoginSuccess(parsed); + const committed = this.options.store.applyLoginSuccess(parsed); if (parsed.room === null) { - this.closeActiveGame(); + this.releaseOrFault('login lobby replacement'); return; } - this.openCommittedRoom(parsed.reconnect); + this.openCommittedRoom(committed, parsed.reconnect, 'login room open'); } handleSelfJoin(data: unknown): void { - this.requireNotKicked('handle self join'); + this.requireOperational(); const parsed = parseSelfJoinRoomResponse(data); - this.options.store.replaceRoom(parsed); - this.openCommittedRoom(parsed.reconnect); + const committed = this.options.store.replaceRoom(parsed); + this.openCommittedRoom(committed, parsed.reconnect, 'self join room open'); } - handleSelfExit(parsed: ParsedSeatPayload): void { - this.requireNotKicked('handle self exit'); + handleSelfExit(parsed: ParsedSelfExitPayload): void { + this.requireOperational(); const state = this.options.store.getState(); if (state.room.kind !== 'inside') { - throw new Error('self_exit_room requires an active room'); + if (this.options.gameSession.state === 'idle' && this.activeLease === null) return; + const errors = [ + new Error('self_exit_room found an active game without room state'), + ...this.releaseGame(), + ]; + this.throwFault('self exit inconsistent state', errors); } - if (parsed.seat !== state.room.selfSeat) { + if (parsed.seat !== undefined && parsed.seat !== state.room.selfSeat) { throw new RangeError( `self_exit_room seat mismatch: expected ${state.room.selfSeat}, received ${parsed.seat}`, ); } - if (this.options.gameSession.state !== 'active') { - throw new Error('self_exit_room requires an active game session'); + if (this.options.gameSession.state !== 'active' || this.activeLease === null) { + const errors = [ + new Error('self_exit_room requires an active game session and host lease'), + ...this.releaseGame(), + ]; + this.throwFault('self exit inconsistent state', errors); } - this.options.store.clearRoom(); - this.options.gameSession.close(); + + try { + this.options.store.clearRoom(); + } catch (error) { + this.throwFault('self exit state commit', [error, ...this.releaseGame()]); + } + this.releaseOrFault('self exit cleanup'); } - handleServerSwitch(): void { - this.requireNotKicked('handle server switch'); - this.closeActiveGame(); + handleServerSwitch(switchConnection: () => void): void { + this.requireOperational(); + const errors = this.releaseGame(); + try { + switchConnection(); + } catch (error) { + errors.push(error); + } + if (errors.length > 0) this.throwFault('server switch', errors); } - handleKick(_data: unknown): void { - this.closeActiveGame(); + handleKick(_data: unknown, stopConnection: () => void): void { + if (this.wasKicked) return; + this.requireOperational(); this.loginIsPending = false; this.wasKicked = true; + + const errors = this.releaseGame(); + try { + stopConnection(); + } catch (error) { + errors.push(error); + } + if (errors.length > 0) this.throwFault('kick terminal cleanup', errors); } - private openCommittedRoom(reconnect: ReconnectSnapshot): void { - const state = this.options.store.getState(); + private openCommittedRoom( + state: PlatformState, + reconnect: ReconnectSnapshot, + operation: string, + ): void { if (state.room.kind !== 'inside') { - throw new Error('Cannot open a game session without committed room state'); + this.throwFault(operation, [new Error('Cannot open a game session without committed room state')]); + } + + const priorCleanupErrors = this.releaseGame(); + if (priorCleanupErrors.length > 0) this.throwFault(operation, priorCleanupErrors); + + let lease: GameHostLeasePort; + try { + lease = this.options.createGameHostLease(); + if (!lease || typeof lease !== 'object' || typeof lease.invalidate !== 'function') { + throw new TypeError('createGameHostLease must return { host, invalidate }'); + } + } catch (error) { + this.throwFault(operation, [error]); + } + this.activeLease = lease; + + try { + this.options.gameSession.open(lease.host); + this.options.gameSession.publish({ + type: 'room.entered', + roomtype: state.room.roomtype, + }); + if (reconnect.present) this.options.gameSession.restore(reconnect.value); + } catch (error) { + this.throwFault(operation, [error, ...this.releaseGame()]); } - this.closeActiveGame(); - this.options.gameSession.open(this.options.gameHost); - this.options.gameSession.publish({ - type: 'room.entered', - roomtype: state.room.roomtype, - }); - if (reconnect.present) this.options.gameSession.restore(reconnect.value); } - private closeActiveGame(): void { - const state = this.options.gameSession.state; - if (state === 'idle') return; - if (state !== 'active') { - throw new Error(`Cannot close game session while ${state}`); - } - this.options.gameSession.close(); + private releaseOrFault(operation: string): void { + const errors = this.releaseGame(); + if (errors.length > 0) this.throwFault(operation, errors); } - private requireNotKicked(operation: string): void { - if (this.wasKicked) throw new Error(`Cannot ${operation} after kick_server`); + private releaseGame(): unknown[] { + const errors: unknown[] = []; + const lease = this.activeLease; + this.activeLease = null; + + if (this.options.gameSession.state !== 'idle') { + try { + this.options.gameSession.close(); + } catch (error) { + errors.push(error); + } + } + if (lease !== null) { + try { + lease.invalidate(); + } catch (error) { + errors.push(error); + } + } + return errors; + } + + private throwFault(operation: string, errors: readonly unknown[]): never { + const fault = new RuntimeSessionFault(operation, errors); + this.loginIsPending = false; + this.terminalFault = fault; + throw fault; + } + + private requireOperational(): void { + if (this.terminalFault !== null) throw this.terminalFault; + if (this.wasKicked) throw new Error('RuntimeSession is kicked'); } } diff --git a/cocoscreator_projects/YouleNexus/assets/framework/platform/stores/platform-store.ts b/cocoscreator_projects/YouleNexus/assets/framework/platform/stores/platform-store.ts index 536be36..af19bfa 100644 --- a/cocoscreator_projects/YouleNexus/assets/framework/platform/stores/platform-store.ts +++ b/cocoscreator_projects/YouleNexus/assets/framework/platform/stores/platform-store.ts @@ -422,8 +422,8 @@ export class PlatformStore { return (): void => { this.listeners.delete(listener); }; } - private commit(state: PlatformState): void { - if (Object.is(state, this.state)) return; + private commit(state: PlatformState): PlatformState { + if (Object.is(state, this.state)) return state; const previous = this.state; this.state = state; this.transitions.push({ @@ -431,7 +431,7 @@ export class PlatformStore { previous, listeners: [...this.listeners], }); - if (this.publishing) return; + if (this.publishing) return state; this.publishing = true; try { @@ -449,9 +449,10 @@ export class PlatformStore { } finally { this.publishing = false; } + return state; } - applyLoginSuccess(parsed: ParsedLoginResponse): void { + applyLoginSuccess(parsed: ParsedLoginResponse): PlatformState { const input = requireRecord(parsed, '$.login'); const raw = requireRecord(input.raw, '$.login.raw'); const state = requireMatchingState(input.state, raw, '$.login'); @@ -491,14 +492,14 @@ export class PlatformStore { player, requireProtocolFlag(raw.isbattle, '$.login.raw.isbattle'), ); - this.commit(Object.freeze({ + return this.commit(Object.freeze({ app: Object.freeze({ phase: 'logged-in' }), players: roomResult.players, room: roomResult.room, })); } - replaceRoom(parsed: ParsedRoomResponse): void { + replaceRoom(parsed: ParsedRoomResponse): PlatformState { const input = requireRecord(parsed, '$.response'); const raw = requireRecord(input.raw, '$.response.raw'); const state = requireMatchingState(input.state, raw, '$.response'); @@ -536,11 +537,12 @@ export class PlatformStore { '$.response.reconnect.value: expected canonical raw deskinfo reference', ); } - const stage = reconnectPresent - ? 1 - : requireProtocolFlag(raw.deskwar, '$.response.raw.deskwar'); + const stage = requireProtocolFlag(input.stage, '$.response.stage'); + if (reconnectPresent && stage !== 1) { + throw new RangeError('$.response.stage: reconnect room must be in battle stage'); + } const roomResult = buildRoom(input.room, raw, selfPlayer, stage); - this.commit(Object.freeze({ + return this.commit(Object.freeze({ app: previous.app, players: roomResult.players, room: roomResult.room, diff --git a/cocoscreator_projects/YouleNexus/assets/framework/protocol/contracts/index.ts b/cocoscreator_projects/YouleNexus/assets/framework/protocol/contracts/index.ts index 53264a3..5a13f30 100644 --- a/cocoscreator_projects/YouleNexus/assets/framework/protocol/contracts/index.ts +++ b/cocoscreator_projects/YouleNexus/assets/framework/protocol/contracts/index.ts @@ -33,6 +33,7 @@ export { parseOtherOfflinePayload, parseOtherOnlinePayload, parsePlayerPreparePayload, + parseSelfExitRoomPayload, parseSelfJoinRoomResponse, type JoinRoomEnvironment, type JoinRoomPayload, @@ -43,6 +44,7 @@ export { type ParsedRoomResponse, type ParsedRoomserverPayload, type ParsedRoomSnapshot, + type ParsedSelfExitPayload, type ParsedSeatPayload, type ReconnectSnapshot, type RoomActionPayload, diff --git a/cocoscreator_projects/YouleNexus/assets/framework/protocol/contracts/room-contracts.ts b/cocoscreator_projects/YouleNexus/assets/framework/protocol/contracts/room-contracts.ts index 39d32da..65fb764 100644 --- a/cocoscreator_projects/YouleNexus/assets/framework/protocol/contracts/room-contracts.ts +++ b/cocoscreator_projects/YouleNexus/assets/framework/protocol/contracts/room-contracts.ts @@ -150,6 +150,7 @@ export interface ParsedRoomResponse { readonly state: number; readonly room: ParsedRoomSnapshot | null; readonly reconnect: ReconnectSnapshot; + readonly stage: 0 | 1; readonly raw: JsonRecord; } @@ -157,7 +158,13 @@ export function parseSelfJoinRoomResponse(value: unknown): ParsedRoomResponse { const raw = requireRecord(value, '$.data'); const state = requireInteger(raw.state, '$.data.state'); const room = state === 0 ? parseRoomEntryFields(raw) : null; - return { state, room, reconnect: reconnectFrom(raw), raw }; + const parsedDeskwar = optionalField(raw, 'deskwar', '$.data', requireInteger); + if (parsedDeskwar !== undefined && parsedDeskwar !== 0 && parsedDeskwar !== 1) { + throw new RangeError('$.data.deskwar: expected protocol flag 0 or 1'); + } + const reconnect = reconnectFrom(raw); + const stage = reconnect.present ? 1 : parsedDeskwar ?? 0; + return { state, room, reconnect, stage, raw }; } export interface ParsedSeatPayload { @@ -177,6 +184,20 @@ export const parseOtherJoinRoomPayload = parseSeatPayload; export const parseOtherExitRoomPayload = parseSeatPayload; export const parseOtherOfflinePayload = parseSeatPayload; +export interface ParsedSelfExitPayload { + readonly seat?: number; + readonly raw: JsonRecord; +} + +export function parseSelfExitRoomPayload(value: unknown): ParsedSelfExitPayload { + const raw = requireRecord(value, '$.data'); + const seat = optionalField(raw, 'seat', '$.data', requireInteger); + if (seat !== undefined && seat < 0) { + throw new RangeError('$.data.seat: expected non-negative server seat, received number'); + } + return { ...(seat === undefined ? {} : { seat }), raw }; +} + export interface ParsedOnlinePayload extends ParsedSeatPayload { readonly ip: string; } diff --git a/cocoscreator_projects/YouleNexus/assets/framework/protocol/platform-handlers.ts b/cocoscreator_projects/YouleNexus/assets/framework/protocol/platform-handlers.ts index 8c404df..3f666e6 100644 --- a/cocoscreator_projects/YouleNexus/assets/framework/protocol/platform-handlers.ts +++ b/cocoscreator_projects/YouleNexus/assets/framework/protocol/platform-handlers.ts @@ -12,6 +12,7 @@ import { parseOtherOfflinePayload, parseOtherOnlinePayload, parsePlayerPreparePayload, + parseSelfExitRoomPayload, } from './contracts/index.ts'; import { buildFirstSliceEnvelope, @@ -67,7 +68,7 @@ export class PlatformHandlers { break; } case 'self_exit_room': { - const parsed = parseOtherExitRoomPayload(message.data); + const parsed = parseSelfExitRoomPayload(message.data); this.options.session.handleSelfExit(parsed); break; } @@ -103,22 +104,26 @@ export class PlatformHandlers { const parsed = parseConnectRoomserverPayload(message.data); const target = parseSwitchTarget(parsed.roomserver, '$.data.roomserver'); const resend = buildFirstSliceEnvelope('connect_roomserver', parsed.raw); - this.options.session.handleServerSwitch(); - this.options.connection.switchServer(target, resend); + this.options.session.handleServerSwitch( + () => this.options.connection.switchServer(target, resend), + ); break; } case 'connect_agentserver': { const parsed = parseConnectAgentserverPayload(message.data); const target = parseSwitchTarget(parsed.agentserver, '$.data.agentserver'); const resend = buildFirstSliceEnvelope('connect_agentserver', parsed.raw); - this.options.session.handleServerSwitch(); - this.options.connection.switchServer(target, resend); + this.options.session.handleServerSwitch( + () => this.options.connection.switchServer(target, resend), + ); break; } case 'kick_server': { const parsed = parseKickServerPayload(message.data); - this.options.session.handleKick(parsed.raw); - this.options.connection.stopForKick(parsed.raw); + this.options.session.handleKick( + parsed.raw, + () => this.options.connection.stopForKick(parsed.raw), + ); break; } } @@ -141,14 +146,10 @@ function parseSwitchTarget(address: string, path: string): string { throw new TypeError(`${path}: expected a non-empty WebSocket server address`); } const scheme = /^([A-Za-z][A-Za-z0-9+.-]*):\/\//.exec(address); - let target: string; - if (scheme) { - if (scheme[1] !== 'ws' && scheme[1] !== 'wss') { - throw new TypeError(`${path}: unsupported WebSocket scheme ${scheme[1]}`); - } - target = address; - } else { - target = `ws://${address}`; + const target = scheme ? address : `ws://${address}`; + const authority = /^[A-Za-z][A-Za-z0-9+.-]*:\/\/([^/?#]*)/.exec(target)?.[1]; + if (authority === undefined || authority.length === 0) { + throw new TypeError(`${path}: expected a WebSocket address with a non-empty host`); } let parsed: URL; @@ -157,7 +158,14 @@ function parseSwitchTarget(address: string, path: string): string { } catch { throw new TypeError(`${path}: expected a valid WebSocket server address`); } - if ((parsed.protocol !== 'ws:' && parsed.protocol !== 'wss:') || parsed.hostname.length === 0) { + const protocol = parsed.protocol.toLowerCase(); + if ( + (protocol !== 'ws:' && protocol !== 'wss:') + || parsed.hostname.length === 0 + || parsed.username.length > 0 + || parsed.password.length > 0 + || parsed.hash.length > 0 + ) { throw new TypeError(`${path}: expected a valid WebSocket server address`); } return target; diff --git a/cocoscreator_projects/YouleNexus/assets/framework/protocol/router.ts b/cocoscreator_projects/YouleNexus/assets/framework/protocol/router.ts index de12b82..553abcf 100644 --- a/cocoscreator_projects/YouleNexus/assets/framework/protocol/router.ts +++ b/cocoscreator_projects/YouleNexus/assets/framework/protocol/router.ts @@ -14,11 +14,43 @@ export interface RouterOptions { readonly gameSession: Pick; } +interface QueuedDispatch { + readonly message: InboundMessage; + result?: RouterDispatchResult; +} + /** Classifies each inbound business envelope exactly once. */ export class Router { + private readonly queue: QueuedDispatch[] = []; + private draining = false; + constructor(private readonly options: RouterOptions) {} dispatch(message: InboundMessage): RouterDispatchResult { + const queued: QueuedDispatch = { message }; + this.queue.push(queued); + if (this.draining) return this.resultWithoutSideEffects(message); + + this.draining = true; + try { + while (this.queue.length > 0) { + const next = this.queue.shift(); + if (next === undefined) continue; + next.result = this.dispatchOne(next.message); + } + } catch (error) { + this.queue.length = 0; + throw error; + } finally { + this.draining = false; + } + if (queued.result === undefined) { + throw new Error('Router failed to produce a dispatch result'); + } + return queued.result; + } + + private dispatchOne(message: InboundMessage): RouterDispatchResult { if (this.options.session.loginPending && !isLoginGateControl(message)) { return 'ignored-login-gate'; } @@ -31,6 +63,13 @@ export class Router { }); return 'handled-game'; } + + private resultWithoutSideEffects(message: InboundMessage): RouterDispatchResult { + if (this.options.session.loginPending && !isLoginGateControl(message)) { + return 'ignored-login-gate'; + } + return isPlatformRoute(message.route) ? 'handled-platform' : 'handled-game'; + } } function isPlatformRoute(route: string): route is 'platform' | 'agent' | 'room' { diff --git a/cocoscreator_projects/framework-tests/architecture/import-boundaries.test.mjs b/cocoscreator_projects/framework-tests/architecture/import-boundaries.test.mjs index e4cd7b1..2a4d07b 100644 --- a/cocoscreator_projects/framework-tests/architecture/import-boundaries.test.mjs +++ b/cocoscreator_projects/framework-tests/architecture/import-boundaries.test.mjs @@ -1,12 +1,14 @@ import { afterEach, test } from 'node:test'; import assert from 'node:assert/strict'; -import { existsSync, readFileSync } from 'node:fs'; +import { existsSync } from 'node:fs'; import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; -import ts from 'typescript'; -import { scanImportBoundaries } from '../../scripts/lib/import-boundaries.mjs'; +import { + findCompatibilityDependency, + scanImportBoundaries, +} from '../../scripts/lib/import-boundaries.mjs'; const roots = []; @@ -55,45 +57,13 @@ const legacyRuntimeFiles = new Set([ 'protocol/room-handlers.ts', ].map((file) => resolve(frameworkRoot, file))); -function relativeImports(file) { - const source = readFileSync(file, 'utf8'); - const sourceFile = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, false, ts.ScriptKind.TS); - const imports = []; - const visit = (node) => { - let specifier; - if ((ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) - && node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier)) { - specifier = node.moduleSpecifier.text; - } else if (ts.isImportTypeNode(node) - && ts.isLiteralTypeNode(node.argument) - && ts.isStringLiteral(node.argument.literal)) { - specifier = node.argument.literal.text; - } else if (ts.isCallExpression(node) - && node.expression.kind === ts.SyntaxKind.ImportKeyword - && node.arguments.length === 1 && ts.isStringLiteral(node.arguments[0])) { - specifier = node.arguments[0].text; - } - if (specifier?.startsWith('.')) { - const imported = resolve(dirname(file), specifier); - const candidates = [imported, `${imported}.ts`, join(imported, 'index.ts')]; - const resolvedImport = candidates.find((candidate) => existsSync(candidate)); - if (resolvedImport) imports.push(resolvedImport); - } - ts.forEachChild(node, visit); - }; - ts.forEachChild(sourceFile, visit); - return imports; -} - -function findLegacyDependency(entry, path = [], visited = new Set()) { - if (legacyRuntimeFiles.has(entry)) return [...path, entry]; - if (visited.has(entry)) return null; - visited.add(entry); - for (const imported of relativeImports(entry)) { - const found = findLegacyDependency(imported, [...path, entry], visited); - if (found) return found; - } - return null; +function scanCompatibilityFixture(root, entry = 'framework/protocol/router.ts', tsconfigPath) { + return findCompatibilityDependency({ + entry: join(root, entry), + forbiddenFiles: [join(root, 'framework/platform/session.ts')], + traversalRoot: join(root, 'framework'), + tsconfigPath: tsconfigPath ? join(root, tsconfigPath) : undefined, + }); } test('every existing modern runtime entry is transitively isolated from the legacy compatibility island', () => { @@ -103,15 +73,114 @@ test('every existing modern runtime entry is transitively isolated from the lega assert.ok(entries.some((file) => file.endsWith(`${join('protocol', 'router.ts')}`))); for (const entry of entries) { - const dependencyPath = findLegacyDependency(entry); + const dependency = findCompatibilityDependency({ + entry, + forbiddenFiles: [...legacyRuntimeFiles], + traversalRoot: frameworkRoot, + tsconfigPath: fileURLToPath(new URL('../../tsconfig.framework.json', import.meta.url)), + }); assert.equal( - dependencyPath, + dependency, null, - dependencyPath?.map((file) => file.slice(frameworkRoot.length)).join(' -> '), + dependency?.path.map((file) => file.slice(frameworkRoot.length)).join(' -> '), ); } }); +test('compatibility graph catches static imports, re-exports, and import types through barrels', async () => { + const cases = [ + "import '../platform/session.ts'\n", + "export * from '../platform/session.ts'\n", + "export type { LegacySession } from '../platform/session.ts'\n", + "type Legacy = import('../platform/session.ts').LegacySession\n", + ]; + + for (const source of cases) { + const root = await createFixtureRoot(); + await writeFixture(root, 'framework/platform/session.ts', 'export interface LegacySession {}\n'); + await writeFixture(root, 'framework/protocol/barrel.ts', source); + await writeFixture(root, 'framework/protocol/router.ts', "export * from './barrel.ts'\n"); + + const found = scanCompatibilityFixture(root); + assert.equal(found?.kind, 'forbidden', source); + assert.deepEqual( + found?.path.map((file) => file.replaceAll('\\', '/').split('/framework/')[1]), + ['protocol/router.ts', 'protocol/barrel.ts', 'platform/session.ts'], + source, + ); + } +}); + +test('compatibility graph reports direct and ordinary transitive dependency paths', async () => { + const directRoot = await createFixtureRoot(); + await writeFixture(directRoot, 'framework/platform/session.ts', 'export const legacy = true\n'); + await writeFixture(directRoot, 'framework/protocol/router.ts', "import '../platform/session.ts'\n"); + assert.equal(scanCompatibilityFixture(directRoot)?.path.length, 2); + + const transitiveRoot = await createFixtureRoot(); + await writeFixture(transitiveRoot, 'framework/platform/session.ts', 'export const legacy = true\n'); + await writeFixture(transitiveRoot, 'framework/protocol/helper.ts', "import '../platform/session.ts'\n"); + await writeFixture(transitiveRoot, 'framework/protocol/router.ts', "import './helper.ts'\n"); + assert.equal(scanCompatibilityFixture(transitiveRoot)?.path.length, 3); +}); + +test('compatibility graph catches string and no-substitution-template dynamic imports', async () => { + for (const source of [ + "await import('../platform/session.ts')\n", + 'await import(`../platform/session.ts`)\n', + ]) { + const root = await createFixtureRoot(); + await writeFixture(root, 'framework/platform/session.ts', 'export const legacy = true\n'); + await writeFixture(root, 'framework/protocol/router.ts', source); + + assert.equal(scanCompatibilityFixture(root)?.kind, 'forbidden', source); + } +}); + +test('compatibility graph resolves tsconfig path aliases to canonical files', async () => { + const root = await createFixtureRoot(); + await writeFixture(root, 'framework/platform/session.ts', 'export const legacy = true\n'); + await writeFixture(root, 'framework/protocol/router.ts', "import '@legacy/session'\n"); + await writeFixture(root, 'tsconfig.json', JSON.stringify({ + compilerOptions: { + baseUrl: '.', + paths: { '@legacy/*': ['framework/platform/*'] }, + module: 'ESNext', + moduleResolution: 'Bundler', + allowImportingTsExtensions: true, + }, + })); + + const found = scanCompatibilityFixture(root, undefined, 'tsconfig.json'); + assert.equal(found?.kind, 'forbidden'); + assert.ok(found?.path.at(-1)?.replaceAll('\\', '/').endsWith('/framework/platform/session.ts')); +}); + +test('compatibility graph fails closed on a reachable non-static dynamic import', async () => { + const root = await createFixtureRoot(); + await writeFixture(root, 'framework/platform/session.ts', 'export const legacy = true\n'); + await writeFixture( + root, + 'framework/protocol/router.ts', + "const target = '../platform/session.ts'; void import(target)\n", + ); + + const found = scanCompatibilityFixture(root); + assert.equal(found?.kind, 'dynamic'); + assert.ok(found?.path[0]?.replaceAll('\\', '/').endsWith('/framework/protocol/router.ts')); +}); + +test('compatibility graph applies the same isolation to a future platform/runtime.ts entry', async () => { + const root = await createFixtureRoot(); + await writeFixture(root, 'framework/platform/session.ts', 'export const legacy = true\n'); + await writeFixture(root, 'framework/application/barrel.ts', "export * from '../platform/session.ts'\n"); + await writeFixture(root, 'framework/platform/runtime.ts', "import '../application/barrel.ts'\n"); + + const found = scanCompatibilityFixture(root, 'framework/platform/runtime.ts'); + assert.equal(found?.kind, 'forbidden'); + assert.equal(found?.path.length, 3); +}); + test('scanner rejects sdk contracts importing a framework platform path', async () => { const root = await createFixtureRoot(); await writeFixture(root, 'framework/sdk/contracts/bad.ts', "import '../platform/session.ts'\n"); diff --git a/cocoscreator_projects/framework-tests/platform/platform-store.test.ts b/cocoscreator_projects/framework-tests/platform/platform-store.test.ts index 4299043..a260af0 100644 --- a/cocoscreator_projects/framework-tests/platform/platform-store.test.ts +++ b/cocoscreator_projects/framework-tests/platform/platform-store.test.ts @@ -425,13 +425,24 @@ test('replaceRoom atomically replaces the complete room from a parsed DTO', () = assert.equal(after.players.entities[430511]?.ip, '127.0.0.1'); }); -test('replaceRoom derives a reconnect stage only from deskinfo presence', () => { - for (const deskwar of [undefined, 0] as const) { +test('replaceRoom consumes the parser-owned deskwar default and reconnect stage 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 store = new PlatformStore(); store.applyLoginSuccess(parseLoginResponse(fixture('player-login-success.json').data)); const data = fixture('self-join-room.json').data; - if (deskwar === undefined) delete data.deskwar; - else data.deskwar = deskwar; + if (row.deskwar === 'missing') delete data.deskwar; + else data.deskwar = row.deskwar; + if (row.deskinfo) data.deskinfo = { round: 7 }; + else delete data.deskinfo; + data.isbattle = 1; const parsed = parseSelfJoinRoomResponse(data); store.replaceRoom(parsed); @@ -439,7 +450,7 @@ test('replaceRoom derives a reconnect stage only from deskinfo presence', () => const state = store.getState(); assert.equal(state.room.kind, 'inside'); if (state.room.kind !== 'inside') continue; - assert.equal(state.room.stage, 1); + assert.equal(state.room.stage, row.expectedStage, JSON.stringify(row)); } }); @@ -458,21 +469,7 @@ test('applyLoginSuccess requires isbattle even when raw deskwar is fabricated', assert.equal(notifications, 0); }); -test('replaceRoom without reconnect requires deskwar and never substitutes isbattle', () => { - const successfulStore = new PlatformStore(); - successfulStore.applyLoginSuccess( - parseLoginResponse(fixture('player-login-success.json').data), - ); - const successfulData = fixture('self-join-room.json').data; - delete successfulData.deskinfo; - successfulData.deskwar = 0; - successfulStore.replaceRoom(parseSelfJoinRoomResponse(successfulData)); - const successfulState = successfulStore.getState(); - assert.equal(successfulState.room.kind, 'inside'); - if (successfulState.room.kind === 'inside') { - assert.equal(successfulState.room.stage, 0); - } - +test('replaceRoom never substitutes self-join isbattle for missing deskwar or deskinfo', () => { const store = new PlatformStore(); store.applyLoginSuccess(parseLoginResponse(fixture('player-login-success.json').data)); const data = fixture('self-join-room.json').data; @@ -480,13 +477,12 @@ test('replaceRoom without reconnect requires deskwar and never substitutes isbat delete data.deskwar; data.isbattle = 1; const parsed = parseSelfJoinRoomResponse(data); - const before = store.getState(); - let notifications = 0; - store.subscribe(() => { notifications += 1; }); - assert.throws(() => store.replaceRoom(parsed), /deskwar/i); - assert.equal(store.getState(), before); - assert.equal(notifications, 0); + store.replaceRoom(parsed); + + const state = store.getState(); + assert.equal(state.room.kind, 'inside'); + if (state.room.kind === 'inside') assert.equal(state.room.stage, 0); }); test('clearRoom leaves no fabricated room fields and retains only the self entity', () => { diff --git a/cocoscreator_projects/framework-tests/platform/runtime-session.test.ts b/cocoscreator_projects/framework-tests/platform/runtime-session.test.ts index 499bc2a..468d061 100644 --- a/cocoscreator_projects/framework-tests/platform/runtime-session.test.ts +++ b/cocoscreator_projects/framework-tests/platform/runtime-session.test.ts @@ -48,14 +48,20 @@ function makeSession() { }; const gameSession = new GameSessionHost(entry); log.length = 0; - const gameHost: GameHost = { - seat: { toView: (seat) => seat, toServer: (seat) => seat }, - getSnapshot: () => selectGameSnapshot(store.getState()), - subscribe: () => () => {}, - sendGameMessage() {}, - execute() {}, - }; - const session = new RuntimeSession({ store, gameSession, gameHost }); + 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 }; } @@ -109,3 +115,254 @@ test('RuntimeSession self join commits, opens a fresh session, and restores the 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], + ); +}); diff --git a/cocoscreator_projects/framework-tests/protocol/contracts.test.ts b/cocoscreator_projects/framework-tests/protocol/contracts.test.ts index 612afeb..79bf508 100644 --- a/cocoscreator_projects/framework-tests/protocol/contracts.test.ts +++ b/cocoscreator_projects/framework-tests/protocol/contracts.test.ts @@ -15,6 +15,7 @@ import { parseOtherOfflinePayload, parseOtherOnlinePayload, parsePlayerPreparePayload, + parseSelfExitRoomPayload, parseSelfJoinRoomResponse, } from '../../YouleNexus/assets/framework/protocol/contracts/index.ts'; @@ -214,6 +215,38 @@ test('parseSelfJoinRoomResponse 接受金样,不深验 roomtype/deskinfo 并 assert.equal(parsed.reconnect.present && parsed.reconnect.value, opaqueDeskinfo); }); +test('parseSelfJoinRoomResponse defines the optional deskwar default once and deskinfo wins stage', () => { + 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 data = fixture('self-join-room.json').data; + if (row.deskwar === 'missing') delete data.deskwar; + else data.deskwar = row.deskwar; + if (row.deskinfo) data.deskinfo = { round: 7 }; + else delete data.deskinfo; + data.isbattle = 1; + + const parsed = parseSelfJoinRoomResponse(data); + + assert.equal(parsed.stage, row.expectedStage, JSON.stringify(row)); + assert.equal(parsed.reconnect.present, row.deskinfo, JSON.stringify(row)); + } + + for (const deskwar of [-1, 2, 1.5, '1', undefined]) { + assert.throws( + () => parseSelfJoinRoomResponse({ ...fixture('self-join-room.json').data, deskwar }), + /\$\.data\.deskwar/, + ); + } +}); + test('房间请求 builder 使用唯一 route,并仅发协议定义字段', () => { const room = { playerid: 430511, roomcode: '100001' }; assert.deepEqual(buildPrepareRequest(runtimeConfig, room), { @@ -311,6 +344,23 @@ test('座位事件 parser 校验实际消费字段并保留 raw', () => { assert.throws(() => parseOtherOnlinePayload({ seat: 1 }), /\$\.data\.ip.*undefined/); }); +test('self_exit_room parser accepts an omitted seat but strictly validates it when present', () => { + const withoutSeat = { isowner: 1, roomcode: '100001', extension: 'kept' }; + const parsedWithoutSeat = parseSelfExitRoomPayload(withoutSeat); + assert.equal(parsedWithoutSeat.raw, withoutSeat); + assert.equal(Object.prototype.hasOwnProperty.call(parsedWithoutSeat, 'seat'), false); + + const withSeat = { seat: 1, extension: 'kept' }; + const parsedWithSeat = parseSelfExitRoomPayload(withSeat); + assert.equal(parsedWithSeat.raw, withSeat); + assert.equal(parsedWithSeat.seat, 1); + + for (const seat of [-1, 1.2, '1', undefined]) { + assert.throws(() => parseSelfExitRoomPayload({ seat }), /\$\.data\.seat/); + } + assert.throws(() => parseOtherExitRoomPayload({}), /\$\.data\.seat/); +}); + test('切服与踢出 parser 拒绝畸形载荷并保留未知扩展', () => { const roomSwitch = { roomserver: '127.0.0.1:9001', epoch: 7 }; assert.deepEqual(parseConnectRoomserverPayload(roomSwitch), { diff --git a/cocoscreator_projects/framework-tests/protocol/platform-handlers.test.ts b/cocoscreator_projects/framework-tests/protocol/platform-handlers.test.ts index 1fec09c..3394171 100644 --- a/cocoscreator_projects/framework-tests/protocol/platform-handlers.test.ts +++ b/cocoscreator_projects/framework-tests/protocol/platform-handlers.test.ts @@ -11,7 +11,10 @@ import type { } from '../../YouleNexus/assets/framework/sdk/contracts/index.ts'; import { GameSessionHost } from '../../YouleNexus/assets/framework/sdk/runtime/game-session-host.ts'; import type { ConnectionControlPort } from '../../YouleNexus/assets/framework/platform/connection-intent.ts'; -import { RuntimeSession } from '../../YouleNexus/assets/framework/platform/runtime-session.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'; import { PlatformHandlers } from '../../YouleNexus/assets/framework/protocol/platform-handlers.ts'; @@ -21,15 +24,35 @@ function fixture(name: string): Record { return JSON.parse(readFileSync(new URL(`../fixtures/contracts/${name}`, import.meta.url), 'utf8')).data; } +function caughtError(operation: () => void): Error { + try { + operation(); + } catch (error) { + assert.ok(error instanceof Error); + return error; + } + assert.fail('expected operation to throw'); +} + class ConnectionFixture implements ConnectionControlPort { readonly switchCalls: Array<{ target: string; resend: OutboundEnvelope }> = []; readonly stopCalls: unknown[] = []; + switchFailure: Error | undefined; + switchObserver: () => void = () => {}; + stopFailure: Error | undefined; + stopObserver: () => void = () => {}; switchServer(target: string, resend: OutboundEnvelope): void { this.switchCalls.push({ target, resend }); + this.switchObserver(); + if (this.switchFailure) throw this.switchFailure; } - stopForKick(data: unknown): void { this.stopCalls.push(data); } + stopForKick(data: unknown): void { + this.stopCalls.push(data); + this.stopObserver(); + if (this.stopFailure) throw this.stopFailure; + } } function makeHandlers() { @@ -40,6 +63,8 @@ function makeHandlers() { snapshot: PlatformGameSnapshot, ) => void = () => {}; let disposalObserver: () => void = () => {}; + let invalidationObserver: () => void = () => {}; + let invalidations = 0; const entry: GameEntry = { key: 'fixture', gameId: 41, route: 'fixture-game', resolveSeatCount: () => 4, createModule: (): GameModule => ({ @@ -54,12 +79,21 @@ function makeHandlers() { }; const gameSession = new GameSessionHost(entry); disposals = 0; - const gameHost: GameHost = { - seat: { toView: (seat) => seat, toServer: (seat) => seat }, - getSnapshot: () => selectGameSnapshot(store.getState()), - subscribe: () => () => {}, sendGameMessage() {}, execute() {}, - }; - const session = new RuntimeSession({ store, gameSession, gameHost }); + const session = new RuntimeSession({ + store, + gameSession, + createGameHostLease: () => ({ + host: { + seat: { toView: (seat) => seat, toServer: (seat) => seat }, + getSnapshot: () => selectGameSnapshot(store.getState()), + subscribe: () => () => {}, sendGameMessage() {}, execute() {}, + }, + invalidate() { + invalidations += 1; + invalidationObserver(); + }, + }), + }); const connection = new ConnectionFixture(); const handlers = new PlatformHandlers({ store, gameSession, session, connection }); const router = new Router({ session, platform: handlers, gameSession }); @@ -71,11 +105,169 @@ function makeHandlers() { handlers, router, getDisposals: () => disposals, + getInvalidations: () => invalidations, observeEvent(observer: typeof eventObserver) { eventObserver = observer; }, observeDisposal(observer: () => void) { disposalObserver = observer; }, + observeInvalidation(observer: () => void) { invalidationObserver = observer; }, }; } +function makeReentrantRouterFixture(log: string[]) { + const store = new PlatformStore(); + let nextModuleId = 0; + const restores: Array<{ moduleId: number; deskinfo: unknown }> = []; + const entry: GameEntry = { + key: 'reentrant-fixture', + gameId: 41, + route: 'fixture-game', + resolveSeatCount: () => 4, + createModule: (): GameModule => { + const moduleId = ++nextModuleId; + let host: GameHost | undefined; + return { + attach(attachedHost) { + host = attachedHost; + log.push(`attach:${moduleId}:${attachedHost.getSnapshot().room.roomcode}`); + }, + handlePlatformEvent(event) { + if (!host) throw new Error('fixture module has no host'); + const snapshot = host.getSnapshot(); + if (event.type === 'room.entered') { + log.push(`event:${moduleId}:room.entered:${snapshot.room.roomcode}`); + return; + } + if (event.type === 'room.player-offline' || event.type === 'room.player-online') { + const online = snapshot.seats.find((seat) => seat.seat === event.seat)?.online; + log.push(`event:${moduleId}:${event.type}:${String(online)}`); + } + }, + handleGameMessage() {}, + restore(deskinfo) { + restores.push({ moduleId, deskinfo }); + log.push(`restore:${moduleId}`); + }, + dispose() { log.push(`dispose:${moduleId}`); }, + }; + }, + }; + const gameSession = new GameSessionHost(entry); + nextModuleId = 0; + 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() {}, + }), + }); + const connection = new ConnectionFixture(); + const handlers = new PlatformHandlers({ store, gameSession, session, connection }); + const router = new Router({ session, platform: handlers, gameSession }); + return { store, session, gameSession, router, restores }; +} + +test('Router drains reentrant room entries FIFO so each module receives its own committed room and deskinfo', () => { + const log: string[] = []; + const setup = makeReentrantRouterFixture(log); + setup.router.dispatch({ + route: 'agent', rpc: 'player_login', data: fixture('player-login-success.json'), + }); + const roomA = fixture('self-join-room.json'); + const roomB = fixture('self-join-room.json'); + roomA.roomcode = 'room-A'; + roomB.roomcode = 'room-B'; + roomA.deskinfo = { round: 'A' }; + roomB.deskinfo = { round: 'B' }; + let queuedResult: ReturnType | undefined; + + setup.store.subscribe((state) => { + if (state.room.kind === 'inside' && state.room.roomcode === 'room-A') { + log.push('store:A'); + queuedResult = setup.router.dispatch({ + route: 'agent', rpc: 'self_join_room', data: roomB, + }); + } else if (state.room.kind === 'inside' && state.room.roomcode === 'room-B') { + log.push('store:B'); + } + }); + + const result = setup.router.dispatch({ + route: 'agent', rpc: 'self_join_room', data: roomA, + }); + + assert.equal(result, 'handled-platform'); + assert.equal(queuedResult, 'handled-platform'); + assert.deepEqual(log, [ + 'store:A', + 'attach:1:room-A', + 'event:1:room.entered:room-A', + 'restore:1', + 'store:B', + 'dispose:1', + 'attach:2:room-B', + 'event:2:room.entered:room-B', + 'restore:2', + ]); + assert.deepEqual(setup.restores, [ + { moduleId: 1, deskinfo: roomA.deskinfo }, + { moduleId: 2, deskinfo: roomB.deskinfo }, + ]); + assert.equal(setup.restores[0]?.deskinfo, roomA.deskinfo); + assert.equal(setup.restores[1]?.deskinfo, roomB.deskinfo); +}); + +test('Router finishes an offline state-first event before draining a reentrant online message', () => { + const log: string[] = []; + const setup = makeReentrantRouterFixture(log); + setup.router.dispatch({ + route: 'agent', rpc: 'player_login', data: fixture('player-login-room.json'), + }); + setup.router.dispatch({ + route: 'room', + rpc: 'other_join_room', + data: { + seat: 2, playerid: 77, nickname: '对家', avatar: 'http://b', sex: 1, + ip: '127.0.0.2', onstate: 0, bean: 90, isprepare: 0, + }, + }); + log.length = 0; + let queuedResult: ReturnType | undefined; + + setup.store.subscribe((state, previous) => { + if (state.room.kind !== 'inside' || previous.room.kind !== 'inside') return; + const wasOffline = previous.room.offlineSeats.includes(2); + const isOffline = state.room.offlineSeats.includes(2); + if (!wasOffline && isOffline) { + log.push('store:offline'); + queuedResult = setup.router.dispatch({ + route: 'room', rpc: 'other_online', data: { seat: 2, ip: '127.0.0.3' }, + }); + } else if (wasOffline && !isOffline) { + log.push('store:online'); + } + }); + + const result = setup.router.dispatch({ + route: 'room', rpc: 'other_offline', data: { seat: 2 }, + }); + + assert.equal(result, 'handled-platform'); + assert.equal(queuedResult, 'handled-platform'); + assert.deepEqual(log, [ + 'store:offline', + 'event:1:room.player-offline:false', + 'store:online', + 'event:1:room.player-online:true', + ]); +}); + test('PlatformHandlers commits every room event before one typed game event reads the snapshot', () => { const setup = makeHandlers(); setup.handlers.dispatch({ @@ -172,6 +364,41 @@ test('self_exit_room commits the outside snapshot before disposing the game sess assert.equal(setup.gameSession.state, 'idle'); }); +test('self_exit_room accepts omitted seat, validates a present seat, and is idempotent outside', () => { + const setup = makeHandlers(); + setup.router.dispatch({ + route: 'agent', rpc: 'player_login', data: fixture('player-login-room.json'), + }); + const inside = setup.store.getState(); + const disposalsBefore = setup.getDisposals(); + + assert.throws( + () => setup.router.dispatch({ route: 'room', rpc: 'self_exit_room', data: { seat: 0 } }), + /self_exit_room.*seat.*expected 1.*received 0/i, + ); + assert.equal(setup.store.getState(), inside); + assert.equal(setup.gameSession.state, 'active'); + assert.equal(setup.getDisposals(), disposalsBefore); + + let notifications = 0; + setup.store.subscribe(() => { notifications += 1; }); + assert.equal( + setup.router.dispatch({ route: 'room', rpc: 'self_exit_room', data: { reason: 'done' } }), + 'handled-platform', + ); + assert.equal(setup.store.getState().room.kind, 'outside'); + assert.equal(setup.gameSession.state, 'idle'); + assert.equal(notifications, 1); + assert.equal(setup.getDisposals(), disposalsBefore + 1); + + assert.equal( + setup.router.dispatch({ route: 'room', rpc: 'self_exit_room', data: { reason: 'duplicate' } }), + 'handled-platform', + ); + assert.equal(notifications, 1); + assert.equal(setup.getDisposals(), disposalsBefore + 1); +}); + test('PlatformHandlers preserves complete switch payloads and builds the exact resend envelopes', () => { const setup = makeHandlers(); const roomData = { roomserver: '10.0.0.2:3088', epoch: 7 }; @@ -227,6 +454,142 @@ test('PlatformHandlers rejects unknown, route-mismatched, missing, and invalid s assert.deepEqual(setup.connection.switchCalls, []); }); +test('unknown and every route-mismatched platform rpc leave all observable state unchanged', () => { + const setup = makeHandlers(); + setup.router.dispatch({ + route: 'agent', rpc: 'player_login', data: fixture('player-login-room.json'), + }); + const before = setup.store.getState(); + let notifications = 0; + let gameEvents = 0; + setup.store.subscribe(() => { notifications += 1; }); + setup.observeEvent(() => { gameEvents += 1; }); + const rpcsByRoute = [ + ['player_login', 'agent'], + ['self_join_room', 'agent'], + ['player_prepare', 'room'], + ['self_exit_room', 'room'], + ['other_join_room', 'room'], + ['other_exit_room', 'room'], + ['other_offline', 'room'], + ['other_online', 'room'], + ['connect_roomserver', 'room'], + ['connect_agentserver', 'agent'], + ['kick_server', 'agent'], + ] as const; + + for (const route of ['platform', 'agent', 'room'] as const) { + assert.throws( + () => setup.router.dispatch({ route, rpc: 'unknown_rpc', data: {} }), + /unsupported.*unknown_rpc/i, + `${route}:unknown_rpc`, + ); + } + for (const [rpc, expectedRoute] of rpcsByRoute) { + for (const route of ['platform', 'agent', 'room'] as const) { + if (route === expectedRoute) continue; + assert.throws( + () => setup.router.dispatch({ route, rpc, data: {} }), + /route mismatch/i, + `${route}:${rpc}`, + ); + } + } + + assert.equal(setup.store.getState(), before); + assert.equal(setup.gameSession.state, 'active'); + assert.equal(setup.session.state, 'operational'); + assert.equal(notifications, 0); + assert.equal(gameEvents, 0); + assert.equal(setup.getDisposals(), 0); + assert.equal(setup.getInvalidations(), 0); + assert.deepEqual(setup.connection.switchCalls, []); + assert.deepEqual(setup.connection.stopCalls, []); +}); + +test('PlatformHandlers validates every switch URL before closing the active game', () => { + const invalidTargets = [ + 'http://invalid.example', + 'ws://user@example.com', + 'ws://example.com/#fragment', + 'ws:///path', + 'ws://example.com:99999', + 'ws://example.com:abc', + 'ws://', + ]; + + for (const roomserver of invalidTargets) { + const setup = makeHandlers(); + setup.router.dispatch({ + route: 'agent', rpc: 'player_login', data: fixture('player-login-room.json'), + }); + const before = setup.store.getState(); + + assert.throws( + () => setup.router.dispatch({ + route: 'room', rpc: 'connect_roomserver', data: { roomserver }, + }), + /roomserver|WebSocket/i, + roomserver, + ); + + assert.equal(setup.store.getState(), before, roomserver); + assert.equal(setup.gameSession.state, 'active', roomserver); + assert.equal(setup.session.state, 'operational', roomserver); + assert.equal(setup.getDisposals(), 0, roomserver); + assert.equal(setup.getInvalidations(), 0, roomserver); + assert.deepEqual(setup.connection.switchCalls, [], roomserver); + } +}); + +test('PlatformHandlers accepts case-insensitive WS/WSS schemes and preserves full payload identity', () => { + const setup = makeHandlers(); + const roomData = { roomserver: 'WS://EXAMPLE.com:80/path?x=1', extra: { trace: 1 } }; + const agentData = { + agentserver: 'WSS://secure.example:443/socket', opt: 'free_room', extra: { trace: 2 }, + }; + + setup.router.dispatch({ route: 'room', rpc: 'connect_roomserver', data: roomData }); + setup.router.dispatch({ route: 'agent', rpc: 'connect_agentserver', data: agentData }); + + assert.equal(setup.connection.switchCalls[0]?.target, roomData.roomserver); + assert.equal(setup.connection.switchCalls[0]?.resend.data, roomData); + assert.equal(setup.connection.switchCalls[1]?.target, agentData.agentserver); + assert.equal(setup.connection.switchCalls[1]?.resend.data, agentData); + assert.equal( + (setup.connection.switchCalls[1]?.resend.data as typeof agentData).extra, + agentData.extra, + ); +}); + +test('PlatformHandlers attempts a validated switch after cleanup errors and aggregates all failures', () => { + const setup = makeHandlers(); + setup.router.dispatch({ + route: 'agent', rpc: 'player_login', data: fixture('player-login-room.json'), + }); + const data = { roomserver: 'wss://switch.example/socket', epoch: 9 }; + const disposeError = new Error('switch:dispose'); + const invalidateError = new Error('switch:invalidate'); + const switchError = new Error('switch:connection'); + setup.observeDisposal(() => { throw disposeError; }); + setup.observeInvalidation(() => { throw invalidateError; }); + setup.connection.switchFailure = switchError; + + const error = caughtError(() => setup.router.dispatch({ + route: 'room', rpc: 'connect_roomserver', data, + })); + + assert.ok(error instanceof RuntimeSessionFault); + assert.deepEqual(error.errors, [disposeError, invalidateError, switchError]); + assert.equal(setup.gameSession.state, 'idle'); + assert.equal(setup.session.state, 'faulted'); + assert.equal(setup.getDisposals(), 1); + assert.equal(setup.getInvalidations(), 1); + assert.equal(setup.connection.switchCalls.length, 1); + assert.equal(setup.connection.switchCalls[0]?.target, data.roomserver); + assert.equal(setup.connection.switchCalls[0]?.resend.data, data); +}); + test('kick_server closes the game, marks the runtime session, and stops the connection once', () => { const setup = makeHandlers(); setup.handlers.dispatch({ route: 'agent', rpc: 'player_login', data: fixture('player-login-room.json') }); @@ -242,3 +605,64 @@ test('kick_server closes the game, marks the runtime session, and stops the conn assert.deepEqual(setup.connection.stopCalls, [kickData]); assert.equal(setup.connection.stopCalls[0], kickData); }); + +test('kick_server latches before cleanup and makes a reentrant duplicate a no-op', () => { + const setup = makeHandlers(); + setup.router.dispatch({ route: 'agent', rpc: 'player_login', data: fixture('player-login-room.json') }); + const kickData = { msg: 'same-reference' }; + let reentrantResult: ReturnType | undefined; + + setup.session.markLoginPending(); + setup.observeDisposal(() => { + assert.equal(setup.session.kicked, true); + assert.equal(setup.session.loginPending, false); + reentrantResult = setup.router.dispatch({ route: 'agent', rpc: 'kick_server', data: kickData }); + }); + + assert.equal( + setup.router.dispatch({ route: 'agent', rpc: 'kick_server', data: kickData }), + 'handled-platform', + ); + assert.equal(reentrantResult, 'handled-platform'); + assert.equal(setup.getDisposals(), 1); + assert.equal(setup.getInvalidations(), 1); + assert.equal(setup.connection.stopCalls.length, 1); + assert.equal(setup.connection.stopCalls[0], kickData); + assert.equal(setup.session.state, 'kicked'); +}); + +test('kick_server attempts dispose, invalidate, mark, and stop once then aggregates failures', () => { + const setup = makeHandlers(); + setup.router.dispatch({ route: 'agent', rpc: 'player_login', data: fixture('player-login-room.json') }); + const kickData = { msg: 'cleanup-failures' }; + const disposeError = new Error('kick:dispose'); + const invalidateError = new Error('kick:invalidate'); + const stopError = new Error('kick:stop'); + setup.observeDisposal(() => { throw disposeError; }); + setup.observeInvalidation(() => { throw invalidateError; }); + setup.connection.stopFailure = stopError; + setup.session.markLoginPending(); + + const error = caughtError( + () => setup.router.dispatch({ route: 'agent', rpc: 'kick_server', data: kickData }), + ); + + assert.ok(error instanceof RuntimeSessionFault); + assert.deepEqual(error.errors, [disposeError, invalidateError, stopError]); + assert.equal(setup.gameSession.state, 'idle'); + assert.equal(setup.session.kicked, true); + assert.equal(setup.session.state, 'kicked'); + assert.equal(setup.session.loginPending, false); + assert.equal(setup.getDisposals(), 1); + assert.equal(setup.getInvalidations(), 1); + assert.equal(setup.connection.stopCalls.length, 1); + assert.equal(setup.connection.stopCalls[0], kickData); + + assert.equal( + setup.router.dispatch({ route: 'agent', rpc: 'kick_server', data: kickData }), + 'handled-platform', + ); + assert.equal(setup.getDisposals(), 1); + assert.equal(setup.getInvalidations(), 1); + assert.equal(setup.connection.stopCalls.length, 1); +}); diff --git a/cocoscreator_projects/framework-tests/protocol/router.test.ts b/cocoscreator_projects/framework-tests/protocol/router.test.ts index 49301ad..b8ad63a 100644 --- a/cocoscreator_projects/framework-tests/protocol/router.test.ts +++ b/cocoscreator_projects/framework-tests/protocol/router.test.ts @@ -9,6 +9,7 @@ import type { } from '../../YouleNexus/assets/framework/sdk/contracts/index.ts'; import { GameSessionHost } from '../../YouleNexus/assets/framework/sdk/runtime/game-session-host.ts'; import { Router } from '../../YouleNexus/assets/framework/protocol/router.ts'; +import { FIRST_SLICE_RPC_ROUTE } from '../../YouleNexus/assets/framework/protocol/first-slice-routes.ts'; class LoginGateFixture { private pending = false; @@ -110,6 +111,41 @@ test('Router lets kick_server through the login gate but ignores every other rpc assert.deepEqual(calls, [kick]); }); +test('Router login gate enforces the complete route by rpc cross matrix', () => { + const platformRoutes = ['platform', 'agent', 'room'] as const; + const routes = [...platformRoutes, 'fixture-game'] as const; + const rpcs = [...Object.keys(FIRST_SLICE_RPC_ROUTE), 'unknown_rpc']; + + for (const route of routes) { + for (const rpc of rpcs) { + const gate = new LoginGateFixture(); + const platformCalls: InboundMessage[] = []; + const gameCalls: InboundMessage[] = []; + const router = new Router({ + session: gate, + platform: { + dispatch(message: InboundMessage) { + platformCalls.push(message); + return 'handled-platform' as const; + }, + }, + gameSession: makeGameSession(gameCalls), + }); + const message: InboundMessage = { route, rpc, data: { marker: `${route}:${rpc}` } }; + gate.markLoginPending(); + + const passes = route === 'agent' && (rpc === 'player_login' || rpc === 'kick_server'); + assert.equal( + router.dispatch(message), + passes ? 'handled-platform' : 'ignored-login-gate', + `${route}:${rpc}`, + ); + assert.deepEqual(platformCalls, passes ? [message] : [], `${route}:${rpc}`); + assert.deepEqual(gameCalls, [], `${route}:${rpc}`); + } + } +}); + test('Router sends all platform routes to PlatformHandlers exactly once', () => { const gate = new LoginGateFixture(); const calls: InboundMessage[] = []; diff --git a/cocoscreator_projects/scripts/lib/import-boundaries.mjs b/cocoscreator_projects/scripts/lib/import-boundaries.mjs index c293fc5..9a71be0 100644 --- a/cocoscreator_projects/scripts/lib/import-boundaries.mjs +++ b/cocoscreator_projects/scripts/lib/import-boundaries.mjs @@ -1,4 +1,4 @@ -import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { readdirSync, readFileSync, realpathSync, statSync } from 'node:fs'; import { dirname, isAbsolute, relative, resolve } from 'node:path'; import ts from 'typescript'; @@ -38,6 +38,137 @@ export function scanImportBoundaries(options) { return violations; } +/** + * Finds the first dependency path from one modern entry into a forbidden + * compatibility file. Reachable non-static dynamic imports fail closed. + */ +export function findCompatibilityDependency(options) { + const entry = canonicalPath(options.entry); + const traversalRoot = canonicalPath(options.traversalRoot); + const forbiddenFiles = new Set(options.forbiddenFiles.map((file) => pathKey(canonicalPath(file)))); + const compilerOptions = readCompilerOptions(options.tsconfigPath); + const moduleCache = ts.createModuleResolutionCache( + options.tsconfigPath ? dirname(resolve(options.tsconfigPath)) : traversalRoot, + (file) => pathKey(canonicalPath(file)), + compilerOptions, + ); + const visited = new Set(); + + const visit = (file, path) => { + const canonicalFile = canonicalPath(file); + const key = pathKey(canonicalFile); + const nextPath = [...path, canonicalFile]; + if (forbiddenFiles.has(key)) { + return { kind: 'forbidden', path: nextPath, file: canonicalFile }; + } + if (visited.has(key) || !isInsideOrEqual(canonicalFile, traversalRoot)) return null; + visited.add(key); + + const source = readFileSync(canonicalFile, 'utf8'); + for (const edge of extractDependencyEdges(source, canonicalFile)) { + if (edge.kind === 'dynamic') { + return { kind: 'dynamic', path: nextPath, file: canonicalFile }; + } + const imported = resolveDependency(edge.specifier, canonicalFile, compilerOptions, moduleCache); + if (imported === null) continue; + const found = visit(imported, nextPath); + if (found !== null) return found; + } + return null; + }; + + return visit(entry, []); +} + +function readCompilerOptions(tsconfigPath) { + if (!tsconfigPath) { + return { + allowImportingTsExtensions: true, + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + target: ts.ScriptTarget.ES2020, + }; + } + + const configFile = resolve(tsconfigPath); + const loaded = ts.readConfigFile(configFile, ts.sys.readFile); + if (loaded.error) throw new Error(formatTsDiagnostics([loaded.error])); + const parsed = ts.parseJsonConfigFileContent( + loaded.config, + ts.sys, + dirname(configFile), + undefined, + configFile, + ); + if (parsed.errors.length > 0) throw new Error(formatTsDiagnostics(parsed.errors)); + return parsed.options; +} + +function formatTsDiagnostics(diagnostics) { + return ts.formatDiagnostics(diagnostics, { + getCanonicalFileName: (file) => file, + getCurrentDirectory: () => process.cwd(), + getNewLine: () => '\n', + }); +} + +function extractDependencyEdges(source, file) { + const edges = []; + const sourceFile = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, false, ts.ScriptKind.TS); + const visit = (node) => { + if ((ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) + && node.moduleSpecifier + && ts.isStringLiteralLike(node.moduleSpecifier)) { + edges.push({ kind: 'static', specifier: node.moduleSpecifier.text }); + } else if (ts.isImportTypeNode(node) + && ts.isLiteralTypeNode(node.argument) + && ts.isStringLiteralLike(node.argument.literal)) { + edges.push({ kind: 'static', specifier: node.argument.literal.text }); + } else if (ts.isCallExpression(node) + && node.expression.kind === ts.SyntaxKind.ImportKeyword) { + const argument = node.arguments[0]; + if (node.arguments.length === 1 && argument && ts.isStringLiteralLike(argument)) { + edges.push({ kind: 'static', specifier: argument.text }); + } else { + edges.push({ kind: 'dynamic' }); + } + } + ts.forEachChild(node, visit); + }; + ts.forEachChild(sourceFile, visit); + return edges; +} + +function resolveDependency(specifier, containingFile, compilerOptions, moduleCache) { + const result = ts.resolveModuleName( + specifier, + containingFile, + compilerOptions, + ts.sys, + moduleCache, + ); + return result.resolvedModule ? canonicalPath(result.resolvedModule.resolvedFileName) : null; +} + +function canonicalPath(file) { + const absolute = resolve(file); + try { + return realpathSync.native(absolute); + } catch (error) { + if (error && error.code === 'ENOENT') return absolute; + throw error; + } +} + +function pathKey(file) { + return process.platform === 'win32' ? file.toLowerCase() : file; +} + +function isInsideOrEqual(file, directory) { + if (pathKey(file) === pathKey(directory)) return true; + return isInside(file, directory); +} + function* typescriptFiles(directory) { let entries; try {