lantern / web /app.js
visproj's picture
feat: show water falls and corner side-steps; River goal warns about unfilled water
d43e802 verified
Raw History Blame Contribute Delete
27 kB
// 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 = '<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("&ensp;");
}
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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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);
})();