mirror of
https://github.com/zkldi/Tachi.git
synced 2026-10-04 21:08:09 +03:00
Merge pull request #715 from TNG-dev/zkldi/rivals
This commit is contained in:
Submodule client/src/_assets/metronic-scss updated: f93aa62cfb...d44b4be295
@@ -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
|
||||
|
||||
+80
@@ -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>
|
||||
);
|
||||
}
|
||||
+41
-15
@@ -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>
|
||||
|
||||
+65
-23
@@ -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}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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={{
|
||||
|
||||
@@ -186,3 +186,8 @@ export interface ChallengeSubsReturn {
|
||||
songs: Array<SongDocument>;
|
||||
charts: Array<ChartDocument>;
|
||||
}
|
||||
|
||||
export interface ChartRivalsReturn {
|
||||
rivals: Array<PublicUserDocument>;
|
||||
pbs: Array<PBScoreDocument>;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
})[];
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
+72
-2
@@ -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;
|
||||
|
||||
+5
@@ -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
|
||||
|
||||
+2
@@ -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,
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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!`);
|
||||
|
||||
Reference in New Issue
Block a user