feat(sdk): define single-game public contracts
This commit is contained in:
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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[];
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
export * from './contracts/index.ts';
|
||||||
|
|
||||||
import type { EventBus } from '../core/events.ts';
|
import type { EventBus } from '../core/events.ts';
|
||||||
import type { ReadonlyReactive } from '../core/reactive.ts';
|
import type { ReadonlyReactive } from '../core/reactive.ts';
|
||||||
import type { ReadonlyPlayerStore } from '../platform/readonly.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:子游戏调用框架能力的唯一入口。
|
* GameContext:子游戏调用框架能力的唯一入口。
|
||||||
*
|
*
|
||||||
* 只暴露只读 Store + 受限 net.send + 座位工具 + 事件总线。
|
* 只暴露只读 Store + 受限 net.send + 座位工具 + 事件总线。
|
||||||
@@ -47,6 +51,8 @@ export interface GameContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* @deprecated migration-only. Use GameModule from sdk/contracts instead.
|
||||||
|
*
|
||||||
* IGameModule:子游戏实现,被框架调用(框架 spec §4)。
|
* IGameModule:子游戏实现,被框架调用(框架 spec §4)。
|
||||||
*
|
*
|
||||||
* 子游戏实现这个接口并注册自己;框架 Router 据 route 分发对局包。
|
* 子游戏实现这个接口并注册自己;框架 Router 据 route 分发对局包。
|
||||||
|
|||||||
@@ -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/);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -14,7 +14,8 @@
|
|||||||
"convert-ui": "node scripts/convert-ui.mjs",
|
"convert-ui": "node scripts/convert-ui.mjs",
|
||||||
"verify-frames": "node scripts/verify-frames.mjs",
|
"verify-frames": "node scripts/verify-frames.mjs",
|
||||||
"test:framework": "node scripts/run-framework-tests.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": {
|
"devDependencies": {
|
||||||
"@types/node": "^26.0.1",
|
"@types/node": "^26.0.1",
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
Reference in New Issue
Block a user