From 4a25aaa7373755bef8ec3f3c501b67270ec3ed3d Mon Sep 17 00:00:00 2001 From: zkldi <20380519+zkldi@users.noreply.github.com> Date: Thu, 5 Jan 2023 20:45:46 +0000 Subject: [PATCH] checkpoint: 9 (COMMON COMPILES) --- common/src/config/config.test.ts | 10 +- common/src/config/game-support/bms.ts | 10 +- common/src/config/game-support/chunithm.ts | 10 +- common/src/config/game-support/gitadora.ts | 18 +- common/src/config/game-support/iidx.ts | 10 +- common/src/config/game-support/itg.ts | 4 +- common/src/config/game-support/jubeat.ts | 10 +- common/src/config/game-support/maimai-dx.ts | 10 +- common/src/config/game-support/museca.ts | 10 +- common/src/config/game-support/pms.ts | 10 +- common/src/config/game-support/popn.ts | 10 +- common/src/config/game-support/sdvx.ts | 10 +- common/src/config/game-support/usc.ts | 10 +- common/src/config/game-support/wacca.ts | 10 +- common/src/lib/schemas.ts | 625 +++----------------- common/src/seeds/types.ts | 4 +- common/src/types/batch-manual.ts | 9 +- common/src/types/documents.ts | 4 +- common/src/types/game-config-utils.ts | 8 +- common/src/types/game-config.ts | 20 +- common/src/types/internals.ts | 4 +- common/src/types/metrics.ts | 13 + common/src/utils/util.ts | 271 +-------- common/src/webhooks.ts | 7 +- server/src/external/mongo/schemas.ts | 2 +- 25 files changed, 219 insertions(+), 890 deletions(-) diff --git a/common/src/config/config.test.ts b/common/src/config/config.test.ts index cf9419fdf..dedf4e3d3 100644 --- a/common/src/config/config.test.ts +++ b/common/src/config/config.test.ts @@ -2,8 +2,6 @@ import { GetGameConfig, GetGamePTConfig, allSupportedGames } from "./config"; import t from "tap"; t.test("#GetGameConfig", (t) => { - t.plan(allSupportedGames.length); - for (const game of allSupportedGames) { // i don't feel *that* strongly about this restriction, but game IDs *definitely* // can't have things like `:` in them. @@ -17,6 +15,8 @@ t.test("#GetGameConfig", (t) => { "Default playtype should be in the array of playtypes." ); } + + t.end(); }); t.test("#GetGamePTConfig", (t) => { @@ -43,11 +43,9 @@ t.test("#GetGamePTConfig", (t) => { "The default profile rating alg should have an implementation." ); - if (conf.difficultyConfig.type === "FIXED") { + if (conf.difficulties.type === "FIXED") { t.ok( - conf.difficultyConfig.difficultyOrder.includes( - conf.difficultyConfig.defaultDifficulty - ), + conf.difficulties.order.includes(conf.difficulties.default), "The default difficulty should be part of difficultyOrder." ); } diff --git a/common/src/config/game-support/bms.ts b/common/src/config/game-support/bms.ts index 2586cf6f8..a0a3ecdbf 100644 --- a/common/src/config/game-support/bms.ts +++ b/common/src/config/game-support/bms.ts @@ -221,13 +221,13 @@ export const BMS_7K_CONF = { // with no possible method for determining what "song" a chart belongs to. // As such, the only correct method left is to assert that every bit of song // metadata is just its own thing. - difficultyConfig: { + difficulties: { type: "FIXED", // "CHART" isn't a very creative name, but this is in a similar vein to how // games with no playtypes use the value "Single". - difficultyOrder: ["CHART"], - difficultyShorthand: {}, - defaultDifficulty: "CHART", + order: ["CHART"], + shorthand: {}, + default: "CHART", }, classes: { @@ -239,7 +239,7 @@ export const BMS_7K_CONF = { orderedJudgements: ["pgreat", "great", "good", "bad", "poor"], - chartSets: [], + versions: [], chartData: z.strictObject({ notecount: zodNonNegativeInt, diff --git a/common/src/config/game-support/chunithm.ts b/common/src/config/game-support/chunithm.ts index a8435ccc9..5c1127e42 100644 --- a/common/src/config/game-support/chunithm.ts +++ b/common/src/config/game-support/chunithm.ts @@ -74,16 +74,16 @@ export const CHUNITHM_SINGLE_CONF = { // This game technically has a dynamic set of difficulties, with a chart being // able to have as many WORLD'S END charts as it likes. However, this is a little // awkward to implement, and I can't be bothered. Sorry! - difficultyConfig: { + difficulties: { type: "FIXED", - difficultyOrder: ["BASIC", "ADVANCED", "EXPERT", "MASTER"], - difficultyShorthand: { + order: ["BASIC", "ADVANCED", "EXPERT", "MASTER"], + shorthand: { BASIC: "B", ADVANCED: "A", EXPERT: "E", MASTER: "M", }, - defaultDifficulty: "MASTER", + default: "MASTER", }, classes: { @@ -95,7 +95,7 @@ export const CHUNITHM_SINGLE_CONF = { orderedJudgements: ["jcrit", "justice", "attack", "miss"], - chartSets: ["Paradise Lost"], + versions: ["Paradise Lost"], chartData: z.strictObject({ inGameID: zodNonNegativeInt, diff --git a/common/src/config/game-support/gitadora.ts b/common/src/config/game-support/gitadora.ts index 51a902f50..23ea9622e 100644 --- a/common/src/config/game-support/gitadora.ts +++ b/common/src/config/game-support/gitadora.ts @@ -76,9 +76,9 @@ export const GITADORA_GITA_CONF = { defaultSessionRatingAlg: "skill", defaultProfileRatingAlg: "skill", - difficultyConfig: { + difficulties: { type: "FIXED", - difficultyOrder: [ + order: [ "BASIC", "ADVANCED", "EXTREME", @@ -88,7 +88,7 @@ export const GITADORA_GITA_CONF = { "BASS EXTREME", "BASS MASTER", ], - difficultyShorthand: { + shorthand: { BASIC: "G-BSC", ADVANCED: "G-ADV", EXTREME: "G-EXT", @@ -98,7 +98,7 @@ export const GITADORA_GITA_CONF = { "BASS EXTREME": "B-EXT", "BASS MASTER": "B-MAS", }, - defaultDifficulty: "EXTREME", + default: "EXTREME", }, classes: { @@ -107,7 +107,7 @@ export const GITADORA_GITA_CONF = { orderedJudgements: ["perfect", "great", "good", "ok", "miss"], - chartSets: ["Konaste"], + versions: ["Konaste"], chartData: z.strictObject({ inGameID: zodNonNegativeInt, @@ -123,15 +123,15 @@ export const GITADORA_GITA_CONF = { export const GITADORA_DORA_CONF = { ...GITADORA_GITA_CONF, - difficultyConfig: { + difficulties: { type: "FIXED", - difficultyOrder: ["BASIC", "ADVANCED", "EXTREME", "MASTER"], - difficultyShorthand: { + order: ["BASIC", "ADVANCED", "EXTREME", "MASTER"], + shorthand: { BASIC: "D-BSC", ADVANCED: "D-ADV", EXTREME: "D-EXT", MASTER: "D-MAS", }, - defaultDifficulty: "EXTREME", + default: "EXTREME", }, } as const satisfies GamePTConfig; diff --git a/common/src/config/game-support/iidx.ts b/common/src/config/game-support/iidx.ts index d110dda4a..631a171d8 100644 --- a/common/src/config/game-support/iidx.ts +++ b/common/src/config/game-support/iidx.ts @@ -122,9 +122,9 @@ export const IIDX_SP_CONF = { BPI: { description: `An average of the best 10 BPIs this session.` }, }, - difficultyConfig: { + difficulties: { type: "FIXED", - difficultyOrder: [ + order: [ "NORMAL", "HYPER", "ANOTHER", @@ -142,7 +142,7 @@ export const IIDX_SP_CONF = { "Kiraku ANOTHER", "Kiraku LEGGENDARIA", ], - difficultyShorthand: { + shorthand: { NORMAL: "N", HYPER: "H", ANOTHER: "A", @@ -160,7 +160,7 @@ export const IIDX_SP_CONF = { "Kiraku ANOTHER": "A (Kr.)", "Kiraku LEGGENDARIA": "L (Kr.)", }, - defaultDifficulty: "ANOTHER", + default: "ANOTHER", }, classes: { @@ -172,7 +172,7 @@ export const IIDX_SP_CONF = { orderedJudgements: ["pgreat", "great", "good", "bad", "poor"], - chartSets: [ + versions: [ "3rd Style CS", "4th Style CS", "5th Style CS", diff --git a/common/src/config/game-support/itg.ts b/common/src/config/game-support/itg.ts index c238af2a4..0a73aa0e1 100644 --- a/common/src/config/game-support/itg.ts +++ b/common/src/config/game-support/itg.ts @@ -89,7 +89,7 @@ export const ITG_STAMINA_CONF = { defaultSessionRatingAlg: "blockRating", defaultProfileRatingAlg: "highestBlock", - difficultyConfig: { + difficulties: { type: "DYNAMIC", }, @@ -97,7 +97,7 @@ export const ITG_STAMINA_CONF = { orderedJudgements: ["15ms", "fantastic", "excellent", "great", "decent", "wayoff", "miss"], - chartSets: [], + versions: [], chartData: z.strictObject({ hashGSv3: z.string(), diff --git a/common/src/config/game-support/jubeat.ts b/common/src/config/game-support/jubeat.ts index 7a8334eca..9cb994173 100644 --- a/common/src/config/game-support/jubeat.ts +++ b/common/src/config/game-support/jubeat.ts @@ -70,10 +70,10 @@ export const JUBEAT_SINGLE_CONF = { defaultSessionRatingAlg: "jubility", defaultProfileRatingAlg: "jubility", - difficultyConfig: { + difficulties: { type: "FIXED", - difficultyOrder: ["BSC", "ADV", "EXT", "HARD BSC", "HARD ADV", "HARD EXT"], - difficultyShorthand: { + order: ["BSC", "ADV", "EXT", "HARD BSC", "HARD ADV", "HARD EXT"], + shorthand: { BSC: "BSC", ADV: "ADV", EXT: "EXT", @@ -81,7 +81,7 @@ export const JUBEAT_SINGLE_CONF = { "HARD ADV": "H. ADV", "HARD EXT": "H. EXT", }, - defaultDifficulty: "EXT", + default: "EXT", }, classes: { @@ -90,7 +90,7 @@ export const JUBEAT_SINGLE_CONF = { orderedJudgements: ["perfect", "great", "good", "poor", "miss"], - chartSets: [ + versions: [ "jubeat", "ripples", "knit", diff --git a/common/src/config/game-support/maimai-dx.ts b/common/src/config/game-support/maimai-dx.ts index 65157885d..a987e7554 100644 --- a/common/src/config/game-support/maimai-dx.ts +++ b/common/src/config/game-support/maimai-dx.ts @@ -117,9 +117,9 @@ export const MAIMAI_DX_SINGLE_CONF = { defaultSessionRatingAlg: "rate", defaultProfileRatingAlg: "naiveRate", - difficultyConfig: { + difficulties: { type: "FIXED", - difficultyOrder: [ + order: [ "Basic", "Advanced", "Expert", @@ -131,7 +131,7 @@ export const MAIMAI_DX_SINGLE_CONF = { "DX Master", "DX Re:Master", ], - difficultyShorthand: { + shorthand: { Basic: "BAS", Advanced: "ADV", Expert: "EXP", @@ -143,7 +143,7 @@ export const MAIMAI_DX_SINGLE_CONF = { "DX Master": "DX MAS", "DX Re:Master": "DX Re:MAS", }, - defaultDifficulty: "Master", + default: "Master", }, classes: { @@ -159,7 +159,7 @@ export const MAIMAI_DX_SINGLE_CONF = { orderedJudgements: ["pcrit", "perfect", "great", "good", "miss"], - chartSets: ["UNiVERSE PLUS"], + versions: ["UNiVERSE PLUS"], chartData: z.strictObject({ isLatest: z.boolean(), diff --git a/common/src/config/game-support/museca.ts b/common/src/config/game-support/museca.ts index 78f4bd495..a9ad02a78 100644 --- a/common/src/config/game-support/museca.ts +++ b/common/src/config/game-support/museca.ts @@ -67,18 +67,18 @@ export const MUSECA_SINGLE_CONF = { defaultSessionRatingAlg: "curatorSkill", defaultProfileRatingAlg: "curatorSkill", - difficultyConfig: { + difficulties: { type: "FIXED", - difficultyOrder: ["Green", "Yellow", "Red"], - difficultyShorthand: { Green: "G", Yellow: "Y", Red: "R" }, - defaultDifficulty: "Red", + order: ["Green", "Yellow", "Red"], + shorthand: { Green: "G", Yellow: "Y", Red: "R" }, + default: "Red", }, classes: {}, orderedJudgements: ["critical", "near", "miss"], - chartSets: ["1 + 1/2", "1 + 1/2 Rev. B"], + versions: ["1 + 1/2", "1 + 1/2 Rev. B"], chartData: z.strictObject({ inGameID: zodNonNegativeInt }), diff --git a/common/src/config/game-support/pms.ts b/common/src/config/game-support/pms.ts index 0a253642c..30618f05c 100644 --- a/common/src/config/game-support/pms.ts +++ b/common/src/config/game-support/pms.ts @@ -118,11 +118,11 @@ export const PMS_CONTROLLER_CONF = { defaultProfileRatingAlg: "sieglinde", // See `bms.ts` for an explanation of why this is like this. - difficultyConfig: { + difficulties: { type: "FIXED", - difficultyOrder: ["CHART"], - difficultyShorthand: {}, - defaultDifficulty: "CHART", + order: ["CHART"], + shorthand: {}, + default: "CHART", }, classes: { @@ -131,7 +131,7 @@ export const PMS_CONTROLLER_CONF = { orderedJudgements: ["cool", "great", "good", "bad", "poor"], - chartSets: [], + versions: [], chartData: z.strictObject({ notecount: zodNonNegativeInt, diff --git a/common/src/config/game-support/popn.ts b/common/src/config/game-support/popn.ts index 3c18f5037..58dffbdcc 100644 --- a/common/src/config/game-support/popn.ts +++ b/common/src/config/game-support/popn.ts @@ -95,16 +95,16 @@ export const POPN_9B_CONF = { defaultSessionRatingAlg: "classPoints", defaultProfileRatingAlg: "naiveClassPoints", - difficultyConfig: { + difficulties: { type: "FIXED", - difficultyOrder: ["Easy", "Normal", "Hyper", "EX"], - difficultyShorthand: { + order: ["Easy", "Normal", "Hyper", "EX"], + shorthand: { Easy: "E", Normal: "N", Hyper: "H", EX: "EX", }, - defaultDifficulty: "EX", + default: "EX", }, classes: { @@ -116,7 +116,7 @@ export const POPN_9B_CONF = { orderedJudgements: ["cool", "great", "good", "bad"], - chartSets: ["peace", "Kaimei Riddles"], + versions: ["peace", "Kaimei Riddles"], chartData: z.strictObject({ // Array | string | null diff --git a/common/src/config/game-support/sdvx.ts b/common/src/config/game-support/sdvx.ts index ac87f8116..dd206e810 100644 --- a/common/src/config/game-support/sdvx.ts +++ b/common/src/config/game-support/sdvx.ts @@ -129,11 +129,11 @@ export const SDVX_SINGLE_CONF = { defaultSessionRatingAlg: "ProfileVF6", defaultProfileRatingAlg: "VF6", - difficultyConfig: { + difficulties: { type: "FIXED", - difficultyOrder: ["NOV", "ADV", "EXH", "INF", "GRV", "HVN", "VVD", "XCD", "MXM"], - difficultyShorthand: {}, // they're all already short enough - defaultDifficulty: "EXH", + order: ["NOV", "ADV", "EXH", "INF", "GRV", "HVN", "VVD", "XCD", "MXM"], + shorthand: {}, // they're all already short enough + default: "EXH", }, classes: { @@ -149,7 +149,7 @@ export const SDVX_SINGLE_CONF = { orderedJudgements: ["critical", "near", "miss"], - chartSets: [ + versions: [ "BOOTH", "infinite infection", "GRAVITY WARS", diff --git a/common/src/config/game-support/usc.ts b/common/src/config/game-support/usc.ts index fb0031fbf..5912e7475 100644 --- a/common/src/config/game-support/usc.ts +++ b/common/src/config/game-support/usc.ts @@ -69,18 +69,18 @@ export const USC_CONTROLLER_CONF = { defaultSessionRatingAlg: "ProfileVF6", defaultProfileRatingAlg: "VF6", - difficultyConfig: { + difficulties: { type: "FIXED", - difficultyOrder: ["NOV", "ADV", "EXH", "INF"], - difficultyShorthand: {}, // they're all already short enough. - defaultDifficulty: "EXH", + order: ["NOV", "ADV", "EXH", "INF"], + shorthand: {}, // they're all already short enough. + default: "EXH", }, classes: {}, orderedJudgements: ["critical", "near", "miss"], - chartSets: [], + versions: [], chartData: z.strictObject({ hashSHA1: z.union([z.array(z.string()), z.string()]), diff --git a/common/src/config/game-support/wacca.ts b/common/src/config/game-support/wacca.ts index 88191759a..82a6c225c 100644 --- a/common/src/config/game-support/wacca.ts +++ b/common/src/config/game-support/wacca.ts @@ -105,16 +105,16 @@ export const WACCA_SINGLE_CONF = { defaultProfileRatingAlg: "naiveRate", defaultSessionRatingAlg: "rate", - difficultyConfig: { + difficulties: { type: "FIXED", - difficultyOrder: ["NORMAL", "HARD", "EXPERT", "INFERNO"], - difficultyShorthand: { + order: ["NORMAL", "HARD", "EXPERT", "INFERNO"], + shorthand: { NORMAL: "NRM", HARD: "HRD", EXPERT: "EXP", INFERNO: "INF", }, - defaultDifficulty: "EXPERT", + default: "EXPERT", }, classes: { @@ -130,7 +130,7 @@ export const WACCA_SINGLE_CONF = { orderedJudgements: ["marvelous", "great", "good", "miss"], - chartSets: ["REVERSE"], + versions: ["REVERSE"], chartData: z.strictObject({ isHot: z.boolean(), diff --git a/common/src/lib/schemas.ts b/common/src/lib/schemas.ts index 279715bf7..6c262cb75 100644 --- a/common/src/lib/schemas.ts +++ b/common/src/lib/schemas.ts @@ -2,19 +2,20 @@ // their name in the database to the object they schemaify. // The schemas themselves are wrapped in functions that throw on error. -import { GetGameConfig, allSupportedGames, GetGamePTConfig, allGPTStrings } from "../config/config"; +import { GetGameConfig, GetGamePTConfig, allGPTStrings, allSupportedGames } from "../config/config"; import { allImportTypes } from "../constants/import-types"; import { ALL_PERMISSIONS } from "../constants/permissions"; import { UserAuthLevels } from "../types"; -import deepmerge from "deepmerge"; +import { PrudenceZodShim } from "../utils/util"; import { p } from "prudence"; -import type { Game, Playtypes, Playtype } from "../types/game-config"; +import type { Game, Playtype, Playtypes } from "../types/game-config"; import type { GamePTConfig } from "../types/internals"; +import type { ScoreMetric } from "../types/metrics"; import type { NotificationBody } from "../types/notifications"; import type { PrudenceSchema, - ValidationFunctionParentOptionsKeychain, ValidSchemaValue, + ValidationFunctionParentOptionsKeychain, } from "prudence"; export const optNull = (v: ValidSchemaValue): ValidationFunctionParentOptionsKeychain => @@ -38,40 +39,13 @@ function prSchemaFnWrap(schema: PrudenceSchema) { }; } -export const PR_GAMESPECIFIC_SETTINGS = (game: Game): PrudenceSchema => { - switch (game) { - case "iidx": - return { - display2DXTra: p.optional("boolean"), - bpiTarget: p.optional(p.isBoundedInteger(0, 100)), - }; - case "sdvx": - case "usc": - return { - vf6Target: p.optional(p.isBetween(0, 0.5)), - }; - case "bms": - return { - displayTables: ["string"], - }; - case "jubeat": - return { - // 165.1 is the max jubility. - jubilityTarget: p.optional(p.isBetween(0, 165.1)), - }; - - default: - return {}; - } -}; - const PR_GAME_STATS = (game: Game, playtype: Playtypes[Game], gptConfig: GamePTConfig) => ({ userID: p.isPositiveNonZeroInteger, game: p.is(game), playtype: p.is(playtype), - ratings: Object.fromEntries(gptConfig.scoreRatingAlgs.map((s) => [s, "*number"])), + ratings: Object.fromEntries(Object.keys(gptConfig.scoreRatingAlgs).map((s) => [s, "*number"])), classes: Object.fromEntries( - Object.keys(gptConfig.classHumanisedFormat).map((e) => [e, p.optional(p.isInteger)]) + Object.keys(gptConfig.classes).map((e) => [e, p.optional(p.isInteger)]) ), }); @@ -83,42 +57,6 @@ const PR_GOAL_INFO = { achieved: "boolean", }; -const GetHitMeta = (game: Game): PrudenceSchema => { - if (game === "iidx") { - return { - bp: optNull(p.isPositiveInteger), - gauge: optNull(p.isBetween(0, 100)), - gaugeHistory: optNull([p.nullable(p.isBetween(0, 100))]), - 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 === "bms") { - return { - epg: optNull(p.isPositiveInteger), - egr: optNull(p.isPositiveInteger), - egd: optNull(p.isPositiveInteger), - epr: optNull(p.isPositiveInteger), - ebd: optNull(p.isPositiveInteger), - lpg: optNull(p.isPositiveInteger), - lgr: optNull(p.isPositiveInteger), - lgd: optNull(p.isPositiveInteger), - lpr: optNull(p.isPositiveInteger), - lbd: optNull(p.isPositiveInteger), - bp: optNull(p.isPositiveInteger), - gauge: optNull(p.isBetween(0, 100)), - }; - } else if (game === "sdvx" || game === "usc") { - return { gauge: optNull(p.isBetween(0, 100)) }; - } - - return {}; -}; - const extractGPTIDString = (self: unknown) => { if (typeof self !== "object" || !self) { throw new Error("Expected an object."); @@ -171,53 +109,6 @@ const extractGPT = (self: unknown) => { return { game: s.game, playtype: s.playtype }; }; -const PR_RANDOM = p.isIn("NONRAN", "RANDOM", "R-RANDOM", "S-RANDOM", "MIRROR"); - -const GetScoreMeta = (game: Game, playtype: Playtypes[Game]): PrudenceSchema => { - if (game === "iidx" && playtype === "SP") { - return { - random: optNull(PR_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 === "iidx" && playtype === "DP") { - return { - random: optNull({ - left: PR_RANDOM, - right: PR_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 === "sdvx") { - return { inSkillAnalyser: "*?boolean" }; - } else if (game === "usc") { - return { - noteMod: optNull(p.isIn("NORMAL", "MIRROR", "RANDOM", "MIR-RAN")), - gaugeMod: optNull(p.isIn("NORMAL", "HARD", "PERMISSIVE")), - }; - } else if (game === "bms" && playtype === "7K") { - return { - random: optNull(PR_RANDOM), - inputDevice: optNull(p.isIn("KEYBOARD", "BM_CONTROLLER")), - client: optNull(p.isIn("LR2", "lr2oraja")), - }; - } else if (game === "bms" && playtype === "14K") { - return { - random: optNull({ - left: PR_RANDOM, - right: PR_RANDOM, - }), - inputDevice: optNull(p.isIn("KEYBOARD", "BM_CONTROLLER")), - client: optNull(p.isIn("LR2", "lr2oraja")), - }; - } - - return {}; -}; - function IsValidPlaytype(game: Game, str: string): str is Playtypes[Game] { return GetGameConfig(game).playtypes.includes(str as Playtypes[Game]); } @@ -262,193 +153,6 @@ const isValidPlaytype = (self: unknown, parent: Record) => { return true; }; -function GetSongDataForGame(game: Game): PrudenceSchema { - switch (game) { - case "bms": - return { - genre: "?string", - subtitle: "?string", - subartist: "?string", - tableString: "?string", - }; - case "chunithm": - return { - genre: "string", - displayVersion: "string", - }; - case "sdvx": - return { - displayVersion: "string", - }; - case "maimaidx": - return { - displayVersion: "string", - }; - case "museca": - return { - titleJP: "string", - artistJP: "string", - displayVersion: "string", - }; - case "iidx": - return { - genre: "string", - displayVersion: "string", - }; - case "wacca": - return { - genre: "string", - displayVersion: "?string", - titleJP: "string", - artistJP: "string", - }; - case "pms": - return { - genre: "?string", - subtitle: "?string", - subartist: "?string", - tableString: "?string", - }; - case "popn": - return { - genre: "string", - genreEN: "?string", - displayVersion: "?string", - }; - case "jubeat": - return { - displayVersion: "string", - }; - case "itg": - return { - subtitle: "string", - banner: "?string", - }; - case "gitadora": - return { - isHot: "boolean", - }; - case "usc": - return {}; - } -} - -function GetChartDataForGPT(idString: IDStrings): PrudenceSchema { - switch (idString) { - case "bms:7K": - case "bms:14K": - return { - notecount: p.isPositiveNonZeroInteger, - hashSHA256: "?string", - hashMD5: "?string", - aiLevel: "*?string", - tableFolders: [ - { - table: "string", - level: "string", - }, - ], - }; - case "jubeat:Single": - return { - inGameID: p.or(p.isPositiveInteger, [p.isPositiveInteger]), - isHardMode: "boolean", - }; - case "chunithm:Single": - return { - inGameID: p.isPositiveInteger, - }; - case "iidx:SP": - case "iidx:DP": - return { - notecount: p.isPositiveNonZeroInteger, - inGameID: p.or(p.isPositiveNonZeroInteger, [p.isPositiveNonZeroInteger]), - hashSHA256: "?string", - "2dxtraSet": "?string", - kaidenAverage: "?number", - worldRecord: "?number", - bpiCoefficient: "?number", - }; - case "maimaidx:Single": - return { - isLatest: "boolean", - }; - case "museca:Single": - return { - inGameID: p.isPositiveInteger, - }; - case "sdvx:Single": - return { - inGameID: p.isPositiveInteger, - }; - case "usc:Controller": - case "usc:Keyboard": - return { - hashSHA1: p.or("string", ["string"]), - isOfficial: "boolean", - effector: "string", - tableFolders: [ - { - table: "string", - level: "string", - }, - ], - }; - case "wacca:Single": - return { - isHot: "boolean", - }; - case "pms:Controller": - case "pms:Keyboard": - return { - notecount: p.isPositiveNonZeroInteger, - hashSHA256: "?string", - hashMD5: "?string", - tableFolders: [ - { - table: "string", - level: "string", - }, - ], - }; - case "popn:9B": - return { - hashSHA256: p.or("?string", ["string"]), - inGameID: p.isPositiveInteger, - }; - case "itg:Stamina": - return { - chartHash: "string", - breakdown: { - detailed: "string", - partiallySimplified: "string", - simplified: "string", - total: "string", - npsPerMeasure: [p.isPositive], - notesPerMeasure: [p.isPositive], - }, - tech: { - crossovers: p.isPositiveInteger, - jacks: p.isPositiveInteger, - brackets: p.isPositiveInteger, - footswitches: p.isPositiveInteger, - sideswitches: p.isPositiveInteger, - }, - length: p.isPositive, - charter: "string", - displayBPM: p.isPositive, - - // todo, maybe constrain this a bit? - difficultyTag: "string", - }; - case "gitadora:Dora": - case "gitadora:Gita": - return { - inGameID: p.isPositiveInteger, - }; - } -} - export const PR_GOAL_SCHEMA = { game: p.isIn(games), playtype: isValidPlaytype, @@ -494,7 +198,7 @@ export const PR_GOAL_SCHEMA = { }; const PR_SONG_DOCUMENT = (game: Game): PrudenceSchema => { - const data = GetSongDataForGame(game); + const conf = GetGameConfig(game); return { id: p.isPositiveInteger, @@ -502,7 +206,7 @@ const PR_SONG_DOCUMENT = (game: Game): PrudenceSchema => { artist: "string", searchTerms: ["string"], altTitles: ["string"], - data, + data: PrudenceZodShim(conf.songData), }; }; @@ -511,39 +215,28 @@ const PR_CHART_DOCUMENT = (game: Game) => (self: unknown) => { const gptConfig = GetGamePTConfig(game, playtype); - const data = GetChartDataForGPT(`${game}:${playtype}` as IDStrings); - return prSchemaFnWrap({ songID: p.isPositiveInteger, chartID: "string", - rgcID: "?string", + + // TODO REMOVE level: "string", levelNum: "number", + isPrimary: "boolean", - - // temp hack - difficulty: game === "itg" ? "string" : p.isIn(gptConfig.difficulties), playtype: p.is(playtype), - data, - tierlistInfo: Object.fromEntries( - gptConfig.tierlists.map((t) => [ - t, - p.optional({ text: "string", value: "number", individualDifference: "*boolean" }), - ]) - ), - versions: [p.isIn(gptConfig.orderedSupportedVersions)], + + difficulty: + gptConfig.difficulties.type === "DYNAMIC" + ? "string" + : p.isIn(gptConfig.difficulties.order), + + data: PrudenceZodShim(gptConfig.chartData), + + versions: [p.isIn(gptConfig.versions)], })(self); }; -const PR_CHALLENGE = { - chartID: "string", - authorID: p.isPositiveNonZeroInteger, - type: p.isIn("lamp", "score"), - - game: p.isIn(games), - playtype: isValidPlaytype, -}; - // Returns true on success, throws on failure. export type SchemaValidatorFunction = (self: unknown) => true; @@ -563,14 +256,6 @@ function CreateGameSchemas() { const GAME_SCHEMAS = CreateGameSchemas(); -// OK, here's an insane hack. -// We want schemas to be a a record of key -> SchemaValidatorFunction, but it can't -// be naively done with Record, because that results -// in you losing the actual hardcoded keys in the map. - -// The alternative? Declare it const and redeclare it with those keys on export! -// This is typesafe to including garbage in PRE_SCHEMAS, although with a slightly more -// obtuse error message. const PRE_SCHEMAS = { "bms-course-lookup": prSchemaFnWrap({ title: "string", @@ -684,15 +369,6 @@ const PRE_SCHEMAS = { const gptConfig = GetGamePTConfig(game, playtype); - const hitMeta = { - fast: optNull(p.isPositiveInteger), - slow: optNull(p.isPositiveInteger), - maxCombo: optNull(p.isPositiveInteger), - ...GetHitMeta(game), - }; - - const scoreMeta = GetScoreMeta(game, playtype); - return prSchemaFnWrap({ service: "string", game: p.is(game), @@ -708,21 +384,20 @@ const PRE_SCHEMAS = { importType: p.nullable(p.isIn(allImportTypes)), timeAchieved: p.nullable(p.isPositive), scoreData: { - score: "number", - lamp: p.isIn(gptConfig.lamps), - lampIndex: p.isIn(Object.keys(gptConfig.lamps).map(Number)), - grade: p.isIn(gptConfig.grades), - gradeIndex: p.isIn(Object.keys(gptConfig.grades).map(Number)), - percent: p.isBetween(0, gptConfig.percentMax), - esd: "?number", + ...PR_METRICS(gptConfig.providedMetrics), + ...PR_METRICS(gptConfig.derivedMetrics), + judgements: Object.fromEntries( - gptConfig.judgements.map((j) => [j, optNull(p.isInteger)]) + gptConfig.orderedJudgements.map((j) => [j, optNull(p.isInteger)]) ), - hitMeta, + + optional: PR_METRICS(gptConfig.additionalMetrics, true), }, - scoreMeta, + + scoreMeta: PrudenceZodShim(gptConfig.scoreMeta), + calculatedData: Object.fromEntries( - gptConfig.scoreRatingAlgs.map((a) => [a, "*?number"]) + Object.keys(gptConfig.scoreRatingAlgs).map((a) => [a, "*?number"]) ), })(self); }, @@ -731,13 +406,6 @@ const PRE_SCHEMAS = { const gptConfig = GetGamePTConfig(game, playtype); - const hitMeta = { - fast: optNull(p.isPositiveInteger), - slow: optNull(p.isPositiveInteger), - maxCombo: optNull(p.isPositiveInteger), - ...GetHitMeta(game), - }; - return prSchemaFnWrap({ composedFrom: { scorePB: "string", @@ -757,20 +425,16 @@ const PRE_SCHEMAS = { isPrimary: "boolean", timeAchieved: p.nullable(p.isPositive), scoreData: { - score: "number", - lamp: p.isIn(gptConfig.lamps), - lampIndex: p.isIn(Object.keys(gptConfig.lamps).map(Number)), - grade: p.isIn(gptConfig.grades), - gradeIndex: p.isIn(Object.keys(gptConfig.grades).map(Number)), - percent: p.isBetween(0, gptConfig.percentMax), - esd: "?number", + ...PR_METRICS(gptConfig.providedMetrics), + ...PR_METRICS(gptConfig.derivedMetrics), + judgements: Object.fromEntries( - gptConfig.judgements.map((j) => [j, optNull(p.isInteger)]) + gptConfig.orderedJudgements.map((j) => [j, optNull(p.isInteger)]) ), - hitMeta, + optional: PR_METRICS(gptConfig.additionalMetrics, true), }, calculatedData: Object.fromEntries( - gptConfig.scoreRatingAlgs.map((a) => [a, "*?number"]) + Object.keys(gptConfig.scoreRatingAlgs).map((a) => [a, "*?number"]) ), })(self); }, @@ -802,7 +466,7 @@ const PRE_SCHEMAS = { timeStarted: p.isPositiveInteger, highlight: "boolean", calculatedData: Object.fromEntries( - gptConfig.sessionRatingAlgs.map((k) => [k, "*?number"]) + Object.keys(gptConfig.sessionRatingAlgs).map((k) => [k, "*?number"]) ), scoreIDs: ["string"], })(self); @@ -880,7 +544,7 @@ const PRE_SCHEMAS = { return prSchemaFnWrap( Object.assign(PR_GAME_STATS(game, playtype, gptConfig), { rankings: Object.fromEntries( - gptConfig.profileRatingAlgs.map((e) => [ + Object.keys(gptConfig.profileRatingAlgs).map((e) => [ e, { outOf: p.isPositiveNonZeroInteger, ranking: p.isPositiveNonZeroInteger }, ]) @@ -925,7 +589,7 @@ const PRE_SCHEMAS = { ), scoreBucket: p.isIn(null, "grade", "lamp"), - gameSpecific: PR_GAMESPECIFIC_SETTINGS(game), + gameSpecific: PrudenceZodShim(gptConfig.preferences), }, })(s); }, @@ -942,7 +606,7 @@ const PRE_SCHEMAS = { parent.playtype as Playtypes[Game] ); - const keys = Object.keys(gptConfig.classHumanisedFormat); + const keys = Object.keys(gptConfig.classes); if (typeof self !== "string") { return "Expected a string."; @@ -1086,14 +750,6 @@ const PRE_SCHEMAS = { }, }), - "challenge-wall": prSchemaFnWrap(PR_CHALLENGE), - "challenge-subs": prSchemaFnWrap({ - ...PR_CHALLENGE, - userID: p.isPositiveNonZeroInteger, - achieved: "boolean", - achievedAt: "?number", - }), - notifications: prSchemaFnWrap({ title: "string", notifID: "string", @@ -1140,154 +796,16 @@ const PRE_SCHEMAS = { }, }, }), -} as const; +} as const satisfies Record; 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); - let scoreChecker: ValidSchemaValue = p.isPositiveInteger; - - // DISGUSTING TEMPORARY HACK. NEEDS REFACTOR - if (game === "itg" || game === "gitadora") { - scoreChecker = p.isBetween(0, 100); - } else if (game === "maimaidx") { - scoreChecker = p.isBetween(0, 101); - } - return { - score: scoreChecker, - lamp: p.isIn(gptConfig.lamps), + ...PR_METRICS(gptConfig.providedMetrics), + matchType: p.isIn( "songTitle", "tachiSongID", @@ -1299,7 +817,6 @@ const PR_BATCH_MANUAL_SCORE = (game: Game, playtype: Playtype): PrudenceSchema = "popnChartHash" ), identifier: "string", - percent: game === "jubeat" ? p.isBetween(0, 120) : p.is(undefined), comment: optNull(p.isBoundedString(3, 240)), difficulty: "*?string", @@ -1318,10 +835,10 @@ const PR_BATCH_MANUAL_SCORE = (game: Game, playtype: Playtype): PrudenceSchema = } 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 (!gptConfig.orderedJudgements.includes(key)) { + return `Invalid Key ${key}. Expected any of ${gptConfig.orderedJudgements.join( + ", " + )}`; } if ((!Number.isSafeInteger(v) || v < 0) && v !== null) { @@ -1331,13 +848,41 @@ const PR_BATCH_MANUAL_SCORE = (game: Game, playtype: Playtype): PrudenceSchema = return true; }), - hitMeta: optNull( - deepmerge(BaseValidHitMeta, PR_HIT_META(game)) as unknown as ValidSchemaValue - ), - scoreMeta: optNull(PR_SCORE_META(game, playtype)), + additionalMetrics: optNull(PR_METRICS(gptConfig.additionalMetrics, true)), + scoreMeta: optNull(PrudenceZodShim(gptConfig.scoreMeta)), }; }; +function PR_METRIC(metric: ScoreMetric): ValidSchemaValue { + switch (metric.type) { + case "DECIMAL": + return "number"; + + case "INTEGER": + return p.isInteger; + + case "GRAPH": + return ["number"]; + + case "ENUM": + return p.isIn(metric.values); + } +} + +function PR_METRICS(metrics: Record, shouldAllBeOptNull?: boolean) { + const schema: PrudenceSchema = {}; + + for (const [key, value] of Object.entries(metrics)) { + if (shouldAllBeOptNull === true) { + schema[key] = optNull(PR_METRIC(value)); + } else { + schema[key] = PR_METRIC(value); + } + } + + return schema; +} + const PR_BATCH_MANUAL_CLASSES = (game: Game, playtype: Playtype): PrudenceSchema => { const config = GetGamePTConfig(game, playtype); @@ -1345,13 +890,9 @@ const PR_BATCH_MANUAL_CLASSES = (game: Game, playtype: Playtype): PrudenceSchema // for all classes this GPT supports // if `canBeBatchManualSubmitted` is true, allow it to be batchManualSubmitted. - for (const [s, v] of Object.entries(config.classHumanisedFormat)) { - const set = s as AllClassSets; - - const classProperties = config.classProperties[set]; - - if (classProperties.canBeBatchManualSubmitted) { - schema[set] = optNull(p.isIn(v.map((e) => e.id))); + for (const [s, v] of Object.entries(config.classes)) { + if (v.type === "PROVIDED") { + schema[s] = optNull(p.isIn(v.values)); } } diff --git a/common/src/seeds/types.ts b/common/src/seeds/types.ts index 644ab1f08..bef469f4f 100644 --- a/common/src/seeds/types.ts +++ b/common/src/seeds/types.ts @@ -2,7 +2,6 @@ import { allSupportedGames } from "../config/config"; import type { Game, ChartDocument, - GameToGPTStrings, SongDocument, BMSCourseDocument, FolderDocument, @@ -10,13 +9,14 @@ import type { QuestlineDocument, QuestDocument, TableDocument, + GPTStrings, } from "../types"; // lazy, but kinda cool macros. // note that TS won't let you do this multiple times within an object // so, we have to join them ourselves. Ah well, not that bad. type ChartDBSeeds = { - [G in Game as `charts-${G}.json`]: Array>; + [G in Game as `charts-${G}.json`]: Array>; }; type SongDBSeeds = { diff --git a/common/src/types/batch-manual.ts b/common/src/types/batch-manual.ts index d6d513c1e..818bebdca 100644 --- a/common/src/types/batch-manual.ts +++ b/common/src/types/batch-manual.ts @@ -1,7 +1,7 @@ import type { integer } from "../types"; import type { AdditionalMetrics, - ChartSets, + Versions, Difficulties, ExtractedClasses, GPTString, @@ -47,12 +47,7 @@ export interface BatchManual { playtype: GPTStringToPlaytype[GPT]; service: string; - chartSet?: ChartSets[GPT]; - - /** - * @deprecated Use `meta.chartSet` instead. This is an alias for `meta.chartSet`. - */ - version?: ChartSets[GPT]; + version?: Versions[GPT]; }; scores: Array>; classes?: ExtractedClasses[GPT] | null; diff --git a/common/src/types/documents.ts b/common/src/types/documents.ts index 5ae64bfa2..8dab34819 100644 --- a/common/src/types/documents.ts +++ b/common/src/types/documents.ts @@ -1,6 +1,6 @@ import type { ChartDocumentData, - ChartSets, + Versions, Classes, Difficulties, ExtractedAdditionalMetrics, @@ -335,7 +335,7 @@ export interface ChartDocument { difficulty: Difficulties[GPT]; playtype: GPTStringToPlaytype[GPT]; data: ChartDocumentData[GPT]; - chartSets: Array; + versions: Array; } export interface SongDocument { diff --git a/common/src/types/game-config-utils.ts b/common/src/types/game-config-utils.ts index ed11e6b2e..794b2a0d2 100644 --- a/common/src/types/game-config-utils.ts +++ b/common/src/types/game-config-utils.ts @@ -1,6 +1,8 @@ // Types related to game configuration. These are used by our `internal` game config // types +import type { integer } from "../types"; + export interface RatingAlgorithmConfig { /** * Write a short descrption for this rating algorithm. @@ -97,7 +99,7 @@ export interface DynamicDifficulties { export interface FixedDifficulties { type: "FIXED"; - difficultyOrder: ReadonlyArray; + order: ReadonlyArray; /** * For mobile view (and certain pages where things are generally smaller) @@ -105,9 +107,9 @@ export interface FixedDifficulties { * * For example, in IIDX "ANOTHER" is generally shortened to "A". */ - difficultyShorthand: Partial>; + shorthand: Partial>; - defaultDifficulty: Difficulty; + default: Difficulty; } export type DifficultyConfig = diff --git a/common/src/types/game-config.ts b/common/src/types/game-config.ts index e2e187f57..b851830e9 100644 --- a/common/src/types/game-config.ts +++ b/common/src/types/game-config.ts @@ -97,23 +97,21 @@ export type GPTStringToPlaytype = { export type Difficulties = { // If this game has fixed difficulties, infer what they are // otherwise, difficulties are an arbitrary string - [G in GPTString]: typeof GAME_PT_CONFIGS[G]["difficultyConfig"] extends FixedDifficulties< - infer D - > + [G in GPTString]: typeof GAME_PT_CONFIGS[G]["difficulties"] extends FixedDifficulties ? D : string; }; export type DifficultyConfigs = { - [G in GPTString]: typeof GAME_PT_CONFIGS[G]["difficultyConfig"]; + [G in GPTString]: typeof GAME_PT_CONFIGS[G]["difficulties"]; }; export type Judgements = { [G in GPTString]: ExtractArrayElementType; }; -export type ChartSets = { - [G in GPTString]: ExtractArrayElementType; +export type Versions = { + [G in GPTString]: ExtractArrayElementType; }; export type ScoreRatingAlgorithms = { @@ -324,7 +322,7 @@ export interface SpecificGamePTConfig { * allowing any string (as long as its unique.) * */ - difficultyConfig: DifficultyConfigs[GPT]; + difficulties: DifficultyConfigs[GPT]; /** * What judgements does this GPT have? These are typically timing-window names. @@ -334,9 +332,9 @@ export interface SpecificGamePTConfig { orderedJudgements: ReadonlyArray; /** - * What sets of charts do we have for this GPT? + * What versions do we support for this GPT? * - * Chart Sets are the way tachi disambiguates cases (typically in arcade games) where + * Version are the way tachi disambiguates cases (typically in arcade games) where * a chart is modified. * For example, Rising in the Sun * (https://remywiki.com/Rising_in_the_Sun(original_mix)) @@ -348,10 +346,10 @@ export interface SpecificGamePTConfig { * We need to handle these cases, so we disambiguate by attaching "chart sets" onto * every chart. These "chart sets" indicate what sets of chart states they * appeared in for this GPT. Then, when a score is coming in, it can indicate what - * chartSet this score was on. That way, we can make sure they resolve to the right + * version this score was on. That way, we can make sure they resolve to the right * chart. */ - chartSets: ReadonlyArray; + versions: ReadonlyArray; /** * Chart documents get their own GPT-specific record that they use for whatever diff --git a/common/src/types/internals.ts b/common/src/types/internals.ts index 64047e6db..1f909f6d0 100644 --- a/common/src/types/internals.ts +++ b/common/src/types/internals.ts @@ -34,11 +34,11 @@ export type GamePTConfig = Readonly<{ classes: Record; - difficultyConfig: DifficultyConfig; + difficulties: DifficultyConfig; orderedJudgements: ReadonlyArray; - chartSets: ReadonlyArray; + versions: ReadonlyArray; supportedMatchTypes: ReadonlyArray; diff --git a/common/src/types/metrics.ts b/common/src/types/metrics.ts index 4e823cb74..dbb78fb2b 100644 --- a/common/src/types/metrics.ts +++ b/common/src/types/metrics.ts @@ -2,6 +2,19 @@ import type { integer } from "../types"; import type { ChartDocument } from "./documents"; import type { GPTString } from "./game-config"; +export type DecimalMetricValidator = ( + metric: number, + chart: ChartDocument +) => string | true; +export type IntegerMetricValidator = ( + metric: integer, + chart: ChartDocument +) => string | true; +export type GraphMetricValidator = ( + metric: Array, + chart: ChartDocument +) => string | true; + /** * A metric for a score that's a floating point number. */ diff --git a/common/src/utils/util.ts b/common/src/utils/util.ts index 52d53d939..d0e4d9afa 100644 --- a/common/src/utils/util.ts +++ b/common/src/utils/util.ts @@ -1,17 +1,14 @@ import { GetGameConfig, GetGamePTConfig, GetSpecificGPTConfig } from "../config/config"; import type { - integer, - PBScoreDocument, ChartDocument, + GPTStrings, Game, + PBScoreDocument, Playtypes, SongDocument, - GameToGPTStrings, - SpecificGamePTConfig, - GPTString, - GPTStrings, } from "../types"; -import type { PrudenceError } from "prudence"; +import type { PrudenceSchema, ValidSchemaValue } from "prudence"; +import type { AnyZodObject } from "zod"; export function FormatInt(v: number): string { return Math.floor(v).toFixed(0); @@ -47,7 +44,7 @@ export function FormatDifficulty(chart: ChartDocument, game: Game): string { // actually possible. // todo: come up with something better. // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const shortDiff = gptConfig.difficultyConfig.difficultyShorthand[ch.difficulty]; + const shortDiff = gptConfig.difficulties.difficultyShorthand[ch.difficulty]; // gitadora should always use short diffs. they just look better. return `${shortDiff} ${chart.level}`; @@ -76,13 +73,12 @@ export function FormatDifficultyShort(chart: ChartDocument, game: Game): string return `S${itgChart.data.difficultyTag} ${chart.level}`; } - if (gptConfig.difficultyConfig.type === "DYNAMIC") { + if (gptConfig.difficulties.type === "DYNAMIC") { // TODO cap string length return `${chart.difficulty} ${chart.level}`; } - const shortDiff = - gptConfig.difficultyConfig.difficultyShorthand[chart.difficulty] ?? chart.difficulty; + const shortDiff = gptConfig.difficulties.shorthand[chart.difficulty] ?? chart.difficulty; if (gameConfig.playtypes.length === 1 || game === "gitadora") { return `${shortDiff} ${chart.level}`; @@ -172,10 +168,10 @@ export function FormatChart( let diff: string; - if (gptConfig.difficultyConfig.type === "DYNAMIC") { + if (gptConfig.difficulties.type === "DYNAMIC") { diff = chart.difficulty; } else { - diff = gptConfig.difficultyConfig.difficultyShorthand[chart.difficulty] ?? chart.difficulty; + diff = gptConfig.difficulties.shorthand[chart.difficulty] ?? chart.difficulty; } // iidx formats things like SPA instead of SP A. @@ -188,248 +184,35 @@ export function FormatChart( space = " "; } - // return the most recent chartSet this chart appeared in if it + // return the most recent version this chart appeared in if it // is not primary. if (!chart.isPrimary) { - return `${song.title} (${playtypeStr}${space}${diff} ${chart.level} ${chart.chartSets[0]})`; + return `${song.title} (${playtypeStr}${space}${diff} ${chart.level} ${chart.versions[0]})`; } return `${song.title} (${playtypeStr}${space}${diff} ${chart.level})`; } -export function AbsoluteGradeDelta( - game: Game, - playtype: Playtypes[Game], - score: number, - percent: number, - gradeOrIndex: Grades[I] | integer -): number { - const gptConfig = GetGamePTConfig(game, playtype); - - const maxScore = Math.round(score * (100 / percent)); - - const gradeIndex = - typeof gradeOrIndex === "number" ? gradeOrIndex : gptConfig.grades.indexOf(gradeOrIndex); - - const gradeValue = gptConfig.gradeBoundaries[gradeIndex]; - - if (gradeValue === undefined) { - throw new Error(`Grade Index ${gradeIndex} has no corresponding grade value?`); - } - - const gradeScore = Math.ceil((gradeValue / 100) * maxScore); - - return score - gradeScore; -} - -export function RelativeGradeDelta( - game: Game, - playtype: Playtypes[Game], - score: number, - percent: number, - grade: Grades[GPT], - relativeIndex: integer -): { grade: string; delta: number } | null { - const gptConfig = GetGamePTConfig(game, playtype); - - const nextGradeIndex = gptConfig.grades.indexOf(grade) + relativeIndex; - - if (nextGradeIndex < 0 || nextGradeIndex >= gptConfig.grades.length) { - return null; - } - - const nextGrade = gptConfig.grades[nextGradeIndex]; - - if (nextGrade === undefined) { - throw new Error( - `Unexpectedly found no grade at index ${nextGradeIndex} for ${game} ${playtype}.` - ); - } - - return { - grade: nextGrade, - delta: AbsoluteGradeDelta(game, playtype, score, percent, nextGradeIndex), - }; -} - -function WrapGrade(grade: string) { - if (grade.endsWith("-") || grade.endsWith("+")) { - return `(${grade})`; - } - - return grade; -} - -type FormatGradeDeltaReturns = - | { - lower: string; - upper: string; - closer: "upper"; - } - | { - lower: string; - upper?: string; - closer: "lower"; - }; - -export function GenericFormatGradeDelta( - game: Game, - playtype: Playtypes[Game], - score: number, - percent: number, - grade: Grades[I], - formatNumFn: (n: number) => string = (s) => s.toString() -): FormatGradeDeltaReturns { - const upper = RelativeGradeDelta(game, playtype, score, percent, grade, 1); - const lower = AbsoluteGradeDelta(game, playtype, score, percent, grade); - - const formatLower = `${WrapGrade(grade)}+${formatNumFn(lower)}`; - - if (!upper) { - return { - lower: formatLower, - closer: "lower", - }; - } - - const formatUpper = `${WrapGrade(upper.grade)}${formatNumFn(upper.delta)}`; - - // are we closer to the lower bound, or the upper one? - let closer: "lower" | "upper" = upper.delta + lower < 0 ? "lower" : "upper"; - - // lovely hardcoded exception for IIDX - (MAX-)+ is always a stupid metric - // so always mute it. - if (game === "iidx" && formatLower.startsWith("(MAX-)+")) { - closer = "upper"; - } - - return { - lower: formatLower, - upper: formatUpper, - closer, - }; -} - -export function GetCloserGradeDelta( - game: Game, - playtype: Playtypes[Game], - score: number, - percent: number, - grade: Grades[I], - formatNumFn: (n: number) => string = (s) => s.toString() -): string { - const { lower, upper, closer } = GenericFormatGradeDelta( - game, - playtype, - score, - percent, - grade, - formatNumFn - ); - - if (closer === "upper") { - // this type assertion is unecessary in theory, but in practice older versions - // of TS aren't happy with it. - // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion - return upper as string; - } - - return lower; -} - -export function CreateSongMap(songs: Array>) { - const songMap = new Map>(); - - for (const song of songs) { - songMap.set(song.id, song); - } - - return songMap; -} - -export function CreateChartMap(charts: Array>) { - const chartMap = new Map>(); - - for (const chart of charts) { - chartMap.set(chart.chartID, chart); - } - - return chartMap; -} - -/** - * 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}.`; -} - -// games that have funny relative grades, basically. -type IIDXBMSGPTs = "bms:7K" | "bms:14K" | "iidx:DP" | "iidx:SP" | "pms:Controller" | "pms:Keyboard"; -export function IIDXBMSGradeGoalFormatter(pb: PBScoreDocument) { - const { closer, lower, upper } = GenericFormatGradeDelta( - pb.game, - pb.playtype, - pb.scoreData.score, - pb.scoreData.percent, - pb.scoreData.grade - ); - - // if upper doesn't exist, we have to return lower (this is a MAX) - // or something. - if (!upper) { - return lower; - } - - // if the upper bound is relevant to the grade we're looking for - // i.e. the goal is to AAA a chart and the user has AA+20/AAA-100 - // prefer AAA-100 instead of AA+20. - if (upper.startsWith(`${pb.scoreData.grade}-`)) { - return upper; - } - - // otherwise, return whichever is closer. - return closer === "lower" ? lower : upper; -} - -export function GradeGoalFormatter(pb: PBScoreDocument) { - const { closer, lower, upper } = GenericFormatGradeDelta( - pb.game, - pb.playtype, - pb.scoreData.score, - pb.scoreData.percent, - pb.scoreData.grade - ); - - // if upper doesn't exist, we have to return lower (this is a MAX) - // or something. - if (!upper) { - return lower; - } - - // if the upper bound is relevant to the grade we're looking for - // i.e. the goal is to AAA a chart and the user has AA+20/AAA-100 - // prefer AAA-100 instead of AA+20. - if (upper.startsWith(`${pb.scoreData.grade}-`)) { - return upper; - } - - // otherwise, return whichever is closer. - return closer === "lower" ? lower : upper; -} - // For games with 'BP', show that next to the clear. -export function IIDXBMSLampGoalFormatter(pb: PBScoreDocument) { +export function IIDXBMSLampGoalFormatter(pb: PBScoreDocument) { if (typeof pb.scoreData.additionalMetrics.bp === "number") { return `${pb.scoreData.lamp.string} (BP: ${pb.scoreData.additionalMetrics.bp})`; } return pb.scoreData.lamp; } + +/** + * Run a zod schema inside prudence. + */ +export function PrudenceZodShim(zodSchema: AnyZodObject): ValidSchemaValue { + return (self) => { + const res = zodSchema.safeParse(self); + + if (res.success) { + return true; + } + + return res.error.message; + }; +} diff --git a/common/src/webhooks.ts b/common/src/webhooks.ts index 73893adb7..b5262902b 100644 --- a/common/src/webhooks.ts +++ b/common/src/webhooks.ts @@ -1,6 +1,5 @@ -import type { Game, Playtypes } from "."; -import type { GameClassSets } from "./config/game-classes"; -import type { GoalImportStat, IDStrings, integer, QuestImportStat } from "./types"; +import type { GoalImportStat, integer, QuestImportStat } from "./types"; +import type { Classes, Game, GPTString, Playtypes } from "./types/game-config"; /** * An event fired when a users class improves. @@ -9,7 +8,7 @@ export interface WebhookEventClassUpdateV1 { type: "class-update/v1"; content: { userID: integer; - set: GameClassSets[IDStrings]; + set: Classes[GPTString]; old: integer | null; new: integer; game: Game; diff --git a/server/src/external/mongo/schemas.ts b/server/src/external/mongo/schemas.ts index 1b7812790..cd5ef9961 100644 --- a/server/src/external/mongo/schemas.ts +++ b/server/src/external/mongo/schemas.ts @@ -1,6 +1,6 @@ import { TachiConfig } from "lib/setup/config"; import { p } from "prudence"; -import { allImportTypes } from "tachi-common/config/static-config"; +import { allImportTypes } from "tachi-common/constants/import-types"; import { SCHEMAS } from "tachi-common/lib/schemas"; import type { Databases } from "./db"; import type { PrudenceSchema } from "prudence";