refactor utils
This commit is contained in:
parent
ef2db550db
commit
0a031469cc
@ -4,13 +4,10 @@
|
|||||||
"path": "."
|
"path": "."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "../../../src/beatleader-website"
|
"path": "../../../src/beatleader/beatleader-website"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "../../../src/beatleader-server"
|
"path": "../../../src/beatleader/beatleader-server"
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "../../../src/beatleader-mod"
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"settings": {}
|
"settings": {}
|
||||||
|
|||||||
@ -29,7 +29,7 @@
|
|||||||
class="h-full w-full object-cover"
|
class="h-full w-full object-cover"
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="h-full w-full flex items-center justify-center text-xs text-muted">No cover</div>
|
<div class="h-full w-full flex items-center justify-center text-2xl">☁️</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="p-3">
|
<div class="p-3">
|
||||||
|
|||||||
486
src/lib/utils/plebsaber-utils.ts
Normal file
486
src/lib/utils/plebsaber-utils.ts
Normal file
@ -0,0 +1,486 @@
|
|||||||
|
/**
|
||||||
|
* Shared utilities for PlebSaber tools
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 1. Type Definitions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type MapMeta = {
|
||||||
|
songName?: string;
|
||||||
|
key?: string;
|
||||||
|
coverURL?: string;
|
||||||
|
mapper?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StarInfo = {
|
||||||
|
stars?: number;
|
||||||
|
accRating?: number;
|
||||||
|
passRating?: number;
|
||||||
|
techRating?: number;
|
||||||
|
status?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BeatLeaderScore = {
|
||||||
|
timeset?: string | number;
|
||||||
|
accuracy?: number;
|
||||||
|
acc?: number;
|
||||||
|
rank?: number;
|
||||||
|
leaderboard?: {
|
||||||
|
id?: string | number | null;
|
||||||
|
leaderboardId?: string | number | null;
|
||||||
|
song?: { hash?: string | null };
|
||||||
|
difficulty?: { value?: number | string | null; modeName?: string | null };
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BeatLeaderScoresResponse = {
|
||||||
|
data?: BeatLeaderScore[];
|
||||||
|
metadata?: { page?: number; itemsPerPage?: number; total?: number };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Difficulty = {
|
||||||
|
name: string;
|
||||||
|
characteristic: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 2. Constants
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const ONE_YEAR_SECONDS = 365 * 24 * 60 * 60;
|
||||||
|
|
||||||
|
export const DIFFICULTIES = ['Easy', 'Normal', 'Hard', 'Expert', 'ExpertPlus'] as const;
|
||||||
|
|
||||||
|
export const MODES = ['Standard', 'Lawless', 'OneSaber', 'NoArrows', 'Lightshow'] as const;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 3. BeatSaver & BeatLeader API Functions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch BeatSaver metadata for a given song hash
|
||||||
|
*/
|
||||||
|
export async function fetchBeatSaverMeta(hash: string): Promise<MapMeta | null> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`https://api.beatsaver.com/maps/hash/${encodeURIComponent(hash)}`);
|
||||||
|
if (!res.ok) throw new Error(String(res.status));
|
||||||
|
const data: any = await res.json();
|
||||||
|
const cover = data?.versions?.[0]?.coverURL ?? `https://cdn.beatsaver.com/${hash.toLowerCase()}.jpg`;
|
||||||
|
return {
|
||||||
|
songName: data?.metadata?.songName ?? data?.name ?? undefined,
|
||||||
|
key: data?.id ?? undefined,
|
||||||
|
coverURL: cover,
|
||||||
|
mapper: data?.uploader?.name ?? undefined
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
// Fallback to CDN cover only
|
||||||
|
return { coverURL: `https://cdn.beatsaver.com/${hash.toLowerCase()}.jpg` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch BeatLeader star ratings for a given song hash
|
||||||
|
* Returns a map keyed by `${hash}|${difficultyName}|${modeName}`
|
||||||
|
*/
|
||||||
|
export async function fetchBeatLeaderStarsByHash(
|
||||||
|
hash: string,
|
||||||
|
normalizeDifficultyName: (value: number | string | null | undefined) => string
|
||||||
|
): Promise<Record<string, StarInfo>> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/beatleader?path=/leaderboards/hash/${encodeURIComponent(hash)}`);
|
||||||
|
if (!res.ok) return {};
|
||||||
|
const data: any = await res.json();
|
||||||
|
const leaderboards: any[] = Array.isArray(data?.leaderboards) ? data.leaderboards : Array.isArray(data) ? data : [];
|
||||||
|
const result: Record<string, StarInfo> = {};
|
||||||
|
for (const lb of leaderboards) {
|
||||||
|
const diffName: string | undefined = lb?.difficulty?.difficultyName ?? lb?.difficulty?.name ?? undefined;
|
||||||
|
const modeName: string | undefined = lb?.difficulty?.modeName ?? lb?.modeName ?? 'Standard';
|
||||||
|
if (!diffName || !modeName) continue;
|
||||||
|
const normalized = normalizeDifficultyName(diffName);
|
||||||
|
const key = `${hash}|${normalized}|${modeName}`;
|
||||||
|
const info: StarInfo = {
|
||||||
|
stars: lb?.difficulty?.stars ?? lb?.stars,
|
||||||
|
accRating: lb?.difficulty?.accRating,
|
||||||
|
passRating: lb?.difficulty?.passRating,
|
||||||
|
techRating: lb?.difficulty?.techRating,
|
||||||
|
status: lb?.difficulty?.status
|
||||||
|
};
|
||||||
|
result[key] = info;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load metadata for a list of items with unique hashes
|
||||||
|
* Only loads metadata that isn't already in the cache
|
||||||
|
*/
|
||||||
|
export async function loadMetaForHashes(
|
||||||
|
hashes: string[],
|
||||||
|
existingCache: Record<string, MapMeta>,
|
||||||
|
onProgress?: (loaded: number, total: number) => void
|
||||||
|
): Promise<Record<string, MapMeta>> {
|
||||||
|
const uniqueHashes = Array.from(new Set(hashes));
|
||||||
|
const needed = uniqueHashes.filter((h) => !existingCache[h]);
|
||||||
|
if (needed.length === 0) return existingCache;
|
||||||
|
|
||||||
|
const newCache = { ...existingCache };
|
||||||
|
for (let i = 0; i < needed.length; i++) {
|
||||||
|
const h = needed[i];
|
||||||
|
const meta = await fetchBeatSaverMeta(h);
|
||||||
|
if (meta) newCache[h] = meta;
|
||||||
|
if (onProgress) onProgress(i + 1, needed.length);
|
||||||
|
}
|
||||||
|
return newCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load star ratings for a list of hashes
|
||||||
|
* Only loads ratings that aren't already in the cache
|
||||||
|
*/
|
||||||
|
export async function loadStarsForHashes(
|
||||||
|
hashes: string[],
|
||||||
|
existingCache: Record<string, StarInfo>,
|
||||||
|
normalizeFn: (value: number | string | null | undefined) => string,
|
||||||
|
onProgress?: (loaded: number, total: number) => void
|
||||||
|
): Promise<Record<string, StarInfo>> {
|
||||||
|
const uniqueHashes = Array.from(new Set(hashes));
|
||||||
|
// Check if we need to load stars for these hashes
|
||||||
|
const needed = uniqueHashes.filter((h) => {
|
||||||
|
// Check if we have any star data for this hash
|
||||||
|
return !Object.keys(existingCache).some(key => key.startsWith(`${h}|`));
|
||||||
|
});
|
||||||
|
|
||||||
|
if (needed.length === 0) return existingCache;
|
||||||
|
|
||||||
|
const newCache = { ...existingCache };
|
||||||
|
for (let i = 0; i < needed.length; i++) {
|
||||||
|
const h = needed[i];
|
||||||
|
const stars = await fetchBeatLeaderStarsByHash(h, normalizeFn);
|
||||||
|
Object.assign(newCache, stars);
|
||||||
|
if (onProgress) onProgress(i + 1, needed.length);
|
||||||
|
}
|
||||||
|
return newCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch recent scores for a player filtered by difficulty
|
||||||
|
*/
|
||||||
|
export async function fetchAllRecentScoresForDiff(
|
||||||
|
playerId: string,
|
||||||
|
cutoffEpoch: number,
|
||||||
|
reqDiff: string,
|
||||||
|
maxPages = 100
|
||||||
|
): Promise<BeatLeaderScore[]> {
|
||||||
|
const qs = new URLSearchParams({ diff: reqDiff, cutoffEpoch: String(cutoffEpoch), maxPages: String(maxPages) });
|
||||||
|
const url = `/api/beatleader-cache/player/${encodeURIComponent(playerId)}?${qs.toString()}`;
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (!res.ok) throw new Error(`Failed to fetch scores for ${playerId}: ${res.status}`);
|
||||||
|
const data = (await res.json()) as BeatLeaderScoresResponse;
|
||||||
|
return data.data ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch recent scores for a player across all difficulties
|
||||||
|
*/
|
||||||
|
export async function fetchAllRecentScoresAllDiffs(
|
||||||
|
playerId: string,
|
||||||
|
cutoffEpoch: number,
|
||||||
|
normalizeFn: (value: number | string | null | undefined) => string,
|
||||||
|
parseFn: (ts: string | number | undefined) => number
|
||||||
|
): Promise<BeatLeaderScore[]> {
|
||||||
|
const arrays = await Promise.all(
|
||||||
|
DIFFICULTIES.map((d) => fetchAllRecentScoresForDiff(playerId, cutoffEpoch, d))
|
||||||
|
);
|
||||||
|
// Merge and dedupe by leaderboard key (hash|diff|mode) and timeset
|
||||||
|
const merged = new Map<string, BeatLeaderScore>();
|
||||||
|
for (const arr of arrays) {
|
||||||
|
for (const s of arr) {
|
||||||
|
const rawHash = s.leaderboard?.song?.hash ?? undefined;
|
||||||
|
const modeName = s.leaderboard?.difficulty?.modeName ?? 'Standard';
|
||||||
|
if (!rawHash) continue;
|
||||||
|
const hashLower = String(rawHash).toLowerCase();
|
||||||
|
const diffName = normalizeFn(s.leaderboard?.difficulty?.value ?? undefined);
|
||||||
|
const key = `${hashLower}|${diffName}|${modeName}`;
|
||||||
|
const prev = merged.get(key);
|
||||||
|
if (!prev || parseFn(prev.timeset) < parseFn(s.timeset)) merged.set(key, s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(merged.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 4. Data Processing Helpers
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize difficulty names to standard format
|
||||||
|
*/
|
||||||
|
export function normalizeDifficultyName(value: number | string | null | undefined): string {
|
||||||
|
if (value === null || value === undefined) return 'ExpertPlus';
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const v = value.toLowerCase();
|
||||||
|
if (v.includes('expertplus') || v === 'expertplus' || v === 'ex+' || v.includes('ex+')) return 'ExpertPlus';
|
||||||
|
if (v.includes('expert')) return 'Expert';
|
||||||
|
if (v.includes('hard')) return 'Hard';
|
||||||
|
if (v.includes('normal')) return 'Normal';
|
||||||
|
if (v.includes('easy')) return 'Easy';
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
switch (value) {
|
||||||
|
case 1: return 'Easy';
|
||||||
|
case 3: return 'Normal';
|
||||||
|
case 5: return 'Hard';
|
||||||
|
case 7: return 'Expert';
|
||||||
|
case 9: return 'ExpertPlus';
|
||||||
|
default: return 'ExpertPlus';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a timeset value to a number
|
||||||
|
*/
|
||||||
|
export function parseTimeset(ts: string | number | undefined): number {
|
||||||
|
if (ts === undefined) return 0;
|
||||||
|
if (typeof ts === 'number') return ts;
|
||||||
|
const n = Number(ts);
|
||||||
|
return Number.isFinite(n) ? n : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cutoff epoch timestamp from months ago
|
||||||
|
*/
|
||||||
|
export function getCutoffEpochFromMonths(months: number | string): number {
|
||||||
|
const m = Number(months) || 0;
|
||||||
|
const seconds = Math.max(0, m) * 30 * 24 * 60 * 60; // approx 30 days per month
|
||||||
|
return Math.floor(Date.now() / 1000) - seconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize accuracy value to percentage (0-100)
|
||||||
|
*/
|
||||||
|
export function normalizeAccuracy(value: number | undefined): number | null {
|
||||||
|
if (value === undefined || value === null) return null;
|
||||||
|
return value <= 1 ? value * 100 : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a map of latest scores by key (hash|diff|mode)
|
||||||
|
*/
|
||||||
|
export function buildLatestByKey(
|
||||||
|
scores: BeatLeaderScore[],
|
||||||
|
cutoffEpoch: number,
|
||||||
|
normalizeFn: (value: number | string | null | undefined) => string,
|
||||||
|
parseFn: (ts: string | number | undefined) => number
|
||||||
|
): Map<string, BeatLeaderScore> {
|
||||||
|
const byKey = new Map<string, BeatLeaderScore>();
|
||||||
|
for (const s of scores) {
|
||||||
|
const t = parseFn(s.timeset);
|
||||||
|
if (!t || t < cutoffEpoch - ONE_YEAR_SECONDS) continue; // sanity guard
|
||||||
|
const rawHash = s.leaderboard?.song?.hash ?? undefined;
|
||||||
|
const modeName = s.leaderboard?.difficulty?.modeName ?? 'Standard';
|
||||||
|
if (!rawHash) continue;
|
||||||
|
const hashLower = String(rawHash).toLowerCase();
|
||||||
|
const diffName = normalizeFn(s.leaderboard?.difficulty?.value ?? undefined);
|
||||||
|
const key = `${hashLower}|${diffName}|${modeName}`;
|
||||||
|
const prev = byKey.get(key);
|
||||||
|
if (!prev || parseFn(prev.timeset) < t) byKey.set(key, s);
|
||||||
|
}
|
||||||
|
return byKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 5. Statistical Functions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate the mean (average) of an array of numbers
|
||||||
|
*/
|
||||||
|
export function mean(values: number[]): number {
|
||||||
|
if (!values.length) return 0;
|
||||||
|
return values.reduce((a, b) => a + b, 0) / values.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate the median of an array of numbers
|
||||||
|
*/
|
||||||
|
export function median(values: number[]): number {
|
||||||
|
if (!values.length) return 0;
|
||||||
|
const v = [...values].sort((a, b) => a - b);
|
||||||
|
const mid = Math.floor(v.length / 2);
|
||||||
|
return v.length % 2 ? v[mid] : (v[mid - 1] + v[mid]) / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate a percentile of an array of numbers
|
||||||
|
*/
|
||||||
|
export function percentile(values: number[], p: number): number {
|
||||||
|
if (!values.length) return 0;
|
||||||
|
const v = [...values].sort((a, b) => a - b);
|
||||||
|
const idx = Math.min(v.length - 1, Math.max(0, Math.floor((p / 100) * (v.length - 1))));
|
||||||
|
return v[idx];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 6. Playlist Generation
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Increment and return the playlist count for a given key
|
||||||
|
*/
|
||||||
|
export function incrementPlaylistCount(key: string): number {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem('playlist_counts');
|
||||||
|
const obj = raw ? (JSON.parse(raw) as Record<string, number>) : {};
|
||||||
|
const next = (obj[key] ?? 0) + 1;
|
||||||
|
obj[key] = next;
|
||||||
|
localStorage.setItem('playlist_counts', JSON.stringify(obj));
|
||||||
|
return next;
|
||||||
|
} catch {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a list of songs to Beat Saber playlist JSON format
|
||||||
|
*/
|
||||||
|
export function toPlaylistJson(
|
||||||
|
songs: Array<{ hash: string; difficulties: Difficulty[] }>,
|
||||||
|
playlistKey: string,
|
||||||
|
description: string
|
||||||
|
): unknown {
|
||||||
|
const count = incrementPlaylistCount(playlistKey);
|
||||||
|
const playlistTitle = `${playlistKey}-${String(count).padStart(2, '0')}`;
|
||||||
|
return {
|
||||||
|
playlistTitle,
|
||||||
|
playlistAuthor: 'SaberList Tool',
|
||||||
|
songs: songs.map((s) => ({
|
||||||
|
hash: s.hash,
|
||||||
|
difficulties: s.difficulties,
|
||||||
|
})),
|
||||||
|
description,
|
||||||
|
allowDuplicates: false,
|
||||||
|
customData: {}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download a playlist as a .bplist file
|
||||||
|
*/
|
||||||
|
export function downloadPlaylist(playlistData: unknown): void {
|
||||||
|
const title = (playlistData as any).playlistTitle ?? 'playlist';
|
||||||
|
const blob = new Blob([JSON.stringify(playlistData, null, 2)], { type: 'application/json' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `${title}.bplist`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 7. Pagination Utilities
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type PaginationResult<T> = {
|
||||||
|
pageItems: T[];
|
||||||
|
totalPages: number;
|
||||||
|
validPage: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate pagination for a list of items
|
||||||
|
*/
|
||||||
|
export function calculatePagination<T>(
|
||||||
|
items: T[],
|
||||||
|
page: number,
|
||||||
|
pageSize: number
|
||||||
|
): PaginationResult<T> {
|
||||||
|
const totalPages = Math.max(1, Math.ceil(items.length / pageSize));
|
||||||
|
const validPage = Math.min(Math.max(1, page), totalPages);
|
||||||
|
const startIdx = (validPage - 1) * pageSize;
|
||||||
|
const endIdx = startIdx + pageSize;
|
||||||
|
const pageItems = items.slice(startIdx, endIdx);
|
||||||
|
|
||||||
|
return {
|
||||||
|
pageItems,
|
||||||
|
totalPages,
|
||||||
|
validPage
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 8. URL Parameter Utilities
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse URL search parameters with type safety
|
||||||
|
*/
|
||||||
|
export function getUrlParam(
|
||||||
|
searchParams: URLSearchParams,
|
||||||
|
key: string,
|
||||||
|
defaultValue?: string
|
||||||
|
): string | undefined {
|
||||||
|
return searchParams.get(key) ?? defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a URL parameter as a number
|
||||||
|
*/
|
||||||
|
export function getUrlParamAsNumber(
|
||||||
|
searchParams: URLSearchParams,
|
||||||
|
key: string,
|
||||||
|
defaultValue: number
|
||||||
|
): number {
|
||||||
|
const value = searchParams.get(key);
|
||||||
|
if (!value) return defaultValue;
|
||||||
|
const num = Number(value);
|
||||||
|
return Number.isFinite(num) ? num : defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a URL parameter as one of a set of allowed values
|
||||||
|
*/
|
||||||
|
export function getUrlParamAsEnum<T extends string>(
|
||||||
|
searchParams: URLSearchParams,
|
||||||
|
key: string,
|
||||||
|
allowedValues: readonly T[],
|
||||||
|
defaultValue: T
|
||||||
|
): T {
|
||||||
|
const value = searchParams.get(key);
|
||||||
|
if (!value) return defaultValue;
|
||||||
|
return (allowedValues as readonly string[]).includes(value) ? (value as T) : defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update URL without triggering navigation
|
||||||
|
*/
|
||||||
|
export function updateUrlParams(
|
||||||
|
params: Record<string, string | number | boolean | undefined | null>,
|
||||||
|
replace = true
|
||||||
|
): void {
|
||||||
|
const sp = new URLSearchParams(window.location.search);
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(params)) {
|
||||||
|
if (value === undefined || value === null || value === '' || value === false) {
|
||||||
|
sp.delete(key);
|
||||||
|
} else {
|
||||||
|
sp.set(key, String(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const qs = sp.toString();
|
||||||
|
const url = window.location.pathname + (qs ? `?${qs}` : '');
|
||||||
|
|
||||||
|
if (replace) {
|
||||||
|
window.history.replaceState(null, '', url);
|
||||||
|
} else {
|
||||||
|
window.history.pushState(null, '', url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -1,27 +1,21 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import MapCard from '$lib/components/MapCard.svelte';
|
import MapCard from '$lib/components/MapCard.svelte';
|
||||||
import PlayerCompareForm from '$lib/components/PlayerCompareForm.svelte';
|
import PlayerCompareForm from '$lib/components/PlayerCompareForm.svelte';
|
||||||
|
import {
|
||||||
type BeatLeaderScore = {
|
type MapMeta,
|
||||||
timeset?: string | number;
|
type StarInfo,
|
||||||
leaderboard?: {
|
type BeatLeaderScore,
|
||||||
// BeatLeader tends to expose a short id for the leaderboard route
|
type BeatLeaderScoresResponse,
|
||||||
id?: string | number | null;
|
type Difficulty,
|
||||||
leaderboardId?: string | number | null;
|
loadMetaForHashes,
|
||||||
song?: { hash?: string | null };
|
loadStarsForHashes,
|
||||||
difficulty?: { value?: number | string | null; modeName?: string | null };
|
normalizeDifficultyName,
|
||||||
};
|
parseTimeset,
|
||||||
};
|
getCutoffEpochFromMonths,
|
||||||
|
toPlaylistJson,
|
||||||
type BeatLeaderScoresResponse = {
|
downloadPlaylist,
|
||||||
data?: BeatLeaderScore[];
|
ONE_YEAR_SECONDS
|
||||||
metadata?: { page?: number; itemsPerPage?: number; total?: number };
|
} from '$lib/utils/plebsaber-utils';
|
||||||
};
|
|
||||||
|
|
||||||
type Difficulty = {
|
|
||||||
name: string;
|
|
||||||
characteristic: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type SongItem = {
|
type SongItem = {
|
||||||
hash: string;
|
hash: string;
|
||||||
@ -30,8 +24,6 @@
|
|||||||
leaderboardId?: string;
|
leaderboardId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ONE_YEAR_SECONDS = 365 * 24 * 60 * 60;
|
|
||||||
|
|
||||||
let playerA = '';
|
let playerA = '';
|
||||||
let playerB = '';
|
let playerB = '';
|
||||||
let loading = false;
|
let loading = false;
|
||||||
@ -66,131 +58,30 @@
|
|||||||
$: page = Math.min(page, totalPages);
|
$: page = Math.min(page, totalPages);
|
||||||
$: pageItems = sortedResults.slice((page - 1) * pageSizeNum, (page - 1) * pageSizeNum + pageSizeNum);
|
$: pageItems = sortedResults.slice((page - 1) * pageSizeNum, (page - 1) * pageSizeNum + pageSizeNum);
|
||||||
|
|
||||||
type MapMeta = {
|
// Metadata caches
|
||||||
songName?: string;
|
|
||||||
key?: string;
|
|
||||||
coverURL?: string;
|
|
||||||
mapper?: string;
|
|
||||||
};
|
|
||||||
let metaByHash: Record<string, MapMeta> = {};
|
let metaByHash: Record<string, MapMeta> = {};
|
||||||
|
|
||||||
type StarInfo = {
|
|
||||||
stars?: number;
|
|
||||||
accRating?: number;
|
|
||||||
passRating?: number;
|
|
||||||
techRating?: number;
|
|
||||||
status?: number;
|
|
||||||
};
|
|
||||||
// Keyed by `${hash}|${difficultyName}|${modeName}` for precise lookup
|
|
||||||
let starsByKey: Record<string, StarInfo> = {};
|
let starsByKey: Record<string, StarInfo> = {};
|
||||||
let loadingStars = false;
|
let loadingStars = false;
|
||||||
|
|
||||||
async function fetchBeatSaverMeta(hash: string): Promise<MapMeta | null> {
|
// Lazy load metadata when pageItems changes
|
||||||
try {
|
$: if (pageItems.length > 0) {
|
||||||
const res = await fetch(`https://api.beatsaver.com/maps/hash/${encodeURIComponent(hash)}`);
|
loadPageMetadata(pageItems);
|
||||||
if (!res.ok) throw new Error(String(res.status));
|
|
||||||
const data: any = await res.json();
|
|
||||||
const cover = data?.versions?.[0]?.coverURL ?? `https://cdn.beatsaver.com/${hash.toLowerCase()}.jpg`;
|
|
||||||
return {
|
|
||||||
songName: data?.metadata?.songName ?? data?.name ?? undefined,
|
|
||||||
key: data?.id ?? undefined,
|
|
||||||
coverURL: cover,
|
|
||||||
mapper: data?.uploader?.name ?? undefined
|
|
||||||
};
|
|
||||||
} catch {
|
|
||||||
// Fallback to CDN cover only
|
|
||||||
return { coverURL: `https://cdn.beatsaver.com/${hash.toLowerCase()}.jpg` };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadMetaForResults(items: SongItem[]): Promise<void> {
|
async function loadPageMetadata(items: SongItem[]): Promise<void> {
|
||||||
const needed = Array.from(new Set(items.map((i) => i.hash))).filter((h) => !metaByHash[h]);
|
const hashes = items.map(i => i.hash);
|
||||||
if (needed.length === 0) return;
|
|
||||||
|
// Load BeatSaver metadata
|
||||||
loadingMeta = true;
|
loadingMeta = true;
|
||||||
for (const h of needed) {
|
metaByHash = await loadMetaForHashes(hashes, metaByHash);
|
||||||
const meta = await fetchBeatSaverMeta(h);
|
|
||||||
if (meta) metaByHash = { ...metaByHash, [h]: meta };
|
|
||||||
}
|
|
||||||
loadingMeta = false;
|
loadingMeta = false;
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchBeatLeaderStarsByHash(hash: string): Promise<void> {
|
// Load star ratings
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/beatleader?path=/leaderboards/hash/${encodeURIComponent(hash)}`);
|
|
||||||
if (!res.ok) return;
|
|
||||||
const data: any = await res.json();
|
|
||||||
const leaderboards: any[] = Array.isArray(data?.leaderboards) ? data.leaderboards : Array.isArray(data) ? data : [];
|
|
||||||
for (const lb of leaderboards) {
|
|
||||||
const diffName: string | undefined = lb?.difficulty?.difficultyName ?? lb?.difficulty?.name ?? undefined;
|
|
||||||
const modeName: string | undefined = lb?.difficulty?.modeName ?? lb?.modeName ?? 'Standard';
|
|
||||||
if (!diffName || !modeName) continue;
|
|
||||||
const normalized = normalizeDifficultyName(diffName);
|
|
||||||
const key = `${hash}|${normalized}|${modeName}`;
|
|
||||||
const info: StarInfo = {
|
|
||||||
stars: lb?.difficulty?.stars ?? lb?.stars,
|
|
||||||
accRating: lb?.difficulty?.accRating,
|
|
||||||
passRating: lb?.difficulty?.passRating,
|
|
||||||
techRating: lb?.difficulty?.techRating,
|
|
||||||
status: lb?.difficulty?.status
|
|
||||||
};
|
|
||||||
starsByKey = { ...starsByKey, [key]: info };
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadStarsForResults(items: SongItem[]): Promise<void> {
|
|
||||||
const neededHashes = Array.from(new Set(items.map((i) => i.hash)));
|
|
||||||
if (neededHashes.length === 0) return;
|
|
||||||
loadingStars = true;
|
loadingStars = true;
|
||||||
for (const h of neededHashes) {
|
starsByKey = await loadStarsForHashes(hashes, starsByKey, normalizeDifficultyName);
|
||||||
await fetchBeatLeaderStarsByHash(h);
|
|
||||||
}
|
|
||||||
loadingStars = false;
|
loadingStars = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function normalizeDifficultyName(value: number | string | null | undefined): string {
|
|
||||||
if (value === null || value === undefined) return 'ExpertPlus';
|
|
||||||
if (typeof value === 'string') {
|
|
||||||
const v = value.toLowerCase();
|
|
||||||
if (v.includes('expertplus') || v === 'expertplus' || v === 'ex+' || v.includes('ex+')) return 'ExpertPlus';
|
|
||||||
if (v.includes('expert')) return 'Expert';
|
|
||||||
if (v.includes('hard')) return 'Hard';
|
|
||||||
if (v.includes('normal')) return 'Normal';
|
|
||||||
if (v.includes('easy')) return 'Easy';
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
switch (value) {
|
|
||||||
case 1:
|
|
||||||
return 'Easy';
|
|
||||||
case 3:
|
|
||||||
return 'Normal';
|
|
||||||
case 5:
|
|
||||||
return 'Hard';
|
|
||||||
case 7:
|
|
||||||
return 'Expert';
|
|
||||||
case 9:
|
|
||||||
return 'ExpertPlus';
|
|
||||||
default:
|
|
||||||
return 'ExpertPlus';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseTimeset(ts: string | number | undefined): number {
|
|
||||||
if (ts === undefined) return 0;
|
|
||||||
if (typeof ts === 'number') return ts;
|
|
||||||
const n = Number(ts);
|
|
||||||
return Number.isFinite(n) ? n : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCutoffEpochFromMonths(months: number | string): number {
|
|
||||||
const m = Number(months) || 0;
|
|
||||||
const seconds = Math.max(0, m) * 30 * 24 * 60 * 60; // approx 30 days per month
|
|
||||||
return Math.floor(Date.now() / 1000) - seconds;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchAllRecentScores(playerId: string, cutoffEpoch: number, maxPages = 15): Promise<BeatLeaderScore[]> {
|
async function fetchAllRecentScores(playerId: string, cutoffEpoch: number, maxPages = 15): Promise<BeatLeaderScore[]> {
|
||||||
const qs = new URLSearchParams({ diff: 'ExpertPlus', cutoffEpoch: String(cutoffEpoch), maxPages: String(maxPages) });
|
const qs = new URLSearchParams({ diff: 'ExpertPlus', cutoffEpoch: String(cutoffEpoch), maxPages: String(maxPages) });
|
||||||
const url = `/api/beatleader-cache/player/${encodeURIComponent(playerId)}?${qs.toString()}`;
|
const url = `/api/beatleader-cache/player/${encodeURIComponent(playerId)}?${qs.toString()}`;
|
||||||
@ -203,48 +94,13 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
function incrementPlaylistCount(): number {
|
function handleDownloadPlaylist(): void {
|
||||||
try {
|
const payload = toPlaylistJson(
|
||||||
const raw = localStorage.getItem('playlist_counts');
|
results,
|
||||||
const obj = raw ? (JSON.parse(raw) as Record<string, number>) : {};
|
'beatleader_compare_players',
|
||||||
const key = 'beatleader_compare_players';
|
`A's recent songs not played by B. Generated ${new Date().toISOString()}`
|
||||||
const next = (obj[key] ?? 0) + 1;
|
);
|
||||||
obj[key] = next;
|
downloadPlaylist(payload);
|
||||||
localStorage.setItem('playlist_counts', JSON.stringify(obj));
|
|
||||||
return next;
|
|
||||||
} catch {
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function toPlaylistJson(songs: SongItem[]): unknown {
|
|
||||||
const count = incrementPlaylistCount();
|
|
||||||
const playlistTitle = `beatleader_compare_players-${String(count).padStart(2, '0')}`;
|
|
||||||
return {
|
|
||||||
playlistTitle,
|
|
||||||
playlistAuthor: 'SaberList Tool',
|
|
||||||
songs: songs.map((s) => ({
|
|
||||||
hash: s.hash,
|
|
||||||
difficulties: s.difficulties,
|
|
||||||
})),
|
|
||||||
description: `A's recent songs not played by B. Generated ${new Date().toISOString()}`,
|
|
||||||
allowDuplicates: false,
|
|
||||||
customData: {}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function downloadPlaylist(): void {
|
|
||||||
const payload = toPlaylistJson(results);
|
|
||||||
const title = (payload as any).playlistTitle ?? 'playlist';
|
|
||||||
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = `${title}.bplist`;
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
a.remove();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onCompare() {
|
async function onCompare() {
|
||||||
@ -317,14 +173,9 @@
|
|||||||
candidates.sort((x, y) => y.timeset - x.timeset);
|
candidates.sort((x, y) => y.timeset - x.timeset);
|
||||||
const limited = candidates; // return all; pagination handled client-side
|
const limited = candidates; // return all; pagination handled client-side
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
results = limited;
|
results = limited;
|
||||||
page = 1;
|
page = 1;
|
||||||
// Load BeatSaver metadata (covers, titles) for tiles
|
// Metadata will be loaded lazily via reactive statement when pageItems changes
|
||||||
loadMetaForResults(limited);
|
|
||||||
// Load BeatLeader star ratings per hash/diff
|
|
||||||
loadStarsForResults(limited);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
errorMsg = err instanceof Error ? err.message : 'Unknown error';
|
errorMsg = err instanceof Error ? err.message : 'Unknown error';
|
||||||
} finally {
|
} finally {
|
||||||
@ -341,7 +192,7 @@
|
|||||||
<PlayerCompareForm bind:playerA bind:playerB {loading} hasResults={results.length > 0} oncompare={onCompare}>
|
<PlayerCompareForm bind:playerA bind:playerB {loading} hasResults={results.length > 0} oncompare={onCompare}>
|
||||||
<svelte:fragment slot="extra-buttons">
|
<svelte:fragment slot="extra-buttons">
|
||||||
{#if results.length > 0}
|
{#if results.length > 0}
|
||||||
<button type="button" class="rounded-md border border-white/10 px-3 py-2 text-sm" on:click={downloadPlaylist}>Download .bplist</button>
|
<button type="button" class="rounded-md border border-white/10 px-3 py-2 text-sm" on:click={handleDownloadPlaylist}>Download .bplist</button>
|
||||||
{/if}
|
{/if}
|
||||||
</svelte:fragment>
|
</svelte:fragment>
|
||||||
</PlayerCompareForm>
|
</PlayerCompareForm>
|
||||||
|
|||||||
@ -2,28 +2,27 @@
|
|||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import MapCard from '$lib/components/MapCard.svelte';
|
import MapCard from '$lib/components/MapCard.svelte';
|
||||||
import PlayerCompareForm from '$lib/components/PlayerCompareForm.svelte';
|
import PlayerCompareForm from '$lib/components/PlayerCompareForm.svelte';
|
||||||
|
import {
|
||||||
type BeatLeaderScore = {
|
type MapMeta,
|
||||||
timeset?: string | number;
|
type StarInfo,
|
||||||
accuracy?: number;
|
type BeatLeaderScore,
|
||||||
acc?: number;
|
type BeatLeaderScoresResponse,
|
||||||
rank?: number;
|
loadMetaForHashes,
|
||||||
leaderboard?: {
|
loadStarsForHashes,
|
||||||
id?: string | number | null;
|
normalizeDifficultyName,
|
||||||
leaderboardId?: string | number | null;
|
parseTimeset,
|
||||||
song?: { hash?: string | null };
|
getCutoffEpochFromMonths,
|
||||||
difficulty?: { value?: number | string | null; modeName?: string | null };
|
normalizeAccuracy,
|
||||||
};
|
buildLatestByKey,
|
||||||
};
|
fetchAllRecentScoresForDiff,
|
||||||
|
fetchAllRecentScoresAllDiffs,
|
||||||
type BeatLeaderScoresResponse = { data?: BeatLeaderScore[] };
|
mean,
|
||||||
|
median,
|
||||||
type MapMeta = {
|
percentile,
|
||||||
songName?: string;
|
DIFFICULTIES,
|
||||||
key?: string;
|
MODES,
|
||||||
coverURL?: string;
|
ONE_YEAR_SECONDS
|
||||||
mapper?: string;
|
} from '$lib/utils/plebsaber-utils';
|
||||||
};
|
|
||||||
|
|
||||||
type H2HItem = {
|
type H2HItem = {
|
||||||
hash: string;
|
hash: string;
|
||||||
@ -37,10 +36,6 @@
|
|||||||
leaderboardId?: string;
|
leaderboardId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const difficulties = ['Easy', 'Normal', 'Hard', 'Expert', 'ExpertPlus'];
|
|
||||||
const modes = ['Standard', 'Lawless', 'OneSaber', 'NoArrows', 'Lightshow'];
|
|
||||||
const ONE_YEAR_SECONDS = 365 * 24 * 60 * 60;
|
|
||||||
|
|
||||||
let playerA = '';
|
let playerA = '';
|
||||||
let playerB = '';
|
let playerB = '';
|
||||||
const months = 24;
|
const months = 24;
|
||||||
@ -69,122 +64,29 @@
|
|||||||
$: page = Math.min(page, totalPages);
|
$: page = Math.min(page, totalPages);
|
||||||
$: pageItems = sorted.slice((page - 1) * pageSizeNum, (page - 1) * pageSizeNum + pageSizeNum);
|
$: pageItems = sorted.slice((page - 1) * pageSizeNum, (page - 1) * pageSizeNum + pageSizeNum);
|
||||||
|
|
||||||
// Meta cache
|
// Metadata caches
|
||||||
let metaByHash: Record<string, MapMeta> = {};
|
let metaByHash: Record<string, MapMeta> = {};
|
||||||
|
let starsByKey: Record<string, StarInfo> = {};
|
||||||
let loadingMeta = false;
|
let loadingMeta = false;
|
||||||
|
let loadingStars = false;
|
||||||
|
|
||||||
function normalizeAccuracy(value: number | undefined): number | null {
|
// Lazy load metadata when pageItems changes
|
||||||
if (value === undefined || value === null) return null;
|
$: if (pageItems.length > 0) {
|
||||||
return value <= 1 ? value * 100 : value;
|
loadPageMetadata(pageItems);
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeDifficultyName(value: number | string | null | undefined): string {
|
async function loadPageMetadata(list: H2HItem[]): Promise<void> {
|
||||||
if (value === null || value === undefined) return 'ExpertPlus';
|
const hashes = list.map(i => i.hash);
|
||||||
if (typeof value === 'string') {
|
|
||||||
const v = value.toLowerCase();
|
|
||||||
if (v.includes('expertplus') || v === 'expertplus' || v === 'ex+' || v.includes('ex+')) return 'ExpertPlus';
|
|
||||||
if (v.includes('expert')) return 'Expert';
|
|
||||||
if (v.includes('hard')) return 'Hard';
|
|
||||||
if (v.includes('normal')) return 'Normal';
|
|
||||||
if (v.includes('easy')) return 'Easy';
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
switch (value) {
|
|
||||||
case 1: return 'Easy';
|
|
||||||
case 3: return 'Normal';
|
|
||||||
case 5: return 'Hard';
|
|
||||||
case 7: return 'Expert';
|
|
||||||
case 9: return 'ExpertPlus';
|
|
||||||
default: return 'ExpertPlus';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseTimeset(ts: string | number | undefined): number {
|
// Load BeatSaver metadata
|
||||||
if (ts === undefined) return 0;
|
|
||||||
if (typeof ts === 'number') return ts;
|
|
||||||
const n = Number(ts);
|
|
||||||
return Number.isFinite(n) ? n : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCutoffEpochFromMonths(m: number | string): number {
|
|
||||||
const monthsNum = Number(m) || 0;
|
|
||||||
const seconds = Math.max(0, monthsNum) * 30 * 24 * 60 * 60;
|
|
||||||
return Math.floor(Date.now() / 1000) - seconds;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchBeatSaverMeta(inHash: string): Promise<MapMeta | null> {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`https://api.beatsaver.com/maps/hash/${encodeURIComponent(inHash)}`);
|
|
||||||
if (!res.ok) throw new Error(String(res.status));
|
|
||||||
const data: any = await res.json();
|
|
||||||
const cover = data?.versions?.[0]?.coverURL ?? `https://cdn.beatsaver.com/${inHash.toLowerCase()}.jpg`;
|
|
||||||
return {
|
|
||||||
songName: data?.metadata?.songName ?? data?.name ?? undefined,
|
|
||||||
key: data?.id ?? undefined,
|
|
||||||
coverURL: cover,
|
|
||||||
mapper: data?.uploader?.name ?? undefined
|
|
||||||
};
|
|
||||||
} catch {
|
|
||||||
return { coverURL: `https://cdn.beatsaver.com/${inHash.toLowerCase()}.jpg` };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadMetaForResults(list: H2HItem[]): Promise<void> {
|
|
||||||
const needed = Array.from(new Set(list.map((i) => i.hash))).filter((h) => !metaByHash[h]);
|
|
||||||
if (needed.length === 0) return;
|
|
||||||
loadingMeta = true;
|
loadingMeta = true;
|
||||||
for (const h of needed) {
|
metaByHash = await loadMetaForHashes(hashes, metaByHash);
|
||||||
const m = await fetchBeatSaverMeta(h);
|
|
||||||
if (m) metaByHash = { ...metaByHash, [h]: m };
|
|
||||||
}
|
|
||||||
loadingMeta = false;
|
loadingMeta = false;
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchAllRecentScoresForDiff(playerId: string, cutoffEpoch: number, reqDiff: string, maxPages = 100): Promise<BeatLeaderScore[]> {
|
// Load star ratings
|
||||||
const qs = new URLSearchParams({ diff: reqDiff, cutoffEpoch: String(cutoffEpoch), maxPages: String(maxPages) });
|
loadingStars = true;
|
||||||
const url = `/api/beatleader-cache/player/${encodeURIComponent(playerId)}?${qs.toString()}`;
|
starsByKey = await loadStarsForHashes(hashes, starsByKey, normalizeDifficultyName);
|
||||||
const res = await fetch(url);
|
loadingStars = false;
|
||||||
if (!res.ok) throw new Error(`Failed to fetch scores for ${playerId}: ${res.status}`);
|
|
||||||
const data = (await res.json()) as BeatLeaderScoresResponse;
|
|
||||||
return data.data ?? [];
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchAllRecentScoresAllDiffs(playerId: string, cutoffEpoch: number): Promise<BeatLeaderScore[]> {
|
|
||||||
const arrays = await Promise.all(
|
|
||||||
difficulties.map((d) => fetchAllRecentScoresForDiff(playerId, cutoffEpoch, d))
|
|
||||||
);
|
|
||||||
// Merge and dedupe by leaderboard key (hash|diff|mode) and timeset
|
|
||||||
const merged = new Map<string, BeatLeaderScore>();
|
|
||||||
for (const arr of arrays) {
|
|
||||||
for (const s of arr) {
|
|
||||||
const rawHash = s.leaderboard?.song?.hash ?? undefined;
|
|
||||||
const modeName = s.leaderboard?.difficulty?.modeName ?? 'Standard';
|
|
||||||
if (!rawHash) continue;
|
|
||||||
const hashLower = String(rawHash).toLowerCase();
|
|
||||||
const diffName = normalizeDifficultyName(s.leaderboard?.difficulty?.value ?? undefined);
|
|
||||||
const key = `${hashLower}|${diffName}|${modeName}`;
|
|
||||||
const prev = merged.get(key);
|
|
||||||
if (!prev || parseTimeset(prev.timeset) < parseTimeset(s.timeset)) merged.set(key, s);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Array.from(merged.values());
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildLatestByKey(scores: BeatLeaderScore[], cutoffEpoch: number): Map<string, BeatLeaderScore> {
|
|
||||||
const byKey = new Map<string, BeatLeaderScore>();
|
|
||||||
for (const s of scores) {
|
|
||||||
const t = parseTimeset(s.timeset);
|
|
||||||
if (!t || t < cutoffEpoch - ONE_YEAR_SECONDS) continue; // sanity guard
|
|
||||||
const rawHash = s.leaderboard?.song?.hash ?? undefined;
|
|
||||||
const modeName = s.leaderboard?.difficulty?.modeName ?? 'Standard';
|
|
||||||
if (!rawHash) continue;
|
|
||||||
const hashLower = String(rawHash).toLowerCase();
|
|
||||||
const diffName = normalizeDifficultyName(s.leaderboard?.difficulty?.value ?? undefined);
|
|
||||||
const key = `${hashLower}|${diffName}|${modeName}`;
|
|
||||||
const prev = byKey.get(key);
|
|
||||||
if (!prev || parseTimeset(prev.timeset) < t) byKey.set(key, s);
|
|
||||||
}
|
|
||||||
return byKey;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function toH2HItems(aMap: Map<string, BeatLeaderScore>, bMap: Map<string, BeatLeaderScore>): H2HItem[] {
|
function toH2HItems(aMap: Map<string, BeatLeaderScore>, bMap: Map<string, BeatLeaderScore>): H2HItem[] {
|
||||||
@ -227,16 +129,16 @@
|
|||||||
try {
|
try {
|
||||||
const cutoff = getCutoffEpochFromMonths(months);
|
const cutoff = getCutoffEpochFromMonths(months);
|
||||||
const [aScores, bScores] = await Promise.all([
|
const [aScores, bScores] = await Promise.all([
|
||||||
fetchAllRecentScoresAllDiffs(a, cutoff),
|
fetchAllRecentScoresAllDiffs(a, cutoff, normalizeDifficultyName, parseTimeset),
|
||||||
fetchAllRecentScoresAllDiffs(b, cutoff)
|
fetchAllRecentScoresAllDiffs(b, cutoff, normalizeDifficultyName, parseTimeset)
|
||||||
]);
|
]);
|
||||||
const aLatest = buildLatestByKey(aScores, cutoff - ONE_YEAR_SECONDS);
|
const aLatest = buildLatestByKey(aScores, cutoff - ONE_YEAR_SECONDS, normalizeDifficultyName, parseTimeset);
|
||||||
const bLatest = buildLatestByKey(bScores, cutoff - ONE_YEAR_SECONDS);
|
const bLatest = buildLatestByKey(bScores, cutoff - ONE_YEAR_SECONDS, normalizeDifficultyName, parseTimeset);
|
||||||
const combined = toH2HItems(aLatest, bLatest);
|
const combined = toH2HItems(aLatest, bLatest);
|
||||||
combined.sort((x, y) => y.timeset - x.timeset);
|
combined.sort((x, y) => y.timeset - x.timeset);
|
||||||
items = combined;
|
items = combined;
|
||||||
page = 1;
|
page = 1;
|
||||||
loadMetaForResults(combined);
|
// Metadata will be loaded lazily via reactive statement when pageItems changes
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
errorMsg = err instanceof Error ? err.message : 'Unknown error';
|
errorMsg = err instanceof Error ? err.message : 'Unknown error';
|
||||||
} finally {
|
} finally {
|
||||||
@ -291,23 +193,6 @@
|
|||||||
$: updateUrl(true);
|
$: updateUrl(true);
|
||||||
|
|
||||||
// ===== Derived Stats for Visualizations =====
|
// ===== Derived Stats for Visualizations =====
|
||||||
function mean(values: number[]): number {
|
|
||||||
if (!values.length) return 0;
|
|
||||||
return values.reduce((a, b) => a + b, 0) / values.length;
|
|
||||||
}
|
|
||||||
function median(values: number[]): number {
|
|
||||||
if (!values.length) return 0;
|
|
||||||
const v = [...values].sort((a, b) => a - b);
|
|
||||||
const mid = Math.floor(v.length / 2);
|
|
||||||
return v.length % 2 ? v[mid] : (v[mid - 1] + v[mid]) / 2;
|
|
||||||
}
|
|
||||||
function percentile(values: number[], p: number): number {
|
|
||||||
if (!values.length) return 0;
|
|
||||||
const v = [...values].sort((a, b) => a - b);
|
|
||||||
const idx = Math.min(v.length - 1, Math.max(0, Math.floor((p / 100) * (v.length - 1))));
|
|
||||||
return v[idx];
|
|
||||||
}
|
|
||||||
|
|
||||||
$: comparable = items.filter((i) => i.accA != null && i.accB != null) as Array<{
|
$: comparable = items.filter((i) => i.accA != null && i.accB != null) as Array<{
|
||||||
hash: string; diffName: string; modeName: string; timeset: number; accA: number; accB: number; rankA?: number; rankB?: number; leaderboardId?: string;
|
hash: string; diffName: string; modeName: string; timeset: number; accA: number; accB: number; rankA?: number; rankB?: number; leaderboardId?: string;
|
||||||
}>;
|
}>;
|
||||||
@ -562,6 +447,9 @@
|
|||||||
{#if loadingMeta}
|
{#if loadingMeta}
|
||||||
<div class="mt-2 text-xs text-muted">Loading covers…</div>
|
<div class="mt-2 text-xs text-muted">Loading covers…</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if loadingStars}
|
||||||
|
<div class="mt-2 text-xs text-muted">Loading star ratings…</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="mt-4 grid gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
<div class="mt-4 grid gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||||
{#each pageItems as item}
|
{#each pageItems as item}
|
||||||
@ -571,6 +459,7 @@
|
|||||||
coverURL={metaByHash[item.hash]?.coverURL}
|
coverURL={metaByHash[item.hash]?.coverURL}
|
||||||
songName={metaByHash[item.hash]?.songName}
|
songName={metaByHash[item.hash]?.songName}
|
||||||
mapper={metaByHash[item.hash]?.mapper}
|
mapper={metaByHash[item.hash]?.mapper}
|
||||||
|
stars={starsByKey[`${item.hash}|${item.diffName}|${item.modeName}`]?.stars}
|
||||||
timeset={item.timeset}
|
timeset={item.timeset}
|
||||||
diffName={item.diffName}
|
diffName={item.diffName}
|
||||||
modeName={item.modeName}
|
modeName={item.modeName}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user