This commit is contained in:
Lee 2024-10-25 16:56:37 +01:00
parent b911072a47
commit 2edb5c04c9
15 changed files with 509 additions and 125 deletions

@ -2,34 +2,38 @@ import { Controller, Get } from "elysia-decorators";
import { t } from "elysia"; import { t } from "elysia";
import { Leaderboards } from "@ssr/common/leaderboard"; import { Leaderboards } from "@ssr/common/leaderboard";
import { ScoreService } from "../service/score.service"; import { ScoreService } from "../service/score.service";
import { ScoreSortType } from "@ssr/common/sorter/sort-type";
import { SortDirection } from "@ssr/common/sorter/sort-direction";
@Controller("/scores") @Controller("/scores")
export default class ScoresController { export default class ScoresController {
@Get("/player/:leaderboard/:id/:page/:sort", { @Get("/player/:leaderboard/:id/:page/:sort/:direction", {
config: {}, config: {},
params: t.Object({ params: t.Object({
leaderboard: t.String({ required: true }), leaderboard: t.String({ required: true }),
id: t.String({ required: true }), id: t.String({ required: true }),
page: t.Number({ required: true }), page: t.Number({ required: true }),
sort: t.String({ required: true }), sort: t.String({ required: true }),
direction: t.String({ required: true }),
}), }),
query: t.Object({ query: t.Object({
search: t.Optional(t.String()), search: t.Optional(t.String()),
}), }),
}) })
public async getScores({ public async getScores({
params: { leaderboard, id, page, sort }, params: { leaderboard, id, page, sort, direction },
query: { search }, query: { search },
}: { }: {
params: { params: {
leaderboard: Leaderboards; leaderboard: Leaderboards;
id: string; id: string;
page: number; page: number;
sort: string; sort: ScoreSortType;
direction: SortDirection;
}; };
query: { search?: string }; query: { search?: string };
}): Promise<unknown> { }): Promise<unknown> {
return await ScoreService.getPlayerScores(leaderboard, id, page, sort, search); return await ScoreService.getPlayerScores(leaderboard, id, page, sort, direction, search);
} }
@Get("/leaderboard/:leaderboard/:id/:page", { @Get("/leaderboard/:leaderboard/:id/:page", {

@ -24,6 +24,7 @@ import { connectScoresaberWebsocket } from "@ssr/common/websocket/scoresaber-web
import { connectBeatLeaderWebsocket } from "@ssr/common/websocket/beatleader-websocket"; import { connectBeatLeaderWebsocket } from "@ssr/common/websocket/beatleader-websocket";
import { DiscordChannels, initDiscordBot, logToChannel } from "./bot/bot"; import { DiscordChannels, initDiscordBot, logToChannel } from "./bot/bot";
import { EmbedBuilder } from "discord.js"; import { EmbedBuilder } from "discord.js";
import { ScoreSort } from "@ssr/common/score/score-sort";
// Load .env file // Load .env file
dotenv.config({ dotenv.config({
@ -38,7 +39,9 @@ await mongoose.connect(Config.mongoUri!); // Connect to MongoDB
// Connect to websockets // Connect to websockets
connectScoresaberWebsocket({ connectScoresaberWebsocket({
onScore: async score => { onScore: async score => {
await ScoreService.trackScoreSaberScore(score); await ScoreService.trackScoreSaberScore(score.score, score.leaderboard);
await ScoreService.updatePlayerScoresSet(score);
await ScoreService.notifyNumberOne(score); await ScoreService.notifyNumberOne(score);
}, },
onDisconnect: async error => { onDisconnect: async error => {
@ -106,6 +109,46 @@ app.use(
}) })
); );
app.use(
cron({
name: "scores-background-refresh",
pattern: "*/1 * * * *",
protect: true,
run: async () => {
console.log(`Refreshing player score data...`);
const players = await PlayerModel.find({});
console.log(`Found ${players.length} players to refresh.`);
for (const player of players) {
console.log(`Refreshing scores for ${player.id}...`);
let page = 1;
let hasMorePages = true;
while (hasMorePages) {
const scoresPage = await scoresaberService.lookupPlayerScores({
playerId: player.id,
page: page,
limit: 100,
sort: ScoreSort.recent,
});
if (!scoresPage) {
break;
}
if (scoresPage.metadata.total <= page * 100) {
hasMorePages = false;
}
page++;
for (const score of scoresPage.playerScores) {
await ScoreService.trackScoreSaberScore(score.score, score.leaderboard, player.id);
}
}
console.log(`Finished refreshing scores for ${player.id}, total pages refreshed: ${page - 1}.`);
}
},
})
);
/** /**
* Custom error handler * Custom error handler
*/ */

@ -3,9 +3,7 @@ import { formatNumberWithCommas, formatPp } from "@ssr/common/utils/number-utils
import { isProduction } from "@ssr/common/utils/utils"; 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 { scoresaberService } from "@ssr/common/service/impl/scoresaber"; import { scoresaberService } from "@ssr/common/service/impl/scoresaber";
import { ScoreSort } from "@ssr/common/score/score-sort";
import { Leaderboards } from "@ssr/common/leaderboard"; import { Leaderboards } from "@ssr/common/leaderboard";
import Leaderboard from "@ssr/common/leaderboard/leaderboard"; import Leaderboard from "@ssr/common/leaderboard/leaderboard";
import LeaderboardService from "./leaderboard.service"; import LeaderboardService from "./leaderboard.service";
@ -25,10 +23,24 @@ 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 { ScoreType } from "@ssr/common/model/score/score"; import Score, { ScoreType } from "@ssr/common/model/score/score";
import { getScoreSaberLeaderboardFromToken, getScoreSaberScoreFromToken } from "@ssr/common/token-creators"; import { getScoreSaberLeaderboardFromToken, getScoreSaberScoreFromToken } from "@ssr/common/token-creators";
import { ScoreSaberScoreModel } from "@ssr/common/model/score/impl/scoresaber-score"; import {
ScoreSaberScore,
ScoreSaberScoreInternal,
ScoreSaberScoreModel,
} 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 { ScoreSorters } from "@ssr/common/sorter/sorters";
import { ScoreSortType } from "@ssr/common/sorter/sort-type";
import { SortDirection } from "@ssr/common/sorter/sort-direction";
import { Pagination } from "../../../common/src/pagination";
import { PlayerService } from "./player.service";
import { ScoreSort } from "@ssr/common/score/score-sort";
import BeatSaverService from "./beatsaver.service";
import ScoreSaberPlayerToken from "@ssr/common/types/token/scoresaber/score-saber-player-token";
import ScoreSaberScoreToken from "@ssr/common/types/token/scoresaber/score-saber-score-token";
import ScoreSaberLeaderboardToken from "@ssr/common/types/token/scoresaber/score-saber-leaderboard-token";
const playerScoresCache = new SSRCache({ const playerScoresCache = new SSRCache({
ttl: 1000 * 60, // 1 minute ttl: 1000 * 60, // 1 minute
@ -38,6 +50,8 @@ const leaderboardScoresCache = new SSRCache({
ttl: 1000 * 60, // 1 minute ttl: 1000 * 60, // 1 minute
}); });
const ITEMS_PER_PAGE = 8;
export class ScoreService { export class ScoreService {
/** /**
* Notifies the number one score in Discord. * Notifies the number one score in Discord.
@ -118,15 +132,17 @@ export class ScoreService {
} }
/** /**
* Tracks ScoreSaber score. * Updates the players set scores count for today.
* *
* @param score the score to track * @param score the score
* @param leaderboard the leaderboard to track
*/ */
public static async trackScoreSaberScore({ score, leaderboard: leaderboardToken }: ScoreSaberPlayerScoreToken) { public static async updatePlayerScoresSet({
score: scoreToken,
leaderboard: leaderboardToken,
}: ScoreSaberPlayerScoreToken) {
const playerId = scoreToken.leaderboardPlayerInfo.id;
const leaderboard = getScoreSaberLeaderboardFromToken(leaderboardToken); const leaderboard = getScoreSaberLeaderboardFromToken(leaderboardToken);
const playerId = score.leaderboardPlayerInfo.id;
const playerName = score.leaderboardPlayerInfo.name;
const player: PlayerDocument | null = await PlayerModel.findById(playerId); const player: PlayerDocument | null = await PlayerModel.findById(playerId);
// Player is not tracked, so ignore the score. // Player is not tracked, so ignore the score.
if (player == undefined) { if (player == undefined) {
@ -147,37 +163,36 @@ export class ScoreService {
history.scores = scores; history.scores = scores;
player.setStatisticHistory(today, history); player.setStatisticHistory(today, history);
player.sortStatisticHistory();
// Save the changes
player.markModified("statisticHistory");
await player.save(); await player.save();
}
const scoreToken = getScoreSaberScoreFromToken(score, leaderboard, playerId); /**
// eslint-disable-next-line @typescript-eslint/ban-ts-comment * Tracks ScoreSaber score.
// @ts-expect-error *
delete scoreToken.playerInfo; * @param scoreToken the score to track
* @param leaderboardToken the leaderboard for the score
// Check if the score already exists * @param playerId the id of the player
if ( */
await ScoreSaberScoreModel.exists({ public static async trackScoreSaberScore(
playerId: playerId, scoreToken: ScoreSaberScoreToken,
leaderboardId: leaderboard.id, leaderboardToken: ScoreSaberLeaderboardToken,
score: scoreToken.score, playerId?: string
difficulty: leaderboard.difficulty.difficulty,
characteristic: leaderboard.difficulty.characteristic,
})
) { ) {
console.log( playerId = playerId || scoreToken.leaderboardPlayerInfo.id;
`Score already exists for "${playerName}"(${playerId}), scoreId=${scoreToken.scoreId}, score=${scoreToken.score}`
); const leaderboard = getScoreSaberLeaderboardFromToken(leaderboardToken);
const score = getScoreSaberScoreFromToken(scoreToken, leaderboard, playerId);
const player: PlayerDocument | null = await PlayerModel.findById(playerId);
// Player is not tracked, so ignore the score.
if (player == undefined) {
return; return;
} }
await ScoreSaberScoreModel.create(scoreToken); // eslint-disable-next-line @typescript-eslint/ban-ts-comment
console.log( // @ts-expect-error
`Tracked score and updated scores set statistic for "${playerName}"(${playerId}), scores today: ${scores.rankedScores} ranked, ${scores.unrankedScores} unranked` delete score.playerInfo;
);
await ScoreSaberScoreModel.create(score);
} }
/** /**
@ -292,7 +307,8 @@ export class ScoreService {
* @param leaderboardName the leaderboard to get the scores from * @param leaderboardName the leaderboard to get the scores from
* @param playerId the players id * @param playerId the players id
* @param page the page to get * @param page the page to get
* @param sort the sort to use * @param sort the sort type to use
* @param direction the direction to sort the scores
* @param search the search to use * @param search the search to use
* @returns the scores * @returns the scores
*/ */
@ -300,36 +316,104 @@ export class ScoreService {
leaderboardName: Leaderboards, leaderboardName: Leaderboards,
playerId: string, playerId: string,
page: number, page: number,
sort: string, sort: ScoreSortType,
direction: SortDirection,
search?: string search?: string
): Promise<PlayerScoresResponse<unknown, unknown> | undefined> { ): Promise<PlayerScoresResponse<unknown, unknown> | undefined> {
console.log(
`Fetching scores for ${playerId} on ${leaderboardName}, page: ${page}, sort: ${sort}, direction: ${direction}, search: ${search}`
);
return fetchWithCache( return fetchWithCache(
playerScoresCache, playerScoresCache,
`player-scores-${leaderboardName}-${playerId}-${page}-${sort}-${search}`, `player-scores-${leaderboardName}-${playerId}-${page}-${sort}-${search}`,
async () => { async () => {
const scores: PlayerScore<unknown, unknown>[] | undefined = []; const toReturn: PlayerScore<unknown, unknown>[] | undefined = [];
let metadata: Metadata = new Metadata(0, 0, 0, 0); // Default values let metadata: Metadata = new Metadata(0, 0, 0, 0); // Default values
switch (leaderboardName) { switch (leaderboardName) {
case "scoresaber": { case "scoresaber": {
const leaderboardScores = await scoresaberService.lookupPlayerScores({ let isPlayerTracked = false;
try {
isPlayerTracked = (await PlayerService.getPlayer(playerId, false)) != undefined;
} catch {
/* ignored */
}
if (isPlayerTracked) {
const rawScores = ScoreSorters.scoreSaber.sort(
sort,
direction,
(await ScoreSaberScoreModel.find({ playerId: playerId }).exec()) as unknown as ScoreSaberScore[]
);
if (!rawScores || rawScores.length === 0) {
break;
}
const pagination = new Pagination<ScoreSaberScore>().setItemsPerPage(ITEMS_PER_PAGE).setItems(rawScores);
const paginatedPage = pagination.getPage(page);
metadata = paginatedPage.metadata;
for (const score of paginatedPage.items) {
const { leaderboard, beatsaver } = await LeaderboardService.getLeaderboard<ScoreSaberLeaderboard>(
"scoresaber",
String(score.leaderboardId)
);
if (leaderboard == undefined) {
continue;
}
const additionalData = await this.getAdditionalScoreData(
playerId,
leaderboard.songHash,
`${leaderboard.difficulty.difficulty}-${leaderboard.difficulty.characteristic}`,
score.score
);
if (additionalData == undefined) {
continue;
}
toReturn.push({
score: score,
leaderboard: leaderboard,
beatSaver: beatsaver,
});
}
} else {
// Convert the sort type
let scoreSaberSort: ScoreSort;
switch (sort) {
case ScoreSortType.date: {
scoreSaberSort = ScoreSort.recent;
break;
}
case ScoreSortType.pp: {
scoreSaberSort = ScoreSort.top;
break;
}
default: {
scoreSaberSort = ScoreSort.recent;
break;
}
}
const rawScores = await scoresaberService.lookupPlayerScores({
playerId: playerId, playerId: playerId,
page: page, page: page,
sort: sort as ScoreSort, sort: scoreSaberSort,
search: search, search: search,
}); });
if (leaderboardScores == undefined) { if (!rawScores || rawScores.playerScores.length === 0) {
break; break;
} }
metadata = new Metadata( metadata = new Metadata(
Math.ceil(leaderboardScores.metadata.total / leaderboardScores.metadata.itemsPerPage), Math.ceil(rawScores.metadata.total / rawScores.metadata.itemsPerPage),
leaderboardScores.metadata.total, rawScores.metadata.total,
leaderboardScores.metadata.page, rawScores.metadata.page,
leaderboardScores.metadata.itemsPerPage rawScores.metadata.itemsPerPage
); );
for (const token of leaderboardScores.playerScores) { for (const token of rawScores.playerScores) {
const leaderboard = getScoreSaberLeaderboardFromToken(token.leaderboard); const leaderboard = getScoreSaberLeaderboardFromToken(token.leaderboard);
if (leaderboard == undefined) { if (leaderboard == undefined) {
continue; continue;
@ -350,12 +434,14 @@ export class ScoreService {
score.additionalData = additionalData; score.additionalData = additionalData;
} }
scores.push({ toReturn.push({
score: score, score: score,
leaderboard: leaderboard, leaderboard: leaderboard,
beatSaver: await BeatSaverService.getMap(leaderboard.songHash), beatSaver: await BeatSaverService.getMap(leaderboard.songHash),
}); });
} }
}
break; break;
} }
default: { default: {
@ -363,8 +449,9 @@ export class ScoreService {
} }
} }
console.log(metadata);
return { return {
scores: scores, scores: toReturn,
metadata: metadata, metadata: metadata,
}; };
} }

@ -0,0 +1,73 @@
import { NotFoundError } from "backend/src/error/not-found-error";
import { Metadata } from "./types/metadata";
export class Pagination<T> {
/**
* The amount of items per page.
* @private
*/
private itemsPerPage: number = 0;
/**
* The amount of items in total.
* @private
*/
private totalItems: number = 0;
/**
* The items to paginate.
* @private
*/
private items: T[] = [];
/**
* Sets the number of items per page.
*
* @param itemsPerPage - The number of items per page.
* @returns the pagination
*/
setItemsPerPage(itemsPerPage: number): Pagination<T> {
this.itemsPerPage = itemsPerPage;
return this;
}
/**
* Sets the items to paginate.
*
* @param items the items to paginate
* @returns the pagination
*/
setItems(items: T[]): Pagination<T> {
this.items = items;
this.totalItems = items.length;
return this;
}
/**
* Gets a page of items.
*
* @param page the page number to retrieve.
* @returns the page of items.
* @throws throws an error if the page number is invalid.
*/
getPage(page: number): Page<T> {
const totalPages = Math.ceil(this.totalItems / this.itemsPerPage);
if (page < 1 || page > totalPages) {
throw new NotFoundError("Invalid page number");
}
const items = this.items.slice((page - 1) * this.itemsPerPage, page * this.itemsPerPage);
return new Page<T>(items, new Metadata(totalPages, this.totalItems, page, this.itemsPerPage));
}
}
class Page<T> {
readonly items: T[];
readonly metadata: Metadata;
constructor(items: T[], metadata: Metadata) {
this.items = items;
this.metadata = metadata;
}
}

@ -168,6 +168,7 @@ class ScoreSaberService extends Service {
* @param playerId the ID of the player to look up * @param playerId the ID of the player to look up
* @param sort the sort to use * @param sort the sort to use
* @param page the page to get scores for * @param page the page to get scores for
* @param limit the amount of scores to fetch
* @param search * @param search
* @returns the scores of the player, or undefined * @returns the scores of the player, or undefined
*/ */
@ -175,11 +176,13 @@ class ScoreSaberService extends Service {
playerId, playerId,
sort, sort,
page, page,
limit = 8,
search, search,
}: { }: {
playerId: string; playerId: string;
sort: ScoreSort; sort: ScoreSort;
page: number; page: number;
limit?: number;
search?: string; search?: string;
useProxy?: boolean; useProxy?: boolean;
}): Promise<ScoreSaberPlayerScoresPageToken | undefined> { }): Promise<ScoreSaberPlayerScoresPageToken | undefined> {
@ -189,7 +192,7 @@ class ScoreSaberService extends Service {
); );
const response = await this.fetch<ScoreSaberPlayerScoresPageToken>( const response = await this.fetch<ScoreSaberPlayerScoresPageToken>(
LOOKUP_PLAYER_SCORES_ENDPOINT.replace(":id", playerId) LOOKUP_PLAYER_SCORES_ENDPOINT.replace(":id", playerId)
.replace(":limit", 8 + "") .replace(":limit", limit + "")
.replace(":sort", sort) .replace(":sort", sort)
.replace(":page", page + "") + (search ? `&search=${search}` : "") .replace(":page", page + "") + (search ? `&search=${search}` : "")
); );

@ -0,0 +1,69 @@
import { ScoreSorter } from "../score-sorter";
import { ScoreSaberScore } from "../../model/score/impl/scoresaber-score";
import { ScoreSortType } from "../sort-type";
import { SortDirection } from "../sort-direction";
export class ScoreSaberScoreSorter extends ScoreSorter<ScoreSaberScore> {
sort(type: ScoreSortType, direction: SortDirection, items: ScoreSaberScore[]): ScoreSaberScore[] {
switch (type) {
case ScoreSortType.date:
return this.sortRecent(direction, items);
case ScoreSortType.pp:
return this.sortPp(direction, items);
case ScoreSortType.accuracy:
return this.sortAccuracy(direction, items);
case ScoreSortType.misses:
return this.sortMisses(direction, items);
default:
return items;
}
}
/**
* Sorts the scores by the time they were set.
*
* @param direction the direction to sort the scores
* @param items the scores to sort
* @returns the sorted scores
*/
sortRecent(direction: SortDirection, items: ScoreSaberScore[]): ScoreSaberScore[] {
return items.sort((a, b) =>
direction === SortDirection.ASC
? a.timestamp.getTime() - b.timestamp.getTime()
: b.timestamp.getTime() - a.timestamp.getTime()
);
}
/**
* Sorts the scores by their pp value
*
* @param direction the direction to sort the scores
* @param items the scores to sort
* @returns the sorted scores
*/
sortPp(direction: SortDirection, items: ScoreSaberScore[]): ScoreSaberScore[] {
return items.sort((a, b) => (direction === SortDirection.ASC ? a.pp - b.pp : b.pp - a.pp));
}
/**
* Sorts the scores by their accuracy value
*
* @param direction the direction to sort the scores
* @param items the scores to sort
* @returns the sorted scores
*/
sortAccuracy(direction: SortDirection, items: ScoreSaberScore[]): ScoreSaberScore[] {
return items.sort((a, b) => (direction === SortDirection.ASC ? a.accuracy - b.accuracy : b.accuracy - a.accuracy));
}
/**
* Sorts the scores by their misses
*
* @param direction the direction to sort the scores
* @param items the scores to sort
* @returns the sorted scores
*/
sortMisses(direction: SortDirection, items: ScoreSaberScore[]): ScoreSaberScore[] {
return items.sort((a, b) => (direction === SortDirection.ASC ? a.misses - b.misses : b.misses - a.misses));
}
}

@ -0,0 +1,14 @@
import { ScoreSortType } from "./sort-type";
import { SortDirection } from "./sort-direction";
export abstract class ScoreSorter<T> {
/**
* Sorts the items
*
* @param type the type of sort
* @param direction the direction of the sort
* @param items the items to sort
* @returns the sorted items
*/
public abstract sort(type: ScoreSortType, direction: SortDirection, items: T[]): T[];
}

@ -0,0 +1,4 @@
export enum SortDirection {
ASC = "asc",
DESC = "desc",
}

@ -0,0 +1,6 @@
export enum ScoreSortType {
date = "date",
pp = "pp",
accuracy = "accuracy",
misses = "misses",
}

@ -0,0 +1,5 @@
import { ScoreSaberScoreSorter } from "./impl/scoresaber-sorter";
export const ScoreSorters = {
scoreSaber: new ScoreSaberScoreSorter(),
};

@ -1,6 +1,6 @@
import { isServer } from "./utils"; import { isServer } from "./utils";
export type CookieName = "playerId" | "lastScoreSort"; export type CookieName = "playerId" | "lastScoreSort" | "lastScoreSortDirection";
/** /**
* Gets the value of a cookie * Gets the value of a cookie

@ -4,6 +4,8 @@ import PlayerScoresResponse from "../response/player-scores-response";
import { Config } from "../config"; import { Config } from "../config";
import { ScoreSort } from "../score/score-sort"; import { ScoreSort } from "../score/score-sort";
import LeaderboardScoresResponse from "../response/leaderboard-scores-response"; import LeaderboardScoresResponse from "../response/leaderboard-scores-response";
import { ScoreSortType } from "../sorter/sort-type";
import { SortDirection } from "../sorter/sort-direction";
/** /**
* Fetches the player's scores * Fetches the player's scores
@ -12,17 +14,19 @@ import LeaderboardScoresResponse from "../response/leaderboard-scores-response";
* @param id the player id * @param id the player id
* @param page the page * @param page the page
* @param sort the sort * @param sort the sort
* @param direction the direction to sort
* @param search the search * @param search the search
*/ */
export async function fetchPlayerScores<S, L>( export async function fetchPlayerScores<S, L>(
leaderboard: Leaderboards, leaderboard: Leaderboards,
id: string, id: string,
page: number, page: number,
sort: ScoreSort, sort: ScoreSortType,
direction: SortDirection,
search?: string search?: string
) { ) {
return kyFetch<PlayerScoresResponse<S, L>>( return kyFetch<PlayerScoresResponse<S, L>>(
`${Config.apiUrl}/scores/player/${leaderboard}/${id}/${page}/${sort}${search ? `?search=${search}` : ""}` `${Config.apiUrl}/scores/player/${leaderboard}/${id}/${page}/${sort}/${direction}${search ? `?search=${search}` : ""}`
); );
} }

@ -7,13 +7,14 @@ 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 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 { 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"; import { getScoreSaberPlayerFromToken } from "@ssr/common/token-creators";
import { ScoreSortType } from "@ssr/common/sorter/sort-type";
import { SortDirection } from "@ssr/common/sorter/sort-direction";
const UNKNOWN_PLAYER = { const UNKNOWN_PLAYER = {
title: "ScoreSaber Reloaded - Unknown Player", title: "ScoreSaber Reloaded - Unknown Player",
@ -32,7 +33,8 @@ type Props = {
type PlayerData = { type PlayerData = {
player: ScoreSaberPlayer | undefined; player: ScoreSaberPlayer | undefined;
scores: PlayerScoresResponse<ScoreSaberScore, ScoreSaberLeaderboard> | undefined; scores: PlayerScoresResponse<ScoreSaberScore, ScoreSaberLeaderboard> | undefined;
sort: ScoreSort; sort: ScoreSortType;
direction: SortDirection;
page: number; page: number;
search: string; search: string;
}; };
@ -51,9 +53,11 @@ const playerCache = new SSRCache({
const getPlayerData = async ({ params }: Props, fetchScores: boolean = true): Promise<PlayerData> => { const getPlayerData = async ({ params }: Props, fetchScores: boolean = true): Promise<PlayerData> => {
const { slug } = await params; const { slug } = await params;
const id = slug[0]; // The players id const id = slug[0]; // The players id
const sort: ScoreSort = (slug[1] as ScoreSort) || (await getCookieValue("lastScoreSort", "recent")); // The sorting method const sort: ScoreSortType = (slug[1] as ScoreSortType) || (await getCookieValue("lastScoreSort", ScoreSortType.date)); // The sorting method
const page = parseInt(slug[2]) || 1; // The page number const direction: SortDirection =
const search = (slug[3] as string) || ""; // The search query (slug[2] as SortDirection) || (await getCookieValue("lastScoreSortDirection", SortDirection.DESC)); // The sorting direction
const page = parseInt(slug[3]) || 1; // The page number
const search = (slug[4] as string) || ""; // The search query
const cacheId = `${id}-${sort}-${page}-${search}-${fetchScores}`; const cacheId = `${id}-${sort}-${page}-${search}-${fetchScores}`;
if (playerCache.has(cacheId)) { if (playerCache.has(cacheId)) {
@ -64,11 +68,19 @@ const getPlayerData = async ({ params }: Props, fetchScores: boolean = true): Pr
const player = playerToken && (await getScoreSaberPlayerFromToken(playerToken, await getCookieValue("playerId"))); const player = playerToken && (await getScoreSaberPlayerFromToken(playerToken, await getCookieValue("playerId")));
let scores: PlayerScoresResponse<ScoreSaberScore, ScoreSaberLeaderboard> | undefined; let scores: PlayerScoresResponse<ScoreSaberScore, ScoreSaberLeaderboard> | undefined;
if (fetchScores) { if (fetchScores) {
scores = await fetchPlayerScores<ScoreSaberScore, ScoreSaberLeaderboard>("scoresaber", id, page, sort, search); scores = await fetchPlayerScores<ScoreSaberScore, ScoreSaberLeaderboard>(
"scoresaber",
id,
page,
sort,
direction,
search
);
} }
const playerData = { const playerData = {
sort: sort, sort: sort,
direction: direction,
page: page, page: page,
search: search, search: search,
player: player, player: player,
@ -123,14 +135,21 @@ export async function generateViewport(props: Props): Promise<Viewport> {
} }
export default async function PlayerPage(props: Props) { export default async function PlayerPage(props: Props) {
const { player, scores, sort, page, search } = await getPlayerData(props); const { player, scores, sort, direction, page, search } = await getPlayerData(props);
if (player == undefined) { if (player == undefined) {
return redirect("/"); return redirect("/");
} }
return ( return (
<div className="flex flex-col h-full w-full"> <div className="flex flex-col h-full w-full">
<PlayerData initialPlayerData={player} initialScoreData={scores} initialSearch={search} sort={sort} page={page} /> <PlayerData
initialPlayerData={player}
initialScoreData={scores}
initialSearch={search}
sort={sort}
direction={direction}
page={page}
/>
</div> </div>
); );
} }

@ -19,16 +19,26 @@ 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"; import { getScoreSaberPlayerFromToken } from "@ssr/common/token-creators";
import { ScoreSortType } from "@ssr/common/sorter/sort-type";
import { SortDirection } from "@ssr/common/sorter/sort-direction";
type Props = { type Props = {
initialPlayerData: ScoreSaberPlayer; initialPlayerData: ScoreSaberPlayer;
initialScoreData?: PlayerScoresResponse<ScoreSaberScore, ScoreSaberLeaderboard>; initialScoreData?: PlayerScoresResponse<ScoreSaberScore, ScoreSaberLeaderboard>;
initialSearch?: string; initialSearch?: string;
sort: ScoreSort; sort: ScoreSortType;
direction: SortDirection;
page: number; page: number;
}; };
export default function PlayerData({ initialPlayerData, initialScoreData, initialSearch, sort, page }: Props) { export default function PlayerData({
initialPlayerData,
initialScoreData,
initialSearch,
sort,
direction,
page,
}: Props) {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const miniRankingsRef = useRef<HTMLDivElement>(null); const miniRankingsRef = useRef<HTMLDivElement>(null);
const isMiniRankingsVisible = useIsVisible(miniRankingsRef); const isMiniRankingsVisible = useIsVisible(miniRankingsRef);
@ -65,6 +75,7 @@ export default function PlayerData({ initialPlayerData, initialScoreData, initia
initialSearch={initialSearch} initialSearch={initialSearch}
player={player} player={player}
sort={sort} sort={sort}
direction={direction}
page={page} page={page}
/> />
</article> </article>

@ -1,6 +1,6 @@
import { capitalizeFirstLetter } from "@/common/string-utils"; import { capitalizeFirstLetter } from "@/common/string-utils";
import useWindowDimensions from "@/hooks/use-window-dimensions"; import useWindowDimensions from "@/hooks/use-window-dimensions";
import { ClockIcon, TrophyIcon, XMarkIcon } from "@heroicons/react/24/solid"; import { ArrowDownIcon, ClockIcon, TrophyIcon, XMarkIcon } from "@heroicons/react/24/solid";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { motion, useAnimation } from "framer-motion"; import { motion, useAnimation } from "framer-motion";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
@ -13,44 +13,55 @@ import { clsx } from "clsx";
import { useDebounce } from "@uidotdev/usehooks"; import { useDebounce } from "@uidotdev/usehooks";
import { scoreAnimation } from "@/components/score/score-animation"; import { scoreAnimation } from "@/components/score/score-animation";
import ScoreSaberPlayer 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 { setCookieValue } from "@ssr/common/utils/cookie-utils"; import { setCookieValue } from "@ssr/common/utils/cookie-utils";
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 { SortDirection } from "@ssr/common/sorter/sort-direction";
import { ScoreSortType } from "@ssr/common/sorter/sort-type";
type Props = { type Props = {
initialScoreData?: PlayerScoresResponse<ScoreSaberScore, ScoreSaberLeaderboard>; initialScoreData?: PlayerScoresResponse<ScoreSaberScore, ScoreSaberLeaderboard>;
initialSearch?: string; initialSearch?: string;
player: ScoreSaberPlayer; player: ScoreSaberPlayer;
sort: ScoreSort; sort: ScoreSortType;
direction: SortDirection;
page: number; page: number;
}; };
type PageState = { type PageState = {
page: number; page: number;
sort: ScoreSort; sort: ScoreSortType;
direction: SortDirection;
}; };
const scoreSort = [ const scoreSort = [
{ {
name: "Top", name: "PP",
value: ScoreSort.top, value: ScoreSortType.pp,
icon: <TrophyIcon className="w-5 h-5" />,
}, },
{ {
name: "Recent", name: "Date",
value: ScoreSort.recent, value: ScoreSortType.date,
icon: <ClockIcon className="w-5 h-5" />, },
{
name: "Acc",
value: ScoreSortType.accuracy,
requiresTrackedPlayer: true,
},
{
name: "Misses",
value: ScoreSortType.misses,
requiresTrackedPlayer: true,
}, },
]; ];
export default function PlayerScores({ initialScoreData, initialSearch, player, sort, page }: Props) { export default function PlayerScores({ initialScoreData, initialSearch, player, sort, direction, page }: Props) {
const { width } = useWindowDimensions(); const { width } = useWindowDimensions();
const controls = useAnimation(); const controls = useAnimation();
const [pageState, setPageState] = useState<PageState>({ page, sort }); const [pageState, setPageState] = useState<PageState>({ page, sort, direction });
const [previousPage, setPreviousPage] = useState(page); const [previousPage, setPreviousPage] = useState(page);
const [scores, setScores] = useState<PlayerScoresResponse<ScoreSaberScore, ScoreSaberLeaderboard> | undefined>( const [scores, setScores] = useState<PlayerScoresResponse<ScoreSaberScore, ScoreSaberLeaderboard> | undefined>(
initialScoreData initialScoreData
@ -69,6 +80,7 @@ export default function PlayerScores({ initialScoreData, initialSearch, player,
player.id, player.id,
pageState.page, pageState.page,
pageState.sort, pageState.sort,
pageState.direction,
debouncedSearchTerm debouncedSearchTerm
), ),
enabled: shouldFetch && (debouncedSearchTerm.length >= 3 || debouncedSearchTerm.length === 0), enabled: shouldFetch && (debouncedSearchTerm.length >= 3 || debouncedSearchTerm.length === 0),
@ -88,14 +100,30 @@ export default function PlayerScores({ initialScoreData, initialSearch, player,
* *
* @param newSort the new sort * @param newSort the new sort
*/ */
const handleSortChange = async (newSort: ScoreSort) => { const handleSortChange = async (newSort: ScoreSortType) => {
if (newSort !== pageState.sort) { if (newSort !== pageState.sort) {
setPageState({ page: 1, sort: newSort }); setPageState({ ...pageState, page: 1, sort: newSort, direction: SortDirection.DESC });
setShouldFetch(true); // Set to true to trigger fetch setShouldFetch(true); // Set to true to trigger fetch
await setCookieValue("lastScoreSort", newSort); // Set the default score sort await setCookieValue("lastScoreSort", newSort); // Set the default score sort
} }
}; };
/**
* Change the score sort direction.
*
* @param newDirection the new sort direction
*/
const handleSortDirectionChange = async (newDirection: SortDirection) => {
// Player doesn't have scores tracked anyway, so no need to change this.
if (!player.isBeingTracked) {
return;
}
setPageState({ ...pageState, page: 1, direction: newDirection });
setShouldFetch(true); // Set to true to trigger fetch
await setCookieValue("lastScoreSortDirection", newDirection); // Set the default score sort
};
/** /**
* Change the score search term. * Change the score search term.
* *
@ -131,9 +159,9 @@ export default function PlayerScores({ initialScoreData, initialSearch, player,
*/ */
const getUrl = useCallback( const getUrl = useCallback(
(page: number) => { (page: number) => {
return `/player/${player.id}/${pageState.sort}/${page}${isSearchActive ? `?search=${debouncedSearchTerm}` : ""}`; return `/player/${player.id}/${pageState.sort}/${pageState.direction}/${page}${isSearchActive ? `?search=${debouncedSearchTerm}` : ""}`;
}, },
[debouncedSearchTerm, player.id, pageState.sort, isSearchActive] [player.id, pageState.sort, pageState.direction, isSearchActive, debouncedSearchTerm]
); );
/** /**
@ -167,16 +195,30 @@ export default function PlayerScores({ initialScoreData, initialSearch, player,
<div ref={topOfScoresRef} className="absolute" /> <div ref={topOfScoresRef} className="absolute" />
<div className="flex gap-2"> <div className="flex gap-2">
{scoreSort.map(sortOption => ( {scoreSort
.filter(sort => !(!player.isBeingTracked && sort.requiresTrackedPlayer))
.map(sortOption => (
<Button <Button
key={sortOption.value} key={sortOption.value}
variant={sortOption.value === pageState.sort ? "default" : "outline"} variant={sortOption.value === pageState.sort ? "default" : "outline"}
onClick={() => handleSortChange(sortOption.value)} onClick={async () => {
if (sortOption.value !== pageState.sort) {
await handleSortChange(sortOption.value);
} else {
await handleSortDirectionChange(
pageState.direction === SortDirection.ASC ? SortDirection.DESC : SortDirection.ASC
);
}
}}
size="sm" size="sm"
className="flex items-center gap-1" className="flex items-center justify-center gap-1"
> >
{sortOption.icon} {`${capitalizeFirstLetter(sortOption.name)}`}
{`${capitalizeFirstLetter(sortOption.name)} Scores`} {player.isBeingTracked && (
<ArrowDownIcon
className={`w-4 h-4 ${pageState.direction === SortDirection.ASC && pageState.sort === sortOption.value ? "rotate-180" : ""}`}
/>
)}
</Button> </Button>
))} ))}
</div> </div>
@ -223,7 +265,7 @@ export default function PlayerScores({ initialScoreData, initialSearch, player,
))} ))}
</motion.div> </motion.div>
{scores.metadata.totalPages > 1 && ( {scores.metadata.totalPages >= 1 && (
<Pagination <Pagination
mobilePagination={width < 768} mobilePagination={width < 768}
page={pageState.page} page={pageState.page}