/** * 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 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 } as const satisfies Record; 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 { 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> { 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 = {}; 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, onProgress?: (loaded: number, total: number) => void ): Promise> { 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, normalizeFn: (value: number | string | null | undefined) => string, onProgress?: (loaded: number, total: number) => void ): Promise> { 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 { 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 { 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(); 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 { 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 { const byKey = new Map(); 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) : {}; 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 = { pageItems: T[]; totalPages: number; validPage: number; }; /** * Calculate pagination for a list of items */ export function calculatePagination( items: T[], page: number, pageSize: number ): PaginationResult { 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( 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, 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); } }