import { readFileSync, statSync } from 'node:fs'; import { createServer } from 'vite'; const server = await createServer({ logLevel: 'error', server: { middlewareMode: true }, appType: 'custom' }); const minimumReadableIconFrameSize = 96; 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'); assertIntegerAtLeast( errors, manifest.frameWidth, minimumReadableIconFrameSize, 'battleUiIconManifest.frameWidth for desktop readability' ); assertIntegerAtLeast( errors, manifest.frameHeight, minimumReadableIconFrameSize, 'battleUiIconManifest.frameHeight for desktop readability' ); 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}`); } if (dimensions.bitDepth !== 8 || dimensions.colorType !== 6) { errors.push(`battle UI icon atlas must be 8-bit RGBA PNG color type 6, got bitDepth=${dimensions.bitDepth} colorType=${dimensions.colorType}`); } 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), bitDepth: bytes[24], colorType: bytes[25] }; } function assertPositiveInteger(errors, value, context) { if (!Number.isInteger(value) || value <= 0) { errors.push(`${context} must be a positive integer`); } } function assertIntegerAtLeast(errors, value, minimum, context) { if (!Number.isInteger(value) || value < minimum) { errors.push(`${context} must be at least ${minimum}`); } } function assertNonEmptyString(errors, value, context) { if (typeof value !== 'string' || value.trim().length === 0) { errors.push(`${context} must be a non-empty string`); } }