From 83834d165579fa2c5affd546202a8b5bc2b8e1fc Mon Sep 17 00:00:00 2001 From: zkldi <20380519+zkldi@users.noreply.github.com> Date: Fri, 14 Oct 2022 19:38:39 +0100 Subject: [PATCH 01/10] feat: first rival outline --- .../_game/_playtype/rivals/RivalsMainPage.tsx | 50 +++++++++++++++---- client/src/components/user/UGPTHeader.tsx | 2 +- .../components/util/modal/UserSelectModal.tsx | 2 +- 3 files changed, 41 insertions(+), 13 deletions(-) diff --git a/client/src/app/pages/dashboard/users/games/_game/_playtype/rivals/RivalsMainPage.tsx b/client/src/app/pages/dashboard/users/games/_game/_playtype/rivals/RivalsMainPage.tsx index c805c0b5d..b16b2bed1 100644 --- a/client/src/app/pages/dashboard/users/games/_game/_playtype/rivals/RivalsMainPage.tsx +++ b/client/src/app/pages/dashboard/users/games/_game/_playtype/rivals/RivalsMainPage.tsx @@ -32,17 +32,25 @@ export default function RivalsMainPage({
- - - Rival Score Feed + + + Goals & Milestones + + + + Compare - - Manage Rivals + + Activity - + - Pinned Charts + Manage Folders + + + + Change Rivals
@@ -50,16 +58,36 @@ export default function RivalsMainPage({ + activity + + + + + path="/dashboard/users/:userID/games/:game/:playtype/rivals/folders" + > + folders + + + path="/dashboard/users/:userID/games/:game/:playtype/rivals/targets" + > + goals milestones + + + + compare +
diff --git a/client/src/components/user/UGPTHeader.tsx b/client/src/components/user/UGPTHeader.tsx index 145a97dfb..f17e8d29d 100644 --- a/client/src/components/user/UGPTHeader.tsx +++ b/client/src/components/user/UGPTHeader.tsx @@ -104,7 +104,7 @@ export function UGPTBottomNav({ Rivals , - Targets + Goals & Milestones , ]; diff --git a/client/src/components/util/modal/UserSelectModal.tsx b/client/src/components/util/modal/UserSelectModal.tsx index 311484f6c..c1783fddb 100644 --- a/client/src/components/util/modal/UserSelectModal.tsx +++ b/client/src/components/util/modal/UserSelectModal.tsx @@ -67,7 +67,7 @@ export default function UserSelectModal({

{user.username}

{excludeSet.includes(user.id) ? ( ) : ( + + + )} + { if (rivals.length >= 5) { SendErrorToast(`Can't have more than 5 rivals!`); } else { setRivals([...rivals, user]); + + // if we're now at max rivals, exit. + if (rivals.length + 1 >= 5) { + setShow(false); + } } }} show={show} From 543d1298ff8d0f3c1583a91570445756cf1f56ae Mon Sep 17 00:00:00 2001 From: zkldi <20380519+zkldi@users.noreply.github.com> Date: Fri, 14 Oct 2022 21:03:29 +0100 Subject: [PATCH 03/10] feat: bump submodule scss --- client/src/_assets/metronic-scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/_assets/metronic-scss b/client/src/_assets/metronic-scss index f93aa62cf..d44b4be29 160000 --- a/client/src/_assets/metronic-scss +++ b/client/src/_assets/metronic-scss @@ -1 +1 @@ -Subproject commit f93aa62cfb31bb9f656443a5de6d798ce313b7e5 +Subproject commit d44b4be29500a7dfb668f6f5d9703c2f619d71f8 From d70e30e72718da853c45c8959d41f7886bc380bb Mon Sep 17 00:00:00 2001 From: zkldi <20380519+zkldi@users.noreply.github.com> Date: Fri, 14 Oct 2022 21:21:45 +0100 Subject: [PATCH 04/10] feat: rival chart dropdown + more stuff, score graph dropdowns, etc. --- .../_game/_playtype/rivals/RivalsMainPage.tsx | 8 +- ...sOverviewPage.tsx => RivalsManagePage.tsx} | 12 +- .../src/components/tables/cells/IndexCell.tsx | 1 + .../tables/components/DropdownRow.tsx | 7 +- .../tables/dropdowns/PBDropdown.tsx | 11 +- .../tables/dropdowns/ScoreDropdown.tsx | 11 +- .../components/DocumentComponent.tsx | 40 +++++++ .../dropdowns/components/PlayHistory.tsx | 2 +- .../dropdowns/components/RivalCompare.tsx | 64 +++++++++++ .../components/tables/headers/GameHeaders.tsx | 6 +- .../history-scores/HistoryScoreTable.tsx | 20 +++- .../tables/rivals/RivalChartTable.tsx | 105 ++++++++++++++++++ client/src/types/api-returns.ts | 5 + client/src/types/tables.ts | 7 ++ .../games/_game/_playtype/pbs/router.ts | 30 ++++- 15 files changed, 311 insertions(+), 18 deletions(-) rename client/src/app/pages/dashboard/users/games/_game/_playtype/rivals/{RivalsOverviewPage.tsx => RivalsManagePage.tsx} (93%) create mode 100644 client/src/components/tables/dropdowns/components/RivalCompare.tsx create mode 100644 client/src/components/tables/rivals/RivalChartTable.tsx diff --git a/client/src/app/pages/dashboard/users/games/_game/_playtype/rivals/RivalsMainPage.tsx b/client/src/app/pages/dashboard/users/games/_game/_playtype/rivals/RivalsMainPage.tsx index b16b2bed1..c14be4a02 100644 --- a/client/src/app/pages/dashboard/users/games/_game/_playtype/rivals/RivalsMainPage.tsx +++ b/client/src/app/pages/dashboard/users/games/_game/_playtype/rivals/RivalsMainPage.tsx @@ -4,10 +4,10 @@ import Icon from "components/util/Icon"; import SelectLinkButton from "components/util/SelectLinkButton"; import useUGPTBase from "components/util/useUGPTBase"; import React from "react"; -import { Row, Col } from "react-bootstrap"; -import { Switch, Route } from "react-router-dom"; +import { Col, Row } from "react-bootstrap"; +import { Route, Switch } from "react-router-dom"; import { FormatGame, Game, GetGameConfig, Playtype, PublicUserDocument } from "tachi-common"; -import RivalsOverviewPage from "./RivalsOverviewPage"; +import RivalsManagePage from "./RivalsManagePage"; export default function RivalsMainPage({ reqUser, @@ -65,7 +65,7 @@ export default function RivalsMainPage({ exact path="/dashboard/users/:userID/games/:game/:playtype/rivals/manage" > - + Looks like you're not signed in. How did you get to this page?; + } + return ( <> @@ -152,6 +158,10 @@ function RivalsOverviewPage({ if (res.success) { setCurrentRivals(rivals); + setSettings({ + ...settings, + rivals: rivals.map((e) => e.id), + }); } }} variant="primary" diff --git a/client/src/components/tables/cells/IndexCell.tsx b/client/src/components/tables/cells/IndexCell.tsx index 3a03008d1..6d62ac08f 100644 --- a/client/src/components/tables/cells/IndexCell.tsx +++ b/client/src/components/tables/cells/IndexCell.tsx @@ -1,6 +1,7 @@ import React from "react"; import { integer } from "tachi-common"; +// gold, silver, bronze const COLORS = ["rgba(212,175,55,0.2)", "rgba(192,192,192,0.2)", "rgba(139,69,19,0.2)"]; export default function IndexCell({ index }: { index: integer }) { diff --git a/client/src/components/tables/components/DropdownRow.tsx b/client/src/components/tables/components/DropdownRow.tsx index 5c4557c41..1fea2f404 100644 --- a/client/src/components/tables/components/DropdownRow.tsx +++ b/client/src/components/tables/components/DropdownRow.tsx @@ -6,7 +6,8 @@ export default function DropdownRow({ children, className, dropdown, -}: { dropdown: React.ReactNode; className?: string } & JustChildren) { + nested = false, +}: { dropdown: React.ReactNode; className?: string; nested?: boolean } & JustChildren) { const [showDropdown, setShowDropdown] = useState(false); const [renderDropdown, setRenderDropdown] = useState(false); @@ -34,8 +35,8 @@ export default function DropdownRow({ setShowDropdown(!showDropdown)}> {children} - - + + {renderDropdown ? (
{dropdown}
diff --git a/client/src/components/tables/dropdowns/PBDropdown.tsx b/client/src/components/tables/dropdowns/PBDropdown.tsx index e699edbb9..c42886abe 100644 --- a/client/src/components/tables/dropdowns/PBDropdown.tsx +++ b/client/src/components/tables/dropdowns/PBDropdown.tsx @@ -15,6 +15,7 @@ import DocComponentCreator, { } from "./components/DocumentComponent"; import DropdownStructure from "./components/DropdownStructure"; import PlayHistory from "./components/PlayHistory"; +import RivalCompare from "./components/RivalCompare"; import { GPTDropdownSettings } from "./GPTDropdownSettings"; export interface ScoreDropdownProps { @@ -35,13 +36,11 @@ export default function PBDropdown({ userID: integer; chart: ChartDocument; scoreState: ScoreState; - defaultView?: "pb" | "scorePB" | "lampPB" | "history" | "debug"; + defaultView?: "pb" | "scorePB" | "lampPB" | "history" | "debug" | "rivals"; } & GamePT) { const DocComponent: DocumentComponentType = (props) => DocComponentCreator({ ...props, ...GPTDropdownSettings(game, playtype) }); - // const { settings } = useContext(UserSettingsContext); - const [view, setView] = useState(defaultView); const { data, error } = useApiQuery>( @@ -109,6 +108,8 @@ export default function PBDropdown({ ); } else if (view === "debug") { body = ; + } else if (view === "rivals") { + body = ; } else { body = ( ({ Play History{histData && ` (${histData.length})`} + + + Rivals + diff --git a/client/src/components/tables/dropdowns/ScoreDropdown.tsx b/client/src/components/tables/dropdowns/ScoreDropdown.tsx index e0a72756e..cf2b89695 100644 --- a/client/src/components/tables/dropdowns/ScoreDropdown.tsx +++ b/client/src/components/tables/dropdowns/ScoreDropdown.tsx @@ -21,6 +21,7 @@ import DropdownStructure from "./components/DropdownStructure"; import ManageScore from "./components/ManageScore"; import PBCompare from "./components/PBCompare"; import PlayHistory from "./components/PlayHistory"; +import RivalCompare from "./components/RivalCompare"; import { GPTDropdownSettings } from "./GPTDropdownSettings"; export interface ScoreState { @@ -45,7 +46,7 @@ export default function ScoreDropdown({ user: PublicUserDocument; chart: ChartDocument; scoreState: ScoreState; - defaultView?: "vsPB" | "moreInfo" | "history" | "debug" | "manage"; + defaultView?: "vsPB" | "moreInfo" | "history" | "debug" | "manage" | "rivals"; thisScore: ScoreDocument; } & GamePT) { const DocComponent: DocumentComponentType = (props) => @@ -106,6 +107,8 @@ export default function ScoreDropdown({ body = ; } else if (view === "manage") { body = ; + } else if (view === "rivals") { + body = ; } return ( @@ -130,6 +133,12 @@ export default function ScoreDropdown({ Manage Score )} + {currentUser && ( + + + Rivals + + )} diff --git a/client/src/components/tables/dropdowns/components/DocumentComponent.tsx b/client/src/components/tables/dropdowns/components/DocumentComponent.tsx index f3b04ce2b..57a0df5e2 100644 --- a/client/src/components/tables/dropdowns/components/DocumentComponent.tsx +++ b/client/src/components/tables/dropdowns/components/DocumentComponent.tsx @@ -152,3 +152,43 @@ export default function DocumentComponent({ ); } + +export function GraphAndJudgementDataComponent({ + score, + GraphComponent = null, + chart, +}: { + score: ScoreDocument | PBScoreDocument; + renderScoreInfo?: boolean; + showSingleScoreNote?: boolean; + forceScoreData?: boolean; + chart: ChartDocument; + GraphComponent?: + | (({ + score, + chart, + }: { + score: ScoreDocument | PBScoreDocument; + chart: ChartDocument; + }) => JSX.Element) + | null; +}) { + return ( +
+
+
+ {GraphComponent ? ( + + ) : ( +
+ No graphs available :( +
+ )} +
+
+
+ +
+
+ ); +} diff --git a/client/src/components/tables/dropdowns/components/PlayHistory.tsx b/client/src/components/tables/dropdowns/components/PlayHistory.tsx index 9810fe493..c2103530c 100644 --- a/client/src/components/tables/dropdowns/components/PlayHistory.tsx +++ b/client/src/components/tables/dropdowns/components/PlayHistory.tsx @@ -25,7 +25,7 @@ export default function PlayHistory({ } return ( -
+
); diff --git a/client/src/components/tables/dropdowns/components/RivalCompare.tsx b/client/src/components/tables/dropdowns/components/RivalCompare.tsx new file mode 100644 index 000000000..34abdad67 --- /dev/null +++ b/client/src/components/tables/dropdowns/components/RivalCompare.tsx @@ -0,0 +1,64 @@ +import { NumericSOV } from "util/sorts"; +import RivalChartTable from "components/tables/rivals/RivalChartTable"; +import ApiError from "components/util/ApiError"; +import Loading from "components/util/Loading"; +import useApiQuery from "components/util/query/useApiQuery"; +import useUGPTBase from "components/util/useUGPTBase"; +import { UserContext } from "context/UserContext"; +import React, { useContext } from "react"; +import { Link } from "react-router-dom"; +import { ChartDocument, Game } from "tachi-common"; +import { ChartRivalsReturn } from "types/api-returns"; +import { RivalChartDataset } from "types/tables"; + +export default function RivalCompare({ chart, game }: { chart: ChartDocument; game: Game }) { + const { user: currentUser } = useContext(UserContext); + + const playtype = chart.playtype; + + if (!currentUser) { + return
You're not signed in. How did you even get to this page?
; + } + + const base = useUGPTBase({ reqUser: currentUser, game, playtype }); + + const { data, error } = useApiQuery( + `/users/${currentUser.id}/games/${game}/${playtype}/pbs/${chart.chartID}/rivals` + ); + + if (!data) { + return ; + } + + if (error) { + return ; + } + + if (data.rivals.length === 0) { + return ( +
+ You have no rivals set! +
+ Why not set some? +
+ ); + } + + const rivalDataset: RivalChartDataset = [...data.rivals, currentUser] + .map((u) => ({ + ...u, + __related: { + pb: data.pbs.find((p) => p.userID === u.id) ?? null, + }, + })) + .sort(NumericSOV((x) => x.__related.pb?.scoreData.percent ?? -Infinity, true)) + .map((e, index) => ({ + ...e, + __related: { + ...e.__related, + index, + }, + })); + + return ; +} diff --git a/client/src/components/tables/headers/GameHeaders.tsx b/client/src/components/tables/headers/GameHeaders.tsx index 67edcd08f..76a3ab57a 100644 --- a/client/src/components/tables/headers/GameHeaders.tsx +++ b/client/src/components/tables/headers/GameHeaders.tsx @@ -10,12 +10,14 @@ import { ScoreDocument, } from "tachi-common"; import { SetState } from "types/react"; -import { FolderDataset, PBDataset, ScoreDataset } from "types/tables"; +import { FolderDataset, PBDataset, RivalChartDataset, ScoreDataset } from "types/tables"; import { Playtype } from "types/tachi"; import SelectableRating from "../components/SelectableRating"; import { Header, ZTableTHProps } from "../components/TachiTable"; -export function GetGPTCoreHeaders( +export function GetGPTCoreHeaders< + Dataset extends FolderDataset | PBDataset | ScoreDataset | RivalChartDataset +>( game: Game, playtype: Playtype, rating: ScoreCalculatedDataLookup[IDStrings], diff --git a/client/src/components/tables/history-scores/HistoryScoreTable.tsx b/client/src/components/tables/history-scores/HistoryScoreTable.tsx index a3aafc158..1de8582f8 100644 --- a/client/src/components/tables/history-scores/HistoryScoreTable.tsx +++ b/client/src/components/tables/history-scores/HistoryScoreTable.tsx @@ -15,6 +15,11 @@ import TimestampCell from "../cells/TimestampCell"; import TachiTable from "../components/TachiTable"; import ScoreCoreCells from "../game-core-cells/ScoreCoreCells"; import { GetGPTCoreHeaders } from "../headers/GameHeaders"; +import DropdownRow from "../components/DropdownRow"; +import { GraphAndJudgementDataComponent } from "../dropdowns/components/DocumentComponent"; +import { GPTDropdownSettings } from "../dropdowns/GPTDropdownSettings"; +import { EmptyHeader } from "../headers/IndicatorHeader"; +import DropdownIndicatorCell from "../cells/DropdownIndicatorCell"; export default function HistoryScoreTable({ dataset, @@ -44,6 +49,7 @@ export default function HistoryScoreTable({ headers={[ ...headers, ["Timestamp", "Timestamp", NumericSOV((x) => x.timeAchieved ?? 0)], + EmptyHeader, ]} defaultSortMode="Timestamp" defaultReverseSort @@ -66,9 +72,19 @@ function Row({ chart: ChartDocument; }) { return ( - + + } + > - + + ); } diff --git a/client/src/components/tables/rivals/RivalChartTable.tsx b/client/src/components/tables/rivals/RivalChartTable.tsx new file mode 100644 index 000000000..102f98686 --- /dev/null +++ b/client/src/components/tables/rivals/RivalChartTable.tsx @@ -0,0 +1,105 @@ +import { NumericSOV, StrSOV } from "util/sorts"; +import useScoreRatingAlg from "components/util/useScoreRatingAlg"; +import React, { useState } from "react"; +import { ChartDocument, Game, IDStrings, ScoreCalculatedDataLookup } from "tachi-common"; +import { RivalChartDataset } from "types/tables"; +import IndexCell from "../cells/IndexCell"; +import RankingCell from "../cells/RankingCell"; +import TimestampCell from "../cells/TimestampCell"; +import UserCell from "../cells/UserCell"; +import DropdownRow from "../components/DropdownRow"; +import TachiTable, { Header } from "../components/TachiTable"; +import { GraphAndJudgementDataComponent } from "../dropdowns/components/DocumentComponent"; +import { GPTDropdownSettings } from "../dropdowns/GPTDropdownSettings"; +import ScoreCoreCells from "../game-core-cells/ScoreCoreCells"; +import { GetGPTCoreHeaders } from "../headers/GameHeaders"; + +export default function RivalChartTable({ + dataset, + game, + chart, +}: { + dataset: RivalChartDataset; + game: Game; + chart: ChartDocument; +}) { + const playtype = chart.playtype; + + const defaultRating = useScoreRatingAlg(game, playtype); + + const [rating, setRating] = useState(defaultRating); + + const headers: Header[] = [ + ["#", "#", NumericSOV((x) => x.__related.index)], + ["User", "User", StrSOV((x) => x.username)], + ...GetGPTCoreHeaders( + game, + playtype, + rating, + setRating, + (x) => x.__related.pb + ), + [ + "Site Ranking", + "Site Rank", + NumericSOV((x) => x.__related.pb?.rankingData.rank ?? -Infinity), + ], + ["Last Raised", "Last Raised", NumericSOV((x) => x.__related.pb?.timeAchieved ?? 0)], + ]; + + return ( + ( + + )} + /> + ); +} + +function Row({ + data, + rating, + game, + chart, +}: { + data: RivalChartDataset[0]; + game: Game; + rating: ScoreCalculatedDataLookup[I]; + chart: ChartDocument; +}) { + const pb = data.__related.pb; + + if (!pb) { + return ( + + N/A + + Not Played. + + ); + } + + return ( + + } + > + + + + + + + ); +} diff --git a/client/src/types/api-returns.ts b/client/src/types/api-returns.ts index a34b8e8d6..67baaa312 100644 --- a/client/src/types/api-returns.ts +++ b/client/src/types/api-returns.ts @@ -186,3 +186,8 @@ export interface ChallengeSubsReturn { songs: Array; charts: Array; } + +export interface ChartRivalsReturn { + rivals: Array; + pbs: Array; +} diff --git a/client/src/types/tables.ts b/client/src/types/tables.ts index 211ba4d7c..5eb251517 100644 --- a/client/src/types/tables.ts +++ b/client/src/types/tables.ts @@ -49,3 +49,10 @@ export type UGSDataset = (UserGameStats & { index: integer; }; })[]; + +export type RivalChartDataset = (PublicUserDocument & { + __related: { + pb: PBScoreDocument | null; + index: number; + }; +})[]; diff --git a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/pbs/router.ts b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/pbs/router.ts index 71166b327..4e89c7ccf 100644 --- a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/pbs/router.ts +++ b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/pbs/router.ts @@ -1,5 +1,6 @@ import { Router } from "express"; import db from "external/mongo/db"; +import { GetRivalUsers } from "lib/rivals/rivals"; import { SearchSpecificGameSongsAndCharts } from "lib/search/search"; import { AggressiveRateLimitMiddleware } from "server/middleware/rate-limiter"; import { GetGamePTConfig } from "tachi-common"; @@ -7,7 +8,7 @@ import { GetRelevantSongsAndCharts } from "utils/db"; import { IsValidScoreAlg } from "utils/misc"; import { GetAdjacentAbove, GetAdjacentBelow } from "utils/queries/pbs"; import { GetUGPT } from "utils/req-tachi-data"; -import { FilterChartsAndSongs, GetScoreIDsFromComposed } from "utils/scores"; +import { FilterChartsAndSongs, GetPBOnChart, GetScoreIDsFromComposed } from "utils/scores"; import { GetUsersWithIDs } from "utils/user"; const router: Router = Router({ mergeParams: true }); @@ -203,6 +204,33 @@ router.get("/:chartID", async (req, res) => { }); }); +/** + * Returns a user's PB on the given chart, and all of their rivals performances aswell. + * + * @name GET /api/v1/users/:userID/games/:game/:playtype/pbs/:chartID/rivals + */ +router.get("/:chartID/rivals", async (req, res) => { + const { user, game, playtype } = GetUGPT(req); + + const rivals = await GetRivalUsers(user.id, game, playtype); + + const pbs = await db["personal-bests"].find({ + userID: { $in: rivals.map((e) => e.id) }, + chartID: req.params.chartID, + }); + + const usersPB = await GetPBOnChart(user.id, req.params.chartID); + + return res.status(200).json({ + success: true, + description: `Retrieved PBs and Rival PBs.`, + body: { + pbs: [...pbs, usersPB], + rivals, + }, + }); +}); + /** * Return this users PB on this chart, and 5 nearby players on the * leaderboard. From 5379dd491e55e80d09aaf6db21443728c6fbd34d Mon Sep 17 00:00:00 2001 From: zkldi <20380519+zkldi@users.noreply.github.com> Date: Fri, 14 Oct 2022 22:54:34 +0100 Subject: [PATCH 05/10] feat: rivals on gptchartpage --- .../games/_game/_playtype/GPTChartPage.tsx | 48 +++++++++++++++---- .../_game/_playtype/rivals/RivalsMainPage.tsx | 2 +- 2 files changed, 39 insertions(+), 11 deletions(-) diff --git a/client/src/app/pages/dashboard/games/_game/_playtype/GPTChartPage.tsx b/client/src/app/pages/dashboard/games/_game/_playtype/GPTChartPage.tsx index ec74f30c6..495220a8d 100644 --- a/client/src/app/pages/dashboard/games/_game/_playtype/GPTChartPage.tsx +++ b/client/src/app/pages/dashboard/games/_game/_playtype/GPTChartPage.tsx @@ -29,7 +29,11 @@ import { PublicUserDocument, SongDocument, } from "tachi-common"; -import { ChartPBLeaderboardReturn, UGPTChartLeaderboardAdjacent } from "types/api-returns"; +import { + ChartPBLeaderboardReturn, + ChartRivalsReturn, + UGPTChartLeaderboardAdjacent, +} from "types/api-returns"; import { GamePT, SetState } from "types/react"; import { PBDataset } from "types/tables"; @@ -73,6 +77,7 @@ export default function GPTChartPage({ interface ChartPBData { leaderboard: ChartPBLeaderboardReturn; adjacent?: UGPTChartLeaderboardAdjacent; + rivals?: ChartRivalsReturn; playcount: integer; } @@ -87,7 +92,7 @@ function InternalGPTChartPage({ } & GamePT) { const { user } = useContext(UserContext); - const [mode, setMode] = useState<"leaderboard" | "adjacent">("leaderboard"); + const [mode, setMode] = useState<"leaderboard" | "adjacent" | "rivals">("leaderboard"); const { data, error } = useQuery( ["PBInfo", `${chart.chartID}`], @@ -113,15 +118,24 @@ function InternalGPTChartPage({ `/users/${user.id}/games/${game}/${playtype}/pbs/${chart.chartID}/leaderboard-adjacent` ); - if (!nRes.success) { - return { leaderboard: lRes.body, playcount: pRes.body.count }; - } + const rRes = await APIFetchV1( + `/users/${user.id}/games/${game}/${playtype}/pbs/${chart.chartID}/rivals` + ); - return { + const returnValue: ChartPBData = { leaderboard: lRes.body, - adjacent: nRes.body, playcount: pRes.body.count, }; + + if (nRes.success) { + returnValue.adjacent = nRes.body; + } + + if (rRes.success) { + returnValue.rivals = rRes.body; + } + + return returnValue; } return { leaderboard: lRes.body, playcount: pRes.body.count }; @@ -148,6 +162,12 @@ function InternalGPTChartPage({ } } + if (data.rivals) { + for (const user of data.rivals.rivals) { + userMap.set(user.id, user); + } + } + if (user) { // Add current user, since there's no guarantee they are returned from either API. userMap.set(user.id, user); @@ -173,6 +193,12 @@ function InternalGPTChartPage({ Your Position + {user && ( + + + VS. Rivals + + )}
@@ -198,22 +224,24 @@ function ChartLeaderboardTable({ data: ChartPBData; user: PublicUserDocument | null; userMap: Map; - mode: "leaderboard" | "adjacent"; + mode: "leaderboard" | "adjacent" | "rivals"; chart: ChartDocument; song: SongDocument; } & GamePT) { const dataset: PBDataset = useMemo(() => { const ds: PBDataset = []; - let pbs = []; + let pbs: Array = []; if (mode === "leaderboard") { pbs = data.leaderboard.pbs; - } else { + } else if (mode === "adjacent") { pbs = [ ...data.adjacent!.adjacentAbove, data.adjacent!.pb, ...data.adjacent!.adjacentBelow, ]; + } else if (mode === "rivals") { + pbs = data.rivals!.pbs; } for (const pb of pbs) { diff --git a/client/src/app/pages/dashboard/users/games/_game/_playtype/rivals/RivalsMainPage.tsx b/client/src/app/pages/dashboard/users/games/_game/_playtype/rivals/RivalsMainPage.tsx index c14be4a02..1cbcef9a8 100644 --- a/client/src/app/pages/dashboard/users/games/_game/_playtype/rivals/RivalsMainPage.tsx +++ b/client/src/app/pages/dashboard/users/games/_game/_playtype/rivals/RivalsMainPage.tsx @@ -50,7 +50,7 @@ export default function RivalsMainPage({ - Change Rivals + Manage Rivals From 2c3af78b7cfa43d3bbeb9e82ffae47865b8d9520 Mon Sep 17 00:00:00 2001 From: zkldi <20380519+zkldi@users.noreply.github.com> Date: Fri, 14 Oct 2022 23:53:35 +0100 Subject: [PATCH 06/10] feat: rival ranking on server --- common/src/types.ts | 6 ++++ server/src/lib/rivals/rivals.ts | 30 +++++++++++++++++++ .../score-import/framework/common/types.ts | 1 + .../framework/pb/create-pb-doc.ts | 21 +++++++++++-- .../framework/pb/process-pbs.test.ts | 10 +++++-- .../score-import/framework/pb/process-pbs.ts | 6 ++-- .../score-importing/score-import-main.ts | 23 ++++++++++---- .../src/lib/score-mutation/delete-scores.ts | 20 ++++++++++--- server/src/lib/score-mutation/update-score.ts | 5 ++-- .../router/ir/usc/_playtype/router.test.ts | 1 + .../router/ir/usc/_playtype/usc.test.ts | 1 + server/src/test-utils/test-data.ts | 1 + .../src/utils/calculations/recalc-scores.ts | 27 +++++++++++++---- 13 files changed, 129 insertions(+), 23 deletions(-) diff --git a/common/src/types.ts b/common/src/types.ts index f2638b558..549c2f740 100644 --- a/common/src/types.ts +++ b/common/src/types.ts @@ -1256,6 +1256,12 @@ export interface PBScoreDocument extends MongoD rankingData: { rank: integer; outOf: integer; + + // out of their rivals, what is their position on this chart? + // note that we don't need to store rivalOutOf, as it's pretty much a constant + // that can just be read from the UGPT settings. + // null if the user has no rivals. + rivalRank: integer | null; }; userID: integer; chartID: string; diff --git a/server/src/lib/rivals/rivals.ts b/server/src/lib/rivals/rivals.ts index cece54034..0fe1a3df2 100644 --- a/server/src/lib/rivals/rivals.ts +++ b/server/src/lib/rivals/rivals.ts @@ -48,6 +48,36 @@ export async function GetRivalUsers(userID: integer, game: Game, playtype: Playt return rivals; } +/** + * Retrieve *all* rival IDs for people on this game. Used to recalculate rival movements on charts, + * since that is stored and cached. + */ +export async function GetEveryonesRivalIDs( + game: Game, + playtype: Playtype +): Promise>> { + const allGameSettings = await db["game-settings"].find( + { + game, + playtype, + }, + { + projection: { + userID: 1, + rivals: 1, + }, + } + ); + + const lookupTable: Record> = {}; + + for (const d of allGameSettings) { + lookupTable[d.userID] = d.rivals; + } + + return lookupTable; +} + /** * Sets an array of userIDs to be this user's rivals. Performs validation on all of the * rivals being players of the game, and not being duplicates. The maximum amount of rivals diff --git a/server/src/lib/score-import/framework/common/types.ts b/server/src/lib/score-import/framework/common/types.ts index cd722594d..d21248bd8 100644 --- a/server/src/lib/score-import/framework/common/types.ts +++ b/server/src/lib/score-import/framework/common/types.ts @@ -12,3 +12,4 @@ export type DryScore = Pick< }; export type ScorePlaytypeMap = Partial>>; +export type ChartIDPlaytypeMap = Partial>>; diff --git a/server/src/lib/score-import/framework/pb/create-pb-doc.ts b/server/src/lib/score-import/framework/pb/create-pb-doc.ts index 8ca2643cd..eb25ad973 100644 --- a/server/src/lib/score-import/framework/pb/create-pb-doc.ts +++ b/server/src/lib/score-import/framework/pb/create-pb-doc.ts @@ -6,9 +6,10 @@ import { USCMergeFn, } from "./game-specific-merge"; import db from "external/mongo/db"; +import { GetEveryonesRivalIDs, GetRivalIDs, GetRivalUsers } from "lib/rivals/rivals"; import type { KtLogger } from "lib/logger/logger"; import type { BulkWriteUpdateOneOperation } from "mongodb"; -import type { Game, integer, PBScoreDocument, ScoreDocument } from "tachi-common"; +import type { Game, integer, PBScoreDocument, Playtype, ScoreDocument } from "tachi-common"; export type PBScoreDocumentNoRank = Omit; @@ -64,9 +65,9 @@ export async function CreatePBDoc(userID: integer, chartID: string, logger: KtLo } /** - * Updates user's rankings on a given chart. + * Updates rankings on a given chart. */ -export async function UpdateChartRanking(chartID: string) { +export async function UpdateChartRanking(game: Game, playtype: Playtype, chartID: string) { const scores = await db["personal-bests"].find( { chartID }, { @@ -77,13 +78,26 @@ export async function UpdateChartRanking(chartID: string) { } ); + const allRivals = await GetEveryonesRivalIDs(game, playtype); + const bwrite: Array> = []; let rank = 0; + // what users have we saw so far? used for rivalRanking calculations + const seenUserIDs: Array = []; + for (const score of scores) { rank++; + const thisUsersRivals = allRivals[score.userID]; + + let rivalRank: integer | null = null; + + if (thisUsersRivals) { + rivalRank = thisUsersRivals.filter((e) => seenUserIDs.includes(e)).length + 1; + } + bwrite.push({ updateOne: { filter: { chartID: score.chartID, userID: score.userID }, @@ -92,6 +106,7 @@ export async function UpdateChartRanking(chartID: string) { rankingData: { rank, outOf: scores.length, + rivalRank, }, }, }, diff --git a/server/src/lib/score-import/framework/pb/process-pbs.test.ts b/server/src/lib/score-import/framework/pb/process-pbs.test.ts index 2167591f7..78362e253 100644 --- a/server/src/lib/score-import/framework/pb/process-pbs.test.ts +++ b/server/src/lib/score-import/framework/pb/process-pbs.test.ts @@ -16,7 +16,7 @@ t.test("#ProcessPBs", (t) => { await db["personal-bests"].remove({}); // scores on 511 SPA are pre-loaded into the database - await ProcessPBs(1, new Set([Testing511SPA.chartID]), logger); + await ProcessPBs("iidx", "SP", 1, new Set([Testing511SPA.chartID]), logger); const pbs = await db["personal-bests"].find({}); @@ -48,7 +48,13 @@ t.test("#ProcessPBs", (t) => { }), ]); - await ProcessPBs(1, new Set([Testing511SPA.chartID, "test1", "test2", "test3"]), logger); + await ProcessPBs( + "iidx", + "SP", + 1, + new Set([Testing511SPA.chartID, "test1", "test2", "test3"]), + logger + ); const pbs = await db["personal-bests"].find({}); diff --git a/server/src/lib/score-import/framework/pb/process-pbs.ts b/server/src/lib/score-import/framework/pb/process-pbs.ts index 2a51addb7..22634f341 100644 --- a/server/src/lib/score-import/framework/pb/process-pbs.ts +++ b/server/src/lib/score-import/framework/pb/process-pbs.ts @@ -2,12 +2,14 @@ import { CreatePBDoc, UpdateChartRanking } from "./create-pb-doc"; import db from "external/mongo/db"; import type { PBScoreDocumentNoRank } from "./create-pb-doc"; import type { KtLogger } from "lib/logger/logger"; -import type { integer } from "tachi-common"; +import type { Game, integer, Playtype } from "tachi-common"; /** * Process, recalculate and update a users PBs for this set of chartIDs. */ export async function ProcessPBs( + game: Game, + playtype: Playtype, userID: integer, chartIDs: Set, logger: KtLogger @@ -58,7 +60,7 @@ export async function ProcessPBs( // now that everything has been updated or inserted, we can refresh // the chart rankings. - await Promise.all(pbDocs.map((e) => UpdateChartRanking(e.chartID))); + await Promise.all(pbDocs.map((e) => UpdateChartRanking(game, playtype, e.chartID))); // and we're done! } diff --git a/server/src/lib/score-import/framework/score-importing/score-import-main.ts b/server/src/lib/score-import/framework/score-importing/score-import-main.ts index 819e3a835..4ce9d0711 100644 --- a/server/src/lib/score-import/framework/score-importing/score-import-main.ts +++ b/server/src/lib/score-import/framework/score-importing/score-import-main.ts @@ -14,7 +14,7 @@ import { GetGameConfig } from "tachi-common"; import { GetMillisecondsSince } from "utils/misc"; import { GetUserWithID } from "utils/user"; import type { ConverterFunction, ImportInputParser } from "../../import-types/common/types"; -import type { ScorePlaytypeMap } from "../common/types"; +import type { ChartIDPlaytypeMap, ScorePlaytypeMap } from "../common/types"; import type { ClassHandler } from "../user-game-stats/types"; import type { KtLogger } from "lib/logger/logger"; import type { ScoreImportJob } from "lib/score-import/worker/types"; @@ -292,21 +292,33 @@ export async function HandlePostImportSteps( void SetJobProgress(job, "Processing scores and updating PBs."); + const playtypes = Object.keys(scorePlaytypeMap) as Array; + // --- 5. PersonalBests --- // We want to keep an updated reference of a users best score on a given chart. // This function also handles conjoining different scores together (such as unioning best lamp and // best score). const pbTimeStart = process.hrtime.bigint(); - await ProcessPBs(user.id, chartIDs, logger); + // processing PBs is a playtype-specific action. As such, we need to split chartIDs + // accordingly + const chartIDsSeparatedByPlaytype: ChartIDPlaytypeMap = {}; + + for (const [playtype, scores] of Object.entries(scorePlaytypeMap)) { + chartIDsSeparatedByPlaytype[playtype as Playtype] = new Set(scores.map((e) => e.chartID)); + } + + await Promise.all( + Object.entries(chartIDsSeparatedByPlaytype).map(([playtype, chartIDs]) => + ProcessPBs(game, playtype as Playtype, user.id, chartIDs, logger) + ) + ); const pbTime = GetMillisecondsSince(pbTimeStart); const pbTimeRel = pbTime / chartIDs.size; logger.debug(`PB Processing took ${pbTime} milliseconds (${pbTimeRel}ms/doc)`); - const playtypes = Object.keys(scorePlaytypeMap) as Array; - void SetJobProgress(job, "Updating profile statistics."); // --- 6. Game Stats --- @@ -413,7 +425,8 @@ function ParseImportInfo(importInfo: Array) { chartIDs.add(info.content.score.chartID); if (scorePlaytypeMap[info.content.score.playtype]) { - scorePlaytypeMap[info.content.score.playtype]!.push(info.content.score); + // @ts-expect-error obviously delusional typescript moment + scorePlaytypeMap[info.content.score.playtype].push(info.content.score); } else { scorePlaytypeMap[info.content.score.playtype] = [info.content.score]; } diff --git a/server/src/lib/score-mutation/delete-scores.ts b/server/src/lib/score-mutation/delete-scores.ts index cf19eed33..038c36f0a 100644 --- a/server/src/lib/score-mutation/delete-scores.ts +++ b/server/src/lib/score-mutation/delete-scores.ts @@ -84,14 +84,20 @@ export async function DeleteScore( }); if (userHasOtherScores && attemptPBReprocess) { - await ProcessPBs(score.userID, new Set([score.chartID]), logger); + await ProcessPBs( + score.game, + score.playtype, + score.userID, + new Set([score.chartID]), + logger + ); } else { await db["personal-bests"].remove({ userID: score.userID, chartID: score.chartID, }); - await UpdateChartRanking(score.chartID); + await UpdateChartRanking(score.game, score.playtype, score.chartID); } await UpdateUsersGamePlaytypeStats(score.game, score.playtype, score.userID, null, logger); @@ -186,14 +192,20 @@ export async function DeleteMultipleScores(scores: Array, blackli }); if (userHasOtherScores) { - await ProcessPBs(score.userID, new Set([score.chartID]), logger); + await ProcessPBs( + score.game, + score.playtype, + score.userID, + new Set([score.chartID]), + logger + ); } else { await db["personal-bests"].remove({ userID: score.userID, chartID: score.chartID, }); - await UpdateChartRanking(score.chartID); + await UpdateChartRanking(score.game, score.playtype, score.chartID); } if (blacklist) { diff --git a/server/src/lib/score-mutation/update-score.ts b/server/src/lib/score-mutation/update-score.ts index 1ac2c65e9..9ba64fb75 100644 --- a/server/src/lib/score-mutation/update-score.ts +++ b/server/src/lib/score-mutation/update-score.ts @@ -64,6 +64,7 @@ export default async function UpdateScore( logger.verbose("Received Update Score request."); + // eslint-disable-next-line require-atomic-updates newScore.calculatedData = await CreateCalculatedData( newScore, chart, @@ -148,13 +149,13 @@ export default async function UpdateScore( chartID: newScore.chartID, }); - await UpdateChartRanking(newScore.chartID); + await UpdateChartRanking(newScore.game, newScore.playtype, newScore.chartID); if (updateOldChart) { await UpdateAllPBs([userID], { chartID: oldScore.chartID, }); - await UpdateChartRanking(oldScore.chartID); + await UpdateChartRanking(oldScore.game, oldScore.playtype, oldScore.chartID); } const imports = await db.imports.find({ diff --git a/server/src/server/router/ir/usc/_playtype/router.test.ts b/server/src/server/router/ir/usc/_playtype/router.test.ts index 955e22ba4..ae6e529a8 100644 --- a/server/src/server/router/ir/usc/_playtype/router.test.ts +++ b/server/src/server/router/ir/usc/_playtype/router.test.ts @@ -132,6 +132,7 @@ const USC_SCORE_PB: PBScoreDocument = { rankingData: { rank: 1, outOf: 2, + rivalRank: null, }, songID: 1, userID: 1, diff --git a/server/src/server/router/ir/usc/_playtype/usc.test.ts b/server/src/server/router/ir/usc/_playtype/usc.test.ts index 6a716cdd6..970b9764b 100644 --- a/server/src/server/router/ir/usc/_playtype/usc.test.ts +++ b/server/src/server/router/ir/usc/_playtype/usc.test.ts @@ -26,6 +26,7 @@ const mockScorePB: PBScoreDocument<"usc:Controller" | "usc:Keyboard"> = { rankingData: { outOf: 2, rank: 1, + rivalRank: null, }, scoreData: { esd: null, diff --git a/server/src/test-utils/test-data.ts b/server/src/test-utils/test-data.ts index 96e8b50b9..f23171277 100644 --- a/server/src/test-utils/test-data.ts +++ b/server/src/test-utils/test-data.ts @@ -95,6 +95,7 @@ export const TestingIIDXSPScorePB: PBScoreDocument<"iidx:SP"> = { rankingData: { rank: 1, outOf: 2, + rivalRank: null, }, songID: 1, game: "iidx", diff --git a/server/src/utils/calculations/recalc-scores.ts b/server/src/utils/calculations/recalc-scores.ts index 3a5dac342..c284d0caf 100644 --- a/server/src/utils/calculations/recalc-scores.ts +++ b/server/src/utils/calculations/recalc-scores.ts @@ -7,6 +7,8 @@ import { GetAndUpdateUsersGoals } from "lib/score-import/framework/goals/goals"; import { UpdateUsersMilestones } from "lib/score-import/framework/milestones/milestones"; import { ProcessPBs } from "lib/score-import/framework/pb/process-pbs"; import { UpdateUsersGamePlaytypeStats } from "lib/score-import/framework/user-game-stats/update-ugs"; +import { TachiConfig } from "lib/setup/config"; +import { GetGameConfig } from "tachi-common"; import { EfficientDBIterate } from "utils/efficient-db-iterate"; import { FormatUserDoc } from "utils/user"; import type { Game, integer, Playtype, PublicUserDocument } from "tachi-common"; @@ -95,12 +97,27 @@ export async function UpdateAllPBs(userIDs?: Array, filter = {}) { for (const user of allUsers) { logger.verbose(`Finding ${FormatUserDoc(user)}'s scores.`); - const scores = await db.scores.find(deepmerge({ userID: user.id }, filter), { - projection: { chartID: 1 }, - }); + for (const game of TachiConfig.GAMES) { + const gameConfig = GetGameConfig(game); - logger.verbose(`PBing ${FormatUserDoc(user)}'s scores.`); - await ProcessPBs(user.id, new Set(scores.map((e) => e.chartID)), logger); + for (const playtype of gameConfig.validPlaytypes) { + const scores = await db.scores.find( + deepmerge({ userID: user.id, game, playtype }, filter), + { + projection: { chartID: 1 }, + } + ); + + logger.verbose(`PBing ${FormatUserDoc(user)}'s scores.`); + await ProcessPBs( + game, + playtype, + user.id, + new Set(scores.map((e) => e.chartID)), + logger + ); + } + } } logger.verbose(`Done!`); From 0b54d6d862fd06b68391d5125a460a8dca3d4818 Mon Sep 17 00:00:00 2001 From: zkldi <20380519+zkldi@users.noreply.github.com> Date: Sat, 15 Oct 2022 02:49:41 +0100 Subject: [PATCH 07/10] feat: rival ranking in UI --- .../games/_game/_playtype/GPTChartPage.tsx | 1 + client/src/app/routes/GameRoutes.tsx | 5 +- .../components/tables/cells/RankingCell.tsx | 70 +++++++++++++++++-- .../tables/components/SelectableRanking.tsx | 64 +++++++++++++++++ .../components/tables/folders/FolderTable.tsx | 33 ++++++--- .../tables/headers/RankingHeader.tsx | 37 ++++++++++ client/src/components/tables/pbs/PBTable.tsx | 19 ++++- .../tables/rivals/RivalChartTable.tsx | 31 +++++--- client/src/context/UGPTContext.tsx | 11 --- client/src/util/tables/create-search.ts | 8 ++- client/src/util/ztable/search.ts | 12 +++- .../framework/pb/create-pb-doc.ts | 3 +- 12 files changed, 252 insertions(+), 42 deletions(-) create mode 100644 client/src/components/tables/components/SelectableRanking.tsx create mode 100644 client/src/components/tables/headers/RankingHeader.tsx diff --git a/client/src/app/pages/dashboard/games/_game/_playtype/GPTChartPage.tsx b/client/src/app/pages/dashboard/games/_game/_playtype/GPTChartPage.tsx index 495220a8d..3477cb8be 100644 --- a/client/src/app/pages/dashboard/games/_game/_playtype/GPTChartPage.tsx +++ b/client/src/app/pages/dashboard/games/_game/_playtype/GPTChartPage.tsx @@ -261,6 +261,7 @@ function ChartLeaderboardTable({ return ( (); @@ -66,7 +67,9 @@ export default function GameRoutes() {
- + + + diff --git a/client/src/components/tables/cells/RankingCell.tsx b/client/src/components/tables/cells/RankingCell.tsx index 48a033dc5..7d1d863b5 100644 --- a/client/src/components/tables/cells/RankingCell.tsx +++ b/client/src/components/tables/cells/RankingCell.tsx @@ -1,15 +1,73 @@ +import useLUGPTSettings from "components/util/useLUGPTSettings"; import React from "react"; +import { integer } from "rg-stats/js/util/types"; import { PBScoreDocument } from "tachi-common"; +export type RankingViewMode = "global" | "rival" | "both-if-self" | "global-no-switch"; + export default function RankingCell({ rankingData, + userID, + rankingViewMode, }: { rankingData: PBScoreDocument["rankingData"]; + userID: integer; + rankingViewMode: RankingViewMode; }) { - return ( - - #{rankingData.rank} - /{rankingData.outOf} - - ); + const { settings } = useLUGPTSettings(); + + switch (rankingViewMode) { + case "global": + case "global-no-switch": + return ( + + #{rankingData.rank} + /{rankingData.outOf} + + ); + case "rival": + if (!settings) { + return ( + + No Settings, yet tried to view rival stats? not possible. how'd you get + here. report this. + + ); + } + + if (settings?.userID !== userID) { + return ( + + N/A + + ); + } + + return ( + + #{rankingData.rivalRank} + /{settings.rivals.length + 1} + + ); + + case "both-if-self": + if (settings?.userID === userID && rankingData.rivalRank !== null) { + return ( + + Global #{rankingData.rank} + /{rankingData.outOf} +
+ Rival #{rankingData.rivalRank} + /{settings.rivals.length + 1} + + ); + } + + return ( + + #{rankingData.rank} + /{rankingData.outOf} + + ); + } } diff --git a/client/src/components/tables/components/SelectableRanking.tsx b/client/src/components/tables/components/SelectableRanking.tsx new file mode 100644 index 000000000..84ddef0da --- /dev/null +++ b/client/src/components/tables/components/SelectableRanking.tsx @@ -0,0 +1,64 @@ +import useLUGPTSettings from "components/util/useLUGPTSettings"; +import React from "react"; +import { SetState } from "types/react"; +import { RankingViewMode } from "../cells/RankingCell"; +import SortableTH from "./SortableTH"; +import { ZTableTHProps } from "./TachiTable"; + +export default function SelectableRanking({ + rankingViewMode, + setRankingViewMode, + changeSort, + currentSortMode, + reverseSort, +}: { + rankingViewMode: RankingViewMode; + setRankingViewMode: SetState; +} & ZTableTHProps) { + const { settings } = useLUGPTSettings(); + + if (rankingViewMode === "both-if-self" || rankingViewMode === "global-no-switch" || !settings) { + return ( + + ); + } + + return ( + + +
+ changeSort("Site Ranking")}> + + + + + ); +} diff --git a/client/src/components/tables/folders/FolderTable.tsx b/client/src/components/tables/folders/FolderTable.tsx index 88ad80192..616d5c15e 100644 --- a/client/src/components/tables/folders/FolderTable.tsx +++ b/client/src/components/tables/folders/FolderTable.tsx @@ -6,9 +6,10 @@ import { Game, IDStrings, ScoreCalculatedDataLookup } from "tachi-common"; import { FolderDataset } from "types/tables"; import { Playtype } from "types/tachi"; import Muted from "components/util/Muted"; +import useLUGPTSettings from "components/util/useLUGPTSettings"; import DifficultyCell from "../cells/DifficultyCell"; import IndicatorsCell from "../cells/IndicatorsCell"; -import RankingCell from "../cells/RankingCell"; +import RankingCell, { RankingViewMode } from "../cells/RankingCell"; import TimestampCell from "../cells/TimestampCell"; import TitleCell from "../cells/TitleCell"; import DropdownRow from "../components/DropdownRow"; @@ -19,6 +20,7 @@ import ScoreCoreCells from "../game-core-cells/ScoreCoreCells"; import ChartHeader from "../headers/ChartHeader"; import { GetGPTCoreHeaders } from "../headers/GameHeaders"; import { EmptyHeader, FolderIndicatorHeader } from "../headers/IndicatorHeader"; +import { CreateRankingHeader } from "../headers/RankingHeader"; export default function FolderTable({ dataset, @@ -31,7 +33,10 @@ export default function FolderTable({ }) { const defaultRating = useScoreRatingAlg(game, playtype); + const { settings } = useLUGPTSettings(); + const [rating, setRating] = useState(defaultRating); + const [rankingViewMode, setRankingViewMode] = useState("global"); const headers: Header[] = [ ChartHeader(game, (k) => k), @@ -45,11 +50,11 @@ export default function FolderTable({ setRating, (x) => x.__related.pb ), - [ - "Site Ranking", - "Site Rank", - NumericSOV((x) => x.__related.pb?.rankingData.rank ?? -Infinity), - ], + CreateRankingHeader( + rankingViewMode, + setRankingViewMode, + (k) => k.__related.pb?.rankingData + ), ["Last Raised", "Last Raised", NumericSOV((x) => x.__related.pb?.timeAchieved ?? 0)], ]; @@ -60,7 +65,13 @@ export default function FolderTable({ entryName="Charts" searchFunctions={CreateDefaultFolderSearchParams(game, playtype)} rowFunction={(data) => ( - + )} /> ); @@ -70,10 +81,12 @@ function Row({ data, rating, game, + rankingViewMode, }: { data: FolderDataset[0]; game: Game; rating: ScoreCalculatedDataLookup[I]; + rankingViewMode: RankingViewMode; }) { const score = data.__related.pb; @@ -110,7 +123,11 @@ function Row({ PB - + ); diff --git a/client/src/components/tables/headers/RankingHeader.tsx b/client/src/components/tables/headers/RankingHeader.tsx new file mode 100644 index 000000000..9f3b28fa8 --- /dev/null +++ b/client/src/components/tables/headers/RankingHeader.tsx @@ -0,0 +1,37 @@ +import { NumericSOV } from "util/sorts"; +import React from "react"; +import { PBScoreDocument } from "tachi-common"; +import { SetState } from "types/react"; +import { RankingViewMode } from "../cells/RankingCell"; +import SelectableRanking from "../components/SelectableRanking"; +import { Header, ZTableTHProps } from "../components/TachiTable"; + +export function CreateRankingHeader( + rankingViewMode: RankingViewMode, + setRankingViewMode: SetState, + kMapToRankingData: (k: T) => PBScoreDocument["rankingData"] | undefined +): Header { + return [ + "Site Ranking", + "Site Ranking", + + NumericSOV((x) => { + const rankingData = kMapToRankingData(x); + + if (!rankingData) { + return -Infinity; + } + + return rankingViewMode === "rival" + ? rankingData.rivalRank ?? -Infinity + : rankingData.rank; + }), + (thProps: ZTableTHProps) => ( + + ), + ]; +} diff --git a/client/src/components/tables/pbs/PBTable.tsx b/client/src/components/tables/pbs/PBTable.tsx index 33091825d..6c859b3d7 100644 --- a/client/src/components/tables/pbs/PBTable.tsx +++ b/client/src/components/tables/pbs/PBTable.tsx @@ -8,7 +8,7 @@ import { PBDataset } from "types/tables"; import { Playtype } from "types/tachi"; import DropdownIndicatorCell from "../cells/DropdownIndicatorCell"; import IndexCell from "../cells/IndexCell"; -import RankingCell from "../cells/RankingCell"; +import RankingCell, { RankingViewMode } from "../cells/RankingCell"; import TimestampCell from "../cells/TimestampCell"; import DropdownRow from "../components/DropdownRow"; import TachiTable, { Header } from "../components/TachiTable"; @@ -18,6 +18,7 @@ import ScoreCoreCells from "../game-core-cells/ScoreCoreCells"; import ChartHeader from "../headers/ChartHeader"; import { GetGPTCoreHeaders } from "../headers/GameHeaders"; import { EmptyHeader } from "../headers/IndicatorHeader"; +import { CreateRankingHeader } from "../headers/RankingHeader"; import PBLeadingRows from "./PBLeadingRows"; export default function PBTable({ @@ -29,6 +30,7 @@ export default function PBTable({ showChart = true, playtype, alg, + defaultRankingViewMode, }: { dataset: PBDataset; indexCol?: boolean; @@ -38,10 +40,14 @@ export default function PBTable({ playtype: Playtype; game: Game; alg?: ScoreCalculatedDataLookup[I]; + defaultRankingViewMode?: RankingViewMode; }) { const defaultRating = useScoreRatingAlg(game, playtype); const [rating, setRating] = useState(alg ?? defaultRating); + const [rankingViewMode, setRankingViewMode] = useState( + defaultRankingViewMode ?? "global" + ); const headers: Header[0]>[] = [ ...GetPBLeadingHeaders( @@ -51,7 +57,7 @@ export default function PBTable({ ), EmptyHeader, ...GetGPTCoreHeaders(game, playtype, rating, setRating, (x) => x), - ["Site Ranking", "Site Rank", NumericSOV((x) => x.rankingData.rank)], + CreateRankingHeader(rankingViewMode, setRankingViewMode, (k) => k.rankingData), ["Last Raised", "Last Raised", NumericSOV((x) => x.timeAchieved ?? 0)], EmptyHeader, ]; @@ -86,6 +92,7 @@ export default function PBTable({ rating={rating} game={game} playtype={playtype} + rankingViewMode={rankingViewMode} /> )} /> @@ -100,6 +107,7 @@ function Row({ showUser, game, rating, + rankingViewMode, }: { pb: PBDataset[0]; indexCol: boolean; @@ -110,6 +118,7 @@ function Row({ playtype: Playtype; // ts bug? rating: any; // ScoreCalculatedDataLookup[I]; + rankingViewMode: RankingViewMode; }) { const scoreState = usePBState(pb); @@ -128,7 +137,11 @@ function Row({ {indexCol && } - + {showPlaycount && {pb.__playcount ?? 0}} diff --git a/client/src/components/tables/rivals/RivalChartTable.tsx b/client/src/components/tables/rivals/RivalChartTable.tsx index 102f98686..03a5495eb 100644 --- a/client/src/components/tables/rivals/RivalChartTable.tsx +++ b/client/src/components/tables/rivals/RivalChartTable.tsx @@ -4,7 +4,7 @@ import React, { useState } from "react"; import { ChartDocument, Game, IDStrings, ScoreCalculatedDataLookup } from "tachi-common"; import { RivalChartDataset } from "types/tables"; import IndexCell from "../cells/IndexCell"; -import RankingCell from "../cells/RankingCell"; +import RankingCell, { RankingViewMode } from "../cells/RankingCell"; import TimestampCell from "../cells/TimestampCell"; import UserCell from "../cells/UserCell"; import DropdownRow from "../components/DropdownRow"; @@ -13,6 +13,7 @@ import { GraphAndJudgementDataComponent } from "../dropdowns/components/Document import { GPTDropdownSettings } from "../dropdowns/GPTDropdownSettings"; import ScoreCoreCells from "../game-core-cells/ScoreCoreCells"; import { GetGPTCoreHeaders } from "../headers/GameHeaders"; +import { CreateRankingHeader } from "../headers/RankingHeader"; export default function RivalChartTable({ dataset, @@ -28,6 +29,7 @@ export default function RivalChartTable({ const defaultRating = useScoreRatingAlg(game, playtype); const [rating, setRating] = useState(defaultRating); + const [rankingViewMode, setRankingViewMode] = useState("global"); const headers: Header[] = [ ["#", "#", NumericSOV((x) => x.__related.index)], @@ -39,11 +41,11 @@ export default function RivalChartTable({ setRating, (x) => x.__related.pb ), - [ - "Site Ranking", - "Site Rank", - NumericSOV((x) => x.__related.pb?.rankingData.rank ?? -Infinity), - ], + CreateRankingHeader( + rankingViewMode, + setRankingViewMode, + (k) => k.__related.pb?.rankingData + ), ["Last Raised", "Last Raised", NumericSOV((x) => x.__related.pb?.timeAchieved ?? 0)], ]; @@ -55,7 +57,14 @@ export default function RivalChartTable({ noTopDisplayStr defaultSortMode="#" rowFunction={(data) => ( - + )} /> ); @@ -66,11 +75,13 @@ function Row({ rating, game, chart, + rankingViewMode, }: { data: RivalChartDataset[0]; game: Game; rating: ScoreCalculatedDataLookup[I]; chart: ChartDocument; + rankingViewMode: RankingViewMode; }) { const pb = data.__related.pb; @@ -98,7 +109,11 @@ function Row({ - + ); diff --git a/client/src/context/UGPTContext.tsx b/client/src/context/UGPTContext.tsx index 897c839c7..8251b3045 100644 --- a/client/src/context/UGPTContext.tsx +++ b/client/src/context/UGPTContext.tsx @@ -4,7 +4,6 @@ import fetchUGPTData, { UGPTData } from "components/util/query/fetchUGPTData"; import React, { createContext, useContext, useEffect, useState } from "react"; import { useParams } from "react-router-dom"; import { JustChildren, SetState } from "types/react"; -import { ErrorPage } from "app/pages/ErrorPage"; import { UserContext } from "./UserContext"; /** @@ -33,7 +32,6 @@ export const UGPTContext = createContext<{ setLoggedInData: () => void 0, viewingData: null, - setViewingData: () => void 0, }); @@ -106,15 +104,6 @@ export function UGPTContextProvider({ children }: JustChildren) { return ; } - if (!viewingData) { - return ( - - ); - } - return ( ( score: (x) => x.scoreData.score, percent: (x) => x.scoreData.percent, ranking: (x) => x.rankingData.rank, + rivalRanking: (x) => x.rankingData.rivalRank, highlight: (x) => !!x.highlight, username: (x) => x.__related.user?.username ?? null, lamp: { @@ -98,9 +99,10 @@ export function CreateDefaultFolderSearchParams title: (x) => x.__related.song.title, difficulty: (x) => x.difficulty, level: (x) => x.levelNum, - score: (x) => x.__related.pb?.scoreData.score ?? -Infinity, - percent: (x) => x.__related.pb?.scoreData.percent ?? -Infinity, - ranking: (x) => x.__related.pb?.rankingData.rank ?? -Infinity, + score: (x) => x.__related.pb?.scoreData.score ?? null, + percent: (x) => x.__related.pb?.scoreData.percent ?? null, + ranking: (x) => x.__related.pb?.rankingData.rank ?? null, + rivalRanking: (x) => x.__related.pb?.rankingData.rivalRank ?? null, highlight: (x) => !!x.__related.pb?.highlight, played: (x) => !!x.__related.pb, lamp: { diff --git a/client/src/util/ztable/search.ts b/client/src/util/ztable/search.ts index 3ce3caf65..71bf0824b 100644 --- a/client/src/util/ztable/search.ts +++ b/client/src/util/ztable/search.ts @@ -204,6 +204,16 @@ export function GetValueGetter(v: ValueGetterOrHybrid) { return v.valueGetter; } +function GetValueInsensitive(record: Record, key: string): T | undefined { + for (const [recKey, value] of Object.entries(record)) { + if (recKey.toLowerCase() === key.toLowerCase()) { + return value; + } + } + + return undefined; +} + export function ComposeSearchFunction( valueGetters: Record> ): ZTableSearchFn { @@ -237,7 +247,7 @@ export function ComposeSearchFunction( } for (const directive of directives) { - const vgOrHybrid = valueGetters[directive.key]; + const vgOrHybrid = GetValueInsensitive(valueGetters, directive.key); if (!vgOrHybrid) { continue; diff --git a/server/src/lib/score-import/framework/pb/create-pb-doc.ts b/server/src/lib/score-import/framework/pb/create-pb-doc.ts index eb25ad973..5fd06d12c 100644 --- a/server/src/lib/score-import/framework/pb/create-pb-doc.ts +++ b/server/src/lib/score-import/framework/pb/create-pb-doc.ts @@ -89,12 +89,13 @@ export async function UpdateChartRanking(game: Game, playtype: Playtype, chartID for (const score of scores) { rank++; + seenUserIDs.push(score.userID); const thisUsersRivals = allRivals[score.userID]; let rivalRank: integer | null = null; - if (thisUsersRivals) { + if (thisUsersRivals && thisUsersRivals.length > 0) { rivalRank = thisUsersRivals.filter((e) => seenUserIDs.includes(e)).length + 1; } From 5941902257b66e2e71d5bc8f2011f93e4897ce19 Mon Sep 17 00:00:00 2001 From: zkldi <20380519+zkldi@users.noreply.github.com> Date: Sun, 16 Oct 2022 15:26:00 +0100 Subject: [PATCH 08/10] feat: set default rival or global rankings --- .../_game/_playtype/GPTLeaderboardsPage.tsx | 1 + .../games/_game/_playtype/ScoresPage.tsx | 6 +++- .../_game/_playtype/UGPTSettingsPage.tsx | 16 +++++++++++ .../components/tables/folders/FolderTable.tsx | 10 ++++--- client/src/components/tables/pbs/PBTable.tsx | 2 +- .../components/util/usePreferredRanking.tsx | 10 +++++++ common/src/types.ts | 1 + .../lib/game-settings/create-game-settings.ts | 1 + server/src/lib/migration/migrations.ts | 3 +- .../add-preferredRanking-to-ugpt.ts | 28 +++++++++++++++++++ .../games/_game/_playtype/settings/router.ts | 5 ++++ 11 files changed, 76 insertions(+), 7 deletions(-) create mode 100644 client/src/components/util/usePreferredRanking.tsx create mode 100644 server/src/lib/migration/migrations/add-preferredRanking-to-ugpt.ts diff --git a/client/src/app/pages/dashboard/games/_game/_playtype/GPTLeaderboardsPage.tsx b/client/src/app/pages/dashboard/games/_game/_playtype/GPTLeaderboardsPage.tsx index 6e5786917..4e3b3c4be 100644 --- a/client/src/app/pages/dashboard/games/_game/_playtype/GPTLeaderboardsPage.tsx +++ b/client/src/app/pages/dashboard/games/_game/_playtype/GPTLeaderboardsPage.tsx @@ -236,6 +236,7 @@ function ScoreLeaderboard({ game, playtype }: GamePT) { showChart indexCol alg={alg} + defaultRankingViewMode="both-if-self" /> ); diff --git a/client/src/app/pages/dashboard/users/games/_game/_playtype/ScoresPage.tsx b/client/src/app/pages/dashboard/users/games/_game/_playtype/ScoresPage.tsx index 8fa9a5934..e66731f44 100644 --- a/client/src/app/pages/dashboard/users/games/_game/_playtype/ScoresPage.tsx +++ b/client/src/app/pages/dashboard/users/games/_game/_playtype/ScoresPage.tsx @@ -27,6 +27,7 @@ import { UnsuccessfulAPIResponse, } from "tachi-common"; import { GamePT, SetState } from "types/react"; +import usePreferredRanking from "components/util/usePreferredRanking"; export default function ScoresPage({ reqUser, @@ -177,6 +178,8 @@ function PBsOverview({ const { data, error } = useFetchPBs(url, reqUser); + const preferredRanking = usePreferredRanking(); + return (
@@ -195,7 +198,8 @@ function PBsOverview({ showPlaycount={showPlaycount} indexCol={indexCol} alg={alg} - playtype={playtype as "SP" | "DP"} + playtype={playtype} + defaultRankingViewMode={preferredRanking} /> ) : ( diff --git a/client/src/app/pages/dashboard/users/games/_game/_playtype/UGPTSettingsPage.tsx b/client/src/app/pages/dashboard/users/games/_game/_playtype/UGPTSettingsPage.tsx index cc917275b..3c29a2b5e 100644 --- a/client/src/app/pages/dashboard/users/games/_game/_playtype/UGPTSettingsPage.tsx +++ b/client/src/app/pages/dashboard/users/games/_game/_playtype/UGPTSettingsPage.tsx @@ -104,6 +104,7 @@ function PreferencesForm({ reqUser, game, playtype }: Props) { gameSpecific: settings!.preferences.gameSpecific as any, defaultTable: settings!.preferences.defaultTable, scoreBucket: settings!.preferences.scoreBucket ?? gptConfig.scoreBucket, + preferredRanking: settings!.preferences.preferredRanking ?? "global", }, onSubmit: async (values) => { const rj = await APIFetchV1( @@ -221,6 +222,21 @@ function PreferencesForm({ reqUser, game, playtype }: Props) { breakdown. You can still view all the same stats! + + Preferred Ranking + + + + + + What should {TachiConfig.name} default to when showing your score rankings? + + Preferred Table ({ }) { const defaultRating = useScoreRatingAlg(game, playtype); - const { settings } = useLUGPTSettings(); + const preferredRanking = usePreferredRanking(); const [rating, setRating] = useState(defaultRating); - const [rankingViewMode, setRankingViewMode] = useState("global"); + const [rankingViewMode, setRankingViewMode] = useState( + preferredRanking ?? "global" + ); const headers: Header[] = [ ChartHeader(game, (k) => k), diff --git a/client/src/components/tables/pbs/PBTable.tsx b/client/src/components/tables/pbs/PBTable.tsx index 6c859b3d7..aadc52f87 100644 --- a/client/src/components/tables/pbs/PBTable.tsx +++ b/client/src/components/tables/pbs/PBTable.tsx @@ -40,7 +40,7 @@ export default function PBTable({ playtype: Playtype; game: Game; alg?: ScoreCalculatedDataLookup[I]; - defaultRankingViewMode?: RankingViewMode; + defaultRankingViewMode?: RankingViewMode | null; }) { const defaultRating = useScoreRatingAlg(game, playtype); diff --git a/client/src/components/util/usePreferredRanking.tsx b/client/src/components/util/usePreferredRanking.tsx new file mode 100644 index 000000000..54ec630a9 --- /dev/null +++ b/client/src/components/util/usePreferredRanking.tsx @@ -0,0 +1,10 @@ +import { UGPTSettings } from "tachi-common"; +import useLUGPTSettings from "./useLUGPTSettings"; + +export default function usePreferredRanking(): + | UGPTSettings["preferences"]["preferredRanking"] + | null { + const { settings } = useLUGPTSettings(); + + return settings?.preferences.preferredRanking ?? null; +} diff --git a/common/src/types.ts b/common/src/types.ts index 549c2f740..6845c0211 100644 --- a/common/src/types.ts +++ b/common/src/types.ts @@ -1471,6 +1471,7 @@ export interface UGPTSettings extends MongoDBDo stats: Array; scoreBucket: "grade" | "lamp" | null; defaultTable: string | null; + preferredRanking: "global" | "rival" | null; gameSpecific: UGPTSpecificPreferences[I]; }; rivals: Array; diff --git a/server/src/lib/game-settings/create-game-settings.ts b/server/src/lib/game-settings/create-game-settings.ts index 35f030966..d9e81c14b 100644 --- a/server/src/lib/game-settings/create-game-settings.ts +++ b/server/src/lib/game-settings/create-game-settings.ts @@ -43,6 +43,7 @@ export async function CreateGameSettings(userID: integer, game: Game, playtype: preferredScoreAlg: null, scoreBucket: null, defaultTable: null, + preferredRanking: null, stats: [], gameSpecific, }, diff --git a/server/src/lib/migration/migrations.ts b/server/src/lib/migration/migrations.ts index 6e3fbb1c5..16eeaa821 100644 --- a/server/src/lib/migration/migrations.ts +++ b/server/src/lib/migration/migrations.ts @@ -1,3 +1,4 @@ +import UGPTAddPreferredRanking from "./migrations/add-preferredRanking-to-ugpt"; import UGPTRivalsMigration from "./migrations/add-rivals-to-ugpt"; import RecalcBrokenIIDXNotecounts from "./migrations/recalc-broken-iidx-notecounts"; import RemoveIIDXBeginners from "./migrations/remove-iidx-beginners"; @@ -27,7 +28,7 @@ export const FAKE_MIGRATION: Migration = { const REGISTERED_MIGRATIONS: Array = Environment.nodeEnv === "test" ? [FAKE_MIGRATION] - : [UGPTRivalsMigration, RemoveMultifolderStats]; + : [UGPTRivalsMigration, RemoveMultifolderStats, UGPTAddPreferredRanking]; // only apply type-specific migrations if we're not in testing if (Environment.nodeEnv !== "test") { diff --git a/server/src/lib/migration/migrations/add-preferredRanking-to-ugpt.ts b/server/src/lib/migration/migrations/add-preferredRanking-to-ugpt.ts new file mode 100644 index 000000000..8ec001cc5 --- /dev/null +++ b/server/src/lib/migration/migrations/add-preferredRanking-to-ugpt.ts @@ -0,0 +1,28 @@ +import db from "external/mongo/db"; +import type { Migration } from "utils/types"; + +const migration: Migration = { + id: "add-preferredRanking-to-ugpt", + up: async () => { + await db["game-settings"].update( + {}, + { + $set: { + "preferences.preferredRanking": null, + }, + }, + { multi: true } + ); + }, + down: async () => { + await db["game-settings"].update( + {}, + { + $unset: { "preferences.preferredRanking": 1 }, + }, + { multi: true } + ); + }, +}; + +export default migration; diff --git a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/settings/router.ts b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/settings/router.ts index 06b18214e..bf1be647e 100644 --- a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/settings/router.ts +++ b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/settings/router.ts @@ -53,6 +53,7 @@ router.patch( preferredSessionAlg: p.optional(p.nullable(p.isIn(gptConfig.sessionRatingAlgs))), preferredProfileAlg: p.optional(p.nullable(p.isIn(gptConfig.profileRatingAlgs))), defaultTable: "*?string", + preferredRanking: optNull(p.isIn("global", "rival")), // This is handled with game-specific schema validation below. gameSpecific: optNull(gameSpecificSchema), @@ -104,6 +105,10 @@ router.patch( updateQuery[`preferences.defaultTable`] = body.defaultTable; } + if (body.preferredRanking !== undefined) { + updateQuery[`preferences.preferredRanking`] = body.preferredRanking; + } + if (body.gameSpecific) { for (const [key, value] of Object.entries(body.gameSpecific)) { // @ts-expect-error This is a very hacky way of applying changes. From 3e8dd5a4a6228f554e7d76906a586545c247f51c Mon Sep 17 00:00:00 2001 From: zkldi <20380519+zkldi@users.noreply.github.com> Date: Sun, 16 Oct 2022 18:03:11 +0100 Subject: [PATCH 09/10] feat: rivals scorederboard --- .../_game/_playtype/GPTLeaderboardsPage.tsx | 92 ++--------- .../_playtype/rivals/RivalsComparePage.tsx | 80 ++++++++++ .../_game/_playtype/rivals/RivalsMainPage.tsx | 10 +- .../_playtype/rivals/RivalsManagePage.tsx | 11 +- .../src/components/game/ScoreLeaderboard.tsx | 146 ++++++++++++++++++ .../src/components/util/SelectLinkButton.tsx | 8 +- client/src/util/routing.ts | 8 +- .../games/_game/_playtype/rivals/router.ts | 74 ++++++++- 8 files changed, 335 insertions(+), 94 deletions(-) create mode 100644 client/src/app/pages/dashboard/users/games/_game/_playtype/rivals/RivalsComparePage.tsx create mode 100644 client/src/components/game/ScoreLeaderboard.tsx diff --git a/client/src/app/pages/dashboard/games/_game/_playtype/GPTLeaderboardsPage.tsx b/client/src/app/pages/dashboard/games/_game/_playtype/GPTLeaderboardsPage.tsx index 4e3b3c4be..8f859eea4 100644 --- a/client/src/app/pages/dashboard/games/_game/_playtype/GPTLeaderboardsPage.tsx +++ b/client/src/app/pages/dashboard/games/_game/_playtype/GPTLeaderboardsPage.tsx @@ -1,12 +1,12 @@ -import { CreateChartMap, CreateSongMap, CreateUserMap } from "util/data"; +import { CreateUserMap } from "util/data"; import { UppercaseFirst } from "util/misc"; import { NumericSOV, StrSOV } from "util/sorts"; import ClassBadge from "components/game/ClassBadge"; +import ScoreLeaderboard from "components/game/ScoreLeaderboard"; import useSetSubheader from "components/layout/header/useSetSubheader"; import IndexCell from "components/tables/cells/IndexCell"; import UserCell from "components/tables/cells/UserCell"; import TachiTable, { Header } from "components/tables/components/TachiTable"; -import PBTable from "components/tables/pbs/PBTable"; import ApiError from "components/util/ApiError"; import Divider from "components/util/Divider"; import Icon from "components/util/Icon"; @@ -14,13 +14,13 @@ import Loading from "components/util/Loading"; import Muted from "components/util/Muted"; import useApiQuery from "components/util/query/useApiQuery"; import SelectButton from "components/util/SelectButton"; -import useScoreRatingAlg, { useProfileRatingAlg } from "components/util/useScoreRatingAlg"; +import { useProfileRatingAlg } from "components/util/useScoreRatingAlg"; import React, { useState } from "react"; import { Col, Form, Row } from "react-bootstrap"; import { FormatGame, GetGameConfig, GetGamePTConfig } from "tachi-common"; -import { ScoreLeaderboardReturns, UserLeaderboardReturns } from "types/api-returns"; +import { UserLeaderboardReturns } from "types/api-returns"; import { GamePT } from "types/react"; -import { PBDataset, UGSDataset } from "types/tables"; +import { UGSDataset } from "types/tables"; export default function GPTLeaderboardsPage({ game, playtype }: GamePT) { useSetSubheader( @@ -50,7 +50,11 @@ export default function GPTLeaderboardsPage({ game, playtype }: GamePT) { {mode === "profile" ? ( ) : ( - + )} @@ -165,79 +169,3 @@ function ProfileLeaderboard({ game, playtype }: GamePT) { ); } - -function ScoreLeaderboard({ game, playtype }: GamePT) { - const gptConfig = GetGamePTConfig(game, playtype); - - const defaultAlg = useScoreRatingAlg(game, playtype); - - const [alg, setAlg] = useState(defaultAlg); - - const SelectComponent = - gptConfig.scoreRatingAlgs.length > 1 ? ( - setAlg(e.target.value as any)}> - {gptConfig.scoreRatingAlgs.map((e) => ( - - ))} - - ) : null; - - const { data, error } = useApiQuery( - `/games/${game}/${playtype}/score-leaderboard?alg=${alg}` - ); - - if (error) { - return ( - <> - {SelectComponent} - - - ); - } - - if (!data) { - return ( - <> - {SelectComponent} - - - ); - } - - const songMap = CreateSongMap(data.songs); - const chartMap = CreateChartMap(data.charts); - const userMap = CreateUserMap(data.users); - - const pbDataset: PBDataset = []; - - for (const [index, pb] of data.pbs.entries()) { - pbDataset.push({ - ...pb, - __related: { - chart: chartMap.get(pb.chartID)!, - song: songMap.get(pb.songID)!, - index, - user: userMap.get(pb.userID)!, - }, - }); - } - - return ( - <> - {SelectComponent} - - - - ); -} diff --git a/client/src/app/pages/dashboard/users/games/_game/_playtype/rivals/RivalsComparePage.tsx b/client/src/app/pages/dashboard/users/games/_game/_playtype/rivals/RivalsComparePage.tsx new file mode 100644 index 000000000..8a5bd1f99 --- /dev/null +++ b/client/src/app/pages/dashboard/users/games/_game/_playtype/rivals/RivalsComparePage.tsx @@ -0,0 +1,80 @@ +import ScoreLeaderboard from "components/game/ScoreLeaderboard"; +import useSetSubheader from "components/layout/header/useSetSubheader"; +import Divider from "components/util/Divider"; +import Icon from "components/util/Icon"; +import SelectLinkButton from "components/util/SelectLinkButton"; +import useUGPTBase from "components/util/useUGPTBase"; +import React from "react"; +import { Col, Row } from "react-bootstrap"; +import { Route, Switch } from "react-router-dom"; +import { FormatGame, Game, GetGameConfig, Playtype, PublicUserDocument } from "tachi-common"; + +export default function RivalsComparePage({ + reqUser, + game, + playtype, +}: { + reqUser: PublicUserDocument; + game: Game; + playtype: Playtype; +}) { + const gameConfig = GetGameConfig(game); + + useSetSubheader( + ["Users", reqUser.username, "Games", gameConfig.name, playtype, "Rivals", "Compare"], + [reqUser, game, playtype], + `Comparing ${reqUser.username}'s ${FormatGame(game, playtype)} Rivals` + ); + + const base = `${useUGPTBase({ reqUser, game, playtype })}/rivals/compare`; + + return ( + + +
+ + + Compare Top 100s + + + + Best PBs + + + + Folder Comparisons + +
+ + + + + + top100 + + + + + + + + folders + + + +
+ ); +} diff --git a/client/src/app/pages/dashboard/users/games/_game/_playtype/rivals/RivalsMainPage.tsx b/client/src/app/pages/dashboard/users/games/_game/_playtype/rivals/RivalsMainPage.tsx index 1cbcef9a8..816d452cc 100644 --- a/client/src/app/pages/dashboard/users/games/_game/_playtype/rivals/RivalsMainPage.tsx +++ b/client/src/app/pages/dashboard/users/games/_game/_playtype/rivals/RivalsMainPage.tsx @@ -7,6 +7,7 @@ import React from "react"; import { Col, Row } from "react-bootstrap"; import { Route, Switch } from "react-router-dom"; import { FormatGame, Game, GetGameConfig, Playtype, PublicUserDocument } from "tachi-common"; +import RivalsComparePage from "./RivalsComparePage"; import RivalsManagePage from "./RivalsManagePage"; export default function RivalsMainPage({ @@ -36,7 +37,7 @@ export default function RivalsMainPage({ Goals & Milestones - + Compare @@ -82,11 +83,8 @@ export default function RivalsMainPage({ goals milestones - - compare + + diff --git a/client/src/app/pages/dashboard/users/games/_game/_playtype/rivals/RivalsManagePage.tsx b/client/src/app/pages/dashboard/users/games/_game/_playtype/rivals/RivalsManagePage.tsx index 047554054..2d857089c 100644 --- a/client/src/app/pages/dashboard/users/games/_game/_playtype/rivals/RivalsManagePage.tsx +++ b/client/src/app/pages/dashboard/users/games/_game/_playtype/rivals/RivalsManagePage.tsx @@ -12,8 +12,9 @@ import UserIcon from "components/util/UserIcon"; import { UserContext } from "context/UserContext"; import React, { useContext, useState } from "react"; import { Button, Col } from "react-bootstrap"; -import { Game, Playtype, PublicUserDocument } from "tachi-common"; +import { FormatGame, Game, GetGameConfig, Playtype, PublicUserDocument } from "tachi-common"; import useLUGPTSettings from "components/util/useLUGPTSettings"; +import useSetSubheader from "components/layout/header/useSetSubheader"; export default function RivalsManagePage({ reqUser, @@ -24,6 +25,14 @@ export default function RivalsManagePage({ game: Game; playtype: Playtype; }) { + const gameConfig = GetGameConfig(game); + + useSetSubheader( + ["Users", reqUser.username, "Games", gameConfig.name, playtype, "Rivals", "Manage"], + [reqUser, game, playtype], + `Managing ${reqUser.username}'s ${FormatGame(game, playtype)} Rivals` + ); + const { data, error } = useApiQuery( `/users/${reqUser.id}/games/${game}/${playtype}/rivals` ); diff --git a/client/src/components/game/ScoreLeaderboard.tsx b/client/src/components/game/ScoreLeaderboard.tsx new file mode 100644 index 000000000..44b43bdd9 --- /dev/null +++ b/client/src/components/game/ScoreLeaderboard.tsx @@ -0,0 +1,146 @@ +import { DEFAULT_BAR_PROPS } from "util/charts"; +import { CreateChartMap, CreateUserMap } from "util/data"; +import { UppercaseFirst } from "util/misc"; +import { NumericSOV, StrSOV } from "util/sorts"; +import { ResponsiveBar } from "@nivo/bar"; +import PBTable from "components/tables/pbs/PBTable"; +import ApiError from "components/util/ApiError"; +import Loading from "components/util/Loading"; +import useApiQuery from "components/util/query/useApiQuery"; +import useScoreRatingAlg from "components/util/useScoreRatingAlg"; +import React, { useState } from "react"; +import { Form } from "react-bootstrap"; +import { COLOUR_SET, CreateSongMap, GetGamePTConfig, integer } from "tachi-common"; +import { ScoreLeaderboardReturns } from "types/api-returns"; +import { GamePT } from "types/react"; +import { PBDataset } from "types/tables"; +import Divider from "components/util/Divider"; + +const USER_COLOURS = [ + COLOUR_SET.blue, + COLOUR_SET.red, + COLOUR_SET.green, + COLOUR_SET.pink, + COLOUR_SET.purple, + COLOUR_SET.orange, +]; + +export default function ScoreLeaderboard({ game, playtype, url }: GamePT & { url: string }) { + const gptConfig = GetGamePTConfig(game, playtype); + + const defaultAlg = useScoreRatingAlg(game, playtype); + + const [alg, setAlg] = useState(defaultAlg); + + const SelectComponent = + gptConfig.scoreRatingAlgs.length > 1 ? ( + setAlg(e.target.value as any)}> + {gptConfig.scoreRatingAlgs.map((e) => ( + + ))} + + ) : null; + + const { data, error } = useApiQuery(`${url}?alg=${alg}`); + + if (error) { + return ( + <> + {SelectComponent} + + + ); + } + + if (!data) { + return ( + <> + {SelectComponent} + + + ); + } + + const songMap = CreateSongMap(data.songs); + const chartMap = CreateChartMap(data.charts); + const userMap = CreateUserMap(data.users); + + const pbDataset: PBDataset = []; + + for (const [index, pb] of data.pbs.entries()) { + pbDataset.push({ + ...pb, + __related: { + chart: chartMap.get(pb.chartID)!, + song: songMap.get(pb.songID)!, + index, + user: userMap.get(pb.userID)!, + }, + }); + } + + return ( + <> + {SelectComponent} + + + + + + ); +} + +function DistributionChart({ dataset }: { dataset: PBDataset }) { + // username -> scores in the top N + const dist: Record = {}; + + for (const pb of dataset) { + const key = pb.__related.user?.username; + + if (key === undefined) { + // shouldn't be possible, but lets not have the ui crash + // because of something so menial + continue; + } + + if (key in dist) { + dist[key]++; + } else { + dist[key] = 1; + } + } + + const aggregatedData = Object.entries(dist) + .map(([username, count]) => ({ username, count })) + .sort(NumericSOV((x) => x.count)); + + const usernames = [...new Set(aggregatedData.map((e) => e.username))]; + + return ( +
+ + USER_COLOURS[(usernames.indexOf(x.data.username) ?? 0) % USER_COLOURS.length] + } + margin={{ left: 80, bottom: 40, top: 20, right: 20 }} + tooltip={() => <>} + {...DEFAULT_BAR_PROPS} + /> +
+ ); +} diff --git a/client/src/components/util/SelectLinkButton.tsx b/client/src/components/util/SelectLinkButton.tsx index 69058c82a..7d229f55a 100644 --- a/client/src/components/util/SelectLinkButton.tsx +++ b/client/src/components/util/SelectLinkButton.tsx @@ -9,15 +9,21 @@ export default function SelectLinkButton({ onVariant = "primary", offVariant = "outline-secondary", to, + matchIfStartsWith = false, }: { onVariant?: ButtonVariant; offVariant?: ButtonVariant; to: string; + matchIfStartsWith?: boolean; } & JustChildren) { return ( {children} diff --git a/client/src/util/routing.ts b/client/src/util/routing.ts index c75f10794..a18afc729 100644 --- a/client/src/util/routing.ts +++ b/client/src/util/routing.ts @@ -1,6 +1,6 @@ import { EscapeStringRegexp } from "./misc"; -export function DoesMatchRoute(str: string, route: string) { +export function DoesMatchRoute(str: string, route: string, ends = true) { const comps = EscapeStringRegexp(route).split("/"); let regexStr = ""; @@ -12,7 +12,11 @@ export function DoesMatchRoute(str: string, route: string) { } } - regexStr += "?$"; + regexStr += "?"; + + if (ends) { + regexStr += "$"; + } const regex = new RegExp(regexStr, "u"); return !!str.match(regex); diff --git a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/rivals/router.ts b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/rivals/router.ts index 19098b63d..7a1f6f5ef 100644 --- a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/rivals/router.ts +++ b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/rivals/router.ts @@ -1,12 +1,17 @@ import { RequireAuthedAsUser } from "../../../../middleware"; import { Router } from "express"; +import db from "external/mongo/db"; import { SetRivalsFailReasons } from "lib/constants/err-codes"; -import { GetChallengerUsers, GetRivalUsers, SetRivals } from "lib/rivals/rivals"; +import { GetChallengerUsers, GetRivalIDs, GetRivalUsers, SetRivals } from "lib/rivals/rivals"; import p from "prudence"; import { RequirePermissions } from "server/middleware/auth"; import prValidate from "server/middleware/prudence-validate"; -import { FormatGame } from "tachi-common"; +import { FormatGame, GetGamePTConfig } from "tachi-common"; +import { GetRelevantSongsAndCharts } from "utils/db"; +import { IsString } from "utils/misc"; import { GetUGPT } from "utils/req-tachi-data"; +import { CheckStrScoreAlg } from "utils/string-checks"; +import { GetUsersWithIDs } from "utils/user"; import type { integer } from "tachi-common"; const router: Router = Router({ mergeParams: true }); @@ -97,4 +102,69 @@ router.get("/challengers", async (req, res) => { }); }); +/** + * Retrieve a "score leaderboard" for this user's set of rivals. + * + * This is - effectively - the best 100 scores from this set of users on the given + * rating algorithm. + * + * @param alg - The score rating algorithm to sort on. Defaults to whatever the GPTConfig + * default is. + * + * @name GET /api/v1/users/:userID/games/:game/:playtype/rivals/score-leaderboard + */ +router.get("/score-leaderboard", async (req, res) => { + const { user, game, playtype } = GetUGPT(req); + const gptConfig = GetGamePTConfig(game, playtype); + + let alg = gptConfig.defaultScoreRatingAlg; + + if (IsString(req.query.alg)) { + const temp = CheckStrScoreAlg(game, playtype, req.query.alg); + + if (temp === null) { + return res.status(400).json({ + success: false, + description: `Invalid value of ${ + req.query.alg + } for alg. Expected one of ${gptConfig.scoreRatingAlgs.join(", ")}`, + }); + } + + alg = temp; + } + + const rivalIDs = await GetRivalIDs(user.id, game, playtype); + const userSet = [...rivalIDs, user.id]; + + const pbs = await db["personal-bests"].find( + { + game, + playtype, + userID: { $in: userSet }, + }, + { + sort: { + [`calculatedData.${alg}`]: -1, + }, + limit: 100, + } + ); + + const users = await GetUsersWithIDs(pbs.map((e) => e.userID)); + + const { songs, charts } = await GetRelevantSongsAndCharts(pbs, game); + + return res.status(200).send({ + success: true, + description: `Successfully returned ${pbs.length} pbs.`, + body: { + pbs, + songs, + charts, + users, + }, + }); +}); + export default router; From 1be6acb326e5840bbf6c6c5137941f898b614a64 Mon Sep 17 00:00:00 2001 From: zkldi <20380519+zkldi@users.noreply.github.com> Date: Sun, 16 Oct 2022 19:19:58 +0100 Subject: [PATCH 10/10] fix: make server tests compile and work --- server/src/lib/showcase/get-stats.test.ts | 1 + .../_userID/games/_game/_playtype/settings/router.test.ts | 5 +++++ .../_userID/games/_game/_playtype/showcase/router.test.ts | 2 ++ server/src/test-utils/mock-db/game-settings.json | 1 + server/src/test-utils/test-data.ts | 1 + 5 files changed, 10 insertions(+) diff --git a/server/src/lib/showcase/get-stats.test.ts b/server/src/lib/showcase/get-stats.test.ts index 69ea12bf2..34f05bc45 100644 --- a/server/src/lib/showcase/get-stats.test.ts +++ b/server/src/lib/showcase/get-stats.test.ts @@ -26,6 +26,7 @@ t.test("#EvalulateUsersStatsShowcase", (t) => { preferredSessionAlg: null, scoreBucket: null, defaultTable: null, + preferredRanking: null, stats: [ { folderID: TestingIIDXFolderSP10.folderID, diff --git a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/settings/router.test.ts b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/settings/router.test.ts index 40509c96f..d080d3fab 100644 --- a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/settings/router.test.ts +++ b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/settings/router.test.ts @@ -19,6 +19,7 @@ t.test("GET /api/v1/users/:userID/games/:game/:playtype/settings", (t) => { preferredScoreAlg: null, preferredSessionAlg: null, preferredProfileAlg: null, + preferredRanking: null, scoreBucket: null, defaultTable: null, stats: [], @@ -68,6 +69,7 @@ t.test("PATCH /api/v1/users/:userID/games/:game/:playtype/settings", (t) => { preferredSessionAlg: null, preferredProfileAlg: null, scoreBucket: null, + preferredRanking: null, defaultTable: null, stats: [], gameSpecific: { @@ -123,6 +125,7 @@ t.test("PATCH /api/v1/users/:userID/games/:game/:playtype/settings", (t) => { preferredScoreAlg: "ktLampRating", preferredSessionAlg: null, preferredProfileAlg: null, + preferredRanking: null, scoreBucket: null, defaultTable: null, stats: [], @@ -183,6 +186,7 @@ t.test("PATCH /api/v1/users/:userID/games/:game/:playtype/settings", (t) => { preferredScoreAlg: null, preferredSessionAlg: null, preferredProfileAlg: null, + preferredRanking: null, scoreBucket: null, defaultTable: null, stats: [], @@ -415,6 +419,7 @@ t.test("PATCH /api/v1/users/:userID/games/:game/:playtype/settings", (t) => { preferredScoreAlg: null, preferredSessionAlg: null, preferredProfileAlg: null, + preferredRanking: null, scoreBucket: null, defaultTable: "mock_table", stats: [], diff --git a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/showcase/router.test.ts b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/showcase/router.test.ts index d5ba4d7e0..aa36692e7 100644 --- a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/showcase/router.test.ts +++ b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/showcase/router.test.ts @@ -22,6 +22,7 @@ const SetFolders = async () => { preferredProfileAlg: null, preferredScoreAlg: null, preferredSessionAlg: null, + preferredRanking: null, scoreBucket: null, defaultTable: null, stats: [ @@ -87,6 +88,7 @@ t.test("GET /api/v1/users/:userID/games/:game/:playtype/showcase", (t) => { preferredProfileAlg: null, preferredScoreAlg: null, preferredSessionAlg: null, + preferredRanking: null, scoreBucket: null, defaultTable: null, stats: [ diff --git a/server/src/test-utils/mock-db/game-settings.json b/server/src/test-utils/mock-db/game-settings.json index 7af25afa6..9f02ccb76 100644 --- a/server/src/test-utils/mock-db/game-settings.json +++ b/server/src/test-utils/mock-db/game-settings.json @@ -9,6 +9,7 @@ "preferredProfileAlg": null, "scoreBucket": null, "defaultTable": null, + "preferredRanking": null, "stats": [], "gameSpecific": { "display2DXTra": false, diff --git a/server/src/test-utils/test-data.ts b/server/src/test-utils/test-data.ts index f23171277..711aa7797 100644 --- a/server/src/test-utils/test-data.ts +++ b/server/src/test-utils/test-data.ts @@ -729,6 +729,7 @@ export const FakeGameSettings: UGPTSettings = { preferredScoreAlg: null, preferredSessionAlg: null, scoreBucket: null, + preferredRanking: null, stats: [], }, rivals: [],