|
|
|
@@ -0,0 +1,100 @@
|
|
|
|
|
'use strict';
|
|
|
|
|
|
|
|
|
|
const fs = require('node:fs');
|
|
|
|
|
const path = require('node:path');
|
|
|
|
|
const {parseVersionManifest} = require('../../assets/app/composition/version-manifest.js');
|
|
|
|
|
|
|
|
|
|
function fail(message) {throw new Error(`[youle-version-manifest] ${message}`);}
|
|
|
|
|
|
|
|
|
|
// Creator scene scripts use UUIDs compressed to 23 characters (first five hex digits unchanged).
|
|
|
|
|
function scriptType(uuid) {
|
|
|
|
|
if (typeof uuid !== 'string' || !/^[\da-f]{8}(-[\da-f]{4}){3}-[\da-f]{12}$/i.test(uuid)) fail('Invalid SubgameAssets script UUID');
|
|
|
|
|
const hex = uuid.replace(/-/g, '');
|
|
|
|
|
const base64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
|
|
|
|
let compressed = hex.slice(0, 5);
|
|
|
|
|
for (let i = 5; i < hex.length; i += 3) {
|
|
|
|
|
const bits = Number.parseInt(hex.slice(i, i + 3), 16);
|
|
|
|
|
compressed += base64[bits >> 6] + base64[bits & 63];
|
|
|
|
|
}
|
|
|
|
|
return compressed;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function gameKeyFromScene(bytes, uuid) {
|
|
|
|
|
const scene = JSON.parse(bytes.toString('utf8'));
|
|
|
|
|
if (!Array.isArray(scene)) fail('Startup scene must contain serialized scene objects');
|
|
|
|
|
const type = scriptType(uuid);
|
|
|
|
|
const selectors = scene.filter(item => item && (item.__type__ === type || item.__type__ === uuid));
|
|
|
|
|
if (selectors.length !== 1) fail('Startup scene must contain exactly one SubgameAssets selector');
|
|
|
|
|
const selector = selectors[0];
|
|
|
|
|
if (selector._enabled !== true) fail('Startup SubgameAssets must be enabled');
|
|
|
|
|
let node = scene[selector.node?.__id__];
|
|
|
|
|
const visited = new Set();
|
|
|
|
|
while (node && !visited.has(node)) {
|
|
|
|
|
visited.add(node);
|
|
|
|
|
if (node._active !== true) fail('Startup SubgameAssets must be on an active node hierarchy');
|
|
|
|
|
if (node.__type__ === 'cc.Scene') break;
|
|
|
|
|
node = scene[node._parent?.__id__];
|
|
|
|
|
}
|
|
|
|
|
if (!node || node.__type__ !== 'cc.Scene') fail('Startup SubgameAssets has no valid scene hierarchy');
|
|
|
|
|
if (typeof selector.gameKey !== 'string' || !/^[A-Za-z0-9_-]+$/.test(selector.gameKey)) fail('Startup SubgameAssets.gameKey must be explicitly serialized');
|
|
|
|
|
return selector.gameKey;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function manifestPath(projectPath, gameKey) {
|
|
|
|
|
const games = path.join(projectPath, 'assets/games');
|
|
|
|
|
const matches = [];
|
|
|
|
|
// The application contract is resources/<gameKey>/version. No independent game selector or registry.
|
|
|
|
|
for (const game of fs.readdirSync(games, {withFileTypes: true})) {
|
|
|
|
|
if (!game.isDirectory()) continue;
|
|
|
|
|
const candidate = path.join(games, game.name, 'resources', gameKey, 'version.xml');
|
|
|
|
|
if (fs.existsSync(candidate)) matches.push(candidate);
|
|
|
|
|
}
|
|
|
|
|
if (matches.length !== 1) fail(`Expected exactly one resources/${gameKey}/version.xml; found ${matches.length}`);
|
|
|
|
|
return matches[0];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function createSnapshot({projectPath, scenePath, startScene}) {
|
|
|
|
|
if (typeof startScene !== 'string' || !startScene) fail('Build startScene is required');
|
|
|
|
|
if (typeof scenePath !== 'string' || path.extname(scenePath) !== '.scene') fail('Build startScene did not resolve to a scene asset');
|
|
|
|
|
const relative = path.relative(path.join(projectPath, 'assets'), scenePath);
|
|
|
|
|
if (relative.startsWith('..') || path.isAbsolute(relative)) fail('Build startScene must be inside project assets');
|
|
|
|
|
const scriptMetaPath = path.join(projectPath, 'assets/app/composition/SubgameAssets.ts.meta');
|
|
|
|
|
const scriptMetaBytes = fs.readFileSync(scriptMetaPath);
|
|
|
|
|
const sceneBytes = fs.readFileSync(scenePath);
|
|
|
|
|
const gameKey = gameKeyFromScene(sceneBytes, JSON.parse(scriptMetaBytes.toString('utf8')).uuid);
|
|
|
|
|
const xmlPath = manifestPath(projectPath, gameKey);
|
|
|
|
|
const bundleMetaPath = path.dirname(path.dirname(xmlPath)) + '.meta';
|
|
|
|
|
const bundleMetaBytes = fs.readFileSync(bundleMetaPath);
|
|
|
|
|
const bundleData = JSON.parse(bundleMetaBytes.toString('utf8')).userData;
|
|
|
|
|
if (bundleData?.isBundle !== true || bundleData.bundleName !== 'version-' + gameKey) {
|
|
|
|
|
fail(`Manifest resources must be a bundle named version-${gameKey}: ${bundleMetaPath}`);
|
|
|
|
|
}
|
|
|
|
|
const xmlBytes = fs.readFileSync(xmlPath);
|
|
|
|
|
const identity = parseVersionManifest(xmlBytes.toString('utf8'));
|
|
|
|
|
return {projectPath, scenePath, startScene, sceneBytes, scriptMetaPath, scriptMetaBytes, bundleMetaPath, bundleMetaBytes,
|
|
|
|
|
sceneUrl: `db://assets/${relative.split(path.sep).join('/')}`, gameKey, xmlPath, xmlBytes, identity};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function publishSnapshot(snapshot, options, result) {
|
|
|
|
|
if (!snapshot) fail('Missing pre-build manifest snapshot');
|
|
|
|
|
if (options.startScene !== snapshot.startScene) fail('Build startScene changed during build');
|
|
|
|
|
const launchScene = result.settings?.launch?.launchScene;
|
|
|
|
|
if (launchScene !== snapshot.sceneUrl && launchScene !== snapshot.startScene) fail('Built launch scene does not match selected startup scene');
|
|
|
|
|
const projectBundles = result.settings?.assets?.projectBundles;
|
|
|
|
|
if (!Array.isArray(projectBundles) || !projectBundles.includes('version-' + snapshot.gameKey)) {
|
|
|
|
|
fail(`Built project is missing selected version-${snapshot.gameKey} bundle`);
|
|
|
|
|
}
|
|
|
|
|
for (const [file, bytes] of [[snapshot.scenePath, snapshot.sceneBytes], [snapshot.scriptMetaPath, snapshot.scriptMetaBytes], [snapshot.bundleMetaPath, snapshot.bundleMetaBytes], [snapshot.xmlPath, snapshot.xmlBytes]]) {
|
|
|
|
|
if (!fs.readFileSync(file).equals(bytes)) fail(`Source changed during build: ${file}`);
|
|
|
|
|
}
|
|
|
|
|
if (manifestPath(snapshot.projectPath, snapshot.gameKey) !== snapshot.xmlPath) fail('Manifest selection changed during build');
|
|
|
|
|
const output = result.paths?.output;
|
|
|
|
|
if (typeof output !== 'string' || !path.isAbsolute(output)) fail('Builder did not provide an absolute output directory');
|
|
|
|
|
if (!fs.statSync(path.join(output, 'index.html')).isFile()) fail('Web output index.html is missing');
|
|
|
|
|
const destination = path.join(output, 'version.xml');
|
|
|
|
|
fs.writeFileSync(destination, snapshot.xmlBytes);
|
|
|
|
|
if (!fs.readFileSync(destination).equals(snapshot.xmlBytes)) fail('Published version.xml differs from source');
|
|
|
|
|
return destination;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
module.exports = {createSnapshot, publishSnapshot};
|