2026-03-07 01:09:00 -08:00
|
|
|
const fs = require("fs");
|
|
|
|
|
const path = require("path");
|
|
|
|
|
|
|
|
|
|
const projectRoot = path.resolve(__dirname, "..");
|
|
|
|
|
const decksRoot = path.join(projectRoot, "asset", "tarot deck");
|
|
|
|
|
const registryPath = path.join(decksRoot, "decks.json");
|
|
|
|
|
const ignoredFolderNames = new Set(["template", "templates", "example", "examples"]);
|
|
|
|
|
const tarotSuits = ["wands", "cups", "swords", "disks"];
|
|
|
|
|
const majorTrumpNumbers = Array.from({ length: 22 }, (_, index) => index);
|
|
|
|
|
const expectedMinorCardCount = 56;
|
2026-03-07 05:17:50 -08:00
|
|
|
const cardBackCandidateExtensions = ["webp", "png", "jpg", "jpeg", "avif", "gif"];
|
2026-03-07 01:09:00 -08:00
|
|
|
|
|
|
|
|
function isPlainObject(value) {
|
|
|
|
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function slugifyId(input) {
|
|
|
|
|
return String(input || "")
|
|
|
|
|
.trim()
|
|
|
|
|
.toLowerCase()
|
|
|
|
|
.replace(/&/g, " and ")
|
|
|
|
|
.replace(/['\"`]/g, "")
|
|
|
|
|
.replace(/[^a-z0-9]+/g, "-")
|
|
|
|
|
.replace(/^-+|-+$/g, "")
|
|
|
|
|
.replace(/-{2,}/g, "-");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function asNonEmptyString(value) {
|
|
|
|
|
const normalized = String(value || "").trim();
|
|
|
|
|
return normalized || null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-07 05:17:50 -08:00
|
|
|
function isRemoteAssetPath(value) {
|
|
|
|
|
return /^(https?:)?\/\//i.test(String(value || "").trim());
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-07 01:09:00 -08:00
|
|
|
function toTitleCase(value) {
|
|
|
|
|
const normalized = String(value || "").trim().toLowerCase();
|
|
|
|
|
if (!normalized) {
|
|
|
|
|
return "";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return normalized.charAt(0).toUpperCase() + normalized.slice(1);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function applyTemplate(template, variables) {
|
|
|
|
|
return String(template || "")
|
|
|
|
|
.replace(/\{([a-zA-Z0-9_]+)\}/g, (_, token) => {
|
|
|
|
|
const value = variables[token];
|
|
|
|
|
return value == null ? "" : String(value);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function readManifestJson(manifestPath) {
|
|
|
|
|
try {
|
|
|
|
|
const text = fs.readFileSync(manifestPath, "utf8");
|
|
|
|
|
const parsed = JSON.parse(text);
|
|
|
|
|
return parsed && typeof parsed === "object" ? parsed : null;
|
|
|
|
|
} catch {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function hasNonEmptyStringMapEntries(value) {
|
|
|
|
|
if (!isPlainObject(value)) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Object.values(value).some((entryValue) => String(entryValue || "").trim());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function hasRankResolver(minorRule) {
|
|
|
|
|
if (!isPlainObject(minorRule)) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const rankOrder = Array.isArray(minorRule.rankOrder) ? minorRule.rankOrder.filter((entry) => String(entry || "").trim()) : [];
|
|
|
|
|
if (rankOrder.length) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return hasNonEmptyStringMapEntries(minorRule.rankIndexByKey);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function hasSuitNumberMap(value) {
|
|
|
|
|
if (!isPlainObject(value)) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return tarotSuits.every((suitId) => Number.isFinite(Number(value[suitId])));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function hasSuitStringMap(value) {
|
|
|
|
|
if (!isPlainObject(value)) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return tarotSuits.every((suitId) => String(value[suitId] || "").trim());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isValidMinorOverrideKey(value) {
|
|
|
|
|
return /^(ace|two|three|four|five|six|seven|eight|nine|ten|knight|queen|prince|princess|king|page|[2-9]|10)\s+of\s+(cups|wands|swords|pentacles|disks)$/i.test(String(value || "").trim());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getDefinedMajorCount(majors) {
|
|
|
|
|
const mode = asNonEmptyString(majors?.mode);
|
|
|
|
|
if (mode === "trump-template") {
|
|
|
|
|
return majorTrumpNumbers.length;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (mode === "canonical-map" || mode === "trump-map") {
|
|
|
|
|
return Object.values(majors?.cards || {})
|
|
|
|
|
.map((value) => String(value || "").trim())
|
|
|
|
|
.filter(Boolean)
|
|
|
|
|
.length;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function validateMajorsRule(majors) {
|
|
|
|
|
if (!isPlainObject(majors)) {
|
|
|
|
|
return "majors must be an object";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const mode = asNonEmptyString(majors.mode);
|
|
|
|
|
if (!mode) {
|
|
|
|
|
return "majors.mode is required";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (mode === "canonical-map" || mode === "trump-map") {
|
|
|
|
|
if (!hasNonEmptyStringMapEntries(majors.cards)) {
|
|
|
|
|
return `majors.cards must be a non-empty object for mode '${mode}'`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (getDefinedMajorCount(majors) < majorTrumpNumbers.length) {
|
|
|
|
|
return `majors.cards must define all ${majorTrumpNumbers.length} major trumps for mode '${mode}'`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (mode === "trump-template") {
|
|
|
|
|
const template = asNonEmptyString(majors.template);
|
|
|
|
|
if (!template) {
|
|
|
|
|
return "majors.template is required for mode 'trump-template'";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (majors.numberPad != null && !Number.isInteger(Number(majors.numberPad))) {
|
|
|
|
|
return "majors.numberPad must be an integer when provided";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return `unsupported majors.mode '${mode}'`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function validateMinorsRule(minors) {
|
|
|
|
|
if (!isPlainObject(minors)) {
|
|
|
|
|
return "minors must be an object";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const mode = asNonEmptyString(minors.mode);
|
|
|
|
|
if (!mode) {
|
|
|
|
|
return "minors.mode is required";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!hasRankResolver(minors)) {
|
|
|
|
|
return "minors must define rankOrder or rankIndexByKey";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (getRankEntries(minors).length !== 14) {
|
|
|
|
|
return `minors must define exactly 14 ranks to cover ${expectedMinorCardCount} cards`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (mode === "suit-prefix-and-rank-order") {
|
|
|
|
|
if (!hasSuitStringMap(minors.suitPrefix)) {
|
|
|
|
|
return "minors.suitPrefix must define wands, cups, swords, and disks";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (minors.indexStart != null && !Number.isInteger(Number(minors.indexStart))) {
|
|
|
|
|
return "minors.indexStart must be an integer when provided";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (minors.indexPad != null && !Number.isInteger(Number(minors.indexPad))) {
|
|
|
|
|
return "minors.indexPad must be an integer when provided";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (mode === "suit-base-and-rank-order" || mode === "suit-base-number-template") {
|
|
|
|
|
if (!hasSuitNumberMap(minors.suitBase)) {
|
|
|
|
|
return "minors.suitBase must define numeric bases for wands, cups, swords, and disks";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (minors.numberPad != null && !Number.isInteger(Number(minors.numberPad))) {
|
|
|
|
|
return "minors.numberPad must be an integer when provided";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return `unsupported minors.mode '${mode}'`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function validateDeckManifest(manifest) {
|
|
|
|
|
const errors = [];
|
|
|
|
|
|
|
|
|
|
if (!isPlainObject(manifest)) {
|
|
|
|
|
return ["deck.json must contain an object"];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const majorsError = validateMajorsRule(manifest.majors);
|
|
|
|
|
if (majorsError) {
|
|
|
|
|
errors.push(majorsError);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const minorsError = validateMinorsRule(manifest.minors);
|
|
|
|
|
if (minorsError) {
|
|
|
|
|
errors.push(minorsError);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (manifest.nameOverrides != null && !isPlainObject(manifest.nameOverrides)) {
|
|
|
|
|
errors.push("nameOverrides must be an object when provided");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (manifest.minorNameOverrides != null && !isPlainObject(manifest.minorNameOverrides)) {
|
|
|
|
|
errors.push("minorNameOverrides must be an object when provided");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (isPlainObject(manifest.minorNameOverrides)) {
|
|
|
|
|
Object.entries(manifest.minorNameOverrides).forEach(([rawKey, rawValue]) => {
|
|
|
|
|
if (!isValidMinorOverrideKey(rawKey)) {
|
|
|
|
|
errors.push(`minorNameOverrides contains unsupported card key '${rawKey}'`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!asNonEmptyString(rawValue)) {
|
|
|
|
|
errors.push(`minorNameOverrides '${rawKey}' must map to a non-empty string`);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (manifest.majorNameOverridesByTrump != null && !isPlainObject(manifest.majorNameOverridesByTrump)) {
|
|
|
|
|
errors.push("majorNameOverridesByTrump must be an object when provided");
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-07 05:17:50 -08:00
|
|
|
if (manifest.cardBack != null && !asNonEmptyString(manifest.cardBack)) {
|
|
|
|
|
errors.push("cardBack must be a non-empty string when provided");
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-07 01:09:00 -08:00
|
|
|
return errors;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getRankEntries(minors) {
|
|
|
|
|
const rankOrder = Array.isArray(minors?.rankOrder)
|
|
|
|
|
? minors.rankOrder
|
|
|
|
|
.map((entry) => String(entry || "").trim())
|
|
|
|
|
.filter(Boolean)
|
|
|
|
|
: [];
|
|
|
|
|
|
|
|
|
|
if (rankOrder.length) {
|
|
|
|
|
return rankOrder.map((rankWord, rankIndex) => ({
|
|
|
|
|
rankWord,
|
|
|
|
|
rankKey: rankWord.toLowerCase(),
|
|
|
|
|
rankIndex
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const rankIndexByKey = isPlainObject(minors?.rankIndexByKey) ? minors.rankIndexByKey : {};
|
|
|
|
|
return Object.entries(rankIndexByKey)
|
|
|
|
|
.map(([rankKey, rankIndex]) => ({
|
|
|
|
|
rankWord: toTitleCase(rankKey),
|
|
|
|
|
rankKey: String(rankKey || "").trim().toLowerCase(),
|
|
|
|
|
rankIndex: Number(rankIndex)
|
|
|
|
|
}))
|
|
|
|
|
.filter((entry) => entry.rankKey && Number.isInteger(entry.rankIndex) && entry.rankIndex >= 0)
|
|
|
|
|
.sort((left, right) => left.rankIndex - right.rankIndex);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getReferencedMajorFiles(manifest) {
|
|
|
|
|
const majors = manifest?.majors;
|
|
|
|
|
const mode = asNonEmptyString(majors?.mode);
|
|
|
|
|
if (!mode) {
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (mode === "canonical-map" || mode === "trump-map") {
|
|
|
|
|
return Object.values(majors.cards || {})
|
|
|
|
|
.map((value) => String(value || "").trim())
|
|
|
|
|
.filter(Boolean);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (mode === "trump-template") {
|
|
|
|
|
const template = String(majors.template || "{number}.png");
|
|
|
|
|
const numberPad = Number.isInteger(Number(majors.numberPad)) ? Number(majors.numberPad) : 2;
|
|
|
|
|
return majorTrumpNumbers.map((trump) => applyTemplate(template, {
|
|
|
|
|
trump,
|
|
|
|
|
number: String(trump).padStart(numberPad, "0")
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getReferencedMinorFiles(manifest) {
|
|
|
|
|
const minors = manifest?.minors;
|
|
|
|
|
const mode = asNonEmptyString(minors?.mode);
|
|
|
|
|
if (!mode) {
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const rankEntries = getRankEntries(minors);
|
|
|
|
|
if (!rankEntries.length) {
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (mode === "suit-prefix-and-rank-order") {
|
|
|
|
|
const template = String(minors.template || "{suit}{index}.png");
|
|
|
|
|
const indexStart = Number.isInteger(Number(minors.indexStart)) ? Number(minors.indexStart) : 1;
|
|
|
|
|
const indexPad = Number.isInteger(Number(minors.indexPad)) ? Number(minors.indexPad) : 2;
|
|
|
|
|
return tarotSuits.flatMap((suitId) => {
|
|
|
|
|
const suitPrefix = String(minors?.suitPrefix?.[suitId] || "").trim();
|
|
|
|
|
return rankEntries.map((entry) => applyTemplate(template, {
|
|
|
|
|
suit: suitPrefix,
|
|
|
|
|
suitId,
|
|
|
|
|
index: String(indexStart + entry.rankIndex).padStart(indexPad, "0"),
|
|
|
|
|
rank: entry.rankWord,
|
|
|
|
|
rankKey: entry.rankKey
|
|
|
|
|
}));
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (mode === "suit-base-and-rank-order") {
|
|
|
|
|
const template = String(minors.template || "{number}_{rank} {suit}.webp");
|
|
|
|
|
const numberPad = Number.isInteger(Number(minors.numberPad)) ? Number(minors.numberPad) : 2;
|
|
|
|
|
return tarotSuits.flatMap((suitId) => {
|
|
|
|
|
const suitBase = Number(minors?.suitBase?.[suitId]);
|
|
|
|
|
const suitWord = String(minors?.suitLabel?.[suitId] || toTitleCase(suitId));
|
|
|
|
|
return rankEntries.map((entry) => applyTemplate(template, {
|
|
|
|
|
number: String(suitBase + entry.rankIndex).padStart(numberPad, "0"),
|
|
|
|
|
rank: entry.rankWord,
|
|
|
|
|
rankKey: entry.rankKey,
|
|
|
|
|
suit: suitWord,
|
|
|
|
|
suitId,
|
|
|
|
|
index: entry.rankIndex + 1
|
|
|
|
|
}));
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (mode === "suit-base-number-template") {
|
|
|
|
|
const template = String(minors.template || "{number}.png");
|
|
|
|
|
const numberPad = Number.isInteger(Number(minors.numberPad)) ? Number(minors.numberPad) : 2;
|
|
|
|
|
return tarotSuits.flatMap((suitId) => {
|
|
|
|
|
const suitBase = Number(minors?.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
|
|
|
|
|
}));
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-07 05:17:50 -08:00
|
|
|
function getReferencedCardBackFiles(manifest) {
|
|
|
|
|
const cardBack = asNonEmptyString(manifest?.cardBack);
|
|
|
|
|
if (!cardBack || isRemoteAssetPath(cardBack)) {
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return [cardBack];
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-07 01:09:00 -08:00
|
|
|
function summarizeMissingFiles(fileList) {
|
|
|
|
|
const maxPreview = 8;
|
|
|
|
|
const preview = fileList.slice(0, maxPreview).join(", ");
|
|
|
|
|
if (fileList.length <= maxPreview) {
|
|
|
|
|
return preview;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return `${preview}, ... (+${fileList.length - maxPreview} more)`;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-07 05:17:50 -08:00
|
|
|
function detectDeckCardBackRelativePath(folderName, manifest) {
|
|
|
|
|
const explicitCardBack = asNonEmptyString(manifest?.cardBack);
|
|
|
|
|
if (explicitCardBack) {
|
|
|
|
|
return explicitCardBack;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const deckFolderPath = path.join(decksRoot, folderName);
|
|
|
|
|
for (let index = 0; index < cardBackCandidateExtensions.length; index += 1) {
|
|
|
|
|
const extension = cardBackCandidateExtensions[index];
|
|
|
|
|
const candidateName = `back.${extension}`;
|
|
|
|
|
if (fs.existsSync(path.join(deckFolderPath, candidateName))) {
|
|
|
|
|
return candidateName;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-07 01:09:00 -08:00
|
|
|
function auditDeckFiles(folderName, manifest) {
|
|
|
|
|
const deckFolderPath = path.join(decksRoot, folderName);
|
|
|
|
|
const referencedFiles = [
|
|
|
|
|
...getReferencedMajorFiles(manifest),
|
2026-03-07 05:17:50 -08:00
|
|
|
...getReferencedMinorFiles(manifest),
|
|
|
|
|
...getReferencedCardBackFiles(manifest)
|
2026-03-07 01:09:00 -08:00
|
|
|
]
|
|
|
|
|
.map((relativePath) => String(relativePath || "").trim())
|
|
|
|
|
.filter(Boolean);
|
|
|
|
|
|
|
|
|
|
const uniqueReferencedFiles = Array.from(new Set(referencedFiles));
|
|
|
|
|
const missingFiles = uniqueReferencedFiles.filter((relativePath) => !fs.existsSync(path.join(deckFolderPath, relativePath)));
|
|
|
|
|
|
|
|
|
|
if (!missingFiles.length) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return `missing ${missingFiles.length} referenced image file${missingFiles.length === 1 ? "" : "s"}: ${summarizeMissingFiles(missingFiles)}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function shouldIgnoreDeckFolder(folderName) {
|
|
|
|
|
const normalized = String(folderName || "").trim().toLowerCase();
|
|
|
|
|
if (!normalized) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return normalized.startsWith("_") || normalized.startsWith(".") || ignoredFolderNames.has(normalized);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function compileDeckRegistry() {
|
|
|
|
|
if (!fs.existsSync(decksRoot)) {
|
|
|
|
|
throw new Error(`Deck root not found: ${decksRoot}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const entries = fs.readdirSync(decksRoot, { withFileTypes: true });
|
|
|
|
|
const deckRows = [];
|
|
|
|
|
const warnings = [];
|
|
|
|
|
const seenIds = new Set();
|
|
|
|
|
|
|
|
|
|
entries.forEach((entry) => {
|
|
|
|
|
if (!entry.isDirectory()) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const folderName = entry.name;
|
|
|
|
|
if (shouldIgnoreDeckFolder(folderName)) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const manifestFsPath = path.join(decksRoot, folderName, "deck.json");
|
|
|
|
|
|
|
|
|
|
if (!fs.existsSync(manifestFsPath)) {
|
|
|
|
|
warnings.push(`Skipped '${folderName}': missing deck.json`);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const manifest = readManifestJson(manifestFsPath);
|
|
|
|
|
if (!manifest) {
|
|
|
|
|
warnings.push(`Skipped '${folderName}': deck.json is invalid JSON`);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const validationErrors = validateDeckManifest(manifest);
|
|
|
|
|
if (validationErrors.length) {
|
|
|
|
|
warnings.push(`Skipped '${folderName}': ${validationErrors.join("; ")}`);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const auditError = auditDeckFiles(folderName, manifest);
|
|
|
|
|
if (auditError) {
|
|
|
|
|
warnings.push(`Skipped '${folderName}': ${auditError}`);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const idFromManifest = asNonEmptyString(manifest.id);
|
|
|
|
|
const labelFromManifest = asNonEmptyString(manifest.label);
|
|
|
|
|
const fallbackId = slugifyId(folderName);
|
|
|
|
|
|
|
|
|
|
if (!fallbackId && !idFromManifest) {
|
|
|
|
|
warnings.push(`Skipped '${folderName}': unable to infer deck id`);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const id = (idFromManifest || fallbackId).toLowerCase();
|
|
|
|
|
const label = labelFromManifest || folderName;
|
|
|
|
|
const basePath = `asset/tarot deck/${folderName}`;
|
2026-03-07 05:17:50 -08:00
|
|
|
const cardBackPath = detectDeckCardBackRelativePath(folderName, manifest);
|
2026-03-07 01:09:00 -08:00
|
|
|
|
|
|
|
|
if (seenIds.has(id)) {
|
|
|
|
|
warnings.push(`Skipped '${folderName}': duplicate deck id '${id}'`);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
seenIds.add(id);
|
|
|
|
|
|
|
|
|
|
deckRows.push({
|
|
|
|
|
id,
|
|
|
|
|
label,
|
|
|
|
|
basePath,
|
2026-03-07 05:17:50 -08:00
|
|
|
manifestPath: `${basePath}/deck.json`,
|
|
|
|
|
...(cardBackPath ? { cardBackPath } : {})
|
2026-03-07 01:09:00 -08:00
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Keep output deterministic for stable diffs.
|
|
|
|
|
deckRows.sort((a, b) => a.label.localeCompare(b.label, "en", { sensitivity: "base" }));
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
registry: { decks: deckRows },
|
|
|
|
|
warnings
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function writeDeckRegistry(registry) {
|
|
|
|
|
fs.writeFileSync(registryPath, `${JSON.stringify(registry, null, 2)}\n`, "utf8");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function parseCliOptions(argv) {
|
|
|
|
|
const args = new Set(Array.isArray(argv) ? argv : []);
|
|
|
|
|
return {
|
|
|
|
|
validateOnly: args.has("--validate-only"),
|
|
|
|
|
strict: args.has("--strict")
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function main() {
|
|
|
|
|
const options = parseCliOptions(process.argv.slice(2));
|
|
|
|
|
const { registry, warnings } = compileDeckRegistry();
|
|
|
|
|
|
|
|
|
|
const count = Array.isArray(registry.decks) ? registry.decks.length : 0;
|
|
|
|
|
|
|
|
|
|
if (options.validateOnly) {
|
|
|
|
|
console.log(`[decks] Validated ${count} deck manifest${count === 1 ? "" : "s"}`);
|
|
|
|
|
} else {
|
|
|
|
|
writeDeckRegistry(registry);
|
|
|
|
|
console.log(`[decks] Wrote ${count} deck entr${count === 1 ? "y" : "ies"} to ${path.relative(projectRoot, registryPath)}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
warnings.forEach((warning) => {
|
|
|
|
|
console.warn(`[decks] ${warning}`);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (options.strict && warnings.length) {
|
|
|
|
|
process.exitCode = 1;
|
|
|
|
|
console.error(`[decks] Validation failed with ${warnings.length} warning${warnings.length === 1 ? "" : "s"}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
main();
|