655 lines
18 KiB
JavaScript
655 lines
18 KiB
JavaScript
(function () {
|
|
const DEFAULT_DECK_ID = "ceremonial-magick";
|
|
|
|
const trumpNumberByCanonicalName = {
|
|
fool: 0,
|
|
magus: 1,
|
|
magician: 1,
|
|
"high priestess": 2,
|
|
empress: 3,
|
|
emperor: 4,
|
|
hierophant: 5,
|
|
lovers: 6,
|
|
chariot: 7,
|
|
lust: 8,
|
|
strength: 8,
|
|
hermit: 9,
|
|
fortune: 10,
|
|
"wheel of fortune": 10,
|
|
justice: 11,
|
|
"hanged man": 12,
|
|
death: 13,
|
|
art: 14,
|
|
temperance: 14,
|
|
devil: 15,
|
|
tower: 16,
|
|
star: 17,
|
|
moon: 18,
|
|
sun: 19,
|
|
aeon: 20,
|
|
judgement: 20,
|
|
judgment: 20,
|
|
universe: 21,
|
|
world: 21
|
|
};
|
|
|
|
const pipValueByToken = {
|
|
ace: 1,
|
|
two: 2,
|
|
three: 3,
|
|
four: 4,
|
|
five: 5,
|
|
six: 6,
|
|
seven: 7,
|
|
eight: 8,
|
|
nine: 9,
|
|
ten: 10,
|
|
"2": 2,
|
|
"3": 3,
|
|
"4": 4,
|
|
"5": 5,
|
|
"6": 6,
|
|
"7": 7,
|
|
"8": 8,
|
|
"9": 9,
|
|
"10": 10
|
|
};
|
|
|
|
const rankWordByPipValue = {
|
|
1: "Ace",
|
|
2: "Two",
|
|
3: "Three",
|
|
4: "Four",
|
|
5: "Five",
|
|
6: "Six",
|
|
7: "Seven",
|
|
8: "Eight",
|
|
9: "Nine",
|
|
10: "Ten"
|
|
};
|
|
|
|
const trumpRomanToNumber = {
|
|
I: 1,
|
|
II: 2,
|
|
III: 3,
|
|
IV: 4,
|
|
V: 5,
|
|
VI: 6,
|
|
VII: 7,
|
|
VIII: 8,
|
|
IX: 9,
|
|
X: 10,
|
|
XI: 11,
|
|
XII: 12,
|
|
XIII: 13,
|
|
XIV: 14,
|
|
XV: 15,
|
|
XVI: 16,
|
|
XVII: 17,
|
|
XVIII: 18,
|
|
XIX: 19,
|
|
XX: 20,
|
|
XXI: 21
|
|
};
|
|
|
|
const suitSearchAliasesById = {
|
|
wands: ["wands"],
|
|
cups: ["cups"],
|
|
swords: ["swords"],
|
|
disks: ["disks", "pentacles", "coins"]
|
|
};
|
|
|
|
const DECK_REGISTRY_PATH = "asset/tarot deck/decks.json";
|
|
|
|
const deckManifestSources = buildDeckManifestSources();
|
|
|
|
const manifestCache = new Map();
|
|
let activeDeckId = DEFAULT_DECK_ID;
|
|
|
|
function canonicalMajorName(cardName) {
|
|
return String(cardName || "")
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/^the\s+/, "")
|
|
.replace(/\s+/g, " ");
|
|
}
|
|
|
|
function canonicalMinorName(cardName) {
|
|
const parsedMinor = parseMinorCard(cardName);
|
|
if (!parsedMinor) {
|
|
return "";
|
|
}
|
|
|
|
return `${String(parsedMinor.rankKey || "").trim().toLowerCase()} of ${parsedMinor.suitId}`;
|
|
}
|
|
|
|
function toTitleCase(value) {
|
|
const normalized = String(value || "").trim().toLowerCase();
|
|
if (!normalized) {
|
|
return "";
|
|
}
|
|
return normalized.charAt(0).toUpperCase() + normalized.slice(1);
|
|
}
|
|
|
|
function normalizeDeckId(deckId) {
|
|
const normalized = String(deckId || "").trim().toLowerCase();
|
|
if (deckManifestSources[normalized]) {
|
|
return normalized;
|
|
}
|
|
|
|
if (deckManifestSources[DEFAULT_DECK_ID]) {
|
|
return DEFAULT_DECK_ID;
|
|
}
|
|
|
|
const fallbackId = Object.keys(deckManifestSources)[0];
|
|
return fallbackId || DEFAULT_DECK_ID;
|
|
}
|
|
|
|
function normalizeTrumpNumber(value) {
|
|
const parsed = Number(value);
|
|
if (!Number.isInteger(parsed) || parsed < 0 || parsed > 21) {
|
|
return null;
|
|
}
|
|
|
|
return parsed;
|
|
}
|
|
|
|
function parseTrumpNumberKey(value) {
|
|
const normalized = String(value || "").trim().toUpperCase();
|
|
if (!normalized) {
|
|
return null;
|
|
}
|
|
|
|
if (/^\d+$/.test(normalized)) {
|
|
return normalizeTrumpNumber(Number(normalized));
|
|
}
|
|
|
|
if (Object.prototype.hasOwnProperty.call(trumpRomanToNumber, normalized)) {
|
|
return normalizeTrumpNumber(trumpRomanToNumber[normalized]);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function normalizeSuitId(suitInput) {
|
|
const suit = String(suitInput || "").trim().toLowerCase();
|
|
if (suit === "pentacles") {
|
|
return "disks";
|
|
}
|
|
return suit;
|
|
}
|
|
|
|
function resolveDeckOptions(optionsOrDeckId) {
|
|
let resolvedDeckId = activeDeckId;
|
|
let trumpNumber = null;
|
|
|
|
if (typeof optionsOrDeckId === "string") {
|
|
resolvedDeckId = normalizeDeckId(optionsOrDeckId);
|
|
} else if (optionsOrDeckId && typeof optionsOrDeckId === "object") {
|
|
if (optionsOrDeckId.deckId) {
|
|
resolvedDeckId = normalizeDeckId(optionsOrDeckId.deckId);
|
|
}
|
|
trumpNumber = normalizeTrumpNumber(optionsOrDeckId.trumpNumber);
|
|
}
|
|
|
|
return { resolvedDeckId, trumpNumber };
|
|
}
|
|
|
|
function parseMinorCard(cardName) {
|
|
const match = String(cardName || "")
|
|
.trim()
|
|
.match(/^(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);
|
|
|
|
if (!match) {
|
|
return null;
|
|
}
|
|
|
|
const rankToken = String(match[1] || "").toLowerCase();
|
|
const suitId = normalizeSuitId(match[2]);
|
|
const pipValue = pipValueByToken[rankToken] ?? null;
|
|
|
|
if (Number.isFinite(pipValue)) {
|
|
const rankWord = rankWordByPipValue[pipValue] || "";
|
|
return {
|
|
suitId,
|
|
pipValue,
|
|
court: "",
|
|
rankWord,
|
|
rankKey: rankWord.toLowerCase()
|
|
};
|
|
}
|
|
|
|
const courtWord = toTitleCase(rankToken);
|
|
if (!courtWord) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
suitId,
|
|
pipValue: null,
|
|
court: rankToken,
|
|
rankWord: courtWord,
|
|
rankKey: rankToken
|
|
};
|
|
}
|
|
|
|
function applyTemplate(template, variables) {
|
|
return String(template || "")
|
|
.replace(/\{([a-zA-Z0-9_]+)\}/g, (_, token) => {
|
|
const value = variables[token];
|
|
return value == null ? "" : String(value);
|
|
});
|
|
}
|
|
|
|
function readManifestJsonSync(path) {
|
|
try {
|
|
const request = new XMLHttpRequest();
|
|
request.open("GET", encodeURI(path), false);
|
|
request.send(null);
|
|
|
|
const okStatus = (request.status >= 200 && request.status < 300) || request.status === 0;
|
|
if (!okStatus || !request.responseText) {
|
|
return null;
|
|
}
|
|
|
|
return JSON.parse(request.responseText);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function toDeckSourceMap(sourceList) {
|
|
const sourceMap = {};
|
|
if (!Array.isArray(sourceList)) {
|
|
return sourceMap;
|
|
}
|
|
|
|
sourceList.forEach((entry) => {
|
|
const id = String(entry?.id || "").trim().toLowerCase();
|
|
const basePath = String(entry?.basePath || "").trim().replace(/\/$/, "");
|
|
const manifestPath = String(entry?.manifestPath || "").trim();
|
|
if (!id || !basePath || !manifestPath) {
|
|
return;
|
|
}
|
|
|
|
sourceMap[id] = {
|
|
id,
|
|
label: String(entry?.label || id),
|
|
basePath,
|
|
manifestPath
|
|
};
|
|
});
|
|
|
|
return sourceMap;
|
|
}
|
|
|
|
function buildDeckManifestSources() {
|
|
const registry = readManifestJsonSync(DECK_REGISTRY_PATH);
|
|
const registryDecks = Array.isArray(registry)
|
|
? registry
|
|
: (Array.isArray(registry?.decks) ? registry.decks : null);
|
|
|
|
return toDeckSourceMap(registryDecks);
|
|
}
|
|
|
|
function normalizeDeckManifest(source, rawManifest) {
|
|
if (!rawManifest || typeof rawManifest !== "object") {
|
|
return null;
|
|
}
|
|
|
|
const rawNameOverrides = rawManifest.nameOverrides;
|
|
const nameOverrides = {};
|
|
if (rawNameOverrides && typeof rawNameOverrides === "object") {
|
|
Object.entries(rawNameOverrides).forEach(([rawKey, rawValue]) => {
|
|
const key = canonicalMajorName(rawKey);
|
|
const value = String(rawValue || "").trim();
|
|
if (key && value) {
|
|
nameOverrides[key] = value;
|
|
}
|
|
});
|
|
}
|
|
|
|
const rawMajorNameOverridesByTrump = rawManifest.majorNameOverridesByTrump;
|
|
const majorNameOverridesByTrump = {};
|
|
if (rawMajorNameOverridesByTrump && typeof rawMajorNameOverridesByTrump === "object") {
|
|
Object.entries(rawMajorNameOverridesByTrump).forEach(([rawKey, rawValue]) => {
|
|
const trumpNumber = parseTrumpNumberKey(rawKey);
|
|
const value = String(rawValue || "").trim();
|
|
if (Number.isInteger(trumpNumber) && value) {
|
|
majorNameOverridesByTrump[trumpNumber] = value;
|
|
}
|
|
});
|
|
}
|
|
|
|
const rawMinorNameOverrides = rawManifest.minorNameOverrides;
|
|
const minorNameOverrides = {};
|
|
if (rawMinorNameOverrides && typeof rawMinorNameOverrides === "object") {
|
|
Object.entries(rawMinorNameOverrides).forEach(([rawKey, rawValue]) => {
|
|
const key = canonicalMinorName(rawKey);
|
|
const value = String(rawValue || "").trim();
|
|
if (key && value) {
|
|
minorNameOverrides[key] = value;
|
|
}
|
|
});
|
|
}
|
|
|
|
return {
|
|
id: source.id,
|
|
label: String(rawManifest.label || source.label || source.id),
|
|
basePath: String(source.basePath || "").replace(/\/$/, ""),
|
|
majors: rawManifest.majors || {},
|
|
minors: rawManifest.minors || {},
|
|
nameOverrides,
|
|
minorNameOverrides,
|
|
majorNameOverridesByTrump
|
|
};
|
|
}
|
|
|
|
function getDeckManifest(deckId) {
|
|
const normalizedDeckId = normalizeDeckId(deckId);
|
|
if (manifestCache.has(normalizedDeckId)) {
|
|
return manifestCache.get(normalizedDeckId);
|
|
}
|
|
|
|
const source = deckManifestSources[normalizedDeckId];
|
|
if (!source) {
|
|
manifestCache.set(normalizedDeckId, null);
|
|
return null;
|
|
}
|
|
|
|
const rawManifest = readManifestJsonSync(source.manifestPath);
|
|
const normalizedManifest = normalizeDeckManifest(source, rawManifest);
|
|
manifestCache.set(normalizedDeckId, normalizedManifest);
|
|
return normalizedManifest;
|
|
}
|
|
|
|
function getRankIndex(minorRule, parsedMinor) {
|
|
if (!minorRule || !parsedMinor) {
|
|
return null;
|
|
}
|
|
|
|
const lowerRankWord = String(parsedMinor.rankWord || "").toLowerCase();
|
|
const lowerRankKey = String(parsedMinor.rankKey || "").toLowerCase();
|
|
|
|
const indexByKey = minorRule.rankIndexByKey;
|
|
if (indexByKey && typeof indexByKey === "object") {
|
|
const mapped = Number(indexByKey[lowerRankKey]);
|
|
if (Number.isInteger(mapped) && mapped >= 0) {
|
|
return mapped;
|
|
}
|
|
}
|
|
|
|
const rankOrder = Array.isArray(minorRule.rankOrder) ? minorRule.rankOrder : [];
|
|
for (let i = 0; i < rankOrder.length; i += 1) {
|
|
const candidate = String(rankOrder[i] || "").toLowerCase();
|
|
if (candidate && (candidate === lowerRankWord || candidate === lowerRankKey)) {
|
|
return i;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function resolveMajorFile(manifest, canonicalName) {
|
|
const majorRule = manifest?.majors;
|
|
if (!majorRule || typeof majorRule !== "object") {
|
|
return null;
|
|
}
|
|
|
|
if (majorRule.mode === "canonical-map") {
|
|
const cards = majorRule.cards || {};
|
|
const fileName = cards[canonicalName];
|
|
return typeof fileName === "string" && fileName ? fileName : null;
|
|
}
|
|
|
|
const trumpNo = trumpNumberByCanonicalName[canonicalName];
|
|
if (!Number.isInteger(trumpNo) || trumpNo < 0 || trumpNo > 21) {
|
|
return null;
|
|
}
|
|
|
|
if (majorRule.mode === "trump-map") {
|
|
const cards = majorRule.cards || {};
|
|
const fileName = cards[String(trumpNo)] ?? cards[trumpNo];
|
|
return typeof fileName === "string" && fileName ? fileName : null;
|
|
}
|
|
|
|
if (majorRule.mode === "trump-template") {
|
|
const numberPad = Number.isInteger(majorRule.numberPad) ? majorRule.numberPad : 2;
|
|
const template = String(majorRule.template || "{number}.png");
|
|
const number = String(trumpNo).padStart(numberPad, "0");
|
|
return applyTemplate(template, {
|
|
trump: trumpNo,
|
|
number
|
|
});
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function resolveMinorFile(manifest, parsedMinor) {
|
|
const minorRule = manifest?.minors;
|
|
if (!minorRule || typeof minorRule !== "object") {
|
|
return null;
|
|
}
|
|
|
|
const rankIndex = getRankIndex(minorRule, parsedMinor);
|
|
if (!Number.isInteger(rankIndex) || rankIndex < 0) {
|
|
return null;
|
|
}
|
|
|
|
if (minorRule.mode === "suit-base-and-rank-order") {
|
|
const suitBaseRaw = Number(minorRule?.suitBase?.[parsedMinor.suitId]);
|
|
if (!Number.isFinite(suitBaseRaw)) {
|
|
return null;
|
|
}
|
|
|
|
const numberPad = Number.isInteger(minorRule.numberPad) ? minorRule.numberPad : 2;
|
|
const cardNumber = String(suitBaseRaw + rankIndex).padStart(numberPad, "0");
|
|
const suitWord = String(minorRule?.suitLabel?.[parsedMinor.suitId] || toTitleCase(parsedMinor.suitId));
|
|
const template = String(minorRule.template || "{number}_{rank} {suit}.webp");
|
|
|
|
return applyTemplate(template, {
|
|
number: cardNumber,
|
|
rank: parsedMinor.rankWord,
|
|
rankKey: parsedMinor.rankKey,
|
|
suit: suitWord,
|
|
suitId: parsedMinor.suitId,
|
|
index: rankIndex + 1
|
|
});
|
|
}
|
|
|
|
if (minorRule.mode === "suit-prefix-and-rank-order") {
|
|
const suitPrefix = minorRule?.suitPrefix?.[parsedMinor.suitId];
|
|
if (!suitPrefix) {
|
|
return null;
|
|
}
|
|
|
|
const indexStart = Number.isInteger(minorRule.indexStart) ? minorRule.indexStart : 1;
|
|
const indexPad = Number.isInteger(minorRule.indexPad) ? minorRule.indexPad : 2;
|
|
const suitIndex = String(indexStart + rankIndex).padStart(indexPad, "0");
|
|
const template = String(minorRule.template || "{suit}{index}.png");
|
|
|
|
return applyTemplate(template, {
|
|
suit: suitPrefix,
|
|
suitId: parsedMinor.suitId,
|
|
index: suitIndex,
|
|
rank: parsedMinor.rankWord,
|
|
rankKey: parsedMinor.rankKey
|
|
});
|
|
}
|
|
|
|
if (minorRule.mode === "suit-base-number-template") {
|
|
const suitBaseRaw = Number(minorRule?.suitBase?.[parsedMinor.suitId]);
|
|
if (!Number.isFinite(suitBaseRaw)) {
|
|
return null;
|
|
}
|
|
|
|
const numberPad = Number.isInteger(minorRule.numberPad) ? minorRule.numberPad : 2;
|
|
const cardNumber = String(suitBaseRaw + rankIndex).padStart(numberPad, "0");
|
|
const template = String(minorRule.template || "{number}.png");
|
|
|
|
return applyTemplate(template, {
|
|
number: cardNumber,
|
|
suitId: parsedMinor.suitId,
|
|
rank: parsedMinor.rankWord,
|
|
rankKey: parsedMinor.rankKey,
|
|
index: rankIndex
|
|
});
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function resolveWithDeck(deckId, cardName) {
|
|
const manifest = getDeckManifest(deckId);
|
|
if (!manifest) {
|
|
return null;
|
|
}
|
|
|
|
const canonical = canonicalMajorName(cardName);
|
|
const majorFile = resolveMajorFile(manifest, canonical);
|
|
if (majorFile) {
|
|
return `${manifest.basePath}/${majorFile}`;
|
|
}
|
|
|
|
const parsedMinor = parseMinorCard(cardName);
|
|
if (!parsedMinor) {
|
|
return null;
|
|
}
|
|
|
|
const minorFile = resolveMinorFile(manifest, parsedMinor);
|
|
if (!minorFile) {
|
|
return null;
|
|
}
|
|
|
|
return `${manifest.basePath}/${minorFile}`;
|
|
}
|
|
|
|
function resolveTarotCardImage(cardName) {
|
|
const activePath = resolveWithDeck(activeDeckId, cardName);
|
|
if (activePath) {
|
|
return encodeURI(activePath);
|
|
}
|
|
|
|
if (activeDeckId !== DEFAULT_DECK_ID) {
|
|
const fallbackPath = resolveWithDeck(DEFAULT_DECK_ID, cardName);
|
|
if (fallbackPath) {
|
|
return encodeURI(fallbackPath);
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function resolveDisplayNameWithDeck(deckId, cardName, trumpNumber) {
|
|
const manifest = getDeckManifest(deckId);
|
|
const fallbackName = String(cardName || "").trim();
|
|
if (!manifest) {
|
|
return fallbackName;
|
|
}
|
|
|
|
let resolvedTrumpNumber = normalizeTrumpNumber(trumpNumber);
|
|
if (!Number.isInteger(resolvedTrumpNumber)) {
|
|
const canonical = canonicalMajorName(cardName);
|
|
resolvedTrumpNumber = normalizeTrumpNumber(trumpNumberByCanonicalName[canonical]);
|
|
}
|
|
|
|
if (Number.isInteger(resolvedTrumpNumber)) {
|
|
const byTrump = manifest?.majorNameOverridesByTrump?.[resolvedTrumpNumber];
|
|
if (byTrump) {
|
|
return byTrump;
|
|
}
|
|
}
|
|
|
|
const canonical = canonicalMajorName(cardName);
|
|
const override = manifest?.nameOverrides?.[canonical];
|
|
if (override) {
|
|
return override;
|
|
}
|
|
|
|
const minorKey = canonicalMinorName(cardName);
|
|
const minorOverride = manifest?.minorNameOverrides?.[minorKey];
|
|
if (minorOverride) {
|
|
return minorOverride;
|
|
}
|
|
|
|
return fallbackName;
|
|
}
|
|
|
|
function getTarotCardSearchAliases(cardName, optionsOrDeckId) {
|
|
const fallbackName = String(cardName || "").trim();
|
|
if (!fallbackName) {
|
|
return [];
|
|
}
|
|
|
|
const { resolvedDeckId, trumpNumber } = resolveDeckOptions(optionsOrDeckId);
|
|
const aliases = new Set();
|
|
aliases.add(fallbackName);
|
|
|
|
const displayName = String(resolveDisplayNameWithDeck(resolvedDeckId, fallbackName, trumpNumber) || "").trim();
|
|
if (displayName) {
|
|
aliases.add(displayName);
|
|
}
|
|
|
|
const canonicalMajor = canonicalMajorName(fallbackName);
|
|
const resolvedTrumpNumber = Number.isInteger(normalizeTrumpNumber(trumpNumber))
|
|
? normalizeTrumpNumber(trumpNumber)
|
|
: normalizeTrumpNumber(trumpNumberByCanonicalName[canonicalMajor]);
|
|
|
|
if (Number.isInteger(resolvedTrumpNumber)) {
|
|
aliases.add(canonicalMajor);
|
|
aliases.add(`the ${canonicalMajor}`);
|
|
aliases.add(`trump ${resolvedTrumpNumber}`);
|
|
}
|
|
|
|
const parsedMinor = parseMinorCard(fallbackName);
|
|
if (parsedMinor) {
|
|
const suitAliases = suitSearchAliasesById[parsedMinor.suitId] || [parsedMinor.suitId];
|
|
suitAliases.forEach((suitAlias) => {
|
|
aliases.add(`${parsedMinor.rankKey} of ${suitAlias}`);
|
|
if (Number.isInteger(parsedMinor.pipValue)) {
|
|
aliases.add(`${parsedMinor.pipValue} of ${suitAlias}`);
|
|
}
|
|
});
|
|
}
|
|
|
|
return Array.from(aliases);
|
|
}
|
|
|
|
function getTarotCardDisplayName(cardName, optionsOrDeckId) {
|
|
const { resolvedDeckId, trumpNumber } = resolveDeckOptions(optionsOrDeckId);
|
|
|
|
return resolveDisplayNameWithDeck(resolvedDeckId, cardName, trumpNumber);
|
|
}
|
|
|
|
function setActiveDeck(deckId) {
|
|
activeDeckId = normalizeDeckId(deckId);
|
|
getDeckManifest(activeDeckId);
|
|
return activeDeckId;
|
|
}
|
|
|
|
function getDeckOptions() {
|
|
return Object.values(deckManifestSources).map((source) => {
|
|
const manifest = getDeckManifest(source.id);
|
|
return {
|
|
id: source.id,
|
|
label: manifest?.label || source.label
|
|
};
|
|
});
|
|
}
|
|
|
|
document.addEventListener("settings:updated", (event) => {
|
|
const nextDeck = event?.detail?.settings?.tarotDeck;
|
|
setActiveDeck(nextDeck);
|
|
});
|
|
|
|
window.TarotCardImages = {
|
|
resolveTarotCardImage,
|
|
getTarotCardDisplayName,
|
|
getTarotCardSearchAliases,
|
|
setActiveDeck,
|
|
getDeckOptions,
|
|
getActiveDeck: () => activeDeckId
|
|
};
|
|
})();
|