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:
@@ -5,7 +5,9 @@ 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'),
|
||||
gamesDir: resolve(projectDir, 'YouleNexus/assets/games'),
|
||||
publicDirs: [resolve(projectDir, 'YouleNexus/assets/scripts')],
|
||||
appDir: resolve(projectDir, 'YouleNexus/assets/app'),
|
||||
tsconfigPath: resolve(projectDir, 'tsconfig.framework.json'),
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
#!/usr/bin/env node
|
||||
import {fileURLToPath} from 'node:url';
|
||||
import {resolve} from 'node:path';
|
||||
import {loadRoomAssetInventory,loadRoomAssetDefinitions,validateRoomAssets} from './lib/room-assets.mjs';
|
||||
|
||||
try {
|
||||
if(process.argv.length>4)throw Error('Usage: node scripts/check-room-assets.mjs [assets-directory] [game-definitions.ts]');
|
||||
const assetsDir=process.argv[2]?resolve(process.argv[2]):fileURLToPath(new URL('../YouleNexus/assets/',import.meta.url));
|
||||
const definitionFile=process.argv[3]?resolve(process.argv[3]):resolve(assetsDir,'games');
|
||||
const definitions=loadRoomAssetDefinitions(definitionFile);
|
||||
const errors=validateRoomAssets(loadRoomAssetInventory(assetsDir),definitions);
|
||||
if(errors.length){console.error(errors.join('\n'));process.exitCode=1;}
|
||||
else console.log(`Room asset composition checks passed (${definitions.length} games).`);
|
||||
} catch(error){console.error(`Room asset composition check failed: ${error.message}`);process.exitCode=1;}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createRequire} from 'node:module';
|
||||
import {mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync} from 'node:fs';
|
||||
import {tmpdir} from 'node:os';
|
||||
import {join, resolve} from 'node:path';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const corePath = '../../YouleNexus/extensions/youle-version-manifest/core.js';
|
||||
const uuid = 'ec19e470-cf36-40e8-b420-e4314e5fbf07';
|
||||
const type = 'ec19eRwzzZA6LQg5DFOX78H';
|
||||
const source = readFileSync(new URL('../../YouleNexus/assets/games/erqiwang/resources/erqiwang/version.xml', import.meta.url));
|
||||
function fixture(t, key = 'erqiwang') {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'youle-version-build-'));
|
||||
t.after(() => rmSync(dir, {recursive: true, force: true}));
|
||||
const write = (p, data) => {mkdirSync(resolve(p, '..'), {recursive: true}); writeFileSync(p, data);};
|
||||
write(join(dir, 'assets/app/composition/SubgameAssets.ts.meta'), JSON.stringify({uuid}));
|
||||
const scenePath = join(dir, 'assets/scenes/Chosen.scene');
|
||||
const scene = [{__type__: 'cc.Scene', _active: true}, {__type__: 'cc.Node', _active: true, _parent: {__id__: 0}}, {__type__: type, _enabled: true, node: {__id__: 1}, gameKey: key}];
|
||||
write(scenePath, JSON.stringify(scene));
|
||||
const xmlPath = join(dir, 'assets/games/anything/resources', key, 'version.xml');
|
||||
write(xmlPath, source);
|
||||
const bundleMetaPath = join(dir, 'assets/games/anything/resources.meta');
|
||||
write(bundleMetaPath, JSON.stringify({userData: {isBundle: true, bundleName: 'version-' + key}}));
|
||||
const output = join(dir, 'custom-output/web');
|
||||
write(join(output, 'index.html'), '<html></html>');
|
||||
const options = {startScene: 'scene-id', platform: 'web-desktop'};
|
||||
const result = {paths: {output}, settings: {launch: {launchScene: 'db://assets/scenes/Chosen.scene'}, assets: {projectBundles: ['main', 'version-' + key]}}};
|
||||
return {dir, write, scenePath, scene, xmlPath, bundleMetaPath, output, options, result,
|
||||
input: {projectPath: dir, scenePath, startScene: options.startScene}};
|
||||
}
|
||||
test('publishes the selected scene manifest byte-for-byte beside the actual index.html', t => {
|
||||
const {createSnapshot, publishSnapshot} = require(corePath);
|
||||
const f = fixture(t);
|
||||
const snapshot = createSnapshot(f.input);
|
||||
publishSnapshot(snapshot, f.options, f.result);
|
||||
assert.deepEqual(readFileSync(join(f.output, 'version.xml')), source);
|
||||
assert.equal(snapshot.gameKey, 'erqiwang');
|
||||
});
|
||||
test('selects another game from its scene without a hardcoded game registry', t => {
|
||||
const {createSnapshot, publishSnapshot} = require(corePath);
|
||||
const f = fixture(t, 'Game_Surface_3');
|
||||
const template = readFileSync(new URL('../../YouleNexus/assets/games/template/resources/Game_Surface_3/version.xml', import.meta.url));
|
||||
f.write(f.xmlPath, template);
|
||||
const snapshot = createSnapshot(f.input);
|
||||
publishSnapshot(snapshot, f.options, f.result);
|
||||
assert.deepEqual(readFileSync(join(f.output, 'version.xml')), template);
|
||||
});
|
||||
for (const problem of ['missing', 'invalid', 'duplicate', 'unselected', 'disabled', 'inactive', 'ambiguous', 'missing bundle metadata', 'disabled bundle', 'mismatched bundle name']) {
|
||||
test(`rejects ${problem} source or selection before build`, t => {
|
||||
const {createSnapshot} = require(corePath);
|
||||
const f = fixture(t);
|
||||
if (problem === 'missing') rmSync(f.xmlPath);
|
||||
if (problem === 'invalid') f.write(f.xmlPath, '<broken>');
|
||||
if (problem === 'duplicate') f.write(join(f.dir, 'assets/games/second/resources/erqiwang/version.xml'), source);
|
||||
if (problem === 'unselected') delete f.scene[2].gameKey;
|
||||
if (problem === 'disabled') f.scene[2]._enabled = false;
|
||||
if (problem === 'inactive') f.scene[1]._active = false;
|
||||
if (problem === 'ambiguous') f.scene.push({...f.scene[2]});
|
||||
if (problem === 'missing bundle metadata') rmSync(f.bundleMetaPath);
|
||||
if (problem === 'disabled bundle') f.write(f.bundleMetaPath, JSON.stringify({userData: {isBundle: false, bundleName: 'version-erqiwang'}}));
|
||||
if (problem === 'mismatched bundle name') f.write(f.bundleMetaPath, JSON.stringify({userData: {isBundle: true, bundleName: 'version-other'}}));
|
||||
f.write(f.scenePath, JSON.stringify(f.scene));
|
||||
assert.throws(() => createSnapshot(f.input));
|
||||
});
|
||||
}
|
||||
for (const problem of ['xml changed', 'scene changed', 'start scene changed', 'launch scene changed', 'missing index', 'bundle metadata changed', 'excluded version bundle', 'missing packaged bundle list']) {
|
||||
test(`rejects ${problem} after build`, t => {
|
||||
const {createSnapshot, publishSnapshot} = require(corePath);
|
||||
const f = fixture(t);
|
||||
const snapshot = createSnapshot(f.input);
|
||||
if (problem === 'xml changed') f.write(f.xmlPath, Buffer.concat([source, Buffer.from('\n')]));
|
||||
if (problem === 'scene changed') f.write(f.scenePath, '[]');
|
||||
if (problem === 'start scene changed') f.options.startScene = 'other';
|
||||
if (problem === 'launch scene changed') f.result.settings.launch.launchScene = 'db://assets/scenes/Other.scene';
|
||||
if (problem === 'missing index') rmSync(join(f.output, 'index.html'));
|
||||
if (problem === 'bundle metadata changed') f.write(f.bundleMetaPath, JSON.stringify({userData: {isBundle: true, bundleName: 'version-other'}}));
|
||||
if (problem === 'excluded version bundle') f.result.settings.assets.projectBundles = ['main', 'version-other'];
|
||||
if (problem === 'missing packaged bundle list') delete f.result.settings.assets.projectBundles;
|
||||
assert.throws(() => publishSnapshot(snapshot, f.options, f.result));
|
||||
});
|
||||
}
|
||||
test('Creator hooks use the task startScene and publish through the actual output path', async t => {
|
||||
const f = fixture(t);
|
||||
const previous = globalThis.Editor;
|
||||
t.after(() => {globalThis.Editor = previous;});
|
||||
const calls = [];
|
||||
globalThis.Editor = {
|
||||
Project: {path: f.dir},
|
||||
Message: {request: async (...args) => {calls.push(args); return f.scenePath;}},
|
||||
};
|
||||
const hooks = require('../../YouleNexus/extensions/youle-version-manifest/hooks.js');
|
||||
assert.equal(hooks.throwError, true);
|
||||
await hooks.onBeforeBuild(f.options);
|
||||
await hooks.onAfterBuild(f.options, f.result);
|
||||
assert.deepEqual(calls, [['asset-db', 'query-path', 'scene-id']]);
|
||||
assert.deepEqual(readFileSync(join(f.output, 'version.xml')), source);
|
||||
await assert.rejects(hooks.onAfterBuild(f.options, f.result), /Missing pre-build/);
|
||||
});
|
||||
test('only Web Desktop and Web Mobile register the required manifest hooks', () => {
|
||||
const {configs} = require('../../YouleNexus/extensions/youle-version-manifest/builder.js');
|
||||
assert.deepEqual(Object.keys(configs).sort(), ['web-desktop', 'web-mobile']);
|
||||
assert.equal(configs['web-desktop'].hooks, './hooks');
|
||||
assert.equal(configs['web-mobile'].hooks, './hooks');
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
/** Execute through Cocos MCP in the editor Game View. No business RPC is sent. */
|
||||
export async function verifyRoomRefactorPreview(){
|
||||
const e=await System.import('cc'),modules=Array.from(System.entries()).map(([,m])=>m);
|
||||
const get=name=>{const value=modules.find(m=>m[name])?.[name];if(!value)throw Error('Missing compiled export '+name);return value;};
|
||||
const assert=(value,message)=>{if(!value)throw Error(message);};
|
||||
const tick=()=>new Promise(resolve=>setTimeout(resolve,80));
|
||||
const load=uuid=>new Promise((resolve,reject)=>e.assetManager.loadAny(uuid,(error,asset)=>error?reject(error):resolve(asset)));
|
||||
const scene=e.director.getScene(),startup=scene.getComponentInChildren('PlatformStartup');
|
||||
assert(startup,'PlatformStartup required');startup.unsubscribe?.();startup.unsubscribe=null;startup.runtime.stop();startup.panel?.dispose();startup.panel=null;
|
||||
const canvas=scene.getChildByName('Canvas'),composition=startup.getComponent('GameComposition');assert(composition,'abstract GameComposition resolves moved script');
|
||||
for(const node of [...canvas.children])if(node.name==='Layer418_ChatPannel')node.destroy();await tick();
|
||||
const definitions={erqiwang:e.js.getClassByName('GameEntry_erqiwang').definition,Game_Surface_3:e.js.getClassByName('GameEntry_Game_Surface_3').definition},created=[];
|
||||
for(const [key,uuid]of [['erqiwang',composition.createRoom._uuid],['Game_Surface_3','584dbb85-4acc-4bea-9d9b-e0cbeb8741da']]){
|
||||
const root=e.instantiate(await load(uuid));root.parent=canvas;const page=root.getComponent('CreateRoomPage'),packets=[];
|
||||
assert(page&&page.__classname__!==null,key+' create page');
|
||||
const cases=key==='erqiwang'?32:1;
|
||||
for(let bits=0;bits<cases;bits++){
|
||||
page.open({previousRoomtype:null,submit:value=>packets.push(value),cancel(){},reportError:message=>{throw Error(message);}});
|
||||
if(key==='erqiwang')for(let group=0;group<5;group++){
|
||||
const value=(bits>>group)&1,index=group<3?group*2+value:group+3;
|
||||
page.options[index].isChecked=!!value||group<3;page.options[index].node.emit(e.Toggle.EventType.TOGGLE);
|
||||
}
|
||||
page.submitButton.node.emit(e.Button.EventType.CLICK);
|
||||
}
|
||||
assert(packets.length===cases,key+' actual submit count');
|
||||
if(key==='erqiwang'){assert(packets.every(value=>typeof value==='string'&&/^[01]{5}000000$/.test(value)),'all erqiwang sends are eleven bits');assert(new Set(packets).size===32,'all erqiwang combinations distinct');}
|
||||
else assert(Array.isArray(packets[0]),'template keeps array');
|
||||
created.push({key,submissions:packets.length,format:typeof packets[0]});page.close();root.destroy();
|
||||
}
|
||||
const Chat=get('RoomChatPanel'),cache=get('roomAvatarCache');
|
||||
const panel=new Chat(canvas,startup.roomChatPrefab,startup.chatPhrasePrefab,startup.chatHistoryPrefab,definitions.erqiwang.chat,{text(){},phrase(){},expression(){},voice(){},error:message=>{throw Error(message);},readAnnouncements(){}});
|
||||
panel.open();panel.select('History');
|
||||
const history=Array.from({length:50},(_,i)=>Object.freeze({id:i+1,kind:'text',sender:{nickname:'玩家'+i,avatar:''},text:'测试聊天 '+i}));
|
||||
const state=messages=>({app:{phase:'logged-in'},room:{kind:'inside'},roomSocial:{history:messages,announcements:[]}});
|
||||
panel.render(state(history),false,'');await tick();
|
||||
const content=panel.content('History'),nodes=[...content.children],scroll=panel.scroll('History');scroll.scrollToTop(0);await tick();const offset=scroll.getScrollOffset().y;
|
||||
const layout=content.getComponent(e.Layout),original=layout.updateLayout;let layouts=0,layoutChecks=0;layout.updateLayout=function(...args){layoutChecks++;if(this._layoutDirty||args[0])layouts++;return original.apply(this,args);};
|
||||
const drawHistory=panel.renderHistory;let renderPasses=0;panel.renderHistory=function(messages){renderPasses++;return drawHistory.call(this,messages);};
|
||||
const before=performance.now();for(let i=0;i<5;i++)panel.render(state([...history,{id:51,kind:'text',sender:{nickname:'新玩家',avatar:''},text:'追加消息'}]),false,'');await tick();
|
||||
const elapsed=performance.now()-before;
|
||||
assert(content.children.length===51,'one additional row');assert(nodes.every((node,i)=>content.children[i]===node),'all fifty existing row identities retained');assert(Math.abs(scroll.getScrollOffset().y-offset)<1,'scroll position retained while reading history');assert(renderPasses===1,'same-frame state changes must render history once');
|
||||
layout.updateLayout=original;
|
||||
const activeLeases=cache.leases;panel.dispose();await tick();assert(cache.leases===0,'avatar leases return to zero');
|
||||
return {created,chat:{beforeRows:50,afterRows:51,retainedRows:50,renderPasses,layouts,layoutChecks,scrollPreserved:true,elapsedIncludingFrameWaitMs:elapsed},leases:{beforeDispose:activeLeases,afterDispose:cache.leases},environment:{userAgent:navigator.userAgent,width:e.view.getVisibleSize().width,height:e.view.getVisibleSize().height}};
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/** Actual Cocos allocations and event ownership; no network game session or server mutation. */
|
||||
export async function verifyRoomResourceCycles(avatarUrl){
|
||||
const e=await System.import('cc'),modules=Array.from(System.entries()).map(([,m])=>m),get=name=>modules.find(m=>m[name])?.[name];
|
||||
const assert=(ok,message)=>{if(!ok)throw Error(message);},tick=()=>new Promise(r=>setTimeout(r,25));
|
||||
const canvas=e.director.getScene().getChildByName('Canvas'),startup=e.director.getScene().getComponentInChildren('PlatformStartup'),cache=get('roomAvatarCache'),Ui=get('RoomUi');
|
||||
const owners=new Ui(message=>{throw Error(message);});const nodes=[new e.Node('avatar-test-a'),new e.Node('avatar-test-b')];
|
||||
nodes.forEach(node=>{node.parent=canvas;node.addComponent(e.Sprite);owners.avatar(node,avatarUrl);});
|
||||
const lease=cache.acquire(avatarUrl),asset=await lease.value;lease.release();await tick();
|
||||
assert(nodes.every(node=>node.getComponent(e.Sprite).spriteFrame===asset.frame),'same URL shares one actual SpriteFrame');assert(cache.leases===2,'each node owns one lease');owners.dispose();nodes.forEach(node=>node.destroy());await tick();assert(cache.leases===0,'avatar owners release all leases');
|
||||
const Chat=get('RoomChatPanel'),definitions={erqiwang:e.js.getClassByName('GameEntry_erqiwang').definition,Game_Surface_3:e.js.getClassByName('GameEntry_Game_Surface_3').definition},Scope=get('RoomScope');
|
||||
const baseline=canvas.children.length;let current=null;let count=0;
|
||||
for(let i=0;i<100;i++){
|
||||
const scope=new Scope(i+1,()=>{});current=new Chat(canvas,startup.roomChatPrefab,startup.chatPhrasePrefab,startup.chatHistoryPrefab,definitions.erqiwang.chat,{text(){},phrase(){},expression(){},voice(){},error:message=>{throw Error(message);},readAnnouncements(){}});
|
||||
const owned=current;scope.own(()=>owned.dispose());
|
||||
current.open();current.select('History');current.render({app:{phase:'logged-in'},room:{kind:'inside'},roomSocial:{history:[{id:i+1,kind:'text',sender:{nickname:'cycle '+i,avatar:avatarUrl},text:'生命周期检查'}],announcements:[]}},false,'');
|
||||
await tick();const errors=scope.dispose();assert(errors.length===0,'scope release has no errors');assert(scope.resourceCount===0,'scope owns no resources after leave');current=null;await tick();count++;
|
||||
}
|
||||
assert(canvas.children.length===baseline,'100 UI room scopes return node count to baseline');assert(cache.leases===0,'100 UI room scopes return avatar leases to zero');
|
||||
return {cycles:count,nodesBefore:baseline,nodesAfter:canvas.children.length,activeAvatarLeases:cache.leases,cachedAvatarResources:cache.size,sharedSpriteFrame:true};
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/** Run with Cocos MCP in Game View; no business packets are sent. */
|
||||
export async function verifyVersionXmlPreview(){
|
||||
const cc=await System.import('cc'),modules=Array.from(System.entries()).map(([,m])=>m);
|
||||
const exported=name=>{const result=modules.find(m=>m[name])?.[name];if(!result)throw Error('Missing '+name);return result;};
|
||||
const assert=(value,message)=>{if(!value)throw Error(message);};
|
||||
const startup=cc.director.getScene().getComponentInChildren('PlatformStartup');
|
||||
const selected=startup.getComponent('SubgameAssets');
|
||||
assert(selected.versionManifest.gameid===startup.config.identity.gameid,'live game identity');
|
||||
assert(selected.versionManifest.versionCode===startup.config.identity.version,'live numeric version');
|
||||
let label;function find(n){for(const c of n.components)if(c instanceof cc.Label&&n.name==='版本显示')label=c.string;n.children.forEach(find);}find(cc.director.getScene());
|
||||
assert(label==='v'+selected.versionManifest.version,'live display version');
|
||||
const outcomes=[];
|
||||
for(const key of ['erqiwang','Game_Surface_3']){
|
||||
const node=new cc.Node('VersionContractTest');
|
||||
try{const component=node.addComponent(exported('SubgameAssets'));component.gameKey=key;
|
||||
const identity=await component.loadBuildIdentity();assert(identity.gameid===component.versionManifest.gameid,'selected XML identity');
|
||||
outcomes.push({key,gameid:identity.gameid,versionCode:identity.version,version:component.displayVersion});
|
||||
}finally{node.destroy();}
|
||||
}
|
||||
const Feedback=exported('FeedbackPanels');
|
||||
const feedback=new Feedback(cc.director.getScene().getChildByName('Canvas'),{tips:startup.tipsPrefab,loading:startup.waitPrefab,reconnect:startup.reconnectPrefab,kick:startup.kickPrefab});
|
||||
feedback.loadingNode.destroy();
|
||||
await new Promise(resolve=>setTimeout(resolve,100));
|
||||
feedback.dispose();
|
||||
const invalid=Object.create(Feedback.prototype);invalid.loadingNode=new cc.Node('InvalidLiveLoading');
|
||||
let rejected=false;try{invalid.renderLoading(false);}catch{rejected=true;}finally{invalid.loadingNode.destroy();}
|
||||
assert(rejected,'invalid live panel must still reject');
|
||||
return {label,games:outcomes,disposedScenePanel:true,invalidLivePanelRejected:true};
|
||||
}
|
||||
Reference in New Issue
Block a user