Files
Sanctification/spikes/card-harness/scripts/card-catalog.mjs
2026-09-11 21:19:08 -07:00

112 lines
4.1 KiB
JavaScript

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 validatePair(name, artworkBytes, maskBytes) {
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`)
}
}
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`
if (!fileSet.has(maskFile)) {
errors.push(`Missing ${maskFile} for ${artworkFile}`)
continue
}
try {
const [artworkBytes, maskBytes] = await Promise.all([
readFile(resolve(root, artworkFile)),
readFile(resolve(root, maskFile)),
])
const dimensions = validatePair(name, artworkBytes, maskBytes)
const revision = createHash('sha256')
.update(artworkBytes)
.update(maskBytes)
.digest('hex')
.slice(0, 16)
cards.push({
name,
artwork: `/card-art/${encodeURIComponent(artworkFile)}?v=${revision}`,
mask: `/card-art/${encodeURIComponent(maskFile)}?v=${revision}`,
revision,
...dimensions,
})
} catch (error) {
errors.push(error instanceof Error ? error.message : String(error))
}
}
for (const maskFile of maskFiles) {
const artworkFile = `${maskFile.slice(0, -9)}.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
}
}