diff --git a/projects/backend/src/controller/scores.controller.ts b/projects/backend/src/controller/scores.controller.ts index 4458f19..9a332e1 100644 --- a/projects/backend/src/controller/scores.controller.ts +++ b/projects/backend/src/controller/scores.controller.ts @@ -1,6 +1,7 @@ import { Controller, Get } from "elysia-decorators"; import { t } from "elysia"; import { Leaderboards } from "@ssr/common/leaderboard"; +import { TopScoresResponse } from "@ssr/common/response/top-scores-response"; import { ScoreService } from "../service/score.service"; @Controller("/scores") @@ -73,4 +74,14 @@ export default class ScoresController { }): Promise { return (await ScoreService.getScoreHistory(playerId, leaderboardId, page)).toJSON(); } + + @Get("/top", { + config: {}, + }) + public async getTopScores(): Promise { + const scores = await ScoreService.getTopScores(); + return { + scores, + }; + } } diff --git a/projects/backend/src/service/player.service.ts b/projects/backend/src/service/player.service.ts index 9785c70..2df8aa7 100644 --- a/projects/backend/src/service/player.service.ts +++ b/projects/backend/src/service/player.service.ts @@ -48,7 +48,7 @@ export class PlayerService { await newPlayer.save(); await this.seedPlayerHistory(newPlayer.id, playerToken); - await this.trackPlayerScores(newPlayer.id, SCORESABER_REQUEST_COOLDOWN); + await this.refreshAllPlayerScores(newPlayer.id); // Notify in production if (isProduction()) { @@ -247,17 +247,27 @@ export class PlayerService { } /** - * Tracks a player's scores from the ScoreSaber API. + * Refreshes all the players scores. * * @param playerId the player's id - * @param perPageCooldown the cooldown between pages */ - public static async trackPlayerScores(playerId: string, perPageCooldown: number) { + public static async refreshAllPlayerScores(playerId: string) { const player = await PlayerModel.findById(playerId); if (player == null) { throw new NotFoundError(`Player "${playerId}" not found`); } + await this.refreshPlayerScoreSaberScores(player); + } + + /** + * Ensures that all the players scores from the + * ScoreSaber API are up-to-date. + * + * @param player the player to refresh + * @private + */ + private static async refreshPlayerScoreSaberScores(player: PlayerDocument) { console.log(`Refreshing scores for ${player.id}...`); let page = 1; let hasMorePages = true; @@ -298,7 +308,7 @@ export class PlayerService { } page++; - await delay(perPageCooldown); // Cooldown between page requests + await delay(SCORESABER_REQUEST_COOLDOWN); // Cooldown between page requests } // Mark player as seeded @@ -318,7 +328,7 @@ export class PlayerService { console.log(`Found ${players.length} players to refresh.`); for (const player of players) { - await this.trackPlayerScores(player.id, SCORESABER_REQUEST_COOLDOWN); + await this.refreshAllPlayerScores(player.id); await delay(SCORESABER_REQUEST_COOLDOWN); // Cooldown between players } } diff --git a/projects/backend/src/service/score.service.ts b/projects/backend/src/service/score.service.ts index 2e4cf22..16cbadd 100644 --- a/projects/backend/src/service/score.service.ts +++ b/projects/backend/src/service/score.service.ts @@ -184,6 +184,9 @@ export class ScoreService { if (player == undefined) { return; } + // Update player name + player.name = playerName; + await player.save(); // The score has already been tracked, so ignore it. if ( @@ -291,6 +294,103 @@ export class ScoreService { ); } + /** + * Gets the top tracked scores. + * + * @param amount the amount of scores to get + * @returns the top scores + */ + public static async getTopScores(amount: number = 100) { + const foundScores = await ScoreSaberScoreModel.aggregate([ + // Start sorting by timestamp descending using the new compound index + { $sort: { leaderboardId: 1, playerId: 1, timestamp: -1 } }, + { + $group: { + _id: { leaderboardId: "$leaderboardId", playerId: "$playerId" }, + latestScore: { $first: "$$ROOT" }, // Retrieve the latest score per group + }, + }, + // Sort by pp of the latest scores in descending order + { $sort: { "latestScore.pp": -1 } }, + { $limit: amount }, + ]); + + // Collect unique leaderboard IDs + const leaderboardIds = [...new Set(foundScores.map(s => s.latestScore.leaderboardId))]; + const leaderboardMap = await this.fetchLeaderboardsInBatch(leaderboardIds); + + // Collect player IDs for batch retrieval + const playerIds = foundScores.map(result => result.latestScore.playerId); + const players = await PlayerModel.find({ _id: { $in: playerIds } }).exec(); + const playerMap = new Map(players.map(player => [player._id.toString(), player])); + + // Prepare to fetch additional data concurrently + const scoreDataPromises = foundScores.map(async result => { + const score: ScoreSaberScore = result.latestScore; + const leaderboardResponse = leaderboardMap[score.leaderboardId]; + if (!leaderboardResponse) { + return null; // Skip if leaderboard data is not available + } + + const { leaderboard, beatsaver } = leaderboardResponse; + + // Fetch additional data concurrently + const [additionalData, previousScore] = await Promise.all([ + this.getAdditionalScoreData( + score.playerId, + leaderboard.songHash, + `${leaderboard.difficulty.difficulty}-${leaderboard.difficulty.characteristic}`, + score.score + ), + this.getPreviousScore(score.playerId, leaderboard.id + "", score.timestamp), + ]); + + // Attach additional and previous score data if available + if (additionalData) score.additionalData = additionalData; + if (previousScore) score.previousScore = previousScore; + + // Attach player info if available + const player = playerMap.get(score.playerId.toString()); + if (player) { + score.playerInfo = { + id: player._id, + name: player.name, + }; + } + + return { + score: score as ScoreSaberScore, + leaderboard: leaderboard, + beatSaver: beatsaver, + }; + }); + return (await Promise.all(scoreDataPromises)).filter(score => score !== null); + } + + /** + * Fetches leaderboards in a batch. + * + * @param leaderboardIds the ids of the leaderboards + * @returns the fetched leaderboards + * @private + */ + private static async fetchLeaderboardsInBatch(leaderboardIds: string[]) { + // Remove duplicates from leaderboardIds + const uniqueLeaderboardIds = Array.from(new Set(leaderboardIds)); + + const leaderboardResponses = await Promise.all( + uniqueLeaderboardIds.map(id => LeaderboardService.getLeaderboard("scoresaber", id)) + ); + + return leaderboardResponses.reduce( + (map, response) => { + if (response) map[response.leaderboard.id] = response; + return map; + }, + {} as Record + ); + } + /** * Gets the additional score data for a player's score. * diff --git a/projects/common/src/model/player.ts b/projects/common/src/model/player.ts index 0cc24c9..3bc090e 100644 --- a/projects/common/src/model/player.ts +++ b/projects/common/src/model/player.ts @@ -14,6 +14,12 @@ export class Player { @prop() public _id!: string; + /** + * The player's name. + */ + @prop() + public name?: string; + /** * The player's statistic history. */ diff --git a/projects/common/src/model/score/impl/scoresaber-score.ts b/projects/common/src/model/score/impl/scoresaber-score.ts index 4e08a0c..06920be 100644 --- a/projects/common/src/model/score/impl/scoresaber-score.ts +++ b/projects/common/src/model/score/impl/scoresaber-score.ts @@ -1,4 +1,4 @@ -import { getModelForClass, modelOptions, plugin, Prop, ReturnModelType, Severity } from "@typegoose/typegoose"; +import { getModelForClass, index, modelOptions, plugin, Prop, ReturnModelType, Severity } from "@typegoose/typegoose"; import Score from "../score"; import { type ScoreSaberLeaderboardPlayerInfoToken } from "../../../types/token/scoresaber/score-saber-leaderboard-player-info-token"; import { Document } from "mongoose"; @@ -20,6 +20,7 @@ import { PreviousScore } from "../previous-score"; }, }, }) +@index({ leaderboardId: 1, playerId: 1, timestamp: -1 }) // Compound index for optimized queries @plugin(AutoIncrementID, { field: "_id", startAt: 1, @@ -44,7 +45,7 @@ export class ScoreSaberScoreInternal extends Score { * The amount of pp for the score. * @private */ - @Prop({ required: true }) + @Prop({ required: true, index: true }) public pp!: number; /** diff --git a/projects/common/src/model/score/score.ts b/projects/common/src/model/score/score.ts index bb3f623..d785fee 100644 --- a/projects/common/src/model/score/score.ts +++ b/projects/common/src/model/score/score.ts @@ -95,7 +95,7 @@ export default class Score { * The time the score was set. * @private */ - @prop({ required: true }) + @prop({ required: true, index: true }) public readonly timestamp!: Date; } diff --git a/projects/common/src/response/top-scores-response.ts b/projects/common/src/response/top-scores-response.ts new file mode 100644 index 0000000..c42e798 --- /dev/null +++ b/projects/common/src/response/top-scores-response.ts @@ -0,0 +1,10 @@ +import { ScoreSaberLeaderboard } from "src/model/leaderboard/impl/scoresaber-leaderboard"; +import { ScoreSaberScore } from "../model/score/impl/scoresaber-score"; +import { PlayerScore } from "../score/player-score"; + +export type TopScoresResponse = { + /** + * The top scores. + */ + scores: PlayerScore[]; +}; diff --git a/projects/common/src/types/token/scoresaber/score-saber-leaderboard-player-info-token.ts b/projects/common/src/types/token/scoresaber/score-saber-leaderboard-player-info-token.ts index e6569b0..bc152c2 100644 --- a/projects/common/src/types/token/scoresaber/score-saber-leaderboard-player-info-token.ts +++ b/projects/common/src/types/token/scoresaber/score-saber-leaderboard-player-info-token.ts @@ -1,8 +1,8 @@ export type ScoreSaberLeaderboardPlayerInfoToken = { id: string; - name: string; - profilePicture: string; - country: string; - permissions: number; - role: string; + name?: string; + profilePicture?: string; + country?: string; + permissions?: number; + role?: string; }; diff --git a/projects/website/src/app/(pages)/scores/page.tsx b/projects/website/src/app/(pages)/scores/live/page.tsx similarity index 100% rename from projects/website/src/app/(pages)/scores/page.tsx rename to projects/website/src/app/(pages)/scores/live/page.tsx diff --git a/projects/website/src/app/(pages)/scores/top/page.tsx b/projects/website/src/app/(pages)/scores/top/page.tsx new file mode 100644 index 0000000..fce2eb0 --- /dev/null +++ b/projects/website/src/app/(pages)/scores/top/page.tsx @@ -0,0 +1,55 @@ +import { Metadata } from "next"; +import Card from "@/components/card"; +import { kyFetch } from "@ssr/common/utils/utils"; +import { Config } from "@ssr/common/config"; +import { TopScoresResponse } from "@ssr/common/response/top-scores-response"; +import Score from "@/components/score/score"; +import Link from "next/link"; + +export const metadata: Metadata = { + title: "Top Scores", +}; + +export default async function TopScoresPage() { + const scores = await kyFetch(`${Config.apiUrl}/scores/top`); + + return ( + +
+

Top 100 ScoreSaber Scores

+

This will only show scores that have been tracked.

+
+ + {!scores ? ( +

No scores found

+ ) : ( +
+ {scores.scores.map(({ score, leaderboard, beatSaver }, index) => { + const player = score.playerInfo; + const name = score.playerInfo ? player.name || player.id : score.playerId; + + return ( +
+

+ Set by{" "} + + {name} + +

+ +
+ ); + })} +
+ )} +
+ ); +} diff --git a/projects/website/src/components/footer.tsx b/projects/website/src/components/footer.tsx index b959a62..0cb1a90 100644 --- a/projects/website/src/components/footer.tsx +++ b/projects/website/src/components/footer.tsx @@ -39,7 +39,12 @@ const items: NavbarItem[] = [ }, { name: "Score Feed", - link: "/scores", + link: "/scores/live", + openInNewTab: false, + }, + { + name: "Top Scores", + link: "/scores/top", openInNewTab: false, }, ];