diff --git a/server/package.json b/server/package.json index 83d3f9cd9..dd2e47a3f 100644 --- a/server/package.json +++ b/server/package.json @@ -83,7 +83,7 @@ "rimraf": "3.0.2", "safe-json-stringify": "1.2.0", "seq-logging": "^1.1.1", - "tachi-common": "0.3.16", + "tachi-common": "0.3.17", "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 fd4000f6a..399ae5cdf 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.3.16 + tachi-common: 0.3.17 tap: 15.0.9 ts-node: 10.0.0 tsconfig-paths: 3.10.1 @@ -107,7 +107,7 @@ dependencies: rimraf: 3.0.2 safe-json-stringify: 1.2.0 seq-logging: 1.1.1 - tachi-common: 0.3.16 + tachi-common: 0.3.17 ts-node: 10.0.0_83f53b0a0c5616d3fa00ed4e30b9ce1b typescript: 4.3.4 winston: 3.3.3 @@ -5387,8 +5387,8 @@ packages: strip-ansi: 6.0.0 dev: true - /tachi-common/0.3.16: - resolution: {integrity: sha512-cCTD+gKWRvOuQAUEf9MNkIAt961JKu/pHOF/Sw+OoMNxAQfiJ9qwjsIF9YXBX16r0q+Olh1Ps+ImaP3j5/PLRQ==} + /tachi-common/0.3.17: + resolution: {integrity: sha512-YL5UlTT5qHSaAzIbSgz2UzagTE9HV++C0XVAopFg8wYwUei2b1R5shwXgzyO0RtZgqVT0ivz2ug+jx+RN37pCw==} dependencies: monk: 7.3.4 transitivePeerDependencies: 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 05a085e5b..42caab878 100644 --- a/server/src/lib/score-import/import-types/common/types.ts +++ b/server/src/lib/score-import/import-types/common/types.ts @@ -21,6 +21,7 @@ import { BeatorajaContext, BeatorajaScore } from "../ir/beatoraja/types"; import { FervidexStaticContext, FervidexStaticScore } from "../ir/fervidex-static/types"; import { FervidexContext, FervidexScore } from "../ir/fervidex/types"; import { KsHookSV3CContext, KsHookSV3CScore } from "../ir/kshook-sv3c/types"; +import { LR2HookContext, LR2HookScore } from "../ir/lr2hook/types"; import { IRUSCContext } from "../ir/usc/types"; import { KaiContext } from "./api-kai/types"; import { BatchManualContext } from "./batch-manual/types"; @@ -41,6 +42,7 @@ export interface ImportTypeDataMap { "ir/beatoraja": BeatorajaScore; "ir/usc": USCClientScore; "ir/kshook-sv3c": KsHookSV3CScore; + "ir/lr2hook": LR2HookScore; // These aren't placeholder values - the data is yielded in a way that // the value of these is legitimately unknown at convert time. @@ -68,6 +70,7 @@ export interface ImportTypeContextMap { "ir/beatoraja": BeatorajaContext; "ir/usc": IRUSCContext; "ir/kshook-sv3c": KsHookSV3CContext; + "ir/lr2hook": LR2HookContext; "api/arc-iidx": EmptyObject; "api/arc-sdvx": EmptyObject; diff --git a/server/src/lib/score-import/import-types/converters.ts b/server/src/lib/score-import/import-types/converters.ts index acf58c102..15b56ddde 100644 --- a/server/src/lib/score-import/import-types/converters.ts +++ b/server/src/lib/score-import/import-types/converters.ts @@ -14,6 +14,7 @@ import { ConverterIRBeatoraja } from "./ir/beatoraja/converter"; import { ConverterIRFervidexStatic } from "./ir/fervidex-static/converter"; import { ConverterIRFervidex } from "./ir/fervidex/converter"; import { ConverterIRKsHookSV3C } from "./ir/kshook-sv3c/converter"; +import { ConverterLR2Hook } from "./ir/lr2hook/converter"; import { ConverterIRUSC } from "./ir/usc/converter"; export type ConverterMap = { @@ -43,4 +44,5 @@ export const Converters: ConverterMap = { "ir/direct-manual": ConverterBatchManual, "ir/usc": ConverterIRUSC, "ir/kshook-sv3c": ConverterIRKsHookSV3C, + "ir/lr2hook": ConverterLR2Hook, }; diff --git a/server/src/lib/score-import/import-types/ir/lr2hook/converter.test.ts b/server/src/lib/score-import/import-types/ir/lr2hook/converter.test.ts new file mode 100644 index 000000000..ec70ad485 --- /dev/null +++ b/server/src/lib/score-import/import-types/ir/lr2hook/converter.test.ts @@ -0,0 +1,61 @@ +import CreateLogCtx from "lib/logger/logger"; +import t from "tap"; +import ResetDBState from "test-utils/resets"; +import { TestingLR2HookScore } from "test-utils/test-data"; +import { ConverterLR2Hook } from "./converter"; + +const logger = CreateLogCtx(__filename); + +t.test("#ConverterLR2Hook", (t) => { + t.beforeEach(ResetDBState); + + t.test("Should match a score with its song and chart.", async (t) => { + const res = await ConverterLR2Hook( + TestingLR2HookScore, + { timeReceived: 10 }, + "ir/lr2hook", + logger + ); + + t.hasStrict(res, { + song: { + id: 27339, + }, + chart: { + chartID: "88eb6cc5683e2740cbd07f588a5f3db1db8d467b", + data: { + hashMD5: TestingLR2HookScore.md5, + }, + }, + dryScore: { + scoreData: { + score: TestingLR2HookScore.scoreData.exScore, + }, + game: "bms", + importType: "ir/lr2hook", + scoreMeta: { + client: "LR2", + }, + }, + }); + + t.end(); + }); + + t.test("Should throw an error if song or chart can't be found.", (t) => { + t.rejects( + () => + ConverterLR2Hook( + Object.assign({}, TestingLR2HookScore, { md5: "nonsense_md5" }), + { timeReceived: 10 }, + "ir/lr2hook", + logger + ), + "Should throw a KTDataNotFoundError if chart can't be found." + ); + + t.end(); + }); + + t.end(); +}); diff --git a/server/src/lib/score-import/import-types/ir/lr2hook/converter.ts b/server/src/lib/score-import/import-types/ir/lr2hook/converter.ts new file mode 100644 index 000000000..036bc8e6e --- /dev/null +++ b/server/src/lib/score-import/import-types/ir/lr2hook/converter.ts @@ -0,0 +1,86 @@ +import { Lamps } from ".pnpm/tachi-common@0.3.16/node_modules/tachi-common"; +import { + InternalFailure, + KTDataNotFoundFailure, +} from "lib/score-import/framework/common/converter-failures"; +import { GenericGetGradeAndPercent } from "lib/score-import/framework/common/score-utils"; +import { DryScore } from "lib/score-import/framework/common/types"; +import { FindBMSChartOnHash } from "utils/queries/charts"; +import { FindSongOnID } from "utils/queries/songs"; +import { ConverterFunction } from "../../common/types"; +import { LR2HookContext, LR2HookScore } from "./types"; + +export const ConverterLR2Hook: ConverterFunction = async ( + data, + context, + importType, + logger +) => { + const chart = await FindBMSChartOnHash(data.md5); + + if (!chart) { + throw new KTDataNotFoundFailure( + `Could not find chart with md5 ${data.md5}.`, + importType, + data, + context + ); + } + + const song = await FindSongOnID("bms", chart.songID); + + if (!song) { + logger.severe(`Song ${chart.songID} (bms) has no parent song?`); + throw new InternalFailure(`Song ${chart.songID} (bms) has no parent song?`); + } + + const { percent, grade } = GenericGetGradeAndPercent("bms", data.scoreData.exScore, chart); + + const dryScore: DryScore<"bms:7K" | "bms:14K"> = { + game: "bms", + service: "LR2Hook", + comment: null, + importType: "ir/lr2hook", + timeAchieved: context.timeReceived, + scoreData: { + score: data.scoreData.exScore, + percent, + grade, + lamp: ConvertLamp(data.scoreData.lamp), + judgements: { + pgreat: data.scoreData.pgreat, + great: data.scoreData.great, + good: data.scoreData.good, + bad: data.scoreData.bad, + poor: data.scoreData.poor, + }, + hitMeta: { + bp: data.scoreData.bad + data.scoreData.poor, + maxCombo: data.scoreData.maxCombo, + gauge: data.scoreData.hpGraph[999] ?? 0, + }, + }, + scoreMeta: { + client: "LR2", + }, + }; + + return { song, chart, dryScore }; +}; + +function ConvertLamp(lamp: LR2HookScore["scoreData"]["lamp"]): Lamps["bms:7K" | "bms:14K"] { + switch (lamp) { + case "EASY": + return "EASY CLEAR"; + case "FAIL": + return "FAILED"; + case "FULL COMBO": + return "FULL COMBO"; + case "HARD": + return "HARD CLEAR"; + case "NO PLAY": + return "NO PLAY"; + case "NORMAL": + return "CLEAR"; + } +} diff --git a/server/src/lib/score-import/import-types/ir/lr2hook/parser.test.ts b/server/src/lib/score-import/import-types/ir/lr2hook/parser.test.ts new file mode 100644 index 000000000..24b3d4b3b --- /dev/null +++ b/server/src/lib/score-import/import-types/ir/lr2hook/parser.test.ts @@ -0,0 +1,92 @@ +import CreateLogCtx from "lib/logger/logger"; +import t from "tap"; +import { ParseLR2Hook } from "./parser"; +import deepmerge from "deepmerge"; +import { TestingLR2HookScore } from "test-utils/test-data"; +import { ApplyNTimes } from "utils/misc"; + +const logger = CreateLogCtx(__filename); + +t.test("#ParseLR2Hook", (t) => { + const assertFail = (data: any, message: string) => { + t.throws(() => ParseLR2Hook(data, logger), message); + }; + + const assertSuccess = (data: any, message: string) => { + try { + t.doesNotThrow(() => ParseLR2Hook(data, logger), message); + + const res = ParseLR2Hook(data, logger); + + t.equal(res.game, "bms"); + t.type(res.context.timeReceived, "number"); + t.ok(Array.isArray(res.iterable)); + t.equal(res.classHandler, null); + } catch (err) { + t.fail(`[${message}] ${err.message}`); + } + }; + + const dm = (data: any) => deepmerge(TestingLR2HookScore, data); + const dms = (data: any) => + deepmerge( + TestingLR2HookScore, + { scoreData: data }, + { + arrayMerge: (a, b) => b, + } + ); + + assertSuccess(TestingLR2HookScore, "Should parse a valid score."); + assertSuccess( + dm({ unexpectedField: "foo" }), + "Should allow excess keys that we do not recognise." + ); + assertSuccess( + dm({ scoreData: { unexpectedField: "foo" } }), + "Should allow excess keys inside scoreData that we do not recognise." + ); + + assertFail({}, "Should reject an empty object"); + + for (const key of [ + "pgreat", + "good", + "bad", + "poor", + "great", + "maxCombo", + "exScore", + "notesTotal", + "notesPlayed", + ]) { + assertFail(dms({ [key]: -1 }), `Should reject negative ${key}`); + assertFail(dms({ [key]: 0.5 }), `Should reject decimal ${key}`); + assertFail(dms({ [key]: "0" }), `Should reject string ${key}`); + assertFail(dms({ [key]: null }), `Should reject nonsense ${key}`); + } + + assertFail(dms({ lamp: "UNKNOWN_LAMP" }), "Should reject unknown lamp."); + assertFail(dms({ lamp: null }), "Should reject null lamp."); + assertFail(dms({ lamp: undefined }), "Should reject no lamp."); + + assertFail( + dms({ hpGraph: ApplyNTimes(999, () => 50) }), + "Should disallow hp graph with <1000 elements." + ); + assertFail(dms({ hpGraph: [] }), "Should disallow hp graph with 0 elements."); + assertFail( + dms({ hpGraph: ApplyNTimes(1001, () => 50) }), + "Should disallow hp graph with >1000 elements." + ); + assertFail( + dms({ hpGraph: ApplyNTimes(1000, () => 101) }), + "Should disallow hp graph with elements larger than 100." + ); + assertFail( + dms({ hpGraph: ApplyNTimes(1000, () => -1) }), + "Should disallow hp graph with negative element values." + ); + + t.end(); +}); diff --git a/server/src/lib/score-import/import-types/ir/lr2hook/parser.ts b/server/src/lib/score-import/import-types/ir/lr2hook/parser.ts new file mode 100644 index 000000000..5676497ca --- /dev/null +++ b/server/src/lib/score-import/import-types/ir/lr2hook/parser.ts @@ -0,0 +1,59 @@ +import { KtLogger } from "lib/logger/logger"; +import { ParserFunctionReturns } from "../../common/types"; +import { LR2HookContext, LR2HookScore } from "./types"; +import p, { PrudenceSchema } from "prudence"; +import ScoreImportFatalError from "lib/score-import/framework/score-importing/score-import-error"; +import { FormatPrError } from "utils/prudence"; + +const PR_LR2Hook: PrudenceSchema = { + md5: "string", + scoreData: { + pgreat: p.isPositiveInteger, + great: p.isPositiveInteger, + good: p.isPositiveInteger, + bad: p.isPositiveInteger, + poor: p.isPositiveInteger, + maxCombo: p.isPositiveInteger, + exScore: p.isPositiveInteger, + moneyScore: p.isPositiveInteger, + notesTotal: p.isPositiveInteger, + notesPlayed: p.isPositiveInteger, + lamp: p.isIn("NO PLAY", "FAIL", "EASY", "NORMAL", "HARD", "FULL COMBO"), + hpGraph: (self) => { + if (!Array.isArray(self)) { + return "Expected an array"; + } + if (self.length !== 1000) { + return "Expected an array with length 1000."; + } + if (self.some((x) => x < 0 || x > 100 || !Number.isInteger(x))) { + return "Expected an array of 1000 integers between 0 and 100."; + } + + return true; + }, + }, +}; + +export function ParseLR2Hook( + body: Record, + logger: KtLogger +): ParserFunctionReturns { + // Ignore excess keys, as lr2hook is likely to add more features in the future. + const err = p(body, PR_LR2Hook, undefined, { allowExcessKeys: true }); + + if (err) { + throw new ScoreImportFatalError(400, FormatPrError(err)); + } + + const score = body as unknown as LR2HookScore; + + return { + game: "bms", + iterable: [score], + context: { + timeReceived: Date.now(), + }, + classHandler: null, + }; +} diff --git a/server/src/lib/score-import/import-types/ir/lr2hook/types.ts b/server/src/lib/score-import/import-types/ir/lr2hook/types.ts new file mode 100644 index 000000000..59ff88010 --- /dev/null +++ b/server/src/lib/score-import/import-types/ir/lr2hook/types.ts @@ -0,0 +1,23 @@ +import { integer } from "tachi-common"; + +export interface LR2HookScore { + md5: string; + scoreData: { + pgreat: integer; + great: integer; + good: integer; + bad: integer; + poor: integer; + maxCombo: integer; + exScore: integer; + moneyScore: integer; + notesTotal: integer; + notesPlayed: integer; + lamp: "NO PLAY" | "FAIL" | "EASY" | "NORMAL" | "HARD" | "FULL COMBO"; + hpGraph: integer[]; + }; +} + +export interface LR2HookContext { + timeReceived: number; +} diff --git a/server/src/lib/score-import/import-types/parsers.ts b/server/src/lib/score-import/import-types/parsers.ts index 842781a87..b0d554b3a 100644 --- a/server/src/lib/score-import/import-types/parsers.ts +++ b/server/src/lib/score-import/import-types/parsers.ts @@ -17,6 +17,7 @@ import ParseDirectManual from "./ir/direct-manual/parser"; import { ParseFervidexStatic } from "./ir/fervidex-static/parser"; import { ParseFervidexSingle } from "./ir/fervidex/parser"; import { ParseKsHookSV3C } from "./ir/kshook-sv3c/parser"; +import { ParseLR2Hook } from "./ir/lr2hook/parser"; import { ParseIRUSC } from "./ir/usc/parser"; export const Parsers = { @@ -42,4 +43,5 @@ export const Parsers = { "ir/direct-manual": ParseDirectManual, "ir/usc": ParseIRUSC, "ir/kshook-sv3c": ParseKsHookSV3C, + "ir/lr2hook": ParseLR2Hook, }; diff --git a/server/src/test-utils/test-data.ts b/server/src/test-utils/test-data.ts index abeb8b1b9..23d924437 100644 --- a/server/src/test-utils/test-data.ts +++ b/server/src/test-utils/test-data.ts @@ -10,6 +10,7 @@ import { import { DryScore } from "lib/score-import/framework/common/types"; import { BarbatosScore } from "lib/score-import/import-types/ir/barbatos/types"; import { KsHookSV3CScore } from "lib/score-import/import-types/ir/kshook-sv3c/types"; +import { LR2HookScore } from "lib/score-import/import-types/ir/lr2hook/types"; import path from "path"; import { USCClientScore } from "server/router/ir/usc/_playtype/types"; import { @@ -22,6 +23,7 @@ import { SongDocument, UserGoalDocument, } from "tachi-common"; +import { ApplyNTimes, RFA } from "utils/misc"; const file = (name: string) => path.join(__dirname, "/test-data", name); @@ -41,6 +43,24 @@ export const TestingIIDXSPScore = GetKTDataJSON( export const TestingKsHookSV3CScore = GetKTDataJSON("./kshook-sv3c/base.json") as KsHookSV3CScore; +export const TestingLR2HookScore: LR2HookScore = { + md5: "38616b85332037cc12924f2ae2840262", + scoreData: { + pgreat: 1000, + great: 500, + good: 100, + bad: 50, + poor: 25, + exScore: 2500, + maxCombo: 50, + moneyScore: 150_000, + notesTotal: 2256, + notesPlayed: 2256, + lamp: "HARD", + hpGraph: ApplyNTimes(1000, () => RFA([100, 50, 80, 0])), + }, +}; + export const Testing511SPA = GetKTDataJSON("./tachi/iidx-511spa.json") as ChartDocument<"iidx:SP">; export const Testing511Song = GetKTDataJSON("./tachi/iidx-511-song.json") as SongDocument<"iidx">; diff --git a/server/src/utils/misc.ts b/server/src/utils/misc.ts index 67f00cfbb..2d6563d10 100644 --- a/server/src/utils/misc.ts +++ b/server/src/utils/misc.ts @@ -37,7 +37,7 @@ export function MStoS(ms: number) { /** * Random From Array - Selects a random value from an array. */ -export function RFA(arr: unknown[]) { +export function RFA(arr: T[]): T { return arr[Math.floor(Math.random() * arr.length)]; } @@ -133,3 +133,12 @@ export function Sleep(ms: number) { export function GetTimeXHoursAgo(hours: integer) { return Date.now() - ONE_HOUR * hours; } + +export function ApplyNTimes(n: integer, fn: (i: integer) => T): T[] { + const arr = []; + for (let i = 0; i < n; i++) { + arr.push(fn(i)); + } + + return arr; +}