add top scores page
Some checks failed
Deploy Backend / docker (ubuntu-latest) (push) Successful in 46s
Deploy Website / docker (ubuntu-latest) (push) Failing after 1m31s

This commit is contained in:
Lee 2024-10-28 13:18:40 +00:00
parent 0a5d42f6ac
commit f52b62ba83
11 changed files with 213 additions and 15 deletions

@ -1,6 +1,7 @@
import { Controller, Get } from "elysia-decorators"; 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 { TopScoresResponse } from "@ssr/common/response/top-scores-response";
import { ScoreService } from "../service/score.service"; import { ScoreService } from "../service/score.service";
@Controller("/scores") @Controller("/scores")
@ -73,4 +74,14 @@ export default class ScoresController {
}): Promise<unknown> { }): Promise<unknown> {
return (await ScoreService.getScoreHistory(playerId, leaderboardId, page)).toJSON(); return (await ScoreService.getScoreHistory(playerId, leaderboardId, page)).toJSON();
} }
@Get("/top", {
config: {},
})
public async getTopScores(): Promise<TopScoresResponse> {
const scores = await ScoreService.getTopScores();
return {
scores,
};
}
} }

@ -48,7 +48,7 @@ export class PlayerService {
await newPlayer.save(); await newPlayer.save();
await this.seedPlayerHistory(newPlayer.id, playerToken); await this.seedPlayerHistory(newPlayer.id, playerToken);
await this.trackPlayerScores(newPlayer.id, SCORESABER_REQUEST_COOLDOWN); await this.refreshAllPlayerScores(newPlayer.id);
// Notify in production // Notify in production
if (isProduction()) { 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 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); const player = await PlayerModel.findById(playerId);
if (player == null) { if (player == null) {
throw new NotFoundError(`Player "${playerId}" not found`); 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}...`); console.log(`Refreshing scores for ${player.id}...`);
let page = 1; let page = 1;
let hasMorePages = true; let hasMorePages = true;
@ -298,7 +308,7 @@ export class PlayerService {
} }
page++; page++;
await delay(perPageCooldown); // Cooldown between page requests await delay(SCORESABER_REQUEST_COOLDOWN); // Cooldown between page requests
} }
// Mark player as seeded // Mark player as seeded
@ -318,7 +328,7 @@ export class PlayerService {
console.log(`Found ${players.length} players to refresh.`); console.log(`Found ${players.length} players to refresh.`);
for (const player of players) { 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 await delay(SCORESABER_REQUEST_COOLDOWN); // Cooldown between players
} }
} }

@ -184,6 +184,9 @@ export class ScoreService {
if (player == undefined) { if (player == undefined) {
return; return;
} }
// Update player name
player.name = playerName;
await player.save();
// The score has already been tracked, so ignore it. // The score has already been tracked, so ignore it.
if ( 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<ScoreSaberLeaderboard>("scoresaber", id))
);
return leaderboardResponses.reduce(
(map, response) => {
if (response) map[response.leaderboard.id] = response;
return map;
},
{} as Record<string, { leaderboard: ScoreSaberLeaderboard; beatsaver?: BeatSaverMap }>
);
}
/** /**
* Gets the additional score data for a player's score. * Gets the additional score data for a player's score.
* *

@ -14,6 +14,12 @@ export class Player {
@prop() @prop()
public _id!: string; public _id!: string;
/**
* The player's name.
*/
@prop()
public name?: string;
/** /**
* The player's statistic history. * The player's statistic history.
*/ */

@ -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 Score from "../score";
import { type ScoreSaberLeaderboardPlayerInfoToken } from "../../../types/token/scoresaber/score-saber-leaderboard-player-info-token"; import { type ScoreSaberLeaderboardPlayerInfoToken } from "../../../types/token/scoresaber/score-saber-leaderboard-player-info-token";
import { Document } from "mongoose"; 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, { @plugin(AutoIncrementID, {
field: "_id", field: "_id",
startAt: 1, startAt: 1,
@ -44,7 +45,7 @@ export class ScoreSaberScoreInternal extends Score {
* The amount of pp for the score. * The amount of pp for the score.
* @private * @private
*/ */
@Prop({ required: true }) @Prop({ required: true, index: true })
public pp!: number; public pp!: number;
/** /**

@ -95,7 +95,7 @@ export default class Score {
* The time the score was set. * The time the score was set.
* @private * @private
*/ */
@prop({ required: true }) @prop({ required: true, index: true })
public readonly timestamp!: Date; public readonly timestamp!: Date;
} }

@ -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<ScoreSaberScore, ScoreSaberLeaderboard>[];
};

@ -1,8 +1,8 @@
export type ScoreSaberLeaderboardPlayerInfoToken = { export type ScoreSaberLeaderboardPlayerInfoToken = {
id: string; id: string;
name: string; name?: string;
profilePicture: string; profilePicture?: string;
country: string; country?: string;
permissions: number; permissions?: number;
role: string; role?: string;
}; };

@ -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<TopScoresResponse>(`${Config.apiUrl}/scores/top`);
return (
<Card className="flex flex-col gap-2 w-full xl:w-[75%]">
<div>
<p className="font-semibold'">Top 100 ScoreSaber Scores</p>
<p className="text-gray-400">This will only show scores that have been tracked.</p>
</div>
{!scores ? (
<p>No scores found</p>
) : (
<div className="flex flex-col gap-2 divide-y divide-border">
{scores.scores.map(({ score, leaderboard, beatSaver }, index) => {
const player = score.playerInfo;
const name = score.playerInfo ? player.name || player.id : score.playerId;
return (
<div key={index} className="flex flex-col pt-2">
<p className="text-sm">
Set by{" "}
<Link href={`/player/${player.id}`}>
<span className="text-ssr hover:brightness-[66%] transition-all transform-gpu">{name}</span>
</Link>
</p>
<Score
score={score}
leaderboard={leaderboard}
beatSaverMap={beatSaver}
settings={{
hideLeaderboardDropdown: true,
hideAccuracyChanger: true,
}}
/>
</div>
);
})}
</div>
)}
</Card>
);
}

@ -39,7 +39,12 @@ const items: NavbarItem[] = [
}, },
{ {
name: "Score Feed", name: "Score Feed",
link: "/scores", link: "/scores/live",
openInNewTab: false,
},
{
name: "Top Scores",
link: "/scores/top",
openInNewTab: false, openInNewTab: false,
}, },
]; ];