Improved harness - with card selection
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -1,96 +1,78 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { resolve } from 'node:path'
|
||||
import { PNG } from 'pngjs'
|
||||
import { scanCardCatalog } from './card-catalog.mjs'
|
||||
import { printings, resolutions, resolveCardAssetRequest, scanCardCatalog } from './card-catalog.mjs'
|
||||
|
||||
function png(width, height, pixel) {
|
||||
const image = new PNG({ width, height })
|
||||
for (let offset = 0; offset < image.data.length; offset += 4) {
|
||||
image.data.set(pixel, offset)
|
||||
async function createCard(root, options = {}) {
|
||||
const folderName = options.folderName ?? 'BP-002-david'
|
||||
const directory = resolve(root, folderName)
|
||||
await mkdir(directory, { recursive: true })
|
||||
await writeFile(resolve(directory, 'card.json'), JSON.stringify({ cardId: 'BP-002', folderName, title: 'David', rarity: 'Blessed' }))
|
||||
await writeFile(resolve(directory, 'manifest.json'), JSON.stringify({
|
||||
stage: options.stage ?? 'approved', cardId: 'BP-002', folderName, revision: 'accepted-revision',
|
||||
}))
|
||||
for (const [resolutionName, dimensions] of Object.entries(resolutions)) {
|
||||
const files = {}
|
||||
for (const printing of printings) {
|
||||
const target = resolve(directory, resolutionName, printing)
|
||||
await mkdir(target, { recursive: true })
|
||||
for (const filename of ['card.png', 'finish-mask.png', ...(['normal', 'borderless'].includes(printing) ? ['text-mask.png'] : [])]) {
|
||||
const key = `${printing}/${filename}`
|
||||
files[key] = { sha256: `${resolutionName}-${printing}-${filename}`, dimensions }
|
||||
await writeFile(resolve(target, filename), key)
|
||||
}
|
||||
}
|
||||
await writeFile(resolve(directory, resolutionName, 'manifest.json'), JSON.stringify({ dimensions, files }))
|
||||
}
|
||||
return PNG.sync.write(image)
|
||||
return directory
|
||||
}
|
||||
|
||||
test('catalog pairs filename-based card art with validated masks', async (t) => {
|
||||
const root = await mkdtemp(resolve(tmpdir(), 'card-catalog-'))
|
||||
test('catalog exposes every accepted resolution and printing from artifact manifests', async (t) => {
|
||||
const root = await mkdtemp(resolve(tmpdir(), 'accepted-card-catalog-'))
|
||||
t.after(() => rm(root, { recursive: true }))
|
||||
await Promise.all([
|
||||
writeFile(resolve(root, 'david.png'), png(10, 14, [20, 40, 60, 255])),
|
||||
writeFile(resolve(root, 'david-mask.png'), png(10, 14, [128, 128, 128, 255])),
|
||||
])
|
||||
await createCard(root)
|
||||
const catalog = await scanCardCatalog(root)
|
||||
assert.deepEqual(catalog.errors, [])
|
||||
assert.equal(catalog.schemaVersion, 2)
|
||||
assert.equal(catalog.cards.length, 1)
|
||||
assert.equal(catalog.cards[0].name, 'david')
|
||||
assert.match(catalog.cards[0].artwork, /^\/card-art\/david\.png\?v=[0-9a-f]{16}$/)
|
||||
assert.equal(catalog.cards[0].width, 10)
|
||||
assert.equal(catalog.cards[0].height, 14)
|
||||
const card = catalog.cards[0]
|
||||
assert.equal(card.cardId, 'BP-002')
|
||||
assert.equal(card.title, 'David')
|
||||
assert.equal(card.acceptedRevision, 'accepted-revision')
|
||||
assert.equal(card.variants.high.normal.width, 2000)
|
||||
assert.match(card.variants.high.normal.artwork, /^\/__card_asset\/BP-002-david\/high\/normal\/card\.png\?v=/)
|
||||
assert.match(card.variants.low.normal.textMask, /text-mask\.png\?v=/)
|
||||
assert.equal(card.variants.low.textless.textMask, undefined)
|
||||
})
|
||||
|
||||
test('catalog reports missing pairs and invalid runtime images explicitly', async (t) => {
|
||||
const root = await mkdtemp(resolve(tmpdir(), 'card-catalog-invalid-'))
|
||||
test('catalog rejects unapproved and identity-mismatched packages without hiding errors', async (t) => {
|
||||
const root = await mkdtemp(resolve(tmpdir(), 'invalid-accepted-card-catalog-'))
|
||||
t.after(() => rm(root, { recursive: true }))
|
||||
await Promise.all([
|
||||
writeFile(resolve(root, 'orphan.png'), png(10, 14, [20, 40, 60, 255])),
|
||||
writeFile(resolve(root, 'mask-only-mask.png'), png(10, 14, [0, 0, 0, 255])),
|
||||
writeFile(resolve(root, 'bad.png'), png(10, 14, [20, 40, 60, 254])),
|
||||
writeFile(resolve(root, 'bad-mask.png'), png(10, 14, [20, 21, 20, 255])),
|
||||
])
|
||||
await createCard(root, { folderName: 'BP-002-draft', stage: 'candidate' })
|
||||
const bad = await createCard(root, { folderName: 'BP-002-wrong-folder' })
|
||||
await writeFile(resolve(bad, 'manifest.json'), JSON.stringify({
|
||||
stage: 'approved', cardId: 'BP-999', folderName: 'BP-002-wrong-folder', revision: 'x',
|
||||
}))
|
||||
const catalog = await scanCardCatalog(root)
|
||||
assert.deepEqual(catalog.cards, [])
|
||||
assert.ok(catalog.errors.some(error => error.includes('Missing orphan-mask.png')))
|
||||
assert.ok(catalog.errors.some(error => error.includes('Missing mask-only.png')))
|
||||
assert.ok(catalog.errors.some(error => error.includes('bad.png must be fully opaque')))
|
||||
assert.ok(catalog.errors.some(error => error.includes('not approved')))
|
||||
assert.ok(catalog.errors.some(error => error.includes('identity differs')))
|
||||
})
|
||||
|
||||
test('optional text masks are discovered without becoming artwork or orphan finish masks', async (t) => {
|
||||
const root = await mkdtemp(resolve(tmpdir(), 'card-text-mask-'))
|
||||
test('artifact asset routes allow only real runtime files inside the accepted root', async (t) => {
|
||||
const root = await mkdtemp(resolve(tmpdir(), 'accepted-card-route-'))
|
||||
t.after(() => rm(root, { recursive: true }))
|
||||
const artwork = png(10, 14, [20, 40, 60, 255])
|
||||
const finish = png(10, 14, [128, 128, 128, 255])
|
||||
await Promise.all([
|
||||
writeFile(resolve(root, 'Card name.png'), artwork),
|
||||
writeFile(resolve(root, 'Card name-mask.png'), finish),
|
||||
])
|
||||
const legacy = await scanCardCatalog(root)
|
||||
assert.equal(legacy.cards[0].textMask, undefined)
|
||||
const { createHash } = await import('node:crypto')
|
||||
assert.equal(legacy.cards[0].revision,
|
||||
createHash('sha256').update(artwork).update(finish).digest('hex').slice(0, 16))
|
||||
await writeFile(resolve(root, 'Card name-text-mask.png'), png(10, 14, [255, 255, 255, 255]))
|
||||
const masked = await scanCardCatalog(root)
|
||||
assert.deepEqual(masked.errors, [])
|
||||
assert.equal(masked.cards.length, 1)
|
||||
assert.match(masked.cards[0].textMask, /^\/card-art\/Card%20name-text-mask\.png\?v=[0-9a-f]{16}$/)
|
||||
assert.notEqual(masked.cards[0].revision, legacy.cards[0].revision)
|
||||
await writeFile(resolve(root, 'Card name-text-mask.png'), png(10, 14, [0, 0, 0, 255]))
|
||||
assert.notEqual((await scanCardCatalog(root)).cards[0].revision, masked.cards[0].revision)
|
||||
await rm(resolve(root, 'Card name-text-mask.png'))
|
||||
assert.deepEqual(await scanCardCatalog(root), legacy)
|
||||
})
|
||||
|
||||
test('invalid optional text masks reject the card and orphan masks name the correct missing artwork', async (t) => {
|
||||
const root = await mkdtemp(resolve(tmpdir(), 'invalid-card-text-mask-'))
|
||||
t.after(() => rm(root, { recursive: true }))
|
||||
await Promise.all([
|
||||
writeFile(resolve(root, 'card.png'), png(10, 14, [20, 40, 60, 255])),
|
||||
writeFile(resolve(root, 'card-mask.png'), png(10, 14, [128, 128, 128, 255])),
|
||||
writeFile(resolve(root, 'orphan-text-mask.png'), png(10, 14, [0, 0, 0, 255])),
|
||||
])
|
||||
for (const [bytes, error] of [
|
||||
[png(5, 7, [0, 0, 0, 255]), 'must match artwork dimensions'],
|
||||
[png(10, 14, [0, 1, 0, 255]), 'must be an opaque grayscale mask'],
|
||||
[png(10, 14, [0, 0, 0, 254]), 'must be an opaque grayscale mask'],
|
||||
[Buffer.from('broken PNG'), 'is not a readable PNG'],
|
||||
]) {
|
||||
await writeFile(resolve(root, 'card-text-mask.png'), bytes)
|
||||
const catalog = await scanCardCatalog(root)
|
||||
assert.deepEqual(catalog.cards, [])
|
||||
assert.ok(catalog.errors.some(message => message.includes('card-text-mask.png') && message.includes(error)))
|
||||
assert.ok(catalog.errors.includes('Missing orphan.png for orphan-text-mask.png'))
|
||||
assert.ok(!catalog.errors.some(message => message.includes('orphan-text.png')))
|
||||
}
|
||||
await createCard(root)
|
||||
const path = await resolveCardAssetRequest('/__card_asset/BP-002-david/high/normal/card.png', root)
|
||||
assert.equal(path, resolve(root, 'BP-002-david/high/normal/card.png'))
|
||||
assert.equal(await resolveCardAssetRequest('/__card_asset/BP-002-david/high/normal/manifest.json', root), undefined)
|
||||
assert.equal(await resolveCardAssetRequest('/__card_asset/..%2Foutside/high/normal/card.png', root), undefined)
|
||||
const outside = resolve(root, '..', `outside-${Date.now()}.png`)
|
||||
await writeFile(outside, 'outside')
|
||||
t.after(() => rm(outside))
|
||||
await rm(resolve(root, 'BP-002-david/high/normal/card.png'))
|
||||
await symlink(outside, resolve(root, 'BP-002-david/high/normal/card.png'))
|
||||
assert.equal(await resolveCardAssetRequest('/__card_asset/BP-002-david/high/normal/card.png', root), undefined)
|
||||
})
|
||||
|
||||
@@ -63,12 +63,30 @@ test('advertised optional-mask failures reject loading and clean up every succes
|
||||
}
|
||||
})
|
||||
|
||||
test('runtime catalog validation accepts missing/valid optional URLs and rejects malformed text-mask fields', () => {
|
||||
const catalog = { schemaVersion: 1, cards: [asset], errors: [] }
|
||||
test('runtime catalog validation accepts complete artifact variants and rejects malformed text-mask fields', () => {
|
||||
const artifactAsset = {
|
||||
artwork: '/__card_asset/BP-002-david/high/normal/card.png',
|
||||
mask: '/__card_asset/BP-002-david/high/normal/finish-mask.png',
|
||||
revision: 'test', width: 2000, height: 2800,
|
||||
}
|
||||
const resolutions = ['low', 'med', 'high']
|
||||
const printings = ['normal', 'borderless', 'textless', 'boundless']
|
||||
const variants = Object.fromEntries(resolutions.map(resolution => [resolution,
|
||||
Object.fromEntries(printings.map(printing => [printing, { ...artifactAsset,
|
||||
artwork: `/__card_asset/BP-002-david/${resolution}/${printing}/card.png`,
|
||||
mask: `/__card_asset/BP-002-david/${resolution}/${printing}/finish-mask.png`,
|
||||
}]))
|
||||
]))
|
||||
const card = { cardId: 'BP-002', title: 'David', rarity: 'Blessed', folderName: 'BP-002-david',
|
||||
acceptedRevision: 'accepted', variants }
|
||||
const catalog = { schemaVersion: 2, cards: [card], errors: [] }
|
||||
assert.deepEqual(validateCardCatalog(catalog), catalog)
|
||||
const masked = { ...catalog, cards: [{ ...asset, textMask: '/card-art/test-text-mask.png?v=revision' }] }
|
||||
const masked = structuredClone(catalog)
|
||||
masked.cards[0].variants.high.normal.textMask = '/__card_asset/BP-002-david/high/normal/text-mask.png'
|
||||
assert.deepEqual(validateCardCatalog(masked), masked)
|
||||
for (const textMask of [null, 1, false, '', '/reference/text.png', 'https://example.com/text.png']) {
|
||||
assert.throws(() => validateCardCatalog({ ...catalog, cards: [{ ...asset, textMask }] }), /invalid format/)
|
||||
const malformed = structuredClone(catalog)
|
||||
malformed.cards[0].variants.high.normal.textMask = textMask
|
||||
assert.throws(() => validateCardCatalog(malformed), /invalid format/)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -541,3 +541,59 @@ test('pack frames report motion and touch settling but settled and hidden scenes
|
||||
pack.setActive(false)
|
||||
assert.equal(pack.tick(performance.now()), false)
|
||||
})
|
||||
|
||||
|
||||
test('streamed packs acquire current and next fronts and release outgoing leases', async (t) => {
|
||||
installDOM()
|
||||
let now = 1000
|
||||
t.mock.method(performance, 'now', () => now)
|
||||
const canvas = new TestCanvas()
|
||||
const acquired = []
|
||||
const released = []
|
||||
const placeholder = { artwork: new THREE.Texture(), mask: new THREE.Texture() }
|
||||
const contents = [
|
||||
{ fixture: 'one', finish: 'Holographic', substrate: 'Linen' },
|
||||
{ fixture: 'two', finish: 'Foil', substrate: 'Metal' },
|
||||
{ fixture: 'three', finish: 'Printed ink', substrate: 'Paper' },
|
||||
]
|
||||
const pack = new PackOpening({
|
||||
canvas, contents, placeholderTextures: placeholder,
|
||||
async acquireTextures(spec) {
|
||||
acquired.push(spec.fixture)
|
||||
return { textures: { artwork: new THREE.Texture(), mask: new THREE.Texture() },
|
||||
release: () => released.push(spec.fixture) }
|
||||
},
|
||||
resolveSurfaceDefaults(finish, substrate) {
|
||||
return { finishStrength: finish === 'Holographic' ? 0.33 : 1,
|
||||
roughness: 0.23, normalStrength: substrate === 'Linen' ? 0.05 : 1,
|
||||
metalBrushHorizontal: false }
|
||||
},
|
||||
backMaterial: new THREE.MeshPhysicalMaterial(), normalMap: new THREE.Texture(),
|
||||
environment: new THREE.CubeTexture(), lightPosition: new THREE.Vector3(), onChange() {},
|
||||
})
|
||||
pack.setActive(true)
|
||||
pack.skip()
|
||||
now += 3200
|
||||
pack.tick(now)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
assert.deepEqual(acquired, ['one'])
|
||||
assert.ok(pack.cards.every(card => !card.front.visible))
|
||||
assert.equal(pack.cards[0].material.uniforms.finishStrength.value, 0.33)
|
||||
assert.equal(pack.cards[0].material.uniforms.normalStrength.value, 0.05)
|
||||
|
||||
pack.skip()
|
||||
assert.equal(pack.state, 'lifted')
|
||||
pack.skip()
|
||||
assert.equal(pack.state, 'inspecting')
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
assert.deepEqual(acquired, ['one', 'two'])
|
||||
assert.equal(pack.cards[0].front.visible, true)
|
||||
|
||||
pack.skip()
|
||||
assert.equal(pack.state, 'stackReady')
|
||||
assert.deepEqual(released, ['one'])
|
||||
pack.dispose()
|
||||
assert.deepEqual(released, ['one', 'two'])
|
||||
})
|
||||
|
||||
76
card-harness/scripts/surface-defaults.mjs
Normal file
76
card-harness/scripts/surface-defaults.mjs
Normal file
@@ -0,0 +1,76 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { readFile, rename, writeFile } from 'node:fs/promises'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
export const surfaceDefaultsPath = resolve(here, '..', 'config', 'surface-defaults.json')
|
||||
const finishNames = ['Printed ink', 'Foil', 'Holographic', 'Gold leaf', 'Frosted glass']
|
||||
const materialNames = ['Paper', 'Linen', 'Metal', 'Wood', 'Leather']
|
||||
|
||||
function number(value, min, max, label) {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value < min || value > max) {
|
||||
throw new Error(`${label} must be between ${min} and ${max}`)
|
||||
}
|
||||
}
|
||||
|
||||
export function validateSurfaceDefaults(value) {
|
||||
if (!value || typeof value !== 'object' || value.schemaVersion !== 1 ||
|
||||
!value.finishes || typeof value.finishes !== 'object' ||
|
||||
!value.materials || typeof value.materials !== 'object') {
|
||||
throw new Error('Surface defaults use an unsupported format')
|
||||
}
|
||||
for (const name of finishNames) {
|
||||
const item = value.finishes[name]
|
||||
if (!item || typeof item !== 'object') throw new Error(`Missing defaults for finish ${name}`)
|
||||
if (Object.keys(item).some(key => key !== 'finishStrength')) throw new Error(`Unsupported setting for finish ${name}`)
|
||||
number(item.finishStrength, 0, 1, `${name} finish strength`)
|
||||
}
|
||||
for (const name of materialNames) {
|
||||
const item = value.materials[name]
|
||||
if (!item || typeof item !== 'object') throw new Error(`Missing defaults for material ${name}`)
|
||||
if (Object.keys(item).some(key => !['surfaceDetail', 'roughness', 'metalBrushHorizontal'].includes(key))) {
|
||||
throw new Error(`Unsupported setting for material ${name}`)
|
||||
}
|
||||
number(item.surfaceDetail, 0, 1, `${name} surface detail`)
|
||||
number(item.roughness, 0.05, 0.8, `${name} roughness`)
|
||||
if (item.metalBrushHorizontal !== undefined && typeof item.metalBrushHorizontal !== 'boolean') {
|
||||
throw new Error(`${name} metal grain orientation must be a boolean`)
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function revision(bytes) {
|
||||
return createHash('sha256').update(bytes).digest('hex').slice(0, 16)
|
||||
}
|
||||
|
||||
export async function readSurfaceDefaults(writable = false, path = surfaceDefaultsPath) {
|
||||
const bytes = await readFile(path)
|
||||
return { defaults: validateSurfaceDefaults(JSON.parse(bytes)), revision: revision(bytes), writable }
|
||||
}
|
||||
|
||||
export async function updateSurfaceDefaults(update, path = surfaceDefaultsPath) {
|
||||
const current = await readSurfaceDefaults(true, path)
|
||||
if (!update || update.revision !== current.revision) {
|
||||
const error = new Error('Surface defaults changed since this page loaded; refresh and try again')
|
||||
error.statusCode = 409
|
||||
throw error
|
||||
}
|
||||
const next = structuredClone(current.defaults)
|
||||
if (update.group === 'finish' && finishNames.includes(update.name)) {
|
||||
next.finishes[update.name] = update.values
|
||||
} else if (update.group === 'material' && materialNames.includes(update.name)) {
|
||||
next.materials[update.name] = update.values
|
||||
} else {
|
||||
const error = new Error('Unknown surface-default group or selection')
|
||||
error.statusCode = 400
|
||||
throw error
|
||||
}
|
||||
validateSurfaceDefaults(next)
|
||||
const bytes = `${JSON.stringify(next, null, 2)}\n`
|
||||
const temporary = `${path}.tmp`
|
||||
await writeFile(temporary, bytes)
|
||||
await rename(temporary, path)
|
||||
return { defaults: next, revision: revision(bytes), writable: true }
|
||||
}
|
||||
45
card-harness/scripts/surface-defaults.test.mjs
Normal file
45
card-harness/scripts/surface-defaults.test.mjs
Normal file
@@ -0,0 +1,45 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { resolve } from 'node:path'
|
||||
import { readSurfaceDefaults, updateSurfaceDefaults, validateSurfaceDefaults } from './surface-defaults.mjs'
|
||||
|
||||
const initial = {
|
||||
schemaVersion: 1,
|
||||
finishes: {
|
||||
'Printed ink': { finishStrength: 0.6 }, Foil: { finishStrength: 1 },
|
||||
Holographic: { finishStrength: 0.33 }, 'Gold leaf': { finishStrength: 0.6 },
|
||||
'Frosted glass': { finishStrength: 0.6 },
|
||||
},
|
||||
materials: {
|
||||
Paper: { surfaceDetail: 0.14, roughness: 0.23 },
|
||||
Linen: { surfaceDetail: 0.05, roughness: 0.23 },
|
||||
Metal: { surfaceDetail: 1, roughness: 0.23, metalBrushHorizontal: false },
|
||||
Wood: { surfaceDetail: 0.14, roughness: 0.23 },
|
||||
Leather: { surfaceDetail: 0.45, roughness: 0.23 },
|
||||
},
|
||||
}
|
||||
|
||||
test('surface defaults validate requested finish/material strengths and reject unknown settings', () => {
|
||||
assert.equal(validateSurfaceDefaults(structuredClone(initial)).finishes.Holographic.finishStrength, 0.33)
|
||||
const malformed = structuredClone(initial)
|
||||
malformed.materials.Linen.shaderSecret = 1
|
||||
assert.throws(() => validateSurfaceDefaults(malformed), /Unsupported setting/)
|
||||
})
|
||||
|
||||
test('surface default saves are atomic, scoped, and revision checked', async (t) => {
|
||||
const directory = await mkdtemp(resolve(tmpdir(), 'surface-defaults-'))
|
||||
t.after(() => rm(directory, { recursive: true }))
|
||||
const path = resolve(directory, 'defaults.json')
|
||||
await writeFile(path, `${JSON.stringify(initial, null, 2)}\n`)
|
||||
const before = await readSurfaceDefaults(true, path)
|
||||
const after = await updateSurfaceDefaults({ revision: before.revision, group: 'finish',
|
||||
name: 'Holographic', values: { finishStrength: 0.41 } }, path)
|
||||
assert.equal(after.defaults.finishes.Holographic.finishStrength, 0.41)
|
||||
assert.deepEqual(after.defaults.materials, initial.materials)
|
||||
assert.notEqual(after.revision, before.revision)
|
||||
await assert.rejects(updateSurfaceDefaults({ revision: before.revision, group: 'material',
|
||||
name: 'Linen', values: { surfaceDetail: 0.08, roughness: 0.23 } }, path), /changed since/)
|
||||
assert.doesNotMatch(await readFile(path, 'utf8'), /\.tmp/)
|
||||
})
|
||||
Reference in New Issue
Block a user