fix crying
This commit is contained in:
parent
1d9433ef02
commit
cd1f010698
@ -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
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
@ -71,53 +71,6 @@ class ScoreSaberScorePublic extends ScoreSaberScoreInternal {
|
||||
}
|
||||
|
||||
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 const ScoreSaberScoreModel: ReturnModelType<typeof ScoreSaberScoreInternal> =
|
||||
getModelForClass(ScoreSaberScoreInternal);
|
||||
|
@ -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<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.
|
||||
*/
|
||||
|
331
projects/common/src/token-creators.ts
Normal file
331
projects/common/src/token-creators.ts
Normal file
@ -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 { 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",
|
||||
|
@ -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;
|
||||
|
@ -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<ScoreSaberWebsocketMessageToken>("wss://scoresaber.com/ws");
|
||||
@ -40,12 +39,18 @@ export default function ScoreFeed() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col divide-y divide-border">
|
||||
{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 (
|
||||
<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">
|
||||
Set by{" "}
|
||||
<Link href={`/player/${player.id}`}>
|
||||
@ -53,7 +58,7 @@ export default function ScoreFeed() {
|
||||
</Link>
|
||||
</p>
|
||||
<Score
|
||||
score={getScoreSaberScoreFromToken(score.score, leaderboard)}
|
||||
score={score}
|
||||
leaderboard={leaderboard}
|
||||
settings={{
|
||||
noScoreButtons: true,
|
||||
|
@ -23,6 +23,11 @@ import { ScoreStatsToken } from "@ssr/common/types/token/beatleader/score-stats/
|
||||
import { beatLeaderService } from "@ssr/common/service/impl/beatleader";
|
||||
|
||||
type Props = {
|
||||
/**
|
||||
* The score to display.
|
||||
*/
|
||||
score: ScoreSaberScore;
|
||||
|
||||
/**
|
||||
* The leaderboard.
|
||||
*/
|
||||
@ -33,11 +38,6 @@ type Props = {
|
||||
*/
|
||||
beatSaverMap?: BeatSaverMap;
|
||||
|
||||
/**
|
||||
* The score to display.
|
||||
*/
|
||||
score: ScoreSaberScore;
|
||||
|
||||
/**
|
||||
* Score settings
|
||||
*/
|
||||
@ -123,7 +123,7 @@ export default function Score({ leaderboard, beatSaverMap, score, settings }: Pr
|
||||
useEffect(() => {
|
||||
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);
|
||||
|
Reference in New Issue
Block a user