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-weight: 700;
opacity: 0.92;
display: flex;
align-items: center;
gap: 0.45rem;
}
#friendScoresHeaderImg {
width: 1.5rem;
height: 1.5rem;
object-fit: contain;
}
#friendScoresList {

View File

@ -39,7 +39,7 @@
</div>
</div>
<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>
<div id="friendScoresEmpty">No map loaded</div>
</div>
@ -66,6 +66,11 @@
<label>Show time: <input id="timeInput" type="checkbox"></label>
<label>Show score: <input id="scoreInput" 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>Position: <select id="positionInput">
<option value="[false,false]">Top left</option>

View File

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

View File

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

View File

@ -9,7 +9,8 @@ import { fetchBeatSaverMapById, fetchBeatSaverMeta } from "./beatsaver.ts";
import {
fetchAllMapScoresByHash,
fetchBLLeaderboardsByHash,
fetchMutualFriendIds,
fetchFriendIds,
type FriendMode,
normalizeAccuracy,
} from "./beatleader.ts";
@ -176,6 +177,8 @@ const mistakes = must<HTMLElement>("mistakes");
const friendScoresPanel = must<HTMLElement>("friendScores");
const friendScoresList = must<HTMLOListElement>("friendScoresList");
const friendScoresEmpty = must<HTMLElement>("friendScoresEmpty");
const friendScoresHeaderText = must<HTMLElement>("friendScoresHeaderText");
const friendScoresHeaderImg = must<HTMLImageElement>("friendScoresHeaderImg");
let currentMapHash = "";
let friendScoreRequestId = 0;
@ -190,6 +193,8 @@ function updateScore(score: Score) {
function clearFriendScores(message: string) {
friendScoresList.replaceChildren();
friendScoresEmpty.textContent = message;
friendScoresHeaderText.textContent = "frenz?";
friendScoresHeaderImg.src = "assets/notlikesteve.webp";
friendScoresPanel.classList.remove("has-items", "is-loading");
}
@ -197,7 +202,9 @@ 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";
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";
@ -233,7 +240,7 @@ async function refreshMapFriendScores() {
try {
const [leaderboards, mutualFriendIds] = await Promise.all([
fetchBLLeaderboardsByHash(hash),
fetchMutualFriendIds(playerId),
fetchFriendIds(playerId, settings.friendMode),
]);
if (requestId !== friendScoreRequestId) return;
if (leaderboards.length === 0) {
@ -241,7 +248,12 @@ async function refreshMapFriendScores() {
return;
}
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;
}
const scores = await fetchAllMapScoresByHash(hash, leaderboards);
@ -280,6 +292,7 @@ interface Settings {
time: boolean;
score: boolean;
friends: boolean;
friendMode: FriendMode;
bsr: boolean;
debug: boolean;
mockBsr: string;
@ -296,6 +309,7 @@ const settings: Settings = {
time: true,
score: true,
friends: true,
friendMode: "mutual",
bsr: false,
debug: false,
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");
mockBsrInput.value = settings.mockBsr;
mockBsrInput.oninput = () => {
@ -430,6 +452,7 @@ const requestListEl = must<HTMLOListElement>("requestList");
const requestOverlayEl = must<HTMLElement>("requestOverlay");
const requestEmptyEl = must<HTMLElement>("requestEmpty");
const requestTitleCache = new Map<string, string>();
const requestTitleMisses = new Set<string>();
function useRequestHistorySim() {
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) {
if (requestTitleMisses.has(key)) return;
if (requestTitleCache.has(key)) {
titleEl.textContent = requestTitleCache.get(key) ?? "";
return;
}
try {
const map = await fetchBeatSaverMapById(key);
if (!map) return;
if (!map) {
requestTitleMisses.add(key);
return;
}
const name = map.metadata?.songName ?? map.name;
if (name && typeof name === "string") {
requestTitleCache.set(key, name);
titleEl.textContent = name;
return;
}
requestTitleMisses.add(key);
} catch {
// keep !bsr placeholder
requestTitleMisses.add(key);
}
}