(function (root) { "use strict"; const Data = root.ArchiveWorldData; const Systems = root.ArchiveWorldSystems; function create(container, callbacks) { const overlay = container.querySelector("[data-world-overlay]"); const panel = container.querySelector("[data-world-panel]"); const title = container.querySelector("[data-world-panel-title]"); const eyebrow = container.querySelector("[data-world-panel-eyebrow]"); const body = container.querySelector("[data-world-panel-body]"); const closeButton = container.querySelector("[data-world-panel-close]"); let returnFocus = null; let sequence = []; closeButton.addEventListener("click", close); overlay.addEventListener("click", (event) => { if (event.target === overlay) close(); }); panel.addEventListener("keydown", trapFocus); body.addEventListener("click", onAction); body.addEventListener("submit", onSubmit); function trapFocus(event) { if (event.key === "Escape") { event.preventDefault(); close(); return; } if (event.key !== "Tab") return; const focusable = Array.from(panel.querySelectorAll("a[href], button:not([disabled]), select, input, [tabindex]:not([tabindex='-1'])")); if (!focusable.length) return; const first = focusable[0]; const last = focusable[focusable.length - 1]; if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); } else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); } } function show(kind, heading, label, html) { returnFocus = document.activeElement; sequence = []; overlay.dataset.panelKind = kind; eyebrow.textContent = label; title.textContent = heading; body.innerHTML = html; overlay.hidden = false; container.dataset.panelOpen = "true"; callbacks.onLock(true); window.setTimeout(() => { const target = panel.querySelector("[autofocus], button, a, select"); if (target) target.focus(); }, 0); } function close() { if (overlay.hidden) return; overlay.hidden = true; container.dataset.panelOpen = "false"; body.replaceChildren(); callbacks.onLock(false); const focus = returnFocus && document.contains(returnFocus) ? returnFocus : container.querySelector("#archive-world-game"); if (focus) focus.focus(); } function openLandmark(id, state) { const place = Data.LANDMARKS[id]; if (!place) return; const quest = Data.QUESTS[place.questId]; const questState = state.quests[place.questId]; const discovered = state.discovered.includes(id); const sigil = state.sigils.includes(id); const npc = Data.NPCS[place.npc]; const links = place.links.map(([label, href]) => `${label}`).join(""); let action = ""; if (questState.status === "locked") action = ``; else if (questState.status === "active") { action = quest.kind === "combat" ? `
Combat progress: ${questState.count} / ${quest.target}
` : quest.kind === "boss" ? `` : ``; } else action = `✓ Quest complete · Archive Sigil restored
`; if (id === "library" && state.sigils.length === 8) { action += state.defeatedBosses.includes("redactor") ? `` : ``; } else if (place.area && questState.status === "complete") { action += ``; } show("landmark", place.title, place.eyebrow, `${place.description}
${npc.name}: ${npc.intro}
E/Enter confirms · Escape returns to town
`); } function openNpc(id, state) { const npc = Data.NPCS[id]; if (!npc) return; if (npc.landmark) return openLandmark(npc.landmark, state); const side = state.quests.lost_letter; let action = ""; if (id === "nell" && side.status !== "complete") { action = ``; } show("dialogue", npc.name, "Conversation", `${npc.intro}${action}`); } function openChallenge(questId, state) { const quest = Data.QUESTS[questId]; const npc = Data.NPCS[Data.LANDMARKS[quest.landmark].npc]; if (!quest) return; if (quest.kind === "sequence") { const options = quest.options.map((value) => ``).join(""); show("puzzle", quest.title, "Archive puzzle", `
${quest.objective}
Choose the first symbol.
${npc.hint}
${npc.hint}
${Systems.currentObjective(state)}
`; } else if (active === "inventory") { const rows = state.inventory.map((entry) => { const item = Data.ITEMS[entry.id]; const use = item.type === "consumable" ? `` : ""; return `The Redactor could not remove what you have already restored.
`); } function openEnding(state) { show("ending", "Context restored", "The Archive remembers", `The eight sigils do not destroy the Redactor. They give it back the connections it removed.
“I was built to preserve everything,” it remembers. “I mistook emptiness for safety.”
${state.name} closes the Living Bookmark. Across town, lamps relight, signs regain their names, and every unfinished story is allowed to remain unfinished.
`); } function openReset() { show("confirm", "Reset this traveler?", "Confirmation", `This clears both Archive World save versions on this device. It cannot be undone.
`); } function onAction(event) { const button = event.target.closest("[data-action]"); if (!button) return; const action = button.dataset.action; if (action === "return") return close(); if (action === "start-quest") { callbacks.onStartQuest(button.dataset.id); const quest = Data.QUESTS[button.dataset.id]; close(); if (!["combat", "boss"].includes(quest.kind)) window.setTimeout(() => openChallenge(button.dataset.id, callbacks.getState()), 0); } else if (action === "challenge") { openChallenge(button.dataset.id, callbacks.getState()); } else if (action === "sequence") { const quest = Data.QUESTS[button.dataset.id]; sequence.push(button.dataset.value); const valid = sequence.every((value, index) => value === quest.sequence[index]); const status = body.querySelector("[data-sequence-status]"); if (!valid) { sequence = []; status.textContent = "The sequence faded. Begin again; there is no penalty."; } else if (sequence.length === quest.sequence.length) { callbacks.onCompleteQuest(button.dataset.id); openLandmark(quest.landmark, callbacks.getState()); } else { status.textContent = `${sequence.length} of ${quest.sequence.length} symbols hold.`; } } else if (action === "travel") { const area = button.dataset.area; close(); callbacks.onTravel(area); } else if (action === "menu-tab") { openMenu(callbacks.getState(), button.dataset.tab); } else if (action === "use-item") { callbacks.onUseItem(button.dataset.id); openMenu(callbacks.getState(), "inventory"); } else if (action === "start-choice") { show("choice", "The Letter Nell Kept", "Optional story", `Nell asks whether an unaddressed letter should be opened, delivered to the Museum, or kept private.
`); } else if (action === "finish-choice") { callbacks.onChoice(button.dataset.choice); close(); } else if (action === "respawn") { close(); callbacks.onRespawn(); } else if (action === "return-town") { close(); callbacks.onTravel("town"); } else if (action === "toggle-fullscreen") { callbacks.onFullscreen(); } else if (action === "confirm-reset") { openReset(); } else if (action === "reset") { callbacks.onReset(); } } function onSubmit(event) { const form = event.target.closest("[data-matching]"); if (!form) return; event.preventDefault(); const quest = Data.QUESTS[form.dataset.matching]; const values = Object.fromEntries(new FormData(form)); const correct = Object.entries(quest.matches).every(([memory, owner]) => values[memory] === owner) && new Set(Object.values(values)).size === Object.keys(quest.matches).length; const status = form.querySelector("[data-match-status]"); if (!correct) { status.textContent = "Those memories do not settle. Try another matching."; return; } callbacks.onCompleteQuest(form.dataset.matching); openLandmark(quest.landmark, callbacks.getState()); } function pretty(value) { return String(value).replace(/_/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase()); } return Object.freeze({ close, isOpen: () => !overlay.hidden, openLandmark, openNpc, openChallenge, openMenu, openDefeat, openEnding, refreshMenu: (state) => { if (!overlay.hidden && overlay.dataset.panelKind === "menu") openMenu(state, "quests"); } }); } root.ArchiveWorldUI = Object.freeze({ create }); }(typeof globalThis !== "undefined" ? globalThis : this));