diff --git a/docs/plan.md b/docs/plan.md index c0d17eb..955a969 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -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,`), 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_` on SS and `` 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 (`` 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//replays/.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 diff --git a/scripts/archive/cli.ts b/scripts/archive/cli.ts index 2542b9b..6f66bc1 100644 --- a/scripts/archive/cli.ts +++ b/scripts/archive/cli.ts @@ -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 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); diff --git a/scripts/archive/scoresaber.ts b/scripts/archive/scoresaber.ts index a03613d..b2dce9f 100644 --- a/scripts/archive/scoresaber.ts +++ b/scripts/archive/scoresaber.ts @@ -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(); }