diff --git a/bun.lockb b/bun.lockb index f38cf1d..c8dc299 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/projects/common/src/service/impl/scoresaber.ts b/projects/common/src/service/impl/scoresaber.ts index 27c8295..3f3640f 100644 --- a/projects/common/src/service/impl/scoresaber.ts +++ b/projects/common/src/service/impl/scoresaber.ts @@ -6,6 +6,8 @@ import { ScoreSort } from "../../types/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"; +import { clamp, lerp } from "../../utils/math-utils"; +import { CurvePoint } from "../../utils/curve-point"; const API_BASE = "https://scoresaber.com/api"; @@ -24,7 +26,49 @@ const LOOKUP_PLAYER_SCORES_ENDPOINT = `${API_BASE}/player/:id/scores?limit=:limi const LOOKUP_LEADERBOARD_ENDPOINT = `${API_BASE}/leaderboard/by-id/:id/info`; const LOOKUP_LEADERBOARD_SCORES_ENDPOINT = `${API_BASE}/leaderboard/by-id/:id/scores?page=:page`; +const STAR_MULTIPLIER = 42.117208413; + class ScoreSaberService extends Service { + private curvePoints = [ + new CurvePoint(0, 0), + new CurvePoint(0.6, 0.18223233667439062), + new CurvePoint(0.65, 0.5866010012767576), + new CurvePoint(0.7, 0.6125565959114954), + new CurvePoint(0.75, 0.6451808210101443), + new CurvePoint(0.8, 0.6872268862950283), + new CurvePoint(0.825, 0.7150465663454271), + new CurvePoint(0.85, 0.7462290664143185), + new CurvePoint(0.875, 0.7816934560296046), + new CurvePoint(0.9, 0.825756123560842), + new CurvePoint(0.91, 0.8488375988124467), + new CurvePoint(0.92, 0.8728710341448851), + new CurvePoint(0.93, 0.9039994071865736), + new CurvePoint(0.94, 0.9417362980580238), + new CurvePoint(0.95, 1), + new CurvePoint(0.955, 1.0388633331418984), + new CurvePoint(0.96, 1.0871883573850478), + new CurvePoint(0.965, 1.1552120359501035), + new CurvePoint(0.97, 1.2485807759957321), + new CurvePoint(0.9725, 1.3090333065057616), + new CurvePoint(0.975, 1.3807102743105126), + new CurvePoint(0.9775, 1.4664726399289512), + new CurvePoint(0.98, 1.5702410055532239), + new CurvePoint(0.9825, 1.697536248647543), + new CurvePoint(0.985, 1.8563887693647105), + new CurvePoint(0.9875, 2.058947159052738), + new CurvePoint(0.99, 2.324506282149922), + new CurvePoint(0.99125, 2.4902905794106913), + new CurvePoint(0.9925, 2.685667856592722), + new CurvePoint(0.99375, 2.9190155639254955), + new CurvePoint(0.995, 3.2022017597337955), + new CurvePoint(0.99625, 3.5526145337555373), + new CurvePoint(0.9975, 3.996793606763322), + new CurvePoint(0.99825, 4.325027383589547), + new CurvePoint(0.999, 4.715470646416203), + new CurvePoint(0.9995, 5.019543595874787), + new CurvePoint(1, 5.367394282890631), + ]; + constructor() { super("ScoreSaber"); } @@ -35,7 +79,7 @@ class ScoreSaberService extends Service { * @param query the query to search for * @returns the players that match the query, or undefined if no players were found */ - async searchPlayers(query: string): Promise { + public async searchPlayers(query: string): Promise { const before = performance.now(); this.log(`Searching for players matching "${query}"...`); const results = await this.fetch(SEARCH_PLAYERS_ENDPOINT.replace(":query", query)); @@ -56,7 +100,7 @@ class ScoreSaberService extends Service { * @param playerId the ID of the player to look up * @returns the player that matches the ID, or undefined */ - async lookupPlayer(playerId: string): Promise { + public async lookupPlayer(playerId: string): Promise { const before = performance.now(); this.log(`Looking up player "${playerId}"...`); const token = await this.fetch(LOOKUP_PLAYER_ENDPOINT.replace(":id", playerId)); @@ -73,7 +117,7 @@ class ScoreSaberService extends Service { * @param page the page to get players for * @returns the players on the page, or undefined */ - async lookupPlayers(page: number): Promise { + public async lookupPlayers(page: number): Promise { const before = performance.now(); this.log(`Looking up players on page "${page}"...`); const response = await this.fetch( @@ -93,7 +137,7 @@ class ScoreSaberService extends Service { * @param country the country to get players for * @returns the players on the page, or undefined */ - async lookupPlayersByCountry(page: number, country: string): Promise { + public async lookupPlayersByCountry(page: number, country: string): Promise { const before = performance.now(); this.log(`Looking up players on page "${page}" for country "${country}"...`); const response = await this.fetch( @@ -115,7 +159,7 @@ class ScoreSaberService extends Service { * @param search * @returns the scores of the player, or undefined */ - async lookupPlayerScores({ + public async lookupPlayerScores({ playerId, sort, page, @@ -151,7 +195,7 @@ class ScoreSaberService extends Service { * * @param leaderboardId the ID of the leaderboard to look up */ - async lookupLeaderboard(leaderboardId: string): Promise { + public async lookupLeaderboard(leaderboardId: string): Promise { const before = performance.now(); this.log(`Looking up leaderboard "${leaderboardId}"...`); const response = await this.fetch( @@ -171,7 +215,7 @@ class ScoreSaberService extends Service { * @param page the page to get scores for * @returns the scores of the leaderboard, or undefined */ - async lookupLeaderboardScores( + public async lookupLeaderboardScores( leaderboardId: string, page: number ): Promise { @@ -188,6 +232,53 @@ class ScoreSaberService extends Service { ); return response; } + + /** + * Gets the modifier for the given accuracy. + * + * @param accuracy The accuracy. + * @return The modifier. + */ + public getModifier(accuracy: number): number { + accuracy = clamp(accuracy, 0, 100) / 100; // Normalize accuracy to a range of [0, 1] + + if (accuracy <= 0) { + return 0; + } + + if (accuracy >= 1) { + return this.curvePoints[this.curvePoints.length - 1].getMultiplier(); + } + + for (let i = 0; i < this.curvePoints.length - 1; i++) { + const point = this.curvePoints[i]; + const nextPoint = this.curvePoints[i + 1]; + if (accuracy >= point.getAcc() && accuracy <= nextPoint.getAcc()) { + return lerp( + point.getMultiplier(), + nextPoint.getMultiplier(), + (accuracy - point.getAcc()) / (nextPoint.getAcc() - point.getAcc()) + ); + } + } + + return 0; + } + + /** + * Gets the performance points (PP) based on stars and accuracy. + * + * @param stars The star count. + * @param accuracy The accuracy. + * @returns The calculated PP. + */ + public getPp(stars: number, accuracy: number): number { + if (accuracy <= 1) { + accuracy *= 100; // Convert the accuracy to a percentage + } + const pp = stars * STAR_MULTIPLIER; // Calculate base PP value + return this.getModifier(accuracy) * pp; // Calculate and return final PP value + } } export const scoresaberService = new ScoreSaberService(); diff --git a/projects/common/src/utils/curve-point.ts b/projects/common/src/utils/curve-point.ts new file mode 100644 index 0000000..bc05e78 --- /dev/null +++ b/projects/common/src/utils/curve-point.ts @@ -0,0 +1,14 @@ +export class CurvePoint { + constructor( + private acc: number, + private multiplier: number + ) {} + + getAcc(): number { + return this.acc; + } + + getMultiplier(): number { + return this.multiplier; + } +} diff --git a/projects/common/src/utils/math-utils.ts b/projects/common/src/utils/math-utils.ts new file mode 100644 index 0000000..29ff53a --- /dev/null +++ b/projects/common/src/utils/math-utils.ts @@ -0,0 +1,29 @@ +/** + * Clamps a value between two values. + * + * @param value the value + * @param min the minimum + * @param max the maximum + */ +export function clamp(value: number, min: number, max: number) { + if (min !== null && value < min) { + return min; + } + + if (max !== null && value > max) { + return max; + } + + return value; +} + +/** + * Lerps between two values. + * + * @param v0 value 0 + * @param v1 value 1 + * @param t the amount to lerp + */ +export function lerp(v0: number, v1: number, t: number) { + return v0 + t * (v1 - v0); +} diff --git a/projects/website/package.json b/projects/website/package.json index e57194f..1bee849 100644 --- a/projects/website/package.json +++ b/projects/website/package.json @@ -15,7 +15,9 @@ "@radix-ui/react-avatar": "^1.1.0", "@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-label": "^2.1.0", + "@radix-ui/react-popover": "^1.1.2", "@radix-ui/react-scroll-area": "^1.1.0", + "@radix-ui/react-slider": "^1.2.1", "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-toast": "^1.2.1", "@radix-ui/react-tooltip": "^1.1.2", diff --git a/projects/website/src/components/score/leaderboard-button.tsx b/projects/website/src/components/score/leaderboard-button.tsx deleted file mode 100644 index 6e2d1fd..0000000 --- a/projects/website/src/components/score/leaderboard-button.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { Button } from "@/components/ui/button"; -import { ArrowDownIcon } from "@heroicons/react/24/solid"; -import clsx from "clsx"; -import { Dispatch, SetStateAction } from "react"; - -type Props = { - isLeaderboardExpanded: boolean; - setIsLeaderboardExpanded: Dispatch>; -}; - -export default function LeaderboardButton({ isLeaderboardExpanded, setIsLeaderboardExpanded }: Props) { - return ( -
- -
- ); -} diff --git a/projects/website/src/components/score/score-buttons.tsx b/projects/website/src/components/score/score-buttons.tsx index 1a214ee..8a547d7 100644 --- a/projects/website/src/components/score/score-buttons.tsx +++ b/projects/website/src/components/score/score-buttons.tsx @@ -5,27 +5,34 @@ import { songNameToYouTubeLink } from "@/common/youtube-utils"; import BeatSaverLogo from "@/components/logos/beatsaver-logo"; import YouTubeLogo from "@/components/logos/youtube-logo"; import { useToast } from "@/hooks/use-toast"; -import { Dispatch, SetStateAction } from "react"; -import LeaderboardButton from "./leaderboard-button"; +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 { Button } from "@/components/ui/button"; +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"; type Props = { + score: ScoreSaberScoreToken; leaderboard: ScoreSaberLeaderboardToken; beatSaverMap?: BeatSaverMap; alwaysSingleLine?: boolean; - isLeaderboardExpanded?: boolean; - setIsLeaderboardExpanded?: Dispatch>; + setIsLeaderboardExpanded: (isExpanded: boolean) => void; + setScore: (score: ScoreSaberScoreToken) => void; }; export default function ScoreButtons({ + score, leaderboard, beatSaverMap, alwaysSingleLine, - isLeaderboardExpanded, setIsLeaderboardExpanded, + setScore, }: Props) { + const [leaderboardExpanded, setLeaderboardExpanded] = useState(false); const { toast } = useToast(); return ( @@ -74,12 +81,30 @@ export default function ScoreButtons({ - {isLeaderboardExpanded != undefined && setIsLeaderboardExpanded != undefined && ( - - )} +
+ {/* Edit score button */} + {score && leaderboard && setScore && ( + + )} + + {/* View Leaderboard button */} + {leaderboardExpanded != undefined && setIsLeaderboardExpanded != undefined && ( +
+ +
+ )} +
); } diff --git a/projects/website/src/components/score/score-editor-button.tsx b/projects/website/src/components/score/score-editor-button.tsx new file mode 100644 index 0000000..bdeeb8a --- /dev/null +++ b/projects/website/src/components/score/score-editor-button.tsx @@ -0,0 +1,70 @@ +import { Button } from "@/components/ui/button"; +import { CogIcon } from "@heroicons/react/24/solid"; +import clsx from "clsx"; +import ScoreSaberScoreToken from "@ssr/common/types/token/scoresaber/score-saber-score-token"; +import { useState } from "react"; +import { Slider } from "@/components/ui/slider"; +import ScoreSaberLeaderboardToken from "@ssr/common/types/token/scoresaber/score-saber-leaderboard-token"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { ResetIcon } from "@radix-ui/react-icons"; +import Tooltip from "@/components/tooltip"; + +type ScoreEditorButtonProps = { + score: ScoreSaberScoreToken; + leaderboard: ScoreSaberLeaderboardToken; + setScore: (score: ScoreSaberScoreToken) => void; +}; + +export default function ScoreEditorButton({ score, leaderboard, setScore }: 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 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; + setScore({ + ...score, + baseScore: newBaseScore, + }); + }; + + const handleSliderReset = () => { + setScore({ + ...score, + baseScore: (accuracy / 100) * maxScore, + }); + }; + + return ( +
+ { + setIsScoreEditMode(open); + handleSliderReset(); + }} + > + + + + +
+

Accuracy Changer

+ {/* Accuracy Slider */} + + + Set accuracy to score accuracy

}> + {/* Reset Button (Changes accuracy back to the original accuracy) */} + +
+
+
+
+
+ ); +} diff --git a/projects/website/src/components/score/score-stats.tsx b/projects/website/src/components/score/score-stats.tsx index bac0969..5191a5a 100644 --- a/projects/website/src/components/score/score-stats.tsx +++ b/projects/website/src/components/score/score-stats.tsx @@ -73,7 +73,7 @@ const badges: ScoreBadge[] = [ { name: "Score", create: (score: ScoreSaberScoreToken) => { - return `${formatNumberWithCommas(score.baseScore)}`; + return `${formatNumberWithCommas(Number(score.baseScore.toFixed(0)))}`; }, }, { diff --git a/projects/website/src/components/score/score.tsx b/projects/website/src/components/score/score.tsx index 8d74639..fa13631 100644 --- a/projects/website/src/components/score/score.tsx +++ b/projects/website/src/components/score/score.tsx @@ -12,6 +12,7 @@ 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 { getPageFromRank } from "@ssr/common/utils/utils"; +import { scoresaberService } from "@ssr/common/service/impl/scoresaber"; type Props = { /** @@ -27,33 +28,47 @@ type Props = { export default function Score({ player, playerScore }: Props) { const { score, leaderboard } = playerScore; + const [baseScore, setBaseScore] = useState(score.baseScore); const [beatSaverMap, setBeatSaverMap] = useState(); const [isLeaderboardExpanded, setIsLeaderboardExpanded] = useState(false); const fetchBeatSaverData = useCallback(async () => { - const beatSaverMap = await lookupBeatSaverMap(leaderboard.songHash); - setBeatSaverMap(beatSaverMap); + const beatSaverMapData = await lookupBeatSaverMap(leaderboard.songHash); + setBeatSaverMap(beatSaverMapData); }, [leaderboard.songHash]); useEffect(() => { fetchBeatSaverData(); }, [fetchBeatSaverData]); + const accuracy = (baseScore / leaderboard.maxScore) * 100; + const pp = scoresaberService.getPp(leaderboard.stars, accuracy); return (
-
+ {/* Score Info */} +
{ + setBaseScore(score.baseScore); + }} + /> + -
+ + {/* Leaderboard */} {isLeaderboardExpanded && ( , + React.ComponentPropsWithoutRef +>(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( + + + +)); +PopoverContent.displayName = PopoverPrimitive.Content.displayName; + +export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }; diff --git a/projects/website/src/components/ui/slider.tsx b/projects/website/src/components/ui/slider.tsx new file mode 100644 index 0000000..e1528bd --- /dev/null +++ b/projects/website/src/components/ui/slider.tsx @@ -0,0 +1,24 @@ +"use client"; + +import * as React from "react"; +import * as SliderPrimitive from "@radix-ui/react-slider"; +import { cn } from "@/common/utils"; + +const Slider = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + + +)); +Slider.displayName = SliderPrimitive.Root.displayName; + +export { Slider };