From e1556d50f4f7b0dc5c67ec71866aca3400d4091b Mon Sep 17 00:00:00 2001 From: Joywayer Date: Sat, 5 Sep 2026 00:41:39 +0800 Subject: [PATCH] feat(sdk): define single-game public contracts --- .../framework/sdk/contracts/game-entry.ts | 10 ++ .../framework/sdk/contracts/game-host.ts | 15 ++ .../framework/sdk/contracts/game-module.ts | 11 ++ .../sdk/contracts/game-seat-mapper.ts | 5 + .../assets/framework/sdk/contracts/index.ts | 6 + .../sdk/contracts/platform-events.ts | 15 ++ .../framework/sdk/contracts/snapshots.ts | 45 ++++++ .../YouleNexus/assets/framework/sdk/index.ts | 8 +- .../architecture/import-boundaries.test.mjs | 87 +++++++++++ .../framework-tests/sdk/contracts.test.ts | 28 ++++ cocoscreator_projects/package.json | 3 +- .../scripts/check-import-boundaries.mjs | 18 +++ .../scripts/lib/import-boundaries.mjs | 136 ++++++++++++++++++ 13 files changed, 385 insertions(+), 2 deletions(-) create mode 100644 cocoscreator_projects/YouleNexus/assets/framework/sdk/contracts/game-entry.ts create mode 100644 cocoscreator_projects/YouleNexus/assets/framework/sdk/contracts/game-host.ts create mode 100644 cocoscreator_projects/YouleNexus/assets/framework/sdk/contracts/game-module.ts create mode 100644 cocoscreator_projects/YouleNexus/assets/framework/sdk/contracts/game-seat-mapper.ts create mode 100644 cocoscreator_projects/YouleNexus/assets/framework/sdk/contracts/index.ts create mode 100644 cocoscreator_projects/YouleNexus/assets/framework/sdk/contracts/platform-events.ts create mode 100644 cocoscreator_projects/YouleNexus/assets/framework/sdk/contracts/snapshots.ts create mode 100644 cocoscreator_projects/framework-tests/architecture/import-boundaries.test.mjs create mode 100644 cocoscreator_projects/framework-tests/sdk/contracts.test.ts create mode 100644 cocoscreator_projects/scripts/check-import-boundaries.mjs create mode 100644 cocoscreator_projects/scripts/lib/import-boundaries.mjs diff --git a/cocoscreator_projects/YouleNexus/assets/framework/sdk/contracts/game-entry.ts b/cocoscreator_projects/YouleNexus/assets/framework/sdk/contracts/game-entry.ts new file mode 100644 index 0000000..f073d5e --- /dev/null +++ b/cocoscreator_projects/YouleNexus/assets/framework/sdk/contracts/game-entry.ts @@ -0,0 +1,10 @@ +import type { GameModule } from './game-module.ts'; + +/** The one compile-time game identity and module factory for an app. */ +export interface GameEntry { + readonly key: string; + readonly gameId: string | number; + readonly route: string; + resolveSeatCount(roomtype: readonly unknown[]): number; + createModule(): GameModule; +} diff --git a/cocoscreator_projects/YouleNexus/assets/framework/sdk/contracts/game-host.ts b/cocoscreator_projects/YouleNexus/assets/framework/sdk/contracts/game-host.ts new file mode 100644 index 0000000..d1b1b2f --- /dev/null +++ b/cocoscreator_projects/YouleNexus/assets/framework/sdk/contracts/game-host.ts @@ -0,0 +1,15 @@ +import type { GameSeatMapper } from './game-seat-mapper.ts'; +import type { PlatformGameSnapshot } from './snapshots.ts'; + +export type GameHostCommand = + | { readonly type: 'room.prepare' } + | { readonly type: 'room.exit' }; + +/** The only platform capability surface available to a game module. */ +export interface GameHost { + readonly seat: GameSeatMapper; + getSnapshot(): PlatformGameSnapshot; + subscribe(listener: (snapshot: PlatformGameSnapshot) => void): () => void; + sendGameMessage(rpc: string, data: unknown): void; + execute(command: GameHostCommand): void; +} diff --git a/cocoscreator_projects/YouleNexus/assets/framework/sdk/contracts/game-module.ts b/cocoscreator_projects/YouleNexus/assets/framework/sdk/contracts/game-module.ts new file mode 100644 index 0000000..a121a3a --- /dev/null +++ b/cocoscreator_projects/YouleNexus/assets/framework/sdk/contracts/game-module.ts @@ -0,0 +1,11 @@ +import type { GameHost } from './game-host.ts'; +import type { GameServerMessage, PlatformToGameEvent } from './platform-events.ts'; + +/** Per-room game implementation with an explicit lifecycle. */ +export interface GameModule { + attach(host: GameHost): void; + handlePlatformEvent(event: PlatformToGameEvent): void; + handleGameMessage(message: GameServerMessage): void; + restore(deskinfo: unknown): void; + dispose(): void; +} diff --git a/cocoscreator_projects/YouleNexus/assets/framework/sdk/contracts/game-seat-mapper.ts b/cocoscreator_projects/YouleNexus/assets/framework/sdk/contracts/game-seat-mapper.ts new file mode 100644 index 0000000..f2cd891 --- /dev/null +++ b/cocoscreator_projects/YouleNexus/assets/framework/sdk/contracts/game-seat-mapper.ts @@ -0,0 +1,5 @@ +/** Maps server seat numbers to the current game's view-seat numbers. */ +export interface GameSeatMapper { + toView(serverSeat: number): number; + toServer(viewSeat: number): number; +} diff --git a/cocoscreator_projects/YouleNexus/assets/framework/sdk/contracts/index.ts b/cocoscreator_projects/YouleNexus/assets/framework/sdk/contracts/index.ts new file mode 100644 index 0000000..4a9fe88 --- /dev/null +++ b/cocoscreator_projects/YouleNexus/assets/framework/sdk/contracts/index.ts @@ -0,0 +1,6 @@ +export * from './game-entry.ts'; +export * from './game-host.ts'; +export * from './game-module.ts'; +export * from './game-seat-mapper.ts'; +export * from './platform-events.ts'; +export * from './snapshots.ts'; diff --git a/cocoscreator_projects/YouleNexus/assets/framework/sdk/contracts/platform-events.ts b/cocoscreator_projects/YouleNexus/assets/framework/sdk/contracts/platform-events.ts new file mode 100644 index 0000000..e9a836a --- /dev/null +++ b/cocoscreator_projects/YouleNexus/assets/framework/sdk/contracts/platform-events.ts @@ -0,0 +1,15 @@ +/** Platform lifecycle events delivered to the current game module. */ +export type PlatformToGameEvent = + | { readonly type: 'room.entered'; readonly roomtype: readonly unknown[] } + | { readonly type: 'room.player-joined'; readonly seat: number } + | { readonly type: 'room.player-left'; readonly seat: number } + | { readonly type: 'room.player-ready'; readonly seat: number } + | { readonly type: 'room.player-offline'; readonly seat: number } + | { readonly type: 'room.player-online'; readonly seat: number } + | { readonly type: 'room.dissolved' }; + +/** Opaque game-route server message; only the game interprets its data. */ +export interface GameServerMessage { + readonly rpc: string; + readonly data: unknown; +} diff --git a/cocoscreator_projects/YouleNexus/assets/framework/sdk/contracts/snapshots.ts b/cocoscreator_projects/YouleNexus/assets/framework/sdk/contracts/snapshots.ts new file mode 100644 index 0000000..14f50ca --- /dev/null +++ b/cocoscreator_projects/YouleNexus/assets/framework/sdk/contracts/snapshots.ts @@ -0,0 +1,45 @@ +/** Public connection phases available to a game. */ +export type PlatformGameConnectionPhase = + | 'connected' + | 'logged-in' + | 'reconnecting' + | 'slow' + | 'kicked'; + +/** Plain public connection projection. */ +export interface PlatformGameAppSnapshot { + readonly phase: PlatformGameConnectionPhase; +} + +/** Plain public identity projection for the local player. */ +export interface PlatformGameSelfSnapshot { + readonly playerId: number; + readonly seat: number; +} + +/** Plain public room projection. `roomtype` remains opaque server data. */ +export interface PlatformGameRoomSnapshot { + readonly roomcode: string; + readonly roomtype: readonly unknown[]; + readonly stage: number; + readonly needprepare: number; + readonly infinite: number; +} + +/** Plain public player projection for one occupied game seat. */ +export interface GameSeatSnapshot { + readonly seat: number; + readonly playerId: number; + readonly nickname: string; + readonly avatar: string; + readonly online: boolean; + readonly ready: boolean; +} + +/** Immutable platform data exposed to a game module. */ +export interface PlatformGameSnapshot { + readonly connection: PlatformGameAppSnapshot; + readonly self: PlatformGameSelfSnapshot; + readonly room: PlatformGameRoomSnapshot; + readonly seats: readonly GameSeatSnapshot[]; +} diff --git a/cocoscreator_projects/YouleNexus/assets/framework/sdk/index.ts b/cocoscreator_projects/YouleNexus/assets/framework/sdk/index.ts index 1535a92..4a9d22e 100644 --- a/cocoscreator_projects/YouleNexus/assets/framework/sdk/index.ts +++ b/cocoscreator_projects/YouleNexus/assets/framework/sdk/index.ts @@ -1,3 +1,5 @@ +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'; @@ -26,6 +28,8 @@ export interface GameSeat { } /** + * @deprecated migration-only. Use GameHost from sdk/contracts instead. + * * GameContext:子游戏调用框架能力的唯一入口。 * * 只暴露只读 Store + 受限 net.send + 座位工具 + 事件总线。 @@ -47,6 +51,8 @@ export interface GameContext { } /** + * @deprecated migration-only. Use GameModule from sdk/contracts instead. + * * IGameModule:子游戏实现,被框架调用(框架 spec §4)。 * * 子游戏实现这个接口并注册自己;框架 Router 据 route 分发对局包。 @@ -92,4 +98,4 @@ export interface IGameModule { onDissolve?(): void; /** 玩家离线/上线(在线由 onPlayerJoin 重复触发,离线由本钩子)。 */ onOffline?(seat: number): void; -} \ No newline at end of file +} diff --git a/cocoscreator_projects/framework-tests/architecture/import-boundaries.test.mjs b/cocoscreator_projects/framework-tests/architecture/import-boundaries.test.mjs new file mode 100644 index 0000000..8d66805 --- /dev/null +++ b/cocoscreator_projects/framework-tests/architecture/import-boundaries.test.mjs @@ -0,0 +1,87 @@ +import { afterEach, test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { scanImportBoundaries } from '../../scripts/lib/import-boundaries.mjs'; + +const roots = []; + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +async function createFixtureRoot() { + const root = await mkdtemp(join(tmpdir(), 'youle-import-boundaries-')); + roots.push(root); + return root; +} + +async function writeFixture(root, relativePath, source) { + const file = join(root, relativePath); + await mkdir(dirname(file), { recursive: true }); + await writeFile(file, source, 'utf8'); +} + +function scan(root) { + return scanImportBoundaries({ + frameworkDir: join(root, 'framework'), + gamesDir: join(root, 'games'), + }); +} + +test('scanner rejects sdk contracts importing a framework platform path', async () => { + const root = await createFixtureRoot(); + await writeFixture(root, 'framework/sdk/contracts/bad.ts', "import '../platform/session.ts'\n"); + assert.match(scan(root)[0].message, /sdk.*platform/); +}); + +test('scanner rejects game imports of framework net internals', async () => { + const root = await createFixtureRoot(); + await writeFixture(root, 'games/a/assets/game/bad.ts', "import '../../../framework/net/net-client.ts'\n"); + assert.match(scan(root)[0].message, /game.*framework\/net/); +}); + +test('scanner permits a game import of the public sdk barrel', async () => { + const root = await createFixtureRoot(); + await writeFixture(root, 'games/a/assets/game/allowed.ts', "import '../../../../framework/sdk/index.ts'\n"); + assert.deepEqual(scan(root), []); +}); + +test('scanner rejects games importing migration-only sdk declarations', async () => { + const root = await createFixtureRoot(); + await writeFixture(root, 'games/a/assets/game/bad.ts', "import type { GameContext } from '../../../../framework/sdk/index.ts'\n"); + assert.match(scan(root)[0].message, /game.*GameContext.*migration-only/); +}); + +test('scanner rejects cc imports in sdk contracts', async () => { + const root = await createFixtureRoot(); + await writeFixture(root, 'framework/sdk/contracts/bad.ts', "import { Node } from 'cc'\n"); + assert.match(scan(root)[0].message, /sdk\/contracts.*cc/); +}); + +test('scanner permits cc imports in game code', async () => { + const root = await createFixtureRoot(); + await writeFixture(root, 'games/a/assets/game/allowed.ts', "import { Node } from 'cc'\n"); + assert.deepEqual(scan(root), []); +}); + +test('scanner rejects framework imports resolving under games', async () => { + const root = await createFixtureRoot(); + await writeFixture(root, 'framework/ui/bad.ts', "import '../../games/a/assets/game/entry.ts'\n"); + assert.match(scan(root)[0].message, /framework.*games/); +}); + +test('scanner rejects new production imports of a quarantined legacy runtime', async () => { + const root = await createFixtureRoot(); + await writeFixture(root, 'framework/application/bad.ts', "import '../net/net-client.ts'\n"); + const violations = scan(root); + assert.equal(violations.length, 1); + assert.match(violations[0].message, /new production code.*legacy runtime/); +}); + +test('scanner rejects string-literal dynamic imports that cross a boundary', async () => { + const root = await createFixtureRoot(); + await writeFixture(root, 'games/a/assets/game/bad.ts', "await import('../../../framework/net/net-client.ts')\n"); + assert.match(scan(root)[0].message, /game.*framework\/net/); +}); diff --git a/cocoscreator_projects/framework-tests/sdk/contracts.test.ts b/cocoscreator_projects/framework-tests/sdk/contracts.test.ts new file mode 100644 index 0000000..9193c99 --- /dev/null +++ b/cocoscreator_projects/framework-tests/sdk/contracts.test.ts @@ -0,0 +1,28 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import type { + GameEntry, GameHost, GameModule, GameServerMessage, + PlatformGameSnapshot, PlatformToGameEvent, +} from '../../YouleNexus/assets/framework/sdk/contracts/index.ts'; + +test('single GameEntry contract has no registry or runtime version member', () => { + const module: GameModule = { + attach(_host: GameHost) {}, + handlePlatformEvent(_event: PlatformToGameEvent) {}, + handleGameMessage(_message: GameServerMessage) {}, + restore(_deskinfo: unknown) {}, + dispose() {}, + }; + const entry: GameEntry = { + key: 'fixture', gameId: 41, route: 'fixture-route', + resolveSeatCount: () => 4, + createModule: () => module, + }; + assert.equal(entry.route, 'fixture-route'); + assert.equal('apiVersion' in entry, false); +}); + +test('public snapshot is plain readonly data', () => { + const value = {} as PlatformGameSnapshot; + assert.equal('state' in value, false); +}); diff --git a/cocoscreator_projects/package.json b/cocoscreator_projects/package.json index b0411b3..bb14be4 100644 --- a/cocoscreator_projects/package.json +++ b/cocoscreator_projects/package.json @@ -14,7 +14,8 @@ "convert-ui": "node scripts/convert-ui.mjs", "verify-frames": "node scripts/verify-frames.mjs", "test:framework": "node scripts/run-framework-tests.mjs", - "typecheck:framework": "tsc -p tsconfig.framework.json --noEmit" + "typecheck:framework": "tsc -p tsconfig.framework.json --noEmit", + "check-boundaries": "node scripts/check-import-boundaries.mjs" }, "devDependencies": { "@types/node": "^26.0.1", diff --git a/cocoscreator_projects/scripts/check-import-boundaries.mjs b/cocoscreator_projects/scripts/check-import-boundaries.mjs new file mode 100644 index 0000000..80efaef --- /dev/null +++ b/cocoscreator_projects/scripts/check-import-boundaries.mjs @@ -0,0 +1,18 @@ +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { scanImportBoundaries } from './lib/import-boundaries.mjs'; + +const projectDir = resolve(fileURLToPath(new URL('..', import.meta.url))); +const violations = scanImportBoundaries({ + frameworkDir: resolve(projectDir, 'YouleNexus/assets/framework'), + gamesDir: resolve(projectDir, 'games'), +}); + +if (violations.length > 0) { + for (const { file, specifier, message } of violations) { + console.error(`[import-boundaries] ${file}: ${specifier} — ${message}`); + } + process.exitCode = 1; +} else { + console.log('[import-boundaries] OK'); +} diff --git a/cocoscreator_projects/scripts/lib/import-boundaries.mjs b/cocoscreator_projects/scripts/lib/import-boundaries.mjs new file mode 100644 index 0000000..d7be542 --- /dev/null +++ b/cocoscreator_projects/scripts/lib/import-boundaries.mjs @@ -0,0 +1,136 @@ +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { dirname, isAbsolute, relative, resolve } from 'node:path'; + +const SDK_ALLOWED = /framework[\\/]sdk(?:[\\/]|$)/; +const FRAMEWORK_INTERNAL = /framework[\\/](?:net|protocol|platform|application|domain|presentation|ui|core|compat)(?:[\\/]|$)/; +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']); + +/** + * Recursively scans TypeScript imports for framework/game layering violations. + * Paths in violations are normalized for useful, cross-platform diagnostics. + */ +export function scanImportBoundaries(options) { + const frameworkDir = resolve(options.frameworkDir); + const gamesDir = resolve(options.gamesDir); + const violations = []; + + for (const file of [...typescriptFiles(frameworkDir), ...typescriptFiles(gamesDir)]) { + const source = readFileSync(file, 'utf8'); + for (const imported of extractImports(source)) { + const resolved = imported.specifier.startsWith('.') + ? resolve(dirname(file), imported.specifier) + : null; + const violation = findViolation({ + file, + specifier: imported.specifier, + bindings: imported.bindings, + resolved, + frameworkDir, + gamesDir, + }); + if (violation) violations.push(violation); + } + } + + return violations; +} + +function* typescriptFiles(directory) { + let entries; + try { + entries = readdirSync(directory); + } catch (error) { + if (error && error.code === 'ENOENT') return; + throw error; + } + for (const entry of entries) { + const file = resolve(directory, entry); + if (statSync(file).isDirectory()) yield* typescriptFiles(file); + else if (file.endsWith('.ts')) yield file; + } +} + +function extractImports(source) { + const imports = []; + const staticImport = /\bimport\s+(?!\()(?:(?:type\s+)?([^;\n]+?)\s+from\s+)?(['"])([^'"\n]+)\2/g; + for (const match of source.matchAll(staticImport)) { + imports.push({ specifier: match[3], bindings: match[1] ?? '' }); + } + const dynamicImport = /\bimport\s*\(\s*(['"])([^'"\n]+)\1\s*\)/g; + for (const match of source.matchAll(dynamicImport)) { + imports.push({ specifier: match[2], bindings: '' }); + } + return imports; +} + +function findViolation(context) { + const { file, specifier, bindings, resolved, frameworkDir, gamesDir } = context; + const filePath = displayPath(file); + const resolvedPath = resolved ? displayPath(resolved) : specifier; + const isContract = CONTRACTS_PATH.test(filePath); + const isGame = isInside(file, gamesDir); + const isFramework = isInside(file, frameworkDir); + + if (isContract && specifier === 'cc') { + return violation(filePath, specifier, `sdk/contracts cannot import cc (${filePath})`); + } + + if (isContract && resolved && dirname(resolved) !== dirname(file)) { + return violation(filePath, specifier, `sdk/contracts may import only sibling contracts; sdk path resolved to ${resolvedPath}`); + } + + if (isGame && resolved && isFrameworkReference(resolved, frameworkDir) && !SDK_ALLOWED.test(resolvedPath)) { + return violation(filePath, specifier, `game code may import only framework/sdk; game import resolved to ${resolvedPath}`); + } + + if (isGame && resolved && SDK_ALLOWED.test(resolvedPath) && importsMigrationOnlyName(bindings)) { + return violation(filePath, specifier, `game code cannot import ${migrationOnlyName(bindings)}; it is migration-only`); + } + + if (isFramework && resolved && isInside(resolved, gamesDir)) { + return violation(filePath, specifier, `framework code cannot import games; framework import resolved to ${resolvedPath}`); + } + + if (resolved && LEGACY_RUNTIME.test(resolvedPath) && !isLegacyRuntimeImporter(filePath, frameworkDir)) { + return violation(filePath, specifier, `new production code cannot import quarantined legacy runtime ${resolvedPath}`); + } + + return null; +} + +function isFrameworkReference(path, frameworkDir) { + return isInside(path, frameworkDir) + || SDK_ALLOWED.test(path) + || FRAMEWORK_INTERNAL.test(path) + || /(?:^|[\\/])framework(?:[\\/]|$)/.test(path); +} + +function isLegacyRuntimeImporter(filePath, frameworkDir) { + const normalizedFrameworkDir = displayPath(frameworkDir); + return filePath === `${normalizedFrameworkDir}/sdk/index.ts` || LEGACY_RUNTIME.test(filePath); +} + +function importsMigrationOnlyName(bindings) { + return [...MIGRATION_ONLY_NAMES].some((name) => new RegExp(`\\b${name}\\b`).test(bindings)); +} + +function migrationOnlyName(bindings) { + return [...MIGRATION_ONLY_NAMES].find((name) => new RegExp(`\\b${name}\\b`).test(bindings)); +} + +function isInside(file, directory) { + const pathFromDirectory = relative(directory, file); + return pathFromDirectory !== '' && !pathFromDirectory.startsWith('..') && !isAbsolute(pathFromDirectory); +} + +function displayPath(file) { + return file.replaceAll('\\', '/'); +} + +function violation(file, specifier, message) { + return { file, specifier, message }; +} + +export { FRAMEWORK_INTERNAL, LEGACY_RUNTIME, SDK_ALLOWED };