Verify story image asset dimensions
This commit is contained in:
@@ -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']);
|
||||
|
||||
201
scripts/verify-story-image-asset-data.mjs
Normal file
201
scripts/verify-story-image-asset-data.mjs
Normal file
@@ -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<string,\s*string>\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`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user