From b1e998744bde3dfe68b4af65685bff5ce490ffcc Mon Sep 17 00:00:00 2001 From: Wickedness Date: Sun, 5 Jul 2026 08:08:25 +0900 Subject: [PATCH] Verify story image asset dimensions --- package.json | 1 + scripts/verify-release-candidate.mjs | 1 + scripts/verify-story-image-asset-data.mjs | 201 ++++++++++++++++++++++ 3 files changed, 203 insertions(+) create mode 100644 scripts/verify-story-image-asset-data.mjs diff --git a/package.json b/package.json index 3556ff3..3b6de9d 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "verify:battle-data": "node scripts/verify-battle-scenario-data.mjs", "verify:campaign-data": "node scripts/verify-campaign-flow-data.mjs", "verify:story-assets": "node scripts/verify-story-asset-data.mjs", + "verify:story-images": "node scripts/verify-story-image-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", diff --git a/scripts/verify-release-candidate.mjs b/scripts/verify-release-candidate.mjs index 12edbe2..c1d4d47 100644 --- a/scripts/verify-release-candidate.mjs +++ b/scripts/verify-release-candidate.mjs @@ -12,6 +12,7 @@ try { runCommand('node', ['scripts/verify-campaign-flow-data.mjs']); runCommand('node', ['scripts/verify-battle-scenario-data.mjs']); runCommand('node', ['scripts/verify-story-asset-data.mjs']); + runCommand('node', ['scripts/verify-story-image-asset-data.mjs']); runCommand('node', ['scripts/verify-visual-asset-data.mjs']); runCommand('node', ['scripts/verify-unit-sprite-asset-data.mjs']); runCommand('node', ['scripts/verify-battle-map-asset-data.mjs']); diff --git a/scripts/verify-story-image-asset-data.mjs b/scripts/verify-story-image-asset-data.mjs new file mode 100644 index 0000000..ab46e7a --- /dev/null +++ b/scripts/verify-story-image-asset-data.mjs @@ -0,0 +1,201 @@ +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { join, sep } from 'node:path'; +import { createServer } from 'vite'; + +const storyDir = join('src', 'assets', 'images', 'story'); +const portraitDir = join('src', 'assets', 'images', 'portraits'); +const titleImagePath = join('src', 'assets', 'images', 'taoyuan-oath-title.png'); +const storyAssetsSourcePath = join('src', 'game', 'data', 'storyAssets.ts'); + +const expectedStorySize = { width: 1672, height: 941 }; +const expectedPortraitSize = { width: 1254, height: 1254 }; + +const server = await createServer({ + logLevel: 'error', + server: { middlewareMode: true }, + appType: 'custom' +}); + +try { + const { storyBackgroundAssets } = await server.ssrLoadModule('/src/game/data/storyAssets.ts'); + const { portraitAssets } = await server.ssrLoadModule('/src/game/data/portraitAssets.ts'); + const errors = []; + const storyAliasKeys = parseStoryAliasKeys(readFileSync(storyAssetsSourcePath, 'utf8'), errors); + + const storySourceKeys = validateStoryImages(errors, storyBackgroundAssets); + validateTitleImage(errors, storyBackgroundAssets); + validateStoryManifestKeys(errors, storyBackgroundAssets, storySourceKeys, storyAliasKeys); + + const portraitSlugs = validatePortraitImages(errors, portraitAssets); + validatePortraitManifestKeys(errors, portraitAssets, portraitSlugs); + + if (errors.length) { + console.error(`Story image 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 ${storySourceKeys.size} story backgrounds, ${portraitSlugs.size} portraits, ` + + `${storyAliasKeys.size} story aliases, and the title artwork dimensions.` + ); + } +} finally { + await server.close(); +} + +function validateStoryImages(errors, storyBackgroundAssets) { + const sourceKeys = new Set(); + for (const fileName of imageFiles(storyDir)) { + const path = join(storyDir, fileName); + validatePngFile(errors, path, expectedStorySize, 'story background'); + + const key = storyKeyFromFileName(fileName); + sourceKeys.add(key); + if (!storyBackgroundAssets[key]) { + errors.push(`${path}: expected storyBackgroundAssets key "${key}"`); + } + validateRuntimeUrl(errors, key, storyBackgroundAssets[key], path); + } + + return sourceKeys; +} + +function validateTitleImage(errors, storyBackgroundAssets) { + validatePngFile(errors, titleImagePath, expectedStorySize, 'title artwork'); + ['story-taoyuan-oath-title', 'title-taoyuan'].forEach((key) => { + if (!storyBackgroundAssets[key]) { + errors.push(`${titleImagePath}: expected storyBackgroundAssets key "${key}"`); + } + }); + validateRuntimeUrl(errors, 'story-taoyuan-oath-title', storyBackgroundAssets['story-taoyuan-oath-title'], titleImagePath); +} + +function validateStoryManifestKeys(errors, storyBackgroundAssets, sourceKeys, aliasKeys) { + const expectedKeys = new Set([...sourceKeys, 'story-taoyuan-oath-title', ...aliasKeys]); + Object.keys(storyBackgroundAssets) + .sort() + .forEach((key) => { + if (!expectedKeys.has(key)) { + errors.push(`storyBackgroundAssets contains unexpected key "${key}"`); + } + }); +} + +function validatePortraitImages(errors, portraitAssets) { + const slugs = new Set(); + for (const fileName of imageFiles(portraitDir)) { + const path = join(portraitDir, fileName); + validatePngFile(errors, path, expectedPortraitSize, 'portrait'); + + const slug = fileName.replace(/\.png$/i, ''); + slugs.add(slug); + if (!portraitAssets[slug]) { + errors.push(`${path}: expected portraitAssets slug "${slug}"`); + } + validateRuntimeUrl(errors, slug, portraitAssets[slug], path); + } + + return slugs; +} + +function validatePortraitManifestKeys(errors, portraitAssets, slugs) { + Object.keys(portraitAssets) + .sort() + .forEach((slug) => { + if (!slugs.has(slug)) { + errors.push(`portraitAssets contains unexpected slug "${slug}"`); + } + }); +} + +function validatePngFile(errors, path, expectedSize, label) { + let bytes; + let stats; + try { + bytes = readFileSync(path); + stats = statSync(path); + } catch (error) { + errors.push(`${path}: cannot read ${label}: ${error.message}`); + return; + } + + if (stats.size < 100 * 1024) { + errors.push(`${path}: ${label} is suspiciously small (${stats.size} bytes)`); + } + + const pngSignature = '89504e470d0a1a0a'; + if (bytes.subarray(0, 8).toString('hex') !== pngSignature) { + errors.push(`${path}: ${label} does not have a PNG signature`); + return; + } + if (bytes.subarray(12, 16).toString('ascii') !== 'IHDR') { + errors.push(`${path}: ${label} is missing a PNG IHDR chunk`); + return; + } + + const width = bytes.readUInt32BE(16); + const height = bytes.readUInt32BE(20); + if (width !== expectedSize.width || height !== expectedSize.height) { + errors.push( + `${path}: ${label} should be ${expectedSize.width}x${expectedSize.height}, got ${width}x${height}` + ); + } +} + +function validateRuntimeUrl(errors, key, url, path) { + assertNonEmptyString(errors, url, `${key} runtime asset URL`); + if (typeof url !== 'string' || url.startsWith('data:')) { + return; + } + + const runtimePath = assetUrlToPath(url); + if (runtimePath && runtimePath !== path) { + errors.push(`${key}: runtime URL points at "${runtimePath}" but source file is "${path}"`); + } +} + +function imageFiles(dir) { + return readdirSync(dir, { withFileTypes: true }) + .filter((entry) => entry.isFile()) + .map((entry) => entry.name) + .sort() + .filter((fileName) => /\.png$/i.test(fileName)); +} + +function storyKeyFromFileName(fileName) { + const slug = fileName.replace(/\.png$/i, '').replace(/^\d+-/, ''); + return `story-${slug}`; +} + +function parseStoryAliasKeys(source, errors) { + const match = source.match(/const\s+storyAssetAliases:\s*Record\s*=\s*{([\s\S]*?)\n};/); + if (!match) { + errors.push(`${storyAssetsSourcePath}: cannot find storyAssetAliases`); + return new Set(); + } + + const aliases = new Set(); + const entryPattern = /['"]([^'"]+)['"]\s*:/g; + for (const entry of match[1].matchAll(entryPattern)) { + aliases.add(entry[1]); + } + return aliases; +} + +function assetUrlToPath(url) { + const marker = 'src/assets/images/'; + 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`); + } +}