card harness dynamic changes
This commit is contained in:
111
spikes/card-harness/scripts/card-catalog.mjs
Normal file
111
spikes/card-harness/scripts/card-catalog.mjs
Normal file
@@ -0,0 +1,111 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user