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