1485 lines
44 KiB
JavaScript
1485 lines
44 KiB
JavaScript
|
|
/* 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
|
||
|
|
};
|
||
|
|
})();
|