add score "edit" mode
Some checks are pending
Deploy Website / deploy (push) Waiting to run
Deploy Backend / deploy (push) Successful in 2m32s

This commit is contained in:
Lee 2024-10-12 07:21:55 +01:00
parent f26b997fbb
commit 98e8273c07
12 changed files with 328 additions and 51 deletions

BIN
bun.lockb

Binary file not shown.

@ -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<ScoreSaberPlayerSearchToken | undefined> {
public async searchPlayers(query: string): Promise<ScoreSaberPlayerSearchToken | undefined> {
const before = performance.now();
this.log(`Searching for players matching "${query}"...`);
const results = await this.fetch<ScoreSaberPlayerSearchToken>(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<ScoreSaberPlayerToken | undefined> {
public async lookupPlayer(playerId: string): Promise<ScoreSaberPlayerToken | undefined> {
const before = performance.now();
this.log(`Looking up player "${playerId}"...`);
const token = await this.fetch<ScoreSaberPlayerToken>(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<ScoreSaberPlayersPageToken | undefined> {
public async lookupPlayers(page: number): Promise<ScoreSaberPlayersPageToken | undefined> {
const before = performance.now();
this.log(`Looking up players on page "${page}"...`);
const response = await this.fetch<ScoreSaberPlayersPageToken>(
@ -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<ScoreSaberPlayersPageToken | undefined> {
public async lookupPlayersByCountry(page: number, country: string): Promise<ScoreSaberPlayersPageToken | undefined> {
const before = performance.now();
this.log(`Looking up players on page "${page}" for country "${country}"...`);
const response = await this.fetch<ScoreSaberPlayersPageToken>(
@ -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<ScoreSaberLeaderboardToken | undefined> {
public async lookupLeaderboard(leaderboardId: string): Promise<ScoreSaberLeaderboardToken | undefined> {
const before = performance.now();
this.log(`Looking up leaderboard "${leaderboardId}"...`);
const response = await this.fetch<ScoreSaberLeaderboardToken>(
@ -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<ScoreSaberLeaderboardScoresPageToken | undefined> {
@ -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();

@ -0,0 +1,14 @@
export class CurvePoint {
constructor(
private acc: number,
private multiplier: number
) {}
getAcc(): number {
return this.acc;
}
getMultiplier(): number {
return this.multiplier;
}
}

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

@ -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",

@ -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<SetStateAction<boolean>>;
};
export default function LeaderboardButton({ isLeaderboardExpanded, setIsLeaderboardExpanded }: Props) {
return (
<div className="pr-2 flex items-center justify-center h-full cursor-default">
<Button
className="p-0 hover:bg-transparent"
variant="ghost"
onClick={() => setIsLeaderboardExpanded(!isLeaderboardExpanded)}
>
<ArrowDownIcon
className={clsx("w-6 h-6 transition-all transform-gpu", isLeaderboardExpanded ? "" : "rotate-180")}
/>
</Button>
</div>
);
}

@ -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<SetStateAction<boolean>>;
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({
<YouTubeLogo />
</ScoreButton>
</div>
{isLeaderboardExpanded != undefined && setIsLeaderboardExpanded != undefined && (
<LeaderboardButton
isLeaderboardExpanded={isLeaderboardExpanded}
setIsLeaderboardExpanded={setIsLeaderboardExpanded}
/>
<div className={`flex ${alwaysSingleLine ? "flex-row" : "flex-row lg:flex-col"} items-center justify-center`}>
{/* Edit score button */}
{score && leaderboard && setScore && (
<ScoreEditorButton score={score} leaderboard={leaderboard} setScore={setScore} />
)}
{/* View Leaderboard button */}
{leaderboardExpanded != undefined && setIsLeaderboardExpanded != undefined && (
<div className="pr-2 flex items-center justify-center cursor-default">
<Button
className="p-0 hover:bg-transparent"
variant="ghost"
onClick={() => {
setLeaderboardExpanded(!leaderboardExpanded);
setIsLeaderboardExpanded?.(!leaderboardExpanded);
}}
>
<ArrowDownIcon
className={clsx("w-6 h-6 transition-all transform-gpu", leaderboardExpanded ? "" : "rotate-180")}
/>
</Button>
</div>
)}
</div>
</div>
);
}

@ -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 (
<div className="pr-2 flex items-center justify-center cursor-default relative">
<Popover
onOpenChange={open => {
setIsScoreEditMode(open);
handleSliderReset();
}}
>
<PopoverTrigger>
<Button className="p-0 hover:bg-transparent" variant="ghost">
<CogIcon className={clsx("w-6 h-6 transition-all transform-gpu", isScoreEditMode ? "" : "rotate-180")} />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" side="left">
<div className="p-3 flex flex-col gap-2">
<p className="text-sm font-medium mb-1">Accuracy Changer</p>
{/* Accuracy Slider */}
<Slider className="w-full" min={accuracy} max={100} step={0.01} onValueChange={handleSliderChange} />
<Tooltip display={<p>Set accuracy to score accuracy</p>}>
{/* Reset Button (Changes accuracy back to the original accuracy) */}
<Button onClick={handleSliderReset} className="absolute top-1 right-1 p-1" variant="ghost">
<ResetIcon className="w-4 h-4" />
</Button>
</Tooltip>
</div>
</PopoverContent>
</Popover>
</div>
);
}

@ -73,7 +73,7 @@ const badges: ScoreBadge[] = [
{
name: "Score",
create: (score: ScoreSaberScoreToken) => {
return `${formatNumberWithCommas(score.baseScore)}`;
return `${formatNumberWithCommas(Number(score.baseScore.toFixed(0)))}`;
},
},
{

@ -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<number>(score.baseScore);
const [beatSaverMap, setBeatSaverMap] = useState<BeatSaverMap | undefined>();
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 (
<div className="pb-2 pt-2">
<div
className={`grid w-full gap-2 lg:gap-0 first:pt-0 last:pb-0 grid-cols-[20px 1fr_1fr] lg:grid-cols-[0.5fr_4fr_1fr_300px]`}
>
{/* Score Info */}
<div className="grid w-full gap-2 lg:gap-0 grid-cols-[20px 1fr_1fr] lg:grid-cols-[0.5fr_4fr_1fr_300px]">
<ScoreRankInfo score={score} leaderboard={leaderboard} />
<ScoreSongInfo leaderboard={leaderboard} beatSaverMap={beatSaverMap} />
<ScoreButtons
leaderboard={leaderboard}
beatSaverMap={beatSaverMap}
isLeaderboardExpanded={isLeaderboardExpanded}
score={score}
setIsLeaderboardExpanded={setIsLeaderboardExpanded}
setScore={score => {
setBaseScore(score.baseScore);
}}
/>
<ScoreStats
score={{
...score,
baseScore,
pp: pp ? pp : score.pp,
}}
leaderboard={leaderboard}
/>
<ScoreStats score={score} leaderboard={leaderboard} />
</div>
{/* Leaderboard */}
{isLeaderboardExpanded && (
<motion.div
initial={{ opacity: 0, y: -50 }}

@ -0,0 +1,32 @@
"use client";
import * as React from "react";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import { cn } from "@/common/utils";
const Popover = PopoverPrimitive.Root;
const PopoverTrigger = PopoverPrimitive.Trigger;
const PopoverAnchor = PopoverPrimitive.Anchor;
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
));
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };

@ -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<typeof SliderPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
>(({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn("relative flex w-full touch-none select-none items-center", className)}
{...props}
>
<SliderPrimitive.Track className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-primary/20">
<SliderPrimitive.Range className="absolute h-full bg-primary" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-4 w-4 rounded-full border border-primary/50 bg-background shadow transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" />
</SliderPrimitive.Root>
));
Slider.displayName = SliderPrimitive.Root.displayName;
export { Slider };