Download web/app.js from visproj/lantern: direct link, hf CLI and curl.
- Browser
- Download file 27 kB
-
https://huggingface.co/spaces/visproj/lantern/resolve/main/web/app.js
- Command line
-
hf download hf://spaces/visproj/lantern/web/app.js
-
curl -L -o app.js https://huggingface.co/spaces/visproj/lantern/resolve/main/web/app.js
27 kB
| // Lantern front end: renders live (SSE) or recorded episodes. No framework, no build. | |
| ; | |
| const $ = (id) => document.getElementById(id); | |
| // NLE colour indices (curses palette) mapped onto the cave palette. | |
| const NLE_COLORS = [ | |
| "#5b6375", "#d0685f", "#7fb07a", "#b88a4e", "#6f8fd6", "#b07cc6", "#6fb8c0", "#c9c3b5", | |
| "#8a93a6", "#e8955a", "#a6dc8e", "#f2d36b", "#8fb2ff", "#de9ce6", "#93e0e6", "#f4efe4", | |
| ]; | |
| const ARROWS = { | |
| north: "โ north", south: "โ south", east: "โ east", west: "โ west", | |
| northeast: "โ northeast", northwest: "โ northwest", southeast: "โ southeast", southwest: "โ southwest", | |
| pickup: "pick up", apply: "apply", descend: "go down", wait: "wait", | |
| }; | |
| const state = { | |
| tasks: [], | |
| events: [], | |
| turns: [], // indices into events of type "turn" | |
| selected: -1, // index into turns | |
| follow: true, // stick to the newest turn while live | |
| live: null, // { id, source } | |
| replayTimer: null, | |
| }; | |
| // โโ data helpers โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| async function api(path, options) { | |
| const res = await fetch(path, options); | |
| const body = await res.json().catch(() => ({})); | |
| if (!res.ok) throw new Error(body.detail || `Request failed (${res.status}).`); | |
| return body; | |
| } | |
| function startEvent() { return state.events.find((e) => e.type === "start"); } | |
| function endEvent() { return state.events.find((e) => e.type === "end"); } | |
| function taskTitle(id) { return (state.tasks.find((t) => t.id === id) || {}).title || id; } | |
| // โโ map โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| function renderMap(frame, trail, route) { | |
| const map = $("map"); | |
| map.replaceChildren(); | |
| if (!frame) return; | |
| $("empty").hidden = true; | |
| state.lastFrame = frame; | |
| if (window.Lantern3D) window.Lantern3D.update(frame, route); | |
| const [top, left] = frame.origin; | |
| const hx = frame.status.x - left; | |
| const hy = frame.status.y - top; | |
| let heroEl = null; | |
| frame.rows.forEach((row, r) => { | |
| const line = document.createElement("div"); | |
| line.className = "row"; | |
| [...row].forEach((ch, c) => { | |
| const span = document.createElement("span"); | |
| span.textContent = ch; | |
| const d = Math.max(Math.abs(r - hy), Math.abs(c - hx)); | |
| // Lamplight falls off with distance; explored-but-distant cells stay dimly remembered. | |
| span.style.opacity = d <= 1 ? 1 : Math.max(0.36, 1 - (d - 1) * 0.085).toFixed(2); | |
| if (r === hy && c === hx) { span.className = "hero"; heroEl = span; } | |
| else if (trail.has(`${c + left},${r + top}`) && ch !== " ") span.className = "trail"; | |
| else span.style.color = NLE_COLORS[frame.colors[r][c] & 15] || NLE_COLORS[7]; | |
| line.appendChild(span); | |
| }); | |
| map.appendChild(line); | |
| }); | |
| if (heroEl) { | |
| const m = map.getBoundingClientRect(); | |
| const h = heroEl.getBoundingClientRect(); | |
| map.style.setProperty("--hx", `${h.left - m.left + h.width / 2}px`); | |
| map.style.setProperty("--hy", `${h.top - m.top + h.height / 2}px`); | |
| } | |
| } | |
| function trailUpTo(turnIdx) { | |
| const trail = new Set(); | |
| const start = startEvent(); | |
| if (start) trail.add(`${start.frame.status.x},${start.frame.status.y}`); | |
| for (let i = 0; i <= turnIdx; i++) { | |
| const turn = state.events[state.turns[i]]; | |
| turn.executed.forEach((e) => (e.path || []).forEach(([x, y]) => trail.add(`${x},${y}`))); | |
| trail.add(`${turn.frame.status.x},${turn.frame.status.y}`); | |
| } | |
| return trail; | |
| } | |
| // โโ panels โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| function renderMind(turn) { | |
| const reasoning = $("reasoning"); | |
| reasoning.replaceChildren(); | |
| if (turn && turn.advice && turn.advice.length) { | |
| const p = document.createElement("p"); | |
| p.className = "advice"; | |
| p.textContent = `You advised: "${turn.advice.join('", "')}"`; | |
| reasoning.appendChild(p); | |
| } | |
| const reply = turn ? (turn.executed.map((e) => e.reply).filter(Boolean).join(" ") || turn.fallback_reply || "") : ""; | |
| if (reply) { | |
| const r = document.createElement("p"); | |
| r.className = "reply"; | |
| r.textContent = `Beam: ${reply}`; | |
| reasoning.appendChild(r); | |
| } | |
| const text = turn ? (turn.reasoning || turn.content || "") : ""; | |
| if (!text) { | |
| const p = document.createElement("p"); | |
| p.className = "quiet"; | |
| p.textContent = turn ? "Beam acted without writing out its reasoning." : "Beam's reasoning for each move appears here."; | |
| reasoning.appendChild(p); | |
| } else { | |
| text.split(/\n{2,}/).forEach((para) => { | |
| const p = document.createElement("p"); | |
| p.textContent = para.trim(); | |
| reasoning.appendChild(p); | |
| }); | |
| } | |
| const plan = $("plan"); | |
| plan.replaceChildren(); | |
| (turn ? turn.executed : []).filter((e) => e.tool === "act").forEach((e) => { | |
| e.planned.forEach((a, i) => { | |
| const li = document.createElement("li"); | |
| li.textContent = ARROWS[a] || a.replace(/_/g, " "); | |
| if (i >= e.done.length) li.className = "skipped"; | |
| plan.appendChild(li); | |
| }); | |
| if (e.interrupted) { | |
| const li = document.createElement("li"); | |
| li.className = "why"; | |
| li.textContent = "Stopped early: something changed."; | |
| plan.appendChild(li); | |
| } | |
| }); | |
| $("notes").textContent = turn && turn.notes ? turn.notes : ""; | |
| if (!$("notes").textContent) $("notes").innerHTML = '<span class="quiet">Nothing yet.</span>'; | |
| $("prompt").textContent = turn ? turn.prompt : ""; | |
| } | |
| function renderStatus(turn) { | |
| const status = $("status"); | |
| const end = endEvent(); | |
| const frame = turn ? turn.frame : (startEvent() || {}).frame; | |
| if (!frame) return; | |
| const s = frame.status; | |
| const parts = [`HP <strong>${s.hp}/${s.max_hp}</strong>`]; | |
| if (turn) parts.push(`step <strong>${turn.steps}</strong>`, `call <strong>${turn.call}</strong> of ${state.turns.length}`); | |
| if (frame.message) parts.push(`โ${escapeHtml(frame.message)}โ`); | |
| const isLast = state.selected === state.turns.length - 1; | |
| if (end && isLast) parts.unshift(outcomeHtml(end)); | |
| status.innerHTML = parts.join(" "); | |
| } | |
| const OUTCOMES = { | |
| success: ["win", "Goal reached"], | |
| failure: ["loss", "Beam died or failed"], | |
| timeout: ["loss", "Ran out of steps"], | |
| stalled: ["loss", "Stalled without acting"], | |
| budget: ["loss", "Hit the call budget"], | |
| stopped: ["", "Stopped"], | |
| }; | |
| function outcomeHtml(end) { | |
| const [cls, label] = OUTCOMES[end.outcome] || ["", escapeHtml(end.outcome)]; | |
| const tokens = (end.usage.prompt_tokens || 0) + (end.usage.completion_tokens || 0); | |
| return `<strong class="${cls}">${label}</strong> in ${end.steps} steps, ${end.usage.calls} calls, ${tokens.toLocaleString()} tokens.`; | |
| } | |
| function renderTimeline() { | |
| const tl = $("timeline"); | |
| const end = endEvent(); | |
| // Add only the ticks that are new, so live updates do not rebuild the whole strip. | |
| while (tl.children.length < state.turns.length) { | |
| const i = tl.children.length; | |
| const t = state.events[state.turns[i]]; | |
| const acts = t.executed.filter((e) => e.tool === "act"); | |
| const done = acts.reduce((n, e) => n + e.done.length, 0); | |
| const b = document.createElement("button"); | |
| b.type = "button"; | |
| b.className = "tick" + (done ? " act" : "") + (acts.some((e) => e.interrupted) ? " interrupted" : ""); | |
| if (t.advice && t.advice.length) b.classList.add("coached"); | |
| b.style.height = `${8 + Math.min(done, 8) * 4.5}px`; | |
| b.setAttribute("role", "option"); | |
| b.setAttribute("aria-label", `Call ${i + 1}: ${done} action${done === 1 ? "" : "s"}`); | |
| b.addEventListener("click", () => { stopReplay(); select(i, false); }); | |
| tl.appendChild(b); | |
| } | |
| if (end && tl.lastElementChild) { | |
| tl.lastElementChild.classList.add(end.outcome === "success" ? "win" : end.outcome === "stopped" ? "act" : "loss"); | |
| } | |
| [...tl.children].forEach((b, i) => b.setAttribute("aria-selected", String(i === state.selected))); | |
| } | |
| function renderJournal() { | |
| const start = startEvent(); | |
| const lessonsEv = state.events.find((e) => e.type === "lessons"); | |
| fillList($("lessons-in"), (start && start.lessons) || [], "Nothing yet. This is a fresh start."); | |
| fillList($("lessons-out"), lessonsEv ? lessonsEv.lessons : [], endEvent() ? "No new lessons." : "Written when the run ends.", "new"); | |
| } | |
| function fillList(ul, items, emptyText, cls = "") { | |
| ul.replaceChildren(); | |
| if (!items.length) { | |
| const li = document.createElement("li"); | |
| li.className = "quiet"; | |
| li.style.listStyle = "none"; | |
| li.textContent = emptyText; | |
| ul.appendChild(li); | |
| return; | |
| } | |
| items.forEach((t) => { const li = document.createElement("li"); li.textContent = t; if (cls) li.className = cls; ul.appendChild(li); }); | |
| } | |
| // Moments worth a close-up: taking an item, unlocking a door, moving a boulder, winning. | |
| function isKeyMoment(turn, i) { | |
| const msg = (turn.frame.message || "").toLowerCase(); | |
| const won = i === state.turns.length - 1 && endEvent() && endEvent().outcome === "success"; | |
| return won || /^[a-z] - /.test(msg) || msg.includes("unlock") || msg.includes("boulder"); | |
| } | |
| const MOVES = { north: [0, -1], south: [0, 1], east: [1, 0], west: [-1, 0], | |
| northeast: [1, -1], northwest: [-1, -1], southeast: [1, 1], southwest: [-1, 1] }; | |
| // Stepping into unfilled water makes NetHack drag the hero out onto a nearby dry square, so the | |
| // recorded path jumps past the water. Put the water cell back into the route, marked so the | |
| // 3D hero visibly sinks into it, and note when (in steps) each fall happened. | |
| // | |
| // NetHack also allows diagonal steps past the corner of a boulder or wall. A straight line | |
| // between cell centres would clip through the corner, so bend that step around the open side. | |
| const SOLID = new Set(["boulder", "wall", "tree"]); | |
| function routeWithFalls(turn, i) { | |
| const prev = i > 0 ? state.events[state.turns[i - 1]].frame : startEvent() && startEvent().frame; | |
| const frame = turn.frame; | |
| const kindIn = (f, x, y) => f && ((f.kinds || [])[y - f.origin[0]] || [])[x - f.origin[1]]; | |
| const kindAt = (x, y) => kindIn(frame, x, y); | |
| // Boulders can move during the turn, so a corner counts as solid in either frame. | |
| const solid = (x, y) => SOLID.has(kindIn(prev, x, y)) || SOLID.has(kindAt(x, y)); | |
| let pos = prev ? [prev.status.x, prev.status.y] : null; | |
| const route = [], falls = [], corners = []; | |
| for (const e of turn.executed) { | |
| (e.path || []).forEach(([x, y], k) => { | |
| const d = MOVES[(e.done || [])[k]]; | |
| if (pos && d) { | |
| const wx = pos[0] + d[0], wy = pos[1] + d[1]; | |
| if ((wx !== x || wy !== y) && kindAt(wx, wy) === "water") { | |
| route.push([wx, wy, "water"]); | |
| falls.push(route.length); | |
| } else if (d[0] && d[1] && wx === x && wy === y) { | |
| const a = solid(x, pos[1]), b = solid(pos[0], y); | |
| if (a !== b) { | |
| // Swing a quarter of a cell toward the open side square. | |
| const [ox, oy] = a ? [pos[0], y] : [x, pos[1]]; | |
| const mx = (pos[0] + x) / 2, my = (pos[1] + y) / 2; | |
| route.push([mx + (ox - mx) * 0.5, my + (oy - my) * 0.5, "around"]); | |
| corners.push(route.length); | |
| } else if (a && b) { | |
| corners.push(route.length + 1); // squeezed between two: nothing to bend around | |
| } | |
| } | |
| } | |
| route.push([x, y]); | |
| pos = [x, y]; | |
| }); | |
| } | |
| return { route, falls, corners }; | |
| } | |
| function select(i, follow) { | |
| if (i < 0 || i >= state.turns.length) return; | |
| // Stepping forward one call animates Beam's real step-by-step route; any other jump snaps. | |
| const stepForward = i === state.selected + 1; | |
| state.selected = i; | |
| state.follow = follow; | |
| const turn = state.events[state.turns[i]]; | |
| const { route, falls, corners } = stepForward ? routeWithFalls(turn, i) : { route: null, falls: [], corners: [] }; | |
| renderMap(turn.frame, trailUpTo(i), route); | |
| if (stepForward && window.LanternAudio) { | |
| window.LanternAudio.turn(turn); | |
| // Replays already hold their end event; sound it when playback reaches the last call. | |
| if (!state.live && i === state.turns.length - 1 && endEvent()) window.LanternAudio.end(endEvent()); | |
| } | |
| const reply = turn.executed.map((e) => e.reply).filter(Boolean).join(" ") || turn.fallback_reply || ""; | |
| if (stepForward && reply && window.Lantern3D) window.Lantern3D.say(reply); | |
| // Say what happened as the hero reaches the water, so the jump out doesn't look like a bug. | |
| if (window.Lantern3D && falls.length) { | |
| setTimeout(() => window.Lantern3D.say("Splash! Fell in and crawled out.", 3500), (falls[0] - 1) * 222); | |
| } else if (window.Lantern3D && corners.length && !state.cornerExplained && !reply) { | |
| // Once per run is enough to explain it; after that the curved walk speaks for itself. | |
| state.cornerExplained = true; | |
| setTimeout(() => window.Lantern3D.say("Diagonal past the corner: that's allowed here.", 3500), | |
| (corners[0] - 1) * 222); | |
| } | |
| if (stepForward && window.Lantern3D && (isKeyMoment(turn, i) || falls.length)) { | |
| // Let the walk finish first, then move in close. | |
| const steps = turn.executed.reduce((n, e) => n + (e.path || []).length, 0); | |
| const delay = Math.max(0, steps - 1) * 222; | |
| setTimeout(() => window.Lantern3D.spotlight(), delay); | |
| // Hold the ending banner until the close-up has played out, so it doesn't cover it. | |
| if (i === state.turns.length - 1) { | |
| state.endingHoldUntil = performance.now() + delay + 2800; | |
| setTimeout(renderEnding, delay + 2850); | |
| } | |
| } | |
| renderMind(turn); | |
| renderStatus(turn); | |
| renderTimeline(); | |
| renderEnding(); | |
| $("follow").hidden = !(state.live && !follow); | |
| const tick = $("timeline").children[i]; | |
| if (tick) tick.scrollIntoView({ block: "nearest", inline: "nearest" }); | |
| } | |
| // โโ event intake โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| function resetView() { | |
| // Clear the old run before anything re-renders, or its ending banner flashes back up. | |
| state.events = []; state.turns = []; state.selected = -1; state.follow = true; state.cornerExplained = false; | |
| state.endingDismissed = false; | |
| $("ending").hidden = true; | |
| stopReplay(); | |
| if (window.Lantern3D) window.Lantern3D.reset(); | |
| $("timeline").replaceChildren(); | |
| renderMind(null); | |
| } | |
| // Recordings made before the env fix end with an all-blank frame (NLE zeroes its final | |
| // observation). Show the last real view instead of an empty map with Beam at (0, 0). | |
| function isBlankFrame(frame) { | |
| return !frame || frame.rows.every((row) => /^[\u0000 ]*$/.test(row)); | |
| } | |
| function repairTurn(ev) { | |
| if (ev.type !== "turn" || !isBlankFrame(ev.frame)) return ev; | |
| const prev = state.turns.length ? state.events[state.turns[state.turns.length - 1]].frame : (startEvent() || {}).frame; | |
| return prev ? { ...ev, frame: { ...prev, message: ev.frame.message || prev.message } } : ev; | |
| } | |
| function ingest(ev) { | |
| ev = repairTurn(ev); | |
| state.events.push(ev); | |
| if (ev.type === "start") { | |
| $("goal").textContent = `${ev.title}. ${ev.goal}`; | |
| renderMap(ev.frame, new Set()); | |
| renderStatus(null); | |
| renderJournal(); | |
| } else if (ev.type === "turn") { | |
| state.turns.push(state.events.length - 1); | |
| if (state.follow) select(state.turns.length - 1, true); else renderTimeline(); | |
| } else if (ev.type === "lessons" || ev.type === "end") { | |
| if (state.live && window.LanternAudio) { | |
| if (ev.type === "end") window.LanternAudio.end(ev); else window.LanternAudio.lessons(); | |
| } | |
| // Live runs learn they've won only after the last move is drawn, so the win close-up | |
| // (which replays get from isKeyMoment) has to start here. | |
| if (state.live && ev.type === "end" && ev.outcome === "success" && window.Lantern3D) { | |
| window.Lantern3D.spotlight(); | |
| state.endingHoldUntil = performance.now() + 2800; | |
| setTimeout(renderEnding, 2850); | |
| } | |
| renderJournal(); | |
| renderTimeline(); | |
| if (state.turns.length) renderStatus(state.events[state.turns[state.selected]]); | |
| } else if (ev.type === "error") { | |
| // Credit and busy errors describe the server right now, so they only act on live runs; | |
| // in a replay they're history and just show in the status line. | |
| if (state.live && ev.kind === "credits") blockLive(ev.message); | |
| else if (state.live && ev.kind === "busy") showNotice(ev.message); | |
| else $("status").textContent = `The run failed: ${ev.message}`; | |
| } | |
| renderEnding(); | |
| } | |
| // โโ end-of-run banner โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| function renderEnding() { | |
| const end = endEvent(); | |
| const atEnd = state.turns.length && state.selected === state.turns.length - 1; | |
| const box = $("ending"); | |
| const held = performance.now() < (state.endingHoldUntil || 0); | |
| if (!end || !atEnd || state.replayTimer || state.endingDismissed || held) { box.hidden = true; return; } | |
| const [cls, label] = OUTCOMES[end.outcome] || ["", end.outcome]; | |
| $("ending-title").textContent = label; | |
| $("ending-title").className = `ending-title ${cls}`; | |
| $("new-run").disabled = !!state.liveBlocked || !!state.live; | |
| $("ending-detail").textContent = `${end.steps} steps, ${end.usage.calls} Beam calls.`; | |
| box.hidden = false; | |
| } | |
| // โโ live availability (credits) โโโโโโโโโโโโโโโโโโโโโโ | |
| function blockLive(message) { | |
| state.liveBlocked = true; | |
| $("start").disabled = true; | |
| showNotice(message); | |
| } | |
| async function checkLive() { | |
| const s = await api("/api/status").catch(() => null); | |
| if (s && s.live_available === false) blockLive(s.live_message); | |
| if (s && typeof s.your_runs_left === "number") { | |
| const n = Math.min(s.your_runs_left, s.runs_left_today); | |
| $("runs-left").textContent = n === 1 ? "1 live run left today" : `${n} live runs left today`; | |
| if (n === 0 && !state.liveBlocked) { | |
| $("start").disabled = true; | |
| $("runs-left").textContent = "No live runs left today. Replays are free."; | |
| } | |
| } | |
| return s; | |
| } | |
| // โโ live runs โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| async function startRun(evt) { | |
| evt.preventDefault(); | |
| const body = { task: $("task").value, seed: Number($("seed").value) || 0, use_lessons: $("lessons").checked }; | |
| $("start").disabled = true; | |
| $("ending").hidden = true; | |
| showNotice(""); | |
| try { | |
| const { id } = await api("/api/runs", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }); | |
| watch(id); | |
| showCoach(true); | |
| checkLive(); | |
| } catch (err) { | |
| if (/credits/i.test(err.message)) blockLive(err.message); | |
| else { showNotice(err.message); $("start").disabled = false; } | |
| } | |
| } | |
| function showNotice(text) { | |
| $("notice").textContent = text; | |
| $("notice").hidden = !text; | |
| } | |
| function watch(id) { | |
| resetView(); | |
| state.episodeId = id; | |
| $("status").textContent = "Lighting the lanternโฆ"; | |
| const source = new EventSource(`/api/runs/${id}/events`); | |
| state.live = { id, source }; | |
| $("stop").hidden = false; | |
| $("start").disabled = true; | |
| source.onmessage = (m) => ingest(JSON.parse(m.data)); | |
| source.addEventListener("close", finishLive); | |
| // EventSource retries forever on its own; if the run is gone (e.g. the Space restarted), | |
| // stop retrying after a few failures and give the controls back. | |
| let failures = 0; | |
| source.onopen = () => { failures = 0; }; | |
| source.onerror = () => { | |
| failures += 1; | |
| if (source.readyState === EventSource.CLOSED || failures >= 3) { | |
| if (!endEvent()) showNotice("Lost connection to this run. It may have ended when the Space restarted; start a new one."); | |
| finishLive(); | |
| } | |
| }; | |
| } | |
| function finishLive() { | |
| if (!state.live) return; | |
| showCoach(false); | |
| state.live.source.close(); | |
| state.live = null; | |
| $("stop").hidden = true; | |
| $("stop").disabled = false; | |
| $("start").disabled = !!state.liveBlocked; | |
| $("follow").hidden = true; | |
| loadEpisodes(); | |
| loadTasks(); | |
| checkLive(); | |
| renderEnding(); | |
| } | |
| async function stopRun() { | |
| if (!state.live) return; | |
| $("stop").disabled = true; | |
| await api(`/api/runs/${state.live.id}/stop`, { method: "POST" }).catch(() => {}); | |
| } | |
| // โโ replays โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| async function openReplay(id) { | |
| if (state.live) return; | |
| const rec = await api(`/api/episodes/${id}`); | |
| resetView(); | |
| state.episodeId = id; | |
| if (state.tasks.some((t) => t.id === rec.task)) $("task").value = rec.task; | |
| state.follow = false; | |
| rec.events.forEach((ev) => { | |
| if (ev.type !== "turn") return ingest(ev); | |
| state.events.push(repairTurn(ev)); | |
| state.turns.push(state.events.length - 1); | |
| }); | |
| renderTimeline(); | |
| const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches; | |
| if (!state.turns.length) return; | |
| if (reduce) { select(state.turns.length - 1, false); return; } | |
| let i = 0; | |
| select(0, false); | |
| state.replayTimer = setInterval(() => { | |
| if (++i >= state.turns.length) return stopReplay(); | |
| select(i, false); | |
| }, 900); | |
| window.scrollTo({ top: 0, behavior: "smooth" }); | |
| } | |
| function stopReplay() { | |
| if (state.replayTimer) clearInterval(state.replayTimer); | |
| state.replayTimer = null; | |
| renderEnding(); | |
| } | |
| async function loadEpisodes() { | |
| const list = await api("/api/episodes").catch(() => []); | |
| state.episodes = list; | |
| const ul = $("episodes"); | |
| ul.replaceChildren(); | |
| if (!list.length) { | |
| ul.innerHTML = '<li class="quiet">No finished runs yet. Start one above and it will appear here.</li>'; | |
| return; | |
| } | |
| list.forEach((ep) => { | |
| const li = document.createElement("li"); | |
| const b = document.createElement("button"); | |
| b.type = "button"; | |
| const [cls, label] = OUTCOMES[ep.outcome] || ["", escapeHtml(ep.outcome)]; | |
| b.innerHTML = `<span>${escapeHtml(taskTitle(ep.task))}, layout ${ep.seed}${ep.use_lessons ? "" : ", no journal"}</span><span class="${cls}">${label}</span>`; | |
| b.addEventListener("click", () => openReplay(ep.id)); | |
| li.appendChild(b); | |
| ul.appendChild(li); | |
| }); | |
| } | |
| async function loadTasks() { | |
| state.tasks = await api("/api/tasks"); | |
| const sel = $("task"); | |
| const current = sel.value; | |
| sel.replaceChildren(...state.tasks.map((t) => { | |
| const o = document.createElement("option"); | |
| o.value = t.id; | |
| o.textContent = t.lessons ? `${t.title} (${t.lessons} lessons)` : t.title; | |
| return o; | |
| })); | |
| if (current) sel.value = current; | |
| if (!startEvent()) showGoal(); | |
| } | |
| function showGoal() { | |
| const t = state.tasks.find((x) => x.id === $("task").value); | |
| if (t) $("goal").textContent = `${t.title}. ${t.goal}`; | |
| } | |
| function escapeHtml(s) { | |
| return String(s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); | |
| } | |
| // โโ coaching โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| function showCoach(on) { | |
| $("coach").hidden = !on; | |
| $("coach-log").textContent = on ? "A hint stops Beam's current plan after its next move; then Beam decides whether to follow it." : ""; | |
| } | |
| async function sendHint(text) { | |
| if (!state.live || !text.trim()) return; | |
| try { | |
| // Send where the viewer is looking: the screen can lag the game by a few moves. | |
| const shown = state.selected >= 0 ? state.events[state.turns[state.selected]] : null; | |
| const frame = shown ? shown.frame : (startEvent() || {}).frame; | |
| const seen = frame ? { seen_step: shown ? shown.steps : 0, seen_x: frame.status.x, seen_y: frame.status.y } : {}; | |
| const { queued } = await api(`/api/runs/${state.live.id}/hint`, { | |
| method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text, ...seen }) }); | |
| $("coach-log").textContent = `Sent: "${queued}"`; | |
| $("coach-log").classList.remove("refused"); | |
| } catch (err) { | |
| // Refusals (personal data, profanity, limits) must be noticed, not lost in grey text. | |
| $("coach-log").textContent = err.message; | |
| $("coach-log").classList.add("refused"); | |
| } | |
| } | |
| // โโ wiring โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| // The 3D module loads asynchronously; catch it up on whatever is already on screen. | |
| window.addEventListener("lantern3d-ready", () => { if (state.lastFrame) window.Lantern3D.update(state.lastFrame); }); | |
| $("controls").addEventListener("submit", startRun); | |
| document.querySelectorAll(".coach-buttons button").forEach((b) => b.addEventListener("click", () => sendHint(b.dataset.hint))); | |
| // Optional chaining: a cached older page without the coach panel must not break everything else. | |
| $("coach-form")?.addEventListener("submit", (e) => { e.preventDefault(); sendHint($("coach-text").value); $("coach-text").value = ""; }); | |
| $("stop").addEventListener("click", stopRun); | |
| $("follow").addEventListener("click", () => select(state.turns.length - 1, true)); | |
| $("ending-close").addEventListener("click", () => { state.endingDismissed = true; renderEnding(); $("task").focus(); }); | |
| $("again").addEventListener("click", () => { if (state.episodeId) openReplay(state.episodeId); }); | |
| // Start right away on the same level with the next layout, so it's a fresh dungeon. | |
| $("new-run").addEventListener("click", () => { | |
| $("seed").value = String((Number($("seed").value) || 0) + 1); | |
| $("controls").requestSubmit(); | |
| }); | |
| $("task").addEventListener("change", () => { if (!state.live && !startEvent()) showGoal(); }); | |
| $("timeline").addEventListener("keydown", (e) => { | |
| if (e.key === "ArrowLeft") { stopReplay(); select(state.selected - 1, false); e.preventDefault(); } | |
| if (e.key === "ArrowRight") { stopReplay(); select(state.selected + 1, state.live && state.selected + 1 === state.turns.length - 1); e.preventDefault(); } | |
| }); | |
| (async () => { | |
| await loadTasks(); | |
| await loadEpisodes(); | |
| const s = await checkLive(); | |
| if (s && s.busy) { | |
| $("status").textContent = "Beam is already exploring for another visitor. Open a replay or try again shortly."; | |
| } | |
| // Never greet a visitor with an empty stage: play the most recent finished run. | |
| const latest = (state.episodes || []).find((e) => e.outcome === "success") || (state.episodes || [])[0]; | |
| if (latest && !state.live) openReplay(latest.id); | |
| })(); | |