Fix scores from friends

This commit is contained in:
pleb 2026-04-11 17:59:45 -07:00
parent d73c1ac495
commit f2181c7cf3
7 changed files with 91 additions and 16 deletions

BIN
assets/notlikesteve.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 744 B

BIN
assets/peepohigh.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

@ -307,6 +307,15 @@ span:empty {
font-size: 1.3rem; font-size: 1.3rem;
font-weight: 700; font-weight: 700;
opacity: 0.92; opacity: 0.92;
display: flex;
align-items: center;
gap: 0.45rem;
}
#friendScoresHeaderImg {
width: 1.5rem;
height: 1.5rem;
object-fit: contain;
} }
#friendScoresList { #friendScoresList {

View File

@ -39,7 +39,7 @@
</div> </div>
</div> </div>
<div id="friendScores" aria-live="polite"> <div id="friendScores" aria-live="polite">
<div id="friendScoresHeader">Mutual friends on BeatLeader</div> <div id="friendScoresHeader"><span id="friendScoresHeaderText">frenz!</span> <img id="friendScoresHeaderImg" src="assets/peepohigh.webp" alt=""></div>
<ol id="friendScoresList"></ol> <ol id="friendScoresList"></ol>
<div id="friendScoresEmpty">No map loaded</div> <div id="friendScoresEmpty">No map loaded</div>
</div> </div>
@ -66,6 +66,11 @@
<label>Show time: <input id="timeInput" type="checkbox"></label> <label>Show time: <input id="timeInput" type="checkbox"></label>
<label>Show score: <input id="scoreInput" type="checkbox"></label> <label>Show score: <input id="scoreInput" type="checkbox"></label>
<label>Show friend scores: <input id="friendsInput" type="checkbox"></label> <label>Show friend scores: <input id="friendsInput" type="checkbox"></label>
<label>Friend list mode: <select id="friendModeInput">
<option value="mutual">Followed + follower (mutual)</option>
<option value="following">Following (I follow them)</option>
<option value="followers">Followers (they follow me)</option>
</select></label>
<label>Show BSR / map id: <input id="bsrInput" type="checkbox"></label> <label>Show BSR / map id: <input id="bsrInput" type="checkbox"></label>
<label>Position: <select id="positionInput"> <label>Position: <select id="positionInput">
<option value="[false,false]">Top left</option> <option value="[false,false]">Top left</option>

View File

@ -118,16 +118,18 @@ async function fetchAllFollowers(playerId, type2, maxPages = 100) {
} }
return all; return all;
} }
async function fetchMutualFriendIds(playerId, maxPages = 100) { async function fetchFriendIds(playerId, mode, maxPages = 100) {
const canonicalPlayerId = await resolveBeatLeaderPlayerId(playerId); const canonicalPlayerId = await resolveBeatLeaderPlayerId(playerId);
const [following, followers] = await Promise.all([ const [following, followers] = await Promise.all([
fetchAllFollowers(canonicalPlayerId, "Following", maxPages), fetchAllFollowers(canonicalPlayerId, "Following", maxPages),
fetchAllFollowers(canonicalPlayerId, "Followers", maxPages) fetchAllFollowers(canonicalPlayerId, "Followers", maxPages)
]); ]);
const followingIds = new Set(following.map((entry) => String(entry.id))); const followingIds = new Set(following.map((entry) => String(entry.id)));
const followerIds = new Set(followers.map((entry) => String(entry.id)));
if (mode === "following") return followingIds;
if (mode === "followers") return followerIds;
const mutuals = /* @__PURE__ */ new Set(); const mutuals = /* @__PURE__ */ new Set();
for (const entry of followers) { for (const id of followerIds) {
const id = String(entry.id);
if (followingIds.has(id)) { if (followingIds.has(id)) {
mutuals.add(id); mutuals.add(id);
} }
@ -277,6 +279,8 @@ var mistakes = must("mistakes");
var friendScoresPanel = must("friendScores"); var friendScoresPanel = must("friendScores");
var friendScoresList = must("friendScoresList"); var friendScoresList = must("friendScoresList");
var friendScoresEmpty = must("friendScoresEmpty"); var friendScoresEmpty = must("friendScoresEmpty");
var friendScoresHeaderText = must("friendScoresHeaderText");
var friendScoresHeaderImg = must("friendScoresHeaderImg");
var currentMapHash = ""; var currentMapHash = "";
var friendScoreRequestId = 0; var friendScoreRequestId = 0;
function updateScore(score) { function updateScore(score) {
@ -288,13 +292,17 @@ function updateScore(score) {
function clearFriendScores(message) { function clearFriendScores(message) {
friendScoresList.replaceChildren(); friendScoresList.replaceChildren();
friendScoresEmpty.textContent = message; friendScoresEmpty.textContent = message;
friendScoresHeaderText.textContent = "frenz?";
friendScoresHeaderImg.src = "assets/notlikesteve.webp";
friendScoresPanel.classList.remove("has-items", "is-loading"); friendScoresPanel.classList.remove("has-items", "is-loading");
} }
function renderFriendScores(items) { function renderFriendScores(items) {
friendScoresList.replaceChildren(); friendScoresList.replaceChildren();
friendScoresPanel.classList.toggle("has-items", items.length > 0); friendScoresPanel.classList.toggle("has-items", items.length > 0);
friendScoresPanel.classList.remove("is-loading"); friendScoresPanel.classList.remove("is-loading");
friendScoresEmpty.textContent = items.length ? "" : "No mutual scores on this map"; 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) { for (const item of items) {
const li = document.createElement("li"); const li = document.createElement("li");
li.className = "friend-score-item"; li.className = "friend-score-item";
@ -329,7 +337,7 @@ async function refreshMapFriendScores() {
try { try {
const [leaderboards, mutualFriendIds] = await Promise.all([ const [leaderboards, mutualFriendIds] = await Promise.all([
fetchBLLeaderboardsByHash(hash), fetchBLLeaderboardsByHash(hash),
fetchMutualFriendIds(playerId) fetchFriendIds(playerId, settings.friendMode)
]); ]);
if (requestId !== friendScoreRequestId) return; if (requestId !== friendScoreRequestId) return;
if (leaderboards.length === 0) { if (leaderboards.length === 0) {
@ -337,7 +345,8 @@ async function refreshMapFriendScores() {
return; return;
} }
if (mutualFriendIds.size === 0) { if (mutualFriendIds.size === 0) {
clearFriendScores("No mutual BeatLeader followers"); const relationLabel = settings.friendMode === "following" ? "No followed BeatLeader players" : settings.friendMode === "followers" ? "No BeatLeader followers" : "No mutual BeatLeader followers";
clearFriendScores(relationLabel);
return; return;
} }
const scores = await fetchAllMapScoresByHash(hash, leaderboards); const scores = await fetchAllMapScoresByHash(hash, leaderboards);
@ -371,6 +380,7 @@ var settings = {
time: true, time: true,
score: true, score: true,
friends: true, friends: true,
friendMode: "mutual",
bsr: false, bsr: false,
debug: false, debug: false,
mockBsr: "4f4e4", mockBsr: "4f4e4",
@ -451,6 +461,13 @@ for (const key of [
} }
}; };
} }
var friendModeInput = must("friendModeInput");
friendModeInput.value = settings.friendMode;
friendModeInput.onchange = () => {
settings.friendMode = friendModeInput.value;
saveSettings();
void refreshMapFriendScores();
};
var mockBsrInput = must("mockBsrInput"); var mockBsrInput = must("mockBsrInput");
mockBsrInput.value = settings.mockBsr; mockBsrInput.value = settings.mockBsr;
mockBsrInput.oninput = () => { mockBsrInput.oninput = () => {
@ -495,6 +512,7 @@ var requestListEl = must("requestList");
var requestOverlayEl = must("requestOverlay"); var requestOverlayEl = must("requestOverlay");
var requestEmptyEl = must("requestEmpty"); var requestEmptyEl = must("requestEmpty");
var requestTitleCache = /* @__PURE__ */ new Map(); var requestTitleCache = /* @__PURE__ */ new Map();
var requestTitleMisses = /* @__PURE__ */ new Set();
function useRequestHistorySim() { function useRequestHistorySim() {
return settings.debug || new URLSearchParams(location.search).get("debug") === "1"; return settings.debug || new URLSearchParams(location.search).get("debug") === "1";
} }
@ -544,19 +562,26 @@ function requesterLine(item) {
return parts.length ? parts.join(" ") : item.rqn || ""; return parts.length ? parts.join(" ") : item.rqn || "";
} }
async function enrichRequestTitle(key, titleEl) { async function enrichRequestTitle(key, titleEl) {
if (requestTitleMisses.has(key)) return;
if (requestTitleCache.has(key)) { if (requestTitleCache.has(key)) {
titleEl.textContent = requestTitleCache.get(key) ?? ""; titleEl.textContent = requestTitleCache.get(key) ?? "";
return; return;
} }
try { try {
const map = await fetchBeatSaverMapById(key); const map = await fetchBeatSaverMapById(key);
if (!map) return; if (!map) {
requestTitleMisses.add(key);
return;
}
const name = map.metadata?.songName ?? map.name; const name = map.metadata?.songName ?? map.name;
if (name && typeof name === "string") { if (name && typeof name === "string") {
requestTitleCache.set(key, name); requestTitleCache.set(key, name);
titleEl.textContent = name; titleEl.textContent = name;
return;
} }
requestTitleMisses.add(key);
} catch { } catch {
requestTitleMisses.add(key);
} }
} }
function renderRequestList(items) { function renderRequestList(items) {

View File

@ -9,6 +9,7 @@ import type {
const BASE_URL = "https://api.beatleader.com"; const BASE_URL = "https://api.beatleader.com";
const PAGE_SIZE = 100; const PAGE_SIZE = 100;
const USE_RUNTIME_PROXY = typeof document !== "undefined"; const USE_RUNTIME_PROXY = typeof document !== "undefined";
export type FriendMode = "mutual" | "following" | "followers";
function beatleaderUrl(path: string): string { function beatleaderUrl(path: string): string {
if (USE_RUNTIME_PROXY) { if (USE_RUNTIME_PROXY) {
@ -162,15 +163,21 @@ async function fetchAllFollowers(
} }
export async function fetchMutualFriendIds(playerId: string, maxPages = 100): Promise<Set<string>> { export async function fetchMutualFriendIds(playerId: string, maxPages = 100): Promise<Set<string>> {
return fetchFriendIds(playerId, "mutual", maxPages);
}
export async function fetchFriendIds(playerId: string, mode: FriendMode, maxPages = 100): Promise<Set<string>> {
const canonicalPlayerId = await resolveBeatLeaderPlayerId(playerId); const canonicalPlayerId = await resolveBeatLeaderPlayerId(playerId);
const [following, followers] = await Promise.all([ const [following, followers] = await Promise.all([
fetchAllFollowers(canonicalPlayerId, "Following", maxPages), fetchAllFollowers(canonicalPlayerId, "Following", maxPages),
fetchAllFollowers(canonicalPlayerId, "Followers", maxPages), fetchAllFollowers(canonicalPlayerId, "Followers", maxPages),
]); ]);
const followingIds = new Set(following.map((entry) => String(entry.id))); const followingIds = new Set(following.map((entry) => String(entry.id)));
const followerIds = new Set(followers.map((entry) => String(entry.id)));
if (mode === "following") return followingIds;
if (mode === "followers") return followerIds;
const mutuals = new Set<string>(); const mutuals = new Set<string>();
for (const entry of followers) { for (const id of followerIds) {
const id = String(entry.id);
if (followingIds.has(id)) { if (followingIds.has(id)) {
mutuals.add(id); mutuals.add(id);
} }

View File

@ -9,7 +9,8 @@ import { fetchBeatSaverMapById, fetchBeatSaverMeta } from "./beatsaver.ts";
import { import {
fetchAllMapScoresByHash, fetchAllMapScoresByHash,
fetchBLLeaderboardsByHash, fetchBLLeaderboardsByHash,
fetchMutualFriendIds, fetchFriendIds,
type FriendMode,
normalizeAccuracy, normalizeAccuracy,
} from "./beatleader.ts"; } from "./beatleader.ts";
@ -176,6 +177,8 @@ const mistakes = must<HTMLElement>("mistakes");
const friendScoresPanel = must<HTMLElement>("friendScores"); const friendScoresPanel = must<HTMLElement>("friendScores");
const friendScoresList = must<HTMLOListElement>("friendScoresList"); const friendScoresList = must<HTMLOListElement>("friendScoresList");
const friendScoresEmpty = must<HTMLElement>("friendScoresEmpty"); const friendScoresEmpty = must<HTMLElement>("friendScoresEmpty");
const friendScoresHeaderText = must<HTMLElement>("friendScoresHeaderText");
const friendScoresHeaderImg = must<HTMLImageElement>("friendScoresHeaderImg");
let currentMapHash = ""; let currentMapHash = "";
let friendScoreRequestId = 0; let friendScoreRequestId = 0;
@ -190,6 +193,8 @@ function updateScore(score: Score) {
function clearFriendScores(message: string) { function clearFriendScores(message: string) {
friendScoresList.replaceChildren(); friendScoresList.replaceChildren();
friendScoresEmpty.textContent = message; friendScoresEmpty.textContent = message;
friendScoresHeaderText.textContent = "frenz?";
friendScoresHeaderImg.src = "assets/notlikesteve.webp";
friendScoresPanel.classList.remove("has-items", "is-loading"); friendScoresPanel.classList.remove("has-items", "is-loading");
} }
@ -197,7 +202,9 @@ function renderFriendScores(items: Array<{ name: string; acc: number }>) {
friendScoresList.replaceChildren(); friendScoresList.replaceChildren();
friendScoresPanel.classList.toggle("has-items", items.length > 0); friendScoresPanel.classList.toggle("has-items", items.length > 0);
friendScoresPanel.classList.remove("is-loading"); friendScoresPanel.classList.remove("is-loading");
friendScoresEmpty.textContent = items.length ? "" : "No mutual scores on this map"; 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) { for (const item of items) {
const li = document.createElement("li"); const li = document.createElement("li");
li.className = "friend-score-item"; li.className = "friend-score-item";
@ -233,7 +240,7 @@ async function refreshMapFriendScores() {
try { try {
const [leaderboards, mutualFriendIds] = await Promise.all([ const [leaderboards, mutualFriendIds] = await Promise.all([
fetchBLLeaderboardsByHash(hash), fetchBLLeaderboardsByHash(hash),
fetchMutualFriendIds(playerId), fetchFriendIds(playerId, settings.friendMode),
]); ]);
if (requestId !== friendScoreRequestId) return; if (requestId !== friendScoreRequestId) return;
if (leaderboards.length === 0) { if (leaderboards.length === 0) {
@ -241,7 +248,12 @@ async function refreshMapFriendScores() {
return; return;
} }
if (mutualFriendIds.size === 0) { if (mutualFriendIds.size === 0) {
clearFriendScores("No mutual BeatLeader followers"); const relationLabel = settings.friendMode === "following"
? "No followed BeatLeader players"
: settings.friendMode === "followers"
? "No BeatLeader followers"
: "No mutual BeatLeader followers";
clearFriendScores(relationLabel);
return; return;
} }
const scores = await fetchAllMapScoresByHash(hash, leaderboards); const scores = await fetchAllMapScoresByHash(hash, leaderboards);
@ -280,6 +292,7 @@ interface Settings {
time: boolean; time: boolean;
score: boolean; score: boolean;
friends: boolean; friends: boolean;
friendMode: FriendMode;
bsr: boolean; bsr: boolean;
debug: boolean; debug: boolean;
mockBsr: string; mockBsr: string;
@ -296,6 +309,7 @@ const settings: Settings = {
time: true, time: true,
score: true, score: true,
friends: true, friends: true,
friendMode: "mutual",
bsr: false, bsr: false,
debug: false, debug: false,
mockBsr: "4f4e4", mockBsr: "4f4e4",
@ -380,6 +394,14 @@ for (const key of ["cover", "mapInfo", "time", "score", "friends", "bsr", "debug
}; };
} }
const friendModeInput = must<HTMLSelectElement>("friendModeInput");
friendModeInput.value = settings.friendMode;
friendModeInput.onchange = () => {
settings.friendMode = friendModeInput.value as FriendMode;
saveSettings();
void refreshMapFriendScores();
};
const mockBsrInput = must<HTMLInputElement>("mockBsrInput"); const mockBsrInput = must<HTMLInputElement>("mockBsrInput");
mockBsrInput.value = settings.mockBsr; mockBsrInput.value = settings.mockBsr;
mockBsrInput.oninput = () => { mockBsrInput.oninput = () => {
@ -430,6 +452,7 @@ const requestListEl = must<HTMLOListElement>("requestList");
const requestOverlayEl = must<HTMLElement>("requestOverlay"); const requestOverlayEl = must<HTMLElement>("requestOverlay");
const requestEmptyEl = must<HTMLElement>("requestEmpty"); const requestEmptyEl = must<HTMLElement>("requestEmpty");
const requestTitleCache = new Map<string, string>(); const requestTitleCache = new Map<string, string>();
const requestTitleMisses = new Set<string>();
function useRequestHistorySim() { function useRequestHistorySim() {
return settings.debug || new URLSearchParams(location.search).get("debug") === "1"; return settings.debug || new URLSearchParams(location.search).get("debug") === "1";
@ -473,20 +496,26 @@ function requesterLine(item: ChatRequestEntry) {
} }
async function enrichRequestTitle(key: string, titleEl: HTMLElement) { async function enrichRequestTitle(key: string, titleEl: HTMLElement) {
if (requestTitleMisses.has(key)) return;
if (requestTitleCache.has(key)) { if (requestTitleCache.has(key)) {
titleEl.textContent = requestTitleCache.get(key) ?? ""; titleEl.textContent = requestTitleCache.get(key) ?? "";
return; return;
} }
try { try {
const map = await fetchBeatSaverMapById(key); const map = await fetchBeatSaverMapById(key);
if (!map) return; if (!map) {
requestTitleMisses.add(key);
return;
}
const name = map.metadata?.songName ?? map.name; const name = map.metadata?.songName ?? map.name;
if (name && typeof name === "string") { if (name && typeof name === "string") {
requestTitleCache.set(key, name); requestTitleCache.set(key, name);
titleEl.textContent = name; titleEl.textContent = name;
return;
} }
requestTitleMisses.add(key);
} catch { } catch {
// keep !bsr placeholder requestTitleMisses.add(key);
} }
} }