From 1c124cf2169d952efe42bb24d39947f1106e598b Mon Sep 17 00:00:00 2001 From: Joywayer Date: Sat, 5 Sep 2026 05:23:58 +0800 Subject: [PATCH] refactor(platform): unify routing and session dispatch --- .../framework/platform/connection-intent.ts | 13 + .../framework/platform/runtime-session.ts | 107 ++++++++ .../assets/framework/protocol/active-game.ts | 17 -- .../framework/protocol/platform-handlers.ts | 164 ++++++++++++ .../assets/framework/protocol/router.ts | 80 +++--- .../architecture/import-boundaries.test.mjs | 84 +++++- .../platform/runtime-session.test.ts | 111 ++++++++ .../protocol/active-game.test.ts | 40 --- .../protocol/platform-handlers.test.ts | 244 ++++++++++++++++++ .../framework-tests/protocol/router.test.ts | 227 +++++++++++----- 10 files changed, 916 insertions(+), 171 deletions(-) create mode 100644 cocoscreator_projects/YouleNexus/assets/framework/platform/connection-intent.ts create mode 100644 cocoscreator_projects/YouleNexus/assets/framework/platform/runtime-session.ts delete mode 100644 cocoscreator_projects/YouleNexus/assets/framework/protocol/active-game.ts create mode 100644 cocoscreator_projects/YouleNexus/assets/framework/protocol/platform-handlers.ts create mode 100644 cocoscreator_projects/framework-tests/platform/runtime-session.test.ts delete mode 100644 cocoscreator_projects/framework-tests/protocol/active-game.test.ts create mode 100644 cocoscreator_projects/framework-tests/protocol/platform-handlers.test.ts diff --git a/cocoscreator_projects/YouleNexus/assets/framework/platform/connection-intent.ts b/cocoscreator_projects/YouleNexus/assets/framework/platform/connection-intent.ts new file mode 100644 index 0000000..9de583a --- /dev/null +++ b/cocoscreator_projects/YouleNexus/assets/framework/platform/connection-intent.ts @@ -0,0 +1,13 @@ +import type { OutboundEnvelope } from '../core/types/envelope.ts'; + +export type OutboundMessage = OutboundEnvelope; + +export type ConnectionIntent = + | { readonly type: 'none' } + | { readonly type: 'login'; readonly envelope: OutboundMessage } + | { readonly type: 'switch'; readonly target: string; readonly resend: OutboundMessage }; + +export interface ConnectionControlPort { + switchServer(target: string, resend: OutboundMessage): void; + stopForKick(data: unknown): void; +} diff --git a/cocoscreator_projects/YouleNexus/assets/framework/platform/runtime-session.ts b/cocoscreator_projects/YouleNexus/assets/framework/platform/runtime-session.ts new file mode 100644 index 0000000..c360ab1 --- /dev/null +++ b/cocoscreator_projects/YouleNexus/assets/framework/platform/runtime-session.ts @@ -0,0 +1,107 @@ +import type { GameHost } from '../sdk/contracts/index.ts'; +import { GameSessionHost } from '../sdk/runtime/game-session-host.ts'; +import { + parseLoginResponse, + parseSelfJoinRoomResponse, + type ParsedSeatPayload, + type ReconnectSnapshot, +} from '../protocol/contracts/index.ts'; +import { PlatformStore } from './stores/platform-store.ts'; + +export interface RuntimeSessionOptions { + readonly store: PlatformStore; + readonly gameSession: GameSessionHost; + readonly gameHost: GameHost; +} + +/** Coordinates the canonical platform snapshot with the one active game session. */ +export class RuntimeSession { + private loginIsPending = false; + private wasKicked = false; + + constructor(private readonly options: RuntimeSessionOptions) {} + + get loginPending(): boolean { return this.loginIsPending; } + + get kicked(): boolean { return this.wasKicked; } + + markLoginPending(): void { + this.requireNotKicked('mark login pending'); + this.loginIsPending = true; + } + + handleLogin(data: unknown): void { + this.requireNotKicked('handle login'); + this.loginIsPending = false; + const parsed = parseLoginResponse(data); + this.options.store.applyLoginSuccess(parsed); + if (parsed.room === null) { + this.closeActiveGame(); + return; + } + this.openCommittedRoom(parsed.reconnect); + } + + handleSelfJoin(data: unknown): void { + this.requireNotKicked('handle self join'); + const parsed = parseSelfJoinRoomResponse(data); + this.options.store.replaceRoom(parsed); + this.openCommittedRoom(parsed.reconnect); + } + + handleSelfExit(parsed: ParsedSeatPayload): void { + this.requireNotKicked('handle self exit'); + const state = this.options.store.getState(); + if (state.room.kind !== 'inside') { + throw new Error('self_exit_room requires an active room'); + } + if (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'); + } + this.options.store.clearRoom(); + this.options.gameSession.close(); + } + + handleServerSwitch(): void { + this.requireNotKicked('handle server switch'); + this.closeActiveGame(); + } + + handleKick(_data: unknown): void { + this.closeActiveGame(); + this.loginIsPending = false; + this.wasKicked = true; + } + + private openCommittedRoom(reconnect: ReconnectSnapshot): void { + const state = this.options.store.getState(); + if (state.room.kind !== 'inside') { + throw new Error('Cannot open a game session without committed room state'); + } + 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 requireNotKicked(operation: string): void { + if (this.wasKicked) throw new Error(`Cannot ${operation} after kick_server`); + } +} diff --git a/cocoscreator_projects/YouleNexus/assets/framework/protocol/active-game.ts b/cocoscreator_projects/YouleNexus/assets/framework/protocol/active-game.ts deleted file mode 100644 index dbd5e5f..0000000 --- a/cocoscreator_projects/YouleNexus/assets/framework/protocol/active-game.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { IGameModule } from '../sdk/index.ts'; - -/** - * 当前激活的子游戏槽(= 旧 Game_Modify 的运行时角色)。 - * - * 框架在进房时 setGame(),离房时 clear()。Router 据此分发对局 route 的包。 - * 单例设计:同一时刻只允许一个子游戏激活(与旧 12_Logic 一致)。 - */ -export class ActiveGame { - private game: IGameModule | null = null; - - get(): IGameModule | null { return this.game; } - - set(game: IGameModule): void { this.game = game; } - - clear(): void { this.game = null; } -} \ No newline at end of file diff --git a/cocoscreator_projects/YouleNexus/assets/framework/protocol/platform-handlers.ts b/cocoscreator_projects/YouleNexus/assets/framework/protocol/platform-handlers.ts new file mode 100644 index 0000000..8c404df --- /dev/null +++ b/cocoscreator_projects/YouleNexus/assets/framework/protocol/platform-handlers.ts @@ -0,0 +1,164 @@ +import type { InboundMessage } from '../core/types/envelope.ts'; +import type { GameSessionHost } from '../sdk/runtime/game-session-host.ts'; +import type { ConnectionControlPort } from '../platform/connection-intent.ts'; +import type { RuntimeSession } from '../platform/runtime-session.ts'; +import type { PlatformStore } from '../platform/stores/platform-store.ts'; +import { + parseConnectAgentserverPayload, + parseConnectRoomserverPayload, + parseKickServerPayload, + parseOtherExitRoomPayload, + parseOtherJoinRoomPayload, + parseOtherOfflinePayload, + parseOtherOnlinePayload, + parsePlayerPreparePayload, +} from './contracts/index.ts'; +import { + buildFirstSliceEnvelope, + FIRST_SLICE_RPC_ROUTE, + routeForFirstSlice, + type FirstSliceRpc, +} from './first-slice-routes.ts'; + +export interface PlatformHandlersOptions { + readonly store: PlatformStore; + readonly gameSession: GameSessionHost; + readonly session: RuntimeSession; + readonly connection: ConnectionControlPort; +} + +export type PlatformDispatchResult = 'handled-platform'; + +/** Strict first-slice platform RPC dispatcher. */ +export class PlatformHandlers { + constructor(private readonly options: PlatformHandlersOptions) {} + + dispatch(message: InboundMessage): PlatformDispatchResult { + if (!isPlatformRoute(message.route)) { + throw new Error( + `PlatformHandlers received non-platform route: route=${message.route} rpc=${message.rpc}`, + ); + } + if (!Object.prototype.hasOwnProperty.call(FIRST_SLICE_RPC_ROUTE, message.rpc)) { + throw new Error( + `Unsupported platform rpc: route=${message.route} rpc=${message.rpc}`, + ); + } + const rpc = message.rpc as FirstSliceRpc; + const expectedRoute = routeForFirstSlice(rpc); + if (message.route !== expectedRoute) { + throw new Error( + `Platform rpc route mismatch: rpc=${rpc} expected=${expectedRoute} received=${message.route}`, + ); + } + + switch (rpc) { + case 'player_login': + this.options.session.handleLogin(message.data); + break; + case 'self_join_room': + this.options.session.handleSelfJoin(message.data); + break; + case 'player_prepare': { + const parsed = parsePlayerPreparePayload(message.data); + this.requireActiveGame(rpc); + this.options.store.playerReady(parsed); + this.options.gameSession.publish({ type: 'room.player-ready', seat: parsed.seat }); + break; + } + case 'self_exit_room': { + const parsed = parseOtherExitRoomPayload(message.data); + this.options.session.handleSelfExit(parsed); + break; + } + case 'other_join_room': { + const parsed = parseOtherJoinRoomPayload(message.data); + this.requireActiveGame(rpc); + this.options.store.playerJoin(parsed); + this.options.gameSession.publish({ type: 'room.player-joined', seat: parsed.seat }); + break; + } + case 'other_exit_room': { + const parsed = parseOtherExitRoomPayload(message.data); + this.requireActiveGame(rpc); + this.options.store.playerExit(parsed); + this.options.gameSession.publish({ type: 'room.player-left', seat: parsed.seat }); + break; + } + case 'other_offline': { + const parsed = parseOtherOfflinePayload(message.data); + this.requireActiveGame(rpc); + this.options.store.playerOffline(parsed); + this.options.gameSession.publish({ type: 'room.player-offline', seat: parsed.seat }); + break; + } + case 'other_online': { + const parsed = parseOtherOnlinePayload(message.data); + this.requireActiveGame(rpc); + this.options.store.playerOnline(parsed); + this.options.gameSession.publish({ type: 'room.player-online', seat: parsed.seat }); + break; + } + case 'connect_roomserver': { + 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); + 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); + break; + } + case 'kick_server': { + const parsed = parseKickServerPayload(message.data); + this.options.session.handleKick(parsed.raw); + this.options.connection.stopForKick(parsed.raw); + break; + } + } + return 'handled-platform'; + } + + private requireActiveGame(rpc: string): void { + if (this.options.gameSession.state !== 'active') { + throw new Error(`${rpc} requires an active game session`); + } + } +} + +function isPlatformRoute(route: string): route is 'platform' | 'agent' | 'room' { + return route === 'platform' || route === 'agent' || route === 'room'; +} + +function parseSwitchTarget(address: string, path: string): string { + if (address.length === 0 || address.trim() !== address) { + 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}`; + } + + let parsed: URL; + try { + parsed = new URL(target); + } catch { + throw new TypeError(`${path}: expected a valid WebSocket server address`); + } + if ((parsed.protocol !== 'ws:' && parsed.protocol !== 'wss:') || parsed.hostname.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 4ddbc62..de12b82 100644 --- a/cocoscreator_projects/YouleNexus/assets/framework/protocol/router.ts +++ b/cocoscreator_projects/YouleNexus/assets/framework/protocol/router.ts @@ -1,53 +1,43 @@ import type { InboundMessage } from '../core/types/envelope.ts'; -import { Route } from '../core/constants.ts'; -import type { ActiveGame } from './active-game.ts'; +import type { GameSessionHost } from '../sdk/runtime/game-session-host.ts'; +import type { RuntimeSession } from '../platform/runtime-session.ts'; +import type { PlatformDispatchResult, PlatformHandlers } from './platform-handlers.ts'; -/** 平台层 handler 表:key = rpc 字符串(与协议逐字对齐)。 */ -export type PlatformHandlers = Record void>; +export type RouterDispatchResult = + | 'ignored-login-gate' + | PlatformDispatchResult + | 'handled-game'; -/** - * Router:收包路由分界(C §4.1 的红线)。 - * - * 分发规则: - * - route ∈ {platform, agent, room} → 平台 handlers(RpcList 覆盖的包) - * - route = (= activeGame.route)→ activeGame.onReceive - * - 其他或未注册:显式抛错(第二准则:不静默吞包) - * - * 不持有状态——只做消息分发。状态由 PlatformSession/子游戏维护。 - */ +export interface RouterOptions { + readonly session: Pick; + readonly platform: Pick; + readonly gameSession: Pick; +} + +/** Classifies each inbound business envelope exactly once. */ export class Router { - constructor( - private readonly opts: { - activeGame: ActiveGame; - platformHandlers: PlatformHandlers; - }, - ) {} + constructor(private readonly options: RouterOptions) {} - dispatch(msg: InboundMessage): void { - const { route, rpc, data } = msg; - // 平台三层 route:platform/agent/room 全部走平台 handlers - if (route === Route.platform || route === Route.agent || route === Route.room) { - const handler = this.opts.platformHandlers[rpc]; - if (!handler) { - throw new Error( - `Router 收到未注册的平台 rpc: route=${route} rpc=${rpc}(platformHandlers 未注册,第二准则禁止兜底)`, - ); - } - handler(data); - return; + dispatch(message: InboundMessage): RouterDispatchResult { + if (this.options.session.loginPending && !isLoginGateControl(message)) { + return 'ignored-login-gate'; } - // 对局 route:必须与 activeGame.route 严格匹配 - const game = this.opts.activeGame.get(); - if (!game) { - throw new Error( - `Router 收到对局包但无激活子游戏: route=${route} rpc=${rpc}(未进房或子游戏未注册)`, - ); + if (isPlatformRoute(message.route)) { + return this.options.platform.dispatch(message); } - if (game.route !== route) { - throw new Error( - `Router route 与 activeGame.route 不匹配: msg.route=${route} activeGame.route=${game.route}`, - ); - } - game.onReceive(rpc, data); + this.options.gameSession.dispatchGameMessage(message.route, { + rpc: message.rpc, + data: message.data, + }); + return 'handled-game'; } -} \ No newline at end of file +} + +function isPlatformRoute(route: string): route is 'platform' | 'agent' | 'room' { + return route === 'platform' || route === 'agent' || route === 'room'; +} + +function isLoginGateControl(message: InboundMessage): boolean { + return message.route === 'agent' + && (message.rpc === 'player_login' || message.rpc === 'kick_server'); +} diff --git a/cocoscreator_projects/framework-tests/architecture/import-boundaries.test.mjs b/cocoscreator_projects/framework-tests/architecture/import-boundaries.test.mjs index 386ebd9..e4cd7b1 100644 --- a/cocoscreator_projects/framework-tests/architecture/import-boundaries.test.mjs +++ b/cocoscreator_projects/framework-tests/architecture/import-boundaries.test.mjs @@ -1,8 +1,11 @@ import { afterEach, test } from 'node:test'; import assert from 'node:assert/strict'; +import { existsSync, readFileSync } from 'node:fs'; import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { dirname, join } from 'node:path'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import ts from 'typescript'; import { scanImportBoundaries } from '../../scripts/lib/import-boundaries.mjs'; const roots = []; @@ -30,6 +33,85 @@ function scan(root) { }); } +const frameworkRoot = fileURLToPath(new URL('../../YouleNexus/assets/framework/', import.meta.url)); +const modernRuntimeEntries = [ + 'net/wire-client.ts', + 'platform/runtime-session.ts', + 'protocol/platform-handlers.ts', + 'protocol/router.ts', + // Task 10 creates this entry; the same assertion starts covering it as soon as it exists. + 'platform/runtime.ts', +]; +const legacyRuntimeFiles = new Set([ + 'net/net-client.ts', + 'platform/session.ts', + 'platform/startup.ts', + 'platform/room-rpc-bus.ts', + 'platform/readonly.ts', + 'platform/stores/app-store.ts', + 'platform/stores/player-store.ts', + 'platform/stores/room-store.ts', + 'platform/stores/types.ts', + '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; +} + +test('every existing modern runtime entry is transitively isolated from the legacy compatibility island', () => { + const entries = modernRuntimeEntries + .map((file) => resolve(frameworkRoot, file)) + .filter((file) => existsSync(file)); + assert.ok(entries.some((file) => file.endsWith(`${join('protocol', 'router.ts')}`))); + + for (const entry of entries) { + const dependencyPath = findLegacyDependency(entry); + assert.equal( + dependencyPath, + null, + dependencyPath?.map((file) => file.slice(frameworkRoot.length)).join(' -> '), + ); + } +}); + 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/runtime-session.test.ts b/cocoscreator_projects/framework-tests/platform/runtime-session.test.ts new file mode 100644 index 0000000..499bc2a --- /dev/null +++ b/cocoscreator_projects/framework-tests/platform/runtime-session.test.ts @@ -0,0 +1,111 @@ +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 gameHost: GameHost = { + seat: { toView: (seat) => seat, toServer: (seat) => seat }, + getSnapshot: () => selectGameSnapshot(store.getState()), + subscribe: () => () => {}, + sendGameMessage() {}, + execute() {}, + }; + const session = new RuntimeSession({ store, gameSession, gameHost }); + 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']); +}); diff --git a/cocoscreator_projects/framework-tests/protocol/active-game.test.ts b/cocoscreator_projects/framework-tests/protocol/active-game.test.ts deleted file mode 100644 index 0886bb8..0000000 --- a/cocoscreator_projects/framework-tests/protocol/active-game.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import { ActiveGame } from '../../YouleNexus/assets/framework/protocol/active-game.ts'; -import type { IGameModule, GameContext } from '../../YouleNexus/assets/framework/sdk/index.ts'; - -class MockGame implements IGameModule { - readonly route = 'mock'; - events: string[] = []; - onEnter(_ctx: GameContext): void { this.events.push('onEnter'); } - onExit(): void { this.events.push('onExit'); } - onReceive(rpc: string, _d: unknown): void { this.events.push(`onReceive:${rpc}`); } - onReconnect(_d: unknown): void { this.events.push('onReconnect'); } -} - -test('初始: getGame 返回 null', () => { - assert.equal(new ActiveGame().get(), null); -}); - -test('setGame: 设置后 get 返回同一实例', () => { - const slot = new ActiveGame(); - const g = new MockGame(); - slot.set(g); - assert.equal(slot.get(), g); -}); - -test('clear: 清空后 get 返回 null', () => { - const slot = new ActiveGame(); - slot.set(new MockGame()); - slot.clear(); - assert.equal(slot.get(), null); -}); - -test('set 覆盖前一个 game', () => { - const slot = new ActiveGame(); - const g1 = new MockGame(); - const g2 = new MockGame(); - slot.set(g1); - slot.set(g2); - assert.equal(slot.get(), g2); -}); \ No newline at end of file diff --git a/cocoscreator_projects/framework-tests/protocol/platform-handlers.test.ts b/cocoscreator_projects/framework-tests/protocol/platform-handlers.test.ts new file mode 100644 index 0000000..1fec09c --- /dev/null +++ b/cocoscreator_projects/framework-tests/protocol/platform-handlers.test.ts @@ -0,0 +1,244 @@ +import { readFileSync } from 'node:fs'; +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import type { OutboundEnvelope } from '../../YouleNexus/assets/framework/core/types/envelope.ts'; +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 type { ConnectionControlPort } from '../../YouleNexus/assets/framework/platform/connection-intent.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'; +import { PlatformHandlers } from '../../YouleNexus/assets/framework/protocol/platform-handlers.ts'; +import { Router } from '../../YouleNexus/assets/framework/protocol/router.ts'; + +function fixture(name: string): Record { + return JSON.parse(readFileSync(new URL(`../fixtures/contracts/${name}`, import.meta.url), 'utf8')).data; +} + +class ConnectionFixture implements ConnectionControlPort { + readonly switchCalls: Array<{ target: string; resend: OutboundEnvelope }> = []; + readonly stopCalls: unknown[] = []; + + switchServer(target: string, resend: OutboundEnvelope): void { + this.switchCalls.push({ target, resend }); + } + + stopForKick(data: unknown): void { this.stopCalls.push(data); } +} + +function makeHandlers() { + const store = new PlatformStore(); + let disposals = 0; + let eventObserver: ( + event: PlatformToGameEvent, + snapshot: PlatformGameSnapshot, + ) => void = () => {}; + let disposalObserver: () => void = () => {}; + const entry: GameEntry = { + key: 'fixture', gameId: 41, route: 'fixture-game', resolveSeatCount: () => 4, + createModule: (): GameModule => ({ + attach() {}, + handlePlatformEvent(event) { + eventObserver(event, selectGameSnapshot(store.getState())); + }, + handleGameMessage() {}, + restore() {}, + dispose() { disposals += 1; disposalObserver(); }, + }), + }; + 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 connection = new ConnectionFixture(); + const handlers = new PlatformHandlers({ store, gameSession, session, connection }); + const router = new Router({ session, platform: handlers, gameSession }); + return { + store, + gameSession, + session, + connection, + handlers, + router, + getDisposals: () => disposals, + observeEvent(observer: typeof eventObserver) { eventObserver = observer; }, + observeDisposal(observer: () => void) { disposalObserver = observer; }, + }; +} + +test('PlatformHandlers commits every room event before one typed game event reads the snapshot', () => { + const setup = makeHandlers(); + setup.handlers.dispatch({ + route: 'agent', rpc: 'player_login', data: fixture('player-login-success.json'), + }); + + function dispatchWithEvent( + message: { route: string; rpc: string; data: Record }, + expectedType: PlatformToGameEvent['type'], + assertSnapshot: (snapshot: PlatformGameSnapshot) => void, + ): void { + const order: string[] = []; + let received = 0; + const unsubscribe = setup.store.subscribe(() => { order.push('store'); }); + setup.observeEvent((event, snapshot) => { + received += 1; + order.push('game'); + assert.equal(event.type, expectedType); + assertSnapshot(snapshot); + }); + + assert.equal(setup.handlers.dispatch(message), 'handled-platform'); + + unsubscribe(); + assert.equal(received, 1); + assert.deepEqual(order, ['store', 'game']); + } + + const join = fixture('self-join-room.json'); + delete join.deskinfo; + join.deskwar = 0; + dispatchWithEvent( + { route: 'agent', rpc: 'self_join_room', data: join }, + 'room.entered', + (snapshot) => { assert.equal(snapshot.room.roomcode, '100001'); }, + ); + + dispatchWithEvent( + { + 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, + }, + }, + 'room.player-joined', + (snapshot) => { assert.equal(snapshot.seats.find((seat) => seat.seat === 2)?.playerId, 77); }, + ); + + dispatchWithEvent( + { route: 'room', rpc: 'player_prepare', data: { seat: 2, deskwar: 1 } }, + 'room.player-ready', + (snapshot) => { + assert.equal(snapshot.seats.find((seat) => seat.seat === 2)?.ready, true); + assert.equal(snapshot.room.stage, 1); + }, + ); + + dispatchWithEvent( + { route: 'room', rpc: 'other_offline', data: { seat: 2 } }, + 'room.player-offline', + (snapshot) => { assert.equal(snapshot.seats.find((seat) => seat.seat === 2)?.online, false); }, + ); + + dispatchWithEvent( + { route: 'room', rpc: 'other_online', data: { seat: 2, ip: '127.0.0.3' } }, + 'room.player-online', + (snapshot) => { assert.equal(snapshot.seats.find((seat) => seat.seat === 2)?.online, true); }, + ); + + dispatchWithEvent( + { route: 'room', rpc: 'other_exit_room', data: { seat: 2 } }, + 'room.player-left', + (snapshot) => { assert.equal(snapshot.seats.some((seat) => seat.seat === 2), false); }, + ); +}); + +test('self_exit_room commits the outside snapshot before disposing the game session', () => { + const setup = makeHandlers(); + setup.handlers.dispatch({ + route: 'agent', rpc: 'player_login', data: fixture('player-login-room.json'), + }); + const order: string[] = []; + setup.store.subscribe(() => { order.push('store'); }); + setup.observeDisposal(() => { + order.push('dispose'); + assert.equal(setup.store.getState().room.kind, 'outside'); + }); + + setup.handlers.dispatch({ route: 'room', rpc: 'self_exit_room', data: { seat: 1 } }); + + assert.deepEqual(order, ['store', 'dispose']); + assert.equal(setup.gameSession.state, 'idle'); +}); + +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 }; + const agentData = { + agentserver: 'secure.example:443/path', opt: 'free_room', trace: { id: 'x' }, + }; + + assert.equal(setup.router.dispatch({ + route: 'room', rpc: 'connect_roomserver', data: roomData, + }), 'handled-platform'); + assert.equal(setup.router.dispatch({ + route: 'agent', rpc: 'connect_agentserver', data: agentData, + }), 'handled-platform'); + + assert.deepEqual(setup.connection.switchCalls, [ + { + target: 'ws://10.0.0.2:3088', + resend: { app: 'youle', route: 'room', rpc: 'connect_roomserver', data: roomData }, + }, + { + target: 'ws://secure.example:443/path', + resend: { app: 'youle', route: 'agent', rpc: 'connect_agentserver', data: agentData }, + }, + ]); + assert.equal(setup.connection.switchCalls[0]?.resend.data, roomData); + assert.equal(setup.connection.switchCalls[1]?.resend.data, agentData); +}); + +test('PlatformHandlers rejects unknown, route-mismatched, missing, and invalid switch targets without effects', () => { + const setup = makeHandlers(); + const before = setup.store.getState(); + + assert.throws( + () => setup.handlers.dispatch({ route: 'agent', rpc: 'update_bean', data: { bean: 1 } }), + /update_bean/, + ); + assert.throws( + () => setup.handlers.dispatch({ route: 'agent', rpc: 'connect_roomserver', data: { roomserver: 'x:1' } }), + /route/i, + ); + assert.throws( + () => setup.handlers.dispatch({ route: 'room', rpc: 'connect_roomserver', data: {} }), + /roomserver/, + ); + assert.throws( + () => setup.handlers.dispatch({ + route: 'room', rpc: 'connect_roomserver', data: { roomserver: 'http://invalid.example' }, + }), + /roomserver|WebSocket/i, + ); + + assert.equal(setup.store.getState(), before); + assert.deepEqual(setup.connection.switchCalls, []); +}); + +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') }); + const kickData = { msg: '检查到新版本', code: 1 }; + + setup.session.markLoginPending(); + setup.router.dispatch({ route: 'agent', rpc: 'kick_server', data: kickData }); + + assert.equal(setup.gameSession.state, 'idle'); + assert.equal(setup.session.kicked, true); + assert.equal(setup.session.loginPending, false); + assert.equal(setup.getDisposals(), 1); + assert.deepEqual(setup.connection.stopCalls, [kickData]); + assert.equal(setup.connection.stopCalls[0], kickData); +}); diff --git a/cocoscreator_projects/framework-tests/protocol/router.test.ts b/cocoscreator_projects/framework-tests/protocol/router.test.ts index 7c494b1..49301ad 100644 --- a/cocoscreator_projects/framework-tests/protocol/router.test.ts +++ b/cocoscreator_projects/framework-tests/protocol/router.test.ts @@ -1,75 +1,166 @@ -import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { Router } from '../../YouleNexus/assets/framework/protocol/router.ts'; -import { ActiveGame } from '../../YouleNexus/assets/framework/protocol/active-game.ts'; +import { test } from 'node:test'; import type { InboundMessage } from '../../YouleNexus/assets/framework/core/types/envelope.ts'; -import type { IGameModule, GameContext } from '../../YouleNexus/assets/framework/sdk/index.ts'; +import type { + GameEntry, + GameHost, + GameModule, + PlatformGameSnapshot, +} 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'; -class MockGame implements IGameModule { - readonly route = 'mock'; - events: string[] = []; - onEnter(_ctx: GameContext): void {} - onExit(): void {} - onReceive(rpc: string, _d: unknown): void { this.events.push(`onReceive:${rpc}`); } - onReconnect(_d: unknown): void {} +class LoginGateFixture { + private pending = false; + + get loginPending(): boolean { return this.pending; } + markLoginPending(): void { this.pending = true; } + completeLogin(): void { this.pending = false; } } -const msg = (route: string, rpc: string, data: unknown = {}): InboundMessage => - ({ route, rpc, data } as InboundMessage); - -test('platform route → 平台 handler(已注册)', () => { - const slot = new ActiveGame(); - const calls: Array<[string, unknown]> = []; - const router = new Router({ - activeGame: slot, - platformHandlers: { player_login: (d) => calls.push(['player_login', d]) }, - }); - router.dispatch(msg('agent', 'player_login', { state: 0 })); - assert.equal(calls.length, 1); - assert.equal(calls[0][0], 'player_login'); -}); - -test('platform route → 平台 handler 未注册 → 显式抛错(第二准则)', () => { - const slot = new ActiveGame(); - const router = new Router({ activeGame: slot, platformHandlers: {} }); - assert.throws(() => router.dispatch(msg('agent', 'unknown_rpc')), /unknown_rpc/); -}); - -test('非 platform route 但 activeGame 已注册 → onReceive', () => { - const slot = new ActiveGame(); - const game = new MockGame(); - slot.set(game); - const router = new Router({ activeGame: slot, platformHandlers: {} }); - router.dispatch(msg('mock', 'deal_card', { n: 1 })); - assert.deepEqual(game.events, ['onReceive:deal_card']); -}); - -test('非 platform route 但 activeGame 未注册 → 显式抛错', () => { - const slot = new ActiveGame(); - const router = new Router({ activeGame: slot, platformHandlers: {} }); - assert.throws(() => router.dispatch(msg('mock', 'deal_card')), /mock.*deal_card/); -}); - -test('route 与 activeGame.route 不匹配 → 显式抛错', () => { - const slot = new ActiveGame(); - slot.set(new MockGame()); // route = 'mock' - const router = new Router({ activeGame: slot, platformHandlers: {} }); - assert.throws(() => router.dispatch(msg('other-game', 'rpc')), /other-game/); -}); - -test('platform/agent/room 三 route 全部走平台 handler(对称)', () => { - const slot = new ActiveGame(); - const calls: string[] = []; - const router = new Router({ - activeGame: slot, - platformHandlers: { - r1: () => calls.push('r1'), - r2: () => calls.push('r2'), - r3: () => calls.push('r3'), +function makeGameSession(received: InboundMessage[]): GameSessionHost { + const entry: GameEntry = { + key: 'fixture', + gameId: 41, + route: 'fixture-game', + resolveSeatCount: () => 4, + createModule: (): GameModule => ({ + attach() {}, + handlePlatformEvent() {}, + handleGameMessage(message) { + received.push({ route: entry.route, rpc: message.rpc, data: message.data }); + }, + restore() {}, + dispose() {}, + }), + }; + const snapshot: PlatformGameSnapshot = { + connection: { phase: 'logged-in' }, + self: { playerId: 1, seat: 0 }, + room: { + roomcode: '100001', roomtype: [], stage: 0, needprepare: 1, infinite: 0, }, + seats: [], + }; + const host: GameHost = { + seat: { toView: (seat) => seat, toServer: (seat) => seat }, + getSnapshot: () => snapshot, + subscribe: () => () => {}, + sendGameMessage() {}, + execute() {}, + }; + const session = new GameSessionHost(entry); + session.open(host); + return session; +} + +test('Router applies the exact login gate before its single dispatch split', () => { + const gate = new LoginGateFixture(); + const platformCalls: InboundMessage[] = []; + const gameCalls: InboundMessage[] = []; + const gameSession = makeGameSession(gameCalls); + const router = new Router({ + session: gate, + platform: { + dispatch(message: InboundMessage) { + platformCalls.push(message); + if (message.rpc === 'player_login') gate.completeLogin(); + return 'handled-platform' as const; + }, + }, + gameSession, }); - router.dispatch(msg('platform', 'r1')); - router.dispatch(msg('agent', 'r2')); - router.dispatch(msg('room', 'r3')); - assert.deepEqual(calls, ['r1', 'r2', 'r3']); -}); \ No newline at end of file + const updateBeanMessage: InboundMessage = { + route: 'agent', rpc: 'update_bean', data: { bean: 10 }, + }; + const playerLoginMessage: InboundMessage = { + route: 'agent', rpc: 'player_login', data: { state: 0 }, + }; + const gameMessage: InboundMessage = { + route: 'fixture-game', rpc: 'deal', data: { card: 7 }, + }; + + gate.markLoginPending(); + assert.equal(router.dispatch(updateBeanMessage), 'ignored-login-gate'); + assert.equal(router.dispatch(gameMessage), 'ignored-login-gate'); + assert.equal(router.dispatch(playerLoginMessage), 'handled-platform'); + assert.equal(router.dispatch(gameMessage), 'handled-game'); + + assert.deepEqual(platformCalls, [playerLoginMessage]); + assert.deepEqual(gameCalls, [gameMessage]); +}); + +test('Router lets kick_server through the login gate but ignores every other rpc', () => { + const gate = new LoginGateFixture(); + const calls: InboundMessage[] = []; + const router = new Router({ + session: gate, + platform: { + dispatch(message: InboundMessage) { + calls.push(message); + return 'handled-platform' as const; + }, + }, + gameSession: makeGameSession([]), + }); + const kick: InboundMessage = { route: 'agent', rpc: 'kick_server', data: { msg: 'bye' } }; + + gate.markLoginPending(); + assert.equal(router.dispatch(kick), 'handled-platform'); + assert.deepEqual(calls, [kick]); +}); + +test('Router sends all platform routes to PlatformHandlers exactly once', () => { + const gate = new LoginGateFixture(); + const calls: InboundMessage[] = []; + const router = new Router({ + session: gate, + platform: { + dispatch(message: InboundMessage) { + calls.push(message); + return 'handled-platform' as const; + }, + }, + gameSession: makeGameSession([]), + }); + const messages: InboundMessage[] = [ + { route: 'platform', rpc: 'p', data: {} }, + { route: 'agent', rpc: 'a', data: {} }, + { route: 'room', rpc: 'r', data: {} }, + ]; + + for (const message of messages) { + assert.equal(router.dispatch(message), 'handled-platform'); + } + assert.deepEqual(calls, messages); +}); + +test('Router preserves game payload identity and mismatched routes have no side effects', () => { + const gate = new LoginGateFixture(); + const gameCalls: InboundMessage[] = []; + const platformCalls: InboundMessage[] = []; + const router = new Router({ + session: gate, + platform: { + dispatch(message: InboundMessage) { + platformCalls.push(message); + return 'handled-platform' as const; + }, + }, + gameSession: makeGameSession(gameCalls), + }); + const data = { cards: [1, 2, 3] }; + + assert.throws( + () => router.dispatch({ route: 'other-game', rpc: 'deal', data }), + /route.*fixture-game.*other-game|route.*other-game/i, + ); + assert.equal(platformCalls.length, 0); + assert.equal(gameCalls.length, 0); + + assert.equal( + router.dispatch({ route: 'fixture-game', rpc: 'deal', data }), + 'handled-game', + ); + assert.equal(gameCalls[0]?.data, data); +});