mirror of
https://github.com/zkldi/Tachi.git
synced 2026-10-05 05:18:12 +03:00
Merge pull request #793 from TNG-dev/zkldi/cg-support
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Vendored
+3
@@ -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"),
|
||||
|
||||
Vendored
+6
@@ -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.
|
||||
|
||||
@@ -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": "セツナトリップ"
|
||||
}
|
||||
]
|
||||
Reference in New Issue
Block a user