diff --git a/server/src/api/ir/usc/common.ts b/server/src/api/ir/usc/common.ts index f2e69e6eb..06bdf8e5f 100644 --- a/server/src/api/ir/usc/common.ts +++ b/server/src/api/ir/usc/common.ts @@ -26,7 +26,7 @@ export interface USCClientScore { near: integer; error: integer; options: { - gaugeType: integer; + gaugeType: 0 | 1; gaugeOpt: integer; mirror: boolean; random: boolean; diff --git a/server/src/api/ir/usc/usc.ts b/server/src/api/ir/usc/usc.ts index a1a98bab9..a063950c5 100644 --- a/server/src/api/ir/usc/usc.ts +++ b/server/src/api/ir/usc/usc.ts @@ -6,6 +6,10 @@ import { ChartDocument, PBScoreDocument } from "kamaitachi-common"; import { AssertStrAsPositiveNonZeroInt } from "../../../score-import/framework/common/string-asserts"; import CreateLogCtx from "../../../common/logger"; import { KtchiScoreToServerScore } from "./common"; +import { ExpressWrappedScoreImportMain } from "../../../score-import/framework/express-wrapper"; +import { GetUserWithID } from "../../../common/user"; +import { KtLogger } from "../../../types"; +import { ParseIRUSC } from "../../../score-import/import-types/ir/usc/parser"; const logger = CreateLogCtx("usc.ts"); @@ -57,6 +61,8 @@ const ValidateUSCRequest: RequestHandler = async (req, res, next) => { } req[SYMBOL_KtchiData] = { uscAuthDoc }; + + return next(); }; router.use(ValidateUSCRequest); @@ -225,6 +231,46 @@ router.get("/charts/:chartHash/leaderboard", RetrieveChart, async (req, res) => * https://uscir.readthedocs.io/en/latest/endpoints/score-submit.html * @name POST /api/ir/usc/scores */ -router.post("/scores", async (req, res) => {}); +router.post("/scores", async (req, res) => { + if (typeof req.body.chart !== "object" || req.body.chart === null) { + return res.status(200).json({ + statusCode: STATUS_CODES.BAD_REQ, + description: "Invalid chart provided.", + }); + } + + if (typeof req.body.chart.chartHash !== "string") { + return res.status(200).json({ + statusCode: STATUS_CODES.BAD_REQ, + description: "Invalid chart provided.", + }); + } + + const chartDoc = (await FindChartOnSHA256( + "usc", + req.body.chart.chartHash + )) as ChartDocument<"usc:Single"> | null; + + if (!chartDoc) { + return res.status(200).json({ + statusCode: STATUS_CODES.CHART_REFUSE, + description: "This chart is not supported.", + }); + } + + const userDoc = await GetUserWithID(req[SYMBOL_KtchiData]!.uscAuthDoc!.userID); + + if (!userDoc) { + logger.severe(`User ${req[SYMBOL_KtchiData]!.uscAuthDoc!.userID} as no parent userDoc?`); + return res.status(200).json({ + statusCode: STATUS_CODES.SERVER_ERROR, + description: "An internal server error has occured.", + }); + } + + const importParser = (logger: KtLogger) => ParseIRUSC(req.body, chartDoc, logger); + + const importRes = await ExpressWrappedScoreImportMain(userDoc, false, "ir/usc", importParser); +}); export default router; diff --git a/server/src/score-import/import-types/ir/usc/converter.ts b/server/src/score-import/import-types/ir/usc/converter.ts new file mode 100644 index 000000000..89b1b6e31 --- /dev/null +++ b/server/src/score-import/import-types/ir/usc/converter.ts @@ -0,0 +1,78 @@ +import { USCClientScore } from "../../../../api/ir/usc/common"; +import { FindSongOnID } from "../../../../common/database-lookup/song"; +import { ConverterFunction, DryScore, KtLogger } from "../../../../types"; +import { InternalFailure } from "../../../framework/common/converter-failures"; +import { GenericGetGradeAndPercent } from "../../../framework/common/score-utils"; +import { IRUSCContext } from "./types"; +import { Lamps } from "kamaitachi-common"; + +function DeriveNoteMod(data: USCClientScore): "NORMAL" | "MIRROR" | "RANDOM" | "MIR-RAN" { + if (data.options.mirror && data.options.random) { + return "MIR-RAN"; + } else if (data.options.mirror) { + return "MIRROR"; + } else if (data.options.random) { + return "RANDOM"; + } + + return "NORMAL"; +} + +function DeriveLamp(scoreDoc: USCClientScore, logger: KtLogger): Lamps["usc:Single"] { + if (scoreDoc.score === 10_000_000) { + return "PERFECT ULTIMATE CHAIN"; + } else if (scoreDoc.error === 0) { + return "ULTIMATE CHAIN"; + } else if (scoreDoc.options.gaugeType === 0) { + return scoreDoc.gauge >= 70 ? "CLEAR" : "FAILED"; + } else if (scoreDoc.options.gaugeType === 1) { + return scoreDoc.gauge > 0 ? "EXCESSIVE CLEAR" : "FAILED"; + } + + logger.error(`Could not derive Lamp from Score Document`, { scoreDoc }); + throw new InternalFailure(`Could not derive Lamp from Score Document`); +} + +export const ConverterIRUSC: ConverterFunction = async ( + data, + context, + importType, + logger +) => { + const song = await FindSongOnID("usc", context.chart.songID); + + if (!song) { + logger.severe(`Song-Chart desync on USCIR ${context.chart.songID}.`); + throw new InternalFailure(`Song-Chart desync on USCIR ${context.chart.songID}.`); + } + + const { grade, percent } = GenericGetGradeAndPercent("usc", data.score, context.chart); + + const dryScore: DryScore<"usc:Single"> = { + comment: null, + game: "usc", + importType, + timeAchieved: Date.now(), + service: "USC-IR", + scoreData: { + grade, + percent, + score: data.score, + lamp: DeriveLamp(data, logger), + hitData: { + critical: data.crit, + near: data.near, + miss: data.error, + }, + hitMeta: { + gauge: data.gauge, + }, + }, + scoreMeta: { + gaugeMod: data.options.gaugeOpt === 0 ? "NORMAL" : "HARD", + noteMod: DeriveNoteMod(data), + }, + }; + + return { chart: context.chart, song, dryScore }; +}; diff --git a/server/src/score-import/import-types/ir/usc/parser.ts b/server/src/score-import/import-types/ir/usc/parser.ts index e69de29bb..e0cbdebf3 100644 --- a/server/src/score-import/import-types/ir/usc/parser.ts +++ b/server/src/score-import/import-types/ir/usc/parser.ts @@ -0,0 +1,50 @@ +import { KtLogger, ParserFunctionReturnsSync } from "../../../../types"; +import { ChartDocument } from "kamaitachi-common"; +import p, { PrudenceSchema } from "prudence"; +import { InvalidScoreFailure } from "../../../framework/common/converter-failures"; +import { FormatPrError } from "../../../../common/prudence"; +import { USCClientScore } from "../../../../api/ir/usc/common"; +import { IRUSCContext } from "./types"; +import { ConverterIRUSC } from "./converter"; + +const PR_USCIRScore: PrudenceSchema = { + score: p.isBoundedInteger(0, 10_000_000), + gauge: p.isBetween(0, 100), + timestamp: p.isPositiveInteger, + crit: p.isPositiveInteger, + near: p.isPositiveInteger, + error: p.isPositiveInteger, + options: { + gaugeType: p.isIn(0, 1), + mirror: "boolean", + random: "boolean", + autoFlags: p.isInteger, + }, +}; + +export function ParseIRUSC( + body: Record, + chart: ChartDocument<"usc:Single">, + logger: KtLogger +): ParserFunctionReturnsSync { + const err = p( + body.score, + PR_USCIRScore, + {}, + { throwOnNonObject: false, allowExcessKeys: true } + ); + + if (err) { + throw new InvalidScoreFailure(FormatPrError(err, "Invalid USC Score.")); + } + + return { + context: { + chart, + }, + game: "usc", + iterable: [body.score] as USCClientScore[], + classHandler: null, + ConverterFunction: ConverterIRUSC, + }; +} diff --git a/server/src/score-import/import-types/ir/usc/types.ts b/server/src/score-import/import-types/ir/usc/types.ts new file mode 100644 index 000000000..3125753a7 --- /dev/null +++ b/server/src/score-import/import-types/ir/usc/types.ts @@ -0,0 +1,5 @@ +import { ChartDocument } from "kamaitachi-common"; + +export interface IRUSCContext { + chart: ChartDocument<"usc:Single">; +}