feat: enable batch manual errmsgs in client before uploading

this runs prudence schemas on the browser

straightforward enough, required a little refactoring, but we're good.
This commit is contained in:
zkldi
2022-10-21 01:48:50 +01:00
parent 5ca091bf77
commit b650213cc2
7 changed files with 266 additions and 234 deletions
+1
View File
@@ -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",
@@ -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: {
+1
View File
@@ -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": {
+228 -1
View File
@@ -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<keyof typeof PRE_SCHEMAS, SchemaValidatorFunction> = 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)),
});
+15
View File
@@ -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<G extends Game = Game>(songs: Array<SongDocument<G
return songMap;
}
/**
* Formats a PrudenceError into something a little more readable.
* @param err - The prudence error to format.
* @param foreword - A description of what kind of error this was. Defaults to "Error".
*/
export function FormatPrError(err: PrudenceError, foreword = "Error"): string {
const receivedText =
typeof err.userVal === "object" && err.userVal !== null
? ""
: ` | Received ${err.userVal} [${err.userVal === null ? "null" : typeof err.userVal}]`;
return `${foreword}: ${err.keychain} | ${err.message}${receivedText}.`;
}
+4
View File
@@ -132,6 +132,7 @@ importers:
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
@@ -192,6 +193,7 @@ importers:
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@17.0.2
react-bootstrap: 1.6.1_alyzgwsqb3v5agqihhenzpdwfm
@@ -233,11 +235,13 @@ importers:
'@types/express-serve-static-core': 4.17.29
'@types/mongodb': 3.6.18
'@types/tap': ^15.0.7
deepmerge: ^4.2.2
prudence: 0.9.8
tap: 15.1.6
dependencies:
'@types/express-serve-static-core': 4.17.29
'@types/mongodb': 3.6.18
deepmerge: 4.2.2
prudence: 0.9.8
devDependencies:
'@types/tap': 15.0.7
@@ -1,245 +1,16 @@
import { CreateBatchManualClassHandler } from "./class-handler";
import ScoreImportFatalError from "../../../framework/score-importing/score-import-error";
import deepmerge from "deepmerge";
import { TachiConfig } from "lib/setup/config";
import p from "prudence";
import { GetGameConfig, GetGamePTConfig } from "tachi-common";
import { GetGameConfig } from "tachi-common";
import { PR_BATCH_MANUAL } from "tachi-common/lib/schemas";
import { IsRecord, IsValidGame, IsValidPlaytype } from "utils/misc";
import { FormatPrError } from "utils/prudence";
import type { ParserFunctionReturns } from "../types";
import type { BatchManualContext } from "./types";
import type { KtLogger } from "lib/logger/logger";
import type { PrudenceSchema, ValidSchemaValue } from "prudence";
import type { BatchManual, BatchManualScore, Game, ImportTypes, Playtype } from "tachi-common";
const optNull = (v: ValidSchemaValue) => 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.