Files
joywayer 35216f75e7 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.
2026-09-09 03:13:18 +08:00

129 lines
8.5 KiB
JavaScript

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;
}