import { readdirSync, readFileSync, statSync } from 'node:fs'; import { join } from 'node:path'; import { createServer } from 'vite'; const unitDir = join('src', 'assets', 'images', 'units'); const frameSize = 313; const rows = 4; const baseFramesPerDirection = 16; const actionFramesPerDirection = 36; const expectedBaseSize = { width: frameSize * baseFramesPerDirection, height: frameSize * rows }; const expectedActionSize = { width: frameSize * actionFramesPerDirection, height: frameSize * rows }; const battleScenePath = 'src/game/scenes/BattleScene.ts'; const nonTextureUnitLiterals = new Set(['unit-defeated']); const requiredDynamicBaseKeys = [ 'unit-rebel', 'unit-rebel-archer', 'unit-rebel-cavalry', 'unit-rebel-leader', 'unit-shu-infantry', 'unit-shu-spearman', 'unit-shu-archer', 'unit-shu-cavalry', 'unit-shu-strategist', 'unit-shu-officer', 'unit-wei-infantry', 'unit-wei-archer', 'unit-wei-cavalry', 'unit-wei-strategist', 'unit-wei-officer', 'unit-wu-infantry', 'unit-wu-archer', 'unit-wu-cavalry', 'unit-wu-strategist', 'unit-wu-officer', 'unit-nanman-infantry', 'unit-nanman-shaman', 'unit-nanman-officer' ]; const server = await createServer({ logLevel: 'error', server: { middlewareMode: true }, appType: 'custom' }); try { const { battleScenarios } = await server.ssrLoadModule('/src/game/data/battles.ts'); const { hasUnitSheetAsset } = await server.ssrLoadModule('/src/game/data/unitAssets.ts'); const { storyCutsceneActorTextureKeys } = await server.ssrLoadModule('/src/game/data/storyCutscenes.ts'); const scenarioModule = await server.ssrLoadModule('/src/game/data/scenario.ts'); const errors = []; const { baseKeys, actionKeys } = collectUnitSheetKeys(errors); validateUnitSheetPairs(errors, baseKeys, actionKeys); validateUnitSheetDimensions(errors, baseKeys, actionKeys); validateRequiredDynamicBaseKeys(errors, baseKeys, hasUnitSheetAsset); validateBattleSceneTextureLiterals(errors, baseKeys, actionKeys, hasUnitSheetAsset); validateCutsceneTextureKeys(errors, scenarioModule, battleScenarios, storyCutsceneActorTextureKeys, baseKeys); if (errors.length) { console.error(`Unit sprite 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 ${baseKeys.size} unit base sheets, ${actionKeys.size} action sheets, ` + `${Object.keys(battleScenarios).length} battle scenario sprite dependencies, and cutscene texture keys.` ); } } finally { await server.close(); } function collectUnitSheetKeys(errors) { const baseKeys = new Set(); const actionKeys = new Set(); readdirSync(unitDir, { withFileTypes: true }) .filter((entry) => entry.isFile() && /^unit-.+\.png$/i.test(entry.name)) .forEach((entry) => { const key = entry.name.replace(/\.png$/i, ''); if (key.endsWith('-actions')) { actionKeys.add(key); } else { baseKeys.add(key); } }); if (baseKeys.size === 0) { errors.push(`No unit base sprite sheets found in ${unitDir}`); } if (actionKeys.size === 0) { errors.push(`No unit action sprite sheets found in ${unitDir}`); } return { baseKeys, actionKeys }; } function validateUnitSheetPairs(errors, baseKeys, actionKeys) { Array.from(baseKeys) .sort() .forEach((key) => { if (!actionKeys.has(`${key}-actions`)) { errors.push(`${key}: missing paired action sheet ${key}-actions.png`); } }); Array.from(actionKeys) .sort() .forEach((key) => { const baseKey = key.replace(/-actions$/, ''); if (!baseKeys.has(baseKey)) { errors.push(`${key}: action sheet has no paired base sheet ${baseKey}.png`); } }); } function validateUnitSheetDimensions(errors, baseKeys, actionKeys) { Array.from(baseKeys) .sort() .forEach((key) => validatePngDimensions(errors, key, join(unitDir, `${key}.png`), expectedBaseSize)); Array.from(actionKeys) .sort() .forEach((key) => validatePngDimensions(errors, key, join(unitDir, `${key}.png`), expectedActionSize)); } function validateRequiredDynamicBaseKeys(errors, baseKeys, hasUnitSheetAsset) { requiredDynamicBaseKeys.forEach((key) => { if (!baseKeys.has(key) || !hasUnitSheetAsset(key)) { errors.push(`dynamic unit texture base "${key}" is required by BattleScene but missing from unit assets`); } }); } function validateBattleSceneTextureLiterals(errors, baseKeys, actionKeys, hasUnitSheetAsset) { const source = readFileSync(battleScenePath, 'utf8'); const literalKeys = new Set([...source.matchAll(/['"`](unit-[a-z0-9-]+)['"`]/g)].map((match) => match[1])); literalKeys.forEach((key) => { if (nonTextureUnitLiterals.has(key)) { return; } if (key.endsWith('-actions')) { if (!actionKeys.has(key)) { errors.push(`${battleScenePath}: literal action texture "${key}" has no matching PNG`); } return; } if (!baseKeys.has(key) || !hasUnitSheetAsset(key)) { errors.push(`${battleScenePath}: literal unit texture "${key}" has no matching paired unit sheet`); } }); } function validateCutsceneTextureKeys(errors, scenarioModule, battleScenarios, storyCutsceneActorTextureKeys, baseKeys) { collectStorySources(scenarioModule, battleScenarios).forEach(({ source, pages }) => { pages.forEach((page, index) => { storyCutsceneActorTextureKeys(page.cutscene).forEach((key) => { if (!baseKeys.has(key)) { errors.push(`${source}[${index}]/${page.id}: cutscene texture "${key}" has no matching base sheet`); } }); }); }); } function collectStorySources(scenarioModule, battleScenarios) { const sources = []; const seenArrays = new WeakSet(); Object.entries(scenarioModule) .filter(([, value]) => isStoryPageList(value)) .sort(([left], [right]) => left.localeCompare(right)) .forEach(([name, pages]) => { sources.push({ source: name, pages }); seenArrays.add(pages); }); Object.entries(battleScenarios) .sort(([left], [right]) => left.localeCompare(right)) .forEach(([battleId, scenario]) => { if (!isStoryPageList(scenario.victoryPages) || seenArrays.has(scenario.victoryPages)) { return; } sources.push({ source: `${battleId}.victoryPages`, pages: scenario.victoryPages }); seenArrays.add(scenario.victoryPages); }); return sources; } function isStoryPageList(value) { return Array.isArray(value) && value.length > 0 && value.every(isStoryPage); } function isStoryPage(page) { return Boolean(page && typeof page === 'object' && typeof page.id === 'string' && typeof page.text === 'string'); } function validatePngDimensions(errors, key, path, expected) { let dimensions; try { dimensions = readPngDimensions(path); } catch (error) { errors.push(`${key}: cannot read "${path}": ${error.message}`); return; } if (dimensions.width !== expected.width || dimensions.height !== expected.height) { errors.push(`${key}: expected ${expected.width}x${expected.height}, got ${dimensions.width}x${dimensions.height}`); } } 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) }; }