// Lantern front end: renders live (SSE) or recorded episodes. No framework, no build. "use strict"; 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 = 'Nothing yet.'; $("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 ${s.hp}/${s.max_hp}`]; if (turn) parts.push(`step ${turn.steps}`, `call ${turn.call} 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 `${label} 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 = '
  • No finished runs yet. Start one above and it will appear here.
  • '; 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 = `${escapeHtml(taskTitle(ep.task))}, layout ${ep.seed}${ep.use_lessons ? "" : ", no journal"}${label}`; 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); })();