diff --git a/src/app/player/[id]/page.tsx b/src/app/player/[id]/page.tsx index 44958c2..328d76f 100644 --- a/src/app/player/[id]/page.tsx +++ b/src/app/player/[id]/page.tsx @@ -1,35 +1,17 @@ "use client"; -import Avatar from "@/components/Avatar"; import Card from "@/components/Card"; import Container from "@/components/Container"; -import Label from "@/components/Label"; -import Pagination from "@/components/Pagination"; -import PlayerChart from "@/components/PlayerChart"; -import Score from "@/components/Score"; +import Error from "@/components/Error"; +import PlayerInfo from "@/components/PlayerInfo"; +import Scores from "@/components/Scores"; import { Spinner } from "@/components/Spinner"; import { ScoresaberPlayer } from "@/schemas/scoresaber/player"; -import { ScoresaberPlayerScore } from "@/schemas/scoresaber/playerScore"; -import { usePlayerScoresStore } from "@/store/playerScoresStore"; import { useSettingsStore } from "@/store/settingsStore"; import { SortType, SortTypes } from "@/types/SortTypes"; -import { formatNumber } from "@/utils/number"; -import { fetchScores, getPlayerInfo } from "@/utils/scoresaber/api"; -import useStore from "@/utils/useStore"; -import { GlobeAsiaAustraliaIcon, HomeIcon } from "@heroicons/react/20/solid"; -import Image from "next/image"; -import { useRouter, useSearchParams } from "next/navigation"; -import { useCallback, useEffect, useRef, useState } from "react"; -import ReactCountryFlag from "react-country-flag"; -import { toast } from "react-toastify"; - -type PageInfo = { - loading: boolean; - page: number; - totalPages: number; - sortType: SortType; - scores: ScoresaberPlayerScore[]; -}; +import { getPlayerInfo } from "@/utils/scoresaber/api"; +import { useSearchParams } from "next/navigation"; +import { useEffect, useState } from "react"; type PlayerInfo = { loading: boolean; @@ -41,11 +23,7 @@ const DEFAULT_SORT_TYPE = SortTypes.top; export default function Player({ params }: { params: { id: string } }) { const [mounted, setMounted] = useState(false); - const settingsStore = useStore(useSettingsStore, (store) => store); - const playerScoreStore = useStore(usePlayerScoresStore, (store) => store); - const searchParams = useSearchParams(); - const router = useRouter(); let page; const pageString = searchParams.get("page"); @@ -72,99 +50,12 @@ export default function Player({ params }: { params: { id: string } }) { player: undefined, }); - const [scores, setScores] = useState({ - loading: true, - page: page, - totalPages: 1, - sortType: sortType, - scores: [], - }); - - const updateScoresPage = useCallback( - (sortType: SortType, page: any) => { - console.log("Switching page to", page); - fetchScores(params.id, page, sortType.value, 10).then( - (scoresResponse) => { - if (!scoresResponse) { - setError(true); - setErrorMessage("No Scores"); - setScores({ ...scores, loading: false }); - return; - } - setScores({ - ...scores, - scores: scoresResponse.scores, - totalPages: scoresResponse.pageInfo.totalPages, - loading: false, - page: page, - sortType: sortType, - }); - useSettingsStore.setState({ - lastUsedSortType: sortType, - }); - - if (page > 1) { - router.push( - `/player/${params.id}?page=${page}&sort=${sortType.value}`, - { - scroll: false, - }, - ); - } else { - router.push(`/player/${params.id}?sort=${sortType.value}`, { - scroll: false, - }); - } - }, - ); - }, - [params.id, router, scores], - ); - - const toastId: any = useRef(null); - - async function claimProfile() { - settingsStore?.setUserId(params.id); - settingsStore?.refreshProfile(); - - const reponse = await playerScoreStore?.addPlayer( - params.id, - (page, totalPages) => { - const autoClose = page == totalPages ? 5000 : false; - - if (page == 1) { - toastId.current = toast.info( - `Fetching scores ${page}/${totalPages}`, - { - autoClose: autoClose, - progress: page / totalPages, - }, - ); - } else { - toast.update(toastId.current, { - progress: page / totalPages, - render: `Fetching scores ${page}/${totalPages}`, - autoClose: autoClose, - }); - } - - console.log(`Fetching scores for ${params.id} (${page}/${totalPages})`); - }, - ); - if (reponse?.error) { - toast.error("Failed to claim profile"); - console.log(reponse.message); - return; - } - - toast.success("Successfully claimed profile"); - } - useEffect(() => { setMounted(true); if (!params.id) { setError(true); + setErrorMessage("No player id"); setPlayer({ ...player, loading: false }); return; } @@ -184,9 +75,8 @@ export default function Player({ params }: { params: { id: string } }) { return; } setPlayer({ ...player, player: playerResponse, loading: false }); - updateScoresPage(scores.sortType, 1); }); - }, [error, mounted, params.id, player, scores, updateScoresPage]); + }, [error, mounted, params.id, player]); if (player.loading || error || !player.player) { return ( @@ -195,20 +85,10 @@ export default function Player({ params }: { params: { id: string } }) {
- {player.loading && } - - {error && ( -
-

{errorMessage}

- - Sad cat -
- )} +
+ {error && } + {!error && } +
@@ -222,147 +102,8 @@ export default function Player({ params }: { params: { id: string } }) { return (
- {/* Player Info */} - -
-
- {/* Avatar */} -
- -
- - {/* Settings Buttons */} -
- {settingsStore?.userId !== params.id && ( - - )} -
-
-
- {/* Name */} -

{playerData.name}

- -
- {/* Global Rank */} -
- -

#{playerData.rank}

-
- - {/* Country Rank */} -
- -

#{playerData.countryRank}

-
- - {/* PP */} -
-

{formatNumber(playerData.pp)}pp

-
-
- {/* Labels */} -
-
- - {/* Chart */} - -
-
-
- - {/* Scores */} - - {/* Sort */} -
-
- {Object.values(SortTypes).map((sortType) => { - return ( - - ); - })} -
-
- -
- {scores.loading ? ( -
- -
- ) : ( -
- {!scores.loading && scores.scores.length == 0 ? ( -

{errorMessage}

- ) : ( - scores.scores.map((scoreData, id) => { - const { score, leaderboard } = scoreData; - - return ( - - ); - }) - )} -
- )} -
- - {/* Pagination */} -
-
- { - updateScoresPage(scores.sortType, page); - }} - /> -
-
-
+ +
); diff --git a/src/components/Error.tsx b/src/components/Error.tsx new file mode 100644 index 0000000..80da99b --- /dev/null +++ b/src/components/Error.tsx @@ -0,0 +1,24 @@ +import Image from "next/image"; + +type ErrorProps = { + errorMessage?: string; +}; + +export default function Error({ errorMessage }: ErrorProps) { + return ( +
+
+

Something went wrong!

+

{errorMessage}

+ + Sad cat +
+
+ ); +} diff --git a/src/components/Label.tsx b/src/components/Label.tsx index f7e0129..2b1a888 100644 --- a/src/components/Label.tsx +++ b/src/components/Label.tsx @@ -2,7 +2,7 @@ import clsx from "clsx"; type LabelProps = { title: string; - value: string; + value: any; className?: string; }; diff --git a/src/components/PlayerInfo.tsx b/src/components/PlayerInfo.tsx new file mode 100644 index 0000000..7f47a56 --- /dev/null +++ b/src/components/PlayerInfo.tsx @@ -0,0 +1,163 @@ +import { ScoresaberPlayer } from "@/schemas/scoresaber/player"; +import { usePlayerScoresStore } from "@/store/playerScoresStore"; +import { useSettingsStore } from "@/store/settingsStore"; +import { formatNumber } from "@/utils/number"; +import { calcPpBoundary, getHighestPpPlay } from "@/utils/scoresaber/scores"; +import { GlobeAsiaAustraliaIcon, HomeIcon } from "@heroicons/react/20/solid"; +import { useRef } from "react"; +import ReactCountryFlag from "react-country-flag"; +import { toast } from "react-toastify"; +import { useStore } from "zustand"; +import Avatar from "./Avatar"; +import Card from "./Card"; +import Label from "./Label"; +import PlayerChart from "./PlayerChart"; + +type PlayerInfoProps = { + playerData: ScoresaberPlayer; +}; + +export default function PlayerInfo({ playerData }: PlayerInfoProps) { + const playerId = playerData.id; + const settingsStore = useStore(useSettingsStore, (store) => store); + const playerScoreStore = useStore(usePlayerScoresStore, (store) => store); + + // Whether we have scores for this player in the local database + const hasLocalScores = playerScoreStore?.exists(playerId); + + const toastId: any = useRef(null); + + async function claimProfile() { + settingsStore?.setUserId(playerId); + settingsStore?.refreshProfile(); + + const reponse = await playerScoreStore?.addPlayer( + playerId, + (page, totalPages) => { + const autoClose = page == totalPages ? 5000 : false; + + if (page == 1) { + toastId.current = toast.info( + `Fetching scores ${page}/${totalPages}`, + { + autoClose: autoClose, + progress: page / totalPages, + }, + ); + } else { + toast.update(toastId.current, { + progress: page / totalPages, + render: `Fetching scores ${page}/${totalPages}`, + autoClose: autoClose, + }); + } + + console.log(`Fetching scores for ${playerId} (${page}/${totalPages})`); + }, + ); + if (reponse?.error) { + toast.error("Failed to claim profile"); + console.log(reponse.message); + return; + } + + toast.success("Successfully claimed profile"); + } + + return ( + + {/* Player Info */} +
+
+ {/* Avatar */} +
+ +
+ + {/* Settings Buttons */} +
+ {settingsStore?.userId !== playerId && ( + + )} +
+
+
+ {/* Name */} +

{playerData.name}

+ +
+ {/* Global Rank */} +
+ +

#{playerData.rank}

+
+ + {/* Country Rank */} +
+ +

#{playerData.countryRank}

+
+ + {/* PP */} +
+

{formatNumber(playerData.pp)}pp

+
+
+ {/* Labels */} +
+
+ + {/* Chart */} + +
+
+
+ ); +} diff --git a/src/components/Scores.tsx b/src/components/Scores.tsx new file mode 100644 index 0000000..37142e5 --- /dev/null +++ b/src/components/Scores.tsx @@ -0,0 +1,150 @@ +import { ScoresaberPlayer } from "@/schemas/scoresaber/player"; +import { ScoresaberPlayerScore } from "@/schemas/scoresaber/playerScore"; +import { useSettingsStore } from "@/store/settingsStore"; +import { SortType, SortTypes } from "@/types/SortTypes"; +import { fetchScores } from "@/utils/scoresaber/api"; +import { useRouter } from "next/navigation"; +import { useCallback, useEffect, useState } from "react"; +import Card from "./Card"; +import Pagination from "./Pagination"; +import Score from "./Score"; +import { Spinner } from "./Spinner"; + +type PageInfo = { + loading: boolean; + page: number; + totalPages: number; + sortType: SortType; + scores: ScoresaberPlayerScore[]; +}; + +type ScoresProps = { + playerData: ScoresaberPlayer; + page: number; + sortType: SortType; +}; + +export default function Scores({ playerData, page, sortType }: ScoresProps) { + const playerId = playerData.id; + + const router = useRouter(); + + const [error, setError] = useState(false); + const [errorMessage, setErrorMessage] = useState(""); + + const [scores, setScores] = useState({ + loading: true, + page: page, + totalPages: 1, + sortType: sortType, + scores: [], + }); + + const updateScoresPage = useCallback( + (sortType: SortType, page: any) => { + console.log("Switching page to", page); + fetchScores(playerId, page, sortType.value, 10).then((scoresResponse) => { + if (!scoresResponse) { + setError(true); + setErrorMessage("No Scores"); + setScores({ ...scores, loading: false }); + return; + } + setScores({ + ...scores, + scores: scoresResponse.scores, + totalPages: scoresResponse.pageInfo.totalPages, + loading: false, + page: page, + sortType: sortType, + }); + useSettingsStore.setState({ + lastUsedSortType: sortType, + }); + + if (page > 1) { + router.push( + `/player/${playerId}?page=${page}&sort=${sortType.value}`, + { + scroll: false, + }, + ); + } else { + router.push(`/player/${playerId}?sort=${sortType.value}`, { + scroll: false, + }); + } + }); + }, + [playerId, router, scores], + ); + + useEffect(() => { + if (!scores.loading || error) return; + + updateScoresPage(scores.sortType, scores.page); + }, [error, playerId, updateScoresPage, scores]); + + return ( + + {/* Sort */} +
+
+ {Object.values(SortTypes).map((sortType) => { + return ( + + ); + })} +
+
+ +
+ {scores.loading ? ( +
+ +
+ ) : ( +
+ {!scores.loading && scores.scores.length == 0 ? ( +

{errorMessage}

+ ) : ( + scores.scores.map((scoreData, id) => { + const { score, leaderboard } = scoreData; + + return ( + + ); + }) + )} +
+ )} +
+ + {/* Pagination */} +
+
+ { + updateScoresPage(scores.sortType, page); + }} + /> +
+
+
+ ); +} diff --git a/src/utils/scoresaber/scores.ts b/src/utils/scoresaber/scores.ts new file mode 100644 index 0000000..5feeb61 --- /dev/null +++ b/src/utils/scoresaber/scores.ts @@ -0,0 +1,170 @@ +// Yoinked from https://github.com/Shurdoof/pp-calculator/blob/c24b5ca452119339928831d74e6d603fb17fd5ef/src/lib/pp/calculator.ts +// Thank for for this I have no fucking idea what the maths is doing but it works! + +import { usePlayerScoresStore } from "@/store/playerScoresStore"; + +export const WEIGHT_COEFFICIENT = 0.965; + +const starMultiplier = 42.11; +const ppCurve = [ + [1, 7], + [0.999, 6.24], + [0.9975, 5.31], + [0.995, 4.14], + [0.9925, 3.31], + [0.99, 2.73], + [0.9875, 2.31], + [0.985, 2.0], + [0.9825, 1.775], + [0.98, 1.625], + [0.9775, 1.515], + [0.975, 1.43], + [0.9725, 1.36], + [0.97, 1.3], + [0.965, 1.195], + [0.96, 1.115], + [0.955, 1.05], + [0.95, 1], + [0.94, 0.94], + [0.93, 0.885], + [0.92, 0.835], + [0.91, 0.79], + [0.9, 0.75], + [0.875, 0.655], + [0.85, 0.57], + [0.825, 0.51], + [0.8, 0.47], + [0.75, 0.4], + [0.7, 0.34], + [0.65, 0.29], + [0.6, 0.25], + [0.0, 0.0], +]; + +function clamp(value: number, min: number, max: number) { + if (min !== null && value < min) { + return min; + } + + if (max !== null && value > max) { + return max; + } + + return value; +} + +function lerp(v0: number, v1: number, t: number) { + return v0 + t * (v1 - v0); +} + +function calculatePPModifier(c1: Array, c2: Array, acc: number) { + const distance = (c2[0] - acc) / (c2[0] - c1[0]); + return lerp(c2[1], c1[1], distance); +} + +function findPPModifier(acc: number, curve: Array) { + acc = clamp(acc, 0, 100) / 100; + + let prev = curve[1]; + for (const item of curve) { + if (item[0] <= acc) { + return calculatePPModifier(item, prev, acc); + } + prev = item; + } +} + +export function getScoreSaberPP(acc: number, stars: number) { + const ppValue = stars * starMultiplier; + const modifier = findPPModifier(acc * 100, ppCurve); + if (!modifier) return undefined; + + const finalPP = modifier * ppValue; + return { + pp: Number.isNaN(finalPP) ? undefined : finalPP, + }; +} + +export function getTotalPpFromSortedPps(ppArray: Array, startIdx = 0) { + return ppArray.reduce( + (cum, pp, idx) => cum + Math.pow(WEIGHT_COEFFICIENT, idx + startIdx) * pp, + 0, + ); +} + +function calcRawPpAtIdx( + bottomScores: Array, + idx: number, + expected: number, +) { + const oldBottomPp = getTotalPpFromSortedPps(bottomScores, idx); + const newBottomPp = getTotalPpFromSortedPps(bottomScores, idx + 1); + + // 0.965^idx * rawPpToFind = expected + oldBottomPp - newBottomPp; + // rawPpToFind = (expected + oldBottomPp - newBottomPp) / 0.965^idx; + return ( + (expected + oldBottomPp - newBottomPp) / Math.pow(WEIGHT_COEFFICIENT, idx) + ); +} + +/** + * Gets the amount of raw pp needed to gain the expected pp + * + * @param playerId the player id + * @param expectedPp the expected pp + * @returns the pp boundary (+ per raw pp) + */ +export function calcPpBoundary(playerId: string, expectedPp = 1) { + const rankedScores = usePlayerScoresStore + .getState() + .players.find((p) => p.id === playerId) + ?.scores?.filter((s) => s.score.pp !== undefined); + if (!rankedScores) return null; + + const rankedScorePps = rankedScores + .map((s) => s.score.pp) + .sort((a, b) => b - a); + + let idx = rankedScorePps.length - 1; + + while (idx >= 0) { + const bottomSlice = rankedScorePps.slice(idx); + const bottomPp = getTotalPpFromSortedPps(bottomSlice, idx); + + bottomSlice.unshift(rankedScorePps[idx]); + const modifiedBottomPp = getTotalPpFromSortedPps(bottomSlice, idx); + const diff = modifiedBottomPp - bottomPp; + + if (diff > expectedPp) { + const ppBoundary = calcRawPpAtIdx( + rankedScorePps.slice(idx + 1), + idx + 1, + expectedPp, + ); + return ppBoundary; + } + + idx--; + } + return calcRawPpAtIdx(rankedScorePps, 0, expectedPp); +} + +/** + * Get the highest pp play of a player + * + * @param playerId the player id + * @returns the highest pp play + */ +export function getHighestPpPlay(playerId: string) { + const rankedScores = usePlayerScoresStore + .getState() + .players.find((p) => p.id === playerId) + ?.scores?.filter((s) => s.score.pp !== undefined); + if (!rankedScores) return null; + + const rankedScorePps = rankedScores + .map((s) => s.score.pp) + .sort((a, b) => b - a); + + return rankedScorePps[0]; +}