diff --git a/package.json b/package.json index 9ba0bd0..054080a 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "verify:campaign-data": "node scripts/verify-campaign-flow-data.mjs", "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: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-release-candidate.mjs b/scripts/verify-release-candidate.mjs index 0822885..ab49d41 100644 --- a/scripts/verify-release-candidate.mjs +++ b/scripts/verify-release-candidate.mjs @@ -13,6 +13,7 @@ try { runCommand('node', ['scripts/verify-battle-scenario-data.mjs']); runCommand('node', ['scripts/verify-story-asset-data.mjs']); runCommand('node', ['scripts/verify-visual-asset-data.mjs']); + runCommand('node', ['scripts/verify-unit-sprite-asset-data.mjs']); runCommand('node', ['scripts/verify-audio-asset-data.mjs']); mkdirSync(screenshotDir, { recursive: true }); serverProcess = await ensurePreviewServer(targetUrl); diff --git a/scripts/verify-unit-sprite-asset-data.mjs b/scripts/verify-unit-sprite-asset-data.mjs new file mode 100644 index 0000000..bbf09d2 --- /dev/null +++ b/scripts/verify-unit-sprite-asset-data.mjs @@ -0,0 +1,229 @@ +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) + }; +}