diff --git a/AGENTS.md b/AGENTS.md index 962e859..b5ec664 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,3 +23,17 @@ Do **not** add or maintain code paths for opening the overlay as **`file://`**. ## Out of scope here Beat Saber Plus itself (game mod) exposes the socket; this repo is only the HTML/CSS/TS client. + +## Recent implementation notes (2026-04) + +- **Live API calls must be runtime-proxied**: browser/OBS hits CORS on BeatLeader/BeatSaver. Keep live requests same-origin via `src/server/serve.ts`: + - `/api/beatleader?path=/...` + - `/api/beatsaver?path=/...` +- **Map correlation is hash-based**: resolve BeatSaver map/hash first, then BeatLeader leaderboards via `/leaderboards/hash/{hash}`. +- **Score source for friend matching**: use `/leaderboard/{leaderboardId}` scores for stable `playerId` fields; do not rely on v5 score payload shape for player IDs. +- **Mutual friends definition**: intersection of `/player/{id}/followers?type=Following` and `type=Followers` (paged by `page` + `count` only). +- **Overlay feature added**: `#friendScores` panel shows best accuracy per mutual friend for current map, sorted DESC. +- **Debug defaults**: + - mock map key: `4f4e4` + - debug BeatLeader player id: `76561199407393962` + - both debug inputs must have no effect when debug is disabled. diff --git a/deno.json b/deno.json index 11cd6ba..6336bc9 100644 --- a/deno.json +++ b/deno.json @@ -6,6 +6,8 @@ "tasks": { "build": "deno bundle --platform=browser --check src/client/index.ts -o index.js", "serve": "deno task build && deno run --allow-net --allow-read --allow-env src/server/serve.ts", - "dev": "deno bundle --platform=browser --watch --check src/client/index.ts -o index.js" + "dev": "deno bundle --platform=browser --watch --check src/client/index.ts -o index.js", + "test:live": "deno test --allow-net src/client/live-api.test.ts", + "test:friends": "deno test --allow-net src/client/live-friends.test.ts" } } diff --git a/deno.lock b/deno.lock index a119795..24a2248 100644 --- a/deno.lock +++ b/deno.lock @@ -1,6 +1,7 @@ { "version": "5", "specifiers": { + "jsr:@std/assert@*": "1.0.19", "jsr:@std/cli@^1.0.28": "1.0.28", "jsr:@std/encoding@^1.0.10": "1.0.10", "jsr:@std/fmt@^1.0.9": "1.0.9", @@ -15,6 +16,12 @@ "jsr:@std/streams@^1.0.17": "1.0.17" }, "jsr": { + "@std/assert@1.0.19": { + "integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e", + "dependencies": [ + "jsr:@std/internal" + ] + }, "@std/cli@1.0.28": { "integrity": "74ef9b976db59ca6b23a5283469c9072be6276853807a83ec6c7ce412135c70a" }, diff --git a/index.css b/index.css index 422bc0c..cef1898 100644 --- a/index.css +++ b/index.css @@ -293,12 +293,63 @@ span:empty { content: "FC"; } +/* Mutual friend scores */ + +#friendScores { + display: flex; + flex-direction: column; + gap: 0.3rem; + margin-top: 0.1rem; + max-width: 44rem; +} + +#friendScoresHeader { + font-size: 1.3rem; + font-weight: 700; + opacity: 0.92; +} + +#friendScoresList { + margin: 0; + padding-left: 2rem; + font-size: 1.35rem; + line-height: 1.2; +} + +#friendScoresList:empty { + display: none; +} + +#friendScoresEmpty { + font-size: 1.2rem; + opacity: 0.82; +} + +#friendScores.has-items #friendScoresEmpty { + display: none; +} + +#friendScores.is-loading #friendScoresEmpty { + opacity: 1; +} + +.friend-score-item { + display: flex; + align-items: baseline; + gap: 0.7rem; +} + +.friend-acc { + font-weight: 700; +} + /* Settings */ body:not(.cover) #coverImg, body:not(.mapInfo) #mapInfo, body:not(.time) #time, body:not(.score) #score, +body:not(.friends) #friendScores, body:not(.bsr) #bsrKey { display: none; } @@ -338,3 +389,11 @@ body.bottom #time { float: right; margin-left: 1em; } + +body:not(.debug) #mockBsrSetting { + display: none; +} + +body:not(.debug) #debugPlayerSetting { + display: none; +} diff --git a/index.html b/index.html index af01196..79ac8dd 100644 --- a/index.html +++ b/index.html @@ -37,6 +37,11 @@ 96.9 7 + +
+
Mutual friends on BeatLeader
+
    +
    No map loaded
    @@ -60,6 +65,7 @@ + + +
    About This was forked from Iza's overlay diff --git a/index.js b/index.js index 9a7d9d0..0207bf6 100644 --- a/index.js +++ b/index.js @@ -1,3 +1,144 @@ +// src/client/beatsaver.ts +var BASE_URL = "https://api.beatsaver.com"; +var USE_RUNTIME_PROXY = typeof document !== "undefined"; +function beatsaverUrl(path) { + if (USE_RUNTIME_PROXY) { + return `/api/beatsaver?path=${encodeURIComponent(path)}`; + } + return `${BASE_URL}${path}`; +} +async function fetchBeatSaverMeta(hash) { + try { + const response = await fetch(beatsaverUrl(`/maps/hash/${encodeURIComponent(hash)}`)); + if (!response.ok) return null; + return await response.json(); + } catch { + return null; + } +} +async function fetchBeatSaverMapById(mapId) { + try { + const response = await fetch(beatsaverUrl(`/maps/id/${encodeURIComponent(mapId)}`)); + if (!response.ok) return null; + return await response.json(); + } catch { + return null; + } +} + +// src/client/beatleader.ts +var BASE_URL2 = "https://api.beatleader.com"; +var PAGE_SIZE = 100; +var USE_RUNTIME_PROXY2 = typeof document !== "undefined"; +function beatleaderUrl(path) { + if (USE_RUNTIME_PROXY2) { + return `/api/beatleader?path=${encodeURIComponent(path)}`; + } + return `${BASE_URL2}${path}`; +} +async function fetchBLLeaderboardsByHash(hash) { + try { + const res = await fetch(beatleaderUrl(`/leaderboards/hash/${encodeURIComponent(hash)}`)); + if (!res.ok) return []; + const data = await res.json(); + const leaderboards = Array.isArray(data) ? data : Array.isArray(data.leaderboards) ? data.leaderboards : []; + return leaderboards; + } catch { + return []; + } +} +async function resolveBeatLeaderPlayerId(playerId) { + try { + const res = await fetch(beatleaderUrl(`/player/${encodeURIComponent(playerId)}`)); + if (!res.ok) return playerId; + const data = await res.json(); + const canonicalId = data.id; + return canonicalId == null ? playerId : String(canonicalId); + } catch { + return playerId; + } +} +async function fetchLeaderboardScoresById(leaderboardId, maxPages = 20) { + const scores = []; + for (let page = 1; page <= maxPages; page += 1) { + const qs = new URLSearchParams({ + leaderboardContext: "general", + page: String(page), + sortBy: "rank", + order: "desc" + }); + const url = beatleaderUrl(`/leaderboard/${encodeURIComponent(leaderboardId)}?${qs}`); + let res; + try { + res = await fetch(url); + } catch { + break; + } + if (!res.ok) break; + const payload = await res.json(); + const batch = Array.isArray(payload.scores) ? payload.scores : []; + if (batch.length === 0) break; + scores.push(...batch); + if (batch.length < PAGE_SIZE) break; + } + return scores; +} +async function fetchAllMapScoresByHash(hash, leaderboards, maxPagesPerLeaderboard = 20) { + 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(); +} +async function fetchFollowersPage(playerId, type2, page, count) { + const qs = new URLSearchParams({ + type: type2, + page: String(page), + count: String(count) + }); + const url = beatleaderUrl(`/player/${encodeURIComponent(playerId)}/followers?${qs}`); + try { + const response = await fetch(url); + if (!response.ok) return []; + const data = await response.json(); + return Array.isArray(data) ? data : []; + } catch { + return []; + } +} +async function fetchAllFollowers(playerId, type2, maxPages = 100) { + const all = []; + for (let page = 1; page <= maxPages; page += 1) { + const batch = await fetchFollowersPage(playerId, type2, page, PAGE_SIZE); + if (batch.length === 0) break; + all.push(...batch); + if (batch.length < PAGE_SIZE) break; + } + return all; +} +async function fetchMutualFriendIds(playerId, maxPages = 100) { + const canonicalPlayerId = await resolveBeatLeaderPlayerId(playerId); + const [following, followers] = await Promise.all([ + fetchAllFollowers(canonicalPlayerId, "Following", maxPages), + fetchAllFollowers(canonicalPlayerId, "Followers", maxPages) + ]); + const followingIds = new Set(following.map((entry) => String(entry.id))); + const mutuals = /* @__PURE__ */ new Set(); + for (const entry of followers) { + const id = String(entry.id); + if (followingIds.has(id)) { + mutuals.add(id); + } + } + return mutuals; +} +function normalizeAccuracy(value) { + if (typeof value !== "number" || !Number.isFinite(value)) return null; + return value <= 1 ? value * 100 : value; +} + // src/client/index.ts function must(id) { const element = document.getElementById(id); @@ -32,6 +173,10 @@ var beatSaberPlus = { break; } break; + case "handshake": + currentPlayerPlatformId = data.playerPlatformId || ""; + void refreshMapFriendScores(); + break; default: console.log("message", e.data); break; @@ -41,6 +186,7 @@ var beatSaberPlus = { var provider = beatSaberPlus; var retryMs = 1e4; var retries = 0; +var currentPlayerPlatformId = ""; function connect() { console.log(`Connecting to ${provider.url} (attempt ${retries++})`); const ws = new WebSocket(provider.url); @@ -72,6 +218,7 @@ var duration = 0; async function updateMapInfo(data) { 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 || ""; @@ -84,19 +231,22 @@ async function updateMapInfo(data) { bsrKey.textContent = data.BSRKey || "???"; timeMultiplier = data.timeMultiplier || 1; duration = data.duration / 1e3; + void refreshMapFriendScores(); if (custom && !wip) { document.body.classList.add("loading"); try { - const response = await fetch(`https://api.beatsaver.com/maps/hash/${data.level_id.substring(13, 53)}`); - const map = await response.json(); - if (!map.id) return; + 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); + 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 = ""; } } var timeText = must("timeText"); @@ -124,19 +274,107 @@ function formatTime(t) { } var accuracy = must("accuracy"); var mistakes = must("mistakes"); +var friendScoresPanel = must("friendScores"); +var friendScoresList = must("friendScoresList"); +var friendScoresEmpty = must("friendScoresEmpty"); +var currentMapHash = ""; +var friendScoreRequestId = 0; function updateScore(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 clearFriendScores(message) { + friendScoresList.replaceChildren(); + friendScoresEmpty.textContent = message; + friendScoresPanel.classList.remove("has-items", "is-loading"); +} +function renderFriendScores(items) { + friendScoresList.replaceChildren(); + friendScoresPanel.classList.toggle("has-items", items.length > 0); + friendScoresPanel.classList.remove("is-loading"); + friendScoresEmpty.textContent = items.length ? "" : "No mutual scores on this map"; + for (const item of items) { + const li = document.createElement("li"); + li.className = "friend-score-item"; + 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(name, acc); + 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, mutualFriendIds] = await Promise.all([ + fetchBLLeaderboardsByHash(hash), + fetchMutualFriendIds(playerId) + ]); + if (requestId !== friendScoreRequestId) return; + if (leaderboards.length === 0) { + clearFriendScores("No BeatLeader leaderboards found"); + return; + } + if (mutualFriendIds.size === 0) { + clearFriendScores("No mutual BeatLeader followers"); + return; + } + const scores = await fetchAllMapScoresByHash(hash, leaderboards); + if (requestId !== friendScoreRequestId) return; + const bestByPlayer = /* @__PURE__ */ new Map(); + for (const score of scores) { + const playerId2 = score.playerId ?? (typeof score.player === "object" ? score.player?.id : null); + const playerKey = playerId2 == null ? "" : String(playerId2); + 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 playerName = score.playerName || (typeof score.player === "object" ? score.player?.name : typeof score.player === "string" ? score.player : null); + bestByPlayer.set(playerKey, { + name: playerName || playerKey, + acc + }); + } + } + 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"); + } +} var settings = { cover: true, mapInfo: true, time: true, score: true, + friends: true, bsr: false, debug: false, + mockBsr: "4f4e4", + debugPlayerId: "76561199407393962", right: false, bottom: true, scale: 1, @@ -162,6 +400,32 @@ function saveSettings() { } 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; + const hash = map.versions?.[0]?.hash?.toLowerCase?.() || ""; + currentMapHash = hash; + title.textContent = map.metadata?.songName || map.name || title.textContent || ""; + subTitle.textContent = map.metadata?.songSubName || ""; + artist.textContent = map.metadata?.songAuthorName || ""; + mapper.textContent = map.metadata?.levelAuthorName || ""; + bsrKey.textContent = map.id || key; + type.textContent = "MOCK"; + const coverUrl = map.versions?.[0]?.coverURL; + if (coverUrl) cover.src = coverUrl; + 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); @@ -170,6 +434,7 @@ for (const key of [ "mapInfo", "time", "score", + "friends", "bsr", "debug" ]) { @@ -178,9 +443,29 @@ for (const key of [ input.oninput = () => { settings[key] = input.checked; saveSettings(); - if (key === "debug") void loadRequestQueue(); + if (key === "friends") void refreshMapFriendScores(); + if (key === "debug") { + void loadRequestQueue(); + void applyMockMapFromBsr(); + void refreshMapFriendScores(); + } }; } +var mockBsrInput = must("mockBsrInput"); +mockBsrInput.value = settings.mockBsr; +mockBsrInput.oninput = () => { + settings.mockBsr = mockBsrInput.value.trim(); + saveSettings(); + void applyMockMapFromBsr(); +}; +var debugPlayerInput = must("debugPlayerInput"); +debugPlayerInput.value = settings.debugPlayerId; +debugPlayerInput.oninput = () => { + settings.debugPlayerId = debugPlayerInput.value.trim(); + saveSettings(); + if (settings.debug) void refreshMapFriendScores(); +}; +void applyMockMapFromBsr(); var scale = must("scaleInput"); scale.valueAsNumber = settings.scale * 100; scale.oninput = () => { @@ -264,9 +549,8 @@ async function enrichRequestTitle(key, titleEl) { return; } try { - const response = await fetch(`https://api.beatsaver.com/maps/id/${encodeURIComponent(key)}`); - if (!response.ok) return; - const map = await response.json(); + const map = await fetchBeatSaverMapById(key); + if (!map) return; const name = map.metadata?.songName ?? map.name; if (name && typeof name === "string") { requestTitleCache.set(key, name); diff --git a/plebsaber-stream.code-workspace b/plebsaber-stream.code-workspace index bbde028..e014306 100644 --- a/plebsaber-stream.code-workspace +++ b/plebsaber-stream.code-workspace @@ -4,7 +4,7 @@ "path": "." }, { - "path": "../plebsaber.stream" + "path": "../../src/plebsaber.stream" } ], "settings": {} diff --git a/src/client/beatleader.ts b/src/client/beatleader.ts new file mode 100644 index 0000000..22eed08 --- /dev/null +++ b/src/client/beatleader.ts @@ -0,0 +1,199 @@ +import type { + BeatLeaderFollower, + BeatLeaderLeaderboard, + BeatLeaderLeaderboardsByHashResponse, + BeatLeaderScore, + BeatLeaderScoresResponse, +} from "./types.ts"; + +const BASE_URL = "https://api.beatleader.com"; +const PAGE_SIZE = 100; +const USE_RUNTIME_PROXY = typeof document !== "undefined"; + +function beatleaderUrl(path: string): string { + if (USE_RUNTIME_PROXY) { + return `/api/beatleader?path=${encodeURIComponent(path)}`; + } + return `${BASE_URL}${path}`; +} + +interface BeatLeaderPlayerLookup { + id?: string | number | null; +} + +export async function fetchBLLeaderboardsByHash(hash: string): Promise { + try { + const res = await fetch(beatleaderUrl(`/leaderboards/hash/${encodeURIComponent(hash)}`)); + if (!res.ok) return []; + const data = await res.json() as BeatLeaderLeaderboardsByHashResponse | BeatLeaderLeaderboard[]; + const leaderboards = Array.isArray(data) + ? data + : Array.isArray(data.leaderboards) + ? data.leaderboards + : []; + return leaderboards; + } catch { + return []; + } +} + +async function resolveBeatLeaderPlayerId(playerId: string): Promise { + try { + const res = await fetch(beatleaderUrl(`/player/${encodeURIComponent(playerId)}`)); + if (!res.ok) return playerId; + const data = await res.json() as BeatLeaderPlayerLookup; + const canonicalId = data.id; + return canonicalId == null ? playerId : String(canonicalId); + } catch { + return playerId; + } +} + +export async function fetchAllLeaderboardScoresByHash( + hash: string, + diff: string, + mode: string, + maxPages = 20, +): Promise { + const scores: BeatLeaderScore[] = []; + for (let page = 1; page <= maxPages; page += 1) { + const qs = new URLSearchParams({ page: String(page), count: String(PAGE_SIZE) }); + const url = beatleaderUrl( + `/v5/scores/${encodeURIComponent(hash)}/${encodeURIComponent(diff)}/${encodeURIComponent(mode)}?${qs}`, + ); + let res: Response; + try { + res = await fetch(url); + } catch { + break; + } + if (!res.ok) break; + const payload = await res.json() as BeatLeaderScoresResponse | BeatLeaderScore[]; + const batch = Array.isArray(payload) ? payload : payload.data ?? []; + if (batch.length === 0) break; + scores.push(...batch); + if (batch.length < PAGE_SIZE) break; + } + return scores; +} + +interface BeatLeaderLeaderboardScoresResponse { + scores?: BeatLeaderScore[]; +} + +async function fetchLeaderboardScoresById( + leaderboardId: string, + maxPages = 20, +): Promise { + const scores: BeatLeaderScore[] = []; + for (let page = 1; page <= maxPages; page += 1) { + const qs = new URLSearchParams({ + leaderboardContext: "general", + page: String(page), + sortBy: "rank", + order: "desc", + }); + const url = beatleaderUrl(`/leaderboard/${encodeURIComponent(leaderboardId)}?${qs}`); + let res: Response; + try { + res = await fetch(url); + } catch { + break; + } + if (!res.ok) break; + const payload = await res.json() as BeatLeaderLeaderboardScoresResponse; + const batch = Array.isArray(payload.scores) ? payload.scores : []; + if (batch.length === 0) break; + scores.push(...batch); + if (batch.length < PAGE_SIZE) break; + } + return scores; +} + +export async function fetchAllMapScoresByHash( + hash: string, + leaderboards: BeatLeaderLeaderboard[], + maxPagesPerLeaderboard = 20, +): Promise { + 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(); +} + +async function fetchFollowersPage( + playerId: string, + type: "Followers" | "Following", + page: number, + count: number, +): Promise { + const qs = new URLSearchParams({ + type, + page: String(page), + count: String(count), + }); + const url = beatleaderUrl(`/player/${encodeURIComponent(playerId)}/followers?${qs}`); + try { + const response = await fetch(url); + if (!response.ok) return []; + const data = await response.json() as BeatLeaderFollower[]; + return Array.isArray(data) ? data : []; + } catch { + return []; + } +} + +async function fetchAllFollowers( + playerId: string, + type: "Followers" | "Following", + maxPages = 100, +): Promise { + const all: BeatLeaderFollower[] = []; + for (let page = 1; page <= maxPages; page += 1) { + const batch = await fetchFollowersPage(playerId, type, page, PAGE_SIZE); + if (batch.length === 0) break; + all.push(...batch); + if (batch.length < PAGE_SIZE) break; + } + return all; +} + +export async function fetchMutualFriendIds(playerId: string, maxPages = 100): Promise> { + const canonicalPlayerId = await resolveBeatLeaderPlayerId(playerId); + const [following, followers] = await Promise.all([ + fetchAllFollowers(canonicalPlayerId, "Following", maxPages), + fetchAllFollowers(canonicalPlayerId, "Followers", maxPages), + ]); + const followingIds = new Set(following.map((entry) => String(entry.id))); + const mutuals = new Set(); + for (const entry of followers) { + const id = String(entry.id); + if (followingIds.has(id)) { + mutuals.add(id); + } + } + return mutuals; +} + +export async function fetchMutualFriends(playerId: string, maxPages = 100): Promise { + const canonicalPlayerId = await resolveBeatLeaderPlayerId(playerId); + const [following, followers] = await Promise.all([ + fetchAllFollowers(canonicalPlayerId, "Following", maxPages), + fetchAllFollowers(canonicalPlayerId, "Followers", maxPages), + ]); + const followingIds = new Set(following.map((entry) => String(entry.id))); + return followers + .filter((entry) => followingIds.has(String(entry.id))) + .map((entry) => ({ + ...entry, + id: String(entry.id), + })); +} + +export function normalizeAccuracy(value: number | null | undefined): number | null { + if (typeof value !== "number" || !Number.isFinite(value)) return null; + return value <= 1 ? value * 100 : value; +} diff --git a/src/client/beatsaver.ts b/src/client/beatsaver.ts new file mode 100644 index 0000000..9ec9fff --- /dev/null +++ b/src/client/beatsaver.ts @@ -0,0 +1,51 @@ +export interface BeatSaverDiff { + characteristic: string; + difficulty: string; + label?: string; +} + +export interface BeatSaverMap { + id?: string; + name?: string; + metadata?: { + songName?: string; + songSubName?: string; + songAuthorName?: string; + levelAuthorName?: string; + }; + versions?: Array<{ + hash?: string; + coverURL?: string; + diffs?: BeatSaverDiff[]; + }>; +} + +const BASE_URL = "https://api.beatsaver.com"; +const USE_RUNTIME_PROXY = typeof document !== "undefined"; + +function beatsaverUrl(path: string): string { + if (USE_RUNTIME_PROXY) { + return `/api/beatsaver?path=${encodeURIComponent(path)}`; + } + return `${BASE_URL}${path}`; +} + +export async function fetchBeatSaverMeta(hash: string): Promise { + try { + const response = await fetch(beatsaverUrl(`/maps/hash/${encodeURIComponent(hash)}`)); + if (!response.ok) return null; + return await response.json() as BeatSaverMap; + } catch { + return null; + } +} + +export async function fetchBeatSaverMapById(mapId: string): Promise { + try { + const response = await fetch(beatsaverUrl(`/maps/id/${encodeURIComponent(mapId)}`)); + if (!response.ok) return null; + return await response.json() as BeatSaverMap; + } catch { + return null; + } +} diff --git a/src/client/index.ts b/src/client/index.ts index 5bad73a..3c8d1c2 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -5,6 +5,13 @@ import type { MapInfo, Score, } from "./types.ts"; +import { fetchBeatSaverMapById, fetchBeatSaverMeta } from "./beatsaver.ts"; +import { + fetchAllMapScoresByHash, + fetchBLLeaderboardsByHash, + fetchMutualFriendIds, + normalizeAccuracy, +} from "./beatleader.ts"; function must(id: string): T { const element = document.getElementById(id); @@ -43,6 +50,10 @@ const beatSaberPlus = { break; } break; + case "handshake": + currentPlayerPlatformId = data.playerPlatformId || ""; + void refreshMapFriendScores(); + break; default: console.log("message", e.data); break; @@ -53,6 +64,7 @@ const beatSaberPlus = { const provider = beatSaberPlus; const retryMs = 10000; let retries = 0; +let currentPlayerPlatformId = ""; function connect() { console.log(`Connecting to ${provider.url} (attempt ${retries++})`); @@ -92,6 +104,7 @@ 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 || ""; @@ -104,24 +117,26 @@ async function updateMapInfo(data: MapInfo) { 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 response = await fetch(`https://api.beatsaver.com/maps/hash/${data.level_id.substring(13, 53)}`); - const map = await response.json(); - if (!map.id) return; + const map = await fetchBeatSaverMeta(currentMapHash); + if (!map?.id) return; bsrKey.textContent = map.id; - mapper.textContent = map.metadata.levelAuthorName; // Replace mapper name with full authors list - const diff = map.versions[0].diffs.find( - (d: { characteristic: string; difficulty: string; label?: string }) => - d.characteristic === data.characteristic && d.difficulty === data.difficulty, + 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 = ""; } } @@ -158,6 +173,12 @@ function formatTime(t: number) { const accuracy = must("accuracy"); const mistakes = must("mistakes"); +const friendScoresPanel = must("friendScores"); +const friendScoresList = must("friendScoresList"); +const friendScoresEmpty = must("friendScoresEmpty"); + +let currentMapHash = ""; +let friendScoreRequestId = 0; function updateScore(score: Score) { if (!settings.score) return; @@ -166,6 +187,91 @@ function updateScore(score: Score) { accuracy.classList.toggle("failed", score.currentHealth === 0); } +function clearFriendScores(message: string) { + friendScoresList.replaceChildren(); + friendScoresEmpty.textContent = message; + friendScoresPanel.classList.remove("has-items", "is-loading"); +} + +function renderFriendScores(items: Array<{ name: string; acc: number }>) { + friendScoresList.replaceChildren(); + friendScoresPanel.classList.toggle("has-items", items.length > 0); + friendScoresPanel.classList.remove("is-loading"); + friendScoresEmpty.textContent = items.length ? "" : "No mutual scores on this map"; + for (const item of items) { + const li = document.createElement("li"); + li.className = "friend-score-item"; + 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(name, acc); + 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, mutualFriendIds] = await Promise.all([ + fetchBLLeaderboardsByHash(hash), + fetchMutualFriendIds(playerId), + ]); + if (requestId !== friendScoreRequestId) return; + if (leaderboards.length === 0) { + clearFriendScores("No BeatLeader leaderboards found"); + return; + } + if (mutualFriendIds.size === 0) { + clearFriendScores("No mutual BeatLeader followers"); + return; + } + const scores = await fetchAllMapScoresByHash(hash, leaderboards); + if (requestId !== friendScoreRequestId) return; + const bestByPlayer = new Map(); + for (const score of scores) { + const playerId = score.playerId ?? (typeof score.player === "object" ? score.player?.id : null); + const playerKey = playerId == null ? "" : String(playerId); + 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 playerName = + score.playerName || + (typeof score.player === "object" ? score.player?.name : typeof score.player === "string" ? score.player : null); + bestByPlayer.set(playerKey, { + name: playerName || playerKey, + acc, + }); + } + } + 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 { @@ -173,8 +279,11 @@ interface Settings { mapInfo: boolean; time: boolean; score: boolean; + friends: boolean; bsr: boolean; debug: boolean; + mockBsr: string; + debugPlayerId: string; right: boolean; bottom: boolean; scale: number; @@ -186,8 +295,11 @@ const settings: Settings = { mapInfo: true, time: true, score: true, + friends: true, bsr: false, debug: false, + mockBsr: "4f4e4", + debugPlayerId: "76561199407393962", right: false, bottom: true, scale: 1, @@ -217,22 +329,75 @@ function saveSettings() { 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; + const hash = map.versions?.[0]?.hash?.toLowerCase?.() || ""; + currentMapHash = hash; + title.textContent = map.metadata?.songName || map.name || title.textContent || ""; + subTitle.textContent = map.metadata?.songSubName || ""; + artist.textContent = map.metadata?.songAuthorName || ""; + mapper.textContent = map.metadata?.levelAuthorName || ""; + bsrKey.textContent = map.id || key; + type.textContent = "MOCK"; + const coverUrl = map.versions?.[0]?.coverURL; + if (coverUrl) cover.src = coverUrl; + 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); // Settings UI -for (const key of ["cover", "mapInfo", "time", "score", "bsr", "debug"] as const) { +for (const key of ["cover", "mapInfo", "time", "score", "friends", "bsr", "debug"] as const) { const input = must(`${key}Input`); input.checked = settings[key]; input.oninput = () => { settings[key] = input.checked; saveSettings(); - if (key === "debug") void loadRequestQueue(); + if (key === "friends") void refreshMapFriendScores(); + if (key === "debug") { + void loadRequestQueue(); + void applyMockMapFromBsr(); + void refreshMapFriendScores(); + } }; } +const mockBsrInput = must("mockBsrInput"); +mockBsrInput.value = settings.mockBsr; +mockBsrInput.oninput = () => { + settings.mockBsr = mockBsrInput.value.trim(); + saveSettings(); + void applyMockMapFromBsr(); +}; + +const debugPlayerInput = must("debugPlayerInput"); +debugPlayerInput.value = settings.debugPlayerId; +debugPlayerInput.oninput = () => { + settings.debugPlayerId = debugPlayerInput.value.trim(); + saveSettings(); + if (settings.debug) void refreshMapFriendScores(); +}; + +void applyMockMapFromBsr(); + const scale = must("scaleInput"); scale.valueAsNumber = settings.scale * 100; scale.oninput = () => { @@ -313,9 +478,8 @@ async function enrichRequestTitle(key: string, titleEl: HTMLElement) { return; } try { - const response = await fetch(`https://api.beatsaver.com/maps/id/${encodeURIComponent(key)}`); - if (!response.ok) return; - const map = await response.json(); + const map = await fetchBeatSaverMapById(key); + if (!map) return; const name = map.metadata?.songName ?? map.name; if (name && typeof name === "string") { requestTitleCache.set(key, name); diff --git a/src/client/live-api.test.ts b/src/client/live-api.test.ts new file mode 100644 index 0000000..a12fcbe --- /dev/null +++ b/src/client/live-api.test.ts @@ -0,0 +1,82 @@ +import { assert, assertEquals } from "jsr:@std/assert"; +import { + fetchAllMapScoresByHash, + fetchAllLeaderboardScoresByHash, + fetchBLLeaderboardsByHash, + fetchMutualFriends, + fetchMutualFriendIds, + normalizeAccuracy, +} from "./beatleader.ts"; +import { fetchBeatSaverMapById } from "./beatsaver.ts"; + +const MAP_KEY = "4f4e4"; +const PLAYER_ID = "76561199407393962"; +const MODE = "Standard"; + +function normalizeDifficultyName(value: string | null | undefined) { + return (value ?? "").toLowerCase().replace(/\s+/g, "").replace("expert+", "expertplus"); +} + +function leaderboardId(lb: { id?: string | number | null; leaderboardId?: string | number | null }) { + const id = lb.id ?? lb.leaderboardId; + return id == null ? "" : String(id); +} + +Deno.test({ + name: "live lookup: map hash -> expertplus leaderboard -> mutual friend scores", + sanitizeOps: false, + sanitizeResources: false, + async fn() { + const map = await fetchBeatSaverMapById(MAP_KEY); + assert(map?.versions?.[0]?.hash, `BeatSaver map ${MAP_KEY} should resolve to a hash`); + const hash = String(map.versions[0].hash).toLowerCase(); + + const leaderboards = await fetchBLLeaderboardsByHash(hash); + assert(leaderboards.length > 0, "Expected BeatLeader leaderboards for hash"); + + const expertPlus = leaderboards.find((lb) => { + const modeOk = (lb.difficulty?.modeName ?? "").toLowerCase() === MODE.toLowerCase(); + const diff = normalizeDifficultyName(lb.difficulty?.difficultyName); + return modeOk && diff === "expertplus"; + }); + assert(expertPlus, "Expected Standard ExpertPlus leaderboard"); + + const nonExpertPlus = leaderboards.find((lb) => { + const modeOk = (lb.difficulty?.modeName ?? "").toLowerCase() === MODE.toLowerCase(); + const diff = normalizeDifficultyName(lb.difficulty?.difficultyName); + return modeOk && diff !== "expertplus"; + }); + assert(nonExpertPlus, "Expected a Standard non-ExpertPlus leaderboard"); + assert( + leaderboardId(expertPlus) !== leaderboardId(nonExpertPlus), + "ExpertPlus should map to a different leaderboard id than another difficulty", + ); + + const [expertPlusScores, allMapScores, mutualIds, mutualFriends] = await Promise.all([ + fetchAllLeaderboardScoresByHash(hash, "ExpertPlus", MODE, 8), + fetchAllMapScoresByHash(hash, leaderboards, 120), + fetchMutualFriendIds(PLAYER_ID, 100), + fetchMutualFriends(PLAYER_ID, 100), + ]); + assert(expertPlusScores.length > 0, "Expected some ExpertPlus scores"); + assert(allMapScores.length > 0, "Expected map to have scores across leaderboards"); + assert(mutualIds.size > 0, "Expected at least one mutual friend"); + console.log( + "Mutual friends:", + mutualFriends.map((friend) => `${friend.name || friend.alias || "unknown"} (${friend.id})`).join(", "), + ); + + const mutualScores = allMapScores.filter((score) => { + const playerId = score.playerId ?? (typeof score.player === "object" ? score.player?.id : null); + return playerId != null && mutualIds.has(String(playerId)); + }); + assert(mutualScores.length > 0, "Expected at least one mutual friend score on this map"); + + const accuracies = mutualScores + .map((score) => normalizeAccuracy(score.accuracy ?? score.acc)) + .filter((value): value is number => value !== null); + assert(accuracies.length > 0, "Expected mutual scores to include accuracy values"); + assert(accuracies.some((acc) => acc > 0), "Expected positive accuracy values"); + assertEquals(hash.length, 40); + }, +}); diff --git a/src/client/live-friends.test.ts b/src/client/live-friends.test.ts new file mode 100644 index 0000000..4399ce0 --- /dev/null +++ b/src/client/live-friends.test.ts @@ -0,0 +1,19 @@ +import { assert } from "jsr:@std/assert"; +import { fetchMutualFriends } from "./beatleader.ts"; + +const PLAYER_ID = "76561199407393962"; + +Deno.test({ + name: "live lookup: player has mutual BeatLeader friends", + sanitizeOps: false, + sanitizeResources: false, + async fn() { + const mutuals = await fetchMutualFriends(PLAYER_ID, 100); + assert(mutuals.length > 0, `Expected mutual friends for player ${PLAYER_ID}`); + console.log("Mutual friends:"); + for (const friend of mutuals) { + const label = friend.name || friend.alias || "(no name)"; + console.log(`- ${label} (${friend.id})`); + } + }, +}); diff --git a/src/client/types.ts b/src/client/types.ts index 3a90534..72e2af2 100644 --- a/src/client/types.ts +++ b/src/client/types.ts @@ -83,3 +83,49 @@ export interface ChatRequestPayload { queue: ChatRequestEntry[]; history: ChatRequestEntry[]; } + +export interface BeatLeaderDifficulty { + modeName?: string | null; + difficultyName?: string | null; + value?: number | string | null; +} + +export interface BeatLeaderLeaderboard { + id?: string | number | null; + leaderboardId?: string | number | null; + difficulty?: BeatLeaderDifficulty; +} + +export interface BeatLeaderLeaderboardsByHashResponse { + leaderboards?: BeatLeaderLeaderboard[]; +} + +export interface BeatLeaderPlayer { + id?: string | null; + name?: string | null; + avatar?: string | null; +} + +export interface BeatLeaderScore { + id?: number | null; + accuracy?: number | null; + acc?: number | null; + baseScore?: number | null; + modifiedScore?: number | null; + playerId?: string | number | null; + playerName?: string | null; + player?: BeatLeaderPlayer | string | null; +} + +export interface BeatLeaderScoresResponse { + data?: BeatLeaderScore[]; +} + +export interface BeatLeaderFollower { + id: string; + alias?: string | null; + name?: string | null; + avatar?: string | null; + count?: number | null; + mutual?: boolean; +} diff --git a/src/server/serve.ts b/src/server/serve.ts index 0da88be..0589e69 100644 --- a/src/server/serve.ts +++ b/src/server/serve.ts @@ -32,6 +32,42 @@ function readOptionalPathFile(): string | undefined { const chatRequestDatabase = Deno.env.get("CHAT_REQUEST_DATABASE")?.trim() || readOptionalPathFile(); +function isSafeProxyPath(path: string): boolean { + if (!path) return false; + if (!path.startsWith("/")) return false; + if (path.includes("://")) return false; + if (path.includes("\\") || path.includes("\r") || path.includes("\n")) return false; + return true; +} + +async function proxyApiRequest(req: Request, upstreamBase: string): Promise { + const url = new URL(req.url); + const path = url.searchParams.get("path") ?? ""; + if (!isSafeProxyPath(path)) { + return new Response("Invalid path\n", { status: 400 }); + } + const upstream = new URL(`${upstreamBase}${path}`); + for (const [key, value] of url.searchParams.entries()) { + if (key === "path") continue; + upstream.searchParams.append(key, value); + } + try { + const upstreamRes = await fetch(upstream, { method: "GET" }); + const headers = new Headers(); + const contentType = upstreamRes.headers.get("content-type"); + if (contentType) headers.set("content-type", contentType); + headers.set("cache-control", "no-store"); + return new Response(upstreamRes.body, { + status: upstreamRes.status, + statusText: upstreamRes.statusText, + headers, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return new Response(`Proxy request failed: ${message}\n`, { status: 502 }); + } +} + function isChatRequestFilename(pathname: string): boolean { const base = pathname.split("/").pop() ?? ""; return base === "ChatRequest.json" || base === "database.json"; @@ -39,6 +75,12 @@ function isChatRequestFilename(pathname: string): boolean { Deno.serve({ port, hostname: "127.0.0.1" }, async (req) => { const url = new URL(req.url); + if (req.method === "GET" && url.pathname === "/api/beatleader") { + return proxyApiRequest(req, "https://api.beatleader.com"); + } + if (req.method === "GET" && url.pathname === "/api/beatsaver") { + return proxyApiRequest(req, "https://api.beatsaver.com"); + } if (req.method === "GET" && chatRequestDatabase && isChatRequestFilename(url.pathname)) { try { let text = await Deno.readTextFile(chatRequestDatabase);