import ScoreSaberLeaderboard from "./leaderboard/impl/scoresaber-leaderboard"; import ScoreSaberLeaderboardToken from "./types/token/scoresaber/score-saber-leaderboard-token"; import LeaderboardDifficulty from "./leaderboard/leaderboard-difficulty"; import { getDifficultyFromScoreSaberDifficulty } from "./utils/scoresaber-utils"; import { MapCharacteristic } from "./types/map-characteristic"; import { LeaderboardStatus } from "./leaderboard/leaderboard-status"; import { formatDateMinimal, getDaysAgoDate, getMidnightAlignedDate, parseDate } from "./utils/time-utils"; import ScoreSaberPlayerToken from "./types/token/scoresaber/score-saber-player-token"; import ScoreSaberPlayer, { ScoreSaberBadge, ScoreSaberBio } from "./player/impl/scoresaber-player"; import { PlayerHistory } from "./player/player-history"; import ky from "ky"; import { Config } from "./config"; import { getValueFromHistory } from "./utils/player-utils"; import { getPageFromRank } from "./utils/utils"; import ScoreSaberScoreToken from "./types/token/scoresaber/score-saber-score-token"; import { ScoreSaberScore } from "./model/score/impl/scoresaber-score"; import { Modifier } from "./score/modifier"; /** * Parses a {@link ScoreSaberLeaderboardToken} into a {@link ScoreSaberLeaderboard}. * * @param token the token to parse */ export function getScoreSaberLeaderboardFromToken(token: ScoreSaberLeaderboardToken): ScoreSaberLeaderboard { const difficulty: LeaderboardDifficulty = { leaderboardId: token.difficulty.leaderboardId, difficulty: getDifficultyFromScoreSaberDifficulty(token.difficulty.difficulty), characteristic: token.difficulty.gameMode.replace("Solo", "") as MapCharacteristic, difficultyRaw: token.difficulty.difficultyRaw, }; let status: LeaderboardStatus = "Unranked"; if (token.qualified) { status = "Qualified"; } else if (token.ranked) { status = "Ranked"; } return { id: token.id, songHash: token.songHash.toUpperCase(), songName: token.songName, songSubName: token.songSubName, songAuthorName: token.songAuthorName, levelAuthorName: token.levelAuthorName, difficulty: difficulty, difficulties: token.difficulties != undefined && token.difficulties.length > 0 ? token.difficulties.map(difficulty => { return { leaderboardId: difficulty.leaderboardId, difficulty: getDifficultyFromScoreSaberDifficulty(difficulty.difficulty), characteristic: difficulty.gameMode.replace("Solo", "") as MapCharacteristic, difficultyRaw: difficulty.difficultyRaw, }; }) : [difficulty], maxScore: token.maxScore, ranked: token.ranked, songArt: token.coverImage, timestamp: parseDate(token.createdDate), stars: token.stars, plays: token.plays, dailyPlays: token.dailyPlays, qualified: token.qualified, status: status, }; } /** * Gets a {@link ScoreSaberScore} from a {@link ScoreSaberScoreToken}. * * @param token the token to convert * @param playerId the id of the player who set the score * @param leaderboard the leaderboard the score was set on */ export function getScoreSaberScoreFromToken( token: ScoreSaberScoreToken, leaderboard: ScoreSaberLeaderboard, playerId?: string ): ScoreSaberScore { const modifiers: Modifier[] = token.modifiers == undefined || token.modifiers === "" ? [] : token.modifiers.split(",").map(mod => { mod = mod.toUpperCase(); const modifier = Modifier[mod as keyof typeof Modifier]; if (modifier === undefined) { throw new Error(`Unknown modifier: ${mod}`); } return modifier; }); return { playerId: playerId || token.leaderboardPlayerInfo.id, leaderboardId: leaderboard.id, difficulty: leaderboard.difficulty.difficulty, characteristic: leaderboard.difficulty.characteristic, score: token.baseScore, accuracy: (token.baseScore / leaderboard.maxScore) * 100, rank: token.rank, modifiers: modifiers, misses: token.missedNotes + token.badCuts, missedNotes: token.missedNotes, badCuts: token.badCuts, fullCombo: token.fullCombo, timestamp: new Date(token.timeSet), scoreId: token.id, pp: token.pp, weight: token.weight, maxCombo: token.maxCombo, playerInfo: token.leaderboardPlayerInfo, }; } /** * Gets the ScoreSaber Player from an {@link ScoreSaberPlayerToken}. * * @param token the player token * @param playerIdCookie the id of the claimed player */ export async function getScoreSaberPlayerFromToken( token: ScoreSaberPlayerToken, playerIdCookie?: string ): Promise { const bio: ScoreSaberBio = { lines: token.bio?.split("\n") || [], linesStripped: token.bio?.replace(/<[^>]+>/g, "")?.split("\n") || [], // strips html tags }; const badges: ScoreSaberBadge[] = token.badges?.map(badge => { return { url: badge.image, description: badge.description, }; }) || []; let isBeingTracked = false; const todayDate = formatDateMinimal(getMidnightAlignedDate(new Date())); let statisticHistory: { [key: string]: PlayerHistory } = {}; try { const { statistics: history } = await ky .get<{ statistics: { [key: string]: PlayerHistory }; }>( `${Config.apiUrl}/player/history/${token.id}/50/${playerIdCookie && playerIdCookie == token.id ? "?createIfMissing=true" : ""}` ) .json(); if (history) { // Use the latest data for today history[todayDate] = { ...history[todayDate], rank: token.rank, countryRank: token.countryRank, pp: token.pp, replaysWatched: token.scoreStats.replaysWatched, accuracy: { ...history[todayDate]?.accuracy, averageRankedAccuracy: token.scoreStats.averageRankedAccuracy, }, scores: { ...history[todayDate]?.scores, totalScores: token.scoreStats.totalPlayCount, totalRankedScores: token.scoreStats.rankedPlayCount, }, score: { ...history[todayDate]?.score, totalScore: token.scoreStats.totalScore, totalRankedScore: token.scoreStats.totalRankedScore, }, }; isBeingTracked = true; } statisticHistory = history; } catch (e) {} const playerRankHistory = token.histories.split(",").map(value => { return parseInt(value); }); playerRankHistory.push(token.rank); let missingDays = 0; let daysAgo = 0; // Start from current day for (let i = playerRankHistory.length - 1; i >= 0; i--) { const rank = playerRankHistory[i]; if (rank == 999_999) { continue; } const date = getMidnightAlignedDate(getDaysAgoDate(daysAgo)); daysAgo += 1; const dateKey = formatDateMinimal(date); if (!statisticHistory[dateKey] || statisticHistory[dateKey].rank == undefined) { missingDays += 1; statisticHistory[dateKey] = { ...statisticHistory[dateKey], rank: rank, }; } } if (missingDays > 0 && missingDays != playerRankHistory.length) { console.log( `Player has ${missingDays} missing day${missingDays > 1 ? "s" : ""}, filling in with fallback history...` ); } // Sort the fallback history statisticHistory = Object.entries(statisticHistory) .sort((a, b) => Date.parse(b[0]) - Date.parse(a[0])) .reduce((obj, [key, value]) => ({ ...obj, [key]: value }), {}); /** * Gets the change in the given stat * * @param statType the stat to check * @param isNegativeChange whether to multiply the change by 1 or -1 * @param daysAgo the amount of days ago to get the stat for * @return the change */ const getStatisticChange = (statType: string, isNegativeChange: boolean, daysAgo: number = 1): number | undefined => { const todayStats = statisticHistory[todayDate]; let otherDate: Date | undefined; if (daysAgo === 1) { otherDate = getMidnightAlignedDate(getDaysAgoDate(1)); // Yesterday } else { const targetDate = getDaysAgoDate(daysAgo); // Filter available dates to find the closest one to the target const availableDates = Object.keys(statisticHistory) .map(dateKey => new Date(dateKey)) .filter(date => { // Convert date back to the correct format for statisticHistory lookup const formattedDate = formatDateMinimal(date); const statsForDate = statisticHistory[formattedDate]; const hasStat = statsForDate && statType in statsForDate; // Only consider past dates with the required statType const isPast = date.getTime() < new Date().getTime(); return hasStat && isPast; }); // If no valid dates are found, return undefined if (availableDates.length === 0) { return undefined; } // Find the closest date from the filtered available dates otherDate = availableDates.reduce((closestDate, currentDate) => { const currentDiff = Math.abs(currentDate.getTime() - targetDate.getTime()); const closestDiff = Math.abs(closestDate.getTime() - targetDate.getTime()); return currentDiff < closestDiff ? currentDate : closestDate; }, availableDates[0]); // Start with the first available date } // Ensure todayStats exists and contains the statType if (!todayStats || !getValueFromHistory(todayStats, statType)) { return undefined; } const otherStats = statisticHistory[formatDateMinimal(otherDate)]; // This is now validated // Ensure otherStats exists and contains the statType if (!otherStats || !getValueFromHistory(otherStats, statType)) { return undefined; } const statToday = getValueFromHistory(todayStats, statType); const statOther = getValueFromHistory(otherStats, statType); if (statToday === undefined || statOther === undefined) { return undefined; } return (statToday - statOther) * (!isNegativeChange ? 1 : -1); }; const getStatisticChanges = (daysAgo: number): PlayerHistory => { return { rank: getStatisticChange("rank", true, daysAgo), countryRank: getStatisticChange("countryRank", true, daysAgo), pp: getStatisticChange("pp", false, daysAgo), replaysWatched: getStatisticChange("replaysWatched", false, daysAgo), accuracy: { averageRankedAccuracy: getStatisticChange("accuracy.averageRankedAccuracy", false, daysAgo), }, score: { totalScore: getStatisticChange("score.totalScore", false, daysAgo), totalRankedScore: getStatisticChange("score.totalRankedScore", false, daysAgo), }, scores: { totalScores: getStatisticChange("scores.totalScores", false, daysAgo), totalRankedScores: getStatisticChange("scores.totalRankedScores", false, daysAgo), rankedScores: getStatisticChange("scores.rankedScores", false, daysAgo), unrankedScores: getStatisticChange("scores.unrankedScores", false, daysAgo), }, }; }; return { id: token.id, name: token.name, avatar: token.profilePicture, country: token.country, rank: token.rank, countryRank: token.countryRank, joinedDate: new Date(token.firstSeen), bio: bio, pp: token.pp, statisticChange: { daily: getStatisticChanges(1), weekly: getStatisticChanges(7), monthly: getStatisticChanges(30), }, role: token.role == null ? undefined : token.role, badges: badges, statisticHistory: statisticHistory, statistics: token.scoreStats, rankPages: { global: getPageFromRank(token.rank, 50), country: getPageFromRank(token.countryRank, 50), }, permissions: token.permissions, banned: token.banned, inactive: token.inactive, isBeingTracked: isBeingTracked, }; }