diff --git a/card-harness/CURRENT-DEFAULTS.md b/card-harness/CURRENT-DEFAULTS.md index f1bb4e0..451ff0c 100644 --- a/card-harness/CURRENT-DEFAULTS.md +++ b/card-harness/CURRENT-DEFAULTS.md @@ -82,7 +82,7 @@ Available inspection poses: | Back | `(-0.06, 180 degrees + 0.12 radians, 0)` | | Free | Current manually controlled rotation | -Reset restores the Front pose, David, Holographic finish, Paper substrate, default surface values, default camera, and default light. +Reset restores the Front pose, BP-002 David, High/Normal, Holographic finish, Paper substrate, repository surface defaults, default camera, and default light. ## Light @@ -114,9 +114,9 @@ Changing a light type, color, position, intensity, environment contribution, or |---|---:|---:| | Finish | Holographic | Printed ink / Foil / Holographic / Gold leaf / Frosted glass | | Material | Paper | Paper / Linen / Metal / Wood / Leather | -| Finish strength | 0.60 | 0-1 | -| Roughness | 0.23 | 0.05-0.8 | -| Surface detail | 0.14 (Leather selection: 0.45) | 0-1.0 | +| Finish strength | Per finish: Holographic `0.33`, Foil `1.00`, others `0.60` | 0-1 | +| Roughness | Per material; initially `0.23` | 0.05-0.8 | +| Surface detail | Paper/Wood `0.14`, Linen `0.05`, Metal `1.00`, Leather `0.45` | 0-1.0 | | Horizontal metal grain (Lab only) | Off (Vertical) | Off / On (Horizontal) | | Environment intensity | 0.70 | 0-1.5 | | Condition | 1.0 (mint) | 0-1 | @@ -128,10 +128,12 @@ September 12 material tuning (pending visual review): Roughness now contributes 75% of effective substrate roughness, with a 25% material bias. Every step across `0.05-0.8` changes highlight width; the former substrate floors/ceilings are removed. Surface detail accepts `0-1.0` in Lab and saved experiment imports. Selecting a -material restores its detail default: Leather `0.45`, all others `0.14`. Imported -experiments retain their explicit detail value (clamped only to `0-1.0`); Reset -restores Paper with detail `0.14`. Existing relief caps remain to bound parallax -and prevent extreme recesses. Random Leather pack cards also use `0.45`. +finish or material applies its entry from `config/surface-defaults.json`; Lab can +reapply or save the current finish/material values to that repository file while +the development server is running. Imported experiments retain their explicit +values (clamped only to supported ranges); Reset resolves Holographic/Paper from +the same file. Existing relief caps remain to bound parallax and prevent extreme +recesses. New pack cards also resolve their rolled finish/material through the file. Leather, Gold leaf, and Frosted glass are experimental @@ -304,16 +306,16 @@ Condition uses `1.0` for mint and `0.0` for the most worn supported appearance. ## Local Asset Evaluation -Lab mode can replace the active artwork or finish mask with a local image through the browser file picker. Local files remain in browser memory, are not copied into the project, and are discarded when the page reloads or the selected fixture is restored. Reset restores the David reference artwork and mask. +Lab mode can replace the active artwork or finish mask with a local image through the browser file picker. Local files remain in browser memory, are not copied into the project, and are discarded when the page reloads or the selected card is restored. Reset restores BP-002 David at High/Normal. ## Experiment Snapshots -Lab mode can save named experiments in browser storage, export or import a versioned JSON snapshot, and capture the current canvas as PNG. Snapshot schema version 2 records: +Lab mode can save named experiments in browser storage, export or import a versioned JSON snapshot, and capture the current canvas as PNG. Snapshot schema version 3 records the stable card ID, accepted revision, resolution, and printing in addition to: - `metalBrushHorizontal`: boolean (`false` = Vertical, `true` = Horizontal), including browser saves and JSON exports; omitted fields in older experiments restore Horizontal. Reset and all pack cards use Vertical. - Runtime-look revision and save time -- Reference fixture and custom asset filenames +- Card selection and custom asset filenames - Finish, substrate, and surface controls - Condition and imperfection seed - Lighting preset, environment, type, color, position, intensity, and exposure @@ -321,13 +323,13 @@ Lab mode can save named experiments in browser storage, export or import a versi Local image contents are intentionally not embedded in snapshots. When a snapshot records custom artwork or a custom mask, those files must be loaded again after restoring the configuration. -Schema version 1 snapshots remain loadable and restore as mint condition with seed `81251`. +Schema versions 1 and 2 remain loadable through the legacy fixture-name mapping; version 1 restores mint condition with seed `81251`. ## Source of Truth - Geometry: `src/cardGeometry.ts` - Runtime shader: `src/cardMaterial.ts` -- Scene and control defaults: `src/main.ts` +- Surface defaults: `config/surface-defaults.json`; scene/control behavior: `src/main.ts` - Generated asset contract: `public/reference/manifest.json` - Art policy: `../art-direction.md` @@ -338,7 +340,7 @@ Pack is a third mode beside Inspect and Lab. It does not revise - Controller: `src/packOpening.ts`; foil meshes: `src/foilWrapper.ts`; integration and lazy local-asset loading: `src/main.ts`. - States: `sealed → opening → stackReady → lifting → lifted → revealing → inspecting → advancing → stackReady / complete`. -- Order: David / Printed ink / Linen; Timothy / Foil / Paper; David / Holographic / Metal. Surface defaults remain strength `0.60`, roughness `0.23`, detail `0.14`, mint condition and seed `81251`. Pack inherits the current lighting. +- Each ten-card pack rolls accepted card IDs, finishes, and materials independently. It uses the selected resolution/printing (High/Normal by default), resolves surface presets from `config/surface-defaults.json`, and inherits current lighting. Only current/next fronts are acquired; an outgoing texture lease is released after its advance completes. - Wrapper: two `64 × 80` subdivided pouch surfaces joined at their side/bottom boundaries and one `64 × 10` top ribbon, sharing horizontal samples along the attached seam. Total 21,760 triangles. Dimensions: width `3.36`, bottom `-2.24`, tear seam `1.97`, top `2.28`; center depth `-0.09`, inflated half-depth `0.37`. Surface folds are shallow deterministic UV-based displacements; contact dent is bounded to `0.045` units. Side and bottom crimps remain separated surfaces, with no overlapping cap/label meshes or transparent fades. - Foil material: deep teal printed canvas, warm gold seam and restrained typography; standard material roughness `0.43`, metalness `0.68`, double-sided. The existing scene lighting/environment lights the foil. This is a wrapper-specific recipe, not a card-material revision. - Opening lasts `3200 ms` from sealed (remaining duration scales with retained progress); lift `620 ms`, reveal `820 ms`, advance `650 ms`. Elapsed-time smoothstep interpolation and quaternion rotation remain unchanged for card transitions. diff --git a/card-harness/README.md b/card-harness/README.md index cd39de8..9f9df06 100644 --- a/card-harness/README.md +++ b/card-harness/README.md @@ -6,10 +6,10 @@ The approved visual baseline is recorded in [`CURRENT-DEFAULTS.md`](CURRENT-DEFA ## Included proof scope -- David printed-ink, foil, and holographic presets -- Independently selectable paper, linen, metal, and wood substrates -- Timothy as the second-artwork fixture -- David color-based coverage and title-panel protection; automatic saturation/value coverage for Timothy +- Searchable accepted-card library sourced directly from `artifacts/cards` +- Low, Med, and High resolution plus Normal, Borderless, Textless, and Boundless printing controls +- Independently selectable finishes and paper, linen, metal, wood, and leather substrates +- Per-finish and per-material defaults editable from Lab and versioned in the repository - Procedural rounded-card geometry generated from shared physical dimensions - Deterministic linen normal map - Card manipulation, camera inspection, zoom, frame-rate-independent flip/reset, interruption-safe sweep, and movable lighting @@ -190,11 +190,19 @@ Timothy regression artwork and universal card back from the project root, and generates the two finish masks and linen normal map. Startup and production builds run this automatically. No spike assets are required. -Inspect/Lab and Pack load artwork and masks from `public/card-art`; the generated -David and Timothy fixtures are retained for shader regression checks. The app -uses the generated universal back and linen normal map. Card geometry is generated +Inspect/Lab and Pack discover approved card packages directly from `../artifacts/cards`. +The development server exposes only allowlisted runtime PNGs through a contained +asset route; no copies are required in `public/card-art`. The generated David and +Timothy files in `public/reference` remain shader-regression fixtures. The app uses +the generated universal back and linen normal map. Card geometry is generated directly in Three.js from the dimensions recorded in the manifest. +The toolbar defaults to High and exposes Low, Med, High plus all four printings. +The Card button opens a searchable, rarity-filterable accepted-card drawer whose +lazy thumbnails always use Low/Normal. Pack supports the same resolution and +printing selection. It streams only the current and next front, sharing duplicate +assets and releasing outgoing textures; hidden High fronts are not loaded en masse. + The current runtime reference revision is `runtime-look-v4-2026-09-07`. The approved linen, metal, and wood depth follow-ups change runtime shader behavior, not the source artwork or generated normal map. The asset manifest therefore retains its @@ -270,11 +278,11 @@ The browser/version, viewport, drawing-buffer resolution, texture settings, and ## Optional text masks -Place `[image-name]-text-mask.png` next to `[image-name].png` and the required -`[image-name]-mask.png` in `public/card-art`. It must match artwork dimensions -and be opaque grayscale: **white protects lettering, black retains the material**. -Generate it from the rendered text layer with a small, recorded feathered margin; -it is separate from finish coverage and card silhouette alpha. +Accepted Normal and Borderless packages provide `text-mask.png` beside `card.png` +and `finish-mask.png` in each resolution/printing directory. It must match artwork +dimensions and be opaque grayscale: **white protects lettering, black retains the +material**. Generate it from the rendered text layer with a small, recorded feathered +margin; it is separate from finish coverage and card silhouette alpha. The catalog exposes `textMask` only when the optional file exists, validates it, and includes its bytes in cache revisions. Inspect/Lab and selected pack cards load @@ -318,6 +326,9 @@ motion timings; idle gaps are excluded. Timing windows reset on mode changes. ResizeObserver handles canvas layout changes. Pack cards share two geometry buffers while retaining their individual transforms and materials. +Diagnostic readouts and timing-recording controls are hidden by default. **Show diagnostics** +in the toolbar reveals them; the current card/material status remains visible either way. + The diagnostic display also reports median CPU scene-update/render-submission time, draw calls, resident renderer texture count, and shader program count. CPU submission time can include driver waits; it is not a direct GPU timing measurement. Resource @@ -332,9 +343,9 @@ Card faces now cull inward-facing triangles; the wrapper remains double-sided fo its curls/opening. Uniform-only edge changes reuse the material shader version. The overlay no longer blurs the animated canvas beneath it. -Pack resources stay resident after switching modes to preserve its state -and avoid repeating preparation. Catalog entries alone do not load artwork: -Inspect/Lab load the current fixture, and Pack loads its selected artwork only. +Pack wrapper, geometry, and placeholder resources stay resident after switching +modes to preserve state. Catalog entries alone do not load artwork: Inspect/Lab load +the selected variant, while Pack keeps at most the current and next front acquired. For a useful comparison, keep viewport, card, pose, lighting, and motion identical; allow at least four seconds for the 240-frame window to turn over at 60 Hz (longer diff --git a/card-harness/config/surface-defaults.json b/card-harness/config/surface-defaults.json new file mode 100644 index 0000000..adb0bca --- /dev/null +++ b/card-harness/config/surface-defaults.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 1, + "finishes": { + "Printed ink": { + "finishStrength": 0.6 + }, + "Foil": { + "finishStrength": 1.0 + }, + "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.0, + "roughness": 0.23, + "metalBrushHorizontal": false + }, + "Wood": { + "surfaceDetail": 0.14, + "roughness": 0.23 + }, + "Leather": { + "surfaceDetail": 0.45, + "roughness": 0.23 + } + } +} diff --git a/card-harness/public/card-art/README.md b/card-harness/public/card-art/README.md deleted file mode 100644 index 4696aa5..0000000 --- a/card-harness/public/card-art/README.md +++ /dev/null @@ -1,21 +0,0 @@ -# Card Art Drop Folder - -Add each card as one PNG artwork and one matching PNG finish mask: - -```text -david.png -david-mask.png -``` - -The filename before `.png` is the card name shown in the harness. Names are -case-sensitive. Artwork and mask must: - -- have identical dimensions; -- use a 5:7 aspect ratio; -- be fully opaque; -- use an opaque grayscale mask. - -During `npm run dev`, adding or replacing either PNG refreshes the searchable -card catalog automatically. Reopening or focusing the Card field also forces a -rescan. Production builds generate `manifest.json` from this folder and fail if -any pair is missing or invalid. diff --git a/card-harness/public/card-art/david-mask.png b/card-harness/public/card-art/david-mask.png deleted file mode 100644 index 23b1764..0000000 Binary files a/card-harness/public/card-art/david-mask.png and /dev/null differ diff --git a/card-harness/public/card-art/david-text-mask.png b/card-harness/public/card-art/david-text-mask.png deleted file mode 100644 index 661c7e3..0000000 Binary files a/card-harness/public/card-art/david-text-mask.png and /dev/null differ diff --git a/card-harness/public/card-art/david.png b/card-harness/public/card-art/david.png deleted file mode 100644 index 311e32a..0000000 Binary files a/card-harness/public/card-art/david.png and /dev/null differ diff --git a/card-harness/public/card-art/manifest.json b/card-harness/public/card-art/manifest.json deleted file mode 100644 index ce56541..0000000 --- a/card-harness/public/card-art/manifest.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "schemaVersion": 1, - "cards": [ - { - "name": "david", - "artwork": "/card-art/david.png?v=cc45a552b6516d2b", - "mask": "/card-art/david-mask.png?v=cc45a552b6516d2b", - "textMask": "/card-art/david-text-mask.png?v=cc45a552b6516d2b", - "revision": "cc45a552b6516d2b", - "width": 1000, - "height": 1400 - }, - { - "name": "the-fall", - "artwork": "/card-art/the-fall.png?v=ff92f7967bf1217b", - "mask": "/card-art/the-fall-mask.png?v=ff92f7967bf1217b", - "textMask": "/card-art/the-fall-text-mask.png?v=ff92f7967bf1217b", - "revision": "ff92f7967bf1217b", - "width": 1000, - "height": 1400 - }, - { - "name": "timothy", - "artwork": "/card-art/timothy.png?v=04a57871358d4c6d", - "mask": "/card-art/timothy-mask.png?v=04a57871358d4c6d", - "textMask": "/card-art/timothy-text-mask.png?v=04a57871358d4c6d", - "revision": "04a57871358d4c6d", - "width": 2000, - "height": 2800 - } - ], - "errors": [] -} diff --git a/card-harness/public/card-art/the-fall-mask.png b/card-harness/public/card-art/the-fall-mask.png deleted file mode 100644 index ffcb375..0000000 Binary files a/card-harness/public/card-art/the-fall-mask.png and /dev/null differ diff --git a/card-harness/public/card-art/the-fall-text-mask.png b/card-harness/public/card-art/the-fall-text-mask.png deleted file mode 100644 index 24bd456..0000000 Binary files a/card-harness/public/card-art/the-fall-text-mask.png and /dev/null differ diff --git a/card-harness/public/card-art/the-fall.png b/card-harness/public/card-art/the-fall.png deleted file mode 100644 index 72e32c8..0000000 Binary files a/card-harness/public/card-art/the-fall.png and /dev/null differ diff --git a/card-harness/public/card-art/timothy-mask.png b/card-harness/public/card-art/timothy-mask.png deleted file mode 100644 index c8f5a6d..0000000 Binary files a/card-harness/public/card-art/timothy-mask.png and /dev/null differ diff --git a/card-harness/public/card-art/timothy-text-mask.png b/card-harness/public/card-art/timothy-text-mask.png deleted file mode 100644 index 6b50676..0000000 Binary files a/card-harness/public/card-art/timothy-text-mask.png and /dev/null differ diff --git a/card-harness/public/card-art/timothy.png b/card-harness/public/card-art/timothy.png deleted file mode 100644 index e6dc92a..0000000 Binary files a/card-harness/public/card-art/timothy.png and /dev/null differ diff --git a/card-harness/scripts/card-catalog.mjs b/card-harness/scripts/card-catalog.mjs index 6702b08..14e0385 100644 --- a/card-harness/scripts/card-catalog.mjs +++ b/card-harness/scripts/card-catalog.mjs @@ -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 } diff --git a/card-harness/scripts/card-catalog.test.mjs b/card-harness/scripts/card-catalog.test.mjs index 601cf0c..816aed6 100644 --- a/card-harness/scripts/card-catalog.test.mjs +++ b/card-harness/scripts/card-catalog.test.mjs @@ -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) }) diff --git a/card-harness/scripts/card-textures.test.mjs b/card-harness/scripts/card-textures.test.mjs index 92fb69e..d476355 100644 --- a/card-harness/scripts/card-textures.test.mjs +++ b/card-harness/scripts/card-textures.test.mjs @@ -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/) } }) diff --git a/card-harness/scripts/pack-performance.test.mjs b/card-harness/scripts/pack-performance.test.mjs index c79e60d..9953f57 100644 --- a/card-harness/scripts/pack-performance.test.mjs +++ b/card-harness/scripts/pack-performance.test.mjs @@ -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']) +}) diff --git a/card-harness/scripts/surface-defaults.mjs b/card-harness/scripts/surface-defaults.mjs new file mode 100644 index 0000000..c176202 --- /dev/null +++ b/card-harness/scripts/surface-defaults.mjs @@ -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 } +} diff --git a/card-harness/scripts/surface-defaults.test.mjs b/card-harness/scripts/surface-defaults.test.mjs new file mode 100644 index 0000000..bd06df8 --- /dev/null +++ b/card-harness/scripts/surface-defaults.test.mjs @@ -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/) +}) diff --git a/card-harness/src/cardCatalog.ts b/card-harness/src/cardCatalog.ts index fecac8c..89d6f44 100644 --- a/card-harness/src/cardCatalog.ts +++ b/card-harness/src/cardCatalog.ts @@ -1,5 +1,9 @@ +export const resolutionNames = ['low', 'med', 'high'] as const +export const printingNames = ['normal', 'borderless', 'textless', 'boundless'] as const +export type CardResolution = typeof resolutionNames[number] +export type CardPrinting = typeof printingNames[number] + export interface CardAsset { - name: string artwork: string mask: string textMask?: string @@ -7,56 +11,57 @@ export interface CardAsset { width: number height: number } - -export interface CardCatalog { - schemaVersion: 1 - cards: CardAsset[] - errors: string[] +export interface CatalogCard { + cardId: string + title: string + rarity: string + folderName: string + acceptedRevision: string + variants: Record> } +export interface CardCatalog { schemaVersion: 2; cards: CatalogCard[]; errors: string[] } -function isCardAsset(value: unknown): value is CardAsset { +function isAsset(value: unknown): value is CardAsset { if (typeof value !== 'object' || value === null) return false - const card = value as Record - return typeof card.name === 'string' && card.name.length > 0 && - typeof card.artwork === 'string' && card.artwork.startsWith('/card-art/') && - typeof card.mask === 'string' && card.mask.startsWith('/card-art/') && - (card.textMask === undefined || (typeof card.textMask === 'string' && card.textMask.startsWith('/card-art/'))) && - typeof card.revision === 'string' && - Number.isInteger(card.width) && Number.isInteger(card.height) + const asset = value as Record + return typeof asset.artwork === 'string' && asset.artwork.startsWith('/__card_asset/') && + typeof asset.mask === 'string' && asset.mask.startsWith('/__card_asset/') && + (asset.textMask === undefined || (typeof asset.textMask === 'string' && asset.textMask.startsWith('/__card_asset/'))) && + typeof asset.revision === 'string' && Number.isInteger(asset.width) && Number.isInteger(asset.height) } - -export async function fetchCardCatalog(): Promise { - const urls = import.meta.env.DEV - ? ['/__card_catalog', '/card-art/manifest.json'] - : ['/card-art/manifest.json'] - const failures: string[] = [] - for (const url of urls) { - try { - const response = await fetch(url, { cache: 'no-store' }) - if (!response.ok) throw new Error(`request failed (${response.status})`) - const contentType = response.headers.get('content-type') ?? '' - if (!contentType.includes('application/json')) { - throw new Error(`expected JSON but received ${contentType || 'an unknown content type'}`) - } - return validateCardCatalog(await response.json()) - } catch (error) { - failures.push(`${url}: ${error instanceof Error ? error.message : String(error)}`) - } - } - throw new Error(`Card catalog unavailable: ${failures.join('; ')}`) +function isCard(value: unknown): value is CatalogCard { + if (typeof value !== 'object' || value === null) return false + const card = value as Record, variants = card.variants as Record | undefined + return typeof card.cardId === 'string' && typeof card.title === 'string' && typeof card.rarity === 'string' && + typeof card.folderName === 'string' && typeof card.acceptedRevision === 'string' && !!variants && + resolutionNames.every(resolution => { + const group = variants[resolution] as Record | undefined + return !!group && printingNames.every(printing => isAsset(group[printing])) + }) } - export function validateCardCatalog(value: unknown): CardCatalog { if (typeof value !== 'object' || value === null) throw new Error('Card catalog is not an object') const catalog = value as Record - if ( - catalog.schemaVersion !== 1 || - !Array.isArray(catalog.cards) || - !catalog.cards.every(isCardAsset) || - !Array.isArray(catalog.errors) || - !catalog.errors.every(error => typeof error === 'string') - ) { + if (catalog.schemaVersion !== 2 || !Array.isArray(catalog.cards) || !catalog.cards.every(isCard) || + !Array.isArray(catalog.errors) || !catalog.errors.every(error => typeof error === 'string')) { throw new Error('Card catalog has an unsupported or invalid format') } return catalog as unknown as CardCatalog } +export async function fetchCardCatalog(): Promise { + const response = await fetch('/__card_catalog', { cache: 'no-store' }) + if (!response.ok) throw new Error(`Card catalog request failed (${response.status})`) + const type = response.headers.get('content-type') ?? '' + if (!type.includes('application/json')) throw new Error(`Card catalog returned ${type || 'unknown content'}`) + return validateCardCatalog(await response.json()) +} +export function resolveCardAsset(card: CatalogCard, resolution: CardResolution, printing: CardPrinting) { + return card.variants[resolution][printing] +} +export function cardLabel(card: CatalogCard) { return `${card.title} · ${card.cardId}` } +export function findLegacyCard(cards: readonly CatalogCard[], value: string) { + const normalized = value.trim().toLocaleLowerCase() + return cards.find(card => card.cardId.toLocaleLowerCase() === normalized || + card.title.toLocaleLowerCase() === normalized || card.folderName.toLocaleLowerCase() === normalized || + card.folderName.slice(card.cardId.length + 1).toLocaleLowerCase() === normalized) +} diff --git a/card-harness/src/cardPicker.ts b/card-harness/src/cardPicker.ts new file mode 100644 index 0000000..4254100 --- /dev/null +++ b/card-harness/src/cardPicker.ts @@ -0,0 +1,123 @@ +import { cardLabel, type CatalogCard } from './cardCatalog' + +type SelectCard = (card: CatalogCard) => void + +export class CardPicker { + private cards: readonly CatalogCard[] = [] + private selectedCardId = '' + private visibleCount = 60 + private readonly dialog = document.createElement('dialog') + private readonly search = document.createElement('input') + private readonly rarity = document.createElement('select') + private readonly count = document.createElement('span') + private readonly results = document.createElement('div') + private readonly onSelect: SelectCard + + constructor(cards: readonly CatalogCard[], onSelect: SelectCard) { + this.onSelect = onSelect + this.dialog.className = 'card-picker' + this.dialog.setAttribute('aria-labelledby', 'card-picker-title') + this.search.type = 'search' + this.search.placeholder = 'Search title or card ID' + this.search.autocomplete = 'off' + this.search.spellcheck = false + this.search.setAttribute('aria-label', 'Search accepted cards') + this.rarity.setAttribute('aria-label', 'Filter by rarity') + this.results.className = 'card-picker-results' + this.results.addEventListener('scroll', () => { + if (this.results.scrollTop + this.results.clientHeight >= this.results.scrollHeight - 320) { + this.visibleCount += 60 + this.render() + } + }) + this.dialog.innerHTML = ` +
+

Accepted card library

Choose a card

+ +
+
+ ` + this.dialog.querySelector('.card-picker-filters')!.append(this.search, this.rarity, this.count) + this.dialog.append(this.results) + document.body.append(this.dialog) + this.dialog.querySelector('.card-picker-close')!.addEventListener('click', () => this.close()) + this.dialog.addEventListener('click', event => { if (event.target === this.dialog) this.close() }) + this.dialog.addEventListener('cancel', event => { event.preventDefault(); this.close() }) + this.search.addEventListener('input', () => { this.visibleCount = 60; this.render() }) + this.rarity.addEventListener('change', () => { this.visibleCount = 60; this.render() }) + this.update(cards) + } + + update(cards: readonly CatalogCard[]) { + this.cards = cards + const selectedRarity = this.rarity.value + const rarities = [...new Set(cards.map(card => card.rarity))].sort() + this.rarity.replaceChildren( + new Option('All rarities', ''), + ...rarities.map(value => new Option(value, value)), + ) + if (rarities.includes(selectedRarity)) this.rarity.value = selectedRarity + this.render() + } + + open(selectedCardId: string) { + this.selectedCardId = selectedCardId + this.visibleCount = 60 + this.render() + this.dialog.showModal() + requestAnimationFrame(() => this.search.focus()) + } + + close() { if (this.dialog.open) this.dialog.close() } + + adjacent(selectedCardId: string, delta: -1 | 1) { + const index = this.cards.findIndex(card => card.cardId === selectedCardId) + if (index < 0 || !this.cards.length) return undefined + return this.cards[(index + delta + this.cards.length) % this.cards.length] + } + + private filteredCards() { + const words = this.search.value.trim().toLocaleLowerCase().split(/\s+/).filter(Boolean) + return this.cards.filter(card => { + if (this.rarity.value && card.rarity !== this.rarity.value) return false + const haystack = `${card.title} ${card.cardId} ${card.rarity}`.toLocaleLowerCase() + return words.every(word => haystack.includes(word)) + }) + } + + private render() { + const filtered = this.filteredCards() + this.count.textContent = `${filtered.length} card${filtered.length === 1 ? '' : 's'}` + const fragment = document.createDocumentFragment() + for (const card of filtered.slice(0, this.visibleCount)) { + const button = document.createElement('button') + button.type = 'button' + button.className = 'card-picker-result' + button.classList.toggle('selected', card.cardId === this.selectedCardId) + button.setAttribute('aria-label', `Choose ${cardLabel(card)}, ${card.rarity}`) + const image = document.createElement('img') + image.src = card.variants.low.normal.artwork + image.alt = '' + image.loading = 'lazy' + image.decoding = 'async' + const copy = document.createElement('span') + copy.innerHTML = `` + copy.querySelector('strong')!.textContent = card.title + copy.querySelector('small')!.textContent = `${card.cardId} · ${card.rarity}` + button.append(image, copy) + button.addEventListener('click', () => { + this.selectedCardId = card.cardId + this.close() + this.onSelect(card) + }) + fragment.append(button) + } + this.results.replaceChildren(fragment) + if (!filtered.length) { + const empty = document.createElement('p') + empty.className = 'card-picker-empty' + empty.textContent = 'No accepted cards match this search.' + this.results.append(empty) + } + } +} diff --git a/card-harness/src/experimentSnapshot.ts b/card-harness/src/experimentSnapshot.ts index 0660cc8..1a5d184 100644 --- a/card-harness/src/experimentSnapshot.ts +++ b/card-harness/src/experimentSnapshot.ts @@ -3,6 +3,7 @@ import { finishNames, substrateNames, surfaceDetailMax, type FinishName, type SubstrateName, type MaterialControls, } from './cardMaterial' +import { printingNames, resolutionNames, type CardPrinting, type CardResolution } from './cardCatalog' type FixtureName = string type LightingPreset = 'Studio' | 'Warm gallery' | 'Cool window' | 'Dramatic spot' | 'Flat review' | 'Custom' @@ -10,11 +11,15 @@ type EnvironmentName = 'Studio' | 'Warm' | 'Cool' | 'Dark' | 'Neutral' type LightType = 'Point' | 'Directional' | 'Spot' export interface ExperimentSnapshot { - schemaVersion: 1 | 2 + schemaVersion: 1 | 2 | 3 runtimeRevision: 'runtime-look-v4-2026-09-07' name: string savedAt: string - fixture: FixtureName + fixture?: FixtureName + cardId?: string + acceptedRevision?: string + resolution?: CardResolution + printing?: CardPrinting customArtworkName?: string customMaskName?: string finish: FinishName @@ -52,7 +57,11 @@ function isVectorTuple(value: unknown): value is [number, number, number] { export function isExperimentSnapshot(value: unknown): value is ExperimentSnapshot { if (typeof value !== 'object' || value === null) return false const snapshot = value as Record - const fixtureValid = typeof snapshot.fixture === 'string' && snapshot.fixture.length > 0 + const legacyFixtureValid = typeof snapshot.fixture === 'string' && snapshot.fixture.length > 0 + const cardSelectionValid = typeof snapshot.cardId === 'string' && snapshot.cardId.length > 0 && + typeof snapshot.acceptedRevision === 'string' && + resolutionNames.some(name => name === snapshot.resolution) && + printingNames.some(name => name === snapshot.printing) const finishValid = finishNames.some(name => name === snapshot.finish) const substrateValid = snapshot.substrate === 'Plastic' || substrateNames.some(name => name === snapshot.substrate) @@ -77,7 +86,8 @@ export function isExperimentSnapshot(value: unknown): value is ExperimentSnapsho (snapshot.customArtworkName === undefined || typeof snapshot.customArtworkName === 'string') && (snapshot.customMaskName === undefined || typeof snapshot.customMaskName === 'string') - const schemaValid = snapshot.schemaVersion === 1 || snapshot.schemaVersion === 2 + const schemaValid = snapshot.schemaVersion === 1 || snapshot.schemaVersion === 2 || snapshot.schemaVersion === 3 + const selectionValid = snapshot.schemaVersion === 3 ? cardSelectionValid : legacyFixtureValid const wearValid = snapshot.schemaVersion === 1 || (isFiniteNumber(snapshot.condition) && isFiniteNumber(snapshot.imperfectionSeed)) @@ -85,7 +95,7 @@ export function isExperimentSnapshot(value: unknown): value is ExperimentSnapsho snapshot.runtimeRevision === 'runtime-look-v4-2026-09-07' && typeof snapshot.name === 'string' && typeof snapshot.savedAt === 'string' && - fixtureValid && + selectionValid && optionalNamesValid && finishValid && substrateValid && diff --git a/card-harness/src/main.ts b/card-harness/src/main.ts index 08d57c5..2a35292 100644 --- a/card-harness/src/main.ts +++ b/card-harness/src/main.ts @@ -8,7 +8,6 @@ import { applyMaterialControls, createCardMaterial, createStudioCubeTexture, - defaultSurfaceDetail, surfaceDetailMax, finishNames, substrateNames, @@ -21,10 +20,15 @@ import { isExperimentSnapshot, type ExperimentSnapshot, } from './experimentSnapshot' import { createCardGeometry } from './cardGeometry' -import { PackOpening, packSize, rollPackContents } from './packOpening' +import { PackOpening, packSize, rollPackContents, type CardTextureLease, type PackCardSpec } from './packOpening' import { defaultPackStyle, packStyles } from './packDesigns' -import { fetchCardCatalog, type CardAsset, type CardCatalog } from './cardCatalog' -import { loadCardTextures, disposeCardTextures } from './cardTextures' +import { + cardLabel, fetchCardCatalog, findLegacyCard, printingNames, resolutionNames, resolveCardAsset, + type CardCatalog, type CardPrinting, type CardResolution, +} from './cardCatalog' +import { CardPicker } from './cardPicker' +import { SurfaceDefaultsStore } from './surfaceDefaults' +import { loadCardTextures, disposeCardTextures, type CardTextures } from './cardTextures' import { RenderCadence } from './renderCadence' import { PerformanceRecording } from './performanceRecording' import { drawingPixelRatio, FramePacing, GpuTimer, median, rendererIdentity } from './renderDiagnostics' @@ -48,9 +52,13 @@ let displayedIdle = false let observedPixelRatio = window.devicePixelRatio let cardCatalog = await fetchCardCatalog() -if (!cardCatalog.cards.length) throw new Error('The card-art folder contains no valid artwork/mask pairs') -let fixtureAssets = new Map(cardCatalog.cards.map(card => [card.name, card])) -let defaultFixture = fixtureAssets.has('David') ? 'David' : cardCatalog.cards[0].name +if (!cardCatalog.cards.length) throw new Error('The accepted artifacts catalog contains no valid cards') +let fixtureAssets = new Map(cardCatalog.cards.map(card => [card.cardId, card])) +let defaultFixture = fixtureAssets.has('BP-002') ? 'BP-002' : cardCatalog.cards[0].cardId +let selectedResolution: CardResolution = 'high' +let selectedPrinting: CardPrinting = 'normal' +const defaultsStore = new SurfaceDefaultsStore() +await defaultsStore.refresh() function configureFrontTexture(texture: THREE.Texture, colorSpace: THREE.ColorSpace) { texture.colorSpace = colorSpace @@ -89,10 +97,23 @@ document.querySelector('#app')!.innerHTML = ` ${packStyles.map(style => `${style}`).join('')} -