const $ = (selector) => document.querySelector(selector); const $$ = (selector) => [...document.querySelectorAll(selector)]; const INTRO_MINIMUM_MS = 900; const REQUIRED_EXPANSIONS = { royalty: { name: "Royalty", art: "/art/NotOwned_Royalty.png" }, ideology: { name: "Ideology", art: "/art/NotOwned_Ideology.png" }, biotech: { name: "Biotech", art: "/art/NotOwned_Biotech.png" }, anomaly: { name: "Anomaly", art: "/art/NotOwned_Anomaly.png" }, odyssey: { name: "Odyssey", art: null } }; const INSTALL_PATHS = { windows: { label: "Windows", path: "%USERPROFILE%\\AppData\\LocalLow\\Ludeon Studios\\RimWorld by Ludeon Studios\\ModLists" }, macos: { label: "macOS", path: "~/Library/Application Support/RimWorld/ModLists" }, linux: { label: "Linux", path: "~/.config/unity3d/Ludeon Studios/RimWorld by Ludeon Studios/ModLists" } }; const state = { currentStep: 0, listType: "standard", sortAcknowledged: false, polling: null, rmlReady: false }; function detectedOperatingSystem() { const platform = String(navigator.userAgentData?.platform || navigator.platform || navigator.userAgent || "").toLowerCase(); if (platform.includes("mac")) return "macos"; if (platform.includes("linux") || platform.includes("x11")) return "linux"; return "windows"; } function renderInstallLocation() { const install = INSTALL_PATHS[detectedOperatingSystem()]; $("#detected-os-name").textContent = install.label; $("#detected-os-path").textContent = install.path; } function resetListType() { const standard = $('input[name="list-type"][value="standard"]'); const acknowledgement = $("#acknowledge-autosort"); standard.checked = true; state.listType = "standard"; state.sortAcknowledged = false; $("#homebrew-settings").hidden = true; $("#autosort-confirmation").classList.remove("acknowledged"); acknowledgement.setAttribute("aria-pressed", "false"); acknowledgement.textContent = "Yes, I understand"; setMessage("", "", "#wizard-message"); updateDownloadAvailability(); } function formatDate(value, fallback = "Not available") { if (!value) return fallback; return new Intl.DateTimeFormat(undefined, { month: "long", day: "numeric", year: "numeric", hour: "numeric", minute: "2-digit" }).format(new Date(value)); } function formatRelativeDate(value) { if (!value) return "Waiting for the first refresh"; const elapsedSeconds = Math.max(0, Math.round((Date.now() - new Date(value).getTime()) / 1000)); if (elapsedSeconds < 60) return "Updated just now"; const minutes = Math.floor(elapsedSeconds / 60); if (minutes < 60) return `Updated ${minutes} minute${minutes === 1 ? "" : "s"} ago`; const hours = Math.floor(minutes / 60); if (hours < 24) return `Updated ${hours} hour${hours === 1 ? "" : "s"} ago`; const days = Math.floor(hours / 24); return `Updated ${days} day${days === 1 ? "" : "s"} ago`; } function setMessage(text, kind = "", selector = "#action-message") { const element = $(selector); element.textContent = text; element.className = `${element.dataset.baseClass || element.className.split(" ")[0]} ${kind}`.trim(); } async function api(path, options = {}) { const response = await fetch(path, options); const contentType = response.headers.get("content-type") || ""; if (!response.ok) { let detail = `${response.status} ${response.statusText}`; if (contentType.includes("json")) { const body = await response.json(); detail = typeof body.detail === "string" ? body.detail : body.detail?.message || detail; } throw new Error(detail); } return contentType.includes("json") ? response.json() : response; } function renderServiceState(data) { const element = $("#service-state"); const label = element.querySelector("span:last-child"); const total = Number(data.totals?.total_mods || 0); const ready = Number(data.totals?.ready_mods || 0); const skipped = Number(data.totals?.skipped_mods || 0); const resolved = ready + skipped; element.className = "service-state"; const progress = data.scheduler?.progress || {}; const progressPercent = Number(progress.percent || 0); if (data.scheduler?.initial_download) { element.classList.add("working"); label.textContent = progress.total_mods ? `Downloading first-time mods · ${Math.round(progressPercent)}%` : "Downloading first-time mods"; } else if (data.scheduler?.running) { element.classList.add("working"); label.textContent = progress.total_mods ? `Updating now · ${Math.round(progressPercent)}%` : "Updating now"; } else if (data.latest_run?.status === "failed") { element.classList.add("failed"); label.textContent = "Update needs attention"; } else if (!data.steamcmd_available) { element.classList.add("failed"); label.textContent = "Downloads unavailable"; } else if (resolved < total) { element.classList.add("working"); label.textContent = "Preparing RML"; } else { element.classList.add("ready"); label.textContent = "Ready"; } } function updateDownloadAvailability() { const downloadButton = $("#download-list"); const availability = $("#rml-availability"); const needsFile = state.listType === "homebrew" && !$("#mods-config").files[0]; downloadButton.disabled = !state.rmlReady || needsFile || !state.sortAcknowledged; if (!state.rmlReady) { availability.textContent = "The current RML is still being prepared. You can review every step now and download when it is ready."; availability.className = "availability waiting"; } else if (needsFile) { availability.textContent = "Choose ModsConfig.xml to finish your homebrew list."; availability.className = "availability waiting"; } else if (!state.sortAcknowledged) { availability.textContent = "Confirm the auto-sort step above to unlock your download."; availability.className = "availability waiting"; } else { availability.textContent = state.listType === "homebrew" ? "Your homebrew list is ready to build." : "The current Progression list is ready to download."; availability.className = "availability ready"; } } function renderDashboard(data) { renderServiceState(data); const totals = data.totals || {}; const total = Number(totals.total_mods || 0); const ready = Number(totals.ready_mods || 0); const skipped = Number(totals.skipped_mods || 0); const resolved = ready + skipped; const counts = Object.fromEntries( (data.collections || []).map(collection => [String(collection.name).toLowerCase(), Number(collection.item_count || 0)]) ); $("#count-total").textContent = total ? total.toLocaleString() : "—"; for (const category of ["core", "content", "cosmetics"]) { const formatted = counts[category]?.toLocaleString() || "—"; $(`#count-${category}`).textContent = formatted; $(`[data-collection-count="${category}"]`).textContent = formatted; } const updatedAt = data.rml_updated_at; const progress = data.scheduler?.progress || {}; const initialDownload = Boolean(data.scheduler?.initial_download); $("#last-updated").textContent = initialDownload ? `Downloading ${Math.round(Number(progress.percent || 0))}%` : (data.scheduler?.running ? "Updating now" : formatRelativeDate(updatedAt)); $("#last-updated").title = formatDate(updatedAt); state.rmlReady = total > 0 && resolved === total && !data.scheduler?.running; const nextRefresh = data.scheduler?.next_refresh_at; if (initialDownload) { const completed = Number(progress.completed_mods || 0).toLocaleString(); const target = Number(progress.total_mods || 0).toLocaleString(); $("#update-detail").textContent = progress.total_mods ? `First-time setup: ${completed} of ${target} entries resolved · ${ready.toLocaleString()} mods ready. Hourly checks begin after it finishes.` : "Preparing the first-time Workshop download. Hourly checks are paused."; } else if (data.scheduler?.running) { $("#update-detail").textContent = "The hourly Workshop refresh is currently running."; } else if (!state.rmlReady) { $("#update-detail").textContent = "The current RML is being prepared from the latest Workshop data."; } else { $("#update-detail").textContent = nextRefresh ? `Next automatic check ${formatDate(nextRefresh)}.` : "Workshop data refreshes every hour."; } updateDownloadAvailability(); } async function loadDashboard() { try { renderDashboard(await api("/api/status")); } catch (error) { const element = $("#service-state"); element.className = "service-state failed"; element.querySelector("span:last-child").textContent = "Backend unavailable"; $("#last-updated").textContent = "Unavailable"; $("#update-detail").textContent = "The service could not be reached."; state.rmlReady = false; updateDownloadAvailability(); setMessage(error.message, "error"); } } function showStep(step) { const panels = $$(".wizard-step"); const progressItems = $$(".wizard-progress li"); state.currentStep = Math.max(0, Math.min(step, panels.length - 1)); panels.forEach((panel, index) => { const active = index === state.currentStep; panel.hidden = !active; panel.classList.toggle("active", active); }); progressItems.forEach((item, index) => { item.classList.toggle("active", index === state.currentStep); item.classList.toggle("complete", index < state.currentStep); if (index === state.currentStep) item.setAttribute("aria-current", "step"); else item.removeAttribute("aria-current"); }); if (state.currentStep === 2) updateExpansionRequirements(); } function updateExpansionRequirements() { const inputs = $$("#dlc-options input"); const missing = inputs .filter(input => !input.checked) .map(input => REQUIRED_EXPANSIONS[input.value]); const warning = $("#expansion-warning"); const iconList = $("#missing-expansion-icons"); inputs.forEach(input => { const isMissing = !input.checked; input.closest("label").classList.toggle("missing", isMissing); input.setAttribute("aria-invalid", String(isMissing)); }); warning.hidden = missing.length === 0; $("#expansions-next").disabled = missing.length > 0; if (!missing.length) { iconList.replaceChildren(); $("#expansion-warning-text").textContent = ""; return; } const names = missing.map(expansion => expansion.name); $("#expansion-warning-text").textContent = `${names.join(", ")} ${missing.length === 1 ? "is" : "are"} required before you can continue.`; iconList.innerHTML = missing.map(expansion => expansion.art ? `` : `!ODYSSEYREQUIRED` ).join(""); } function selectedDlcQuery() { return $$("#dlc-options input:checked") .map(input => `dlc=${encodeURIComponent(input.value)}`) .join("&"); } async function downloadResponse(responsePromise, filename) { const response = await responsePromise; const blob = await response.blob(); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = filename; document.body.appendChild(link); link.click(); link.remove(); URL.revokeObjectURL(url); } $("#start-wizard").addEventListener("click", () => { resetListType(); showStep(0); $("#download-wizard").showModal(); }); $("#close-wizard").addEventListener("click", () => $("#download-wizard").close()); $("#download-wizard").addEventListener("click", event => { if (event.target === event.currentTarget) event.currentTarget.close(); }); $$('[data-next]').forEach(button => button.addEventListener("click", () => showStep(state.currentStep + 1))); $$('[data-back]').forEach(button => button.addEventListener("click", () => showStep(state.currentStep - 1))); $("#setup-confirm").addEventListener("change", event => { $("#setup-next").disabled = !event.target.checked; }); $$("#dlc-options input").forEach(input => input.addEventListener("change", updateExpansionRequirements)); $("#show-all-paths").addEventListener("click", event => { const paths = $("#all-install-paths"); const show = paths.hidden; paths.hidden = !show; event.currentTarget.setAttribute("aria-expanded", String(show)); event.currentTarget.textContent = show ? "Hide other OS" : "Show other OS"; }); $("#acknowledge-autosort").addEventListener("click", event => { state.sortAcknowledged = true; $("#autosort-confirmation").classList.add("acknowledged"); event.currentTarget.setAttribute("aria-pressed", "true"); event.currentTarget.textContent = "Understood ✓"; updateDownloadAvailability(); }); $$('input[name="list-type"]').forEach(input => input.addEventListener("change", event => { state.listType = event.target.value; $("#homebrew-settings").hidden = state.listType !== "homebrew"; setMessage("", "", "#wizard-message"); updateDownloadAvailability(); })); $("#mods-config").addEventListener("change", event => { const file = event.target.files[0]; $("#file-label").textContent = file?.name || "Choose ModsConfig.xml"; $(".file-picker").classList.toggle("has-file", Boolean(file)); updateDownloadAvailability(); }); $("#download-list").addEventListener("click", async () => { const button = $("#download-list"); button.disabled = true; setMessage("Building your mod list…", "", "#wizard-message"); try { if (state.listType === "homebrew") { const file = $("#mods-config").files[0]; if (!file) throw new Error("Choose ModsConfig.xml before building a homebrew list."); const form = new FormData(); form.append("mods_config", file); await downloadResponse(api("/api/modlists/homebrew", { method: "POST", body: form }), "ProgressionHomebrew.rml"); setMessage("ProgressionHomebrew.rml is ready. Auto-sort it after loading.", "success", "#wizard-message"); } else { const query = selectedDlcQuery(); await downloadResponse(api(`/api/modlists/vanilla${query ? `?${query}` : ""}`), "ProgressionVanilla.rml"); setMessage("ProgressionVanilla.rml is ready.", "success", "#wizard-message"); } } catch (error) { setMessage(error.message, "error", "#wizard-message"); } finally { updateDownloadAvailability(); } }); async function start() { $("#action-message").dataset.baseClass = "action-message"; $("#wizard-message").dataset.baseClass = "wizard-message"; renderInstallLocation(); const minimumIntro = new Promise(resolve => window.setTimeout(resolve, INTRO_MINIMUM_MS)); await Promise.all([loadDashboard(), minimumIntro]); document.body.classList.add("is-ready"); window.setTimeout(() => { $("#intro").hidden = true; }, 850); } start(); state.polling = window.setInterval(loadDashboard, 60_000);