mirror of
https://github.com/zkldi/Tachi.git
synced 2026-10-04 21:08:09 +03:00
feat: initial cg support
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
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 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);
|
||||
|
||||
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 />;
|
||||
}
|
||||
|
||||
if (data) {
|
||||
return <CGImporter cgType={cgType} game={game} cardID={data.cardID} />;
|
||||
} else {
|
||||
return (
|
||||
<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();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function CGImporter({ cgType, game, cardID }: Pick<Props, "cgType" | "game"> & { cardID: string }) {
|
||||
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>.
|
||||
</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 {cgType === "dev" ? "CG-DEV" : "CG"} {game}
|
||||
</code>
|
||||
!
|
||||
</div>
|
||||
<Divider />
|
||||
<ImportStateRenderer state={importState} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CGNeedsIntegrate({
|
||||
cgType,
|
||||
onSubmit,
|
||||
}: Pick<Props, "cgType"> & {
|
||||
onSubmit: (cardID: string, pin: string) => Promise<void>;
|
||||
}) {
|
||||
const cgName = cgType === "dev" ? "CG Dev" : "CG";
|
||||
|
||||
const [cardID, setCardID] = useState("");
|
||||
const [pin, setPin] = useState("");
|
||||
|
||||
// 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">We need to know what card you use on {cgName}!</h3>
|
||||
|
||||
<Row>
|
||||
<Col xs={12} lg={8} className="offset-lg-2">
|
||||
<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} />
|
||||
<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>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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,4 +1,9 @@
|
||||
import { GenericCalculatePercent, GetGradeFromPercent, ValidatePercent } from "./score-utils";
|
||||
import {
|
||||
GenericCalculatePercent,
|
||||
GetGradeFromPercent,
|
||||
JubeatGetMusicRate,
|
||||
ValidatePercent,
|
||||
} from "./score-utils";
|
||||
import t from "tap";
|
||||
import { isApproximately } from "test-utils/asserts";
|
||||
import { Testing511SPA } from "test-utils/test-data";
|
||||
@@ -144,3 +149,20 @@ t.test("#ValidatePercent", (t) => {
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("#JubeatGetMusicRate", (t) => {
|
||||
t.equal(
|
||||
JubeatGetMusicRate(423, 136, 41, 14, 5, 619, false),
|
||||
73.0,
|
||||
"Should correctly calculate jubeat music rate."
|
||||
);
|
||||
t.equal(JubeatGetMusicRate(418, 158, 90, 54, 92, 818, false), 73.0);
|
||||
|
||||
t.equal(
|
||||
JubeatGetMusicRate(423, 136, 41, 14, 5, 619, true),
|
||||
73.0 * 1.2,
|
||||
"Should correctly calculate hard mode jubeat music rate."
|
||||
);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
@@ -183,6 +186,22 @@ export function JubeatGetGrade(score: number): Grades["jubeat:Single"] {
|
||||
return "E";
|
||||
}
|
||||
|
||||
export function JubeatGetMusicRate(
|
||||
perfect: integer,
|
||||
great: integer,
|
||||
good: integer,
|
||||
poor: integer,
|
||||
miss: integer,
|
||||
chartMaxCombo: integer,
|
||||
hardMode: boolean
|
||||
) {
|
||||
const multiplier = hardMode ? 120 : 100;
|
||||
|
||||
const rawMusicRate = ((perfect + 0.2 * great + 0.05 * good) / chartMaxCombo) * multiplier;
|
||||
|
||||
return FloorToNDP(rawMusicRate, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the ESD for a given game + percent combo. This function returns
|
||||
* null if the game does not support support ESD.
|
||||
@@ -218,3 +237,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,103 @@
|
||||
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: 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, 1_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,167 @@
|
||||
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: 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,149 @@
|
||||
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(version, 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: 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(
|
||||
version: GPTSupportedVersions["sdvx:Single"],
|
||||
clearType: number
|
||||
): Lamps["sdvx:Single"] {
|
||||
switch (clearType) {
|
||||
case 0:
|
||||
return "FAILED";
|
||||
case 1:
|
||||
return "CLEAR";
|
||||
}
|
||||
|
||||
// this version doesn't have excessive clears, so the ints are off by one.
|
||||
if (version === "booth") {
|
||||
switch (clearType) {
|
||||
case 2:
|
||||
return "ULTIMATE CHAIN";
|
||||
case 3:
|
||||
return "PERFECT ULTIMATE CHAIN";
|
||||
}
|
||||
} else {
|
||||
switch (clearType) {
|
||||
case 2:
|
||||
return "EXCESSIVE CLEAR";
|
||||
case 3:
|
||||
return "ULTIMATE CHAIN";
|
||||
case 4:
|
||||
return "PERFECT ULTIMATE CHAIN";
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidScoreFailure(
|
||||
`Invalid lamp of ${clearType} for ${version} - 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,14 @@
|
||||
import type { 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;
|
||||
}
|
||||
}
|
||||
@@ -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,138 @@
|
||||
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,
|
||||
},
|
||||
{
|
||||
projection: {
|
||||
// i mean, it's not a security thing really but nobody needs
|
||||
// to see this.
|
||||
pin: 0,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
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 @@
|
||||
[]
|
||||
Reference in New Issue
Block a user