add top scores page
This commit is contained in:
parent
0a5d42f6ac
commit
f52b62ba83
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
10
projects/common/src/response/top-scores-response.ts
Normal file
10
projects/common/src/response/top-scores-response.ts
Normal file
@ -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;
|
||||||
};
|
};
|
||||||
|
55
projects/website/src/app/(pages)/scores/top/page.tsx
Normal file
55
projects/website/src/app/(pages)/scores/top/page.tsx
Normal file
@ -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,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
Reference in New Issue
Block a user