From b3c124631a35a6574cbef6918eb23f70bbea337a Mon Sep 17 00:00:00 2001 From: Liam Date: Thu, 17 Oct 2024 15:30:14 +0100 Subject: [PATCH] move score page fetching to the backend --- bun.lockb | Bin 357600 -> 357632 bytes .../src/controller/leaderboard.controller.ts | 26 +++ .../src/controller/player.controller.ts | 4 +- .../src/controller/scores.controller.ts | 55 +++++++ projects/backend/src/index.ts | 13 +- projects/backend/src/service/app.service.ts | 2 +- .../backend/src/service/beatsaver.service.ts | 30 ++++ .../backend/src/service/image.service.tsx | 2 +- .../src/service/leaderboard.service.ts | 78 +++++++++ .../backend/src/service/player.service.ts | 2 +- projects/backend/src/service/score.service.ts | 148 ++++++++++++++++++ projects/backend/tsconfig.json | 4 +- projects/common/package.json | 3 +- projects/common/src/leaderboard.ts | 5 + .../impl/scoresaber-leaderboard.ts | 63 ++++++++ .../src/leaderboard/leaderboard-difficulty.ts | 23 +++ .../common/src/leaderboard/leaderboard.ts | 75 +++++++++ .../src/model/beatsaver/beatsaver-author.ts | 13 ++ .../src/model/beatsaver/beatsaver-map.ts | 51 ++++++ .../{backend => common}/src/model/player.ts | 7 +- .../player/impl/scoresaber-player.ts | 8 +- .../src/{types => }/player/player-history.ts | 0 .../player/player-tracked-since.ts | 0 .../common/src/{types => }/player/player.ts | 0 .../src/response/leaderboard-response.ts | 13 ++ .../response/leaderboard-scores-response.ts | 25 +++ .../src/response/player-scores-response.ts | 14 ++ projects/common/src/score/difficulty.ts | 1 + .../common/src/score/impl/scoresaber-score.ts | 62 ++++++++ .../common/src/{types => }/score/modifier.ts | 2 + .../src/score/player-leaderboard-score.ts | 6 + projects/common/src/score/player-score.ts | 18 +++ .../src/{types => }/score/score-sort.ts | 0 projects/common/src/score/score.ts | 51 ++++++ .../common/src/service/impl/scoresaber.ts | 2 +- projects/common/src/service/service.ts | 1 - projects/common/src/types/metadata.ts | 28 ++++ projects/common/src/types/page.ts | 13 ++ .../src/types/score/impl/scoresaber-score.ts | 47 ------ projects/common/src/types/score/score.ts | 116 -------------- .../score-saber-leaderboard-token.ts | 4 +- projects/common/src/utils/leaderboard.util.ts | 14 ++ projects/common/src/utils/player-utils.ts | 2 +- projects/common/src/utils/score-utils.ts | 37 +++++ projects/common/src/utils/scoresaber-utils.ts | 4 +- projects/common/tsconfig.json | 2 + .../(pages)/leaderboard/[...slug]/page.tsx | 51 +++--- .../src/app/(pages)/player/[...slug]/page.tsx | 20 ++- .../website/src/common/database/database.ts | 7 - .../common/database/types/beatsaver-map.ts | 23 --- .../website/src/components/api/api-health.tsx | 2 +- .../src/components/friend/add-friend.tsx | 2 +- .../leaderboard/leaderboard-data.tsx | 69 ++++---- .../leaderboard/leaderboard-info.tsx | 8 +- .../leaderboard/leaderboard-player.tsx | 8 +- .../leaderboard/leaderboard-score-stats.tsx | 24 +-- .../leaderboard/leaderboard-score.tsx | 10 +- .../leaderboard/leaderboard-scores.tsx | 64 ++++---- .../leaderboard-song-star-count.tsx | 8 +- .../player/chart/generic-player-chart.tsx | 2 +- .../player/chart/player-accuracy-chart.tsx | 2 +- .../components/player/chart/player-charts.tsx | 2 +- .../player/chart/player-ranking-chart.tsx | 2 +- .../src/components/player/player-badges.tsx | 2 +- .../src/components/player/player-data.tsx | 18 +-- .../src/components/player/player-header.tsx | 2 +- .../src/components/player/player-scores.tsx | 65 ++++---- .../src/components/player/player-stats.tsx | 2 +- .../player/player-tracked-status.tsx | 2 +- .../website/src/components/ranking/mini.tsx | 2 +- .../src/components/score/score-badge.tsx | 15 +- .../src/components/score/score-buttons.tsx | 16 +- .../components/score/score-editor-button.tsx | 18 +-- .../score/score-feed/score-feed.tsx | 2 +- .../src/components/score/score-info.tsx | 19 ++- .../src/components/score/score-rank-info.tsx | 12 +- .../src/components/score/score-stats.tsx | 35 +++-- .../website/src/components/score/score.tsx | 56 +++---- 78 files changed, 1150 insertions(+), 494 deletions(-) create mode 100644 projects/backend/src/controller/leaderboard.controller.ts create mode 100644 projects/backend/src/controller/scores.controller.ts create mode 100644 projects/backend/src/service/beatsaver.service.ts create mode 100644 projects/backend/src/service/leaderboard.service.ts create mode 100644 projects/common/src/leaderboard.ts create mode 100644 projects/common/src/leaderboard/impl/scoresaber-leaderboard.ts create mode 100644 projects/common/src/leaderboard/leaderboard-difficulty.ts create mode 100644 projects/common/src/leaderboard/leaderboard.ts create mode 100644 projects/common/src/model/beatsaver/beatsaver-author.ts create mode 100644 projects/common/src/model/beatsaver/beatsaver-map.ts rename projects/{backend => common}/src/model/player.ts (91%) rename projects/common/src/{types => }/player/impl/scoresaber-player.ts (97%) rename projects/common/src/{types => }/player/player-history.ts (100%) rename projects/common/src/{types => }/player/player-tracked-since.ts (100%) rename projects/common/src/{types => }/player/player.ts (100%) create mode 100644 projects/common/src/response/leaderboard-response.ts create mode 100644 projects/common/src/response/leaderboard-scores-response.ts create mode 100644 projects/common/src/response/player-scores-response.ts create mode 100644 projects/common/src/score/difficulty.ts create mode 100644 projects/common/src/score/impl/scoresaber-score.ts rename projects/common/src/{types => }/score/modifier.ts (90%) create mode 100644 projects/common/src/score/player-leaderboard-score.ts create mode 100644 projects/common/src/score/player-score.ts rename projects/common/src/{types => }/score/score-sort.ts (100%) create mode 100644 projects/common/src/score/score.ts create mode 100644 projects/common/src/types/metadata.ts create mode 100644 projects/common/src/types/page.ts delete mode 100644 projects/common/src/types/score/impl/scoresaber-score.ts delete mode 100644 projects/common/src/types/score/score.ts create mode 100644 projects/common/src/utils/leaderboard.util.ts create mode 100644 projects/common/src/utils/score-utils.ts delete mode 100644 projects/website/src/common/database/types/beatsaver-map.ts diff --git a/bun.lockb b/bun.lockb index 269e20d764b9a0f162e57fa216dc4880f3c169b3..467e84f7c6368dfca985dd25177a96cae3c953cc 100755 GIT binary patch delta 53568 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 z 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 V=IoaSZ>Q)99A-Q{{?c&k{{U@jb=?2} delta 53759 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 aV`xRyGYm$@&ipsK@fQ)RK|978PW?Yfd?8r? 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); }} /> )}