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`); } }