diff --git a/bun.lockb b/bun.lockb index 269e20d..467e84f 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/projects/backend/src/controller/leaderboard.controller.ts b/projects/backend/src/controller/leaderboard.controller.ts new file mode 100644 index 0000000..82cb74a --- /dev/null +++ b/projects/backend/src/controller/leaderboard.controller.ts @@ -0,0 +1,26 @@ +import { Controller, Get } from "elysia-decorators"; +import { t } from "elysia"; +import { Leaderboards } from "@ssr/common/leaderboard"; +import LeaderboardService from "../service/leaderboard.service"; + +@Controller("/leaderboard") +export default class LeaderboardController { + @Get("/:leaderboard/:id", { + config: {}, + params: t.Object({ + id: t.String({ required: true }), + leaderboard: t.String({ required: true }), + }), + }) + public async getLeaderboard({ + params: { leaderboard, id }, + }: { + params: { + leaderboard: Leaderboards; + id: string; + page: number; + }; + }): Promise { + return await LeaderboardService.getLeaderboard(leaderboard, id); + } +} diff --git a/projects/backend/src/controller/player.controller.ts b/projects/backend/src/controller/player.controller.ts index 9e09b0b..e72ef67 100644 --- a/projects/backend/src/controller/player.controller.ts +++ b/projects/backend/src/controller/player.controller.ts @@ -1,8 +1,8 @@ import { Controller, Get } from "elysia-decorators"; import { PlayerService } from "../service/player.service"; import { t } from "elysia"; -import { PlayerHistory } from "@ssr/common/types/player/player-history"; -import { PlayerTrackedSince } from "@ssr/common/types/player/player-tracked-since"; +import { PlayerHistory } from "@ssr/common/player/player-history"; +import { PlayerTrackedSince } from "@ssr/common/player/player-tracked-since"; @Controller("/player") export default class PlayerController { diff --git a/projects/backend/src/controller/scores.controller.ts b/projects/backend/src/controller/scores.controller.ts new file mode 100644 index 0000000..59f47f2 --- /dev/null +++ b/projects/backend/src/controller/scores.controller.ts @@ -0,0 +1,55 @@ +import { Controller, Get } from "elysia-decorators"; +import { t } from "elysia"; +import { Leaderboards } from "@ssr/common/leaderboard"; +import { ScoreService } from "../service/score.service"; + +@Controller("/scores") +export default class ScoresController { + @Get("/player/:leaderboard/:id/:page/:sort", { + config: {}, + params: t.Object({ + leaderboard: t.String({ required: true }), + id: t.String({ required: true }), + page: t.Number({ required: true }), + sort: t.String({ required: true }), + }), + query: t.Object({ + search: t.Optional(t.String()), + }), + }) + public async getScores({ + params: { leaderboard, id, page, sort }, + query: { search }, + }: { + params: { + leaderboard: Leaderboards; + id: string; + page: number; + sort: string; + }; + query: { search?: string }; + }): Promise { + return await ScoreService.getPlayerScores(leaderboard, id, page, sort, search); + } + + @Get("/leaderboard/:leaderboard/:id/:page", { + config: {}, + params: t.Object({ + leaderboard: t.String({ required: true }), + id: t.String({ required: true }), + page: t.Number({ required: true }), + }), + }) + public async getLeaderboardScores({ + params: { leaderboard, id, page }, + }: { + params: { + leaderboard: Leaderboards; + id: string; + page: number; + }; + query: { search?: string }; + }): Promise { + return await ScoreService.getLeaderboardScores(leaderboard, id, page); + } +} diff --git a/projects/backend/src/index.ts b/projects/backend/src/index.ts index 4dd20a0..f0b563c 100644 --- a/projects/backend/src/index.ts +++ b/projects/backend/src/index.ts @@ -14,7 +14,6 @@ import { setLogLevel } from "@typegoose/typegoose"; import PlayerController from "./controller/player.controller"; import { PlayerService } from "./service/player.service"; import { cron } from "@elysiajs/cron"; -import { PlayerDocument, PlayerModel } from "./model/player"; import { scoresaberService } from "@ssr/common/service/impl/scoresaber"; import { delay } from "@ssr/common/utils/utils"; import { connectScoreSaberWebSocket } from "@ssr/common/websocket/scoresaber-websocket"; @@ -22,6 +21,9 @@ import ImageController from "./controller/image.controller"; import ReplayController from "./controller/replay.controller"; import { ScoreService } from "./service/score.service"; import { Config } from "@ssr/common/config"; +import { PlayerDocument, PlayerModel } from "@ssr/common/model/player"; +import ScoresController from "./controller/scores.controller"; +import LeaderboardController from "./controller/leaderboard.controller"; // Load .env file dotenv.config({ @@ -159,7 +161,14 @@ app.use( */ app.use( decorators({ - controllers: [AppController, PlayerController, ImageController, ReplayController], + controllers: [ + AppController, + PlayerController, + ImageController, + ReplayController, + ScoresController, + LeaderboardController, + ], }) ); diff --git a/projects/backend/src/service/app.service.ts b/projects/backend/src/service/app.service.ts index 2575987..60c692d 100644 --- a/projects/backend/src/service/app.service.ts +++ b/projects/backend/src/service/app.service.ts @@ -1,4 +1,4 @@ -import { PlayerModel } from "../model/player"; +import { PlayerModel } from "@ssr/common/model/player"; import { AppStatistics } from "@ssr/common/types/backend/app-statistics"; export class AppService { diff --git a/projects/backend/src/service/beatsaver.service.ts b/projects/backend/src/service/beatsaver.service.ts new file mode 100644 index 0000000..6a4e385 --- /dev/null +++ b/projects/backend/src/service/beatsaver.service.ts @@ -0,0 +1,30 @@ +import { beatsaverService } from "@ssr/common/service/impl/beatsaver"; +import { BeatSaverMap, BeatSaverMapModel } from "@ssr/common/model/beatsaver/beatsaver-map"; + +export default class BeatSaverService { + /** + * Gets a map by its hash. + * + * @param hash the hash of the map + * @returns the beatsaver map + */ + public static async getMap(hash: string): Promise { + let map = await BeatSaverMapModel.findById(hash); + if (map != undefined) { + return map.toObject() as BeatSaverMap; + } + + const token = await beatsaverService.lookupMap(hash); + if (token == undefined) { + return undefined; + } + map = await BeatSaverMapModel.create({ + _id: hash, + bsr: token.id, + author: { + id: token.uploader.id, + }, + }); + return map.toObject() as BeatSaverMap; + } +} diff --git a/projects/backend/src/service/image.service.tsx b/projects/backend/src/service/image.service.tsx index 83b63e9..b5484e0 100644 --- a/projects/backend/src/service/image.service.tsx +++ b/projects/backend/src/service/image.service.tsx @@ -7,7 +7,7 @@ import { StarIcon } from "../../components/star-icon"; import { GlobeIcon } from "../../components/globe-icon"; import NodeCache from "node-cache"; import ScoreSaberLeaderboardToken from "@ssr/common/types/token/scoresaber/score-saber-leaderboard-token"; -import ScoreSaberPlayer, { getScoreSaberPlayerFromToken } from "@ssr/common/types/player/impl/scoresaber-player"; +import ScoreSaberPlayer, { getScoreSaberPlayerFromToken } from "@ssr/common/player/impl/scoresaber-player"; import { Jimp } from "jimp"; import { extractColors } from "extract-colors"; import { Config } from "@ssr/common/config"; diff --git a/projects/backend/src/service/leaderboard.service.ts b/projects/backend/src/service/leaderboard.service.ts new file mode 100644 index 0000000..cf1c773 --- /dev/null +++ b/projects/backend/src/service/leaderboard.service.ts @@ -0,0 +1,78 @@ +import { Leaderboards } from "@ssr/common/leaderboard"; +import { scoresaberService } from "@ssr/common/service/impl/scoresaber"; +import { SSRCache } from "@ssr/common/cache"; +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/beatsaver-map"; + +const leaderboardCache = new SSRCache({ + ttl: 1000 * 60 * 60 * 24, +}); + +export default class LeaderboardService { + /** + * Gets the leaderboard. + * + * @param leaderboard the leaderboard + * @param id the id + */ + private static async getLeaderboardToken(leaderboard: Leaderboards, id: string): Promise { + const cacheKey = `${leaderboard}-${id}`; + if (leaderboardCache.has(cacheKey)) { + return leaderboardCache.get(cacheKey) as T; + } + + switch (leaderboard) { + case "scoresaber": { + const leaderboard = (await scoresaberService.lookupLeaderboard(id)) as T; + leaderboardCache.set(cacheKey, leaderboard); + return leaderboard; + } + default: { + return undefined; + } + } + } + + /** + * Gets a leaderboard. + * + * @param leaderboardName the leaderboard to get + * @param id the players id + * @returns the scores + */ + public static async getLeaderboard( + leaderboardName: Leaderboards, + id: string + ): Promise> { + let leaderboard: Leaderboard | undefined; + let beatSaverMap: BeatSaverMap | undefined; + + switch (leaderboardName) { + case "scoresaber": { + const leaderboardToken = await LeaderboardService.getLeaderboardToken( + leaderboardName, + id + ); + if (leaderboardToken == undefined) { + throw new NotFoundError(`Leaderboard not found for "${id}"`); + } + leaderboard = getScoreSaberLeaderboardFromToken(leaderboardToken); + beatSaverMap = await BeatSaverService.getMap(leaderboard.songHash); + break; + } + default: { + throw new NotFoundError(`Leaderboard "${leaderboardName}" not found`); + } + } + + return { + leaderboard: leaderboard, + beatsaver: beatSaverMap, + }; + } +} diff --git a/projects/backend/src/service/player.service.ts b/projects/backend/src/service/player.service.ts index dc442b2..a703b96 100644 --- a/projects/backend/src/service/player.service.ts +++ b/projects/backend/src/service/player.service.ts @@ -1,4 +1,4 @@ -import { PlayerDocument, PlayerModel } from "../model/player"; +import { PlayerDocument, PlayerModel } from "@ssr/common/model/player"; import { NotFoundError } from "../error/not-found-error"; import { getDaysAgoDate, getMidnightAlignedDate } from "@ssr/common/utils/time-utils"; import { scoresaberService } from "@ssr/common/service/impl/scoresaber"; diff --git a/projects/backend/src/service/score.service.ts b/projects/backend/src/service/score.service.ts index 1043b78..758996c 100644 --- a/projects/backend/src/service/score.service.ts +++ b/projects/backend/src/service/score.service.ts @@ -5,6 +5,21 @@ import { MessageBuilder, Webhook } from "discord-webhook-node"; import { formatPp } from "@ssr/common/utils/number-utils"; import { isProduction } from "@ssr/common/utils/utils"; import { Config } from "@ssr/common/config"; +import { Metadata } from "@ssr/common/types/metadata"; +import { NotFoundError } from "elysia"; +import BeatSaverService from "./beatsaver.service"; +import { getScoreSaberLeaderboardFromToken } from "@ssr/common/leaderboard/impl/scoresaber-leaderboard"; +import { getScoreSaberScoreFromToken } from "@ssr/common/score/impl/scoresaber-score"; +import { scoresaberService } from "@ssr/common/service/impl/scoresaber"; +import { ScoreSort } from "@ssr/common/score/score-sort"; +import { Leaderboards } from "@ssr/common/leaderboard"; +import Leaderboard from "@ssr/common/leaderboard/leaderboard"; +import LeaderboardService from "./leaderboard.service"; +import { BeatSaverMap } from "@ssr/common/model/beatsaver/beatsaver-map"; +import { PlayerScore } from "@ssr/common/score/player-score"; +import LeaderboardScoresResponse from "@ssr/common/response/leaderboard-scores-response"; +import Score from "@ssr/common/score/score"; +import PlayerScoresResponse from "@ssr/common/response/player-scores-response"; export class ScoreService { /** @@ -45,4 +60,137 @@ export class ScoreService { embed.setColor("#00ff00"); await hook.send(embed); } + + /** + * Gets scores for a player. + * + * @param leaderboardName the leaderboard to get the scores from + * @param id the players id + * @param page the page to get + * @param sort the sort to use + * @param search the search to use + * @returns the scores + */ + public static async getPlayerScores( + leaderboardName: Leaderboards, + id: string, + page: number, + sort: string, + search?: string + ): Promise> { + const scores: PlayerScore[] | undefined = []; + let beatSaverMap: BeatSaverMap | undefined; + let metadata: Metadata = new Metadata(0, 0, 0, 0); // Default values + + switch (leaderboardName) { + case "scoresaber": { + const leaderboardScores = await scoresaberService.lookupPlayerScores({ + playerId: id, + page: page, + sort: sort as ScoreSort, + search: search, + }); + if (leaderboardScores == undefined) { + throw new NotFoundError( + `No scores found for "${id}", leaderboard "${leaderboardName}", page "${page}", sort "${sort}", search "${search}"` + ); + } + + for (const token of leaderboardScores.playerScores) { + const score = getScoreSaberScoreFromToken(token.score); + if (score == undefined) { + continue; + } + const tokenLeaderboard = getScoreSaberLeaderboardFromToken(token.leaderboard); + if (tokenLeaderboard == undefined) { + continue; + } + beatSaverMap = await BeatSaverService.getMap(tokenLeaderboard.songHash); + + scores.push({ + score: score, + leaderboard: tokenLeaderboard, + beatSaver: beatSaverMap, + }); + } + + metadata = new Metadata( + Math.ceil(leaderboardScores.metadata.total / leaderboardScores.metadata.itemsPerPage), + leaderboardScores.metadata.total, + leaderboardScores.metadata.page, + leaderboardScores.metadata.itemsPerPage + ); + break; + } + default: { + throw new NotFoundError(`Leaderboard "${leaderboardName}" not found`); + } + } + + return { + scores: scores, + metadata: metadata, + }; + } + + /** + * Gets scores for a leaderboard. + * + * @param leaderboardName the leaderboard to get the scores from + * @param id the leaderboard id + * @param page the page to get + * @returns the scores + */ + public static async getLeaderboardScores( + leaderboardName: Leaderboards, + id: string, + page: number + ): Promise> { + const scores: Score[] = []; + let leaderboard: Leaderboard | undefined; + let beatSaverMap: BeatSaverMap | undefined; + let metadata: Metadata = new Metadata(0, 0, 0, 0); // Default values + + switch (leaderboardName) { + case "scoresaber": { + const leaderboardScores = await scoresaberService.lookupLeaderboardScores(id, page); + if (leaderboardScores == undefined) { + throw new NotFoundError(`No scores found for "${id}", leaderboard "${leaderboardName}", page "${page}""`); + } + + const leaderboardResponse = await LeaderboardService.getLeaderboard(leaderboardName, id); + if (leaderboardResponse == undefined) { + throw new NotFoundError(`Leaderboard "${leaderboardName}" not found`); + } + leaderboard = leaderboardResponse.leaderboard; + beatSaverMap = leaderboardResponse.beatsaver; + + for (const token of leaderboardScores.scores) { + const score = getScoreSaberScoreFromToken(token); + if (score == undefined) { + continue; + } + scores.push(score); + } + + metadata = new Metadata( + Math.ceil(leaderboardScores.metadata.total / leaderboardScores.metadata.itemsPerPage), + leaderboardScores.metadata.total, + leaderboardScores.metadata.page, + leaderboardScores.metadata.itemsPerPage + ); + break; + } + default: { + throw new NotFoundError(`Leaderboard "${leaderboardName}" not found`); + } + } + + return { + scores: scores, + leaderboard: leaderboard, + beatSaver: beatSaverMap, + metadata: metadata, + }; + } } diff --git a/projects/backend/tsconfig.json b/projects/backend/tsconfig.json index 39cee78..ab4f221 100644 --- a/projects/backend/tsconfig.json +++ b/projects/backend/tsconfig.json @@ -10,6 +10,6 @@ "skipLibCheck": true, "experimentalDecorators": true, "emitDecoratorMetadata": true, - "jsx": "react" - } + "jsx": "react", + }, } diff --git a/projects/common/package.json b/projects/common/package.json index 043a398..7202f4d 100644 --- a/projects/common/package.json +++ b/projects/common/package.json @@ -20,6 +20,7 @@ }, "dependencies": { "ky": "^1.7.2", - "ws": "^8.18.0" + "ws": "^8.18.0", + "@typegoose/typegoose": "^12.8.0" } } diff --git a/projects/common/src/leaderboard.ts b/projects/common/src/leaderboard.ts new file mode 100644 index 0000000..4256235 --- /dev/null +++ b/projects/common/src/leaderboard.ts @@ -0,0 +1,5 @@ +const Leaderboards = { + SCORESABER: "scoresaber", +} as const; + +export type Leaderboards = (typeof Leaderboards)[keyof typeof Leaderboards]; diff --git a/projects/common/src/leaderboard/impl/scoresaber-leaderboard.ts b/projects/common/src/leaderboard/impl/scoresaber-leaderboard.ts new file mode 100644 index 0000000..553de3e --- /dev/null +++ b/projects/common/src/leaderboard/impl/scoresaber-leaderboard.ts @@ -0,0 +1,63 @@ +import Leaderboard from "../leaderboard"; +import LeaderboardDifficulty from "../leaderboard-difficulty"; +import ScoreSaberLeaderboardToken from "../../types/token/scoresaber/score-saber-leaderboard-token"; +import { getDifficultyFromScoreSaberDifficulty } from "../../utils/scoresaber-utils"; +import { parseDate } from "../../utils/time-utils"; + +export default interface ScoreSaberLeaderboard extends Leaderboard { + /** + * The star count for the leaderboard. + */ + readonly stars: number; + + /** + * The total amount of plays. + */ + readonly plays: number; + + /** + * The amount of plays today. + */ + readonly dailyPlays: number; +} + +/** + * 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), + gameMode: token.difficulty.gameMode, + difficultyRaw: token.difficulty.difficultyRaw, + }; + return { + id: token.id, + songHash: token.songHash, + 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), + gameMode: difficulty.gameMode, + 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, + }; +} diff --git a/projects/common/src/leaderboard/leaderboard-difficulty.ts b/projects/common/src/leaderboard/leaderboard-difficulty.ts new file mode 100644 index 0000000..e7b26d2 --- /dev/null +++ b/projects/common/src/leaderboard/leaderboard-difficulty.ts @@ -0,0 +1,23 @@ +import { Difficulty } from "../score/difficulty"; + +export default interface LeaderboardDifficulty { + /** + * The id of the leaderboard. + */ + leaderboardId: number; + + /** + * The difficulty of the leaderboard. + */ + difficulty: Difficulty; + + /** + * The game mode of the leaderboard. + */ + gameMode: string; + + /** + * The raw difficulty of the leaderboard. + */ + difficultyRaw: string; +} diff --git a/projects/common/src/leaderboard/leaderboard.ts b/projects/common/src/leaderboard/leaderboard.ts new file mode 100644 index 0000000..b2e4132 --- /dev/null +++ b/projects/common/src/leaderboard/leaderboard.ts @@ -0,0 +1,75 @@ +import LeaderboardDifficulty from "./leaderboard-difficulty"; + +export default interface Leaderboard { + /** + * The id of the leaderboard. + * @private + */ + readonly id: number; + + /** + * The hash of the song this leaderboard is for. + * @private + */ + readonly songHash: string; + + /** + * The name of the song this leaderboard is for. + * @private + */ + readonly songName: string; + + /** + * The sub name of the leaderboard. + * @private + */ + readonly songSubName: string; + + /** + * The author of the song this leaderboard is for. + * @private + */ + readonly songAuthorName: string; + + /** + * The author of the level this leaderboard is for. + * @private + */ + readonly levelAuthorName: string; + + /** + * The difficulty of the leaderboard. + * @private + */ + readonly difficulty: LeaderboardDifficulty; + + /** + * The difficulties of the leaderboard. + * @private + */ + readonly difficulties: LeaderboardDifficulty[]; + + /** + * The maximum score of the leaderboard. + * @private + */ + readonly maxScore: number; + + /** + * Whether the leaderboard is ranked. + * @private + */ + readonly ranked: boolean; + + /** + * The link to the song art. + * @private + */ + readonly songArt: string; + + /** + * The date the leaderboard was created. + * @private + */ + readonly timestamp: Date; +} diff --git a/projects/common/src/model/beatsaver/beatsaver-author.ts b/projects/common/src/model/beatsaver/beatsaver-author.ts new file mode 100644 index 0000000..a88e4ab --- /dev/null +++ b/projects/common/src/model/beatsaver/beatsaver-author.ts @@ -0,0 +1,13 @@ +import { prop } from "@typegoose/typegoose"; + +export default class BeatsaverAuthor { + /** + * The id of the author. + */ + @prop({ required: true }) + id: number; + + constructor(id: number) { + this.id = id; + } +} diff --git a/projects/common/src/model/beatsaver/beatsaver-map.ts b/projects/common/src/model/beatsaver/beatsaver-map.ts new file mode 100644 index 0000000..e068c59 --- /dev/null +++ b/projects/common/src/model/beatsaver/beatsaver-map.ts @@ -0,0 +1,51 @@ +import { getModelForClass, modelOptions, prop, ReturnModelType, Severity } from "@typegoose/typegoose"; +import { Document } from "mongoose"; +import BeatsaverAuthor from "./beatsaver-author"; + +/** + * The model for a BeatSaver map. + */ +@modelOptions({ + options: { allowMixed: Severity.ALLOW }, + schemaOptions: { + toObject: { + virtuals: true, + transform: function (_, ret) { + ret.id = ret._id; + delete ret._id; + delete ret.__v; + return ret; + }, + }, + }, +}) +export class BeatSaverMap { + /** + * The internal MongoDB ID (_id). + */ + @prop({ required: true }) + private _id!: string; + + /** + * The bsr code for the map. + * @private + */ + @prop({ required: true }) + public bsr!: string; + + /** + * The author of the map. + */ + @prop({ required: true, _id: false, type: () => BeatsaverAuthor }) + public author!: BeatsaverAuthor; + + /** + * Exposes `id` as a virtual field mapped from `_id`. + */ + public get id(): string { + return this._id; + } +} + +export type BeatSaverMapDocument = BeatSaverMap & Document; +export const BeatSaverMapModel: ReturnModelType = getModelForClass(BeatSaverMap); diff --git a/projects/backend/src/model/player.ts b/projects/common/src/model/player.ts similarity index 91% rename from projects/backend/src/model/player.ts rename to projects/common/src/model/player.ts index 52a1162..f9a955c 100644 --- a/projects/backend/src/model/player.ts +++ b/projects/common/src/model/player.ts @@ -1,7 +1,7 @@ import { getModelForClass, modelOptions, prop, ReturnModelType, Severity } from "@typegoose/typegoose"; import { Document } from "mongoose"; -import { PlayerHistory } from "@ssr/common/types/player/player-history"; -import { formatDateMinimal, getDaysAgoDate, getMidnightAlignedDate } from "@ssr/common/utils/time-utils"; +import { PlayerHistory } from "../player/player-history"; +import { formatDateMinimal, getDaysAgoDate, getMidnightAlignedDate } from "../utils/time-utils"; /** * The model for a player. @@ -109,8 +109,5 @@ export class Player { } } -// This type defines a Mongoose document based on Player. export type PlayerDocument = Player & Document; - -// This type ensures that PlayerModel returns Mongoose documents (PlayerDocument) that have Mongoose methods (save, remove, etc.) export const PlayerModel: ReturnModelType = getModelForClass(Player); diff --git a/projects/common/src/types/player/impl/scoresaber-player.ts b/projects/common/src/player/impl/scoresaber-player.ts similarity index 97% rename from projects/common/src/types/player/impl/scoresaber-player.ts rename to projects/common/src/player/impl/scoresaber-player.ts index 55a4b2f..23898b6 100644 --- a/projects/common/src/types/player/impl/scoresaber-player.ts +++ b/projects/common/src/player/impl/scoresaber-player.ts @@ -1,10 +1,10 @@ import Player, { StatisticChange } from "../player"; import ky from "ky"; import { PlayerHistory } from "../player-history"; -import ScoreSaberPlayerToken from "../../token/scoresaber/score-saber-player-token"; -import { formatDateMinimal, getDaysAgoDate, getMidnightAlignedDate } from "../../../utils/time-utils"; -import { getPageFromRank } from "../../../utils/utils"; -import { Config } from "../../../config"; +import ScoreSaberPlayerToken from "../../types/token/scoresaber/score-saber-player-token"; +import { formatDateMinimal, getDaysAgoDate, getMidnightAlignedDate } from "../../utils/time-utils"; +import { getPageFromRank } from "../../utils/utils"; +import { Config } from "../../config"; /** * A ScoreSaber player. diff --git a/projects/common/src/types/player/player-history.ts b/projects/common/src/player/player-history.ts similarity index 100% rename from projects/common/src/types/player/player-history.ts rename to projects/common/src/player/player-history.ts diff --git a/projects/common/src/types/player/player-tracked-since.ts b/projects/common/src/player/player-tracked-since.ts similarity index 100% rename from projects/common/src/types/player/player-tracked-since.ts rename to projects/common/src/player/player-tracked-since.ts diff --git a/projects/common/src/types/player/player.ts b/projects/common/src/player/player.ts similarity index 100% rename from projects/common/src/types/player/player.ts rename to projects/common/src/player/player.ts diff --git a/projects/common/src/response/leaderboard-response.ts b/projects/common/src/response/leaderboard-response.ts new file mode 100644 index 0000000..d1e101b --- /dev/null +++ b/projects/common/src/response/leaderboard-response.ts @@ -0,0 +1,13 @@ +import { BeatSaverMap } from "../model/beatsaver/beatsaver-map"; + +export type LeaderboardResponse = { + /** + * The leaderboard. + */ + leaderboard: L; + + /** + * The beatsaver map. + */ + beatsaver?: BeatSaverMap; +}; diff --git a/projects/common/src/response/leaderboard-scores-response.ts b/projects/common/src/response/leaderboard-scores-response.ts new file mode 100644 index 0000000..2812ffa --- /dev/null +++ b/projects/common/src/response/leaderboard-scores-response.ts @@ -0,0 +1,25 @@ +import { Metadata } from "../types/metadata"; +import { BeatSaverMap } from "../model/beatsaver/beatsaver-map"; +import Score from "../score/score"; + +export default interface LeaderboardScoresResponse { + /** + * The scores that were set. + */ + readonly scores: Score[]; + + /** + * The leaderboard that was used. + */ + readonly leaderboard: L; + + /** + * The beatsaver map for the song. + */ + readonly beatSaver?: BeatSaverMap; + + /** + * The pagination metadata. + */ + readonly metadata: Metadata; +} diff --git a/projects/common/src/response/player-scores-response.ts b/projects/common/src/response/player-scores-response.ts new file mode 100644 index 0000000..205243a --- /dev/null +++ b/projects/common/src/response/player-scores-response.ts @@ -0,0 +1,14 @@ +import { Metadata } from "../types/metadata"; +import { PlayerScore } from "../score/player-score"; + +export default interface PlayerScoresResponse { + /** + * The scores that were set. + */ + readonly scores: PlayerScore[]; + + /** + * The pagination metadata. + */ + readonly metadata: Metadata; +} diff --git a/projects/common/src/score/difficulty.ts b/projects/common/src/score/difficulty.ts new file mode 100644 index 0000000..13c47d3 --- /dev/null +++ b/projects/common/src/score/difficulty.ts @@ -0,0 +1 @@ +export type Difficulty = "Easy" | "Normal" | "Hard" | "Expert" | "Expert+" | "Unknown"; diff --git a/projects/common/src/score/impl/scoresaber-score.ts b/projects/common/src/score/impl/scoresaber-score.ts new file mode 100644 index 0000000..419ba21 --- /dev/null +++ b/projects/common/src/score/impl/scoresaber-score.ts @@ -0,0 +1,62 @@ +import Score from "../score"; +import { Modifier } from "../modifier"; +import ScoreSaberScoreToken from "../../types/token/scoresaber/score-saber-score-token"; +import ScoreSaberLeaderboardPlayerInfoToken from "../../types/token/scoresaber/score-saber-leaderboard-player-info-token"; + +export default interface ScoreSaberScore extends Score { + /** + * The score's id. + */ + readonly id: string; + + /** + * The amount of pp for the score. + * @private + */ + readonly pp: number; + + /** + * The weight of the score, or undefined if not ranked.s + * @private + */ + readonly weight?: number; + + /** + * The player who set the score + */ + readonly playerInfo: ScoreSaberLeaderboardPlayerInfoToken; +} + +/** + * Gets a {@link ScoreSaberScore} from a {@link ScoreSaberScoreToken}. + * + * @param token the token to convert + */ +export function getScoreSaberScoreFromToken(token: ScoreSaberScoreToken): 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 { + leaderboard: "scoresaber", + score: token.baseScore, + rank: token.rank, + modifiers: modifiers, + misses: token.missedNotes, + badCuts: token.badCuts, + fullCombo: token.fullCombo, + timestamp: new Date(token.timeSet), + id: token.id, + pp: token.pp, + weight: token.weight, + playerInfo: token.leaderboardPlayerInfo, + }; +} diff --git a/projects/common/src/types/score/modifier.ts b/projects/common/src/score/modifier.ts similarity index 90% rename from projects/common/src/types/score/modifier.ts rename to projects/common/src/score/modifier.ts index 6c4b05d..a600a2b 100644 --- a/projects/common/src/types/score/modifier.ts +++ b/projects/common/src/score/modifier.ts @@ -15,4 +15,6 @@ export enum Modifier { CS = "Fail on Saber Clash", IF = "One Life", BE = "Battery Energy", + NF = "No Fail", + NB = "No Bombs", } diff --git a/projects/common/src/score/player-leaderboard-score.ts b/projects/common/src/score/player-leaderboard-score.ts new file mode 100644 index 0000000..2ec388d --- /dev/null +++ b/projects/common/src/score/player-leaderboard-score.ts @@ -0,0 +1,6 @@ +export default interface PlayerLeaderboardScore { + /** + * The score that was set. + */ + readonly score: S; +} diff --git a/projects/common/src/score/player-score.ts b/projects/common/src/score/player-score.ts new file mode 100644 index 0000000..4712fc5 --- /dev/null +++ b/projects/common/src/score/player-score.ts @@ -0,0 +1,18 @@ +import { BeatSaverMap } from "../model/beatsaver/beatsaver-map"; + +export interface PlayerScore { + /** + * The score. + */ + readonly score: S; + + /** + * The leaderboard the score was set on. + */ + readonly leaderboard: L; + + /** + * The BeatSaver of the song. + */ + readonly beatSaver?: BeatSaverMap; +} diff --git a/projects/common/src/types/score/score-sort.ts b/projects/common/src/score/score-sort.ts similarity index 100% rename from projects/common/src/types/score/score-sort.ts rename to projects/common/src/score/score-sort.ts diff --git a/projects/common/src/score/score.ts b/projects/common/src/score/score.ts new file mode 100644 index 0000000..b52cb7b --- /dev/null +++ b/projects/common/src/score/score.ts @@ -0,0 +1,51 @@ +import { Modifier } from "./modifier"; +import { Leaderboards } from "../leaderboard"; + +export default interface Score { + /** + * The leaderboard the score is from. + */ + readonly leaderboard: Leaderboards; + + /** + * The base score for the score. + * @private + */ + readonly score: number; + + /** + * The rank for the score. + * @private + */ + readonly rank: number; + + /** + * The modifiers used on the score. + * @private + */ + readonly modifiers: Modifier[]; + + /** + * The amount missed notes. + * @private + */ + readonly misses: number; + + /** + * The amount of bad cuts. + * @private + */ + readonly badCuts: number; + + /** + * Whether every note was hit. + * @private + */ + readonly fullCombo: boolean; + + /** + * The time the score was set. + * @private + */ + readonly timestamp: Date; +} diff --git a/projects/common/src/service/impl/scoresaber.ts b/projects/common/src/service/impl/scoresaber.ts index f526c66..2d618a1 100644 --- a/projects/common/src/service/impl/scoresaber.ts +++ b/projects/common/src/service/impl/scoresaber.ts @@ -2,7 +2,7 @@ import Service from "../service"; import { ScoreSaberPlayerSearchToken } from "../../types/token/scoresaber/score-saber-player-search-token"; import ScoreSaberPlayerToken from "../../types/token/scoresaber/score-saber-player-token"; import { ScoreSaberPlayersPageToken } from "../../types/token/scoresaber/score-saber-players-page-token"; -import { ScoreSort } from "../../types/score/score-sort"; +import { ScoreSort } from "../../score/score-sort"; import ScoreSaberPlayerScoresPageToken from "../../types/token/scoresaber/score-saber-player-scores-page-token"; import ScoreSaberLeaderboardToken from "../../types/token/scoresaber/score-saber-leaderboard-token"; import ScoreSaberLeaderboardScoresPageToken from "../../types/token/scoresaber/score-saber-leaderboard-scores-page-token"; diff --git a/projects/common/src/service/service.ts b/projects/common/src/service/service.ts index 2aa76ca..34634f5 100644 --- a/projects/common/src/service/service.ts +++ b/projects/common/src/service/service.ts @@ -40,7 +40,6 @@ export default class Service { try { return await ky.get(this.buildRequestUrl(true, url)).json(); } catch (error) { - console.error(`Error fetching data from ${url}:`, error); return undefined; } } diff --git a/projects/common/src/types/metadata.ts b/projects/common/src/types/metadata.ts new file mode 100644 index 0000000..d4c5dd1 --- /dev/null +++ b/projects/common/src/types/metadata.ts @@ -0,0 +1,28 @@ +export class Metadata { + /** + * The amount of pages in the pagination + */ + public readonly totalPages: number; + + /** + * The total amount of items + */ + public readonly totalItems: number; + + /** + * The current page + */ + public readonly page: number; + + /** + * The amount of items per page + */ + public readonly itemsPerPage: number; + + constructor(totalPages: number, totalItems: number, page: number, itemsPerPage: number) { + this.totalPages = totalPages; + this.totalItems = totalItems; + this.page = page; + this.itemsPerPage = itemsPerPage; + } +} diff --git a/projects/common/src/types/page.ts b/projects/common/src/types/page.ts new file mode 100644 index 0000000..313cfad --- /dev/null +++ b/projects/common/src/types/page.ts @@ -0,0 +1,13 @@ +import { Metadata } from "./metadata"; + +export type Page = { + /** + * The data to return. + */ + data: T[]; + + /** + * The metadata of the page. + */ + metadata: Metadata; +}; diff --git a/projects/common/src/types/score/impl/scoresaber-score.ts b/projects/common/src/types/score/impl/scoresaber-score.ts deleted file mode 100644 index f14fc1d..0000000 --- a/projects/common/src/types/score/impl/scoresaber-score.ts +++ /dev/null @@ -1,47 +0,0 @@ -import Score from "../score"; -import { Modifier } from "../modifier"; -import ScoreSaberScoreToken from "../../token/scoresaber/score-saber-score-token"; - -export default class ScoreSaberScore extends Score { - constructor( - score: number, - weight: number | undefined, - rank: number, - worth: number, - modifiers: Modifier[], - misses: number, - badCuts: number, - fullCombo: boolean, - timestamp: Date - ) { - super(score, weight, rank, worth, modifiers, misses, badCuts, fullCombo, timestamp); - } - - /** - * Gets a {@link ScoreSaberScore} from a {@link ScoreSaberScoreToken}. - * - * @param token the token to convert - */ - public static fromToken(token: ScoreSaberScoreToken): ScoreSaberScore { - const modifiers: Modifier[] = 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 new ScoreSaberScore( - token.baseScore, - token.weight, - token.rank, - token.pp, - modifiers, - token.missedNotes, - token.badCuts, - token.fullCombo, - new Date(token.timeSet) - ); - } -} diff --git a/projects/common/src/types/score/score.ts b/projects/common/src/types/score/score.ts deleted file mode 100644 index 9913b76..0000000 --- a/projects/common/src/types/score/score.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { Modifier } from "./modifier"; - -export default class Score { - /** - * The base score for the score. - * @private - */ - private readonly _score: number; - - /** - * The weight of the score, or undefined if not ranked.s - * @private - */ - private readonly _weight: number | undefined; - - /** - * The rank for the score. - * @private - */ - private readonly _rank: number; - - /** - * The worth of the score (this could be pp, ap, cr, etc.), - * or undefined if not ranked. - * @private - */ - private readonly _worth: number; - - /** - * The modifiers used on the score. - * @private - */ - private readonly _modifiers: Modifier[]; - - /** - * The amount missed notes. - * @private - */ - private readonly _misses: number; - - /** - * The amount of bad cuts. - * @private - */ - private readonly _badCuts: number; - - /** - * Whether every note was hit. - * @private - */ - private readonly _fullCombo: boolean; - - /** - * The time the score was set. - * @private - */ - private readonly _timestamp: Date; - - constructor( - score: number, - weight: number | undefined, - rank: number, - worth: number, - modifiers: Modifier[], - misses: number, - badCuts: number, - fullCombo: boolean, - timestamp: Date - ) { - this._score = score; - this._weight = weight; - this._rank = rank; - this._worth = worth; - this._modifiers = modifiers; - this._misses = misses; - this._badCuts = badCuts; - this._fullCombo = fullCombo; - this._timestamp = timestamp; - } - - get score(): number { - return this._score; - } - - get weight(): number | undefined { - return this._weight; - } - - get rank(): number { - return this._rank; - } - - get worth(): number { - return this._worth; - } - - get modifiers(): Modifier[] { - return this._modifiers; - } - - get misses(): number { - return this._misses; - } - - get badCuts(): number { - return this._badCuts; - } - - get fullCombo(): boolean { - return this._fullCombo; - } - - get timestamp(): Date { - return this._timestamp; - } -} diff --git a/projects/common/src/types/token/scoresaber/score-saber-leaderboard-token.ts b/projects/common/src/types/token/scoresaber/score-saber-leaderboard-token.ts index 5434a72..4bab590 100644 --- a/projects/common/src/types/token/scoresaber/score-saber-leaderboard-token.ts +++ b/projects/common/src/types/token/scoresaber/score-saber-leaderboard-token.ts @@ -19,8 +19,8 @@ export default interface ScoreSaberLeaderboardToken { maxPP: number; stars: number; positiveModifiers: boolean; - plays: boolean; - dailyPlays: boolean; + plays: number; + dailyPlays: number; coverImage: string; difficulties: ScoreSaberDifficultyToken[]; } diff --git a/projects/common/src/utils/leaderboard.util.ts b/projects/common/src/utils/leaderboard.util.ts new file mode 100644 index 0000000..1b3f820 --- /dev/null +++ b/projects/common/src/utils/leaderboard.util.ts @@ -0,0 +1,14 @@ +import { Config } from "../config"; +import { LeaderboardResponse } from "../response/leaderboard-response"; +import { kyFetch } from "./utils"; +import { Leaderboards } from "../leaderboard"; + +/** + * Fetches the leaderboard + * + * @param id the leaderboard id + * @param leaderboard the leaderboard + */ +export async function fetchLeaderboard(leaderboard: Leaderboards, id: string) { + return kyFetch>(`${Config.apiUrl}/leaderboard/${leaderboard}/${id}`); +} diff --git a/projects/common/src/utils/player-utils.ts b/projects/common/src/utils/player-utils.ts index 8b13d0a..9b263f3 100644 --- a/projects/common/src/utils/player-utils.ts +++ b/projects/common/src/utils/player-utils.ts @@ -1,4 +1,4 @@ -import { PlayerHistory } from "../types/player/player-history"; +import { PlayerHistory } from "../player/player-history"; import { kyFetch } from "./utils"; import { Config } from "../config"; diff --git a/projects/common/src/utils/score-utils.ts b/projects/common/src/utils/score-utils.ts new file mode 100644 index 0000000..8af83b1 --- /dev/null +++ b/projects/common/src/utils/score-utils.ts @@ -0,0 +1,37 @@ +import { Leaderboards } from "../leaderboard"; +import { kyFetch } from "./utils"; +import PlayerScoresResponse from "../response/player-scores-response"; +import { Config } from "../config"; +import { ScoreSort } from "../score/score-sort"; + +/** + * Fetches the player's scores + * + * @param leaderboard the leaderboard + * @param id the player id + * @param page the page + * @param sort the sort + * @param search the search + */ +export async function fetchPlayerScores( + leaderboard: Leaderboards, + id: string, + page: number, + sort: ScoreSort, + search?: string +) { + return kyFetch>( + `${Config.apiUrl}/scores/player/${leaderboard}/${id}/${page}/${sort}${search ? `?search=${search}` : ""}` + ); +} + +/** + * Fetches the player's scores + * + * @param leaderboard the leaderboard + * @param id the player id + * @param page the page + */ +export async function fetchLeaderboardScores(leaderboard: Leaderboards, id: string, page: number) { + return kyFetch>(`${Config.apiUrl}/scores/leaderboard/${leaderboard}/${id}/${page}`); +} diff --git a/projects/common/src/utils/scoresaber-utils.ts b/projects/common/src/utils/scoresaber-utils.ts index 01f41bc..ea433ce 100644 --- a/projects/common/src/utils/scoresaber-utils.ts +++ b/projects/common/src/utils/scoresaber-utils.ts @@ -1,9 +1,11 @@ +import { Difficulty } from "../score/difficulty"; + /** * Formats the ScoreSaber difficulty number * * @param diff the diffuiclity number */ -export function getDifficultyFromScoreSaberDifficulty(diff: number) { +export function getDifficultyFromScoreSaberDifficulty(diff: number): Difficulty { switch (diff) { case 1: { return "Easy"; diff --git a/projects/common/tsconfig.json b/projects/common/tsconfig.json index 77048d9..8d0b47e 100644 --- a/projects/common/tsconfig.json +++ b/projects/common/tsconfig.json @@ -7,6 +7,8 @@ "moduleResolution": "node", "skipLibCheck": true, "allowSyntheticDefaultImports": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": true, "strict": true, "baseUrl": "./", "paths": { diff --git a/projects/website/src/app/(pages)/leaderboard/[...slug]/page.tsx b/projects/website/src/app/(pages)/leaderboard/[...slug]/page.tsx index a32ba67..6af6011 100644 --- a/projects/website/src/app/(pages)/leaderboard/[...slug]/page.tsx +++ b/projects/website/src/app/(pages)/leaderboard/[...slug]/page.tsx @@ -3,11 +3,14 @@ import { redirect } from "next/navigation"; import { Colors } from "@/common/colors"; import { getAverageColor } from "@/common/image-utils"; import { LeaderboardData } from "@/components/leaderboard/leaderboard-data"; -import { scoresaberService } from "@ssr/common/service/impl/scoresaber"; -import ScoreSaberLeaderboardScoresPageToken from "@ssr/common/types/token/scoresaber/score-saber-leaderboard-scores-page-token"; import NodeCache from "node-cache"; -import ScoreSaberLeaderboardToken from "@ssr/common/types/token/scoresaber/score-saber-leaderboard-token"; import { Config } from "@ssr/common/config"; +import ScoreSaberScore from "@ssr/common/score/impl/scoresaber-score"; +import { LeaderboardResponse } from "@ssr/common/response/leaderboard-response"; +import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard"; +import { fetchLeaderboard } from "@ssr/common/utils/leaderboard.util"; +import PlayerScoresResponse from "../../../../../../common/src/response/player-scores-response.ts"; +import { fetchLeaderboardScores } from "@ssr/common/utils/score-utils"; const UNKNOWN_LEADERBOARD = { title: "ScoreSaber Reloaded - Unknown Leaderboard", @@ -24,8 +27,8 @@ type Props = { }; type LeaderboardData = { - leaderboard: ScoreSaberLeaderboardToken | undefined; - scores: ScoreSaberLeaderboardScoresPageToken | undefined; + leaderboardResponse: LeaderboardResponse; + scores?: PlayerScoresResponse; page: number; }; @@ -38,7 +41,10 @@ const leaderboardCache = new NodeCache({ stdTTL: 60, checkperiod: 120 }); * @param fetchScores whether to fetch the scores * @returns the leaderboard data and scores */ -const getLeaderboardData = async ({ params }: Props, fetchScores: boolean = true) => { +const getLeaderboardData = async ( + { params }: Props, + fetchScores: boolean = true +): Promise => { const { slug } = await params; const id = slug[0]; // The leaderboard id const page = parseInt(slug[1]) || 1; // The page number @@ -47,16 +53,17 @@ const getLeaderboardData = async ({ params }: Props, fetchScores: boolean = true if (leaderboardCache.has(cacheId)) { return leaderboardCache.get(cacheId) as LeaderboardData; } - - const leaderboard = await scoresaberService.lookupLeaderboard(id); - let scores: ScoreSaberLeaderboardScoresPageToken | undefined; - if (fetchScores) { - scores = await scoresaberService.lookupLeaderboardScores(id + "", page); + const leaderboard = await fetchLeaderboard("scoresaber", id + ""); + if (leaderboard === undefined) { + return undefined; } - const leaderboardData = { + const scores = fetchScores + ? await fetchLeaderboardScores("scoresaber", id + "", page) + : undefined; + const leaderboardData: LeaderboardData = { + leaderboardResponse: leaderboard, page: page, - leaderboard: leaderboard, scores: scores, }; @@ -65,8 +72,8 @@ const getLeaderboardData = async ({ params }: Props, fetchScores: boolean = true }; export async function generateMetadata(props: Props): Promise { - const { leaderboard } = await getLeaderboardData(props, false); - if (leaderboard === undefined) { + const response = await getLeaderboardData(props, false); + if (response === undefined) { return { title: UNKNOWN_LEADERBOARD.title, description: UNKNOWN_LEADERBOARD.description, @@ -77,6 +84,7 @@ export async function generateMetadata(props: Props): Promise { }; } + const { leaderboard } = response.leaderboardResponse; return { title: `${leaderboard.songName} ${leaderboard.songSubName} by ${leaderboard.songAuthorName}`, openGraph: { @@ -95,24 +103,25 @@ export async function generateMetadata(props: Props): Promise { } export async function generateViewport(props: Props): Promise { - const { leaderboard } = await getLeaderboardData(props, false); - if (leaderboard === undefined) { + const response = await getLeaderboardData(props, false); + if (response === undefined) { return { themeColor: Colors.primary, }; } - const color = await getAverageColor(leaderboard.coverImage); + const color = await getAverageColor(response.leaderboardResponse.leaderboard.songArt); return { themeColor: color.color, }; } export default async function LeaderboardPage(props: Props) { - const { leaderboard, scores, page } = await getLeaderboardData(props); - if (leaderboard == undefined) { + const response = await getLeaderboardData(props); + if (response == undefined) { return redirect("/"); } + const { leaderboardResponse, scores } = response; - return ; + return ; } diff --git a/projects/website/src/app/(pages)/player/[...slug]/page.tsx b/projects/website/src/app/(pages)/player/[...slug]/page.tsx index 86161ca..222ec50 100644 --- a/projects/website/src/app/(pages)/player/[...slug]/page.tsx +++ b/projects/website/src/app/(pages)/player/[...slug]/page.tsx @@ -3,13 +3,16 @@ import { Metadata, Viewport } from "next"; import { redirect } from "next/navigation"; import { Colors } from "@/common/colors"; import { getAverageColor } from "@/common/image-utils"; -import { ScoreSort } from "@ssr/common/types/score/score-sort"; import { scoresaberService } from "@ssr/common/service/impl/scoresaber"; -import ScoreSaberPlayerScoresPageToken from "@ssr/common/types/token/scoresaber/score-saber-player-scores-page-token"; -import ScoreSaberPlayer, { getScoreSaberPlayerFromToken } from "@ssr/common/types/player/impl/scoresaber-player"; import NodeCache from "node-cache"; 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 { ScoreSort } from "@ssr/common/score/score-sort"; +import ScoreSaberScore from "@ssr/common/score/impl/scoresaber-score"; +import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard"; +import { fetchPlayerScores } from "@ssr/common/utils/score-utils"; +import PlayerScoresResponse from "../../../../../../common/src/response/player-scores-response"; const UNKNOWN_PLAYER = { title: "ScoreSaber Reloaded - Unknown Player", @@ -27,7 +30,7 @@ type Props = { type PlayerData = { player: ScoreSaberPlayer | undefined; - scores: ScoreSaberPlayerScoresPageToken | undefined; + scores: PlayerScoresResponse | undefined; sort: ScoreSort; page: number; search: string; @@ -56,14 +59,9 @@ const getPlayerData = async ({ params }: Props, fetchScores: boolean = true): Pr const playerToken = await scoresaberService.lookupPlayer(id); const player = playerToken && (await getScoreSaberPlayerFromToken(playerToken, await getCookieValue("playerId"))); - let scores: ScoreSaberPlayerScoresPageToken | undefined; + let scores: PlayerScoresResponse | undefined; if (fetchScores) { - scores = await scoresaberService.lookupPlayerScores({ - playerId: id, - sort, - page, - search, - }); + scores = await fetchPlayerScores("scoresaber", id, page, sort, search); } const playerData = { diff --git a/projects/website/src/common/database/database.ts b/projects/website/src/common/database/database.ts index 2f630ad..93ea1ab 100644 --- a/projects/website/src/common/database/database.ts +++ b/projects/website/src/common/database/database.ts @@ -1,5 +1,4 @@ import Dexie, { EntityTable } from "dexie"; -import BeatSaverMap from "./types/beatsaver-map"; import Settings from "./types/settings"; import { Friend } from "@/common/database/types/friends"; import ScoreSaberPlayerToken from "@ssr/common/types/token/scoresaber/score-saber-player-token"; @@ -15,11 +14,6 @@ export default class Database extends Dexie { */ settings!: EntityTable; - /** - * Cached BeatSaver maps - */ - beatSaverMaps!: EntityTable; - /** * The added friends */ @@ -37,7 +31,6 @@ export default class Database extends Dexie { // Mapped tables this.settings.mapToClass(Settings); - this.beatSaverMaps.mapToClass(BeatSaverMap); // Populate default settings if the table is empty this.on("populate", () => this.populateDefaults()); diff --git a/projects/website/src/common/database/types/beatsaver-map.ts b/projects/website/src/common/database/types/beatsaver-map.ts deleted file mode 100644 index 31a33e4..0000000 --- a/projects/website/src/common/database/types/beatsaver-map.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Entity } from "dexie"; -import Database from "../database"; -import { BeatSaverMapToken } from "@ssr/common/types/token/beatsaver/beat-saver-map-token"; - -/** - * A beat saver map. - */ -export default class BeatSaverMap extends Entity { - /** - * The hash of the map. - */ - hash!: string; - - /** - * The bsr code for the map. - */ - bsr!: string; - - /** - * The full data for the map. - */ - fullData!: BeatSaverMapToken; -} diff --git a/projects/website/src/components/api/api-health.tsx b/projects/website/src/components/api/api-health.tsx index 15f59ec..ede3118 100644 --- a/projects/website/src/components/api/api-health.tsx +++ b/projects/website/src/components/api/api-health.tsx @@ -36,7 +36,7 @@ export function ApiHealth() { ? "The API has recovered connectivity." : "The API has lost connectivity, some data may be unavailable.", variant: online ? "success" : "destructive", - duration: 10_000, // 10 seconds + duration: 5_000, // 5 seconds }); } diff --git a/projects/website/src/components/friend/add-friend.tsx b/projects/website/src/components/friend/add-friend.tsx index 0cc4d9b..61e6fb8 100644 --- a/projects/website/src/components/friend/add-friend.tsx +++ b/projects/website/src/components/friend/add-friend.tsx @@ -6,7 +6,7 @@ import { useToast } from "@/hooks/use-toast"; import Tooltip from "../tooltip"; import { Button } from "../ui/button"; import { PersonIcon } from "@radix-ui/react-icons"; -import ScoreSaberPlayer from "@ssr/common/types/player/impl/scoresaber-player"; +import ScoreSaberPlayer from "@ssr/common/player/impl/scoresaber-player"; import { trackPlayer } from "@ssr/common/utils/player-utils"; type Props = { diff --git a/projects/website/src/components/leaderboard/leaderboard-data.tsx b/projects/website/src/components/leaderboard/leaderboard-data.tsx index a422dbc..fd051a2 100644 --- a/projects/website/src/components/leaderboard/leaderboard-data.tsx +++ b/projects/website/src/components/leaderboard/leaderboard-data.tsx @@ -2,70 +2,55 @@ import LeaderboardScores from "@/components/leaderboard/leaderboard-scores"; import { LeaderboardInfo } from "@/components/leaderboard/leaderboard-info"; +import ScoreSaberScore from "@ssr/common/score/impl/scoresaber-score"; +import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard"; +import { LeaderboardResponse } from "@ssr/common/response/leaderboard-response"; import { useQuery } from "@tanstack/react-query"; -import { useCallback, useEffect, useState } from "react"; -import BeatSaverMap from "@/common/database/types/beatsaver-map"; -import ScoreSaberLeaderboardScoresPageToken from "@ssr/common/types/token/scoresaber/score-saber-leaderboard-scores-page-token"; -import ScoreSaberLeaderboardToken from "@ssr/common/types/token/scoresaber/score-saber-leaderboard-token"; -import { scoresaberService } from "@ssr/common/service/impl/scoresaber"; -import { lookupBeatSaverMap } from "@/common/beatsaver-utils"; +import { useState } from "react"; +import { fetchLeaderboard } from "@ssr/common/utils/leaderboard.util"; +import PlayerScoresResponse from "../../../../common/src/response/player-scores-response.ts"; + +const REFRESH_INTERVAL = 1000 * 60 * 5; type LeaderboardDataProps = { /** - * The page to show when opening the leaderboard. + * The initial leaderboard data. */ - initialPage?: number; + initialLeaderboard: LeaderboardResponse; /** - * The initial scores to show. + * The initial score data. */ - initialScores?: ScoreSaberLeaderboardScoresPageToken; - - /** - * The leaderboard to display. - */ - initialLeaderboard: ScoreSaberLeaderboardToken; + initialScores: PlayerScoresResponse; }; -export function LeaderboardData({ initialPage, initialScores, initialLeaderboard }: LeaderboardDataProps) { - const [beatSaverMap, setBeatSaverMap] = useState(); - const [selectedLeaderboardId, setSelectedLeaderboardId] = useState(initialLeaderboard.id); +export function LeaderboardData({ initialLeaderboard, initialScores }: LeaderboardDataProps) { + const [currentLeaderboardId, setCurrentLeaderboardId] = useState(initialLeaderboard.leaderboard.id); - let currentLeaderboard = initialLeaderboard; - const { data } = useQuery({ - queryKey: ["leaderboard", selectedLeaderboardId], - queryFn: () => scoresaberService.lookupLeaderboard(selectedLeaderboardId + ""), - initialData: initialLeaderboard, + let leaderboard = initialLeaderboard; + const { data, isLoading, isError } = useQuery({ + queryKey: ["leaderboard", currentLeaderboardId], + queryFn: async (): Promise | undefined> => { + return fetchLeaderboard("scoresaber", currentLeaderboardId + ""); + }, + refetchInterval: REFRESH_INTERVAL, + refetchIntervalInBackground: false, }); - if (data) { - currentLeaderboard = data; - } - - const fetchBeatSaverData = useCallback(async () => { - const beatSaverMap = await lookupBeatSaverMap(initialLeaderboard.songHash); - setBeatSaverMap(beatSaverMap); - }, [initialLeaderboard.songHash]); - - useEffect(() => { - fetchBeatSaverData(); - }, [fetchBeatSaverData]); - - if (!currentLeaderboard) { - return null; + if (data && (!isLoading || !isError)) { + leaderboard = data; } return (
setCurrentLeaderboardId(newId)} showDifficulties isLeaderboardPage - leaderboardChanged={id => setSelectedLeaderboardId(id)} /> - +
); } diff --git a/projects/website/src/components/leaderboard/leaderboard-info.tsx b/projects/website/src/components/leaderboard/leaderboard-info.tsx index 2b679ae..47291b3 100644 --- a/projects/website/src/components/leaderboard/leaderboard-info.tsx +++ b/projects/website/src/components/leaderboard/leaderboard-info.tsx @@ -2,14 +2,14 @@ import Card from "@/components/card"; import Image from "next/image"; import { LeaderboardSongStarCount } from "@/components/leaderboard/leaderboard-song-star-count"; import ScoreButtons from "@/components/score/score-buttons"; -import BeatSaverMap from "@/common/database/types/beatsaver-map"; -import ScoreSaberLeaderboardToken from "@ssr/common/types/token/scoresaber/score-saber-leaderboard-token"; +import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard"; +import { BeatSaverMap } from "@ssr/common/model/beatsaver/beatsaver-map"; type LeaderboardInfoProps = { /** * The leaderboard to display. */ - leaderboard: ScoreSaberLeaderboardToken; + leaderboard: ScoreSaberLeaderboard; /** * The beat saver map associated with the leaderboard. @@ -46,7 +46,7 @@ export function LeaderboardInfo({ leaderboard, beatSaverMap }: LeaderboardInfoPr {`${leaderboard.songName} { return "bg-pp"; }, - create: (score: ScoreSaberScoreToken) => { + create: (score: ScoreSaberScore) => { const pp = score.pp; if (pp === 0) { return undefined; @@ -23,12 +23,12 @@ const badges: ScoreBadge[] = [ }, { name: "Accuracy", - color: (score: ScoreSaberScoreToken, leaderboard: ScoreSaberLeaderboardToken) => { - const acc = (score.baseScore / leaderboard.maxScore) * 100; + color: (score: ScoreSaberScore, leaderboard: ScoreSaberLeaderboard) => { + const acc = (score.score / leaderboard.maxScore) * 100; return getScoreBadgeFromAccuracy(acc).color; }, - create: (score: ScoreSaberScoreToken, leaderboard: ScoreSaberLeaderboardToken) => { - const acc = (score.baseScore / leaderboard.maxScore) * 100; + create: (score: ScoreSaberScore, leaderboard: ScoreSaberLeaderboard) => { + const acc = (score.score / leaderboard.maxScore) * 100; const scoreBadge = getScoreBadgeFromAccuracy(acc); let accDetails = `Accuracy ${scoreBadge.name != "-" ? scoreBadge.name : ""}`; if (scoreBadge.max == null) { @@ -56,12 +56,12 @@ const badges: ScoreBadge[] = [ }, { name: "Full Combo", - create: (score: ScoreSaberScoreToken) => { - const fullCombo = score.missedNotes === 0; + create: (score: ScoreSaberScore) => { + const fullCombo = score.misses === 0; return ( <> -

{fullCombo ? FC : formatNumberWithCommas(score.missedNotes)}

+

{fullCombo ? FC : formatNumberWithCommas(score.misses)}

); @@ -70,8 +70,8 @@ const badges: ScoreBadge[] = [ ]; type Props = { - score: ScoreSaberScoreToken; - leaderboard: ScoreSaberLeaderboardToken; + score: ScoreSaberScore; + leaderboard: ScoreSaberLeaderboard; }; export default function LeaderboardScoreStats({ score, leaderboard }: Props) { diff --git a/projects/website/src/components/leaderboard/leaderboard-score.tsx b/projects/website/src/components/leaderboard/leaderboard-score.tsx index c1dd1de..21c013a 100644 --- a/projects/website/src/components/leaderboard/leaderboard-score.tsx +++ b/projects/website/src/components/leaderboard/leaderboard-score.tsx @@ -1,9 +1,9 @@ import LeaderboardPlayer from "./leaderboard-player"; import LeaderboardScoreStats from "./leaderboard-score-stats"; import ScoreRankInfo from "@/components/score/score-rank-info"; -import ScoreSaberPlayer from "@ssr/common/types/player/impl/scoresaber-player"; -import ScoreSaberScoreToken from "@ssr/common/types/token/scoresaber/score-saber-score-token"; -import ScoreSaberLeaderboardToken from "@ssr/common/types/token/scoresaber/score-saber-leaderboard-token"; +import ScoreSaberPlayer from "@ssr/common/player/impl/scoresaber-player"; +import ScoreSaberScore from "@ssr/common/score/impl/scoresaber-score"; +import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard"; type Props = { /** @@ -14,12 +14,12 @@ type Props = { /** * The score to display. */ - score: ScoreSaberScoreToken; + score: ScoreSaberScore; /** * The leaderboard to display. */ - leaderboard: ScoreSaberLeaderboardToken; + leaderboard: ScoreSaberLeaderboard; }; export default function LeaderboardScore({ player, score, leaderboard }: Props) { diff --git a/projects/website/src/components/leaderboard/leaderboard-scores.tsx b/projects/website/src/components/leaderboard/leaderboard-scores.tsx index 43fe3ea..e2fc1e2 100644 --- a/projects/website/src/components/leaderboard/leaderboard-scores.tsx +++ b/projects/website/src/components/leaderboard/leaderboard-scores.tsx @@ -11,10 +11,11 @@ import { scoreAnimation } from "@/components/score/score-animation"; import { Button } from "@/components/ui/button"; import { clsx } from "clsx"; import { getDifficultyFromRawDifficulty } from "@/common/song-utils"; -import ScoreSaberLeaderboardScoresPageToken from "@ssr/common/types/token/scoresaber/score-saber-leaderboard-scores-page-token"; -import ScoreSaberPlayer from "@ssr/common/types/player/impl/scoresaber-player"; -import ScoreSaberLeaderboardToken from "@ssr/common/types/token/scoresaber/score-saber-leaderboard-token"; -import { scoresaberService } from "@ssr/common/service/impl/scoresaber"; +import { fetchLeaderboardScores } from "@ssr/common/utils/score-utils"; +import ScoreSaberScore from "@ssr/common/score/impl/scoresaber-score"; +import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard"; +import ScoreSaberPlayer from "@ssr/common/player/impl/scoresaber-player"; +import PlayerScoresResponse from "../../../../common/src/response/player-scores-response.ts"; type LeaderboardScoresProps = { /** @@ -25,18 +26,18 @@ type LeaderboardScoresProps = { /** * The initial scores to show. */ - initialScores?: ScoreSaberLeaderboardScoresPageToken; + initialScores?: PlayerScoresResponse; + + /** + * The leaderboard to display. + */ + leaderboard: ScoreSaberLeaderboard; /** * The player who set the score. */ player?: ScoreSaberPlayer; - /** - * The leaderboard to display. - */ - leaderboard: ScoreSaberLeaderboardToken; - /** * Whether to show the difficulties. */ @@ -73,17 +74,20 @@ export default function LeaderboardScores({ const [selectedLeaderboardId, setSelectedLeaderboardId] = useState(leaderboard.id); const [previousPage, setPreviousPage] = useState(initialPage); const [currentPage, setCurrentPage] = useState(initialPage); - const [currentScores, setCurrentScores] = useState(initialScores); + const [currentScores, setCurrentScores] = useState< + PlayerScoresResponse | undefined + >(initialScores); const topOfScoresRef = useRef(null); - const [shouldFetch, setShouldFetch] = useState(false); + const [shouldFetch, setShouldFetch] = useState(true); - const { - data: scores, - isError, - isLoading, - } = useQuery({ - queryKey: ["leaderboardScores-" + leaderboard.id, selectedLeaderboardId, currentPage], - queryFn: () => scoresaberService.lookupLeaderboardScores(selectedLeaderboardId + "", currentPage), + const { data, isError, isLoading } = useQuery({ + queryKey: ["leaderboardScores", selectedLeaderboardId, currentPage], + queryFn: () => + fetchLeaderboardScores( + "scoresaber", + selectedLeaderboardId + "", + currentPage + ), staleTime: 30 * 1000, enabled: (shouldFetch && isLeaderboardPage) || !isLeaderboardPage, }); @@ -93,9 +97,9 @@ export default function LeaderboardScores({ */ const handleScoreAnimation = useCallback(async () => { await controls.start(previousPage >= currentPage ? "hiddenRight" : "hiddenLeft"); - setCurrentScores(scores); + setCurrentScores(data); await controls.start("visible"); - }, [controls, currentPage, previousPage, scores]); + }, [controls, currentPage, previousPage, data]); /** * Set the selected leaderboard. @@ -118,10 +122,10 @@ export default function LeaderboardScores({ * Set the current scores. */ useEffect(() => { - if (scores) { + if (data) { handleScoreAnimation(); } - }, [scores, handleScoreAnimation]); + }, [data, handleScoreAnimation]); /** * Handle scrolling to the top of the @@ -185,17 +189,19 @@ export default function LeaderboardScores({ variants={scoreAnimation} className="grid min-w-full grid-cols-1 divide-y divide-border" > - {currentScores.scores.map((playerScore, index) => ( - - - - ))} + {currentScores.scores.map((playerScore, index) => { + return ( + + + + ); + })} { return `/leaderboard/${selectedLeaderboardId}/${page}`; diff --git a/projects/website/src/components/leaderboard/leaderboard-song-star-count.tsx b/projects/website/src/components/leaderboard/leaderboard-song-star-count.tsx index f3e1526..ac2476d 100644 --- a/projects/website/src/components/leaderboard/leaderboard-song-star-count.tsx +++ b/projects/website/src/components/leaderboard/leaderboard-song-star-count.tsx @@ -1,13 +1,12 @@ import { songDifficultyToColor } from "@/common/song-utils"; import { StarIcon } from "@heroicons/react/24/solid"; -import { getDifficultyFromScoreSaberDifficulty } from "@ssr/common/utils/scoresaber-utils"; -import ScoreSaberLeaderboardToken from "@ssr/common/types/token/scoresaber/score-saber-leaderboard-token"; +import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard"; type LeaderboardSongStarCountProps = { /** * The leaderboard for the song */ - leaderboard: ScoreSaberLeaderboardToken; + leaderboard: ScoreSaberLeaderboard; }; export function LeaderboardSongStarCount({ leaderboard }: LeaderboardSongStarCountProps) { @@ -15,12 +14,11 @@ export function LeaderboardSongStarCount({ leaderboard }: LeaderboardSongStarCou return null; } - const diff = getDifficultyFromScoreSaberDifficulty(leaderboard.difficulty.difficulty); return (
diff --git a/projects/website/src/components/player/chart/generic-player-chart.tsx b/projects/website/src/components/player/chart/generic-player-chart.tsx index d02f3f4..1f2a838 100644 --- a/projects/website/src/components/player/chart/generic-player-chart.tsx +++ b/projects/website/src/components/player/chart/generic-player-chart.tsx @@ -3,7 +3,7 @@ import React from "react"; import GenericChart, { DatasetConfig } from "@/components/chart/generic-chart"; import { getValueFromHistory } from "@/common/player-utils"; -import ScoreSaberPlayer from "@ssr/common/types/player/impl/scoresaber-player"; +import ScoreSaberPlayer from "@ssr/common/player/impl/scoresaber-player"; import { parseDate } from "@ssr/common/utils/time-utils"; type Props = { diff --git a/projects/website/src/components/player/chart/player-accuracy-chart.tsx b/projects/website/src/components/player/chart/player-accuracy-chart.tsx index 2e9ef22..7196869 100644 --- a/projects/website/src/components/player/chart/player-accuracy-chart.tsx +++ b/projects/website/src/components/player/chart/player-accuracy-chart.tsx @@ -3,7 +3,7 @@ import React from "react"; import { DatasetConfig } from "@/components/chart/generic-chart"; import GenericPlayerChart from "@/components/player/chart/generic-player-chart"; -import ScoreSaberPlayer from "@ssr/common/types/player/impl/scoresaber-player"; +import ScoreSaberPlayer from "@ssr/common/player/impl/scoresaber-player"; import { isWholeNumber } from "@ssr/common/utils/number-utils"; type Props = { diff --git a/projects/website/src/components/player/chart/player-charts.tsx b/projects/website/src/components/player/chart/player-charts.tsx index 2a4388f..0d3a721 100644 --- a/projects/website/src/components/player/chart/player-charts.tsx +++ b/projects/website/src/components/player/chart/player-charts.tsx @@ -6,7 +6,7 @@ import Tooltip from "@/components/tooltip"; import PlayerAccuracyChart from "@/components/player/chart/player-accuracy-chart"; import { GlobeAmericasIcon } from "@heroicons/react/24/solid"; import { TrendingUpIcon } from "lucide-react"; -import ScoreSaberPlayer from "@ssr/common/types/player/impl/scoresaber-player"; +import ScoreSaberPlayer from "@ssr/common/player/impl/scoresaber-player"; type PlayerChartsProps = { /** diff --git a/projects/website/src/components/player/chart/player-ranking-chart.tsx b/projects/website/src/components/player/chart/player-ranking-chart.tsx index 01e9f43..75ed318 100644 --- a/projects/website/src/components/player/chart/player-ranking-chart.tsx +++ b/projects/website/src/components/player/chart/player-ranking-chart.tsx @@ -4,7 +4,7 @@ import { formatNumberWithCommas, isWholeNumber } from "@ssr/common/utils/number- import React from "react"; import { DatasetConfig } from "@/components/chart/generic-chart"; import GenericPlayerChart from "@/components/player/chart/generic-player-chart"; -import ScoreSaberPlayer from "@ssr/common/types/player/impl/scoresaber-player"; +import ScoreSaberPlayer from "@ssr/common/player/impl/scoresaber-player"; type Props = { player: ScoreSaberPlayer; diff --git a/projects/website/src/components/player/player-badges.tsx b/projects/website/src/components/player/player-badges.tsx index 7ebed33..42440fa 100644 --- a/projects/website/src/components/player/player-badges.tsx +++ b/projects/website/src/components/player/player-badges.tsx @@ -1,6 +1,6 @@ import Image from "next/image"; import Tooltip from "@/components/tooltip"; -import ScoreSaberPlayer from "@ssr/common/types/player/impl/scoresaber-player"; +import ScoreSaberPlayer from "@ssr/common/player/impl/scoresaber-player"; type Props = { player: ScoreSaberPlayer; diff --git a/projects/website/src/components/player/player-data.tsx b/projects/website/src/components/player/player-data.tsx index 29fda09..dbde923 100644 --- a/projects/website/src/components/player/player-data.tsx +++ b/projects/website/src/components/player/player-data.tsx @@ -10,30 +10,26 @@ import { useIsMobile } from "@/hooks/use-is-mobile"; import { useIsVisible } from "@/hooks/use-is-visible"; import { useRef } from "react"; import PlayerCharts from "@/components/player/chart/player-charts"; -import ScoreSaberPlayer, { getScoreSaberPlayerFromToken } from "@ssr/common/types/player/impl/scoresaber-player"; -import ScoreSaberPlayerScoresPageToken from "@ssr/common/types/token/scoresaber/score-saber-player-scores-page-token"; -import { ScoreSort } from "@ssr/common/types/score/score-sort"; 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 { ScoreSort } from "@ssr/common/score/score-sort"; +import ScoreSaberScore from "@ssr/common/score/impl/scoresaber-score"; +import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard"; +import PlayerScoresResponse from "../../../../common/src/response/player-scores-response.ts"; const REFRESH_INTERVAL = 1000 * 60 * 5; type Props = { initialPlayerData: ScoreSaberPlayer; - initialScoreData?: ScoreSaberPlayerScoresPageToken; + initialScoreData?: PlayerScoresResponse; initialSearch?: string; sort: ScoreSort; page: number; }; -export default function PlayerData({ - initialPlayerData: initialPlayerData, - initialScoreData, - initialSearch, - sort, - page, -}: Props) { +export default function PlayerData({ initialPlayerData, initialScoreData, initialSearch, sort, page }: Props) { const isMobile = useIsMobile(); const miniRankingsRef = useRef(null); const isMiniRankingsVisible = useIsVisible(miniRankingsRef); diff --git a/projects/website/src/components/player/player-header.tsx b/projects/website/src/components/player/player-header.tsx index 936c870..5d23907 100644 --- a/projects/website/src/components/player/player-header.tsx +++ b/projects/website/src/components/player/player-header.tsx @@ -8,7 +8,7 @@ import PlayerStats from "./player-stats"; import Tooltip from "@/components/tooltip"; import { ReactElement } from "react"; import PlayerTrackedStatus from "@/components/player/player-tracked-status"; -import ScoreSaberPlayer from "@ssr/common/types/player/impl/scoresaber-player"; +import ScoreSaberPlayer from "@ssr/common/player/impl/scoresaber-player"; import Link from "next/link"; import { capitalizeFirstLetter } from "@/common/string-utils"; import AddFriend from "@/components/friend/add-friend"; diff --git a/projects/website/src/components/player/player-scores.tsx b/projects/website/src/components/player/player-scores.tsx index ba7b585..ce969bb 100644 --- a/projects/website/src/components/player/player-scores.tsx +++ b/projects/website/src/components/player/player-scores.tsx @@ -12,14 +12,16 @@ import { Input } from "@/components/ui/input"; import { clsx } from "clsx"; import { useDebounce } from "@uidotdev/usehooks"; import { scoreAnimation } from "@/components/score/score-animation"; -import ScoreSaberPlayer from "@ssr/common/types/player/impl/scoresaber-player"; -import ScoreSaberPlayerScoresPageToken from "@ssr/common/types/token/scoresaber/score-saber-player-scores-page-token"; -import { ScoreSort } from "@ssr/common/types/score/score-sort"; -import { scoresaberService } from "@ssr/common/service/impl/scoresaber"; +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 ScoreSaberScore from "@ssr/common/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"; type Props = { - initialScoreData?: ScoreSaberPlayerScoresPageToken; + initialScoreData?: PlayerScoresResponse; initialSearch?: string; player: ScoreSaberPlayer; sort: ScoreSort; @@ -50,27 +52,25 @@ export default function PlayerScores({ initialScoreData, initialSearch, player, const [pageState, setPageState] = useState({ page, sort }); const [previousPage, setPreviousPage] = useState(page); - const [currentScores, setCurrentScores] = useState(initialScoreData); + const [scores, setScores] = useState | undefined>( + initialScoreData + ); const [searchTerm, setSearchTerm] = useState(initialSearch || ""); const debouncedSearchTerm = useDebounce(searchTerm, 250); const [shouldFetch, setShouldFetch] = useState(false); const topOfScoresRef = useRef(null); const isSearchActive = debouncedSearchTerm.length >= 3; - const { - data: scores, - isError, - isLoading, - } = useQuery({ + const { data, isError, isLoading } = useQuery({ queryKey: ["playerScores", player.id, pageState, debouncedSearchTerm], - queryFn: () => { - return scoresaberService.lookupPlayerScores({ - playerId: player.id, - page: pageState.page, - sort: pageState.sort, - ...(isSearchActive && { search: debouncedSearchTerm }), - }); - }, + queryFn: () => + fetchPlayerScores( + "scoresaber", + player.id, + pageState.page, + pageState.sort, + debouncedSearchTerm + ), staleTime: 30 * 1000, // 30 seconds enabled: shouldFetch && (debouncedSearchTerm.length >= 3 || debouncedSearchTerm.length === 0), }); @@ -80,9 +80,9 @@ export default function PlayerScores({ initialScoreData, initialSearch, player, */ const handleScoreAnimation = useCallback(async () => { await controls.start(previousPage >= pageState.page ? "hiddenRight" : "hiddenLeft"); - setCurrentScores(scores); + setScores(data); await controls.start("visible"); - }, [scores, controls, previousPage, pageState.page]); + }, [controls, previousPage, pageState.page, data]); /** * Change the score sort. @@ -122,8 +122,10 @@ export default function PlayerScores({ initialScoreData, initialSearch, player, * Handle score animation. */ useEffect(() => { - if (scores) handleScoreAnimation(); - }, [scores, handleScoreAnimation]); + if (data) { + handleScoreAnimation(); + } + }, [data, handleScoreAnimation]); /** * Gets the URL to the page. @@ -203,10 +205,10 @@ export default function PlayerScores({ initialScoreData, initialSearch, player,
- {currentScores && ( + {scores !== undefined && ( <>
- {isError || (currentScores.playerScores.length === 0 &&

No scores found. Invalid Page or Search?

)} + {isError || (scores.scores.length === 0 &&

No scores found. Invalid Page or Search?

)}
- {currentScores.playerScores.map((playerScore, index) => ( - - + {scores.scores.map((score, index) => ( + + ))} @@ -225,7 +232,7 @@ export default function PlayerScores({ initialScoreData, initialSearch, player, { return getUrl(page); diff --git a/projects/website/src/components/player/player-stats.tsx b/projects/website/src/components/player/player-stats.tsx index 101a45d..41f382c 100644 --- a/projects/website/src/components/player/player-stats.tsx +++ b/projects/website/src/components/player/player-stats.tsx @@ -1,6 +1,6 @@ import { formatNumberWithCommas } from "@ssr/common/utils/number-utils"; import StatValue from "@/components/stat-value"; -import ScoreSaberPlayer from "@ssr/common/types/player/impl/scoresaber-player"; +import ScoreSaberPlayer from "@ssr/common/player/impl/scoresaber-player"; import { formatDate } from "@ssr/common/utils/time-utils"; import { ReactNode } from "react"; import Tooltip from "@/components/tooltip"; diff --git a/projects/website/src/components/player/player-tracked-status.tsx b/projects/website/src/components/player/player-tracked-status.tsx index 5854861..436c167 100644 --- a/projects/website/src/components/player/player-tracked-status.tsx +++ b/projects/website/src/components/player/player-tracked-status.tsx @@ -6,7 +6,7 @@ import Tooltip from "@/components/tooltip"; import { InformationCircleIcon } from "@heroicons/react/16/solid"; import { formatNumberWithCommas } from "@ssr/common/utils/number-utils"; import { PlayerTrackedSince } from "@ssr/common/types/player/player-tracked-since"; -import ScoreSaberPlayer from "@ssr/common/types/player/impl/scoresaber-player"; +import ScoreSaberPlayer from "@ssr/common/player/impl/scoresaber-player"; import { Config } from "@ssr/common/config"; type Props = { diff --git a/projects/website/src/components/ranking/mini.tsx b/projects/website/src/components/ranking/mini.tsx index c36e4c8..f5a2a68 100644 --- a/projects/website/src/components/ranking/mini.tsx +++ b/projects/website/src/components/ranking/mini.tsx @@ -7,7 +7,7 @@ import Card from "../card"; import CountryFlag from "../country-flag"; import { Avatar, AvatarImage } from "../ui/avatar"; import { PlayerRankingSkeleton } from "@/components/ranking/player-ranking-skeleton"; -import ScoreSaberPlayer from "@ssr/common/types/player/impl/scoresaber-player"; +import ScoreSaberPlayer from "@ssr/common/player/impl/scoresaber-player"; import { ScoreSaberPlayersPageToken } from "@ssr/common/types/token/scoresaber/score-saber-players-page-token"; import { scoresaberService } from "@ssr/common/service/impl/scoresaber"; import ScoreSaberPlayerToken from "@ssr/common/types/token/scoresaber/score-saber-player-token"; diff --git a/projects/website/src/components/score/score-badge.tsx b/projects/website/src/components/score/score-badge.tsx index 86ca9bf..ea1b710 100644 --- a/projects/website/src/components/score/score-badge.tsx +++ b/projects/website/src/components/score/score-badge.tsx @@ -1,17 +1,14 @@ import StatValue from "@/components/stat-value"; -import ScoreSaberScoreToken from "@ssr/common/types/token/scoresaber/score-saber-score-token"; -import ScoreSaberLeaderboardToken from "@ssr/common/types/token/scoresaber/score-saber-leaderboard-token"; +import ScoreSaberScore from "@ssr/common/score/impl/scoresaber-score"; +import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard"; /** * A badge to display in the score stats. */ export type ScoreBadge = { name: string; - color?: (score: ScoreSaberScoreToken, leaderboard: ScoreSaberLeaderboardToken) => string | undefined; - create: ( - score: ScoreSaberScoreToken, - leaderboard: ScoreSaberLeaderboardToken - ) => string | React.ReactNode | undefined; + color?: (score: ScoreSaberScore, leaderboard: ScoreSaberLeaderboard) => string | undefined; + create: (score: ScoreSaberScore, leaderboard: ScoreSaberLeaderboard) => string | React.ReactNode | undefined; }; /** @@ -19,8 +16,8 @@ export type ScoreBadge = { */ type ScoreBadgeProps = { badges: ScoreBadge[]; - score: ScoreSaberScoreToken; - leaderboard: ScoreSaberLeaderboardToken; + score: ScoreSaberScore; + leaderboard: ScoreSaberLeaderboard; }; export function ScoreBadges({ badges, score, leaderboard }: ScoreBadgeProps) { diff --git a/projects/website/src/components/score/score-buttons.tsx b/projects/website/src/components/score/score-buttons.tsx index 3d18f34..2124531 100644 --- a/projects/website/src/components/score/score-buttons.tsx +++ b/projects/website/src/components/score/score-buttons.tsx @@ -1,6 +1,5 @@ "use client"; -import BeatSaverMap from "@/common/database/types/beatsaver-map"; import { songNameToYouTubeLink } from "@/common/youtube-utils"; import BeatSaverLogo from "@/components/logos/beatsaver-logo"; import YouTubeLogo from "@/components/logos/youtube-logo"; @@ -8,19 +7,20 @@ import { useToast } from "@/hooks/use-toast"; import { useState } from "react"; import ScoreButton from "./score-button"; import { copyToClipboard } from "@/common/browser-utils"; -import ScoreSaberLeaderboardToken from "@ssr/common/types/token/scoresaber/score-saber-leaderboard-token"; import { ArrowDownIcon } from "@heroicons/react/24/solid"; import clsx from "clsx"; import ScoreEditorButton from "@/components/score/score-editor-button"; -import ScoreSaberScoreToken from "@ssr/common/types/token/scoresaber/score-saber-score-token"; +import ScoreSaberScore from "@ssr/common/score/impl/scoresaber-score"; +import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard"; +import { BeatSaverMap } from "@ssr/common/model/beatsaver/beatsaver-map"; type Props = { - score?: ScoreSaberScoreToken; - leaderboard: ScoreSaberLeaderboardToken; + score?: ScoreSaberScore; + leaderboard: ScoreSaberLeaderboard; beatSaverMap?: BeatSaverMap; alwaysSingleLine?: boolean; setIsLeaderboardExpanded?: (isExpanded: boolean) => void; - updateScore?: (score: ScoreSaberScoreToken) => void; + updateScore?: (score: ScoreSaberScore) => void; }; export default function ScoreButtons({ @@ -35,7 +35,7 @@ export default function ScoreButtons({ const { toast } = useToast(); return ( -
+
@@ -90,7 +90,7 @@ export default function ScoreButtons({ {/* View Leaderboard button */} {leaderboardExpanded != undefined && setIsLeaderboardExpanded != undefined && ( -
+
void; + score: ScoreSaberScore; + leaderboard: ScoreSaberLeaderboard; + updateScore: (score: ScoreSaberScore) => void; }; export default function ScoreEditorButton({ score, leaderboard, updateScore }: ScoreEditorButtonProps) { const [isScoreEditMode, setIsScoreEditMode] = useState(false); const maxScore = leaderboard.maxScore || 1; // Use 1 to prevent division by zero - const accuracy = (score.baseScore / maxScore) * 100; + const accuracy = (score.score / maxScore) * 100; const handleSliderChange = (value: number[]) => { const newAccuracy = Math.max(0, Math.min(value[0], 100)); // Ensure the accuracy stays within 0-100 const newBaseScore = (newAccuracy / 100) * maxScore; updateScore({ ...score, - baseScore: newBaseScore, + score: newBaseScore, }); }; const handleSliderReset = () => { updateScore({ ...score, - baseScore: (accuracy / 100) * maxScore, + score: (accuracy / 100) * maxScore, }); }; return ( -
+
{ setIsScoreEditMode(open); diff --git a/projects/website/src/components/score/score-feed/score-feed.tsx b/projects/website/src/components/score/score-feed/score-feed.tsx index d6964a9..3f781ff 100644 --- a/projects/website/src/components/score/score-feed/score-feed.tsx +++ b/projects/website/src/components/score/score-feed/score-feed.tsx @@ -49,7 +49,7 @@ export default function ScoreFeed() {

-

Difficulty: {diff}

+

Difficulty: {difficulty.difficulty}

{starCount > 0 &&

Stars: {starCount.toFixed(2)}

} } @@ -33,7 +32,7 @@ export default function ScoreSongInfo({ leaderboard, beatSaverMap }: Props) {
{starCount > 0 ? ( @@ -42,13 +41,13 @@ export default function ScoreSongInfo({ leaderboard, beatSaverMap }: Props) {
) : ( -

{diff}

+

{difficulty.difficulty}

)}
Song Artwork {format({ - date: new Date(score.timeSet), + date: new Date(score.timestamp), format: "DD MMMM YYYY HH:mm a", })}

} > -

{timeAgo(new Date(score.timeSet))}

+

{timeAgo(new Date(score.timestamp))}

); diff --git a/projects/website/src/components/score/score-stats.tsx b/projects/website/src/components/score/score-stats.tsx index b2bbbe3..52c7c8d 100644 --- a/projects/website/src/components/score/score-stats.tsx +++ b/projects/website/src/components/score/score-stats.tsx @@ -4,8 +4,8 @@ import { XMarkIcon } from "@heroicons/react/24/solid"; import clsx from "clsx"; import Tooltip from "@/components/tooltip"; import { ScoreBadge, ScoreBadges } from "@/components/score/score-badge"; -import ScoreSaberScoreToken from "@ssr/common/types/token/scoresaber/score-saber-score-token"; -import ScoreSaberLeaderboardToken from "@ssr/common/types/token/scoresaber/score-saber-leaderboard-token"; +import ScoreSaberScore from "@ssr/common/score/impl/scoresaber-score"; +import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard"; const badges: ScoreBadge[] = [ { @@ -13,12 +13,13 @@ const badges: ScoreBadge[] = [ color: () => { return "bg-pp"; }, - create: (score: ScoreSaberScoreToken) => { + create: (score: ScoreSaberScore) => { const pp = score.pp; - if (pp === 0) { + const weight = score.weight; + if (pp === 0 || pp === undefined || weight === undefined) { return undefined; } - const weightedPp = pp * score.weight; + const weightedPp = pp * weight; return ( <> @@ -26,7 +27,7 @@ const badges: ScoreBadge[] = [ display={

- Weighted: {formatPp(weightedPp)}pp ({(100 * score.weight).toFixed(2)}%) + Weighted: {formatPp(weightedPp)}pp ({(100 * weight).toFixed(2)}%)

} @@ -39,12 +40,12 @@ const badges: ScoreBadge[] = [ }, { name: "Accuracy", - color: (score: ScoreSaberScoreToken, leaderboard: ScoreSaberLeaderboardToken) => { - const acc = (score.baseScore / leaderboard.maxScore) * 100; + color: (score: ScoreSaberScore, leaderboard: ScoreSaberLeaderboard) => { + const acc = (score.score / leaderboard.maxScore) * 100; return getScoreBadgeFromAccuracy(acc).color; }, - create: (score: ScoreSaberScoreToken, leaderboard: ScoreSaberLeaderboardToken) => { - const acc = (score.baseScore / leaderboard.maxScore) * 100; + create: (score: ScoreSaberScore, leaderboard: ScoreSaberLeaderboard) => { + const acc = (score.score / leaderboard.maxScore) * 100; const scoreBadge = getScoreBadgeFromAccuracy(acc); let accDetails = `Accuracy ${scoreBadge.name != "-" ? scoreBadge.name : ""}`; if (scoreBadge.max == null) { @@ -72,8 +73,8 @@ const badges: ScoreBadge[] = [ }, { name: "Score", - create: (score: ScoreSaberScoreToken) => { - return `${formatNumberWithCommas(Number(score.baseScore.toFixed(0)))}`; + create: (score: ScoreSaberScore) => { + return `${formatNumberWithCommas(Number(score.score.toFixed(0)))}`; }, }, { @@ -86,14 +87,14 @@ const badges: ScoreBadge[] = [ }, { name: "Full Combo", - create: (score: ScoreSaberScoreToken) => { + create: (score: ScoreSaberScore) => { return ( {!score.fullCombo ? ( <> -

Missed Notes: {formatNumberWithCommas(score.missedNotes)}

+

Missed Notes: {formatNumberWithCommas(score.misses)}

Bad Cuts: {formatNumberWithCommas(score.badCuts)}

) : ( @@ -107,7 +108,7 @@ const badges: ScoreBadge[] = [ {score.fullCombo ? ( FC ) : ( - formatNumberWithCommas(score.missedNotes + score.badCuts) + formatNumberWithCommas(score.misses + score.badCuts) )}

@@ -119,8 +120,8 @@ const badges: ScoreBadge[] = [ ]; type Props = { - score: ScoreSaberScoreToken; - leaderboard: ScoreSaberLeaderboardToken; + score: ScoreSaberScore; + leaderboard: ScoreSaberLeaderboard; }; export default function ScoreStats({ score, leaderboard }: Props) { diff --git a/projects/website/src/components/score/score.tsx b/projects/website/src/components/score/score.tsx index 562aba9..74d5bf9 100644 --- a/projects/website/src/components/score/score.tsx +++ b/projects/website/src/components/score/score.tsx @@ -1,18 +1,18 @@ "use client"; -import BeatSaverMap from "@/common/database/types/beatsaver-map"; import LeaderboardScores from "@/components/leaderboard/leaderboard-scores"; -import { useCallback, useEffect, useState } from "react"; +import { useEffect, useState } from "react"; import ScoreButtons from "./score-buttons"; import ScoreSongInfo from "./score-info"; import ScoreRankInfo from "./score-rank-info"; import ScoreStats from "./score-stats"; import { motion } from "framer-motion"; -import ScoreSaberPlayer from "@ssr/common/types/player/impl/scoresaber-player"; -import ScoreSaberPlayerScoreToken from "@ssr/common/types/token/scoresaber/score-saber-player-score-token"; -import { lookupBeatSaverMap } from "@/common/beatsaver-utils"; +import ScoreSaberPlayer from "@ssr/common/player/impl/scoresaber-player"; import { getPageFromRank } from "@ssr/common/utils/utils"; import { scoresaberService } from "@ssr/common/service/impl/scoresaber"; +import ScoreSaberScore from "@ssr/common/score/impl/scoresaber-score"; +import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard"; +import { BeatSaverMap } from "@ssr/common/model/beatsaver/beatsaver-map"; type Props = { /** @@ -20,10 +20,20 @@ type Props = { */ player?: ScoreSaberPlayer; + /** + * The leaderboard. + */ + leaderboard: ScoreSaberLeaderboard; + + /** + * The beat saver map for this song. + */ + beatSaverMap?: BeatSaverMap; + /** * The score to display. */ - playerScore: ScoreSaberPlayerScoreToken; + score: ScoreSaberScore; /** * Score settings @@ -33,36 +43,18 @@ type Props = { }; }; -export default function Score({ player, playerScore, settings }: Props) { - const { score, leaderboard } = playerScore; - const [baseScore, setBaseScore] = useState(score.baseScore); - const [beatSaverMap, setBeatSaverMap] = useState(); +export default function Score({ player, leaderboard, beatSaverMap, score, settings }: Props) { + const [baseScore, setBaseScore] = useState(score.score); const [isLeaderboardExpanded, setIsLeaderboardExpanded] = useState(false); - const fetchBeatSaverData = useCallback(async () => { - // No need to fetch if no buttons - if (settings?.noScoreButtons == true) { - return; - } - const beatSaverMapData = await lookupBeatSaverMap(leaderboard.songHash); - setBeatSaverMap(beatSaverMapData); - }, [leaderboard.songHash, settings?.noScoreButtons]); - /** * Set the base score */ useEffect(() => { - if (playerScore?.score?.baseScore) { - setBaseScore(playerScore.score.baseScore); + if (score?.score) { + setBaseScore(score.score); } - }, [playerScore]); - - /** - * Fetch the beatSaver data on page load - */ - useEffect(() => { - fetchBeatSaverData(); - }, [fetchBeatSaverData]); + }, [score]); /** * Close the leaderboard when the score changes @@ -72,7 +64,7 @@ export default function Score({ player, playerScore, settings }: Props) { }, [score]); const accuracy = (baseScore / leaderboard.maxScore) * 100; - const pp = baseScore === score.baseScore ? score.pp : scoresaberService.getPp(leaderboard.stars, accuracy); + const pp = baseScore === score.score ? score.pp : scoresaberService.getPp(leaderboard.stars, accuracy); // Dynamic grid column classes const gridColsClass = settings?.noScoreButtons @@ -92,14 +84,14 @@ export default function Score({ player, playerScore, settings }: Props) { score={score} setIsLeaderboardExpanded={setIsLeaderboardExpanded} updateScore={score => { - setBaseScore(score.baseScore); + setBaseScore(score.score); }} /> )}