Spaces:
Running
Running
Download static/app.js from Jofthomas/GeoGuess_Arena: direct link, hf CLI and curl.
- Browser
- Download file 31.7 kB
-
https://huggingface.co/spaces/Jofthomas/GeoGuess_Arena/resolve/main/static/app.js
- Command line
-
hf download hf://spaces/Jofthomas/GeoGuess_Arena/static/app.js
-
curl -L -o app.js https://huggingface.co/spaces/Jofthomas/GeoGuess_Arena/resolve/main/static/app.js
31.7 kB
| ; | |
| 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 ? `<img src="${esc(img)}" alt="">` : initial; | |
| return `<span class="avatar ${size}${human ? " human" : ""}${img ? " pic" : ""}" style="--c:${color}">${face}</span>`; | |
| } | |
| 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") => `<svg class="${cls}"><use href="#${id}"/></svg>`; | |
| 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 | |
| ? `<span class="nav-user">@${esc(hfUser.username)}</span>` | |
| : `<a class="link accent" href="/oauth/huggingface/login">Sign in</a>`; | |
| $("#sign-out-slot").innerHTML = hfUser ? `<a class="link muted" href="/oauth/huggingface/logout">Sign out</a>` : ""; | |
| 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 = `<ul> | |
| <li>${state.cfg.rounds} rounds, ${mins} minutes each. Everyone gets dropped at the same Street View spot.</li> | |
| <li>The AIs play with tools: look, pan, zoom, move, a map, a pin, and up to ${state.cfg.max_steps} moves per round | |
| plus ${state.cfg.max_geocodes} text-only place lookups.</li> | |
| <li>While you play, their feed is blind: you see what they do, not what they see. | |
| Lock in your guess and their reasoning and pictures go live.</li> | |
| <li>Up to 5,000 points per round, decaying with distance. After each round, click an AI to replay its full trace.</li> | |
| <li>Mistral Large is free for everyone. Sign in with Hugging Face for the open models (inference on your HF account), | |
| or paste your own key for the closed ones. Keys stay in memory for the game only and are never logged.</li></ul>`; | |
| $("#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 = `<img src="${esc(hfUser.avatar)}" alt="">`; | |
| } | |
| // 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 = "") => `<div class="stat" title="${title}"><b>${n}</b><small>${label}</small></div>`; | |
| $("#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 ? `<span class="mode-ico"><img src="${icon}" alt=""></span>` : ""}${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 = `<span class="mark">${svgIcon("i-check", "")}</span><svg class="lock"><use href="#i-lock"/></svg> | |
| ${avatarHtml(m.label, m.color, "lg", false, m.avatar)} | |
| <div class="fname">${esc(m.label)}</div><div class="org">${esc(m.org || "")}</div> | |
| ${m.byok ? `<div class="keyline">Your Key</div>` : `<div class="tag">${tag}</div>`}`; | |
| 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) => | |
| `<option value="${esc(x.id)}"${x.id === m.model_id ? " selected" : ""}>${esc(x.label)}</option>`).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 = `<div class="row">${avatarHtml(m.label, m.color, "", false, m.avatar)}<span class="nm">${esc(m.label)}</span> | |
| <span class="status ${a.status}">${statusText(a.status)}</span></div> | |
| <div class="bar"><i style="width:${(100 * a.step) / state.cfg.max_steps}%"></i></div> | |
| <div class="last">${svgIcon("i-info")}<span>${a.status === "locked" ? "Locked in a guess" : "Getting its bearings"}</span></div> | |
| <div class="feed"></div>`; | |
| 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 ? `<div class="tl">${title}</div>` : ""} | |
| ${ev.reasoning ? `<details><summary>thinking</summary><div class="thinking">${esc(ev.reasoning)}</div></details>` : ""} | |
| ${ev.text ? `<div class="text">${esc(ev.text)}</div>` : ""}${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 ? "" : | |
| `<img loading="lazy" src="/api/games/${state.game.game_id}/rounds/${round}/images/${agent}/${ref}" alt="">`; | |
| } | |
| function openLightbox(src) { | |
| $("#lightbox img").src = src; | |
| $("#lightbox").classList.remove("hidden"); | |
| } | |
| function callChips(calls) { | |
| return (calls || []).map((c) => | |
| `<span class="call">${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(", "))})</span>`).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 | |
| ? `<em>${fmtPts(me.score)}</em> pts · ${fmtKm(me.distance_km)} off` | |
| : "No guess, no points"; | |
| $("#reveal-sub").innerHTML = me.guess | |
| ? `You placed <b>#${myRank}</b> 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 `<tr class="${key === HUMAN ? "me" : "clickable"}" data-key="${key}"> | |
| <td class="rank">${i + 1}</td> | |
| <td><div class="who">${avatarOf(key)}<div>${esc(labelOf(key))} | |
| <div class="sub">${fmtKm(p.distance_km)} · ${sub}</div></div></div></td> | |
| <td class="pts">${fmtPts(p.score)}<div class="sub">Σ ${fmtPts(rev.totals[key])}</div></td> | |
| <td class="chev">${key === HUMAN ? "" : "<span>Trace ›</span>"}</td></tr>`; | |
| }).join(""); | |
| $("#round-table").innerHTML = `<table class="lb"><thead><tr><th>#</th><th>Player</th> | |
| <th style="text-align:right">Points</th><th></th></tr></thead><tbody>${rows}</tbody></table> | |
| <p class="note">${svgIcon("i-info")}<span>Click an AI to replay everything it saw, thought and did.</span></p>`; | |
| $("#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 = `<div class="verdict" style="--c:${color}"> | |
| ${p.guess ? `Guessed <b>${esc(p.guess.country || `${p.guess.lat.toFixed(3)}, ${p.guess.lng.toFixed(3)}`)}</b>: | |
| ${fmtKm(p.distance_km)} off, <b>${fmtPts(p.score)}</b> pts` : "<b>No guess</b>"} | |
| <div class="sub muted" style="margin-top:6px">${p.steps} tool calls · ${Math.round(p.elapsed_s)}s · finish: ${esc(p.finish)}</div> | |
| ${p.guess?.reasoning ? `<div style="margin-top:8px">“${esc(p.guess.reasoning)}”</div>` : ""} | |
| ${p.error ? `<div class="error" style="margin-top:8px">${esc(p.error)}</div>` : ""} | |
| </div>`; | |
| for (const e of p.trace) { | |
| if (e.role === "user") { | |
| html += `<div class="step"><div class="tl">Game</div><div class="text">${esc(e.text)}</div>${img(e.image_ref)}</div>`; | |
| } else if (e.role === "assistant") { | |
| const calls = callChips(e.tool_calls); | |
| html += `<div class="step" style="--c:${color}"><div class="tl">${esc(labelOf(key))}</div> | |
| ${e.text ? `<div class="text">${esc(e.text)}</div>` : ""} | |
| ${e.reasoning ? `<details><summary>thinking</summary><div class="thinking">${esc(e.reasoning)}</div></details>` : ""} | |
| ${calls}</div>`; | |
| } else if (e.role === "tool") { | |
| html += `<div class="step"><div class="tl">${ACTION_ICONS[e.name] || ""} ${esc(e.name)} result</div> | |
| <div class="text muted">${esc(e.text)}</div>${img(e.image_ref)}</div>`; | |
| } | |
| } | |
| $("#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) => `<div class="place p${i + 1}" style="--c:${colorOf(order[i])}"> | |
| ${avatarOf(order[i], i === 0 ? "xl" : "lg")}<div class="nm">${esc(labelOf(order[i]))}</div> | |
| <div class="block"><div class="n">${i + 1}</div><div class="pts">${fmtPts(totals[order[i]])}</div></div></div>`).join(""); | |
| $("#final-table").innerHTML = `<table class="lb"><tbody>${order.map((k, i) => ` | |
| <tr class="${k === HUMAN ? "me" : ""}"><td class="rank">${i + 1}</td> | |
| <td><div class="who">${avatarOf(k)}${esc(labelOf(k))}</div></td> | |
| <td class="pts">${fmtPts(totals[k])}</td></tr>`).join("")}</tbody></table>`; | |
| 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}`)); | |