added thumbs generation for performation and also added a new deck format for registration

This commit is contained in:
2026-03-08 05:40:53 -07:00
parent 78abb582dd
commit 4713bbd54b
11 changed files with 1255 additions and 44 deletions

View File

@@ -8,6 +8,15 @@ const ignoredFolderNames = new Set(["template", "templates", "example", "example
const tarotSuits = ["wands", "cups", "swords", "disks"];
const majorTrumpNumbers = Array.from({ length: 22 }, (_, index) => index);
const expectedMinorCardCount = 56;
const defaultPipRankOrder = ["Ace", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight", "Nine", "Ten"];
const defaultThumbnailConfig = {
root: "thumbs",
width: 240,
height: 360,
fit: "inside",
quality: 82
};
const supportedThumbnailFits = new Set(["contain", "cover", "fill", "inside", "outside"]);
const cardBackCandidateExtensions = ["webp", "png", "jpg", "jpeg", "avif", "gif"];
function isPlainObject(value) {
@@ -61,6 +70,71 @@ function readManifestJson(manifestPath) {
}
}
function normalizeThumbnailConfig(thumbnails) {
if (thumbnails == null) {
return null;
}
if (thumbnails === false) {
return false;
}
if (!isPlainObject(thumbnails)) {
return null;
}
return {
root: asNonEmptyString(thumbnails.root) || defaultThumbnailConfig.root,
width: Number.isInteger(Number(thumbnails.width)) && Number(thumbnails.width) > 0
? Number(thumbnails.width)
: defaultThumbnailConfig.width,
height: Number.isInteger(Number(thumbnails.height)) && Number(thumbnails.height) > 0
? Number(thumbnails.height)
: defaultThumbnailConfig.height,
fit: asNonEmptyString(thumbnails.fit) || defaultThumbnailConfig.fit,
quality: Number.isInteger(Number(thumbnails.quality)) && Number(thumbnails.quality) >= 1 && Number(thumbnails.quality) <= 100
? Number(thumbnails.quality)
: defaultThumbnailConfig.quality
};
}
function validateThumbnailConfig(thumbnails) {
if (thumbnails == null || thumbnails === false) {
return null;
}
if (!isPlainObject(thumbnails)) {
return "thumbnails must be an object or false when provided";
}
const root = asNonEmptyString(thumbnails.root) || defaultThumbnailConfig.root;
if (!root || root.startsWith("/") || isRemoteAssetPath(root)) {
return "thumbnails.root must be a relative folder name";
}
if (thumbnails.width != null && (!Number.isInteger(Number(thumbnails.width)) || Number(thumbnails.width) <= 0)) {
return "thumbnails.width must be a positive integer when provided";
}
if (thumbnails.height != null && (!Number.isInteger(Number(thumbnails.height)) || Number(thumbnails.height) <= 0)) {
return "thumbnails.height must be a positive integer when provided";
}
if (thumbnails.quality != null) {
const quality = Number(thumbnails.quality);
if (!Number.isInteger(quality) || quality < 1 || quality > 100) {
return "thumbnails.quality must be an integer between 1 and 100 when provided";
}
}
const fit = asNonEmptyString(thumbnails.fit) || defaultThumbnailConfig.fit;
if (!supportedThumbnailFits.has(fit)) {
return `thumbnails.fit must be one of ${Array.from(supportedThumbnailFits).join(", ")}`;
}
return null;
}
function hasNonEmptyStringMapEntries(value) {
if (!isPlainObject(value)) {
return false;
@@ -69,12 +143,20 @@ function hasNonEmptyStringMapEntries(value) {
return Object.values(value).some((entryValue) => String(entryValue || "").trim());
}
function hasRankResolver(minorRule) {
function getNormalizedRankOrder(rankSource, fallbackRankOrder = []) {
const explicitRankOrder = Array.isArray(rankSource?.rankOrder) ? rankSource.rankOrder : [];
const rankOrderSource = explicitRankOrder.length ? explicitRankOrder : fallbackRankOrder;
return rankOrderSource
.map((entry) => String(entry || "").trim())
.filter(Boolean);
}
function hasRankResolver(minorRule, fallbackRankOrder = []) {
if (!isPlainObject(minorRule)) {
return false;
}
const rankOrder = Array.isArray(minorRule.rankOrder) ? minorRule.rankOrder.filter((entry) => String(entry || "").trim()) : [];
const rankOrder = getNormalizedRankOrder(minorRule, fallbackRankOrder);
if (rankOrder.length) {
return true;
}
@@ -166,6 +248,27 @@ function validateMinorsRule(minors) {
return "minors.mode is required";
}
if (mode === "split-number-template") {
const courtsError = validateMinorNumberTemplateGroup(minors.courts, {
label: "minors.courts",
expectedRankCount: 4
});
if (courtsError) {
return courtsError;
}
const smallsError = validateMinorNumberTemplateGroup(minors.smalls, {
label: "minors.smalls",
expectedRankCount: defaultPipRankOrder.length,
fallbackRankOrder: defaultPipRankOrder
});
if (smallsError) {
return smallsError;
}
return null;
}
if (!hasRankResolver(minors)) {
return "minors must define rankOrder or rankIndexByKey";
}
@@ -205,6 +308,38 @@ function validateMinorsRule(minors) {
return `unsupported minors.mode '${mode}'`;
}
function validateMinorNumberTemplateGroup(groupRule, options = {}) {
const label = String(options.label || "minor group");
const expectedRankCount = Number(options.expectedRankCount);
const fallbackRankOrder = Array.isArray(options.fallbackRankOrder) ? options.fallbackRankOrder : [];
if (!isPlainObject(groupRule)) {
return `${label} must be an object`;
}
if (!hasRankResolver(groupRule, fallbackRankOrder)) {
return `${label} must define rankOrder or rankIndexByKey`;
}
if (getRankEntries(groupRule, fallbackRankOrder).length !== expectedRankCount) {
return `${label} must define exactly ${expectedRankCount} ranks`;
}
if (!hasSuitNumberMap(groupRule.suitBase)) {
return `${label}.suitBase must define numeric bases for wands, cups, swords, and disks`;
}
if (!asNonEmptyString(groupRule.template)) {
return `${label}.template is required`;
}
if (groupRule.numberPad != null && !Number.isInteger(Number(groupRule.numberPad))) {
return `${label}.numberPad must be an integer when provided`;
}
return null;
}
function validateDeckManifest(manifest) {
const errors = [];
@@ -250,15 +385,16 @@ function validateDeckManifest(manifest) {
errors.push("cardBack must be a non-empty string when provided");
}
const thumbnailsError = validateThumbnailConfig(manifest.thumbnails);
if (thumbnailsError) {
errors.push(thumbnailsError);
}
return errors;
}
function getRankEntries(minors) {
const rankOrder = Array.isArray(minors?.rankOrder)
? minors.rankOrder
.map((entry) => String(entry || "").trim())
.filter(Boolean)
: [];
function getRankEntries(minors, fallbackRankOrder = []) {
const rankOrder = getNormalizedRankOrder(minors, fallbackRankOrder);
if (rankOrder.length) {
return rankOrder.map((rankWord, rankIndex) => ({
@@ -311,6 +447,13 @@ function getReferencedMinorFiles(manifest) {
return [];
}
if (mode === "split-number-template") {
return [
...getReferencedSplitMinorGroupFiles(minors.courts),
...getReferencedSplitMinorGroupFiles(minors.smalls, defaultPipRankOrder)
];
}
const rankEntries = getRankEntries(minors);
if (!rankEntries.length) {
return [];
@@ -367,6 +510,27 @@ function getReferencedMinorFiles(manifest) {
return [];
}
function getReferencedSplitMinorGroupFiles(groupRule, fallbackRankOrder = []) {
const rankEntries = getRankEntries(groupRule, fallbackRankOrder);
if (!rankEntries.length) {
return [];
}
const template = String(groupRule?.template || "{number}.png");
const numberPad = Number.isInteger(Number(groupRule?.numberPad)) ? Number(groupRule.numberPad) : 2;
return tarotSuits.flatMap((suitId) => {
const suitBase = Number(groupRule?.suitBase?.[suitId]);
return rankEntries.map((entry) => applyTemplate(template, {
number: String(suitBase + entry.rankIndex).padStart(numberPad, "0"),
rank: entry.rankWord,
rankKey: entry.rankKey,
suitId,
index: entry.rankIndex
}));
});
}
function getReferencedCardBackFiles(manifest) {
const cardBack = asNonEmptyString(manifest?.cardBack);
if (!cardBack || isRemoteAssetPath(cardBack)) {
@@ -491,6 +655,7 @@ function compileDeckRegistry() {
const label = labelFromManifest || folderName;
const basePath = `asset/tarot deck/${folderName}`;
const cardBackPath = detectDeckCardBackRelativePath(folderName, manifest);
const thumbnailConfig = normalizeThumbnailConfig(manifest.thumbnails);
if (seenIds.has(id)) {
warnings.push(`Skipped '${folderName}': duplicate deck id '${id}'`);
@@ -504,6 +669,7 @@ function compileDeckRegistry() {
label,
basePath,
manifestPath: `${basePath}/deck.json`,
...(thumbnailConfig && thumbnailConfig !== false ? { thumbnailRoot: thumbnailConfig.root } : {}),
...(cardBackPath ? { cardBackPath } : {})
});
});