diff --git a/server/pnpm-lock.yaml b/server/pnpm-lock.yaml index b1108ff29..e35a75798 100644 --- a/server/pnpm-lock.yaml +++ b/server/pnpm-lock.yaml @@ -61,7 +61,7 @@ dependencies: express-session: 1.17.1 fast-json-stable-hash: 1.0.2 fast-xml-parser: 3.19.0 - kamaitachi-common: github.com/zkldi/Kamaitachi-common/0398d77719606979c88833b37bce8020958cf058 + kamaitachi-common: github.com/zkldi/Kamaitachi-common/99d4590a08602bf9229583282cdbe77bdf8e8d97 mongodb: 3.6.6 monk: 7.3.4 multer: 1.4.2 @@ -4344,8 +4344,8 @@ packages: engines: {node: '>=6'} dev: true - github.com/zkldi/Kamaitachi-common/0398d77719606979c88833b37bce8020958cf058: - resolution: {tarball: https://codeload.github.com/zkldi/Kamaitachi-common/tar.gz/0398d77719606979c88833b37bce8020958cf058} + github.com/zkldi/Kamaitachi-common/99d4590a08602bf9229583282cdbe77bdf8e8d97: + resolution: {tarball: https://codeload.github.com/zkldi/Kamaitachi-common/tar.gz/99d4590a08602bf9229583282cdbe77bdf8e8d97} name: kamaitachi-common version: 0.1.0 dependencies: 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 9464a6173..66ae0c34e 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,8 @@ import { ImportTypes, Game, AnyChartDocument, AnySongDocument } from "kamaitachi import { ConverterFailure } from "../../framework/common/converter-failures"; import { ClassHandler } from "../../framework/user-game-stats/classes"; import { DryScore } from "../../framework/common/types"; +import { BeatorajaIRScoreFormat } from "../../../../server/router/ir/beatoraja/charts/convert-scores"; +import { BeatorajaContext } from "../ir/beatoraja/types"; export interface ImportTypeDataMap { "file/eamusement-iidx-csv": IIDXEamusementCSVData; @@ -25,7 +27,7 @@ export interface ImportTypeDataMap { "ir/fervidex": FervidexScore; "ir/fervidex-static": FervidexStaticScore; "ir/chunitachi": BatchManualScore; - "ir/beatoraja": EmptyObject; + "ir/beatoraja": BeatorajaIRScoreFormat; "ir/usc": EmptyObject; "api/arc-iidx": unknown; @@ -49,7 +51,7 @@ export interface ImportTypeContextMap { "ir/fervidex": FervidexContext; "ir/fervidex-static": FervidexStaticContext; "ir/chunitachi": BatchManualContext; - "ir/beatoraja": EmptyObject; + "ir/beatoraja": BeatorajaContext; "ir/usc": EmptyObject; "api/arc-iidx": EmptyObject; diff --git a/server/src/lib/score-import/import-types/ir/beatoraja/converter.ts b/server/src/lib/score-import/import-types/ir/beatoraja/converter.ts new file mode 100644 index 000000000..4fe00c832 --- /dev/null +++ b/server/src/lib/score-import/import-types/ir/beatoraja/converter.ts @@ -0,0 +1,123 @@ +import { FindChartOnSHA256 } from "../../../../../utils/queries/charts"; +import { FindSongOnID } from "../../../../../utils/queries/songs"; +import { InternalFailure } from "../../../framework/common/converter-failures"; +import { GenericGetGradeAndPercent } from "../../../framework/common/score-utils"; +import { DryScore } from "../../../framework/common/types"; +import { ConverterFunction } from "../../common/types"; +import { BeatorajaContext, BeatorajaScore } from "./types"; +import { ChartDocument } from "kamaitachi-common"; + +const LAMP_LOOKUP = { + NoPlay: "NO PLAY", + Failed: "FAILED", + LightAssistEasy: "ASSIST CLEAR", + Easy: "EASY CLEAR", + Normal: "CLEAR", + Hard: "HARD CLEAR", + ExHard: "EX HARD CLEAR", + FullCombo: "FULL COMBO", + Perfect: "FULL COMBO", +} as const; + +const RANDOM_LOOKUP = { + 0: "NONRAN", + 1: "MIRROR", + 2: "RANDOM", + 3: "R-RANDOM", + 4: "S-RANDOM", +} as const; + +export const ConverterIRBeatoraja: ConverterFunction = async ( + data, + context, + importType, + logger +) => { + const chart = (await FindChartOnSHA256("bms", data.sha256)) as ChartDocument< + "bms:7K" | "bms:14K" + >; + + if (!chart) { + // @todo #141 Import charts into the Kamaitachi Database if the chart + // doesn't exist. + throw new Error("unimplemented"); + } + + const song = await FindSongOnID("bms", chart.songID); + + if (!song) { + logger.severe(`Song-Chart Desync with BMS ${chart.chartID}.`); + throw new InternalFailure(`Song-Chart Desync with BMS ${chart.chartID}.`); + } + + // if CN mode + if (data.lntype === 1) { + // Divide the EX score by the CN note count, then multiply it by the LN + // notecount. This converts CN EXScores to LN EXScores by routing + // through percent. + data.exscore = Math.floor((data.exscore / context.chart.notes) * chart.data.notecount); + } + + const { grade, percent } = GenericGetGradeAndPercent("bms", data.exscore, chart); + + const hitMeta: DryScore<"bms:7K" | "bms:14K">["scoreData"]["hitMeta"] = { + bp: data.minbp === -1 ? null : data.minbp, + gauge: data.gauge === -1 ? null : data.gauge, + }; + + let hitData: DryScore<"bms:7K" | "bms:14K">["scoreData"]["hitData"] = {}; + + // if NOT CN mode we can assign judgements properly + if (data.lntype === 0) { + for (const k of [ + "ebd", + "lbd", + "egd", + "lgd", + "egr", + "lgr", + "epg", + "lpg", + "epr", + "lpr", + ] as const) { + hitMeta[k] = data[k]; + } + + hitMeta.epr! += data.ems; + hitMeta.lpr! += data.lms; + + hitData = { + pgreat: data.epg + data.lpg, + great: data.egr + data.lgr, + good: data.egd + data.lgd, + bad: data.ebd + data.lbd, + poor: data.epr + data.lpr + data.ems + data.lms, + }; + } + + const lamp = LAMP_LOOKUP[data.clear]; + + const dryScore: DryScore<"bms:7K" | "bms:14K"> = { + comment: null, + game: "bms", + importType, + scoreData: { + grade, + percent, + score: data.exscore, + lamp, + hitMeta, + hitData, + }, + scoreMeta: { + client: context.client, + inputDevice: data.deviceType, + random: RANDOM_LOOKUP[data.option] ?? null, + }, + timeAchieved: Date.now(), + service: "Beatoraja IR", + }; + + return { song, chart, dryScore }; +}; diff --git a/server/src/lib/score-import/import-types/ir/beatoraja/parser.ts b/server/src/lib/score-import/import-types/ir/beatoraja/parser.ts new file mode 100644 index 000000000..83c4180fa --- /dev/null +++ b/server/src/lib/score-import/import-types/ir/beatoraja/parser.ts @@ -0,0 +1,114 @@ +import { KtLogger } from "../../../../logger/logger"; +import p from "prudence"; +import ScoreImportFatalError from "../../../framework/score-importing/score-import-error"; +import { FormatPrError } from "../../../../../utils/prudence"; +import { BeatorajaChart, BeatorajaContext, BeatorajaScore } from "./types"; +import { ConverterIRBeatoraja } from "./converter"; +import { ParserFunctionReturnsSync } from "../../common/types"; + +const PR_BeatorajaScore = { + sha256: "string", + exscore: p.isPositiveInteger, + passnotes: p.isPositiveInteger, + gauge: p.or(p.isBetween(0, 100), p.is(-1)), + deviceType: p.isIn("BM_CONTROLLER", "KEYBOARD"), + minbp: p.or(p.isPositiveInteger, p.is(-1)), + option: p.isBoundedInteger(0, 4), + + lntype: p.isIn(0, 1), + clear: p.isIn( + "NoPlay", + "Failed", + "LightAssistEasy", + "Easy", + "Normal", + "Hard", + "ExHard", + "FullCombo", + "Perfect" + ), + assist: p.is(0), + maxcombo: p.isPositiveInteger, + + epg: p.isPositiveInteger, + egr: p.isPositiveInteger, + egd: p.isPositiveInteger, + ebd: p.isPositiveInteger, + epr: p.isPositiveInteger, + lpg: p.isPositiveInteger, + lgr: p.isPositiveInteger, + lgd: p.isPositiveInteger, + lbd: p.isPositiveInteger, + lpr: p.isPositiveInteger, + ems: p.isPositiveInteger, + lms: p.isPositiveInteger, +}; + +const PR_BeatorajaChart = { + md5: "string", + sha256: "string", + title: "string", + subtitle: "string", + genre: "string", + artist: "string", + subartist: "string", + total: p.isPositive, + + // currently only accepted playtypes. + mode: p.isIn("BEAT_7K", "BEAT_14K"), + judge: p.isPositive, + notes: p.isPositiveInteger, + hasUndefinedLN: p.is(false), + hasRandom: p.is(false), +}; + +export function ParseBeatorajaSingle( + body: Record, + logger: KtLogger +): ParserFunctionReturnsSync { + const err = p( + body.score, + PR_BeatorajaScore, + {}, + { allowExcessKeys: true, throwOnNonObject: false } + ); + + if (err) { + throw new ScoreImportFatalError( + 400, + FormatPrError(err, "Invalid Beatoraja Import - Score") + ); + } + + const chartErr = p( + body.chart, + PR_BeatorajaChart, + {}, + { allowExcessKeys: true, throwOnNonObject: false } + ); + + if (chartErr) { + throw new ScoreImportFatalError( + 400, + FormatPrError(chartErr, "Invalid Beatoraja Score - Chart") + ); + } + + const client = body.client; + + if (client !== "beatoraja" && client !== "lr2oraja") { + throw new ScoreImportFatalError(400, `Unknown client ${client}`); + } + + // asserted using prudence. + return { + context: { + client, + chart: body.chart as BeatorajaChart, + }, + game: "bms", + iterable: ([body] as unknown) as BeatorajaScore[], + ConverterFunction: ConverterIRBeatoraja, + classHandler: null, + }; +} diff --git a/server/src/lib/score-import/import-types/ir/beatoraja/types.ts b/server/src/lib/score-import/import-types/ir/beatoraja/types.ts new file mode 100644 index 000000000..4d8675bd9 --- /dev/null +++ b/server/src/lib/score-import/import-types/ir/beatoraja/types.ts @@ -0,0 +1,58 @@ +import { integer } from "kamaitachi-common"; + +export interface BeatorajaContext { + client: "beatoraja" | "lr2oraja"; + chart: BeatorajaChart; +} + +export interface BeatorajaChart { + md5: string; + sha256: string; + title: string; + subtitle: string; + genre: string; + artist: string; + subartist: string; + total: integer; + + mode: "BEAT_7K" | "BEAT_14K"; + judge: number; + notes: integer; +} + +export interface BeatorajaScore { + sha256: string; + exscore: integer; + passnotes: integer; + gauge: number; + deviceType: "BM_CONTROLLER" | "KEYBOARD"; + minbp: integer; + option: 0 | 1 | 2 | 3 | 4; + + lntype: 0 | 1; + clear: + | "NoPlay" + | "Failed" + | "LightAssistEasy" + | "Easy" + | "Normal" + | "Hard" + | "ExHard" + | "FullCombo" + | "Perfect"; + assist: 0; + maxcombo: integer; + + epg: integer; + egr: integer; + egd: integer; + ebd: integer; + epr: integer; + lpg: integer; + lgr: integer; + lgd: integer; + lbd: integer; + lpr: integer; + ems: integer; + lms: integer; +} diff --git a/server/src/server/router/ir/beatoraja/charts/convert-scores.ts b/server/src/server/router/ir/beatoraja/charts/convert-scores.ts index 471ef6c4c..62885ac39 100644 --- a/server/src/server/router/ir/beatoraja/charts/convert-scores.ts +++ b/server/src/server/router/ir/beatoraja/charts/convert-scores.ts @@ -34,7 +34,7 @@ type BeatorajaPartialScoreFormat = { maxcombo: integer | null; }; -export type BeatorajaScoreFormat = BeatorajaPartialScoreFormat & BeatorajaScoreJudgements; +export type BeatorajaIRScoreFormat = BeatorajaPartialScoreFormat & BeatorajaScoreJudgements; const logger = CreateLogCtx(__filename); diff --git a/server/src/server/router/ir/beatoraja/router.ts b/server/src/server/router/ir/beatoraja/router.ts index 3db361f25..311f3dcbe 100644 --- a/server/src/server/router/ir/beatoraja/router.ts +++ b/server/src/server/router/ir/beatoraja/router.ts @@ -1,7 +1,14 @@ import { Router } from "express"; import db from "../../../../external/mongo/db"; +import { SYMBOL_KtchiData } from "../../../../lib/constants/ktchi"; +import { KtLogger } from "../../../../lib/logger/logger"; +import { ExpressWrappedScoreImportMain } from "../../../../lib/score-import/framework/express-wrapper"; +import { ParseBeatorajaSingle } from "../../../../lib/score-import/import-types/ir/beatoraja/parser"; import { Random20Hex } from "../../../../utils/misc"; -import { PRIVATEINFO_GetUserCaseInsensitive } from "../../../../utils/user"; +import { + GetUserWithIDGuaranteed, + PRIVATEINFO_GetUserCaseInsensitive, +} from "../../../../utils/user"; import prValidate from "../../../middleware/prudence-validate"; import { PasswordCompare } from "../../api/v1/auth/auth"; import { ValidateAuthToken, ValidateIRClientVersion } from "./auth"; @@ -66,7 +73,18 @@ router.use(ValidateAuthToken); * store it as a new chart alongside the new score. * @name POST /ir/beatoraja/submit-score */ -router.post("/submit-score", async (req, res) => {}); +router.post("/submit-score", async (req, res) => { + const userDoc = await GetUserWithIDGuaranteed(req[SYMBOL_KtchiData]!.beatorajaAuthDoc!.userID); + + const ParserFunction = (logger: KtLogger) => ParseBeatorajaSingle(req.body, logger); + + const importRes = await ExpressWrappedScoreImportMain( + userDoc, + false, + "ir/beatoraja", + ParserFunction + ); +}); /** * Submits a course result to Kamaitachi. This only accepts a limited set of diff --git a/server/src/server/router/ir/usc/usc.ts b/server/src/server/router/ir/usc/usc.ts index 1bdd2ea2f..17ee3d79e 100644 --- a/server/src/server/router/ir/usc/usc.ts +++ b/server/src/server/router/ir/usc/usc.ts @@ -167,7 +167,7 @@ export async function CreatePOSTScoresResponseBody( const usersRanking = scorePB.rankingData.rank; - // This returns N scores immediately ranked higher + // This returns immediately ranked higher // than the current user. const adjAbove = (await db["score-pbs"].find( diff --git a/server/src/test-utils/test-data/beatoraja/base.json b/server/src/test-utils/test-data/beatoraja/base.json index 2c87f68ec..e54ae8a06 100644 --- a/server/src/test-utils/test-data/beatoraja/base.json +++ b/server/src/test-utils/test-data/beatoraja/base.json @@ -1,5 +1,5 @@ { - "song": { + "chart": { "md5": "f8dcdfe070630bbb365323c662561a1a", "sha256": "a6ed343ac9db07af0dd6eb8af3c80d79032aa777fd7ff2c28feb885717f47b89", "title": "星の器〜STAR OF ANDROMEDA(ANOTHER)", @@ -55,5 +55,6 @@ "judgeAlgorithm": "Combo", "rule": "LR2", "exscore": 1004 - } - } \ No newline at end of file + }, + "client": "beatoraja" +} \ No newline at end of file