diff --git a/client/package.json b/client/package.json index a6a37359e..9fd48efd6 100644 --- a/client/package.json +++ b/client/package.json @@ -60,6 +60,7 @@ "postcss-safe-parser": "4.0.1", "pretty-bytes": "5.6.0", "prop-types": "15.6.0", + "prudence": "0.9.8", "react": "17.0.2", "react-async": "10.0.1", "react-bootstrap": "1.6.1", diff --git a/client/src/app/pages/dashboard/import/BatchManualPage.tsx b/client/src/app/pages/dashboard/import/BatchManualPage.tsx index d3854ab16..183589887 100644 --- a/client/src/app/pages/dashboard/import/BatchManualPage.tsx +++ b/client/src/app/pages/dashboard/import/BatchManualPage.tsx @@ -2,8 +2,15 @@ import ImportFileInfo from "components/imports/ImportFileInfo"; import useSetSubheader from "components/layout/header/useSetSubheader"; import { TachiConfig } from "lib/config"; import React, { useState } from "react"; -import { BatchManual, FormatGame, GetGameConfig, GetGamePTConfig } from "tachi-common"; -import { ImportStates, NotStartedState } from "types/import"; +import { + BatchManual, + FormatGame, + FormatPrError, + GetGameConfig, + GetGamePTConfig, +} from "tachi-common"; +import p from "prudence"; +import { PR_BATCH_MANUAL } from "tachi-common/lib/schemas"; export default function BatchManualPage() { useSetSubheader(["Dashboard", "Import Scores", "Batch Manual"]); @@ -32,6 +39,12 @@ export default function BatchManualPage() { ); } + const err = p(data, PR_BATCH_MANUAL(data.meta.game, data.meta.playtype)); + + if (err) { + throw new Error(FormatPrError(err, "Invalid BATCH-MANUAL: ")); + } + return { valid: true, info: { diff --git a/common/package.json b/common/package.json index 346382e56..630ea7810 100644 --- a/common/package.json +++ b/common/package.json @@ -17,6 +17,7 @@ "dependencies": { "@types/express-serve-static-core": "4.17.29", "@types/mongodb": "3.6.18", + "deepmerge": "^4.2.2", "prudence": "0.9.8" }, "devDependencies": { diff --git a/common/src/lib/schemas.ts b/common/src/lib/schemas.ts index 8253b1692..f16c1c051 100644 --- a/common/src/lib/schemas.ts +++ b/common/src/lib/schemas.ts @@ -6,9 +6,10 @@ import { allIDStrings, allImportTypes, allSupportedGames } from "../config/stati import { ALL_PERMISSIONS } from "../constants/permissions"; import { GetGameConfig, GetGamePTConfig } from "../index"; import { UserAuthLevels } from "../types"; +import deepmerge from "deepmerge"; import p from "prudence"; import type { GamePTConfig } from "../index"; -import type { Game, IDStrings, NotificationBody, Playtypes } from "../types"; +import type { Game, IDStrings, NotificationBody, Playtypes, Playtype } from "../types"; import type { PrudenceSchema, ValidationFunctionParentOptionsKeychain, @@ -1186,3 +1187,229 @@ const PRE_SCHEMAS = { } as const; export const SCHEMAS: Record = PRE_SCHEMAS; + +const BaseValidHitMeta = { + fast: optNull(p.isPositiveInteger), + slow: optNull(p.isPositiveInteger), + maxCombo: optNull(p.isPositiveInteger), +}; + +const PR_DP_RANDOM = (self: unknown) => { + if (!Array.isArray(self) || self.length !== 2) { + return "Expected an array with length 2"; + } + + for (const a of self) { + if (p.isIn("NONRAN", "RANDOM", "R-RANDOM", "S-RANDOM", "MIRROR")(a) !== true) { + return false; + } + } + + return true; +}; + +const PR_SCORE_META = (game: Game, playtype: Playtype): PrudenceSchema => { + if (game === "iidx") { + const random = + playtype === "SP" + ? optNull(p.isIn("NONRAN", "RANDOM", "R-RANDOM", "S-RANDOM", "MIRROR")) + : PR_DP_RANDOM; + + return { + random, + assist: optNull(p.isIn("NO ASSIST", "AUTO SCRATCH", "LEGACY NOTE", "FULL ASSIST")), + range: optNull(p.isIn("NONE", "SUDDEN+", "HIDDEN+", "SUD+ HID+", "LIFT", "LIFT SUD+")), + gauge: optNull(p.isIn("ASSISTED EASY", "EASY", "NORMAL", "HARD", "EX HARD")), + }; + } else if (game === "bms") { + const random = + playtype === "7K" + ? optNull(p.isIn("NONRAN", "RANDOM", "R-RANDOM", "S-RANDOM", "MIRROR")) + : optNull(PR_DP_RANDOM); + + return { + random, + inputDevice: optNull(p.isIn("KEYBOARD", "BM_CONTROLLER")), + client: optNull(p.isIn("LR2", "lr2oraja")), + }; + } else if (game === "usc") { + return { + noteMod: optNull(p.isIn("NORMAL", "MIRROR", "RANDOM", "MIR-RAN")), + gaugeMod: optNull(p.isIn("NORMAL", "HARD", "PERMISSIVE")), + }; + } else if (game === "sdvx") { + return { + inSkillAnalyser: "*?boolean", + }; + } else if (game === "wacca") { + return { mirror: "*?boolean" }; + } else if (game === "popn") { + return { + hiSpeed: optNull(p.isPositive), + hidden: optNull(p.isInteger), + sudden: optNull(p.isInteger), + random: optNull(p.isIn("NONRAN", "MIRROR", "RANDOM", "S-RANDOM")), + gauge: optNull(p.isIn("NORMAL", "EASY", "HARD", "DANGER")), + }; + } + + return {}; +}; + +const PR_HIT_META = (game: Game): PrudenceSchema => { + if (game === "iidx") { + return { + bp: optNull(p.isPositiveInteger), + gauge: optNull(p.isBetween(0, 100)), + gaugeHistory: optNull([p.isBetween(0, 100)]), + scoreHistory: optNull([p.isPositiveInteger]), + comboBreak: optNull(p.isPositiveInteger), + gsm: optNull({ + EASY: [p.nullable(p.isBetween(0, 100))], + NORMAL: [p.nullable(p.isBetween(0, 100))], + HARD: [p.nullable(p.isBetween(0, 100))], + EX_HARD: [p.nullable(p.isBetween(0, 100))], + }), + }; + } else if (game === "sdvx") { + return { + gauge: optNull(p.isBetween(0, 100)), + }; + } else if (game === "usc") { + return { gauge: optNull(p.isBetween(0, 1)) }; + } else if (game === "bms") { + return { + bp: optNull(p.isPositiveInteger), + gauge: optNull(p.isBetween(0, 100)), + lbd: optNull(p.isPositiveInteger), + ebd: optNull(p.isPositiveInteger), + lpr: optNull(p.isPositiveInteger), + epr: optNull(p.isPositiveInteger), + lgd: optNull(p.isPositiveInteger), + egd: optNull(p.isPositiveInteger), + lgr: optNull(p.isPositiveInteger), + egr: optNull(p.isPositiveInteger), + lpg: optNull(p.isPositiveInteger), + epg: optNull(p.isPositiveInteger), + }; + } else if (game === "popn") { + return { + gauge: optNull(p.isBetween(0, 100)), + specificClearType: optNull( + p.isIn( + "failedUnknown", + "failedCircle", + "failedDiamond", + "failedStar", + "easyClear", + "clearCircle", + "clearDiamond", + "clearStar", + "fullComboCircle", + "fullComboDiamond", + "fullComboStar", + "perfect" + ) + ), + }; + } + + return {}; +}; + +const PR_BATCH_MANUAL_SCORE = (game: Game, playtype: Playtype): PrudenceSchema => { + const gptConfig = GetGamePTConfig(game, playtype); + + const scoreChecker = + game === "itg" || game === "gitadora" ? p.isBetween(0, 100) : p.isPositiveInteger; + + return { + score: scoreChecker, + lamp: p.isIn(gptConfig.lamps), + matchType: p.isIn( + "songTitle", + "ddrSongHash", + "tachiSongID", + "bmsChartHash", + "itgChartHash", + "inGameID", + "uscChartHash", + "popnChartHash" + ), + identifier: "string", + percent: game === "jubeat" ? p.isBetween(0, 120) : p.is(undefined), + comment: optNull(p.isBoundedString(3, 240)), + difficulty: "*?string", + + // this is checked in converting instead + // the lowest acceptable time is september 9th 2001 - this check saves people who dont + // read any documentation and would otherwise submit garbage. + timeAchieved: optNull( + (self) => + (typeof self === "number" && self > 1_000_000_000_000) || + self === 0 || + "Expected a number greater than 1 Trillion - did you pass unix seconds instead of milliseconds?" + ), + judgements: optNull((self) => { + if (typeof self !== "object" || self === null) { + return "Not a valid object."; + } + + for (const [key, v] of Object.entries(self)) { + // @ts-expect-error Frustrating check as it's a string but not a judgement so it's not really + // happy with it. + if (!gptConfig.judgements.includes(key)) { + return `Invalid Key ${key}. Expected any of ${gptConfig.judgements.join(", ")}`; + } + + if ((!Number.isSafeInteger(v) || v < 0) && v !== null) { + return `Key ${key} had an invalid value of ${v} [type: ${typeof v}]`; + } + } + + return true; + }), + hitMeta: optNull( + deepmerge(BaseValidHitMeta, PR_HIT_META(game)) as unknown as ValidSchemaValue + ), + scoreMeta: optNull(PR_SCORE_META(game, playtype)), + }; +}; + +// both iidx sp and dp share dans. +const IIDXStringDans = GetGamePTConfig("iidx", "SP").classHumanisedFormat.dan.map((e) => e.id); +const SDVXStringDans = GetGamePTConfig("sdvx", "Single").classHumanisedFormat.dan.map((e) => e.id); +const WaccaStringStageUps = GetGamePTConfig("wacca", "Single").classHumanisedFormat.stageUp.map( + (e) => e.id +); + +const PR_BATCH_MANUAL_CLASSES = (game: Game): PrudenceSchema => { + // This can be implemented for any non-static class (i.e. dans). + switch (game) { + case "iidx": + return { + dan: optNull(p.isIn(IIDXStringDans)), + }; + case "sdvx": + return { + dan: optNull(p.isIn(SDVXStringDans)), + }; + case "wacca": + return { + stageUp: optNull(p.isIn(WaccaStringStageUps)), + }; + default: + return {}; + } +}; + +export const PR_BATCH_MANUAL = (game: Game, playtype: Playtype): PrudenceSchema => ({ + meta: { + service: p.isBoundedString(3, 60), + game: p.isIn(allSupportedGames), + playtype: p.is(playtype), + version: "*?string", + }, + scores: [PR_BATCH_MANUAL_SCORE(game, playtype)], + classes: optNull(PR_BATCH_MANUAL_CLASSES(game)), +}); diff --git a/common/src/utils/util.ts b/common/src/utils/util.ts index 8e6dd1ba5..4f34da49d 100644 --- a/common/src/utils/util.ts +++ b/common/src/utils/util.ts @@ -1,6 +1,7 @@ import { GetGameConfig, GetGamePTConfig } from "../config/config"; import type { ChartDocument, Game, IDStrings, Playtypes, SongDocument } from ".."; import type { Grades, integer } from "../types"; +import type { PrudenceError } from "prudence"; export function FormatInt(v: number): string { return v.toString(); @@ -263,3 +264,17 @@ export function CreateSongMap(songs: Array p.optional(p.nullable(v)); - -const BaseValidHitMeta = { - fast: optNull(p.isPositiveInteger), - slow: optNull(p.isPositiveInteger), - maxCombo: optNull(p.isPositiveInteger), -}; - -const PR_DP_RANDOM = (self: unknown) => { - if (!Array.isArray(self) || self.length !== 2) { - return "Expected an array with length 2"; - } - - for (const a of self) { - if (p.isIn("NONRAN", "RANDOM", "R-RANDOM", "S-RANDOM", "MIRROR")(a) !== true) { - return false; - } - } - - return true; -}; - -const PR_SCORE_META = (game: Game, playtype: Playtype): PrudenceSchema => { - if (game === "iidx") { - const random = - playtype === "SP" - ? optNull(p.isIn("NONRAN", "RANDOM", "R-RANDOM", "S-RANDOM", "MIRROR")) - : PR_DP_RANDOM; - - return { - random, - assist: optNull(p.isIn("NO ASSIST", "AUTO SCRATCH", "LEGACY NOTE", "FULL ASSIST")), - range: optNull(p.isIn("NONE", "SUDDEN+", "HIDDEN+", "SUD+ HID+", "LIFT", "LIFT SUD+")), - gauge: optNull(p.isIn("ASSISTED EASY", "EASY", "NORMAL", "HARD", "EX HARD")), - }; - } else if (game === "bms") { - const random = - playtype === "7K" - ? optNull(p.isIn("NONRAN", "RANDOM", "R-RANDOM", "S-RANDOM", "MIRROR")) - : optNull(PR_DP_RANDOM); - - return { - random, - inputDevice: optNull(p.isIn("KEYBOARD", "BM_CONTROLLER")), - client: optNull(p.isIn("LR2", "lr2oraja")), - }; - } else if (game === "usc") { - return { - noteMod: optNull(p.isIn("NORMAL", "MIRROR", "RANDOM", "MIR-RAN")), - gaugeMod: optNull(p.isIn("NORMAL", "HARD", "PERMISSIVE")), - }; - } else if (game === "sdvx") { - return { - inSkillAnalyser: "*?boolean", - }; - } else if (game === "wacca") { - return { mirror: "*?boolean" }; - } else if (game === "popn") { - return { - hiSpeed: optNull(p.isPositive), - hidden: optNull(p.isInteger), - sudden: optNull(p.isInteger), - random: optNull(p.isIn("NONRAN", "MIRROR", "RANDOM", "S-RANDOM")), - gauge: optNull(p.isIn("NORMAL", "EASY", "HARD", "DANGER")), - }; - } - - return {}; -}; - -const PR_HIT_META = (game: Game): PrudenceSchema => { - if (game === "iidx") { - return { - bp: optNull(p.isPositiveInteger), - gauge: optNull(p.isBetween(0, 100)), - gaugeHistory: optNull([p.isBetween(0, 100)]), - scoreHistory: optNull([p.isPositiveInteger]), - comboBreak: optNull(p.isPositiveInteger), - gsm: optNull({ - EASY: [p.nullable(p.isBetween(0, 100))], - NORMAL: [p.nullable(p.isBetween(0, 100))], - HARD: [p.nullable(p.isBetween(0, 100))], - EX_HARD: [p.nullable(p.isBetween(0, 100))], - }), - }; - } else if (game === "sdvx") { - return { - gauge: optNull(p.isBetween(0, 100)), - }; - } else if (game === "usc") { - return { gauge: optNull(p.isBetween(0, 1)) }; - } else if (game === "bms") { - return { - bp: optNull(p.isPositiveInteger), - gauge: optNull(p.isBetween(0, 100)), - lbd: optNull(p.isPositiveInteger), - ebd: optNull(p.isPositiveInteger), - lpr: optNull(p.isPositiveInteger), - epr: optNull(p.isPositiveInteger), - lgd: optNull(p.isPositiveInteger), - egd: optNull(p.isPositiveInteger), - lgr: optNull(p.isPositiveInteger), - egr: optNull(p.isPositiveInteger), - lpg: optNull(p.isPositiveInteger), - epg: optNull(p.isPositiveInteger), - }; - } else if (game === "popn") { - return { - gauge: optNull(p.isBetween(0, 100)), - specificClearType: optNull( - p.isIn( - "failedUnknown", - "failedCircle", - "failedDiamond", - "failedStar", - "easyClear", - "clearCircle", - "clearDiamond", - "clearStar", - "fullComboCircle", - "fullComboDiamond", - "fullComboStar", - "perfect" - ) - ), - }; - } - - return {}; -}; - -const PR_BATCH_MANUAL_SCORE = (game: Game, playtype: Playtype): PrudenceSchema => { - const gptConfig = GetGamePTConfig(game, playtype); - - const scoreChecker = - game === "itg" || game === "gitadora" ? p.isBetween(0, 100) : p.isPositiveInteger; - - return { - score: scoreChecker, - lamp: p.isIn(gptConfig.lamps), - matchType: p.isIn( - "songTitle", - "ddrSongHash", - "tachiSongID", - "bmsChartHash", - "itgChartHash", - "inGameID", - "uscChartHash", - "popnChartHash" - ), - identifier: "string", - percent: game === "jubeat" ? p.isBetween(0, 120) : p.is(undefined), - comment: optNull(p.isBoundedString(3, 240)), - difficulty: "*?string", - - // this is checked in converting instead - // the lowest acceptable time is september 9th 2001 - this check saves people who dont - // read any documentation and would otherwise submit garbage. - timeAchieved: optNull( - (self) => - (typeof self === "number" && self > 1_000_000_000_000) || - self === 0 || - "Expected a number greater than 1 Trillion - did you pass unix seconds instead of milliseconds?" - ), - judgements: optNull((self) => { - if (typeof self !== "object" || self === null) { - return "Not a valid object."; - } - - for (const [key, v] of Object.entries(self)) { - // @ts-expect-error Frustrating check as it's a string but not a judgement so it's not really - // happy with it. - if (!gptConfig.judgements.includes(key)) { - return `Invalid Key ${key}. Expected any of ${gptConfig.judgements.join(", ")}`; - } - - if ((!Number.isSafeInteger(v) || v < 0) && v !== null) { - return `Key ${key} had an invalid value of ${v} [type: ${typeof v}]`; - } - } - - return true; - }), - hitMeta: optNull( - deepmerge(BaseValidHitMeta, PR_HIT_META(game)) as unknown as ValidSchemaValue - ), - scoreMeta: optNull(PR_SCORE_META(game, playtype)), - }; -}; - -// both iidx sp and dp share dans. -const IIDXStringDans = GetGamePTConfig("iidx", "SP").classHumanisedFormat.dan.map((e) => e.id); -const SDVXStringDans = GetGamePTConfig("sdvx", "Single").classHumanisedFormat.dan.map((e) => e.id); -const WaccaStringStageUps = GetGamePTConfig("wacca", "Single").classHumanisedFormat.stageUp.map( - (e) => e.id -); - -const PR_BATCH_MANUAL_CLASSES = (game: Game): PrudenceSchema => { - // This can be implemented for any non-static class (i.e. dans). - switch (game) { - case "iidx": - return { - dan: optNull(p.isIn(IIDXStringDans)), - }; - case "sdvx": - return { - dan: optNull(p.isIn(SDVXStringDans)), - }; - case "wacca": - return { - stageUp: optNull(p.isIn(WaccaStringStageUps)), - }; - default: - return {}; - } -}; - -const PR_BATCH_MANUAL = (game: Game, playtype: Playtype): PrudenceSchema => ({ - meta: { - service: p.isBoundedString(3, 60), - game: p.isIn(TachiConfig.GAMES), - playtype: p.is(playtype), - version: "*?string", - }, - scores: [PR_BATCH_MANUAL_SCORE(game, playtype)], - classes: optNull(PR_BATCH_MANUAL_CLASSES(game)), -}); - /** * Parses an object of BATCH-MANUAL data. * @param object - The object to parse.