// ==UserScript== // @name OpenFront Lobby Overlay // @namespace https://minhkarl.github.io // @version 1.7.27 // @description Replaces OpenFront's home-screen lobby preview cards with the richer minhkarl.github.io dashboard cards, and removes the JOIN LOBBY button. // @match https://openfront.io/* // @run-at document-idle // @require https://raw.githubusercontent.com/minhkarl/minhkarl.github.io/main/lobby-wire.js // @require https://raw.githubusercontent.com/minhkarl/minhkarl.github.io/main/modifier-labels.js // @require https://raw.githubusercontent.com/minhkarl/minhkarl.github.io/main/lobby-filters.js // @updateURL https://raw.githubusercontent.com/minhkarl/minhkarl.github.io/main/openfront-lobby-overlay.user.js // @downloadURL https://raw.githubusercontent.com/minhkarl/minhkarl.github.io/main/openfront-lobby-overlay.user.js // @grant GM_xmlhttpRequest // @connect trackerfront.com // ==/UserScript== (function () { "use strict"; // OpenFront's multi-server rollout (docs/MultiServer.md) means there is no // fixed lobby host any more — window.BOOTSTRAP_CONFIG on the real client // carries none of numWorkers/serverHost/cluster (confirmed live // 2026-09-20: DevTools showed only gitCommit/assetManifest/cdnBase/gameEnv/ // jwtAudience/stripePublishableKey/turnstileSiteKey), so a page has to ask // the same API the game's own ClientEnv/ServerList does: which servers // exist right now, and how many workers each one runs. This is the actual // fix for the "reconnecting forever" failure — a red herring earlier in // this file's history briefly blamed a Tampermonkey sandbox/origin issue // (@grant unsafeWindow), but every wss://openfront.io/w{N}/lobbies attempt // was failing because openfront.io itself is no longer a real game server // to connect to, not because of which realm the socket opened from. // // Cached briefly so a burst of reconnects doesn't refetch on every // attempt; a server "draining" or "fenced" still serves a live lobby list // even though it won't take a new game, so only "no servers at all" falls // through to the pre-multi-server guess below. const CLUSTER_LIST_URL = "https://api.openfront.io/cluster.json?site=openfront.io"; const SERVER_CACHE_MS = 30_000; let serverCache = null; // { host, numWorkers, cachedAt } async function resolveServer() { if (serverCache && Date.now() - serverCache.cachedAt < SERVER_CACHE_MS) { return serverCache; } try { const res = await fetch(CLUSTER_LIST_URL, { headers: { Accept: "application/json" } }); if (!res.ok) throw new Error(`cluster.json responded ${res.status}`); const data = await res.json(); const entries = Object.values(data?.servers || {}); const pick = entries.find((s) => s?.state === "open") || entries.find((s) => s?.state !== "fenced") || entries[0]; if (!pick?.host || !pick.numWorkers) throw new Error("cluster.json had no usable server"); serverCache = { host: pick.host, numWorkers: pick.numWorkers, cachedAt: Date.now() }; } catch (e) { console.error("[of-overlay] cluster.json lookup failed, falling back to openfront.io directly:", e); // Last-resort guess matching this script's pre-multi-server behavior, // for the (now unlikely) case the API itself is unreachable. serverCache = { host: "openfront.io", numWorkers: 5, cachedAt: Date.now() }; } return serverCache; } // Hosted lobbies must join via join-lobby-modal.open({lobbyId}) with NO // lobbyInfo — JoinLobbyModal.onOpen() only calls handleUrlJoin() (which // dispatches the real join) when lobbyInfo is absent. Passing it up front // leaves the modal stuck on "Connecting..." forever. const CATEGORIES = [ { key: "ffa", label: "Free For All", dot: "#4f9eff", source: "public" }, { key: "team", label: "Teams", dot: "#4ade80", source: "public" }, { key: "special", label: "Special", dot: "#facc15", source: "public" }, { key: "hosted", label: "Custom", dot: "#f472b6", source: "hosted" }, ]; // Filter/sort matching rules live in lobby-filters.js (@require above) — // shared with index.html's own filter panel so a saved profile or a filter // choice behaves identically in both places. const LF = window.OpenFrontLobbyFilters; // lobby-filters.js's defaultFilters() mirrors index.html, which has no map // filter — `maps` is an overlay-only addition layered on top, so it's // added here rather than in the shared module. function freshFilters() { return { ...LF.defaultFilters(), maps: [] }; } // --- TrackerFront integration: openfront.io itself shows no ranked-ELO or // game-history UI, so the left sidebar's stats come from trackerfront.com's // public API instead. Its own frontend calls it same-origin (relative // /api/public/... paths — read directly out of its bundle 2026-09-20) and // that API sends no Access-Control-Allow-Origin header at all, so a plain // page fetch() from openfront.io would be silently blocked by the browser // exactly like api.openfront.io blocked the standalone dashboard earlier. // GM_xmlhttpRequest issues the request from the userscript engine instead // of the page's own realm, which isn't subject to that same check — this // is the one thing @grant none couldn't do, hence the @grant/@connect // added above. const TF_API_BASE = "https://trackerfront.com/api/public"; const TF_REFRESH_MS = 5 * 60_000; function gmFetchJson(url) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: "GET", url, headers: { Accept: "application/json" }, onload: (res) => { if (res.status < 200 || res.status >= 300) { reject(new Error(`${url} responded ${res.status}`)); return; } try { resolve(JSON.parse(res.responseText)); } catch (e) { reject(e); } }, onerror: () => reject(new Error(`${url} request failed`)), }); }); } const RANK_COLORS = { iron: "#8d8d8d", bronze: "#b8763e", silver: "#c7ccd6", gold: "#f2c14e", platinum: "#4fd1c5", diamond: "#4f9eff", master: "#c084fc", }; function rankColor(name) { return RANK_COLORS[String(name || "").toLowerCase()] || "#4f9eff"; } // trackerfront.com's own rank-tier icons — plain hotlinking, unlike // its JSON API, needs no GM_xmlhttpRequest workaround at all: an // isn't subject to CORS the way fetch/XHR reads are. "platinium" is their // own asset filename typo (confirmed live 2026-09-21), not ours to fix. const TF_ASSET_BASE = "https://trackerfront.com/asset"; const RANK_ASSET_NAMES = { iron: "iron", bronze: "bronze", silver: "silver", gold: "gold", platinum: "platinium", diamond: "diamond", master: "master", challenger: "challenger", }; function rankIconUrl(name) { const key = RANK_ASSET_NAMES[String(name || "").toLowerCase()]; return key ? `${TF_ASSET_BASE}/${key}.svg` : null; } // UsernameInput.ts (openfrontio/OpenFrontIO) persists the chosen name to // this exact localStorage key — verified 2026-09-20 against main. Doesn't // account for the "use verified name" override showing a different display // name than what's stored here; good enough since trackerfront's own // /search matches against both a player's raw username and display name. function getLocalUsername() { try { return localStorage.getItem("username") || ""; } catch { return ""; } } // /players/{id}/games comes back most-recent-first (confirmed from a live // response) — count backwards from the front until the streak breaks. function computeStreak(games) { if (!games || games.length === 0) return null; const won = games[0].won; let count = 0; for (const g of games) { if (g.won !== won) break; count++; } return { won, count }; } async function fetchTrackerFrontStats(username) { if (!username) return { state: "no-username" }; let matches; try { matches = await gmFetchJson(`${TF_API_BASE}/search?q=${encodeURIComponent(username)}&limit=8`); } catch (e) { console.error("[of-overlay] trackerfront search failed", e); return { state: "error" }; } const match = matches.find((p) => p.username === username) || matches.find((p) => p.display_name === username) || null; if (!match) return { state: "not-found", username }; try { const [profile, games, history] = await Promise.all([ gmFetchJson(`${TF_API_BASE}/players/${match.public_uuid}`), gmFetchJson(`${TF_API_BASE}/players/${match.public_uuid}/games?limit=10`), gmFetchJson(`${TF_API_BASE}/players/${match.public_uuid}/history?limit=12`), ]); return { state: "ok", profile, games, history, streak: computeStreak(games) }; } catch (e) { console.error("[of-overlay] trackerfront profile fetch failed", e); return { state: "error" }; } } // A plain inline-SVG polyline instead of a charting library — a userscript // has no bundler step to pull one in through, and a handful of RR points // doesn't need more than this. // Ascending tier order — index doubles as "how many tiers below master". const RANK_TIER_ORDER = ["iron", "bronze", "silver", "gold", "platinum", "diamond", "master", "challenger"]; function rankTierIndex(name) { const i = RANK_TIER_ORDER.indexOf(String(name || "").toLowerCase()); return i === -1 ? 0 : i; } // RR is 0-100 *within* the current tier, so plotting it raw makes every // promotion (RR wrapping from ~100 back to ~0 as the next tier starts) // read as a huge loss instead of the gain it actually was. Folding the // tier into the value (tierIndex*100 + rr) keeps the line climbing // through a promotion the way the player's actual standing did — a real // drop only shows up now when a game was actually lost, which the dot for // that point also confirms directly instead of leaving it to guesswork. function sparklineSvg(history, color) { const points = (history || []) .filter((h) => h?.rank && typeof h.rank.rr === "number") .map((h) => ({ value: rankTierIndex(h.rank.name) * 100 + h.rank.rr, won: h.won })); if (points.length < 2) return ""; const w = 100, h = 36, pad = 3; const values = points.map((p) => p.value); const min = Math.min(...values); const span = Math.max(...values) - min || 1; const step = w / (points.length - 1); const coords = points.map((p, i) => [ i * step, h - pad - ((p.value - min) / span) * (h - pad * 2), ]); const linePath = coords.map(([x, y], i) => `${i === 0 ? "M" : "L"}${x.toFixed(1)},${y.toFixed(1)}`).join(" "); const areaPath = `${linePath} L${w.toFixed(1)},${h} L0,${h} Z`; const dots = coords .map(([x, y], i) => { const won = points[i].won; const dotColor = won === true ? "#4ade80" : won === false ? "#f87171" : color; return ``; }) .join(""); // Presentation via `style`, not attributes — a plain SVG presentation // attribute loses to a stylesheet rule targeting the same property // regardless of source order, so `style` is the only reliable way to // get the current rank's color (or a per-point win/loss color) in. return ` ${dots} `; } function formatShortDate(iso) { const d = iso ? new Date(iso) : null; if (!d || Number.isNaN(d.getTime())) return ""; return d.toLocaleDateString(undefined, { month: "short", day: "numeric" }); } // Small per-opponent rank-tier icons, truncated with a "+N" like // trackerfront's own recent-games list — tracked_ranks is every tracked // player's rank in that particular game, not just the two people shown. const MAX_TRACKED_ICONS = 6; function trackedRanksHtml(tracked) { if (!Array.isArray(tracked) || tracked.length === 0) return ""; const shown = tracked.slice(0, MAX_TRACKED_ICONS); const extra = tracked.length - shown.length; const icons = shown .map((t) => { const url = rankIconUrl(t?.name); return url ? `` : ""; }) .join(""); const extraHtml = extra > 0 ? `+${extra}` : ""; return `
${icons}${extraHtml}
`; } // Blends trackerfront's own two "recent games" layouts: a map thumbnail // and win/loss framing like its game-history cards, in the single compact // row its player-profile list actually uses (a full card per game is too // tall to show more than one in this sidebar). function trackerGameRowHtml(g) { const delta = typeof g.rr_delta === "number" ? `${g.rr_delta >= 0 ? "+" : ""}${g.rr_delta.toFixed(1)}` : ""; const sub = [g.game_mode, g.num_players != null ? `${g.num_players}P` : null].filter(Boolean).join(" · "); return `
${escapeHtml(g.game_map || "—")} ${escapeHtml(delta)}
${g.won ? "W" : "L"} ${escapeHtml(sub)} ${escapeHtml(formatShortDate(g.played_at))}
${trackedRanksHtml(g.tracked_ranks)}
`; } function buildTrackerFrontPanelHtml(data) { if (!data || data.state === "no-username") { return `
Set a username in-game to see your stats here.
`; } if (data.state === "not-found") { return `
No ranked games found for ${escapeHtml(data.username)} yet.
`; } if (data.state === "error") { return `
Couldn't load stats from trackerfront.com right now.
`; } const { profile, games, streak, history } = data; const rank = profile.rank || {}; const rankName = (rank.name || "unranked").toUpperCase(); const color = rankColor(rank.name); const rr = typeof rank.rr === "number" ? Math.max(0, Math.min(100, rank.rr)) : null; const rankIcon = rankIconUrl(rank.name); const streakHtml = streak && streak.count >= 2 ? `
${streak.won ? "🔥" : "❄️"} ${streak.count} ${streak.won ? "win" : "loss"} streak
` : ""; const lastGames = (games || []).slice(0, 2).map(trackerGameRowHtml).join(""); return `
${rankIcon ? `` : ""} ${escapeHtml(rankName)} ${profile.verified ? `✓` : ""} ${profile.clan_tag ? `[${escapeHtml(profile.clan_tag)}]` : ""}
${rr != null ? rr.toFixed(1) : "—"}/100
${sparklineSvg(history, color)}
Global rank #${profile.global_position ?? "—"} / ${profile.total_ranked ?? "—"}
${profile.games_scored ?? 0} scored games
${streakHtml}
Last games
${lastGames || `
No games yet
`}
`; } // Bumped on every call so an overlapping refresh (e.g. the 5-minute timer // firing again before a slow request resolved) can't have its response // land after a newer one already did. let tfGeneration = 0; async function refreshTrackerFrontPanel(bodyEl) { const generation = ++tfGeneration; const data = await fetchTrackerFrontStats(getLocalUsername()); if (generation !== tfGeneration || !bodyEl.isConnected) return; bodyEl.innerHTML = buildTrackerFrontPanelHtml(data); } function mountTrackerFrontPanel(bodyEl, refreshBtn) { bodyEl.innerHTML = `
Loading…
`; refreshTrackerFrontPanel(bodyEl); setInterval(() => refreshTrackerFrontPanel(bodyEl), TF_REFRESH_MS); refreshBtn?.addEventListener("click", () => refreshTrackerFrontPanel(bodyEl)); } // --- OpenFront's own ranked (1v1/2v2) ELO. There is no public API for it // — it only ever reaches the page as the detail of a "userMeResponse" // document event, broadcast (Main.ts) after an authenticated /users/@me // call this script has no way to make itself (the JWT it needs lives in // an in-memory closure inside OpenFront's own auth module, never in // localStorage/cookies). listens for that same event and // computes its own `elo`/`elo2v2` fields from it to feed its own display — // and since TypeScript's `private`/@state() decorators are compile-time // only, those fields are exactly as readable from here as they are from // inside that component. This never touches the token; it just reads the // same broadcast (and the same component) OpenFront's own ranked UI does. --- const rankedElo = { oneVOne: null, twoVTwo: null }; function readRankedEloFromModal() { const modal = document.querySelector("ranked-modal"); if (!modal) return; const usable = (v) => typeof v === "number" || (typeof v === "string" && v && v !== "..."); if (usable(modal.elo)) rankedElo.oneVOne = modal.elo; if (usable(modal.elo2v2)) rankedElo.twoVTwo = modal.elo2v2; renderRankedElo(); } function rankedEloHtml() { if (rankedElo.oneVOne == null && rankedElo.twoVTwo == null) { return `
Opens once you visit Ranked this session.
`; } const row = (label, value) => `
${label}${escapeHtml(String(value ?? "—"))}
`; return row("1v1", rankedElo.oneVOne) + row("2v2", rankedElo.twoVTwo); } function renderRankedElo() { const el = document.getElementById("ofov-rankedElo"); if (el) el.innerHTML = rankedEloHtml(); } function initRankedElo() { // RankedModal.ts's own listener for this same event is registered long // before ours (it's a page-level element mounted at boot; this script // runs at document-idle), so by the time our handler runs in the same // dispatch its `elo`/`elo2v2` are already updated — read straight away. document.addEventListener("userMeResponse", readRankedEloFromModal); // Covers the case where that event already fired (player already // signed in) before this script attached its own listener. readRankedEloFromModal(); } // 's identity row: flag + username + "use verified" + skin. // Moved into the left sidebar's own slot (see mount()) instead of staying // above the lobby grid — the same DOM node, just relocated, not rebuilt, // so none of its own state/behavior changes. Retried like // relocateFooter/relocateNewsBox since neither the identity row nor the // sidebar are guaranteed to exist in the same tick. function relocateIdentityBar(attemptsLeft = 15) { const identityRow = document.querySelector('div[class*="sm:min-h-[60px]"]'); const slot = document.getElementById("ofov-identitySlot"); if (!identityRow || !slot) { if (attemptsLeft > 0) setTimeout(() => relocateIdentityBar(attemptsLeft - 1), 300); return; } if (identityRow.classList.contains("ofov-identityRelocated")) return; identityRow.classList.add("ofov-identityRelocated"); slot.appendChild(identityRow); } const ALERTS_STORAGE_KEY = "ofovLobbyAlertsEnabled"; function loadAlertsEnabled() { try { return localStorage.getItem(ALERTS_STORAGE_KEY) === "true"; } catch { return false; } } const state = { games: { ffa: [], team: [], special: [], hosted: [] }, byId: new Map(), // gameID -> that card's .ofov-time element, rebuilt once per render() so // the once-a-second timer tick can update text directly instead of // re-querying the DOM every tick. timeEls: new Map(), // gameID -> that card's player-count , same reasoning as timeEls — // "counts" frames arrive often, so patchCounts needs a direct reference // rather than a querySelector per lobby per frame. metaEls: new Map(), serverTime: undefined, serverTimeCapturedAt: undefined, filters: freshFilters(), tri: LF.initTriState(), activeProfiles: new Set(), profileSelectionOrder: [], // True while a saved profile's values are being written into the form — // suppresses the "editing manually clears active profiles" rule so // selecting a profile doesn't immediately deselect itself. applyingProfile: false, // --- "New match" alerts, ported from index.html's own app.js. --- alertsEnabled: loadAlertsEnabled(), // The matching-game-ID snapshot alerts compare each new one against — // only IDs *added* since this snapshot count as "new". Reset whenever // the active filters/profiles change so a filter tweak's own newly- // matching lobbies don't read as a flood of "new" matches. alertPrevIds: new Set(), alertBaselineReady: false, alertLastFilterKey: "", audioCtx: null, titleBlinkTimer: null, titleBlinkStopTimer: null, originalTitle: "", }; const PROFILE_STORAGE_KEY = "ofovLobbyProfiles"; const PROFILE_ACTIVE_STORAGE_KEY = "ofovLobbyActiveProfiles"; const TEAM_FILTER_OPTIONS = [ { value: "any", label: "All" }, { value: "format:Duos", label: "Duos (2 per team)" }, { value: "format:Trios", label: "Trios (3 per team)" }, { value: "format:Quads", label: "Quads (4 per team)" }, { value: "teams:2", label: "2 teams" }, { value: "teams:3", label: "3 teams" }, { value: "teams:4", label: "4 teams" }, { value: "teams:5", label: "5 teams" }, { value: "teams:6", label: "6 teams" }, { value: "teams:7", label: "7 teams" }, { value: "teams:8", label: "8 teams" }, ]; const SORT_OPTIONS = [ { value: "starts_asc", label: "Starts Soonest" }, { value: "players_desc", label: "Players ↓" }, { value: "players_asc", label: "Players ↑" }, { value: "maxPlayers_desc", label: "Max Players ↓" }, { value: "maxPlayers_asc", label: "Max Players ↑" }, { value: "map_asc", label: "Map A→Z" }, ]; // Full snapshots (the only source of serverTime) arrive well under once a // second, so extrapolate "now" from elapsed time; past this cutoff, stop // trusting the extrapolation and fall back to "Open". const STALE_AFTER_MS = 30_000; function estimatedServerTime() { if (state.serverTime == null || state.serverTimeCapturedAt == null) return undefined; const elapsed = Date.now() - state.serverTimeCapturedAt; if (elapsed > STALE_AFTER_MS) return undefined; return state.serverTime + elapsed; } // Same handful of maps recur across every lobby on every render — cache // the computed URL per name instead of re-running the regex replaces. const thumbnailUrlCache = new Map(); function getMapThumbnailUrl(mapName) { const key = String(mapName || ""); let url = thumbnailUrlCache.get(key); if (url === undefined) { const slug = key .toLowerCase() .replace(/[\s_]/g, "") .replace(/[^\w]/g, ""); url = `https://raw.githubusercontent.com/openfrontio/OpenFrontIO/main/resources/maps/${slug}/thumbnail.webp`; thumbnailUrlCache.set(key, url); } return url; } function formatDuration(s) { s = Math.max(0, Math.round(s)); const m = Math.floor(s / 60); const r = s % 60; if (m <= 0) return `${r}s`; if (m < 60) return `${m}m ${r}s`; const h = Math.floor(m / 60); return `${h}h ${m % 60}m`; } function escapeHtml(s) { return String(s) .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'"); } // Shared with index.html via modifier-labels.js (@require above) — one // source of truth instead of two hand-copies drifting apart. const modifierBadges = (cfg) => window.OpenFrontModifierLabels.gameModifierLabels(cfg); // Badges sit absolutely-positioned over a fixed-height card — past this // many they'd get silently clipped, so fold the rest into a "+N more" chip. const MAX_CARD_BADGES = 3; function timeText(lobby, serverTimeNow) { if (!lobby.startsAt || !serverTimeNow) return "Open"; const deltaS = (lobby.startsAt - serverTimeNow) / 1000; return deltaS > 0 ? formatDuration(deltaS) : "Starting"; } // Duos/Trios/Quads store a preset name (not a number) in playerTeams, so // team count has to be derived from total players. const TEAM_FORMAT_SIZE = { Duos: 2, Trios: 3, Quads: 4 }; function modeSummaryLine(cfg, numClients) { if (cfg?.gameMode !== "Team") return "Free For All"; const totalPlayers = cfg?.maxPlayers ?? numClients; const playerTeams = cfg?.playerTeams; const perTeam = TEAM_FORMAT_SIZE[playerTeams]; if (perTeam) { const teamCount = totalPlayers ? Math.floor(totalPlayers / perTeam) : null; return teamCount ? `${teamCount} Teams of ${perTeam}` : playerTeams; } if (playerTeams === "Humans Vs Nations") { return "Humans vs Nations"; } if (typeof playerTeams === "number") { const perTeamCount = totalPlayers && playerTeams > 0 ? Math.floor(totalPlayers / playerTeams) : null; return perTeamCount ? `${playerTeams} Teams of ${perTeamCount}` : `${playerTeams} Teams`; } return "Team"; } function cardHtml(lobby, serverTime, source) { const cfg = lobby.gameConfig || {}; const map = cfg.gameMap || "—"; const title = lobby.label || map; const allBadgeLabels = modifierBadges(cfg); const visibleBadgeLabels = allBadgeLabels.slice(0, MAX_CARD_BADGES); const hiddenBadgeLabels = allBadgeLabels.slice(MAX_CARD_BADGES); const badges = visibleBadgeLabels.map((l) => `${escapeHtml(l)}`).join("") + (hiddenBadgeLabels.length ? `+${hiddenBadgeLabels.length} more` : ""); const featuredBadge = lobby.featured ? `★ Featured` : ""; const cardTimeText = timeText(lobby, serverTime); const mode = modeSummaryLine(cfg, lobby.numClients); // A native title tooltip is invisible/delayed and doesn't help with // badges that are ellipsis-truncated in place (as opposed to the ones // folded into "+N more") — show every modifier as plain wrapped text // over the image on hover instead. const modifierOverlay = allBadgeLabels.length ? `
${allBadgeLabels .map((label) => `${escapeHtml(label)}`) .join("")}
` : ""; return `
${escapeHtml(map)}
${featuredBadge}${badges}
${modifierOverlay}
${escapeHtml(cardTimeText)}
${escapeHtml(title)}
${escapeHtml(mode)}
${lobby.numClients ?? 0}${cfg.maxPlayers != null ? "/" + cfg.maxPlayers : ""} 👥
`; } // --- Filters/profiles: matching rules come from lobby-filters.js; this // section is just storage + the DOM form that edits state.filters/tri. --- function getStoredProfiles() { try { const parsed = JSON.parse(localStorage.getItem(PROFILE_STORAGE_KEY) || "[]"); return Array.isArray(parsed) ? parsed : []; } catch { return []; } } function saveStoredProfiles(profiles) { try { localStorage.setItem(PROFILE_STORAGE_KEY, JSON.stringify(profiles)); } catch {} } function loadActiveProfileNames() { try { const parsed = JSON.parse(localStorage.getItem(PROFILE_ACTIVE_STORAGE_KEY) || "[]"); return Array.isArray(parsed) ? parsed.filter((n) => typeof n === "string") : []; } catch { return []; } } function saveActiveProfileNames() { try { localStorage.setItem(PROFILE_ACTIVE_STORAGE_KEY, JSON.stringify(state.profileSelectionOrder)); } catch {} } function normalizeProfileFilters(value) { value = value || {}; return { type: value.type || "all", hideEmpty: value.hideEmpty === true, teamFilters: Array.isArray(value.teamFilters) ? value.teamFilters.filter((v) => v && v !== "any") : [], maps: Array.isArray(value.maps) ? value.maps.filter((v) => typeof v === "string" && v) : [], sort: value.sort || "starts_asc", minJoined: value.minJoined ?? null, maxJoined: value.maxJoined ?? null, maxPlayersEq: value.maxPlayersEq ?? null, minMaxPlayers: value.minMaxPlayers ?? null, maxMaxPlayers: value.maxMaxPlayers ?? null, minPerTeam: value.minPerTeam ?? null, maxPerTeam: value.maxPerTeam ?? null, }; } function normalizeProfileTri(rawTri) { const tri = new Map(); if (Array.isArray(rawTri)) { for (const entry of rawTri) { if (Array.isArray(entry) && entry.length >= 2 && entry[0]) { tri.set(entry[0], LF.normalizeModifierRule(entry[1])); } } } return tri; } // Every active profile is OR'd together: a lobby matching any one of them // passes, same as index.html's own multi-profile behavior. function getActiveProfileDefinitions() { if (state.activeProfiles.size === 0) return []; return getStoredProfiles() .filter((p) => p?.name && state.activeProfiles.has(p.name)) .map((p) => ({ name: p.name, filters: normalizeProfileFilters(p.filters), tri: normalizeProfileTri(p.filters?.tri) })); } function getProfileSnapshot() { return { ...state.filters, tri: Array.from(state.tri.entries()) }; } // Overlay-only, so it isn't part of lobby-filters.js's matchAllFilters — // applied as an extra pass alongside it instead. function matchesMapFilter(g, maps) { if (!maps || maps.length === 0) return true; return maps.includes(g.map); } function matchesCurrentFilters(g) { const profiles = getActiveProfileDefinitions(); if (profiles.length === 0) { return LF.matchAllFilters(g, state.filters, state.tri) && matchesMapFilter(g, state.filters.maps); } return profiles.some( (profile) => LF.matchAllFilters(g, profile.filters, profile.tri) && matchesMapFilter(g, profile.filters.maps), ); } // --- "New match" alerts — ported from index.html's own app.js so both // surfaces behave the same way: a sound, a flashing toggle, a blinking // page title, and (with permission) a desktop notification whenever a // lobby that matches the current filters/profiles appears that wasn't // there a moment ago. Hosted lobbies are excluded, same as app.js — those // are player-created listings, not matchmaking games, so a host // rehosting isn't a "new match" worth alerting on. --- function getMatchingGamesFlat() { const flattened = []; for (const key of Object.keys(state.games)) { for (const raw of state.games[key] || []) flattened.push(LF.normalizeGame(raw, key)); } return flattened.filter((g) => g.rawType !== "hosted" && matchesCurrentFilters(g)); } // Changes whenever the active filters/profiles do, so a filter edit's own // newly-matching lobbies reset the baseline instead of alerting on all of // them at once. function getAlertFilterKey() { const triEntries = Array.from(state.tri.entries()).sort(([a], [b]) => a.localeCompare(b)); const activeProfiles = Array.from(state.activeProfiles).sort(); return JSON.stringify({ activeProfiles, filters: state.filters, tri: triEntries }); } function resetAlertBaseline() { state.alertPrevIds = new Set(getMatchingGamesFlat().map((g) => String(g.id))); state.alertBaselineReady = true; state.alertLastFilterKey = getAlertFilterKey(); } function updateAlertToggleUI() { const btn = document.getElementById("ofov-alertToggle"); if (!btn) return; btn.textContent = state.alertsEnabled ? "Alerts: On" : "Alerts: Off"; btn.classList.toggle("ofov-alertOn", state.alertsEnabled); } function stopTitleBlink() { if (state.titleBlinkTimer) clearInterval(state.titleBlinkTimer); if (state.titleBlinkStopTimer) clearTimeout(state.titleBlinkStopTimer); state.titleBlinkTimer = null; state.titleBlinkStopTimer = null; document.title = state.originalTitle; } function startTitleBlink(count) { stopTitleBlink(); let visible = false; state.titleBlinkTimer = setInterval(() => { visible = !visible; document.title = visible ? `(${count}) New OpenFront match!` : state.originalTitle; }, 800); state.titleBlinkStopTimer = setTimeout(stopTitleBlink, 10_000); } // A plain two-tone beep via Web Audio — no audio asset to ship in a // userscript, and this is exactly what app.js already uses. function playAlertSound() { if (!state.alertsEnabled) return; try { const AudioCtx = window.AudioContext || window.webkitAudioContext; if (!AudioCtx) return; const ctx = state.audioCtx || new AudioCtx(); state.audioCtx = ctx; if (ctx.state === "suspended") ctx.resume().catch(() => {}); const now = ctx.currentTime; const osc = ctx.createOscillator(); const gain = ctx.createGain(); osc.type = "sine"; osc.frequency.setValueAtTime(880, now); osc.frequency.setValueAtTime(660, now + 0.13); gain.gain.setValueAtTime(0.0001, now); gain.gain.exponentialRampToValueAtTime(0.18, now + 0.02); gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.35); osc.connect(gain); gain.connect(ctx.destination); osc.start(now); osc.stop(now + 0.38); } catch {} } function flashAlertToggle() { const btn = document.getElementById("ofov-alertToggle"); if (!btn) return; btn.classList.remove("ofov-alertFlash"); void btn.offsetWidth; btn.classList.add("ofov-alertFlash"); setTimeout(() => btn.classList.remove("ofov-alertFlash"), 4500); } // No join-by-URL path exists here (joining goes through the in-page // modal/event, not a navigable link) — clicking the notification just // brings the tab forward instead of also attempting to join. function showDesktopNotification(newGames) { if (!state.alertsEnabled) return; if (!("Notification" in window) || Notification.permission !== "granted") return; const first = newGames[0]; const cfg = first.cfg || {}; const maxPlayers = typeof cfg.maxPlayers === "number" ? `/${cfg.maxPlayers}` : ""; const body = newGames.length === 1 ? `${cfg.gameMap ?? "Unknown map"} · ${first.joined ?? 0}${maxPlayers} players` : `${newGames.length} new lobbies match your filters`; try { const n = new Notification("OpenFront lobby match", { body, tag: "ofov-lobby-match", renotify: true }); n.onclick = () => { window.focus(); n.close(); }; } catch {} } async function setAlertsEnabled(enabled) { state.alertsEnabled = enabled; try { localStorage.setItem(ALERTS_STORAGE_KEY, String(enabled)); } catch {} updateAlertToggleUI(); if (enabled) { resetAlertBaseline(); try { state.audioCtx = state.audioCtx || new (window.AudioContext || window.webkitAudioContext)(); if (state.audioCtx.state === "suspended") await state.audioCtx.resume(); } catch {} if ("Notification" in window && Notification.permission === "default") { try { await Notification.requestPermission(); } catch {} } } else { stopTitleBlink(); } } // Called once per "full" snapshot (see connect()) — comparing against the // last snapshot rather than every render() call, which also fires for // pure filter/UI edits the tri-state guard below already treats as a // baseline reset rather than a source of new matches. function handleNewMatchingGames() { const filterKey = getAlertFilterKey(); const matching = getMatchingGamesFlat(); const currentIds = new Set(matching.map((g) => String(g.id))); if (!state.alertsEnabled) { state.alertPrevIds = currentIds; state.alertBaselineReady = true; state.alertLastFilterKey = filterKey; return; } if (!state.alertBaselineReady || state.alertLastFilterKey !== filterKey) { state.alertPrevIds = currentIds; state.alertBaselineReady = true; state.alertLastFilterKey = filterKey; return; } const newGames = matching.filter((g) => !state.alertPrevIds.has(String(g.id))); state.alertPrevIds = currentIds; state.alertLastFilterKey = filterKey; if (newGames.length === 0) return; flashAlertToggle(); playAlertSound(); startTitleBlink(newGames.length); showDesktopNotification(newGames); } // The full map list, same source lobby-wire.js's decoder uses (kept in // sync by the repo's resync automation) — every map the game knows about, // not just ones with a lobby open right now. Static, so unlike the profile // select this never needs refreshing after the panel is first built. function getKnownMaps() { return (window.OpenFrontWire?.GAME_MAP || []).slice().sort(); } // Filters/sorts across all four server buckets together (a "Teams" type // filter, say, should empty the FFA/Special/Custom columns too, not just // hide within each), then re-buckets survivors back into their original // column so render() can keep drawing four columns. function getVisibleGames() { const flattened = []; for (const key of Object.keys(state.games)) { for (const raw of state.games[key] || []) { flattened.push(LF.normalizeGame(raw, key)); } } const matching = flattened.filter((g) => matchesCurrentFilters(g)); const sorted = LF.sortGames(matching, state.filters.sort); const buckets = { ffa: [], team: [], special: [], hosted: [] }; for (const g of sorted) { if (buckets[g.rawType]) buckets[g.rawType].push(g.raw); } return buckets; } function triButtonGroup(id) { const current = LF.normalizeModifierRule(state.tri.get(id)); const makeButton = (value, text, title) => ``; return `
${makeButton(LF.TRI.OR, "OR", "Match if at least one selected OR modifier is present")} ${makeButton(LF.TRI.AND, "AND", "Require every selected AND modifier")} ${makeButton(LF.TRI.NOT, "NOT", "Exclude lobbies containing this modifier")}
`; } function modifierGroupsHtml(filters) { return filters.map((f) => `
${escapeHtml(f.label)}
${triButtonGroup(f.id)}
`).join(""); } // --- Compact multi-select dropdown: a trigger button showing a summary, // and a popover list of checkable rows — click a row to toggle it, no // ctrl/cmd needed. Mirrors index.html's own custom-select widget so the // interaction feels the same in both places, without pulling in its // DOM-specific implementation. --- function summarizeMultiSelect(options, selected, emptyLabel) { if (selected.size === 0) return emptyLabel; if (selected.size <= 2) { return options.filter((o) => selected.has(o.value)).map((o) => o.label).join(", "); } return `${selected.size} selected`; } function multiSelectItemsHtml(options, selected) { return options .map( (o) => ``, ) .join(""); } function multiSelectDropdownHtml(id, options, selected, emptyLabel) { return `
`; } function getMultiSelectChecked(container) { return Array.from(container.querySelectorAll('.ofov-msList input[type="checkbox"]:checked')).map((c) => c.value); } // Wires one dropdown's open/close and change behavior. onChange receives // the newly-checked values; the caller decides what that means (a plain // filter field vs. the profile-activation flow, which needs more than // just recording the selection). function wireMultiSelectDropdown(panel, id, { onChange, emptyLabel = "All" } = {}) { const container = panel.querySelector(`#${id}`); if (!container) return; const trigger = container.querySelector(".ofov-msTrigger"); const list = container.querySelector(".ofov-msList"); trigger.addEventListener("click", (e) => { e.stopPropagation(); const willOpen = list.hidden; // Only one dropdown open at a time within this panel. panel.querySelectorAll(".ofov-msList").forEach((l) => { if (l !== list) l.hidden = true; }); panel.querySelectorAll(".ofov-msTrigger").forEach((t) => { if (t !== trigger) t.setAttribute("aria-expanded", "false"); }); list.hidden = !willOpen; trigger.setAttribute("aria-expanded", String(willOpen)); }); list.addEventListener("change", () => { const options = Array.from(list.querySelectorAll("label")).map((lbl) => ({ value: lbl.querySelector("input").value, label: lbl.querySelector("span")?.textContent ?? "", })); const selected = new Set(getMultiSelectChecked(container)); trigger.textContent = summarizeMultiSelect(options, selected, emptyLabel); onChange?.(Array.from(selected)); }); } function buildFiltersPanelHtml() { const f = state.filters; const sortOptionsHtml = SORT_OPTIONS.map( (o) => ``, ).join(""); const teamsDropdownHtml = multiSelectDropdownHtml( "ofov-f-teams", TEAM_FILTER_OPTIONS.filter((o) => o.value !== "any"), new Set(f.teamFilters), "All", ); const mapsDropdownHtml = multiSelectDropdownHtml( "ofov-f-maps", getKnownMaps().map((m) => ({ value: m, label: m })), new Set(f.maps || []), "All maps", ); return `
Lobby
${teamsDropdownHtml}
${mapsDropdownHtml}
Players
Modifier & Custom Lobby filters
Modifier logic
${modifierGroupsHtml([...LF.boolFilters, ...LF.numExact])}
Custom Lobby
${modifierGroupsHtml(LF.privateBoolFilters)}
Profiles
${multiSelectDropdownHtml("ofov-f-profileSelect", [], state.activeProfiles, "Manual filters")}
`; } function readFiltersFromForm(panel) { const val = (id) => panel.querySelector(`#${id}`)?.value; const teamsContainer = panel.querySelector("#ofov-f-teams"); const teamFilters = teamsContainer ? getMultiSelectChecked(teamsContainer) : []; const mapsContainer = panel.querySelector("#ofov-f-maps"); const maps = mapsContainer ? getMultiSelectChecked(mapsContainer) : []; return { type: val("ofov-f-type") || "all", hideEmpty: val("ofov-f-hideEmpty") === "yes", teamFilters, maps, sort: val("ofov-f-sort") || "starts_asc", minJoined: LF.parseNum(val("ofov-f-minJoined")), maxJoined: LF.parseNum(val("ofov-f-maxJoined")), maxPlayersEq: LF.parseNum(val("ofov-f-maxPlayersEq")), minMaxPlayers: LF.parseNum(val("ofov-f-minMaxPlayers")), maxMaxPlayers: LF.parseNum(val("ofov-f-maxMaxPlayers")), minPerTeam: LF.parseNum(val("ofov-f-minPerTeam")), maxPerTeam: LF.parseNum(val("ofov-f-maxPerTeam")), }; } // Editing anything by hand exits "profile mode" (matches index.html) — // otherwise a tweak would look ignored, since active profiles still OR in // their own saved values on top of whatever you just typed. function leaveProfileModeOnManualChange(panel) { if (state.applyingProfile || state.activeProfiles.size === 0) return; state.activeProfiles.clear(); state.profileSelectionOrder = []; saveActiveProfileNames(); const container = panel.querySelector("#ofov-f-profileSelect"); if (container) { container.querySelectorAll('.ofov-msList input[type="checkbox"]').forEach((cb) => { cb.checked = false; }); const trigger = container.querySelector(".ofov-msTrigger"); if (trigger) trigger.textContent = "Manual filters"; } } function refreshProfileSelect(panel) { const container = panel.querySelector("#ofov-f-profileSelect"); if (!container) return; const list = container.querySelector(".ofov-msList"); const trigger = container.querySelector(".ofov-msTrigger"); const profiles = getStoredProfiles().filter((p) => p?.name).sort((a, b) => String(a.name).localeCompare(String(b.name))); const validNames = new Set(profiles.map((p) => p.name)); state.activeProfiles = new Set(Array.from(state.activeProfiles).filter((n) => validNames.has(n))); state.profileSelectionOrder = state.profileSelectionOrder.filter((n) => state.activeProfiles.has(n)); const options = profiles.map((p) => ({ value: p.name, label: p.name })); list.innerHTML = multiSelectItemsHtml(options, state.activeProfiles); trigger.textContent = summarizeMultiSelect(options, state.activeProfiles, "Manual filters"); saveActiveProfileNames(); } // Rebuilds the whole panel from state.filters/tri, which wipes whatever the // fresh markup doesn't already know — profileName included — so a loaded // profile's name is passed through explicitly rather than set on the // about-to-be-replaced input beforehand. function applyProfileSnapshot(rawFilters, panel, profileName) { if (!rawFilters) return; state.applyingProfile = true; try { state.filters = normalizeProfileFilters(rawFilters); state.tri = normalizeProfileTri(rawFilters.tri); panel.innerHTML = buildFiltersPanelHtml(); wireFiltersPanel(panel); refreshProfileSelect(panel); const nameInput = panel.querySelector("#ofov-f-profileName"); if (nameInput && profileName) nameInput.value = profileName; } finally { state.applyingProfile = false; } // Not resetAlertBaseline() here — initProfilesAndFilters() also calls // this, at page load, before any lobby snapshot has arrived; resetting // there would mark the (empty) baseline "ready" early, and the first // real snapshot would then read every already-matching lobby as newly // matched. The genuine user-driven call site (the profile dropdown's // onChange, below) resets it itself instead. render(estimatedServerTime()); } function saveCurrentProfile(panel) { const nameInput = panel.querySelector("#ofov-f-profileName"); const name = nameInput?.value.trim(); if (!name) { alert("Name the profile first."); return; } const profiles = getStoredProfiles().filter((p) => p?.name !== name); profiles.push({ name, filters: getProfileSnapshot(), updatedAt: Date.now() }); saveStoredProfiles(profiles); state.activeProfiles = new Set([name]); state.profileSelectionOrder = [name]; refreshProfileSelect(panel); resetAlertBaseline(); render(estimatedServerTime()); } function deleteSelectedProfile(panel) { const nameInput = panel.querySelector("#ofov-f-profileName"); const typedName = nameInput?.value.trim(); const names = typedName ? [typedName] : Array.from(state.activeProfiles); if (names.length === 0) { alert("Select a saved profile first."); return; } const remove = new Set(names); const profiles = getStoredProfiles(); const next = profiles.filter((p) => !remove.has(p?.name)); if (next.length === profiles.length) { alert("That profile does not exist."); return; } saveStoredProfiles(next); for (const n of remove) state.activeProfiles.delete(n); state.profileSelectionOrder = state.profileSelectionOrder.filter((n) => !remove.has(n)); if (nameInput) nameInput.value = ""; refreshProfileSelect(panel); resetAlertBaseline(); render(estimatedServerTime()); } function wireFiltersPanel(panel) { const onManualFilterChange = () => { leaveProfileModeOnManualChange(panel); state.filters = readFiltersFromForm(panel); resetAlertBaseline(); render(estimatedServerTime()); }; panel.querySelectorAll("select, input[type=number]").forEach((el) => { el.addEventListener("change", onManualFilterChange); if (el.tagName === "INPUT") el.addEventListener("input", onManualFilterChange); }); wireMultiSelectDropdown(panel, "ofov-f-teams", { onChange: onManualFilterChange, emptyLabel: "All" }); wireMultiSelectDropdown(panel, "ofov-f-maps", { onChange: onManualFilterChange, emptyLabel: "All maps" }); wireMultiSelectDropdown(panel, "ofov-f-profileSelect", { emptyLabel: "Manual filters", onChange: (selected) => { state.activeProfiles = new Set(selected); state.profileSelectionOrder = selected; saveActiveProfileNames(); const lastName = selected[selected.length - 1]; const lastProfile = lastName ? getStoredProfiles().find((p) => p?.name === lastName) : null; if (lastProfile) { applyProfileSnapshot(lastProfile.filters, panel, lastProfile.name); resetAlertBaseline(); } else { resetAlertBaseline(); render(estimatedServerTime()); } }, }); panel.querySelectorAll("[data-tri][data-val]").forEach((btn) => { btn.addEventListener("click", () => { leaveProfileModeOnManualChange(panel); const id = btn.getAttribute("data-tri"); const value = btn.getAttribute("data-val"); const current = LF.normalizeModifierRule(state.tri.get(id)); const next = current === value ? LF.TRI.NONE : value; state.tri.set(id, next); panel.querySelectorAll(`[data-tri="${CSS.escape(id)}"]`).forEach((b) => { b.classList.toggle("active", next !== LF.TRI.NONE && b.getAttribute("data-val") === next); }); resetAlertBaseline(); render(estimatedServerTime()); }); }); panel.querySelector("#ofov-f-reset")?.addEventListener("click", () => { state.filters = freshFilters(); state.tri = LF.initTriState(); state.activeProfiles.clear(); state.profileSelectionOrder = []; saveActiveProfileNames(); panel.innerHTML = buildFiltersPanelHtml(); wireFiltersPanel(panel); refreshProfileSelect(panel); resetAlertBaseline(); render(estimatedServerTime()); }); panel.querySelector("#ofov-f-profileSave")?.addEventListener("click", () => saveCurrentProfile(panel)); panel.querySelector("#ofov-f-profileDelete")?.addEventListener("click", () => deleteSelectedProfile(panel)); } function initProfilesAndFilters(panel) { state.profileSelectionOrder = loadActiveProfileNames(); state.activeProfiles = new Set(state.profileSelectionOrder); refreshProfileSelect(panel); const lastName = state.profileSelectionOrder.at(-1); const lastProfile = lastName ? getStoredProfiles().find((p) => p?.name === lastName) : null; if (lastProfile) { applyProfileSnapshot(lastProfile.filters, panel, lastProfile.name); } } // Shows the fade only when there's actually more below the visible area — // a column that fits without scrolling gets no fade at all. function updateColumnFade(colCardsEl) { const col = colCardsEl.closest(".ofov-col"); if (!col) return; const hasMore = colCardsEl.scrollHeight - colCardsEl.clientHeight - colCardsEl.scrollTop > 2; col.classList.toggle("ofov-hasMoreBelow", hasMore); } function nodeFromHtml(html) { const t = document.createElement("template"); t.innerHTML = html.trim(); return t.content.firstElementChild; } // Identifies everything a card shows besides the live-ticking // count/countdown (those patch separately via patchCounts/tickTimers). A // hosted lobby can keep the same game ID across a rehost while its // map/modifiers change entirely — this fingerprint is how reconcileColumn // notices that and rebuilds the card instead of leaving stale content // behind on a reused node. function cardFingerprint(g) { const cfg = g.gameConfig || {}; return [ g.gameID, cfg.gameMap, cfg.maxPlayers, cfg.playerTeams, g.label, g.accent, g.featured, JSON.stringify(cfg.publicGameModifiers || {}), cfg.rankedType, cfg.nations, ].join(":"); } // Build the four empty column shells once — reused for the lifetime of // the page rather than torn down and rebuilt (see reconcileColumn for why). function ensureGridColumns(root) { if (root.querySelector(".ofov-col")) return; root.innerHTML = CATEGORIES.map( ({ key, label, dot }) => `
${escapeHtml(label.toUpperCase())} 0
`, ).join(""); } // Reconciles one column's cards against the desired game list, keyed by // id, instead of replacing .ofov-colCards' innerHTML wholesale — a full // rebuild on every "full" snapshot (arriving every few seconds) recreated // this element each time, which reset its scrollTop to 0 (scrolled-down // users got yanked back to the top) and re-decoded every card's image, // which is what read as stutter. Reusing the node for a game that's still // here — and only rebuilding it if cardFingerprint says its content // actually changed — keeps the container itself, and most cards, fully // untouched across a snapshot that didn't really change anything. function reconcileColumn(colCardsEl, games, serverTime, source) { const existing = new Map(); colCardsEl.querySelectorAll(":scope > .ofov-card[data-game-id]").forEach((n) => existing.set(n.dataset.gameId, n)); if (games.length === 0) { for (const node of existing.values()) node.remove(); if (!colCardsEl.querySelector(":scope > .ofov-colEmpty")) { colCardsEl.innerHTML = `
No open lobbies
`; } return; } colCardsEl.querySelector(":scope > .ofov-colEmpty")?.remove(); const frag = document.createDocumentFragment(); for (const g of games) { const id = String(g.gameID); const fp = cardFingerprint(g); let node = existing.get(id); if (node) existing.delete(id); if (!node || node.dataset.ofovFp !== fp) { // One malformed lobby (or a modifier-labels.js @require that failed // to load) shouldn't take down every column — skip just that card. try { node = nodeFromHtml(cardHtml(g, serverTime, source)); node.dataset.ofovFp = fp; } catch (e) { console.error("[of-overlay] failed to render lobby card", g?.gameID, e); continue; } } frag.appendChild(node); } for (const node of existing.values()) node.remove(); colCardsEl.appendChild(frag); } function render(serverTime) { const root = document.getElementById("ofov-grid"); if (!root) return; ensureGridColumns(root); // FLIP (First-Last-Invert-Play): a card that shifts position — most // commonly the one below a lobby that just ended sliding up to take its // place — jumps from doing this instantly to visibly animating into its // new spot. Read every surviving card's rect first, in one pass, before // reconciling — writing a transform on one card would otherwise // invalidate layout for the next card's rect read, forcing a separate // synchronous reflow per moved card instead of sharing one. const firstRects = new Map(); root.querySelectorAll(".ofov-card[data-game-id]").forEach((card) => { firstRects.set(card.dataset.gameId, card.getBoundingClientRect()); }); const visible = getVisibleGames(); for (const { key, source } of CATEGORIES) { const list = visible[key] || []; const colCardsEl = root.querySelector(`.ofov-colCards[data-col-cards="${key}"]`); const countEl = root.querySelector(`[data-col-count="${key}"]`); if (countEl) countEl.textContent = String(list.length); if (colCardsEl) reconcileColumn(colCardsEl, list, serverTime, source); } root.querySelectorAll(".ofov-colCards").forEach(updateColumnFade); state.timeEls.clear(); state.metaEls.clear(); const moves = []; root.querySelectorAll(".ofov-card[data-game-id]").forEach((card) => { const gameId = card.dataset.gameId; const timeEl = card.querySelector(".ofov-time"); if (timeEl) state.timeEls.set(gameId, timeEl); const metaEl = card.querySelector(".ofov-meta span"); if (metaEl) state.metaEls.set(gameId, metaEl); const first = firstRects.get(gameId); if (!first) return; const last = card.getBoundingClientRect(); const dx = first.left - last.left; const dy = first.top - last.top; if (Math.abs(dx) < 1 && Math.abs(dy) < 1) return; moves.push({ card, dx, dy }); }); if (moves.length) { for (const { card, dx, dy } of moves) { card.style.transition = "none"; card.style.transform = `translate(${dx}px, ${dy}px)`; } // One shared reflow for the whole batch of moved cards, so the browser // registers every starting position before the transition below is // applied — otherwise each pair of style writes above/below could // coalesce into one frame and the cards would just snap with no // visible motion. void root.offsetHeight; for (const { card } of moves) { card.style.transition = "transform 220ms ease"; card.style.transform = ""; card.addEventListener( "transitionend", () => { card.style.transition = ""; }, { once: true }, ); } } } // A hosted lobby that dips below the player minimum needed to keep its // countdown running reports startsAt: null for a beat, then a real value // again once someone rejoins — without smoothing that out, the card reads // as "Open" and back on every such blip, and since sort_asc sorts by // startsAt, it visibly swaps places in its column each time too. Holding // the last real value for a short grace window absorbs the blip without // hiding a countdown that's actually been cancelled for good — past the // grace window it's treated as genuinely Open again. const STARTS_AT_STICKY_MS = 8_000; const startsAtSticky = new Map(); // gameID -> { startsAt, seenAt } function stabilizeStartsAt(games) { const now = Date.now(); for (const g of games) { const id = String(g.gameID); if (g.startsAt) { startsAtSticky.set(id, { startsAt: g.startsAt, seenAt: now }); continue; } const sticky = startsAtSticky.get(id); if (sticky && now - sticky.seenAt < STARTS_AT_STICKY_MS) { g.startsAt = sticky.startsAt; } else { startsAtSticky.delete(id); } } } function reindex() { state.byId.clear(); for (const key of Object.keys(state.games)) { for (const g of state.games[key]) state.byId.set(String(g.gameID), g); } // A game that's left every category entirely (ended, or the host closed // it) has nothing left to smooth for — without this the map would keep // one stale entry per lobby that ever counted down, forever. for (const id of startsAtSticky.keys()) { if (!state.byId.has(id)) startsAtSticky.delete(id); } } // Patch player-count text in place rather than re-rendering the grid — // counts frames arrive often, and rebuilding on every one destroyed // whatever card the mouse was resting on (retriggering its hover transition // on every update, which looked like pulsating). function patchCounts(updatedIds) { for (const id of updatedIds) { const g = state.byId.get(id); const metaSpan = state.metaEls.get(id); if (!g || !metaSpan) continue; const maxPlayers = g.gameConfig?.maxPlayers; metaSpan.textContent = `${g.numClients ?? 0}${maxPlayers != null ? "/" + maxPlayers : ""} 👥`; } } // Text-only patch, same reasoning as patchCounts above. Passing `now` // through even when estimatedServerTime() has gone stale (undefined) is // deliberate — timeText() already falls back to "Open" in that case, so // countdowns don't freeze at their last value once the socket's been // quiet past STALE_AFTER_MS. function tickTimers() { const now = estimatedServerTime(); for (const [gameId, timeEl] of state.timeEls) { const lobby = state.byId.get(gameId); if (lobby) timeEl.textContent = timeText(lobby, now); } } // Exponential backoff (capped, with jitter) for reconnects — a flat // fixed-interval retry means every connected client hammers the lobby // server on the same cadence for as long as it's down. const RECONNECT_BASE_MS = 1000; const RECONNECT_MAX_MS = 30_000; let reconnectDelayMs = RECONNECT_BASE_MS; async function connect() { const { host, numWorkers } = await resolveServer(); const worker = `w${Math.floor(Math.random() * numWorkers)}`; const ws = new WebSocket(`wss://${host}/${worker}/lobbies`); ws.binaryType = "arraybuffer"; ws.onopen = () => { reconnectDelayMs = RECONNECT_BASE_MS; }; ws.onmessage = (ev) => { let msg; try { msg = window.OpenFrontWire.decodeLobbyMessage(ev.data); } catch (e) { console.error("[of-overlay] decode failed", e); return; } if (msg.type === "full") { state.games = { ffa: msg.games?.ffa || [], team: msg.games?.team || [], special: msg.games?.special || [], hosted: msg.games?.hosted || [], }; state.serverTime = msg.serverTime; state.serverTimeCapturedAt = Date.now(); for (const key of Object.keys(state.games)) stabilizeStartsAt(state.games[key]); reindex(); render(state.serverTime); handleNewMatchingGames(); return; } if (msg.type === "counts") { const updatedIds = []; for (const [id, count] of Object.entries(msg.counts || {})) { const g = state.byId.get(String(id)); if (g) { g.numClients = count; updatedIds.push(String(id)); } } patchCounts(updatedIds); } }; ws.onclose = () => { const delay = reconnectDelayMs; reconnectDelayMs = Math.min(reconnectDelayMs * 2, RECONNECT_MAX_MS); setTimeout(connect, delay + Math.random() * delay * 0.3); }; ws.onerror = () => { try { ws.close(); } catch {} }; } function injectStyle() { const style = document.createElement("style"); style.textContent = ` game-mode-selector div[class*="sm:grid-cols-[2fr_1fr]"] { display: none !important; } /* Hides the native SOLO/CREATE/RANKED/JOIN row (replaced below) via CSS rather than touching game-mode-selector's own Lit-managed DOM. */ game-mode-selector div[class*="h-14"] { display: none !important; } /* Identity row (flag + username + skin, in 's render()) — shrunk, not hidden: still fully usable, just claiming less of the vertical space above the lobby grid. Targets the exact Tailwind arbitrary-value classes from PlayPage.ts (min-h-[60px]/max-h-[52px]/ h-[50px]) the same substring-match way the native grid is hidden above, since none of these elements have their own id/data-hook. Also has sm:flex-1 in its own class list, and its flex-column parent gets stretched tall by the top-strip grid's items-stretch (to match Streaming Now's height) — flex-grow fills that freed space right back up regardless of min-height/max-height, which is why capping only the height left it looking unchanged. flex:0 0 auto stops it from growing to fill that space at all. */ div[class*="sm:min-h-[60px]"] { min-height: 60px !important; max-height: 72px !important; flex: 0 0 auto !important; } flag-input[class*="max-h-[52px]"], cosmetics-input[class*="max-h-[52px]"] { max-height: 56px !important; } username-input[class*="sm:h-[50px]"] { height: 56px !important; } /* Streaming Now is now just a small hover-icon (see below), not a real column of content, so the 2fr/1fr split PlayPage.ts's grid switches to whenever a stream is live wastes a third of the row on it. Give the icon just enough column to fit itself (auto) and let the identity bar's column (1fr) take the rest, both centered on the row's cross axis — items-stretch was the source of the original "everything stretches to match" bug, so replace it outright rather than leaving it for the icon to fight too. The attribute selector matches on the literal Tailwind class text, which is present whether or not anyone is live — :has(.streaming-live) is what actually restricts this to the live case, matching the native rule it's overriding; without it, nobody streaming still forced the two-column split against an empty second column, throwing off the identity bar's alignment. */ div[class*="lg:has-[.streaming-live]:grid-cols-[2fr_1fr]"]:has(.streaming-live) { grid-template-columns: 1fr auto !important; align-items: center !important; } /* Streaming Now — collapsed to a small square icon sitting in that auto column beside the identity bar (a normal in-flow grid item now, not fixed/absolute — that's what actually keeps it visually anchored next to the bar instead of floating in a page corner unrelated to it). Hovering (or focusing into it via keyboard) reveals streaming-now's real rendered panel — untouched, not reimplemented — as a popover that grows out of the icon in place, the same hide-native/reveal-on-demand pattern used for news-box. */ streaming-now.streaming-live { flex: 0 0 auto !important; align-self: center !important; position: relative !important; z-index: 90000 !important; width: 46px !important; min-width: 46px !important; height: 46px !important; min-height: 0 !important; max-height: none !important; overflow: visible !important; cursor: pointer !important; } streaming-now.streaming-live::before { content: "🔴"; position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-size: 1.3rem; animation: ofovStreamPulse 1.6s ease-in-out infinite; pointer-events: none; } @keyframes ofovStreamPulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.45; } } /* Targets the real content div by its own distinctive class fragment (sm:justify-center, from StreamingNow.ts's render()) rather than "> div" — a positional child selector breaks the moment that render() gains a sibling (it already renders a