/* ui-quiz.js — Correspondences quiz */ (function () { "use strict"; const state = { initialized: false, scoreCorrect: 0, scoreAnswered: 0, selectedCategory: "random", selectedDifficulty: "normal", questionBank: [], templateByKey: new Map(), runUnseenKeys: [], runRetryKeys: [], runRetrySet: new Set(), currentQuestion: null, answeredCurrent: false, autoAdvanceTimer: null, autoAdvanceDelayMs: 1500 }; const FIXED_CATEGORY_OPTIONS = [ { value: "random", label: "Random" }, { value: "all", label: "All" } ]; const CATEGORY_META = [ { id: "english-gematria", label: "English Gematria" }, { id: "hebrew-numerology", label: "Hebrew Gematria" }, { id: "english-hebrew-mapping", label: "Alphabet Mapping" }, { id: "zodiac-rulers", label: "Zodiac Rulers" }, { id: "zodiac-elements", label: "Zodiac Elements" }, { id: "planetary-weekdays", label: "Planetary Weekdays" }, { id: "zodiac-tarot", label: "Zodiac ↔ Tarot" }, { id: "kabbalah-path-between-sephirot", label: "Kabbalah: Path Between Sephirot" }, { id: "kabbalah-path-letter", label: "Kabbalah: Path Number ↔ Letter" }, { id: "kabbalah-path-tarot", label: "Kabbalah: Path ↔ Tarot Trump" }, { id: "sephirot-planets", label: "Sephirot ↔ Planet" }, { id: "tarot-decan-sign", label: "Tarot Decans: Card ↔ Decan" }, { id: "tarot-decan-ruler", label: "Tarot Decans: Card ↔ Ruler" }, { id: "tarot-cube-location", label: "Tarot ↔ Cube Location" }, { id: "cube-hebrew-letter", label: "Cube ↔ Hebrew Letter" }, { id: "playing-card-tarot", label: "Playing Card ↔ Tarot" } ]; // Dynamic category plugin registry — populated by registerQuizCategory() const DYNAMIC_CATEGORY_REGISTRY = []; function registerQuizCategory(id, label, builder) { if (typeof id !== "string" || !id || typeof builder !== "function") { return; } const existingIndex = DYNAMIC_CATEGORY_REGISTRY.findIndex((entry) => entry.id === id); if (existingIndex >= 0) { DYNAMIC_CATEGORY_REGISTRY[existingIndex] = { id, label: String(label || id), builder }; } else { DYNAMIC_CATEGORY_REGISTRY.push({ id, label: String(label || id), builder }); } if (!CATEGORY_META.some((item) => item.id === id)) { CATEGORY_META.push({ id, label: String(label || id) }); } } let categoryEl; let difficultyEl; let questionTypeEl; let questionEl; let optionsEl; let feedbackEl; let resetEl; let scoreCorrectEl; let scoreAnsweredEl; let scoreAccuracyEl; function getElements() { categoryEl = document.getElementById("quiz-category"); difficultyEl = document.getElementById("quiz-difficulty"); questionTypeEl = document.getElementById("quiz-question-type"); questionEl = document.getElementById("quiz-question"); optionsEl = document.getElementById("quiz-options"); feedbackEl = document.getElementById("quiz-feedback"); resetEl = document.getElementById("quiz-reset"); scoreCorrectEl = document.getElementById("quiz-score-correct"); scoreAnsweredEl = document.getElementById("quiz-score-answered"); scoreAccuracyEl = document.getElementById("quiz-score-accuracy"); } function isReady() { return Boolean( categoryEl && difficultyEl && questionTypeEl && questionEl && optionsEl && feedbackEl && resetEl && scoreCorrectEl && scoreAnsweredEl && scoreAccuracyEl ); } function clearAutoAdvanceTimer() { if (!state.autoAdvanceTimer) { return; } clearTimeout(state.autoAdvanceTimer); state.autoAdvanceTimer = null; } function queueAutoAdvance() { clearAutoAdvanceTimer(); state.autoAdvanceTimer = setTimeout(() => { state.autoAdvanceTimer = null; showNextQuestion(); }, state.autoAdvanceDelayMs); } function toTitleCase(value) { const text = String(value || "").trim().toLowerCase(); if (!text) { return ""; } return text.charAt(0).toUpperCase() + text.slice(1); } function normalizeOption(value) { return String(value || "").trim(); } function normalizeKey(value) { return normalizeOption(value).toLowerCase(); } function toUniqueOptionList(values) { const seen = new Set(); const unique = []; (values || []).forEach((value) => { const formatted = normalizeOption(value); if (!formatted) { return; } const key = normalizeKey(formatted); if (seen.has(key)) { return; } seen.add(key); unique.push(formatted); }); return unique; } function shuffle(list) { const clone = list.slice(); for (let index = clone.length - 1; index > 0; index -= 1) { const nextIndex = Math.floor(Math.random() * (index + 1)); [clone[index], clone[nextIndex]] = [clone[nextIndex], clone[index]]; } return clone; } function pickMany(list, count) { if (!Array.isArray(list) || !list.length || count <= 0) { return []; } return shuffle(list).slice(0, Math.min(count, list.length)); } function getActiveDifficulty() { const value = String(state.selectedDifficulty || "normal").toLowerCase(); if (value === "easy" || value === "hard") { return value; } return "normal"; } function resolveDifficultyValue(valueByDifficulty, difficulty = getActiveDifficulty()) { if (valueByDifficulty == null) { return ""; } if (typeof valueByDifficulty !== "object" || Array.isArray(valueByDifficulty)) { return valueByDifficulty; } if (Object.prototype.hasOwnProperty.call(valueByDifficulty, difficulty)) { return valueByDifficulty[difficulty]; } if (Object.prototype.hasOwnProperty.call(valueByDifficulty, "normal")) { return valueByDifficulty.normal; } if (Object.prototype.hasOwnProperty.call(valueByDifficulty, "easy")) { return valueByDifficulty.easy; } if (Object.prototype.hasOwnProperty.call(valueByDifficulty, "hard")) { return valueByDifficulty.hard; } return ""; } function buildOptions(correctValue, poolValues) { const correct = normalizeOption(correctValue); if (!correct) { return null; } const uniquePool = toUniqueOptionList(poolValues || []); if (!uniquePool.some((value) => normalizeKey(value) === normalizeKey(correct))) { uniquePool.push(correct); } const distractors = uniquePool.filter((value) => normalizeKey(value) !== normalizeKey(correct)); if (distractors.length < 3) { return null; } const selectedDistractors = pickMany(distractors, 3); const options = shuffle([correct, ...selectedDistractors]); const correctIndex = options.findIndex((value) => normalizeKey(value) === normalizeKey(correct)); if (correctIndex < 0 || options.length < 4) { return null; } return { options, correctIndex }; } function createQuestionTemplate(payload, poolValues) { const key = String(payload?.key || "").trim(); const promptByDifficulty = payload?.promptByDifficulty ?? payload?.prompt; const answerByDifficulty = payload?.answerByDifficulty ?? payload?.answer; const poolByDifficulty = poolValues; const categoryId = String(payload?.categoryId || "").trim(); const category = String(payload?.category || "Correspondence").trim(); const defaultPrompt = String(resolveDifficultyValue(promptByDifficulty, "normal") || "").trim(); const defaultAnswer = normalizeOption(resolveDifficultyValue(answerByDifficulty, "normal")); const defaultPool = toUniqueOptionList(resolveDifficultyValue(poolByDifficulty, "normal") || []); if (!key || !defaultPrompt || !defaultAnswer || !categoryId || !category) { return null; } if (!defaultPool.some((value) => normalizeKey(value) === normalizeKey(defaultAnswer))) { defaultPool.push(defaultAnswer); } const distractorCount = defaultPool.filter((value) => normalizeKey(value) !== normalizeKey(defaultAnswer)).length; if (distractorCount < 3) { return null; } return { key, categoryId, category, promptByDifficulty, answerByDifficulty, poolByDifficulty }; } function instantiateQuestion(template) { if (!template) { return null; } const prompt = String(resolveDifficultyValue(template.promptByDifficulty) || "").trim(); const answer = normalizeOption(resolveDifficultyValue(template.answerByDifficulty)); const pool = toUniqueOptionList(resolveDifficultyValue(template.poolByDifficulty) || []); if (!prompt || !answer) { return null; } const built = buildOptions(answer, pool); if (!built) { return null; } return { key: template.key, categoryId: template.categoryId, category: template.category, prompt, answer, options: built.options, correctIndex: built.correctIndex }; } function buildQuestionBank(referenceData, magickDataset) { const grouped = magickDataset?.grouped || {}; const alphabets = grouped.alphabets || {}; const englishLetters = Array.isArray(alphabets?.english) ? alphabets.english : []; const hebrewLetters = Array.isArray(alphabets?.hebrew) ? alphabets.hebrew : []; const kabbalahTree = grouped?.kabbalah?.["kabbalah-tree"] || {}; const treePaths = Array.isArray(kabbalahTree?.paths) ? kabbalahTree.paths : []; const treeSephiroth = Array.isArray(kabbalahTree?.sephiroth) ? kabbalahTree.sephiroth : []; const sephirotById = grouped?.kabbalah?.sephirot && typeof grouped.kabbalah.sephirot === "object" ? grouped.kabbalah.sephirot : {}; const cube = grouped?.kabbalah?.cube && typeof grouped.kabbalah.cube === "object" ? grouped.kabbalah.cube : {}; const cubeWalls = Array.isArray(cube?.walls) ? cube.walls : []; const cubeEdges = Array.isArray(cube?.edges) ? cube.edges : []; const cubeCenter = cube?.center && typeof cube.center === "object" ? cube.center : null; const playingCardsData = grouped?.["playing-cards-52"]; const playingCards = Array.isArray(playingCardsData) ? playingCardsData : (Array.isArray(playingCardsData?.entries) ? playingCardsData.entries : []); const signs = Array.isArray(referenceData?.signs) ? referenceData.signs : []; const planetsById = referenceData?.planets && typeof referenceData.planets === "object" ? referenceData.planets : {}; const planets = Object.values(planetsById); const decansBySign = referenceData?.decansBySign && typeof referenceData.decansBySign === "object" ? referenceData.decansBySign : {}; const normalizeId = (value) => String(value || "").trim().toLowerCase(); const toRomanNumeral = (value) => { const numeric = Number(value); if (!Number.isFinite(numeric) || numeric <= 0) { return String(value || ""); } const intValue = Math.trunc(numeric); const lookup = [ [1000, "M"], [900, "CM"], [500, "D"], [400, "CD"], [100, "C"], [90, "XC"], [50, "L"], [40, "XL"], [10, "X"], [9, "IX"], [5, "V"], [4, "IV"], [1, "I"] ]; let current = intValue; let result = ""; lookup.forEach(([size, symbol]) => { while (current >= size) { result += symbol; current -= size; } }); return result || String(intValue); }; const labelFromId = (value) => { const id = String(value || "").trim(); if (!id) { return ""; } return id .replace(/[_-]+/g, " ") .replace(/\s+/g, " ") .trim() .split(" ") .map((part) => part ? part.charAt(0).toUpperCase() + part.slice(1) : "") .join(" "); }; const getPlanetLabelById = (planetId) => { const normalized = normalizeId(planetId); if (!normalized) { return ""; } const directPlanet = planetsById[normalized]; if (directPlanet?.name) { return directPlanet.name; } if (normalized === "primum-mobile") { return "Primum Mobile"; } if (normalized === "olam-yesodot") { return "Earth / Elements"; } return labelFromId(normalized); }; const hebrewById = new Map( hebrewLetters .filter((entry) => entry?.hebrewLetterId) .map((entry) => [normalizeId(entry.hebrewLetterId), entry]) ); const formatHebrewLetterLabel = (entry, fallbackId = "") => { if (entry?.name && entry?.char) { return `${entry.name} (${entry.char})`; } if (entry?.name) { return entry.name; } if (entry?.char) { return entry.char; } return labelFromId(fallbackId); }; const sephiraNameByNumber = new Map( treeSephiroth .filter((entry) => Number.isFinite(Number(entry?.number)) && entry?.name) .map((entry) => [Math.trunc(Number(entry.number)), String(entry.name)]) ); const sephiraNameById = new Map( treeSephiroth .filter((entry) => entry?.sephiraId && entry?.name) .map((entry) => [normalizeId(entry.sephiraId), String(entry.name)]) ); const getSephiraName = (numberValue, idValue) => { const numberKey = Number(numberValue); if (Number.isFinite(numberKey)) { const byNumber = sephiraNameByNumber.get(Math.trunc(numberKey)); if (byNumber) { return byNumber; } } const byId = sephiraNameById.get(normalizeId(idValue)); if (byId) { return byId; } if (Number.isFinite(numberKey)) { return `Sephira ${Math.trunc(numberKey)}`; } return labelFromId(idValue); }; const formatPathLetter = (path) => { const transliteration = String(path?.hebrewLetter?.transliteration || "").trim(); const glyph = String(path?.hebrewLetter?.char || "").trim(); if (transliteration && glyph) { return `${transliteration} (${glyph})`; } if (transliteration) { return transliteration; } if (glyph) { return glyph; } return ""; }; const flattenDecans = Object.values(decansBySign) .flatMap((entries) => (Array.isArray(entries) ? entries : [])); const signNameById = new Map( signs .filter((entry) => entry?.id && entry?.name) .map((entry) => [normalizeId(entry.id), String(entry.name)]) ); const formatDecanLabel = (decan) => { const signName = signNameById.get(normalizeId(decan?.signId)) || labelFromId(decan?.signId); const index = Number(decan?.index); if (!signName || !Number.isFinite(index)) { return ""; } return `${signName} Decan ${toRomanNumeral(index)}`; }; const bank = []; const englishGematriaPool = englishLetters .map((item) => (Number.isFinite(Number(item?.pythagorean)) ? String(item.pythagorean) : "")) .filter(Boolean); const hebrewNumerologyPool = hebrewLetters .map((item) => (Number.isFinite(Number(item?.numerology)) ? String(item.numerology) : "")) .filter(Boolean); const hebrewNameAndCharPool = hebrewLetters .filter((item) => item?.name && item?.char) .map((item) => `${item.name} (${item.char})`); const hebrewCharPool = hebrewLetters .map((item) => item?.char) .filter(Boolean); const planetNamePool = planets .map((planet) => planet?.name) .filter(Boolean); const planetWeekdayPool = planets .map((planet) => planet?.weekday) .filter(Boolean); const zodiacElementPool = signs .map((sign) => toTitleCase(sign?.element)) .filter(Boolean); const zodiacTarotPool = signs .map((sign) => sign?.tarot?.majorArcana) .filter(Boolean); const pathNumberPool = toUniqueOptionList( treePaths .map((path) => { const pathNo = Number(path?.pathNumber); return Number.isFinite(pathNo) ? String(Math.trunc(pathNo)) : ""; }) ); const pathLetterPool = toUniqueOptionList(treePaths.map((path) => formatPathLetter(path))); const pathTarotPool = toUniqueOptionList(treePaths.map((path) => normalizeOption(path?.tarot?.card))); const decanLabelPool = toUniqueOptionList(flattenDecans.map((decan) => formatDecanLabel(decan))); const decanRulerPool = toUniqueOptionList( flattenDecans.map((decan) => getPlanetLabelById(decan?.rulerPlanetId)) ); const cubeWallLabelPool = toUniqueOptionList( cubeWalls.map((wall) => `${String(wall?.name || labelFromId(wall?.id)).trim()} Wall`) ); const cubeEdgeLabelPool = toUniqueOptionList( cubeEdges.map((edge) => `${String(edge?.name || labelFromId(edge?.id)).trim()} Edge`) ); const cubeLocationPool = toUniqueOptionList([ ...cubeWallLabelPool, ...cubeEdgeLabelPool, "Center" ]); const cubeHebrewLetterPool = toUniqueOptionList([ ...cubeWalls.map((wall) => { const hebrew = hebrewById.get(normalizeId(wall?.hebrewLetterId)); return formatHebrewLetterLabel(hebrew, wall?.hebrewLetterId); }), ...cubeEdges.map((edge) => { const hebrew = hebrewById.get(normalizeId(edge?.hebrewLetterId)); return formatHebrewLetterLabel(hebrew, edge?.hebrewLetterId); }), formatHebrewLetterLabel(hebrewById.get(normalizeId(cubeCenter?.hebrewLetterId)), cubeCenter?.hebrewLetterId) ]); const playingTarotPool = toUniqueOptionList( playingCards.map((entry) => normalizeOption(entry?.tarotCard)) ); englishLetters.forEach((entry) => { if (!entry?.letter || !Number.isFinite(Number(entry?.pythagorean))) { return; } const template = createQuestionTemplate( { key: `english-gematria:${entry.letter}`, categoryId: "english-gematria", category: "English Gematria", promptByDifficulty: `${entry.letter} has a simple gematria value of`, answerByDifficulty: String(entry.pythagorean) }, englishGematriaPool ); if (template) { bank.push(template); } }); hebrewLetters.forEach((entry) => { if (!entry?.name || !entry?.char || !Number.isFinite(Number(entry?.numerology))) { return; } const template = createQuestionTemplate( { key: `hebrew-number:${entry.hebrewLetterId || entry.name}`, categoryId: "hebrew-numerology", category: "Hebrew Gematria", promptByDifficulty: { easy: `${entry.name} (${entry.char}) has a gematria value of`, normal: `${entry.name} (${entry.char}) has a gematria value of`, hard: `${entry.char} has a gematria value of` }, answerByDifficulty: String(entry.numerology) }, hebrewNumerologyPool ); if (template) { bank.push(template); } }); englishLetters.forEach((entry) => { if (!entry?.letter || !entry?.hebrewLetterId) { return; } const mappedHebrew = hebrewById.get(String(entry.hebrewLetterId)); if (!mappedHebrew?.name || !mappedHebrew?.char) { return; } const template = createQuestionTemplate( { key: `english-hebrew:${entry.letter}`, categoryId: "english-hebrew-mapping", category: "Alphabet Mapping", promptByDifficulty: { easy: `${entry.letter} maps to which Hebrew letter`, normal: `${entry.letter} maps to which Hebrew letter`, hard: `${entry.letter} maps to which Hebrew glyph` }, answerByDifficulty: { easy: `${mappedHebrew.name} (${mappedHebrew.char})`, normal: `${mappedHebrew.name} (${mappedHebrew.char})`, hard: mappedHebrew.char } }, { easy: hebrewNameAndCharPool, normal: hebrewNameAndCharPool, hard: hebrewCharPool } ); if (template) { bank.push(template); } }); signs.forEach((entry) => { if (!entry?.name || !entry?.rulingPlanetId) { return; } const rulerName = planetsById[String(entry.rulingPlanetId)]?.name; if (!rulerName) { return; } const template = createQuestionTemplate( { key: `zodiac-ruler:${entry.id || entry.name}`, categoryId: "zodiac-rulers", category: "Zodiac Rulers", promptByDifficulty: `${entry.name} is ruled by`, answerByDifficulty: rulerName }, planetNamePool ); if (template) { bank.push(template); } }); signs.forEach((entry) => { if (!entry?.name || !entry?.element) { return; } const template = createQuestionTemplate( { key: `zodiac-element:${entry.id || entry.name}`, categoryId: "zodiac-elements", category: "Zodiac Elements", promptByDifficulty: `${entry.name} is`, answerByDifficulty: toTitleCase(entry.element) }, zodiacElementPool ); if (template) { bank.push(template); } }); planets.forEach((entry) => { if (!entry?.name || !entry?.weekday) { return; } const template = createQuestionTemplate( { key: `planet-weekday:${entry.id || entry.name}`, categoryId: "planetary-weekdays", category: "Planetary Weekdays", promptByDifficulty: `${entry.name} corresponds to`, answerByDifficulty: entry.weekday }, planetWeekdayPool ); if (template) { bank.push(template); } }); signs.forEach((entry) => { if (!entry?.name || !entry?.tarot?.majorArcana) { return; } const template = createQuestionTemplate( { key: `zodiac-tarot:${entry.id || entry.name}`, categoryId: "zodiac-tarot", category: "Zodiac ↔ Tarot", promptByDifficulty: `${entry.name} corresponds to`, answerByDifficulty: entry.tarot.majorArcana }, zodiacTarotPool ); if (template) { bank.push(template); } }); treePaths.forEach((path) => { const pathNo = Number(path?.pathNumber); if (!Number.isFinite(pathNo)) { return; } const pathNumberLabel = String(Math.trunc(pathNo)); const fromNo = Number(path?.connects?.from); const toNo = Number(path?.connects?.to); const fromName = getSephiraName(fromNo, path?.connectIds?.from); const toName = getSephiraName(toNo, path?.connectIds?.to); const pathLetter = formatPathLetter(path); const tarotCard = normalizeOption(path?.tarot?.card); if (fromName && toName) { const template = createQuestionTemplate( { key: `kabbalah-path-between:${pathNumberLabel}`, categoryId: "kabbalah-path-between-sephirot", category: "Kabbalah Paths", promptByDifficulty: { easy: `Which path is between ${fromName} and ${toName}`, normal: `What path connects ${fromName} and ${toName}`, hard: `${fromName} ↔ ${toName} is which path` }, answerByDifficulty: pathNumberLabel }, pathNumberPool ); if (template) { bank.push(template); } } if (pathLetter) { const numberToLetterTemplate = createQuestionTemplate( { key: `kabbalah-path-letter:${pathNumberLabel}`, categoryId: "kabbalah-path-letter", category: "Kabbalah Paths", promptByDifficulty: { easy: `Which letter is on Path ${pathNumberLabel}`, normal: `Path ${pathNumberLabel} carries which Hebrew letter`, hard: `Letter on Path ${pathNumberLabel}` }, answerByDifficulty: pathLetter }, pathLetterPool ); if (numberToLetterTemplate) { bank.push(numberToLetterTemplate); } const letterToNumberTemplate = createQuestionTemplate( { key: `kabbalah-letter-path-number:${pathNumberLabel}`, categoryId: "kabbalah-path-letter", category: "Kabbalah Paths", promptByDifficulty: { easy: `${pathLetter} belongs to which path`, normal: `${pathLetter} corresponds to Path`, hard: `${pathLetter} is on Path` }, answerByDifficulty: pathNumberLabel }, pathNumberPool ); if (letterToNumberTemplate) { bank.push(letterToNumberTemplate); } } if (tarotCard) { const pathToTarotTemplate = createQuestionTemplate( { key: `kabbalah-path-tarot:${pathNumberLabel}`, categoryId: "kabbalah-path-tarot", category: "Kabbalah ↔ Tarot", promptByDifficulty: { easy: `Path ${pathNumberLabel} corresponds to which Tarot trump`, normal: `Which Tarot trump is on Path ${pathNumberLabel}`, hard: `Tarot trump on Path ${pathNumberLabel}` }, answerByDifficulty: tarotCard }, pathTarotPool ); if (pathToTarotTemplate) { bank.push(pathToTarotTemplate); } const tarotToPathTemplate = createQuestionTemplate( { key: `tarot-trump-path:${pathNumberLabel}`, categoryId: "kabbalah-path-tarot", category: "Tarot ↔ Kabbalah", promptByDifficulty: { easy: `${tarotCard} is on which path`, normal: `Which path corresponds to ${tarotCard}`, hard: `${tarotCard} corresponds to Path` }, answerByDifficulty: pathNumberLabel }, pathNumberPool ); if (tarotToPathTemplate) { bank.push(tarotToPathTemplate); } } }); Object.values(sephirotById).forEach((sephira) => { const sephiraName = String(sephira?.name?.roman || sephira?.name?.en || "").trim(); const planetLabel = getPlanetLabelById(sephira?.planetId); if (!sephiraName || !planetLabel) { return; } const template = createQuestionTemplate( { key: `sephirot-planet:${normalizeId(sephira?.id || sephiraName)}`, categoryId: "sephirot-planets", category: "Sephirot ↔ Planet", promptByDifficulty: { easy: `${sephiraName} corresponds to which planet`, normal: `Planetary correspondence of ${sephiraName}`, hard: `${sephiraName} corresponds to` }, answerByDifficulty: planetLabel }, toUniqueOptionList(Object.values(sephirotById).map((entry) => getPlanetLabelById(entry?.planetId))) ); if (template) { bank.push(template); } }); flattenDecans.forEach((decan) => { const decanId = String(decan?.id || "").trim(); const card = normalizeOption(decan?.tarotMinorArcana); const decanLabel = formatDecanLabel(decan); const rulerLabel = getPlanetLabelById(decan?.rulerPlanetId); if (!decanId || !card) { return; } if (decanLabel) { const template = createQuestionTemplate( { key: `tarot-decan-sign:${decanId}`, categoryId: "tarot-decan-sign", category: "Tarot Decans", promptByDifficulty: { easy: `${card} belongs to which decan`, normal: `Which decan contains ${card}`, hard: `${card} is in` }, answerByDifficulty: decanLabel }, decanLabelPool ); if (template) { bank.push(template); } } if (rulerLabel) { const template = createQuestionTemplate( { key: `tarot-decan-ruler:${decanId}`, categoryId: "tarot-decan-ruler", category: "Tarot Decans", promptByDifficulty: { easy: `The decan of ${card} is ruled by`, normal: `Who rules the decan for ${card}`, hard: `${card} decan ruler` }, answerByDifficulty: rulerLabel }, decanRulerPool ); if (template) { bank.push(template); } } }); cubeWalls.forEach((wall) => { const wallName = String(wall?.name || labelFromId(wall?.id)).trim(); const wallLabel = wallName ? `${wallName} Wall` : ""; const tarotCard = normalizeOption(wall?.associations?.tarotCard); const hebrew = hebrewById.get(normalizeId(wall?.hebrewLetterId)); const hebrewLabel = formatHebrewLetterLabel(hebrew, wall?.hebrewLetterId); if (tarotCard && wallLabel) { const template = createQuestionTemplate( { key: `tarot-cube-wall:${normalizeId(wall?.id || wallName)}`, categoryId: "tarot-cube-location", category: "Tarot ↔ Cube", promptByDifficulty: { easy: `${tarotCard} is on which Cube wall`, normal: `Where is ${tarotCard} on the Cube`, hard: `${tarotCard} location on Cube` }, answerByDifficulty: wallLabel }, cubeLocationPool ); if (template) { bank.push(template); } } if (wallLabel && hebrewLabel) { const template = createQuestionTemplate( { key: `cube-wall-letter:${normalizeId(wall?.id || wallName)}`, categoryId: "cube-hebrew-letter", category: "Cube ↔ Hebrew", promptByDifficulty: { easy: `${wallLabel} corresponds to which Hebrew letter`, normal: `Which Hebrew letter is on ${wallLabel}`, hard: `${wallLabel} letter` }, answerByDifficulty: hebrewLabel }, cubeHebrewLetterPool ); if (template) { bank.push(template); } } }); cubeEdges.forEach((edge) => { const edgeName = String(edge?.name || labelFromId(edge?.id)).trim(); const edgeLabel = edgeName ? `${edgeName} Edge` : ""; const hebrew = hebrewById.get(normalizeId(edge?.hebrewLetterId)); const hebrewLabel = formatHebrewLetterLabel(hebrew, edge?.hebrewLetterId); const tarotCard = normalizeOption(hebrew?.tarot?.card); if (tarotCard && edgeLabel) { const template = createQuestionTemplate( { key: `tarot-cube-edge:${normalizeId(edge?.id || edgeName)}`, categoryId: "tarot-cube-location", category: "Tarot ↔ Cube", promptByDifficulty: { easy: `${tarotCard} is on which Cube edge`, normal: `Where is ${tarotCard} on the Cube edges`, hard: `${tarotCard} edge location` }, answerByDifficulty: edgeLabel }, cubeLocationPool ); if (template) { bank.push(template); } } if (edgeLabel && hebrewLabel) { const template = createQuestionTemplate( { key: `cube-edge-letter:${normalizeId(edge?.id || edgeName)}`, categoryId: "cube-hebrew-letter", category: "Cube ↔ Hebrew", promptByDifficulty: { easy: `${edgeLabel} corresponds to which Hebrew letter`, normal: `Which Hebrew letter is on ${edgeLabel}`, hard: `${edgeLabel} letter` }, answerByDifficulty: hebrewLabel }, cubeHebrewLetterPool ); if (template) { bank.push(template); } } }); if (cubeCenter) { const centerTarot = normalizeOption(cubeCenter?.associations?.tarotCard || cubeCenter?.tarotCard); const centerHebrew = hebrewById.get(normalizeId(cubeCenter?.hebrewLetterId)); const centerHebrewLabel = formatHebrewLetterLabel(centerHebrew, cubeCenter?.hebrewLetterId); if (centerTarot) { const template = createQuestionTemplate( { key: "tarot-cube-center", categoryId: "tarot-cube-location", category: "Tarot ↔ Cube", promptByDifficulty: { easy: `${centerTarot} is located at which Cube position`, normal: `Where is ${centerTarot} on the Cube`, hard: `${centerTarot} Cube location` }, answerByDifficulty: "Center" }, cubeLocationPool ); if (template) { bank.push(template); } } if (centerHebrewLabel) { const template = createQuestionTemplate( { key: "cube-center-letter", categoryId: "cube-hebrew-letter", category: "Cube ↔ Hebrew", promptByDifficulty: { easy: "The Cube center corresponds to which Hebrew letter", normal: "Which Hebrew letter is at the Cube center", hard: "Cube center letter" }, answerByDifficulty: centerHebrewLabel }, cubeHebrewLetterPool ); if (template) { bank.push(template); } } } playingCards.forEach((entry) => { const cardId = String(entry?.id || "").trim(); const rankLabel = normalizeOption(entry?.rankLabel || entry?.rank); const suitLabel = normalizeOption(entry?.suitLabel || labelFromId(entry?.suit)); const tarotCard = normalizeOption(entry?.tarotCard); if (!cardId || !rankLabel || !suitLabel || !tarotCard) { return; } const template = createQuestionTemplate( { key: `playing-card-tarot:${cardId}`, categoryId: "playing-card-tarot", category: "Playing Card ↔ Tarot", promptByDifficulty: { easy: `${rankLabel} of ${suitLabel} maps to which Tarot card`, normal: `${rankLabel} of ${suitLabel} corresponds to`, hard: `${rankLabel} of ${suitLabel} maps to` }, answerByDifficulty: tarotCard }, playingTarotPool ); if (template) { bank.push(template); } }); // Dynamic plugin categories DYNAMIC_CATEGORY_REGISTRY.forEach(({ builder }) => { try { const dynamicTemplates = builder(referenceData, magickDataset); if (Array.isArray(dynamicTemplates)) { dynamicTemplates.forEach((t) => { if (t) { bank.push(t); } }); } } catch (_e) { // skip broken plugins silently } }); return bank; } function refreshQuestionBank(referenceData, magickDataset) { state.questionBank = buildQuestionBank(referenceData, magickDataset); state.templateByKey = new Map(state.questionBank.map((template) => [template.key, template])); const hasTemplate = (key) => state.templateByKey.has(key); state.runUnseenKeys = state.runUnseenKeys.filter(hasTemplate); state.runRetryKeys = state.runRetryKeys.filter(hasTemplate); state.runRetrySet = new Set(state.runRetryKeys); if (state.currentQuestion && !state.templateByKey.has(state.currentQuestion.key)) { state.currentQuestion = null; state.answeredCurrent = true; } } function getScopedTemplates() { if (!state.questionBank.length) { return []; } const mode = state.selectedCategory || "random"; if (mode === "random" || mode === "all") { return state.questionBank.slice(); } return state.questionBank.filter((template) => template.categoryId === mode); } function getCategoryOptions() { const availableCategoryIds = new Set(state.questionBank.map((template) => template.categoryId)); const dynamic = CATEGORY_META .filter((item) => availableCategoryIds.has(item.id)) .map((item) => ({ value: item.id, label: item.label })); return [...FIXED_CATEGORY_OPTIONS, ...dynamic]; } function renderCategoryOptions() { if (!categoryEl) { return false; } const options = getCategoryOptions(); const preservedValue = state.selectedCategory; categoryEl.innerHTML = ""; options.forEach((optionDef) => { const option = document.createElement("option"); option.value = optionDef.value; option.textContent = optionDef.label; categoryEl.appendChild(option); }); const validValues = new Set(options.map((optionDef) => optionDef.value)); const nextSelected = validValues.has(preservedValue) ? preservedValue : "random"; state.selectedCategory = nextSelected; categoryEl.value = nextSelected; return nextSelected !== preservedValue; } function syncDifficultyControl() { if (!difficultyEl) { return; } const difficulty = getActiveDifficulty(); state.selectedDifficulty = difficulty; difficultyEl.value = difficulty; } function getRunLabel() { const mode = state.selectedCategory || "random"; if (mode === "random") { return "Random"; } if (mode === "all") { return "All"; } return CATEGORY_META.find((item) => item.id === mode)?.label || "Category"; } function startRun(resetScore = false) { clearAutoAdvanceTimer(); if (resetScore) { state.scoreCorrect = 0; state.scoreAnswered = 0; } const templates = getScopedTemplates(); const mode = state.selectedCategory || "random"; if (!templates.length) { state.runUnseenKeys = []; state.runRetryKeys = []; state.runRetrySet = new Set(); state.currentQuestion = null; state.answeredCurrent = true; updateScoreboard(); renderNoQuestionState(); return; } let orderedTemplates; if (mode === "all") { orderedTemplates = templates .slice() .sort((left, right) => { const leftCategory = String(left.category || ""); const rightCategory = String(right.category || ""); if (leftCategory === rightCategory) { return String(left.key || "").localeCompare(String(right.key || "")); } return leftCategory.localeCompare(rightCategory); }); } else { orderedTemplates = shuffle(templates); } state.runUnseenKeys = orderedTemplates.map((template) => template.key); state.runRetryKeys = []; state.runRetrySet = new Set(); state.currentQuestion = null; state.answeredCurrent = true; updateScoreboard(); showNextQuestion(); } function popNextTemplateFromRun() { while (state.runUnseenKeys.length) { const key = state.runUnseenKeys.shift(); const template = state.templateByKey.get(key); if (template) { return template; } } while (state.runRetryKeys.length) { const key = state.runRetryKeys.shift(); state.runRetrySet.delete(key); const template = state.templateByKey.get(key); if (template) { return template; } } return null; } function renderRunCompleteState() { state.currentQuestion = null; state.answeredCurrent = true; questionTypeEl.textContent = getRunLabel(); questionEl.textContent = "Run complete. All previously missed questions are now correct."; optionsEl.innerHTML = ""; feedbackEl.textContent = "Change category/difficulty or press Reset Score to start a new run."; } function updateScoreboard() { if (!isReady()) { return; } const answered = state.scoreAnswered; const correct = state.scoreCorrect; const accuracy = answered > 0 ? `${Math.round((correct / answered) * 100)}%` : "0%"; scoreCorrectEl.textContent = String(correct); scoreAnsweredEl.textContent = String(answered); scoreAccuracyEl.textContent = accuracy; } function renderQuestion(question) { if (!isReady()) { return; } state.currentQuestion = question; state.answeredCurrent = false; questionTypeEl.textContent = question.category; questionEl.textContent = question.prompt; feedbackEl.textContent = "Choose the best answer."; optionsEl.innerHTML = ""; question.options.forEach((optionText, index) => { const button = document.createElement("button"); button.type = "button"; button.className = "quiz-option"; button.textContent = `${String.fromCharCode(65 + index)}. ${optionText}`; button.dataset.optionIndex = String(index); button.addEventListener("click", () => { submitAnswer(index); }); optionsEl.appendChild(button); }); } function renderNoQuestionState() { if (!isReady()) { return; } state.currentQuestion = null; state.answeredCurrent = true; questionTypeEl.textContent = getRunLabel(); questionEl.textContent = "Not enough variation for this category yet."; optionsEl.innerHTML = ""; feedbackEl.textContent = "Try Random/All or switch to another category."; } function showNextQuestion() { clearAutoAdvanceTimer(); const totalPending = state.runUnseenKeys.length + state.runRetryKeys.length; if (totalPending <= 0) { if (state.questionBank.length) { renderRunCompleteState(); } else { renderNoQuestionState(); } return; } const maxAttempts = totalPending + 1; for (let index = 0; index < maxAttempts; index += 1) { const template = popNextTemplateFromRun(); if (!template) { continue; } const question = instantiateQuestion(template); if (question) { renderQuestion(question); return; } } renderNoQuestionState(); } function submitAnswer(optionIndex) { if (!state.currentQuestion || state.answeredCurrent) { return; } state.answeredCurrent = true; state.scoreAnswered += 1; const isCorrect = optionIndex === state.currentQuestion.correctIndex; if (isCorrect) { state.scoreCorrect += 1; } const optionButtons = optionsEl.querySelectorAll(".quiz-option"); optionButtons.forEach((button, index) => { button.disabled = true; if (index === state.currentQuestion.correctIndex) { button.classList.add("is-correct"); } else if (index === optionIndex) { button.classList.add("is-wrong"); } }); if (isCorrect) { feedbackEl.textContent = "Correct. Next question incoming…"; } else { feedbackEl.textContent = `Not quite. Correct answer: ${state.currentQuestion.answer}. Next question incoming…`; const failedKey = state.currentQuestion.key; if (!state.runRetrySet.has(failedKey)) { state.runRetrySet.add(failedKey); state.runRetryKeys.push(failedKey); } } updateScoreboard(); queueAutoAdvance(); } function bindEvents() { if (!isReady()) { return; } categoryEl.addEventListener("change", () => { state.selectedCategory = String(categoryEl.value || "random"); startRun(true); }); difficultyEl.addEventListener("change", () => { state.selectedDifficulty = String(difficultyEl.value || "normal").toLowerCase(); syncDifficultyControl(); startRun(true); }); resetEl.addEventListener("click", () => { startRun(true); }); } function ensureQuizSection(referenceData, magickDataset) { ensureQuizSection._referenceData = referenceData; ensureQuizSection._magickDataset = magickDataset; if (!state.initialized) { getElements(); if (!isReady()) { return; } bindEvents(); state.initialized = true; updateScoreboard(); } refreshQuestionBank(referenceData, magickDataset); const categoryAdjusted = renderCategoryOptions(); syncDifficultyControl(); if (categoryAdjusted) { startRun(false); return; } const hasRunPending = state.runUnseenKeys.length > 0 || state.runRetryKeys.length > 0; if (!state.currentQuestion && !hasRunPending) { startRun(false); return; } if (!state.currentQuestion && hasRunPending) { showNextQuestion(); } updateScoreboard(); } window.QuizSectionUi = { ensureQuizSection, registerQuizCategory }; })();