import { readdirSync, readFileSync, statSync } from 'node:fs'; import { join } from 'node:path'; const explorationAssetDirectory = join('src', 'assets', 'images', 'exploration'); const expectedAssets = [ 'prologue-village.webp', 'prologue-militia-camp.webp' ]; const expectedWidth = 1920; const expectedHeight = 1080; const maximumAssetBytes = 2 * 1024 * 1024; const maximumTotalBytes = 4 * 1024 * 1024; const rasterAssetPattern = /\.(?:avif|jpe?g|png|webp)$/i; const errors = []; let totalBytes = 0; validateDirectoryCoverage(errors); for (const fileName of expectedAssets) { const path = join(explorationAssetDirectory, fileName); const inspection = inspectWebpAsset(errors, path, fileName); if (!inspection) { continue; } totalBytes += inspection.size; if (inspection.size > maximumAssetBytes) { errors.push( `${path}: ${formatBytes(inspection.size)} exceeds the per-background limit of ` + `${formatBytes(maximumAssetBytes)}` ); } if (inspection.width !== expectedWidth || inspection.height !== expectedHeight) { errors.push( `${path}: expected ${expectedWidth}x${expectedHeight}, got ` + `${inspection.width}x${inspection.height}` ); } if (inspection.hasAlpha) { errors.push(`${path}: exploration backgrounds must be opaque WebP images without alpha`); } if (inspection.animated) { errors.push(`${path}: exploration backgrounds must be static WebP images`); } } if (totalBytes > maximumTotalBytes) { errors.push( `${explorationAssetDirectory}: combined background size ${formatBytes(totalBytes)} exceeds ` + `${formatBytes(maximumTotalBytes)}` ); } if (errors.length > 0) { console.error(`Prologue exploration asset verification failed with ${errors.length} issue(s):`); errors.forEach((error) => console.error(`- ${error}`)); process.exitCode = 1; } else { console.log( `Verified ${expectedAssets.length} opaque ${expectedWidth}x${expectedHeight} exploration ` + `WebP backgrounds (${formatBytes(totalBytes)} total; ${formatBytes(maximumAssetBytes)} ` + `per-file / ${formatBytes(maximumTotalBytes)} combined limits).` ); } function validateDirectoryCoverage(validationErrors) { let entries; try { entries = readdirSync(explorationAssetDirectory, { withFileTypes: true }); } catch (error) { validationErrors.push( `${explorationAssetDirectory}: cannot read exploration asset directory: ${error.message}` ); return; } const actualRasterFiles = entries .filter((entry) => entry.isFile() && rasterAssetPattern.test(entry.name)) .map((entry) => entry.name) .sort(); const expectedRasterFiles = [...expectedAssets].sort(); for (const fileName of expectedRasterFiles) { if (!actualRasterFiles.includes(fileName)) { validationErrors.push(`${explorationAssetDirectory}: missing required asset "${fileName}"`); } } for (const fileName of actualRasterFiles) { if (!expectedRasterFiles.includes(fileName)) { validationErrors.push( `${explorationAssetDirectory}: unexpected raster asset "${fileName}"; ` + `only the two optimized runtime WebP backgrounds should be tracked here` ); } } } function inspectWebpAsset(validationErrors, path, label) { let bytes; let stats; try { bytes = readFileSync(path); stats = statSync(path); } catch (error) { validationErrors.push(`${path}: cannot read ${label}: ${error.message}`); return undefined; } if (!stats.isFile()) { validationErrors.push(`${path}: expected a regular WebP file`); return undefined; } if (bytes.length < 20) { validationErrors.push(`${path}: file is too small to be a valid WebP image`); return undefined; } if ( bytes.subarray(0, 4).toString('ascii') !== 'RIFF' || bytes.subarray(8, 12).toString('ascii') !== 'WEBP' ) { validationErrors.push(`${path}: missing the WebP RIFF header`); return undefined; } const declaredLength = bytes.readUInt32LE(4) + 8; if (declaredLength !== bytes.length) { validationErrors.push( `${path}: RIFF container declares ${declaredLength} bytes, file contains ${bytes.length}` ); return undefined; } let dimensions; let hasAlpha = false; let animated = false; let foundImagePayload = false; 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; const paddedChunkEnd = dataOffset + chunkSize + (chunkSize % 2); if (dataOffset + chunkSize > bytes.length || paddedChunkEnd > bytes.length) { validationErrors.push(`${path}: "${chunkType}" chunk exceeds the RIFF container`); return undefined; } if (chunkType === 'VP8X') { if (chunkSize < 10) { validationErrors.push(`${path}: VP8X chunk is too short`); return undefined; } const featureFlags = bytes[dataOffset]; dimensions = { width: readUInt24LE(bytes, dataOffset + 4) + 1, height: readUInt24LE(bytes, dataOffset + 7) + 1 }; hasAlpha ||= (featureFlags & 0x10) !== 0; animated ||= (featureFlags & 0x02) !== 0; } else if (chunkType === 'VP8 ') { if ( chunkSize < 10 || bytes[dataOffset + 3] !== 0x9d || bytes[dataOffset + 4] !== 0x01 || bytes[dataOffset + 5] !== 0x2a ) { validationErrors.push(`${path}: invalid lossy VP8 image payload`); return undefined; } dimensions ??= { width: bytes.readUInt16LE(dataOffset + 6) & 0x3fff, height: bytes.readUInt16LE(dataOffset + 8) & 0x3fff }; foundImagePayload = true; } else if (chunkType === 'VP8L') { if (chunkSize < 5 || bytes[dataOffset] !== 0x2f) { validationErrors.push(`${path}: invalid lossless VP8L image payload`); return undefined; } const losslessHeader = bytes.readUInt32LE(dataOffset + 1); dimensions ??= { width: (losslessHeader & 0x3fff) + 1, height: ((losslessHeader >>> 14) & 0x3fff) + 1 }; hasAlpha ||= (losslessHeader & 0x10000000) !== 0; foundImagePayload = true; } else if (chunkType === 'ALPH') { hasAlpha = true; } else if (chunkType === 'ANIM' || chunkType === 'ANMF') { animated = true; } offset = paddedChunkEnd; } if (offset !== bytes.length) { validationErrors.push(`${path}: RIFF container has an incomplete trailing chunk header`); return undefined; } if (!dimensions || !foundImagePayload) { validationErrors.push(`${path}: WebP dimensions or image payload could not be read`); return undefined; } return { size: stats.size, width: dimensions.width, height: dimensions.height, hasAlpha, animated }; } function readUInt24LE(bytes, offset) { return bytes[offset] | (bytes[offset + 1] << 8) | (bytes[offset + 2] << 16); } function formatBytes(bytes) { return `${(bytes / (1024 * 1024)).toFixed(2)} MiB`; }