Merge pull request #738 from TNG-dev/zkldi/rival-pb-compare

This commit is contained in:
zkldi
2022-11-01 23:48:15 +00:00
committed by GitHub
27 changed files with 972 additions and 212 deletions
@@ -41,7 +41,7 @@ export default function GPTLeaderboardsPage({ game, playtype }: GamePT) {
</SelectButton>
<SelectButton id="score" value={mode} setValue={setMode}>
<Icon type="sort-numeric-up-alt" />
Score Leaderboards
PB Leaderboards
</SelectButton>
</div>
</Col>
@@ -53,7 +53,7 @@ export default function GPTLeaderboardsPage({ game, playtype }: GamePT) {
<ScoreLeaderboard
game={game}
playtype={playtype}
url={`/games/${game}/${playtype}/score-leaderboard`}
url={`/games/${game}/${playtype}/pb-leaderboard`}
/>
)}
</Col>
@@ -0,0 +1,372 @@
import { CreateSongMap } from "util/data";
import Card from "components/layout/page/Card";
import ComparePBsTable from "components/tables/rivals/ComparePBsTable";
import ProfilePicture from "components/user/ProfilePicture";
import UGPTRatingsTable from "components/user/UGPTStatsOverview";
import ApiError from "components/util/ApiError";
import Divider from "components/util/Divider";
import Loading from "components/util/Loading";
import useApiQuery from "components/util/query/useApiQuery";
import useLUGPTSettings from "components/util/useLUGPTSettings";
import React, { useContext, useEffect, useMemo, useState } from "react";
import { Button, Col, Form, Row } from "react-bootstrap";
import { Link } from "react-router-dom";
import { COLOUR_SET, FolderDocument, GetGamePTConfig, PublicUserDocument } from "tachi-common";
import { UGPTFolderReturns, UGPTStatsReturn } from "types/api-returns";
import { GamePT } from "types/react";
import { ComparePBsDataset } from "types/tables";
import UserSelectModal from "components/util/modal/UserSelectModal";
import UserIcon from "components/util/UserIcon";
import { UserContext } from "context/UserContext";
import MiniTable from "components/tables/components/MiniTable";
export default function RivalCompareFolderPage({
reqUser,
game,
playtype,
folder,
}: GamePT & {
reqUser: PublicUserDocument;
folder: FolderDocument;
}) {
const { settings } = useLUGPTSettings();
const { user } = useContext(UserContext);
const [selectedUser, setSelectedUser] = useState<PublicUserDocument | null>(null);
const [show, setShow] = useState(false);
// honestly don't care if this errors or not
const { data, error } = useApiQuery<Array<PublicUserDocument>>(
`/users/${settings?.userID}/games/${game}/${playtype}/rivals`,
undefined
// [settings?.rivals.join(",")]
);
if (!data && !error) {
return <Loading />;
}
let suggestUsers = data ?? [];
// if the user viewing this page is logged in and *is not*
// the person this page is for
// show them in the suggestions.
if (user && user.id !== reqUser.id && data) {
suggestUsers = [...suggestUsers, user];
}
return (
<div>
<Card header="Pick a user to compare against...">
{suggestUsers.length > 0 && (
<>
<Col xs={12} className="d-flex justify-content-center flex-wrap">
{suggestUsers.map((e) => (
<UserIcon key={e.id} user={e} game={game} playtype={playtype}>
<Button
variant={
selectedUser?.id === e.id ? "secondary" : "primary"
}
onClick={() => setSelectedUser(e)}
>
Compare Against
</Button>
</UserIcon>
))}
</Col>
<Divider />
</>
)}
<UserSelectModal
callback={(user) => {
setSelectedUser(user);
setShow(false);
}}
show={show}
setShow={setShow}
url={`/games/${game}/${playtype}/players`}
excludeSet={[reqUser.id]}
excludeMsg="Can't pick the same user!"
/>
<Button
className={suggestUsers.length === 0 ? "d-flex mx-auto" : ""}
variant={selectedUser ? "secondary" : "primary"}
onClick={() => setShow(true)}
>
Pick{suggestUsers.length > 0 ? " Other" : ""} User
</Button>
</Card>
{selectedUser !== null && folder !== null && (
<>
<Divider />
<FolderCompare
reqUser={reqUser}
game={game}
playtype={playtype}
withUser={selectedUser}
folder={folder}
/>
</>
)}
</div>
);
}
function FolderCompare({
reqUser,
game,
playtype,
withUser,
folder,
}: {
reqUser: PublicUserDocument;
withUser: PublicUserDocument;
folder: FolderDocument;
} & GamePT) {
const gptConfig = GetGamePTConfig(game, playtype);
const { data: baseData, error: baseError } = useApiQuery<UGPTFolderReturns>(
`/users/${reqUser.id}/games/${game}/${playtype}/folders/${folder.folderID}`
);
const { data: compareData, error: compareError } = useApiQuery<UGPTFolderReturns>(
`/users/${withUser.id}/games/${game}/${playtype}/folders/${folder.folderID}`
);
const [shouldIncludeNotPlayed, setShouldIncludeNotPlayed] = useState(false);
const [shouldESD, setShouldESD] = useState(gptConfig.supportsESD);
useEffect(() => {
setShouldESD(gptConfig.supportsESD);
}, [gptConfig]);
const dataset = useMemo(() => {
// i *LOVE* the rules of hooks! they're so convenient!
if (!baseData || !compareData) {
return [];
}
const basePBLookup = new Map(baseData.pbs.map((e) => [e.chartID, e]));
const comparePBLookup = new Map(compareData.pbs.map((e) => [e.chartID, e]));
const songMap = CreateSongMap(baseData.songs);
let ds: ComparePBsDataset = baseData.charts.map((chart) => ({
chart,
base: basePBLookup.get(chart.chartID) ?? null,
compare: comparePBLookup.get(chart.chartID) ?? null,
song: songMap.get(chart.songID)!,
}));
if (!shouldIncludeNotPlayed) {
ds = ds.filter((e) => e.base && e.compare);
}
return ds;
}, [shouldIncludeNotPlayed, baseData, compareData]);
if (baseError) {
return <ApiError error={baseError} />;
}
if (compareError) {
return <ApiError error={compareError} />;
}
if (!compareData || !baseData) {
return <Loading />;
}
return (
<Row>
<UserCard username={reqUser.username} game={game} playtype={playtype} />
<UserCard username={withUser.username} game={game} playtype={playtype} />
<Col xs={12}>
<Divider />
</Col>
<CompareCard
dataset={dataset}
baseUsername={reqUser.username}
otherUsername={withUser.username}
/>
<Col xs={12}>
<Divider />
<Form.Check
checked={shouldIncludeNotPlayed}
onChange={() => setShouldIncludeNotPlayed(!shouldIncludeNotPlayed)}
label="Include charts without plays?"
/>
{gptConfig.supportsESD && (
<Form.Check
checked={shouldESD}
onChange={() => setShouldESD(!shouldESD)}
label="Sort score differences intelligently? (i.e. Treat the difference between 70% and 71% as smaller than the difference between 99% and 100%)"
/>
)}
<Divider />
</Col>
<ComparePBsTable
baseUser={reqUser.username}
compareUser={withUser.username}
dataset={dataset}
game={game}
shouldESD={shouldESD}
playtype={playtype}
/>
</Row>
);
}
function UserCard({ username, game, playtype }: { username: string } & GamePT) {
const { data, error } = useApiQuery<UGPTStatsReturn>(
`/users/${username}/games/${game}/${playtype}`
);
if (error) {
return <ApiError error={error} />;
}
return (
<Col xs={12} lg={6}>
<Card>
<Row className="align-items-center">
<Col lg={3}>
<ProfilePicture user={username} />
</Col>
<Col lg={3}>
<h4>
<Link
className="gentle-link"
to={`/dashboard/users/${username}/games/${game}/${playtype}`}
>
{username}
</Link>
</h4>
</Col>
<Col lg={6}>
{data ? <UGPTRatingsTable ugs={data.gameStats} /> : <Loading />}
</Col>
</Row>
</Card>
</Col>
);
}
function CompareCard({
dataset,
baseUsername,
otherUsername,
}: {
dataset: ComparePBsDataset;
baseUsername: string;
otherUsername: string;
}) {
// this code sucks
let scoreWin = 0;
let scoreDraw = 0;
let scoreLoss = 0;
let lampWin = 0;
let lampDraw = 0;
let lampLoss = 0;
for (const d of dataset) {
if (!d.compare && !d.base) {
scoreDraw++;
lampDraw++;
continue;
}
if (!d.compare) {
scoreWin++;
lampWin++;
continue;
}
if (!d.base) {
scoreLoss++;
lampLoss++;
continue;
}
if (d.base.scoreData.percent > d.compare.scoreData.percent) {
scoreWin++;
} else if (d.base.scoreData.percent < d.compare.scoreData.percent) {
scoreLoss++;
} else {
scoreDraw++;
}
if (d.base.scoreData.lampIndex > d.compare.scoreData.lampIndex) {
lampWin++;
} else if (d.base.scoreData.lampIndex < d.compare.scoreData.lampIndex) {
lampLoss++;
} else {
lampDraw++;
}
}
return (
<Col xs={12}>
<Card>
<Row>
<Col xs={12} lg={6}>
<MiniTable headers={["Score Win/Loss"]} colSpan={23}>
<tr>
<td>{baseUsername} Win</td>
<td style={{ color: COLOUR_SET.green }}>{scoreWin}</td>
<td style={{ color: COLOUR_SET.green }}>
{((100 * scoreWin) / dataset.length).toFixed(2)}%
</td>
</tr>
<tr>
<td>Draw</td>
<td style={{ color: COLOUR_SET.gold }}>{scoreDraw}</td>
<td style={{ color: COLOUR_SET.gold }}>
{((100 * scoreDraw) / dataset.length).toFixed(2)}%
</td>
</tr>
<tr>
<td>{otherUsername} Win</td>
<td style={{ color: COLOUR_SET.red }}>{scoreLoss}</td>
<td style={{ color: COLOUR_SET.red }}>
{((100 * scoreLoss) / dataset.length).toFixed(2)}%
</td>
</tr>
</MiniTable>
</Col>
<Col xs={12} lg={6}>
<MiniTable headers={["Lamp Win/Loss"]} colSpan={23}>
<tr>
<td>{baseUsername} Win</td>
<td style={{ color: COLOUR_SET.green }}>{lampWin}</td>
<td style={{ color: COLOUR_SET.green }}>
{((100 * lampWin) / dataset.length).toFixed(2)}%
</td>
</tr>
<tr>
<td>Draw</td>
<td style={{ color: COLOUR_SET.gold }}>{lampDraw}</td>
<td style={{ color: COLOUR_SET.gold }}>
{((100 * lampDraw) / dataset.length).toFixed(2)}%
</td>
</tr>
<tr>
<td>{otherUsername} Loss</td>
<td style={{ color: COLOUR_SET.red }}>{lampLoss}</td>
<td style={{ color: COLOUR_SET.red }}>
{((100 * lampLoss) / dataset.length).toFixed(2)}%
</td>
</tr>
</MiniTable>
</Col>
</Row>
</Card>
</Col>
);
}
@@ -71,10 +71,7 @@ export default function FoldersMainPage({ reqUser, game, playtype }: Props) {
>
<RecentFoldersPage {...{ reqUser, game, playtype }} />
</Route>
<Route
exact
path="/dashboard/users/:userID/games/:game/:playtype/folders/:folderID"
>
<Route path="/dashboard/users/:userID/games/:game/:playtype/folders/:folderID">
<SpecificFolderPage {...{ reqUser, game, playtype }} />
</Route>
</Switch>
@@ -14,11 +14,13 @@ import Icon from "components/util/Icon";
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 ReferToUser from "components/util/ReferToUser";
import SelectLinkButton from "components/util/SelectLinkButton";
import useUGPTBase from "components/util/useUGPTBase";
import { useFormik } from "formik";
import React, { useEffect, useMemo, useState } from "react";
import { Col, Form, Row } from "react-bootstrap";
import { Link, useParams } from "react-router-dom";
import { Link, Route, Switch, useParams } from "react-router-dom";
import {
ChartDocument,
ChartTierlistInfo,
@@ -36,7 +38,7 @@ import {
import { UGPTFolderReturns } from "types/api-returns";
import { FolderDataset } from "types/tables";
import { Playtype } from "types/tachi";
import ReferToUser from "components/util/ReferToUser";
import FolderComparePage from "./FolderComparePage";
interface Props {
reqUser: PublicUserDocument;
@@ -51,8 +53,6 @@ export default function SpecificFolderPage({ reqUser, game, playtype }: Props) {
`/users/${reqUser.id}/games/${game}/${playtype}/folders/${folderID}`
);
const [mode, setMode] = useState<"normal" | "ladder" | "timeline">("normal");
const gptConfig = GetGamePTConfig(game, playtype);
const folderDataset = useMemo(() => {
@@ -97,6 +97,8 @@ export default function SpecificFolderPage({ reqUser, game, playtype }: Props) {
);
}, [folderDataset]);
const base = `${useUGPTBase({ reqUser, game, playtype })}/folders/${folderID}`;
if (error) {
return <ApiError error={error} />;
}
@@ -113,47 +115,63 @@ export default function SpecificFolderPage({ reqUser, game, playtype }: Props) {
</div>
<div className="col-12 d-flex">
<div className="btn-group mx-auto">
<SelectButton value={mode} setValue={setMode} id="normal">
<SelectLinkButton to={base}>
<Icon type="table" />
Normal View
</SelectButton>
</SelectLinkButton>
{gptConfig.tierlists.length !== 0 &&
// temp: tierlist view sucks for BMS and PMS
game !== "bms" &&
game !== "pms" && (
<SelectButton value={mode} setValue={setMode} id="ladder">
<SelectLinkButton to={`${base}/tierlist`}>
<Icon type="sort-alpha-up" />
Tierlist View
</SelectButton>
</SelectLinkButton>
)}
<SelectButton value={mode} setValue={setMode} id="timeline">
<SelectLinkButton to={`${base}/timeline`}>
<Icon type="stream" />
Timeline View
</SelectButton>
</SelectLinkButton>
<SelectLinkButton to={`${base}/compare`}>
<Icon type="users" />
Compare Against User
</SelectLinkButton>
</div>
</div>
<div className="col-12">
<Divider />
</div>
<div className="col-12">
{mode === "normal" ? (
<FolderTable dataset={folderDataset} game={game} playtype={playtype} />
) : mode === "ladder" ? (
<TierlistBreakdown
folderDataset={folderDataset}
game={game}
playtype={playtype}
reqUser={reqUser}
data={data}
/>
) : (
<TimelineView
game={game}
playtype={playtype}
reqUser={reqUser}
folderID={folderID}
/>
)}
<Switch>
<Route exact path={base}>
<FolderTable dataset={folderDataset} game={game} playtype={playtype} />
</Route>
<Route exact path={`${base}/tierlist`}>
<TierlistBreakdown
folderDataset={folderDataset}
game={game}
playtype={playtype}
reqUser={reqUser}
data={data}
/>
</Route>
<Route exact path={`${base}/timeline`}>
<TimelineView
game={game}
playtype={playtype}
reqUser={reqUser}
folderID={folderID}
/>
</Route>
<Route exact path={`${base}/compare`}>
<FolderComparePage
game={game}
playtype={playtype}
reqUser={reqUser}
folder={data.folder}
/>
</Route>
</Switch>
</div>
</div>
);
@@ -163,11 +181,17 @@ function TimelineView({ game, playtype, reqUser, folderID }: Props & { folderID:
const gptConfig = GetGamePTConfig(game, playtype);
const [type, setType] = useState<"lamp" | "grade">(gptConfig.scoreBucket);
const [value, setValue] = useState<integer>(
type === "grade" ? gptConfig.grades.length - 1 : gptConfig.lamps.length - 1
type === "grade"
? gptConfig.grades.indexOf(gptConfig.clearGrade)
: gptConfig.lamps.indexOf(gptConfig.clearLamp)
);
useEffect(() => {
setValue(type === "grade" ? gptConfig.grades.length - 1 : gptConfig.lamps.length - 1);
setValue(
type === "grade"
? gptConfig.grades.indexOf(gptConfig.clearGrade)
: gptConfig.lamps.indexOf(gptConfig.clearLamp)
);
}, [type]);
return (
@@ -1,88 +0,0 @@
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 useLUGPTSettings from "components/util/useLUGPTSettings";
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`;
const { settings } = useLUGPTSettings();
if (!settings) {
return <div>You have no settings. How did you get here?</div>;
}
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}
refreshDeps={[`rivals-score-leaderboard-${settings.rivals.join(",")}`]}
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>
);
}
@@ -1,3 +1,4 @@
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";
@@ -8,7 +9,6 @@ import React from "react";
import { Col, Row } from "react-bootstrap";
import { Redirect, Route, Switch } from "react-router-dom";
import { FormatGame, Game, GetGameConfig, Playtype, PublicUserDocument } from "tachi-common";
import RivalsComparePage from "./RivalsComparePage";
import RivalsManagePage from "./RivalsManagePage";
export default function RivalsMainPage({
@@ -40,21 +40,17 @@ export default function RivalsMainPage({
<Row>
<Col xs={12} className="text-center">
<div className="btn-group">
<SelectLinkButton to={`${base}/rivals/targets`}>
<Icon type="bullseye" />
Goals & Quests
</SelectLinkButton>
<SelectLinkButton matchIfStartsWith to={`${base}/rivals/compare`}>
<Icon type="balance-scale-left" />
Compare
<SelectLinkButton to={`${base}/rivals/pb-leaderboard`}>
<Icon type="sort-amount-up" />
Rival's Bests
</SelectLinkButton>
<SelectLinkButton to={`${base}/rivals`}>
<Icon type="list" />
Activity
</SelectLinkButton>
<SelectLinkButton to={`${base}/rivals/folders`}>
<SelectLinkButton to={`${base}/rivals/tracking`}>
<Icon type="thumbtack" />
Manage Folders
Manage Tracked Charts
</SelectLinkButton>
<SelectLinkButton to={`${base}/rivals/manage`}>
<Icon type="users" />
@@ -79,20 +75,14 @@ export default function RivalsMainPage({
<Route
exact
path="/dashboard/users/:userID/games/:game/:playtype/rivals/folders"
path="/dashboard/users/:userID/games/:game/:playtype/rivals/pb-leaderboard"
>
NOT IMPLEMENTED YET
</Route>
<Route
exact
path="/dashboard/users/:userID/games/:game/:playtype/rivals/targets"
>
NOT IMPLEMENTED YET
</Route>
<Route path="/dashboard/users/:userID/games/:game/:playtype/rivals/compare">
<RivalsComparePage reqUser={reqUser} game={game} playtype={playtype} />
<ScoreLeaderboard
game={game}
playtype={playtype}
refreshDeps={[`rivals-pb-leaderboard-${settings.rivals.join(",")}`]}
url={`/users/${reqUser.id}/games/${game}/${playtype}/rivals/pb-leaderboard`}
/>
</Route>
</Switch>
</Col>
@@ -202,6 +202,7 @@ function RivalsOverviewPage({
setShow={setShow}
url={`/games/${game}/${playtype}/players`}
excludeSet={rivals.map((e) => e.id)}
excludeMsg="Already Rivaled!"
/>
</>
)}
+10 -8
View File
@@ -8,20 +8,22 @@ export default function Card({
className,
style,
}: {
header: JSX.Element | string;
header?: JSX.Element | string;
footer?: JSX.Element | string;
className?: string;
style?: CSSProperties;
} & JustChildren) {
return (
<div className={`card card-custom ${className ? className : ""}`} style={style}>
<div className="card-header">
{typeof header === "string" ? (
<h3 className="text-center mb-0">{header}</h3>
) : (
header
)}
</div>
{header && (
<div className="card-header">
{typeof header === "string" ? (
<h3 className="text-center mb-0">{header}</h3>
) : (
header
)}
</div>
)}
<div className="card-body">{children}</div>
{footer && <div className="card-footer">{footer}</div>}
</div>
@@ -0,0 +1,22 @@
import { ChangeOpacity } from "util/color-opacity";
import { FormatMillions } from "util/misc";
import React from "react";
import { GetGamePTConfig, PBScoreDocument, ScoreDocument } from "tachi-common";
export default function JubeatScoreCell({ sc }: { sc: ScoreDocument | PBScoreDocument }) {
const gptConfig = GetGamePTConfig(sc.game, sc.playtype);
return (
<td
style={{
backgroundColor: ChangeOpacity(gptConfig.gradeColours[sc.scoreData.grade], 0.2),
}}
>
<strong>{sc.scoreData.grade}</strong>
<br />
<b>{sc.scoreData.percent.toFixed(2)}%</b>
<br />
{FormatMillions(sc.scoreData.score)}
</td>
);
}
@@ -0,0 +1,146 @@
import { FormatMillions } from "util/misc";
import React from "react";
import { COLOUR_SET, Game, PBScoreDocument, Playtype } from "tachi-common";
import { ESDCompare } from "tachi-common/lib/esd";
import Muted from "components/util/Muted";
export default function PBCompareCell({
base,
compare,
game,
playtype,
compareType,
shouldESD,
}: {
base: PBScoreDocument | null;
compare: PBScoreDocument | null;
game: Game;
playtype: Playtype;
compareType: "score" | "lamp";
shouldESD: boolean;
}) {
let status: "win" | "draw" | "lose";
// wut?
if (!base && !compare) {
status = "draw";
} else if (!base) {
status = "lose";
} else if (!compare) {
status = "win";
} else if (compareType === "score") {
status = cmp(base.scoreData.score, compare.scoreData.score);
} else {
status = cmp(base.scoreData.lampIndex, compare.scoreData.lampIndex);
}
let scoreRenderFn = (k: number) => k.toString();
let shouldShowPercent = false;
switch (game) {
case "chunithm":
case "ddr":
case "gitadora":
case "maimai":
case "usc":
case "sdvx":
case "popn":
case "wacca":
case "museca":
scoreRenderFn = FormatMillions;
shouldShowPercent = false;
break;
case "jubeat":
scoreRenderFn = FormatMillions;
shouldShowPercent = true;
break;
case "bms":
case "iidx":
scoreRenderFn = (k: number) => k.toString();
shouldShowPercent = true;
break;
}
const fmtCmpPercent = fmtNum(
(base?.scoreData.percent ?? 0) - (compare?.scoreData.percent ?? 0),
2,
"%"
);
const cmpScore = (base?.scoreData.score ?? 0) - (compare?.scoreData.score ?? 0);
const cmpLamp = (base?.scoreData.lampIndex ?? 0) - (compare?.scoreData.lampIndex ?? 0);
let esdDiff;
if (shouldESD) {
if (base?.scoreData.esd && compare?.scoreData.esd) {
esdDiff = ESDCompare(compare.scoreData.esd, base.scoreData.esd);
}
}
return (
<td
style={{
color:
status === "win"
? COLOUR_SET.green
: status === "draw"
? COLOUR_SET.gold
: COLOUR_SET.red,
borderLeft: "1px black double",
borderRight: "1px black double",
}}
>
{compareType === "score" ? (
<>
<strong>{(cmpScore > 0 ? "+" : "") + scoreRenderFn(cmpScore)}</strong>
{shouldShowPercent && (
<>
<br />
<small>({fmtCmpPercent})</small>
</>
)}
{esdDiff !== undefined && (
<>
<br />
{esdDiff.toFixed(2) === "0.00" ? (
<Muted>Negligable Difference</Muted>
) : (
<Muted>
Relative: {(esdDiff > 0 ? "+" : "") + esdDiff.toFixed(2)}
</Muted>
)}
</>
)}
</>
) : (
<>
<strong>{(cmpLamp > 0 ? "+" : "") + cmpLamp}</strong>
</>
)}
</td>
);
}
function cmp(a: number, b: number) {
if (a === b) {
return "draw";
} else if (a > b) {
return "win";
}
return "lose";
}
function fmtNum(num: number, fixed?: number, suffix?: string) {
let str = fixed ? num.toFixed(fixed) : num.toString();
if (num >= 0) {
str = `+${str}`;
}
if (suffix) {
str += suffix;
}
return str;
}
@@ -0,0 +1,47 @@
import React from "react";
import { SetState } from "types/react";
import { ZTableTHProps } from "./TachiTable";
export default function SelectableCompareType({
compareType,
setCompareType,
changeSort,
currentSortMode,
reverseSort,
}: {
compareType: "score" | "lamp";
setCompareType: SetState<"score" | "lamp">;
} & ZTableTHProps) {
return (
<th>
<select
onChange={(v) => setCompareType(v.target.value as "score" | "lamp")}
value={compareType}
style={{
backgroundColor: "#131313",
border: "none",
color: "#ffffff",
fontSize: "inherit",
font: "inherit",
textAlign: "center",
}}
>
<option value="score">Vs. (Score)</option>
<option value="lamp">Vs. (Lamp)</option>
</select>
<br />
<span onClick={() => changeSort("Vs.")}>
<i
className={`flaticon2-arrow-up icon-sm sort-icon ${
currentSortMode === "Vs." && reverseSort ? "active" : ""
}`}
></i>
<i
className={`flaticon2-arrow-down icon-sm sort-icon ${
currentSortMode === "Vs." && !reverseSort ? "active" : ""
}`}
></i>
</span>
</th>
);
}
@@ -1,9 +1,8 @@
import { ChangeOpacity } from "util/color-opacity";
import { FormatMillions } from "util/misc";
import React from "react";
import { GetGamePTConfig, PBScoreDocument, ScoreDocument } from "tachi-common";
import { PBScoreDocument, ScoreDocument } from "tachi-common";
import CHUNITHMJudgementCell from "../cells/CHUNITHMJudgementCell";
import LampCell from "../cells/LampCell";
import MillionsScoreCell from "../cells/MillionsScoreCell";
import RatingCell from "../cells/RatingCell";
export default function CHUNITHMCoreCells({
@@ -13,19 +12,9 @@ export default function CHUNITHMCoreCells({
sc: ScoreDocument<"chunithm:Single"> | PBScoreDocument<"chunithm:Single">;
rating: keyof ScoreDocument["calculatedData"];
}) {
const gptConfig = GetGamePTConfig(sc.game, sc.playtype);
return (
<>
<td
style={{
backgroundColor: ChangeOpacity(gptConfig.gradeColours[sc.scoreData.grade], 0.2),
}}
>
<strong>{sc.scoreData.grade}</strong>
<br />
{FormatMillions(sc.scoreData.score)}
</td>
<MillionsScoreCell score={sc} />
<CHUNITHMJudgementCell score={sc} />
<LampCell score={sc} />
<RatingCell score={sc} rating={rating} />
@@ -1,8 +1,7 @@
import { ChangeOpacity } from "util/color-opacity";
import { FormatMillions } from "util/misc";
import React from "react";
import { GetGamePTConfig, PBScoreDocument, ScoreDocument } from "tachi-common";
import { PBScoreDocument, ScoreDocument } from "tachi-common";
import JubeatJudgementCell from "../cells/JubeatJudgementCell";
import JubeatScoreCell from "../cells/JubeatScoreCell";
import LampCell from "../cells/LampCell";
import RatingCell from "../cells/RatingCell";
@@ -13,21 +12,9 @@ export default function JubeatCoreCells({
sc: ScoreDocument<"jubeat:Single"> | PBScoreDocument<"jubeat:Single">;
rating: keyof ScoreDocument["calculatedData"];
}) {
const gptConfig = GetGamePTConfig(sc.game, sc.playtype);
return (
<>
<td
style={{
backgroundColor: ChangeOpacity(gptConfig.gradeColours[sc.scoreData.grade], 0.2),
}}
>
<strong>{sc.scoreData.grade}</strong>
<br />
<b>{sc.scoreData.percent.toFixed(2)}%</b>
<br />
{FormatMillions(sc.scoreData.score)}
</td>
<JubeatScoreCell sc={sc} />
<JubeatJudgementCell score={sc} />
<LampCell score={sc} />
<RatingCell score={sc} rating={rating} />
@@ -0,0 +1,84 @@
import IIDXLampCell from "components/tables/cells/IIDXLampCell";
import JubeatScoreCell from "components/tables/cells/JubeatScoreCell";
import LampCell from "components/tables/cells/LampCell";
import MillionsScoreCell from "components/tables/cells/MillionsScoreCell";
import PopnLampCell from "components/tables/cells/PopnLampCell";
import ScoreCell from "components/tables/cells/ScoreCell";
import SDVXLampCell from "components/tables/cells/SDVXLampCell";
import React from "react";
import { ChartDocument, Game, PBScoreDocument, ScoreDocument } from "tachi-common";
import BMSOrPMSLampCell from "../cells/BMSOrPMSLampCell";
import BMSCoreCells from "./BMSCoreCells";
export default function ShortScoreCoreCells({
game,
score,
chart,
}: {
score: ScoreDocument | PBScoreDocument;
chart: ChartDocument;
game: Game;
}) {
const sc = score as any; // lazy hack
if (game === "iidx") {
return (
<>
<ScoreCell score={sc} />
<IIDXLampCell sc={sc} chart={chart as ChartDocument<"iidx:SP" | "iidx:DP">} />
</>
);
} else if (game === "bms" || game === "pms") {
return (
<>
<ScoreCell score={sc} />
<BMSOrPMSLampCell score={sc} />
</>
);
} else if (
game === "sdvx" ||
game === "usc" ||
game === "museca" ||
game === "wacca" ||
game === "chunithm" ||
game === "ddr" ||
game === "gitadora"
) {
return (
<>
<MillionsScoreCell score={sc} />
<SDVXLampCell score={sc} />
</>
);
} else if (game === "maimai") {
return (
<>
<ScoreCell score={sc} />
<LampCell score={sc} />
</>
);
} else if (game === "jubeat") {
return (
<>
<JubeatScoreCell sc={sc} />
<LampCell score={sc} />
</>
);
} else if (game === "popn") {
return (
<>
<MillionsScoreCell score={sc} />
<PopnLampCell score={sc} />
</>
);
} else if (game === "itg") {
return (
<>
<ScoreCell showScore={false} score={sc} />
<LampCell score={sc} />
</>
);
}
return <>No cells defined for {game}. Report this!</>;
}
@@ -10,13 +10,19 @@ import {
ScoreDocument,
} from "tachi-common";
import { SetState } from "types/react";
import { FolderDataset, PBDataset, RivalChartDataset, ScoreDataset } from "types/tables";
import {
ComparePBsDataset,
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 | RivalChartDataset
Dataset extends FolderDataset | PBDataset | ScoreDataset | RivalChartDataset | ComparePBsDataset
>(
game: Game,
playtype: Playtype,
@@ -0,0 +1,127 @@
import { NumericSOV, StrSOV } from "util/sorts";
import { CreatePBCompareSearchParams } from "util/tables/create-search";
import React, { useState } from "react";
import { Game, IDStrings, Playtype } from "tachi-common";
import { ESDCompare } from "tachi-common/lib/esd";
import { ComparePBsDataset } from "types/tables";
import DifficultyCell from "../cells/DifficultyCell";
import PBCompareCell from "../cells/PBCompareCell";
import TitleCell from "../cells/TitleCell";
import SelectableCompareType from "../components/SelectableCompareType";
import TachiTable, { Header, ZTableTHProps } from "../components/TachiTable";
import ShortScoreCoreCells from "../game-core-cells/ShortScoreCoreCells";
import ChartHeader from "../headers/ChartHeader";
export default function ComparePBsTable<I extends IDStrings = IDStrings>({
dataset,
game,
playtype,
baseUser,
compareUser,
shouldESD,
}: {
dataset: ComparePBsDataset<I>;
game: Game;
playtype: Playtype;
baseUser: string;
compareUser: string;
shouldESD: boolean;
}) {
const [compareType, setCompareType] = useState<"score" | "lamp">("score");
const headers: Header<ComparePBsDataset[0]>[] = [
ChartHeader(game, (d) => d.chart),
["Song", "Song", StrSOV((x) => x.song.title)],
["", "", () => 1, () => <td colSpan={2}>{baseUser}</td>],
[
"Vs.",
"Vs.",
NumericSOV((x) => {
if (!x.base) {
return -Infinity;
}
if (!x.compare) {
return -Infinity;
}
if (compareType === "score") {
if (shouldESD && x.base.scoreData.esd && x.compare.scoreData.esd) {
// esd is inverted, whoops
const e = ESDCompare(x.compare.scoreData.esd, x.base.scoreData.esd);
if (e !== 0) {
return e;
}
}
return x.base.scoreData.percent - x.compare.scoreData.percent;
}
return x.base.scoreData.lampIndex - x.compare.scoreData.lampIndex;
}),
(thProps: ZTableTHProps) => (
<SelectableCompareType
key={compareType}
compareType={compareType}
setCompareType={setCompareType}
{...thProps}
/>
),
],
["", "", () => 1, () => <td colSpan={2}>{compareUser}</td>],
];
return (
<TachiTable
// very funny way of getting the table to re-render when shouldESD is changed
key={`${shouldESD}`}
dataset={dataset}
headers={headers}
entryName="Charts"
defaultReverseSort
defaultSortMode="Vs."
searchFunctions={CreatePBCompareSearchParams(game, playtype)}
rowFunction={(data) => (
<Row data={data} game={game} compareType={compareType} shouldESD={shouldESD} />
)}
/>
);
}
function Row<I extends IDStrings = IDStrings>({
data,
game,
compareType,
shouldESD,
}: {
data: ComparePBsDataset<I>[0];
game: Game;
compareType: "score" | "lamp";
shouldESD: boolean;
}) {
return (
<tr>
<DifficultyCell chart={data.chart} game={game} alwaysShort />
<TitleCell game={game} song={data.song} chart={data.chart} />
{data.base ? (
<ShortScoreCoreCells score={data.base} game={game} chart={data.chart} />
) : (
<td colSpan={2}>Not Played</td>
)}
<PBCompareCell
shouldESD={shouldESD}
base={data.base}
compare={data.compare}
game={game}
playtype={data.chart.playtype}
compareType={compareType}
/>
{data.compare ? (
<ShortScoreCoreCells score={data.compare} game={game} chart={data.chart} />
) : (
<td colSpan={2}>Not Played</td>
)}
</tr>
);
}
+22 -1
View File
@@ -2,7 +2,28 @@ import { ToAPIURL } from "util/api";
import React from "react";
import { PublicUserDocument } from "tachi-common";
export default function ProfilePicture({ user, src }: { user: PublicUserDocument; src?: string }) {
export default function ProfilePicture({
user,
src,
}: {
user: PublicUserDocument | string;
src?: string;
}) {
if (typeof user === "string") {
return (
<img
src={src ? src : ToAPIURL(`/users/${user}/pfp`)}
alt={`${user}'s Profile Picture`}
className="rounded"
style={{
width: "128px",
height: "128px",
boxShadow: "0px 0px 10px 0px #000000",
}}
/>
);
}
return (
<img
src={src ? src : ToAPIURL(`/users/${user.id}/pfp`)}
+7 -7
View File
@@ -4,7 +4,7 @@ import NavItem from "components/nav/NavItem";
import MiniTable from "components/tables/components/MiniTable";
import Divider from "components/util/Divider";
import React from "react";
import { Game, GetGamePTConfig, PublicUserDocument } from "tachi-common";
import { Game, PublicUserDocument } from "tachi-common";
import { UGPTStatsReturn } from "types/api-returns";
import { Playtype } from "types/tachi";
import ProfileBadges from "./ProfileBadges";
@@ -100,16 +100,16 @@ export function UGPTBottomNav({
<NavItem key="leaderboard" to={`${baseUrl}/leaderboard`}>
Leaderboard
</NavItem>,
<NavItem key="rivals" to={`${baseUrl}/rivals`}>
Rivals
</NavItem>,
<NavItem key="targets" to={`${baseUrl}/targets`}>
Goals & Quests
</NavItem>,
];
if (isRequestedUser) {
navItems.push(
<NavItem key="rivals" to={`${baseUrl}/rivals`}>
Rivals
</NavItem>,
<NavItem key="targets" to={`${baseUrl}/targets`}>
Goals & Quests
</NavItem>,
<NavItem key="settings" to={`${baseUrl}/settings`}>
Settings
</NavItem>
+2 -2
View File
@@ -9,7 +9,7 @@ export default function UserIcon({
children,
game,
playtype,
}: { user: PublicUserDocument } & JustChildren & Partial<GamePT>) {
}: { user: PublicUserDocument } & Partial<JustChildren> & Partial<GamePT>) {
return (
<div className="text-center p-8">
<ProfilePicture user={user} />
@@ -25,7 +25,7 @@ export default function UserIcon({
{user.username}
</Link>
</h4>
<div className="d-flex justify-content-center">{children}</div>
{children && <div className="d-flex justify-content-center">{children}</div>}
</div>
);
}
@@ -1,10 +1,9 @@
import { APIFetchV1 } from "util/api";
import ProfilePicture from "components/user/ProfilePicture";
import React, { useContext, useEffect, useState } from "react";
import React, { useEffect, useState } from "react";
import { Button, Col, Modal, Row } from "react-bootstrap";
import { integer, PublicUserDocument } from "tachi-common";
import { SetState } from "types/react";
import { UserContext } from "context/UserContext";
import DebounceSearch from "../DebounceSearch";
import Divider from "../Divider";
@@ -14,12 +13,14 @@ export default function UserSelectModal({
setShow,
url = `/users`,
excludeSet = [],
excludeMsg = "N/A",
}: {
show: boolean;
setShow: SetState<boolean>;
callback: (user: PublicUserDocument) => void;
url?: string;
excludeSet?: Array<integer>;
excludeMsg?: string;
}) {
const [search, setSearch] = useState("");
const [users, setUsers] = useState<Array<PublicUserDocument> | null>(null);
@@ -67,7 +68,7 @@ export default function UserSelectModal({
<h4 className="mt-2">{user.username}</h4>
{excludeSet.includes(user.id) ? (
<Button variant="outline-secondary" disabled>
Already Rivalled!
{excludeMsg}
</Button>
) : (
<Button
+6
View File
@@ -121,6 +121,12 @@ export interface UGPTFolderReturns<I extends IDStrings = IDStrings> {
pbs: PBScoreDocument<I>[];
}
export interface GPTFolderReturns<I extends IDStrings = IDStrings> {
folder: FolderDocument;
songs: SongDocument<IDStringToGame[I]>[];
charts: ChartDocument<I>[];
}
export interface GPTStatsReturn {
config: GamePTConfig;
playerCount: integer;
+7
View File
@@ -56,3 +56,10 @@ export type RivalChartDataset<I extends IDStrings = IDStrings> = (PublicUserDocu
index: number;
};
})[];
export type ComparePBsDataset<I extends IDStrings = IDStrings> = Array<{
base: PBScoreDocument<I> | null;
compare: PBScoreDocument<I> | null;
chart: ChartDocument;
song: SongDocument;
}>;
+20 -2
View File
@@ -1,14 +1,14 @@
import { HumanFriendlyStrToGradeIndex, HumanFriendlyStrToLampIndex } from "util/str-to-num";
import { ValueGetterOrHybrid } from "util/ztable/search";
import {
BMS_TABLES,
ChartDocument,
Game,
GamePTConfig,
GetGamePTConfig,
IDStrings,
BMS_TABLES,
} from "tachi-common";
import { FolderDataset, PBDataset, ScoreDataset } from "types/tables";
import { ComparePBsDataset, FolderDataset, PBDataset, ScoreDataset } from "types/tables";
import { Playtype } from "types/tachi";
function GetBMSTableVal(chart: ChartDocument<"bms:7K" | "bms:14K">, key: string) {
@@ -88,6 +88,24 @@ export function CreateDefaultPBSearchParams<I extends IDStrings = IDStrings>(
return searchFunctions;
}
export function CreatePBCompareSearchParams<I extends IDStrings = IDStrings>(
game: Game,
playtype: Playtype
) {
const searchFunctions: Record<string, ValueGetterOrHybrid<ComparePBsDataset<I>[0]>> = {
artist: (x) => x.song.artist,
title: (x) => x.song.title,
difficulty: (x) => x.chart.difficulty,
level: (x) => x.chart.levelNum,
};
if (game === "bms") {
HandleBMSNonsense(searchFunctions, playtype, (k) => k);
}
return searchFunctions;
}
export function CreateDefaultFolderSearchParams<I extends IDStrings = IDStrings>(
game: Game,
playtype: Playtype
+3 -3
View File
@@ -507,9 +507,9 @@ GET /api/v1/games/bms/7K/tableID/insane
*****
## Retrieve the score leaderboard for this Game.
## Retrieve the PB leaderboard for this Game.
`GET /api/v1/games/:game/:playtype/score-leaderboard`
`GET /api/v1/games/:game/:playtype/pb-leaderboard`
### Parameters
@@ -522,7 +522,7 @@ GET /api/v1/games/bms/7K/tableID/insane
| Property | Type | Description |
| :: | :: | :: |
| `pbs` | Array&lt;PBDocument&gt; | The array of pbs part of the score leaderboard. |
| `pbs` | Array&lt;PBDocument&gt; | The array of pbs part of the PB leaderboard. |
| `songs` | Array&lt;[SongDocument](../../schemas/song.md)&gt; | The array of songs part of the PBs. |
| `charts` | Array&lt;[ChartDocument](../../schemas/chart.md)&gt; | The array of charts part of the PBs. |
| `users` | Array&lt;[UserDocument](../../schemas/user.md)&gt; | The array of users part of the PBs. |
@@ -153,9 +153,9 @@ router.get("/leaderboard", async (req, res) => {
* @param alg - An alternative algorithm to use instead of the gpts default.
* @param limit - How many scores to return.
*
* @name GET /api/v1/games/:game/:playtype/score-leaderboard
* @name GET /api/v1/games/:game/:playtype/pb-leaderboard
*/
router.get("/score-leaderboard", async (req, res) => {
router.get("/pb-leaderboard", async (req, res) => {
const { game, playtype } = GetGPT(req);
const gptConfig = GetGamePTConfig(game, playtype);
@@ -2,6 +2,7 @@ import { Router } from "express";
import db from "external/mongo/db";
import { GetRivalUsers } from "lib/rivals/rivals";
import { SearchSpecificGameSongsAndCharts } from "lib/search/search";
import prValidate from "server/middleware/prudence-validate";
import { AggressiveRateLimitMiddleware } from "server/middleware/rate-limiter";
import { GetGamePTConfig } from "tachi-common";
import { GetRelevantSongsAndCharts } from "utils/db";
@@ -97,7 +98,7 @@ router.get("/all", AggressiveRateLimitMiddleware, async (req, res) => {
*
* @name GET /api/v1/users/:userID/games/:game/:playtype/pbs/best
*/
router.get("/best", async (req, res) => {
router.get("/best", prValidate({ alg: "*string" }), async (req, res) => {
const { user, game, playtype } = GetUGPT(req);
const gptConfig = GetGamePTConfig(game, playtype);
@@ -103,7 +103,7 @@ router.get("/challengers", async (req, res) => {
});
/**
* Retrieve a "score leaderboard" for this user's set of rivals.
* Retrieve a "PB 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.
@@ -111,9 +111,9 @@ router.get("/challengers", async (req, res) => {
* @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
* @name GET /api/v1/users/:userID/games/:game/:playtype/rivals/pb-leaderboard
*/
router.get("/score-leaderboard", async (req, res) => {
router.get("/pb-leaderboard", async (req, res) => {
const { user, game, playtype } = GetUGPT(req);
const gptConfig = GetGamePTConfig(game, playtype);