Files
heros_web/scripts/verify-visual-asset-data.mjs

147 lines
5.4 KiB
JavaScript

import { readFileSync, statSync } from 'node:fs';
import { createServer } from 'vite';
const server = await createServer({
logLevel: 'error',
server: { middlewareMode: true },
appType: 'custom'
});
try {
const { battleUiIconManifest } = await server.ssrLoadModule('/src/game/data/battleUiIcons.ts');
const { itemCatalog, equipmentSlots } = await server.ssrLoadModule('/src/game/data/battleItems.ts');
const errors = [];
validateBattleUiIconAtlas(errors, battleUiIconManifest);
validateItemCatalog(errors, itemCatalog, equipmentSlots);
validateGeneratedEquipmentIcons(errors, itemCatalog);
if (errors.length) {
console.error(`Visual asset data verification failed with ${errors.length} issue(s):`);
errors.slice(0, 100).forEach((error) => console.error(`- ${error}`));
if (errors.length > 100) {
console.error(`- ...and ${errors.length - 100} more`);
}
process.exitCode = 1;
} else {
console.log(
`Verified battle UI atlas ${battleUiIconManifest.columns}x${battleUiIconManifest.rows}, ` +
`${Object.keys(battleUiIconManifest.frames).length} icon keys, and ${Object.keys(itemCatalog).length} generated equipment icons.`
);
}
} finally {
await server.close();
}
function validateBattleUiIconAtlas(errors, manifest) {
assertNonEmptyString(errors, manifest.source, 'battleUiIconManifest.source');
assertNonEmptyString(errors, manifest.textureKey, 'battleUiIconManifest.textureKey');
assertPositiveInteger(errors, manifest.frameWidth, 'battleUiIconManifest.frameWidth');
assertPositiveInteger(errors, manifest.frameHeight, 'battleUiIconManifest.frameHeight');
assertPositiveInteger(errors, manifest.columns, 'battleUiIconManifest.columns');
assertPositiveInteger(errors, manifest.rows, 'battleUiIconManifest.rows');
let dimensions;
try {
dimensions = readPngDimensions(manifest.source);
} catch (error) {
errors.push(`battle UI icon atlas cannot be read at "${manifest.source}": ${error.message}`);
return;
}
const expectedWidth = manifest.columns * manifest.frameWidth;
const expectedHeight = manifest.rows * manifest.frameHeight;
if (dimensions.width !== expectedWidth || dimensions.height !== expectedHeight) {
errors.push(`battle UI icon atlas is ${dimensions.width}x${dimensions.height}, expected ${expectedWidth}x${expectedHeight}`);
}
const frameLimit = manifest.columns * manifest.rows;
Object.entries(manifest.frames).forEach(([key, frame]) => {
assertNonEmptyString(errors, key, 'battle UI icon key');
if (!Number.isInteger(frame) || frame < 0 || frame >= frameLimit) {
errors.push(`battle UI icon "${key}" frame ${frame} is outside atlas frame range 0..${frameLimit - 1}`);
}
});
}
function validateItemCatalog(errors, itemCatalog, equipmentSlots) {
const validSlots = new Set(equipmentSlots);
const validRanks = new Set(['common', 'treasure']);
Object.entries(itemCatalog).forEach(([id, item]) => {
const context = `itemCatalog.${id}`;
if (item.id !== id) {
errors.push(`${context}: item id does not match catalog key`);
}
assertNonEmptyString(errors, item.name, `${context}.name`);
assertNonEmptyString(errors, item.description, `${context}.description`);
if (!validSlots.has(item.slot)) {
errors.push(`${context}: unknown equipment slot "${item.slot}"`);
}
if (!validRanks.has(item.rank)) {
errors.push(`${context}: unknown equipment rank "${item.rank}"`);
}
if (!Array.isArray(item.effects) || item.effects.length === 0) {
errors.push(`${context}: effects must not be empty`);
} else {
item.effects.forEach((effect, index) => assertNonEmptyString(errors, effect, `${context}.effects[${index}]`));
}
['attackBonus', 'defenseBonus', 'strategyBonus'].forEach((field) => {
if (item[field] !== undefined && (!Number.isInteger(item[field]) || item[field] < 0)) {
errors.push(`${context}.${field} must be a non-negative integer`);
}
});
});
}
function validateGeneratedEquipmentIcons(errors, itemCatalog) {
const bootScene = readFileSync('src/game/scenes/BootScene.ts', 'utf8');
const generatedItemIds = new Set([...bootScene.matchAll(/createItemIcon\('item-([^']+)'/g)].map((match) => match[1]));
const catalogItemIds = new Set(Object.keys(itemCatalog));
Array.from(catalogItemIds)
.sort()
.forEach((itemId) => {
if (!generatedItemIds.has(itemId)) {
errors.push(`BootScene does not generate an equipment icon texture for item-${itemId}`);
}
});
Array.from(generatedItemIds)
.sort()
.forEach((itemId) => {
if (!catalogItemIds.has(itemId)) {
errors.push(`BootScene generates item-${itemId}, but itemCatalog has no matching item`);
}
});
}
function readPngDimensions(path) {
const stats = statSync(path);
if (stats.size < 24) {
throw new Error('file is too small to be a PNG');
}
const bytes = readFileSync(path);
const signature = '89504e470d0a1a0a';
if (bytes.subarray(0, 8).toString('hex') !== signature) {
throw new Error('file does not have a PNG signature');
}
return {
width: bytes.readUInt32BE(16),
height: bytes.readUInt32BE(20)
};
}
function assertPositiveInteger(errors, value, context) {
if (!Number.isInteger(value) || value <= 0) {
errors.push(`${context} must be a positive integer`);
}
}
function assertNonEmptyString(errors, value, context) {
if (typeof value !== 'string' || value.trim().length === 0) {
errors.push(`${context} must be a non-empty string`);
}
}