Compare commits
2 Commits
86830adc47
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 0df2242f1c | |||
| 3410e3324e |
@@ -26,5 +26,5 @@ Beat Saber Plus itself (game mod) exposes the socket; this repo is only the HTML
|
||||
- **Live API calls are proxied** same-origin via `src/server/serve.ts`: `/api/beatleader?path=/...`, `/api/beatsaver?path=/...` (CORS).
|
||||
- **Map correlation is hash-based**: resolve BeatSaver map/hash first, then BeatLeader leaderboards via `/leaderboards/hash/{hash}`.
|
||||
- **Friend scores** use `/leaderboard/{leaderboardId}` for stable `playerId` fields.
|
||||
- **Mutual friends**: intersection of `/player/{id}/followers?type=Following` and `type=Followers` (paged by `page` + `count`).
|
||||
- **Mutual friends**: intersection of `/player/{id}/followers?type=Following` and `type=Followers` (paged by `page` + `count`). **`all` friends**: union of both lists (deduped).
|
||||
- **`#friendScores`**: best accuracy per friend for the current map, sorted descending.
|
||||
|
||||
@@ -21,10 +21,12 @@ Configuration is `overlay.toml` in the repo root (copy from `overlay.toml.exampl
|
||||
|
||||
- `chat_request_database` — absolute path to Beat Saber Plus `ChatRequest/Database.json`. The server serves that file as `ChatRequest.json` over HTTP (no symlink).
|
||||
- `beatleader_player_id` — default BeatLeader id for friend scores (the server logs a warning if this is missing).
|
||||
- Overlay UI defaults — optional toggles and layout matching the settings dialog: `cover`, `map_info`, `time`, `score`, `friends`, `friend_mode` (`mutual` \| `following` \| `followers`), `bsr`, `right`, `bottom`, `scale`, `fade`.
|
||||
- Overlay UI defaults — optional toggles and layout matching the settings dialog: `cover`, `map_info`, `time`, `score`, `friends`, `friend_mode` (`mutual` \| `following` \| `followers` \| `all`), `bsr`, `right`, `bottom`, `scale`, `fade`.
|
||||
|
||||
Mutual friend mode shows scores from players that you follow and that follow you back on Beatleader. Or in other words, users that are in both your following and follower list.
|
||||
|
||||
Use `friend_mode = "all"` to include anyone you follow **or** anyone who follows you (union, deduped by player id).
|
||||
|
||||
## Usage
|
||||
|
||||
Clone the repo and run `deno task serve` as above.
|
||||
|
||||
@@ -317,6 +317,48 @@ span:empty {
|
||||
max-width: 44rem;
|
||||
}
|
||||
|
||||
.personal-best-row {
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
font-size: 1.3rem;
|
||||
font-weight: 700;
|
||||
opacity: 0.92;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
#friendScores.has-personal-best .personal-best-row {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.personal-best-label {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
opacity: 0.75;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.personal-best-acc {
|
||||
font-feature-settings: "tnum";
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.personal-best-delta {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
font-feature-settings: "tnum";
|
||||
font-variant-numeric: tabular-nums;
|
||||
min-width: 4.2ch;
|
||||
}
|
||||
|
||||
.personal-best-delta.personal-best-delta--ahead {
|
||||
color: #3ddc97;
|
||||
}
|
||||
|
||||
.personal-best-delta.personal-best-delta--behind {
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
#friendScoresHeader {
|
||||
font-size: 1.3rem;
|
||||
font-weight: 700;
|
||||
|
||||
@@ -39,6 +39,11 @@
|
||||
</div>
|
||||
</div>
|
||||
<div id="friendScores" aria-live="polite">
|
||||
<div id="personalBestRow" class="personal-best-row">
|
||||
<span class="personal-best-label">PB</span>
|
||||
<span id="personalBestAcc" class="personal-best-acc"></span>
|
||||
<span id="personalBestDelta" class="personal-best-delta"></span>
|
||||
</div>
|
||||
<div id="friendScoresHeader"><img id="friendScoresPlayerAvatar" src="images/unknown.svg" alt=""> <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>
|
||||
@@ -70,6 +75,7 @@
|
||||
<option value="mutual">Followed + follower (mutual)</option>
|
||||
<option value="following">Following (I follow them)</option>
|
||||
<option value="followers">Followers (they follow me)</option>
|
||||
<option value="all">All friends (following or followers)</option>
|
||||
</select></label>
|
||||
<label id="beatLeaderPlayerSetting">Player id: <span class="beatLeaderPlayerRow">
|
||||
<span class="debugSongIdHint">e.g. <button type="button" id="beatLeaderPlayerExample" title="Fill with pleb's numeric id">pleb</button></span>
|
||||
|
||||
@@ -188,6 +188,23 @@ async function fetchFriends(playerId, mode, maxPages = 100) {
|
||||
if (mode === "followers") {
|
||||
return followers.map((entry) => normalizeFollowerEntry(entry));
|
||||
}
|
||||
if (mode === "all") {
|
||||
const seen = /* @__PURE__ */ new Set();
|
||||
const out = [];
|
||||
for (const entry of following) {
|
||||
const id = String(entry.id);
|
||||
if (seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
out.push(normalizeFollowerEntry(entry));
|
||||
}
|
||||
for (const entry of followers) {
|
||||
const id = String(entry.id);
|
||||
if (seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
out.push(normalizeFollowerEntry(entry));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return followers.filter((entry) => followingIds.has(String(entry.id))).map((entry) => normalizeFollowerEntry(entry));
|
||||
}
|
||||
function normalizeAccuracy(value) {
|
||||
@@ -246,6 +263,9 @@ var beatSaberPlus = {
|
||||
switch (data._event) {
|
||||
case "gameState":
|
||||
document.body.dataset.gameState = data.gameStateChanged;
|
||||
if (data.gameStateChanged === "Menu") {
|
||||
resetPersonalBestDeltaPlaceholder();
|
||||
}
|
||||
break;
|
||||
case "mapInfo":
|
||||
void updateMapInfo(data.mapInfoChanged);
|
||||
@@ -475,6 +495,9 @@ var friendScoresEmpty = must("friendScoresEmpty");
|
||||
var friendScoresHeaderText = must("friendScoresHeaderText");
|
||||
var friendScoresPlayerAvatar = must("friendScoresPlayerAvatar");
|
||||
var friendScoresHeaderImg = must("friendScoresHeaderImg");
|
||||
var personalBestAcc = must("personalBestAcc");
|
||||
var personalBestDelta = must("personalBestDelta");
|
||||
var currentPbAccuracyPercent = null;
|
||||
var cachedConfiguredPlayerAvatarKey = "";
|
||||
var cachedConfiguredPlayerAvatarSrc = "images/unknown.svg";
|
||||
async function refreshConfiguredPlayerAvatar() {
|
||||
@@ -497,11 +520,42 @@ async function refreshConfiguredPlayerAvatar() {
|
||||
cachedConfiguredPlayerAvatarSrc = profile?.avatar?.trim() || "images/unknown.svg";
|
||||
friendScoresPlayerAvatar.src = cachedConfiguredPlayerAvatarSrc;
|
||||
}
|
||||
function applyPersonalBestRow(pbPercent) {
|
||||
currentPbAccuracyPercent = pbPercent;
|
||||
friendScoresPanel.classList.toggle("has-personal-best", pbPercent !== null);
|
||||
if (pbPercent === null) {
|
||||
personalBestAcc.textContent = "";
|
||||
personalBestDelta.textContent = "";
|
||||
personalBestDelta.classList.remove("personal-best-delta--ahead", "personal-best-delta--behind");
|
||||
return;
|
||||
}
|
||||
personalBestAcc.textContent = `${pbPercent.toFixed(2)}%`;
|
||||
resetPersonalBestDeltaPlaceholder();
|
||||
}
|
||||
function resetPersonalBestDeltaPlaceholder() {
|
||||
if (currentPbAccuracyPercent === null) return;
|
||||
personalBestDelta.textContent = "\u2014";
|
||||
personalBestDelta.classList.remove("personal-best-delta--ahead", "personal-best-delta--behind");
|
||||
}
|
||||
function updatePersonalBestDelta(liveAccuracyPercent) {
|
||||
if (currentPbAccuracyPercent === null) return;
|
||||
const delta = liveAccuracyPercent - currentPbAccuracyPercent;
|
||||
personalBestDelta.textContent = `${delta >= 0 ? "+" : ""}${delta.toFixed(2)}%`;
|
||||
personalBestDelta.classList.toggle("personal-best-delta--ahead", delta > 5e-4);
|
||||
personalBestDelta.classList.toggle("personal-best-delta--behind", delta < -5e-4);
|
||||
if (Math.abs(delta) <= 5e-4) {
|
||||
personalBestDelta.classList.remove("personal-best-delta--ahead", "personal-best-delta--behind");
|
||||
}
|
||||
}
|
||||
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);
|
||||
if (settings.score) {
|
||||
accuracy.textContent = (score.accuracy * 100).toFixed(1);
|
||||
mistakes.textContent = score.missCount ? String(score.missCount) : "";
|
||||
accuracy.classList.toggle("failed", score.currentHealth === 0);
|
||||
}
|
||||
if (settings.friends) {
|
||||
updatePersonalBestDelta(score.accuracy * 100);
|
||||
}
|
||||
}
|
||||
function avatarFromScore(score) {
|
||||
if (typeof score.player === "object" && score.player?.avatar) {
|
||||
@@ -511,6 +565,7 @@ function avatarFromScore(score) {
|
||||
return url || null;
|
||||
}
|
||||
function clearFriendScores(message) {
|
||||
applyPersonalBestRow(null);
|
||||
friendScoresList.replaceChildren();
|
||||
friendScoresEmpty.textContent = message;
|
||||
friendScoresHeaderText.textContent = "frenz?";
|
||||
@@ -548,6 +603,7 @@ function friendsRelationListKey(playerId) {
|
||||
}
|
||||
function beginFriendScoresForNewMapContext() {
|
||||
friendScoreRequestId += 1;
|
||||
applyPersonalBestRow(null);
|
||||
if (!settings.friends) return;
|
||||
if (!currentMapHash) {
|
||||
clearFriendScores("No map loaded");
|
||||
@@ -561,7 +617,7 @@ function beginFriendScoresForNewMapContext() {
|
||||
friendScoresList.replaceChildren();
|
||||
friendScoresPanel.classList.remove("has-items");
|
||||
friendScoresPanel.classList.add("is-loading");
|
||||
friendScoresEmpty.textContent = "Loading mutual friend scores...";
|
||||
friendScoresEmpty.textContent = "Loading friend scores...";
|
||||
}
|
||||
async function refreshMapFriendScores() {
|
||||
const hash = currentMapHash;
|
||||
@@ -578,10 +634,11 @@ async function refreshMapFriendScores() {
|
||||
clearFriendScores("Waiting for BeatLeader player id");
|
||||
return;
|
||||
}
|
||||
applyPersonalBestRow(null);
|
||||
friendScoresList.replaceChildren();
|
||||
friendScoresPanel.classList.remove("has-items");
|
||||
friendScoresPanel.classList.add("is-loading");
|
||||
friendScoresEmpty.textContent = "Loading mutual friend scores...";
|
||||
friendScoresEmpty.textContent = "Loading friend scores...";
|
||||
const requestId = ++friendScoreRequestId;
|
||||
try {
|
||||
const relKey = friendsRelationListKey(playerId);
|
||||
@@ -594,10 +651,12 @@ async function refreshMapFriendScores() {
|
||||
friendsRelationCache = fetched;
|
||||
return fetched;
|
||||
})();
|
||||
const [leaderboards, friends] = await Promise.all([
|
||||
const [leaderboards, friends, selfProfile] = await Promise.all([
|
||||
fetchBLLeaderboardsByHash(hash),
|
||||
friendsPromise
|
||||
friendsPromise,
|
||||
fetchBeatLeaderPlayer(playerId)
|
||||
]);
|
||||
const myBeatLeaderId = selfProfile?.id ?? playerId;
|
||||
if (requestId !== friendScoreRequestId) return;
|
||||
if (leaderboards.length === 0) {
|
||||
clearFriendScores("No BeatLeader leaderboards found");
|
||||
@@ -614,12 +673,21 @@ async function refreshMapFriendScores() {
|
||||
]));
|
||||
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";
|
||||
const relationLabel = settings.friendMode === "following" ? "No followed BeatLeader players" : settings.friendMode === "followers" ? "No BeatLeader followers" : settings.friendMode === "all" ? "No followed or follower BeatLeader players" : "No mutual BeatLeader followers";
|
||||
clearFriendScores(relationLabel);
|
||||
return;
|
||||
}
|
||||
const scores = await fetchAllMapScoresByHash(hash, forPlayMode);
|
||||
if (requestId !== friendScoreRequestId) return;
|
||||
let playerPbAcc = null;
|
||||
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 || playerKey !== String(myBeatLeaderId)) continue;
|
||||
const acc = normalizeAccuracy(score.accuracy ?? score.acc);
|
||||
if (acc === null) continue;
|
||||
if (playerPbAcc === null || acc > playerPbAcc) playerPbAcc = acc;
|
||||
}
|
||||
const bestByPlayer = /* @__PURE__ */ new Map();
|
||||
for (const score of scores) {
|
||||
const scorePlayerId = score.playerId ?? (typeof score.player === "object" ? score.player?.id : null);
|
||||
@@ -640,6 +708,10 @@ async function refreshMapFriendScores() {
|
||||
});
|
||||
}
|
||||
}
|
||||
if (playerPbAcc !== null) {
|
||||
bestByPlayer.delete(String(myBeatLeaderId));
|
||||
}
|
||||
applyPersonalBestRow(playerPbAcc);
|
||||
const sorted = Array.from(bestByPlayer.values()).sort((a, b) => b.acc - a.acc);
|
||||
renderFriendScores(sorted);
|
||||
} catch {
|
||||
|
||||
@@ -12,7 +12,7 @@ map_info = true
|
||||
time = true
|
||||
score = true
|
||||
friends = true
|
||||
friend_mode = "mutual" # mutual | following | followers
|
||||
friend_mode = "mutual" # mutual | following | followers | all
|
||||
bsr = false
|
||||
right = false
|
||||
bottom = true
|
||||
|
||||
@@ -196,6 +196,23 @@ export async function fetchFriends(playerId: string, mode: FriendMode, maxPages
|
||||
if (mode === "followers") {
|
||||
return followers.map((entry) => normalizeFollowerEntry(entry as BeatLeaderFollower));
|
||||
}
|
||||
if (mode === "all") {
|
||||
const seen = new Set<string>();
|
||||
const out: BeatLeaderFollower[] = [];
|
||||
for (const entry of following) {
|
||||
const id = String(entry.id);
|
||||
if (seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
out.push(normalizeFollowerEntry(entry as BeatLeaderFollower));
|
||||
}
|
||||
for (const entry of followers) {
|
||||
const id = String(entry.id);
|
||||
if (seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
out.push(normalizeFollowerEntry(entry as BeatLeaderFollower));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return followers
|
||||
.filter((entry) => followingIds.has(String(entry.id)))
|
||||
.map((entry) => normalizeFollowerEntry(entry as BeatLeaderFollower));
|
||||
|
||||
+69
-7
@@ -72,6 +72,9 @@ const beatSaberPlus = {
|
||||
switch (data._event) {
|
||||
case "gameState":
|
||||
document.body.dataset.gameState = data.gameStateChanged;
|
||||
if (data.gameStateChanged === "Menu") {
|
||||
resetPersonalBestDeltaPlaceholder();
|
||||
}
|
||||
break;
|
||||
case "mapInfo":
|
||||
void updateMapInfo(data.mapInfoChanged);
|
||||
@@ -342,6 +345,11 @@ const friendScoresEmpty = must<HTMLElement>("friendScoresEmpty");
|
||||
const friendScoresHeaderText = must<HTMLElement>("friendScoresHeaderText");
|
||||
const friendScoresPlayerAvatar = must<HTMLImageElement>("friendScoresPlayerAvatar");
|
||||
const friendScoresHeaderImg = must<HTMLImageElement>("friendScoresHeaderImg");
|
||||
const personalBestAcc = must<HTMLElement>("personalBestAcc");
|
||||
const personalBestDelta = must<HTMLElement>("personalBestDelta");
|
||||
|
||||
/** BeatLeader PB (percent) for the current map + difficulty; null if none or friends panel inactive. */
|
||||
let currentPbAccuracyPercent: number | null = null;
|
||||
|
||||
let cachedConfiguredPlayerAvatarKey = "";
|
||||
let cachedConfiguredPlayerAvatarSrc = "images/unknown.svg";
|
||||
@@ -367,11 +375,45 @@ async function refreshConfiguredPlayerAvatar() {
|
||||
friendScoresPlayerAvatar.src = cachedConfiguredPlayerAvatarSrc;
|
||||
}
|
||||
|
||||
function applyPersonalBestRow(pbPercent: number | null) {
|
||||
currentPbAccuracyPercent = pbPercent;
|
||||
friendScoresPanel.classList.toggle("has-personal-best", pbPercent !== null);
|
||||
if (pbPercent === null) {
|
||||
personalBestAcc.textContent = "";
|
||||
personalBestDelta.textContent = "";
|
||||
personalBestDelta.classList.remove("personal-best-delta--ahead", "personal-best-delta--behind");
|
||||
return;
|
||||
}
|
||||
personalBestAcc.textContent = `${pbPercent.toFixed(2)}%`;
|
||||
resetPersonalBestDeltaPlaceholder();
|
||||
}
|
||||
|
||||
function resetPersonalBestDeltaPlaceholder() {
|
||||
if (currentPbAccuracyPercent === null) return;
|
||||
personalBestDelta.textContent = "—";
|
||||
personalBestDelta.classList.remove("personal-best-delta--ahead", "personal-best-delta--behind");
|
||||
}
|
||||
|
||||
function updatePersonalBestDelta(liveAccuracyPercent: number) {
|
||||
if (currentPbAccuracyPercent === null) return;
|
||||
const delta = liveAccuracyPercent - currentPbAccuracyPercent;
|
||||
personalBestDelta.textContent = `${delta >= 0 ? "+" : ""}${delta.toFixed(2)}%`;
|
||||
personalBestDelta.classList.toggle("personal-best-delta--ahead", delta > 0.0005);
|
||||
personalBestDelta.classList.toggle("personal-best-delta--behind", delta < -0.0005);
|
||||
if (Math.abs(delta) <= 0.0005) {
|
||||
personalBestDelta.classList.remove("personal-best-delta--ahead", "personal-best-delta--behind");
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
if (settings.score) {
|
||||
accuracy.textContent = (score.accuracy * 100).toFixed(1);
|
||||
mistakes.textContent = score.missCount ? String(score.missCount) : "";
|
||||
accuracy.classList.toggle("failed", score.currentHealth === 0);
|
||||
}
|
||||
if (settings.friends) {
|
||||
updatePersonalBestDelta(score.accuracy * 100);
|
||||
}
|
||||
}
|
||||
|
||||
function avatarFromScore(score: BeatLeaderScore): string | null {
|
||||
@@ -383,6 +425,7 @@ function avatarFromScore(score: BeatLeaderScore): string | null {
|
||||
}
|
||||
|
||||
function clearFriendScores(message: string) {
|
||||
applyPersonalBestRow(null);
|
||||
friendScoresList.replaceChildren();
|
||||
friendScoresEmpty.textContent = message;
|
||||
friendScoresHeaderText.textContent = "frenz?";
|
||||
@@ -427,6 +470,7 @@ function friendsRelationListKey(playerId: string): string {
|
||||
*/
|
||||
function beginFriendScoresForNewMapContext() {
|
||||
friendScoreRequestId += 1;
|
||||
applyPersonalBestRow(null);
|
||||
if (!settings.friends) return;
|
||||
if (!currentMapHash) {
|
||||
clearFriendScores("No map loaded");
|
||||
@@ -440,7 +484,7 @@ function beginFriendScoresForNewMapContext() {
|
||||
friendScoresList.replaceChildren();
|
||||
friendScoresPanel.classList.remove("has-items");
|
||||
friendScoresPanel.classList.add("is-loading");
|
||||
friendScoresEmpty.textContent = "Loading mutual friend scores...";
|
||||
friendScoresEmpty.textContent = "Loading friend scores...";
|
||||
}
|
||||
|
||||
async function refreshMapFriendScores() {
|
||||
@@ -458,10 +502,11 @@ async function refreshMapFriendScores() {
|
||||
clearFriendScores("Waiting for BeatLeader player id");
|
||||
return;
|
||||
}
|
||||
applyPersonalBestRow(null);
|
||||
friendScoresList.replaceChildren();
|
||||
friendScoresPanel.classList.remove("has-items");
|
||||
friendScoresPanel.classList.add("is-loading");
|
||||
friendScoresEmpty.textContent = "Loading mutual friend scores...";
|
||||
friendScoresEmpty.textContent = "Loading friend scores...";
|
||||
const requestId = ++friendScoreRequestId;
|
||||
try {
|
||||
const relKey = friendsRelationListKey(playerId);
|
||||
@@ -474,10 +519,12 @@ async function refreshMapFriendScores() {
|
||||
friendsRelationCache = fetched;
|
||||
return fetched;
|
||||
})();
|
||||
const [leaderboards, friends] = await Promise.all([
|
||||
const [leaderboards, friends, selfProfile] = await Promise.all([
|
||||
fetchBLLeaderboardsByHash(hash),
|
||||
friendsPromise,
|
||||
fetchBeatLeaderPlayer(playerId),
|
||||
]);
|
||||
const myBeatLeaderId = selfProfile?.id ?? playerId;
|
||||
if (requestId !== friendScoreRequestId) return;
|
||||
if (leaderboards.length === 0) {
|
||||
clearFriendScores("No BeatLeader leaderboards found");
|
||||
@@ -495,12 +542,23 @@ async function refreshMapFriendScores() {
|
||||
? "No followed BeatLeader players"
|
||||
: settings.friendMode === "followers"
|
||||
? "No BeatLeader followers"
|
||||
: settings.friendMode === "all"
|
||||
? "No followed or follower BeatLeader players"
|
||||
: "No mutual BeatLeader followers";
|
||||
clearFriendScores(relationLabel);
|
||||
return;
|
||||
}
|
||||
const scores = await fetchAllMapScoresByHash(hash, forPlayMode);
|
||||
if (requestId !== friendScoreRequestId) return;
|
||||
let playerPbAcc: number | null = null;
|
||||
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 || playerKey !== String(myBeatLeaderId)) continue;
|
||||
const acc = normalizeAccuracy(score.accuracy ?? score.acc);
|
||||
if (acc === null) continue;
|
||||
if (playerPbAcc === null || acc > playerPbAcc) playerPbAcc = acc;
|
||||
}
|
||||
const bestByPlayer = new Map<string, { name: string; acc: number; avatar: string | null }>();
|
||||
for (const score of scores) {
|
||||
const scorePlayerId = score.playerId ?? (typeof score.player === "object" ? score.player?.id : null);
|
||||
@@ -523,6 +581,10 @@ async function refreshMapFriendScores() {
|
||||
});
|
||||
}
|
||||
}
|
||||
if (playerPbAcc !== null) {
|
||||
bestByPlayer.delete(String(myBeatLeaderId));
|
||||
}
|
||||
applyPersonalBestRow(playerPbAcc);
|
||||
const sorted = Array.from(bestByPlayer.values()).sort((a, b) => b.acc - a.acc);
|
||||
renderFriendScores(sorted);
|
||||
} catch {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { assert } from "jsr:@std/assert";
|
||||
import { assert, assertGreaterOrEqual } from "jsr:@std/assert";
|
||||
import { fetchFriends } from "./beatleader.ts";
|
||||
|
||||
const PLAYER_ID = "76561199407393962";
|
||||
@@ -12,3 +12,20 @@ Deno.test({
|
||||
assert(mutuals.length > 0, `Expected mutual friends for player ${PLAYER_ID}`);
|
||||
},
|
||||
});
|
||||
|
||||
Deno.test({
|
||||
name: "live lookup: all friend mode is superset of mutual",
|
||||
sanitizeOps: false,
|
||||
sanitizeResources: false,
|
||||
async fn() {
|
||||
const [mutuals, allFriends] = await Promise.all([
|
||||
fetchFriends(PLAYER_ID, "mutual", 100),
|
||||
fetchFriends(PLAYER_ID, "all", 100),
|
||||
]);
|
||||
assertGreaterOrEqual(allFriends.length, mutuals.length, "Expected union count >= mutual count");
|
||||
const allIds = new Set(allFriends.map((f) => f.id));
|
||||
for (const m of mutuals) {
|
||||
assert(allIds.has(m.id), `Expected mutual id ${m.id} in all friend list`);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -21,7 +21,7 @@ export interface OverlayConfigApiBody {
|
||||
defaults: Partial<OverlaySettings>;
|
||||
}
|
||||
|
||||
const FRIEND_MODES = new Set<FriendMode>(["mutual", "following", "followers"]);
|
||||
const FRIEND_MODES = new Set<FriendMode>(["mutual", "following", "followers", "all"]);
|
||||
|
||||
/** Merge `/api/overlay-config` into the object used as `defaults` before applying the URL hash. */
|
||||
export function mergeOverlayConfigResponse(
|
||||
@@ -84,7 +84,7 @@ export function overlayTomlToDefaults(toml: OverlayToml): {
|
||||
if (FRIEND_MODES.has(raw as FriendMode)) defaults.friendMode = raw as FriendMode;
|
||||
else {
|
||||
warnings.push(
|
||||
`overlay.toml: invalid friend_mode "${toml.friend_mode}" (expected mutual, following, or followers)`,
|
||||
`overlay.toml: invalid friend_mode "${toml.friend_mode}" (expected mutual, following, followers, or all)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
// https://github.com/hardcpp/BeatSaberPlus/wiki/%5BEN%5D-Song-Overlay
|
||||
|
||||
export type FriendMode = "mutual" | "following" | "followers";
|
||||
export type FriendMode = "mutual" | "following" | "followers" | "all";
|
||||
|
||||
/** Overlay UI + BeatLeader id (URL hash overrides defaults from overlay.toml / `/api/overlay-config`). */
|
||||
export interface OverlaySettings {
|
||||
|
||||
Reference in New Issue
Block a user