fix crying
All checks were successful
Deploy Backend / docker (ubuntu-latest) (push) Successful in 46s
Deploy Website / docker (ubuntu-latest) (push) Successful in 2m23s

This commit is contained in:
Lee 2024-10-24 14:28:18 +01:00
parent 1d9433ef02
commit cd1f010698
11 changed files with 360 additions and 337 deletions

@ -6,12 +6,13 @@ import { getDifficultyFromScoreSaberDifficulty } from "@ssr/common/utils/scoresa
import { StarIcon } from "../../components/star-icon"; import { StarIcon } from "../../components/star-icon";
import { GlobeIcon } from "../../components/globe-icon"; import { GlobeIcon } from "../../components/globe-icon";
import ScoreSaberLeaderboardToken from "@ssr/common/types/token/scoresaber/score-saber-leaderboard-token"; 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 { Jimp } from "jimp";
import { extractColors } from "extract-colors"; import { extractColors } from "extract-colors";
import { Config } from "@ssr/common/config"; import { Config } from "@ssr/common/config";
import { fetchWithCache } from "../common/cache.util"; import { fetchWithCache } from "../common/cache.util";
import { SSRCache } from "@ssr/common/cache"; import { SSRCache } from "@ssr/common/cache";
import { getScoreSaberPlayerFromToken } from "@ssr/common/token-creators";
const cache = new SSRCache({ const cache = new SSRCache({
ttl: 1000 * 60 * 60, // 1 hour ttl: 1000 * 60 * 60, // 1 hour

@ -5,9 +5,9 @@ import { LeaderboardResponse } from "@ssr/common/response/leaderboard-response";
import Leaderboard from "@ssr/common/leaderboard/leaderboard"; import Leaderboard from "@ssr/common/leaderboard/leaderboard";
import ScoreSaberLeaderboardToken from "@ssr/common/types/token/scoresaber/score-saber-leaderboard-token"; import ScoreSaberLeaderboardToken from "@ssr/common/types/token/scoresaber/score-saber-leaderboard-token";
import { NotFoundError } from "elysia"; import { NotFoundError } from "elysia";
import { getScoreSaberLeaderboardFromToken } from "@ssr/common/leaderboard/impl/scoresaber-leaderboard";
import BeatSaverService from "./beatsaver.service"; import BeatSaverService from "./beatsaver.service";
import { BeatSaverMap } from "@ssr/common/model/beatsaver/map"; import { BeatSaverMap } from "@ssr/common/model/beatsaver/map";
import { getScoreSaberLeaderboardFromToken } from "@ssr/common/token-creators";
const leaderboardCache = new SSRCache({ const leaderboardCache = new SSRCache({
ttl: 1000 * 60 * 60 * 24, ttl: 1000 * 60 * 60 * 24,

@ -4,9 +4,6 @@ import { isProduction } from "@ssr/common/utils/utils";
import { Metadata } from "@ssr/common/types/metadata"; import { Metadata } from "@ssr/common/types/metadata";
import { NotFoundError } from "elysia"; import { NotFoundError } from "elysia";
import BeatSaverService from "./beatsaver.service"; import BeatSaverService from "./beatsaver.service";
import ScoreSaberLeaderboard, {
getScoreSaberLeaderboardFromToken,
} from "@ssr/common/leaderboard/impl/scoresaber-leaderboard";
import { scoresaberService } from "@ssr/common/service/impl/scoresaber"; import { scoresaberService } from "@ssr/common/service/impl/scoresaber";
import { ScoreSort } from "@ssr/common/score/score-sort"; import { ScoreSort } from "@ssr/common/score/score-sort";
import { Leaderboards } from "@ssr/common/leaderboard"; import { Leaderboards } from "@ssr/common/leaderboard";
@ -28,8 +25,10 @@ import {
AdditionalScoreDataModel, AdditionalScoreDataModel,
} from "@ssr/common/model/additional-score-data/additional-score-data"; } from "@ssr/common/model/additional-score-data/additional-score-data";
import { BeatLeaderScoreImprovementToken } from "@ssr/common/types/token/beatleader/score/score-improvement"; 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 { 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({ const playerScoresCache = new SSRCache({
ttl: 1000 * 60, // 1 minute ttl: 1000 * 60, // 1 minute

@ -32,54 +32,3 @@ export default interface ScoreSaberLeaderboard extends Leaderboard {
*/ */
readonly status: LeaderboardStatus; 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,
};
}

@ -71,53 +71,6 @@ class ScoreSaberScorePublic extends ScoreSaberScoreInternal {
} }
export type ScoreSaberScore = InstanceType<typeof ScoreSaberScorePublic>; export type ScoreSaberScore = InstanceType<typeof ScoreSaberScorePublic>;
/**
* 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 type ScoreSaberScoreDocument = ScoreSaberScore & Document;
export const ScoreSaberScoreModel: ReturnModelType<typeof ScoreSaberScoreInternal> = export const ScoreSaberScoreModel: ReturnModelType<typeof ScoreSaberScoreInternal> =
getModelForClass(ScoreSaberScoreInternal); getModelForClass(ScoreSaberScoreInternal);

@ -73,223 +73,6 @@ export default interface ScoreSaberPlayer extends Player {
isBeingTracked?: boolean; 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<ScoreSaberPlayer> {
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. * A bio of a player.
*/ */

@ -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<ScoreSaberPlayer> {
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,
};
}

@ -6,13 +6,14 @@ import { getAverageColor } from "@/common/image-utils";
import { scoresaberService } from "@ssr/common/service/impl/scoresaber"; import { scoresaberService } from "@ssr/common/service/impl/scoresaber";
import { getCookieValue } from "@ssr/common/utils/cookie-utils"; import { getCookieValue } from "@ssr/common/utils/cookie-utils";
import { Config } from "@ssr/common/config"; 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 { ScoreSort } from "@ssr/common/score/score-sort";
import { ScoreSaberScore } from "@ssr/common/model/score/impl/scoresaber-score"; import { ScoreSaberScore } from "@ssr/common/model/score/impl/scoresaber-score";
import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard"; import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard";
import { fetchPlayerScores } from "@ssr/common/utils/score-utils"; import { fetchPlayerScores } from "@ssr/common/utils/score-utils";
import PlayerScoresResponse from "@ssr/common/response/player-scores-response"; import PlayerScoresResponse from "@ssr/common/response/player-scores-response";
import { SSRCache } from "@ssr/common/cache"; import { SSRCache } from "@ssr/common/cache";
import { getScoreSaberPlayerFromToken } from "@ssr/common/token-creators";
const UNKNOWN_PLAYER = { const UNKNOWN_PLAYER = {
title: "ScoreSaber Reloaded - Unknown Player", title: "ScoreSaber Reloaded - Unknown Player",

@ -13,11 +13,12 @@ import PlayerCharts from "@/components/player/chart/player-charts";
import { scoresaberService } from "@ssr/common/service/impl/scoresaber"; import { scoresaberService } from "@ssr/common/service/impl/scoresaber";
import useDatabase from "@/hooks/use-database"; import useDatabase from "@/hooks/use-database";
import { useLiveQuery } from "dexie-react-hooks"; 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 { ScoreSort } from "@ssr/common/score/score-sort";
import { ScoreSaberScore } from "@ssr/common/model/score/impl/scoresaber-score"; import { ScoreSaberScore } from "@ssr/common/model/score/impl/scoresaber-score";
import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard"; import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard";
import PlayerScoresResponse from "@ssr/common/response/player-scores-response"; import PlayerScoresResponse from "@ssr/common/response/player-scores-response";
import { getScoreSaberPlayerFromToken } from "@ssr/common/token-creators";
type Props = { type Props = {
initialPlayerData: ScoreSaberPlayer; initialPlayerData: ScoreSaberPlayer;

@ -2,13 +2,12 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import ScoreSaberPlayerScoreToken from "@ssr/common/types/token/scoresaber/score-saber-player-score-token"; 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 { parseDate } from "@ssr/common/utils/time-utils";
import Link from "next/link"; import Link from "next/link";
import useWebSocket, { ReadyState } from "react-use-websocket"; import useWebSocket, { ReadyState } from "react-use-websocket";
import { ScoreSaberWebsocketMessageToken } from "@ssr/common/types/token/scoresaber/websocket/scoresaber-websocket-message"; import { ScoreSaberWebsocketMessageToken } from "@ssr/common/types/token/scoresaber/websocket/scoresaber-websocket-message";
import { getScoreSaberScoreFromToken } from "@ssr/common/model/score/impl/scoresaber-score"; import Score from "@/components/score/score";
import { getScoreSaberLeaderboardFromToken } from "@ssr/common/leaderboard/impl/scoresaber-leaderboard"; import { getScoreSaberLeaderboardFromToken, getScoreSaberScoreFromToken } from "@ssr/common/token-creators";
export default function ScoreFeed() { export default function ScoreFeed() {
const { readyState, lastJsonMessage } = useWebSocket<ScoreSaberWebsocketMessageToken>("wss://scoresaber.com/ws"); const { readyState, lastJsonMessage } = useWebSocket<ScoreSaberWebsocketMessageToken>("wss://scoresaber.com/ws");
@ -40,12 +39,18 @@ export default function ScoreFeed() {
return ( return (
<div className="flex flex-col divide-y divide-border"> <div className="flex flex-col divide-y divide-border">
{scores.map(score => { {scores.map(scoreToken => {
const player = score.score.leaderboardPlayerInfo; if (!scoreToken.leaderboard || !scoreToken.score) {
const leaderboard = getScoreSaberLeaderboardFromToken(score.leaderboard); 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 ( return (
<div key={score.score.id} className="flex flex-col py-2"> <div key={score.scoreId} className="flex flex-col py-2">
<p className="text-sm"> <p className="text-sm">
Set by{" "} Set by{" "}
<Link href={`/player/${player.id}`}> <Link href={`/player/${player.id}`}>
@ -53,7 +58,7 @@ export default function ScoreFeed() {
</Link> </Link>
</p> </p>
<Score <Score
score={getScoreSaberScoreFromToken(score.score, leaderboard)} score={score}
leaderboard={leaderboard} leaderboard={leaderboard}
settings={{ settings={{
noScoreButtons: true, noScoreButtons: true,

@ -23,6 +23,11 @@ import { ScoreStatsToken } from "@ssr/common/types/token/beatleader/score-stats/
import { beatLeaderService } from "@ssr/common/service/impl/beatleader"; import { beatLeaderService } from "@ssr/common/service/impl/beatleader";
type Props = { type Props = {
/**
* The score to display.
*/
score: ScoreSaberScore;
/** /**
* The leaderboard. * The leaderboard.
*/ */
@ -33,11 +38,6 @@ type Props = {
*/ */
beatSaverMap?: BeatSaverMap; beatSaverMap?: BeatSaverMap;
/**
* The score to display.
*/
score: ScoreSaberScore;
/** /**
* Score settings * Score settings
*/ */
@ -123,7 +123,7 @@ export default function Score({ leaderboard, beatSaverMap, score, settings }: Pr
useEffect(() => { useEffect(() => {
setIsLeaderboardExpanded(false); setIsLeaderboardExpanded(false);
setLeaderboardDropdownData(undefined); setLeaderboardDropdownData(undefined);
}, [score]); }, [score.scoreId]);
const accuracy = (baseScore / leaderboard.maxScore) * 100; const accuracy = (baseScore / leaderboard.maxScore) * 100;
const pp = baseScore === score.score ? score.pp : scoresaberService.getPp(leaderboard.stars, accuracy); const pp = baseScore === score.score ? score.pp : scoresaberService.getPp(leaderboard.stars, accuracy);