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.
129 lines
8.5 KiB
JavaScript
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;
|
|
}
|