Files
heros_web/scripts/verify-story-image-asset-data.mjs

446 lines
15 KiB
JavaScript

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