diff --git a/index.css b/index.css index 9917565..c83549b 100644 --- a/index.css +++ b/index.css @@ -47,6 +47,46 @@ body { flex-direction: column; } +/* Debug HUD (body.debug): below main overlay, still visible during body.loading */ +#debugHud { + display: none; + position: absolute; + left: 0; + bottom: 0; + z-index: 6; + max-width: min(100%, 48rem); + padding: 0.35rem 0.55rem; + font-size: 1.15rem; + font-weight: 600; + line-height: 1.3; + white-space: normal; + background: rgba(0, 0, 0, 0.62); + border-radius: 0.35rem; + pointer-events: none; +} + +body.debug #debugHud { + display: block; +} + +#debugHud .debugHud-title { + font-size: 1.25rem; + font-weight: 700; + margin-bottom: 0.15rem; + opacity: 0.95; +} + +#debugHud .debugHud-k { + opacity: 0.72; + margin-right: 0.25rem; +} + +#debugHud code { + font-family: ui-monospace, "Cascadia Code", monospace; + font-size: 0.92em; + word-break: break-all; +} + #songOverlay { flex: 1; display: flex; @@ -349,8 +389,8 @@ span:empty { } .friend-avatar { - width: 32px; - height: 32px; + width: 1.5rem; + height: 1.5rem; border-radius: 50%; object-fit: cover; flex-shrink: 0; @@ -416,7 +456,3 @@ body.bottom #time { body:not(.debug) #mockBsrSetting { display: none; } - -body:not(.debug) #debugPlayerSetting { - display: none; -} diff --git a/index.html b/index.html index c8be2f6..1deb0ca 100644 --- a/index.html +++ b/index.html @@ -44,6 +44,20 @@
No map loaded
+
+
Debug
+
level_id
+
hash (level_id)
+
hash (BeatLeader)
+
BS+ BSRKey
+
BeatSaver id
+
BeatSaver
+
char / diff
+
BS+ playerPlatformId
+
BeatLeader id (effective)
+
BeatLeader leaderboard ids
+
friend scores
+
Song requests
    @@ -71,6 +85,7 @@ + -
    About This was forked from Iza's overlay diff --git a/index.js b/index.js index 649cc19..38b7cf4 100644 --- a/index.js +++ b/index.js @@ -26,10 +26,36 @@ async function fetchBeatSaverMapById(mapId) { } } +// src/client/overlay-server-log.ts +function mirrorOverlayLog(scope, phase, detail) { + if (typeof location === "undefined") return; + if (location.protocol !== "http:" && location.protocol !== "https:") return; + const body = JSON.stringify({ + scope, + phase, + detail + }); + void fetch("/api/overlay-log", { + method: "POST", + headers: { + "Content-Type": "application/json; charset=utf-8" + }, + body, + keepalive: true + }).catch(() => { + }); +} + // src/client/beatleader.ts var BASE_URL2 = "https://api.beatleader.com"; var PAGE_SIZE = 100; +var MAX_LEADERBOARD_SCORE_PAGES = 2e3; var USE_RUNTIME_PROXY2 = typeof document !== "undefined"; +function blDiag(phase, detail) { + if (!USE_RUNTIME_PROXY2) return; + console.log(`[BS+ overlay] beatleader:${phase}`, detail); + mirrorOverlayLog("beatleader", phase, detail); +} function beatleaderUrl(path) { if (USE_RUNTIME_PROXY2) { return `/api/beatleader?path=${encodeURIComponent(path)}`; @@ -37,60 +63,168 @@ function beatleaderUrl(path) { return `${BASE_URL2}${path}`; } async function fetchBLLeaderboardsByHash(hash) { + const path = `/leaderboards/hash/${encodeURIComponent(hash)}`; + blDiag("leaderboardsByHash", { + path, + hash + }); try { - const res = await fetch(beatleaderUrl(`/leaderboards/hash/${encodeURIComponent(hash)}`)); - if (!res.ok) return []; + const res = await fetch(beatleaderUrl(path)); + if (!res.ok) { + blDiag("leaderboardsByHash", { + path, + hash, + reason: "http-not-ok", + status: res.status, + statusText: res.statusText + }); + return []; + } const data = await res.json(); const leaderboards = Array.isArray(data) ? data : Array.isArray(data.leaderboards) ? data.leaderboards : []; + blDiag("leaderboardsByHash", { + path, + hash, + count: leaderboards.length + }); return leaderboards; - } catch { + } catch (err) { + blDiag("leaderboardsByHash", { + path, + hash, + reason: "fetch-error", + error: String(err) + }); return []; } } async function resolveBeatLeaderPlayerId(playerId) { + const path = `/player/${encodeURIComponent(playerId)}`; + blDiag("resolvePlayer", { + path, + playerId + }); try { - const res = await fetch(beatleaderUrl(`/player/${encodeURIComponent(playerId)}`)); - if (!res.ok) return playerId; + const res = await fetch(beatleaderUrl(path)); + if (!res.ok) { + blDiag("resolvePlayer", { + path, + playerId, + reason: "http-not-ok", + status: res.status, + usingId: playerId + }); + return playerId; + } const data = await res.json(); const canonicalId = data.id; - return canonicalId == null ? playerId : String(canonicalId); - } catch { + const out = canonicalId == null ? playerId : String(canonicalId); + blDiag("resolvePlayer", { + path, + playerId, + canonicalId: out, + changed: out !== playerId + }); + return out; + } catch (err) { + blDiag("resolvePlayer", { + path, + playerId, + reason: "fetch-error", + error: String(err), + usingId: playerId + }); return playerId; } } -async function fetchLeaderboardScoresById(leaderboardId, maxPages = 20) { +async function fetchLeaderboardScoresById(leaderboardId, maxPages = MAX_LEADERBOARD_SCORE_PAGES) { const scores = []; - for (let page = 1; page <= maxPages; page += 1) { + const pageSize = PAGE_SIZE; + let page = 1; + let hitPageCap = false; + for (; ; ) { + if (page > maxPages) { + hitPageCap = true; + break; + } const qs = new URLSearchParams({ leaderboardContext: "general", page: String(page), sortBy: "rank", - order: "desc" + order: "desc", + count: String(pageSize) }); - const url = beatleaderUrl(`/leaderboard/${encodeURIComponent(leaderboardId)}?${qs}`); + const path = `/leaderboard/${encodeURIComponent(leaderboardId)}?${qs}`; + const url = beatleaderUrl(path); let res; try { res = await fetch(url); - } catch { + } catch (err) { + blDiag("leaderboardScores", { + leaderboardId, + page, + path, + reason: "fetch-error", + error: String(err) + }); + break; + } + if (!res.ok) { + blDiag("leaderboardScores", { + leaderboardId, + page, + path, + reason: "http-not-ok", + status: res.status, + statusText: res.statusText + }); break; } - if (!res.ok) break; const payload = await res.json(); const batch = Array.isArray(payload.scores) ? payload.scores : []; + if (page === 1) { + blDiag("leaderboardScores", { + leaderboardId, + page, + path, + firstPageBatchSize: batch.length, + pageSize + }); + } if (batch.length === 0) break; scores.push(...batch); - if (batch.length < PAGE_SIZE) break; + if (batch.length < pageSize) break; + page += 1; } + blDiag("leaderboardScoresTotal", { + leaderboardId, + totalScores: scores.length, + ...hitPageCap ? { + warning: "hit-max-pages-cap", + maxPages + } : {} + }); return scores; } -async function fetchAllMapScoresByHash(hash, leaderboards, maxPagesPerLeaderboard = 20) { +async function fetchAllMapScoresByHash(hash, leaderboards, maxPagesPerLeaderboard = MAX_LEADERBOARD_SCORE_PAGES) { + const ids = leaderboards.map((lb) => lb.id == null ? "" : String(lb.id)).filter(Boolean); + blDiag("fetchAllMapScoresByHash", { + hash, + leaderboardCount: leaderboards.length, + leaderboardIds: ids + }); const requests = leaderboards.map((lb) => { const leaderboardId = lb.id == null ? null : String(lb.id); if (!leaderboardId) return Promise.resolve([]); return fetchLeaderboardScoresById(leaderboardId, maxPagesPerLeaderboard); }); const batches = await Promise.all(requests); - return batches.flat(); + const flat = batches.flat(); + blDiag("fetchAllMapScoresByHash", { + hash, + totalScores: flat.length + }); + return flat; } async function fetchFollowersPage(playerId, type2, page, count) { const qs = new URLSearchParams({ @@ -98,13 +232,43 @@ async function fetchFollowersPage(playerId, type2, page, count) { page: String(page), count: String(count) }); - const url = beatleaderUrl(`/player/${encodeURIComponent(playerId)}/followers?${qs}`); + const path = `/player/${encodeURIComponent(playerId)}/followers?${qs}`; + const url = beatleaderUrl(path); try { const response = await fetch(url); - if (!response.ok) return []; + if (!response.ok) { + blDiag("followersPage", { + playerId, + type: type2, + page, + path, + reason: "http-not-ok", + status: response.status, + statusText: response.statusText + }); + return []; + } const data = await response.json(); - return Array.isArray(data) ? data : []; - } catch { + const rows = Array.isArray(data) ? data : []; + if (page === 1) { + blDiag("followersPage", { + playerId, + type: type2, + page, + path, + count: rows.length + }); + } + return rows; + } catch (err) { + blDiag("followersPage", { + playerId, + type: type2, + page, + path, + reason: "fetch-error", + error: String(err) + }); return []; } } @@ -126,18 +290,46 @@ function normalizeFollowerEntry(entry) { } async function fetchFriends(playerId, mode, maxPages = 100) { const canonicalPlayerId = await resolveBeatLeaderPlayerId(playerId); + blDiag("fetchFriendsStart", { + playerId, + canonicalPlayerId, + mode, + maxPages + }); const [following, followers] = await Promise.all([ fetchAllFollowers(canonicalPlayerId, "Following", maxPages), fetchAllFollowers(canonicalPlayerId, "Followers", maxPages) ]); + blDiag("fetchFriendsLists", { + canonicalPlayerId, + mode, + followingCount: following.length, + followersCount: followers.length + }); const followingIds = new Set(following.map((entry) => String(entry.id))); if (mode === "following") { - return following.map((entry) => normalizeFollowerEntry(entry)); + const out2 = following.map((entry) => normalizeFollowerEntry(entry)); + blDiag("fetchFriendsResult", { + mode, + count: out2.length + }); + return out2; } if (mode === "followers") { - return followers.map((entry) => normalizeFollowerEntry(entry)); + const out2 = followers.map((entry) => normalizeFollowerEntry(entry)); + blDiag("fetchFriendsResult", { + mode, + count: out2.length + }); + return out2; } - return followers.filter((entry) => followingIds.has(String(entry.id))).map((entry) => normalizeFollowerEntry(entry)); + const out = followers.filter((entry) => followingIds.has(String(entry.id))).map((entry) => normalizeFollowerEntry(entry)); + blDiag("fetchFriendsResult", { + mode: "mutual", + count: out.length, + reasonIfEmpty: out.length === 0 ? following.length === 0 || followers.length === 0 ? "missing-following-or-followers-list" : "no-intersection" : void 0 + }); + return out; } function normalizeAccuracy(value) { if (typeof value !== "number" || !Number.isFinite(value)) return null; @@ -153,6 +345,42 @@ function must(id) { function parseJson(raw) { return JSON.parse(raw); } +var settings = { + cover: true, + mapInfo: true, + time: true, + score: true, + friends: true, + friendMode: "mutual", + bsr: false, + debug: false, + mockBsr: "4f4e4", + debugPlayerId: "76561199407393962", + right: false, + bottom: true, + scale: 1, + fade: 300 +}; +var defaults = structuredClone(settings); +var style = document.createElement("style"); +function loadSettings() { + const params = new URLSearchParams(location.hash.slice(1)); + let css = ""; + for (const [key, def] of Object.entries(defaults)) { + const value = parseJson(params.get(key) || "null") ?? def; + settings[key] = value; + if (typeof def === "boolean") document.body.classList.toggle(key, Boolean(value)); + else css += `--${key}: ${value}; `; + } + style.textContent = `:root { ${css}}`; +} +function saveSettings() { + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(settings)) { + if (value !== defaults[key]) params.set(key, JSON.stringify(value)); + } + location.replace(`#${params.toString()}`); +} var beatSaberPlus = { // https://github.com/hardcpp/BeatSaberPlus/wiki/%5BEN%5D-Song-Overlay url: "ws://localhost:2947/socket", @@ -180,6 +408,10 @@ var beatSaberPlus = { break; case "handshake": currentPlayerPlatformId = data.playerPlatformId || ""; + console.log("[BS+ overlay] BS+ handshake", { + playerPlatformId: currentPlayerPlatformId || "(empty)" + }); + updateDebugHud(); void refreshMapFriendScores(); break; default: @@ -192,6 +424,68 @@ var provider = beatSaberPlus; var retryMs = 1e4; var retries = 0; var currentPlayerPlatformId = ""; +function getEffectivePlayerId() { + const configured = settings.debugPlayerId.trim(); + const raw = configured || currentPlayerPlatformId; + if (!raw) return ""; + const steamIdCandidate = raw.match(/\d{17,20}/)?.[0]; + if (steamIdCandidate) return steamIdCandidate; + if (/^\d+$/.test(raw)) return raw; + if (configured) return raw; + return ""; +} +var currentMapHash = ""; +var friendsRelationCacheKey = ""; +var friendsRelationCache = null; +var friendScoreRequestId = 0; +var mapInfoRequestId = 0; +var lastMapLevelId = ""; +var lastBsPlusBsrKey = ""; +var lastCharDiffStr = ""; +var lastBeatSaverIdDisplay = "\u2014"; +var lastBeatSaverNote = "\u2014"; +var lastFriendScoresDebug = "\u2014"; +var rawLevelHash = ""; +var lastBeatLeaderLeaderboardIds = "\u2014"; +function formatErr(err) { + return err instanceof Error ? err.message : String(err); +} +var debugHud = { + levelId: must("debugHudLevelId"), + rawHash: must("debugHudRawHash"), + hash: must("debugHudHash"), + bsPlusBsr: must("debugHudBsPlusBsr"), + beatSaverId: must("debugHudBeatSaverId"), + beatSaverNote: must("debugHudBeatSaverNote"), + charDiff: must("debugHudCharDiff"), + handshake: must("debugHudHandshake"), + blId: must("debugHudBlId"), + blLeaderboards: must("debugHudBlLeaderboards"), + friends: must("debugHudFriends") +}; +function updateDebugHud() { + if (!settings.debug) return; + debugHud.levelId.textContent = lastMapLevelId || "\u2014"; + debugHud.rawHash.textContent = rawLevelHash || "\u2014"; + debugHud.hash.textContent = currentMapHash || "\u2014"; + debugHud.bsPlusBsr.textContent = lastBsPlusBsrKey || "\u2014"; + debugHud.beatSaverId.textContent = lastBeatSaverIdDisplay; + debugHud.beatSaverNote.textContent = lastBeatSaverNote; + debugHud.charDiff.textContent = lastCharDiffStr || "\u2014"; + debugHud.handshake.textContent = currentPlayerPlatformId || "\u2014"; + debugHud.blId.textContent = getEffectivePlayerId() || "\u2014"; + debugHud.blLeaderboards.textContent = lastBeatLeaderLeaderboardIds; + debugHud.friends.textContent = lastFriendScoresDebug; +} +function beatLeaderboardId(lb) { + const id = lb.id ?? lb.leaderboardId; + return id == null ? "" : String(id); +} +function resolvedHashFromBeatSaverMap(map, fallback) { + const v = map.versions?.[0]?.hash; + if (typeof v === "string" && v.length > 0) return v.toLowerCase().trim(); + return fallback; +} function connect() { console.log(`Connecting to ${provider.url} (attempt ${retries++})`); const ws = new WebSocket(provider.url); @@ -221,9 +515,31 @@ var bsrKey = must("bsrKey"); var timeMultiplier = 1; var duration = 0; async function updateMapInfo(data) { + const reqId = ++mapInfoRequestId; const custom = data.level_id.startsWith("custom_level_"); const wip = custom && data.level_id.endsWith("WIP"); - currentMapHash = custom ? data.level_id.substring(13, 53).toLowerCase() : ""; + rawLevelHash = custom ? data.level_id.substring(13, 53).toLowerCase() : ""; + currentMapHash = rawLevelHash; + lastMapLevelId = data.level_id; + lastBsPlusBsrKey = data.BSRKey || ""; + lastCharDiffStr = `${data.characteristic} / ${data.difficulty}`; + lastBeatLeaderLeaderboardIds = "\u2014"; + console.log("[BS+ overlay] map: new song", { + level_id: data.level_id, + hashLevelId: rawLevelHash || "(none)", + custom, + wip, + bsPlusBsrKey: data.BSRKey || "(empty, not used for APIs)", + characteristic: data.characteristic, + difficulty: data.difficulty + }); + if (settings.debug) { + console.log("[BS+ overlay] map: detail", { + requestId: reqId, + name: data.name, + artist: data.artist + }); + } cover.src = data.coverRaw ? `data:image/jpeg;base64,${data.coverRaw}` : "images/unknown.svg"; title.textContent = data.name || ""; subTitle.textContent = data.sub_name || ""; @@ -233,25 +549,68 @@ async function updateMapInfo(data) { characteristic.src = `images/characteristic/${data.characteristic}.svg`; difficultyLabel.textContent = ""; type.textContent = !custom ? "OST" : wip ? "WIP" : ""; - bsrKey.textContent = data.BSRKey || "???"; + bsrKey.textContent = custom && !wip ? "\u2026" : custom ? rawLevelHash || "???" : "???"; timeMultiplier = data.timeMultiplier || 1; duration = data.duration / 1e3; - void refreshMapFriendScores(); + lastBeatSaverIdDisplay = "\u2014"; + lastBeatSaverNote = custom && !wip ? "loading\u2026" : wip ? "WIP (no BeatSaver)" : !custom ? "OST (no hash)" : "\u2014"; + updateDebugHud(); if (custom && !wip) { document.body.classList.add("loading"); try { - const map = await fetchBeatSaverMeta(currentMapHash); - if (!map?.id) return; - bsrKey.textContent = map.id; - mapper.textContent = map.metadata?.levelAuthorName || ""; - const diff = map.versions?.[0]?.diffs?.find((d) => d.characteristic === data.characteristic && d.difficulty === data.difficulty); - if (diff?.label) difficultyLabel.textContent = diff.label; + console.log("[BS+ overlay] map: BeatSaver lookup by hash (from level_id)", rawLevelHash); + const map = await fetchBeatSaverMeta(rawLevelHash); + if (reqId !== mapInfoRequestId) return; + if (!map?.id) { + lastBeatSaverIdDisplay = "\u2014"; + lastBeatSaverNote = "BeatSaver: no map (check hash / proxy)"; + currentMapHash = rawLevelHash; + console.warn("[BS+ overlay] map: BeatSaver miss \u2014 BeatLeader will use level_id hash", { + hashLevelId: rawLevelHash + }); + } else { + lastBeatSaverIdDisplay = map.id; + lastBeatSaverNote = "ok"; + const resolved = resolvedHashFromBeatSaverMap(map, rawLevelHash); + if (resolved !== rawLevelHash) { + console.log("[BS+ overlay] map: using BeatSaver version hash for BeatLeader", { + hashLevelId: rawLevelHash, + hashBeatLeader: resolved, + beatSaverId: map.id + }); + } + currentMapHash = resolved; + console.log("[BS+ overlay] map: BeatSaver ok", { + beatSaverId: map.id, + hashBeatLeader: currentMapHash + }); + bsrKey.textContent = map.id; + mapper.textContent = map.metadata?.levelAuthorName || ""; + const diff = map.versions?.[0]?.diffs?.find((d) => d.characteristic === data.characteristic && d.difficulty === data.difficulty); + if (diff?.label) difficultyLabel.textContent = diff.label; + } + } catch (err) { + if (reqId !== mapInfoRequestId) return; + lastBeatSaverIdDisplay = "\u2014"; + lastBeatSaverNote = `error: ${formatErr(err)}`; + currentMapHash = rawLevelHash; + console.error("[BS+ overlay] map: BeatSaver fetch failed \u2014 BeatLeader will use level_id hash", err); } finally { document.body.classList.remove("loading"); + if (reqId === mapInfoRequestId) { + updateDebugHud(); + void refreshMapFriendScores(); + } } } else { - bsrKey.textContent = "???"; + if (custom && wip) { + bsrKey.textContent = rawLevelHash || "???"; + } else { + bsrKey.textContent = "???"; + } difficultyLabel.textContent = ""; + updateDebugHud(); + void refreshMapFriendScores(); } } var timeText = must("timeText"); @@ -284,8 +643,6 @@ var friendScoresList = must("friendScoresList"); var friendScoresEmpty = must("friendScoresEmpty"); var friendScoresHeaderText = must("friendScoresHeaderText"); var friendScoresHeaderImg = must("friendScoresHeaderImg"); -var currentMapHash = ""; -var friendScoreRequestId = 0; function updateScore(score) { if (!settings.score) return; accuracy.textContent = (score.accuracy * 100).toFixed(1); @@ -332,31 +689,99 @@ function renderFriendScores(items) { friendScoresList.appendChild(li); } } +function friendsDiag(message, detail = {}) { + if (!settings.friends) return; + console.log(`[BS+ overlay] friends:${message}`, detail); + mirrorOverlayLog("friends", message, detail); +} +function friendsRelationListKey(playerId) { + return `${playerId}\0${settings.friendMode}`; +} async function refreshMapFriendScores() { const hash = currentMapHash; if (!settings.friends) { + lastFriendScoresDebug = "off"; + lastBeatLeaderLeaderboardIds = "\u2014"; + updateDebugHud(); clearFriendScores("Disabled in settings"); return; } if (!hash) { + friendsDiag("skip", { + reason: "no-map-hash", + hint: "Need custom map level_id hash or resolved BeatSaver hash" + }); + lastFriendScoresDebug = "no hash"; + lastBeatLeaderLeaderboardIds = "\u2014"; + updateDebugHud(); clearFriendScores("No map loaded"); return; } const playerId = getEffectivePlayerId(); if (!playerId) { + friendsDiag("skip", { + reason: "no-player-id", + hint: "Wait for BS+ handshake (playerPlatformId) or set debug BeatLeader id in settings" + }); + lastFriendScoresDebug = "no BeatLeader player id"; + lastBeatLeaderLeaderboardIds = "\u2014"; + updateDebugHud(); clearFriendScores("Waiting for BeatLeader player id"); return; } friendScoresPanel.classList.add("is-loading"); friendScoresEmpty.textContent = "Loading mutual friend scores..."; + lastFriendScoresDebug = "loading\u2026"; + updateDebugHud(); const requestId = ++friendScoreRequestId; + friendsDiag("start", { + requestId, + hash, + playerId, + friendMode: settings.friendMode, + debugPlayerOverride: Boolean(settings.debug && settings.debugPlayerId.trim()) + }); try { + const relKey = friendsRelationListKey(playerId); + const friendsPromise = (async () => { + if (friendsRelationCache !== null && relKey === friendsRelationCacheKey) { + friendsDiag("friends-list-cache", { + hit: true, + friendCount: friendsRelationCache.length + }); + return friendsRelationCache; + } + const fetched = await fetchFriends(playerId, settings.friendMode); + friendsRelationCacheKey = relKey; + friendsRelationCache = fetched; + friendsDiag("friends-list-cache", { + hit: false, + friendCount: fetched.length + }); + return fetched; + })(); const [leaderboards, friends] = await Promise.all([ fetchBLLeaderboardsByHash(hash), - fetchFriends(playerId, settings.friendMode) + friendsPromise ]); if (requestId !== friendScoreRequestId) return; + const leaderboardIds = leaderboards.map(beatLeaderboardId).filter(Boolean); + lastBeatLeaderLeaderboardIds = leaderboardIds.length ? leaderboardIds.join(", ") : "none"; + updateDebugHud(); + friendsDiag("parallel-fetch-done", { + hash, + leaderboardCount: leaderboards.length, + leaderboardIds, + friendCount: friends.length + }); if (leaderboards.length === 0) { + friendsDiag("empty-ui", { + reason: "no-beatleader-leaderboards-for-hash", + hash, + hint: "BeatLeader has no leaderboards for this map hash (wrong hash, unranked, or API/proxy error \u2014 see beatleader:leaderboardsByHash logs)" + }); + lastFriendScoresDebug = "0 leaderboards for hash"; + updateDebugHud(); clearFriendScores("No BeatLeader leaderboards found"); return; } @@ -366,12 +791,25 @@ async function refreshMapFriendScores() { ])); const mutualFriendIds = new Set(friends.map((f) => f.id)); if (mutualFriendIds.size === 0) { + friendsDiag("empty-ui", { + reason: "no-friends-for-mode", + friendMode: settings.friendMode, + hash, + hint: "Following/followers lists empty or mutual mode has no intersection \u2014 see beatleader:fetchFriendsResult" + }); + lastFriendScoresDebug = `0 friends (${settings.friendMode})`; + updateDebugHud(); const relationLabel = settings.friendMode === "following" ? "No followed BeatLeader players" : settings.friendMode === "followers" ? "No BeatLeader followers" : "No mutual BeatLeader followers"; clearFriendScores(relationLabel); return; } const scores = await fetchAllMapScoresByHash(hash, leaderboards); if (requestId !== friendScoreRequestId) return; + friendsDiag("scores-aggregated", { + hash, + rawScoreRows: scores.length, + friendIdsInRelation: mutualFriendIds.size + }); const bestByPlayer = /* @__PURE__ */ new Map(); for (const score of scores) { const scorePlayerId = score.playerId ?? (typeof score.player === "object" ? score.player?.id : null); @@ -393,55 +831,57 @@ async function refreshMapFriendScores() { } } const sorted = Array.from(bestByPlayer.values()).sort((a, b) => b.acc - a.acc); + if (sorted.length === 0 && scores.length > 0) { + friendsDiag("empty-ui", { + reason: "no-friend-scores-on-leaderboards", + hash, + rawScoreRows: scores.length, + friendIdsInRelation: mutualFriendIds.size, + hint: "Leaderboard scores exist but none match friend ids (playerId on scores vs BeatLeader friend ids)" + }); + } else if (sorted.length === 0 && scores.length === 0) { + friendsDiag("empty-ui", { + reason: "no-scores-on-map-leaderboards", + hash, + leaderboardIds, + hint: "No ranked rows returned for these leaderboards \u2014 see beatleader:leaderboardScores logs" + }); + } else { + friendsDiag("done", { + rows: sorted.length, + hash + }); + } + lastFriendScoresDebug = `${leaderboards.length} LB, ${friends.length} friends, ${scores.length} scores \u2192 ${sorted.length} rows`; + updateDebugHud(); renderFriendScores(sorted); - } catch { + } catch (err) { if (requestId !== friendScoreRequestId) return; + friendsDiag("error", { + message: formatErr(err), + hash, + playerId + }); + lastFriendScoresDebug = `error: ${formatErr(err)}`; + lastBeatLeaderLeaderboardIds = "\u2014"; + updateDebugHud(); clearFriendScores("Failed loading BeatLeader scores"); } } -var settings = { - cover: true, - mapInfo: true, - time: true, - score: true, - friends: true, - friendMode: "mutual", - bsr: false, - debug: false, - mockBsr: "4f4e4", - debugPlayerId: "76561199407393962", - right: false, - bottom: true, - scale: 1, - fade: 300 -}; -var defaults = structuredClone(settings); -var style = document.createElement("style"); -function loadSettings() { - const params = new URLSearchParams(location.hash.slice(1)); - let css = ""; - for (const [key, def] of Object.entries(defaults)) { - const value = parseJson(params.get(key) || "null") ?? def; - settings[key] = value; - if (typeof def === "boolean") document.body.classList.toggle(key, Boolean(value)); - else css += `--${key}: ${value}; `; - } - style.textContent = `:root { ${css}}`; -} -function saveSettings() { - const params = new URLSearchParams(); - for (const [key, value] of Object.entries(settings)) { - if (value !== defaults[key]) params.set(key, JSON.stringify(value)); - } - location.replace(`#${params.toString()}`); -} async function applyMockMapFromBsr() { const key = settings.mockBsr.trim(); if (!settings.debug || !key) return; const map = await fetchBeatSaverMapById(key); - if (!map) return; + if (!map) { + console.warn("[BS+ overlay] map: mock BSR lookup returned nothing", { + key + }); + return; + } const hash = map.versions?.[0]?.hash?.toLowerCase?.() || ""; + rawLevelHash = hash; currentMapHash = hash; + lastBeatLeaderLeaderboardIds = "\u2014"; title.textContent = map.metadata?.songName || map.name || title.textContent || ""; subTitle.textContent = map.metadata?.songSubName || ""; artist.textContent = map.metadata?.songAuthorName || ""; @@ -450,20 +890,23 @@ async function applyMockMapFromBsr() { type.textContent = "MOCK"; const coverUrl = map.versions?.[0]?.coverURL; if (coverUrl) cover.src = coverUrl; + lastMapLevelId = `mock:${key}`; + lastBsPlusBsrKey = ""; + lastCharDiffStr = ""; + lastBeatSaverIdDisplay = map.id || "\u2014"; + lastBeatSaverNote = "mock BSR"; + updateDebugHud(); + console.log("[BS+ overlay] map: mock from BSR key", { + key, + hash: currentMapHash, + mapId: map.id + }); void refreshMapFriendScores(); } -function getEffectivePlayerId() { - const raw = settings.debug && settings.debugPlayerId.trim() ? settings.debugPlayerId.trim() : currentPlayerPlatformId; - if (!raw) return ""; - const steamIdCandidate = raw.match(/\d{17,20}/)?.[0]; - if (steamIdCandidate) return steamIdCandidate; - if (/^\d+$/.test(raw)) return raw; - if (settings.debug) return raw; - return ""; -} window.onhashchange = loadSettings; loadSettings(); document.head.appendChild(style); +updateDebugHud(); for (const key of [ "cover", "mapInfo", @@ -480,6 +923,7 @@ for (const key of [ saveSettings(); if (key === "friends") void refreshMapFriendScores(); if (key === "debug") { + updateDebugHud(); void loadRequestQueue(); void applyMockMapFromBsr(); void refreshMapFriendScores(); @@ -500,12 +944,12 @@ mockBsrInput.oninput = () => { saveSettings(); void applyMockMapFromBsr(); }; -var debugPlayerInput = must("debugPlayerInput"); -debugPlayerInput.value = settings.debugPlayerId; -debugPlayerInput.oninput = () => { - settings.debugPlayerId = debugPlayerInput.value.trim(); +var beatLeaderPlayerInput = must("beatLeaderPlayerInput"); +beatLeaderPlayerInput.value = settings.debugPlayerId; +beatLeaderPlayerInput.oninput = () => { + settings.debugPlayerId = beatLeaderPlayerInput.value.trim(); saveSettings(); - if (settings.debug) void refreshMapFriendScores(); + void refreshMapFriendScores(); }; void applyMockMapFromBsr(); var scale = must("scaleInput"); diff --git a/src/client/beatleader.ts b/src/client/beatleader.ts index aaf8dc2..1fccf0f 100644 --- a/src/client/beatleader.ts +++ b/src/client/beatleader.ts @@ -5,12 +5,26 @@ import type { BeatLeaderScore, BeatLeaderScoresResponse, } from "./types.ts"; +import { mirrorOverlayLog } from "./overlay-server-log.ts"; const BASE_URL = "https://api.beatleader.com"; const PAGE_SIZE = 100; +/** + * `/leaderboard/{id}` uses `page` + `count` like v5 scores. Without `count`, the API default page + * size is small, so `batch.length < PAGE_SIZE` stopped pagination after the first page. + * `MAX_LEADERBOARD_SCORE_PAGES` bounds total requests for pathological maps. + */ +const MAX_LEADERBOARD_SCORE_PAGES = 2000; const USE_RUNTIME_PROXY = typeof document !== "undefined"; export type FriendMode = "mutual" | "following" | "followers"; +/** Browser overlay only: BeatLeader request/result tracing (Deno tests use no `document`). */ +function blDiag(phase: string, detail: Record) { + if (!USE_RUNTIME_PROXY) return; + console.log(`[BS+ overlay] beatleader:${phase}`, detail); + mirrorOverlayLog("beatleader", phase, detail); +} + function beatleaderUrl(path: string): string { if (USE_RUNTIME_PROXY) { return `/api/beatleader?path=${encodeURIComponent(path)}`; @@ -23,29 +37,44 @@ interface BeatLeaderPlayerLookup { } export async function fetchBLLeaderboardsByHash(hash: string): Promise { + const path = `/leaderboards/hash/${encodeURIComponent(hash)}`; + blDiag("leaderboardsByHash", { path, hash }); try { - const res = await fetch(beatleaderUrl(`/leaderboards/hash/${encodeURIComponent(hash)}`)); - if (!res.ok) return []; + const res = await fetch(beatleaderUrl(path)); + if (!res.ok) { + blDiag("leaderboardsByHash", { path, hash, reason: "http-not-ok", status: res.status, statusText: res.statusText }); + return []; + } const data = await res.json() as BeatLeaderLeaderboardsByHashResponse | BeatLeaderLeaderboard[]; const leaderboards = Array.isArray(data) ? data : Array.isArray(data.leaderboards) ? data.leaderboards : []; + blDiag("leaderboardsByHash", { path, hash, count: leaderboards.length }); return leaderboards; - } catch { + } catch (err) { + blDiag("leaderboardsByHash", { path, hash, reason: "fetch-error", error: String(err) }); return []; } } async function resolveBeatLeaderPlayerId(playerId: string): Promise { + const path = `/player/${encodeURIComponent(playerId)}`; + blDiag("resolvePlayer", { path, playerId }); try { - const res = await fetch(beatleaderUrl(`/player/${encodeURIComponent(playerId)}`)); - if (!res.ok) return playerId; + const res = await fetch(beatleaderUrl(path)); + if (!res.ok) { + blDiag("resolvePlayer", { path, playerId, reason: "http-not-ok", status: res.status, usingId: playerId }); + return playerId; + } const data = await res.json() as BeatLeaderPlayerLookup; const canonicalId = data.id; - return canonicalId == null ? playerId : String(canonicalId); - } catch { + const out = canonicalId == null ? playerId : String(canonicalId); + blDiag("resolvePlayer", { path, playerId, canonicalId: out, changed: out !== playerId }); + return out; + } catch (err) { + blDiag("resolvePlayer", { path, playerId, reason: "fetch-error", error: String(err), usingId: playerId }); return playerId; } } @@ -84,45 +113,78 @@ interface BeatLeaderLeaderboardScoresResponse { async function fetchLeaderboardScoresById( leaderboardId: string, - maxPages = 20, + maxPages = MAX_LEADERBOARD_SCORE_PAGES, ): Promise { const scores: BeatLeaderScore[] = []; - for (let page = 1; page <= maxPages; page += 1) { + const pageSize = PAGE_SIZE; + let page = 1; + let hitPageCap = false; + for (;;) { + if (page > maxPages) { + hitPageCap = true; + break; + } const qs = new URLSearchParams({ leaderboardContext: "general", page: String(page), sortBy: "rank", order: "desc", + count: String(pageSize), }); - const url = beatleaderUrl(`/leaderboard/${encodeURIComponent(leaderboardId)}?${qs}`); + const path = `/leaderboard/${encodeURIComponent(leaderboardId)}?${qs}`; + const url = beatleaderUrl(path); let res: Response; try { res = await fetch(url); - } catch { + } catch (err) { + blDiag("leaderboardScores", { leaderboardId, page, path, reason: "fetch-error", error: String(err) }); + break; + } + if (!res.ok) { + blDiag("leaderboardScores", { + leaderboardId, + page, + path, + reason: "http-not-ok", + status: res.status, + statusText: res.statusText, + }); break; } - if (!res.ok) break; const payload = await res.json() as BeatLeaderLeaderboardScoresResponse; const batch = Array.isArray(payload.scores) ? payload.scores : []; + if (page === 1) { + blDiag("leaderboardScores", { leaderboardId, page, path, firstPageBatchSize: batch.length, pageSize }); + } if (batch.length === 0) break; scores.push(...batch); - if (batch.length < PAGE_SIZE) break; + if (batch.length < pageSize) break; + page += 1; } + blDiag("leaderboardScoresTotal", { + leaderboardId, + totalScores: scores.length, + ...(hitPageCap ? { warning: "hit-max-pages-cap", maxPages } : {}), + }); return scores; } export async function fetchAllMapScoresByHash( hash: string, leaderboards: BeatLeaderLeaderboard[], - maxPagesPerLeaderboard = 20, + maxPagesPerLeaderboard = MAX_LEADERBOARD_SCORE_PAGES, ): Promise { + const ids = leaderboards.map((lb) => (lb.id == null ? "" : String(lb.id))).filter(Boolean); + blDiag("fetchAllMapScoresByHash", { hash, leaderboardCount: leaderboards.length, leaderboardIds: ids }); const requests = leaderboards.map((lb) => { const leaderboardId = lb.id == null ? null : String(lb.id); if (!leaderboardId) return Promise.resolve([]); return fetchLeaderboardScoresById(leaderboardId, maxPagesPerLeaderboard); }); const batches = await Promise.all(requests); - return batches.flat(); + const flat = batches.flat(); + blDiag("fetchAllMapScoresByHash", { hash, totalScores: flat.length }); + return flat; } async function fetchFollowersPage( @@ -136,13 +198,30 @@ async function fetchFollowersPage( page: String(page), count: String(count), }); - const url = beatleaderUrl(`/player/${encodeURIComponent(playerId)}/followers?${qs}`); + const path = `/player/${encodeURIComponent(playerId)}/followers?${qs}`; + const url = beatleaderUrl(path); try { const response = await fetch(url); - if (!response.ok) return []; + if (!response.ok) { + blDiag("followersPage", { + playerId, + type, + page, + path, + reason: "http-not-ok", + status: response.status, + statusText: response.statusText, + }); + return []; + } const data = await response.json() as BeatLeaderFollower[]; - return Array.isArray(data) ? data : []; - } catch { + const rows = Array.isArray(data) ? data : []; + if (page === 1) { + blDiag("followersPage", { playerId, type, page, path, count: rows.length }); + } + return rows; + } catch (err) { + blDiag("followersPage", { playerId, type, page, path, reason: "fetch-error", error: String(err) }); return []; } } @@ -176,20 +255,41 @@ function normalizeFollowerEntry(entry: BeatLeaderFollower): BeatLeaderFollower { /** Friend list for the given mode, with `avatar` / `name` from BeatLeader follower payloads. */ export async function fetchFriends(playerId: string, mode: FriendMode, maxPages = 100): Promise { const canonicalPlayerId = await resolveBeatLeaderPlayerId(playerId); + blDiag("fetchFriendsStart", { playerId, canonicalPlayerId, mode, maxPages }); const [following, followers] = await Promise.all([ fetchAllFollowers(canonicalPlayerId, "Following", maxPages), fetchAllFollowers(canonicalPlayerId, "Followers", maxPages), ]); + blDiag("fetchFriendsLists", { + canonicalPlayerId, + mode, + followingCount: following.length, + followersCount: followers.length, + }); const followingIds = new Set(following.map((entry) => String(entry.id))); if (mode === "following") { - return following.map((entry) => normalizeFollowerEntry(entry as BeatLeaderFollower)); + const out = following.map((entry) => normalizeFollowerEntry(entry as BeatLeaderFollower)); + blDiag("fetchFriendsResult", { mode, count: out.length }); + return out; } if (mode === "followers") { - return followers.map((entry) => normalizeFollowerEntry(entry as BeatLeaderFollower)); + const out = followers.map((entry) => normalizeFollowerEntry(entry as BeatLeaderFollower)); + blDiag("fetchFriendsResult", { mode, count: out.length }); + return out; } - return followers + const out = followers .filter((entry) => followingIds.has(String(entry.id))) .map((entry) => normalizeFollowerEntry(entry as BeatLeaderFollower)); + blDiag("fetchFriendsResult", { + mode: "mutual", + count: out.length, + reasonIfEmpty: out.length === 0 + ? (following.length === 0 || followers.length === 0 + ? "missing-following-or-followers-list" + : "no-intersection") + : undefined, + }); + return out; } export async function fetchFriendIds(playerId: string, mode: FriendMode, maxPages = 100): Promise> { diff --git a/src/client/index.ts b/src/client/index.ts index 4ef7312..d54eae7 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -1,4 +1,6 @@ import type { + BeatLeaderFollower, + BeatLeaderLeaderboard, BeatLeaderScore, BeatSaberPlusEvent, ChatRequestEntry, @@ -14,6 +16,7 @@ import { type FriendMode, normalizeAccuracy, } from "./beatleader.ts"; +import { mirrorOverlayLog } from "./overlay-server-log.ts"; function must(id: string): T { const element = document.getElementById(id); @@ -25,288 +28,6 @@ function parseJson(raw: string): T { return JSON.parse(raw) as T; } -// WebSocket connection - -const beatSaberPlus = { - // https://github.com/hardcpp/BeatSaberPlus/wiki/%5BEN%5D-Song-Overlay - url: "ws://localhost:2947/socket", - onMessage: (e: MessageEvent) => { - const data = parseJson(e.data); - switch (data._type) { - case "event": - switch (data._event) { - case "gameState": - document.body.dataset.gameState = data.gameStateChanged; - break; - case "mapInfo": - void updateMapInfo(data.mapInfoChanged); - break; - case "pause": - updateTime(data.pauseTime, true); - break; - case "resume": - updateTime(data.resumeTime, false); - break; - case "score": - updateScore(data.scoreEvent); - break; - } - break; - case "handshake": - currentPlayerPlatformId = data.playerPlatformId || ""; - void refreshMapFriendScores(); - break; - default: - console.log("message", e.data); - break; - } - }, -}; - -const provider = beatSaberPlus; -const retryMs = 10000; -let retries = 0; -let currentPlayerPlatformId = ""; - -function connect() { - console.log(`Connecting to ${provider.url} (attempt ${retries++})`); - const ws = new WebSocket(provider.url); - ws.onopen = onOpen; - ws.onmessage = provider.onMessage; - ws.onclose = onClose; -} - -function onOpen() { - console.log("Connection open."); - retries = 0; -} - -function onClose(e: CloseEvent) { - console.log(`Connection closed. code: ${e.code}, reason: ${e.reason}, clean: ${e.wasClean}`); - setTimeout(connect, retryMs); -} - -connect(); - -// Map info - -const cover = must("coverImg"); -const title = must("title"); -const subTitle = must("subTitle"); -const artist = must("artist"); -const mapper = must("mapper"); -const difficulty = must("difficulty"); -const characteristic = must("characteristicImg"); -const difficultyLabel = must("difficultyLabel"); -const type = must("type"); -const bsrKey = must("bsrKey"); -let timeMultiplier = 1; -let duration = 0; - -async function updateMapInfo(data: MapInfo) { - const custom = data.level_id.startsWith("custom_level_"); - const wip = custom && data.level_id.endsWith("WIP"); - currentMapHash = custom ? data.level_id.substring(13, 53).toLowerCase() : ""; - cover.src = data.coverRaw ? `data:image/jpeg;base64,${data.coverRaw}` : "images/unknown.svg"; - title.textContent = data.name || ""; - subTitle.textContent = data.sub_name || ""; - artist.textContent = data.artist || ""; - mapper.textContent = data.mapper || ""; - difficulty.textContent = data.difficulty?.replace("Plus", " +") || ""; - characteristic.src = `images/characteristic/${data.characteristic}.svg`; - difficultyLabel.textContent = ""; // BS+ does not provide label - type.textContent = !custom ? "OST" : wip ? "WIP" : ""; - bsrKey.textContent = data.BSRKey || "???"; // Always empty? - timeMultiplier = data.timeMultiplier || 1; - duration = data.duration / 1000; - void refreshMapFriendScores(); - - // Fetch extra info from BeatSaver - if (custom && !wip) { - document.body.classList.add("loading"); - try { - const map = await fetchBeatSaverMeta(currentMapHash); - if (!map?.id) return; - bsrKey.textContent = map.id; - mapper.textContent = map.metadata?.levelAuthorName || ""; - const diff = map.versions?.[0]?.diffs?.find( - (d) => d.characteristic === data.characteristic && d.difficulty === data.difficulty, - ); - if (diff?.label) difficultyLabel.textContent = diff.label; - } finally { - document.body.classList.remove("loading"); - } - } else { - bsrKey.textContent = "???"; - difficultyLabel.textContent = ""; - } -} - -// Song time - -const timeText = must("timeText"); -const timeBar = must("timeBar"); -const intervalMs = 500; -let intervalId = 0; -let currentTime = 0; - -function updateTime(time: number, paused: boolean) { - if (!settings.time) return; - setTime(time); - clearInterval(intervalId); - if (paused) return; - intervalId = window.setInterval(() => setTime(currentTime + intervalMs * timeMultiplier / 1000), intervalMs); -} - -function setTime(time: number) { - currentTime = time; - timeText.textContent = `${formatTime(currentTime)} / ${formatTime(duration)}`; - timeBar.style.width = `${currentTime / (duration || Infinity) * 100}%`; -} - -function formatTime(t: number) { - t = Math.floor(t); - const minutes = Math.floor(t / 60); - const seconds = t - minutes * 60; - return `${minutes}:${String(seconds).padStart(2, "0")}`; -} - -// Score - -const accuracy = must("accuracy"); -const mistakes = must("mistakes"); -const friendScoresPanel = must("friendScores"); -const friendScoresList = must("friendScoresList"); -const friendScoresEmpty = must("friendScoresEmpty"); -const friendScoresHeaderText = must("friendScoresHeaderText"); -const friendScoresHeaderImg = must("friendScoresHeaderImg"); - -let currentMapHash = ""; -let friendScoreRequestId = 0; - -function updateScore(score: Score) { - if (!settings.score) return; - accuracy.textContent = (score.accuracy * 100).toFixed(1); - mistakes.textContent = score.missCount ? String(score.missCount) : ""; - accuracy.classList.toggle("failed", score.currentHealth === 0); -} - -function avatarFromScore(score: BeatLeaderScore): string | null { - if (typeof score.player === "object" && score.player?.avatar) { - return score.player.avatar; - } - const url = score.playerAvatar?.trim(); - return url || null; -} - -function clearFriendScores(message: string) { - friendScoresList.replaceChildren(); - friendScoresEmpty.textContent = message; - friendScoresHeaderText.textContent = "frenz?"; - friendScoresHeaderImg.src = "assets/notlikesteve.webp"; - friendScoresPanel.classList.remove("has-items", "is-loading"); -} - -function renderFriendScores(items: Array<{ name: string; acc: number; avatar: string | null }>) { - friendScoresList.replaceChildren(); - friendScoresPanel.classList.toggle("has-items", items.length > 0); - friendScoresPanel.classList.remove("is-loading"); - friendScoresEmpty.textContent = items.length ? "" : "No friend scores on this map"; - friendScoresHeaderText.textContent = items.length ? "frenz!" : "frenz?"; - friendScoresHeaderImg.src = items.length ? "assets/peepohigh.webp" : "assets/notlikesteve.webp"; - for (const item of items) { - const li = document.createElement("li"); - li.className = "friend-score-item"; - const avatar = document.createElement("img"); - avatar.className = "friend-avatar"; - avatar.alt = ""; - avatar.decoding = "async"; - avatar.loading = "lazy"; - avatar.src = item.avatar?.trim() || "images/unknown.svg"; - const name = document.createElement("span"); - name.className = "friend-name"; - name.textContent = item.name; - const acc = document.createElement("span"); - acc.className = "friend-acc"; - acc.textContent = `${item.acc.toFixed(2)}%`; - li.append(acc, avatar, name); - friendScoresList.appendChild(li); - } -} - -async function refreshMapFriendScores() { - const hash = currentMapHash; - if (!settings.friends) { - clearFriendScores("Disabled in settings"); - return; - } - if (!hash) { - clearFriendScores("No map loaded"); - return; - } - const playerId = getEffectivePlayerId(); - if (!playerId) { - clearFriendScores("Waiting for BeatLeader player id"); - return; - } - friendScoresPanel.classList.add("is-loading"); - friendScoresEmpty.textContent = "Loading mutual friend scores..."; - const requestId = ++friendScoreRequestId; - try { - const [leaderboards, friends] = await Promise.all([ - fetchBLLeaderboardsByHash(hash), - fetchFriends(playerId, settings.friendMode), - ]); - if (requestId !== friendScoreRequestId) return; - if (leaderboards.length === 0) { - clearFriendScores("No BeatLeader leaderboards found"); - return; - } - const friendById = new Map(friends.map((f) => [f.id, f])); - const mutualFriendIds = new Set(friends.map((f) => f.id)); - if (mutualFriendIds.size === 0) { - const relationLabel = settings.friendMode === "following" - ? "No followed BeatLeader players" - : settings.friendMode === "followers" - ? "No BeatLeader followers" - : "No mutual BeatLeader followers"; - clearFriendScores(relationLabel); - return; - } - const scores = await fetchAllMapScoresByHash(hash, leaderboards); - if (requestId !== friendScoreRequestId) return; - const bestByPlayer = new Map(); - for (const score of scores) { - const scorePlayerId = score.playerId ?? (typeof score.player === "object" ? score.player?.id : null); - const playerKey = scorePlayerId == null ? "" : String(scorePlayerId); - if (!playerKey || !mutualFriendIds.has(playerKey)) continue; - const acc = normalizeAccuracy(score.accuracy ?? score.acc); - if (acc === null) continue; - const existing = bestByPlayer.get(playerKey); - if (!existing || acc > existing.acc) { - const friendMeta = friendById.get(playerKey); - const playerName = - score.playerName || - (typeof score.player === "object" ? score.player?.name : typeof score.player === "string" ? score.player : null); - const fromScore = avatarFromScore(score); - const fromFriend = friendMeta?.avatar?.trim() || null; - bestByPlayer.set(playerKey, { - name: playerName || friendMeta?.name || playerKey, - acc, - avatar: fromScore ?? fromFriend, - }); - } - } - const sorted = Array.from(bestByPlayer.values()).sort((a, b) => b.acc - a.acc); - renderFriendScores(sorted); - } catch { - if (requestId !== friendScoreRequestId) return; - clearFriendScores("Failed loading BeatLeader scores"); - } -} - -// Settings - interface Settings { cover: boolean; mapInfo: boolean; @@ -364,13 +85,528 @@ function saveSettings() { location.replace(`#${params.toString()}`); } +// WebSocket connection + +const beatSaberPlus = { + // https://github.com/hardcpp/BeatSaberPlus/wiki/%5BEN%5D-Song-Overlay + url: "ws://localhost:2947/socket", + onMessage: (e: MessageEvent) => { + const data = parseJson(e.data); + switch (data._type) { + case "event": + switch (data._event) { + case "gameState": + document.body.dataset.gameState = data.gameStateChanged; + break; + case "mapInfo": + void updateMapInfo(data.mapInfoChanged); + break; + case "pause": + updateTime(data.pauseTime, true); + break; + case "resume": + updateTime(data.resumeTime, false); + break; + case "score": + updateScore(data.scoreEvent); + break; + } + break; + case "handshake": + currentPlayerPlatformId = data.playerPlatformId || ""; + console.log("[BS+ overlay] BS+ handshake", { playerPlatformId: currentPlayerPlatformId || "(empty)" }); + updateDebugHud(); + void refreshMapFriendScores(); + break; + default: + console.log("message", e.data); + break; + } + }, +}; + +const provider = beatSaberPlus; +const retryMs = 10000; +let retries = 0; +let currentPlayerPlatformId = ""; + +function getEffectivePlayerId() { + const configured = settings.debugPlayerId.trim(); + const raw = configured || currentPlayerPlatformId; + if (!raw) return ""; + const steamIdCandidate = raw.match(/\d{17,20}/)?.[0]; + if (steamIdCandidate) return steamIdCandidate; + if (/^\d+$/.test(raw)) return raw; + if (configured) return raw; + return ""; +} + +let currentMapHash = ""; +/** Cached BeatLeader following/followers/mutual list; refetch only when player id or friend mode changes. */ +let friendsRelationCacheKey = ""; +let friendsRelationCache: BeatLeaderFollower[] | null = null; +let friendScoreRequestId = 0; +let mapInfoRequestId = 0; +let lastMapLevelId = ""; +let lastBsPlusBsrKey = ""; +let lastCharDiffStr = ""; +let lastBeatSaverIdDisplay = "—"; +let lastBeatSaverNote = "—"; +let lastFriendScoresDebug = "—"; +/** Hex hash from BS+ `level_id` (before BeatSaver version hash). */ +let rawLevelHash = ""; +let lastBeatLeaderLeaderboardIds = "—"; + +function formatErr(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +const debugHud = { + levelId: must("debugHudLevelId"), + rawHash: must("debugHudRawHash"), + hash: must("debugHudHash"), + bsPlusBsr: must("debugHudBsPlusBsr"), + beatSaverId: must("debugHudBeatSaverId"), + beatSaverNote: must("debugHudBeatSaverNote"), + charDiff: must("debugHudCharDiff"), + handshake: must("debugHudHandshake"), + blId: must("debugHudBlId"), + blLeaderboards: must("debugHudBlLeaderboards"), + friends: must("debugHudFriends"), +} as const; + +function updateDebugHud() { + if (!settings.debug) return; + debugHud.levelId.textContent = lastMapLevelId || "—"; + debugHud.rawHash.textContent = rawLevelHash || "—"; + debugHud.hash.textContent = currentMapHash || "—"; + debugHud.bsPlusBsr.textContent = lastBsPlusBsrKey || "—"; + debugHud.beatSaverId.textContent = lastBeatSaverIdDisplay; + debugHud.beatSaverNote.textContent = lastBeatSaverNote; + debugHud.charDiff.textContent = lastCharDiffStr || "—"; + debugHud.handshake.textContent = currentPlayerPlatformId || "—"; + debugHud.blId.textContent = getEffectivePlayerId() || "—"; + debugHud.blLeaderboards.textContent = lastBeatLeaderLeaderboardIds; + debugHud.friends.textContent = lastFriendScoresDebug; +} + +function beatLeaderboardId(lb: BeatLeaderLeaderboard): string { + const id = lb.id ?? lb.leaderboardId; + return id == null ? "" : String(id); +} + +function resolvedHashFromBeatSaverMap(map: NonNullable>>, fallback: string): string { + const v = map.versions?.[0]?.hash; + if (typeof v === "string" && v.length > 0) return v.toLowerCase().trim(); + return fallback; +} + +function connect() { + console.log(`Connecting to ${provider.url} (attempt ${retries++})`); + const ws = new WebSocket(provider.url); + ws.onopen = onOpen; + ws.onmessage = provider.onMessage; + ws.onclose = onClose; +} + +function onOpen() { + console.log("Connection open."); + retries = 0; +} + +function onClose(e: CloseEvent) { + console.log(`Connection closed. code: ${e.code}, reason: ${e.reason}, clean: ${e.wasClean}`); + setTimeout(connect, retryMs); +} + +connect(); + +// Map info + +const cover = must("coverImg"); +const title = must("title"); +const subTitle = must("subTitle"); +const artist = must("artist"); +const mapper = must("mapper"); +const difficulty = must("difficulty"); +const characteristic = must("characteristicImg"); +const difficultyLabel = must("difficultyLabel"); +const type = must("type"); +const bsrKey = must("bsrKey"); +let timeMultiplier = 1; +let duration = 0; + +async function updateMapInfo(data: MapInfo) { + const reqId = ++mapInfoRequestId; + const custom = data.level_id.startsWith("custom_level_"); + const wip = custom && data.level_id.endsWith("WIP"); + rawLevelHash = custom ? data.level_id.substring(13, 53).toLowerCase() : ""; + currentMapHash = rawLevelHash; + lastMapLevelId = data.level_id; + lastBsPlusBsrKey = data.BSRKey || ""; + lastCharDiffStr = `${data.characteristic} / ${data.difficulty}`; + lastBeatLeaderLeaderboardIds = "—"; + + console.log("[BS+ overlay] map: new song", { + level_id: data.level_id, + hashLevelId: rawLevelHash || "(none)", + custom, + wip, + bsPlusBsrKey: data.BSRKey || "(empty, not used for APIs)", + characteristic: data.characteristic, + difficulty: data.difficulty, + }); + if (settings.debug) { + console.log("[BS+ overlay] map: detail", { requestId: reqId, name: data.name, artist: data.artist }); + } + + cover.src = data.coverRaw ? `data:image/jpeg;base64,${data.coverRaw}` : "images/unknown.svg"; + title.textContent = data.name || ""; + subTitle.textContent = data.sub_name || ""; + artist.textContent = data.artist || ""; + mapper.textContent = data.mapper || ""; + difficulty.textContent = data.difficulty?.replace("Plus", " +") || ""; + characteristic.src = `images/characteristic/${data.characteristic}.svg`; + difficultyLabel.textContent = ""; // BS+ does not provide label + type.textContent = !custom ? "OST" : wip ? "WIP" : ""; + // Display: BeatSaver map id when resolved; never rely on BS+ BSRKey for lookups. + bsrKey.textContent = custom && !wip ? "…" : custom ? rawLevelHash || "???" : "???"; + timeMultiplier = data.timeMultiplier || 1; + duration = data.duration / 1000; + + lastBeatSaverIdDisplay = "—"; + lastBeatSaverNote = custom && !wip ? "loading…" : wip ? "WIP (no BeatSaver)" : !custom ? "OST (no hash)" : "—"; + updateDebugHud(); + + // Fetch BeatSaver first for custom maps; BeatLeader uses version hash (or level_id hash fallback), not BS+ BSRKey. + if (custom && !wip) { + document.body.classList.add("loading"); + try { + console.log("[BS+ overlay] map: BeatSaver lookup by hash (from level_id)", rawLevelHash); + const map = await fetchBeatSaverMeta(rawLevelHash); + if (reqId !== mapInfoRequestId) return; + if (!map?.id) { + lastBeatSaverIdDisplay = "—"; + lastBeatSaverNote = "BeatSaver: no map (check hash / proxy)"; + currentMapHash = rawLevelHash; + console.warn("[BS+ overlay] map: BeatSaver miss — BeatLeader will use level_id hash", { + hashLevelId: rawLevelHash, + }); + } else { + lastBeatSaverIdDisplay = map.id; + lastBeatSaverNote = "ok"; + const resolved = resolvedHashFromBeatSaverMap(map, rawLevelHash); + if (resolved !== rawLevelHash) { + console.log("[BS+ overlay] map: using BeatSaver version hash for BeatLeader", { + hashLevelId: rawLevelHash, + hashBeatLeader: resolved, + beatSaverId: map.id, + }); + } + currentMapHash = resolved; + console.log("[BS+ overlay] map: BeatSaver ok", { + beatSaverId: map.id, + hashBeatLeader: currentMapHash, + }); + bsrKey.textContent = map.id; + mapper.textContent = map.metadata?.levelAuthorName || ""; + const diff = map.versions?.[0]?.diffs?.find( + (d) => d.characteristic === data.characteristic && d.difficulty === data.difficulty, + ); + if (diff?.label) difficultyLabel.textContent = diff.label; + } + } catch (err) { + if (reqId !== mapInfoRequestId) return; + lastBeatSaverIdDisplay = "—"; + lastBeatSaverNote = `error: ${formatErr(err)}`; + currentMapHash = rawLevelHash; + console.error("[BS+ overlay] map: BeatSaver fetch failed — BeatLeader will use level_id hash", err); + } finally { + document.body.classList.remove("loading"); + if (reqId === mapInfoRequestId) { + updateDebugHud(); + void refreshMapFriendScores(); + } + } + } else { + if (custom && wip) { + bsrKey.textContent = rawLevelHash || "???"; + } else { + bsrKey.textContent = "???"; + } + difficultyLabel.textContent = ""; + updateDebugHud(); + void refreshMapFriendScores(); + } +} + +// Song time + +const timeText = must("timeText"); +const timeBar = must("timeBar"); +const intervalMs = 500; +let intervalId = 0; +let currentTime = 0; + +function updateTime(time: number, paused: boolean) { + if (!settings.time) return; + setTime(time); + clearInterval(intervalId); + if (paused) return; + intervalId = window.setInterval(() => setTime(currentTime + intervalMs * timeMultiplier / 1000), intervalMs); +} + +function setTime(time: number) { + currentTime = time; + timeText.textContent = `${formatTime(currentTime)} / ${formatTime(duration)}`; + timeBar.style.width = `${currentTime / (duration || Infinity) * 100}%`; +} + +function formatTime(t: number) { + t = Math.floor(t); + const minutes = Math.floor(t / 60); + const seconds = t - minutes * 60; + return `${minutes}:${String(seconds).padStart(2, "0")}`; +} + +// Score + +const accuracy = must("accuracy"); +const mistakes = must("mistakes"); +const friendScoresPanel = must("friendScores"); +const friendScoresList = must("friendScoresList"); +const friendScoresEmpty = must("friendScoresEmpty"); +const friendScoresHeaderText = must("friendScoresHeaderText"); +const friendScoresHeaderImg = must("friendScoresHeaderImg"); + +function updateScore(score: Score) { + if (!settings.score) return; + accuracy.textContent = (score.accuracy * 100).toFixed(1); + mistakes.textContent = score.missCount ? String(score.missCount) : ""; + accuracy.classList.toggle("failed", score.currentHealth === 0); +} + +function avatarFromScore(score: BeatLeaderScore): string | null { + if (typeof score.player === "object" && score.player?.avatar) { + return score.player.avatar; + } + const url = score.playerAvatar?.trim(); + return url || null; +} + +function clearFriendScores(message: string) { + friendScoresList.replaceChildren(); + friendScoresEmpty.textContent = message; + friendScoresHeaderText.textContent = "frenz?"; + friendScoresHeaderImg.src = "assets/notlikesteve.webp"; + friendScoresPanel.classList.remove("has-items", "is-loading"); +} + +function renderFriendScores(items: Array<{ name: string; acc: number; avatar: string | null }>) { + friendScoresList.replaceChildren(); + friendScoresPanel.classList.toggle("has-items", items.length > 0); + friendScoresPanel.classList.remove("is-loading"); + friendScoresEmpty.textContent = items.length ? "" : "No friend scores on this map"; + friendScoresHeaderText.textContent = items.length ? "frenz!" : "frenz?"; + friendScoresHeaderImg.src = items.length ? "assets/peepohigh.webp" : "assets/notlikesteve.webp"; + for (const item of items) { + const li = document.createElement("li"); + li.className = "friend-score-item"; + const avatar = document.createElement("img"); + avatar.className = "friend-avatar"; + avatar.alt = ""; + avatar.decoding = "async"; + avatar.loading = "lazy"; + avatar.src = item.avatar?.trim() || "images/unknown.svg"; + const name = document.createElement("span"); + name.className = "friend-name"; + name.textContent = item.name; + const acc = document.createElement("span"); + acc.className = "friend-acc"; + acc.textContent = `${item.acc.toFixed(2)}%`; + li.append(acc, avatar, name); + friendScoresList.appendChild(li); + } +} + +/** Friend-score flow: always log to the browser/OBS console when the friends panel is enabled (not gated on debug). */ +function friendsDiag(message: string, detail: Record = {}) { + if (!settings.friends) return; + console.log(`[BS+ overlay] friends:${message}`, detail); + mirrorOverlayLog("friends", message, detail); +} + +function friendsRelationListKey(playerId: string): string { + return `${playerId}\0${settings.friendMode}`; +} + +async function refreshMapFriendScores() { + const hash = currentMapHash; + if (!settings.friends) { + lastFriendScoresDebug = "off"; + lastBeatLeaderLeaderboardIds = "—"; + updateDebugHud(); + clearFriendScores("Disabled in settings"); + return; + } + if (!hash) { + friendsDiag("skip", { reason: "no-map-hash", hint: "Need custom map level_id hash or resolved BeatSaver hash" }); + lastFriendScoresDebug = "no hash"; + lastBeatLeaderLeaderboardIds = "—"; + updateDebugHud(); + clearFriendScores("No map loaded"); + return; + } + const playerId = getEffectivePlayerId(); + if (!playerId) { + friendsDiag("skip", { + reason: "no-player-id", + hint: "Wait for BS+ handshake (playerPlatformId) or set debug BeatLeader id in settings", + }); + lastFriendScoresDebug = "no BeatLeader player id"; + lastBeatLeaderLeaderboardIds = "—"; + updateDebugHud(); + clearFriendScores("Waiting for BeatLeader player id"); + return; + } + friendScoresPanel.classList.add("is-loading"); + friendScoresEmpty.textContent = "Loading mutual friend scores..."; + lastFriendScoresDebug = "loading…"; + updateDebugHud(); + const requestId = ++friendScoreRequestId; + friendsDiag("start", { + requestId, + hash, + playerId, + friendMode: settings.friendMode, + debugPlayerOverride: Boolean(settings.debug && settings.debugPlayerId.trim()), + }); + try { + const relKey = friendsRelationListKey(playerId); + const friendsPromise: Promise = (async () => { + if (friendsRelationCache !== null && relKey === friendsRelationCacheKey) { + friendsDiag("friends-list-cache", { hit: true, friendCount: friendsRelationCache.length }); + return friendsRelationCache; + } + const fetched = await fetchFriends(playerId, settings.friendMode); + friendsRelationCacheKey = relKey; + friendsRelationCache = fetched; + friendsDiag("friends-list-cache", { hit: false, friendCount: fetched.length }); + return fetched; + })(); + const [leaderboards, friends] = await Promise.all([ + fetchBLLeaderboardsByHash(hash), + friendsPromise, + ]); + if (requestId !== friendScoreRequestId) return; + const leaderboardIds = leaderboards.map(beatLeaderboardId).filter(Boolean); + lastBeatLeaderLeaderboardIds = leaderboardIds.length ? leaderboardIds.join(", ") : "none"; + updateDebugHud(); + friendsDiag("parallel-fetch-done", { + hash, + leaderboardCount: leaderboards.length, + leaderboardIds, + friendCount: friends.length, + }); + if (leaderboards.length === 0) { + friendsDiag("empty-ui", { + reason: "no-beatleader-leaderboards-for-hash", + hash, + hint: "BeatLeader has no leaderboards for this map hash (wrong hash, unranked, or API/proxy error — see beatleader:leaderboardsByHash logs)", + }); + lastFriendScoresDebug = "0 leaderboards for hash"; + updateDebugHud(); + clearFriendScores("No BeatLeader leaderboards found"); + return; + } + const friendById = new Map(friends.map((f) => [f.id, f])); + const mutualFriendIds = new Set(friends.map((f) => f.id)); + if (mutualFriendIds.size === 0) { + friendsDiag("empty-ui", { + reason: "no-friends-for-mode", + friendMode: settings.friendMode, + hash, + hint: "Following/followers lists empty or mutual mode has no intersection — see beatleader:fetchFriendsResult", + }); + lastFriendScoresDebug = `0 friends (${settings.friendMode})`; + updateDebugHud(); + const relationLabel = settings.friendMode === "following" + ? "No followed BeatLeader players" + : settings.friendMode === "followers" + ? "No BeatLeader followers" + : "No mutual BeatLeader followers"; + clearFriendScores(relationLabel); + return; + } + const scores = await fetchAllMapScoresByHash(hash, leaderboards); + if (requestId !== friendScoreRequestId) return; + friendsDiag("scores-aggregated", { hash, rawScoreRows: scores.length, friendIdsInRelation: mutualFriendIds.size }); + const bestByPlayer = new Map(); + for (const score of scores) { + const scorePlayerId = score.playerId ?? (typeof score.player === "object" ? score.player?.id : null); + const playerKey = scorePlayerId == null ? "" : String(scorePlayerId); + if (!playerKey || !mutualFriendIds.has(playerKey)) continue; + const acc = normalizeAccuracy(score.accuracy ?? score.acc); + if (acc === null) continue; + const existing = bestByPlayer.get(playerKey); + if (!existing || acc > existing.acc) { + const friendMeta = friendById.get(playerKey); + const playerName = + score.playerName || + (typeof score.player === "object" ? score.player?.name : typeof score.player === "string" ? score.player : null); + const fromScore = avatarFromScore(score); + const fromFriend = friendMeta?.avatar?.trim() || null; + bestByPlayer.set(playerKey, { + name: playerName || friendMeta?.name || playerKey, + acc, + avatar: fromScore ?? fromFriend, + }); + } + } + const sorted = Array.from(bestByPlayer.values()).sort((a, b) => b.acc - a.acc); + if (sorted.length === 0 && scores.length > 0) { + friendsDiag("empty-ui", { + reason: "no-friend-scores-on-leaderboards", + hash, + rawScoreRows: scores.length, + friendIdsInRelation: mutualFriendIds.size, + hint: "Leaderboard scores exist but none match friend ids (playerId on scores vs BeatLeader friend ids)", + }); + } else if (sorted.length === 0 && scores.length === 0) { + friendsDiag("empty-ui", { + reason: "no-scores-on-map-leaderboards", + hash, + leaderboardIds, + hint: "No ranked rows returned for these leaderboards — see beatleader:leaderboardScores logs", + }); + } else { + friendsDiag("done", { rows: sorted.length, hash }); + } + lastFriendScoresDebug = `${leaderboards.length} LB, ${friends.length} friends, ${scores.length} scores → ${sorted.length} rows`; + updateDebugHud(); + renderFriendScores(sorted); + } catch (err) { + if (requestId !== friendScoreRequestId) return; + friendsDiag("error", { message: formatErr(err), hash, playerId }); + lastFriendScoresDebug = `error: ${formatErr(err)}`; + lastBeatLeaderLeaderboardIds = "—"; + updateDebugHud(); + clearFriendScores("Failed loading BeatLeader scores"); + } +} + async function applyMockMapFromBsr() { const key = settings.mockBsr.trim(); if (!settings.debug || !key) return; const map = await fetchBeatSaverMapById(key); - if (!map) return; + if (!map) { + console.warn("[BS+ overlay] map: mock BSR lookup returned nothing", { key }); + return; + } const hash = map.versions?.[0]?.hash?.toLowerCase?.() || ""; + rawLevelHash = hash; currentMapHash = hash; + lastBeatLeaderLeaderboardIds = "—"; title.textContent = map.metadata?.songName || map.name || title.textContent || ""; subTitle.textContent = map.metadata?.songSubName || ""; artist.textContent = map.metadata?.songAuthorName || ""; @@ -379,24 +615,20 @@ async function applyMockMapFromBsr() { type.textContent = "MOCK"; const coverUrl = map.versions?.[0]?.coverURL; if (coverUrl) cover.src = coverUrl; + lastMapLevelId = `mock:${key}`; + lastBsPlusBsrKey = ""; + lastCharDiffStr = ""; + lastBeatSaverIdDisplay = map.id || "—"; + lastBeatSaverNote = "mock BSR"; + updateDebugHud(); + console.log("[BS+ overlay] map: mock from BSR key", { key, hash: currentMapHash, mapId: map.id }); void refreshMapFriendScores(); } -function getEffectivePlayerId() { - const raw = settings.debug && settings.debugPlayerId.trim() - ? settings.debugPlayerId.trim() - : currentPlayerPlatformId; - if (!raw) return ""; - const steamIdCandidate = raw.match(/\d{17,20}/)?.[0]; - if (steamIdCandidate) return steamIdCandidate; - if (/^\d+$/.test(raw)) return raw; - if (settings.debug) return raw; - return ""; -} - window.onhashchange = loadSettings; loadSettings(); document.head.appendChild(style); +updateDebugHud(); // Settings UI @@ -408,6 +640,7 @@ for (const key of ["cover", "mapInfo", "time", "score", "friends", "bsr", "debug saveSettings(); if (key === "friends") void refreshMapFriendScores(); if (key === "debug") { + updateDebugHud(); void loadRequestQueue(); void applyMockMapFromBsr(); void refreshMapFriendScores(); @@ -431,12 +664,12 @@ mockBsrInput.oninput = () => { void applyMockMapFromBsr(); }; -const debugPlayerInput = must("debugPlayerInput"); -debugPlayerInput.value = settings.debugPlayerId; -debugPlayerInput.oninput = () => { - settings.debugPlayerId = debugPlayerInput.value.trim(); +const beatLeaderPlayerInput = must("beatLeaderPlayerInput"); +beatLeaderPlayerInput.value = settings.debugPlayerId; +beatLeaderPlayerInput.oninput = () => { + settings.debugPlayerId = beatLeaderPlayerInput.value.trim(); saveSettings(); - if (settings.debug) void refreshMapFriendScores(); + void refreshMapFriendScores(); }; void applyMockMapFromBsr(); diff --git a/src/client/overlay-server-log.ts b/src/client/overlay-server-log.ts new file mode 100644 index 0000000..688221f --- /dev/null +++ b/src/client/overlay-server-log.ts @@ -0,0 +1,15 @@ +/** + * Mirrors overlay diagnostics to the Deno static server terminal (`POST /api/overlay-log`). + * Browser console still receives the same messages via `console.log`. + */ +export function mirrorOverlayLog(scope: string, phase: string, detail: Record): void { + if (typeof location === "undefined") return; + if (location.protocol !== "http:" && location.protocol !== "https:") return; + const body = JSON.stringify({ scope, phase, detail }); + void fetch("/api/overlay-log", { + method: "POST", + headers: { "Content-Type": "application/json; charset=utf-8" }, + body, + keepalive: true, + }).catch(() => {}); +} diff --git a/src/server/serve.ts b/src/server/serve.ts index 0589e69..ffd15a0 100644 --- a/src/server/serve.ts +++ b/src/server/serve.ts @@ -75,6 +75,18 @@ function isChatRequestFilename(pathname: string): boolean { Deno.serve({ port, hostname: "127.0.0.1" }, async (req) => { const url = new URL(req.url); + if (req.method === "POST" && url.pathname === "/api/overlay-log") { + try { + const raw = await req.text(); + const parsed = JSON.parse(raw) as { scope?: string; phase?: string; detail?: unknown }; + const scope = typeof parsed.scope === "string" ? parsed.scope : "?"; + const phase = typeof parsed.phase === "string" ? parsed.phase : "?"; + console.log(`[overlay] ${scope}:${phase}`, parsed.detail ?? ""); + } catch { + console.log("[overlay] overlay-log: invalid JSON body"); + } + return new Response(null, { status: 204 }); + } if (req.method === "GET" && url.pathname === "/api/beatleader") { return proxyApiRequest(req, "https://api.beatleader.com"); } @@ -105,6 +117,7 @@ Deno.serve({ port, hostname: "127.0.0.1" }, async (req) => { }); console.log(`Overlay: http://127.0.0.1:${port}/index.html`); +console.log("Friend/BeatLeader diagnostics from the page also print here (browser console has the same lines)."); if (chatRequestDatabase) { console.log(`Chat request database file: ${chatRequestDatabase}`); } else {