Joffrey Thomas
Guests default to the name Player
1e2a29a
Raw History Blame Contribute Delete
31.7 kB
"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) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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}`));