import { readFileSync, readdirSync, statSync } from 'node:fs'; import { createServer } from 'vite'; const server = await createServer({ logLevel: 'error', server: { middlewareMode: true }, appType: 'custom' }); const minimumReadableIconFrameSize = 96; const expectedV2IconFrames = { attack: 4, hit: 16, critical: 17, counter: 19, fire: 24, bean: 27, salve: 28, wine: 29, confusion: 30, benevolentCommand: 31, azureDragonStrike: 32, changbanRoar: 33, singleRiderRescue: 34, eastWindFire: 35, hundredPacePierce: 36, westernCavalryCharge: 37, qilinStratagem: 38, burn: 39 }; const expectedDedicatedUsableIcons = { benevolentCommand: 'benevolentCommand', azureDragonStrike: 'azureDragonStrike', changbanRoar: 'changbanRoar', singleRiderRescue: 'singleRiderRescue', eastWindFire: 'eastWindFire', hundredPacePierce: 'hundredPacePierce', westernCavalryCharge: 'westernCavalryCharge', qilinStratagem: 'qilinStratagem' }; try { const { battleUiIconManifest, battleUiIconTextureForSize } = await server.ssrLoadModule('/src/game/data/battleUiIcons.ts'); const { usableCatalog } = await server.ssrLoadModule('/src/game/data/battleUsables.ts'); const { itemCatalog, equipmentSlots } = await server.ssrLoadModule('/src/game/data/battleItems.ts'); const errors = []; validateBattleUiIconAtlas(errors, battleUiIconManifest); validateBattleUiIconTextureRouting(errors, battleUiIconManifest, battleUiIconTextureForSize); validateBattleUsableIcons(errors, usableCatalog, battleUiIconManifest.frames); 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'); assertNonEmptyString(errors, manifest.microSource, 'battleUiIconManifest.microSource'); assertNonEmptyString(errors, manifest.microTextureKey, 'battleUiIconManifest.microTextureKey'); assertPositiveInteger(errors, manifest.microFrameWidth, 'battleUiIconManifest.microFrameWidth'); assertPositiveInteger(errors, manifest.microFrameHeight, 'battleUiIconManifest.microFrameHeight'); assertPositiveInteger(errors, manifest.microMaxDisplaySize, 'battleUiIconManifest.microMaxDisplaySize'); 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}`); } let microDimensions; try { microDimensions = readPngDimensions(manifest.microSource); } catch (error) { errors.push(`battle UI micro icon atlas cannot be read at "${manifest.microSource}": ${error.message}`); return; } const expectedMicroWidth = manifest.columns * manifest.microFrameWidth; const expectedMicroHeight = manifest.rows * manifest.microFrameHeight; if (microDimensions.width !== expectedMicroWidth || microDimensions.height !== expectedMicroHeight) { errors.push( `battle UI micro icon atlas is ${microDimensions.width}x${microDimensions.height}, expected ${expectedMicroWidth}x${expectedMicroHeight}` ); } if (microDimensions.bitDepth !== 8 || microDimensions.colorType !== 6) { errors.push( `battle UI micro icon atlas must be 8-bit RGBA PNG color type 6, got bitDepth=${microDimensions.bitDepth} colorType=${microDimensions.colorType}` ); } const frameLimit = manifest.columns * manifest.rows; if (frameLimit !== 40) { errors.push(`battle UI icon atlas must contain 40 frames, got ${frameLimit}`); } 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}`); } }); const coveredFrames = new Set(Object.values(manifest.frames)); for (let frame = 0; frame < frameLimit; frame += 1) { if (!coveredFrames.has(frame)) { errors.push(`battle UI icon atlas frame ${frame} has no manifest key`); } } validateBattleUiIconSources(errors, manifest); } function validateBattleUiIconSources(errors, manifest) { const sources = manifest.v2Sources; if (!sources || typeof sources !== 'object' || Array.isArray(sources)) { errors.push('battleUiIconManifest.v2Sources must be an object'); return; } const entries = Object.entries(sources); if (entries.length !== 18) { errors.push(`battleUiIconManifest.v2Sources must contain 18 source icons, got ${entries.length}`); } const sourceFrames = new Set(); entries.forEach(([key, source]) => { const context = `battleUiIconManifest.v2Sources.${key}`; if (expectedV2IconFrames[key] !== source.frame) { errors.push(`${context}.frame must remain ${expectedV2IconFrames[key]}, got ${source.frame}`); } if (manifest.frames[key] !== source.frame) { errors.push(`${context}.frame ${source.frame} does not match battleUiIconManifest.frames.${key} ${manifest.frames[key]}`); } if (sourceFrames.has(source.frame)) { errors.push(`${context}.frame duplicates frame ${source.frame}`); } sourceFrames.add(source.frame); assertNonEmptyString(errors, source.source, `${context}.source`); let dimensions; try { dimensions = readPngDimensions(source.source); } catch (error) { errors.push(`${context}.source cannot be read at "${source.source}": ${error.message}`); return; } if (dimensions.width !== dimensions.height || dimensions.width < 512) { errors.push(`${context}.source must be a square master of at least 512px, got ${dimensions.width}x${dimensions.height}`); } if (dimensions.bitDepth !== 8 || dimensions.colorType !== 6) { errors.push(`${context}.source must be an 8-bit RGBA PNG, got bitDepth=${dimensions.bitDepth} colorType=${dimensions.colorType}`); } }); } function validateBattleUiIconTextureRouting(errors, manifest, textureForSize) { if (textureForSize(14) !== manifest.microTextureKey || textureForSize(manifest.microMaxDisplaySize) !== manifest.microTextureKey) { errors.push('battle UI icons at or below microMaxDisplaySize must use the micro atlas'); } if (textureForSize(manifest.microMaxDisplaySize + 1) !== manifest.textureKey || textureForSize(70) !== manifest.textureKey) { errors.push('battle UI icons above microMaxDisplaySize must use the full atlas'); } } function validateBattleUsableIcons(errors, usableCatalog, frames) { Object.entries(usableCatalog).forEach(([id, usable]) => { const context = `usableCatalog.${id}.iconKey`; assertNonEmptyString(errors, usable.iconKey, context); if (!(usable.iconKey in frames)) { errors.push(`${context} references unknown battle UI icon "${usable.iconKey}"`); } if (usable.command === 'item' && usable.iconKey !== id) { errors.push(`${context} must use the item's dedicated icon key "${id}", got "${usable.iconKey}"`); } }); const dedicatedIconKeys = []; Object.entries(expectedDedicatedUsableIcons).forEach(([usableId, expectedIconKey]) => { const usable = usableCatalog[usableId]; if (!usable) { errors.push(`usableCatalog.${usableId} must exist for its signature icon`); return; } if (usable.iconKey !== expectedIconKey) { errors.push(`usableCatalog.${usableId}.iconKey must use dedicated icon "${expectedIconKey}", got "${usable.iconKey}"`); } dedicatedIconKeys.push(usable.iconKey); }); if (new Set(dedicatedIconKeys).size !== dedicatedIconKeys.length) { errors.push('signature usable icon keys must not be duplicated'); } const statusIconKeys = ['confusion', 'burn']; const reservedGenericIconKeys = ['shout', 'fire']; const premiumIconKeys = [...dedicatedIconKeys, ...statusIconKeys]; if (new Set(premiumIconKeys).size !== premiumIconKeys.length) { errors.push('signature and status icon keys must be unique'); } [...statusIconKeys, ...reservedGenericIconKeys].forEach((iconKey) => { if (!(iconKey in frames)) { errors.push(`battle UI icon manifest is missing dedicated status/generic icon "${iconKey}"`); } }); if (frames.confusion === frames.shout) { errors.push('confusion status icon frame must be distinct from shout'); } if (frames.burn === frames.fire) { errors.push('burn status icon frame must be distinct from fire tactic'); } const premiumFrames = premiumIconKeys.map((iconKey) => frames[iconKey]).filter(Number.isInteger); if (new Set(premiumFrames).size !== premiumFrames.length) { errors.push('signature and status icon frames must not be duplicated'); } } 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 campScene = readFileSync('src/game/scenes/CampScene.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`); } }); validateEquipmentIconDirectory(errors, catalogItemIds, 'src/assets/images/ui/equipment-icons/128', 128); validateEquipmentIconDirectory(errors, catalogItemIds, 'src/assets/images/ui/equipment-icons/32', 32); const bootPreloadChecks = [ { pattern: /import\.meta\.glob\('\.\.\/\.\.\/assets\/images\/ui\/equipment-icons\/128\/\*\.png'/, message: 'BootScene must import the 128px equipment icon glob' }, { pattern: /import\.meta\.glob\('\.\.\/\.\.\/assets\/images\/ui\/equipment-icons\/32\/\*\.png'/, message: 'BootScene must import the 32px equipment icon glob' }, { pattern: /this\.preloadEquipmentIcons\(equipmentIcon128Modules,\s*''\)/, message: 'BootScene must preload 128px equipment icons as item-${id}' }, { pattern: /this\.preloadEquipmentIcons\(equipmentIcon32Modules,\s*'-micro'\)/, message: 'BootScene must preload 32px equipment icons as item-${id}-micro' }, { pattern: /this\.load\.image\(`item-\$\{itemId\}\$\{suffix\}`,\s*url\)/, message: 'BootScene equipment icon loader must preserve item-${id}${suffix} texture keys' } ]; bootPreloadChecks.forEach(({ pattern, message }) => { if (!pattern.test(bootScene)) { errors.push(message); } }); const smallCampEquipmentIcons = [ ...campScene.matchAll( /const icon = [^\n]*this\.add\.image\([^\n]*, `item-\$\{[^`]+?\}(-micro)?`\)\);[\s\S]{0,180}?icon\.setDisplaySize\(this\.campUiLength\((\d+)\)/g ) ]; smallCampEquipmentIcons.forEach((match) => { const suffix = match[1] ?? ''; const displaySize = Number(match[2]); if (displaySize <= 22 && suffix !== '-micro') { errors.push(`CampScene equipment icon displayed at ${displaySize}px must use the -micro texture`); } }); if (smallCampEquipmentIcons.filter((match) => Number(match[2]) <= 22 && match[1] === '-micro').length < 3) { errors.push('CampScene must route its main 22px-or-smaller equipment slots through -micro textures'); } if ( !/renderSortieEquipmentIcons\([^)]*frameSize = 18, iconSize = 14[^)]*\)[\s\S]{0,900}`item-\$\{item\.id\}-micro`/.test( campScene ) ) { errors.push('CampScene sortie equipment strip must use item-${id}-micro at its 14px default size'); } } function validateEquipmentIconDirectory(errors, catalogItemIds, directory, expectedSize) { let filenames; try { filenames = readdirSync(directory); } catch (error) { errors.push(`equipment icon directory cannot be read at "${directory}": ${error.message}`); return; } const pngFilenames = filenames.filter((filename) => filename.endsWith('.png')); filenames .filter((filename) => !filename.endsWith('.png')) .forEach((filename) => errors.push(`${directory} contains unexpected non-PNG asset "${filename}"`)); const assetIds = new Set(pngFilenames.map((filename) => filename.slice(0, -4))); Array.from(catalogItemIds) .sort() .forEach((itemId) => { if (!assetIds.has(itemId)) { errors.push(`${directory} is missing ${itemId}.png`); return; } const source = `${directory}/${itemId}.png`; let dimensions; try { dimensions = readPngDimensions(source); } catch (error) { errors.push(`equipment icon cannot be read at "${source}": ${error.message}`); return; } if (dimensions.width !== expectedSize || dimensions.height !== expectedSize) { errors.push(`${source} must be exactly ${expectedSize}x${expectedSize}, got ${dimensions.width}x${dimensions.height}`); } if (dimensions.bitDepth !== 8 || dimensions.colorType !== 6) { errors.push(`${source} must be an 8-bit RGBA PNG, got bitDepth=${dimensions.bitDepth} colorType=${dimensions.colorType}`); } }); Array.from(assetIds) .sort() .forEach((itemId) => { if (!catalogItemIds.has(itemId)) { errors.push(`${directory} contains extra icon ${itemId}.png with no matching itemCatalog entry`); } }); } 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`); } }