From 269a5cbc9baa549fd7a02578d096b92e57aa9947 Mon Sep 17 00:00:00 2001 From: zkldi <20380519+zkldi@users.noreply.github.com> Date: Sat, 17 Dec 2022 11:04:13 +0000 Subject: [PATCH 01/14] feat: rival-info table --- .../lib/game-specific/custom-bms-tables.ts | 152 ++++++++++++++---- .../games/@gameSpecificRoutes/bms/router.ts | 2 +- .../router/api/v1/users/_userID/router.ts | 23 ++- server/src/utils/queries/scores.ts | 45 ++++++ 4 files changed, 180 insertions(+), 42 deletions(-) create mode 100644 server/src/utils/queries/scores.ts diff --git a/server/src/lib/game-specific/custom-bms-tables.ts b/server/src/lib/game-specific/custom-bms-tables.ts index 3f41ee543..f9a1ea170 100644 --- a/server/src/lib/game-specific/custom-bms-tables.ts +++ b/server/src/lib/game-specific/custom-bms-tables.ts @@ -1,12 +1,15 @@ import CreateLogCtx from "lib/logger/logger"; -import { ServerConfig } from "lib/setup/config"; +import { GetRivalUsers } from "lib/rivals/rivals"; +import { ServerConfig, TachiConfig } from "lib/setup/config"; import { CreateSongMap } from "tachi-common"; +import { GetRelevantSongsAndCharts } from "utils/db"; import { GetFolderCharts, GetFolderNamesInOrder, GetFoldersFromTable, GetTableForIDGuaranteed, } from "utils/folder"; +import { GetRecentUGPTScores } from "utils/queries/scores"; import path from "path"; import type { BMSTableEntry, BMSTableHead } from "bms-table-loader"; import type { Request, Response } from "express-serve-static-core"; @@ -14,6 +17,7 @@ import type { ChartDocument, FolderDocument, Playtypes, + SongDocument, TableDocument, integer, } from "tachi-common"; @@ -23,6 +27,33 @@ const logger = CreateLogCtx(__filename); // Instead of just supporting existing tables, Tachi should also be able // to emit its own, custom BMS tables. These may be dynamic. +function AppendAndConvertChartsToBMSBody( + body: Array, + charts: Array>, + songMap: Map, + level: string +) { + for (const chart of charts) { + const song = songMap.get(chart.songID); + + // if we've got metadata to add... + if (song) { + body.push({ + level, + title: song.title, + artist: song.artist, + md5: chart.data.hashMD5, + }); + } else { + logger.warn(`BMS Chart md5=${chart.data.hashMD5} has no parent song.`); + body.push({ + level, + md5: chart.data.hashMD5, + }); + } + } +} + /** * Convert a table in Tachi into a bms header.json and body.json. */ @@ -59,32 +90,16 @@ export async function TachiTableToBMSTableJSON( const charts = data.charts as Array>; const songMap = CreateSongMap(data.songs); - for (const chart of charts) { - const song = songMap.get(chart.songID); - - // if we've got metadata to add... - if (song) { - body.push({ - level: folder.title, - title: song.title, - artist: song.artist, - md5: chart.data.hashMD5, - }); - } else { - logger.warn(`BMS Chart md5=${chart.data.hashMD5} has no parent song.`); - body.push({ - level: folder.title, - md5: chart.data.hashMD5, - }); - } - } + AppendAndConvertChartsToBMSBody(body, charts, songMap, folder.title); } return body; } export type TachiBMSTable = { - playtype: Playtypes["bms"]; + playtype: Playtypes["bms"] | null; // what playtype is this for? If null, this table + // is for all playtypes. + urlName: string; // what do we call this in the url? tableName: string; // what should it be called in-game? symbol: string; // what symbol should this table have? @@ -93,13 +108,16 @@ export type TachiBMSTable = { forSpecificUser: true; // if this table is user-dependent // like, say, their rivals scores or something. // then the callbacks need to recieve that info. - getLevelOrder: (userID: integer) => Promise | undefined>; - getBody: (userID: integer) => Promise>; + getLevelOrder: ( + userID: integer, + playtype: Playtypes["bms"] + ) => Promise | undefined>; + getBody: (userID: integer, playtype: Playtypes["bms"]) => Promise>; } | { forSpecificUser?: false; - getLevelOrder: () => Promise | undefined>; - getBody: () => Promise>; + getLevelOrder: (playtype: Playtypes["bms"]) => Promise | undefined>; + getBody: (playtype: Playtypes["bms"]) => Promise>; } ); @@ -136,6 +154,14 @@ function GetUserID(req: Request) { throw new Error(`No userID in params here. Is this route mounted in the right place?`); } +function GetPlaytype(req: Request) { + if ("playtype" in req.params) { + return req.params.playtype as Playtypes["bms"]; + } + + throw new Error(`No playtype in params here. Is this route mounted in the right place?`); +} + /** * Handle a request for a bms table. This endpoint should return "HTML" with the caveat * that atleast one of the lines should refer to a "bmstable" meta header. @@ -170,15 +196,16 @@ export async function HandleBMSTableHeaderRequest( try { let levelOrder; let dataUrl; + const playtype = GetPlaytype(req); if (bmsTable.forSpecificUser === true) { const userID = GetUserID(req); dataUrl = BMSTableToAbsoluteURL(bmsTable, "body", userID); - levelOrder = await bmsTable.getLevelOrder(userID); + levelOrder = await bmsTable.getLevelOrder(userID, playtype); } else { - levelOrder = await bmsTable.getLevelOrder(); + levelOrder = await bmsTable.getLevelOrder(playtype); dataUrl = BMSTableToAbsoluteURL(bmsTable, "body", null); } @@ -207,12 +234,14 @@ export async function HandleBMSTableBodyRequest( try { let body; + const playtype = GetPlaytype(req); + if (bmsTable.forSpecificUser === true) { const userID = GetUserID(req); - body = await bmsTable.getBody(userID); + body = await bmsTable.getBody(userID, playtype); } else { - body = await bmsTable.getBody(); + body = await bmsTable.getBody(playtype); } return res.status(200).send(body); @@ -263,4 +292,69 @@ export const CUSTOM_TACHI_BMS_TABLES: Array = [ return GetFolderNamesInOrder(table); }, }, + + { + urlName: "rival-info", + playtype: null, + symbol: "Rival", + tableName: `${TachiConfig.NAME} Rival Stats`, + forSpecificUser: true, + async getBody(userID, playtype) { + const rivals = await GetRivalUsers(userID, "bms", playtype); + + const body: Array = []; + + const promises = []; + + for (const rival of rivals) { + promises.push(async () => { + const scores = await GetRecentUGPTScores(rival.id, "bms", playtype); + + const data = await GetRelevantSongsAndCharts(scores, "bms"); + const charts = data.charts as unknown as Array< + ChartDocument<"bms:7K" | "bms:14K"> + >; + + const songMap = CreateSongMap(data.songs); + + AppendAndConvertChartsToBMSBody( + body, + charts, + songMap, + `${rival.username} Recent Plays` + ); + }); + + promises.push(async () => { + const scores = await GetRecentUGPTScores(rival.id, "bms", playtype); + + const data = await GetRelevantSongsAndCharts(scores, "bms"); + const charts = data.charts as unknown as Array< + ChartDocument<"bms:7K" | "bms:14K"> + >; + + const songMap = CreateSongMap(data.songs); + + AppendAndConvertChartsToBMSBody( + body, + charts, + songMap, + `${rival.username} Recent Highlights` + ); + }); + } + + await Promise.all(promises); + + return body; + }, + async getLevelOrder(userID, playtype) { + const rivals = await GetRivalUsers(userID, "bms", playtype); + + return rivals.flatMap((rival) => [ + `${rival.username} Recent Plays`, + `${rival.username} Recent Highlights`, + ]); + }, + }, ]; diff --git a/server/src/server/router/api/v1/users/_userID/games/@gameSpecificRoutes/bms/router.ts b/server/src/server/router/api/v1/users/_userID/games/@gameSpecificRoutes/bms/router.ts index 6eb77db89..22d042f6a 100644 --- a/server/src/server/router/api/v1/users/_userID/games/@gameSpecificRoutes/bms/router.ts +++ b/server/src/server/router/api/v1/users/_userID/games/@gameSpecificRoutes/bms/router.ts @@ -45,7 +45,7 @@ const FindCustomBMSTable: RequestHandler = (req, res, next) => { /** * Return some HTML for this custom table. * - * @note Since this is the GPT route, trying to fetch user specific custom tables + * @note Since this is the UGPT route, trying to fetch GPT custom tables * will result in a 404. This applies for all subsequent :tableUrlName routes. * * @name GET /api/v1/users/:userID/games/bms/:playtype/custom-tables/:tableUrlName diff --git a/server/src/server/router/api/v1/users/_userID/router.ts b/server/src/server/router/api/v1/users/_userID/router.ts index e02d3c631..a29837059 100644 --- a/server/src/server/router/api/v1/users/_userID/router.ts +++ b/server/src/server/router/api/v1/users/_userID/router.ts @@ -1,3 +1,13 @@ +import { + GetGoalSummary, + GetRecentPlaycount, + GetRecentSessions, + GetRecentlyViewedFoldersAnyGPT, +} from "utils/queries/summary"; +import { GetUser } from "utils/req-tachi-data"; +import type { ProfileRatingAlgs } from "utils/string-checks"; +import { FormatUserDoc, GetAllRankings, GetUserWithID } from "utils/user"; +import { HashPassword, PasswordCompare, ValidatePassword } from "../../auth/auth"; import apiTokensRouter from "./api-tokens/router"; import bannerRouter from "./banner/router"; import followingRouter from "./following/router"; @@ -10,7 +20,6 @@ import { GetUserFromParam, RequireSelfRequestFromUser } from "./middleware"; import notifsRouter from "./notifications/router"; import pfpRouter from "./pfp/router"; import settingsRouter from "./settings/router"; -import { HashPassword, PasswordCompare, ValidatePassword } from "../../auth/auth"; import { Router } from "express"; import db from "external/mongo/db"; import { GetRecentActivity } from "lib/activity/activity"; @@ -21,17 +30,7 @@ import p from "prudence"; import prValidate from "server/middleware/prudence-validate"; import { DeleteUndefinedProps, IsNonEmptyString, StripUrl } from "utils/misc"; import { optNullFluffStrField } from "utils/prudence"; -import { - GetGoalSummary, - GetRecentlyViewedFoldersAnyGPT, - GetRecentPlaycount, - GetRecentSessions, -} from "utils/queries/summary"; -import { GetUser } from "utils/req-tachi-data"; -import { FormatUserDoc, GetAllRankings, GetUserWithID } from "utils/user"; -import type { ActivityConstraint } from "lib/activity/activity"; -import type { IDStrings, ImportTypes, integer, UserGameStats } from "tachi-common"; -import type { ProfileRatingAlgs } from "utils/string-checks"; +import type { IDStrings, ImportTypes, UserGameStats, integer } from "tachi-common"; const logger = CreateLogCtx(__filename); diff --git a/server/src/utils/queries/scores.ts b/server/src/utils/queries/scores.ts new file mode 100644 index 000000000..39f4705e2 --- /dev/null +++ b/server/src/utils/queries/scores.ts @@ -0,0 +1,45 @@ +import db from "external/mongo/db"; +import type { Game, Playtype, integer } from "tachi-common"; + +export async function GetRecentUGPTScores( + userID: integer, + game: Game, + playtype: Playtype, + limit = 100 +) { + return db.scores.find( + { + userID, + game, + playtype, + }, + { + sort: { + timeAdded: -1, + }, + limit, + } + ); +} + +export async function GetRecentUGPTHighlights( + userID: integer, + game: Game, + playtype: Playtype, + limit = 100 +) { + return db.scores.find( + { + userID, + game, + playtype, + highlight: true, + }, + { + sort: { + timeAdded: -1, + }, + limit, + } + ); +} From 628d0ce9ca925be88d4bf294a9c0103962b35122 Mon Sep 17 00:00:00 2001 From: zkldi <20380519+zkldi@users.noreply.github.com> Date: Sun, 18 Dec 2022 03:16:38 +0000 Subject: [PATCH 02/14] fix: remove incorrect reminder --- client/src/components/tables/cells/BPICell.tsx | 2 -- 1 file changed, 2 deletions(-) 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. } From 98ca458cff613ac0cb7b2a242856c0b23d02e175 Mon Sep 17 00:00:00 2001 From: zkldi <20380519+zkldi@users.noreply.github.com> Date: Sun, 18 Dec 2022 06:27:11 +0000 Subject: [PATCH 03/14] feat: initial cg support --- .../app/pages/dashboard/import/ImportPage.tsx | 97 ++++++++- .../users/FervidexIntegrationPage.tsx | 13 +- client/src/app/routes/ImportRoutes.tsx | 20 ++ .../components/imports/CGIntegrationPage.tsx | 195 ++++++++++++++++++ client/src/components/util/FormInput.tsx | 3 + client/src/util/misc.ts | 12 ++ common/src/config/static-config.ts | 6 + common/src/types.ts | 16 +- server/example/conf.json5 | 6 + server/src/external/mongo/db.ts | 3 + server/src/external/mongo/schemas.ts | 6 + .../framework/common/score-utils.test.ts | 24 ++- .../framework/common/score-utils.ts | 54 ++++- .../common/api-cg/museca/converter.ts | 103 +++++++++ .../import-types/common/api-cg/parsers.ts | 150 ++++++++++++++ .../common/api-cg/popn/converter.ts | 167 +++++++++++++++ .../common/api-cg/sdvx/converter.ts | 149 +++++++++++++ .../common/api-cg/traverse-api.ts | 109 ++++++++++ .../import-types/common/api-cg/types.ts | 89 ++++++++ .../import-types/common/api-cg/util.ts | 14 ++ .../common/api-kai/traverse-api.ts | 4 +- .../score-import/import-types/common/types.ts | 30 ++- .../score-import/import-types/converters.ts | 13 ++ .../lib/score-import/import-types/parsers.ts | 26 ++- server/src/lib/setup/config.ts | 18 ++ .../_userID/integrations/cg/_cgType/router.ts | 138 +++++++++++++ .../v1/users/_userID/integrations/router.ts | 2 + .../src/test-utils/mock-db/cg-card-info.json | 1 + 28 files changed, 1437 insertions(+), 31 deletions(-) create mode 100644 client/src/components/imports/CGIntegrationPage.tsx create mode 100644 server/src/lib/score-import/import-types/common/api-cg/museca/converter.ts create mode 100644 server/src/lib/score-import/import-types/common/api-cg/parsers.ts create mode 100644 server/src/lib/score-import/import-types/common/api-cg/popn/converter.ts create mode 100644 server/src/lib/score-import/import-types/common/api-cg/sdvx/converter.ts create mode 100644 server/src/lib/score-import/import-types/common/api-cg/traverse-api.ts create mode 100644 server/src/lib/score-import/import-types/common/api-cg/types.ts create mode 100644 server/src/lib/score-import/import-types/common/api-cg/util.ts create mode 100644 server/src/server/router/api/v1/users/_userID/integrations/cg/_cgType/router.ts create mode 100644 server/src/test-utils/mock-db/cg-card-info.json 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/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/components/imports/CGIntegrationPage.tsx b/client/src/components/imports/CGIntegrationPage.tsx new file mode 100644 index 000000000..b171669ca --- /dev/null +++ b/client/src/components/imports/CGIntegrationPage.tsx @@ -0,0 +1,195 @@ +import { APIFetchV1 } from "util/api"; +import { ErrorPage } from "app/pages/ErrorPage"; +import useSetSubheader from "components/layout/header/useSetSubheader"; +import ApiError from "components/util/ApiError"; +import Divider from "components/util/Divider"; +import FormInput from "components/util/FormInput"; +import Loading from "components/util/Loading"; +import useImport from "components/util/import/useImport"; +import useApiQuery from "components/util/query/useApiQuery"; +import { UserContext } from "context/UserContext"; +import React, { useContext, useMemo, useReducer, useState } from "react"; +import { Button, Col, Form, Row } from "react-bootstrap"; +import { APIImportTypes, CGCardInfo, GetGameConfig } from "tachi-common"; +import ImportStateRenderer from "./ImportStateRenderer"; + +interface Props { + cgType: "dev" | "prod"; + game: "sdvx" | "popn" | "museca"; +} + +export default function CGIntegrationPage({ cgType, game }: Props) { + const gameConfig = GetGameConfig(game); + const cgName = cgType === "dev" ? "CG Dev" : "CG"; + + const [reload, shouldReloadCardInfo] = useReducer((x) => x + 1, 0); + + useSetSubheader(["Import Scores", `${gameConfig.name} Sync (${cgName})`]); + + const { user } = useContext(UserContext); + + if (!user) { + return ; + } + + 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 ; + } + + if (data) { + return ; + } else { + return ( + { + const res = await APIFetchV1( + `/users/${user.id}/integrations/cg/${cgType}`, + { + method: "PUT", + body: JSON.stringify({ cardID, pin }), + headers: { + "Content-Type": "application/json", + }, + }, + true, + true + ); + + if (res.success) { + shouldReloadCardInfo(); + } + }} + /> + ); + } +} + +function CGImporter({ cgType, game, cardID }: Pick & { cardID: string }) { + const importType: APIImportTypes = `api/cg-${cgType}-${game}`; + const cgName = cgType === "dev" ? "CG Dev" : "CG"; + + const { importState, runImport } = useImport("/import/from-api", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + importType, + }), + }); + + return ( +
+

+ Importing scores from {cgName} card{" "} + {cardID.match(/.{1,4}/gu)?.join(" ")}. +

+ +
+ +
+ +
+ Play on {cgName} a lot? You can synchronise your scores straight from the discord by + typing{" "} + + /sync {cgType === "dev" ? "CG-DEV" : "CG"} {game} + + ! +
+ + +
+ ); +} + +function CGNeedsIntegrate({ + cgType, + onSubmit, +}: Pick & { + onSubmit: (cardID: string, pin: string) => Promise; +}) { + const cgName = cgType === "dev" ? "CG Dev" : "CG"; + + const [cardID, setCardID] = useState(""); + const [pin, setPin] = useState(""); + + // strip any whitespace the user feels like entering + const realCardID = useMemo(() => cardID.replace(/\s+/gu, ""), [cardID]); + + const shouldDisable = useMemo(() => { + // yes i could turn this into a boolean with ! + // but have you *seen* how ugly that is? + if (/^[0-9]{4}$/u.exec(pin) && /^[a-zA-Z0-9]{16}$/u.exec(realCardID)) { + return false; + } + + return true; + }, [pin, realCardID]); + + return ( +
+

We need to know what card you use on {cgName}!

+ + + + + + 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/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.test.ts b/server/src/lib/score-import/framework/common/score-utils.test.ts index b8ac74349..d89d51bc7 100644 --- a/server/src/lib/score-import/framework/common/score-utils.test.ts +++ b/server/src/lib/score-import/framework/common/score-utils.test.ts @@ -1,4 +1,9 @@ -import { GenericCalculatePercent, GetGradeFromPercent, ValidatePercent } from "./score-utils"; +import { + GenericCalculatePercent, + GetGradeFromPercent, + JubeatGetMusicRate, + ValidatePercent, +} from "./score-utils"; import t from "tap"; import { isApproximately } from "test-utils/asserts"; import { Testing511SPA } from "test-utils/test-data"; @@ -144,3 +149,20 @@ t.test("#ValidatePercent", (t) => { t.end(); }); + +t.test("#JubeatGetMusicRate", (t) => { + t.equal( + JubeatGetMusicRate(423, 136, 41, 14, 5, 619, false), + 73.0, + "Should correctly calculate jubeat music rate." + ); + t.equal(JubeatGetMusicRate(418, 158, 90, 54, 92, 818, false), 73.0); + + t.equal( + JubeatGetMusicRate(423, 136, 41, 14, 5, 619, true), + 73.0 * 1.2, + "Should correctly calculate hard mode jubeat music rate." + ); + + t.end(); +}); 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..03bde4c5f 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; @@ -183,6 +186,22 @@ export function JubeatGetGrade(score: number): Grades["jubeat:Single"] { return "E"; } +export function JubeatGetMusicRate( + perfect: integer, + great: integer, + good: integer, + poor: integer, + miss: integer, + chartMaxCombo: integer, + hardMode: boolean +) { + const multiplier = hardMode ? 120 : 100; + + const rawMusicRate = ((perfect + 0.2 * great + 0.05 * good) / chartMaxCombo) * multiplier; + + return FloorToNDP(rawMusicRate, 1); +} + /** * Calculates the ESD for a given game + percent combo. This function returns * null if the game does not support support ESD. @@ -218,3 +237,34 @@ export function ParseDateFromString(str: string | null | undefined): number | nu return date; } + +/** + * Turn a museca score into its lamp. Note that we disagree with the game on what + * constitutes a clear -- instead, 800k is marked as the minimum point for a clear. + * + * Museca actually handles clears differently with a bunch of grafica nonsense, + * but nobody actually cares about it, so... + */ +export function MusecaGetLamp(score: integer, missCount: integer): Lamps["museca:Single"] { + if (score === 1_000_000) { + return "PERFECT CONNECT ALL"; + } else if (missCount === 0) { + return "CONNECT ALL"; + } else if (score >= 800_000) { + return "CLEAR"; + } + + return "FAILED"; +} + +export function JubeatGetLamp(score: integer, missCount: integer): Lamps["jubeat:Single"] { + if (score === 1_000_000) { + return "EXCELLENT"; + } else if (missCount === 0) { + return "FULL COMBO"; + } else if (score >= 700_000) { + return "CLEAR"; + } + + return "FAILED"; +} 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..f28451f3d --- /dev/null +++ b/server/src/lib/score-import/import-types/common/api-cg/museca/converter.ts @@ -0,0 +1,103 @@ +import { + InternalFailure, + InvalidScoreFailure, + KTDataNotFoundFailure, +} from "lib/score-import/framework/common/converter-failures"; +import { + GenericGetGradeAndPercent, + MusecaGetLamp, + ParseDateFromString, +} from "lib/score-import/framework/common/score-utils"; +import { FindChartOnInGameIDVersion } from "utils/queries/charts"; +import { FindSongOnID } from "utils/queries/songs"; +import type { ConverterFunction } from "../../types"; +import type { CGContext, CGMusecaScore } from "../types"; +import type { DryScore } from "lib/score-import/framework/common/types"; +import type { Difficulties, GPTSupportedVersions } from "tachi-common"; + +export const ConverterAPICGMuseca: ConverterFunction = async ( + data, + context, + importType, + logger +) => { + const difficulty = ConvertDifficulty(data.difficulty); + const version = ConvertVersion(data.version); + + const chart = await FindChartOnInGameIDVersion( + "museca", + data.internalId, + "Single", + difficulty, + version + ); + + if (!chart) { + throw new KTDataNotFoundFailure( + `Could not find chart with songID ${data.internalId} (${difficulty} - Version ${version})`, + importType, + data, + context + ); + } + + const song = await FindSongOnID("museca", chart.songID); + + if (!song) { + logger.severe(`Song-Chart desync with song ID ${chart.songID} (museca).`); + throw new InternalFailure(`Song-Chart desync with song ID ${chart.songID} (museca).`); + } + + const lamp = MusecaGetLamp(data.score, data.error); + + const { percent, grade } = GenericGetGradeAndPercent("museca", data.score, chart); + + const timeAchieved = ParseDateFromString(data.dateTime); + + const dryScore: DryScore<"museca:Single"> = { + comment: null, + game: "museca", + importType, + timeAchieved, + service: context.service, + scoreData: { + grade, + percent, + score: data.score, + lamp, + judgements: { + critical: data.critical, + near: data.near, + miss: data.error, + }, + hitMeta: { + maxCombo: data.maxChain, + }, + }, + scoreMeta: {}, + }; + + return { song, chart, dryScore }; +}; + +function ConvertDifficulty(diff: number): Difficulties["museca:Single"] { + switch (diff) { + case 0: + return "Green"; + case 1: + return "Yellow"; + case 2: + return "Red"; + } + + throw new InvalidScoreFailure(`Invalid difficulty of ${diff} - Could not convert.`); +} + +function ConvertVersion(ver: number): GPTSupportedVersions["museca:Single"] { + switch (ver) { + case 1: + return "1.5-b"; + } + + throw new InvalidScoreFailure(`Unknown/Unsupported Game Version ${ver}.`); +} 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..10b1cdfaf --- /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, 1_000_000), + exScore: p.isPositiveInteger, + clearType: p.isPositiveInteger, + + // unused + scoreGrade: p.any, + maxChain: p.isPositiveInteger, + critical: p.isPositiveInteger, + near: p.isPositiveInteger, + error: p.isPositiveInteger, + dateTime: "string", +}; + +const PR_CG_MUSECA = { + internalId: p.isPositiveInteger, + difficulty: p.isPositiveInteger, + version: p.isPositiveInteger, + score: p.isBoundedInteger(0, 1_000_000), + + // unused + clearType: p.any, + scoreGrade: p.any, + + maxChain: p.isPositiveInteger, + critical: p.isPositiveInteger, + near: p.isPositiveInteger, + error: p.isPositiveInteger, + dateTime: "string", +}; + +const PR_CG_POPN = { + internalId: p.isPositiveInteger, + difficulty: p.isPositiveInteger, + version: p.isPositiveInteger, + clearFlag: p.isPositiveInteger, + score: p.isBoundedInteger(0, 100_000), + + coolCount: p.isPositiveInteger, + greatCount: p.isPositiveInteger, + goodCount: p.isPositiveInteger, + badCount: p.isPositiveInteger, + + dateTime: "string", +}; + +// given a CG game, what should the returned data look like? +const CG_SCHEMAS: Record = { + 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.ts b/server/src/lib/score-import/import-types/common/api-cg/popn/converter.ts new file mode 100644 index 000000000..f807c75e1 --- /dev/null +++ b/server/src/lib/score-import/import-types/common/api-cg/popn/converter.ts @@ -0,0 +1,167 @@ +import { + InternalFailure, + InvalidScoreFailure, + KTDataNotFoundFailure, +} from "lib/score-import/framework/common/converter-failures"; +import { + GenericGetGradeAndPercent, + ParseDateFromString, +} from "lib/score-import/framework/common/score-utils"; +import { FindChartOnInGameIDVersion } from "utils/queries/charts"; +import { FindSongOnID } from "utils/queries/songs"; +import type { ConverterFunction } from "../../types"; +import type { CGContext, CGPopnScore } from "../types"; +import type { DryScore } from "lib/score-import/framework/common/types"; +import type { + Difficulties, + GPTSupportedVersions, + HitMetaLookup, + Lamps, + integer, +} from "tachi-common"; + +export const ConverterAPICGPopn: ConverterFunction = async ( + data, + context, + importType, + logger +) => { + const difficulty = ConvertDifficulty(data.difficulty); + const version = ConvertVersion(data.version); + + const chart = await FindChartOnInGameIDVersion( + "popn", + data.internalId, + "9B", + difficulty, + version + ); + + if (!chart) { + throw new KTDataNotFoundFailure( + `Could not find chart with songID ${data.internalId} (${difficulty} - Version ${version})`, + importType, + data, + context + ); + } + + const song = await FindSongOnID("popn", chart.songID); + + if (!song) { + logger.severe(`Song-Chart desync with song ID ${chart.songID} (popn).`); + throw new InternalFailure(`Song-Chart desync with song ID ${chart.songID} (popn).`); + } + + const lamp = GetLamp(data.clearFlag); + const specificClearType = GetSpecificClearMedal(data.clearFlag); + + const gradeAndPercent = GenericGetGradeAndPercent("popn", data.score, chart); + const percent = gradeAndPercent.percent; // ugly declarations because one is const + // and the other isn't... + let grade = gradeAndPercent.grade; + + // TEMP HACK: TODO MOVE THIS TO COMMON SOMEHOW + // pop'n scores are capped at an A rank if they're fails. + if (lamp === "FAILED" && percent >= 90) { + grade = "A"; + } + + const timeAchieved = ParseDateFromString(data.dateTime); + + const dryScore: DryScore<"popn:9B"> = { + comment: null, + game: "popn", + importType, + timeAchieved, + service: context.service, + scoreData: { + grade, + percent, + score: data.score, + lamp, + judgements: { + cool: data.coolCount, + great: data.greatCount, + good: data.goodCount, + bad: data.badCount, + }, + hitMeta: { + specificClearType, + }, + }, + scoreMeta: {}, + }; + + return { song, chart, dryScore }; +}; + +function ConvertDifficulty(diff: number): Difficulties["popn:9B"] { + switch (diff) { + case 0: + return "Easy"; + case 1: + return "Normal"; + case 2: + return "Hyper"; + case 3: + return "EX"; + } + + throw new InvalidScoreFailure(`Invalid difficulty of ${diff} - Could not convert.`); +} + +function ConvertVersion(ver: number): GPTSupportedVersions["popn:9B"] { + switch (ver) { + case 26: + return "kaimei"; + case 25: + return "peace"; + } + + throw new InvalidScoreFailure(`Unknown/Unsupported Game Version ${ver}.`); +} + +function GetLamp(clearFlag: integer): Lamps["popn:9B"] { + if (clearFlag === 11) { + return "PERFECT"; + } else if (clearFlag >= 8) { + return "FULL COMBO"; + } else if (clearFlag >= 5) { + return "CLEAR"; + } else if (clearFlag === 4) { + return "EASY CLEAR"; + } + + return "FAILED"; +} + +function GetSpecificClearMedal(clearFlag: integer): HitMetaLookup["popn:9B"]["specificClearType"] { + switch (clearFlag) { + case 1: + return "failedCircle"; + case 2: + return "failedDiamond"; + case 3: + return "failedStar"; + case 4: + return "easyClear"; + case 5: + return "clearCircle"; + case 6: + return "clearDiamond"; + case 7: + return "clearStar"; + case 8: + return "fullComboCircle"; + case 9: + return "fullComboDiamond"; + case 10: + return "fullComboStar"; + case 11: + return "perfect"; + } + + // no idea. + return null; +} 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..12dfc1b84 --- /dev/null +++ b/server/src/lib/score-import/import-types/common/api-cg/sdvx/converter.ts @@ -0,0 +1,149 @@ +import { + InternalFailure, + InvalidScoreFailure, + KTDataNotFoundFailure, +} from "lib/score-import/framework/common/converter-failures"; +import { + GenericGetGradeAndPercent, + ParseDateFromString, +} from "lib/score-import/framework/common/score-utils"; +import { FindSDVXChartOnInGameIDVersion } from "utils/queries/charts"; +import { FindSongOnID } from "utils/queries/songs"; +import type { ConverterFunction } from "../../types"; +import type { CGContext, CGSDVXScore } from "../types"; +import type { DryScore } from "lib/score-import/framework/common/types"; +import type { GPTSupportedVersions, Lamps } from "tachi-common"; + +export const ConverterAPICGSDVX: ConverterFunction = async ( + data, + context, + importType, + logger +) => { + const difficulty = ConvertDifficulty(data.difficulty); + const version = ConvertVersion(data.version); + + const chart = await FindSDVXChartOnInGameIDVersion(data.internalId, difficulty, version); + + if (!chart) { + throw new KTDataNotFoundFailure( + `Could not find chart with songID ${data.internalId} (${difficulty} - Version ${version})`, + importType, + data, + context + ); + } + + const song = await FindSongOnID("sdvx", chart.songID); + + if (!song) { + logger.severe(`Song-Chart desync with song ID ${chart.songID} (sdvx).`); + throw new InternalFailure(`Song-Chart desync with song ID ${chart.songID} (sdvx).`); + } + + const lamp = ConvertCGSDVXLamp(version, data.clearType); + + const { percent, grade } = GenericGetGradeAndPercent("sdvx", data.score, chart); + + const timeAchieved = ParseDateFromString(data.dateTime); + + const dryScore: DryScore<"sdvx:Single"> = { + comment: null, + game: "sdvx", + importType, + timeAchieved, + service: context.service, + scoreData: { + grade, + percent, + score: data.score, + lamp, + judgements: { + critical: data.critical, + near: data.near, + miss: data.error, + }, + hitMeta: { + maxCombo: data.maxChain, + }, + }, + scoreMeta: {}, + }; + + return { song, chart, dryScore }; +}; + +function ConvertDifficulty(diff: number) { + switch (diff) { + case 0: + return "NOV"; + case 1: + return "ADV"; + case 2: + return "EXH"; + case 3: + return "ANY_INF"; + case 4: + return "MXM"; + } + + throw new InvalidScoreFailure(`Invalid difficulty of ${diff} - Could not convert.`); +} + +function ConvertVersion(ver: number): GPTSupportedVersions["sdvx:Single"] { + switch (ver) { + case 1: + return "booth"; + case 2: + return "inf"; + case 3: + return "gw"; + case 4: + return "heaven"; + case 5: + return "vivid"; + case 6: + return "exceed"; + } + + throw new InvalidScoreFailure(`Unknown Game Version ${ver}.`); +} + +/** + * Convert CG's clearType enum into a Tachi lamp. Note that what numbers mean what are + * dependent on what version of the game we're listening for. + */ +function ConvertCGSDVXLamp( + version: GPTSupportedVersions["sdvx:Single"], + clearType: number +): Lamps["sdvx:Single"] { + switch (clearType) { + case 0: + return "FAILED"; + case 1: + return "CLEAR"; + } + + // this version doesn't have excessive clears, so the ints are off by one. + if (version === "booth") { + switch (clearType) { + case 2: + return "ULTIMATE CHAIN"; + case 3: + return "PERFECT ULTIMATE CHAIN"; + } + } else { + switch (clearType) { + case 2: + return "EXCESSIVE CLEAR"; + case 3: + return "ULTIMATE CHAIN"; + case 4: + return "PERFECT ULTIMATE CHAIN"; + } + } + + throw new InvalidScoreFailure( + `Invalid lamp of ${clearType} for ${version} - Could not convert.` + ); +} 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..3e4e123d4 --- /dev/null +++ b/server/src/lib/score-import/import-types/common/api-cg/util.ts @@ -0,0 +1,14 @@ +import type { CGSupportedGames } from "./types"; +import type { Game } from "tachi-common"; + +export function CGGameToTachiGame(cgGame: CGSupportedGames): Game { + switch (cgGame) { + case "jb": + return "jubeat"; + case "msc": + return "museca"; + case "popn": + case "sdvx": + return cgGame; + } +} 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..67b6bea64 --- /dev/null +++ b/server/src/server/router/api/v1/users/_userID/integrations/cg/_cgType/router.ts @@ -0,0 +1,138 @@ +import { RequireSelfRequestFromUser } from "../../../middleware"; +import { Router } from "express"; +import db from "external/mongo/db"; +import p from "prudence"; +import prValidate from "server/middleware/prudence-validate"; +import { GetTachiData } from "utils/req-tachi-data"; +import type { RequestHandler } from "express-serve-static-core"; +import type { CGCardInfo } from "tachi-common"; + +const router: Router = Router({ mergeParams: true }); + +const ValidateCGType: RequestHandler = (req, res, next) => { + if (req.params.cgType === "dev" || req.params.cgType === "prod") { + next(); + return; + } + + return res.status(404).json({ + success: false, + description: `No such service 'cg/${req.params.cgType}' is supported.`, + }); +}; + +/** + * Retrieve this user's card info (cardID). + * + * @name GET /api/v1/users/:userID/integrations/cg/:cgType + */ +router.get("/", ValidateCGType, RequireSelfRequestFromUser, async (req, res) => { + const user = GetTachiData(req, "requestedUser"); + const cgType = req.params.cgType as "dev" | "prod"; + + const cardInfo = await db["cg-card-info"].findOne( + { + userID: user.id, + service: cgType, + }, + { + projection: { + // i mean, it's not a security thing really but nobody needs + // to see this. + pin: 0, + }, + } + ); + + if (!cardInfo) { + return res.status(200).json({ + success: true, + description: `User has no card info set.`, + body: null, + }); + } + + return res.status(200).json({ + success: true, + description: `Found card info.`, + body: cardInfo, + }); +}); + +/** + * Write new card details for this CG integration. + * + * @name PUT /api/v1/users/:userID/integrations/cg/:cgType + */ +router.put( + "/", + ValidateCGType, + RequireSelfRequestFromUser, + prValidate( + { + cardID: p.regex(/^[a-zA-Z0-9]{16}$/u), + pin: p.regex(/^[0-9]{4}$/u), + }, + { + cardID: "Expected 16 characters.", + pin: "Expected 4 digits.", + } + ), + async (req, res) => { + const user = GetTachiData(req, "requestedUser"); + const cgType = req.params.cgType as "dev" | "prod"; + + const { cardID, pin } = req.safeBody as { + cardID: string; + pin: string; + }; + + const newCardInfo: CGCardInfo = { + service: cgType, + cardID, + pin, + userID: user.id, + }; + + await db["cg-card-info"].update( + { + userID: user.id, + service: cgType, + }, + { $set: newCardInfo }, + { + // insert new card info if the user doesn't have it yet. + upsert: true, + } + ); + + return res.status(200).json({ + success: true, + description: `Updated cardID and pin.`, + body: {}, + }); + } +); + +/** + * Unset this user's card details for this CG integration. + * + * @name DELETE /api/v1/users/:userID/integrations/cg/:cgType + */ +router.delete("/", ValidateCGType, RequireSelfRequestFromUser, async (req, res) => { + const user = GetTachiData(req, "requestedUser"); + const cgType = req.params.cgType as "dev" | "prod"; + + await db["cg-card-info"].remove({ + userID: user.id, + service: cgType, + }); + + return res.status(200).json({ + success: true, + description: `Deleted stored card info.`, + body: {}, + }); +}); + +export default router; 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 From 01e05ba90cd8e96dcf7050b3c04ecda60094fd3a Mon Sep 17 00:00:00 2001 From: zkldi <20380519+zkldi@users.noreply.github.com> Date: Sun, 18 Dec 2022 08:34:35 +0000 Subject: [PATCH 04/14] test: cg-popn tests --- .../common/api-cg/popn/converter.test.ts | 122 ++++++++++++++++++ .../src/test-utils/mock-db/charts-popn.json | 3 +- 2 files changed, 124 insertions(+), 1 deletion(-) create mode 100644 server/src/lib/score-import/import-types/common/api-cg/popn/converter.test.ts 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..74a5b5529 --- /dev/null +++ b/server/src/lib/score-import/import-types/common/api-cg/popn/converter.test.ts @@ -0,0 +1,122 @@ +import { ConverterAPICGPopn } from "./converter"; +import CreateLogCtx from "lib/logger/logger"; +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", + timeAchieved: 1559805262000, + service: "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/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": [ From d48ccee388dc71829981c5a36dc708107cb3419d Mon Sep 17 00:00:00 2001 From: zkldi <20380519+zkldi@users.noreply.github.com> Date: Sun, 18 Dec 2022 08:45:35 +0000 Subject: [PATCH 05/14] test: other cg converter tests honestly these tests are super barebones but i mean they're good enough. I hate writing tests. --- .../common/api-cg/museca/converter.test.ts | 103 +++++++++++++++++ .../common/api-cg/sdvx/converter.test.ts | 104 ++++++++++++++++++ .../src/test-utils/mock-db/charts-museca.json | 20 ++++ .../src/test-utils/mock-db/songs-museca.json | 14 +++ 4 files changed, 241 insertions(+) create mode 100644 server/src/lib/score-import/import-types/common/api-cg/museca/converter.test.ts create mode 100644 server/src/lib/score-import/import-types/common/api-cg/sdvx/converter.test.ts create mode 100644 server/src/test-utils/mock-db/charts-museca.json create mode 100644 server/src/test-utils/mock-db/songs-museca.json 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..fc5c62c6a --- /dev/null +++ b/server/src/lib/score-import/import-types/common/api-cg/museca/converter.test.ts @@ -0,0 +1,103 @@ +import { ConverterAPICGMuseca } from "./converter"; +import CreateLogCtx from "lib/logger/logger"; +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", + timeAchieved: 1559805262000, + service: "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/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..b8cc0ed96 --- /dev/null +++ b/server/src/lib/score-import/import-types/common/api-cg/sdvx/converter.test.ts @@ -0,0 +1,104 @@ +import { ConverterAPICGSDVX } from "./converter"; +import CreateLogCtx from "lib/logger/logger"; +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", + timeAchieved: 1559805262000, + service: "dev", + scoreData: { + grade: "A+", + percent: 91.23, + score: 9_123_000, + lamp: "EXCESSIVE 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: 0 }), { + dryScore: mkOutput({ scoreData: { lamp: "FAILED" } }), + }); + t.hasStrict(await convert({ clearType: 1 }), { + dryScore: mkOutput({ scoreData: { lamp: "CLEAR" } }), + }); + t.hasStrict(await convert({ clearType: 2 }), { + dryScore: mkOutput({ scoreData: { lamp: "EXCESSIVE CLEAR" } }), + }); + t.hasStrict(await convert({ clearType: 3 }), { + dryScore: mkOutput({ scoreData: { lamp: "ULTIMATE CHAIN" } }), + }); + t.hasStrict(await convert({ clearType: 4 }), { + dryScore: mkOutput({ scoreData: { lamp: "PERFECT ULTIMATE CHAIN" } }), + }); + + t.end(); + }); + + t.end(); +}); 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/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 From 90611bb04686e442662d662be46fbb7b49360b6a Mon Sep 17 00:00:00 2001 From: zkldi <20380519+zkldi@users.noreply.github.com> Date: Sun, 18 Dec 2022 08:59:38 +0000 Subject: [PATCH 06/14] fix: misc cg-dev fixes --- .../common/api-cg/museca/converter.test.ts | 4 ++-- .../common/api-cg/museca/converter.ts | 3 ++- .../import-types/common/api-cg/parsers.ts | 2 +- .../common/api-cg/popn/converter.test.ts | 4 ++-- .../common/api-cg/popn/converter.ts | 3 ++- .../common/api-cg/sdvx/converter.test.ts | 6 +++--- .../common/api-cg/sdvx/converter.ts | 17 +++++++++-------- .../import-types/common/api-cg/util.ts | 6 +++++- 8 files changed, 26 insertions(+), 19 deletions(-) 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 index fc5c62c6a..acff1a347 100644 --- 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 @@ -32,7 +32,7 @@ function mkOutput(modifant: any = {}): DryScore<"museca:Single"> { game: "museca", importType: "api/cg-dev-museca", timeAchieved: 1559805262000, - service: "dev", + service: "CG Dev", scoreData: { grade: "優", percent: 91.2, @@ -53,7 +53,7 @@ function mkOutput(modifant: any = {}): DryScore<"museca:Single"> { t.test("#ConverterAPICGMuseca", (t) => { const context: CGContext = { - service: "dev", + service: "CG Dev", userID: 1, }; 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 index f28451f3d..4df8abb82 100644 --- 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 @@ -1,3 +1,4 @@ +import { FormatCGService } from "../util"; import { InternalFailure, InvalidScoreFailure, @@ -59,7 +60,7 @@ export const ConverterAPICGMuseca: ConverterFunction = game: "museca", importType, timeAchieved, - service: context.service, + service: FormatCGService(context.service), scoreData: { grade, percent, 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 index 10b1cdfaf..dc4518468 100644 --- 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 @@ -42,7 +42,7 @@ const PR_CG_SDVX = { internalId: p.isPositiveInteger, difficulty: p.isPositiveInteger, version: p.isPositiveInteger, - score: p.isBoundedInteger(0, 1_000_000), + score: p.isBoundedInteger(0, 10_000_000), exScore: p.isPositiveInteger, clearType: p.isPositiveInteger, 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 index 74a5b5529..d95b9c6aa 100644 --- 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 @@ -31,7 +31,7 @@ function mkOutput(modifant: any = {}): DryScore<"popn:9B"> { game: "popn", importType: "api/cg-dev-popn", timeAchieved: 1559805262000, - service: "dev", + service: "CG Dev", scoreData: { grade: "A", percent: 87, @@ -55,7 +55,7 @@ function mkOutput(modifant: any = {}): DryScore<"popn:9B"> { t.test("#ConverterAPICGPopn", (t) => { const context: CGContext = { - service: "dev", + service: "CG Dev", userID: 1, }; 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 index f807c75e1..b17169ea7 100644 --- 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 @@ -1,3 +1,4 @@ +import { FormatCGService } from "../util"; import { InternalFailure, InvalidScoreFailure, @@ -74,7 +75,7 @@ export const ConverterAPICGPopn: ConverterFunction = asy game: "popn", importType, timeAchieved, - service: context.service, + service: FormatCGService(context.service), scoreData: { grade, percent, 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 index b8cc0ed96..628e6e38e 100644 --- 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 @@ -33,12 +33,12 @@ function mkOutput(modifant: any = {}): DryScore<"sdvx:Single"> { game: "sdvx", importType: "api/cg-dev-sdvx", timeAchieved: 1559805262000, - service: "dev", + service: "CG Dev", scoreData: { grade: "A+", percent: 91.23, score: 9_123_000, - lamp: "EXCESSIVE CLEAR", + lamp: "CLEAR", judgements: { critical: 100, near: 50, @@ -54,7 +54,7 @@ function mkOutput(modifant: any = {}): DryScore<"sdvx:Single"> { t.test("#ConverterAPICGSDVX", (t) => { const context: CGContext = { - service: "dev", + service: "CG Dev", userID: 1, }; 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 index 12dfc1b84..5f22341e9 100644 --- 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 @@ -1,3 +1,4 @@ +import { FormatCGService } from "../util"; import { InternalFailure, InvalidScoreFailure, @@ -52,7 +53,7 @@ export const ConverterAPICGSDVX: ConverterFunction = asy game: "sdvx", importType, timeAchieved, - service: context.service, + service: FormatCGService(context.service), scoreData: { grade, percent, @@ -118,27 +119,27 @@ function ConvertCGSDVXLamp( clearType: number ): Lamps["sdvx:Single"] { switch (clearType) { - case 0: - return "FAILED"; case 1: + return "FAILED"; + case 2: return "CLEAR"; } // this version doesn't have excessive clears, so the ints are off by one. if (version === "booth") { switch (clearType) { - case 2: - return "ULTIMATE CHAIN"; case 3: + return "ULTIMATE CHAIN"; + case 4: return "PERFECT ULTIMATE CHAIN"; } } else { switch (clearType) { - case 2: - return "EXCESSIVE CLEAR"; case 3: - return "ULTIMATE CHAIN"; + return "EXCESSIVE CLEAR"; case 4: + return "ULTIMATE CHAIN"; + case 5: return "PERFECT ULTIMATE CHAIN"; } } 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 index 3e4e123d4..3191cd990 100644 --- 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 @@ -1,4 +1,4 @@ -import type { CGSupportedGames } from "./types"; +import type { CGServices, CGSupportedGames } from "./types"; import type { Game } from "tachi-common"; export function CGGameToTachiGame(cgGame: CGSupportedGames): Game { @@ -12,3 +12,7 @@ export function CGGameToTachiGame(cgGame: CGSupportedGames): Game { return cgGame; } } + +export function FormatCGService(cgService: CGServices) { + return cgService === "dev" ? "CG Dev" : "CG"; +} From a060edd217b2d274632971f7c662397fcc08d31e Mon Sep 17 00:00:00 2001 From: zkldi <20380519+zkldi@users.noreply.github.com> Date: Sun, 18 Dec 2022 09:02:05 +0000 Subject: [PATCH 07/14] feat: enable cg on bot --- bot/src/slashCommands/commands/sync.ts | 6 ++++++ 1 file changed, 6 insertions(+) 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(), From 1a4a342d6074e92efa6247ec654fb1294a6f1844 Mon Sep 17 00:00:00 2001 From: zkldi <20380519+zkldi@users.noreply.github.com> Date: Sun, 18 Dec 2022 09:28:00 +0000 Subject: [PATCH 08/14] feat: edit cardid --- .../dashboard/users/UserIntegrationsPage.tsx | 156 +++++++++++------ client/src/app/routes/UserRoutes.tsx | 2 +- .../components/imports/CGIntegrationPage.tsx | 160 +++++++++++------- .../_userID/integrations/cg/_cgType/router.ts | 17 +- 4 files changed, 205 insertions(+), 130 deletions(-) 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/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 index b171669ca..64a65c56c 100644 --- a/client/src/components/imports/CGIntegrationPage.tsx +++ b/client/src/components/imports/CGIntegrationPage.tsx @@ -11,6 +11,8 @@ 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 { @@ -23,6 +25,7 @@ export default function CGIntegrationPage({ cgType, game }: Props) { 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})`]); @@ -47,36 +50,60 @@ export default function CGIntegrationPage({ cgType, game }: Props) { return ; } - if (data) { - return ; - } else { - return ( - { - const res = await APIFetchV1( - `/users/${user.id}/integrations/cg/${cgType}`, - { - method: "PUT", - body: JSON.stringify({ cardID, pin }), - headers: { - "Content-Type": "application/json", - }, - }, - true, - true - ); + 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(); - } - }} - /> - ); - } + if (res.success) { + shouldReloadCardInfo(); + } + }} + initialCardID={data?.cardID ?? undefined} + initialPin={data?.pin ?? undefined} + /> + + + )} + {data && ( + + )} + + ); } -function CGImporter({ cgType, game, cardID }: Pick & { cardID: string }) { +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"; @@ -94,7 +121,13 @@ function CGImporter({ cgType, game, cardID }: Pick & {

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

@@ -128,16 +161,20 @@ function CGImporter({ cgType, game, cardID }: Pick & { ); } -function CGNeedsIntegrate({ +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(""); - const [pin, setPin] = useState(""); + 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]); @@ -154,42 +191,37 @@ function CGNeedsIntegrate({ return (
-

We need to know what card you use on {cgName}!

+

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}? -
+ + + 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! - )} + {pin.length > 0 && !/^[0-9]{4}$/u.exec(pin) ? ( + Invalid PIN. This should be 4 digits. + ) : ( + pin.length > 0 && Looking good! + )} - -
- -
- -
+ +
+ +
); } 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 index 67b6bea64..1b6a64548 100644 --- 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 @@ -30,19 +30,10 @@ router.get("/", ValidateCGType, RequireSelfRequestFromUser, async (req, res) => const user = GetTachiData(req, "requestedUser"); const cgType = req.params.cgType as "dev" | "prod"; - const cardInfo = await db["cg-card-info"].findOne( - { - userID: user.id, - service: cgType, - }, - { - projection: { - // i mean, it's not a security thing really but nobody needs - // to see this. - pin: 0, - }, - } - ); + const cardInfo = await db["cg-card-info"].findOne({ + userID: user.id, + service: cgType, + }); if (!cardInfo) { return res.status(200).json({ From f43be31ca797056d841f6d84db2990891e0f10c9 Mon Sep 17 00:00:00 2001 From: zkldi <20380519+zkldi@users.noreply.github.com> Date: Sun, 18 Dec 2022 09:30:08 +0000 Subject: [PATCH 09/14] fix: it's just /sync now --- client/src/components/imports/CGIntegrationPage.tsx | 6 +----- client/src/components/imports/KAIIntegrationPage.tsx | 6 +----- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/client/src/components/imports/CGIntegrationPage.tsx b/client/src/components/imports/CGIntegrationPage.tsx index 64a65c56c..fcc07c900 100644 --- a/client/src/components/imports/CGIntegrationPage.tsx +++ b/client/src/components/imports/CGIntegrationPage.tsx @@ -149,11 +149,7 @@ function CGImporter({
Play on {cgName} a lot? You can synchronise your scores straight from the discord by - typing{" "} - - /sync {cgType === "dev" ? "CG-DEV" : "CG"} {game} - - ! + typing /sync!
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!
From d35a6fad71b5808234b31a55878af3f376b14ac4 Mon Sep 17 00:00:00 2001 From: zkldi <20380519+zkldi@users.noreply.github.com> Date: Sun, 18 Dec 2022 09:42:59 +0000 Subject: [PATCH 10/14] fix: rival text not centered --- .../src/components/tables/dropdowns/components/RivalCompare.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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? From 78fa39a2fad80e3bc5b308b892bd8b157e5dd44b Mon Sep 17 00:00:00 2001 From: zkldi <20380519+zkldi@users.noreply.github.com> Date: Sun, 18 Dec 2022 10:08:57 +0000 Subject: [PATCH 11/14] fix: disasterous find & replace --- .../import-types/common/api-cg/museca/converter.test.ts | 2 +- .../import-types/common/api-cg/popn/converter.test.ts | 2 +- .../import-types/common/api-cg/sdvx/converter.test.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) 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 index acff1a347..0f97941d8 100644 --- 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 @@ -53,7 +53,7 @@ function mkOutput(modifant: any = {}): DryScore<"museca:Single"> { t.test("#ConverterAPICGMuseca", (t) => { const context: CGContext = { - service: "CG Dev", + service: "dev", userID: 1, }; 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 index d95b9c6aa..e57c54f0a 100644 --- 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 @@ -55,7 +55,7 @@ function mkOutput(modifant: any = {}): DryScore<"popn:9B"> { t.test("#ConverterAPICGPopn", (t) => { const context: CGContext = { - service: "CG Dev", + service: "dev", userID: 1, }; 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 index 628e6e38e..d8086cec9 100644 --- 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 @@ -54,7 +54,7 @@ function mkOutput(modifant: any = {}): DryScore<"sdvx:Single"> { t.test("#ConverterAPICGSDVX", (t) => { const context: CGContext = { - service: "CG Dev", + service: "dev", userID: 1, }; From 7e757448cdc2a99db8e66a3f381ed4fd5b9d5017 Mon Sep 17 00:00:00 2001 From: zkldi <20380519+zkldi@users.noreply.github.com> Date: Sun, 18 Dec 2022 23:01:46 +0000 Subject: [PATCH 12/14] fix: convert sdvx lamp properly (booth needs no special handling) --- .../common/api-cg/sdvx/converter.ts | 36 +++++-------------- 1 file changed, 9 insertions(+), 27 deletions(-) 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 index 5f22341e9..0bede9c52 100644 --- 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 @@ -42,7 +42,7 @@ export const ConverterAPICGSDVX: ConverterFunction = asy throw new InternalFailure(`Song-Chart desync with song ID ${chart.songID} (sdvx).`); } - const lamp = ConvertCGSDVXLamp(version, data.clearType); + const lamp = ConvertCGSDVXLamp(data.clearType); const { percent, grade } = GenericGetGradeAndPercent("sdvx", data.score, chart); @@ -114,37 +114,19 @@ function ConvertVersion(ver: number): GPTSupportedVersions["sdvx:Single"] { * Convert CG's clearType enum into a Tachi lamp. Note that what numbers mean what are * dependent on what version of the game we're listening for. */ -function ConvertCGSDVXLamp( - version: GPTSupportedVersions["sdvx:Single"], - clearType: number -): Lamps["sdvx:Single"] { +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"; } - // this version doesn't have excessive clears, so the ints are off by one. - if (version === "booth") { - switch (clearType) { - case 3: - return "ULTIMATE CHAIN"; - case 4: - return "PERFECT ULTIMATE CHAIN"; - } - } else { - switch (clearType) { - case 3: - return "EXCESSIVE CLEAR"; - case 4: - return "ULTIMATE CHAIN"; - case 5: - return "PERFECT ULTIMATE CHAIN"; - } - } - - throw new InvalidScoreFailure( - `Invalid lamp of ${clearType} for ${version} - Could not convert.` - ); + throw new InvalidScoreFailure(`Invalid lamp of ${clearType} - Could not convert.`); } From 695d028a8a2e9158c61b97083b16ac7bc9eda3e2 Mon Sep 17 00:00:00 2001 From: zkldi <20380519+zkldi@users.noreply.github.com> Date: Sun, 18 Dec 2022 23:10:10 +0000 Subject: [PATCH 13/14] test: make tests work and remove unused util --- .../framework/common/score-utils.test.ts | 24 +------------------ .../framework/common/score-utils.ts | 16 ------------- .../common/api-cg/sdvx/converter.test.ts | 10 ++++---- 3 files changed, 6 insertions(+), 44 deletions(-) diff --git a/server/src/lib/score-import/framework/common/score-utils.test.ts b/server/src/lib/score-import/framework/common/score-utils.test.ts index d89d51bc7..b8ac74349 100644 --- a/server/src/lib/score-import/framework/common/score-utils.test.ts +++ b/server/src/lib/score-import/framework/common/score-utils.test.ts @@ -1,9 +1,4 @@ -import { - GenericCalculatePercent, - GetGradeFromPercent, - JubeatGetMusicRate, - ValidatePercent, -} from "./score-utils"; +import { GenericCalculatePercent, GetGradeFromPercent, ValidatePercent } from "./score-utils"; import t from "tap"; import { isApproximately } from "test-utils/asserts"; import { Testing511SPA } from "test-utils/test-data"; @@ -149,20 +144,3 @@ t.test("#ValidatePercent", (t) => { t.end(); }); - -t.test("#JubeatGetMusicRate", (t) => { - t.equal( - JubeatGetMusicRate(423, 136, 41, 14, 5, 619, false), - 73.0, - "Should correctly calculate jubeat music rate." - ); - t.equal(JubeatGetMusicRate(418, 158, 90, 54, 92, 818, false), 73.0); - - t.equal( - JubeatGetMusicRate(423, 136, 41, 14, 5, 619, true), - 73.0 * 1.2, - "Should correctly calculate hard mode jubeat music rate." - ); - - t.end(); -}); 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 03bde4c5f..c439dc7d4 100644 --- a/server/src/lib/score-import/framework/common/score-utils.ts +++ b/server/src/lib/score-import/framework/common/score-utils.ts @@ -186,22 +186,6 @@ export function JubeatGetGrade(score: number): Grades["jubeat:Single"] { return "E"; } -export function JubeatGetMusicRate( - perfect: integer, - great: integer, - good: integer, - poor: integer, - miss: integer, - chartMaxCombo: integer, - hardMode: boolean -) { - const multiplier = hardMode ? 120 : 100; - - const rawMusicRate = ((perfect + 0.2 * great + 0.05 * good) / chartMaxCombo) * multiplier; - - return FloorToNDP(rawMusicRate, 1); -} - /** * Calculates the ESD for a given game + percent combo. This function returns * null if the game does not support support ESD. 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 index d8086cec9..e7b2cc3a1 100644 --- 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 @@ -81,19 +81,19 @@ t.test("#ConverterAPICGSDVX", (t) => { }); t.test("Lamps", async (t) => { - t.hasStrict(await convert({ clearType: 0 }), { + t.hasStrict(await convert({ clearType: 1 }), { dryScore: mkOutput({ scoreData: { lamp: "FAILED" } }), }); - t.hasStrict(await convert({ clearType: 1 }), { + t.hasStrict(await convert({ clearType: 2 }), { dryScore: mkOutput({ scoreData: { lamp: "CLEAR" } }), }); - t.hasStrict(await convert({ clearType: 2 }), { + t.hasStrict(await convert({ clearType: 3 }), { dryScore: mkOutput({ scoreData: { lamp: "EXCESSIVE CLEAR" } }), }); - t.hasStrict(await convert({ clearType: 3 }), { + t.hasStrict(await convert({ clearType: 4 }), { dryScore: mkOutput({ scoreData: { lamp: "ULTIMATE CHAIN" } }), }); - t.hasStrict(await convert({ clearType: 4 }), { + t.hasStrict(await convert({ clearType: 5 }), { dryScore: mkOutput({ scoreData: { lamp: "PERFECT ULTIMATE CHAIN" } }), }); From 9a2eae3f7ea0f1465bc90dc5e947c5fa4bccc17b Mon Sep 17 00:00:00 2001 From: zkldi <20380519+zkldi@users.noreply.github.com> Date: Sun, 18 Dec 2022 23:52:14 +0000 Subject: [PATCH 14/14] fix: timezone issues --- .../import-types/common/api-cg/museca/converter.test.ts | 6 +++++- .../import-types/common/api-cg/popn/converter.test.ts | 6 +++++- .../import-types/common/api-cg/sdvx/converter.test.ts | 6 +++++- 3 files changed, 15 insertions(+), 3 deletions(-) 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 index 0f97941d8..529fed82f 100644 --- 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 @@ -1,5 +1,6 @@ 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"; @@ -31,7 +32,10 @@ function mkOutput(modifant: any = {}): DryScore<"museca:Single"> { comment: null, game: "museca", importType: "api/cg-dev-museca", - timeAchieved: 1559805262000, + + // 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: "優", 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 index e57c54f0a..735adf42d 100644 --- 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 @@ -1,5 +1,6 @@ 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"; @@ -30,7 +31,10 @@ function mkOutput(modifant: any = {}): DryScore<"popn:9B"> { comment: null, game: "popn", importType: "api/cg-dev-popn", - timeAchieved: 1559805262000, + + // 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", 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 index e7b2cc3a1..4dd91eb48 100644 --- 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 @@ -1,5 +1,6 @@ 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"; @@ -32,7 +33,10 @@ function mkOutput(modifant: any = {}): DryScore<"sdvx:Single"> { comment: null, game: "sdvx", importType: "api/cg-dev-sdvx", - timeAchieved: 1559805262000, + + // 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+",