(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}
Discovered
${discovered ? "Recorded" : "New location"}
Quest
${quest.title}
Objective
${quest.objective}
Reward
${quest.reward}
${action}

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.

${options}
Hint from ${npc.name}

${npc.hint}

`); } else if (quest.kind === "matching") { const ownerOptions = ``; show("puzzle", quest.title, "Memory matching", `

Return each memory to its owner. Every owner receives one memory.

Hint from ${npc.name}

${npc.hint}

`); } } function openMenu(state, tab) { const active = tab || "quests"; const nav = ["quests", "inventory", "journal", "controls", "settings"].map((name) => ``).join(""); let content = ""; if (active === "quests") { const rows = Data.QUEST_IDS.filter((id) => state.quests[id].status !== "locked").map((id) => { const q = Data.QUESTS[id]; return `
  • ${q.title}${state.quests[id].status === "complete" ? "Complete" : q.objective}${q.reward}
  • `; }).join("") || "
  • Speak with Gatekeeper Orin to begin.
  • "; content = `

    Quest log

    ${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 `
  • ${item.name}${entry.quantity > 1 ? ` ×${entry.quantity}` : ""}${item.description}
    ${use}
  • `; }).join(""); content = `

    Inventory

    `; } else if (active === "journal") { content = `

    Discovery journal

    `; } else if (active === "controls") { content = `

    Controls

    Move
    WASD / arrow keys / direction pad
    Attack
    Space / Attack
    Explore
    E or Enter / Explore
    Use item
    Q / Item
    Cycle item
    Tab
    Pause
    Escape / Menu
    `; } else { content = `

    Settings

    `; } show("menu", "Traveler's folio", "Paused", `
    ${content}
    `); } function openDefeat() { show("defeat", "The Bookmark holds your place", "Defeated", `

    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));