import { createHash } from 'node:crypto' import { readFile, readdir, realpath, stat } from 'node:fs/promises' import { dirname, relative, resolve, sep } from 'node:path' import { fileURLToPath } from 'node:url' const here = dirname(fileURLToPath(import.meta.url)) export const harnessRoot = resolve(here, '..') 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']) 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 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 } async function ensureFile(path, label) { if (!(await stat(path)).isFile()) throw new Error(`${label} is not a file`) } 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`) } 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 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], } } } 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: 2, cards, errors } } 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 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 }