refactor(platform): complete decoupled vertical runtime
This commit is contained in:
@@ -1,6 +1,71 @@
|
|||||||
# framework — 框架真源(唯一权威副本)
|
# framework — 框架真源(唯一权威副本)
|
||||||
|
|
||||||
各子游戏工程的 `assets/framework` 是指向**本目录**的 junction(见 `scripts/setup-links.mjs`)。
|
各子游戏工程的 `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 迁移产物。
|
||||||
|
|||||||
@@ -321,6 +321,7 @@ export class PlatformRuntime {
|
|||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case 'open':
|
case 'open':
|
||||||
this.wireIsOpen = true;
|
this.wireIsOpen = true;
|
||||||
|
this.store.setConnectionPhase('connected');
|
||||||
this.markReady('socket');
|
this.markReady('socket');
|
||||||
if (this.connectionIntent.type === 'switch') {
|
if (this.connectionIntent.type === 'switch') {
|
||||||
const loginEnvelope = this.loginEnvelope;
|
const loginEnvelope = this.loginEnvelope;
|
||||||
@@ -348,6 +349,7 @@ export class PlatformRuntime {
|
|||||||
case 'slow':
|
case 'slow':
|
||||||
case 'reconnecting':
|
case 'reconnecting':
|
||||||
this.wireIsOpen = false;
|
this.wireIsOpen = false;
|
||||||
|
this.store.setConnectionPhase(event.type);
|
||||||
this.options.scene.showReconnect();
|
this.options.scene.showReconnect();
|
||||||
break;
|
break;
|
||||||
case 'close':
|
case 'close':
|
||||||
@@ -429,6 +431,7 @@ export class PlatformRuntime {
|
|||||||
this.connectionIntent = { type: 'none' };
|
this.connectionIntent = { type: 'none' };
|
||||||
this.wireIsOpen = false;
|
this.wireIsOpen = false;
|
||||||
const errors: unknown[] = [];
|
const errors: unknown[] = [];
|
||||||
|
this.captureCleanupError(errors, () => { this.store.setConnectionPhase('kicked'); });
|
||||||
this.captureCleanupError(errors, () => { this.clearLoginGuard(); });
|
this.captureCleanupError(errors, () => { this.clearLoginGuard(); });
|
||||||
this.captureCleanupError(errors, () => { this.options.scene.showKicked(data); });
|
this.captureCleanupError(errors, () => { this.options.scene.showKicked(data); });
|
||||||
this.captureCleanupError(errors, () => { this.stopWireOnce(); });
|
this.captureCleanupError(errors, () => { this.stopWireOnce(); });
|
||||||
|
|||||||
+14
-1
@@ -14,6 +14,7 @@ import {
|
|||||||
} from '../../protocol/contracts/index.ts';
|
} from '../../protocol/contracts/index.ts';
|
||||||
import type {
|
import type {
|
||||||
InsideRoomState,
|
InsideRoomState,
|
||||||
|
PlatformConnectionPhase,
|
||||||
PlatformPlayer,
|
PlatformPlayer,
|
||||||
PlatformState,
|
PlatformState,
|
||||||
} from './platform-types.ts';
|
} from './platform-types.ts';
|
||||||
@@ -422,6 +423,15 @@ export class PlatformStore {
|
|||||||
return (): void => { this.listeners.delete(listener); };
|
return (): void => { this.listeners.delete(listener); };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setConnectionPhase(phase: Exclude<PlatformConnectionPhase, 'idle' | 'connecting' | 'logged-in'>): 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 {
|
private commit(state: PlatformState): PlatformState {
|
||||||
if (Object.is(state, this.state)) return state;
|
if (Object.is(state, this.state)) return state;
|
||||||
const previous = this.state;
|
const previous = this.state;
|
||||||
@@ -543,7 +553,10 @@ export class PlatformStore {
|
|||||||
}
|
}
|
||||||
const roomResult = buildRoom(input.room, raw, selfPlayer, stage);
|
const roomResult = buildRoom(input.room, raw, selfPlayer, stage);
|
||||||
return this.commit(Object.freeze({
|
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,
|
players: roomResult.players,
|
||||||
room: roomResult.room,
|
room: roomResult.room,
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -1,101 +1 @@
|
|||||||
export * from './contracts/index.ts';
|
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<PlayerState> };
|
|
||||||
/** 只读 RoomStore(含 deskinfo 透传引用)。 */
|
|
||||||
readonly room: ReadonlyRoomStore & { readonly state: ReadonlyReactive<RoomState> };
|
|
||||||
/** 只读 AppStore(连接相位 + 身份)。 */
|
|
||||||
readonly app: ReadonlyAppStore & { readonly state: ReadonlyReactive<AppState> };
|
|
||||||
/** 座位↔视图工具。 */
|
|
||||||
readonly seat: GameSeat;
|
|
||||||
/** 事件总线(子游戏可发自定义事件供自己订阅,框架不消费)。 */
|
|
||||||
readonly events: EventBus<Record<string, unknown[]>>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { afterEach, test } from 'node:test';
|
import { afterEach, test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
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 { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { dirname, join, resolve } from 'node:path';
|
import { dirname, join, resolve } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import {
|
import {
|
||||||
findCompatibilityDependency,
|
findCompatibilityDependency,
|
||||||
|
LEGACY_RUNTIME,
|
||||||
scanImportBoundaries,
|
scanImportBoundaries,
|
||||||
} from '../../scripts/lib/import-boundaries.mjs';
|
} 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 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([
|
const legacyRuntimeFiles = new Set([
|
||||||
'net/net-client.ts',
|
'net/net-client.ts',
|
||||||
'platform/session.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', () => {
|
test('every modern framework file is transitively isolated from the exact legacy compatibility island', () => {
|
||||||
const entries = modernRuntimeEntries
|
const files = readdirSync(frameworkRoot, { recursive: true })
|
||||||
.map((file) => resolve(frameworkRoot, file))
|
.filter(file => file.endsWith('.ts'))
|
||||||
.filter((file) => existsSync(file));
|
.map(file => resolve(frameworkRoot, file));
|
||||||
assert.ok(entries.some((file) => file.endsWith(`${join('protocol', 'router.ts')}`)));
|
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) {
|
for (const entry of entries) {
|
||||||
const dependency = findCompatibilityDependency({
|
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 () => {
|
test('compatibility graph catches static imports, re-exports, and import types through barrels', async () => {
|
||||||
const cases = [
|
const cases = [
|
||||||
"import '../platform/session.ts'\n",
|
"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/);
|
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();
|
const root = await createFixtureRoot();
|
||||||
for (const file of [
|
for (const file of [
|
||||||
'framework/core/events.ts',
|
'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);",
|
"import type { Value as Constants } from '../core/constants.ts'; void (0 as unknown as Constants);",
|
||||||
].join('\n'));
|
].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);
|
const violations = scan(root);
|
||||||
assert.equal(violations.length, 1);
|
assert.equal(violations.length, 1);
|
||||||
assert.equal(violations[0].specifier, '../core/constants.ts');
|
assert.match(violations[0].message, /IGameModule.*migration-only/);
|
||||||
assert.match(violations[0].message, /sdk.*framework implementation/);
|
});
|
||||||
|
|
||||||
|
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 () => {
|
test('scanner permits sdk-internal relatives and ordinary external pure dependencies', async () => {
|
||||||
|
|||||||
@@ -1,57 +1,181 @@
|
|||||||
import { test } from 'node:test';
|
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { NetClient } from '../../YouleNexus/assets/framework/net/net-client.ts';
|
import { readFileSync } from 'node:fs';
|
||||||
import { FakeTransport } from '../helpers/fake-transport.ts';
|
import { test } from 'node:test';
|
||||||
import { parseLoginResponse } from '../../YouleNexus/assets/framework/protocol/login.ts';
|
import type { RuntimeConfig } from '../../YouleNexus/assets/framework/config/runtime-config.ts';
|
||||||
import type { Clock } from '../../YouleNexus/assets/framework/net/heartbeat.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 CONFIG: RuntimeConfig = {
|
||||||
const IDENTITY = { agentid: 1, gameid: 2, openid: 'o', nickname: 'n', avatar: 'a', sex: 0, province: '', city: '', unionid: 'u', version: 1, channelid: 0, marketid: 0 };
|
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 () => {
|
function fixture(name: string): InboundMessage & { data: Record<string, any> } {
|
||||||
const t = new FakeTransport();
|
return JSON.parse(readFileSync(new URL(`../fixtures/contracts/${name}.json`, import.meta.url), 'utf8'));
|
||||||
const client = new NetClient({ servers: 'ws://srv', transportFactory: () => t, clock: realishClock });
|
}
|
||||||
client.setIdentity(IDENTITY);
|
|
||||||
|
|
||||||
let loginData: any = null;
|
function setup() {
|
||||||
const business: any[] = [];
|
const timers = new Map<object, { fn: () => void; ms: number }>();
|
||||||
client.on('login', (d: any) => { loginData = d; });
|
const clock: Clock = {
|
||||||
client.on('message', (m: any) => business.push(m));
|
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();
|
test('modern login gates business packets and retries exact login after the four-second guard', async (t) => {
|
||||||
await t.flush();
|
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]);
|
||||||
|
|
||||||
// 服务器先发握手包(原始串,应被忽略)
|
first.serverPush(JSON.stringify(fixture('self-join-room')));
|
||||||
t.serverPush('@toconconHELLO');
|
await first.flush();
|
||||||
await t.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
|
// The obsolete connection cannot reach Router or consume the new login gate.
|
||||||
assert.equal(JSON.parse(t.sent[0]).rpc, 'player_login');
|
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 响应(单层,带房间恢复 + 对局)
|
for (const deskinfo of [undefined, null, false, 0, '']) {
|
||||||
t.serverPush(JSON.stringify({
|
test(`modern room login does not restore falsey deskinfo: ${String(deskinfo)}`, async (t) => {
|
||||||
route: 'agent', rpc: 'player_login',
|
const s = setup();
|
||||||
data: { state: 0, playerid: 42, bean: 500, roomcode: 'ROOM1', isbattle: 1, deskinfo: { round: 3 } },
|
t.after(() => s.runtime.stop());
|
||||||
}));
|
await s.runtime.start();
|
||||||
await t.flush();
|
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);
|
test('another game route throws in the sole Router dispatch and becomes one terminal runtime error', async (t) => {
|
||||||
const parsed = parseLoginResponse(loginData);
|
const s = setup();
|
||||||
assert.equal(parsed.ok, true);
|
t.after(() => s.runtime.stop());
|
||||||
assert.equal(parsed.playerid, 42);
|
const thrown: unknown[] = [];
|
||||||
assert.equal(parsed.inRoom, true);
|
const routed: InboundMessage[] = [];
|
||||||
assert.equal(parsed.hasBattle, true);
|
const dispatch = Router.prototype.dispatch;
|
||||||
assert.deepEqual(parsed.deskinfo, { round: 3 });
|
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)
|
assert.equal(routed.length, 2);
|
||||||
t.serverPush(JSON.stringify({ com: '@serverheartbeat' }));
|
assert.equal(thrown.length, 1);
|
||||||
// 正常业务推送(单层,门控已清,放行)
|
assert.match(String(thrown[0]), /route.*mismatch|another-game/i);
|
||||||
t.serverPush(JSON.stringify({ route: 'room', rpc: 'other_join_room', data: { seat: 2 } }));
|
assert.equal(s.fatals.length, 1);
|
||||||
await t.flush();
|
assert.equal(s.fatals[0], thrown[0]);
|
||||||
|
assert.deepEqual(s.gameMessages, []);
|
||||||
assert.equal(business.length, 1);
|
assert.equal(transport.closeCalls, 1);
|
||||||
assert.equal(business[0].rpc, 'other_join_room');
|
assert.equal(s.timers.size, 0);
|
||||||
assert.equal(business[0].data.seat, 2);
|
assert.deepEqual(transport.sent.map(frame => JSON.parse(frame)), [LOGIN]);
|
||||||
|
|
||||||
client.stop();
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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<number, { at: number; fn: () => 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<string, any> } {
|
||||||
|
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<InboundMessage> {
|
||||||
|
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<string, unknown>).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();
|
||||||
|
});
|
||||||
@@ -977,9 +977,12 @@ test('slow and reconnecting show reconnect without clearing or replacing room st
|
|||||||
const callsBefore = setup.scene.calls.length;
|
const callsBefore = setup.scene.calls.length;
|
||||||
|
|
||||||
setup.wire.emit({ type: 'slow' });
|
setup.wire.emit({ type: 'slow' });
|
||||||
|
assert.equal(setup.runtime.state.app.phase, 'slow');
|
||||||
setup.wire.emit({ type: 'reconnecting', server: 'ws://agent' });
|
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.equal(setup.runtime.state.room.kind, 'inside');
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
setup.scene.calls.slice(callsBefore).map((call) => call.name),
|
setup.scene.calls.slice(callsBefore).map((call) => call.name),
|
||||||
|
|||||||
@@ -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<NetClientEvents>;
|
|
||||||
session: PlatformSession;
|
|
||||||
} {
|
|
||||||
const bus = new EventBus<NetClientEvents>();
|
|
||||||
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' });
|
|
||||||
});
|
|
||||||
@@ -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 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 CONTRACTS_PATH = /framework[\\/]sdk[\\/]contracts(?:[\\/]|$)/;
|
||||||
const MIGRATION_ONLY_NAMES = new Set(['GameContext', 'IGameModule']);
|
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.
|
* Recursively scans TypeScript imports for framework/game layering violations.
|
||||||
@@ -208,7 +202,7 @@ function* typescriptFiles(directory) {
|
|||||||
|
|
||||||
function extractImports(source, file) {
|
function extractImports(source, file) {
|
||||||
const imports = [];
|
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) => {
|
const visit = (node) => {
|
||||||
if ((ts.isImportDeclaration(node) || ts.isExportDeclaration(node))
|
if ((ts.isImportDeclaration(node) || ts.isExportDeclaration(node))
|
||||||
@@ -218,7 +212,7 @@ function extractImports(source, file) {
|
|||||||
specifier: node.moduleSpecifier.text,
|
specifier: node.moduleSpecifier.text,
|
||||||
bindings: ts.isImportDeclaration(node) && node.importClause
|
bindings: ts.isImportDeclaration(node) && node.importClause
|
||||||
? node.importClause.getText(sourceFile)
|
? node.importClause.getText(sourceFile)
|
||||||
: '',
|
: ts.isExportDeclaration(node) ? node.exportClause?.getText(sourceFile) ?? '' : '',
|
||||||
});
|
});
|
||||||
} else if (ts.isImportEqualsDeclaration(node)) {
|
} else if (ts.isImportEqualsDeclaration(node)) {
|
||||||
const specifier = externalImportEqualsSpecifier(node);
|
const specifier = externalImportEqualsSpecifier(node);
|
||||||
@@ -228,10 +222,10 @@ function extractImports(source, file) {
|
|||||||
} else if (ts.isImportTypeNode(node)
|
} else if (ts.isImportTypeNode(node)
|
||||||
&& ts.isLiteralTypeNode(node.argument)
|
&& ts.isLiteralTypeNode(node.argument)
|
||||||
&& ts.isStringLiteralLike(node.argument.literal)) {
|
&& 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)) {
|
} else if (isModuleLoaderCall(node)) {
|
||||||
const specifier = literalCallSpecifier(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);
|
ts.forEachChild(node, visit);
|
||||||
};
|
};
|
||||||
@@ -239,6 +233,22 @@ function extractImports(source, file) {
|
|||||||
return imports;
|
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) {
|
function externalImportEqualsSpecifier(node) {
|
||||||
if (!ts.isImportEqualsDeclaration(node)
|
if (!ts.isImportEqualsDeclaration(node)
|
||||||
|| !ts.isExternalModuleReference(node.moduleReference)
|
|| !ts.isExternalModuleReference(node.moduleReference)
|
||||||
@@ -286,8 +296,7 @@ function findViolation(context) {
|
|||||||
if (isSdk
|
if (isSdk
|
||||||
&& resolved
|
&& resolved
|
||||||
&& isInside(resolved, frameworkDir)
|
&& isInside(resolved, frameworkDir)
|
||||||
&& !SDK_ALLOWED.test(resolvedPath)
|
&& !SDK_ALLOWED.test(resolvedPath)) {
|
||||||
&& !isSdkIndexMigrationDependency(filePath, resolved, frameworkDir)) {
|
|
||||||
return violation(filePath, specifier, `sdk cannot import framework implementation ${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}`);
|
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';
|
const importer = isGame ? 'game code' : 'production code';
|
||||||
return violation(filePath, specifier, `${importer} cannot import ${migrationOnlyName(bindings)}; it is migration-only`);
|
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}`);
|
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}`);
|
return violation(filePath, specifier, `new production code cannot import quarantined legacy runtime ${resolvedPath}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -318,28 +327,10 @@ function isFrameworkReference(path, frameworkDir) {
|
|||||||
|| /(?:^|[\\/])framework(?:[\\/]|$)/.test(path);
|
|| /(?:^|[\\/])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) {
|
function isCocosSpecifier(specifier) {
|
||||||
return specifier === 'cc' || specifier.startsWith('cc/');
|
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) {
|
function importsMigrationOnlyName(bindings) {
|
||||||
return [...MIGRATION_ONLY_NAMES].some((name) => new RegExp(`\\b${name}\\b`).test(bindings));
|
return [...MIGRATION_ONLY_NAMES].some((name) => new RegExp(`\\b${name}\\b`).test(bindings));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user