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 - +
- {page === "services" ? ( - - ) : page === "api-keys" ? ( - - ) : ( - - )} + + + + + + + + + + +
@@ -625,9 +631,7 @@ function ServicesPage({ reqUser }: { reqUser: UserDocument }) { ); } - const [page, setPage] = useState<"fervidex" | "kshook" | "arc" | "flo" | "eag" | "min">( - "fervidex" - ); + const baseUrl = `/dashboard/users/${reqUser.username}/integrations/services`; return ( @@ -645,40 +649,43 @@ function ServicesPage({ reqUser }: { reqUser: UserDocument }) {
- - Fervidex - - - KsHook - - - ARC - - - FLO - - - EAG - - - MIN - + Fervidex + CG + CG Dev + KsHook + ARC + FLO + EAG + MIN
- {page === "fervidex" ? ( - - ) : page === "kshook" ? ( - - ) : page === "arc" ? ( - - ) : page === "flo" ? ( - - ) : page === "eag" ? ( - - ) : ( - - )} + + + + + + + + + + + + + + + + + + + + + + + + + +
); } @@ -948,3 +955,48 @@ function APIKeyRow({ ); } + +function CGIntegrationInfo({ cgType, userID }: { cgType: "dev" | "prod"; userID: integer }) { + const [reload, shouldReloadCardInfo] = useReducer((x) => x + 1, 0); + + const { data, error } = useApiQuery( + `/users/${userID}/integrations/cg/${cgType}`, + undefined, + [reload] + ); + + if (error) { + return ; + } + + // null is a valid response for this call, so be explicit with going to loading + if (data === undefined) { + return ; + } + + return ( + { + const res = await APIFetchV1( + `/users/${userID}/integrations/cg/${cgType}`, + { + method: "PUT", + body: JSON.stringify({ cardID, pin }), + headers: { + "Content-Type": "application/json", + }, + }, + true, + true + ); + + if (res.success) { + shouldReloadCardInfo(); + } + }} + initialCardID={data?.cardID ?? undefined} + initialPin={data?.pin ?? undefined} + /> + ); +} diff --git a/client/src/app/routes/ImportRoutes.tsx b/client/src/app/routes/ImportRoutes.tsx index e743eab47..d22b469f8 100644 --- a/client/src/app/routes/ImportRoutes.tsx +++ b/client/src/app/routes/ImportRoutes.tsx @@ -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" /> + + + + + + + + + + + + + + + + + + + diff --git a/client/src/app/routes/UserRoutes.tsx b/client/src/app/routes/UserRoutes.tsx index 473f16f57..bbf524b26 100644 --- a/client/src/app/routes/UserRoutes.tsx +++ b/client/src/app/routes/UserRoutes.tsx @@ -127,7 +127,7 @@ function UserProfileRoutes({ reqUser }: { reqUser: UserDocument }) { - + diff --git a/client/src/components/imports/CGIntegrationPage.tsx b/client/src/components/imports/CGIntegrationPage.tsx new file mode 100644 index 000000000..fcc07c900 --- /dev/null +++ b/client/src/components/imports/CGIntegrationPage.tsx @@ -0,0 +1,223 @@ +import { APIFetchV1 } from "util/api"; +import { ErrorPage } from "app/pages/ErrorPage"; +import useSetSubheader from "components/layout/header/useSetSubheader"; +import ApiError from "components/util/ApiError"; +import Divider from "components/util/Divider"; +import FormInput from "components/util/FormInput"; +import Loading from "components/util/Loading"; +import useImport from "components/util/import/useImport"; +import useApiQuery from "components/util/query/useApiQuery"; +import { UserContext } from "context/UserContext"; +import React, { useContext, useMemo, useReducer, useState } from "react"; +import { Button, Col, Form, Row } from "react-bootstrap"; +import { APIImportTypes, CGCardInfo, GetGameConfig } from "tachi-common"; +import { SetState } from "types/react"; +import Icon from "components/util/Icon"; +import ImportStateRenderer from "./ImportStateRenderer"; + +interface Props { + cgType: "dev" | "prod"; + game: "sdvx" | "popn" | "museca"; +} + +export default function CGIntegrationPage({ cgType, game }: Props) { + const gameConfig = GetGameConfig(game); + const cgName = cgType === "dev" ? "CG Dev" : "CG"; + + const [reload, shouldReloadCardInfo] = useReducer((x) => x + 1, 0); + const [showEdit, setShowEdit] = useState(false); + + useSetSubheader(["Import Scores", `${gameConfig.name} Sync (${cgName})`]); + + const { user } = useContext(UserContext); + + if (!user) { + return ; + } + + const { data, error } = useApiQuery( + `/users/${user.id}/integrations/cg/${cgType}`, + undefined, + [reload] + ); + + if (error) { + return ; + } + + // null is a valid response for this call, so be explicit with going to loading + if (data === undefined) { + return ; + } + + return ( + <> + {(showEdit || !data) && ( + <> + { + const res = await APIFetchV1( + `/users/${user.id}/integrations/cg/${cgType}`, + { + method: "PUT", + body: JSON.stringify({ cardID, pin }), + headers: { + "Content-Type": "application/json", + }, + }, + true, + true + ); + + if (res.success) { + shouldReloadCardInfo(); + } + }} + initialCardID={data?.cardID ?? undefined} + initialPin={data?.pin ?? undefined} + /> + + + )} + {data && ( + + )} + + ); +} + +function CGImporter({ + cgType, + game, + cardID, + showEdit, + setShowEdit, +}: Pick & { + cardID: string; + showEdit: boolean; + setShowEdit: SetState; +}) { + 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 ( +
+

+ Importing scores from {cgName} card{" "} + {cardID.match(/.{1,4}/gu)?.join(" ")}{" "} + setShowEdit(!showEdit)} + type={showEdit ? "times" : "pencil-alt"} + noPad + /> + . +

+ +
+ +
+ +
+ 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