diff --git a/server/package.json b/server/package.json index e9ab70e28..e21e81165 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "tachi-server", - "version": "2.0.4", + "version": "2.0.5", "description": "A score tracking server.", "main": "js/index.js", "private": true, @@ -83,7 +83,7 @@ "rimraf": "3.0.2", "safe-json-stringify": "1.2.0", "seq-logging": "^1.1.1", - "tachi-common": "0.3.14", + "tachi-common": "0.3.19", "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 e85fa1a93..63b897a34 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.14 + tachi-common: 0.3.19 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.14 + tachi-common: 0.3.19 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.14: - resolution: {integrity: sha512-LjUItf/Lp4xHdqhH1ZBE/4Czvco2WwUs2OgHIDkYSot+Wy1F/K2xycgJp6jLkXdJeeJWVO4hSIUQakzeqdhS1A==} + /tachi-common/0.3.19: + resolution: {integrity: sha512-N3/n9emh2X9pA+3jQ1UfkLX9V8hk/oHGE1GPlkReRmyIR9gRAxhf3uZCiorPqibSkPzx9LrGLJcO52FR5NQ8oA==} dependencies: monk: 7.3.4 transitivePeerDependencies: diff --git a/server/scripts/bpi-poyashi-data/update-bpi-poyashi.ts b/server/scripts/bpi-poyashi-data/update-bpi-poyashi.ts index e1e6dfb84..a380dcc42 100644 --- a/server/scripts/bpi-poyashi-data/update-bpi-poyashi.ts +++ b/server/scripts/bpi-poyashi-data/update-bpi-poyashi.ts @@ -8,7 +8,7 @@ import path from "path"; import { ChartDocument, Difficulties, IIDXBPIData } from "tachi-common"; import { FindChartWithPTDFVersion } from "utils/queries/charts"; import { FindSongOnTitle } from "utils/queries/songs"; -import { RecalcAllScores } from "../__KT_DATABASE_MIGRATION/state-sync/recalc-all-scores"; +import { RecalcAllScores } from "../state-sync/recalc-all-scores"; const program = new Command(); diff --git a/server/scripts/single-use/recalc-chunithm-scores.ts b/server/scripts/single-use/recalc-chunithm-scores.ts new file mode 100644 index 000000000..84592f80d --- /dev/null +++ b/server/scripts/single-use/recalc-chunithm-scores.ts @@ -0,0 +1,49 @@ +import db from "external/mongo/db"; +import CreateLogCtx from "lib/logger/logger"; +import { GetGradeFromPercent } from "lib/score-import/framework/common/score-utils"; +import UpdateScore from "lib/score-mutation/update-score"; +import { ChartDocument, GetGamePTConfig } from "tachi-common"; + +const logger = CreateLogCtx(__filename); + +if (require.main === module) { + let i = 0; + db.scores + .find({ game: "chunithm", "scoreData.grade": "SS" }) + // @ts-expect-error dumb + .each(async (oldScore, { pause, resume }) => { + pause(); + + const chart = (await db.charts.chunithm.findOne({ + chartID: oldScore.chartID, + })) as ChartDocument<"chunithm:Single">; + + if (!chart) { + logger.severe(`Chart ${oldScore.chartID} has no chart?`); + return resume(); + } + + const newScore = JSON.parse(JSON.stringify(oldScore)); + + newScore.scoreData.grade = GetGradeFromPercent( + "chunithm", + oldScore.playtype, + oldScore.scoreData.percent + ); + + newScore.scoreData.gradeIndex = GetGamePTConfig( + "chunithm", + oldScore.playtype + ).grades.indexOf(newScore.scoreData.grade); + + await UpdateScore(oldScore, newScore); + + i++; + + resume(); + }) + .then(() => { + logger.info(`Fixed ${i} bad scores.`); + process.exit(0); + }); +} diff --git a/server/scripts/__KT_DATABASE_MIGRATION/state-sync/create-game-profiles.ts b/server/scripts/state-sync/create-game-profiles.ts similarity index 100% rename from server/scripts/__KT_DATABASE_MIGRATION/state-sync/create-game-profiles.ts rename to server/scripts/state-sync/create-game-profiles.ts diff --git a/server/scripts/__KT_DATABASE_MIGRATION/state-sync/dedupe-score-ids.ts b/server/scripts/state-sync/dedupe-score-ids.ts similarity index 100% rename from server/scripts/__KT_DATABASE_MIGRATION/state-sync/dedupe-score-ids.ts rename to server/scripts/state-sync/dedupe-score-ids.ts diff --git a/server/scripts/__KT_DATABASE_MIGRATION/state-sync/recalc-all-scores.ts b/server/scripts/state-sync/recalc-all-scores.ts similarity index 100% rename from server/scripts/__KT_DATABASE_MIGRATION/state-sync/recalc-all-scores.ts rename to server/scripts/state-sync/recalc-all-scores.ts diff --git a/server/scripts/__KT_DATABASE_MIGRATION/state-sync/recalc-sessions.ts b/server/scripts/state-sync/recalc-sessions.ts similarity index 100% rename from server/scripts/__KT_DATABASE_MIGRATION/state-sync/recalc-sessions.ts rename to server/scripts/state-sync/recalc-sessions.ts diff --git a/server/scripts/__KT_DATABASE_MIGRATION/state-sync/sync-state.ts b/server/scripts/state-sync/sync-state.ts similarity index 100% rename from server/scripts/__KT_DATABASE_MIGRATION/state-sync/sync-state.ts rename to server/scripts/state-sync/sync-state.ts diff --git a/server/scripts/__KT_DATABASE_MIGRATION/state-sync/update-all-pbs.ts b/server/scripts/state-sync/update-all-pbs.ts similarity index 100% rename from server/scripts/__KT_DATABASE_MIGRATION/state-sync/update-all-pbs.ts rename to server/scripts/state-sync/update-all-pbs.ts diff --git a/server/src/external/mongo/schemas.ts b/server/src/external/mongo/schemas.ts index d1b7fbb40..c663cf169 100644 --- a/server/src/external/mongo/schemas.ts +++ b/server/src/external/mongo/schemas.ts @@ -373,6 +373,7 @@ export const DatabaseSchemas: Record = { ], (self) => Array.isArray(self) && self.length <= 6 ), + scoreBucket: p.isIn(null, "grade", "lamp"), gameSpecific: game === "iidx" ? { @@ -528,6 +529,7 @@ export const DatabaseSchemas: Record = { developerMode: "boolean", advancedMode: "boolean", contentiousContent: "boolean", + deletableScores: "boolean", }, }), sessions: (self) => { diff --git a/server/src/lib/constants/bms-tables.ts b/server/src/lib/constants/bms-tables.ts index 06e1be4a7..7f46dcc6d 100644 --- a/server/src/lib/constants/bms-tables.ts +++ b/server/src/lib/constants/bms-tables.ts @@ -8,4 +8,6 @@ export const BMS_TABLES = { satellite: "sl", dpNormal: "δ", dpInsane: "★", + scratch: "h◎", + ln: "◆", }; diff --git a/server/src/lib/constants/version.ts b/server/src/lib/constants/version.ts index 87b5cb835..28d7b5d9f 100644 --- a/server/src/lib/constants/version.ts +++ b/server/src/lib/constants/version.ts @@ -4,7 +4,7 @@ const MAJOR = 2; const MINOR = 0; -const PATCH = 4; +const PATCH = 5; // As is with all front-facing zkldi projects, the version names for tachi-server // are from an album I like. In this case, the album is Portishead - Dummy. diff --git a/server/src/lib/game-settings/create-game-settings.ts b/server/src/lib/game-settings/create-game-settings.ts index 19c893a9d..1d9d61e66 100644 --- a/server/src/lib/game-settings/create-game-settings.ts +++ b/server/src/lib/game-settings/create-game-settings.ts @@ -41,6 +41,7 @@ export async function CreateGameSettings(userID: integer, game: Game, playtype: preferredProfileAlg: null, preferredSessionAlg: null, preferredScoreAlg: null, + scoreBucket: null, stats: [], gameSpecific, }, diff --git a/server/src/lib/jobs/bms-table-sync.ts b/server/src/lib/jobs/bms-table-sync.ts index 51abcd0f4..54c19377f 100644 --- a/server/src/lib/jobs/bms-table-sync.ts +++ b/server/src/lib/jobs/bms-table-sync.ts @@ -84,6 +84,22 @@ const registeredTables: BMSTablesDataset[] = [ playtype: "14K", url: "https://stellabms.xyz/dp/score.json", }, + { + name: "Scratch 3rd", + description: "The 7K Sara 3 table.", + humanisedPrefix: "scr", + prefix: BMS_TABLES.scratch, + playtype: "7K", + url: "http://minddnim.web.fc2.com/sara/3rd_hard/json/data.json", + }, + { + name: "LN", + description: "The 7K LN table.", + humanisedPrefix: "ln", + prefix: BMS_TABLES.ln, + playtype: "7K", + url: "http://flowermaster.web.fc2.com/lrnanido/gla/score.json", + }, ]; /* eslint-disable no-await-in-loop */ 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/beatoraja/parser.ts b/server/src/lib/score-import/import-types/ir/beatoraja/parser.ts index de32ce9e8..7ca9671c5 100644 --- a/server/src/lib/score-import/import-types/ir/beatoraja/parser.ts +++ b/server/src/lib/score-import/import-types/ir/beatoraja/parser.ts @@ -68,6 +68,10 @@ const SUPPORTED_BEATORAJA_CLIENTS = [ "LR2oraja 0.8.2", "LR2oraja 0.8.1", "LR2oraja 0.8.0", + "LR2oraja(rekidai.info) 0.8.3", + "LR2oraja(rekidai.info) 0.8.2", + "LR2oraja(rekidai.info) 0.8.1", + "LR2oraja(rekidai.info) 0.8.0", ]; export function ParseBeatorajaSingle( 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..dde272d4a --- /dev/null +++ b/server/src/lib/score-import/import-types/ir/lr2hook/converter.ts @@ -0,0 +1,103 @@ +import { Lamps, ScoreDocument } from "tachi-common"; +import { + InternalFailure, + InvalidScoreFailure, + 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: { + random: chart.playtype === "7K" ? ConvertRandom(data.playerData.random) : null, + client: "LR2", + }, + }; + + return { song, chart, dryScore }; +}; + +function ConvertRandom( + random: LR2HookScore["playerData"]["random"] +): ScoreDocument<"bms:7K">["scoreMeta"]["random"] { + switch (random) { + case "NONRAN": + return "NONRAN"; + case "MIRROR": + return "MIRROR"; + case "RAN": + return "RANDOM"; + case "S-RAN": + return "S-RANDOM"; + } +} + +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..d84b0cc88 --- /dev/null +++ b/server/src/lib/score-import/import-types/ir/lr2hook/parser.test.ts @@ -0,0 +1,109 @@ +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." + ); + assertSuccess( + dm({ playerData: { unexpectedField: "foo" } }), + "Should allow excess keys inside playerData that we do not recognise." + ); + + assertFail( + dm({ playerData: { autoScr: true } }), + "Should reject scores where autoScr is set to true." + ); + assertFail( + dm({ playerData: { random: "H-RAN" } }), + "Should reject scores where random is set to H-RAN." + ); + assertFail( + dm({ playerData: { random: "ALLSCR" } }), + "Should reject scores where random is set to ALLSCR" + ); + + 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..7747a3071 --- /dev/null +++ b/server/src/lib/score-import/import-types/ir/lr2hook/parser.ts @@ -0,0 +1,82 @@ +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 SUPPORTED_RANDOMS = ["NONRAN", "MIRROR", "RAN", "S-RAN"]; + +const PR_LR2Hook: PrudenceSchema = { + md5: "string", + playerData: { + autoScr: p.is(false), + // don't really need this, but it's + // { "ALL", "SINGLE", "7K", "5K", "DOUBLE", "14K", "10K", "9K" }; + gameMode: p.any, + // ALLSCR and H-RAN may also be sent, but we don't support them. + random: p.isIn(SUPPORTED_RANDOMS), + }, + 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, + { + playerData: { + autoScr: + "Auto Scratch cannot be turned on, as it is treated as an EASY CLEAR by LR2.", + random: `Expected any of ${SUPPORTED_RANDOMS.join( + ", " + )}. Note that ALLSCR and H-RAN are not supported!`, + }, + }, + { 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..cf66aac8c --- /dev/null +++ b/server/src/lib/score-import/import-types/ir/lr2hook/types.ts @@ -0,0 +1,28 @@ +import { integer } from "tachi-common"; + +export interface LR2HookScore { + md5: string; + playerData: { + autoScr: boolean; + gameMode: unknown; + random: "NONRAN" | "MIRROR" | "RAN" | "S-RAN"; + }; + 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/lib/showcase/get-stats.test.ts b/server/src/lib/showcase/get-stats.test.ts index 5ec2497d6..8d8004696 100644 --- a/server/src/lib/showcase/get-stats.test.ts +++ b/server/src/lib/showcase/get-stats.test.ts @@ -26,6 +26,7 @@ t.test("#EvalulateUsersStatsShowcase", (t) => { preferredProfileAlg: null, preferredScoreAlg: null, preferredSessionAlg: null, + scoreBucket: null, stats: [ { folderID: TestingIIDXFolderSP10.folderID, diff --git a/server/src/server/router/api/v1/admin/router.ts b/server/src/server/router/api/v1/admin/router.ts index 5d1bd4783..a3b677235 100644 --- a/server/src/server/router/api/v1/admin/router.ts +++ b/server/src/server/router/api/v1/admin/router.ts @@ -8,11 +8,11 @@ import { GetUserWithID } from "utils/user"; import { ONE_MINUTE } from "lib/constants/time"; import { ServerConfig, TachiConfig } from "lib/setup/config"; import { Game, UserAuthLevels } from "tachi-common"; - import db from "external/mongo/db"; import { DeleteScore } from "lib/score-mutation/delete-scores"; -import { UpdateAllPBs } from "utils/calculations/recalc-scores"; +import { RecalcAllScores, UpdateAllPBs } from "utils/calculations/recalc-scores"; import DestroyUserGamePlaytypeData from "utils/reset-state/destroy-ugpt"; +import { RecalcSessions } from "utils/calculations/recalc-sessions"; const logger = CreateLogCtx(__filename); @@ -264,7 +264,7 @@ router.post("/delete-score", prValidate({ scoreID: "string" }), async (req, res) /** * Destroys a users UGPT profile and forces a site recalc. * - * @POST /api/v1/admin/destroy-ugpt + * @name POST /api/v1/admin/destroy-ugpt */ router.post( "/destroy-ugpt", @@ -302,7 +302,7 @@ router.post( /** * Destroy a chart and all of its scores (and sessions). * - * name @POST /api/v1/admin/destroy-chart + * @name POST /api/v1/admin/destroy-chart */ router.post( "/destroy-chart", @@ -370,4 +370,21 @@ router.post( } ); +/** + * Perform a site recalc on this set of scores. + * + * @name POST /api/v1/admin/recalc + */ +router.post("/recalc", async (req, res) => { + const filter = req.body ?? {}; + await RecalcAllScores(filter); + await RecalcSessions(filter); + + return res.status(200).json({ + success: true, + description: `Recalced scores.`, + body: {}, + }); +}); + export default router; diff --git a/server/src/server/router/api/v1/auth/auth.ts b/server/src/server/router/api/v1/auth/auth.ts index 0271925b5..9b89fff0e 100644 --- a/server/src/server/router/api/v1/auth/auth.ts +++ b/server/src/server/router/api/v1/auth/auth.ts @@ -81,6 +81,7 @@ export const DEFAULT_USER_SETTINGS: UserSettings["preferences"] = { advancedMode: false, invisible: false, contentiousContent: false, + deletableScores: false, }; export function HashPassword(plaintext: string) { diff --git a/server/src/server/router/api/v1/games/_game/_playtype/router.ts b/server/src/server/router/api/v1/games/_game/_playtype/router.ts index 0307539cd..b9afc9c92 100644 --- a/server/src/server/router/api/v1/games/_game/_playtype/router.ts +++ b/server/src/server/router/api/v1/games/_game/_playtype/router.ts @@ -76,11 +76,11 @@ router.get("/", async (req, res) => { }); /** - * Returns user-game-stats for this game in batches of 100. + * Returns user-game-stats for this game in batches of 500. * This is sorted by the games default-sorting-statistic. * * @param alg - An alternative algorithm to use instead of the gpts default. - * @param limit - How many users to return at most. Defaults (and is limited to) 50. + * @param limit - How many users to return at most. Defaults (and is limited to) 500. * * @name GET /api/v1/games/:game/:playtype/leaderboard */ @@ -89,12 +89,12 @@ router.get("/leaderboard", async (req, res) => { const playtype = req[SYMBOL_TachiData]!.playtype!; const gptConfig = GetGamePTConfig(game, playtype); - const limit = ParseStrPositiveNonZeroInt(req.query.limit) ?? 50; + const limit = ParseStrPositiveNonZeroInt(req.query.limit) ?? 100; - if (limit > 50) { + if (limit > 500) { return res.status(400).json({ success: false, - description: `Invalid limit. Limit is capped at 50.`, + description: `Invalid limit. Limit is capped at 500.`, }); } diff --git a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/sessions/router.ts b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/sessions/router.ts index 34b5dd87d..e1bc8b446 100644 --- a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/sessions/router.ts +++ b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/sessions/router.ts @@ -114,7 +114,7 @@ router.get("/recent", async (req, res) => { /** * Returns a user's most recent session. * - * @name GET /api/v1/users/:userID/games/:game/:playtype/sessions/recent + * @name GET /api/v1/users/:userID/games/:game/:playtype/sessions/last */ router.get("/last", async (req, res) => { const user = req[SYMBOL_TachiData]!.requestedUser!; diff --git a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/settings/router.test.ts b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/settings/router.test.ts index a18a340e2..ab14daedd 100644 --- a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/settings/router.test.ts +++ b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/settings/router.test.ts @@ -18,6 +18,7 @@ t.test("GET /api/v1/users/:userID/games/:game/:playtype/settings", (t) => { preferredScoreAlg: null, preferredSessionAlg: null, preferredProfileAlg: null, + scoreBucket: null, stats: [], gameSpecific: { display2DXTra: false, @@ -62,6 +63,7 @@ t.test("PATCH /api/v1/users/:userID/games/:game/:playtype/settings", (t) => { preferredScoreAlg: "ktRating", preferredSessionAlg: null, preferredProfileAlg: null, + scoreBucket: null, stats: [], gameSpecific: { display2DXTra: false, @@ -113,6 +115,7 @@ t.test("PATCH /api/v1/users/:userID/games/:game/:playtype/settings", (t) => { preferredScoreAlg: "ktRating", preferredSessionAlg: null, preferredProfileAlg: null, + scoreBucket: null, stats: [], gameSpecific: { display2DXTra: true, diff --git a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/settings/router.ts b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/settings/router.ts index 07184799e..ed865c379 100644 --- a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/settings/router.ts +++ b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/settings/router.ts @@ -5,7 +5,7 @@ import CreateLogCtx from "lib/logger/logger"; import p from "prudence"; import { RequirePermissions } from "server/middleware/auth"; import { GetGamePTConfig } from "tachi-common"; -import { FormatPrError } from "utils/prudence"; +import { FormatPrError, optNull } from "utils/prudence"; import { FormatUserDoc } from "utils/user"; import { RequireAuthedAsUser } from "../../../../middleware"; @@ -37,6 +37,7 @@ router.patch( preferredProfileAlg: p.optional(p.nullable(p.isIn(gptConfig.profileRatingAlgs))), // This is a pretty stupid IIFE level hack, ah well. gameSpecific: p.any, + scoreBucket: optNull(p.isIn("grade", "lamp")), }); if (err) { @@ -80,6 +81,10 @@ router.patch( } } + if (req.body.scoreBucket !== undefined) { + updateQuery[`preferences.scoreBucket`] = req.body.scoreBucket; + } + if (req.body.gameSpecific) { for (const key in req.body.gameSpecific) { updateQuery[`preferences.gameSpecific.${key}`] = req.body.gameSpecific[key]; diff --git a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/showcase/router.test.ts b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/showcase/router.test.ts index 426010bb6..2f4de811c 100644 --- a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/showcase/router.test.ts +++ b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/showcase/router.test.ts @@ -21,6 +21,7 @@ const SetFolders = async () => { preferredProfileAlg: null, preferredScoreAlg: null, preferredSessionAlg: null, + scoreBucket: null, stats: [ { folderID: TestingIIDXFolderSP10.folderID, @@ -82,6 +83,7 @@ t.test("GET /api/v1/users/:userID/games/:game/:playtype/showcase", (t) => { preferredProfileAlg: null, preferredScoreAlg: null, preferredSessionAlg: null, + scoreBucket: null, stats: [ { mode: "folder", diff --git a/server/src/server/router/api/v1/users/_userID/settings/router.test.ts b/server/src/server/router/api/v1/users/_userID/settings/router.test.ts index 1d1a0ccae..f68fa4f9b 100644 --- a/server/src/server/router/api/v1/users/_userID/settings/router.test.ts +++ b/server/src/server/router/api/v1/users/_userID/settings/router.test.ts @@ -19,6 +19,7 @@ t.test("GET /api/v1/users/:userID/settings", (t) => { developerMode: true, advancedMode: false, contentiousContent: false, + deletableScores: false, }, }); @@ -46,6 +47,7 @@ t.test("PATCH /api/v1/users/:userID/settings", async (t) => { developerMode: false, advancedMode: false, contentiousContent: false, + deletableScores: false, }, }); @@ -58,6 +60,7 @@ t.test("PATCH /api/v1/users/:userID/settings", async (t) => { developerMode: false, advancedMode: false, contentiousContent: false, + deletableScores: false, }, }); @@ -80,6 +83,7 @@ t.test("PATCH /api/v1/users/:userID/settings", async (t) => { developerMode: true, advancedMode: false, contentiousContent: false, + deletableScores: false, }, }, "User Settings should be unmodified." diff --git a/server/src/server/router/api/v1/users/_userID/settings/router.ts b/server/src/server/router/api/v1/users/_userID/settings/router.ts index f33393b6c..dd60422bc 100644 --- a/server/src/server/router/api/v1/users/_userID/settings/router.ts +++ b/server/src/server/router/api/v1/users/_userID/settings/router.ts @@ -55,6 +55,7 @@ router.patch( developerMode: "*boolean", contentiousContent: "*boolean", advancedMode: "*boolean", + deletableScores: "*boolean", }), async (req, res) => { const user = req[SYMBOL_TachiData]!.requestedUser!; @@ -64,6 +65,7 @@ router.patch( developerMode: req.body.developerMode, contentiousContent: req.body.contentiousContent, advancedMode: req.body.advancedMode, + deletableScores: req.body.deletableScores, }; DeleteUndefinedProps(preferences); diff --git a/server/src/server/router/ir/beatoraja/charts/router.test.ts b/server/src/server/router/ir/beatoraja/charts/router.test.ts index 38c3086ca..facf69ca8 100644 --- a/server/src/server/router/ir/beatoraja/charts/router.test.ts +++ b/server/src/server/router/ir/beatoraja/charts/router.test.ts @@ -6,6 +6,17 @@ import ResetDBState from "test-utils/resets"; t.test("GET /ir/beatoraja/charts/:chartSHA256/scores", (t) => { t.beforeEach(ResetDBState); + t.beforeEach(() => + db["api-tokens"].insert({ + userID: 1, + identifier: "Mock API Beatoraja Token", + permissions: { + submit_score: true, + }, + token: "mock_token", + fromAPIClient: null, + }) + ); const GAZER_SHA256 = "195fe1be5c3e74fccd04dc426e05f8a9cfa8a1059c339d0a23e99f63661f0b7d"; const GAZER_CHARTID = "88eb6cc5683e2740cbd07f588a5f3db1db8d467b"; @@ -35,6 +46,7 @@ t.test("GET /ir/beatoraja/charts/:chartSHA256/scores", (t) => { const res = await mockApi .get(`/ir/beatoraja/charts/${GAZER_SHA256}/scores`) + .set("Authorization", "Bearer mock_token") .set("X-TachiIR-Version", "v2.0.0"); t.equal(res.status, 200); @@ -45,6 +57,7 @@ t.test("GET /ir/beatoraja/charts/:chartSHA256/scores", (t) => { t.test("Should return 404 if chart doesnt exist", async (t) => { const res = await mockApi .get(`/ir/beatoraja/charts/INVALID/scores`) + .set("Authorization", "Bearer mock_token") .set("X-TachiIR-Version", "v2.0.0"); t.equal(res.status, 404); diff --git a/server/src/server/router/ir/lr2hook/router.test.ts b/server/src/server/router/ir/lr2hook/router.test.ts new file mode 100644 index 000000000..b97c1b146 --- /dev/null +++ b/server/src/server/router/ir/lr2hook/router.test.ts @@ -0,0 +1,61 @@ +import db from "external/mongo/db"; +import t from "tap"; +import mockApi from "test-utils/mock-api"; +import ResetDBState from "test-utils/resets"; +import { TestingLR2HookScore } from "test-utils/test-data"; + +t.test("POST /ir/lr2hook/import", (t) => { + t.beforeEach(ResetDBState); + t.beforeEach(async () => { + await db["api-tokens"].insert({ + token: "foo", + permissions: { submit_score: true }, + fromAPIClient: null, + identifier: "foo", + userID: 1, + }); + }); + + t.test("Should import a score if the API token has the right permissions.", async (t) => { + const res = await mockApi + .post("/ir/lr2hook/import") + .set("Authorization", "Bearer foo") + .send(TestingLR2HookScore); + + t.equal(res.statusCode, 200); + + t.end(); + }); + + t.test("Should import a score if the API token has the right permissions.", async (t) => { + await db["api-tokens"].insert({ + token: "bar", + permissions: { submit_score: false }, + fromAPIClient: null, + identifier: "bar", + userID: 1, + }); + + const res = await mockApi + .post("/ir/lr2hook/import") + .set("Authorization", "Bearer bar") + .send(TestingLR2HookScore); + + t.equal(res.statusCode, 403); + + t.end(); + }); + + t.test("Should import a score if the API token has the right permissions.", async (t) => { + const res = await mockApi + .post("/ir/lr2hook/import") + .set("Authorization", "Bearer unknown token") + .send(TestingLR2HookScore); + + t.equal(res.statusCode, 401); + + t.end(); + }); + + t.end(); +}); diff --git a/server/src/server/router/ir/lr2hook/router.ts b/server/src/server/router/ir/lr2hook/router.ts new file mode 100644 index 000000000..1ff2458ae --- /dev/null +++ b/server/src/server/router/ir/lr2hook/router.ts @@ -0,0 +1,26 @@ +import { Router } from "express"; +import { SYMBOL_TachiAPIAuth } from "lib/constants/tachi"; +import { ExpressWrappedScoreImportMain } from "lib/score-import/framework/express-wrapper"; +import { RequirePermissions } from "server/middleware/auth"; + +const router: Router = Router({ mergeParams: true }); + +router.use(RequirePermissions("submit_score")); + +/** + * Import a score with the LR2Hook Format. + * + * @name POST /ir/lr2hook/import + */ +router.post("/import", async (req, res) => { + const importRes = await ExpressWrappedScoreImportMain( + req[SYMBOL_TachiAPIAuth].userID!, + false, + "ir/lr2hook", + [req.body] + ); + + return res.status(importRes.statusCode).json(importRes.body); +}); + +export default router; diff --git a/server/src/server/router/ir/router.ts b/server/src/server/router/ir/router.ts index 67c864d13..5dd7ce82c 100644 --- a/server/src/server/router/ir/router.ts +++ b/server/src/server/router/ir/router.ts @@ -12,13 +12,13 @@ import directManualIR from "./direct-manual/router"; import fervidexIR from "./fervidex/router"; import ksHookIR from "./kshook/router"; import uscIR from "./usc/router"; +import lr2hookIR from "./lr2hook/router"; const router: Router = Router({ mergeParams: true }); // Common IRs router.use("/direct-manual", SetRequestPermissions, RequireNotGuest, directManualIR); -router.use("/kshook", SetFervidexStyleRequestPermissions, FervidexStyleRequireNotGuest, ksHookIR); // Bokutachi IRs @@ -26,10 +26,18 @@ router.use("/kshook", SetFervidexStyleRequestPermissions, FervidexStyleRequireNo // auth, because the USCIR spec requires a different set of response // codes for auth. router.use("/usc", RequireBokutachi, uscIR); -router.use("/beatoraja", SetRequestPermissions, RequireBokutachi, beatorajaIR); +router.use("/beatoraja", SetRequestPermissions, RequireNotGuest, RequireBokutachi, beatorajaIR); +router.use("/lr2hook", SetRequestPermissions, RequireNotGuest, RequireBokutachi, lr2hookIR); // Kamaitachi IRs +router.use( + "/kshook", + RequireKamaitachi, + SetFervidexStyleRequestPermissions, + FervidexStyleRequireNotGuest, + ksHookIR +); router.use("/barbatos", SetRequestPermissions, RequireNotGuest, RequireKamaitachi, barbatosIR); router.use( "/fervidex", 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 2b279d756..8fd934596 100644 --- a/server/src/server/router/ir/usc/_playtype/router.test.ts +++ b/server/src/server/router/ir/usc/_playtype/router.test.ts @@ -71,17 +71,18 @@ t.test("GET /ir/usc/Keyboard", async (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); + t.beforeEach(async () => { + await db["api-tokens"].insert({ + userID: 1, + identifier: "USC Token", + permissions: { + submit_score: true, + }, + token: "foo", + fromAPIClient: null, + }); + }); TestAuth("/ir/usc/Controller"); diff --git a/server/src/test-utils/mock-db/game-settings.json b/server/src/test-utils/mock-db/game-settings.json index 8ffa629fd..3a34d14dc 100644 --- a/server/src/test-utils/mock-db/game-settings.json +++ b/server/src/test-utils/mock-db/game-settings.json @@ -7,6 +7,7 @@ "preferredScoreAlg": null, "preferredSessionAlg": null, "preferredProfileAlg": null, + "scoreBucket": null, "stats": [], "gameSpecific": { "display2DXTra": false diff --git a/server/src/test-utils/mock-db/user-settings.json b/server/src/test-utils/mock-db/user-settings.json index 374504e92..ba919f0eb 100644 --- a/server/src/test-utils/mock-db/user-settings.json +++ b/server/src/test-utils/mock-db/user-settings.json @@ -5,7 +5,8 @@ "developerMode": true, "invisible": false, "advancedMode": false, - "contentiousContent": false + "contentiousContent": false, + "deletableScores": false } } ] \ No newline at end of file diff --git a/server/src/test-utils/test-data.ts b/server/src/test-utils/test-data.ts index abeb8b1b9..981b57735 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,29 @@ export const TestingIIDXSPScore = GetKTDataJSON( export const TestingKsHookSV3CScore = GetKTDataJSON("./kshook-sv3c/base.json") as KsHookSV3CScore; +export const TestingLR2HookScore: LR2HookScore = { + md5: "38616b85332037cc12924f2ae2840262", + playerData: { + autoScr: false, + gameMode: "7K", + random: "RAN", + }, + 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/folder.test.ts b/server/src/utils/folder.test.ts index afb5cdd1b..6bdd06a21 100644 --- a/server/src/utils/folder.test.ts +++ b/server/src/utils/folder.test.ts @@ -4,7 +4,7 @@ import { ChartDocument, FolderDocument } from "tachi-common"; import t from "tap"; import ResetDBState from "test-utils/resets"; import { Testing511SPA } from "test-utils/test-data"; -import { GetFolderChartIDs, ResolveFolderToCharts } from "./folder"; +import { GetFolderChartIDs, ResolveFolderToCharts, TransposeFolderData } from "./folder"; t.todo("#CreateFolderChartLookup"); t.todo("#GetFolderCharts"); @@ -198,3 +198,47 @@ t.test("#GetFolderChartIDs", (t) => { t.end(); }); + +t.test("#TransposeFolderData", (t) => { + t.strictSame( + TransposeFolderData({ + "foo¬bar": 1, + }), + { + "foo.bar": 1, + }, + "Should transpose single keys." + ); + + t.strictSame( + TransposeFolderData({ + "foo¬bar": 1, + "foo¬baz": 2, + "foo~bar": 2, + "foo~baz": 2, + }), + { + "foo.bar": 1, + "foo.baz": 2, + foo$bar: 2, + foo$baz: 2, + }, + "Should transpose multiple keys." + ); + + t.strictSame( + TransposeFolderData({ + "foo¬bar¬baz": 1, + "foo¬bar~baz": 1, + "foo~bar~baz": 1, + }), + { + "foo.bar$baz": 1, + "foo.bar.baz": 1, + foo$bar$baz: 1, + }, + "Should transpose multiple items in one key." + ); + + t.end(); +}); diff --git a/server/src/utils/folder.ts b/server/src/utils/folder.ts index 3f2fc40f3..59e31c8af 100644 --- a/server/src/utils/folder.ts +++ b/server/src/utils/folder.ts @@ -93,7 +93,7 @@ export function TransposeFolderData(obj: Record) { const transposedObj: Record = {}; for (const key in obj) { - const transposedKey = key.replace(/~/u, "$").replace(/¬/u, "."); + const transposedKey = key.replace(/~/gu, "$").replace(/¬/gu, "."); if (typeof obj[key] === "object" && obj[key]) { transposedObj[transposedKey] = TransposeFolderData(obj[key] as Record); 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; +}