From 21d5ad397fde2d3de78372ce9b2ac80863092d5e Mon Sep 17 00:00:00 2001 From: Wickedness Date: Sun, 5 Jul 2026 08:02:49 +0900 Subject: [PATCH] Verify battle map asset references --- package.json | 1 + scripts/verify-battle-map-asset-data.mjs | 208 +++++++++++++++++++++++ scripts/verify-release-candidate.mjs | 1 + 3 files changed, 210 insertions(+) create mode 100644 scripts/verify-battle-map-asset-data.mjs diff --git a/package.json b/package.json index 054080a..3556ff3 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "verify:story-assets": "node scripts/verify-story-asset-data.mjs", "verify:visual-assets": "node scripts/verify-visual-asset-data.mjs", "verify:unit-sprites": "node scripts/verify-unit-sprite-asset-data.mjs", + "verify:battle-map-assets": "node scripts/verify-battle-map-asset-data.mjs", "verify:audio-assets": "node scripts/verify-audio-asset-data.mjs", "verify:flow": "node scripts/verify-flow.mjs", "verify:save-flow": "node scripts/verify-save-retry-flow.mjs", diff --git a/scripts/verify-battle-map-asset-data.mjs b/scripts/verify-battle-map-asset-data.mjs new file mode 100644 index 0000000..009973d --- /dev/null +++ b/scripts/verify-battle-map-asset-data.mjs @@ -0,0 +1,208 @@ +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 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); + 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) { + Object.entries(staticBattleMapAssets) + .sort(([left], [right]) => left.localeCompare(right)) + .forEach(([mapKey, path]) => { + validateMapFile(errors, mapKey, path); + + 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('