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 portraitCardDir = join(portraitDir, 'cards'); 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 expectedPortraitCardSize = { width: 320, height: 320 }; const allowedPngColorTypes = new Map([ [2, 'RGB'], [6, 'RGBA'] ]); const server = await createServer({ logLevel: 'error', server: { middlewareMode: true }, appType: 'custom' }); try { const { storyBackgroundAssets } = await server.ssrLoadModule('/src/game/data/storyAssets.ts'); const { campaignPortraitKeysByUnitId, portraitAssets, portraitCardAssetEntryForKey, portraitCardAssets, portraitCardTextureKey, portraitTextureKey } = await server.ssrLoadModule('/src/game/data/portraitAssets.ts'); const { campaignRecruitUnits, firstBattleUnits } = await server.ssrLoadModule('/src/game/data/scenario.ts'); const expectedCampaignPortraitUnitIds = new Set( [...firstBattleUnits, ...campaignRecruitUnits] .filter((unit) => unit.faction === 'ally') .map((unit) => unit.id) ); 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); const portraitCardSlugs = validatePortraitCardImages(errors, portraitCardAssets, expectedCampaignPortraitUnitIds); validatePortraitCardManifestKeys(errors, portraitCardAssets, portraitCardSlugs); validateCampaignPortraitCoverage(errors, { expectedCampaignPortraitUnitIds, campaignPortraitKeysByUnitId, portraitAssets, portraitCardAssets, portraitTextureKey, portraitCardTextureKey, portraitCardAssetEntryForKey }); 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, ` + `${portraitCardSlugs.size} portrait cards, ${expectedCampaignPortraitUnitIds.size} campaign portrait mappings, ` + `${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(); const files = assetFiles(portraitDir, /\.(?:png|webp)$/i); const unexpectedFiles = assetFiles(portraitDir, /./) .filter((fileName) => !/\.(?:png|webp)$/i.test(fileName)); unexpectedFiles.forEach((fileName) => { errors.push(`${join(portraitDir, fileName)}: portraits must use PNG or WebP`); }); for (const fileName of files) { const path = join(portraitDir, fileName); if (/\.webp$/i.test(fileName)) { validateWebpFile(errors, path, expectedPortraitSize, 'portrait'); } else { validatePngFile(errors, path, expectedPortraitSize, 'portrait'); } const slug = fileName.replace(/\.(?:png|webp)$/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 validatePortraitCardImages(errors, portraitCardAssets, expectedCampaignPortraitUnitIds) { const slugs = new Set(); const files = assetFiles(portraitCardDir, /\.webp$/i); if (files.length !== expectedCampaignPortraitUnitIds.size) { errors.push( `${portraitCardDir}: expected ${expectedCampaignPortraitUnitIds.size} WebP portrait cards, found ${files.length}` ); } const unexpectedFiles = assetFiles(portraitCardDir, /./).filter((fileName) => !/\.webp$/i.test(fileName)); unexpectedFiles.forEach((fileName) => { errors.push(`${join(portraitCardDir, fileName)}: portrait card assets must use WebP`); }); for (const fileName of files) { const path = join(portraitCardDir, fileName); validateWebpFile(errors, path, expectedPortraitCardSize, 'portrait card'); const slug = fileName.replace(/\.webp$/i, ''); slugs.add(slug); if (!expectedCampaignPortraitUnitIds.has(slug)) { errors.push(`${path}: unexpected campaign portrait card slug "${slug}"`); } if (!portraitCardAssets[slug]) { errors.push(`${path}: expected portraitCardAssets slug "${slug}"`); } validateRuntimeUrl(errors, slug, portraitCardAssets[slug], path); } expectedCampaignPortraitUnitIds.forEach((unitId) => { if (!slugs.has(unitId)) { errors.push(`${portraitCardDir}: missing campaign portrait card "${unitId}.webp"`); } }); return slugs; } function validatePortraitCardManifestKeys(errors, portraitCardAssets, slugs) { Object.keys(portraitCardAssets) .sort() .forEach((slug) => { if (!slugs.has(slug)) { errors.push(`portraitCardAssets contains unexpected slug "${slug}"`); } }); } function validateCampaignPortraitCoverage( errors, { expectedCampaignPortraitUnitIds, campaignPortraitKeysByUnitId, portraitAssets, portraitCardAssets, portraitTextureKey, portraitCardTextureKey, portraitCardAssetEntryForKey } ) { const mappedUnitIds = Object.keys(campaignPortraitKeysByUnitId).sort(); if (mappedUnitIds.length !== expectedCampaignPortraitUnitIds.size) { errors.push( `campaignPortraitKeysByUnitId should contain ${expectedCampaignPortraitUnitIds.size} units, found ${mappedUnitIds.length}` ); } mappedUnitIds.forEach((unitId) => { if (!expectedCampaignPortraitUnitIds.has(unitId)) { errors.push(`campaignPortraitKeysByUnitId contains unexpected unit "${unitId}"`); } }); expectedCampaignPortraitUnitIds.forEach((unitId) => { const portraitKey = campaignPortraitKeysByUnitId[unitId]; if (typeof portraitKey !== 'string' || portraitKey.length === 0) { errors.push(`campaignPortraitKeysByUnitId is missing a portrait key for "${unitId}"`); return; } if (!portraitAssets[unitId]) { errors.push(`campaign unit "${unitId}" is missing its base portrait asset`); } if (!portraitCardAssets[unitId]) { errors.push(`campaign unit "${unitId}" is missing its portrait card asset`); } const expectedBaseTextureKey = `portrait-${unitId}`; const expectedCardTextureKey = `portrait-card-${unitId}`; if (portraitTextureKey(portraitKey) !== expectedBaseTextureKey) { errors.push(`campaign unit "${unitId}" does not resolve base texture key "${expectedBaseTextureKey}"`); } if (portraitCardTextureKey(portraitKey) !== expectedCardTextureKey) { errors.push(`campaign unit "${unitId}" does not resolve card texture key "${expectedCardTextureKey}"`); } const cardEntry = portraitCardAssetEntryForKey(portraitKey); if (cardEntry?.textureKey !== expectedCardTextureKey) { errors.push(`campaign unit "${unitId}" does not expose card entry texture key "${expectedCardTextureKey}"`); } if (cardEntry?.url !== portraitCardAssets[unitId]) { errors.push(`campaign unit "${unitId}" card entry does not expose its manifest URL`); } }); } 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); const bitDepth = bytes[24]; const colorType = bytes[25]; if (width !== expectedSize.width || height !== expectedSize.height) { errors.push( `${path}: ${label} should be ${expectedSize.width}x${expectedSize.height}, got ${width}x${height}` ); } if (bitDepth !== 8 || !allowedPngColorTypes.has(colorType)) { const colorMode = allowedPngColorTypes.get(colorType) ?? `color type ${colorType}`; errors.push(`${path}: ${label} should be 8-bit RGB/RGBA PNG, got ${bitDepth}-bit ${colorMode}`); } } function validateWebpFile(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 < 5 * 1024) { errors.push(`${path}: ${label} is suspiciously small (${stats.size} bytes)`); } if ( bytes.subarray(0, 4).toString('ascii') !== 'RIFF' || bytes.subarray(8, 12).toString('ascii') !== 'WEBP' ) { errors.push(`${path}: ${label} does not have a WebP RIFF header`); return; } const dimensions = readWebpDimensions(bytes); if (!dimensions) { errors.push(`${path}: ${label} does not expose readable WebP dimensions`); return; } if (dimensions.width !== expectedSize.width || dimensions.height !== expectedSize.height) { errors.push( `${path}: ${label} should be ${expectedSize.width}x${expectedSize.height}, ` + `got ${dimensions.width}x${dimensions.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 assetFiles(dir, /\.png$/i); } function assetFiles(dir, pattern) { return readdirSync(dir, { withFileTypes: true }) .filter((entry) => entry.isFile()) .map((entry) => entry.name) .sort() .filter((fileName) => pattern.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 readWebpDimensions(bytes) { let offset = 12; while (offset + 8 <= bytes.length) { const chunkType = bytes.subarray(offset, offset + 4).toString('ascii'); const chunkSize = bytes.readUInt32LE(offset + 4); const dataOffset = offset + 8; if (dataOffset + chunkSize > bytes.length) { return undefined; } if (chunkType === 'VP8X' && chunkSize >= 10) { return { width: readUInt24LE(bytes, dataOffset + 4) + 1, height: readUInt24LE(bytes, dataOffset + 7) + 1 }; } if (chunkType === 'VP8L' && chunkSize >= 5 && bytes[dataOffset] === 0x2f) { const bits = bytes[dataOffset + 1] | (bytes[dataOffset + 2] << 8) | (bytes[dataOffset + 3] << 16) | (bytes[dataOffset + 4] << 24); return { width: (bits & 0x3fff) + 1, height: ((bits >>> 14) & 0x3fff) + 1 }; } if ( chunkType === 'VP8 ' && chunkSize >= 10 && bytes[dataOffset + 3] === 0x9d && bytes[dataOffset + 4] === 0x01 && bytes[dataOffset + 5] === 0x2a ) { return { width: bytes.readUInt16LE(dataOffset + 6) & 0x3fff, height: bytes.readUInt16LE(dataOffset + 8) & 0x3fff }; } offset = dataOffset + chunkSize + (chunkSize % 2); } return undefined; } function readUInt24LE(bytes, offset) { return bytes[offset] | (bytes[offset + 1] << 8) | (bytes[offset + 2] << 16); } 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`); } }