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