add score acc chart
Some checks failed
Deploy Backend / docker (ubuntu-latest) (push) Failing after 32s
Deploy Website / docker (ubuntu-latest) (push) Failing after 31s

This commit is contained in:
Lee 2024-10-23 17:44:55 +01:00
parent 0731d20edc
commit 90c57ad086
23 changed files with 387 additions and 19 deletions

@ -24,12 +24,12 @@ import { Config } from "@ssr/common/config";
import { SSRCache } from "@ssr/common/cache"; import { SSRCache } from "@ssr/common/cache";
import { fetchWithCache } from "../common/cache.util"; import { fetchWithCache } from "../common/cache.util";
import { PlayerDocument, PlayerModel } from "@ssr/common/model/player"; 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 { import {
AdditionalScoreData, AdditionalScoreData,
AdditionalScoreDataModel, AdditionalScoreDataModel,
} from "@ssr/common/model/additional-score-data/additional-score-data"; } 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({ const playerScoresCache = new SSRCache({
ttl: 1000 * 60, // 1 minute ttl: 1000 * 60, // 1 minute

@ -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<ScoreStatsToken | undefined> {
const before = performance.now();
this.log(`Looking score stats for "${scoreId}"...`);
const response = await this.fetch<ScoreStatsToken>(
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();

@ -1,5 +1,5 @@
import { BeatLeaderModifierToken } from "./beatleader-modifiers-token"; import { BeatLeaderModifierToken } from "./modifier/modifiers";
import { BeatLeaderModifierRatingToken } from "./beatleader-modifier-rating-token"; import { BeatLeaderModifierRatingToken } from "./modifier/modifier-rating";
export type BeatLeaderDifficultyToken = { export type BeatLeaderDifficultyToken = {
id: number; id: number;

@ -1,5 +1,5 @@
import { BeatLeaderSongToken } from "./beatleader-song-token"; import { BeatLeaderSongToken } from "./score/song";
import { BeatLeaderDifficultyToken } from "./beatleader-difficulty-token"; import { BeatLeaderDifficultyToken } from "./difficulty";
export type BeatLeaderLeaderboardToken = { export type BeatLeaderLeaderboardToken = {
id: string; id: string;

@ -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;
};

@ -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;
};

@ -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;
};

@ -0,0 +1,6 @@
export type ScoreStatsGraphTrackerToken = {
/**
* The accuracy graph data.
*/
graph: number[];
};

@ -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;
};

@ -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;
};

@ -1,7 +1,7 @@
import { BeatLeaderLeaderboardToken } from "./beatleader-leaderboard-token"; import { BeatLeaderLeaderboardToken } from "../leaderboard";
import { BeatLeaderScoreImprovementToken } from "./beatleader-score-improvement-token"; import { BeatLeaderScoreImprovementToken } from "./score-improvement";
import { BeatLeaderScoreOffsetsToken } from "./beatleader-score-offsets-token"; import { BeatLeaderScoreOffsetsToken } from "./score-offsets";
import { BeatLeaderPlayerToken } from "./beatleader-player-token"; import { BeatLeaderPlayerToken } from "../player";
export type BeatLeaderScoreToken = { export type BeatLeaderScoreToken = {
myScore: null; // ?? myScore: null; // ??

@ -1,5 +1,5 @@
import { connectWebSocket, WebsocketCallbacks } from "./websocket"; import { connectWebSocket, WebsocketCallbacks } from "./websocket";
import { BeatLeaderScoreToken } from "../types/token/beatleader/beatleader-score-token"; import { BeatLeaderScoreToken } from "../types/token/beatleader/score/score";
type BeatLeaderWebsocket = { type BeatLeaderWebsocket = {
/** /**

@ -2,11 +2,11 @@
import React, { useState } from "react"; import React, { useState } from "react";
import GenericChart, { DatasetConfig } from "@/components/chart/generic-chart"; 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 Card from "@/components/card";
import { DualRangeSlider } from "@/components/ui/dual-range-slider"; import { DualRangeSlider } from "@/components/ui/dual-range-slider";
import { useDebounce } from "@uidotdev/usehooks"; import { useDebounce } from "@uidotdev/usehooks";
import { scoresaberService } from "@ssr/common/service/impl/scoresaber";
import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard";
type Props = { type Props = {
/** /**

@ -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<string, (number | null)[]> = {};
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 <GenericChart labels={labels} datasetConfig={datasetConfig} histories={histories} />;
}

@ -9,7 +9,7 @@ import { useQuery } from "@tanstack/react-query";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { fetchLeaderboard } from "@ssr/common/utils/leaderboard.util"; import { fetchLeaderboard } from "@ssr/common/utils/leaderboard.util";
import LeaderboardScoresResponse from "@ssr/common/response/leaderboard-scores-response"; 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"; import Card from "@/components/card";
type LeaderboardDataProps = { type LeaderboardDataProps = {

@ -62,8 +62,7 @@ export default function LeaderboardScores({
selectedLeaderboardId + "", selectedLeaderboardId + "",
currentPage currentPage
), ),
staleTime: 30 * 1000, enabled: shouldFetch,
enabled: (shouldFetch && isLeaderboardPage) || !isLeaderboardPage,
}); });
/** /**

@ -15,6 +15,12 @@ import { BeatSaverMap } from "@ssr/common/model/beatsaver/map";
import { useIsMobile } from "@/hooks/use-is-mobile"; import { useIsMobile } from "@/hooks/use-is-mobile";
import Card from "@/components/card"; import Card from "@/components/card";
import { MapStats } from "@/components/score/map-stats"; 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 = { type Props = {
/** /**
@ -40,10 +46,67 @@ type Props = {
}; };
}; };
type LeaderboardDropdownData = {
/**
* The initial scores.
*/
scores?: LeaderboardScoresResponse<ScoreSaberScore, ScoreSaberLeaderboard>;
/**
* The score stats for this score,
*/
scoreStats?: ScoreStatsToken;
};
export default function Score({ leaderboard, beatSaverMap, score, settings }: Props) { export default function Score({ leaderboard, beatSaverMap, score, settings }: Props) {
const scoresPage = getPageFromRank(score.rank, 12);
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const [baseScore, setBaseScore] = useState<number>(score.score); const [baseScore, setBaseScore] = useState<number>(score.score);
const [isLeaderboardExpanded, setIsLeaderboardExpanded] = useState(false); const [isLeaderboardExpanded, setIsLeaderboardExpanded] = useState(false);
const [loading, setLoading] = useState(false);
const [leaderboardDropdownData, setLeaderboardDropdownData] = useState<LeaderboardDropdownData | undefined>();
const { data, isError, isLoading } = useQuery<LeaderboardDropdownData>({
queryKey: ["leaderboardDropdownData", leaderboard.id, score.id, isLeaderboardExpanded],
queryFn: async () => {
const scores = await fetchLeaderboardScores<ScoreSaberScore, ScoreSaberLeaderboard>(
"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 * Set the base score
@ -59,6 +122,7 @@ export default function Score({ leaderboard, beatSaverMap, score, settings }: Pr
*/ */
useEffect(() => { useEffect(() => {
setIsLeaderboardExpanded(false); setIsLeaderboardExpanded(false);
setLeaderboardDropdownData(undefined);
}, [score]); }, [score]);
const accuracy = (baseScore / leaderboard.maxScore) * 100; const accuracy = (baseScore / leaderboard.maxScore) * 100;
@ -81,7 +145,9 @@ export default function Score({ leaderboard, beatSaverMap, score, settings }: Pr
beatSaverMap={beatSaverMap} beatSaverMap={beatSaverMap}
score={score} score={score}
alwaysSingleLine={isMobile} alwaysSingleLine={isMobile}
setIsLeaderboardExpanded={setIsLeaderboardExpanded} setIsLeaderboardExpanded={(isExpanded: boolean) => {
handleLeaderboardOpen(isExpanded);
}}
updateScore={score => { updateScore={score => {
setBaseScore(score.score); setBaseScore(score.score);
}} }}
@ -98,7 +164,7 @@ export default function Score({ leaderboard, beatSaverMap, score, settings }: Pr
</div> </div>
{/* Leaderboard */} {/* Leaderboard */}
{isLeaderboardExpanded && ( {isLeaderboardExpanded && leaderboardDropdownData && !loading && (
<motion.div <motion.div
initial={{ opacity: 0, y: -50 }} initial={{ opacity: 0, y: -50 }}
exit={{ opacity: 0, y: -50 }} exit={{ opacity: 0, y: -50 }}
@ -108,8 +174,15 @@ export default function Score({ leaderboard, beatSaverMap, score, settings }: Pr
<Card className="flex gap-4 w-full relative border border-input"> <Card className="flex gap-4 w-full relative border border-input">
<MapStats leaderboard={leaderboard} beatSaver={beatSaverMap} /> <MapStats leaderboard={leaderboard} beatSaver={beatSaverMap} />
{leaderboardDropdownData.scoreStats && (
<div className="flex gap-2">
<PlayerScoreAccuracyChart scoreStats={leaderboardDropdownData.scoreStats} />
</div>
)}
<LeaderboardScores <LeaderboardScores
initialPage={getPageFromRank(score.rank, 12)} initialPage={scoresPage}
initialScores={leaderboardDropdownData.scores}
leaderboard={leaderboard} leaderboard={leaderboard}
disableUrlChanging disableUrlChanging
/> />