Initial commit

This commit is contained in:
2026-03-07 01:09:00 -08:00
commit af7d63717e
102 changed files with 68739 additions and 0 deletions

View File

@@ -0,0 +1,516 @@
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;
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;
}
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");
}
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 [];
}
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)`;
}
function auditDeckFiles(folderName, manifest) {
const deckFolderPath = path.join(decksRoot, folderName);
const referencedFiles = [
...getReferencedMajorFiles(manifest),
...getReferencedMinorFiles(manifest)
]
.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}`;
if (seenIds.has(id)) {
warnings.push(`Skipped '${folderName}': duplicate deck id '${id}'`);
return;
}
seenIds.add(id);
deckRows.push({
id,
label,
basePath,
manifestPath: `${basePath}/deck.json`
});
});
// 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();