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 d1ee92051..01e441c79 100644 --- a/server/src/lib/score-import/import-types/common/types.ts +++ b/server/src/lib/score-import/import-types/common/types.ts @@ -8,6 +8,7 @@ import type { BarbatosContext, BarbatosScore, BarbatosSDVX6Score } from "../ir/b import type { BeatorajaContext, BeatorajaScore } from "../ir/beatoraja/types"; import type { FervidexStaticContext, FervidexStaticScore } from "../ir/fervidex-static/types"; import type { FervidexContext, FervidexScore } from "../ir/fervidex/types"; +import type { KsHookSV6CStaticScore } from "../ir/kshook-sv6c-static/types"; import type { KsHookSV6CContext, KsHookSV6CScore } from "../ir/kshook-sv6c/types"; import type { LR2HookContext, LR2HookScore } from "../ir/lr2hook/types"; import type { IRUSCContext } from "../ir/usc/types"; @@ -42,6 +43,7 @@ export interface ImportTypeDataMap { "ir/beatoraja": BeatorajaScore; "ir/usc": USCClientScore; "ir/kshook-sv6c": KsHookSV6CScore; + "ir/kshook-sv6c-static": KsHookSV6CStaticScore; "ir/lr2hook": LR2HookScore; // These aren't placeholder values - the data is yielded in a way that @@ -70,6 +72,7 @@ export interface ImportTypeContextMap { "ir/beatoraja": BeatorajaContext; "ir/usc": IRUSCContext; "ir/kshook-sv6c": KsHookSV6CContext; + "ir/kshook-sv6c-static": EmptyObject; "ir/lr2hook": LR2HookContext; "api/arc-iidx": EmptyObject; diff --git a/server/src/lib/score-import/import-types/converters.ts b/server/src/lib/score-import/import-types/converters.ts index af7e211ba..89990b8fc 100644 --- a/server/src/lib/score-import/import-types/converters.ts +++ b/server/src/lib/score-import/import-types/converters.ts @@ -11,6 +11,7 @@ import { ConverterIRBarbatos } from "./ir/barbatos/converter"; import { ConverterIRBeatoraja } from "./ir/beatoraja/converter"; import { ConverterIRFervidexStatic } from "./ir/fervidex-static/converter"; import { ConverterIRFervidex } from "./ir/fervidex/converter"; +import { ConverterKsHookSV6CStatic } from "./ir/kshook-sv6c-static/converter"; import { ConverterIRKsHookSV6C } from "./ir/kshook-sv6c/converter"; import { ConverterLR2Hook } from "./ir/lr2hook/converter"; import { ConverterIRUSC } from "./ir/usc/converter"; @@ -44,5 +45,6 @@ export const Converters: ConverterMap = { "ir/direct-manual": ConverterBatchManual, "ir/usc": ConverterIRUSC, "ir/kshook-sv6c": ConverterIRKsHookSV6C, + "ir/kshook-sv6c-static": ConverterKsHookSV6CStatic, "ir/lr2hook": ConverterLR2Hook, }; diff --git a/server/src/lib/score-import/import-types/ir/kshook-sv6c-static/converter.test.ts b/server/src/lib/score-import/import-types/ir/kshook-sv6c-static/converter.test.ts new file mode 100644 index 000000000..8298428d4 --- /dev/null +++ b/server/src/lib/score-import/import-types/ir/kshook-sv6c-static/converter.test.ts @@ -0,0 +1,70 @@ +import { ConverterKsHookSV6CStatic } from "./converter"; +import CreateLogCtx from "lib/logger/logger"; +import t from "tap"; +import ResetDBState from "test-utils/resets"; +import { TestingKsHookSV6CStaticScore } from "test-utils/test-data"; + +const logger = CreateLogCtx(__filename); + +t.test("#ConverterKsHookSV6CStatic", (t) => { + t.beforeEach(ResetDBState); + + t.test("Should match a score with its song and chart.", async (t) => { + const res = await ConverterKsHookSV6CStatic( + TestingKsHookSV6CStaticScore, + {}, + "ir/kshook-sv6c-static", + logger + ); + + t.hasStrict(res, { + song: { + id: 1, + }, + chart: { + data: { + inGameID: 1, + }, + difficulty: "ADV", + }, + dryScore: { + scoreData: { + score: 9_579_365, + grade: "AA+", + lamp: "EXCESSIVE CLEAR", + judgements: { + critical: 1184, + near: 46, + miss: 30, + }, + hitMeta: { + maxCombo: 158, + exScore: 1334, + }, + }, + game: "sdvx", + importType: "ir/kshook-sv6c", + scoreMeta: {}, + }, + }); + + t.end(); + }); + + t.test("Should throw an error if song or chart can't be found.", (t) => { + t.rejects( + () => + ConverterKsHookSV6CStatic( + { ...TestingKsHookSV6CStaticScore, music_id: 10000 }, + {}, + "ir/kshook-sv6c", + 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/kshook-sv6c-static/converter.ts b/server/src/lib/score-import/import-types/ir/kshook-sv6c-static/converter.ts new file mode 100644 index 000000000..843fc9722 --- /dev/null +++ b/server/src/lib/score-import/import-types/ir/kshook-sv6c-static/converter.ts @@ -0,0 +1,61 @@ +import { SV6CConvertDifficulty, SV6CConvertLamp } from "../kshook-sv6c/converter"; +import { + InternalFailure, + KTDataNotFoundFailure, +} from "lib/score-import/framework/common/converter-failures"; +import { GenericGetGradeAndPercent } from "lib/score-import/framework/common/score-utils"; +import { FindSDVXChartOnInGameIDVersion } from "utils/queries/charts"; +import { FindSongOnID } from "utils/queries/songs"; +import type { ConverterFunction } from "../../common/types"; +import type { KsHookSV6CStaticScore } from "./types"; +import type { DryScore } from "lib/score-import/framework/common/types"; +import type { EmptyObject } from "utils/types"; + +export const ConverterKsHookSV6CStatic: ConverterFunction< + KsHookSV6CStaticScore, + EmptyObject +> = async (data, context, importType, logger) => { + const diff = SV6CConvertDifficulty(data.difficulty); + + const chart = await FindSDVXChartOnInGameIDVersion(data.music_id, diff, "konaste"); + + if (!chart) { + throw new KTDataNotFoundFailure( + `Could not find chart with songID ${data.music_id} (${diff} for Konaste).`, + importType, + data, + context + ); + } + + const song = await FindSongOnID("sdvx", chart.songID); + + if (!song) { + logger.severe(`Song ${chart.songID} (sdvx) has no parent song?`); + throw new InternalFailure(`Song ${chart.songID} (sdvx) has no parent song?`); + } + + const { percent, grade } = GenericGetGradeAndPercent("sdvx", data.score, chart); + + const dryScore: DryScore<"sdvx:Single"> = { + game: "sdvx", + service: "kshook SV6C Static", + comment: null, + importType: "ir/kshook-sv6c", + timeAchieved: data.timestamp, + scoreData: { + score: data.score, + percent, + grade, + lamp: SV6CConvertLamp(data.clear), + judgements: {}, + hitMeta: { + maxCombo: data.max_chain, + exScore: data.ex_score, + }, + }, + scoreMeta: {}, + }; + + return { song, chart, dryScore }; +}; diff --git a/server/src/lib/score-import/import-types/ir/kshook-sv6c-static/parser.test.ts b/server/src/lib/score-import/import-types/ir/kshook-sv6c-static/parser.test.ts new file mode 100644 index 000000000..7090defb7 --- /dev/null +++ b/server/src/lib/score-import/import-types/ir/kshook-sv6c-static/parser.test.ts @@ -0,0 +1,57 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { ParseKsHookSV6CStatic } from "./parser"; +import deepmerge from "deepmerge"; +import CreateLogCtx from "lib/logger/logger"; +import t from "tap"; +import { TestingKsHookSV6CScore, TestingKsHookSV6CStaticScore } from "test-utils/test-data"; + +const logger = CreateLogCtx(__filename); + +t.test("#ParseKsHookSV6CStatic", (t) => { + const assertFail = (data: any, message: string) => { + t.throws(() => ParseKsHookSV6CStatic(data, logger), message); + }; + + const assertSuccess = (data: any, message: string) => { + try { + t.doesNotThrow(() => ParseKsHookSV6CStatic(data, logger), message); + + const res = ParseKsHookSV6CStatic(data, logger); + + t.equal(res.game, "sdvx"); + t.ok(Array.isArray(res.iterable)); + } catch (err) { + t.fail(`[${message}] ${(err as Error).message}`); + } + }; + + const dm = (data: any) => ({ scores: [deepmerge(TestingKsHookSV6CStaticScore, data)] }); + + assertSuccess({ scores: [TestingKsHookSV6CStaticScore] }, "Should parse a valid score."); + assertSuccess( + dm({ unexpectedField: "foo" }), + "Should allow excess keys that we do not recognise." + ); + + assertFail({}, "Should reject an empty object"); + assertFail({ scores: TestingKsHookSV6CStaticScore }, "Should reject non-array scores"); + assertFail(dm({ clear: "invalid_clear" }), "Should reject invalid clears."); + assertFail(dm({ difficulty: "invalid_difficulty" }), "Should reject invalid difficulties."); + + assertFail(dm({ grade: "invalid_grade" }), "Should reject invalid grades."); + + assertFail(dm({ max_chain: -1 }), "Should reject negative max_chains."); + assertFail(dm({ max_chain: 100.5 }), "Should reject non-integer max_chains."); + + assertFail(dm({ score: -1 }), "Should reject negative scores."); + assertFail(dm({ score: 10_000_001 }), "Should reject > 10m scores."); + assertSuccess(dm({ score: 10_000_000 }), "Should allow scores of 10m."); + assertSuccess(dm({ score: 0 }), "Should allow scores of 0."); + + assertSuccess( + { scores: [TestingKsHookSV6CStaticScore, TestingKsHookSV6CStaticScore] }, + "Should allow multiple scores in the scores array." + ); + + t.end(); +}); diff --git a/server/src/lib/score-import/import-types/ir/kshook-sv6c-static/parser.ts b/server/src/lib/score-import/import-types/ir/kshook-sv6c-static/parser.ts new file mode 100644 index 000000000..bb1e3745a --- /dev/null +++ b/server/src/lib/score-import/import-types/ir/kshook-sv6c-static/parser.ts @@ -0,0 +1,46 @@ +import { PR_KSHOOK_SV6C } from "../kshook-sv6c/parser"; +import ScoreImportFatalError from "lib/score-import/framework/score-importing/score-import-error"; +import p from "prudence"; +import { FormatPrError } from "utils/prudence"; +import type { ParserFunctionReturns } from "../../common/types"; +import type { KsHookSV6CStaticBody, KsHookSV6CStaticScore } from "./types"; +import type { KtLogger } from "lib/logger/logger"; +import type { PrudenceSchema } from "prudence"; +import type { EmptyObject } from "utils/types"; + +const PR_KSHOOK_SV6C_STATIC: PrudenceSchema = { + scores: [ + { + score: PR_KSHOOK_SV6C.score!, + ex_score: PR_KSHOOK_SV6C.ex_score!, + clear: PR_KSHOOK_SV6C.clear!, + difficulty: PR_KSHOOK_SV6C.difficulty!, + grade: PR_KSHOOK_SV6C.grade!, + max_chain: PR_KSHOOK_SV6C.max_chain!, + music_id: PR_KSHOOK_SV6C.music_id!, + + timestamp: p.isPositiveInteger, + }, + ], +}; + +export function ParseKsHookSV6CStatic( + body: Record, + _logger: KtLogger +): ParserFunctionReturns { + // Ignore excess keys, as SV6C might add more features in the future. + const err = p(body, PR_KSHOOK_SV6C_STATIC, undefined, { allowExcessKeys: true }); + + if (err) { + throw new ScoreImportFatalError(400, FormatPrError(err)); + } + + const data = body as unknown as KsHookSV6CStaticBody; + + return { + game: "sdvx", + iterable: data.scores, + context: {}, + classHandler: null, + }; +} diff --git a/server/src/lib/score-import/import-types/ir/kshook-sv6c-static/types.ts b/server/src/lib/score-import/import-types/ir/kshook-sv6c-static/types.ts new file mode 100644 index 000000000..98b9de302 --- /dev/null +++ b/server/src/lib/score-import/import-types/ir/kshook-sv6c-static/types.ts @@ -0,0 +1,17 @@ +import type { KsHookSV6CScore } from "../kshook-sv6c/types"; + +export interface KsHookSV6CStaticScore { + clear: KsHookSV6CScore["clear"]; + difficulty: KsHookSV6CScore["difficulty"]; + grade: KsHookSV6CScore["grade"]; + + ex_score: number; + max_chain: number; + music_id: number; + score: number; + timestamp: number; +} + +export interface KsHookSV6CStaticBody { + scores: Array; +} diff --git a/server/src/lib/score-import/import-types/ir/kshook-sv6c/converter.ts b/server/src/lib/score-import/import-types/ir/kshook-sv6c/converter.ts index b69ecafdb..fef2890c6 100644 --- a/server/src/lib/score-import/import-types/ir/kshook-sv6c/converter.ts +++ b/server/src/lib/score-import/import-types/ir/kshook-sv6c/converter.ts @@ -16,7 +16,7 @@ export const ConverterIRKsHookSV6C: ConverterFunction { - const diff = ConvertDifficulty(data.difficulty); + const diff = SV6CConvertDifficulty(data.difficulty); const chart = await FindSDVXChartOnInGameIDVersion(data.music_id, diff, "konaste"); @@ -48,7 +48,7 @@ export const ConverterIRKsHookSV6C: ConverterFunction { return res.status(responseData.statusCode).json(responseData.body); }); +/** + * Imports statically from KsHook. Analogous to fervidex-static. + * + * @name POST /ir/kshook/sv6c/score/export + */ +router.post("/score/save", async (req, res) => { + const responseData = await ExpressWrappedScoreImportMain( + req[SYMBOL_TACHI_API_AUTH].userID!, + true, + "ir/kshook-sv6c-static", + [req.safeBody] + ); + + if (!responseData.body.success) { + // in-air rewrite description to error. + // @ts-expect-error Hack! + responseData.body.error = responseData.body.description; + + // @ts-expect-error Hack! + delete responseData.body.description; + } + + return res.status(responseData.statusCode).json(responseData.body); +}); + export default router; diff --git a/server/src/test-utils/test-data.ts b/server/src/test-utils/test-data.ts index 4e53527ed..74924b5f5 100644 --- a/server/src/test-utils/test-data.ts +++ b/server/src/test-utils/test-data.ts @@ -17,6 +17,7 @@ import type { BarbatosSDVX6Score, } from "lib/score-import/import-types/ir/barbatos/types"; import type { FervidexScore } from "lib/score-import/import-types/ir/fervidex/types"; +import type { KsHookSV6CStaticScore } from "lib/score-import/import-types/ir/kshook-sv6c-static/types"; import type { KsHookSV6CScore } from "lib/score-import/import-types/ir/kshook-sv6c/types"; import type { LR2HookScore } from "lib/score-import/import-types/ir/lr2hook/types"; import type { USCClientScore } from "server/router/ir/usc/_playtype/types"; @@ -201,6 +202,18 @@ export const TestingKsHookSV6CScore: KsHookSV6CScore = { ex_score: 1334, retry_count: 0, }; + +export const TestingKsHookSV6CStaticScore: KsHookSV6CStaticScore = { + clear: "CLEAR_EXCESSIVE", + difficulty: "DIFFICULTY_ADVANCED", + grade: "GRADE_AA_PLUS", + max_chain: 158, + music_id: 1, + score: 9579365, + ex_score: 1334, + timestamp: 1000, +}; + export const TestingBMS7KScore: ScoreDocument<"bms:7K"> = { calculatedData: { sieglinde: 20,