plebsaber.stream/src/lib/utils/plebsaber-utils.ts

693 lines
22 KiB
TypeScript

/**
* Shared utilities for PlebSaber tools
*/
// ============================================================================
// 1. Type Definitions
// ============================================================================
export type MapMeta = {
songName?: string;
key?: string;
coverURL?: string;
mapper?: string;
score?: number;
};
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 BeatLeaderPlayerProfile = {
id?: string;
name?: string;
avatar?: string | null;
country?: string | null;
rank?: number | null;
countryRank?: number | null;
techPp?: number | null;
accPp?: number | null;
passPp?: number | null;
};
export type RequirementContext = {
adminRank?: number | null;
};
export type ToolRequirement = {
minGlobalRank?: number;
requiresBetterRankThanAdmin?: boolean;
summary: string;
lockedMessage?: string;
};
export type Difficulty = {
name: string;
characteristic: string;
};
export type TriangleCorners = {
tech: { x: number; y: number };
acc: { x: number; y: number };
pass: { x: number; y: number };
};
export type TriangleNormalized = {
tech: number;
acc: number;
pass: number;
};
export type TriangleData = {
corners: TriangleCorners;
normalized: TriangleNormalized;
} | null;
// ============================================================================
// 2. Constants
// ============================================================================
export const ONE_YEAR_SECONDS = 365 * 24 * 60 * 60;
export const PLEB_BEATLEADER_ID = '76561199407393962';
export const DEFAULT_ADMIN_RANK_FALLBACK = 3000;
export const DIFFICULTIES = ['Easy', 'Normal', 'Hard', 'Expert', 'ExpertPlus'] as const;
export const MODES = ['Standard', 'Lawless', 'OneSaber', 'NoArrows', 'Lightshow'] as const;
const DEFAULT_PRIVATE_TOOL_REQUIREMENT: ToolRequirement = {
requiresBetterRankThanAdmin: true,
summary: 'Ranked higher than pleb on BeatLeader',
lockedMessage: 'You must either contribute to BL Patreon (or be ranked higher than pleb) to use this tool'
};
export const TOOL_REQUIREMENTS = {
'compare-histories': DEFAULT_PRIVATE_TOOL_REQUIREMENT,
'player-headtohead': DEFAULT_PRIVATE_TOOL_REQUIREMENT,
'player-playlist-gaps': DEFAULT_PRIVATE_TOOL_REQUIREMENT,
'playlist-discovery': DEFAULT_PRIVATE_TOOL_REQUIREMENT
} as const satisfies Record<string, ToolRequirement>;
export type ToolKey = keyof typeof TOOL_REQUIREMENTS;
export function getToolRequirement(key: string): ToolRequirement | null {
return TOOL_REQUIREMENTS[key as ToolKey] ?? null;
}
export function meetsToolRequirement(
profile: BeatLeaderPlayerProfile | null | undefined,
requirement: ToolRequirement | null | undefined,
context?: RequirementContext
): boolean {
if (!requirement) return true;
const rank = profile?.rank ?? null;
if (requirement.requiresBetterRankThanAdmin) {
const baseline = resolveAdminBaseline(context);
if (typeof rank !== 'number' || !Number.isFinite(rank) || rank <= 0) return false;
return rank <= (baseline ?? DEFAULT_ADMIN_RANK_FALLBACK);
}
if (requirement.minGlobalRank !== undefined) {
if (typeof rank !== 'number' || !Number.isFinite(rank) || rank <= 0) return false;
return rank <= requirement.minGlobalRank;
}
return true;
}
function resolveAdminBaseline(context?: RequirementContext): number | null {
const val = context?.adminRank;
if (typeof val === 'number' && Number.isFinite(val) && val > 0) {
return val;
}
return null;
}
export function formatToolRequirementSummary(
requirement: ToolRequirement | null | undefined,
context?: RequirementContext
): string {
if (!requirement) return '';
if (requirement.requiresBetterRankThanAdmin) {
const baseline = resolveAdminBaseline(context);
if (baseline) {
return `BeatLeader global rank ≤ ${baseline.toLocaleString()} (pleb's current rank)`;
}
return requirement.summary || `BeatLeader global rank ≤ ${DEFAULT_ADMIN_RANK_FALLBACK.toLocaleString()}`;
}
if (requirement.summary) return requirement.summary;
if (requirement.minGlobalRank !== undefined) {
return `BeatLeader global rank ≤ ${requirement.minGlobalRank}`;
}
return '';
}
// ============================================================================
// 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,
score: typeof data?.stats?.score === 'number' ? data.stats.score : 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());
}
/**
* Fetch all scores for a player (no time limit) by paginating through the BeatLeader API
* Useful for playlist gap analysis where we need to check all historical plays
*/
export async function fetchAllPlayerScores(playerId: string, maxPages = 200): Promise<BeatLeaderScore[]> {
const pageSize = 100;
let page = 1;
const all: BeatLeaderScore[] = [];
while (page <= maxPages) {
const url = `/api/beatleader/player/${encodeURIComponent(playerId)}?scores=1&count=${pageSize}&page=${page}&sortBy=date&order=desc`;
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;
const batch = data.data ?? [];
if (batch.length === 0) break;
all.push(...batch);
page += 1;
}
return all;
}
// ============================================================================
// 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. Skill Triangle Calculations
// ============================================================================
const DEFAULT_MAX_TECH_PP = 1300;
const DEFAULT_MAX_ACC_PP = 15000;
const DEFAULT_MAX_PASS_PP = 6000;
const GYRON_LENGTH = 57.74;
const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max);
/**
* Calculate skill triangle data from player's PP values
* Returns corner coordinates and normalized values for rendering the skill triangle
*/
export function calculateSkillTriangle(
techPp: number | null | undefined,
accPp: number | null | undefined,
passPp: number | null | undefined
): TriangleData {
const tech = typeof techPp === 'number' ? techPp : 0;
const acc = typeof accPp === 'number' ? accPp : 0;
const pass = typeof passPp === 'number' ? passPp : 0;
// If all values are zero, return null
if (tech === 0 && acc === 0 && pass === 0) {
return null;
}
// Calculate triangle scale
const triangleScale = Math.max(
1,
tech > 0 ? tech / DEFAULT_MAX_TECH_PP : 0,
acc > 0 ? acc / DEFAULT_MAX_ACC_PP : 0,
pass > 0 ? pass / DEFAULT_MAX_PASS_PP : 0
);
const maxTechPp = DEFAULT_MAX_TECH_PP * triangleScale;
const maxAccPp = DEFAULT_MAX_ACC_PP * triangleScale;
const maxPassPp = DEFAULT_MAX_PASS_PP * triangleScale;
// Normalize PP values
const normalizedTechPp = maxTechPp ? clamp(tech / maxTechPp, 0, 1) : 0;
const normalizedAccPp = maxAccPp ? clamp(acc / maxAccPp, 0, 1) : 0;
const normalizedPassPp = maxPassPp ? clamp(pass / maxPassPp, 0, 1) : 0;
// Calculate corner positions
const cornerTech = {
x: (GYRON_LENGTH - normalizedTechPp * GYRON_LENGTH) * 0.866,
y: 86.6 - (GYRON_LENGTH - normalizedTechPp * GYRON_LENGTH) / 2
};
const cornerAcc = {
x: 100 - (GYRON_LENGTH - normalizedAccPp * GYRON_LENGTH) * 0.866,
y: 86.6 - (GYRON_LENGTH - normalizedAccPp * GYRON_LENGTH) / 2
};
const cornerPass = {
x: 50,
y: (86.6 - GYRON_LENGTH / 2) * (1 - normalizedPassPp)
};
return {
corners: {
tech: cornerTech,
acc: cornerAcc,
pass: cornerPass
},
normalized: {
tech: normalizedTechPp,
acc: normalizedAccPp,
pass: normalizedPassPp
}
};
}
// ============================================================================
// 7. 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);
}
// ============================================================================
// 8. 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
};
}
// ============================================================================
// 9. 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);
}
}