diff --git a/projects/backend/src/service/image.service.tsx b/projects/backend/src/service/image.service.tsx index 68312f0..c0554fe 100644 --- a/projects/backend/src/service/image.service.tsx +++ b/projects/backend/src/service/image.service.tsx @@ -6,12 +6,13 @@ import { getDifficultyFromScoreSaberDifficulty } from "@ssr/common/utils/scoresa import { StarIcon } from "../../components/star-icon"; import { GlobeIcon } from "../../components/globe-icon"; import ScoreSaberLeaderboardToken from "@ssr/common/types/token/scoresaber/score-saber-leaderboard-token"; -import ScoreSaberPlayer, { getScoreSaberPlayerFromToken } from "@ssr/common/player/impl/scoresaber-player"; +import ScoreSaberPlayer from "@ssr/common/player/impl/scoresaber-player"; import { Jimp } from "jimp"; import { extractColors } from "extract-colors"; import { Config } from "@ssr/common/config"; import { fetchWithCache } from "../common/cache.util"; import { SSRCache } from "@ssr/common/cache"; +import { getScoreSaberPlayerFromToken } from "@ssr/common/token-creators"; const cache = new SSRCache({ ttl: 1000 * 60 * 60, // 1 hour diff --git a/projects/backend/src/service/leaderboard.service.ts b/projects/backend/src/service/leaderboard.service.ts index f8f775e..af2ab23 100644 --- a/projects/backend/src/service/leaderboard.service.ts +++ b/projects/backend/src/service/leaderboard.service.ts @@ -5,9 +5,9 @@ import { LeaderboardResponse } from "@ssr/common/response/leaderboard-response"; import Leaderboard from "@ssr/common/leaderboard/leaderboard"; import ScoreSaberLeaderboardToken from "@ssr/common/types/token/scoresaber/score-saber-leaderboard-token"; import { NotFoundError } from "elysia"; -import { getScoreSaberLeaderboardFromToken } from "@ssr/common/leaderboard/impl/scoresaber-leaderboard"; import BeatSaverService from "./beatsaver.service"; import { BeatSaverMap } from "@ssr/common/model/beatsaver/map"; +import { getScoreSaberLeaderboardFromToken } from "@ssr/common/token-creators"; const leaderboardCache = new SSRCache({ ttl: 1000 * 60 * 60 * 24, diff --git a/projects/backend/src/service/score.service.ts b/projects/backend/src/service/score.service.ts index e42d2f8..2815bdf 100644 --- a/projects/backend/src/service/score.service.ts +++ b/projects/backend/src/service/score.service.ts @@ -4,9 +4,6 @@ import { isProduction } from "@ssr/common/utils/utils"; import { Metadata } from "@ssr/common/types/metadata"; import { NotFoundError } from "elysia"; import BeatSaverService from "./beatsaver.service"; -import ScoreSaberLeaderboard, { - getScoreSaberLeaderboardFromToken, -} from "@ssr/common/leaderboard/impl/scoresaber-leaderboard"; import { scoresaberService } from "@ssr/common/service/impl/scoresaber"; import { ScoreSort } from "@ssr/common/score/score-sort"; import { Leaderboards } from "@ssr/common/leaderboard"; @@ -28,8 +25,10 @@ import { AdditionalScoreDataModel, } from "@ssr/common/model/additional-score-data/additional-score-data"; import { BeatLeaderScoreImprovementToken } from "@ssr/common/types/token/beatleader/score/score-improvement"; -import { getScoreSaberScoreFromToken, ScoreSaberScoreModel } from "@ssr/common/model/score/impl/scoresaber-score"; import { ScoreType } from "@ssr/common/model/score/score"; +import { getScoreSaberLeaderboardFromToken, getScoreSaberScoreFromToken } from "@ssr/common/token-creators"; +import { ScoreSaberScoreModel } from "@ssr/common/model/score/impl/scoresaber-score"; +import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard"; const playerScoresCache = new SSRCache({ ttl: 1000 * 60, // 1 minute diff --git a/projects/common/src/leaderboard/impl/scoresaber-leaderboard.ts b/projects/common/src/leaderboard/impl/scoresaber-leaderboard.ts index 8de5306..78cfdbf 100644 --- a/projects/common/src/leaderboard/impl/scoresaber-leaderboard.ts +++ b/projects/common/src/leaderboard/impl/scoresaber-leaderboard.ts @@ -32,54 +32,3 @@ export default interface ScoreSaberLeaderboard extends Leaderboard { */ readonly status: LeaderboardStatus; } - -/** - * 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, - }; -} diff --git a/projects/common/src/model/score/impl/scoresaber-score.ts b/projects/common/src/model/score/impl/scoresaber-score.ts index 7e4fbd8..4280dc9 100644 --- a/projects/common/src/model/score/impl/scoresaber-score.ts +++ b/projects/common/src/model/score/impl/scoresaber-score.ts @@ -71,53 +71,6 @@ class ScoreSaberScorePublic extends ScoreSaberScoreInternal { } export type ScoreSaberScore = InstanceType; - -/** - * 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, - }; -} - export type ScoreSaberScoreDocument = ScoreSaberScore & Document; export const ScoreSaberScoreModel: ReturnModelType = getModelForClass(ScoreSaberScoreInternal); diff --git a/projects/common/src/player/impl/scoresaber-player.ts b/projects/common/src/player/impl/scoresaber-player.ts index b528267..14b58e2 100644 --- a/projects/common/src/player/impl/scoresaber-player.ts +++ b/projects/common/src/player/impl/scoresaber-player.ts @@ -73,223 +73,6 @@ export default interface ScoreSaberPlayer extends Player { isBeingTracked?: boolean; } -/** - * 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") || [], - }; - 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, - }; -} - /** * A bio of a player. */ diff --git a/projects/common/src/token-creators.ts b/projects/common/src/token-creators.ts new file mode 100644 index 0000000..d94c75d --- /dev/null +++ b/projects/common/src/token-creators.ts @@ -0,0 +1,331 @@ +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, + }; +} diff --git a/projects/website/src/app/(pages)/player/[...slug]/page.tsx b/projects/website/src/app/(pages)/player/[...slug]/page.tsx index 058a7eb..c17ed59 100644 --- a/projects/website/src/app/(pages)/player/[...slug]/page.tsx +++ b/projects/website/src/app/(pages)/player/[...slug]/page.tsx @@ -6,13 +6,14 @@ import { getAverageColor } from "@/common/image-utils"; import { scoresaberService } from "@ssr/common/service/impl/scoresaber"; import { getCookieValue } from "@ssr/common/utils/cookie-utils"; import { Config } from "@ssr/common/config"; -import ScoreSaberPlayer, { getScoreSaberPlayerFromToken } from "@ssr/common/player/impl/scoresaber-player"; +import ScoreSaberPlayer from "@ssr/common/player/impl/scoresaber-player"; import { ScoreSort } from "@ssr/common/score/score-sort"; import { ScoreSaberScore } from "@ssr/common/model/score/impl/scoresaber-score"; import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard"; import { fetchPlayerScores } from "@ssr/common/utils/score-utils"; import PlayerScoresResponse from "@ssr/common/response/player-scores-response"; import { SSRCache } from "@ssr/common/cache"; +import { getScoreSaberPlayerFromToken } from "@ssr/common/token-creators"; const UNKNOWN_PLAYER = { title: "ScoreSaber Reloaded - Unknown Player", diff --git a/projects/website/src/components/player/player-data.tsx b/projects/website/src/components/player/player-data.tsx index f1cfccd..7ac6763 100644 --- a/projects/website/src/components/player/player-data.tsx +++ b/projects/website/src/components/player/player-data.tsx @@ -13,11 +13,12 @@ import PlayerCharts from "@/components/player/chart/player-charts"; import { scoresaberService } from "@ssr/common/service/impl/scoresaber"; import useDatabase from "@/hooks/use-database"; import { useLiveQuery } from "dexie-react-hooks"; -import ScoreSaberPlayer, { getScoreSaberPlayerFromToken } from "@ssr/common/player/impl/scoresaber-player"; +import ScoreSaberPlayer from "@ssr/common/player/impl/scoresaber-player"; import { ScoreSort } from "@ssr/common/score/score-sort"; import { ScoreSaberScore } from "@ssr/common/model/score/impl/scoresaber-score"; import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard"; import PlayerScoresResponse from "@ssr/common/response/player-scores-response"; +import { getScoreSaberPlayerFromToken } from "@ssr/common/token-creators"; type Props = { initialPlayerData: ScoreSaberPlayer; diff --git a/projects/website/src/components/score/score-feed/score-feed.tsx b/projects/website/src/components/score/score-feed/score-feed.tsx index 8588c08..e71fc72 100644 --- a/projects/website/src/components/score/score-feed/score-feed.tsx +++ b/projects/website/src/components/score/score-feed/score-feed.tsx @@ -2,13 +2,12 @@ import { useEffect, useState } from "react"; import ScoreSaberPlayerScoreToken from "@ssr/common/types/token/scoresaber/score-saber-player-score-token"; -import Score from "@/components/score/score"; import { parseDate } from "@ssr/common/utils/time-utils"; import Link from "next/link"; import useWebSocket, { ReadyState } from "react-use-websocket"; import { ScoreSaberWebsocketMessageToken } from "@ssr/common/types/token/scoresaber/websocket/scoresaber-websocket-message"; -import { getScoreSaberScoreFromToken } from "@ssr/common/model/score/impl/scoresaber-score"; -import { getScoreSaberLeaderboardFromToken } from "@ssr/common/leaderboard/impl/scoresaber-leaderboard"; +import Score from "@/components/score/score"; +import { getScoreSaberLeaderboardFromToken, getScoreSaberScoreFromToken } from "@ssr/common/token-creators"; export default function ScoreFeed() { const { readyState, lastJsonMessage } = useWebSocket("wss://scoresaber.com/ws"); @@ -40,12 +39,18 @@ export default function ScoreFeed() { return (
- {scores.map(score => { - const player = score.score.leaderboardPlayerInfo; - const leaderboard = getScoreSaberLeaderboardFromToken(score.leaderboard); + {scores.map(scoreToken => { + if (!scoreToken.leaderboard || !scoreToken.score) { + console.error("Invalid leaderboard or score data:", scoreToken); + return null; + } + + const player = scoreToken.score.leaderboardPlayerInfo; + const leaderboard = getScoreSaberLeaderboardFromToken(scoreToken.leaderboard); + const score = getScoreSaberScoreFromToken(scoreToken.score, leaderboard); return ( -
+

Set by{" "} @@ -53,7 +58,7 @@ export default function ScoreFeed() {

{ setIsLeaderboardExpanded(false); setLeaderboardDropdownData(undefined); - }, [score]); + }, [score.scoreId]); const accuracy = (baseScore / leaderboard.maxScore) * 100; const pp = baseScore === score.score ? score.pp : scoresaberService.getPp(leaderboard.stars, accuracy);