diff --git a/projects/backend/src/service/score.service.ts b/projects/backend/src/service/score.service.ts index 0565419..82de6d8 100644 --- a/projects/backend/src/service/score.service.ts +++ b/projects/backend/src/service/score.service.ts @@ -24,12 +24,12 @@ import { Config } from "@ssr/common/config"; import { SSRCache } from "@ssr/common/cache"; import { fetchWithCache } from "../common/cache.util"; import { PlayerDocument, PlayerModel } from "@ssr/common/model/player"; -import { BeatLeaderScoreToken } from "@ssr/common/types/token/beatleader/beatleader-score-token"; +import { BeatLeaderScoreToken } from "@ssr/common/types/token/beatleader/score/score"; import { AdditionalScoreData, AdditionalScoreDataModel, } from "@ssr/common/model/additional-score-data/additional-score-data"; -import { BeatLeaderScoreImprovementToken } from "@ssr/common/types/token/beatleader/beatleader-score-improvement-token"; +import { BeatLeaderScoreImprovementToken } from "@ssr/common/types/token/beatleader/score/score-improvement"; const playerScoresCache = new SSRCache({ ttl: 1000 * 60, // 1 minute diff --git a/projects/common/src/service/impl/beatleader.ts b/projects/common/src/service/impl/beatleader.ts new file mode 100644 index 0000000..62cd828 --- /dev/null +++ b/projects/common/src/service/impl/beatleader.ts @@ -0,0 +1,35 @@ +import Service from "../service"; +import { BeatSaverMapToken } from "../../types/token/beatsaver/map"; +import { ScoreStatsToken } from "../../types/token/beatleader/score-stats/score-stats"; + +const LOOKUP_MAP_STATS_BY_SCORE_ID_ENDPOINT = `https://cdn.scorestats.beatleader.xyz/:scoreId.json`; + +class BeatLeaderService extends Service { + constructor() { + super("BeatLeader"); + } + + /** + * Looks up the score stats for a score + * + * @param scoreId the score id to get + * @returns the score stats, or undefined if nothing was found + */ + async lookupScoreStats(scoreId: number): Promise { + const before = performance.now(); + this.log(`Looking score stats for "${scoreId}"...`); + + const response = await this.fetch( + LOOKUP_MAP_STATS_BY_SCORE_ID_ENDPOINT.replace(":scoreId", scoreId) + ); + // Score stats not found + if (response == undefined) { + return undefined; + } + + this.log(`Found score stats for score "${scoreId}" in ${(performance.now() - before).toFixed(0)}ms`); + return response; + } +} + +export const beatLeaderService = new BeatLeaderService(); diff --git a/projects/common/src/types/token/beatleader/beatleader-difficulty-token.ts b/projects/common/src/types/token/beatleader/difficulty.ts similarity index 79% rename from projects/common/src/types/token/beatleader/beatleader-difficulty-token.ts rename to projects/common/src/types/token/beatleader/difficulty.ts index ab21362..4ededf7 100644 --- a/projects/common/src/types/token/beatleader/beatleader-difficulty-token.ts +++ b/projects/common/src/types/token/beatleader/difficulty.ts @@ -1,5 +1,5 @@ -import { BeatLeaderModifierToken } from "./beatleader-modifiers-token"; -import { BeatLeaderModifierRatingToken } from "./beatleader-modifier-rating-token"; +import { BeatLeaderModifierToken } from "./modifier/modifiers"; +import { BeatLeaderModifierRatingToken } from "./modifier/modifier-rating"; export type BeatLeaderDifficultyToken = { id: number; diff --git a/projects/common/src/types/token/beatleader/beatleader-leaderboard-token.ts b/projects/common/src/types/token/beatleader/leaderboard.ts similarity index 70% rename from projects/common/src/types/token/beatleader/beatleader-leaderboard-token.ts rename to projects/common/src/types/token/beatleader/leaderboard.ts index 6cd979a..a2fce89 100644 --- a/projects/common/src/types/token/beatleader/beatleader-leaderboard-token.ts +++ b/projects/common/src/types/token/beatleader/leaderboard.ts @@ -1,5 +1,5 @@ -import { BeatLeaderSongToken } from "./beatleader-song-token"; -import { BeatLeaderDifficultyToken } from "./beatleader-difficulty-token"; +import { BeatLeaderSongToken } from "./score/song"; +import { BeatLeaderDifficultyToken } from "./difficulty"; export type BeatLeaderLeaderboardToken = { id: string; diff --git a/projects/common/src/types/token/beatleader/beatleader-modifier-rating-token.ts b/projects/common/src/types/token/beatleader/modifier/modifier-rating.ts similarity index 100% rename from projects/common/src/types/token/beatleader/beatleader-modifier-rating-token.ts rename to projects/common/src/types/token/beatleader/modifier/modifier-rating.ts diff --git a/projects/common/src/types/token/beatleader/beatleader-modifiers-token.ts b/projects/common/src/types/token/beatleader/modifier/modifiers.ts similarity index 100% rename from projects/common/src/types/token/beatleader/beatleader-modifiers-token.ts rename to projects/common/src/types/token/beatleader/modifier/modifiers.ts diff --git a/projects/common/src/types/token/beatleader/beatleader-player-token.ts b/projects/common/src/types/token/beatleader/player.ts similarity index 100% rename from projects/common/src/types/token/beatleader/beatleader-player-token.ts rename to projects/common/src/types/token/beatleader/player.ts diff --git a/projects/common/src/types/token/beatleader/score-stats/accuracy-tracker.ts b/projects/common/src/types/token/beatleader/score-stats/accuracy-tracker.ts new file mode 100644 index 0000000..8e0f739 --- /dev/null +++ b/projects/common/src/types/token/beatleader/score-stats/accuracy-tracker.ts @@ -0,0 +1,66 @@ +export type ScoreStatsAccuracyTrackerToken = { + /** + * The accuracy of the right hand. + */ + accRight: number; + + /** + * The accuracy of the left hand. + */ + accLeft: number; + + /** + * The left hand pre-swing. + */ + leftPreswing: number; + + /** + * The right hand pre-swing. + */ + rightPreswing: number; + + /** + * The average pre-swing. + */ + averagePreswing: number; + + /** + * The left hand post-swing. + */ + leftPostswing: number; + + /** + * The right hand post-swing. + */ + rightPostswing: number; + + /** + * The left hand time dependence. + */ + leftTimeDependence: number; + + /** + * The right hand time dependence. + */ + rightTimeDependence: number; + + /** + * The left hand average cut. + */ + leftAverageCut: number[]; + + /** + * The right hand average cut. + */ + rightAverageCut: number[]; + + /** + * The grid accuracy. + */ + gridAcc: number[]; + + /** + * The full combo accuracy. + */ + fcAcc: number; +}; diff --git a/projects/common/src/types/token/beatleader/score-stats/head-position.ts b/projects/common/src/types/token/beatleader/score-stats/head-position.ts new file mode 100644 index 0000000..8339629 --- /dev/null +++ b/projects/common/src/types/token/beatleader/score-stats/head-position.ts @@ -0,0 +1,16 @@ +export type ScoreStatsHeadPositionToken = { + /** + * The X position of the head + */ + x: number; + + /** + * The Y position of the head + */ + y: number; + + /** + * The Z position of the head + */ + z: number; +}; diff --git a/projects/common/src/types/token/beatleader/score-stats/hit-tracker.ts b/projects/common/src/types/token/beatleader/score-stats/hit-tracker.ts new file mode 100644 index 0000000..1dca2a8 --- /dev/null +++ b/projects/common/src/types/token/beatleader/score-stats/hit-tracker.ts @@ -0,0 +1,51 @@ +export type ScoreStatsHitTrackerToken = { + /** + * The maximum combo achieved. + */ + maxCombo: number; + + /** + * The highest amount of 115 notes hit in a row. + */ + maxStreak: number; + + /** + * The left hand timing. + */ + leftTiming: number; + + /** + * The right hand timing. + */ + rightTiming: number; + + /** + * The left hand misses. + */ + leftMiss: number; + + /** + * The right hand misses. + */ + rightMiss: number; + + /** + * The left hand bad cuts. + */ + leftBadCuts: number; + + /** + * The right hand bad cuts. + */ + rightBadCuts: number; + + /** + * The left hand bombs. + */ + leftBombs: number; + + /** + * The right hand bombs. + */ + rightBombs: number; +}; diff --git a/projects/common/src/types/token/beatleader/score-stats/score-graph-tracker.ts b/projects/common/src/types/token/beatleader/score-stats/score-graph-tracker.ts new file mode 100644 index 0000000..34311c3 --- /dev/null +++ b/projects/common/src/types/token/beatleader/score-stats/score-graph-tracker.ts @@ -0,0 +1,6 @@ +export type ScoreStatsGraphTrackerToken = { + /** + * The accuracy graph data. + */ + graph: number[]; +}; diff --git a/projects/common/src/types/token/beatleader/score-stats/score-stats.ts b/projects/common/src/types/token/beatleader/score-stats/score-stats.ts new file mode 100644 index 0000000..85aac80 --- /dev/null +++ b/projects/common/src/types/token/beatleader/score-stats/score-stats.ts @@ -0,0 +1,26 @@ +import { ScoreStatsHitTrackerToken } from "./hit-tracker"; +import { ScoreStatsAccuracyTrackerToken } from "./accuracy-tracker"; +import { ScoreStatsWinTrackerToken } from "./win-tracker"; +import { ScoreStatsGraphTrackerToken } from "./score-graph-tracker"; + +export type ScoreStatsToken = { + /** + * The hit tracker stats. + */ + hitTracker: ScoreStatsHitTrackerToken; + + /** + * The accuracy tracker stats. + */ + accuracyTracker: ScoreStatsAccuracyTrackerToken; + + /** + * The win tracker stats. + */ + winTracker: ScoreStatsWinTrackerToken; + + /** + * The score graph tracker stats. + */ + scoreGraphTracker: ScoreStatsGraphTrackerToken; +}; diff --git a/projects/common/src/types/token/beatleader/score-stats/win-tracker.ts b/projects/common/src/types/token/beatleader/score-stats/win-tracker.ts new file mode 100644 index 0000000..b8114c0 --- /dev/null +++ b/projects/common/src/types/token/beatleader/score-stats/win-tracker.ts @@ -0,0 +1,48 @@ +import { ScoreStatsHeadPositionToken } from "./head-position"; + +export type ScoreStatsWinTrackerToken = { + /** + * Whether the score was won. (not failed) + */ + won: boolean; + + /** + * The time the score ended. + */ + endTime: number; + + /** + * The total amount of pauses. + */ + nbOfPause: number; + + /** + * The total amount of pause time. + */ + totalPauseDuration: number; + + /** + * The jump distance the score was played on. + */ + jumpDistance: number; + + /** + * The average height of the player. + */ + averageHeight: number; + + /** + * The average head position of the player. + */ + averageHeadPosition: ScoreStatsHeadPositionToken; + + /** + * The total score. + */ + totalScore: number; + + /** + * The maximum score for this song. + */ + maxScore: number; +}; diff --git a/projects/common/src/types/token/beatleader/beatleader-score-improvement-token.ts b/projects/common/src/types/token/beatleader/score/score-improvement.ts similarity index 100% rename from projects/common/src/types/token/beatleader/beatleader-score-improvement-token.ts rename to projects/common/src/types/token/beatleader/score/score-improvement.ts diff --git a/projects/common/src/types/token/beatleader/beatleader-score-offsets-token.ts b/projects/common/src/types/token/beatleader/score/score-offsets.ts similarity index 100% rename from projects/common/src/types/token/beatleader/beatleader-score-offsets-token.ts rename to projects/common/src/types/token/beatleader/score/score-offsets.ts diff --git a/projects/common/src/types/token/beatleader/beatleader-score-token.ts b/projects/common/src/types/token/beatleader/score/score.ts similarity index 77% rename from projects/common/src/types/token/beatleader/beatleader-score-token.ts rename to projects/common/src/types/token/beatleader/score/score.ts index cf0bf0b..3500c9a 100644 --- a/projects/common/src/types/token/beatleader/beatleader-score-token.ts +++ b/projects/common/src/types/token/beatleader/score/score.ts @@ -1,7 +1,7 @@ -import { BeatLeaderLeaderboardToken } from "./beatleader-leaderboard-token"; -import { BeatLeaderScoreImprovementToken } from "./beatleader-score-improvement-token"; -import { BeatLeaderScoreOffsetsToken } from "./beatleader-score-offsets-token"; -import { BeatLeaderPlayerToken } from "./beatleader-player-token"; +import { BeatLeaderLeaderboardToken } from "../leaderboard"; +import { BeatLeaderScoreImprovementToken } from "./score-improvement"; +import { BeatLeaderScoreOffsetsToken } from "./score-offsets"; +import { BeatLeaderPlayerToken } from "../player"; export type BeatLeaderScoreToken = { myScore: null; // ?? diff --git a/projects/common/src/types/token/beatleader/beatleader-song-token.ts b/projects/common/src/types/token/beatleader/score/song.ts similarity index 100% rename from projects/common/src/types/token/beatleader/beatleader-song-token.ts rename to projects/common/src/types/token/beatleader/score/song.ts diff --git a/projects/common/src/websocket/beatleader-websocket.ts b/projects/common/src/websocket/beatleader-websocket.ts index 208042e..25eecbe 100644 --- a/projects/common/src/websocket/beatleader-websocket.ts +++ b/projects/common/src/websocket/beatleader-websocket.ts @@ -1,5 +1,5 @@ import { connectWebSocket, WebsocketCallbacks } from "./websocket"; -import { BeatLeaderScoreToken } from "../types/token/beatleader/beatleader-score-token"; +import { BeatLeaderScoreToken } from "../types/token/beatleader/score/score"; type BeatLeaderWebsocket = { /** diff --git a/projects/website/src/components/leaderboard/leaderboard-pp-chart.tsx b/projects/website/src/components/leaderboard/chart/leaderboard-pp-chart.tsx similarity index 100% rename from projects/website/src/components/leaderboard/leaderboard-pp-chart.tsx rename to projects/website/src/components/leaderboard/chart/leaderboard-pp-chart.tsx index c303947..bc4000f 100644 --- a/projects/website/src/components/leaderboard/leaderboard-pp-chart.tsx +++ b/projects/website/src/components/leaderboard/chart/leaderboard-pp-chart.tsx @@ -2,11 +2,11 @@ import React, { useState } from "react"; import GenericChart, { DatasetConfig } from "@/components/chart/generic-chart"; -import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard"; -import { scoresaberService } from "@ssr/common/service/impl/scoresaber"; import Card from "@/components/card"; import { DualRangeSlider } from "@/components/ui/dual-range-slider"; import { useDebounce } from "@uidotdev/usehooks"; +import { scoresaberService } from "@ssr/common/service/impl/scoresaber"; +import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard"; type Props = { /** diff --git a/projects/website/src/components/leaderboard/chart/player-score-accuracy-chart.tsx b/projects/website/src/components/leaderboard/chart/player-score-accuracy-chart.tsx new file mode 100644 index 0000000..96c1def --- /dev/null +++ b/projects/website/src/components/leaderboard/chart/player-score-accuracy-chart.tsx @@ -0,0 +1,48 @@ +"use client"; + +import ScoreSaberScore from "@ssr/common/score/impl/scoresaber-score"; +import { ScoreStatsToken } from "@ssr/common/types/token/beatleader/score-stats/score-stats"; +import { formatTime } from "@ssr/common/utils/time-utils"; +import GenericChart, { DatasetConfig } from "@/components/chart/generic-chart"; + +type Props = { + /** + * The score stats to use in the chart + */ + scoreStats: ScoreStatsToken; +}; + +export default function PlayerScoreAccuracyChart({ scoreStats }: Props) { + const graph = scoreStats.scoreGraphTracker.graph; + + const histories: Record = {}; + const labels: string[] = []; + + for (let seconds = 0; seconds < graph.length; seconds++) { + labels.push(formatTime(seconds)); + + const history = histories["accuracy"]; + if (!history) { + histories["accuracy"] = []; + } + histories["accuracy"].push(graph[seconds] * 100); + } + + const datasetConfig: DatasetConfig[] = [ + { + title: "Accuracy", + field: "accuracy", + color: "#3EC1D3", + axisId: "y", + axisConfig: { + reverse: false, + display: true, + displayName: "Accuracy", + position: "left", + }, + labelFormatter: (value: number) => `${value.toFixed(2)}%`, + }, + ]; + + return ; +} diff --git a/projects/website/src/components/leaderboard/leaderboard-data.tsx b/projects/website/src/components/leaderboard/leaderboard-data.tsx index 1b04552..16ea3a7 100644 --- a/projects/website/src/components/leaderboard/leaderboard-data.tsx +++ b/projects/website/src/components/leaderboard/leaderboard-data.tsx @@ -9,7 +9,7 @@ import { useQuery } from "@tanstack/react-query"; import { useEffect, useState } from "react"; import { fetchLeaderboard } from "@ssr/common/utils/leaderboard.util"; import LeaderboardScoresResponse from "@ssr/common/response/leaderboard-scores-response"; -import LeaderboardPpChart from "@/components/leaderboard/leaderboard-pp-chart"; +import LeaderboardPpChart from "@/components/leaderboard/chart/leaderboard-pp-chart"; import Card from "@/components/card"; type LeaderboardDataProps = { diff --git a/projects/website/src/components/leaderboard/leaderboard-scores.tsx b/projects/website/src/components/leaderboard/leaderboard-scores.tsx index 3ce6fd4..ddab1ed 100644 --- a/projects/website/src/components/leaderboard/leaderboard-scores.tsx +++ b/projects/website/src/components/leaderboard/leaderboard-scores.tsx @@ -62,8 +62,7 @@ export default function LeaderboardScores({ selectedLeaderboardId + "", currentPage ), - staleTime: 30 * 1000, - enabled: (shouldFetch && isLeaderboardPage) || !isLeaderboardPage, + enabled: shouldFetch, }); /** diff --git a/projects/website/src/components/score/score.tsx b/projects/website/src/components/score/score.tsx index 3015cb2..51d4bd7 100644 --- a/projects/website/src/components/score/score.tsx +++ b/projects/website/src/components/score/score.tsx @@ -15,6 +15,12 @@ import { BeatSaverMap } from "@ssr/common/model/beatsaver/map"; import { useIsMobile } from "@/hooks/use-is-mobile"; import Card from "@/components/card"; import { MapStats } from "@/components/score/map-stats"; +import PlayerScoreAccuracyChart from "@/components/leaderboard/chart/player-score-accuracy-chart"; +import { useQuery } from "@tanstack/react-query"; +import { fetchLeaderboardScores } from "@ssr/common/utils/score-utils"; +import LeaderboardScoresResponse from "@ssr/common/response/leaderboard-scores-response"; +import { ScoreStatsToken } from "@ssr/common/types/token/beatleader/score-stats/score-stats"; +import { beatLeaderService } from "@ssr/common/service/impl/beatleader"; type Props = { /** @@ -40,10 +46,67 @@ type Props = { }; }; +type LeaderboardDropdownData = { + /** + * The initial scores. + */ + scores?: LeaderboardScoresResponse; + + /** + * The score stats for this score, + */ + scoreStats?: ScoreStatsToken; +}; + export default function Score({ leaderboard, beatSaverMap, score, settings }: Props) { + const scoresPage = getPageFromRank(score.rank, 12); + const isMobile = useIsMobile(); const [baseScore, setBaseScore] = useState(score.score); const [isLeaderboardExpanded, setIsLeaderboardExpanded] = useState(false); + const [loading, setLoading] = useState(false); + const [leaderboardDropdownData, setLeaderboardDropdownData] = useState(); + + const { data, isError, isLoading } = useQuery({ + queryKey: ["leaderboardDropdownData", leaderboard.id, score.id, isLeaderboardExpanded], + queryFn: async () => { + const scores = await fetchLeaderboardScores( + "scoresaber", + leaderboard.id + "", + scoresPage + ); + const scoreStats = score.additionalData + ? await beatLeaderService.lookupScoreStats(score.additionalData.scoreId) + : undefined; + + return { + scores: scores, + scoreStats: scoreStats, + }; + }, + staleTime: 30 * 1000, + enabled: loading, + }); + + useEffect(() => { + if (data) { + setLeaderboardDropdownData({ + ...data, + scores: data.scores, + scoreStats: data.scoreStats, + }); + setLoading(false); + } + }, [data]); + + const handleLeaderboardOpen = (isExpanded: boolean) => { + if (!isExpanded) { + setLeaderboardDropdownData(undefined); + } + + setLoading(true); + setIsLeaderboardExpanded(isExpanded); + }; /** * Set the base score @@ -59,6 +122,7 @@ export default function Score({ leaderboard, beatSaverMap, score, settings }: Pr */ useEffect(() => { setIsLeaderboardExpanded(false); + setLeaderboardDropdownData(undefined); }, [score]); const accuracy = (baseScore / leaderboard.maxScore) * 100; @@ -81,7 +145,9 @@ export default function Score({ leaderboard, beatSaverMap, score, settings }: Pr beatSaverMap={beatSaverMap} score={score} alwaysSingleLine={isMobile} - setIsLeaderboardExpanded={setIsLeaderboardExpanded} + setIsLeaderboardExpanded={(isExpanded: boolean) => { + handleLeaderboardOpen(isExpanded); + }} updateScore={score => { setBaseScore(score.score); }} @@ -98,7 +164,7 @@ export default function Score({ leaderboard, beatSaverMap, score, settings }: Pr {/* Leaderboard */} - {isLeaderboardExpanded && ( + {isLeaderboardExpanded && leaderboardDropdownData && !loading && ( + {leaderboardDropdownData.scoreStats && ( +
+ +
+ )} +