Merge pull request #715 from TNG-dev/zkldi/rivals

This commit is contained in:
zkldi
2022-10-16 19:27:00 +01:00
committed by GitHub
58 changed files with 1217 additions and 209 deletions
@@ -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<ChartPBData, UnsuccessfulAPIFetchResponse>(
["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<ChartRivalsReturn>(
`/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({
<Icon type="user" />
Your Position
</SelectButton>
{user && (
<SelectButton value={mode} setValue={setMode} id="rivals">
<Icon type="user" />
VS. Rivals
</SelectButton>
)}
</div>
</Col>
<TopShowcase data={data} user={user} userMap={userMap} />
@@ -198,22 +224,24 @@ function ChartLeaderboardTable({
data: ChartPBData;
user: PublicUserDocument | null;
userMap: Map<integer, PublicUserDocument>;
mode: "leaderboard" | "adjacent";
mode: "leaderboard" | "adjacent" | "rivals";
chart: ChartDocument;
song: SongDocument;
} & GamePT) {
const dataset: PBDataset = useMemo(() => {
const ds: PBDataset = [];
let pbs = [];
let pbs: Array<PBScoreDocument> = [];
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) {
@@ -233,6 +261,7 @@ function ChartLeaderboardTable({
return (
<PBTable
defaultRankingViewMode={mode === "rivals" ? "global-no-switch" : "both-if-self"}
key={mode}
dataset={dataset}
game={game}
@@ -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" ? (
<ProfileLeaderboard game={game} playtype={playtype} />
) : (
<ScoreLeaderboard game={game} playtype={playtype} />
<ScoreLeaderboard
game={game}
playtype={playtype}
url={`/games/${game}/${playtype}/score-leaderboard`}
/>
)}
</Col>
</Row>
@@ -165,78 +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 ? (
<Form.Control as="select" value={alg} onChange={(e) => setAlg(e.target.value as any)}>
{gptConfig.scoreRatingAlgs.map((e) => (
<option key={e} value={e}>
{UppercaseFirst(e)}
</option>
))}
</Form.Control>
) : null;
const { data, error } = useApiQuery<ScoreLeaderboardReturns>(
`/games/${game}/${playtype}/score-leaderboard?alg=${alg}`
);
if (error) {
return (
<>
{SelectComponent}
<ApiError error={error} />
</>
);
}
if (!data) {
return (
<>
{SelectComponent}
<Loading />
</>
);
}
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}
<Divider />
<PBTable
dataset={pbDataset}
game={game}
playtype={playtype}
showUser
showChart
indexCol
alg={alg}
/>
</>
);
}
@@ -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 (
<div className="row">
<div className="col-12">
@@ -195,7 +198,8 @@ function PBsOverview({
showPlaycount={showPlaycount}
indexCol={indexCol}
alg={alg}
playtype={playtype as "SP" | "DP"}
playtype={playtype}
defaultRankingViewMode={preferredRanking}
/>
</LoadingWrapper>
) : (
@@ -105,6 +105,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<PublicUserDocument>(
@@ -222,6 +223,21 @@ function PreferencesForm({ reqUser, game, playtype }: Props) {
breakdown. You can still view all the same stats!
</Form.Text>
</Form.Group>
<Form.Group>
<Form.Label>Preferred Ranking</Form.Label>
<Form.Control
as="select"
id="preferredRanking"
value={formik.values.preferredRanking}
onChange={formik.handleChange}
>
<option value="global">Global Rankings</option>
<option value="rival">Rival Rankings</option>
</Form.Control>
<Form.Text className="text-muted">
What should {TachiConfig.name} default to when showing your score rankings?
</Form.Text>
</Form.Group>
<Form.Group>
<Form.Label>Preferred Table</Form.Label>
<Form.Control
@@ -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 (
<Row>
<Col xs={12} className="text-center">
<div className="btn-group">
<SelectLinkButton to={`${base}/top-100`}>
<Icon type="list" />
Compare Top 100s
</SelectLinkButton>
<SelectLinkButton to={base}>
<Icon type="sort-amount-up" />
Best PBs
</SelectLinkButton>
<SelectLinkButton to={`${base}/folders`}>
<Icon type="folder-open" />
Folder Comparisons
</SelectLinkButton>
</div>
<Divider />
</Col>
<Col xs={12}>
<Switch>
<Route
exact
path="/dashboard/users/:userID/games/:game/:playtype/rivals/compare/top-100"
>
top100
</Route>
<Route
exact
path="/dashboard/users/:userID/games/:game/:playtype/rivals/compare"
>
<ScoreLeaderboard
game={game}
playtype={playtype}
url={`/users/${reqUser.id}/games/${game}/${playtype}/rivals/score-leaderboard`}
/>
</Route>
<Route
exact
path="/dashboard/users/:userID/games/:game/:playtype/rivals/compare/folders"
>
folders
</Route>
</Switch>
</Col>
</Row>
);
}
@@ -4,10 +4,11 @@ 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 RivalsComparePage from "./RivalsComparePage";
import RivalsManagePage from "./RivalsManagePage";
export default function RivalsMainPage({
reqUser,
@@ -32,34 +33,59 @@ export default function RivalsMainPage({
<Row>
<Col xs={12} className="text-center">
<div className="btn-group">
<SelectLinkButton to={`${base}/rivals/challenge-board`}>
<Icon type="list" />
Rival Score Feed
<SelectLinkButton to={`${base}/rivals/targets`}>
<Icon type="bullseye" />
Goals & Milestones
</SelectLinkButton>
<SelectLinkButton matchIfStartsWith to={`${base}/rivals/compare`}>
<Icon type="balance-scale-left" />
Compare
</SelectLinkButton>
<SelectLinkButton to={`${base}/rivals`}>
<Icon type="list" />
Activity
</SelectLinkButton>
<SelectLinkButton to={`${base}/rivals/folders`}>
<Icon type="thumbtack" />
Manage Folders
</SelectLinkButton>
<SelectLinkButton to={`${base}/rivals/manage`}>
<Icon type="users" />
Manage Rivals
</SelectLinkButton>
<SelectLinkButton to={`${base}/rivals/pinned-charts`}>
<Icon type="thumbtack" />
Pinned Charts
</SelectLinkButton>
</div>
<Divider />
</Col>
<Col xs={12}>
<Switch>
<Route exact path="/dashboard/users/:userID/games/:game/:playtype/rivals">
<RivalsOverviewPage reqUser={reqUser} game={game} playtype={playtype} />
activity
</Route>
<Route
exact
path="/dashboard/users/:userID/games/:game/:playtype/rivals/feed"
></Route>
path="/dashboard/users/:userID/games/:game/:playtype/rivals/manage"
>
<RivalsManagePage reqUser={reqUser} game={game} playtype={playtype} />
</Route>
<Route
exact
path="/dashboard/users/:userID/games/:game/:playtype/rivals/pinned-charts"
></Route>
path="/dashboard/users/:userID/games/:game/:playtype/rivals/folders"
>
folders
</Route>
<Route
exact
path="/dashboard/users/:userID/games/:game/:playtype/rivals/targets"
>
goals milestones
</Route>
<Route path="/dashboard/users/:userID/games/:game/:playtype/rivals/compare">
<RivalsComparePage reqUser={reqUser} game={game} playtype={playtype} />
</Route>
</Switch>
</Col>
</Row>
@@ -10,11 +10,13 @@ import Muted from "components/util/Muted";
import useApiQuery from "components/util/query/useApiQuery";
import UserIcon from "components/util/UserIcon";
import { UserContext } from "context/UserContext";
import React, { useContext, useEffect, useState } from "react";
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 RivalsOverviewWrapper({
export default function RivalsManagePage({
reqUser,
game,
playtype,
@@ -23,6 +25,14 @@ export default function RivalsOverviewWrapper({
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<PublicUserDocument[]>(
`/users/${reqUser.id}/games/${game}/${playtype}/rivals`
);
@@ -79,29 +89,15 @@ function RivalsOverviewPage({
const isRequestingUser = reqUser.id === user?.id;
const [rivals, setRivals] = useState<Array<PublicUserDocument>>(initialRivals);
const [rivals, setRivals] = useState(initialRivals);
const [show, setShow] = useState(false);
const { settings, setSettings } = useLUGPTSettings();
useEffect(() => {
if (!isRequestingUser) {
return;
}
const [currentRivals, setCurrentRivals] = useState(initialRivals);
APIFetchV1(
`/users/${reqUser.id}/games/${game}/${playtype}/rivals`,
{
method: "PUT",
body: JSON.stringify({
rivalIDs: rivals.map((e) => e.id),
}),
headers: {
"Content-Type": "application/json",
},
},
false,
true
);
}, [rivals]);
if (!settings) {
return <div>Looks like you're not signed in. How did you get to this page?</div>;
}
return (
<>
@@ -144,12 +140,58 @@ function RivalsOverviewPage({
)}
</Col>
{/* kind of a stupid way to check whether the array has changed or not, but who cares. */}
{currentRivals.toString() !== rivals.toString() && (
<>
<Col xs={12}>
<Divider />
</Col>
<Col xs={12} className="d-flex justify-content-center">
<Button
size="lg"
onClick={async () => {
const res = await APIFetchV1(
`/users/${reqUser.id}/games/${game}/${playtype}/rivals`,
{
method: "PUT",
body: JSON.stringify({
rivalIDs: rivals.map((e) => e.id),
}),
headers: {
"Content-Type": "application/json",
},
},
true,
true
);
if (res.success) {
setCurrentRivals(rivals);
setSettings({
...settings,
rivals: rivals.map((e) => e.id),
});
}
}}
variant="primary"
>
Save Changes
</Button>
</Col>
</>
)}
<UserSelectModal
callback={(user) => {
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}
+4 -1
View File
@@ -37,6 +37,7 @@ import {
import { SongsReturn } from "types/api-returns";
import { GamePT, SetState } from "types/react";
import useLUGPTSettings from "components/util/useLUGPTSettings";
import { UGPTContextProvider } from "context/UGPTContext";
export default function GameRoutes() {
const { game } = useParams<{ game: string }>();
@@ -66,7 +67,9 @@ export default function GameRoutes() {
</Route>
<Route path="/dashboard/games/:game/:playtype">
<GamePlaytypeRoutes game={game} />
<UGPTContextProvider>
<GamePlaytypeRoutes game={game} />
</UGPTContextProvider>
</Route>
<Route path="*">
@@ -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 ? (
<Form.Control as="select" value={alg} onChange={(e) => setAlg(e.target.value as any)}>
{gptConfig.scoreRatingAlgs.map((e) => (
<option key={e} value={e}>
{UppercaseFirst(e)}
</option>
))}
</Form.Control>
) : null;
const { data, error } = useApiQuery<ScoreLeaderboardReturns>(`${url}?alg=${alg}`);
if (error) {
return (
<>
{SelectComponent}
<ApiError error={error} />
</>
);
}
if (!data) {
return (
<>
{SelectComponent}
<Loading />
</>
);
}
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}
<Divider />
<DistributionChart dataset={pbDataset} />
<Divider />
<PBTable
dataset={pbDataset}
game={game}
playtype={playtype}
showUser
showChart
indexCol
alg={alg}
defaultRankingViewMode="both-if-self"
/>
</>
);
}
function DistributionChart({ dataset }: { dataset: PBDataset }) {
// username -> scores in the top N
const dist: Record<string, integer> = {};
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 (
<div style={{ height: 200, width: "100%" }}>
<ResponsiveBar
data={aggregatedData}
indexBy="username"
keys={["count"]}
layout="vertical"
colors={(x) =>
USER_COLOURS[(usernames.indexOf(x.data.username) ?? 0) % USER_COLOURS.length]
}
margin={{ left: 80, bottom: 40, top: 20, right: 20 }}
tooltip={() => <></>}
{...DEFAULT_BAR_PROPS}
/>
</div>
);
}
@@ -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 }) {
@@ -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 (
<td>
<strong>#{rankingData.rank}</strong>
<small>/{rankingData.outOf}</small>
</td>
);
const { settings } = useLUGPTSettings();
switch (rankingViewMode) {
case "global":
case "global-no-switch":
return (
<td>
<strong>#{rankingData.rank}</strong>
<small>/{rankingData.outOf}</small>
</td>
);
case "rival":
if (!settings) {
return (
<td>
No Settings, yet tried to view rival stats? not possible. how'd you get
here. report this.
</td>
);
}
if (settings?.userID !== userID) {
return (
<td>
<strong>N/A</strong>
</td>
);
}
return (
<td>
<strong>#{rankingData.rivalRank}</strong>
<small>/{settings.rivals.length + 1}</small>
</td>
);
case "both-if-self":
if (settings?.userID === userID && rankingData.rivalRank !== null) {
return (
<td>
<strong>Global #{rankingData.rank}</strong>
<small>/{rankingData.outOf}</small>
<br />
<strong>Rival #{rankingData.rivalRank}</strong>
<small>/{settings.rivals.length + 1}</small>
</td>
);
}
return (
<td>
<strong>#{rankingData.rank}</strong>
<small>/{rankingData.outOf}</small>
</td>
);
}
}
@@ -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({
<tr className={className} onClick={() => setShowDropdown(!showDropdown)}>
{children}
</tr>
<tr className="expandable-pseudo-row">
<td colSpan={100}>
<tr className={nested ? "nested-expandable-pseudo-row" : "expandable-pseudo-row"}>
<td colSpan={100} style={{ padding: 0, borderTop: "none" }}>
<Collapse in={showDropdown}>
{renderDropdown ? (
<div className="tr-dropdown-container">{dropdown}</div>
@@ -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<RankingViewMode>;
} & ZTableTHProps) {
const { settings } = useLUGPTSettings();
if (rankingViewMode === "both-if-self" || rankingViewMode === "global-no-switch" || !settings) {
return (
<SortableTH
changeSort={changeSort}
currentSortMode={currentSortMode}
name="Ranking"
reverseSort={reverseSort}
shortName="Ranking"
/>
);
}
return (
<th>
<select
onChange={(v) => setRankingViewMode(v.target.value as RankingViewMode)}
value={rankingViewMode}
style={{
backgroundColor: "#131313",
border: "none",
color: "#ffffff",
fontSize: "inherit",
font: "inherit",
textAlign: "center",
}}
>
<option value="global">Global Ranking</option>
<option value="rival">Rival Ranking</option>
</select>
<br />
<span onClick={() => changeSort("Site Ranking")}>
<i
className={`flaticon2-arrow-up icon-sm sort-icon ${
currentSortMode === "Site Ranking" && reverseSort ? "active" : ""
}`}
></i>
<i
className={`flaticon2-arrow-down icon-sm sort-icon ${
currentSortMode === "Site Ranking" && !reverseSort ? "active" : ""
}`}
></i>
</span>
</th>
);
}
@@ -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<I extends IDStrings = IDStrings> {
@@ -35,13 +36,11 @@ export default function PBDropdown<I extends IDStrings = IDStrings>({
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<UGPTChartPBComposition<I>>(
@@ -109,6 +108,8 @@ export default function PBDropdown<I extends IDStrings = IDStrings>({
);
} else if (view === "debug") {
body = <DebugContent data={data} />;
} else if (view === "rivals") {
body = <RivalCompare chart={chart} game={game} />;
} else {
body = (
<DocComponent
@@ -145,6 +146,10 @@ export default function PBDropdown<I extends IDStrings = IDStrings>({
<Icon type="history" />
Play History{histData && ` (${histData.length})`}
</SelectButton>
<SelectButton setValue={setView} value={view} id="rivals">
<Icon type="users" />
Rivals
</SelectButton>
<HasDevModeOn>
<SelectButton setValue={setView} value={view} id="debug">
<Icon type="bug" />
@@ -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<I extends IDStrings = IDStrings>({
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<I extends IDStrings = IDStrings>({
body = <PBCompare data={data} DocComponent={DocComponent} scoreState={scoreState} />;
} else if (view === "manage") {
body = <ManageScore score={thisScore} />;
} else if (view === "rivals") {
body = <RivalCompare chart={chart} game={game} />;
}
return (
@@ -130,6 +133,12 @@ export default function ScoreDropdown<I extends IDStrings = IDStrings>({
Manage Score
</SelectButton>
)}
{currentUser && (
<SelectButton setValue={setView} value={view} id="rivals">
<Icon type="users" />
Rivals
</SelectButton>
)}
<HasDevModeOn>
<SelectButton setValue={setView} value={view} id="debug">
<Icon type="bug" />
@@ -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 (
<div className="row w-100">
<div className="col-9">
<div className="row h-100 justify-content-center">
{GraphComponent ? (
<GraphComponent chart={chart} score={score} />
) : (
<div className="d-flex align-items-center" style={{ height: "200px" }}>
<span className="text-muted">No graphs available :(</span>
</div>
)}
</div>
</div>
<div className="col-3 align-self-center">
<JudgementTable score={score} />
</div>
</div>
);
}
@@ -25,7 +25,7 @@ export default function PlayHistory({
}
return (
<div className="col-12 col-lg-10">
<div className="col-12">
<HistoryScoreTable dataset={data} game={game} playtype={playtype} chart={chart} />
</div>
);
@@ -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 <div>You're not signed in. How did you even get to this page?</div>;
}
const base = useUGPTBase({ reqUser: currentUser, game, playtype });
const { data, error } = useApiQuery<ChartRivalsReturn>(
`/users/${currentUser.id}/games/${game}/${playtype}/pbs/${chart.chartID}/rivals`
);
if (!data) {
return <Loading />;
}
if (error) {
return <ApiError error={error} />;
}
if (data.rivals.length === 0) {
return (
<div className="text-center">
You have no rivals set!
<br />
Why not <Link to={`${base}/rivals/manage`}>set some?</Link>
</div>
);
}
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 <RivalChartTable chart={chart} game={game} dataset={rivalDataset} />;
}
@@ -1,14 +1,15 @@
import { NumericSOV, StrSOV } from "util/sorts";
import { CreateDefaultFolderSearchParams } from "util/tables/create-search";
import Muted from "components/util/Muted";
import usePreferredRanking from "components/util/usePreferredRanking";
import useScoreRatingAlg from "components/util/useScoreRatingAlg";
import React, { useState } from "react";
import { Game, IDStrings, ScoreCalculatedDataLookup } from "tachi-common";
import { FolderDataset } from "types/tables";
import { Playtype } from "types/tachi";
import Muted from "components/util/Muted";
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<I extends IDStrings = IDStrings>({
dataset,
@@ -31,7 +33,12 @@ export default function FolderTable<I extends IDStrings = IDStrings>({
}) {
const defaultRating = useScoreRatingAlg(game, playtype);
const preferredRanking = usePreferredRanking();
const [rating, setRating] = useState(defaultRating);
const [rankingViewMode, setRankingViewMode] = useState<RankingViewMode>(
preferredRanking ?? "global"
);
const headers: Header<FolderDataset[0]>[] = [
ChartHeader(game, (k) => k),
@@ -45,11 +52,11 @@ export default function FolderTable<I extends IDStrings = IDStrings>({
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 +67,13 @@ export default function FolderTable<I extends IDStrings = IDStrings>({
entryName="Charts"
searchFunctions={CreateDefaultFolderSearchParams(game, playtype)}
rowFunction={(data) => (
<Row rating={rating} data={data} key={data.chartID} game={game} />
<Row
rating={rating}
data={data}
key={data.chartID}
game={game}
rankingViewMode={rankingViewMode}
/>
)}
/>
);
@@ -70,10 +83,12 @@ function Row<I extends IDStrings = IDStrings>({
data,
rating,
game,
rankingViewMode,
}: {
data: FolderDataset<I>[0];
game: Game;
rating: ScoreCalculatedDataLookup[I];
rankingViewMode: RankingViewMode;
}) {
const score = data.__related.pb;
@@ -110,7 +125,11 @@ function Row<I extends IDStrings = IDStrings>({
<Muted>PB</Muted>
</td>
<ScoreCoreCells score={score} game={game} rating={rating} chart={data} />
<RankingCell rankingData={score.rankingData} />
<RankingCell
rankingData={score.rankingData}
userID={score.userID}
rankingViewMode={rankingViewMode}
/>
<TimestampCell time={score.timeAchieved} />
</DropdownRow>
);
@@ -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<Dataset extends FolderDataset | PBDataset | ScoreDataset>(
export function GetGPTCoreHeaders<
Dataset extends FolderDataset | PBDataset | ScoreDataset | RivalChartDataset
>(
game: Game,
playtype: Playtype,
rating: ScoreCalculatedDataLookup[IDStrings],
@@ -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<T>(
rankingViewMode: RankingViewMode,
setRankingViewMode: SetState<RankingViewMode>,
kMapToRankingData: (k: T) => PBScoreDocument["rankingData"] | undefined
): Header<T> {
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) => (
<SelectableRanking
rankingViewMode={rankingViewMode}
setRankingViewMode={setRankingViewMode}
{...thProps}
/>
),
];
}
@@ -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 (
<tr>
<DropdownRow
nested
dropdown={
<GraphAndJudgementDataComponent
chart={chart}
score={sc}
{...{ ...GPTDropdownSettings(game, chart.playtype) }}
/>
}
>
<ScoreCoreCells score={sc} game={game} rating={rating as any} chart={chart} />
<TimestampCell time={sc.timeAchieved} service={sc.service} />
</tr>
<DropdownIndicatorCell />
</DropdownRow>
);
}
+16 -3
View File
@@ -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<I extends IDStrings = IDStrings>({
@@ -29,6 +30,7 @@ export default function PBTable<I extends IDStrings = IDStrings>({
showChart = true,
playtype,
alg,
defaultRankingViewMode,
}: {
dataset: PBDataset<I>;
indexCol?: boolean;
@@ -38,10 +40,14 @@ export default function PBTable<I extends IDStrings = IDStrings>({
playtype: Playtype;
game: Game;
alg?: ScoreCalculatedDataLookup[I];
defaultRankingViewMode?: RankingViewMode | null;
}) {
const defaultRating = useScoreRatingAlg(game, playtype);
const [rating, setRating] = useState(alg ?? defaultRating);
const [rankingViewMode, setRankingViewMode] = useState<RankingViewMode>(
defaultRankingViewMode ?? "global"
);
const headers: Header<PBDataset<I>[0]>[] = [
...GetPBLeadingHeaders(
@@ -51,7 +57,7 @@ export default function PBTable<I extends IDStrings = IDStrings>({
),
EmptyHeader,
...GetGPTCoreHeaders<PBDataset>(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<I extends IDStrings = IDStrings>({
rating={rating}
game={game}
playtype={playtype}
rankingViewMode={rankingViewMode}
/>
)}
/>
@@ -100,6 +107,7 @@ function Row<I extends IDStrings = IDStrings>({
showUser,
game,
rating,
rankingViewMode,
}: {
pb: PBDataset<I>[0];
indexCol: boolean;
@@ -110,6 +118,7 @@ function Row<I extends IDStrings = IDStrings>({
playtype: Playtype;
// ts bug?
rating: any; // ScoreCalculatedDataLookup[I];
rankingViewMode: RankingViewMode;
}) {
const scoreState = usePBState(pb);
@@ -128,7 +137,11 @@ function Row<I extends IDStrings = IDStrings>({
{indexCol && <IndexCell index={pb.__related.index} />}
<PBLeadingRows {...{ showUser, showChart, pb, scoreState }} />
<ScoreCoreCells score={pb} game={game} rating={rating} chart={pb.__related.chart} />
<RankingCell rankingData={pb.rankingData} />
<RankingCell
rankingData={pb.rankingData}
userID={pb.userID}
rankingViewMode={rankingViewMode}
/>
<TimestampCell time={pb.timeAchieved} />
{showPlaycount && <td>{pb.__playcount ?? 0}</td>}
<DropdownIndicatorCell />
@@ -0,0 +1,120 @@
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, { RankingViewMode } 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";
import { CreateRankingHeader } from "../headers/RankingHeader";
export default function RivalChartTable<I extends IDStrings = IDStrings>({
dataset,
game,
chart,
}: {
dataset: RivalChartDataset<I>;
game: Game;
chart: ChartDocument;
}) {
const playtype = chart.playtype;
const defaultRating = useScoreRatingAlg(game, playtype);
const [rating, setRating] = useState(defaultRating);
const [rankingViewMode, setRankingViewMode] = useState<RankingViewMode>("global");
const headers: Header<RivalChartDataset[0]>[] = [
["#", "#", NumericSOV((x) => x.__related.index)],
["User", "User", StrSOV((x) => x.username)],
...GetGPTCoreHeaders<RivalChartDataset>(
game,
playtype,
rating,
setRating,
(x) => x.__related.pb
),
CreateRankingHeader(
rankingViewMode,
setRankingViewMode,
(k) => k.__related.pb?.rankingData
),
["Last Raised", "Last Raised", NumericSOV((x) => x.__related.pb?.timeAchieved ?? 0)],
];
return (
<TachiTable
dataset={dataset}
headers={headers}
entryName="Rivals"
noTopDisplayStr
defaultSortMode="#"
rowFunction={(data) => (
<Row
chart={chart}
rating={rating}
data={data}
key={data.id}
game={game}
rankingViewMode={rankingViewMode}
/>
)}
/>
);
}
function Row<I extends IDStrings = IDStrings>({
data,
rating,
game,
chart,
rankingViewMode,
}: {
data: RivalChartDataset<I>[0];
game: Game;
rating: ScoreCalculatedDataLookup[I];
chart: ChartDocument;
rankingViewMode: RankingViewMode;
}) {
const pb = data.__related.pb;
if (!pb) {
return (
<tr>
<td>N/A</td>
<UserCell user={data} game={game} playtype={chart.playtype} />
<td colSpan={7}>Not Played.</td>
</tr>
);
}
return (
<DropdownRow
nested
dropdown={
<GraphAndJudgementDataComponent
chart={chart}
score={data.__related.pb}
{...{ ...GPTDropdownSettings(game, chart.playtype) }}
/>
}
>
<IndexCell index={data.__related.index} />
<UserCell user={data} game={game} playtype={chart.playtype} />
<ScoreCoreCells score={pb} game={game} rating={rating} chart={chart} />
<RankingCell
rankingData={pb.rankingData}
userID={pb.userID}
rankingViewMode={rankingViewMode}
/>
<TimestampCell time={pb.timeAchieved} />
</DropdownRow>
);
}
+1 -1
View File
@@ -104,7 +104,7 @@ export function UGPTBottomNav({
Rivals
</NavItem>,
<NavItem key="targets" to={`${baseUrl}/targets`}>
Targets
Goals & Milestones
</NavItem>,
];
@@ -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 (
<LinkButton
to={to}
className={`btn-${DoesMatchRoute(window.location.href, to) ? onVariant : offVariant}`}
className={`btn-${
DoesMatchRoute(window.location.href, to, !matchIfStartsWith)
? onVariant
: offVariant
}`}
>
{children}
</LinkButton>
@@ -67,7 +67,7 @@ export default function UserSelectModal({
<h4 className="mt-2">{user.username}</h4>
{excludeSet.includes(user.id) ? (
<Button variant="outline-secondary" disabled>
Already Selected
Already Rivalled!
</Button>
) : (
<Button
@@ -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;
}
-11
View File
@@ -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 <Loading />;
}
if (!viewingData) {
return (
<ErrorPage
statusCode={400}
customMessage="This user probably doesn't play this game, or something has gone wrong."
/>
);
}
return (
<UGPTContext.Provider
value={{
+5
View File
@@ -186,3 +186,8 @@ export interface ChallengeSubsReturn {
songs: Array<SongDocument>;
charts: Array<ChartDocument>;
}
export interface ChartRivalsReturn {
rivals: Array<PublicUserDocument>;
pbs: Array<PBScoreDocument>;
}
+7
View File
@@ -49,3 +49,10 @@ export type UGSDataset<I extends IDStrings = IDStrings> = (UserGameStats<I> & {
index: integer;
};
})[];
export type RivalChartDataset<I extends IDStrings = IDStrings> = (PublicUserDocument & {
__related: {
pb: PBScoreDocument<I> | null;
index: number;
};
})[];
+6 -2
View File
@@ -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);
+5 -3
View File
@@ -67,6 +67,7 @@ export function CreateDefaultPBSearchParams<I extends IDStrings = IDStrings>(
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<I extends IDStrings = IDStrings>
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: {
+11 -1
View File
@@ -204,6 +204,16 @@ export function GetValueGetter<D>(v: ValueGetterOrHybrid<D>) {
return v.valueGetter;
}
function GetValueInsensitive<T>(record: Record<string, T>, key: string): T | undefined {
for (const [recKey, value] of Object.entries(record)) {
if (recKey.toLowerCase() === key.toLowerCase()) {
return value;
}
}
return undefined;
}
export function ComposeSearchFunction<D>(
valueGetters: Record<string, ValueGetterOrHybrid<D>>
): ZTableSearchFn<D> {
@@ -237,7 +247,7 @@ export function ComposeSearchFunction<D>(
}
for (const directive of directives) {
const vgOrHybrid = valueGetters[directive.key];
const vgOrHybrid = GetValueInsensitive(valueGetters, directive.key);
if (!vgOrHybrid) {
continue;
+7
View File
@@ -1256,6 +1256,12 @@ export interface PBScoreDocument<I extends IDStrings = IDStrings> 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;
@@ -1465,6 +1471,7 @@ export interface UGPTSettings<I extends IDStrings = IDStrings> extends MongoDBDo
stats: Array<ShowcaseStatDetails>;
scoreBucket: "grade" | "lamp" | null;
defaultTable: string | null;
preferredRanking: "global" | "rival" | null;
gameSpecific: UGPTSpecificPreferences[I];
};
rivals: Array<integer>;
@@ -43,6 +43,7 @@ export async function CreateGameSettings(userID: integer, game: Game, playtype:
preferredScoreAlg: null,
scoreBucket: null,
defaultTable: null,
preferredRanking: null,
stats: [],
gameSpecific,
},
+2 -1
View File
@@ -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<Migration> =
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") {
@@ -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;
+30
View File
@@ -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<Record<number, Array<number>>> {
const allGameSettings = await db["game-settings"].find(
{
game,
playtype,
},
{
projection: {
userID: 1,
rivals: 1,
},
}
);
const lookupTable: Record<integer, Array<integer>> = {};
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
@@ -12,3 +12,4 @@ export type DryScore<I extends IDStrings = IDStrings> = Pick<
};
export type ScorePlaytypeMap = Partial<Record<Playtype, Array<ScoreDocument>>>;
export type ChartIDPlaytypeMap = Partial<Record<Playtype, Set<string>>>;
@@ -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<PBScoreDocument, "rankingData">;
@@ -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,12 +78,26 @@ export async function UpdateChartRanking(chartID: string) {
}
);
const allRivals = await GetEveryonesRivalIDs(game, playtype);
const bwrite: Array<BulkWriteUpdateOneOperation<PBScoreDocument>> = [];
let rank = 0;
// what users have we saw so far? used for rivalRanking calculations
const seenUserIDs: Array<integer> = [];
for (const score of scores) {
rank++;
seenUserIDs.push(score.userID);
const thisUsersRivals = allRivals[score.userID];
let rivalRank: integer | null = null;
if (thisUsersRivals && thisUsersRivals.length > 0) {
rivalRank = thisUsersRivals.filter((e) => seenUserIDs.includes(e)).length + 1;
}
bwrite.push({
updateOne: {
@@ -92,6 +107,7 @@ export async function UpdateChartRanking(chartID: string) {
rankingData: {
rank,
outOf: scores.length,
rivalRank,
},
},
},
@@ -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({});
@@ -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<string>,
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!
}
@@ -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<Playtype>;
// --- 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<Playtype>;
void SetJobProgress(job, "Updating profile statistics.");
// --- 6. Game Stats ---
@@ -413,7 +425,8 @@ function ParseImportInfo(importInfo: Array<ImportProcessingInfo>) {
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];
}
+16 -4
View File
@@ -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<ScoreDocument>, 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) {
@@ -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({
@@ -26,6 +26,7 @@ t.test("#EvalulateUsersStatsShowcase", (t) => {
preferredSessionAlg: null,
scoreBucket: null,
defaultTable: null,
preferredRanking: null,
stats: [
{
folderID: TestingIIDXFolderSP10.folderID,
@@ -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.
@@ -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;
@@ -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: [],
@@ -38,6 +38,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")),
gameSpecific: optNull(gameSpecificSchema),
scoreBucket: optNull(p.isIn("grade", "lamp")),
@@ -88,6 +89,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)) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
@@ -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: [
@@ -132,6 +132,7 @@ const USC_SCORE_PB: PBScoreDocument = {
rankingData: {
rank: 1,
outOf: 2,
rivalRank: null,
},
songID: 1,
userID: 1,
@@ -26,6 +26,7 @@ const mockScorePB: PBScoreDocument<"usc:Controller" | "usc:Keyboard"> = {
rankingData: {
outOf: 2,
rank: 1,
rivalRank: null,
},
scoreData: {
esd: null,
@@ -9,6 +9,7 @@
"preferredProfileAlg": null,
"scoreBucket": null,
"defaultTable": null,
"preferredRanking": null,
"stats": [],
"gameSpecific": {
"display2DXTra": false,
+2
View File
@@ -95,6 +95,7 @@ export const TestingIIDXSPScorePB: PBScoreDocument<"iidx:SP"> = {
rankingData: {
rank: 1,
outOf: 2,
rivalRank: null,
},
songID: 1,
game: "iidx",
@@ -745,6 +746,7 @@ export const FakeGameSettings: UGPTSettings = {
preferredScoreAlg: null,
preferredSessionAlg: null,
scoreBucket: null,
preferredRanking: null,
stats: [],
},
rivals: [],
+22 -5
View File
@@ -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<integer>, 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!`);