Record the phase 5 ScoreSaber merge and map OST hashes in the sync
ScoreSaber names official levels "ost_<LevelId>" where BeatLeader uses "<levelid>"; ssMapHash applies that for leaderboard snapshots and maps. The plan records the merge results and where it differs from the steps. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -733,6 +733,57 @@ profile and scores, and a replay opens in ArcViewer.
|
||||
|
||||
### Phase 5: Convert ScoreSaber scores and merge
|
||||
|
||||
**Built and checked on a scratch copy 2026-11-09; committed, deploy
|
||||
pending.** Server `50f7df6` (`Tools/ArchiveImporter/ScoreMerge.cs`, rules in
|
||||
the fork's `MIRROR.md`), website `5522f245` (SS badge, `/watch/` for `.dat`),
|
||||
exporter `ssMapHash` in this repo. All pushed. Result on the local archive: 3,710 leaderboards with one score
|
||||
each (3,566 BL + 141 SS-only BL leaderboards + 3 DLC levels BL lacks). Winners: 3,564 BL main,
|
||||
1 BL legacy, 139 SS legacy, 6 SS main; 2,022 SS scores were the same play as
|
||||
a BL score and dropped; 20 BL scores got an improvement recomputed against an
|
||||
earlier SS play. An unchanged rerun writes 0 rows. What differs from the
|
||||
steps below:
|
||||
|
||||
- Step 1: **no new columns.** Provenance goes in `Platform`
|
||||
(`scoresaber,<SS player id>`), and `Id` ≥ 1e9 marks SS scores. Columns
|
||||
would have needed a models-fork change, a migration, and edits to the ~90
|
||||
score projections before the website saw them; `Platform` already reaches
|
||||
every score response.
|
||||
- Step 2: OST levels are `ost_<LevelId>` on SS and `<levelid>` on BL; 5 of 8
|
||||
resolve that way. Me Porto Bonito, Nuthin' But A "G" Thang and Stressed Out
|
||||
(3 scores) aren't on BL (404), so the importer builds a song and leaderboard
|
||||
from the SS item (`<LevelId><diff><mode>` ids, no ratings, accuracy 0).
|
||||
The exporter now maps `ost_` hashes too (`scoresaber.ts` `ssMapHash`).
|
||||
HMD and controller come from SS `deviceHmd`/`deviceControllerLeft`. SS
|
||||
replays are served from `storage/ssreplays` → `archive/scoresaber`, so the
|
||||
URL is `/cdn/ssreplays/<player>/replays/<id>.dat`.
|
||||
- **Pp isn't upstream's.** Neither the fork's `rebalanced-pp` formula
|
||||
(median 0.94x, range 0.69–1.16x) nor upstream `master`'s (0.81x)
|
||||
reproduces the pp on archived BL scores; live BL's code is private. So the
|
||||
fold compares every candidate by **locally computed** pp, which keeps the
|
||||
order right, and BL winners keep upstream's pp. The 70 SS winners on
|
||||
ranked maps show local pp, and improvement pp deltas are local on both
|
||||
sides. Matching pp first sent two BL scores (KillerToy, Make Me) to worse
|
||||
SS plays.
|
||||
- Step 3: same-play duplicates (equal base score, ≤ 2 min apart; observed
|
||||
−30 s…+9 s) keep the BL copy. Each upstream `scoreImprovement` is turned
|
||||
back into the best it replaced and added as a candidate, so upstream's
|
||||
improvement stays exactly when that rebuilt best is also the fold's previous
|
||||
best. Older archived BL bests are candidates too.
|
||||
- Step 5: the badge sits next to the rank in both `ScoreRank.svelte` (rank 0
|
||||
already renders "-"). `/watch/` takes the map hash from the score
|
||||
(`/score/{id}` → `song.hash`) for `.dat` replays, from `?scoreId=` or
|
||||
1e9 + the id in the filename. Expanding an SS row shows its leaderboard and
|
||||
no stats tab; the page logs a JSON error for the 404 score stats, harmless.
|
||||
- Check: The Goddess Figure (Normal) is the case where a 2022 legacy SS
|
||||
play beats the BL score; it shows with the SS badge, and the BL Hard score
|
||||
there now reads "+76,821 vs 4 years ago" (vs the legacy SS play). An SS
|
||||
replay redirects to ArcViewer with the `.dat` and local zip and loads the map;
|
||||
watching it play still needs a real browser.
|
||||
|
||||
To deploy: nix-pleb bumps `plebleader-server`, `plebleader-website` and
|
||||
`plebsaber-stream` (sync CLI); switch. The next `plebleader-sync`
|
||||
run re-imports with the merge.
|
||||
|
||||
ScoreSaber scores become BeatLeader `Score` rows, and each leaderboard keeps
|
||||
exactly one score. BeatLeader already shows how a score compares with the
|
||||
previous best (`ScoreImprovement`), so after the merge that comparison covers
|
||||
|
||||
@@ -21,7 +21,7 @@ import * as path from 'node:path';
|
||||
import { ARCHIVE_DIR, BL_API, DEFAULT_MAPS_SINCE, LEGACY_PLAYER_ID, MAIN_PLAYER_ID, PLAYER_IDS, TOKENS_FILE, USER_AGENT, loadDotEnv, parseSince } from './config.ts';
|
||||
import { getAccessToken, login, readTokens, refresh } from './auth.ts';
|
||||
import { archivedScores, blHashes, exportLeaderboards, exportProfile, exportReplays, exportScoreStats, exportScores, newestScoreTime } from './beatleader.ts';
|
||||
import { archivedSSScores, exportSSPlayer, exportSSReplays, exportSSScores, newestSSScoreTime, ssHashes } from './scoresaber.ts';
|
||||
import { archivedSSScores, exportSSPlayer, exportSSReplays, exportSSScores, newestSSScoreTime, ssHashes, ssMapHash } from './scoresaber.ts';
|
||||
import { exportMaps } from './beatsaver.ts';
|
||||
import { archivePath, exists, listFiles, readState, updateState } from './store.ts';
|
||||
|
||||
@@ -83,7 +83,7 @@ function playedHashes(since: number): string[] {
|
||||
function mapHashes(mapsSince: number): string[] {
|
||||
const legacyWithReplay = archivedSSScores(LEGACY_PLAYER_ID, 0)
|
||||
.filter((it) => exists(archivePath('scoresaber', LEGACY_PLAYER_ID, 'replays', `${it.score.id}.dat`)))
|
||||
.map((it) => it.leaderboard.songHash.toLowerCase());
|
||||
.map(ssMapHash);
|
||||
return [...new Set([...playedHashes(mapsSince), ...legacyWithReplay])].sort();
|
||||
}
|
||||
|
||||
@@ -137,7 +137,7 @@ async function sync(opts: { ratings: boolean; ssReplays: boolean }): Promise<num
|
||||
await exportSSPlayer(MAIN_PLAYER_ID);
|
||||
const ssSince = Math.max(0, newestSSScoreTime(MAIN_PLAYER_ID) - SYNC_OVERLAP_S);
|
||||
const newSS = await exportSSScores(MAIN_PLAYER_ID, ssSince);
|
||||
const newSSHashes = [...new Set(newSS.map((it) => it.leaderboard.songHash.toLowerCase()))].sort();
|
||||
const newSSHashes = [...new Set(newSS.map(ssMapHash))].sort();
|
||||
await exportLeaderboards(newSSHashes.filter((h) => !newBLHashes.includes(h)));
|
||||
if (opts.ssReplays) await exportSSReplays(MAIN_PLAYER_ID, ssSince);
|
||||
|
||||
|
||||
@@ -102,6 +102,14 @@ function seedFromScraper(playerId: string, items: SSItem[]): void {
|
||||
if (copied) console.log(` copied ${copied} replays from ${dir}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* The map hash BeatLeader and the archive use for a ScoreSaber item: lowercase, and for official
|
||||
* levels the level id without ScoreSaber's "ost_" prefix (BeatLeader: "beatsaber", SS: "ost_BeatSaber").
|
||||
*/
|
||||
export function ssMapHash(it: SSItem): string {
|
||||
return it.leaderboard.songHash.toLowerCase().replace(/^ost_/, '');
|
||||
}
|
||||
|
||||
export function ssHashes(playerId: string, sinceUnix = 0): string[] {
|
||||
return [...new Set(archivedSSScores(playerId, sinceUnix).map((it) => it.leaderboard.songHash.toLowerCase()))].sort();
|
||||
return [...new Set(archivedSSScores(playerId, sinceUnix).map(ssMapHash))].sort();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user