"use strict"; const HUMAN = "you"; const HUMAN_COLOR = "#ff5a14"; const ACTION_ICONS = { look: "👀", pan: "↻", zoom: "🔍", move: "🚶", view_map: "🗺️", place_pin: "📍", reverse_geocode: "🔎", submit_guess: "🔒", }; const MODE_ICONS = { world: "/static/icons/earth.svg", capitals: "/static/icons/capitals.svg" }; const $ = (sel) => document.querySelector(sel); const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); const fmtKm = (km) => km == null ? "—" : km < 1 ? `${Math.round(km * 1000)} m` : `${km.toLocaleString(undefined, { maximumFractionDigits: km < 100 ? 1 : 0 })} km`; const fmtPts = (n) => (n ?? 0).toLocaleString(); const state = { cfg: null, mode: "world", selected: new Set(), byok: {}, byokModel: {}, game: null, round: null, clockOffset: 0, timerId: null, es: null, pano: null, startPano: null, startPov: null, guessMap: null, guessMarker: null, revealMap: null, revealOverlays: [], lastReveal: null, }; function show(id) { document.querySelectorAll(".screen").forEach((s) => s.classList.toggle("active", s.id === id)); } function loading(on, text = "Finding a place…") { $("#loading").classList.toggle("hidden", !on); $("#loading-text").textContent = text; } async function api(path, opts = {}) { const res = await fetch(path, { headers: { "Content-Type": "application/json" }, ...opts, body: opts.body ? JSON.stringify(opts.body) : undefined, }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.detail || `HTTP ${res.status}`); return data; } function colorOf(key) { if (key === HUMAN) return HUMAN_COLOR; return state.game.models.find((m) => m.key === key)?.color || "#999"; } function labelOf(key) { if (key === HUMAN) return state.game.player || "You"; return state.game.models.find((m) => m.key === key)?.label || key; } function avatarHtml(label, color, size = "", human = false, img = "") { const initial = esc((String(label || "?").trim()[0] || "?").toUpperCase()); const face = img ? `` : initial; return `${face}`; } function avatarOf(key, size = "") { const img = key === HUMAN ? state.cfg.hf_user?.avatar : state.game.models.find((m) => m.key === key)?.avatar; return avatarHtml(labelOf(key), colorOf(key), size, key === HUMAN, img); } /* ================= Google Maps ================= */ let mapsReady = null; function mapsFailed(msg) { console.error(msg); $("#lobby-error").textContent = msg; if (!$("#lobby").classList.contains("active")) alert(msg); } // Google calls this when it rejects the key, e.g. RefererNotAllowedMapError. window.gm_authFailure = () => mapsFailed( `Google Maps rejected the browser key for this site. Add ${location.origin}/* to the key's allowed websites in Google Cloud Console.`); function loadMaps(key) { if (!key) return Promise.reject(new Error("Google Maps key missing: set the GOOGLE_MAPS_BROWSER_KEY secret.")); mapsReady ??= new Promise((resolve, reject) => { window.__gmInit = resolve; const s = document.createElement("script"); s.src = `https://maps.googleapis.com/maps/api/js?key=${encodeURIComponent(key)}&v=weekly&loading=async&callback=__gmInit`; s.async = true; s.onerror = () => reject(new Error("Google Maps failed to load")); document.head.appendChild(s); }); return mapsReady; } function dotIcon(color, scale = 9) { return { path: google.maps.SymbolPath.CIRCLE, fillColor: color, fillOpacity: 1, strokeColor: "#fff", strokeWeight: 2.5, scale }; } /* ================= Lobby ================= */ const svgIcon = (id, cls = "i") => ``; async function initLobby() { state.cfg = await api("/api/config"); loadMaps(state.cfg.maps_key).catch((e) => ($("#lobby-error").textContent = e.message)); const hfUser = state.cfg.hf_user; $("#nav-auth").innerHTML = hfUser ? `@${esc(hfUser.username)}` : `Sign in`; $("#sign-out-slot").innerHTML = hfUser ? `Sign out` : ""; if (state.cfg.traces_repo) $("#nav-traces").href = `https://huggingface.co/datasets/${state.cfg.traces_repo}`; else $("#nav-traces").classList.add("hidden"); $("#hf-login").classList.toggle("hidden", !!hfUser); $("#login-note").classList.toggle("hidden", !!hfUser); const mins = Math.round(state.cfg.round_seconds / 60 * 10) / 10; $("#rules-body").innerHTML = ``; $("#nav-rules").onclick = () => $("#rules-modal").classList.remove("hidden"); $("#rules-modal").onclick = (e) => { if (e.target.id === "rules-modal") $("#rules-modal").classList.add("hidden"); }; const names = (pred) => state.cfg.models.filter(pred).map((m) => esc(m.label)); const list = (xs) => xs.length > 1 ? `${xs.slice(0, -1).join(", ")} and ${xs.at(-1)}` : xs[0]; const hfModels = names((m) => m.hf_login), byokModels = names((m) => m.byok); $("#key-hint").innerHTML = [ hfModels.length && `${list(hfModels)} run on your Hugging Face account once you sign in.`, byokModels.length && `${list(byokModels)} play on your own key.`, (hfModels.length || byokModels.length) && "Keys and tokens are kept in memory for the game only and never logged.", ].filter(Boolean).join(" "); const nameInput = $("#player-name"); const syncAvatar = () => { if (!hfUser?.avatar) $("#player-avatar").textContent = (nameInput.value.trim()[0] || "?").toUpperCase(); }; if (hfUser?.avatar) { $("#player-avatar").classList.add("pic"); $("#player-avatar").innerHTML = ``; } // Signed-in and guest names are remembered separately, so a guest never inherits an HF username. state.nameKey = hfUser ? "arena.name" : "arena.guestName"; nameInput.value = localStorage.getItem(state.nameKey) || hfUser?.username || "Player"; nameInput.oninput = syncAvatar; $("#name-change").onclick = () => { nameInput.focus(); nameInput.select(); }; syncAvatar(); const stat = (n, label, title = "") => `
${n}${label}
`; $("#rules-hint").innerHTML = stat(state.cfg.rounds, "Rounds") + stat(mins, "Min each") + stat(state.cfg.max_steps, "AI moves", "look, pan, zoom, move, map, pin") + stat(`+${state.cfg.max_geocodes}`, "Geocodes", "text-only place lookups, on top of the moves"); const modes = $("#mode-picker"); state.cfg.modes.forEach((m) => { const b = document.createElement("button"); const icon = MODE_ICONS[m]; b.innerHTML = `${icon ? `` : ""}${esc(m)}`; b.className = m === state.mode ? "on" : ""; b.onclick = () => { state.mode = m; modes.querySelectorAll("button").forEach((x) => x.classList.toggle("on", x === b)); }; modes.appendChild(b); }); const grid = $("#model-grid"); const total = state.cfg.models.length; const syncCount = () => ($("#opp-count").textContent = `${state.selected.size} / ${total} Selected`); state.cfg.models.forEach((m) => { const el = document.createElement("div"); // Free models are open to everyone; the rest need a Hugging Face login or the player's key. const needsLogin = !m.byok && !m.free && !hfUser; const offline = !m.available; el.className = "fighter"; el.style.setProperty("--c", m.color); const tag = offline ? "Offline" : m.hf_login ? "Your HF" : "Free"; el.innerHTML = `${svgIcon("i-check", "")} ${avatarHtml(m.label, m.color, "lg", false, m.avatar)}
${esc(m.label)}
${esc(m.org || "")}
${m.byok ? `
Your Key
` : `
${tag}
`}`; const setLocked = (locked) => el.classList.toggle("locked", locked); const toggle = (on) => { on ? state.selected.add(m.key) : state.selected.delete(m.key); el.classList.toggle("on", on); syncCount(); }; if (m.byok) { const paste = document.createElement("button"); paste.className = "paste"; paste.textContent = "Paste key"; const input = document.createElement("input"); input.type = "password"; input.placeholder = m.key_hint || "API key"; input.autocomplete = "off"; input.className = "hidden"; const picker = byokPicker(m, el); paste.onclick = (e) => { e.stopPropagation(); paste.classList.add("hidden"); input.classList.remove("hidden"); input.focus(); }; input.onclick = (e) => e.stopPropagation(); input.oninput = () => { state.byok[m.key] = input.value.trim(); setLocked(!state.byok[m.key]); toggle(!!state.byok[m.key]); picker.refresh(state.byok[m.key]); }; el.append(paste, input, picker.select); } setLocked(m.byok || needsLogin || offline); el.onclick = () => { if (offline) return; if (m.byok && !state.byok[m.key]) { el.querySelector(".paste:not(.hidden)")?.click(); el.querySelector("input").focus(); return; } if (needsLogin) { nudgeLogin(); return; } toggle(!state.selected.has(m.key)); }; // Only the free model starts selected; everything else is opt-in. if (m.default_on && m.free && !offline) toggle(true); grid.appendChild(el); }); syncCount(); $("#start-btn").onclick = startGame; } /* Once a key is pasted, list that provider's models with it; the config's model stays the default. */ function byokPicker(m, tile) { const select = document.createElement("select"); select.className = "hidden"; select.title = "Model"; select.onclick = (e) => e.stopPropagation(); select.onchange = () => (state.byokModel[m.key] = select.value); const keyline = tile.querySelector(".keyline"); const setOptions = (models) => { if (!models.some((x) => x.id === m.model_id)) models.unshift({ id: m.model_id, label: m.model_id }); select.innerHTML = models.map((x) => ``).join(""); state.byokModel[m.key] = m.model_id; }; let timer, seq = 0; const refresh = (key) => { clearTimeout(timer); keyline.textContent = "Your Key"; if (!key) { select.classList.add("hidden"); delete state.byokModel[m.key]; return; } const mine = ++seq; timer = setTimeout(async () => { try { const res = await api("/api/byok/models", { method: "POST", body: { model: m.key, api_key: key } }); if (mine !== seq) return; setOptions(res.models); select.classList.remove("hidden"); } catch (e) { if (mine !== seq) return; // Keep the default model playable; just say the list could not be loaded. select.classList.add("hidden"); state.byokModel[m.key] = m.model_id; keyline.textContent = `⚠ ${e.message}`; } }, 600); }; return { select, refresh }; } function nudgeLogin() { const btn = $("#hf-login"); btn.classList.remove("nudge"); void btn.offsetWidth; btn.classList.add("nudge"); btn.scrollIntoView({ behavior: "smooth", block: "center" }); } async function startGame() { $("#lobby-error").textContent = ""; const player = $("#player-name").value.trim() || "Player"; localStorage.setItem(state.nameKey, player); const models = [...state.selected]; if (!models.length) { $("#lobby-error").textContent = "Pick at least one opponent."; return; } const byok = Object.fromEntries(models.filter((k) => state.byok[k]).map((k) => [k, state.byok[k]])); const byok_models = Object.fromEntries(models.filter((k) => state.byokModel[k]).map((k) => [k, state.byokModel[k]])); loading(true, "Setting up the arena…"); try { await loadMaps(state.cfg.maps_key); const res = await api("/api/games", { method: "POST", body: { player, models, byok, byok_models, mode: state.mode } }); state.game = await api(`/api/games/${res.game_id}`); openEvents(); await nextRound(); } catch (e) { loading(false); $("#lobby-error").textContent = e.message; } } /* ================= Live events ================= */ function openEvents() { state.es?.close(); state.es = new EventSource(`/api/games/${state.game.game_id}/events`); state.es.onmessage = (msg) => { const ev = JSON.parse(msg.data); if (state.round && ev.round !== state.round.round) return; if (ev.type === "agent_action") onAgentAction(ev); else if (ev.type === "agent_status") onAgentStatus(ev); else if (ev.type === "agent_thought") onAgentThought(ev); else if (ev.type === "round_reveal") onReveal(ev); }; } /* ================= Play ================= */ async function nextRound() { loading(true, "Dropping everyone somewhere on Earth…"); try { const rnd = await api(`/api/games/${state.game.game_id}/rounds`, { method: "POST" }); startRound(rnd); } catch (e) { alert(e.message); } finally { loading(false); } } function startRound(rnd) { state.round = rnd; state.clockOffset = rnd.server_now - Date.now() / 1000; show("play"); $("#hud-round").textContent = `${rnd.round}/${rnd.rounds_total}`; $("#hud-score").textContent = fmtPts(state.game.totals[HUMAN]); $("#locked-banner").classList.add("hidden"); $("#guess-box").classList.remove("hidden", "pinned"); const pov = { heading: rnd.heading, pitch: 0 }; state.startPano = rnd.pano_id; state.startPov = pov; if (!state.pano) { state.pano = new google.maps.StreetViewPanorama($("#pano"), { pano: rnd.pano_id, pov, zoom: 0, addressControl: false, showRoadLabels: false, fullscreenControl: false, enableCloseButton: false, motionTracking: false, motionTrackingControl: false, linksControl: true, panControl: true, zoomControl: true, clickToGo: true, }); } else { state.pano.setPano(rnd.pano_id); state.pano.setPov(pov); state.pano.setZoom(0); } $("#return-btn").onclick = () => { state.pano.setPano(state.startPano); state.pano.setPov(state.startPov); }; if (!state.guessMap) { state.guessMap = new google.maps.Map($("#guess-map"), { center: { lat: 20, lng: 0 }, zoom: 1, disableDefaultUI: true, zoomControl: true, clickableIcons: false, draggableCursor: "crosshair", gestureHandling: "greedy", }); state.guessMap.addListener("click", (e) => placeGuess(e.latLng)); } else { state.guessMap.setCenter({ lat: 20, lng: 0 }); state.guessMap.setZoom(1); } state.guessMarker?.setMap(null); state.guessMarker = null; $("#guess-btn").disabled = true; $("#guess-btn").textContent = "Place your Pin"; $("#guess-btn").onclick = submitGuess; renderRivals(rnd.agents); $("#rivals").classList.remove("unlocked"); $("#traces-toggle").classList.add("hidden"); $("#live-mode").textContent = "Blind"; if (rnd.human_locked) unlockThoughts(); clearInterval(state.timerId); state.timerId = setInterval(tick, 250); tick(); } function placeGuess(latLng) { if (state.round.human_locked) return; if (!state.guessMarker) { state.guessMarker = new google.maps.Marker({ map: state.guessMap, icon: dotIcon(HUMAN_COLOR) }); } state.guessMarker.setPosition(latLng); $("#guess-box").classList.add("pinned"); $("#guess-btn").disabled = false; $("#guess-btn").textContent = "Guess"; } async function submitGuess() { if (!state.guessMarker || state.round.human_locked) return; const p = state.guessMarker.getPosition(); $("#guess-btn").disabled = true; try { await api(`/api/games/${state.game.game_id}/guess`, { method: "POST", body: { lat: p.lat(), lng: p.lng() } }); state.round.human_locked = true; $("#guess-box").classList.add("hidden"); $("#locked-banner").classList.remove("hidden"); updateWaiting(); unlockThoughts(); } catch (e) { $("#guess-btn").disabled = false; alert(e.message); } } function tick() { const left = Math.max(0, state.round.deadline_epoch - (Date.now() / 1000 + state.clockOffset)); const t = $("#hud-timer"); t.textContent = `${Math.floor(left / 60)}:${String(Math.floor(left % 60)).padStart(2, "0")}`; t.classList.toggle("warn", left < 20); if (left <= 0) { clearInterval(state.timerId); if (!state.round.human_locked) { if (state.guessMarker) submitGuess(); else { state.round.human_locked = true; $("#guess-box").classList.add("hidden"); $("#locked-banner").classList.remove("hidden"); $("#waiting-text").textContent = "Time's up! No pin, no points. Scoring…"; unlockThoughts(); } } } } function renderRivals(agents) { const list = $("#rival-list"); list.innerHTML = ""; state.game.models.forEach((m) => { const a = agents[m.key] || { status: "thinking", step: 0 }; const el = document.createElement("div"); el.className = "rival"; el.id = `rival-${m.key}`; el.style.setProperty("--c", m.color); el.innerHTML = `
${avatarHtml(m.label, m.color, "", false, m.avatar)}${esc(m.label)} ${statusText(a.status)}
${svgIcon("i-info")}${a.status === "locked" ? "Locked in a guess" : "Getting its bearings"}
`; list.appendChild(el); }); } function statusText(s) { return { thinking: "Thinking", locked: "Locked in", out: "No guess", error: "Crashed" }[s] || s; } function onAgentAction(ev) { const el = $(`#rival-${ev.agent}`); if (!el) return; el.querySelector(".bar > i").style.width = `${(100 * ev.step) / ev.max_steps}%`; const last = el.querySelector(".last"); last.querySelector("span").textContent = `${ev.text} · step ${ev.step}/${ev.max_steps}`; last.classList.remove("flash"); void last.offsetWidth; last.classList.add("flash"); } function onAgentStatus(ev) { const el = $(`#rival-${ev.agent}`); if (!el) return; const badge = el.querySelector(".status"); badge.className = `status ${ev.status}`; badge.textContent = statusText(ev.status); if (ev.status === "locked") { const left = Math.max(0, state.round.deadline_epoch - (Date.now() / 1000 + state.clockOffset)); const used = state.cfg.round_seconds - left; el.querySelector(".last span").textContent = `Locked in after ${ev.step} steps · ${Math.round(used)}s`; } else if (ev.status === "error") { el.querySelector(".last span").textContent = "The API call failed"; } else if (ev.status === "out") { el.querySelector(".last span").textContent = "Ran out without a guess"; } updateWaiting(); } /* Once the human has locked in, the agents' reasoning is no longer a spoiler: fetch what they've said so far, then keep appending from the live feed. */ async function unlockThoughts(retry = true) { const rnd = state.round; if (rnd.thoughtsOpen) return; rnd.thoughtsOpen = true; rnd.seenThoughts = new Set(); $("#rivals").classList.add("unlocked"); $("#traces-toggle").classList.remove("hidden"); syncTracesToggle(); $("#live-mode").textContent = "Unlocked"; try { const r = await api(`/api/games/${state.game.game_id}/thoughts`); if (state.round === rnd) r.thoughts.forEach(onAgentThought); } catch (e) { // The server's clock may be a beat behind ours on timeout; try once more. if (retry && state.round === rnd) { rnd.thoughtsOpen = false; setTimeout(() => state.round === rnd && unlockThoughts(false), 1500); } } } /* The live traces can be folded back into the compact rivals list; the choice sticks across rounds. */ function syncTracesToggle() { const compact = localStorage.getItem("arena.compactTraces") === "1"; $("#rivals").classList.toggle("compact", compact); $("#traces-toggle").textContent = compact ? "Show traces" : "Collapse"; } $("#traces-toggle").onclick = () => { const compact = !$("#rivals").classList.contains("compact"); localStorage.setItem("arena.compactTraces", compact ? "1" : "0"); syncTracesToggle(); }; function onAgentThought(ev) { const rnd = state.round; if (!rnd?.thoughtsOpen || ev.round !== rnd.round || rnd.seenThoughts.has(ev.seq)) return; rnd.seenThoughts.add(ev.seq); const feed = $(`#rival-${ev.agent} .feed`); if (!feed) return; const el = document.createElement("div"); const kind = ev.kind || "assistant"; el.className = `thought ${kind}`; el.dataset.seq = ev.seq; const title = kind === "start" ? "Round start" : kind === "tool" ? `${ACTION_ICONS[ev.name] || ""} ${esc(ev.name)}` : ""; el.innerHTML = `${title ? `
${title}
` : ""} ${ev.reasoning ? `
thinking
${esc(ev.reasoning)}
` : ""} ${ev.text ? `
${esc(ev.text)}
` : ""}${callChips(ev.tool_calls)} ${traceImg(ev.agent, rnd.round, ev.image_ref)}`; el.querySelector("img")?.addEventListener("click", (e) => openLightbox(e.target.src)); // Backlog and live events can interleave: keep each feed in emission order. const after = [...feed.children].find((c) => +c.dataset.seq > ev.seq); const stick = feed.scrollHeight - feed.scrollTop - feed.clientHeight < 30; feed.insertBefore(el, after || null); if (stick) feed.scrollTop = feed.scrollHeight; } function traceImg(agent, round, ref) { return ref == null ? "" : ``; } function openLightbox(src) { $("#lightbox img").src = src; $("#lightbox").classList.remove("hidden"); } function callChips(calls) { return (calls || []).map((c) => `${ACTION_ICONS[c.name] || ""} ${esc(c.name)}(${esc(Object.entries(c.args || {}) .filter(([k]) => k !== "reasoning").map(([k, v]) => `${k}=${typeof v === "number" ? +v.toFixed(3) : v}`).join(", "))})`).join(""); } function updateWaiting() { if (!state.round?.human_locked) return; const pending = [...document.querySelectorAll(".rival .status.thinking")].length; $("#waiting-text").textContent = pending ? `Waiting for ${pending} machine${pending > 1 ? "s" : ""}…` : "Everyone's in. Revealing…"; } /* ================= Reveal ================= */ function onReveal(rev) { clearInterval(state.timerId); state.lastReveal = rev; state.game.totals = rev.totals; show("reveal"); $("#reveal-title").textContent = `Round ${state.round.round} of ${state.round.rounds_total}`; const me = rev.participants[HUMAN]; const myRank = rev.ranking.indexOf(HUMAN) + 1; $("#reveal-headline").innerHTML = me.guess ? `${fmtPts(me.score)} pts · ${fmtKm(me.distance_km)} off` : "No guess, no points"; $("#reveal-sub").innerHTML = me.guess ? `You placed #${myRank} of ${rev.ranking.length} this round.` : "The clock ran out before you pinned."; $("#replay").classList.add("hidden"); $("#round-table").classList.remove("hidden"); renderRoundTable(rev); drawRevealMap(rev); $("#next-btn").textContent = rev.game_over ? "Final results" : "Next round →"; $("#next-btn").onclick = rev.game_over ? showFinal : nextRound; } function renderRoundTable(rev) { const rows = rev.ranking.map((key, i) => { const p = rev.participants[key]; const sub = key === HUMAN ? (p.elapsed_s != null ? `${Math.round(p.elapsed_s)}s` : "no guess") : `${p.steps} steps · ${Math.round(p.elapsed_s)}s${p.guess?.country ? " · " + esc(p.guess.country) : ""}${p.error ? " · error" : ""}`; return ` ${i + 1}
${avatarOf(key)}
${esc(labelOf(key))}
${fmtKm(p.distance_km)} · ${sub}
${fmtPts(p.score)}
Σ ${fmtPts(rev.totals[key])}
${key === HUMAN ? "" : "Trace ›"}`; }).join(""); $("#round-table").innerHTML = `${rows}
#Player Points

${svgIcon("i-info")}Click an AI to replay everything it saw, thought and did.

`; $("#round-table").querySelectorAll("tr.clickable").forEach((tr) => tr.addEventListener("click", () => openReplay(tr.dataset.key))); } function drawRevealMap(rev) { if (!state.revealMap) { state.revealMap = new google.maps.Map($("#reveal-map"), { center: { lat: 20, lng: 0 }, zoom: 2, streetViewControl: false, mapTypeControl: true, clickableIcons: false, gestureHandling: "greedy", }); } state.revealOverlays.forEach((o) => o.setMap(null)); state.revealOverlays = []; const map = state.revealMap; const truth = { lat: rev.truth.lat, lng: rev.truth.lng }; const bounds = new google.maps.LatLngBounds(truth); const add = (o) => state.revealOverlays.push(o); add(new google.maps.Marker({ map, position: truth, zIndex: 999, title: "Actual location", label: { text: "🏁", fontSize: "22px" }, icon: { path: google.maps.SymbolPath.CIRCLE, scale: 0 } })); for (const [key, p] of Object.entries(rev.participants)) { if (!p.guess) continue; const pos = { lat: p.guess.lat, lng: p.guess.lng }; bounds.extend(pos); const color = colorOf(key); add(new google.maps.Polyline({ map, path: [pos, truth], strokeOpacity: 0, geodesic: true, icons: [{ icon: { path: "M 0,-1 0,1", strokeOpacity: 0.9, strokeColor: color, scale: 2.5 }, offset: "0", repeat: "12px" }] })); // AIs are pinned with their logo on a ring in their colour; the human keeps the orange dot. const logo = key !== HUMAN && state.game.models.find((m) => m.key === key)?.avatar; if (logo) add(new google.maps.Marker({ map, position: pos, clickable: false, zIndex: 99, icon: { ...dotIcon(color, 20), strokeWeight: 0 } })); const marker = new google.maps.Marker({ map, position: pos, icon: logo ? { url: logo, scaledSize: new google.maps.Size(30, 30), anchor: new google.maps.Point(15, 15) } : dotIcon(color, key === HUMAN ? 11 : 9), title: `${labelOf(key)} · ${fmtKm(p.distance_km)}`, zIndex: key === HUMAN ? 500 : 100 }); if (key !== HUMAN) marker.addListener("click", () => openReplay(key)); add(marker); if (p.path?.length > 1) { add(new google.maps.Polyline({ map, path: p.path, strokeColor: color, strokeWeight: 4, strokeOpacity: 0.9 })); } } map.fitBounds(bounds, 60); google.maps.event.addListenerOnce(map, "idle", () => { if (map.getZoom() > 15) map.setZoom(15); }); } function openReplay(key) { const rev = state.lastReveal; const p = rev.participants[key]; if (!p?.trace) return; const color = colorOf(key); const img = (ref) => traceImg(key, state.round.round, ref); let html = `
${p.guess ? `Guessed ${esc(p.guess.country || `${p.guess.lat.toFixed(3)}, ${p.guess.lng.toFixed(3)}`)}: ${fmtKm(p.distance_km)} off, ${fmtPts(p.score)} pts` : "No guess"}
${p.steps} tool calls · ${Math.round(p.elapsed_s)}s · finish: ${esc(p.finish)}
${p.guess?.reasoning ? `
“${esc(p.guess.reasoning)}”
` : ""} ${p.error ? `
${esc(p.error)}
` : ""}
`; for (const e of p.trace) { if (e.role === "user") { html += `
Game
${esc(e.text)}
${img(e.image_ref)}
`; } else if (e.role === "assistant") { const calls = callChips(e.tool_calls); html += `
${esc(labelOf(key))}
${e.text ? `
${esc(e.text)}
` : ""} ${e.reasoning ? `
thinking
${esc(e.reasoning)}
` : ""} ${calls}
`; } else if (e.role === "tool") { html += `
${ACTION_ICONS[e.name] || ""} ${esc(e.name)} result
${esc(e.text)}
${img(e.image_ref)}
`; } } $("#replay-title").innerHTML = `${avatarOf(key, "sm")} ${esc(labelOf(key))}`; $("#replay-body").innerHTML = html; $("#replay-body").querySelectorAll("img").forEach((im) => im.addEventListener("click", () => openLightbox(im.src))); $("#round-table").classList.add("hidden"); $("#replay").classList.remove("hidden"); $("#replay").scrollTop = 0; if (p.guess) state.revealMap.panTo({ lat: p.guess.lat, lng: p.guess.lng }); } /* ================= Final ================= */ function showFinal() { state.es?.close(); const totals = state.game.totals; const order = Object.keys(totals).sort((a, b) => totals[b] - totals[a]); const myRank = order.indexOf(HUMAN) + 1; const beaten = order.length - myRank; const machines = order.length - 1; $("#final-title").textContent = myRank === 1 ? "Humanity prevails" : myRank === order.length ? "The machines win" : `You finished #${myRank}`; $("#final-sub").textContent = `You beat ${beaten} of ${machines} AI opponent${machines > 1 ? "s" : ""} with ${fmtPts(totals[HUMAN])} points.`; const podiumOrder = [1, 0, 2].filter((i) => i < order.length); $("#podium").innerHTML = podiumOrder.map((i) => `
${avatarOf(order[i], i === 0 ? "xl" : "lg")}
${esc(labelOf(order[i]))}
${i + 1}
${fmtPts(totals[order[i]])}
`).join(""); $("#final-table").innerHTML = `${order.map((k, i) => ` `).join("")}
${i + 1}
${avatarOf(k)}${esc(labelOf(k))}
${fmtPts(totals[k])}
`; show("final"); } $("#again-btn").onclick = () => { state.game = null; state.round = null; show("lobby"); }; $("#replay-back").onclick = () => { $("#replay").classList.add("hidden"); $("#round-table").classList.remove("hidden"); }; $("#lightbox").onclick = () => $("#lightbox").classList.add("hidden"); initLobby().catch((e) => ($("#lobby-error").textContent = `Failed to load: ${e.message}`));