From 37e37ce8d0db2af5ac713249c700b82669bf7e54 Mon Sep 17 00:00:00 2001 From: zkldi <20380519+zkldi@users.noreply.github.com> Date: Sat, 13 Nov 2021 07:59:33 +0000 Subject: [PATCH 1/5] Move IR/USC into IR/USC/:playtype --- .../ir/usc/{ => _playtype}/router.test.ts | 0 .../server/router/ir/usc/_playtype/router.ts | 429 ++++++++++++++++++ .../router/ir/usc/{ => _playtype}/types.ts | 0 .../router/ir/usc/{ => _playtype}/usc.test.ts | 0 .../router/ir/usc/{ => _playtype}/usc.ts | 0 server/src/server/router/ir/usc/router.ts | 426 +---------------- 6 files changed, 437 insertions(+), 418 deletions(-) rename server/src/server/router/ir/usc/{ => _playtype}/router.test.ts (100%) create mode 100644 server/src/server/router/ir/usc/_playtype/router.ts rename server/src/server/router/ir/usc/{ => _playtype}/types.ts (100%) rename server/src/server/router/ir/usc/{ => _playtype}/usc.test.ts (100%) rename server/src/server/router/ir/usc/{ => _playtype}/usc.ts (100%) diff --git a/server/src/server/router/ir/usc/router.test.ts b/server/src/server/router/ir/usc/_playtype/router.test.ts similarity index 100% rename from server/src/server/router/ir/usc/router.test.ts rename to server/src/server/router/ir/usc/_playtype/router.test.ts diff --git a/server/src/server/router/ir/usc/_playtype/router.ts b/server/src/server/router/ir/usc/_playtype/router.ts new file mode 100644 index 000000000..10a5920c6 --- /dev/null +++ b/server/src/server/router/ir/usc/_playtype/router.ts @@ -0,0 +1,429 @@ +import { RequestHandler, Router } from "express"; +import db from "external/mongo/db"; +import { CDNStoreOrOverwrite } from "lib/cdn/cdn"; +import { GetUSCIRReplayURL } from "lib/cdn/url-format"; +import { ONE_MEGABYTE } from "lib/constants/filesize"; +import { SYMBOL_TachiAPIAuth, SYMBOL_TachiData } from "lib/constants/tachi"; +import { USCIR_MAX_LEADERBOARD_N } from "lib/constants/usc-ir"; +import CreateLogCtx from "lib/logger/logger"; +import { HandleOrphanQueue } from "lib/orphan-queue/orphan-queue"; +import { AssertStrAsPositiveNonZeroInt } from "lib/score-import/framework/common/string-asserts"; +import { ExpressWrappedScoreImportMain } from "lib/score-import/framework/express-wrapper"; +import { ReprocessOrphan } from "lib/score-import/framework/orphans/orphans"; +import { ServerConfig, TachiConfig } from "lib/setup/config"; +import p from "prudence"; +import { RequirePermissions } from "server/middleware/auth"; +import { CreateMulterSingleUploadMiddleware } from "server/middleware/multer-upload"; +import { + ChartDocument, + ImportDocument, + PBScoreDocument, + SuccessfulAPIResponse, +} from "tachi-common"; +import { FormatPrError } from "utils/prudence"; +import { GetBlacklist } from "utils/queries/blacklist"; +import { AssignToReqTachiData } from "utils/req-tachi-data"; +import { USCClientChart } from "./types"; +import { + ConvertUSCChart, + CreatePOSTScoresResponseBody, + TachiScoreToServerScore, + USCChartIndexToDiff, +} from "./usc"; + +const logger = CreateLogCtx(__filename); + +const router: Router = Router({ mergeParams: true }); + +enum STATUS_CODES { + UNAUTH = 41, + CHART_REFUSE = 42, + FORBIDDEN = 43, + NOT_FOUND = 44, + SERVER_ERROR = 50, + SUCCESS = 20, + ACCEPTED = 22, + BAD_REQ = 40, +} + +const ValidateUSCRequest: RequestHandler = async (req, res, next) => { + const token = req.header("Authorization"); + + if (!token) { + return res.status(200).json({ + statusCode: STATUS_CODES.BAD_REQ, + description: "No auth token provided.", + }); + } + + const splitToken = token.split(" "); + + if (splitToken.length !== 2 || splitToken[0] !== "Bearer") { + return res.status(200).json({ + statusCode: STATUS_CODES.BAD_REQ, + description: "Invalid Authorization Header. Expected Bearer ", + }); + } + + const uscAuthDoc = await db["api-tokens"].findOne({ + token: splitToken[1], + }); + + if (!uscAuthDoc) { + return res.status(200).json({ + statusCode: STATUS_CODES.UNAUTH, + description: "Unauthorized.", + }); + } + + req[SYMBOL_TachiAPIAuth] = uscAuthDoc; + + return next(); +}; + +router.use(ValidateUSCRequest); +// This is an implementation of the USCIR spec as per https://uscir.readthedocs.io. +// This specification always returns 200 OK, regardless of whether the result was okay +// as the HTTP code is used to determine whether the server received the request properly, +// rather than the result of the request. + +/** + * Used to check your connection to the server, and receive some basic information. + * https://uscir.readthedocs.io/en/latest/endpoints/heartbeat.html + * @name GET /ir/usc/:playtype + */ +router.get("/", (req, res) => + res.status(200).json({ + statusCode: STATUS_CODES.SUCCESS, + description: "IR Request Successful.", + body: { + serverTime: Math.floor(Date.now() / 1000), + serverName: TachiConfig.NAME, + irVersion: "0.3.1-a", + }, + }) +); + +const RetrieveChart: RequestHandler = async (req, res, next) => { + const chart = await db.charts.usc.findOne({ "data.hashSHA1": req.params.chartHash }); + + AssignToReqTachiData(req, { + uscChartDoc: (chart ?? undefined) as ChartDocument<"usc:Single"> | undefined, + }); + + return next(); +}; + +/** + * Used to check if the server will accept a score for a given chart in advance of submitting it. + * https://uscir.readthedocs.io/en/latest/endpoints/chart-charthash.html + * @name GET /ir/usc/:playtype/charts/:chartHash + */ +router.get("/charts/:chartHash", RetrieveChart, (req, res) => { + const chart = req[SYMBOL_TachiData]!.uscChartDoc; + + if (!chart) { + return res.status(200).json({ + statusCode: STATUS_CODES.NOT_FOUND, + description: "This chart is not available on the IR yet, more people need to play it!", + }); + } + + return res.status(200).json({ + statusCode: STATUS_CODES.SUCCESS, + description: "This chart is tracked by the IR.", + }); +}); + +/** + * Used to retrieve the current server record for the chart with the specified hash. + * https://uscir.readthedocs.io/en/latest/endpoints/record.html + * @name GET /ir/usc/:playtype/charts/:chartHash/record + */ +router.get("/charts/:chartHash/record", RetrieveChart, async (req, res) => { + const chart = req[SYMBOL_TachiData]!.uscChartDoc; + + if (!chart) { + return res.status(200).json({ + statusCode: STATUS_CODES.NOT_FOUND, + description: `This IR doesn't have any record data yet, or ${ + ServerConfig.USC_QUEUE_SIZE + } ${ + ServerConfig.USC_QUEUE_SIZE === 1 ? "person has" : "people have" + } not played the chart yet.`, + }); + } + + const serverRecord = (await db["personal-bests"].findOne({ + chartID: chart.chartID, + "rankingData.rank": 1, + })) as PBScoreDocument<"usc:Single"> | null; + + if (!serverRecord) { + return res.status(200).json({ + statusCode: STATUS_CODES.NOT_FOUND, + description: "No server record found.", + }); + } + + const serverScore = await TachiScoreToServerScore(serverRecord); + + return res.status(200).json({ + statusCode: STATUS_CODES.SUCCESS, + description: "Retrieved score.", + body: { record: serverScore }, + }); +}); + +/** + * Used to retrieve some particular useful subset of the scores from the server. + * https://uscir.readthedocs.io/en/latest/endpoints/leaderboard.html + * @name GET /ir/usc/:playtype/charts/:chartHash/leaderboard + */ +router.get("/charts/:chartHash/leaderboard", RetrieveChart, async (req, res) => { + const chart = req[SYMBOL_TachiData]!.uscChartDoc!; + + if (!(typeof req.query.mode === "string" && ["best", "rivals"].includes(req.query.mode))) { + return res.status(200).json({ + statusCode: STATUS_CODES.BAD_REQ, + description: `Invalid 'mode' param - expected 'best' or 'rivals'.`, + }); + } + + if (typeof req.query.n !== "string") { + return res.status(200).json({ + statusCode: STATUS_CODES.BAD_REQ, + description: `Invalid 'n' param - expected a positive non-zero integer less than or equal to ${USCIR_MAX_LEADERBOARD_N}.`, + }); + } + + let n; + + try { + n = AssertStrAsPositiveNonZeroInt(req.query.n, "Invalid 'N' param."); + } catch (err) { + return res.status(200).json({ + statusCode: STATUS_CODES.BAD_REQ, + description: `Invalid 'n' param - expected a positive non-zero integer less than or equal to ${USCIR_MAX_LEADERBOARD_N}.`, + }); + } + + if (n >= USCIR_MAX_LEADERBOARD_N) { + n = USCIR_MAX_LEADERBOARD_N; + } + + const mode = req.query.mode as "best" | "rivals"; + + if (mode === "rivals") { + return res.status(200).json({ + statusCode: STATUS_CODES.BAD_REQ, + description: "This is currently unsupported.", + }); + } + + const bestScores = (await db["personal-bests"].find( + { + chartID: chart.chartID, + }, + { + sort: { + "scoreData.perecent": -1, + }, + limit: n, + } + )) as PBScoreDocument<"usc:Single">[]; + + const serverScores = await Promise.all(bestScores.map(TachiScoreToServerScore)); + + return res.status(200).json({ + statusCode: STATUS_CODES.SUCCESS, + description: `Returned ${serverScores.length} scores.`, + body: serverScores, + }); +}); + +const PR_USCIRChartDoc = { + chartHash: "string", + artist: "string", + title: "string", + level: p.isBoundedInteger(1, 20), + difficulty: p.isBoundedInteger(0, 3), + effector: "string", + illustrator: "string", + bpm: "string", +}; + +/** + * Sends a score to the server. + * https://uscir.readthedocs.io/en/latest/endpoints/score-submit.html + * @name POST /ir/usc/:playtype/scores + */ +router.post("/scores", RequirePermissions("submit_score"), async (req, res) => { + const chartErr = p( + req.body.chart, + PR_USCIRChartDoc, + {}, + { + throwOnNonObject: false, + allowExcessKeys: true, + } + ); + + if (chartErr) { + return res.status(200).json({ + statusCode: STATUS_CODES.BAD_REQ, + description: FormatPrError(chartErr, "Invalid chart."), + }); + } + + const uscChart = req.body.chart as USCClientChart; + + let chartDoc = (await db.charts.usc.findOne({ + "data.hashSHA1": uscChart.chartHash, + })) as ChartDocument<"usc:Single"> | null; + + // If the chart doesn't exist, call HandleOrphanQueue. + // If this chart has never been seen before, orphan it. + // If this chart is already orphaned, increase its unique player + // playcount. + if (!chartDoc) { + const { song, chart } = ConvertUSCChart(uscChart); + + const uscChartName = `${uscChart.artist} - ${uscChart.title} (${USCChartIndexToDiff( + uscChart.difficulty + )})`; + + chartDoc = await HandleOrphanQueue( + "usc:Single", + "usc", + chart, + song, + { + "chartDoc.data.hashSHA1": uscChart.chartHash, + }, + ServerConfig.USC_QUEUE_SIZE, + req[SYMBOL_TachiAPIAuth].userID!, + uscChartName + ); + + if (chartDoc) { + const blacklist = await GetBlacklist(); + const scoresToDeorphan = await db["orphan-scores"].find({ + "context.chartHash": chartDoc.data.hashSHA1, + }); + + await Promise.all( + scoresToDeorphan.map((score) => ReprocessOrphan(score, blacklist, logger)) + ); + } + } + + const userID = req[SYMBOL_TachiAPIAuth]!.userID!; + + const importRes = await ExpressWrappedScoreImportMain(userID, false, "ir/usc", [ + req.body, + uscChart.chartHash, + ]); + + // If this was an orphan chart request, return ACCEPTED, + // since it may be unorphaned in the future + if (!chartDoc) { + return res.status(200).json({ + statusCode: STATUS_CODES.ACCEPTED, + description: + "This score has been accepted, but is waiting for more players before its parent chart is accepted.", + }); + } + + if (importRes.statusCode === 500) { + return res.status(200).json({ + statusCode: STATUS_CODES.SERVER_ERROR, + description: importRes.body.description, + }); + } else if (importRes.statusCode !== 200) { + return res.status(200).json({ + statusCode: STATUS_CODES.BAD_REQ, + description: importRes.body.description, + }); + } + + const importDoc = (importRes.body as SuccessfulAPIResponse).body as ImportDocument; + + try { + const body = await CreatePOSTScoresResponseBody(userID, chartDoc, importDoc.scoreIDs[0]); + + return res.status(200).json({ + statusCode: STATUS_CODES.SUCCESS, + description: "Successfully imported score.", + body, + }); + } catch (err) { + return res.status(200).json({ + statusCode: STATUS_CODES.SERVER_ERROR, + description: "An internal server error has occured.", + }); + } +}); + +/** + * Used to submit the replay for a given score when requested by the server. + * https://uscir.readthedocs.io/en/latest/endpoints/replay-submit.html + * @name POST /ir/usc/:playtype/replays + */ +router.post( + "/replays", + RequirePermissions("submit_score"), + CreateMulterSingleUploadMiddleware("replay", ONE_MEGABYTE, logger, false), + async (req, res) => { + if (typeof req.body.identifier !== "string") { + return res.status(200).json({ + statusCode: STATUS_CODES.BAD_REQ, + description: "No Identifier Provided.", + }); + } + + if (!req.file) { + return res.status(200).json({ + statusCode: STATUS_CODES.BAD_REQ, + description: "No File Provided.", + }); + } + + const correspondingScore = await db.scores.findOne({ + userID: req[SYMBOL_TachiAPIAuth]!.userID!, + game: "usc", + scoreID: req.body.identifier, + }); + + if (!correspondingScore) { + return res.status(200).json({ + statusCode: STATUS_CODES.NOT_FOUND, + description: "No score corresponds to this identifier.", + }); + } + + try { + await CDNStoreOrOverwrite( + GetUSCIRReplayURL(correspondingScore.scoreID), + req.file.buffer + ); + + return res.status(200).json({ + statusCode: STATUS_CODES.SUCCESS, + description: "Saved replay.", + body: null, + }); + } catch (err) { + // impossible to test pretty much. + /* istanbul ignore next */ + logger.error(`USCIR Replay Store error.`, { err }); + /* istanbul ignore next */ + return res.status(200).json({ + statusCode: STATUS_CODES.SERVER_ERROR, + description: "An error has occured in storing the replay.", + }); + } + } +); + +export default router; diff --git a/server/src/server/router/ir/usc/types.ts b/server/src/server/router/ir/usc/_playtype/types.ts similarity index 100% rename from server/src/server/router/ir/usc/types.ts rename to server/src/server/router/ir/usc/_playtype/types.ts diff --git a/server/src/server/router/ir/usc/usc.test.ts b/server/src/server/router/ir/usc/_playtype/usc.test.ts similarity index 100% rename from server/src/server/router/ir/usc/usc.test.ts rename to server/src/server/router/ir/usc/_playtype/usc.test.ts diff --git a/server/src/server/router/ir/usc/usc.ts b/server/src/server/router/ir/usc/_playtype/usc.ts similarity index 100% rename from server/src/server/router/ir/usc/usc.ts rename to server/src/server/router/ir/usc/_playtype/usc.ts diff --git a/server/src/server/router/ir/usc/router.ts b/server/src/server/router/ir/usc/router.ts index 866406aa3..6d85a0198 100644 --- a/server/src/server/router/ir/usc/router.ts +++ b/server/src/server/router/ir/usc/router.ts @@ -1,429 +1,19 @@ -import { RequestHandler, Router } from "express"; -import db from "external/mongo/db"; -import { CDNStoreOrOverwrite } from "lib/cdn/cdn"; -import { GetUSCIRReplayURL } from "lib/cdn/url-format"; -import { ONE_MEGABYTE } from "lib/constants/filesize"; -import { SYMBOL_TachiAPIAuth, SYMBOL_TachiData } from "lib/constants/tachi"; -import { USCIR_MAX_LEADERBOARD_N } from "lib/constants/usc-ir"; -import CreateLogCtx from "lib/logger/logger"; -import { HandleOrphanQueue } from "lib/orphan-queue/orphan-queue"; -import { AssertStrAsPositiveNonZeroInt } from "lib/score-import/framework/common/string-asserts"; -import { ExpressWrappedScoreImportMain } from "lib/score-import/framework/express-wrapper"; -import { ReprocessOrphan } from "lib/score-import/framework/orphans/orphans"; -import { ServerConfig, TachiConfig } from "lib/setup/config"; -import p from "prudence"; -import { RequirePermissions } from "server/middleware/auth"; -import { CreateMulterSingleUploadMiddleware } from "server/middleware/multer-upload"; -import { - ChartDocument, - ImportDocument, - PBScoreDocument, - SuccessfulAPIResponse, -} from "tachi-common"; -import { FormatPrError } from "utils/prudence"; -import { GetBlacklist } from "utils/queries/blacklist"; -import { AssignToReqTachiData } from "utils/req-tachi-data"; -import { USCClientChart } from "./types"; -import { - ConvertUSCChart, - CreatePOSTScoresResponseBody, - TachiScoreToServerScore, - USCChartIndexToDiff, -} from "./usc"; - -const logger = CreateLogCtx(__filename); +import { Router } from "express"; +import uscirRouter from "./_playtype/router"; const router: Router = Router({ mergeParams: true }); -enum STATUS_CODES { - UNAUTH = 41, - CHART_REFUSE = 42, - FORBIDDEN = 43, - NOT_FOUND = 44, - SERVER_ERROR = 50, - SUCCESS = 20, - ACCEPTED = 22, - BAD_REQ = 40, -} - -const ValidateUSCRequest: RequestHandler = async (req, res, next) => { - const token = req.header("Authorization"); - - if (!token) { - return res.status(200).json({ - statusCode: STATUS_CODES.BAD_REQ, - description: "No auth token provided.", +router.use((req, res, next) => { + if (req.params.playtype !== "Keyboard" && req.params.playtype !== "Controller") { + return res.status(400).json({ + success: false, + description: "Invalid playtype. Expected Keyboard or Controller.", }); } - const splitToken = token.split(" "); - - if (splitToken.length !== 2 || splitToken[0] !== "Bearer") { - return res.status(200).json({ - statusCode: STATUS_CODES.BAD_REQ, - description: "Invalid Authorization Header. Expected Bearer ", - }); - } - - const uscAuthDoc = await db["api-tokens"].findOne({ - token: splitToken[1], - }); - - if (!uscAuthDoc) { - return res.status(200).json({ - statusCode: STATUS_CODES.UNAUTH, - description: "Unauthorized.", - }); - } - - req[SYMBOL_TachiAPIAuth] = uscAuthDoc; - return next(); -}; - -router.use(ValidateUSCRequest); -// This is an implementation of the USCIR spec as per https://uscir.readthedocs.io. -// This specification always returns 200 OK, regardless of whether the result was okay -// as the HTTP code is used to determine whether the server received the request properly, -// rather than the result of the request. - -/** - * Used to check your connection to the server, and receive some basic information. - * https://uscir.readthedocs.io/en/latest/endpoints/heartbeat.html - * @name GET /ir/usc - */ -router.get("/", (req, res) => - res.status(200).json({ - statusCode: STATUS_CODES.SUCCESS, - description: "IR Request Successful.", - body: { - serverTime: Math.floor(Date.now() / 1000), - serverName: TachiConfig.NAME, - irVersion: "0.3.1-a", - }, - }) -); - -const RetrieveChart: RequestHandler = async (req, res, next) => { - const chart = await db.charts.usc.findOne({ "data.hashSHA1": req.params.chartHash }); - - AssignToReqTachiData(req, { - uscChartDoc: (chart ?? undefined) as ChartDocument<"usc:Single"> | undefined, - }); - - return next(); -}; - -/** - * Used to check if the server will accept a score for a given chart in advance of submitting it. - * https://uscir.readthedocs.io/en/latest/endpoints/chart-charthash.html - * @name GET /ir/usc/charts/:chartHash - */ -router.get("/charts/:chartHash", RetrieveChart, (req, res) => { - const chart = req[SYMBOL_TachiData]!.uscChartDoc; - - if (!chart) { - return res.status(200).json({ - statusCode: STATUS_CODES.NOT_FOUND, - description: "This chart is not available on the IR yet, more people need to play it!", - }); - } - - return res.status(200).json({ - statusCode: STATUS_CODES.SUCCESS, - description: "This chart is tracked by the IR.", - }); }); -/** - * Used to retrieve the current server record for the chart with the specified hash. - * https://uscir.readthedocs.io/en/latest/endpoints/record.html - * @name GET /ir/usc/charts/:chartHash/record - */ -router.get("/charts/:chartHash/record", RetrieveChart, async (req, res) => { - const chart = req[SYMBOL_TachiData]!.uscChartDoc; - - if (!chart) { - return res.status(200).json({ - statusCode: STATUS_CODES.NOT_FOUND, - description: `This IR doesn't have any record data yet, or ${ - ServerConfig.USC_QUEUE_SIZE - } ${ - ServerConfig.USC_QUEUE_SIZE === 1 ? "person has" : "people have" - } not played the chart yet.`, - }); - } - - const serverRecord = (await db["personal-bests"].findOne({ - chartID: chart.chartID, - "rankingData.rank": 1, - })) as PBScoreDocument<"usc:Single"> | null; - - if (!serverRecord) { - return res.status(200).json({ - statusCode: STATUS_CODES.NOT_FOUND, - description: "No server record found.", - }); - } - - const serverScore = await TachiScoreToServerScore(serverRecord); - - return res.status(200).json({ - statusCode: STATUS_CODES.SUCCESS, - description: "Retrieved score.", - body: { record: serverScore }, - }); -}); - -/** - * Used to retrieve some particular useful subset of the scores from the server. - * https://uscir.readthedocs.io/en/latest/endpoints/leaderboard.html - * @name GET /ir/usc/charts/:chartHash/leaderboard - */ -router.get("/charts/:chartHash/leaderboard", RetrieveChart, async (req, res) => { - const chart = req[SYMBOL_TachiData]!.uscChartDoc!; - - if (!(typeof req.query.mode === "string" && ["best", "rivals"].includes(req.query.mode))) { - return res.status(200).json({ - statusCode: STATUS_CODES.BAD_REQ, - description: `Invalid 'mode' param - expected 'best' or 'rivals'.`, - }); - } - - if (typeof req.query.n !== "string") { - return res.status(200).json({ - statusCode: STATUS_CODES.BAD_REQ, - description: `Invalid 'n' param - expected a positive non-zero integer less than or equal to ${USCIR_MAX_LEADERBOARD_N}.`, - }); - } - - let n; - - try { - n = AssertStrAsPositiveNonZeroInt(req.query.n, "Invalid 'N' param."); - } catch (err) { - return res.status(200).json({ - statusCode: STATUS_CODES.BAD_REQ, - description: `Invalid 'n' param - expected a positive non-zero integer less than or equal to ${USCIR_MAX_LEADERBOARD_N}.`, - }); - } - - if (n >= USCIR_MAX_LEADERBOARD_N) { - n = USCIR_MAX_LEADERBOARD_N; - } - - const mode = req.query.mode as "best" | "rivals"; - - if (mode === "rivals") { - return res.status(200).json({ - statusCode: STATUS_CODES.BAD_REQ, - description: "This is currently unsupported.", - }); - } - - const bestScores = (await db["personal-bests"].find( - { - chartID: chart.chartID, - }, - { - sort: { - "scoreData.perecent": -1, - }, - limit: n, - } - )) as PBScoreDocument<"usc:Single">[]; - - const serverScores = await Promise.all(bestScores.map(TachiScoreToServerScore)); - - return res.status(200).json({ - statusCode: STATUS_CODES.SUCCESS, - description: `Returned ${serverScores.length} scores.`, - body: serverScores, - }); -}); - -const PR_USCIRChartDoc = { - chartHash: "string", - artist: "string", - title: "string", - level: p.isBoundedInteger(1, 20), - difficulty: p.isBoundedInteger(0, 3), - effector: "string", - illustrator: "string", - bpm: "string", -}; - -/** - * Sends a score to the server. - * https://uscir.readthedocs.io/en/latest/endpoints/score-submit.html - * @name POST /ir/usc/scores - */ -router.post("/scores", RequirePermissions("submit_score"), async (req, res) => { - const chartErr = p( - req.body.chart, - PR_USCIRChartDoc, - {}, - { - throwOnNonObject: false, - allowExcessKeys: true, - } - ); - - if (chartErr) { - return res.status(200).json({ - statusCode: STATUS_CODES.BAD_REQ, - description: FormatPrError(chartErr, "Invalid chart."), - }); - } - - const uscChart = req.body.chart as USCClientChart; - - let chartDoc = (await db.charts.usc.findOne({ - "data.hashSHA1": uscChart.chartHash, - })) as ChartDocument<"usc:Single"> | null; - - // If the chart doesn't exist, call HandleOrphanQueue. - // If this chart has never been seen before, orphan it. - // If this chart is already orphaned, increase its unique player - // playcount. - if (!chartDoc) { - const { song, chart } = ConvertUSCChart(uscChart); - - const uscChartName = `${uscChart.artist} - ${uscChart.title} (${USCChartIndexToDiff( - uscChart.difficulty - )})`; - - chartDoc = await HandleOrphanQueue( - "usc:Single", - "usc", - chart, - song, - { - "chartDoc.data.hashSHA1": uscChart.chartHash, - }, - ServerConfig.USC_QUEUE_SIZE, - req[SYMBOL_TachiAPIAuth].userID!, - uscChartName - ); - - if (chartDoc) { - const blacklist = await GetBlacklist(); - const scoresToDeorphan = await db["orphan-scores"].find({ - "context.chartHash": chartDoc.data.hashSHA1, - }); - - await Promise.all( - scoresToDeorphan.map((score) => ReprocessOrphan(score, blacklist, logger)) - ); - } - } - - const userID = req[SYMBOL_TachiAPIAuth]!.userID!; - - const importRes = await ExpressWrappedScoreImportMain(userID, false, "ir/usc", [ - req.body, - uscChart.chartHash, - ]); - - // If this was an orphan chart request, return ACCEPTED, - // since it may be unorphaned in the future - if (!chartDoc) { - return res.status(200).json({ - statusCode: STATUS_CODES.ACCEPTED, - description: - "This score has been accepted, but is waiting for more players before its parent chart is accepted.", - }); - } - - if (importRes.statusCode === 500) { - return res.status(200).json({ - statusCode: STATUS_CODES.SERVER_ERROR, - description: importRes.body.description, - }); - } else if (importRes.statusCode !== 200) { - return res.status(200).json({ - statusCode: STATUS_CODES.BAD_REQ, - description: importRes.body.description, - }); - } - - const importDoc = (importRes.body as SuccessfulAPIResponse).body as ImportDocument; - - try { - const body = await CreatePOSTScoresResponseBody(userID, chartDoc, importDoc.scoreIDs[0]); - - return res.status(200).json({ - statusCode: STATUS_CODES.SUCCESS, - description: "Successfully imported score.", - body, - }); - } catch (err) { - return res.status(200).json({ - statusCode: STATUS_CODES.SERVER_ERROR, - description: "An internal server error has occured.", - }); - } -}); - -/** - * Used to submit the replay for a given score when requested by the server. - * https://uscir.readthedocs.io/en/latest/endpoints/replay-submit.html - * @name POST /ir/usc/replays - */ -router.post( - "/replays", - RequirePermissions("submit_score"), - CreateMulterSingleUploadMiddleware("replay", ONE_MEGABYTE, logger, false), - async (req, res) => { - if (typeof req.body.identifier !== "string") { - return res.status(200).json({ - statusCode: STATUS_CODES.BAD_REQ, - description: "No Identifier Provided.", - }); - } - - if (!req.file) { - return res.status(200).json({ - statusCode: STATUS_CODES.BAD_REQ, - description: "No File Provided.", - }); - } - - const correspondingScore = await db.scores.findOne({ - userID: req[SYMBOL_TachiAPIAuth]!.userID!, - game: "usc", - scoreID: req.body.identifier, - }); - - if (!correspondingScore) { - return res.status(200).json({ - statusCode: STATUS_CODES.NOT_FOUND, - description: "No score corresponds to this identifier.", - }); - } - - try { - await CDNStoreOrOverwrite( - GetUSCIRReplayURL(correspondingScore.scoreID), - req.file.buffer - ); - - return res.status(200).json({ - statusCode: STATUS_CODES.SUCCESS, - description: "Saved replay.", - body: null, - }); - } catch (err) { - // impossible to test pretty much. - /* istanbul ignore next */ - logger.error(`USCIR Replay Store error.`, { err }); - /* istanbul ignore next */ - return res.status(200).json({ - statusCode: STATUS_CODES.SERVER_ERROR, - description: "An error has occured in storing the replay.", - }); - } - } -); +router.use("/:playtype", uscirRouter); export default router; From 4d16d24da6b2b52f0afe9d8ed5c365c76e5e6c54 Mon Sep 17 00:00:00 2001 From: zkldi <20380519+zkldi@users.noreply.github.com> Date: Sat, 13 Nov 2021 07:59:40 +0000 Subject: [PATCH 2/5] Bump tachi-common to 0.3.0 --- server/package.json | 2 +- server/pnpm-lock.yaml | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/server/package.json b/server/package.json index 34602e3de..bdc15f4e2 100644 --- a/server/package.json +++ b/server/package.json @@ -82,7 +82,7 @@ "rimraf": "3.0.2", "safe-json-stringify": "1.2.0", "seq-logging": "^1.1.1", - "tachi-common": "0.2.46", + "tachi-common": "0.3.0", "ts-node": "10.0.0", "typescript": "4.3.4", "winston": "3.3.3", diff --git a/server/pnpm-lock.yaml b/server/pnpm-lock.yaml index fcabafe06..e3ced08c8 100644 --- a/server/pnpm-lock.yaml +++ b/server/pnpm-lock.yaml @@ -63,7 +63,7 @@ specifiers: safe-json-stringify: 1.2.0 seq-logging: ^1.1.1 supertest: 6.1.3 - tachi-common: 0.2.46 + tachi-common: 0.3.0 tap: 15.0.9 ts-node: 10.0.0 tsconfig-paths: 3.10.1 @@ -108,7 +108,7 @@ dependencies: rimraf: 3.0.2 safe-json-stringify: 1.2.0 seq-logging: 1.1.1 - tachi-common: 0.2.46 + tachi-common: 0.3.0 ts-node: 10.0.0_83f53b0a0c5616d3fa00ed4e30b9ce1b typescript: 4.3.4 winston: 3.3.3 @@ -4960,8 +4960,8 @@ packages: strip-ansi: 6.0.0 dev: true - /tachi-common/0.2.46: - resolution: {integrity: sha512-BTI3j6joaHLh+T/gapHw5lO9BlB9jSCDtaLIECHkEjY0pUBnXcbcA4GEeMjefXUD65hF04BNkg6J7R6X6Cnkmw==} + /tachi-common/0.3.0: + resolution: {integrity: sha512-ch7Msau97/on1d18bqRMUNUQhF9ZixzvNxMsLJNN14DBfS1AVxaoAfIRiQzWQ1jdfvwmcpP4Ge0x1w1prPZVAQ==} dependencies: monk: 7.3.4 transitivePeerDependencies: From eaad1d1cf2755591696c6fce8a7bbce4ca97fa25 Mon Sep 17 00:00:00 2001 From: zkldi <20380519+zkldi@users.noreply.github.com> Date: Sat, 13 Nov 2021 08:51:34 +0000 Subject: [PATCH 3/5] Completely refactor USC support to use Controller/Keyboard playtypes. --- server/src/external/mongo/schemas.ts | 16 ++-- .../calculated-data/calculated-data.test.ts | 43 ++++++++- .../calculated-data/calculated-data.ts | 10 +- .../framework/sessions/calculated-data.ts | 10 +- .../framework/user-game-stats/rating.test.ts | 18 +++- .../framework/user-game-stats/rating.ts | 5 +- .../score-import/import-types/common/types.ts | 2 +- .../import-types/ir/usc/converter.test.ts | 5 +- .../import-types/ir/usc/converter.ts | 36 +++---- .../import-types/ir/usc/parser.test.ts | 3 +- .../import-types/ir/usc/parser.ts | 5 +- .../score-import/import-types/ir/usc/types.ts | 3 + .../lib/score-import/import-types/parsers.ts | 1 - .../router/ir/usc/_playtype/router.test.ts | 95 ++++++++++++------- .../server/router/ir/usc/_playtype/router.ts | 38 ++++++-- .../router/ir/usc/_playtype/usc.test.ts | 10 +- .../src/server/router/ir/usc/_playtype/usc.ts | 45 +++++---- server/src/server/router/ir/usc/router.ts | 11 --- server/src/test-utils/mock-db/charts-usc.json | 2 +- server/src/test-utils/test-data.ts | 6 +- server/src/utils/queries/pbs.ts | 4 +- server/src/utils/types.ts | 2 +- 22 files changed, 243 insertions(+), 127 deletions(-) diff --git a/server/src/external/mongo/schemas.ts b/server/src/external/mongo/schemas.ts index 031618551..1569d0f14 100644 --- a/server/src/external/mongo/schemas.ts +++ b/server/src/external/mongo/schemas.ts @@ -831,12 +831,16 @@ export const DatabaseSchemas: Record = { arcChartID: "?string", }) ), - "charts-usc": prSchemaify( - PR_ChartDocument("usc", "Single", { - hashSHA1: p.or("string", ["string"]), - isOfficial: "boolean", - }) - ), + "charts-usc": (self) => { + const playtype = getPlaytype("usc", self); + + return prSchemaify( + PR_ChartDocument("usc", playtype, { + hashSHA1: p.or("string", ["string"]), + isOfficial: "boolean", + }) + )(self); + }, goals: prSchemaify({ game: p.isIn(games), playtype: isValidPlaytype, diff --git a/server/src/lib/score-import/framework/calculated-data/calculated-data.test.ts b/server/src/lib/score-import/framework/calculated-data/calculated-data.test.ts index 9cdfb4ca0..63ab1c96f 100644 --- a/server/src/lib/score-import/framework/calculated-data/calculated-data.test.ts +++ b/server/src/lib/score-import/framework/calculated-data/calculated-data.test.ts @@ -43,14 +43,27 @@ t.test("#CreateCalculatedData", async (t) => { ); const uscRes = await CreateCalculatedData( - { game: "usc", playtype: "Single" } as ScoreDocument, - { data: { isOfficial: false }, playtype: "Single" } as ChartDocument, + { game: "usc", playtype: "Controller" } as ScoreDocument, + { data: { isOfficial: false }, playtype: "Controller" } as ChartDocument, null, logger ); t.strictSame(uscRes, { VF6: null }, "Should return null if chart was not an official."); + const uscKbRes = await CreateCalculatedData( + { game: "usc", playtype: "Keyboard" } as ScoreDocument, + { data: { isOfficial: false }, playtype: "Keyboard" } as ChartDocument, + null, + logger + ); + + t.strictSame( + uscKbRes, + { VF6: null }, + "Should return null if chart was not an official (Keyboard)." + ); + t.end(); }); @@ -318,10 +331,10 @@ t.test("#CalculateDataForGamePT", (t) => { t.end(); }); - t.test("usc:Single", async (t) => { + t.test("usc:Controller", async (t) => { const res = await CalculateDataForGamePT( "usc", - "Single", + "Controller", Testing511SPA, TestingIIDXSPDryScore, // fake! this is an iidx score. but we're testing null, @@ -333,12 +346,32 @@ t.test("#CalculateDataForGamePT", (t) => { { VF6: "?number", }, - "Response should contain nulled keys for usc:Single" + "Response should contain nulled keys for usc:Controller" ); t.end(); }); + t.test("usc:Keyboard", async (t) => { + const res = await CalculateDataForGamePT( + "usc", + "Keyboard", + Testing511SPA, + TestingIIDXSPDryScore, // fake! this is an iidx score. but we're testing + null, + logger + ); + + prAssert( + res, + { + VF6: "?number", + }, + "Response should contain nulled keys for usc:Keyboard" + ); + + t.end(); + }); // t.test("jubeat:Single", async (t) => { // const res = await CalculateDataForGamePT( // "jubeat", diff --git a/server/src/lib/score-import/framework/calculated-data/calculated-data.ts b/server/src/lib/score-import/framework/calculated-data/calculated-data.ts index 72c66c146..6e1e24072 100644 --- a/server/src/lib/score-import/framework/calculated-data/calculated-data.ts +++ b/server/src/lib/score-import/framework/calculated-data/calculated-data.ts @@ -88,7 +88,8 @@ const CalculatedDataFunctions: CalculatedDataFunctions = { // Single: CalculateDataJubeat, // }, usc: { - Single: CalculateDataSDVXorUSC, + Controller: CalculateDataSDVXorUSC, + Keyboard: CalculateDataSDVXorUSC, }, }; @@ -187,9 +188,12 @@ function CalculateDataSDVXorUSC( dryScore: DryScore, chart: ChartDocument, logger: KtLogger -): CalculatedData<"sdvx:Single" | "usc:Single"> { +): CalculatedData<"sdvx:Single" | "usc:Keyboard" | "usc:Controller"> { // for usc, unofficial charts currently have no VF6 value. - if (dryScore.game === "usc" && !(chart as ChartDocument<"usc:Single">).data.isOfficial) { + if ( + dryScore.game === "usc" && + !(chart as ChartDocument<"usc:Controller" | "usc:Keyboard">).data.isOfficial + ) { return { VF6: null }; } diff --git a/server/src/lib/score-import/framework/sessions/calculated-data.ts b/server/src/lib/score-import/framework/sessions/calculated-data.ts index 77608acd7..0868984a3 100644 --- a/server/src/lib/score-import/framework/sessions/calculated-data.ts +++ b/server/src/lib/score-import/framework/sessions/calculated-data.ts @@ -112,7 +112,15 @@ const CalculatedDataFunctions: CalculatedDataFunctions = { // }), // }, usc: { - Single: (scd) => { + Controller: (scd) => { + const VF6 = AvgBest10Map(scd, "VF6"); + + return { + VF6, + ProfileVF6: VF6 === null ? null : VF6 * 50, + }; + }, + Keyboard: (scd) => { const VF6 = AvgBest10Map(scd, "VF6"); return { diff --git a/server/src/lib/score-import/framework/user-game-stats/rating.test.ts b/server/src/lib/score-import/framework/user-game-stats/rating.test.ts index 14076517c..d1ab7b7d3 100644 --- a/server/src/lib/score-import/framework/user-game-stats/rating.test.ts +++ b/server/src/lib/score-import/framework/user-game-stats/rating.test.ts @@ -28,18 +28,26 @@ t.test("#CalculateRatings", (t) => { t.end(); }); - t.test("Should return VF4 and VF5 for SDVX", async (t) => { + t.test("Should return VF6 for SDVX", async (t) => { const res = await CalculateRatings("sdvx", "Single", 1, logger); - t.strictSame(res, { VF6: 0 }, "Should return VF4 and VF5 keys."); + t.strictSame(res, { VF6: 0 }, "Should return VF6 keys."); t.end(); }); - t.test("Should return VF4 and VF5 for USC", async (t) => { - const res = await CalculateRatings("usc", "Single", 1, logger); + t.test("Should return VF6 for USC:Keyboard", async (t) => { + const res = await CalculateRatings("usc", "Keyboard", 1, logger); - t.strictSame(res, { VF6: 0 }, "Should return VF4 and VF5 keys."); + t.strictSame(res, { VF6: 0 }, "Should return VF6 keys."); + + t.end(); + }); + + t.test("Should return VF6 for USC:Controller", async (t) => { + const res = await CalculateRatings("usc", "Controller", 1, logger); + + t.strictSame(res, { VF6: 0 }, "Should return VF6 keys."); t.end(); }); diff --git a/server/src/lib/score-import/framework/user-game-stats/rating.ts b/server/src/lib/score-import/framework/user-game-stats/rating.ts index 362b9a3eb..69bf6963f 100644 --- a/server/src/lib/score-import/framework/user-game-stats/rating.ts +++ b/server/src/lib/score-import/framework/user-game-stats/rating.ts @@ -87,7 +87,10 @@ const RatingFunctions: RatingFunctions = { }), }, usc: { - Single: async (g, p, u) => ({ + Keyboard: async (g, p, u) => ({ + VF6: await LazySumN("VF6", 50)(g, p, u), + }), + Controller: async (g, p, u) => ({ VF6: await LazySumN("VF6", 50)(g, p, u), }), }, 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 6cf8b4d87..dcb178c7b 100644 --- a/server/src/lib/score-import/import-types/common/types.ts +++ b/server/src/lib/score-import/import-types/common/types.ts @@ -20,10 +20,10 @@ import { import { ConverterFailure } from "../../framework/common/converter-failures"; import { DryScore } from "../../framework/common/types"; import { BeatorajaContext, BeatorajaScore } from "../ir/beatoraja/types"; -import { USCClientScore } from "server/router/ir/usc/types"; import { IRUSCContext } from "../ir/usc/types"; import { ClassHandler } from "../../framework/user-game-stats/types"; import { KsHookSV3CScore } from "../ir/kshook-sv3c/types"; +import { USCClientScore } from "server/router/ir/usc/_playtype/types"; export interface ImportTypeDataMap { "file/eamusement-iidx-csv": IIDXEamusementCSVData; diff --git a/server/src/lib/score-import/import-types/ir/usc/converter.test.ts b/server/src/lib/score-import/import-types/ir/usc/converter.test.ts index 6e5921b55..4465a6653 100644 --- a/server/src/lib/score-import/import-types/ir/usc/converter.test.ts +++ b/server/src/lib/score-import/import-types/ir/usc/converter.test.ts @@ -5,8 +5,7 @@ import d from "deepmerge"; import { uscChart, uscScore } from "test-utils/test-data"; import CreateLogCtx from "lib/logger/logger"; import ResetDBState from "test-utils/resets"; - -import { USCClientScore } from "server/router/ir/usc/types"; +import { USCClientScore } from "server/router/ir/usc/_playtype/types"; const logger = CreateLogCtx(__filename); @@ -78,7 +77,7 @@ t.test("#DeriveNoteMod", (t) => { const dm = (p: Partial) => ConverterIRUSC( d(uscScore, p), - { chartHash: uscChart.data.hashSHA1 as string }, + { chartHash: uscChart.data.hashSHA1 as string, playtype: "Controller" }, "ir/usc", logger ); diff --git a/server/src/lib/score-import/import-types/ir/usc/converter.ts b/server/src/lib/score-import/import-types/ir/usc/converter.ts index 0da32dad0..bab72ed73 100644 --- a/server/src/lib/score-import/import-types/ir/usc/converter.ts +++ b/server/src/lib/score-import/import-types/ir/usc/converter.ts @@ -1,16 +1,4 @@ -import { USCClientScore } from "server/router/ir/usc/types"; -import { FindSongOnID } from "utils/queries/songs"; -import { KtLogger } from "lib/logger/logger"; -import { - InternalFailure, - InvalidScoreFailure, - KTDataNotFoundFailure, -} from "../../../framework/common/converter-failures"; -import { GenericGetGradeAndPercent } from "../../../framework/common/score-utils"; -import { IRUSCContext } from "./types"; -import { Lamps } from "tachi-common"; -import { ConverterFunction } from "../../common/types"; -import { DryScore } from "../../../framework/common/types"; +import db from "external/mongo/db"; import { USC_DEFAULT_HOLD, USC_DEFAULT_MISS, @@ -18,7 +6,19 @@ import { USC_DEFAULT_PERFECT, USC_DEFAULT_SLAM, } from "lib/constants/usc-ir"; -import db from "external/mongo/db"; +import { KtLogger } from "lib/logger/logger"; +import { USCClientScore } from "server/router/ir/usc/_playtype/types"; +import { Lamps } from "tachi-common"; +import { FindSongOnID } from "utils/queries/songs"; +import { + InternalFailure, + InvalidScoreFailure, + KTDataNotFoundFailure, +} from "../../../framework/common/converter-failures"; +import { GenericGetGradeAndPercent } from "../../../framework/common/score-utils"; +import { DryScore } from "../../../framework/common/types"; +import { ConverterFunction } from "../../common/types"; +import { IRUSCContext } from "./types"; /** * Interprets the "note mod" used based on the USC score. @@ -38,7 +38,10 @@ export function DeriveNoteMod(data: USCClientScore): "NORMAL" | "MIRROR" | "RAND /** * Determines the lamp of a USC score. */ -export function DeriveLamp(scoreDoc: USCClientScore, logger: KtLogger): Lamps["usc:Single"] { +export function DeriveLamp( + scoreDoc: USCClientScore, + logger: KtLogger +): Lamps["usc:Keyboard" | "usc:Controller"] { if (scoreDoc.score === 10_000_000) { return "PERFECT ULTIMATE CHAIN"; } else if (scoreDoc.error === 0) { @@ -78,6 +81,7 @@ export const ConverterIRUSC: ConverterFunction = a const chartDoc = await db.charts.usc.findOne({ "data.hashSHA1": context.chartHash, + playtype: context.playtype, }); if (!chartDoc) { @@ -98,7 +102,7 @@ export const ConverterIRUSC: ConverterFunction = a const { grade, percent } = GenericGetGradeAndPercent("usc", data.score, chartDoc); - const dryScore: DryScore<"usc:Single"> = { + const dryScore: DryScore<"usc:Controller" | "usc:Keyboard"> = { comment: null, game: "usc", importType, diff --git a/server/src/lib/score-import/import-types/ir/usc/parser.test.ts b/server/src/lib/score-import/import-types/ir/usc/parser.test.ts index 4ca67475e..0d5f99485 100644 --- a/server/src/lib/score-import/import-types/ir/usc/parser.test.ts +++ b/server/src/lib/score-import/import-types/ir/usc/parser.test.ts @@ -10,6 +10,7 @@ t.test("#ParseIRUSC", (t) => { const res = ParseIRUSC( { score: uscScore } as unknown as Record, uscChart.data.hashSHA1 as string, + "Controller", logger ); @@ -25,7 +26,7 @@ t.test("#ParseIRUSC", (t) => { }); t.test("Should reject empty bodies", (t) => { - t.throws(() => ParseIRUSC({}, uscChart.data.hashSHA1 as string, logger), { + t.throws(() => ParseIRUSC({}, uscChart.data.hashSHA1 as string, "Controller", logger), { statusCode: 400, message: /invalid usc score/iu, }); diff --git a/server/src/lib/score-import/import-types/ir/usc/parser.ts b/server/src/lib/score-import/import-types/ir/usc/parser.ts index bd3b39e0a..43baa5821 100644 --- a/server/src/lib/score-import/import-types/ir/usc/parser.ts +++ b/server/src/lib/score-import/import-types/ir/usc/parser.ts @@ -1,10 +1,11 @@ import { KtLogger } from "lib/logger/logger"; import p, { PrudenceSchema } from "prudence"; -import { USCClientScore } from "server/router/ir/usc/types"; import { FormatPrError } from "utils/prudence"; import ScoreImportFatalError from "../../../framework/score-importing/score-import-error"; import { ParserFunctionReturns } from "../../common/types"; import { IRUSCContext } from "./types"; +import { Playtypes } from "tachi-common"; +import { USCClientScore } from "server/router/ir/usc/_playtype/types"; const PR_USCIRScore: PrudenceSchema = { score: p.isBoundedInteger(0, 10_000_000), @@ -24,6 +25,7 @@ const PR_USCIRScore: PrudenceSchema = { export function ParseIRUSC( body: Record, chartHash: string, + playtype: Playtypes["usc"], logger: KtLogger ): ParserFunctionReturns { const err = p( @@ -40,6 +42,7 @@ export function ParseIRUSC( return { context: { chartHash, + playtype, }, game: "usc", iterable: [body.score] as USCClientScore[], diff --git a/server/src/lib/score-import/import-types/ir/usc/types.ts b/server/src/lib/score-import/import-types/ir/usc/types.ts index 16e168d0c..badada65d 100644 --- a/server/src/lib/score-import/import-types/ir/usc/types.ts +++ b/server/src/lib/score-import/import-types/ir/usc/types.ts @@ -1,3 +1,6 @@ +import { Playtypes } from "tachi-common"; + export interface IRUSCContext { chartHash: string; + playtype: Playtypes["usc"]; } diff --git a/server/src/lib/score-import/import-types/parsers.ts b/server/src/lib/score-import/import-types/parsers.ts index bcd3bb61d..d5db685c3 100644 --- a/server/src/lib/score-import/import-types/parsers.ts +++ b/server/src/lib/score-import/import-types/parsers.ts @@ -5,7 +5,6 @@ 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 { ParseBatchManualFromObject } from "./common/batch-manual/parser"; import ParseBatchManual from "./file/batch-manual/parser"; import ParseEamusementIIDXCSV from "./file/eamusement-iidx-csv/parser"; import { ParseMerIIDX } from "./file/mer-iidx/parser"; diff --git a/server/src/server/router/ir/usc/_playtype/router.test.ts b/server/src/server/router/ir/usc/_playtype/router.test.ts index e66cb764b..9142e5ae3 100644 --- a/server/src/server/router/ir/usc/_playtype/router.test.ts +++ b/server/src/server/router/ir/usc/_playtype/router.test.ts @@ -39,22 +39,23 @@ function TestAuth(url: string) { }); } -t.test("GET /ir/usc", async (t) => { +// Due to how this works, as long as these tests pass, the two IRs work identically. +t.test("GET /ir/usc/Keyboard", async (t) => { + t.beforeEach(ResetDBState); + await db["api-tokens"].insert({ userID: 1, identifier: "USC Token", permissions: { submit_score: true, }, - token: "foo", + token: "bar", fromAPIClient: null, }); - t.beforeEach(ResetDBState); + TestAuth("/ir/usc/Controller"); - TestAuth("/ir/usc"); - - const res = await mockApi.get("/ir/usc").set("Authorization", "Bearer foo"); + const res = await mockApi.get("/ir/usc/Controller").set("Authorization", "Bearer bar"); t.equal(res.body.statusCode, 20, "Should return 20"); t.match( @@ -69,13 +70,43 @@ t.test("GET /ir/usc", async (t) => { t.end(); }); -t.test("GET /ir/usc/charts/:chartHash", (t) => { +t.test("GET /ir/usc/Controller", async (t) => { + await db["api-tokens"].insert({ + userID: 1, + identifier: "USC Token", + permissions: { + submit_score: true, + }, + token: "foo", + fromAPIClient: null, + }); + + t.beforeEach(ResetDBState); + + TestAuth("/ir/usc/Controller"); + + const res = await mockApi.get("/ir/usc/Controller").set("Authorization", "Bearer foo"); + + t.equal(res.body.statusCode, 20, "Should return 20"); + t.match( + res.body.body, + { + serverName: /tachi/iu, + irVersion: /^[0-9]\.[0-9]\.[0-9](-a)?$/iu, + }, + "Should return the right body." + ); + + t.end(); +}); + +t.test("GET /ir/usc/Controller/charts/:chartHash", (t) => { t.beforeEach(ResetDBState); t.beforeEach(InsertFakeUSCAuth); t.test("Should return 20 if the chartHash matches a chart.", async (t) => { const res = await mockApi - .get("/ir/usc/charts/USC_CHART_HASH") + .get("/ir/usc/Controller/charts/USC_CHART_HASH") .set("Authorization", "Bearer foo"); t.equal(res.body.statusCode, 20, "Should return 20"); @@ -85,7 +116,7 @@ t.test("GET /ir/usc/charts/:chartHash", (t) => { t.test("Should return 44 if the chartHash doesn't match a chart.", async (t) => { const res = await mockApi - .get("/ir/usc/charts/INVALID_HASH") + .get("/ir/usc/Controller/charts/INVALID_HASH") .set("Authorization", "Bearer foo"); t.equal(res.body.statusCode, 44, "Should return 44"); @@ -105,7 +136,7 @@ const USC_SCORE_PB: PBScoreDocument = { songID: 1, userID: 1, timeAchieved: 0, - playtype: "Single", + playtype: "Controller", game: "usc", highlight: false, composedFrom: { @@ -135,14 +166,14 @@ const USC_SCORE_PB: PBScoreDocument = { }, }; -t.test("GET /ir/usc/:chartHash/record", (t) => { +t.test("GET /ir/usc/Controller/:chartHash/record", (t) => { t.beforeEach(ResetDBState); t.beforeEach(InsertFakeUSCAuth); - TestAuth("/ir/usc/:chartHash/record"); + TestAuth("/ir/usc/Controller/:chartHash/record"); t.test("Should return 44 if the chartHash doesn't match a chart.", async (t) => { const res = await mockApi - .get("/ir/usc/charts/INVALID_HASH/record") + .get("/ir/usc/Controller/charts/INVALID_HASH/record") .set("Authorization", "Bearer foo"); t.equal(res.body.statusCode, 44, "Should return 44"); @@ -152,7 +183,7 @@ t.test("GET /ir/usc/:chartHash/record", (t) => { t.test("Should return 44 if there are no scores on the chart.", async (t) => { const res = await mockApi - .get("/ir/usc/charts/USC_CHART_HASH/record") + .get("/ir/usc/Controller/charts/USC_CHART_HASH/record") .set("Authorization", "Bearer foo"); t.equal(res.body.statusCode, 44, "Should return 44"); @@ -175,7 +206,7 @@ t.test("GET /ir/usc/:chartHash/record", (t) => { } as ScoreDocument); const res = await mockApi - .get("/ir/usc/charts/USC_CHART_HASH/record") + .get("/ir/usc/Controller/charts/USC_CHART_HASH/record") .set("Authorization", "Bearer foo"); t.equal(res.body.statusCode, 20, "Should return 20"); @@ -206,17 +237,17 @@ t.test("GET /ir/usc/:chartHash/record", (t) => { t.test("GET /charts/:chartHash/leaderboard", (t) => { t.beforeEach(ResetDBState); t.beforeEach(InsertFakeUSCAuth); - TestAuth("/ir/usc/:chartHash/leaderboard"); + TestAuth("/ir/usc/Controller/:chartHash/leaderboard"); t.test("Should return 40 if mode is invalid", async (t) => { const res = await mockApi - .get("/ir/usc/charts/USC_CHART_HASH/leaderboard") + .get("/ir/usc/Controller/charts/USC_CHART_HASH/leaderboard") .set("Authorization", "Bearer foo"); t.equal(res.body.statusCode, 40); const res2 = await mockApi - .get("/ir/usc/charts/USC_CHART_HASH/leaderboard?mode=invalid") + .get("/ir/usc/Controller/charts/USC_CHART_HASH/leaderboard?mode=invalid") .set("Authorization", "Bearer foo"); t.equal(res2.body.statusCode, 40); @@ -226,13 +257,13 @@ t.test("GET /charts/:chartHash/leaderboard", (t) => { t.test("Should return 40 if N is invalid", async (t) => { const res = await mockApi - .get("/ir/usc/charts/USC_CHART_HASH/leaderboard?mode=best") + .get("/ir/usc/Controller/charts/USC_CHART_HASH/leaderboard?mode=best") .set("Authorization", "Bearer foo"); t.equal(res.body.statusCode, 40); const res2 = await mockApi - .get("/ir/usc/charts/USC_CHART_HASH/leaderboard?mode=best&n=foo") + .get("/ir/usc/Controller/charts/USC_CHART_HASH/leaderboard?mode=best&n=foo") .set("Authorization", "Bearer foo"); t.equal(res2.body.statusCode, 40); @@ -242,7 +273,7 @@ t.test("GET /charts/:chartHash/leaderboard", (t) => { t.test("Should return empty arr for mode = best if no scores", async (t) => { const res = await mockApi - .get("/ir/usc/charts/USC_CHART_HASH/leaderboard?mode=best&n=5") + .get("/ir/usc/Controller/charts/USC_CHART_HASH/leaderboard?mode=best&n=5") .set("Authorization", "Bearer foo"); t.equal(res.body.statusCode, 20); @@ -282,7 +313,7 @@ t.test("GET /charts/:chartHash/leaderboard", (t) => { ] as ScoreDocument[]); const res = await mockApi - .get("/ir/usc/charts/USC_CHART_HASH/leaderboard?mode=best&n=2") + .get("/ir/usc/Controller/charts/USC_CHART_HASH/leaderboard?mode=best&n=2") .set("Authorization", "Bearer foo"); t.equal(res.body.statusCode, 20); @@ -318,7 +349,7 @@ t.test("GET /charts/:chartHash/leaderboard", (t) => { ); const res2 = await mockApi - .get("/ir/usc/charts/USC_CHART_HASH/leaderboard?mode=best&n=1") + .get("/ir/usc/Controller/charts/USC_CHART_HASH/leaderboard?mode=best&n=1") .set("Authorization", "Bearer foo"); t.equal(res2.body.statusCode, 20); @@ -362,7 +393,7 @@ t.test("POST /replays", (t) => { const replayFile = GetKTDataBuffer("./usc/replayfile.urf"); const res = await mockApi - .post("/ir/usc/replays") + .post("/ir/usc/Controller/replays") .field("identifier", "MOCK_IDENTIFIER") .attach("replay", replayFile, "replay.urf") .set("Authorization", "Bearer foo"); @@ -392,7 +423,7 @@ t.test("POST /replays", (t) => { const replayFile = GetKTDataBuffer("./usc/replayfile.urf"); const res = await mockApi - .post("/ir/usc/replays") + .post("/ir/usc/Controller/replays") .attach("replay", replayFile, "replay.urf") .set("Authorization", "Bearer foo"); @@ -416,7 +447,7 @@ t.test("POST /replays", (t) => { // const replayFile = GetKTDataBuffer("./usc/replayfile.urf"); const res = await mockApi - .post("/ir/usc/replays") + .post("/ir/usc/Controller/replays") .field("identifier", "MOCK_IDENTIFIER") // .attach("replay", replayFile, "replay.urf") .set("Authorization", "Bearer foo"); @@ -441,7 +472,7 @@ t.test("POST /replays", (t) => { const replayFile = GetKTDataBuffer("./usc/replayfile.urf"); const res = await mockApi - .post("/ir/usc/replays") + .post("/ir/usc/Controller/replays") .field("identifier", "INVALID_IDENTIFIER") .attach("replay", replayFile, "replay.urf") .set("Authorization", "Bearer foo"); @@ -508,7 +539,7 @@ t.test("POST /scores", (t) => { t.test("Should submit a score from a valid request.", async (t) => { const res = await mockApi - .post("/ir/usc/scores") + .post("/ir/usc/Controller/scores") .set("Authorization", "Bearer token") .send(validRequest); @@ -535,7 +566,7 @@ t.test("POST /scores", (t) => { t.test("Should orphan a score and return 22 if chart has never been seen.", async (t) => { const res = await mockApi - .post("/ir/usc/scores") + .post("/ir/usc/Controller/scores") .set("Authorization", "Bearer token") .send( deepmerge(validRequest, { @@ -614,7 +645,7 @@ t.test("POST /scores", (t) => { ] as PublicUserDocument[]); const res = await mockApi - .post("/ir/usc/scores") + .post("/ir/usc/Controller/scores") .set("Authorization", "Bearer token") .send( deepmerge(validRequest, { @@ -627,7 +658,7 @@ t.test("POST /scores", (t) => { t.equal(res.body.statusCode, 22); const res2 = await mockApi - .post("/ir/usc/scores") + .post("/ir/usc/Controller/scores") .set("Authorization", "Bearer token2") .send( deepmerge(validRequest, { @@ -646,7 +677,7 @@ t.test("POST /scores", (t) => { t.strictSame(orphanData?.userIDs, [1, 2]); const res3 = await mockApi - .post("/ir/usc/scores") + .post("/ir/usc/Controller/scores") .set("Authorization", "Bearer token3") .send( deepmerge(validRequest, { diff --git a/server/src/server/router/ir/usc/_playtype/router.ts b/server/src/server/router/ir/usc/_playtype/router.ts index 10a5920c6..7d1c12c35 100644 --- a/server/src/server/router/ir/usc/_playtype/router.ts +++ b/server/src/server/router/ir/usc/_playtype/router.ts @@ -19,6 +19,7 @@ import { ImportDocument, PBScoreDocument, SuccessfulAPIResponse, + Playtypes, } from "tachi-common"; import { FormatPrError } from "utils/prudence"; import { GetBlacklist } from "utils/queries/blacklist"; @@ -30,7 +31,6 @@ import { TachiScoreToServerScore, USCChartIndexToDiff, } from "./usc"; - const logger = CreateLogCtx(__filename); const router: Router = Router({ mergeParams: true }); @@ -81,6 +81,17 @@ const ValidateUSCRequest: RequestHandler = async (req, res, next) => { return next(); }; +router.use((req, res, next) => { + if (req.params.playtype !== "Keyboard" && req.params.playtype !== "Controller") { + return res.status(400).json({ + success: false, + description: "Invalid playtype. Expected Keyboard or Controller.", + }); + } + + return next(); +}); + router.use(ValidateUSCRequest); // This is an implementation of the USCIR spec as per https://uscir.readthedocs.io. // This specification always returns 200 OK, regardless of whether the result was okay @@ -105,10 +116,15 @@ router.get("/", (req, res) => ); const RetrieveChart: RequestHandler = async (req, res, next) => { - const chart = await db.charts.usc.findOne({ "data.hashSHA1": req.params.chartHash }); + const chart = await db.charts.usc.findOne({ + "data.hashSHA1": req.params.chartHash, + playtype: req.params.playtype as Playtypes["usc"], + }); AssignToReqTachiData(req, { - uscChartDoc: (chart ?? undefined) as ChartDocument<"usc:Single"> | undefined, + uscChartDoc: (chart ?? undefined) as + | ChartDocument<"usc:Controller" | "usc:Keyboard"> + | undefined, }); return next(); @@ -157,7 +173,7 @@ router.get("/charts/:chartHash/record", RetrieveChart, async (req, res) => { const serverRecord = (await db["personal-bests"].findOne({ chartID: chart.chartID, "rankingData.rank": 1, - })) as PBScoreDocument<"usc:Single"> | null; + })) as PBScoreDocument<"usc:Controller" | "usc:Keyboard"> | null; if (!serverRecord) { return res.status(200).json({ @@ -231,7 +247,7 @@ router.get("/charts/:chartHash/leaderboard", RetrieveChart, async (req, res) => }, limit: n, } - )) as PBScoreDocument<"usc:Single">[]; + )) as PBScoreDocument<"usc:Controller" | "usc:Keyboard">[]; const serverScores = await Promise.all(bestScores.map(TachiScoreToServerScore)); @@ -259,6 +275,8 @@ const PR_USCIRChartDoc = { * @name POST /ir/usc/:playtype/scores */ router.post("/scores", RequirePermissions("submit_score"), async (req, res) => { + const playtype = req.params.playtype as Playtypes["usc"]; + const chartErr = p( req.body.chart, PR_USCIRChartDoc, @@ -280,21 +298,22 @@ router.post("/scores", RequirePermissions("submit_score"), async (req, res) => { let chartDoc = (await db.charts.usc.findOne({ "data.hashSHA1": uscChart.chartHash, - })) as ChartDocument<"usc:Single"> | null; + playtype, + })) as ChartDocument<"usc:Controller" | "usc:Keyboard"> | null; // If the chart doesn't exist, call HandleOrphanQueue. // If this chart has never been seen before, orphan it. // If this chart is already orphaned, increase its unique player // playcount. if (!chartDoc) { - const { song, chart } = ConvertUSCChart(uscChart); + const { song, chart } = ConvertUSCChart(uscChart, playtype); const uscChartName = `${uscChart.artist} - ${uscChart.title} (${USCChartIndexToDiff( uscChart.difficulty )})`; chartDoc = await HandleOrphanQueue( - "usc:Single", + `usc:${playtype}` as const, "usc", chart, song, @@ -310,6 +329,7 @@ router.post("/scores", RequirePermissions("submit_score"), async (req, res) => { const blacklist = await GetBlacklist(); const scoresToDeorphan = await db["orphan-scores"].find({ "context.chartHash": chartDoc.data.hashSHA1, + "context.playtype": playtype, }); await Promise.all( @@ -323,6 +343,7 @@ router.post("/scores", RequirePermissions("submit_score"), async (req, res) => { const importRes = await ExpressWrappedScoreImportMain(userID, false, "ir/usc", [ req.body, uscChart.chartHash, + playtype, ]); // If this was an orphan chart request, return ACCEPTED, @@ -391,7 +412,6 @@ router.post( const correspondingScore = await db.scores.findOne({ userID: req[SYMBOL_TachiAPIAuth]!.userID!, - game: "usc", scoreID: req.body.identifier, }); diff --git a/server/src/server/router/ir/usc/_playtype/usc.test.ts b/server/src/server/router/ir/usc/_playtype/usc.test.ts index bf89e0e87..92376118d 100644 --- a/server/src/server/router/ir/usc/_playtype/usc.test.ts +++ b/server/src/server/router/ir/usc/_playtype/usc.test.ts @@ -5,7 +5,7 @@ import t from "tap"; import ResetDBState from "test-utils/resets"; import { CreatePOSTScoresResponseBody, TachiScoreToServerScore } from "./usc"; -const mockScorePB: PBScoreDocument<"usc:Single"> = { +const mockScorePB: PBScoreDocument<"usc:Controller" | "usc:Keyboard"> = { chartID: "USC_CHART_ID", calculatedData: { VF6: 0, @@ -17,7 +17,7 @@ const mockScorePB: PBScoreDocument<"usc:Single"> = { game: "usc", highlight: false, isPrimary: true, - playtype: "Single", + playtype: "Controller", rankingData: { outOf: 2, rank: 1, @@ -240,7 +240,9 @@ const mockUserDocs = [2, 3, 4, 5, 6, 7, 8, 9, 10].map((e) => ({ t.test("#CreatePOSTScoresResponseBody", async (t) => { t.beforeEach(ResetDBState); - const chartDoc = (await db.charts.usc.findOne()) as ChartDocument<"usc:Single">; + const chartDoc = (await db.charts.usc.findOne()) as ChartDocument< + "usc:Controller" | "usc:Keyboard" + >; t.test("Should correctly return POSTScoresResponseBody", async (t) => { await db.scores.insert(mockScoreDocument); @@ -257,7 +259,7 @@ t.test("#CreatePOSTScoresResponseBody", async (t) => { const res = await CreatePOSTScoresResponseBody( 1, - chartDoc as ChartDocument<"usc:Single">, + chartDoc as ChartDocument<"usc:Controller" | "usc:Keyboard">, "USER_1_SCORE_PB" ); diff --git a/server/src/server/router/ir/usc/_playtype/usc.ts b/server/src/server/router/ir/usc/_playtype/usc.ts index 17f34a2ae..94f6515e8 100644 --- a/server/src/server/router/ir/usc/_playtype/usc.ts +++ b/server/src/server/router/ir/usc/_playtype/usc.ts @@ -1,15 +1,21 @@ import db from "external/mongo/db"; import { USCIR_ADJACENT_SCORE_N } from "lib/constants/usc-ir"; import CreateLogCtx from "lib/logger/logger"; -import { ChartDocument, integer, PBScoreDocument, ScoreDocument, SongDocument } from "tachi-common"; +import { + ChartDocument, + integer, + PBScoreDocument, + Playtypes, + ScoreDocument, + SongDocument, +} from "tachi-common"; import { MStoS, Random20Hex } from "utils/misc"; import { GetPBOnChart, GetServerRecordOnChart } from "utils/scores"; import { USCClientChart, USCServerScore } from "./types"; - const logger = CreateLogCtx(__filename); export const TACHI_LAMP_TO_USC: Record< - PBScoreDocument<"usc:Single">["scoreData"]["lamp"], + PBScoreDocument<"usc:Controller" | "usc:Keyboard">["scoreData"]["lamp"], USCServerScore["lamp"] > = { // we don't do NO PLAY, so its not handled. @@ -27,7 +33,7 @@ export const TACHI_LAMP_TO_USC: Record< * fields are null. */ export async function TachiScoreToServerScore( - tachiScore: PBScoreDocument<"usc:Single"> + tachiScore: PBScoreDocument<"usc:Controller" | "usc:Keyboard"> ): Promise { // @optimisable // Repeated calls to this may pre-emptively provide usernames @@ -54,7 +60,7 @@ export async function TachiScoreToServerScore( const scorePB = (await db.scores.findOne({ scoreID: tachiScore.composedFrom.scorePB, - })) as ScoreDocument<"usc:Single"> | null; + })) as ScoreDocument<"usc:Controller" | "usc:Keyboard"> | null; if (!scorePB) { logger.severe( @@ -82,13 +88,12 @@ export async function TachiScoreToServerScore( export async function CreatePOSTScoresResponseBody( userID: integer, - chartDoc: ChartDocument<"usc:Single">, + chartDoc: ChartDocument<"usc:Controller" | "usc:Keyboard">, scoreID: string ): Promise { - const scorePB = (await GetPBOnChart( - userID, - chartDoc.chartID - )) as PBScoreDocument<"usc:Single"> | null; + const scorePB = (await GetPBOnChart(userID, chartDoc.chartID)) as PBScoreDocument< + "usc:Controller" | "usc:Keyboard" + > | null; if (!scorePB) { logger.severe(`Score was imported for chart, but no ScorePB was available on this chart?`, { @@ -100,9 +105,9 @@ export async function CreatePOSTScoresResponseBody( ); } - const ktServerRecord = (await GetServerRecordOnChart( - chartDoc.chartID - )) as PBScoreDocument<"usc:Single"> | null; + const ktServerRecord = (await GetServerRecordOnChart(chartDoc.chartID)) as PBScoreDocument< + "usc:Controller" | "usc:Keyboard" + > | null; // this is impossible to trigger without making a race-condition. /* istanbul ignore next */ @@ -133,7 +138,7 @@ export async function CreatePOSTScoresResponseBody( limit: USCIR_ADJACENT_SCORE_N, sort: { "rankingData.rank": -1 }, } - )) as PBScoreDocument<"usc:Single">[]; + )) as PBScoreDocument<"usc:Controller" | "usc:Keyboard">[]; // The specification enforces that we return them in // ascending order, though, so we reverse this after @@ -159,7 +164,7 @@ export async function CreatePOSTScoresResponseBody( limit: USCIR_ADJACENT_SCORE_N, sort: { "rankingData.rank": 1 }, } - )) as PBScoreDocument<"usc:Single">[]; + )) as PBScoreDocument<"usc:Controller" | "usc:Keyboard">[]; const [score, serverRecord, adjacentAbove, adjacentBelow] = await Promise.all([ TachiScoreToServerScore(scorePB), @@ -170,7 +175,7 @@ export async function CreatePOSTScoresResponseBody( const originalScore = (await db.scores.findOne({ scoreID, - })) as ScoreDocument<"usc:Single">; + })) as ScoreDocument<"usc:Controller" | "usc:Keyboard">; if (!originalScore) { logger.severe( @@ -202,14 +207,14 @@ export interface POSTScoresResponseBody { sendReplay: string; } -export function ConvertUSCChart(uscChartDoc: USCClientChart) { - const chart: ChartDocument<"usc:Single"> = { +export function ConvertUSCChart(uscChartDoc: USCClientChart, playtype: Playtypes["usc"]) { + const chart: ChartDocument<"usc:Controller" | "usc:Keyboard"> = { chartID: Random20Hex(), difficulty: USCChartIndexToDiff(uscChartDoc.difficulty), isPrimary: true, level: "?", levelNum: 0, - playtype: "Single", + playtype, rgcID: null, songID: 0, versions: [], @@ -234,6 +239,6 @@ export function ConvertUSCChart(uscChartDoc: USCClientChart) { export function USCChartIndexToDiff( index: 0 | 1 | 2 | 3 -): ChartDocument<"usc:Single">["difficulty"] { +): ChartDocument<"usc:Controller" | "usc:Keyboard">["difficulty"] { return (["NOV", "ADV", "EXH", "INF"] as const)[index]; } diff --git a/server/src/server/router/ir/usc/router.ts b/server/src/server/router/ir/usc/router.ts index 6d85a0198..274a7e4a2 100644 --- a/server/src/server/router/ir/usc/router.ts +++ b/server/src/server/router/ir/usc/router.ts @@ -3,17 +3,6 @@ import uscirRouter from "./_playtype/router"; const router: Router = Router({ mergeParams: true }); -router.use((req, res, next) => { - if (req.params.playtype !== "Keyboard" && req.params.playtype !== "Controller") { - return res.status(400).json({ - success: false, - description: "Invalid playtype. Expected Keyboard or Controller.", - }); - } - - return next(); -}); - router.use("/:playtype", uscirRouter); export default router; diff --git a/server/src/test-utils/mock-db/charts-usc.json b/server/src/test-utils/mock-db/charts-usc.json index 115af8eab..d820d5dae 100644 --- a/server/src/test-utils/mock-db/charts-usc.json +++ b/server/src/test-utils/mock-db/charts-usc.json @@ -4,7 +4,7 @@ "chartID": "USC_CHART_ID", "difficulty": "NOV", "songID": 1, - "playtype": "Single", + "playtype": "Controller", "levelNum": 1, "level": "1", "flags": { diff --git a/server/src/test-utils/test-data.ts b/server/src/test-utils/test-data.ts index 26fe5c7ec..9de13f098 100644 --- a/server/src/test-utils/test-data.ts +++ b/server/src/test-utils/test-data.ts @@ -20,8 +20,8 @@ import { } from "lib/constants/usc-ir"; import { DryScore } from "lib/score-import/framework/common/types"; import { BarbatosScore } from "lib/score-import/import-types/ir/barbatos/types"; -import { USCClientScore } from "../server/router/ir/usc/types"; import { KsHookSV3CScore } from "lib/score-import/import-types/ir/kshook-sv3c/types"; +import { USCClientScore } from "server/router/ir/usc/_playtype/types"; const file = (name: string) => path.join(__dirname, "/test-data", name); @@ -200,12 +200,12 @@ export const barbScore: BarbatosScore = { song_id: 1, }; -export const uscChart: ChartDocument<"usc:Single"> = { +export const uscChart: ChartDocument<"usc:Controller" | "usc:Keyboard"> = { rgcID: null, chartID: "USC_CHART_ID", difficulty: "NOV", songID: 1, - playtype: "Single", + playtype: "Keyboard", levelNum: 1, level: "1", data: { diff --git a/server/src/utils/queries/pbs.ts b/server/src/utils/queries/pbs.ts index 6e8ed50b9..1c27afb31 100644 --- a/server/src/utils/queries/pbs.ts +++ b/server/src/utils/queries/pbs.ts @@ -11,7 +11,7 @@ export async function GetAdjacentAbove(userPB: PBScoreDocument, size = 5) { limit: size, sort: { "rankingData.rank": -1 }, } - )) as PBScoreDocument<"usc:Single">[]; + )) as PBScoreDocument<"usc:Controller" | "usc:Keyboard">[]; return adjAbove; } @@ -26,7 +26,7 @@ export async function GetAdjacentBelow(userPB: PBScoreDocument, size = 5) { limit: size, sort: { "rankingData.rank": 1 }, } - )) as PBScoreDocument<"usc:Single">[]; + )) as PBScoreDocument<"usc:Controller" | "usc:Keyboard">[]; return adjAbove; } diff --git a/server/src/utils/types.ts b/server/src/utils/types.ts index 3e02aa619..748e79584 100644 --- a/server/src/utils/types.ts +++ b/server/src/utils/types.ts @@ -47,7 +47,7 @@ export type EmptyObject = Record; * Data that may be monkey-patched onto req.tachi. This holds things such as middleware results. */ export interface TachiRequestData { - uscChartDoc?: ChartDocument<"usc:Single">; + uscChartDoc?: ChartDocument<"usc:Controller" | "usc:Keyboard">; beatorajaChartDoc?: ChartDocument<"bms:7K" | "bms:14K">; From 2cff4acdbfc9fb1624843cb60e535ef6fbdaca0a Mon Sep 17 00:00:00 2001 From: zkldi <20380519+zkldi@users.noreply.github.com> Date: Sat, 13 Nov 2021 09:05:46 +0000 Subject: [PATCH 4/5] Add separate queue tests --- .../router/ir/usc/_playtype/router.test.ts | 140 ++++++++++++++++++ 1 file changed, 140 insertions(+) diff --git a/server/src/server/router/ir/usc/_playtype/router.test.ts b/server/src/server/router/ir/usc/_playtype/router.test.ts index 9142e5ae3..0b446d626 100644 --- a/server/src/server/router/ir/usc/_playtype/router.test.ts +++ b/server/src/server/router/ir/usc/_playtype/router.test.ts @@ -715,5 +715,145 @@ t.test("POST /scores", (t) => { t.end(); }); + t.test("Should maintain separate orphan queues for the separate playtypes.", async (t) => { + await db["api-tokens"].insert([ + { + userID: 2, + identifier: "token2", + permissions: { submit_score: true }, + token: "token2", + fromAPIClient: null, + }, + { + userID: 3, + identifier: "token3", + permissions: { submit_score: true }, + token: "token3", + fromAPIClient: null, + }, + { + userID: 4, + identifier: "token4", + permissions: { submit_score: true }, + token: "token4", + fromAPIClient: null, + }, + ]); + + await db.users.insert([ + { + id: 2, + username: "foo", + usernameLowercase: "foo", + }, + { + id: 3, + username: "bar", + usernameLowercase: "bar", + }, + { + id: 4, + username: "baz", + usernameLowercase: "baz", + }, + ] as PublicUserDocument[]); + + const res = await mockApi + .post("/ir/usc/Controller/scores") + .set("Authorization", "Bearer token") + .send( + deepmerge(validRequest, { + chart: { + chartHash: "NEW_CHART", + }, + }) + ); + + t.equal(res.body.statusCode, 22); + + const res2 = await mockApi + .post("/ir/usc/Controller/scores") + .set("Authorization", "Bearer token2") + .send( + deepmerge(validRequest, { + chart: { + chartHash: "NEW_CHART", + }, + }) + ); + + t.equal(res2.body.statusCode, 22); + + const orphanData = await db["orphan-chart-queue"].findOne({ + "chartDoc.data.hashSHA1": "NEW_CHART", + idString: "usc:Controller", + }); + + t.strictSame(orphanData?.userIDs, [1, 2]); + + const res3 = await mockApi + .post("/ir/usc/Keyboard/scores") + .set("Authorization", "Bearer token3") + .send( + deepmerge(validRequest, { + chart: { + chartHash: "NEW_CHART", + }, + }) + ); + + t.equal(res3.body.statusCode, 22); + + const orphanData2 = await db["orphan-chart-queue"].findOne({ + "chartDoc.data.hashSHA1": "NEW_CHART", + idString: "usc:Controller", + }); + + t.strictSame( + orphanData2?.userIDs, + [1, 2], + "Should not have added userID 3 to the list of userIDs." + ); + + const res4 = await mockApi + .post("/ir/usc/Controller/scores") + .set("Authorization", "Bearer token4") + .send( + deepmerge(validRequest, { + chart: { + chartHash: "NEW_CHART", + }, + }) + ); + + t.equal(res4.body.statusCode, 20); + + const orphanData3 = await db["orphan-chart-queue"].findOne({ + "chartDoc.data.hashSHA1": "NEW_CHART", + idString: "usc:Controller", + }); + + t.equal(orphanData3, null, "Should have removed the orphan chart from the database."); + + const score = await db.scores.findOne({ + game: "usc", + userID: 4, + }); + + t.hasStrict(score, { + scoreData: { + score: 9_000_000, + percent: 90, + lamp: "FAILED", + }, + scoreMeta: { + noteMod: "MIRROR", + gaugeMod: "NORMAL", + }, + }); + + t.end(); + }); + t.end(); }); From 39d9cfd6910cd7b4b5c48af310ecaacdea0167ce Mon Sep 17 00:00:00 2001 From: zkldi <20380519+zkldi@users.noreply.github.com> Date: Sat, 13 Nov 2021 09:12:39 +0000 Subject: [PATCH 5/5] Enforce that charts-usc|bms can never have things removed. --- server/scripts/sync-database/sync-database.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/server/scripts/sync-database/sync-database.ts b/server/scripts/sync-database/sync-database.ts index fdbeb58af..af3e58f41 100644 --- a/server/scripts/sync-database/sync-database.ts +++ b/server/scripts/sync-database/sync-database.ts @@ -99,10 +99,15 @@ async function GenericUpsert( } const syncInstructions: SyncInstructions[] = [ + { + pattern: /^charts-(usc|bms)$/u, + handler: (charts: ChartDocument[], collection: ICollection, logger) => + GenericUpsert(charts, collection, "chartID", logger, false), + }, { pattern: /^charts-/u, handler: (charts: ChartDocument[], collection: ICollection, logger) => - GenericUpsert(charts, collection, "chartID", logger), + GenericUpsert(charts, collection, "chartID", logger, true), }, { pattern: /^songs-/u,