import { createHash } from 'node:crypto' import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises' import { dirname, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { PNG } from 'pngjs' const here = dirname(fileURLToPath(import.meta.url)) export const harnessRoot = resolve(here, '..') export const cardArtRoot = resolve(harnessRoot, 'public', 'card-art') export const cardCatalogPath = resolve(cardArtRoot, 'manifest.json') function parsePng(name, bytes) { try { return PNG.sync.read(bytes) } catch (error) { throw new Error(`${name} is not a readable PNG: ${error instanceof Error ? error.message : String(error)}`) } } function validateDataMask(filename, bytes, artwork) { const mask = parsePng(filename, bytes) if (mask.width !== artwork.width || mask.height !== artwork.height) { throw new Error(`${filename} must match artwork dimensions; found ` + `${mask.width}x${mask.height} and ${artwork.width}x${artwork.height}`) } for (let offset = 0; offset < mask.data.length; offset += 4) { if (mask.data[offset] !== mask.data[offset + 1] || mask.data[offset] !== mask.data[offset + 2] || mask.data[offset + 3] !== 255) { throw new Error(`${filename} must be an opaque grayscale mask`) } } } function validatePair(name, artworkBytes, maskBytes, textMaskBytes) { const artwork = parsePng(`${name}.png`, artworkBytes) const mask = parsePng(`${name}-mask.png`, maskBytes) if (artwork.width * 7 !== artwork.height * 5) { throw new Error(`${name}.png must use a 5:7 aspect ratio; found ${artwork.width}x${artwork.height}`) } if (mask.width !== artwork.width || mask.height !== artwork.height) { throw new Error( `${name}-mask.png must match ${name}.png dimensions; found ` + `${mask.width}x${mask.height} and ${artwork.width}x${artwork.height}`, ) } for (let offset = 0; offset < artwork.data.length; offset += 4) { if (artwork.data[offset + 3] !== 255) { throw new Error(`${name}.png must be fully opaque for the runtime card surface`) } if ( mask.data[offset] !== mask.data[offset + 1] || mask.data[offset] !== mask.data[offset + 2] || mask.data[offset + 3] !== 255 ) { throw new Error(`${name}-mask.png must be an opaque grayscale finish mask`) } } if (textMaskBytes) validateDataMask(`${name}-text-mask.png`, textMaskBytes, artwork) return { width: artwork.width, height: artwork.height } } export async function scanCardCatalog(root = cardArtRoot) { await mkdir(root, { recursive: true }) const files = (await readdir(root, { withFileTypes: true })) .filter(entry => entry.isFile() && entry.name.toLowerCase().endsWith('.png')) .map(entry => entry.name) const fileSet = new Set(files) const artworkFiles = files.filter(name => !name.toLowerCase().endsWith('-mask.png')) const maskFiles = files.filter(name => name.toLowerCase().endsWith('-mask.png')) const cards = [] const errors = [] for (const artworkFile of artworkFiles) { const name = artworkFile.slice(0, -4) const maskFile = `${name}-mask.png` const textMaskFile = `${name}-text-mask.png` const hasTextMask = fileSet.has(textMaskFile) if (!fileSet.has(maskFile)) { errors.push(`Missing ${maskFile} for ${artworkFile}`) continue } try { const [artworkBytes, maskBytes, textMaskBytes] = await Promise.all([ readFile(resolve(root, artworkFile)), readFile(resolve(root, maskFile)), hasTextMask ? readFile(resolve(root, textMaskFile)) : undefined, ]) const dimensions = validatePair(name, artworkBytes, maskBytes, textMaskBytes) const hash = createHash('sha256').update(artworkBytes).update(maskBytes) if (textMaskBytes) hash.update(textMaskBytes) const revision = hash.digest('hex').slice(0, 16) cards.push({ name, artwork: `/card-art/${encodeURIComponent(artworkFile)}?v=${revision}`, mask: `/card-art/${encodeURIComponent(maskFile)}?v=${revision}`, ...(hasTextMask ? { textMask: `/card-art/${encodeURIComponent(textMaskFile)}?v=${revision}` } : {}), revision, ...dimensions, }) } catch (error) { errors.push(error instanceof Error ? error.message : String(error)) } } for (const maskFile of maskFiles) { const suffixLength = maskFile.toLowerCase().endsWith('-text-mask.png') ? 14 : 9 const artworkFile = `${maskFile.slice(0, -suffixLength)}.png` if (!fileSet.has(artworkFile)) errors.push(`Missing ${artworkFile} for ${maskFile}`) } cards.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })) errors.sort() return { schemaVersion: 1, cards, errors } } export async function writeCardCatalog(root = cardArtRoot, output = cardCatalogPath) { const catalog = await scanCardCatalog(root) await writeFile(output, `${JSON.stringify(catalog, null, 2)}\n`) return catalog } if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { const catalog = await writeCardCatalog() console.log(`Card catalog ready: ${catalog.cards.length} cards, ${catalog.errors.length} errors`) if (catalog.errors.length) { for (const error of catalog.errors) console.error(`- ${error}`) process.exitCode = 1 } }