feat: integrate room framework and isolated subgame bundles

Complete room UI and protocol integration, move game definitions and resources behind bundle entries, publish authoritative version XML, and document single-game builds. Include all current resource changes and experiment artifacts.
This commit is contained in:
2026-09-09 03:13:18 +08:00
parent a7ec8aa6a7
commit 35216f75e7
1028 changed files with 835556 additions and 21415 deletions
@@ -7,6 +7,8 @@ const FRAMEWORK_INTERNAL = /framework[\\/](?:net|protocol|platform|application|d
const LEGACY_RUNTIME = /framework[\\/](?:net[\\/]net-client|platform[\\/](?:session|startup|room-rpc-bus|readonly|stores[\\/](?:app-store|player-store|room-store|types))|protocol[\\/]room-handlers)\.ts$/;
const CONTRACTS_PATH = /framework[\\/]sdk[\\/]contracts(?:[\\/]|$)/;
const MIGRATION_ONLY_NAMES = new Set(['GameContext', 'IGameModule']);
// Pre-existing, quarantined prototype entry points; modern code must not import them.
const LEGACY_SCENE_SCRIPT = /scripts[\\/](?:LoginFlow|RoomEventProbe|RoomSceneStart)\.ts$/;
/**
* Recursively scans TypeScript imports for framework/game layering violations.
@@ -15,6 +17,8 @@ const MIGRATION_ONLY_NAMES = new Set(['GameContext', 'IGameModule']);
export function scanImportBoundaries(options) {
const frameworkDir = resolve(options.frameworkDir);
const gamesDir = resolve(options.gamesDir);
const publicDirs = (options.publicDirs ?? []).map(path=>resolve(path));
const appDir = options.appDir ? resolve(options.appDir) : null;
const compilerOptions = readCompilerOptions(options.tsconfigPath);
const resolutionBase = options.tsconfigPath
? dirname(resolve(options.tsconfigPath))
@@ -26,7 +30,7 @@ export function scanImportBoundaries(options) {
);
const violations = [];
for (const file of [...typescriptFiles(frameworkDir), ...typescriptFiles(gamesDir)]) {
for (const file of [...typescriptFiles(frameworkDir), ...typescriptFiles(gamesDir), ...publicDirs.flatMap(dir=>[...typescriptFiles(dir)]), ...(appDir?[...typescriptFiles(appDir)]:[])]) {
const source = readFileSync(file, 'utf8');
for (const imported of extractImports(source, file)) {
const resolved = resolveDependency(
@@ -44,6 +48,8 @@ export function scanImportBoundaries(options) {
resolved,
frameworkDir,
gamesDir,
publicDirs,
appDir,
});
if (violation) violations.push(violation);
}
@@ -273,13 +279,19 @@ function literalCallSpecifier(node) {
}
function findViolation(context) {
const { file, specifier, bindings, resolved, frameworkDir, gamesDir } = context;
const { file, specifier, bindings, resolved, frameworkDir, gamesDir, publicDirs, appDir } = context;
if(appDir && isInside(file,appDir) && resolved && isInside(resolved,gamesDir))return violation(file.replaceAll('\\','/'),specifier,'app composition cannot import concrete games');
const filePath = displayPath(file);
const resolvedPath = resolved ? displayPath(resolved) : specifier;
const isContract = CONTRACTS_PATH.test(filePath);
const isSdk = SDK_ALLOWED.test(filePath);
const isGame = isInside(file, gamesDir);
const isFramework = isInside(file, frameworkDir);
const isPublic = publicDirs.some(dir=>isInside(file,dir));
if ((isFramework || isPublic) && resolved && (isInside(resolved,gamesDir) || (appDir && isInside(resolved,appDir)))) {
return violation(filePath,specifier,'public framework/platform code cannot import games or app composition');
}
if (isContract && !resolved) {
return violation(filePath, specifier, `sdk/contracts may import only sibling contracts; non-relative specifier ${specifier} is forbidden`);
@@ -304,6 +316,11 @@ function findViolation(context) {
return violation(filePath, specifier, `game code may import only framework/sdk; game import resolved to ${resolvedPath}`);
}
if (isGame && resolved && isInside(resolved,gamesDir)
&& relative(gamesDir,file).split(/[\\/]/)[0] !== relative(gamesDir,resolved).split(/[\\/]/)[0]) {
return violation(filePath,specifier,'game code cannot import another game');
}
if (importsMigrationOnlyName(bindings)) {
const importer = isGame ? 'game code' : 'production code';
return violation(filePath, specifier, `${importer} cannot import ${migrationOnlyName(bindings)}; it is migration-only`);
@@ -313,7 +330,7 @@ function findViolation(context) {
return violation(filePath, specifier, `framework code cannot import games; framework import resolved to ${resolvedPath}`);
}
if (resolved && LEGACY_RUNTIME.test(resolvedPath) && !LEGACY_RUNTIME.test(filePath)) {
if (resolved && (LEGACY_RUNTIME.test(resolvedPath)||LEGACY_SCENE_SCRIPT.test(resolvedPath)) && !LEGACY_RUNTIME.test(filePath)&&!LEGACY_SCENE_SCRIPT.test(filePath)) {
return violation(filePath, specifier, `new production code cannot import quarantined legacy runtime ${resolvedPath}`);
}
@@ -0,0 +1,128 @@
import {existsSync,readFileSync,readdirSync} from 'node:fs';
import {resolve,relative} from 'node:path';
import ts from 'typescript';
const normalize=path=>path.replaceAll('\\','/');
const readJson=path=>JSON.parse(readFileSync(path,'utf8'));
/** Cocos script class IDs retain five UUID hex digits then encode hex triples as base64 pairs. */
export function compressUuid(uuid,headLength=5){
const hex=uuid.replaceAll('-','');
if(!/^[0-9a-f]{32}$/i.test(hex))throw Error(`Invalid asset UUID: ${uuid}`);
const alphabet='ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
let result=hex.slice(0,headLength);
for(let i=headLength;i<32;i+=3){const value=parseInt(hex.slice(i,i+3),16);result+=alphabet[value>>6]+alphabet[value&63];}
return result;
}
/** Read-only source/metadata index; resolve aliases and indirect inheritance through TypeScript symbols. */
export function loadRoomAssetInventory(assetsDir){
const files=readdirSync(assetsDir,{recursive:true}).map(file=>resolve(assetsDir,file));
const sources=files.filter(file=>file.endsWith('.ts')&&!file.endsWith('.d.ts'));
const program=ts.createProgram(sources,{noResolve:false,target:ts.ScriptTarget.ESNext,module:ts.ModuleKind.ESNext,moduleResolution:ts.ModuleResolutionKind.Bundler,allowImportingTsExtensions:true,skipLibCheck:true});
const checker=program.getTypeChecker();
const scripts=[];
function parents(declaration,seen=new Set()){
if(seen.has(declaration))return [];
seen.add(declaration);
const names=[declaration.name?.text].filter(Boolean);
for(const clause of declaration.heritageClauses??[]){
for(const type of clause.types){
let symbol=checker.getSymbolAtLocation(type.expression);
if(symbol?.flags&ts.SymbolFlags.Alias)symbol=checker.getAliasedSymbol(symbol);
for(const base of symbol?.declarations??[]){if(ts.isClassDeclaration(base)||ts.isInterfaceDeclaration(base))names.push(...parents(base,seen));}
}
}
return [...new Set(names)];
}
for(const file of sources){
if(!existsSync(`${file}.meta`))continue;
const source=program.getSourceFile(file);
for(const declaration of source?.statements??[]){
if(!ts.isClassDeclaration(declaration)||!declaration.name)continue;
const decorator=ts.getDecorators(declaration)?.find(item=>ts.isCallExpression(item.expression)&&item.expression.expression.getText(source)==='ccclass');
if(!decorator)continue;
const name=decorator.expression.arguments[0];
if(!name||!ts.isStringLiteral(name))throw Error(`${file}: ccclass must declare a static name`);
scripts.push({name:name.text,uuid:readJson(`${file}.meta`).uuid,path:normalize(relative(assetsDir,file)),contracts:parents(declaration)});
}
}
const assets=files.filter(file=>/\.(prefab|scene)$/.test(file)).map(file=>({
path:normalize(relative(assetsDir,file)),uuid:readJson(`${file}.meta`).uuid,objects:readJson(file),
}));
return {scripts,assets};
}
/** Extract only static resource declarations; never instantiate or execute game factories. */
export function loadRoomAssetDefinitions(file){
if(!file.endsWith('.ts'))return readdirSync(file,{withFileTypes:true}).filter(entry=>entry.isDirectory()).flatMap(entry=>loadRoomAssetDefinitions(resolve(file,entry.name,'game-definition.ts')));
return parseRoomAssetDefinitions(readFileSync(file,'utf8'),file);
}
export function parseRoomAssetDefinitions(text,file='game-definitions.ts'){
const source=ts.createSourceFile(file,text,ts.ScriptTarget.Latest,true);
let definition;
for(const statement of source.statements){if(ts.isVariableStatement(statement))for(const item of statement.declarationList.declarations){if(item.name.getText(source)==='GAME_DEFINITIONS')definition=item.initializer;}}
function unwrap(node){while(node&&(ts.isAsExpression(node)||ts.isSatisfiesExpression(node)||ts.isParenthesizedExpression(node)))node=node.expression;return node;}
function properties(node){node=unwrap(node);if(!node||!ts.isObjectLiteralExpression(node))throw Error(`${file}: expected static GAME_DEFINITIONS object`);return new Map(node.properties.map(property=>{if(!ts.isPropertyAssignment(property))throw Error(`${file}: resource declarations must use explicit properties`);return [property.name.text,property.initializer];}));}
function literal(node,label){node=unwrap(node);if(!node||!ts.isStringLiteral(node)||!node.text)throw Error(`${file}: ${label} must be a nonempty string literal`);return node.text;}
return [...properties(definition)].map(([registration,node])=>{
const fields=properties(node),resources=properties(fields.get('resources'));
const key=literal(fields.get('key'),'key');
if(key!==registration)throw Error(`${file}: registration ${registration} differs from key ${key}`);
return {key,createPageClass:literal(fields.get('createPageClass'),'createPageClass'),roomViewClass:literal(fields.get('roomViewClass'),'roomViewClass'),resources:Object.fromEntries(['createRoom','room','roomScene'].map(field=>[field,literal(resources.get(field),field)]))};
});
}
/** Validate declarations and every serialized game composition, without modifying Cocos assets. */
export function validateRoomAssets(inventory,definitions){
const errors=[];
const assetsByPath=new Map(inventory.assets.map(asset=>[asset.path,asset]));
const assetsByUuid=new Map(inventory.assets.map(asset=>[asset.uuid,asset]));
const scriptsByType=new Map();
for(const script of inventory.scripts){for(const type of [script.uuid,compressUuid(script.uuid),compressUuid(script.uuid,2)])scriptsByType.set(type,script);}
const scriptsByName=new Map(inventory.scripts.map(script=>[script.name,script]));
const byGame=new Map(definitions.map(definition=>[definition.key,definition]));
if(byGame.size!==definitions.length)errors.push('Duplicate game definition keys');
const roomContracts=new Set(definitions.map(definition=>definition.roomViewClass));
const isRoom=script=>script.contracts.some(contract=>roomContracts.has(contract));
for(const definition of definitions){
for(const field of ['createRoom','room','roomScene']){
const path=definition.resources[field],asset=assetsByPath.get(path);
if(!asset){errors.push(`${definition.key}.${field}: unresolved declared resource ${path}`);continue;}
if(!path.endsWith(field==='roomScene'?'.scene':'.prefab'))errors.push(`${definition.key}.${field}: wrong resource kind ${path}`);
if(field==='roomScene')continue;
const contract=field==='createRoom'?'CreateRoomPage':'room view';
const expected=field==='createRoom'?definition.createPageClass:definition.roomViewClass;
if(!scriptsByName.has(expected))errors.push(`${path}: unresolved declared component ${expected}`);
for(const object of asset.objects){if(typeof object.__type__==='string'&&!object.__type__.startsWith('cc.')&&!scriptsByType.has(object.__type__))errors.push(`${path}: unresolved script ${object.__type__}`);}
const components=asset.objects.filter(object=>{
const script=scriptsByType.get(object.__type__);
return script&&(field==='createRoom'?script.contracts.includes('CreateRoomPage'):isRoom(script));
});
if(components.length!==1)errors.push(`${path}: expected exactly one ${contract}, found ${components.length}`);
else {
const component=components[0],actual=scriptsByType.get(component.__type__).name;
if(actual!==expected)errors.push(`${path}: expected ${expected}, found ${actual} for ${definition.key}`);
const rootId=asset.objects[0]?.data?.__id__;
if(component.node?.__id__!==rootId)errors.push(`${path}: ${actual} must be attached to prefab root`);
if(!asset.objects[rootId]?._components?.some(reference=>reference.__id__===asset.objects.indexOf(component)))errors.push(`${path}: ${actual} is not attached in the root component list`);
}
}
}
for(const asset of inventory.assets){
for(const object of asset.objects){
const script=scriptsByType.get(object.__type__);
if(!script?.contracts.includes('SubgameAssets'))continue;
const definition=byGame.get(object.gameKey);
if(!definition){errors.push(`${asset.path}: unknown game ${object.gameKey}`);continue;}
for(const field of ['createRoom','room'])if(object[field])errors.push(`${asset.path}: ${definition.key}.${field} must not be a serialized private resource`);
for(const field of ['roomScene']){
const referenced=assetsByUuid.get(object[field]?.__uuid__);
if(!referenced)errors.push(`${asset.path}: ${definition.key}.${field} unresolved resource ${object[field]?.__uuid__}`);
else if(referenced.path!==definition.resources[field])errors.push(`${asset.path}: ${definition.key}.${field} expected ${definition.resources[field]}, found ${referenced.path}`);
}
}
}
return errors;
}