From 1e3a21b476523f35623ea521891e2c747d182666 Mon Sep 17 00:00:00 2001 From: Joywayer Date: Sat, 5 Sep 2026 10:21:28 +0800 Subject: [PATCH] refactor(platform): complete decoupled vertical runtime --- .../YouleNexus/assets/framework/README.md | 69 ++++- .../assets/framework/platform/runtime.ts | 3 + .../platform/stores/platform-store.ts | 15 +- .../YouleNexus/assets/framework/sdk/index.ts | 100 ------- .../architecture/import-boundaries.test.mjs | 71 +++-- .../integration/login-flow.test.ts | 214 +++++++++++--- .../platform-vertical-slice.test.ts | 277 ++++++++++++++++++ .../framework-tests/platform/runtime.test.ts | 5 +- .../framework-tests/sdk/sdk.test.ts | 132 --------- .../scripts/lib/import-boundaries.mjs | 55 ++-- 10 files changed, 611 insertions(+), 330 deletions(-) create mode 100644 cocoscreator_projects/framework-tests/integration/platform-vertical-slice.test.ts delete mode 100644 cocoscreator_projects/framework-tests/sdk/sdk.test.ts diff --git a/cocoscreator_projects/YouleNexus/assets/framework/README.md b/cocoscreator_projects/YouleNexus/assets/framework/README.md index 8b48024..5a5a3c8 100644 --- a/cocoscreator_projects/YouleNexus/assets/framework/README.md +++ b/cocoscreator_projects/YouleNexus/assets/framework/README.md @@ -1,6 +1,71 @@ # framework — 框架真源(唯一权威副本) 各子游戏工程的 `assets/framework` 是指向**本目录**的 junction(见 `scripts/setup-links.mjs`)。 -六层结构(依赖严格单向,详见 spec §3):core / net / protocol / platform / ui / sdk。 +协议相关实现以 `docs/protocol/` 为权威,远程配置与原生接口也必须复刻原工程。服务器、配置服务和原生侧均零改动。 -协议相关实现一律以 `docs/protocol/` 为唯一信息源(spec §0.1),不在代码或注释中内联复制协议字段。 +## 现代运行时的唯一组合路径 + +```text +game CompositionRoot -> bootstrapPlatform(single GameEntry) +game implementation -> framework/sdk only +WireClient -> PlatformRuntime -> Router exactly once +PlatformStore -> selectors -> GameHost/UI read-only consumers +``` + +这里的 `bootstrapPlatform(single GameEntry)` 是组合设计记号,当前没有同名函数。 +已实现的构造入口是 `new PlatformRuntime(options)`:`gameEntry` 由构建期固定提供一个, +`resolveRuntimeConfig` 使用 `config/runtime-config.ts` 的解析器, +`createWireClient(config)` 用最终 `config.servers` 构造 `WireClient`,注入实际 Transport 工厂。 +组合方还提供 `ScenePort`、资源加载、最短展示等待和登录设备快照能力,然后调用 `start()`; +界面取得账号后显式调用 `login(account)`,socket open 本身不会发送登录。 +`integration/platform-vertical-slice.test.ts` 展示了这一组合的完整无界面回放。 + +Runtime 内部创建唯一 Router、RuntimeSession、PlatformStore、平台命令和 GameSessionHost。 +游戏实现只通过 `framework/sdk/index.ts` 导入公开契约;该入口只重导出 `sdk/contracts`, +不再公开旧 Store、EventBus 或任意路由发包能力。SDK contracts 不导入内部框架模块或 `cc`; +SDK runtime 也不导入框架实现层,平台适配由 `platform/game-host-adapter.ts` 承担。 + +不提供 GameRegistry、运行时 API 协商、多个游戏的包、游戏直接访问 Store/EventBus 的路径。 +每次房间会话创建新的 GameModule 和 Host lease,旧 lease 不能读取或操纵新房间。 +平台先原子提交房间和玩家快照,再创建游戏、发布平台事件;truthy `deskinfo` 在房间场景就绪后 +按原对象引用交给 `restore`,不进入 PlatformStore,falsey/缺失值不触发恢复。 +连接事件也提交到同一个 Store;重连只更换 app 连接状态,保留 room/players 引用。 +切服先发送原始 `connect_*server` 信封,进房成功再原子标记已登录;普通重连才重新发送已保存的登录信封。 + +远程地址唯一取自 `data.urlserver`。配置请求使用空 body 的 POST,沿用无条件 `?` 缓存参数拼接。 +原生 settings 方法名称、调用次序和 WVJB 初始化/handler 名称由 `config/sources`、 +`adapters/native` 实现,`framework-tests/config` 与 `framework-tests/native` 验证。 + +## 仍被 Cocos 脚本使用的兼容隔离区 + +以下是 `LEGACY_RUNTIME` 的完整文件清单,路径相对本目录;现代框架文件禁止直接或传递依赖它们: + +- `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` + +当前 `YouleNexus/assets/scripts/LoginFlow.ts`、`RoomEventProbe.ts`、`RoomSceneStart.ts` +仍直接引用上述兼容文件。因此它们继续保留;阶段 3 必须由 Presenter 和真正的游戏 Composition Root +接管这些脚本的调用后,阶段 6 才能删除兼容区。当前无界面现代回放通过,不表示 Cocos UI 已接入现代运行时。 + +保留的兼容逻辑测试(相对 `framework-tests/`)是 `net/net-client.test.ts`、 +`platform/app-store.test.ts`、`platform/player-store.test.ts`、`platform/room-store.test.ts`、 +`platform/session.test.ts`、`platform/startup.test.ts`、`platform/room-rpc-bus.test.ts`、 +`protocol/room-handlers.test.ts`。它们测试旧脚本仍需使用的行为,不属于现代运行时的依赖图。 +旧 SDK 的完整标识符盘点仅允许 `architecture/import-boundaries.test.mjs` 中的负例, +以及 `protocol/room-handlers.ts` 内一处历史说明。名称子串 `requireActiveGame` 是现代处理器的局部状态检查方法, +不是已移除的旧类型;自动盘点按完整标识符匹配。 + +`scripts/check-import-boundaries.mjs` 不再给 SDK 入口任何内部依赖豁免,也不给旧活动游戏文件名称检查豁免。 +`framework-tests/architecture/import-boundaries.test.mjs` 对全部现代框架 TypeScript 文件检查传递依赖, +并验证重导出、别名、动态导入、CommonJS 和 import type 的负例;无法静态判断的传递加载直接拒绝。 + +阶段 3 的 Presenter/Composition Root 接管、theme/Prefab binding、首个真实子游戏迁移及 ZIP 发布均属于后续独立计划。 +本次逻辑验收不改动 Cocos 序列化资源或现有 UI 迁移产物。 diff --git a/cocoscreator_projects/YouleNexus/assets/framework/platform/runtime.ts b/cocoscreator_projects/YouleNexus/assets/framework/platform/runtime.ts index f4e9a77..7098276 100644 --- a/cocoscreator_projects/YouleNexus/assets/framework/platform/runtime.ts +++ b/cocoscreator_projects/YouleNexus/assets/framework/platform/runtime.ts @@ -321,6 +321,7 @@ export class PlatformRuntime { switch (event.type) { case 'open': this.wireIsOpen = true; + this.store.setConnectionPhase('connected'); this.markReady('socket'); if (this.connectionIntent.type === 'switch') { const loginEnvelope = this.loginEnvelope; @@ -348,6 +349,7 @@ export class PlatformRuntime { case 'slow': case 'reconnecting': this.wireIsOpen = false; + this.store.setConnectionPhase(event.type); this.options.scene.showReconnect(); break; case 'close': @@ -429,6 +431,7 @@ export class PlatformRuntime { this.connectionIntent = { type: 'none' }; this.wireIsOpen = false; const errors: unknown[] = []; + this.captureCleanupError(errors, () => { this.store.setConnectionPhase('kicked'); }); this.captureCleanupError(errors, () => { this.clearLoginGuard(); }); this.captureCleanupError(errors, () => { this.options.scene.showKicked(data); }); this.captureCleanupError(errors, () => { this.stopWireOnce(); }); 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 af19bfa..ae5deaa 100644 --- a/cocoscreator_projects/YouleNexus/assets/framework/platform/stores/platform-store.ts +++ b/cocoscreator_projects/YouleNexus/assets/framework/platform/stores/platform-store.ts @@ -14,6 +14,7 @@ import { } from '../../protocol/contracts/index.ts'; import type { InsideRoomState, + PlatformConnectionPhase, PlatformPlayer, PlatformState, } from './platform-types.ts'; @@ -422,6 +423,15 @@ export class PlatformStore { return (): void => { this.listeners.delete(listener); }; } + setConnectionPhase(phase: Exclude): void { + if (phase !== 'connected' && phase !== 'reconnecting' && phase !== 'slow' && phase !== 'kicked') { + throw new TypeError('app.phase: expected a wire connection phase'); + } + const previous = this.getState(); + if (previous.app.phase === phase) return; + this.commit(Object.freeze({ ...previous, app: Object.freeze({ phase }) })); + } + private commit(state: PlatformState): PlatformState { if (Object.is(state, this.state)) return state; const previous = this.state; @@ -543,7 +553,10 @@ export class PlatformStore { } const roomResult = buildRoom(input.room, raw, selfPlayer, stage); return this.commit(Object.freeze({ - app: previous.app, + // A switch authenticates with connect_*server, then self_join_room instead of player_login. + app: previous.app.phase === 'logged-in' + ? previous.app + : Object.freeze({ phase: 'logged-in' }), players: roomResult.players, room: roomResult.room, })); diff --git a/cocoscreator_projects/YouleNexus/assets/framework/sdk/index.ts b/cocoscreator_projects/YouleNexus/assets/framework/sdk/index.ts index 4a9d22e..0b1e9e6 100644 --- a/cocoscreator_projects/YouleNexus/assets/framework/sdk/index.ts +++ b/cocoscreator_projects/YouleNexus/assets/framework/sdk/index.ts @@ -1,101 +1 @@ export * from './contracts/index.ts'; - -import type { EventBus } from '../core/events.ts'; -import type { ReadonlyReactive } from '../core/reactive.ts'; -import type { ReadonlyPlayerStore } from '../platform/readonly.ts'; -import type { ReadonlyRoomStore } from '../platform/readonly.ts'; -import type { ReadonlyAppStore } from '../platform/readonly.ts'; -import type { PlayerState, RoomState, AppState } from '../platform/stores/types.ts'; - -/** - * 受限的发包通道(架构零耦合规则 §3)。 - * - * 子游戏只能通过这个接口发包,**不能**直接拿到 transport/ws/start 等内部能力。 - * 实现由 Router 在创建 GameContext 时注入(生产环境转发到 NetClient)。 - */ -export interface GameNet { - send(route: string, rpc: string, data: unknown): void; -} - -/** - * 座位↔视图工具(C §3 旧 ChangeToStatus 替代)。 - */ -export interface GameSeat { - /** 绝对座位 → 视图位(自己=0,其余环形顺延)。 */ - toView(mySeat: number, targetSeat: number, seatCount: number): number; - /** 视图位 → 绝对座位。 */ - fromView(mySeat: number, viewSeat: number, seatCount: number): number; -} - -/** - * @deprecated migration-only. Use GameHost from sdk/contracts instead. - * - * GameContext:子游戏调用框架能力的唯一入口。 - * - * 只暴露只读 Store + 受限 net.send + 座位工具 + 事件总线。 - * 子游戏**不能**通过本接口反改 Store;Store 的修改由平台层负责(第二准则:单一来源)。 - */ -export interface GameContext { - /** 受限发包。 */ - readonly net: GameNet; - /** 只读 PlayerStore。 */ - readonly player: ReadonlyPlayerStore & { readonly state: ReadonlyReactive }; - /** 只读 RoomStore(含 deskinfo 透传引用)。 */ - readonly room: ReadonlyRoomStore & { readonly state: ReadonlyReactive }; - /** 只读 AppStore(连接相位 + 身份)。 */ - readonly app: ReadonlyAppStore & { readonly state: ReadonlyReactive }; - /** 座位↔视图工具。 */ - readonly seat: GameSeat; - /** 事件总线(子游戏可发自定义事件供自己订阅,框架不消费)。 */ - readonly events: EventBus>; -} - -/** - * @deprecated migration-only. Use GameModule from sdk/contracts instead. - * - * IGameModule:子游戏实现,被框架调用(框架 spec §4)。 - * - * 子游戏实现这个接口并注册自己;框架 Router 据 route 分发对局包。 - * 子游戏通过 GameContext 调用框架能力(不可 import platform/net 内部)。 - */ -export interface IGameModule { - /** 本游戏的 game route(与 protocol/routes.ts 的 Route 同名)。 */ - readonly route: string; - - /** 进入牌桌场景,框架传入 GameContext。 */ - onEnter(ctx: GameContext): void; - - /** 离开牌桌场景。 */ - onExit(): void; - - /** - * 接收对局包(route = 本游戏 game route 的包)。 - * 框架 router 已按 route 过滤;这里只按 rpc 分发。 - */ - onReceive(rpc: string, data: unknown): void; - - /** - * 登录回包带 deskinfo 时触发(C 规范 §5 边界判据 2:deskinfo 平台层不解析)。 - * 子游戏负责反序列化对局态。 - */ - onReconnect(deskinfo: unknown): void; - - /** - * 可选:返回对局快照。框架在断线重连时收集 → 走 server player_login 响应 deskinfo。 - * 若不实现则重连后从服务器拉取(默认行为)。 - */ - serialize?(): unknown; - - // ---- 平台事件钩子(全部可选,默认空实现)---- - - /** 玩家加入房间(seat = 对方座位)。 */ - onPlayerJoin?(seat: number): void; - /** 玩家离开房间。 */ - onPlayerLeave?(seat: number): void; - /** 玩家准备。 */ - onReady?(seat: number): void; - /** 房间解散。 */ - onDissolve?(): void; - /** 玩家离线/上线(在线由 onPlayerJoin 重复触发,离线由本钩子)。 */ - onOffline?(seat: number): void; -} diff --git a/cocoscreator_projects/framework-tests/architecture/import-boundaries.test.mjs b/cocoscreator_projects/framework-tests/architecture/import-boundaries.test.mjs index bfde9ae..5dbf4ac 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 } from 'node:fs'; +import { existsSync, readdirSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; 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 { findCompatibilityDependency, + LEGACY_RUNTIME, scanImportBoundaries, } from '../../scripts/lib/import-boundaries.mjs'; @@ -37,14 +39,6 @@ function scan(root, tsconfigPath) { } 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', @@ -67,11 +61,14 @@ function scanCompatibilityFixture(root, entry = 'framework/protocol/router.ts', }); } -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')}`))); +test('every modern framework file is transitively isolated from the exact legacy compatibility island', () => { + const files = readdirSync(frameworkRoot, { recursive: true }) + .filter(file => file.endsWith('.ts')) + .map(file => resolve(frameworkRoot, file)); + assert.deepEqual(new Set(files.filter(file => LEGACY_RUNTIME.test(file))), legacyRuntimeFiles); + const entries = files.filter(file => !legacyRuntimeFiles.has(file)); + assert.ok(existsSync(resolve(frameworkRoot, 'platform/runtime.ts'))); + assert.ok(entries.includes(resolve(frameworkRoot, 'sdk/index.ts'))); for (const entry of entries) { const dependency = findCompatibilityDependency({ @@ -88,6 +85,22 @@ test('every existing modern runtime entry is transitively isolated from the lega } }); +test('obsolete SDK identifier inventory stays within explicit negative tests and one legacy comment', () => { + const result = spawnSync('rg', [ + '-l', '--glob', '*.ts', '--glob', '*.mjs', + '\\b(GameContext|IGameModule|ActiveGame)\\b', + 'YouleNexus/assets/framework', 'framework-tests', + ], { cwd: fileURLToPath(new URL('../../', import.meta.url)), encoding: 'utf8' }); + assert.ifError(result.error); + assert.ok(result.status === 0 || result.status === 1, result.stderr); + const allowlist = new Set([ + 'framework-tests/architecture/import-boundaries.test.mjs', + 'YouleNexus/assets/framework/protocol/room-handlers.ts', + ]); + const matches = result.stdout.trim().split(/\r?\n/).filter(Boolean).map(file => file.replaceAll('\\', '/')); + assert.deepEqual(matches.filter(file => !allowlist.has(file)), []); +}); + test('compatibility graph catches static imports, re-exports, and import types through barrels', async () => { const cases = [ "import '../platform/session.ts'\n", @@ -427,7 +440,7 @@ test('scanner rejects a CommonJS Cocos import in nested sdk code', async () => { assert.match(violations[0].message, /sdk.*Cocos/); }); -test('sdk migration barrel exception permits only its four retained legacy dependencies', async () => { +test('sdk public barrel has no exemptions for any former migration dependency', async () => { const root = await createFixtureRoot(); for (const file of [ 'framework/core/events.ts', @@ -446,10 +459,34 @@ test('sdk migration barrel exception permits only its four retained legacy depen "import type { Value as Constants } from '../core/constants.ts'; void (0 as unknown as Constants);", ].join('\n')); + const violations = scan(root); + assert.equal(violations.length, 5); + for (const violation of violations) assert.match(violation.message, /sdk.*framework implementation/); +}); + +test('former active-game file has no exemption from obsolete SDK name checks', async () => { + const root = await createFixtureRoot(); + await writeFixture(root, 'framework/protocol/active-game.ts', "import type { IGameModule } from '../sdk/index.ts'\n"); const violations = scan(root); assert.equal(violations.length, 1); - assert.equal(violations[0].specifier, '../core/constants.ts'); - assert.match(violations[0].message, /sdk.*framework implementation/); + assert.match(violations[0].message, /IGameModule.*migration-only/); +}); + +test('obsolete SDK names are rejected in destructured CommonJS and dynamic imports and named re-exports', async () => { + for (const source of [ + "const { GameContext: Context } = require('../sdk/index.ts');", + "const { IGameModule } = require(`../sdk/index.ts`);", + "const { GameContext } = await import('../sdk/index.ts');", + "const Context = require('../sdk/index.ts').GameContext;", + "export type { IGameModule } from '../sdk/index.ts';", + "type Context = import('../sdk/index.ts').GameContext;", + ]) { + const root = await createFixtureRoot(); + await writeFixture(root, 'framework/application/bad.ts', source); + const violations = scan(root); + assert.equal(violations.length, 1, source); + assert.match(violations[0].message, /migration-only/, source); + } }); test('scanner permits sdk-internal relatives and ordinary external pure dependencies', async () => { diff --git a/cocoscreator_projects/framework-tests/integration/login-flow.test.ts b/cocoscreator_projects/framework-tests/integration/login-flow.test.ts index 346699f..d5c6f5f 100644 --- a/cocoscreator_projects/framework-tests/integration/login-flow.test.ts +++ b/cocoscreator_projects/framework-tests/integration/login-flow.test.ts @@ -1,57 +1,181 @@ -import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { NetClient } from '../../YouleNexus/assets/framework/net/net-client.ts'; -import { FakeTransport } from '../helpers/fake-transport.ts'; -import { parseLoginResponse } from '../../YouleNexus/assets/framework/protocol/login.ts'; +import { readFileSync } from 'node:fs'; +import { test } from 'node:test'; +import type { RuntimeConfig } from '../../YouleNexus/assets/framework/config/runtime-config.ts'; import type { Clock } from '../../YouleNexus/assets/framework/net/heartbeat.ts'; +import { WireClient } from '../../YouleNexus/assets/framework/net/wire-client.ts'; +import { PlatformRuntime } from '../../YouleNexus/assets/framework/platform/runtime.ts'; +import { Router } from '../../YouleNexus/assets/framework/protocol/router.ts'; +import type { InboundMessage } from '../../YouleNexus/assets/framework/core/types/envelope.ts'; +import type { GameHost } from '../../YouleNexus/assets/framework/sdk/index.ts'; +import { FakeTransport } from '../helpers/fake-transport.ts'; -const realishClock: Clock = { setTimeout: (fn, ms) => setTimeout(fn, ms), clearTimeout: (h: any) => clearTimeout(h) }; -const IDENTITY = { agentid: 1, gameid: 2, openid: 'o', nickname: 'n', avatar: 'a', sex: 0, province: '', city: '', unionid: 'u', version: 1, channelid: 0, marketid: 0 }; +const CONFIG: RuntimeConfig = { + mode: 'debug', isDebugger: true, + identity: { agentid: 'A', channelid: 'C', gameid: '41', version: 1, marketid: 4 }, + source: 'direct', servers: ['ws://agent'], gameserver: null, rawConfig: null, +}; +const ACCOUNT = { openid: 'o', nickname: 'n', avatar: 'a', sex: 0, province: '', city: '', unionid: 'u' }; +const LOGIN = { + app: 'youle', route: 'agent', rpc: 'player_login', data: { + agentid: 'A', gameid: '41', openid: 'o', nickname: 'n', avatar: 'a', sex: 0, + province: '', city: '', unionid: 'u', version: 1, channelid: 'C', marketid: 4, + location: null, machineid: 'machine', machineroom: 'install', + }, +}; -test('login 全流程:握手→登录→业务包,且 parseLoginResponse 识别房间恢复', async () => { - const t = new FakeTransport(); - const client = new NetClient({ servers: 'ws://srv', transportFactory: () => t, clock: realishClock }); - client.setIdentity(IDENTITY); +function fixture(name: string): InboundMessage & { data: Record } { + return JSON.parse(readFileSync(new URL(`../fixtures/contracts/${name}.json`, import.meta.url), 'utf8')); +} - let loginData: any = null; - const business: any[] = []; - client.on('login', (d: any) => { loginData = d; }); - client.on('message', (m: any) => business.push(m)); +function setup() { + const timers = new Map void; ms: number }>(); + const clock: Clock = { + setTimeout(fn, ms) { const handle = {}; timers.set(handle, { fn, ms }); return handle; }, + clearTimeout(handle) { timers.delete(handle as object); }, + }; + const transports: FakeTransport[] = []; + const hosts: GameHost[] = []; + const restores: unknown[] = []; + const scenes: string[] = []; + const fatals: Error[] = []; + const gameMessages: unknown[] = []; + const runtime = new PlatformRuntime({ + gameEntry: { + key: 'fixture', gameId: '41', route: 'fixture-game', resolveSeatCount: () => 4, + createModule: () => ({ + attach(host) { hosts.push(host); }, + handlePlatformEvent() {}, + handleGameMessage(message) { gameMessages.push(message); }, + restore(deskinfo) { restores.push(deskinfo); }, + dispose() {}, + }), + }, + resolveRuntimeConfig: async () => CONFIG, + createWireClient(config) { + return new WireClient({ servers: config.servers, clock, transportFactory() { + const transport = new FakeTransport(); + transports.push(transport); + return transport; + } }); + }, + scene: { + showLoading() { scenes.push('loading'); }, showLogin() { scenes.push('login'); }, + showLobby() { scenes.push('lobby'); }, showRoom() { scenes.push('room'); }, + showReconnect() { scenes.push('reconnect'); }, showKicked() { scenes.push('kicked'); }, + showFatal(error) { fatals.push(error); }, + }, + loadResources: async () => {}, waitForMinimumDisplay: async () => {}, clock, + getLoginDeviceSnapshot: () => ({ + location: null, machineid: 'machine', machineroom: 'install', + deviceLogin: { enabled: false }, loginPlayerId: { enabled: false }, + }), + }); + return { + runtime, transports, hosts, restores, scenes, fatals, gameMessages, timers, + fire(ms: number) { + const entry = [...timers].find(([, timer]) => timer.ms === ms); + assert.ok(entry, `missing ${ms}ms timer`); + timers.delete(entry[0]); + entry[1].fn(); + }, + }; +} - client.start(); - await t.flush(); +test('modern login gates business packets and retries exact login after the four-second guard', async (t) => { + const s = setup(); + t.after(() => s.runtime.stop()); + const routed: InboundMessage[] = []; + const dispatch = Router.prototype.dispatch; + t.mock.method(Router.prototype, 'dispatch', function (this: Router, message: InboundMessage) { + routed.push(message); + return dispatch.call(this, message); + }); + await s.runtime.start(); + const first = s.transports[0]!; + await first.flush(); + assert.deepEqual(first.sent, []); + s.runtime.login(ACCOUNT); + assert.deepEqual(first.sent.map(frame => JSON.parse(frame)), [LOGIN]); - // 服务器先发握手包(原始串,应被忽略) - t.serverPush('@toconconHELLO'); - await t.flush(); + first.serverPush(JSON.stringify(fixture('self-join-room'))); + await first.flush(); + assert.equal(routed.length, 1); + assert.equal(s.runtime.state.room.kind, 'outside'); + assert.equal(s.hosts.length, 0); + s.fire(4000); + assert.equal(first.closeCalls, 1); + assert.equal(s.transports.length, 1); + s.fire(10000); + const second = s.transports[1]!; + await second.flush(); + assert.deepEqual(second.sent.map(frame => JSON.parse(frame)), [LOGIN]); - // 客户端应已发出 player_login - assert.equal(JSON.parse(t.sent[0]).rpc, 'player_login'); + // The obsolete connection cannot reach Router or consume the new login gate. + first.serverPush(JSON.stringify(fixture('player-login-room'))); + await first.flush(); + assert.equal(routed.length, 1); + second.serverPush(JSON.stringify(fixture('player-login-success'))); + await second.flush(); + assert.equal(routed.length, 2); + assert.equal(s.scenes[s.scenes.length - 1], 'lobby'); + assert.equal(s.runtime.state.app.phase, 'logged-in'); + assert.equal([...s.timers.values()].some(timer => timer.ms === 4000), false); + second.serverPush(JSON.stringify({ com: '@serverheartbeat' })); + await second.flush(); + assert.equal(routed.length, 2); + assert.deepEqual(s.fatals, []); + assert.deepEqual(s.transports.flatMap(transport => transport.sent.map(frame => JSON.parse(frame))), [LOGIN, LOGIN]); +}); - // 服务器回 login 响应(单层,带房间恢复 + 对局) - t.serverPush(JSON.stringify({ - route: 'agent', rpc: 'player_login', - data: { state: 0, playerid: 42, bean: 500, roomcode: 'ROOM1', isbattle: 1, deskinfo: { round: 3 } }, - })); - await t.flush(); +for (const deskinfo of [undefined, null, false, 0, '']) { + test(`modern room login does not restore falsey deskinfo: ${String(deskinfo)}`, async (t) => { + const s = setup(); + t.after(() => s.runtime.stop()); + await s.runtime.start(); + const transport = s.transports[0]!; + await transport.flush(); + s.runtime.login(ACCOUNT); + const login = fixture('player-login-room'); + if (deskinfo === undefined) delete login.data.deskinfo; + else login.data.deskinfo = deskinfo; + transport.serverPush(JSON.stringify(login)); + await transport.flush(); + assert.equal(s.hosts.length, 1); + assert.equal(s.scenes[s.scenes.length - 1], 'room'); + assert.deepEqual(s.restores, []); + assert.deepEqual(s.fatals, []); + assert.deepEqual(transport.sent.map(frame => JSON.parse(frame)), [LOGIN]); + }); +} - assert.ok(loginData); - const parsed = parseLoginResponse(loginData); - assert.equal(parsed.ok, true); - assert.equal(parsed.playerid, 42); - assert.equal(parsed.inRoom, true); - assert.equal(parsed.hasBattle, true); - assert.deepEqual(parsed.deskinfo, { round: 3 }); +test('another game route throws in the sole Router dispatch and becomes one terminal runtime error', async (t) => { + const s = setup(); + t.after(() => s.runtime.stop()); + const thrown: unknown[] = []; + const routed: InboundMessage[] = []; + const dispatch = Router.prototype.dispatch; + t.mock.method(Router.prototype, 'dispatch', function (this: Router, message: InboundMessage) { + routed.push(message); + try { return dispatch.call(this, message); } + catch (error) { thrown.push(error); throw error; } + }); + await s.runtime.start(); + const transport = s.transports[0]!; + await transport.flush(); + s.runtime.login(ACCOUNT); + transport.serverPush(JSON.stringify(fixture('player-login-room'))); + await transport.flush(); + transport.serverPush(JSON.stringify({ route: 'another-game', rpc: 'deal', data: { cards: [1] } })); + await transport.flush(); - // 心跳包(单层,忽略,不进 business) - t.serverPush(JSON.stringify({ com: '@serverheartbeat' })); - // 正常业务推送(单层,门控已清,放行) - t.serverPush(JSON.stringify({ route: 'room', rpc: 'other_join_room', data: { seat: 2 } })); - await t.flush(); - - assert.equal(business.length, 1); - assert.equal(business[0].rpc, 'other_join_room'); - assert.equal(business[0].data.seat, 2); - - client.stop(); + assert.equal(routed.length, 2); + assert.equal(thrown.length, 1); + assert.match(String(thrown[0]), /route.*mismatch|another-game/i); + assert.equal(s.fatals.length, 1); + assert.equal(s.fatals[0], thrown[0]); + assert.deepEqual(s.gameMessages, []); + assert.equal(transport.closeCalls, 1); + assert.equal(s.timers.size, 0); + assert.deepEqual(transport.sent.map(frame => JSON.parse(frame)), [LOGIN]); }); diff --git a/cocoscreator_projects/framework-tests/integration/platform-vertical-slice.test.ts b/cocoscreator_projects/framework-tests/integration/platform-vertical-slice.test.ts new file mode 100644 index 0000000..8bb8e66 --- /dev/null +++ b/cocoscreator_projects/framework-tests/integration/platform-vertical-slice.test.ts @@ -0,0 +1,277 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { test } from 'node:test'; +import { HttpConfigFetcher } from '../../YouleNexus/assets/framework/config/remote-config-fetcher.ts'; +import { resolveRuntimeConfig } from '../../YouleNexus/assets/framework/config/runtime-config.ts'; +import type { InboundMessage } from '../../YouleNexus/assets/framework/core/types/envelope.ts'; +import type { Clock } from '../../YouleNexus/assets/framework/net/heartbeat.ts'; +import { WireClient } from '../../YouleNexus/assets/framework/net/wire-client.ts'; +import { PlatformRuntime } from '../../YouleNexus/assets/framework/platform/runtime.ts'; +import { Router } from '../../YouleNexus/assets/framework/protocol/router.ts'; +import type { GameEntry, GameHost, GameModule } from '../../YouleNexus/assets/framework/sdk/index.ts'; +import { FakeTransport } from '../helpers/fake-transport.ts'; + +// Independent wire expectations: do not build these with production envelope helpers. +const LOGIN = { + app: 'youle', route: 'agent', rpc: 'player_login', data: { + agentid: 'veRa0qrBf', gameid: 'J9r4022t80pxlb01zOK76DN1WD4QewFl', + openid: 'o1', nickname: '测试号', avatar: 'http://a', sex: 0, + province: '江西', city: '南昌', unionid: 'test_unionid', version: 1, + channelid: 'FtJf073aa', marketid: 4, ip: '127.0.0.1', + location: { latitude: 1, longitude: 2 }, machineid: 'machine-1', + machineroom: 'room-install-1', + }, +}; +const JOIN = { + app: 'youle', route: 'agent', rpc: 'self_join_room', data: { + agentid: 'veRa0qrBf', playerid: 430511, + gameid: 'J9r4022t80pxlb01zOK76DN1WD4QewFl', roomcode: '100001', + location: { latitude: 1, longitude: 2 }, ip: '127.0.0.1', + }, +}; +const SWITCH = { + app: 'youle', route: 'room', rpc: 'connect_roomserver', + data: { roomserver: 'room.example:3089', token: 'original-token', roomcode: '100001' }, +}; +const PREPARE = { + app: 'youle', route: 'room', rpc: 'player_prepare', data: { + agentid: 'veRa0qrBf', playerid: 430511, + gameid: 'J9r4022t80pxlb01zOK76DN1WD4QewFl', roomcode: '100001', + }, +}; + +class ReplayClock implements Clock { + private now = 0; + private next = 0; + readonly timers = new Map void }>(); + setTimeout(fn: () => void, ms: number): unknown { + const handle = ++this.next; + this.timers.set(handle, { at: this.now + ms, fn }); + return handle; + } + clearTimeout(handle: unknown): void { this.timers.delete(handle as number); } + advance(ms: number): void { + const end = this.now + ms; + for (;;) { + const next = [...this.timers].sort((a, b) => a[1].at - b[1].at)[0]; + if (!next || next[1].at > end) break; + this.now = next[1].at; + this.timers.delete(next[0]); + next[1].fn(); + } + this.now = end; + } +} + +function fixture(name: string): InboundMessage & { data: Record } { + return JSON.parse(readFileSync(new URL(`../fixtures/contracts/${name}.json`, import.meta.url), 'utf8')); +} + +test('real modern vertical replay preserves wire contracts, room ordering and terminal kick', async (t) => { + const clock = new ReplayClock(); + const transports: FakeTransport[] = []; + const nativeCalls: string[] = []; + const configRequests: Array<{ url: string; init: RequestInit | undefined }> = []; + const scenes: string[] = []; + const fatals: Error[] = []; + const observations: string[] = []; + const attached: GameHost[] = []; + const restored: unknown[] = []; + const disposed: GameHost[] = []; + const routed: InboundMessage[] = []; + const decoded: InboundMessage[] = []; + const dispatch = Router.prototype.dispatch; + t.mock.method(Router.prototype, 'dispatch', function (this: Router, message: InboundMessage) { + routed.push(message); + return dispatch.call(this, message); + }); + const entry: GameEntry = { + key: 'fixture', gameId: 'J9r4022t80pxlb01zOK76DN1WD4QewFl', route: 'fixture-game', + resolveSeatCount: () => 4, + createModule(): GameModule { + let host: GameHost | undefined; + return { + attach(value) { + host = value; + attached.push(value); + observations.push(`attach:${value.getSnapshot().room.roomcode}`); + value.subscribe(snapshot => { + observations.push(`snapshot:${snapshot.seats.find(seat => seat.seat === 1)?.ready}`); + }); + }, + handlePlatformEvent(event) { + if (event.type === 'room.player-ready') { + observations.push(`ready:${host!.getSnapshot().seats.find(seat => seat.seat === event.seat)?.ready}`); + } + }, + handleGameMessage(message) { observations.push(`game:${message.rpc}`); }, + restore(value) { + assert.equal(host!.getSnapshot().room.roomcode, '100001'); + assert.equal(scenes[scenes.length - 1], 'room'); + restored.push(value); + }, + dispose() { + // GameEntry validation also constructs/disposes a module without attaching. + if (host) disposed.push(host); + }, + }; + }, + }; + const runtime = new PlatformRuntime({ + gameEntry: entry, + resolveRuntimeConfig: () => resolveRuntimeConfig({ + mode: 'release', hostKind: 'native-settings', search: '', + win: { settings: { + getothername(name: string) { + nativeCalls.push(`getothername:${name}`); + if (name === 'agent') return 'veRa0qrBf'; + assert.equal(name, 'gameconfig'); + return 'config.example-config'; + }, + getchannelName() { nativeCalls.push('getchannelName'); return 'FtJf073aa'; }, + getmarketname() { nativeCalls.push('getmarketname'); return 4; }, + } }, + cacheBust: () => 'fixed-cache-bust', + fetcher: new HttpConfigFetcher(async (url, init) => { + configRequests.push({ url: String(url), init }); + return new Response(JSON.stringify({ + urlserver: 'wrong-root.example:1', + data: { urlserver: ['agent.example:3088', 'backup.example:3088'] }, + }), { status: 200 }); + }), + }), + createWireClient(config) { + assert.deepEqual(config.servers, ['ws://agent.example:3088', 'ws://backup.example:3088']); + const wire = new WireClient({ servers: config.servers, clock, transportFactory() { + const transport = new FakeTransport(); + transports.push(transport); + return transport; + } }); + wire.subscribe(event => { if (event.type === 'message') decoded.push(event.message); }); + return wire; + }, + scene: { + showLoading() { scenes.push('loading'); }, showLogin() { scenes.push('login'); }, + showLobby() { scenes.push('lobby'); }, showRoom() { scenes.push('room'); }, + showReconnect() { scenes.push('reconnect'); }, showKicked() { scenes.push('kicked'); }, + showFatal(error) { fatals.push(error); }, + }, + loadResources: async () => {}, waitForMinimumDisplay: async () => {}, clock, + getLoginDeviceSnapshot: () => ({ + ip: '127.0.0.1', location: { latitude: 1, longitude: 2 }, + machineid: 'machine-1', machineroom: 'room-install-1', + deviceLogin: { enabled: false }, loginPlayerId: { enabled: false }, + }), + }); + t.after(() => runtime.stop()); + const expectedFrames: unknown[] = []; + function verifyFrames(): void { + assert.deepEqual(transports.flatMap(transport => transport.sent.map(frame => JSON.parse(frame))), expectedFrames); + assert.deepEqual(fatals, []); + } + async function push(transport: FakeTransport, message: InboundMessage): Promise { + const before = routed.length; + transport.serverPush(JSON.stringify(message)); + await transport.flush(); + assert.equal(routed.length, before + 1, `${message.rpc} must enter Router once`); + assert.equal(routed[routed.length - 1], decoded[decoded.length - 1], 'Router receives the decoded object itself'); + verifyFrames(); + return routed[routed.length - 1]!; + } + + await runtime.start(); + assert.deepEqual(nativeCalls, ['getothername:agent', 'getchannelName', 'getmarketname', 'getothername:gameconfig']); + assert.deepEqual(configRequests, [{ + url: 'http://config.example/config.txt?fixed-cache-bust', init: { method: 'POST', body: '' }, + }]); + const agent = transports[0]!; + assert.equal(agent.url, 'ws://agent.example:3088'); + await agent.flush(); + verifyFrames(); + assert.deepEqual(scenes, ['loading', 'login']); + assert.equal(runtime.ready, true); + assert.equal(runtime.state.app.phase, 'connected'); + agent.serverPush('@toconconHELLO'); + agent.serverPush(JSON.stringify({ com: '@serverheartbeat' })); + await agent.flush(); + assert.equal(routed.length, 0); + + runtime.login({ + openid: 'o1', nickname: '测试号', avatar: 'http://a', sex: 0, + province: '江西', city: '南昌', unionid: 'test_unionid', + }); + expectedFrames.push(LOGIN); + verifyFrames(); + await push(agent, fixture('player-login-success')); + assert.equal(scenes[scenes.length - 1], 'lobby'); + assert.equal(runtime.state.room.kind, 'outside'); + runtime.joinRoom({ roomcode: '100001', location: { latitude: 1, longitude: 2 }, ip: '127.0.0.1' }); + expectedFrames.push(JOIN); + verifyFrames(); + await push(agent, SWITCH); + assert.equal(agent.closeCalls, 1); + clock.advance(9999); + assert.equal(transports.length, 1); + clock.advance(1); + const room = transports[1]!; + assert.equal(room.url, 'ws://room.example:3089'); + assert.deepEqual(room.sent, []); + expectedFrames.push(SWITCH); + await room.flush(); + verifyFrames(); + + const join = fixture('self-join-room'); + delete join.data.deskinfo; + join.data.deskwar = 0; + join.data.players[1].isprepare = 0; + await push(room, join); + assert.equal(attached.length, 1); + assert.equal(runtime.state.room.kind, 'inside'); + assert.equal(scenes[scenes.length - 1], 'room'); + assert.equal(attached[0]!.getSnapshot().connection.phase, 'logged-in'); + assert.deepEqual(restored, []); + assert.deepEqual(observations, ['attach:100001', 'snapshot:false']); + runtime.prepare(); + expectedFrames.push(PREPARE); + verifyFrames(); + await push(room, { route: 'room', rpc: 'player_prepare', data: { seat: 1 } }); + assert.deepEqual(observations.slice(-2), ['snapshot:true', 'ready:true']); + await push(room, { route: 'fixture-game', rpc: 'deal', data: { cards: [7, 8] } }); + assert.equal(observations[observations.length - 1], 'game:deal'); + + const priorRoom = runtime.state.room; + room.serverClose(); + await room.flush(); + assert.equal(scenes[scenes.length - 1], 'reconnect'); + assert.equal(attached[0]!.getSnapshot().connection.phase, 'reconnecting'); + clock.advance(9999); + assert.equal(transports.length, 2); + clock.advance(1); + const reconnected = transports[2]!; + assert.equal(reconnected.url, 'ws://room.example:3089'); + expectedFrames.push(LOGIN); + await reconnected.flush(); + verifyFrames(); + const restoredEnvelope = await push(reconnected, fixture('player-login-room')); + assert.notEqual(runtime.state.room, priorRoom); + assert.equal(attached.length, 2); + assert.notEqual(attached[0], attached[1]); + assert.deepEqual(disposed, [attached[0]]); + assert.throws(() => attached[0]!.getSnapshot(), /invalid|inactive|disposed/i); + assert.equal(restored.length, 1); + assert.equal(restored[0], (restoredEnvelope.data as Record).deskinfo); + assert.equal(Object.prototype.hasOwnProperty.call(runtime.state.room, 'deskinfo'), false); + assert.equal(attached[1]!.getSnapshot().connection.phase, 'logged-in'); + + await push(reconnected, { route: 'agent', rpc: 'kick_server', data: { msg: 'elsewhere' } }); + assert.equal(scenes[scenes.length - 1], 'kicked'); + assert.equal(runtime.state.app.phase, 'kicked'); + assert.deepEqual(disposed, attached); + assert.equal(reconnected.closeCalls, 1); + assert.throws(() => attached[1]!.getSnapshot(), /invalid|inactive|disposed/i); + assert.equal(clock.timers.size, 0); + clock.advance(60000); + assert.equal(transports.length, 3); + assert.deepEqual(routed, decoded); + verifyFrames(); +}); diff --git a/cocoscreator_projects/framework-tests/platform/runtime.test.ts b/cocoscreator_projects/framework-tests/platform/runtime.test.ts index 2257b7b..f43eb10 100644 --- a/cocoscreator_projects/framework-tests/platform/runtime.test.ts +++ b/cocoscreator_projects/framework-tests/platform/runtime.test.ts @@ -977,9 +977,12 @@ test('slow and reconnecting show reconnect without clearing or replacing room st const callsBefore = setup.scene.calls.length; setup.wire.emit({ type: 'slow' }); + assert.equal(setup.runtime.state.app.phase, 'slow'); setup.wire.emit({ type: 'reconnecting', server: 'ws://agent' }); - assert.equal(setup.runtime.state, roomState); + assert.equal(setup.runtime.state.app.phase, 'reconnecting'); + assert.equal(setup.runtime.state.room, roomState.room); + assert.equal(setup.runtime.state.players, roomState.players); assert.equal(setup.runtime.state.room.kind, 'inside'); assert.deepEqual( setup.scene.calls.slice(callsBefore).map((call) => call.name), diff --git a/cocoscreator_projects/framework-tests/sdk/sdk.test.ts b/cocoscreator_projects/framework-tests/sdk/sdk.test.ts deleted file mode 100644 index 0a0e4a0..0000000 --- a/cocoscreator_projects/framework-tests/sdk/sdk.test.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import { EventBus } from '../../YouleNexus/assets/framework/core/events.ts'; -import { toView, fromView } from '../../YouleNexus/assets/framework/core/seat.ts'; -import { PlatformSession } from '../../YouleNexus/assets/framework/platform/session.ts'; -import type { NetClientEvents } from '../../YouleNexus/assets/framework/net/net-client.ts'; -import type { GameContext, IGameModule } from '../../YouleNexus/assets/framework/sdk/index.ts'; - -/** - * 真实子游戏端到端模拟:创建 PlatformSession + 注册 Mock 子游戏 + 注入 GameContext。 - * 验证契约边界(IGameModule 钩子触发、GameContext 只读、send 受限)。 - */ - -function makeContext(): { - ctx: GameContext; - sent: Array<{ route: string; rpc: string; data: any }>; - bus: EventBus; - session: PlatformSession; -} { - const bus = new EventBus(); - const session = new PlatformSession(bus); - - // 受限的 send:只记录,不动 NetClient transport(架构零耦合规则) - const sent: Array<{ route: string; rpc: string; data: any }> = []; - const net = { - send(route: string, rpc: string, data: unknown) { sent.push({ route, rpc, data }); }, - }; - - const ctx: GameContext = { - net, - room: session.room, - player: session.player, - app: session.app, - seat: { toView, fromView }, - events: new EventBus(), - }; - - return { ctx, sent, bus, session }; -} - -/** Mock 子游戏:捕获所有钩子调用。 */ -class MockGame implements IGameModule { - readonly route = 'mock-game'; - events: string[] = []; - - onEnter(_ctx: GameContext): void { this.events.push('onEnter'); } - onExit(): void { this.events.push('onExit'); } - onReceive(rpc: string, _data: unknown): void { this.events.push(`onReceive:${rpc}`); } - onReconnect(deskinfo: unknown): void { this.events.push('onReconnect'); void deskinfo; } - serialize(): unknown { return { mock: 'snapshot' }; } - - // 默认空钩子测试 - onPlayerJoin(seat: number): void { this.events.push(`onPlayerJoin:${seat}`); } - onPlayerLeave(seat: number): void { this.events.push(`onPlayerLeave:${seat}`); } - onReady(seat: number): void { this.events.push(`onReady:${seat}`); } - onDissolve(): void { this.events.push('onDissolve'); } - onOffline(seat: number): void { this.events.push(`onOffline:${seat}`); } -} - -test('IGameModule 契约: 注册后 onEnter 触发', () => { - const { ctx } = makeContext(); - const game = new MockGame(); - game.onEnter(ctx); - assert.deepEqual(game.events, ['onEnter']); -}); - -test('IGameModule 契约: onReceive 接收 rpc + data', () => { - const { ctx } = makeContext(); - const game = new MockGame(); - game.onEnter(ctx); - game.onReceive('game_action', { x: 1 }); - assert.deepEqual(game.events, ['onEnter', 'onReceive:game_action']); -}); - -test('IGameModule 契约: 平台钩子默认空实现不抛错(可选)', () => { - // 一个只实现必需钩子的子游戏 - const minimal: IGameModule = { - route: 'minimal', - onEnter() { /* noop */ }, - onExit() { /* noop */ }, - onReceive(_rpc, _data) { /* noop */ }, - onReconnect(_d) { /* noop */ }, - // 钩子全部可选 - }; - // 编译通过 + 不抛错即通过 - minimal.onPlayerJoin?.(1); - minimal.onPlayerLeave?.(2); - minimal.onReady?.(3); - minimal.onDissolve?.(); - minimal.onOffline?.(4); -}); - -test('GameContext: 只暴露 ReadonlyPlayerStore 接口,不暴露 applyLogin', () => { - const { ctx } = makeContext(); - // type 层面:ctx.player 是 ReadonlyPlayerStore 接口,没有 applyLogin - // 这里用类型断言模拟「如果按 ReadonlyPlayerStore 类型使用,applyLogin 应不存在」 - type ReadonlyView = { readonly state: { readonly value: { playerid: number } } }; - const view = ctx.player as unknown as ReadonlyView; - assert.equal(view.state.value.playerid, 0); - // 通过 keyof 验证:ReadonlyPlayerStore 接口暴露的方法只有 state getter - type ExposedKeys = keyof typeof ctx.player; - assert.ok('state' in ctx.player, 'state 是公开字段'); -}); - -test('GameContext.net.send: 受限接口,不暴露 transport / ws_tcp / start', () => { - const { ctx, sent } = makeContext(); - ctx.net.send('agent', 'join_table', { roomcode: 'R1' }); - assert.deepEqual(sent, [{ route: 'agent', rpc: 'join_table', data: { roomcode: 'R1' } }]); - // 不应有 transport / ws_tcp / start - assert.equal((ctx.net as any).transport, undefined); - assert.equal((ctx.net as any).start, undefined); -}); - -test('GameContext.seat.toView: 替代旧 ChangeToStatus', () => { - const { ctx } = makeContext(); - // 旧 ChangeToStatus 语义(已存在 core/seat.ts): 自己=0,其余环形顺延 - assert.equal(ctx.seat.toView(0, 0, 4), 0); // 自己=自己 - assert.equal(ctx.seat.toView(0, 1, 4), 1); // 右手位 - assert.equal(ctx.seat.toView(0, 3, 4), 3); // 左手位 - // 跨玩家视角:玩家 2 看玩家 0,2 应当是 2(view seat),0 应当是 -2 mod 4 = 2(实际为反向) - assert.equal(ctx.seat.fromView(2, 0, 4), 2); // 验证 fromView 反运算 -}); - -test('IGameModule.route 必须等于自己的 game route(框架 Router 据此分发)', () => { - const game = new MockGame(); - assert.equal(game.route, 'mock-game'); -}); - -test('serialize 返回对局快照(断线重连用)', () => { - const game = new MockGame(); - assert.deepEqual(game.serialize(), { mock: 'snapshot' }); -}); \ No newline at end of file diff --git a/cocoscreator_projects/scripts/lib/import-boundaries.mjs b/cocoscreator_projects/scripts/lib/import-boundaries.mjs index 82df1eb..b7dacbd 100644 --- a/cocoscreator_projects/scripts/lib/import-boundaries.mjs +++ b/cocoscreator_projects/scripts/lib/import-boundaries.mjs @@ -7,12 +7,6 @@ const FRAMEWORK_INTERNAL = /framework[\\/](?:net|protocol|platform|application|d const LEGACY_RUNTIME = /framework[\\/](?:net[\\/]net-client|platform[\\/](?:session|startup|room-rpc-bus|readonly|stores[\\/](?:app-store|player-store|room-store|types))|protocol[\\/]room-handlers)\.ts$/; const CONTRACTS_PATH = /framework[\\/]sdk[\\/]contracts(?:[\\/]|$)/; const MIGRATION_ONLY_NAMES = new Set(['GameContext', 'IGameModule']); -const SDK_INDEX_MIGRATION_DEPENDENCIES = new Set([ - 'core/events.ts', - 'core/reactive.ts', - 'platform/readonly.ts', - 'platform/stores/types.ts', -]); /** * Recursively scans TypeScript imports for framework/game layering violations. @@ -208,7 +202,7 @@ function* typescriptFiles(directory) { function extractImports(source, file) { const imports = []; - const sourceFile = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, false, ts.ScriptKind.TS); + const sourceFile = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); const visit = (node) => { if ((ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) @@ -218,7 +212,7 @@ function extractImports(source, file) { specifier: node.moduleSpecifier.text, bindings: ts.isImportDeclaration(node) && node.importClause ? node.importClause.getText(sourceFile) - : '', + : ts.isExportDeclaration(node) ? node.exportClause?.getText(sourceFile) ?? '' : '', }); } else if (ts.isImportEqualsDeclaration(node)) { const specifier = externalImportEqualsSpecifier(node); @@ -228,10 +222,10 @@ function extractImports(source, file) { } else if (ts.isImportTypeNode(node) && ts.isLiteralTypeNode(node.argument) && ts.isStringLiteralLike(node.argument.literal)) { - imports.push({ specifier: node.argument.literal.text, bindings: '' }); + imports.push({ specifier: node.argument.literal.text, bindings: node.qualifier?.getText(sourceFile) ?? '' }); } else if (isModuleLoaderCall(node)) { const specifier = literalCallSpecifier(node); - if (specifier !== null) imports.push({ specifier, bindings: '' }); + if (specifier !== null) imports.push({ specifier, bindings: moduleLoaderBindings(node, sourceFile) }); } ts.forEachChild(node, visit); }; @@ -239,6 +233,22 @@ function extractImports(source, file) { return imports; } +function moduleLoaderBindings(node, sourceFile) { + let expression = node; + while (expression.parent && ( + ts.isAwaitExpression(expression.parent) + || ts.isParenthesizedExpression(expression.parent) + || ts.isAsExpression(expression.parent) + )) expression = expression.parent; + const parent = expression.parent; + if (parent && ts.isVariableDeclaration(parent) && ts.isObjectBindingPattern(parent.name)) { + return parent.name.getText(sourceFile); + } + if (parent && ts.isPropertyAccessExpression(parent)) return parent.name.getText(sourceFile); + if (parent && ts.isElementAccessExpression(parent)) return parent.argumentExpression.getText(sourceFile); + return ''; +} + function externalImportEqualsSpecifier(node) { if (!ts.isImportEqualsDeclaration(node) || !ts.isExternalModuleReference(node.moduleReference) @@ -286,8 +296,7 @@ function findViolation(context) { if (isSdk && resolved && isInside(resolved, frameworkDir) - && !SDK_ALLOWED.test(resolvedPath) - && !isSdkIndexMigrationDependency(filePath, resolved, frameworkDir)) { + && !SDK_ALLOWED.test(resolvedPath)) { return violation(filePath, specifier, `sdk cannot import framework implementation ${resolvedPath}`); } @@ -295,7 +304,7 @@ function findViolation(context) { return violation(filePath, specifier, `game code may import only framework/sdk; game import resolved to ${resolvedPath}`); } - if (importsMigrationOnlyName(bindings) && !isMigrationOnlyImporter(filePath, frameworkDir)) { + if (importsMigrationOnlyName(bindings)) { const importer = isGame ? 'game code' : 'production code'; return violation(filePath, specifier, `${importer} cannot import ${migrationOnlyName(bindings)}; it is migration-only`); } @@ -304,7 +313,7 @@ function findViolation(context) { return violation(filePath, specifier, `framework code cannot import games; framework import resolved to ${resolvedPath}`); } - if (resolved && LEGACY_RUNTIME.test(resolvedPath) && !isLegacyRuntimeImporter(filePath, frameworkDir)) { + if (resolved && LEGACY_RUNTIME.test(resolvedPath) && !LEGACY_RUNTIME.test(filePath)) { return violation(filePath, specifier, `new production code cannot import quarantined legacy runtime ${resolvedPath}`); } @@ -318,28 +327,10 @@ function isFrameworkReference(path, frameworkDir) { || /(?:^|[\\/])framework(?:[\\/]|$)/.test(path); } -function isLegacyRuntimeImporter(filePath, frameworkDir) { - const normalizedFrameworkDir = displayPath(frameworkDir); - return filePath === `${normalizedFrameworkDir}/sdk/index.ts` || LEGACY_RUNTIME.test(filePath); -} - -function isSdkIndexMigrationDependency(filePath, resolved, frameworkDir) { - if (filePath !== `${displayPath(frameworkDir)}/sdk/index.ts`) return false; - return SDK_INDEX_MIGRATION_DEPENDENCIES.has(displayPath(relative(frameworkDir, resolved))); -} - function isCocosSpecifier(specifier) { return specifier === 'cc' || specifier.startsWith('cc/'); } -/** - * ActiveGame is the retained compatibility owner of IGameModule until Task 11 - * removes both it and the migration-only declarations from sdk/index.ts. - */ -function isMigrationOnlyImporter(filePath, frameworkDir) { - return filePath === `${displayPath(frameworkDir)}/protocol/active-game.ts`; -} - function importsMigrationOnlyName(bindings) { return [...MIGRATION_ONLY_NAMES].some((name) => new RegExp(`\\b${name}\\b`).test(bindings)); }