Improved harness - with card selection

This commit is contained in:
2026-09-15 06:08:43 -07:00
parent 97ed7f05c8
commit 786f63a042
29 changed files with 1398 additions and 494 deletions

View File

@@ -1,129 +1,104 @@
import { createHash } from 'node:crypto'
import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
import { readFile, readdir, realpath, stat } from 'node:fs/promises'
import { dirname, relative, resolve, sep } 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')
export const acceptedCardsRoot = resolve(harnessRoot, '..', 'artifacts', 'cards')
export const resolutions = { low: [500, 700], med: [1000, 1400], high: [2000, 2800] }
export const printings = ['normal', 'borderless', 'textless', 'boundless']
const runtimeFiles = new Set(['card.png', 'finish-mask.png', 'text-mask.png'])
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)}`)
}
async function json(path) { return JSON.parse(await readFile(path, 'utf8')) }
function assetUrl(folderName, resolution, printing, filename, sha256) {
return `/__card_asset/${encodeURIComponent(folderName)}/${resolution}/${printing}/${filename}?v=${sha256.slice(0, 16)}`
}
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 fileRecord(files, key, dimensions) {
const record = files[key]
if (!record || typeof record.sha256 !== 'string') throw new Error(`Missing manifest entry ${key}`)
if (key.endsWith('.png') && (!Array.isArray(record.dimensions) ||
record.dimensions[0] !== dimensions[0] || record.dimensions[1] !== dimensions[1])) {
throw new Error(`Wrong manifest dimensions for ${key}`)
}
return record
}
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 }
async function ensureFile(path, label) {
if (!(await stat(path)).isFile()) throw new Error(`${label} is not a file`)
}
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
async function scanCard(directory) {
const [manifest, content] = await Promise.all([json(resolve(directory, 'manifest.json')), json(resolve(directory, 'card.json'))])
if (manifest.stage !== 'approved') throw new Error('Card package is not approved')
if (manifest.cardId !== content.cardId || manifest.folderName !== content.folderName) throw new Error('Package identity differs between manifests')
if (typeof manifest.revision !== 'string' || typeof content.title !== 'string' || typeof content.rarity !== 'string') {
throw new Error('Package is missing accepted revision, title, or rarity metadata')
}
if (!directory.endsWith(`${sep}${manifest.folderName}`)) throw new Error('folderName differs from directory')
const variants = {}
for (const [resolutionName, dimensions] of Object.entries(resolutions)) {
const resolutionManifest = await json(resolve(directory, resolutionName, 'manifest.json'))
if (resolutionManifest.dimensions?.[0] !== dimensions[0] || resolutionManifest.dimensions?.[1] !== dimensions[1]) {
throw new Error(`${resolutionName} manifest dimensions are invalid`)
}
try {
const [artworkBytes, maskBytes, textMaskBytes] = await Promise.all([
readFile(resolve(root, artworkFile)),
readFile(resolve(root, maskFile)),
hasTextMask ? readFile(resolve(root, textMaskFile)) : undefined,
variants[resolutionName] = {}
for (const printing of printings) {
const artworkKey = `${printing}/card.png`, finishKey = `${printing}/finish-mask.png`, textKey = `${printing}/text-mask.png`
const artwork = fileRecord(resolutionManifest.files, artworkKey, dimensions)
const finishMask = fileRecord(resolutionManifest.files, finishKey, dimensions)
const textMask = printing === 'normal' || printing === 'borderless'
? fileRecord(resolutionManifest.files, textKey, dimensions) : undefined
await Promise.all([
ensureFile(resolve(directory, resolutionName, artworkKey), artworkKey),
ensureFile(resolve(directory, resolutionName, finishKey), finishKey),
...(textMask ? [ensureFile(resolve(directory, resolutionName, textKey), textKey)] : []),
])
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))
const revision = createHash('sha256').update(artwork.sha256).update(finishMask.sha256)
.update(textMask?.sha256 ?? '').digest('hex').slice(0, 16)
variants[resolutionName][printing] = {
artwork: assetUrl(manifest.folderName, resolutionName, printing, 'card.png', artwork.sha256),
mask: assetUrl(manifest.folderName, resolutionName, printing, 'finish-mask.png', finishMask.sha256),
...(textMask ? { textMask: assetUrl(manifest.folderName, resolutionName, printing, 'text-mask.png', textMask.sha256) } : {}),
revision, width: dimensions[0], height: dimensions[1],
}
}
}
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' }))
return { cardId: manifest.cardId, title: content.title, rarity: content.rarity,
folderName: manifest.folderName, acceptedRevision: manifest.revision, variants }
}
export async function scanCardCatalog(root = acceptedCardsRoot) {
const entries = (await readdir(root, { withFileTypes: true }))
.filter(entry => entry.isDirectory() && !entry.isSymbolicLink())
const cards = [], errors = []
let cursor = 0
await Promise.all(Array.from({ length: Math.min(12, entries.length) }, async () => {
while (cursor < entries.length) {
const entry = entries[cursor++]
try { cards.push(await scanCard(resolve(root, entry.name))) }
catch (error) { errors.push(`${entry.name}: ${error instanceof Error ? error.message : String(error)}`) }
}
}))
cards.sort((a, b) => a.cardId.localeCompare(b.cardId, undefined, { numeric: true, sensitivity: 'base' }))
errors.sort()
return { schemaVersion: 1, cards, errors }
return { schemaVersion: 2, 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
export async function resolveCardAssetRequest(pathname, root = acceptedCardsRoot) {
const prefix = '/__card_asset/'
if (!pathname.startsWith(prefix)) return undefined
let parts
try { parts = pathname.slice(prefix.length).split('/').map(decodeURIComponent) } catch { return undefined }
if (parts.length !== 4) return undefined
const [folderName, resolutionName, printing, filename] = parts
if (!/^[A-Z]+-[0-9]{3,}-[a-z0-9-]+$/.test(folderName) || !(resolutionName in resolutions) ||
!printings.includes(printing) || !runtimeFiles.has(filename)) return undefined
const target = resolve(root, folderName, resolutionName, printing, filename), relativeTarget = relative(root, target)
if (!relativeTarget || relativeTarget === '..' || relativeTarget.startsWith(`..${sep}`)) return undefined
const [realRoot, realTarget] = await Promise.all([realpath(root), realpath(target)]), realRelative = relative(realRoot, realTarget)
if (!realRelative || realRelative === '..' || realRelative.startsWith(`..${sep}`)) return undefined
return realTarget
}
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
}
const catalog = await scanCardCatalog()
console.log(`Accepted card catalog: ${catalog.cards.length} cards, ${catalog.errors.length} errors`)
for (const error of catalog.errors) console.error(`- ${error}`)
if (!catalog.cards.length || catalog.errors.length) process.exitCode = 1
}