Merge pull request #793 from TNG-dev/zkldi/cg-support

This commit is contained in:
zkldi
2022-12-19 00:03:49 +00:00
committed by GitHub
39 changed files with 1896 additions and 92 deletions
+6
View File
@@ -20,6 +20,12 @@ const command: SlashCommand = {
["MIN SDVX", "api/min-sdvx"],
["ARC IIDX", "api/arc-iidx"],
["ARC SDVX", "api/arc-sdvx"],
["CG DEV SDVX", "api/cg-dev-sdvx"],
["CG DEV MUSECA", "api/cg-dev-museca"],
["CG DEV Pop'n", "api/cg-dev-popn"],
["CG SDVX", "api/cg-prod-sdvx"],
["CG MUSECA", "api/cg-prod-museca"],
["CG Pop'n", "api/cg-prod-popn"],
])
)
.toJSON(),
@@ -133,7 +133,6 @@ function ImportInfoDisplayer({ game }: { game: Game }) {
<ImportTypeInfoCard key="api/flo-iidx" importType="api/flo-iidx" />,
<ImportTypeInfoCard key="api/eag-iidx" importType="api/eag-iidx" />,
<ImportTypeInfoCard key="api/arc-iidx" importType="api/arc-iidx" />,
// <ImportTypeInfoCard key="file/mer-iidx" importType="file/mer-iidx" />,
<ImportTypeInfoCard key="file/solid-state-squad" importType="file/solid-state-squad" />,
<ImportTypeInfoCard key="file/pli-iidx-csv" importType="file/pli-iidx-csv" />
);
@@ -159,6 +158,8 @@ function ImportInfoDisplayer({ game }: { game: Game }) {
/>,
<ImportTypeInfoCard key="api/flo-sdvx" importType="api/flo-sdvx" />,
<ImportTypeInfoCard key="api/eag-sdvx" importType="api/eag-sdvx" />,
<ImportTypeInfoCard key="api/cg-dev-sdvx" importType="api/cg-dev-sdvx" />,
<ImportTypeInfoCard key="api/cg-prod-sdvx" importType="api/cg-prod-sdvx" />,
<ImportTypeInfoCard key="api/min-sdvx" importType="api/min-sdvx" />,
<ImportTypeInfoCard key="api/arc-sdvx" importType="api/arc-sdvx" />
);
@@ -228,7 +229,9 @@ function ImportInfoDisplayer({ game }: { game: Game }) {
desc={`Automatically upload Pop'n scores to ${TachiConfig.name}!`}
moreInfo="Yep, it's that simple."
key="Silent Hook"
/>
/>,
<ImportTypeInfoCard key="api/cg-dev-popn" importType="api/cg-dev-popn" />,
<ImportTypeInfoCard key="api/cg-prod-popn" importType="api/cg-prod-popn" />
);
} else if (game === "pms") {
Content.unshift(
@@ -249,7 +252,17 @@ function ImportInfoDisplayer({ game }: { game: Game }) {
key="WACCA Site Importer"
/>
);
}
} else if (game === "museca") {
Content.unshift(
<ImportTypeInfoCard key="api/cg-dev-museca" importType="api/cg-dev-museca" />,
<ImportTypeInfoCard key="api/cg-prod-museca" importType="api/cg-prod-museca" />
);
} // else if (game === "jubeat") {
// Content.unshift(
// <ImportTypeInfoCard key="api/cg-dev-jubeat" importType="api/cg-dev-jubeat" />,
// <ImportTypeInfoCard key="api/cg-prod-jubeat" importType="api/cg-prod-jubeat" />
// );
// }
return (
<>
@@ -265,7 +278,7 @@ function ImportTypeInfoCard({
importType,
}: {
importType: FileUploadImportTypes | APIImportTypes;
}) {
}): JSX.Element {
switch (importType) {
case "api/arc-sdvx":
return (
@@ -274,7 +287,7 @@ function ImportTypeInfoCard({
href="sdvx-arc"
desc="Pull your SDVX scores from the ARC Network."
moreInfo="Note: All networks are reduced to their first three letters for anonymity reasons. ARC has a serious problem where it only stores one score per chart. This results in broken timestamps, and technically false score imports. I highly recommend using Barbatos instead, and only importing this once to synchronise things up."
key="ARC Integration"
key="sdvx-arc"
/>
);
case "api/arc-iidx":
@@ -284,7 +297,7 @@ function ImportTypeInfoCard({
href="iidx-arc"
desc="Pull your IIDX scores from the ARC Network."
moreInfo="Note: All networks are reduced to their first three letters for anonymity reasons. ARC has a serious problem where it only stores one score per chart. This results in broken timestamps, and technically false score imports. I highly recommend using Fervidex instead, and only importing this once to synchronise things up."
key="ARC Integration"
key="iidx-arc"
/>
);
case "api/eag-iidx":
@@ -294,7 +307,7 @@ function ImportTypeInfoCard({
href="iidx-eag"
desc="Pull your IIDX scores from the EAG Network."
moreInfo="Note: All networks are reduced to their first three letters for anonymity reasons."
key="EAG Integration"
key="iidx-eag"
/>
);
case "api/flo-iidx":
@@ -304,7 +317,7 @@ function ImportTypeInfoCard({
href="iidx-flo"
desc="Pull your IIDX scores from the FLO Network."
moreInfo="Note: All networks are reduced to their first three letters for anonymity reasons."
key="FLO Integration"
key="iidx-flo"
/>
);
case "api/flo-sdvx":
@@ -314,7 +327,7 @@ function ImportTypeInfoCard({
href="sdvx-flo"
desc="Pull your SDVX scores from the FLO Network."
moreInfo="Note: All networks are reduced to their first three letters for anonymity reasons."
key="FLO Integration"
key="sdvx-flo"
/>
);
case "api/eag-sdvx":
@@ -324,7 +337,7 @@ function ImportTypeInfoCard({
href="sdvx-eag"
desc="Pull your SDVX scores from the EAG Network."
moreInfo="Note: All networks are reduced to their first three letters for anonymity reasons."
key="EAG Integration"
key="sdvx-eag"
/>
);
case "api/min-sdvx":
@@ -334,7 +347,61 @@ function ImportTypeInfoCard({
href="sdvx-min"
desc="Pull your SDVX scores from the MIN Network."
moreInfo="Note: All networks are reduced to their first three letters for anonymity reasons."
key="MIN Integration"
key="sdvx-min"
/>
);
case "api/cg-dev-sdvx":
return (
<ImportInfoCard
name="CG Dev Integration"
href="cg-dev-sdvx"
desc="Pull your SDVX scores from the CG Dev Network."
key="cg-dev-sdvx"
/>
);
case "api/cg-prod-sdvx":
return (
<ImportInfoCard
name="CG Prod Integration"
href="cg-prod-sdvx"
desc="Pull your SDVX scores from the CG Network."
key="cg-prod-sdvx"
/>
);
case "api/cg-dev-popn":
return (
<ImportInfoCard
name="CG Dev Integration"
href="cg-dev-popn"
desc="Pull your pop'n music scores from the CG Dev Network."
key="cg-dev-popn"
/>
);
case "api/cg-prod-popn":
return (
<ImportInfoCard
name="CG Prod Integration"
href="cg-prod-popn"
desc="Pull your pop'n music scores from the CG Network."
key="cg-prod-popn"
/>
);
case "api/cg-dev-museca":
return (
<ImportInfoCard
name="CG Dev Integration"
href="cg-dev-museca"
desc="Pull your MUSECA scores from the CG Dev Network."
key="cg-dev-museca"
/>
);
case "api/cg-prod-museca":
return (
<ImportInfoCard
name="CG Prod Integration"
href="cg-prod-museca"
desc="Pull your MUSECA scores from the CG Network."
key="cg-prod-museca"
/>
);
case "file/eamusement-iidx-csv":
@@ -405,6 +472,14 @@ function ImportTypeInfoCard({
key="PLI .csv"
/>
);
default:
// For some reason, the webpack tschecker thinks
// that the above switch isn't exhaustive. However, it is.
return (
<>
Err: Unknown importType <code>{importType}</code>
</>
);
}
}
@@ -1,4 +1,5 @@
import { APIFetchV1 } from "util/api";
import { isCardIDValid } from "util/misc";
import ApiError from "components/util/ApiError";
import Divider from "components/util/Divider";
import Loading from "components/util/Loading";
@@ -169,15 +170,3 @@ function FervidexForm({
</form>
);
}
function isCardIDValid(cardID: string) {
if (cardID.startsWith("E004")) {
return false;
}
if (cardID[0] === "C") {
return cardID.length === 13;
}
return cardID.length === 16;
}
@@ -10,62 +10,68 @@ 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 SelectLinkButton from "components/util/SelectLinkButton";
import { mode, TachiConfig } from "lib/config";
import React, { useEffect, useState } from "react";
import React, { useEffect, useReducer, useState } from "react";
import { Alert, Button, Col, Form, Modal, Row } from "react-bootstrap";
import { Link } from "react-router-dom";
import { Link, Route, Switch } from "react-router-dom";
import {
APIPermissions,
APITokenDocument,
integer,
UserDocument,
TachiAPIClientDocument,
CGCardInfo,
} from "tachi-common";
import { SetState } from "types/react";
import { CGNeedsIntegrate } from "components/imports/CGIntegrationPage";
import ARCIntegrationPage from "./ARCIntegrationPage";
import FervidexIntegrationPage from "./FervidexIntegrationPage";
import KsHookSV6CIntegrationPage from "./KsHookSV6CIntegrationPage";
export default function UserIntegrationsPage({ reqUser }: { reqUser: UserDocument }) {
const [page, setPage] = useState<"services" | "api-keys" | "oauth-clients">("api-keys");
useSetSubheader(
["Users", reqUser.username, "Integrations"],
[reqUser],
`${reqUser.username}'s Integrations`
);
const baseUrl = `/dashboard/users/${reqUser.username}/integrations`;
return (
<Card header="Integrations" className="col-12 offset-lg-2 col-lg-8">
<Row>
<Col xs={12}>
<div className="btn-group d-flex justify-content-center">
{mode !== "btchi" && (
<SelectButton value={page} setValue={setPage} id="services">
<SelectLinkButton to={`${baseUrl}/services`}>
<Icon type="network-wired" />
Service Configuration
</SelectButton>
</SelectLinkButton>
)}
<SelectButton value={page} setValue={setPage} id="api-keys">
<SelectLinkButton to={`${baseUrl}`}>
<Icon type="key" />
API Keys
</SelectButton>
<SelectButton value={page} setValue={setPage} id="oauth-clients">
</SelectLinkButton>
<SelectLinkButton to={`${baseUrl}/oauth-clients`}>
<Icon type="robot" />
My API Clients
</SelectButton>
</SelectLinkButton>
</div>
<Divider />
</Col>
<Col xs={12}>
{page === "services" ? (
<ServicesPage reqUser={reqUser} />
) : page === "api-keys" ? (
<APIKeysPage reqUser={reqUser} />
) : (
<OAuthClientPage />
)}
<Switch>
<Route exact path={baseUrl}>
<APIKeysPage reqUser={reqUser} />
</Route>
<Route path={`${baseUrl}/services`}>
<ServicesPage reqUser={reqUser} />
</Route>
<Route path={`${baseUrl}/oauth-clients`}>
<OAuthClientPage />
</Route>
</Switch>
</Col>
</Row>
</Card>
@@ -625,9 +631,7 @@ function ServicesPage({ reqUser }: { reqUser: UserDocument }) {
);
}
const [page, setPage] = useState<"fervidex" | "kshook" | "arc" | "flo" | "eag" | "min">(
"fervidex"
);
const baseUrl = `/dashboard/users/${reqUser.username}/integrations/services`;
return (
<Row className="text-center justify-content-center">
@@ -645,40 +649,43 @@ function ServicesPage({ reqUser }: { reqUser: UserDocument }) {
</Col>
<Col xs={12}>
<div className="btn-group">
<SelectButton value={page} setValue={setPage} id="fervidex">
Fervidex
</SelectButton>
<SelectButton value={page} setValue={setPage} id="kshook">
KsHook
</SelectButton>
<SelectButton value={page} setValue={setPage} id="arc">
ARC
</SelectButton>
<SelectButton value={page} setValue={setPage} id="flo">
FLO
</SelectButton>
<SelectButton value={page} setValue={setPage} id="eag">
EAG
</SelectButton>
<SelectButton value={page} setValue={setPage} id="min">
MIN
</SelectButton>
<SelectLinkButton to={`${baseUrl}/fervidex`}>Fervidex</SelectLinkButton>
<SelectLinkButton to={`${baseUrl}/cg`}>CG</SelectLinkButton>
<SelectLinkButton to={`${baseUrl}/cg-dev`}>CG Dev</SelectLinkButton>
<SelectLinkButton to={`${baseUrl}/kshook`}>KsHook</SelectLinkButton>
<SelectLinkButton to={`${baseUrl}/arc`}>ARC</SelectLinkButton>
<SelectLinkButton to={`${baseUrl}/flo`}>FLO</SelectLinkButton>
<SelectLinkButton to={`${baseUrl}/eag`}>EAG</SelectLinkButton>
<SelectLinkButton to={`${baseUrl}/min`}>MIN</SelectLinkButton>
</div>
<Divider />
</Col>
{page === "fervidex" ? (
<FervidexIntegrationPage reqUser={reqUser} />
) : page === "kshook" ? (
<KsHookSV6CIntegrationPage reqUser={reqUser} />
) : page === "arc" ? (
<ARCIntegrationPage reqUser={reqUser} />
) : page === "flo" ? (
<KAIIntegrationStatus userID={reqUser.id} kaiType="flo" />
) : page === "eag" ? (
<KAIIntegrationStatus userID={reqUser.id} kaiType="eag" />
) : (
<KAIIntegrationStatus userID={reqUser.id} kaiType="min" />
)}
<Switch>
<Route exact path={`${baseUrl}/fervidex`}>
<FervidexIntegrationPage reqUser={reqUser} />
</Route>
<Route exact path={`${baseUrl}/cg`}>
<CGIntegrationInfo key="cg" userID={reqUser.id} cgType="prod" />
</Route>
<Route exact path={`${baseUrl}/cg-dev`}>
<CGIntegrationInfo key="cg" userID={reqUser.id} cgType="dev" />
</Route>
<Route exact path={`${baseUrl}/kshook`}>
<KsHookSV6CIntegrationPage reqUser={reqUser} />
</Route>
<Route exact path={`${baseUrl}/arc`}>
<ARCIntegrationPage reqUser={reqUser} />
</Route>
<Route exact path={`${baseUrl}/flo`}>
<KAIIntegrationStatus userID={reqUser.id} kaiType="flo" />
</Route>
<Route exact path={`${baseUrl}/eag`}>
<KAIIntegrationStatus userID={reqUser.id} kaiType="eag" />
</Route>
<Route exact path={`${baseUrl}/min`}>
<KAIIntegrationStatus userID={reqUser.id} kaiType="min" />
</Route>
</Switch>
</Row>
);
}
@@ -948,3 +955,48 @@ function APIKeyRow({
</div>
);
}
function CGIntegrationInfo({ cgType, userID }: { cgType: "dev" | "prod"; userID: integer }) {
const [reload, shouldReloadCardInfo] = useReducer((x) => x + 1, 0);
const { data, error } = useApiQuery<CGCardInfo | null>(
`/users/${userID}/integrations/cg/${cgType}`,
undefined,
[reload]
);
if (error) {
return <ApiError error={error} />;
}
// null is a valid response for this call, so be explicit with going to loading
if (data === undefined) {
return <Loading />;
}
return (
<CGNeedsIntegrate
cgType={cgType}
onSubmit={async (cardID, pin) => {
const res = await APIFetchV1(
`/users/${userID}/integrations/cg/${cgType}`,
{
method: "PUT",
body: JSON.stringify({ cardID, pin }),
headers: {
"Content-Type": "application/json",
},
},
true,
true
);
if (res.success) {
shouldReloadCardInfo();
}
}}
initialCardID={data?.cardID ?? undefined}
initialPin={data?.pin ?? undefined}
/>
);
}
+20
View File
@@ -24,6 +24,7 @@ import { mode } from "lib/config";
import React, { useContext } from "react";
import Switch from "react-bootstrap/esm/Switch";
import { Link, Redirect, Route } from "react-router-dom";
import CGIntegrationPage from "components/imports/CGIntegrationPage";
export default function ImportRoutes() {
const { user } = useContext(UserContext);
@@ -171,6 +172,25 @@ export default function ImportRoutes() {
game="sdvx"
/>
</Route>
<Route exact path="/dashboard/import/cg-dev-sdvx">
<CGIntegrationPage cgType="dev" game="sdvx" />
</Route>
<Route exact path="/dashboard/import/cg-dev-popn">
<CGIntegrationPage cgType="dev" game="popn" />
</Route>
<Route exact path="/dashboard/import/cg-dev-museca">
<CGIntegrationPage cgType="dev" game="museca" />
</Route>
<Route exact path="/dashboard/import/cg-prod-sdvx">
<CGIntegrationPage cgType="prod" game="sdvx" />
</Route>
<Route exact path="/dashboard/import/cg-prod-popn">
<CGIntegrationPage cgType="prod" game="popn" />
</Route>
<Route exact path="/dashboard/import/cg-prod-museca">
<CGIntegrationPage cgType="prod" game="museca" />
</Route>
<Route exact path="/dashboard/import/wacca-site">
<WACCASiteImportPage />
+1 -1
View File
@@ -127,7 +127,7 @@ function UserProfileRoutes({ reqUser }: { reqUser: UserDocument }) {
<UserSettingsPage reqUser={reqUser} />
</RequireAuthAsUserParam>
</Route>
<Route exact path="/dashboard/users/:userID/integrations">
<Route path="/dashboard/users/:userID/integrations">
<RequireAuthAsUserParam>
<UserIntegrationsPage reqUser={reqUser} />
</RequireAuthAsUserParam>
@@ -0,0 +1,223 @@
import { APIFetchV1 } from "util/api";
import { ErrorPage } from "app/pages/ErrorPage";
import useSetSubheader from "components/layout/header/useSetSubheader";
import ApiError from "components/util/ApiError";
import Divider from "components/util/Divider";
import FormInput from "components/util/FormInput";
import Loading from "components/util/Loading";
import useImport from "components/util/import/useImport";
import useApiQuery from "components/util/query/useApiQuery";
import { UserContext } from "context/UserContext";
import React, { useContext, useMemo, useReducer, useState } from "react";
import { Button, Col, Form, Row } from "react-bootstrap";
import { APIImportTypes, CGCardInfo, GetGameConfig } from "tachi-common";
import { SetState } from "types/react";
import Icon from "components/util/Icon";
import ImportStateRenderer from "./ImportStateRenderer";
interface Props {
cgType: "dev" | "prod";
game: "sdvx" | "popn" | "museca";
}
export default function CGIntegrationPage({ cgType, game }: Props) {
const gameConfig = GetGameConfig(game);
const cgName = cgType === "dev" ? "CG Dev" : "CG";
const [reload, shouldReloadCardInfo] = useReducer((x) => x + 1, 0);
const [showEdit, setShowEdit] = useState(false);
useSetSubheader(["Import Scores", `${gameConfig.name} Sync (${cgName})`]);
const { user } = useContext(UserContext);
if (!user) {
return <ErrorPage statusCode={401} />;
}
const { data, error } = useApiQuery<CGCardInfo | null>(
`/users/${user.id}/integrations/cg/${cgType}`,
undefined,
[reload]
);
if (error) {
return <ApiError error={error} />;
}
// null is a valid response for this call, so be explicit with going to loading
if (data === undefined) {
return <Loading />;
}
return (
<>
{(showEdit || !data) && (
<>
<CGNeedsIntegrate
cgType={cgType}
onSubmit={async (cardID, pin) => {
const res = await APIFetchV1(
`/users/${user.id}/integrations/cg/${cgType}`,
{
method: "PUT",
body: JSON.stringify({ cardID, pin }),
headers: {
"Content-Type": "application/json",
},
},
true,
true
);
if (res.success) {
shouldReloadCardInfo();
}
}}
initialCardID={data?.cardID ?? undefined}
initialPin={data?.pin ?? undefined}
/>
<Divider />
</>
)}
{data && (
<CGImporter
cgType={cgType}
game={game}
cardID={data.cardID}
setShowEdit={setShowEdit}
showEdit={showEdit}
/>
)}
</>
);
}
function CGImporter({
cgType,
game,
cardID,
showEdit,
setShowEdit,
}: Pick<Props, "cgType" | "game"> & {
cardID: string;
showEdit: boolean;
setShowEdit: SetState<boolean>;
}) {
const importType: APIImportTypes = `api/cg-${cgType}-${game}`;
const cgName = cgType === "dev" ? "CG Dev" : "CG";
const { importState, runImport } = useImport("/import/from-api", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
importType,
}),
});
return (
<div>
<h2 className="text-center mb-4">
Importing scores from {cgName} card{" "}
<code>{cardID.match(/.{1,4}/gu)?.join(" ")}</code>{" "}
<Icon
onClick={() => setShowEdit(!showEdit)}
type={showEdit ? "times" : "pencil-alt"}
noPad
/>
.
</h2>
<Divider />
<div className="d-flex w-100 justify-content-center">
<Button
className="mx-auto"
variant="primary"
onClick={() => runImport()}
disabled={
importState.state === "waiting_init" ||
importState.state === "waiting_processing"
}
>
{importState.state === "waiting_init" ||
importState.state === "waiting_processing"
? "Syncing..."
: "Click to Sync!"}
</Button>
</div>
<Divider />
<div>
Play on {cgName} a lot? You can synchronise your scores straight from the discord by
typing <code>/sync</code>!
</div>
<Divider />
<ImportStateRenderer state={importState} />
</div>
);
}
export function CGNeedsIntegrate({
cgType,
initialCardID,
initialPin,
onSubmit,
}: Pick<Props, "cgType"> & {
onSubmit: (cardID: string, pin: string) => Promise<void>;
initialCardID?: string;
initialPin?: string;
}) {
const cgName = cgType === "dev" ? "CG Dev" : "CG";
const [cardID, setCardID] = useState(initialCardID ?? "");
const [pin, setPin] = useState(initialPin ?? "");
// strip any whitespace the user feels like entering
const realCardID = useMemo(() => cardID.replace(/\s+/gu, ""), [cardID]);
const shouldDisable = useMemo(() => {
// yes i could turn this into a boolean with !
// but have you *seen* how ugly that is?
if (/^[0-9]{4}$/u.exec(pin) && /^[a-zA-Z0-9]{16}$/u.exec(realCardID)) {
return false;
}
return true;
}, [pin, realCardID]);
return (
<div>
<h3 className="text-center mb-4">Set your {cgName} card.</h3>
<FormInput fieldName="Card ID" setValue={setCardID} value={cardID} />
<Form.Label>
This is the card ID that's displayed in game. It should be 16 characters long.
<br />
{cardID.length > 0 && !/^[a-zA-Z0-9]{16}$/u.exec(realCardID) ? (
<span className="text-danger">
Invalid Card ID. This should be 16 alphanumeric characters.
</span>
) : (
cardID.length > 0 && <span className="text-success">Looking good!</span>
)}
</Form.Label>
<br />
<FormInput fieldName="PIN" setValue={setPin} value={pin} type="password" />
<Form.Label>What PIN do you use to card in to {cgName}?</Form.Label>
<br />
{pin.length > 0 && !/^[0-9]{4}$/u.exec(pin) ? (
<span className="text-danger">Invalid PIN. This should be 4 digits.</span>
) : (
pin.length > 0 && <span className="text-success">Looking good!</span>
)}
<Divider />
<div className="w-100 d-flex justify-content-center">
<Button disabled={shouldDisable} onClick={() => onSubmit(realCardID, pin)}>
Submit Card ID
</Button>
</div>
</div>
);
}
@@ -105,11 +105,7 @@ function KAIImporter({ kaiType, game }: Pick<Props, "kaiType" | "game">) {
<Divider />
<div>
Play on {kaiType} a lot? You can synchronise your scores straight from the discord
by typing{" "}
<code>
/sync {kaiType} {game}
</code>
!
by typing <code>/sync</code>!
</div>
<Divider />
<ImportStateRenderer state={importState} />
@@ -99,8 +99,6 @@ export default function BPICell({
chart.data.bpiCoefficient === -1
? 1.175
: chart.data.bpiCoefficient}
<br />
Tip: Click on your score to see more advanced BPI info.
</Muted>
</>
}
@@ -37,7 +37,7 @@ export default function RivalCompare({ chart, game }: { chart: ChartDocument; ga
if (data.rivals.length === 0) {
return (
<div className="text-center">
<div className="w-100 text-center">
You have no rivals set!
<br />
Why not <Link to={`${base}/rivals/manage`}>set some?</Link>
+3
View File
@@ -8,12 +8,14 @@ export default function FormInput({
setValue,
placeholder,
as,
type,
}: {
fieldName: string;
value: string;
setValue: SetState<string>;
placeholder?: string;
as?: ElementType;
type?: string;
}) {
return (
<InputGroup>
@@ -25,6 +27,7 @@ export default function FormInput({
placeholder={placeholder}
value={value}
onChange={(e) => setValue(e.target.value)}
type={type}
/>
</InputGroup>
);
+12
View File
@@ -582,3 +582,15 @@ export function HumanisedJoinArray(arr: Array<string>, lastJoiner = "or") {
return `${arr.slice(0, arr.length - 1).join(", ")} ${lastJoiner} ${arr[arr.length - 1]!}`;
}
export function isCardIDValid(cardID: string) {
if (cardID.startsWith("E004")) {
return false;
}
if (cardID[0] === "C") {
return cardID.length === 13;
}
return cardID.length === 16;
}
+6
View File
@@ -37,6 +37,12 @@ export const apiImportTypes: Array<APIImportTypes> = [
"api/flo-iidx",
"api/flo-sdvx",
"api/min-sdvx",
"api/cg-dev-museca",
"api/cg-dev-popn",
"api/cg-dev-sdvx",
"api/cg-prod-museca",
"api/cg-prod-popn",
"api/cg-prod-sdvx",
];
export const allImportTypes: Array<ImportTypes> = [
+15 -1
View File
@@ -1282,7 +1282,12 @@ export type APIImportTypes =
| "api/eag-sdvx"
| "api/flo-iidx"
| "api/flo-sdvx"
| "api/min-sdvx";
| "api/min-sdvx"
// cg has dev and prod supported
// with four games.
// this typescript feature for stringliteral expansion is kinda neat.
| `api/cg-${"dev" | "prod"}-${"museca" | "popn" | "sdvx"}`;
export type IRImportTypes =
| "ir/barbatos"
@@ -1363,6 +1368,15 @@ export interface KaiAuthDocument {
service: "EAG" | "FLO" | "MIN";
}
export interface CGCardInfo {
userID: integer;
service: "dev" | "prod";
cardID: string;
// are we gonna do maths on it? no. it's a string. don't bother me.
pin: string;
}
/**
* Used to resolve beatoraja IR courses.
*/
+6
View File
@@ -68,6 +68,12 @@
"api/flo-iidx",
"api/flo-sdvx",
"api/min-sdvx",
"api/cg-dev-sdvx",
"api/cg-dev-popn",
"api/cg-dev-museca",
"api/cg-prod-sdvx",
"api/cg-prod-popn",
"api/cg-prod-museca",
],
},
LOGGER_CONFIG: {
+3
View File
@@ -48,6 +48,7 @@ import type {
UserSettings,
KsHookSettingsDocument,
ImportTrackerDocument as ImportTrackerDocument,
CGCardInfo,
} from "tachi-common";
import type { MigrationDocument, PrivateUserInfoDocument } from "utils/types";
@@ -153,6 +154,8 @@ const db = {
counters: monkDB.get<CounterDocument>("counters"),
"game-stats": monkDB.get<UserGameStats>("game-stats"),
"kai-auth-tokens": monkDB.get<KaiAuthDocument>("kai-auth-tokens"),
"cg-card-info": monkDB.get<CGCardInfo>("cg-card-info"),
"bms-course-lookup": monkDB.get<BMSCourseDocument>("bms-course-lookup"),
"api-tokens": monkDB.get<APITokenDocument>("api-tokens"),
"orphan-scores": monkDB.get<OrphanScoreDocument>("orphan-scores"),
+6
View File
@@ -39,6 +39,12 @@ export const DatabaseSchemas: Record<Databases, SchemaValidatorFunction> = {
refreshToken: "string",
service: p.isIn("FLO", "EAG", "MIN"),
}),
"cg-card-info": prSchemaify({
userID: p.isPositiveNonZeroInteger,
service: p.isIn("dev", "prod"),
cardID: "string",
pin: "string",
}),
"oauth2-auth-codes": prSchemaify({
code: "string",
userID: p.isPositiveNonZeroInteger,
@@ -1,7 +1,7 @@
import { InternalFailure, InvalidScoreFailure } from "./converter-failures";
import CreateLogCtx from "lib/logger/logger";
import { ESDCore, GetGamePTConfig } from "tachi-common";
import { IsNullish, NotNullish } from "utils/misc";
import { FloorToNDP, IsNullish, NotNullish } from "utils/misc";
import type {
ChartDocument,
Game,
@@ -9,6 +9,8 @@ import type {
Grades,
IDStrings,
Playtype,
Lamps,
integer,
} from "tachi-common";
const logger = CreateLogCtx(__filename);
@@ -46,7 +48,7 @@ export function GetGradeFromPercent<I extends IDStrings = IDStrings>(
}
/**
* A Generic function for calculating a percent from a given score on
* A generic function for calculating a percent from a given score on
* a given game.
*/
export function GenericCalculatePercent(game: Game, score: number, chart?: ChartDocument): number {
@@ -93,6 +95,7 @@ export function GenericCalculatePercent(game: Game, score: number, chart?: Chart
}
case "jubeat":
throw new Error(`Cannot calculate percent for jubeat. Music Rate must be provided.`);
case "gitadora":
case "itg":
return score;
@@ -218,3 +221,34 @@ export function ParseDateFromString(str: string | null | undefined): number | nu
return date;
}
/**
* Turn a museca score into its lamp. Note that we disagree with the game on what
* constitutes a clear -- instead, 800k is marked as the minimum point for a clear.
*
* Museca actually handles clears differently with a bunch of grafica nonsense,
* but nobody actually cares about it, so...
*/
export function MusecaGetLamp(score: integer, missCount: integer): Lamps["museca:Single"] {
if (score === 1_000_000) {
return "PERFECT CONNECT ALL";
} else if (missCount === 0) {
return "CONNECT ALL";
} else if (score >= 800_000) {
return "CLEAR";
}
return "FAILED";
}
export function JubeatGetLamp(score: integer, missCount: integer): Lamps["jubeat:Single"] {
if (score === 1_000_000) {
return "EXCELLENT";
} else if (missCount === 0) {
return "FULL COMBO";
} else if (score >= 700_000) {
return "CLEAR";
}
return "FAILED";
}
@@ -0,0 +1,107 @@
import { ConverterAPICGMuseca } from "./converter";
import CreateLogCtx from "lib/logger/logger";
import { ParseDateFromString } from "lib/score-import/framework/common/score-utils";
import t from "tap";
import { dmf } from "test-utils/misc";
import type { CGContext, CGMusecaScore } from "../types";
import type { DryScore } from "lib/score-import/framework/common/types";
const logger = CreateLogCtx(__filename);
function mkInput(modifant: Partial<CGMusecaScore> = {}) {
const validInput: CGMusecaScore = {
internalId: 1, // ALBIDA (Remix) ADV
difficulty: 0,
dateTime: "2019-06-06 08:14:22",
score: 912_000,
version: 1,
clearType: 2,
critical: 100,
near: 50,
error: 10,
maxChain: 300,
scoreGrade: "whatever, this is unused",
};
return dmf(validInput, modifant);
}
// we use any here because it's not easy to allow deep partials
function mkOutput(modifant: any = {}): DryScore<"museca:Single"> {
const validOutput: DryScore<"museca:Single"> = {
comment: null,
game: "museca",
importType: "api/cg-dev-museca",
// we handle it like this because -- with no timezone info
// this will fail in CI; it has a different timezone there!
timeAchieved: ParseDateFromString("2019-06-06 08:14:22"),
service: "CG Dev",
scoreData: {
grade: "優",
percent: 91.2,
score: 912_000,
lamp: "CLEAR",
judgements: {
critical: 100,
near: 50,
miss: 10,
},
hitMeta: {},
},
scoreMeta: {},
};
return dmf(validOutput, modifant);
}
t.test("#ConverterAPICGMuseca", (t) => {
const context: CGContext = {
service: "dev",
userID: 1,
};
const convert = (modifant: Partial<CGMusecaScore> = {}) =>
ConverterAPICGMuseca(mkInput(modifant), context, "api/cg-dev-museca", logger);
t.test("Valid Input", async (t) => {
const res = await convert();
t.hasStrict(res, {
song: {
id: 1,
},
chart: {
difficulty: "Green",
data: {
inGameID: 1,
},
},
dryScore: mkOutput(),
});
t.end();
});
t.test("Lamps", async (t) => {
t.hasStrict(await convert({ score: 1_000_000 }), {
dryScore: { scoreData: { lamp: "PERFECT CONNECT ALL" } },
});
t.hasStrict(await convert({ score: 900_000, error: 0 }), {
dryScore: { scoreData: { lamp: "CONNECT ALL" } },
});
t.hasStrict(await convert({ score: 800_000, error: 10 }), {
dryScore: { scoreData: { lamp: "CLEAR" } },
});
t.hasStrict(await convert({ score: 700_000, error: 10 }), {
dryScore: { scoreData: { lamp: "FAILED" } },
});
t.hasStrict(await convert({ score: 799_999, error: 10 }), {
dryScore: { scoreData: { lamp: "FAILED" } },
});
t.end();
});
t.end();
});
@@ -0,0 +1,104 @@
import { FormatCGService } from "../util";
import {
InternalFailure,
InvalidScoreFailure,
KTDataNotFoundFailure,
} from "lib/score-import/framework/common/converter-failures";
import {
GenericGetGradeAndPercent,
MusecaGetLamp,
ParseDateFromString,
} from "lib/score-import/framework/common/score-utils";
import { FindChartOnInGameIDVersion } from "utils/queries/charts";
import { FindSongOnID } from "utils/queries/songs";
import type { ConverterFunction } from "../../types";
import type { CGContext, CGMusecaScore } from "../types";
import type { DryScore } from "lib/score-import/framework/common/types";
import type { Difficulties, GPTSupportedVersions } from "tachi-common";
export const ConverterAPICGMuseca: ConverterFunction<CGMusecaScore, CGContext> = async (
data,
context,
importType,
logger
) => {
const difficulty = ConvertDifficulty(data.difficulty);
const version = ConvertVersion(data.version);
const chart = await FindChartOnInGameIDVersion(
"museca",
data.internalId,
"Single",
difficulty,
version
);
if (!chart) {
throw new KTDataNotFoundFailure(
`Could not find chart with songID ${data.internalId} (${difficulty} - Version ${version})`,
importType,
data,
context
);
}
const song = await FindSongOnID("museca", chart.songID);
if (!song) {
logger.severe(`Song-Chart desync with song ID ${chart.songID} (museca).`);
throw new InternalFailure(`Song-Chart desync with song ID ${chart.songID} (museca).`);
}
const lamp = MusecaGetLamp(data.score, data.error);
const { percent, grade } = GenericGetGradeAndPercent("museca", data.score, chart);
const timeAchieved = ParseDateFromString(data.dateTime);
const dryScore: DryScore<"museca:Single"> = {
comment: null,
game: "museca",
importType,
timeAchieved,
service: FormatCGService(context.service),
scoreData: {
grade,
percent,
score: data.score,
lamp,
judgements: {
critical: data.critical,
near: data.near,
miss: data.error,
},
hitMeta: {
maxCombo: data.maxChain,
},
},
scoreMeta: {},
};
return { song, chart, dryScore };
};
function ConvertDifficulty(diff: number): Difficulties["museca:Single"] {
switch (diff) {
case 0:
return "Green";
case 1:
return "Yellow";
case 2:
return "Red";
}
throw new InvalidScoreFailure(`Invalid difficulty of ${diff} - Could not convert.`);
}
function ConvertVersion(ver: number): GPTSupportedVersions["museca:Single"] {
switch (ver) {
case 1:
return "1.5-b";
}
throw new InvalidScoreFailure(`Unknown/Unsupported Game Version ${ver}.`);
}
@@ -0,0 +1,150 @@
import { FetchCGScores } from "./traverse-api";
import { CGGameToTachiGame } from "./util";
import db from "external/mongo/db";
import ScoreImportFatalError from "lib/score-import/framework/score-importing/score-import-error";
import fetch from "node-fetch";
import p from "prudence";
import { FormatPrError } from "tachi-common";
import type { ParserFunctionReturns } from "../types";
import type {
CGContext,
CGJubeatScore,
CGMusecaScore,
CGPopnScore,
CGSDVXScore,
CGServices,
CGSupportedGames,
} from "./types";
import type { KtLogger } from "lib/logger/logger";
import type { PrudenceSchema } from "prudence";
import type { integer } from "tachi-common";
const PR_CG_JUBEAT = {
internalId: p.isPositiveInteger,
difficulty: p.isPositiveInteger,
version: p.isPositiveInteger,
// unused
clearFlag: p.any,
score: p.isBoundedInteger(0, 1_000_000),
hardMode: "boolean",
perfectCount: p.isPositiveInteger,
greatCount: p.isPositiveInteger,
goodCount: p.isPositiveInteger,
poorCount: p.isPositiveInteger,
missCount: p.isPositiveInteger,
dateTime: "string",
};
const PR_CG_SDVX = {
internalId: p.isPositiveInteger,
difficulty: p.isPositiveInteger,
version: p.isPositiveInteger,
score: p.isBoundedInteger(0, 10_000_000),
exScore: p.isPositiveInteger,
clearType: p.isPositiveInteger,
// unused
scoreGrade: p.any,
maxChain: p.isPositiveInteger,
critical: p.isPositiveInteger,
near: p.isPositiveInteger,
error: p.isPositiveInteger,
dateTime: "string",
};
const PR_CG_MUSECA = {
internalId: p.isPositiveInteger,
difficulty: p.isPositiveInteger,
version: p.isPositiveInteger,
score: p.isBoundedInteger(0, 1_000_000),
// unused
clearType: p.any,
scoreGrade: p.any,
maxChain: p.isPositiveInteger,
critical: p.isPositiveInteger,
near: p.isPositiveInteger,
error: p.isPositiveInteger,
dateTime: "string",
};
const PR_CG_POPN = {
internalId: p.isPositiveInteger,
difficulty: p.isPositiveInteger,
version: p.isPositiveInteger,
clearFlag: p.isPositiveInteger,
score: p.isBoundedInteger(0, 100_000),
coolCount: p.isPositiveInteger,
greatCount: p.isPositiveInteger,
goodCount: p.isPositiveInteger,
badCount: p.isPositiveInteger,
dateTime: "string",
};
// given a CG game, what should the returned data look like?
const CG_SCHEMAS: Record<CGSupportedGames, PrudenceSchema> = {
jb: PR_CG_JUBEAT,
msc: PR_CG_MUSECA,
sdvx: PR_CG_SDVX,
popn: PR_CG_POPN,
};
/**
* Create a CG parser for this supported game. Since all CG parsing code is effectively
* identical, this basically just placeholders cgGame and service.
*/
export function CreateCGParser<T>(cgGame: CGSupportedGames, service: CGServices) {
return async (
userID: integer,
logger: KtLogger
): Promise<ParserFunctionReturns<T, CGContext>> => {
const cardInfo = await db["cg-card-info"].findOne({
userID,
service,
});
if (!cardInfo) {
throw new ScoreImportFatalError(
401,
`This user has no card info set up for this service.`
);
}
const scores = await FetchCGScores(service, cardInfo, cgGame, logger, fetch);
const SCHEMA = CG_SCHEMAS[cgGame];
// check that this data is in the structure we expected
const err = p({ data: scores }, { data: [SCHEMA] });
if (err) {
throw new ScoreImportFatalError(400, FormatPrError(err, `Invalid CG ${cgGame} Score.`));
}
return {
context: {
service,
userID: cardInfo.userID,
},
game: CGGameToTachiGame(cgGame),
iterable: scores as Array<T>,
classHandler: null,
};
};
}
export const ParseCGDevMuseca = CreateCGParser<CGMusecaScore>("msc", "dev");
export const ParseCGDevSDVX = CreateCGParser<CGSDVXScore>("sdvx", "dev");
export const ParseCGDevJubeat = CreateCGParser<CGJubeatScore>("jb", "dev");
export const ParseCGDevPopn = CreateCGParser<CGPopnScore>("popn", "dev");
export const ParseCGProdMuseca = CreateCGParser<CGMusecaScore>("msc", "prod");
export const ParseCGProdSDVX = CreateCGParser<CGSDVXScore>("sdvx", "prod");
export const ParseCGProdJubeat = CreateCGParser<CGJubeatScore>("jb", "prod");
export const ParseCGProdPopn = CreateCGParser<CGPopnScore>("popn", "prod");
@@ -0,0 +1,126 @@
import { ConverterAPICGPopn } from "./converter";
import CreateLogCtx from "lib/logger/logger";
import { ParseDateFromString } from "lib/score-import/framework/common/score-utils";
import t from "tap";
import { dmf } from "test-utils/misc";
import type { CGContext, CGPopnScore } from "../types";
import type { DryScore } from "lib/score-import/framework/common/types";
const logger = CreateLogCtx(__filename);
function mkInput(modifant: Partial<CGPopnScore> = {}) {
const validInput: CGPopnScore = {
internalId: 0, // this is "i really wanna hurt you"
difficulty: 0,
coolCount: 100,
badCount: 50,
greatCount: 15,
goodCount: 25,
clearFlag: 5, // clear,
dateTime: "2019-06-06 08:14:22",
score: 87_000,
version: 25,
};
return dmf(validInput, modifant);
}
// we use any here because it's not easy to allow deep partials
function mkOutput(modifant: any = {}): DryScore<"popn:9B"> {
const validOutput: DryScore<"popn:9B"> = {
comment: null,
game: "popn",
importType: "api/cg-dev-popn",
// we handle it like this because -- with no timezone info
// this will fail in CI; it has a different timezone there!
timeAchieved: ParseDateFromString("2019-06-06 08:14:22"),
service: "CG Dev",
scoreData: {
grade: "A",
percent: 87,
score: 87000,
lamp: "CLEAR",
judgements: {
cool: 100,
great: 15,
good: 25,
bad: 50,
},
hitMeta: {
specificClearType: "clearCircle",
},
},
scoreMeta: {},
};
return dmf(validOutput, modifant);
}
t.test("#ConverterAPICGPopn", (t) => {
const context: CGContext = {
service: "dev",
userID: 1,
};
const convert = (modifant: Partial<CGPopnScore> = {}) =>
ConverterAPICGPopn(mkInput(modifant), context, "api/cg-dev-popn", logger);
t.test("Valid Input", async (t) => {
const res = await convert();
t.hasStrict(res, {
song: {
id: 1,
},
chart: {
difficulty: "Easy",
data: {
inGameID: 0,
},
},
dryScore: mkOutput(),
});
t.end();
});
t.test("Should cap fails at A grade", async (t) => {
const res = await convert({
clearFlag: 1,
score: 98_000,
});
t.hasStrict(res, {
dryScore: mkOutput({
scoreData: {
lamp: "FAILED",
grade: "A",
score: 98_000,
percent: 98,
hitMeta: {
specificClearType: "failedCircle",
},
},
}),
});
const res2 = await convert({
score: 98_000,
});
t.hasStrict(res2, {
dryScore: mkOutput({
scoreData: {
grade: "S",
score: 98_000,
percent: 98,
},
}),
});
t.end();
});
t.end();
});
@@ -0,0 +1,168 @@
import { FormatCGService } from "../util";
import {
InternalFailure,
InvalidScoreFailure,
KTDataNotFoundFailure,
} from "lib/score-import/framework/common/converter-failures";
import {
GenericGetGradeAndPercent,
ParseDateFromString,
} from "lib/score-import/framework/common/score-utils";
import { FindChartOnInGameIDVersion } from "utils/queries/charts";
import { FindSongOnID } from "utils/queries/songs";
import type { ConverterFunction } from "../../types";
import type { CGContext, CGPopnScore } from "../types";
import type { DryScore } from "lib/score-import/framework/common/types";
import type {
Difficulties,
GPTSupportedVersions,
HitMetaLookup,
Lamps,
integer,
} from "tachi-common";
export const ConverterAPICGPopn: ConverterFunction<CGPopnScore, CGContext> = async (
data,
context,
importType,
logger
) => {
const difficulty = ConvertDifficulty(data.difficulty);
const version = ConvertVersion(data.version);
const chart = await FindChartOnInGameIDVersion(
"popn",
data.internalId,
"9B",
difficulty,
version
);
if (!chart) {
throw new KTDataNotFoundFailure(
`Could not find chart with songID ${data.internalId} (${difficulty} - Version ${version})`,
importType,
data,
context
);
}
const song = await FindSongOnID("popn", chart.songID);
if (!song) {
logger.severe(`Song-Chart desync with song ID ${chart.songID} (popn).`);
throw new InternalFailure(`Song-Chart desync with song ID ${chart.songID} (popn).`);
}
const lamp = GetLamp(data.clearFlag);
const specificClearType = GetSpecificClearMedal(data.clearFlag);
const gradeAndPercent = GenericGetGradeAndPercent("popn", data.score, chart);
const percent = gradeAndPercent.percent; // ugly declarations because one is const
// and the other isn't...
let grade = gradeAndPercent.grade;
// TEMP HACK: TODO MOVE THIS TO COMMON SOMEHOW
// pop'n scores are capped at an A rank if they're fails.
if (lamp === "FAILED" && percent >= 90) {
grade = "A";
}
const timeAchieved = ParseDateFromString(data.dateTime);
const dryScore: DryScore<"popn:9B"> = {
comment: null,
game: "popn",
importType,
timeAchieved,
service: FormatCGService(context.service),
scoreData: {
grade,
percent,
score: data.score,
lamp,
judgements: {
cool: data.coolCount,
great: data.greatCount,
good: data.goodCount,
bad: data.badCount,
},
hitMeta: {
specificClearType,
},
},
scoreMeta: {},
};
return { song, chart, dryScore };
};
function ConvertDifficulty(diff: number): Difficulties["popn:9B"] {
switch (diff) {
case 0:
return "Easy";
case 1:
return "Normal";
case 2:
return "Hyper";
case 3:
return "EX";
}
throw new InvalidScoreFailure(`Invalid difficulty of ${diff} - Could not convert.`);
}
function ConvertVersion(ver: number): GPTSupportedVersions["popn:9B"] {
switch (ver) {
case 26:
return "kaimei";
case 25:
return "peace";
}
throw new InvalidScoreFailure(`Unknown/Unsupported Game Version ${ver}.`);
}
function GetLamp(clearFlag: integer): Lamps["popn:9B"] {
if (clearFlag === 11) {
return "PERFECT";
} else if (clearFlag >= 8) {
return "FULL COMBO";
} else if (clearFlag >= 5) {
return "CLEAR";
} else if (clearFlag === 4) {
return "EASY CLEAR";
}
return "FAILED";
}
function GetSpecificClearMedal(clearFlag: integer): HitMetaLookup["popn:9B"]["specificClearType"] {
switch (clearFlag) {
case 1:
return "failedCircle";
case 2:
return "failedDiamond";
case 3:
return "failedStar";
case 4:
return "easyClear";
case 5:
return "clearCircle";
case 6:
return "clearDiamond";
case 7:
return "clearStar";
case 8:
return "fullComboCircle";
case 9:
return "fullComboDiamond";
case 10:
return "fullComboStar";
case 11:
return "perfect";
}
// no idea.
return null;
}
@@ -0,0 +1,108 @@
import { ConverterAPICGSDVX } from "./converter";
import CreateLogCtx from "lib/logger/logger";
import { ParseDateFromString } from "lib/score-import/framework/common/score-utils";
import t from "tap";
import { dmf } from "test-utils/misc";
import type { CGContext, CGSDVXScore } from "../types";
import type { DryScore } from "lib/score-import/framework/common/types";
const logger = CreateLogCtx(__filename);
function mkInput(modifant: Partial<CGSDVXScore> = {}) {
const validInput: CGSDVXScore = {
internalId: 1, // ALBIDA (Remix) ADV
difficulty: 1,
dateTime: "2019-06-06 08:14:22",
score: 9_123_000,
version: 6,
clearType: 2,
critical: 100,
near: 50,
error: 10,
exScore: 1234,
maxChain: 300,
scoreGrade: "whatever, this is unused",
};
return dmf(validInput, modifant);
}
// we use any here because it's not easy to allow deep partials
function mkOutput(modifant: any = {}): DryScore<"sdvx:Single"> {
const validOutput: DryScore<"sdvx:Single"> = {
comment: null,
game: "sdvx",
importType: "api/cg-dev-sdvx",
// we handle it like this because -- with no timezone info
// this will fail in CI; it has a different timezone there!
timeAchieved: ParseDateFromString("2019-06-06 08:14:22"),
service: "CG Dev",
scoreData: {
grade: "A+",
percent: 91.23,
score: 9_123_000,
lamp: "CLEAR",
judgements: {
critical: 100,
near: 50,
miss: 10,
},
hitMeta: {},
},
scoreMeta: {},
};
return dmf(validOutput, modifant);
}
t.test("#ConverterAPICGSDVX", (t) => {
const context: CGContext = {
service: "dev",
userID: 1,
};
const convert = (modifant: Partial<CGSDVXScore> = {}) =>
ConverterAPICGSDVX(mkInput(modifant), context, "api/cg-dev-sdvx", logger);
t.test("Valid Input", async (t) => {
const res = await convert();
t.hasStrict(res, {
song: {
id: 1,
},
chart: {
difficulty: "ADV",
data: {
inGameID: 1,
},
},
dryScore: mkOutput(),
});
t.end();
});
t.test("Lamps", async (t) => {
t.hasStrict(await convert({ clearType: 1 }), {
dryScore: mkOutput({ scoreData: { lamp: "FAILED" } }),
});
t.hasStrict(await convert({ clearType: 2 }), {
dryScore: mkOutput({ scoreData: { lamp: "CLEAR" } }),
});
t.hasStrict(await convert({ clearType: 3 }), {
dryScore: mkOutput({ scoreData: { lamp: "EXCESSIVE CLEAR" } }),
});
t.hasStrict(await convert({ clearType: 4 }), {
dryScore: mkOutput({ scoreData: { lamp: "ULTIMATE CHAIN" } }),
});
t.hasStrict(await convert({ clearType: 5 }), {
dryScore: mkOutput({ scoreData: { lamp: "PERFECT ULTIMATE CHAIN" } }),
});
t.end();
});
t.end();
});
@@ -0,0 +1,132 @@
import { FormatCGService } from "../util";
import {
InternalFailure,
InvalidScoreFailure,
KTDataNotFoundFailure,
} from "lib/score-import/framework/common/converter-failures";
import {
GenericGetGradeAndPercent,
ParseDateFromString,
} from "lib/score-import/framework/common/score-utils";
import { FindSDVXChartOnInGameIDVersion } from "utils/queries/charts";
import { FindSongOnID } from "utils/queries/songs";
import type { ConverterFunction } from "../../types";
import type { CGContext, CGSDVXScore } from "../types";
import type { DryScore } from "lib/score-import/framework/common/types";
import type { GPTSupportedVersions, Lamps } from "tachi-common";
export const ConverterAPICGSDVX: ConverterFunction<CGSDVXScore, CGContext> = async (
data,
context,
importType,
logger
) => {
const difficulty = ConvertDifficulty(data.difficulty);
const version = ConvertVersion(data.version);
const chart = await FindSDVXChartOnInGameIDVersion(data.internalId, difficulty, version);
if (!chart) {
throw new KTDataNotFoundFailure(
`Could not find chart with songID ${data.internalId} (${difficulty} - Version ${version})`,
importType,
data,
context
);
}
const song = await FindSongOnID("sdvx", chart.songID);
if (!song) {
logger.severe(`Song-Chart desync with song ID ${chart.songID} (sdvx).`);
throw new InternalFailure(`Song-Chart desync with song ID ${chart.songID} (sdvx).`);
}
const lamp = ConvertCGSDVXLamp(data.clearType);
const { percent, grade } = GenericGetGradeAndPercent("sdvx", data.score, chart);
const timeAchieved = ParseDateFromString(data.dateTime);
const dryScore: DryScore<"sdvx:Single"> = {
comment: null,
game: "sdvx",
importType,
timeAchieved,
service: FormatCGService(context.service),
scoreData: {
grade,
percent,
score: data.score,
lamp,
judgements: {
critical: data.critical,
near: data.near,
miss: data.error,
},
hitMeta: {
maxCombo: data.maxChain,
},
},
scoreMeta: {},
};
return { song, chart, dryScore };
};
function ConvertDifficulty(diff: number) {
switch (diff) {
case 0:
return "NOV";
case 1:
return "ADV";
case 2:
return "EXH";
case 3:
return "ANY_INF";
case 4:
return "MXM";
}
throw new InvalidScoreFailure(`Invalid difficulty of ${diff} - Could not convert.`);
}
function ConvertVersion(ver: number): GPTSupportedVersions["sdvx:Single"] {
switch (ver) {
case 1:
return "booth";
case 2:
return "inf";
case 3:
return "gw";
case 4:
return "heaven";
case 5:
return "vivid";
case 6:
return "exceed";
}
throw new InvalidScoreFailure(`Unknown Game Version ${ver}.`);
}
/**
* Convert CG's clearType enum into a Tachi lamp. Note that what numbers mean what are
* dependent on what version of the game we're listening for.
*/
function ConvertCGSDVXLamp(clearType: number): Lamps["sdvx:Single"] {
switch (clearType) {
case 1:
return "FAILED";
case 2:
return "CLEAR";
case 3:
return "EXCESSIVE CLEAR";
case 4:
return "ULTIMATE CHAIN";
case 5:
return "PERFECT ULTIMATE CHAIN";
}
throw new InvalidScoreFailure(`Invalid lamp of ${clearType} - Could not convert.`);
}
@@ -0,0 +1,109 @@
import ScoreImportFatalError from "lib/score-import/framework/score-importing/score-import-error";
import { ServerConfig } from "lib/setup/config";
import p from "prudence";
import nodeFetch from "utils/fetch";
import { URLSearchParams } from "url";
import type { CGErrorResponse, CGScoresResponse, CGServices, CGSupportedGames } from "./types";
import type { KtLogger } from "lib/logger/logger";
import type { PrudenceSchema } from "prudence";
import type { CGCardInfo } from "tachi-common";
import type { NodeFetch } from "utils/fetch";
const PR_CG_RESPONSE: PrudenceSchema = {
success: "boolean",
data: {
profile: {
id: "string",
name: "string",
},
// validated elsewhere
scores: [p.any],
},
};
const PR_CG_ERR_RESPONSE: PrudenceSchema = {
success: "boolean",
message: "string",
};
/**
* Fetch this info from CG. **This function does not validate the content of scores**,
* instead, that should be handled by the parser.
*/
export async function FetchCGScores(
service: CGServices,
cardInfo: CGCardInfo,
game: CGSupportedGames,
logger: KtLogger,
fetch: NodeFetch = nodeFetch
): Promise<Array<unknown>> {
const url = GetCGUrl(service, cardInfo, game);
let validatedRes: CGErrorResponse | CGScoresResponse<unknown>;
try {
const res: unknown = await fetch(url).then((r) => r.json());
const prErr = p({ res }, { res: p.or(PR_CG_RESPONSE, PR_CG_ERR_RESPONSE) });
if (prErr) {
logger.error(`Got unexpected data from CG.`, { res });
throw new Error(`Got unexpected data from CG.`);
}
validatedRes = res as CGErrorResponse | CGScoresResponse<unknown>;
} catch (err) {
logger.error(`Received invalid response from ${url}.`, { err });
throw new ScoreImportFatalError(
500,
`Received invalid response from their API. Are they down?`
);
}
if (validatedRes.success) {
return validatedRes.data.scores;
}
throw new ScoreImportFatalError(
400,
`Failed to fetch data from CG. Error Message: ${validatedRes.message}.`
);
}
function GetCGConf(service: CGServices) {
switch (service) {
case "dev": {
if (!ServerConfig.CG_DEV_CONFIG) {
throw new Error(
`No CG_DEV_CONFIG is available, yet a request to cg-dev was attempted?`
);
}
return ServerConfig.CG_DEV_CONFIG;
}
case "prod": {
if (!ServerConfig.CG_PROD_CONFIG) {
throw new Error(
`No CG_PROD_CONFIG is available, yet a request to cg-prod was attempted?`
);
}
return ServerConfig.CG_PROD_CONFIG;
}
}
}
function GetCGUrl(service: CGServices, cardInfo: CGCardInfo, game: CGSupportedGames) {
const cgConf = GetCGConf(service);
const params = new URLSearchParams();
params.set("api_key", cgConf.API_KEY);
params.set("card_id", cardInfo.cardID);
params.set("pin", cardInfo.pin);
return `${cgConf.URL}/${game}-scores?${params.toString()}`;
}
@@ -0,0 +1,89 @@
import type { integer } from "tachi-common";
export type CGSupportedGames =
| "jb" // jubeat
| "msc" // museca
| "popn"
| "sdvx";
export type CGServices = "dev" | "prod";
export interface CGScoresResponse<T> {
success: true;
data: {
profile: {
id: string;
name: string;
};
scores: Array<T>;
};
}
export interface CGErrorResponse {
success: false;
message: string;
}
export interface CGMusecaScore {
internalId: integer;
difficulty: integer;
version: integer;
score: integer;
clearType: unknown; // we don't use this
scoreGrade: unknown; // don't care, don't use
maxChain: integer;
critical: integer;
near: integer;
error: integer;
dateTime: string;
}
export interface CGPopnScore {
internalId: integer;
difficulty: integer;
version: integer;
clearFlag: integer;
score: integer;
coolCount: integer;
greatCount: integer;
goodCount: integer;
badCount: integer;
dateTime: string;
}
export interface CGSDVXScore {
internalId: integer;
difficulty: integer;
version: integer;
score: integer;
exScore: integer;
clearType: integer; // what this means depends on what version of the game
// we're looking at.
scoreGrade: unknown; // unused, as we derive it
maxChain: integer;
critical: integer;
near: integer;
error: integer;
dateTime: string;
}
export interface CGJubeatScore {
internalId: integer;
difficulty: integer;
version: integer;
clearFlag: unknown; // Weird bitwise field, unused.
score: integer;
hardMode: boolean;
perfectCount: integer;
greatCount: integer;
goodCount: integer;
poorCount: integer;
missCount: integer;
dateTime: string;
}
export interface CGContext {
service: "dev" | "prod";
userID: integer;
}
@@ -0,0 +1,18 @@
import type { CGServices, CGSupportedGames } from "./types";
import type { Game } from "tachi-common";
export function CGGameToTachiGame(cgGame: CGSupportedGames): Game {
switch (cgGame) {
case "jb":
return "jubeat";
case "msc":
return "museca";
case "popn":
case "sdvx":
return cgGame;
}
}
export function FormatCGService(cgService: CGServices) {
return cgService === "dev" ? "CG Dev" : "CG";
}
@@ -79,10 +79,10 @@ export async function* TraverseKaiAPI(
},
});
} catch (err) {
logger.error(`received invalid response from ${url}.`, { err });
logger.error(`Received invalid response from ${url}.`, { err });
throw new ScoreImportFatalError(
500,
`received invalid response from their API. Are they down?`
`Received invalid response from their API. Are they down?`
);
}
@@ -12,6 +12,13 @@ import type { KsHookSV6CStaticScore } from "../ir/kshook-sv6c-static/types";
import type { KsHookSV6CContext, KsHookSV6CScore } from "../ir/kshook-sv6c/types";
import type { LR2HookContext, LR2HookScore } from "../ir/lr2hook/types";
import type { IRUSCContext } from "../ir/usc/types";
import type {
CGContext,
CGJubeatScore,
CGMusecaScore,
CGPopnScore,
CGSDVXScore,
} from "./api-cg/types";
import type { KaiContext } from "./api-kai/types";
import type { BatchManualContext } from "./batch-manual/types";
import type { IIDXEamusementCSVContext, IIDXEamusementCSVData } from "./eamusement-iidx-csv/types";
@@ -55,6 +62,18 @@ export interface ImportTypeDataMap {
"api/min-sdvx": unknown;
"api/eag-iidx": unknown;
"api/eag-sdvx": unknown;
"api/cg-dev-sdvx": CGSDVXScore;
"api/cg-dev-museca": CGMusecaScore;
"api/cg-dev-popn": CGPopnScore;
// temporarily disabled as no musicRate is provided.
// "api/cg-dev-jubeat": CGJubeatScore;
// "api/cg-prod-jubeat": CGJubeatScore;
"api/cg-prod-sdvx": CGSDVXScore;
"api/cg-prod-museca": CGMusecaScore;
"api/cg-prod-popn": CGPopnScore;
}
export interface ImportTypeContextMap {
@@ -82,6 +101,15 @@ export interface ImportTypeContextMap {
"api/min-sdvx": KaiContext;
"api/eag-iidx": KaiContext;
"api/eag-sdvx": KaiContext;
"api/cg-dev-jubeat": CGContext;
"api/cg-dev-sdvx": CGContext;
"api/cg-dev-museca": CGContext;
"api/cg-dev-popn": CGContext;
"api/cg-prod-jubeat": CGContext;
"api/cg-prod-sdvx": CGContext;
"api/cg-prod-museca": CGContext;
"api/cg-prod-popn": CGContext;
}
export interface OrphanScoreDocument<T extends ImportTypes = ImportTypes> extends MongoDBDocument {
@@ -123,4 +151,4 @@ export interface ParserFunctionReturns<D, C> {
export type ParserFunction<D, C, A extends Array<unknown>> = (
...args: A
) => ParserFunctionReturns<D, C>;
) => ParserFunctionReturns<D, C> | Promise<ParserFunctionReturns<D, C>>;
@@ -1,5 +1,8 @@
import { ConvertAPIArcIIDX } from "./api/arc-iidx/converter";
import { ConvertAPIArcSDVX } from "./api/arc-sdvx/converter";
import { ConverterAPICGMuseca } from "./common/api-cg/museca/converter";
import { ConverterAPICGPopn } from "./common/api-cg/popn/converter";
import { ConverterAPICGSDVX } from "./common/api-cg/sdvx/converter";
import { ConvertAPIKaiIIDX } from "./common/api-kai/iidx/converter";
import { ConvertAPIKaiSDVX } from "./common/api-kai/sdvx/converter";
import { ConverterBatchManual } from "./common/batch-manual/converter";
@@ -47,4 +50,14 @@ export const Converters: ConverterMap = {
"ir/kshook-sv6c": ConverterIRKsHookSV6C,
"ir/kshook-sv6c-static": ConverterKsHookSV6CStatic,
"ir/lr2hook": ConverterLR2Hook,
"api/cg-dev-sdvx": ConverterAPICGSDVX,
"api/cg-dev-museca": ConverterAPICGMuseca,
"api/cg-dev-popn": ConverterAPICGPopn,
"api/cg-prod-sdvx": ConverterAPICGSDVX,
"api/cg-prod-museca": ConverterAPICGMuseca,
"api/cg-prod-popn": ConverterAPICGPopn,
// "api/cg-dev-jubeat": ConverterAPICGJubeat,
// "api/cg-prod-jubeat": ConverterAPICGJubeat,
};
@@ -5,6 +5,16 @@ import { ParseEagSDVX } from "./api/eag-sdvx/parser";
import { ParseFloIIDX } from "./api/flo-iidx/parser";
import { ParseFloSDVX } from "./api/flo-sdvx/parser";
import { ParseMinSDVX } from "./api/min-sdvx/parser";
import {
ParseCGDevJubeat,
ParseCGDevMuseca,
ParseCGDevPopn,
ParseCGDevSDVX,
ParseCGProdJubeat,
ParseCGProdMuseca,
ParseCGProdPopn,
ParseCGProdSDVX,
} from "./common/api-cg/parsers";
import ParseBatchManual from "./file/batch-manual/parser";
import ParseEamusementIIDXCSV from "./file/eamusement-iidx-csv/parser";
import ParseEamusementSDVXCSV from "./file/eamusement-sdvx-csv/parser";
@@ -37,6 +47,18 @@ export const Parsers = {
"api/arc-iidx": ParseArcIIDX,
"api/arc-sdvx": ParseArcSDVX,
"api/cg-dev-sdvx": ParseCGDevSDVX,
"api/cg-dev-popn": ParseCGDevPopn,
"api/cg-dev-museca": ParseCGDevMuseca,
"api/cg-prod-sdvx": ParseCGProdSDVX,
// temporarily disabled as no musicRate is provided.
// "api/cg-dev-jubeat": ParseCGDevJubeat,
// "api/cg-prod-jubeat": ParseCGProdJubeat,
"api/cg-prod-popn": ParseCGProdPopn,
"api/cg-prod-museca": ParseCGProdMuseca,
"ir/barbatos": ParseBarbatosSingle,
"ir/beatoraja": ParseBeatorajaSingle,
"ir/fervidex": ParseFervidexSingle,
@@ -46,4 +68,6 @@ export const Parsers = {
"ir/kshook-sv6c": ParseKsHookSV6C,
"ir/kshook-sv6c-static": ParseKsHookSV6CStatic,
"ir/lr2hook": ParseLR2Hook,
};
}; /* satisfies Record<ImportTypes, ParserFunction<any, any, any>>; */
// ^ not supported in our current version of TS, but undoubtedly useful for
// this kind of work.
+18
View File
@@ -51,6 +51,11 @@ export interface OAuth2Info {
REDIRECT_URI: string;
}
export interface CGConfig {
API_KEY: string;
URL: string;
}
export interface TachiServerConfig {
MONGO_DATABASE_NAME: string;
CAPTCHA_SECRET_KEY: string;
@@ -59,6 +64,10 @@ export interface TachiServerConfig {
EAG_API_URL?: string;
MIN_API_URL?: string;
ARC_API_URL?: string;
CG_DEV_CONFIG?: CGConfig;
CG_PROD_CONFIG?: CGConfig;
FLO_OAUTH2_INFO?: OAuth2Info;
EAG_OAUTH2_INFO?: OAuth2Info;
MIN_OAUTH2_INFO?: OAuth2Info;
@@ -136,6 +145,11 @@ const isValidOauth2 = p.optional({
REDIRECT_URI: "string",
});
const isValidCGConfig = p.optional({
API_KEY: "string",
URL: "string",
});
const err = p(config, {
MONGO_DATABASE_NAME: "string",
CAPTCHA_SECRET_KEY: "string",
@@ -144,6 +158,10 @@ const err = p(config, {
EAG_API_URL: p.optional(isValidURL),
MIN_API_URL: p.optional(isValidURL),
ARC_API_URL: p.optional(isValidURL),
CG_DEV_CONFIG: isValidCGConfig,
CG_PROD_CONFIG: isValidCGConfig,
FLO_OAUTH2_INFO: isValidOauth2,
EAG_OAUTH2_INFO: isValidOauth2,
MIN_OAUTH2_INFO: isValidOauth2,
@@ -0,0 +1,129 @@
import { RequireSelfRequestFromUser } from "../../../middleware";
import { Router } from "express";
import db from "external/mongo/db";
import p from "prudence";
import prValidate from "server/middleware/prudence-validate";
import { GetTachiData } from "utils/req-tachi-data";
import type { RequestHandler } from "express-serve-static-core";
import type { CGCardInfo } from "tachi-common";
const router: Router = Router({ mergeParams: true });
const ValidateCGType: RequestHandler = (req, res, next) => {
if (req.params.cgType === "dev" || req.params.cgType === "prod") {
next();
return;
}
return res.status(404).json({
success: false,
description: `No such service 'cg/${req.params.cgType}' is supported.`,
});
};
/**
* Retrieve this user's card info (cardID).
*
* @name GET /api/v1/users/:userID/integrations/cg/:cgType
*/
router.get("/", ValidateCGType, RequireSelfRequestFromUser, async (req, res) => {
const user = GetTachiData(req, "requestedUser");
const cgType = req.params.cgType as "dev" | "prod";
const cardInfo = await db["cg-card-info"].findOne({
userID: user.id,
service: cgType,
});
if (!cardInfo) {
return res.status(200).json({
success: true,
description: `User has no card info set.`,
body: null,
});
}
return res.status(200).json({
success: true,
description: `Found card info.`,
body: cardInfo,
});
});
/**
* Write new card details for this CG integration.
*
* @name PUT /api/v1/users/:userID/integrations/cg/:cgType
*/
router.put(
"/",
ValidateCGType,
RequireSelfRequestFromUser,
prValidate(
{
cardID: p.regex(/^[a-zA-Z0-9]{16}$/u),
pin: p.regex(/^[0-9]{4}$/u),
},
{
cardID: "Expected 16 characters.",
pin: "Expected 4 digits.",
}
),
async (req, res) => {
const user = GetTachiData(req, "requestedUser");
const cgType = req.params.cgType as "dev" | "prod";
const { cardID, pin } = req.safeBody as {
cardID: string;
pin: string;
};
const newCardInfo: CGCardInfo = {
service: cgType,
cardID,
pin,
userID: user.id,
};
await db["cg-card-info"].update(
{
userID: user.id,
service: cgType,
},
{ $set: newCardInfo },
{
// insert new card info if the user doesn't have it yet.
upsert: true,
}
);
return res.status(200).json({
success: true,
description: `Updated cardID and pin.`,
body: {},
});
}
);
/**
* Unset this user's card details for this CG integration.
*
* @name DELETE /api/v1/users/:userID/integrations/cg/:cgType
*/
router.delete("/", ValidateCGType, RequireSelfRequestFromUser, async (req, res) => {
const user = GetTachiData(req, "requestedUser");
const cgType = req.params.cgType as "dev" | "prod";
await db["cg-card-info"].remove({
userID: user.id,
service: cgType,
});
return res.status(200).json({
success: true,
description: `Deleted stored card info.`,
body: {},
});
});
export default router;
@@ -1,4 +1,5 @@
import arcRouter from "./arc/router";
import cgCgTypeRouter from "./cg/_cgType/router";
import fervidexRouter from "./fervidex/router";
import kaiKaiTypeRouter from "./kai/_kaiType/router";
import ksHookSV6CRouter from "./kshook-sv6c/router";
@@ -7,6 +8,7 @@ import { Router } from "express";
const router: Router = Router({ mergeParams: true });
router.use("/arc", arcRouter);
router.use("/cg/:cgType", cgCgTypeRouter);
router.use("/kai/:kaiType", kaiKaiTypeRouter);
router.use("/fervidex", fervidexRouter);
router.use("/kshook-sv6c", ksHookSV6CRouter);
@@ -0,0 +1 @@
[]
@@ -0,0 +1,20 @@
[
{
"chartID": "70b80da02a2037d556026b412c386b2fd1e57dbd",
"data": {
"inGameID": 1
},
"difficulty": "Green",
"isPrimary": true,
"level": "3",
"levelNum": 3,
"playtype": "Single",
"rgcID": null,
"songID": 1,
"tierlistInfo": {},
"versions": [
"1.5",
"1.5-b"
]
}
]
@@ -9,7 +9,8 @@
"rgcID": null,
"isPrimary": true,
"data": {
"hashSHA256": "2c26d666fa7c907e85115dbb279c267c14a263d47b2d46a93f99eae49d779119"
"hashSHA256": "2c26d666fa7c907e85115dbb279c267c14a263d47b2d46a93f99eae49d779119",
"inGameID": 0
},
"tierlistInfo": {},
"versions": [
@@ -0,0 +1,14 @@
[
{
"altTitles": [],
"artist": "Last Note. feat. GUMI",
"data": {
"artistJP": "ラストノートフィーチャリンググミ",
"displayVersion": "1",
"titleJP": "セツナトリップ"
},
"id": 1,
"searchTerms": [],
"title": "セツナトリップ"
}
]