import { readdirSync, readFileSync, statSync } from 'node:fs'; import { join, sep } from 'node:path'; import { createServer } from 'vite'; const battleMapDir = join('src', 'assets', 'images', 'battle'); const battleMapAssetsSourcePath = join('src', 'game', 'data', 'battleMapAssets.ts'); const minimumRasterMapWidth = 1024; const minimumRasterMapHeight = 768; const maximumRasterMapEdge = 8192; const server = await createServer({ logLevel: 'error', server: { middlewareMode: true }, appType: 'custom' }); try { const { battleScenarios } = await server.ssrLoadModule('/src/game/data/battles.ts'); const { battleMapAssets } = await server.ssrLoadModule('/src/game/data/battleMapAssets.ts'); const errors = []; const staticBattleMapAssets = parseBattleMapAssetSource(readFileSync(battleMapAssetsSourcePath, 'utf8'), errors); validateRuntimeMatchesSource(errors, battleMapAssets, staticBattleMapAssets); validateScenarioMapKeys(errors, battleScenarios, battleMapAssets); validateMapAssetFiles(errors, battleMapAssets, staticBattleMapAssets, battleScenarios); validateBattleDirectoryCoverage(errors, staticBattleMapAssets); if (errors.length) { console.error(`Battle map asset 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 ${Object.keys(battleScenarios).length} scenario map keys and ` + `${Object.keys(battleMapAssets).length} battle map assets.` ); } } finally { await server.close(); } function validateScenarioMapKeys(errors, battleScenarios, battleMapAssets) { const scenarioMapKeys = new Set(); Object.entries(battleScenarios) .sort(([left], [right]) => left.localeCompare(right)) .forEach(([scenarioId, scenario]) => { assertNonEmptyString(errors, scenario.mapTextureKey, `${scenarioId}.mapTextureKey`); scenarioMapKeys.add(scenario.mapTextureKey); if (!battleMapAssets[scenario.mapTextureKey]) { errors.push(`${scenarioId}: mapTextureKey "${scenario.mapTextureKey}" is not registered in battleMapAssets`); } }); Object.keys(battleMapAssets) .sort() .forEach((mapKey) => { if (!scenarioMapKeys.has(mapKey)) { errors.push(`battleMapAssets contains unused map key "${mapKey}"`); } }); } function validateRuntimeMatchesSource(errors, battleMapAssets, staticBattleMapAssets) { Object.keys(battleMapAssets) .sort() .forEach((mapKey) => { if (!staticBattleMapAssets[mapKey]) { errors.push(`runtime battleMapAssets contains "${mapKey}" but the source manifest has no matching import`); } }); Object.keys(staticBattleMapAssets) .sort() .forEach((mapKey) => { if (!battleMapAssets[mapKey]) { errors.push(`source battleMapAssets contains "${mapKey}" but runtime export is missing it`); } }); } function validateMapAssetFiles(errors, battleMapAssets, staticBattleMapAssets, battleScenarios) { const scenariosByMapKey = new Map( Object.values(battleScenarios).map((scenario) => [scenario.mapTextureKey, scenario]) ); Object.entries(staticBattleMapAssets) .sort(([left], [right]) => left.localeCompare(right)) .forEach(([mapKey, path]) => { const dimensions = validateMapFile(errors, mapKey, path); const scenario = scenariosByMapKey.get(mapKey); if (dimensions && scenario) { const widthPerTile = dimensions.width / scenario.map.width; const heightPerTile = dimensions.height / scenario.map.height; if (Math.abs(widthPerTile - heightPerTile) > 0.01) { errors.push( `${mapKey}: "${path}" is ${dimensions.width}x${dimensions.height} for a ${scenario.map.width}x${scenario.map.height} grid; expected equal source pixels per tile on both axes` ); } } const url = battleMapAssets[mapKey]; assertNonEmptyString(errors, url, `${mapKey} runtime asset URL`); if (typeof url !== 'string') { return; } if (url.startsWith('data:')) { if (!path.toLowerCase().endsWith('.svg') || !url.startsWith('data:image/svg+xml')) { errors.push(`${mapKey}: runtime data URL does not match source asset type for "${path}"`); } return; } const runtimePath = assetUrlToPath(url); if (!runtimePath) { errors.push(`${mapKey}: URL "${url}" does not point at src/assets/images/battle`); } else if (runtimePath !== path) { errors.push(`${mapKey}: runtime URL points at "${runtimePath}" but source imports "${path}"`); } }); } function validateBattleDirectoryCoverage(errors, staticBattleMapAssets) { const registeredPaths = new Set(Object.values(staticBattleMapAssets)); readdirSync(battleMapDir, { withFileTypes: true }) .filter((entry) => entry.isFile() && /\.(svg|webp)$/i.test(entry.name)) .map((entry) => join(battleMapDir, entry.name)) .sort() .forEach((path) => { if (!registeredPaths.has(path)) { errors.push(`battle map file "${path}" exists but is not registered in battleMapAssets`); } }); } function parseBattleMapAssetSource(source, errors) { const importsByName = new Map(); const importPattern = /import\s+(\w+)\s+from\s+['"]\.\.\/\.\.\/assets\/images\/battle\/([^'"]+)['"];/g; for (const match of source.matchAll(importPattern)) { importsByName.set(match[1], join(battleMapDir, match[2])); } const manifestMatch = source.match(/export\s+const\s+battleMapAssets:\s*Record\s*=\s*{([\s\S]*?)\n};/); if (!manifestMatch) { errors.push(`${battleMapAssetsSourcePath}: cannot find battleMapAssets manifest object`); return {}; } const assets = {}; const entryPattern = /['"]([^'"]+)['"]\s*:\s*(\w+)/g; for (const match of manifestMatch[1].matchAll(entryPattern)) { const [, mapKey, importName] = match; const path = importsByName.get(importName); if (!path) { errors.push(`${mapKey}: source manifest references "${importName}" without a matching battle map import`); continue; } assets[mapKey] = path; } importsByName.forEach((path, importName) => { if (!Object.values(assets).includes(path)) { errors.push(`${battleMapAssetsSourcePath}: import "${importName}" from "${path}" is not used in battleMapAssets`); } }); return assets; } function validateMapFile(errors, context, path) { let stats; let bytes; try { stats = statSync(path); bytes = readFileSync(path); } catch (error) { errors.push(`${context}: cannot read "${path}": ${error.message}`); return; } if (stats.size < 1024) { errors.push(`${context}: "${path}" is too small to be a complete battle map asset`); } if (path.toLowerCase().endsWith('.svg')) { const text = bytes.toString('utf8', 0, Math.min(bytes.length, 2048)); if (!text.includes(' maximumRasterMapEdge || dimensions.height > maximumRasterMapEdge) { errors.push( `${context}: "${path}" is ${dimensions.width}x${dimensions.height}, exceeding the ${maximumRasterMapEdge}px WebGL texture budget` ); } return dimensions; } errors.push(`${context}: "${path}" uses an unsupported map asset extension`); return undefined; } function readWebpDimensions(bytes) { let offset = 12; while (offset + 8 <= bytes.length) { const chunkType = bytes.subarray(offset, offset + 4).toString('ascii'); const chunkSize = bytes.readUInt32LE(offset + 4); const dataOffset = offset + 8; if (dataOffset + chunkSize > bytes.length) { return undefined; } if (chunkType === 'VP8X' && chunkSize >= 10) { return { width: readUInt24LE(bytes, dataOffset + 4) + 1, height: readUInt24LE(bytes, dataOffset + 7) + 1 }; } if (chunkType === 'VP8L' && chunkSize >= 5 && bytes[dataOffset] === 0x2f) { const bits = bytes[dataOffset + 1] | (bytes[dataOffset + 2] << 8) | (bytes[dataOffset + 3] << 16) | (bytes[dataOffset + 4] << 24); return { width: (bits & 0x3fff) + 1, height: ((bits >>> 14) & 0x3fff) + 1 }; } if ( chunkType === 'VP8 ' && chunkSize >= 10 && bytes[dataOffset + 3] === 0x9d && bytes[dataOffset + 4] === 0x01 && bytes[dataOffset + 5] === 0x2a ) { return { width: bytes.readUInt16LE(dataOffset + 6) & 0x3fff, height: bytes.readUInt16LE(dataOffset + 8) & 0x3fff }; } offset = dataOffset + chunkSize + (chunkSize % 2); } return undefined; } function readUInt24LE(bytes, offset) { return bytes[offset] | (bytes[offset + 1] << 8) | (bytes[offset + 2] << 16); } function assetUrlToPath(url) { const marker = 'src/assets/images/battle/'; const index = url.indexOf(marker); if (index < 0) { return undefined; } return url.slice(index).replaceAll('/', sep); } function assertNonEmptyString(errors, value, context) { if (typeof value !== 'string' || value.trim().length === 0) { errors.push(`${context} must be a non-empty string`); } }