"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 = `
${state.cfg.rounds} rounds, ${mins} minutes each. Everyone gets dropped at the same Street View spot.
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.
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.
Up to 5,000 points per round, decaying with distance. After each round, click an AI to replay its full trace.
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.
`;
$("#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)}