diff --git a/bot/src/slashCommands/commands/sync.ts b/bot/src/slashCommands/commands/sync.ts
index c159c1b86..e2ca57124 100644
--- a/bot/src/slashCommands/commands/sync.ts
+++ b/bot/src/slashCommands/commands/sync.ts
@@ -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(),
diff --git a/client/src/app/pages/dashboard/import/ImportPage.tsx b/client/src/app/pages/dashboard/import/ImportPage.tsx
index 5598bbc87..99946c4e1 100644
--- a/client/src/app/pages/dashboard/import/ImportPage.tsx
+++ b/client/src/app/pages/dashboard/import/ImportPage.tsx
@@ -133,7 +133,6 @@ function ImportInfoDisplayer({ game }: { game: Game }) {
,
,
,
- // ,
,
);
@@ -159,6 +158,8 @@ function ImportInfoDisplayer({ game }: { game: Game }) {
/>,
,
,
+ ,
+ ,
,
);
@@ -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"
- />
+ />,
+ ,
+
);
} 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(
+ ,
+
+ );
+ } // else if (game === "jubeat") {
+ // Content.unshift(
+ // ,
+ //
+ // );
+ // }
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 (
+
+ );
+ case "api/cg-prod-sdvx":
+ return (
+
+ );
+ case "api/cg-dev-popn":
+ return (
+
+ );
+ case "api/cg-prod-popn":
+ return (
+
+ );
+ case "api/cg-dev-museca":
+ return (
+
+ );
+ case "api/cg-prod-museca":
+ return (
+
);
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 {importType}
+ >
+ );
}
}
diff --git a/client/src/app/pages/dashboard/users/FervidexIntegrationPage.tsx b/client/src/app/pages/dashboard/users/FervidexIntegrationPage.tsx
index eea35a52e..3b54ab507 100644
--- a/client/src/app/pages/dashboard/users/FervidexIntegrationPage.tsx
+++ b/client/src/app/pages/dashboard/users/FervidexIntegrationPage.tsx
@@ -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({
);
}
-
-function isCardIDValid(cardID: string) {
- if (cardID.startsWith("E004")) {
- return false;
- }
-
- if (cardID[0] === "C") {
- return cardID.length === 13;
- }
-
- return cardID.length === 16;
-}
diff --git a/client/src/app/pages/dashboard/users/UserIntegrationsPage.tsx b/client/src/app/pages/dashboard/users/UserIntegrationsPage.tsx
index c005c4d87..54820a8db 100644
--- a/client/src/app/pages/dashboard/users/UserIntegrationsPage.tsx
+++ b/client/src/app/pages/dashboard/users/UserIntegrationsPage.tsx
@@ -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 (
{mode !== "btchi" && (
-
+
Service Configuration
-
+
)}
-
+
API Keys
-
-
+
+
My API Clients
-
+
+ Play on {cgName} a lot? You can synchronise your scores straight from the discord by
+ typing /sync!
+
+
+
+
+ );
+}
+
+export function CGNeedsIntegrate({
+ cgType,
+ initialCardID,
+ initialPin,
+ onSubmit,
+}: Pick & {
+ onSubmit: (cardID: string, pin: string) => Promise;
+ 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 (
+
+
Set your {cgName} card.
+
+
+
+ This is the card ID that's displayed in game. It should be 16 characters long.
+
+ {cardID.length > 0 && !/^[a-zA-Z0-9]{16}$/u.exec(realCardID) ? (
+
+ Invalid Card ID. This should be 16 alphanumeric characters.
+
+ ) : (
+ cardID.length > 0 && Looking good!
+ )}
+
+
+
+ What PIN do you use to card in to {cgName}?
+
+
+ {pin.length > 0 && !/^[0-9]{4}$/u.exec(pin) ? (
+ Invalid PIN. This should be 4 digits.
+ ) : (
+ pin.length > 0 && Looking good!
+ )}
+
+
+
+
+
+
+ );
+}
diff --git a/client/src/components/imports/KAIIntegrationPage.tsx b/client/src/components/imports/KAIIntegrationPage.tsx
index 945df3ae9..dfb518116 100644
--- a/client/src/components/imports/KAIIntegrationPage.tsx
+++ b/client/src/components/imports/KAIIntegrationPage.tsx
@@ -105,11 +105,7 @@ function KAIImporter({ kaiType, game }: Pick) {
Play on {kaiType} a lot? You can synchronise your scores straight from the discord
- by typing{" "}
-
- /sync {kaiType} {game}
-
- !
+ by typing /sync!
diff --git a/client/src/components/tables/cells/BPICell.tsx b/client/src/components/tables/cells/BPICell.tsx
index eb31ff319..25232b896 100644
--- a/client/src/components/tables/cells/BPICell.tsx
+++ b/client/src/components/tables/cells/BPICell.tsx
@@ -99,8 +99,6 @@ export default function BPICell({
chart.data.bpiCoefficient === -1
? 1.175
: chart.data.bpiCoefficient}
-
- Tip: Click on your score to see more advanced BPI info.
>
}
diff --git a/client/src/components/tables/dropdowns/components/RivalCompare.tsx b/client/src/components/tables/dropdowns/components/RivalCompare.tsx
index 06da192a6..8e0beee19 100644
--- a/client/src/components/tables/dropdowns/components/RivalCompare.tsx
+++ b/client/src/components/tables/dropdowns/components/RivalCompare.tsx
@@ -37,7 +37,7 @@ export default function RivalCompare({ chart, game }: { chart: ChartDocument; ga
if (data.rivals.length === 0) {
return (
-
+
You have no rivals set!
Why not set some?
diff --git a/client/src/components/util/FormInput.tsx b/client/src/components/util/FormInput.tsx
index a02e4b1c8..e6c5d8ab7 100644
--- a/client/src/components/util/FormInput.tsx
+++ b/client/src/components/util/FormInput.tsx
@@ -8,12 +8,14 @@ export default function FormInput({
setValue,
placeholder,
as,
+ type,
}: {
fieldName: string;
value: string;
setValue: SetState;
placeholder?: string;
as?: ElementType;
+ type?: string;
}) {
return (
@@ -25,6 +27,7 @@ export default function FormInput({
placeholder={placeholder}
value={value}
onChange={(e) => setValue(e.target.value)}
+ type={type}
/>
);
diff --git a/client/src/util/misc.ts b/client/src/util/misc.ts
index 89485d876..6db4f6210 100644
--- a/client/src/util/misc.ts
+++ b/client/src/util/misc.ts
@@ -582,3 +582,15 @@ export function HumanisedJoinArray(arr: Array, 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;
+}
diff --git a/common/src/config/static-config.ts b/common/src/config/static-config.ts
index 388282cf8..9849e73b9 100644
--- a/common/src/config/static-config.ts
+++ b/common/src/config/static-config.ts
@@ -37,6 +37,12 @@ export const apiImportTypes: Array = [
"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 = [
diff --git a/common/src/types.ts b/common/src/types.ts
index 9f1570826..d499b3394 100644
--- a/common/src/types.ts
+++ b/common/src/types.ts
@@ -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.
*/
diff --git a/server/example/conf.json5 b/server/example/conf.json5
index 1424a5318..a7136d0db 100644
--- a/server/example/conf.json5
+++ b/server/example/conf.json5
@@ -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: {
diff --git a/server/src/external/mongo/db.ts b/server/src/external/mongo/db.ts
index 6e3922cb3..150340932 100644
--- a/server/src/external/mongo/db.ts
+++ b/server/src/external/mongo/db.ts
@@ -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("counters"),
"game-stats": monkDB.get("game-stats"),
"kai-auth-tokens": monkDB.get("kai-auth-tokens"),
+ "cg-card-info": monkDB.get("cg-card-info"),
+
"bms-course-lookup": monkDB.get("bms-course-lookup"),
"api-tokens": monkDB.get("api-tokens"),
"orphan-scores": monkDB.get("orphan-scores"),
diff --git a/server/src/external/mongo/schemas.ts b/server/src/external/mongo/schemas.ts
index b1ac2c639..ae44a2681 100644
--- a/server/src/external/mongo/schemas.ts
+++ b/server/src/external/mongo/schemas.ts
@@ -39,6 +39,12 @@ export const DatabaseSchemas: Record = {
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,
diff --git a/server/src/lib/score-import/framework/common/score-utils.ts b/server/src/lib/score-import/framework/common/score-utils.ts
index 97e70154b..c439dc7d4 100644
--- a/server/src/lib/score-import/framework/common/score-utils.ts
+++ b/server/src/lib/score-import/framework/common/score-utils.ts
@@ -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(
}
/**
- * 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";
+}
diff --git a/server/src/lib/score-import/import-types/common/api-cg/museca/converter.test.ts b/server/src/lib/score-import/import-types/common/api-cg/museca/converter.test.ts
new file mode 100644
index 000000000..529fed82f
--- /dev/null
+++ b/server/src/lib/score-import/import-types/common/api-cg/museca/converter.test.ts
@@ -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 = {}) {
+ 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 = {}) =>
+ 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();
+});
diff --git a/server/src/lib/score-import/import-types/common/api-cg/museca/converter.ts b/server/src/lib/score-import/import-types/common/api-cg/museca/converter.ts
new file mode 100644
index 000000000..4df8abb82
--- /dev/null
+++ b/server/src/lib/score-import/import-types/common/api-cg/museca/converter.ts
@@ -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 = 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}.`);
+}
diff --git a/server/src/lib/score-import/import-types/common/api-cg/parsers.ts b/server/src/lib/score-import/import-types/common/api-cg/parsers.ts
new file mode 100644
index 000000000..dc4518468
--- /dev/null
+++ b/server/src/lib/score-import/import-types/common/api-cg/parsers.ts
@@ -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 = {
+ 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(cgGame: CGSupportedGames, service: CGServices) {
+ return async (
+ userID: integer,
+ logger: KtLogger
+ ): Promise> => {
+ 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,
+ classHandler: null,
+ };
+ };
+}
+
+export const ParseCGDevMuseca = CreateCGParser("msc", "dev");
+export const ParseCGDevSDVX = CreateCGParser("sdvx", "dev");
+export const ParseCGDevJubeat = CreateCGParser("jb", "dev");
+export const ParseCGDevPopn = CreateCGParser("popn", "dev");
+
+export const ParseCGProdMuseca = CreateCGParser("msc", "prod");
+export const ParseCGProdSDVX = CreateCGParser("sdvx", "prod");
+export const ParseCGProdJubeat = CreateCGParser("jb", "prod");
+export const ParseCGProdPopn = CreateCGParser("popn", "prod");
diff --git a/server/src/lib/score-import/import-types/common/api-cg/popn/converter.test.ts b/server/src/lib/score-import/import-types/common/api-cg/popn/converter.test.ts
new file mode 100644
index 000000000..735adf42d
--- /dev/null
+++ b/server/src/lib/score-import/import-types/common/api-cg/popn/converter.test.ts
@@ -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 = {}) {
+ 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 = {}) =>
+ 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();
+});
diff --git a/server/src/lib/score-import/import-types/common/api-cg/popn/converter.ts b/server/src/lib/score-import/import-types/common/api-cg/popn/converter.ts
new file mode 100644
index 000000000..b17169ea7
--- /dev/null
+++ b/server/src/lib/score-import/import-types/common/api-cg/popn/converter.ts
@@ -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 = 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;
+}
diff --git a/server/src/lib/score-import/import-types/common/api-cg/sdvx/converter.test.ts b/server/src/lib/score-import/import-types/common/api-cg/sdvx/converter.test.ts
new file mode 100644
index 000000000..4dd91eb48
--- /dev/null
+++ b/server/src/lib/score-import/import-types/common/api-cg/sdvx/converter.test.ts
@@ -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 = {}) {
+ 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 = {}) =>
+ 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();
+});
diff --git a/server/src/lib/score-import/import-types/common/api-cg/sdvx/converter.ts b/server/src/lib/score-import/import-types/common/api-cg/sdvx/converter.ts
new file mode 100644
index 000000000..0bede9c52
--- /dev/null
+++ b/server/src/lib/score-import/import-types/common/api-cg/sdvx/converter.ts
@@ -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 = 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.`);
+}
diff --git a/server/src/lib/score-import/import-types/common/api-cg/traverse-api.ts b/server/src/lib/score-import/import-types/common/api-cg/traverse-api.ts
new file mode 100644
index 000000000..149c644ad
--- /dev/null
+++ b/server/src/lib/score-import/import-types/common/api-cg/traverse-api.ts
@@ -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> {
+ const url = GetCGUrl(service, cardInfo, game);
+
+ let validatedRes: CGErrorResponse | CGScoresResponse;
+
+ 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;
+ } 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()}`;
+}
diff --git a/server/src/lib/score-import/import-types/common/api-cg/types.ts b/server/src/lib/score-import/import-types/common/api-cg/types.ts
new file mode 100644
index 000000000..79f45db31
--- /dev/null
+++ b/server/src/lib/score-import/import-types/common/api-cg/types.ts
@@ -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 {
+ success: true;
+ data: {
+ profile: {
+ id: string;
+ name: string;
+ };
+ scores: Array;
+ };
+}
+
+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;
+}
diff --git a/server/src/lib/score-import/import-types/common/api-cg/util.ts b/server/src/lib/score-import/import-types/common/api-cg/util.ts
new file mode 100644
index 000000000..3191cd990
--- /dev/null
+++ b/server/src/lib/score-import/import-types/common/api-cg/util.ts
@@ -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";
+}
diff --git a/server/src/lib/score-import/import-types/common/api-kai/traverse-api.ts b/server/src/lib/score-import/import-types/common/api-kai/traverse-api.ts
index 16fc63605..b1895a5f5 100644
--- a/server/src/lib/score-import/import-types/common/api-kai/traverse-api.ts
+++ b/server/src/lib/score-import/import-types/common/api-kai/traverse-api.ts
@@ -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?`
);
}
diff --git a/server/src/lib/score-import/import-types/common/types.ts b/server/src/lib/score-import/import-types/common/types.ts
index 01e441c79..87e15b17e 100644
--- a/server/src/lib/score-import/import-types/common/types.ts
+++ b/server/src/lib/score-import/import-types/common/types.ts
@@ -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 extends MongoDBDocument {
@@ -123,4 +151,4 @@ export interface ParserFunctionReturns {
export type ParserFunction> = (
...args: A
-) => ParserFunctionReturns;
+) => ParserFunctionReturns | Promise>;
diff --git a/server/src/lib/score-import/import-types/converters.ts b/server/src/lib/score-import/import-types/converters.ts
index 89990b8fc..51d33b4e1 100644
--- a/server/src/lib/score-import/import-types/converters.ts
+++ b/server/src/lib/score-import/import-types/converters.ts
@@ -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,
};
diff --git a/server/src/lib/score-import/import-types/parsers.ts b/server/src/lib/score-import/import-types/parsers.ts
index 2cf1c5e05..ca36af8a5 100644
--- a/server/src/lib/score-import/import-types/parsers.ts
+++ b/server/src/lib/score-import/import-types/parsers.ts
@@ -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>; */
+// ^ not supported in our current version of TS, but undoubtedly useful for
+// this kind of work.
diff --git a/server/src/lib/setup/config.ts b/server/src/lib/setup/config.ts
index 12f39db72..e5dbedf3e 100644
--- a/server/src/lib/setup/config.ts
+++ b/server/src/lib/setup/config.ts
@@ -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,
diff --git a/server/src/server/router/api/v1/users/_userID/integrations/cg/_cgType/router.ts b/server/src/server/router/api/v1/users/_userID/integrations/cg/_cgType/router.ts
new file mode 100644
index 000000000..1b6a64548
--- /dev/null
+++ b/server/src/server/router/api/v1/users/_userID/integrations/cg/_cgType/router.ts
@@ -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;
diff --git a/server/src/server/router/api/v1/users/_userID/integrations/router.ts b/server/src/server/router/api/v1/users/_userID/integrations/router.ts
index a407a86b8..92b928730 100644
--- a/server/src/server/router/api/v1/users/_userID/integrations/router.ts
+++ b/server/src/server/router/api/v1/users/_userID/integrations/router.ts
@@ -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);
diff --git a/server/src/test-utils/mock-db/cg-card-info.json b/server/src/test-utils/mock-db/cg-card-info.json
new file mode 100644
index 000000000..0637a088a
--- /dev/null
+++ b/server/src/test-utils/mock-db/cg-card-info.json
@@ -0,0 +1 @@
+[]
\ No newline at end of file
diff --git a/server/src/test-utils/mock-db/charts-museca.json b/server/src/test-utils/mock-db/charts-museca.json
new file mode 100644
index 000000000..d193c22db
--- /dev/null
+++ b/server/src/test-utils/mock-db/charts-museca.json
@@ -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"
+ ]
+ }
+]
\ No newline at end of file
diff --git a/server/src/test-utils/mock-db/charts-popn.json b/server/src/test-utils/mock-db/charts-popn.json
index 30b8d3307..6e0f33b5c 100644
--- a/server/src/test-utils/mock-db/charts-popn.json
+++ b/server/src/test-utils/mock-db/charts-popn.json
@@ -9,7 +9,8 @@
"rgcID": null,
"isPrimary": true,
"data": {
- "hashSHA256": "2c26d666fa7c907e85115dbb279c267c14a263d47b2d46a93f99eae49d779119"
+ "hashSHA256": "2c26d666fa7c907e85115dbb279c267c14a263d47b2d46a93f99eae49d779119",
+ "inGameID": 0
},
"tierlistInfo": {},
"versions": [
diff --git a/server/src/test-utils/mock-db/songs-museca.json b/server/src/test-utils/mock-db/songs-museca.json
new file mode 100644
index 000000000..17e1eef37
--- /dev/null
+++ b/server/src/test-utils/mock-db/songs-museca.json
@@ -0,0 +1,14 @@
+[
+ {
+ "altTitles": [],
+ "artist": "Last Note. feat. GUMI",
+ "data": {
+ "artistJP": "ラストノートフィーチャリンググミ",
+ "displayVersion": "1",
+ "titleJP": "セツナトリップ"
+ },
+ "id": 1,
+ "searchTerms": [],
+ "title": "セツナトリップ"
+ }
+]
\ No newline at end of file