diff --git a/AGENTS.md b/AGENTS.md
index 962e859..b5ec664 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -23,3 +23,17 @@ Do **not** add or maintain code paths for opening the overlay as **`file://`**.
## Out of scope here
Beat Saber Plus itself (game mod) exposes the socket; this repo is only the HTML/CSS/TS client.
+
+## Recent implementation notes (2026-04)
+
+- **Live API calls must be runtime-proxied**: browser/OBS hits CORS on BeatLeader/BeatSaver. Keep live requests same-origin via `src/server/serve.ts`:
+ - `/api/beatleader?path=/...`
+ - `/api/beatsaver?path=/...`
+- **Map correlation is hash-based**: resolve BeatSaver map/hash first, then BeatLeader leaderboards via `/leaderboards/hash/{hash}`.
+- **Score source for friend matching**: use `/leaderboard/{leaderboardId}` scores for stable `playerId` fields; do not rely on v5 score payload shape for player IDs.
+- **Mutual friends definition**: intersection of `/player/{id}/followers?type=Following` and `type=Followers` (paged by `page` + `count` only).
+- **Overlay feature added**: `#friendScores` panel shows best accuracy per mutual friend for current map, sorted DESC.
+- **Debug defaults**:
+ - mock map key: `4f4e4`
+ - debug BeatLeader player id: `76561199407393962`
+ - both debug inputs must have no effect when debug is disabled.
diff --git a/deno.json b/deno.json
index 11cd6ba..6336bc9 100644
--- a/deno.json
+++ b/deno.json
@@ -6,6 +6,8 @@
"tasks": {
"build": "deno bundle --platform=browser --check src/client/index.ts -o index.js",
"serve": "deno task build && deno run --allow-net --allow-read --allow-env src/server/serve.ts",
- "dev": "deno bundle --platform=browser --watch --check src/client/index.ts -o index.js"
+ "dev": "deno bundle --platform=browser --watch --check src/client/index.ts -o index.js",
+ "test:live": "deno test --allow-net src/client/live-api.test.ts",
+ "test:friends": "deno test --allow-net src/client/live-friends.test.ts"
}
}
diff --git a/deno.lock b/deno.lock
index a119795..24a2248 100644
--- a/deno.lock
+++ b/deno.lock
@@ -1,6 +1,7 @@
{
"version": "5",
"specifiers": {
+ "jsr:@std/assert@*": "1.0.19",
"jsr:@std/cli@^1.0.28": "1.0.28",
"jsr:@std/encoding@^1.0.10": "1.0.10",
"jsr:@std/fmt@^1.0.9": "1.0.9",
@@ -15,6 +16,12 @@
"jsr:@std/streams@^1.0.17": "1.0.17"
},
"jsr": {
+ "@std/assert@1.0.19": {
+ "integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e",
+ "dependencies": [
+ "jsr:@std/internal"
+ ]
+ },
"@std/cli@1.0.28": {
"integrity": "74ef9b976db59ca6b23a5283469c9072be6276853807a83ec6c7ce412135c70a"
},
diff --git a/index.css b/index.css
index 422bc0c..cef1898 100644
--- a/index.css
+++ b/index.css
@@ -293,12 +293,63 @@ span:empty {
content: "FC";
}
+/* Mutual friend scores */
+
+#friendScores {
+ display: flex;
+ flex-direction: column;
+ gap: 0.3rem;
+ margin-top: 0.1rem;
+ max-width: 44rem;
+}
+
+#friendScoresHeader {
+ font-size: 1.3rem;
+ font-weight: 700;
+ opacity: 0.92;
+}
+
+#friendScoresList {
+ margin: 0;
+ padding-left: 2rem;
+ font-size: 1.35rem;
+ line-height: 1.2;
+}
+
+#friendScoresList:empty {
+ display: none;
+}
+
+#friendScoresEmpty {
+ font-size: 1.2rem;
+ opacity: 0.82;
+}
+
+#friendScores.has-items #friendScoresEmpty {
+ display: none;
+}
+
+#friendScores.is-loading #friendScoresEmpty {
+ opacity: 1;
+}
+
+.friend-score-item {
+ display: flex;
+ align-items: baseline;
+ gap: 0.7rem;
+}
+
+.friend-acc {
+ font-weight: 700;
+}
+
/* Settings */
body:not(.cover) #coverImg,
body:not(.mapInfo) #mapInfo,
body:not(.time) #time,
body:not(.score) #score,
+body:not(.friends) #friendScores,
body:not(.bsr) #bsrKey {
display: none;
}
@@ -338,3 +389,11 @@ body.bottom #time {
float: right;
margin-left: 1em;
}
+
+body:not(.debug) #mockBsrSetting {
+ display: none;
+}
+
+body:not(.debug) #debugPlayerSetting {
+ display: none;
+}
diff --git a/index.html b/index.html
index af01196..79ac8dd 100644
--- a/index.html
+++ b/index.html
@@ -37,6 +37,11 @@
96.9
7
+
+
@@ -60,6 +65,7 @@
+