diff --git a/client/src/components/user/UGPTStatCreator.tsx b/client/src/components/user/UGPTStatCreator.tsx index b86095f62..4e9dd7bf0 100644 --- a/client/src/components/user/UGPTStatCreator.tsx +++ b/client/src/components/user/UGPTStatCreator.tsx @@ -38,7 +38,7 @@ export default function UGPTStatCreator({ const formik = useFormik({ initialValues: { mode: "chart", - property: "lamp", + metric: "lamp", folderID: undefined, chartID: undefined, }, @@ -69,7 +69,7 @@ export default function UGPTStatCreator({ useEffect(() => { if (formik.values.mode === "folder" && formik.values.property === "playcount") { - formik.setValues({ ...formik.values, property: "lamp" }); + formik.setValues({ ...formik.values, metric: "lamp" }); } }, [formik.values.mode]); diff --git a/common/src/config/config.test.ts b/common/src/config/config.test.ts index dedf4e3d3..bd10cc2a9 100644 --- a/common/src/config/config.test.ts +++ b/common/src/config/config.test.ts @@ -19,6 +19,12 @@ t.test("#GetGameConfig", (t) => { t.end(); }); +const BANNED_METRIC_NAMES = [ + "enumIndexes", // haha + "optional", // used for optional metrics + "playcount", // used by showcase stats +]; + t.test("#GetGamePTConfig", (t) => { for (const game of allSupportedGames) { const gameConfig = GetGameConfig(game); @@ -49,6 +55,14 @@ t.test("#GetGamePTConfig", (t) => { "The default difficulty should be part of difficultyOrder." ); } + + for (const metric of Object.keys(conf.scoreRatingAlgs)) { + if (BANNED_METRIC_NAMES.includes(metric)) { + t.fail(`Cannot have a metric called ${metric}. This is a banned metric name.`); + } + + t.match(/^[a-zA-Z][a-zA-Z0-9]+$/u, metric, `Should be alphanumeric.`); + } } } diff --git a/common/src/config/game-support/bms.ts b/common/src/config/game-support/bms.ts index 455aaf939..e5f877c7f 100644 --- a/common/src/config/game-support/bms.ts +++ b/common/src/config/game-support/bms.ts @@ -252,7 +252,7 @@ export const BMS_7K_CONF = { }), preferences: z.strictObject({ - displayTables: z.array(z.string()), + displayTables: z.array(z.string()).optional().nullable(), }), scoreMeta: z.strictObject({ diff --git a/common/src/config/game-support/iidx.ts b/common/src/config/game-support/iidx.ts index d111ade45..65bfcb3d9 100644 --- a/common/src/config/game-support/iidx.ts +++ b/common/src/config/game-support/iidx.ts @@ -220,8 +220,8 @@ export const IIDX_SP_CONF = { }), preferences: z.strictObject({ - display2DXTra: z.boolean(), - bpiTarget: z.number(), + display2DXTra: z.boolean().optional().nullable(), + bpiTarget: z.number().lte(100).gte(-15).optional().nullable(), }), scoreMeta: z.strictObject({ diff --git a/common/src/config/game-support/jubeat.ts b/common/src/config/game-support/jubeat.ts index a60a59809..823a41e45 100644 --- a/common/src/config/game-support/jubeat.ts +++ b/common/src/config/game-support/jubeat.ts @@ -107,7 +107,7 @@ export const JUBEAT_SINGLE_CONF = { inGameID: z.union([z.array(zodNonNegativeInt), zodNonNegativeInt]), }), - preferences: z.strictObject({ jubilityTarget: z.number() }), + preferences: z.strictObject({ jubilityTarget: z.number().optional().nullable() }), scoreMeta: z.strictObject({}), diff --git a/common/src/config/game-support/sdvx.ts b/common/src/config/game-support/sdvx.ts index c1200f70f..f3e508536 100644 --- a/common/src/config/game-support/sdvx.ts +++ b/common/src/config/game-support/sdvx.ts @@ -163,7 +163,7 @@ export const SDVX_SINGLE_CONF = { clearTier: zodTierlistData, }), - preferences: z.strictObject({ vf6Target: z.number() }), + preferences: z.strictObject({ vf6Target: z.number().optional().nullable() }), scoreMeta: z.strictObject({}), supportedMatchTypes: ["sdvxInGameID", "songTitle", "tachiSongID"], diff --git a/common/src/config/game-support/usc.ts b/common/src/config/game-support/usc.ts index f7f221abd..ed750b2de 100644 --- a/common/src/config/game-support/usc.ts +++ b/common/src/config/game-support/usc.ts @@ -98,7 +98,7 @@ export const USC_CONTROLLER_CONF = { ), }), - preferences: z.strictObject({ vf6Target: z.number() }), + preferences: z.strictObject({ vf6Target: z.number().optional().nullable() }), scoreMeta: z.strictObject({ noteMod: z.enum(["MIR-RAN", "MIRROR", "NORMAL", "RANDOM"]).optional(), gaugeMod: z.enum(["NORMAL", "HARD", "PERMISSIVE"]).optional(), diff --git a/common/src/constants/grade-boundaries.ts b/common/src/constants/grade-boundaries.ts index 57a8e8c28..34308778a 100644 --- a/common/src/constants/grade-boundaries.ts +++ b/common/src/constants/grade-boundaries.ts @@ -14,7 +14,8 @@ export function MakeGrades(rec: Record) { out.push({ name: name as G, lowerBound: lowerBound as number }); } - out.sort((a, b) => b.lowerBound - a.lowerBound); + // sort ascendingly + out.sort((a, b) => a.lowerBound - b.lowerBound); return out; } diff --git a/common/src/lib/schemas.ts b/common/src/lib/schemas.ts index bc84c757d..85a633161 100644 --- a/common/src/lib/schemas.ts +++ b/common/src/lib/schemas.ts @@ -841,7 +841,8 @@ const PR_BATCH_MANUAL_SCORE = (game: Game, playtype: Playtype): PrudenceSchema = return true; }), - additionalMetrics: optNull(PR_METRICS(gptConfig.optionalMetrics, true)), + optional: optNull(PR_METRICS(gptConfig.optionalMetrics, true)), + hitMeta: optNull(PR_METRICS(gptConfig.optionalMetrics, true)), scoreMeta: optNull(PrudenceZodShim(gptConfig.scoreMeta)), }; }; @@ -897,7 +898,7 @@ const PR_BATCH_MANUAL_CLASSES = (game: Game, playtype: Playtype): PrudenceSchema // if `canBeBatchManualSubmitted` is true, allow it to be batchManualSubmitted. for (const [s, v] of Object.entries(config.classes)) { if (v.type === "PROVIDED") { - schema[s] = optNull(p.isIn(v.values)); + schema[s] = optNull(p.isIn(v.values.map((e) => e.id))); } } diff --git a/common/src/types/batch-manual.ts b/common/src/types/batch-manual.ts index 0aaf0c93c..3ddec9af3 100644 --- a/common/src/types/batch-manual.ts +++ b/common/src/types/batch-manual.ts @@ -30,6 +30,11 @@ export type BatchManualScore = ExtractMetrics judgements?: Record; timeAchieved?: number | null; optional?: AllFieldsNullableOptional>; + + /** + * @deprecated Use `optional` instead. + */ + hitMeta?: AllFieldsNullableOptional>; scoreMeta?: Partial; } & ( | { @@ -47,7 +52,6 @@ export interface BatchManual { game: GPTStringToGame[GPT]; playtype: GPTStringToPlaytype[GPT]; service: string; - version?: Versions[GPT]; }; scores: Array>; diff --git a/common/src/types/documents.ts b/common/src/types/documents.ts index af9745914..a3ff266c1 100644 --- a/common/src/types/documents.ts +++ b/common/src/types/documents.ts @@ -444,7 +444,7 @@ export type QuestSubscriptionDocument = { export type ScoreData = DerivedMetrics[GPT] & ProvidedMetrics[GPT] & { judgements: Partial>; - optional: Partial & { + optional: OptionalMetrics[GPT] & { enumIndexes: OptionalEnumIndexes; }; enumIndexes: ScoreEnumIndexes; diff --git a/common/src/utils/util.test.ts b/common/src/utils/util.test.ts new file mode 100644 index 000000000..f4cd7f07c --- /dev/null +++ b/common/src/utils/util.test.ts @@ -0,0 +1,37 @@ +import { GetGradeDeltas } from "./util"; +import { WACCA_GBOUNDARIES } from "../constants/grade-boundaries"; +import t from "tap"; + +t.test("#GetGradeDeltas", (t) => { + t.strictSame( + GetGradeDeltas(WACCA_GBOUNDARIES, "S", 921_013), + { + lower: "S+21K", + upper: "(S+)-9K", + closer: "upper", + }, + "Should correctly calculate grade boundaries." + ); + + t.strictSame( + GetGradeDeltas(WACCA_GBOUNDARIES, "S", 901_013), + { + lower: "S+1K", + upper: "(S+)-29K", + closer: "lower", + }, + "Should correctly calculate grade boundaries." + ); + + t.strictSame( + GetGradeDeltas(WACCA_GBOUNDARIES, "S", 901_013, (n) => n.toString()), + { + lower: "S+1013", + upper: "(S+)-28987", + closer: "lower", + }, + "Should apply the num format function." + ); + + t.end(); +}); diff --git a/common/src/utils/util.ts b/common/src/utils/util.ts index 688017cb7..ebc8b05b6 100644 --- a/common/src/utils/util.ts +++ b/common/src/utils/util.ts @@ -252,6 +252,7 @@ function RelativeGradeDelta( gradeBoundaries: Array>, scoreGrade: G, scoreValue: number, + // Positive number means higher grade, etc. relativeIndex: number ) { const gradeBoundary = @@ -267,7 +268,7 @@ function RelativeGradeDelta( function AbsoluteGradeDelta(gradeBoundary: GradeBoundary, scoreValue: number) { return { grade: gradeBoundary.name, - delta: gradeBoundary.lowerBound - scoreValue, + delta: scoreValue - gradeBoundary.lowerBound, }; } diff --git a/server/src/game-implementations/games/_common.ts b/server/src/game-implementations/games/_common.ts index 4c884da8d..3c732f628 100644 --- a/server/src/game-implementations/games/_common.ts +++ b/server/src/game-implementations/games/_common.ts @@ -1,3 +1,4 @@ +import { CreatePBMergeFor } from "game-implementations/utils/pb-merge"; import { ProfileSumBestN } from "game-implementations/utils/profile-calc"; import { SessionAvgBest10For } from "game-implementations/utils/session-calc"; import { InternalFailure } from "lib/score-import/framework/common/converter-failures"; @@ -14,12 +15,13 @@ import type { ChartSpecificMetricValidator, GPTClassDerivers, GPTDerivers, - GPTGoalCriteriaFormatters, + GPTGoalFormatters, GPTGoalProgressFormatters, GPTMetricValidators, GPTProfileCalculators, GPTScoreCalculators, GPTSessionCalculators, + PBMergeFunction, ScoreCalculator, } from "game-implementations/types"; import type { GPTStrings, GradeBoundary, integer, SpecificUserGameStats } from "tachi-common"; @@ -200,10 +202,14 @@ export const SDVXLIKE_CLASS_DERIVERS: GPTClassDerivers = { }, }; -export const SDVXLIKE_GOAL_FMT: GPTGoalCriteriaFormatters = { +export const SDVXLIKE_GOAL_FMT: GPTGoalFormatters = { score: GoalFmtScore, }; +export const SDVXLIKE_GOAL_OO_FMT: GPTGoalFormatters = { + score: GoalOutOfFmtScore, +}; + export const SDVXLIKE_GOAL_PG_FMT: GPTGoalProgressFormatters = { score: (pb) => FmtNum(pb.scoreData.score), lamp: (pb) => pb.scoreData.lamp, @@ -216,6 +222,14 @@ export const SDVXLIKE_GOAL_PG_FMT: GPTGoalProgressFormatters = { ), }; +export const SDVXLIKE_PB_MERGERS: Array> = [ + CreatePBMergeFor("largest", "enumIndexes.lamp", "Best Lamp", (base, score) => { + base.scoreData.lamp = score.scoreData.lamp; + }), +]; + +export const SDVXLIKE_DEFAULT_MERGE_NAME = "Best Score"; + export const SGLCalc: ScoreCalculator = (scoreData, chart) => { const ecValue = chart.data.sglEC ?? 0; const hcValue = chart.data.sglHC ?? 0; @@ -241,6 +255,14 @@ export function GoalFmtScore(val: number) { return `Get a score of ${val.toLocaleString("en-GB")} on`; } +export function GoalOutOfFmtPercent(val: number) { + return `${FormatMaxDP(val)}%`; +} + +export function GoalOutOfFmtScore(val: number) { + return val.toLocaleString("en-GB"); +} + /** * Given some grade boundaries and some values, format a grade delta for a goal. * @@ -270,7 +292,7 @@ export function GradeGoalFormatter( // 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(`${goalGrade}-`)) { + if (new RegExp(`^\\(?${goalGrade}\\)?-`, "u").exec(upper)) { return upper; } diff --git a/server/src/game-implementations/games/bms-pms.ts b/server/src/game-implementations/games/bms-pms.ts index 3730ce132..365bbd2f7 100644 --- a/server/src/game-implementations/games/bms-pms.ts +++ b/server/src/game-implementations/games/bms-pms.ts @@ -1,17 +1,33 @@ import { GoalFmtPercent, GoalFmtScore, + GoalOutOfFmtPercent, GradeGoalFormatter, IIDXLIKE_DERIVERS, IIDXLIKE_VALIDATORS, SGLCalc, } from "./_common"; +import { CreatePBMergeFor } from "game-implementations/utils/pb-merge"; import { ProfileAvgBestN } from "game-implementations/utils/profile-calc"; import { SessionAvgBest10For } from "game-implementations/utils/session-calc"; import { IIDXLIKE_GBOUNDARIES } from "tachi-common"; -import type { GPTServerImplementation } from "game-implementations/types"; +import type { GPTServerImplementation, PBMergeFunction } from "game-implementations/types"; import type { GPTStrings } from "tachi-common"; +const BMS_PMS_MERGERS: Array> = [ + CreatePBMergeFor("largest", "enumIndexes.lamp", "Best Lamp", (base, lamp) => { + base.scoreData.lamp = lamp.scoreData.lamp; + + // technically these don't exist on PMS scores but since undefined is a + // legal value for these properties it works out. + base.scoreData.optional.gauge = lamp.scoreData.optional.gauge; + base.scoreData.optional.gaugeHistory = lamp.scoreData.optional.gaugeHistory; + }), + CreatePBMergeFor("smallest", "optional.bp", "Lowest BP", (base, bp) => { + base.scoreData.optional.bp = bp.scoreData.optional.bp; + }), +]; + // bms and pms currently have *identical* // implementations. Nice. @@ -56,6 +72,13 @@ const BMS_IMPL: GPTServerImplementation = { } ), }, + goalOutOfFormatters: { + percent: GoalOutOfFmtPercent, + // don't insert commas or anything. + score: (m) => m.toString(), + }, + pbMergeFunctions: BMS_PMS_MERGERS, + defaultMergeRefName: "Best Score", }; export const BMS_14K_IMPL: GPTServerImplementation<"bms:14K"> = BMS_IMPL; diff --git a/server/src/game-implementations/games/chunithm.ts b/server/src/game-implementations/games/chunithm.ts index 7a63597bd..2f1d2056f 100644 --- a/server/src/game-implementations/games/chunithm.ts +++ b/server/src/game-implementations/games/chunithm.ts @@ -1,4 +1,5 @@ -import { GetGrade, GoalFmtScore, GradeGoalFormatter } from "./_common"; +import { GetGrade, GoalFmtScore, GoalOutOfFmtScore, GradeGoalFormatter } from "./_common"; +import { CreatePBMergeFor } from "game-implementations/utils/pb-merge"; import { ProfileAvgBestN } from "game-implementations/utils/profile-calc"; import { SessionAvgBest10For } from "game-implementations/utils/session-calc"; import { CHUNITHMRating } from "rg-stats"; @@ -62,4 +63,13 @@ export const CHUNITHM_IMPL: GPTServerImplementation<"chunithm:Single"> = { lamp: (pb) => pb.scoreData.lamp, score: (pb) => FmtNum(pb.scoreData.score), }, + goalOutOfFormatters: { + score: GoalOutOfFmtScore, + }, + pbMergeFunctions: [ + CreatePBMergeFor("largest", "enumIndexes.lamp", "Best Lamp", (base, score) => { + base.scoreData.lamp = score.scoreData.lamp; + }), + ], + defaultMergeRefName: "Best Score", }; diff --git a/server/src/game-implementations/games/gitadora.ts b/server/src/game-implementations/games/gitadora.ts index 6debf697a..4e2d88fac 100644 --- a/server/src/game-implementations/games/gitadora.ts +++ b/server/src/game-implementations/games/gitadora.ts @@ -1,5 +1,6 @@ -import { GetGrade, GoalFmtPercent, GradeGoalFormatter } from "./_common"; +import { GetGrade, GoalFmtPercent, GoalOutOfFmtPercent, GradeGoalFormatter } from "./_common"; import db from "external/mongo/db"; +import { CreatePBMergeFor } from "game-implementations/utils/pb-merge"; import { GetBestRatingOnSongs, ProfileSumBestN } from "game-implementations/utils/profile-calc"; import { SessionAvgBest10For } from "game-implementations/utils/session-calc"; import { GITADORASkill } from "rg-stats"; @@ -105,6 +106,15 @@ const GITADORA_IMPL: GPTServerImplementation<"gitadora:Dora" | "gitadora:Gita"> (v) => `${v.toFixed(2)}%` ), }, + goalOutOfFormatters: { + percent: GoalOutOfFmtPercent, + }, + pbMergeFunctions: [ + CreatePBMergeFor("largest", "enumIndexes.lamp", "Best Lamp", (base, score) => { + base.scoreData.lamp = score.scoreData.lamp; + }), + ], + defaultMergeRefName: "Best Percent", }; export const GITADORA_GITA_IMPL: GPTServerImplementation<"gitadora:Gita"> = GITADORA_IMPL; diff --git a/server/src/game-implementations/games/iidx.ts b/server/src/game-implementations/games/iidx.ts index bf9f5d5f9..28c9db80a 100644 --- a/server/src/game-implementations/games/iidx.ts +++ b/server/src/game-implementations/games/iidx.ts @@ -1,18 +1,21 @@ import { GoalFmtPercent, GoalFmtScore, + GoalOutOfFmtPercent, GradeGoalFormatter, IIDXLIKE_DERIVERS, IIDXLIKE_VALIDATORS, } from "./_common"; +import { CreatePBMergeFor } from "game-implementations/utils/pb-merge"; import { ProfileAvgBestN } from "game-implementations/utils/profile-calc"; import { SessionAvgBest10For } from "game-implementations/utils/session-calc"; import { PoyashiBPI } from "rg-stats"; import { IIDXLIKE_GBOUNDARIES } from "tachi-common"; import type { - GPTGoalCriteriaFormatters, + GPTGoalFormatters, GPTGoalProgressFormatters, GPTServerImplementation, + PBMergeFunction, ScoreCalculator, } from "game-implementations/types"; import type { GPTStrings } from "tachi-common"; @@ -41,11 +44,37 @@ const IIDX_PROFILE_CALCS: GPTServerImplementation<"iidx:DP" | "iidx:SP">["profil ktLampRating: ProfileAvgBestN("ktLampRating", 20), }; -const IIDX_GOAL_FMT: GPTGoalCriteriaFormatters<"iidx:DP" | "iidx:SP"> = { +const IIDX_MERGERS: Array> = [ + CreatePBMergeFor("largest", "enumIndexes.lamp", "Best Lamp", (base, lamp) => { + base.scoreData.lamp = lamp.scoreData.lamp; + + // Update lamp related iidx-specific info from the lampPB. + base.scoreData.optional.gsmEasy = lamp.scoreData.optional.gsmEasy; + base.scoreData.optional.gsmNormal = lamp.scoreData.optional.gsmNormal; + base.scoreData.optional.gsmHard = lamp.scoreData.optional.gsmHard; + base.scoreData.optional.gsmEXHard = lamp.scoreData.optional.gsmEXHard; + + base.scoreData.optional.gauge = lamp.scoreData.optional.gauge; + base.scoreData.optional.gaugeHistory = lamp.scoreData.optional.gaugeHistory; + + base.scoreData.optional.comboBreak = lamp.scoreData.optional.comboBreak; + }), + CreatePBMergeFor("smallest", "optional.bp", "Lowest BP", (base, bp) => { + base.scoreData.optional.bp = bp.scoreData.optional.bp; + }), +]; + +const IIDX_GOAL_FMT: GPTGoalFormatters<"iidx:DP" | "iidx:SP"> = { percent: GoalFmtPercent, score: GoalFmtScore, }; +const IIDX_GOAL_OO_FMT: GPTGoalFormatters<"iidx:DP" | "iidx:SP"> = { + percent: GoalOutOfFmtPercent, + // don't insert commas or anything. + score: (m) => m.toString(), +}; + const IIDX_GOAL_PG_FMT: GPTGoalProgressFormatters<"iidx:DP" | "iidx:SP"> = { percent: (pb) => `${pb.scoreData.percent.toFixed(2)}%`, @@ -69,11 +98,9 @@ const IIDX_GOAL_PG_FMT: GPTGoalProgressFormatters<"iidx:DP" | "iidx:SP"> = { // use notecount to turn the percent deltas into whole ex-scores. (deltaPercent) => { - const chartNotecount = Math.floor( - pb.scoreData.score / (pb.scoreData.percent / 100) - ); + const max = Math.floor(pb.scoreData.score / (pb.scoreData.percent / 100)); - return (deltaPercent * (chartNotecount * 2)).toFixed(0); + return ((deltaPercent / 100) * max).toFixed(0); } ), }; @@ -111,6 +138,9 @@ export const IIDX_SP_IMPL: GPTServerImplementation<"iidx:SP"> = { classDerivers: {}, goalCriteriaFormatters: IIDX_GOAL_FMT, goalProgressFormatters: IIDX_GOAL_PG_FMT, + goalOutOfFormatters: IIDX_GOAL_OO_FMT, + pbMergeFunctions: IIDX_MERGERS, + defaultMergeRefName: "Best Score", }; export const IIDX_DP_IMPL: GPTServerImplementation<"iidx:DP"> = { @@ -139,4 +169,7 @@ export const IIDX_DP_IMPL: GPTServerImplementation<"iidx:DP"> = { classDerivers: {}, goalCriteriaFormatters: IIDX_GOAL_FMT, goalProgressFormatters: IIDX_GOAL_PG_FMT, + goalOutOfFormatters: IIDX_GOAL_OO_FMT, + pbMergeFunctions: IIDX_MERGERS, + defaultMergeRefName: "Best Score", }; diff --git a/server/src/game-implementations/games/itg.ts b/server/src/game-implementations/games/itg.ts index e8d1b9900..dd556c2e9 100644 --- a/server/src/game-implementations/games/itg.ts +++ b/server/src/game-implementations/games/itg.ts @@ -1,4 +1,5 @@ import { GetGrade, GoalFmtPercent, GradeGoalFormatter } from "./_common"; +import { CreatePBMergeFor } from "game-implementations/utils/pb-merge"; import { ProfileSumBestN } from "game-implementations/utils/profile-calc"; import { SessionAvgBestNFor } from "game-implementations/utils/session-calc"; import { ITGHighestUnbroken } from "rg-stats"; @@ -114,4 +115,25 @@ export const ITG_STAMINA_IMPL: GPTServerImplementation<"itg:Stamina"> = { (v) => `${v.toFixed(2)}%` ), }, + goalOutOfFormatters: { + survivedPercent: (num) => `${FormatMaxDP(num)}%`, + scorePercent: (num) => `${FormatMaxDP(num)}%`, + finalPercent: (num) => { + if (num >= 100) { + return `CLEAR with ${FormatMaxDP(num - 100)}%`; + } + + return `${FormatMaxDP(num)}%`; + }, + }, + pbMergeFunctions: [ + // we'll pluck the best lamp, but this game has a pretty interesting concept + // for merging PBs. This is probably fine. + CreatePBMergeFor("largest", "enumIndexes.lamp", "Best Lamp", (base, score) => { + base.scoreData.lamp = score.scoreData.lamp; + }), + ], + + // this name sucks, what should we do instead? TODO. + defaultMergeRefName: "Best Result", }; diff --git a/server/src/game-implementations/games/jubeat.ts b/server/src/game-implementations/games/jubeat.ts index 5477d2c9b..b81817074 100644 --- a/server/src/game-implementations/games/jubeat.ts +++ b/server/src/game-implementations/games/jubeat.ts @@ -1,5 +1,13 @@ -import { GetGrade, GoalFmtPercent, GoalFmtScore, GradeGoalFormatter } from "./_common"; +import { + GetGrade, + GoalFmtPercent, + GoalFmtScore, + GoalOutOfFmtPercent, + GoalOutOfFmtScore, + GradeGoalFormatter, +} from "./_common"; import db from "external/mongo/db"; +import { CreatePBMergeFor } from "game-implementations/utils/pb-merge"; import { ProfileSumBestN } from "game-implementations/utils/profile-calc"; import { SessionAvgBest10For } from "game-implementations/utils/session-calc"; import { p } from "prudence"; @@ -204,4 +212,20 @@ export const JUBEAT_IMPL: GPTServerImplementation<"jubeat:Single"> = { JUBEAT_GBOUNDARIES[gradeIndex]!.name ), }, + goalOutOfFormatters: { + musicRate: GoalOutOfFmtPercent, + score: GoalOutOfFmtScore, + }, + + // musicRate is the default prop + // but we want the user's best score to count aswell. + pbMergeFunctions: [ + CreatePBMergeFor("largest", "enumIndexes.lamp", "Best Lamp", (base, score) => { + base.scoreData.lamp = score.scoreData.lamp; + }), + CreatePBMergeFor("largest", "score", "Best Score", (base, score) => { + base.scoreData.score = score.scoreData.score; + }), + ], + defaultMergeRefName: "Best Music Rate", }; diff --git a/server/src/game-implementations/games/maimaidx.ts b/server/src/game-implementations/games/maimaidx.ts index d22a28848..59128ee35 100644 --- a/server/src/game-implementations/games/maimaidx.ts +++ b/server/src/game-implementations/games/maimaidx.ts @@ -1,5 +1,6 @@ -import { GetGrade, GoalFmtPercent, GradeGoalFormatter } from "./_common"; +import { GetGrade, GoalFmtPercent, GoalOutOfFmtPercent, GradeGoalFormatter } from "./_common"; import db from "external/mongo/db"; +import { CreatePBMergeFor } from "game-implementations/utils/pb-merge"; import { ProfileSumBestN } from "game-implementations/utils/profile-calc"; import { SessionAvgBest10For } from "game-implementations/utils/session-calc"; import { MaimaiDXRate } from "rg-stats"; @@ -127,4 +128,13 @@ export const MAIMAIDX_IMPL: GPTServerImplementation<"maimaidx:Single"> = { (v) => `${v.toFixed(2)}%` ), }, + goalOutOfFormatters: { + percent: GoalOutOfFmtPercent, + }, + pbMergeFunctions: [ + CreatePBMergeFor("largest", "enumIndexes.lamp", "Best Lamp", (base, score) => { + base.scoreData.lamp = score.scoreData.lamp; + }), + ], + defaultMergeRefName: "Best Percent", }; diff --git a/server/src/game-implementations/games/museca.ts b/server/src/game-implementations/games/museca.ts index d67cd5b46..f53a86c78 100644 --- a/server/src/game-implementations/games/museca.ts +++ b/server/src/game-implementations/games/museca.ts @@ -1,4 +1,5 @@ -import { GetGrade, GoalFmtScore, GradeGoalFormatter } from "./_common"; +import { GetGrade, GoalFmtScore, GoalOutOfFmtScore, GradeGoalFormatter } from "./_common"; +import { CreatePBMergeFor } from "game-implementations/utils/pb-merge"; import { ProfileSumBestN } from "game-implementations/utils/profile-calc"; import { SessionAvgBest10For } from "game-implementations/utils/session-calc"; import { CuratorSkill } from "rg-stats"; @@ -32,4 +33,13 @@ export const MUSECA_IMPL: GPTServerImplementation<"museca:Single"> = { MUSECA_GBOUNDARIES[gradeIndex]!.name ), }, + goalOutOfFormatters: { + score: GoalOutOfFmtScore, + }, + pbMergeFunctions: [ + CreatePBMergeFor("largest", "enumIndexes.lamp", "Best Lamp", (base, score) => { + base.scoreData.lamp = score.scoreData.lamp; + }), + ], + defaultMergeRefName: "Best Score", }; diff --git a/server/src/game-implementations/games/popn.ts b/server/src/game-implementations/games/popn.ts index 4546542e9..8454ad667 100644 --- a/server/src/game-implementations/games/popn.ts +++ b/server/src/game-implementations/games/popn.ts @@ -1,4 +1,5 @@ -import { GetGrade, GoalFmtScore, GradeGoalFormatter } from "./_common"; +import { GetGrade, GoalFmtScore, GoalOutOfFmtScore, GradeGoalFormatter } from "./_common"; +import { CreatePBMergeFor } from "game-implementations/utils/pb-merge"; import { ProfileSumBestN } from "game-implementations/utils/profile-calc"; import { SessionAvgBest10For } from "game-implementations/utils/session-calc"; import { PopnClassPoints } from "rg-stats"; @@ -102,4 +103,15 @@ export const POPN_9B_IMPL: GPTServerImplementation<"popn:9B"> = { POPN_GBOUNDARIES[gradeIndex]!.name ), }, + goalOutOfFormatters: { + score: GoalOutOfFmtScore, + }, + pbMergeFunctions: [ + CreatePBMergeFor("largest", "enumIndexes.clearMedal", "Best Clear", (base, score) => { + base.scoreData.clearMedal = score.scoreData.clearMedal; + // these are directly related. pluck both. + base.scoreData.lamp = score.scoreData.lamp; + }), + ], + defaultMergeRefName: "Best Score", }; diff --git a/server/src/game-implementations/games/sdvx.ts b/server/src/game-implementations/games/sdvx.ts index 58a5e8afa..95c738582 100644 --- a/server/src/game-implementations/games/sdvx.ts +++ b/server/src/game-implementations/games/sdvx.ts @@ -1,8 +1,11 @@ import { SDVXLIKE_CLASS_DERIVERS, + SDVXLIKE_DEFAULT_MERGE_NAME, SDVXLIKE_DERIVERS, SDVXLIKE_GOAL_FMT, + SDVXLIKE_GOAL_OO_FMT, SDVXLIKE_GOAL_PG_FMT, + SDVXLIKE_PB_MERGERS, SDVXLIKE_PROFILE_CALCS, SDVXLIKE_SCORE_CALCS, SDVXLIKE_SESSION_CALCS, @@ -13,9 +16,16 @@ export const SDVX_IMPL: GPTServerImplementation<"sdvx:Single"> = { derivers: SDVXLIKE_DERIVERS, validators: { exScore: (exScore, chart) => { + if (exScore < 0) { + return `EX Score must be non-negative. Got ${exScore}`; + } + // TODO // gotta figure this out somehow? - throw new Error(`Unimplemented.`); + // we need to store notecounts or something. For now, just allow + // any +ve integer, I guess. + + return true; }, }, scoreCalcs: SDVXLIKE_SCORE_CALCS, @@ -24,4 +34,7 @@ export const SDVX_IMPL: GPTServerImplementation<"sdvx:Single"> = { classDerivers: SDVXLIKE_CLASS_DERIVERS, goalCriteriaFormatters: SDVXLIKE_GOAL_FMT, goalProgressFormatters: SDVXLIKE_GOAL_PG_FMT, + goalOutOfFormatters: SDVXLIKE_GOAL_OO_FMT, + pbMergeFunctions: SDVXLIKE_PB_MERGERS, + defaultMergeRefName: SDVXLIKE_DEFAULT_MERGE_NAME, }; diff --git a/server/src/game-implementations/games/usc.ts b/server/src/game-implementations/games/usc.ts index 278c90c8a..7075df7ac 100644 --- a/server/src/game-implementations/games/usc.ts +++ b/server/src/game-implementations/games/usc.ts @@ -6,6 +6,9 @@ import { SDVXLIKE_CLASS_DERIVERS, SDVXLIKE_GOAL_FMT, SDVXLIKE_GOAL_PG_FMT, + SDVXLIKE_GOAL_OO_FMT, + SDVXLIKE_DEFAULT_MERGE_NAME, + SDVXLIKE_PB_MERGERS, } from "./_common"; import type { GPTServerImplementation } from "game-implementations/types"; import type { GPTStrings } from "tachi-common"; @@ -19,6 +22,9 @@ const USC_IMPL: GPTServerImplementation = { classDerivers: SDVXLIKE_CLASS_DERIVERS, goalCriteriaFormatters: SDVXLIKE_GOAL_FMT, goalProgressFormatters: SDVXLIKE_GOAL_PG_FMT, + goalOutOfFormatters: SDVXLIKE_GOAL_OO_FMT, + pbMergeFunctions: SDVXLIKE_PB_MERGERS, + defaultMergeRefName: SDVXLIKE_DEFAULT_MERGE_NAME, }; export const USC_KEYBOARD_IMPL: GPTServerImplementation<"usc:Keyboard"> = USC_IMPL; diff --git a/server/src/game-implementations/games/wacca.ts b/server/src/game-implementations/games/wacca.ts index baa896586..c2b9ba16a 100644 --- a/server/src/game-implementations/games/wacca.ts +++ b/server/src/game-implementations/games/wacca.ts @@ -1,5 +1,6 @@ -import { GetGrade, GoalFmtScore, GradeGoalFormatter } from "./_common"; +import { GetGrade, GoalFmtScore, GoalOutOfFmtScore, GradeGoalFormatter } from "./_common"; import db from "external/mongo/db"; +import { CreatePBMergeFor } from "game-implementations/utils/pb-merge"; import { ProfileSumBestN } from "game-implementations/utils/profile-calc"; import { SessionAvgBest10For } from "game-implementations/utils/session-calc"; import { WACCARate } from "rg-stats"; @@ -122,4 +123,13 @@ export const WACCA_IMPL: GPTServerImplementation<"wacca:Single"> = { WACCA_GBOUNDARIES[gradeIndex]!.name ), }, + goalOutOfFormatters: { + score: GoalOutOfFmtScore, + }, + pbMergeFunctions: [ + CreatePBMergeFor("largest", "enumIndexes.lamp", "Best Lamp", (base, score) => { + base.scoreData.lamp = score.scoreData.lamp; + }), + ], + defaultMergeRefName: "Best Score", }; diff --git a/server/src/game-implementations/types.ts b/server/src/game-implementations/types.ts index 93ebb8a79..31e7270bd 100644 --- a/server/src/game-implementations/types.ts +++ b/server/src/game-implementations/types.ts @@ -1,4 +1,5 @@ import type { DryScoreData } from "lib/score-import/framework/common/types"; +import type { PBScoreDocumentNoRank } from "lib/score-import/framework/pb/create-pb-doc"; import type { ChartDocument, ClassConfigs, @@ -7,6 +8,7 @@ import type { GPTString, GPTStringToGame, GPTStringToPlaytype, + PBReference, PBScoreDocument, ProfileRatingAlgorithms, ScoreDocument, @@ -86,6 +88,23 @@ export type GPTClassDerivers = { : never; }; +/** + * A PBMergeFunction just gets the user for this score and the chart its on. + * They are expected to mutate the existingPB to add/change whatever + * properties they feel like should be merged. + * + * @note Don't worry about updating enumIndexes. Those are updated for you. + * + * They should then return some information (a name and a scoreID) to indicate + * what this PB is composed of. + */ +export type PBMergeFunction = ( + userID: integer, + chartID: string, + asOfTimestamp: number | null, + existingPB: PBScoreDocumentNoRank +) => Promise; + /** * The only metrics that need validators are those that have `chartDependentMax` set. * Otherwise, a validator is built into the ConfScoreMetric. @@ -117,7 +136,7 @@ export type GoalCriteriaFormatter = (num: number) => string; * A record of all non-enum metrics that need formatters. Enums *always* get formatted * into their string formats. */ -export type GPTGoalCriteriaFormatters = { +export type GPTGoalFormatters = { [K in keyof ConfScoreMetrics[GPT] as ConfScoreMetrics[GPT][K] extends ConfEnumScoreMetric< infer _ > @@ -147,8 +166,20 @@ export interface GPTServerImplementation { sessionCalcs: GPTSessionCalculators; profileCalcs: GPTProfileCalculators; classDerivers: GPTClassDerivers; - goalCriteriaFormatters: GPTGoalCriteriaFormatters; + goalCriteriaFormatters: GPTGoalFormatters; + goalOutOfFormatters: GPTGoalFormatters; goalProgressFormatters: GPTGoalProgressFormatters; + + /** + * How should we mutate PBs (to join best lamps, lowest BPs, etc.) for this GPT? + */ + pbMergeFunctions: Array>; + + /** + * A PB is always initialised with the best score for this game's default + * metric. What should that be called? + */ + defaultMergeRefName: string; } export type GPTImplementations = { diff --git a/server/src/lib/score-import/framework/pb/mergers/utils.ts b/server/src/game-implementations/utils/pb-merge.ts similarity index 89% rename from server/src/lib/score-import/framework/pb/mergers/utils.ts rename to server/src/game-implementations/utils/pb-merge.ts index 493163047..494fff9f9 100644 --- a/server/src/lib/score-import/framework/pb/mergers/utils.ts +++ b/server/src/game-implementations/utils/pb-merge.ts @@ -1,6 +1,6 @@ import db from "external/mongo/db"; -import type { PBScoreDocumentNoRank } from "../create-pb-doc"; -import type { PBMergeFunction } from "./types"; +import type { PBMergeFunction } from "game-implementations/types"; +import type { PBScoreDocumentNoRank } from "lib/score-import/framework/pb/create-pb-doc"; import type { FilterQuery } from "mongodb"; import type { ConfDerivedMetrics, @@ -48,9 +48,12 @@ export function HandleAsOf( * Utility for making a PB merge function. In short, get the best score this user has * on this chart for the stated metric, then run the applicator if a score was found. * + * @param direction - Whether to pick the largest value or smallest value for this metric. + * * @note Don't worry about updating enumIndexes. Those are updated for you,. */ export function CreatePBMergeFor( + direction: "largest" | "smallest", metric: MetricKeys, name: string, applicator: (base: PBScoreDocumentNoRank, score: ScoreDocument) => void @@ -67,7 +70,7 @@ export function CreatePBMergeFor( ), { sort: { - [`scoreData.${metric as string}`]: -1, + [`scoreData.${metric as string}`]: direction === "largest" ? -1 : 1, }, } )) as ScoreDocument | null; diff --git a/server/src/lib/score-import/framework/goals/goals.test.ts b/server/src/lib/score-import/framework/goals/goals.test.ts index c1635dcb1..e138e9179 100644 --- a/server/src/lib/score-import/framework/goals/goals.test.ts +++ b/server/src/lib/score-import/framework/goals/goals.test.ts @@ -71,7 +71,7 @@ t.test("#UpdateGoalsForUser", (t) => { }, new: { progress: 1479, - progressHuman: "1,479", + progressHuman: "1479", outOf: 1, outOfHuman: "1", achieved: true, @@ -85,7 +85,7 @@ t.test("#UpdateGoalsForUser", (t) => { r, { progress: 1479, - progressHuman: "1,479", + progressHuman: "1479", outOf: 1, outOfHuman: "1", achieved: true, diff --git a/server/src/lib/score-import/framework/orphans/orphans.test.ts b/server/src/lib/score-import/framework/orphans/orphans.test.ts index 5bcc74ef5..bf043012e 100644 --- a/server/src/lib/score-import/framework/orphans/orphans.test.ts +++ b/server/src/lib/score-import/framework/orphans/orphans.test.ts @@ -133,7 +133,7 @@ t.test("#ReprocessOrphan", (t) => { success: true, type: "ScoreImported", message: - "Imported score R7a3a2b04bd4882ec06c198d78297fe3d56561502c7b134c067214bbfdf4f1602.", + "Imported score Td27fe9eae3db974d43179cf1dcb32c41c7b84b46be8162269518e0e999236d92.", content: { score: { game: "iidx", @@ -142,16 +142,18 @@ t.test("#ReprocessOrphan", (t) => { importType: "ir/direct-manual", timeAchieved: null, scoreData: { - lampIndex: 5, lamp: "HARD CLEAR", score: 500, + enumIndexes: { + lamp: 5, + }, }, highlight: false, userID: 1, songID: 1, chartID: "c2311194e3897ddb5745b1760d2c0141f933e683", scoreID: - "R7a3a2b04bd4882ec06c198d78297fe3d56561502c7b134c067214bbfdf4f1602", + "Td27fe9eae3db974d43179cf1dcb32c41c7b84b46be8162269518e0e999236d92", playtype: "SP", isPrimary: true, }, diff --git a/server/src/lib/score-import/framework/pb/create-pb-doc.test.ts b/server/src/lib/score-import/framework/pb/create-pb-doc.test.ts index fbd1949ad..89117f7b4 100644 --- a/server/src/lib/score-import/framework/pb/create-pb-doc.test.ts +++ b/server/src/lib/score-import/framework/pb/create-pb-doc.test.ts @@ -52,12 +52,13 @@ t.test("#CreatePBDoc", (t) => { optional: { bp: 1, enumIndexes: {} }, }, calculatedData: { - ktLampRating: 12, + BPI: null, + ktLampRating: 10, }, }; t.test( - "(IIDX) Should use the GameSpecificMergeFN to also join the BP PB if necessary.", + "(IIDX) Should use the Server Impl Merge FNs to also join the BP PB if necessary.", async (t) => { await db.scores.remove({}); await db.scores.insert([ @@ -74,9 +75,6 @@ t.test("#CreatePBDoc", (t) => { lamp: IIDX_LAMPS.FULL_COMBO, }, }, - calculatedData: { - ktLampRating: 12, - }, scoreID: "LAMP_PB_ID", }), deepmerge(IIDXScore, { @@ -91,9 +89,6 @@ t.test("#CreatePBDoc", (t) => { lamp: IIDX_LAMPS.CLEAR, }, }, - calculatedData: { - ktLampRating: 10, - }, scoreID: "BP_PB_ID", }), ]); @@ -105,7 +100,7 @@ t.test("#CreatePBDoc", (t) => { t.strictSame( res, deepmerge(ExamplePBDoc, { - composedFrom: [{ name: "Best BP", scoreID: "BP_PB_ID" }], + composedFrom: [{ name: "Lowest BP", scoreID: "BP_PB_ID" }], scoreData: { optional: { @@ -171,42 +166,6 @@ t.test("#CreatePBDoc", (t) => { t.end(); }); - t.test("(BMS) Should inherit sieglinde from the higher rated score.", async (t) => { - await db.scores.remove({}); - await db.scores.insert([ - TestingBMS7KScore, - deepmerge(TestingBMS7KScore, { - scoreData: { - lamp: "FULL COMBO", - score: 0, - percent: 0, - optional: { - bp: 15, - }, - enumIndexes: { - lamp: IIDX_LAMPS.FULL_COMBO, - }, - }, - calculatedData: { - sieglinde: 500, - }, - scoreID: "LAMP_PB_ID", - }), - ]); - - const res = await CreatePBDoc("bms:7K", 1, BMSGazerChart, logger); - - t.not(res, undefined, "Should actually return something."); - - t.equal( - res?.calculatedData.sieglinde, - 500, - "Should select the lampPBs sieglinde and not the score PBs." - ); - - t.end(); - }); - t.test("(BMS) Should inherit BP from the best BP score.", async (t) => { await db.scores.remove({}); await db.scores.insert([ @@ -253,64 +212,7 @@ t.test("#CreatePBDoc", (t) => { t.strictSame(res?.composedFrom, [ { name: "Best Score", scoreID: TestingBMS7KScore.scoreID }, { name: "Best Lamp", scoreID: "LAMP_PB_ID" }, - { name: "Best BP", scoreID: "BP_PB_ID" }, - ]); - - t.end(); - }); - - t.test("(PMS) Should inherit BP from the best BP score.", async (t) => { - const pmsScore = deepmerge(TestingBMS7KScore, { - game: "pms", - playtype: "Controller", - }) as unknown as ScoreDocument<"pms:Controller">; - - await db.scores.remove({}); - await db.scores.insert([ - pmsScore, - deepmerge(pmsScore, { - scoreData: { - lamp: "FULL COMBO", - score: 0, - percent: 0, - optional: { - bp: 15, - }, - enumIndexes: { - lamp: IIDX_LAMPS.FULL_COMBO, - }, - }, - calculatedData: { - sieglinde: 500, - }, - scoreID: "LAMP_PB_ID", - }), - deepmerge(pmsScore, { - scoreData: { - optional: { - bp: 1, - }, - }, - scoreID: "BP_PB_ID", - }), - ]); - - const res = (await CreatePBDoc("pms:Controller", 1, BMSGazerChart, logger)) as - | PBScoreDocumentNoRank<"pms:Controller"> - | undefined; - - t.not(res, undefined, "Should actually return something."); - - t.equal( - res?.scoreData.optional.bp, - 1, - "Should select the best BP's BP and not the score PBs." - ); - - t.strictSame(res?.composedFrom, [ - { name: "Best Score", scoreID: TestingBMS7KScore.scoreID }, - { name: "Best Lamp", scoreID: "LAMP_PB_ID" }, - { name: "Best BP", scoreID: "BP_PB_ID" }, + { name: "Lowest BP", scoreID: "BP_PB_ID" }, ]); t.end(); diff --git a/server/src/lib/score-import/framework/pb/create-pb-doc.ts b/server/src/lib/score-import/framework/pb/create-pb-doc.ts index ba143911e..d843fbca1 100644 --- a/server/src/lib/score-import/framework/pb/create-pb-doc.ts +++ b/server/src/lib/score-import/framework/pb/create-pb-doc.ts @@ -1,9 +1,10 @@ -import { GPT_PB_DEFAULT_REF_NAMES, GPT_PB_MERGE_FNS } from "./mergers/mergers"; import { CreateScoreCalcData } from "../calculated-data/score"; import { CreateEnumIndexes } from "../score-importing/derivers"; import db from "external/mongo/db"; +import { GPT_SERVER_IMPLEMENTATIONS } from "game-implementations/game-implementations"; import { GetEveryonesRivalIDs } from "lib/rivals/rivals"; import { GetGPTConfig, GetGamePTConfig } from "tachi-common"; +import { DeleteUndefinedProps } from "utils/misc"; import type { KtLogger } from "lib/logger/logger"; import type { BulkWriteUpdateOneOperation, FilterQuery } from "mongodb"; import type { @@ -68,10 +69,12 @@ export async function CreatePBDoc( return; } + const gptImpl = GPT_SERVER_IMPLEMENTATIONS[gpt]; + const pbDoc: PBScoreDocumentNoRank = { composedFrom: [ { - name: GPT_PB_DEFAULT_REF_NAMES[gpt], + name: gptImpl.defaultMergeRefName, scoreID: defaultMetricPB.scoreID, }, ], @@ -87,9 +90,7 @@ export async function CreatePBDoc( calculatedData: defaultMetricPB.calculatedData, }; - const mergeFunctions = GPT_PB_MERGE_FNS[gpt]; - - for (const mergeFn of mergeFunctions) { + for (const mergeFn of gptImpl.pbMergeFunctions) { // these must happen in sync. // eslint-disable-next-line no-await-in-loop const ref = await mergeFn( @@ -101,11 +102,15 @@ export async function CreatePBDoc( pbDoc as any ); - if (ref) { + // if there's a reference to add AND we haven't seen this scoreID before. + if (ref && !pbDoc.composedFrom.map((e) => e.scoreID).includes(ref.scoreID)) { pbDoc.composedFrom.push(ref); } } + // clear up any nonsense + DeleteUndefinedProps(pbDoc.scoreData.optional); + // update any enum indexes that might've been altered const { indexes, optionalIndexes } = CreateEnumIndexes(gpt, pbDoc.scoreData, logger); diff --git a/server/src/lib/score-import/framework/pb/mergers/games/bms.ts b/server/src/lib/score-import/framework/pb/mergers/games/bms.ts deleted file mode 100644 index 930a2dee1..000000000 --- a/server/src/lib/score-import/framework/pb/mergers/games/bms.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { CreatePBMergeFor } from "../utils"; -import type { PBMergeFunction } from "../types"; -import type { GPTStrings } from "tachi-common"; - -export const BMS_PMS_MERGERS: Array> = [ - CreatePBMergeFor("enumIndexes.lamp", "Best Lamp", (base, lamp) => { - base.scoreData.lamp = lamp.scoreData.lamp; - - // sgl is entirely lamp based. - base.calculatedData.sieglinde = lamp.calculatedData.sieglinde; - - // technically these don't exist on PMS scores but since undefined is a - // legal value for these properties it works out. - base.scoreData.optional.gauge = lamp.scoreData.optional.gauge; - base.scoreData.optional.gaugeHistory = lamp.scoreData.optional.gaugeHistory; - }), - CreatePBMergeFor("optional.bp", "Lowest BP", (base, bp) => { - base.scoreData.optional.bp = bp.scoreData.optional.bp; - }), -]; diff --git a/server/src/lib/score-import/framework/pb/mergers/games/iidx.ts b/server/src/lib/score-import/framework/pb/mergers/games/iidx.ts deleted file mode 100644 index 3ec9f736f..000000000 --- a/server/src/lib/score-import/framework/pb/mergers/games/iidx.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { CreatePBMergeFor } from "../utils"; -import type { PBMergeFunction } from "../types"; -import type { GPTStrings } from "tachi-common"; - -export const IIDX_MERGERS: Array> = [ - CreatePBMergeFor("enumIndexes.lamp", "Best Lamp", (base, lamp) => { - // lampRating needs to be updated. - base.calculatedData.ktLampRating = lamp.calculatedData.ktLampRating; - - // Update lamp related iidx-specific info from the lampPB. - base.scoreData.optional.gsmEasy = lamp.scoreData.optional.gsmEasy; - base.scoreData.optional.gsmNormal = lamp.scoreData.optional.gsmNormal; - base.scoreData.optional.gsmHard = lamp.scoreData.optional.gsmHard; - base.scoreData.optional.gsmEXHard = lamp.scoreData.optional.gsmEXHard; - - base.scoreData.optional.gauge = lamp.scoreData.optional.gauge; - base.scoreData.optional.gaugeHistory = lamp.scoreData.optional.gaugeHistory; - - base.scoreData.optional.comboBreak = lamp.scoreData.optional.comboBreak; - }), - CreatePBMergeFor("optional.bp", "Lowest BP", (base, bp) => { - base.scoreData.optional.bp = bp.scoreData.optional.bp; - }), -]; diff --git a/server/src/lib/score-import/framework/pb/mergers/mergers.ts b/server/src/lib/score-import/framework/pb/mergers/mergers.ts deleted file mode 100644 index 0b6502702..000000000 --- a/server/src/lib/score-import/framework/pb/mergers/mergers.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { BMS_PMS_MERGERS } from "./games/bms"; -import { IIDX_MERGERS } from "./games/iidx"; -import { CreatePBMergeFor } from "./utils"; -import type { PBMergeFunction } from "./types"; -import type { GPTString } from "tachi-common"; - -type GPTPBMergeFNs = { - [GPT in GPTString]: Array>; -}; - -export const GPT_PB_MERGE_FNS: GPTPBMergeFNs = { - "iidx:SP": IIDX_MERGERS, - "iidx:DP": IIDX_MERGERS, - - "bms:14K": BMS_PMS_MERGERS, - "bms:7K": BMS_PMS_MERGERS, - "pms:Controller": BMS_PMS_MERGERS, - "pms:Keyboard": BMS_PMS_MERGERS, - - "chunithm:Single": [ - CreatePBMergeFor("enumIndexes.lamp", "Best Lamp", (base, score) => { - base.scoreData.lamp = score.scoreData.lamp; - }), - ], - "sdvx:Single": [ - CreatePBMergeFor("enumIndexes.lamp", "Best Lamp", (base, score) => { - base.scoreData.lamp = score.scoreData.lamp; - }), - CreatePBMergeFor("optional.exScore", "Best EX Score", (base, score) => { - base.scoreData.optional.exScore = score.scoreData.optional.exScore; - }), - ], - - // musicRate is the default prop - // but we want the user's best score to count aswell. - "jubeat:Single": [ - CreatePBMergeFor("enumIndexes.lamp", "Best Lamp", (base, score) => { - base.scoreData.lamp = score.scoreData.lamp; - }), - CreatePBMergeFor("score", "Best Score", (base, score) => { - base.scoreData.score = score.scoreData.score; - }), - ], - - "maimaidx:Single": [ - CreatePBMergeFor("enumIndexes.lamp", "Best Lamp", (base, score) => { - base.scoreData.lamp = score.scoreData.lamp; - }), - ], - - "museca:Single": [ - CreatePBMergeFor("enumIndexes.lamp", "Best Lamp", (base, score) => { - base.scoreData.lamp = score.scoreData.lamp; - }), - ], - - "popn:9B": [ - CreatePBMergeFor("enumIndexes.clearMedal", "Best Clear", (base, score) => { - base.scoreData.clearMedal = score.scoreData.clearMedal; - // these are directly related. pluck both. - base.scoreData.lamp = score.scoreData.lamp; - }), - ], - - "wacca:Single": [ - CreatePBMergeFor("enumIndexes.lamp", "Best Lamp", (base, score) => { - base.scoreData.lamp = score.scoreData.lamp; - }), - ], - - "gitadora:Dora": [ - CreatePBMergeFor("enumIndexes.lamp", "Best Lamp", (base, score) => { - base.scoreData.lamp = score.scoreData.lamp; - }), - ], - "gitadora:Gita": [ - CreatePBMergeFor("enumIndexes.lamp", "Best Lamp", (base, score) => { - base.scoreData.lamp = score.scoreData.lamp; - }), - ], - - "itg:Stamina": [ - // we'll pluck the best lamp, but this game has a pretty interesting concept - // for merging PBs. This is probably fine. - CreatePBMergeFor("enumIndexes.lamp", "Best Lamp", (base, score) => { - base.scoreData.lamp = score.scoreData.lamp; - }), - ], - - "usc:Controller": [ - CreatePBMergeFor("enumIndexes.lamp", "Best Lamp", (base, score) => { - base.scoreData.lamp = score.scoreData.lamp; - }), - ], - "usc:Keyboard": [ - CreatePBMergeFor("enumIndexes.lamp", "Best Lamp", (base, score) => { - base.scoreData.lamp = score.scoreData.lamp; - }), - ], -}; - -/** - * What should the name be for the first reference on a PB? - * - * A PB is initialised from the user's best score on the defaultMetric, so in essence - * this is a name for that default metric. - */ -export const GPT_PB_DEFAULT_REF_NAMES: Record = { - "bms:14K": "Best Score", - "bms:7K": "Best Score", - "chunithm:Single": "Best Score", - "gitadora:Dora": "Best Percent", - "gitadora:Gita": "Best Percent", - "iidx:DP": "Best Score", - "iidx:SP": "Best Score", - "jubeat:Single": "Best Music Rate", - "maimaidx:Single": "Best Percent", - "museca:Single": "Best Score", - "pms:Controller": "Best Score", - "pms:Keyboard": "Best Score", - "popn:9B": "Best Score", - "sdvx:Single": "Best Score", - "usc:Controller": "Best Score", - "usc:Keyboard": "Best Score", - "wacca:Single": "Best Score", - - // this name sucks, what should we do instead? TODO. - "itg:Stamina": "Best Result", -}; diff --git a/server/src/lib/score-import/framework/pb/mergers/types.ts b/server/src/lib/score-import/framework/pb/mergers/types.ts deleted file mode 100644 index c1a2813a0..000000000 --- a/server/src/lib/score-import/framework/pb/mergers/types.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { PBScoreDocumentNoRank } from "../create-pb-doc"; -import type { GPTString, PBReference, integer } from "tachi-common"; - -/** - * A PBMergeFunction just gets the user for this score and the chart its on. - * They are expected to mutate the existingPB to add/change whatever - * properties they feel like should be merged. - * - * @note Don't worry about updating enumIndexes. Those are updated for you. - * - * They should then return some information (a name and a scoreID) to indicate - * what this PB is composed of. - */ -export type PBMergeFunction = ( - userID: integer, - chartID: string, - asOfTimestamp: number | null, - existingPB: PBScoreDocumentNoRank -) => Promise; diff --git a/server/src/lib/score-import/framework/pb/process-pbs.test.ts b/server/src/lib/score-import/framework/pb/process-pbs.test.ts index 78362e253..1d1c7204b 100644 --- a/server/src/lib/score-import/framework/pb/process-pbs.test.ts +++ b/server/src/lib/score-import/framework/pb/process-pbs.test.ts @@ -28,6 +28,24 @@ t.test("#ProcessPBs", (t) => { t.test("Should successfully insert multiple pbs into the score-pb database", async (t) => { await db["personal-bests"].remove({}); + await db.charts.iidx.insert([ + // @ts-expect-error lol + deepmerge(Testing511SPA, { + chartID: "test1", + songID: 2, + }), + // @ts-expect-error lol + deepmerge(Testing511SPA, { + chartID: "test2", + songID: 3, + }), + // @ts-expect-error lol + deepmerge(Testing511SPA, { + chartID: "test3", + songID: 4, + }), + ]); + await db.scores.insert([ // @ts-expect-error lol deepmerge(TestingIIDXSPScore, { diff --git a/server/src/lib/score-import/framework/score-importing/score-id.test.ts b/server/src/lib/score-import/framework/score-importing/score-id.test.ts index 37697375e..bb787a5d3 100644 --- a/server/src/lib/score-import/framework/score-importing/score-id.test.ts +++ b/server/src/lib/score-import/framework/score-importing/score-id.test.ts @@ -77,3 +77,15 @@ t.test("#CreateScoreID", (t) => { t.end(); }); + +t.test("#ScoreID Canary", (t) => { + const scoreID = CreateScoreID("iidx:SP", 1, TestingIIDXSPDryScore, Testing511SPA.chartID); + + t.equal( + scoreID, + "T5da661101a371a32caa57df7f5b43225ba45b273ded5d925da0e64ba6653a9d8", + "ScoreID **SHOULD BE DETERMINISTIC**. A change to the ScoreID algorithm is **a major breaking change.**" + ); + + t.end(); +}); diff --git a/server/src/lib/score-import/framework/score-importing/validate-score.ts b/server/src/lib/score-import/framework/score-importing/validate-score.ts index 36be6ef9e..9ddfa4299 100644 --- a/server/src/lib/score-import/framework/score-importing/validate-score.ts +++ b/server/src/lib/score-import/framework/score-importing/validate-score.ts @@ -69,7 +69,7 @@ function ValidateMetrics( for (const [metric, conf] of Object.entries(metrics)) { const scoreVal: any = valueGetter(score, metric); - if (optional && scoreVal === undefined) { + if (optional && (scoreVal === undefined || scoreVal === null)) { continue; } diff --git a/server/src/lib/score-import/import-types/common/api-cg/popn/converter.test.ts b/server/src/lib/score-import/import-types/common/api-cg/popn/converter.test.ts index 995a79bd9..ec1a50da8 100644 --- a/server/src/lib/score-import/import-types/common/api-cg/popn/converter.test.ts +++ b/server/src/lib/score-import/import-types/common/api-cg/popn/converter.test.ts @@ -81,42 +81,5 @@ t.test("#ConverterAPICGPopn", (t) => { t.end(); }); - t.test("Should cap fails at A grade", async (t) => { - const res = await convert({ - clearFlag: 1, - score: 98_000, - }); - - t.hasStrict(res, { - dryScore: mkOutput({ - scoreData: { - lamp: "FAILED", - grade: "A", - score: 98_000, - percent: 98, - optional: { - specificClearType: "failedCircle", - }, - }, - }), - }); - - const res2 = await convert({ - score: 98_000, - }); - - t.hasStrict(res2, { - dryScore: mkOutput({ - scoreData: { - grade: "S", - score: 98_000, - percent: 98, - }, - }), - }); - - t.end(); - }); - t.end(); }); diff --git a/server/src/lib/score-import/import-types/common/api-kai/iidx/class-handler.test.ts b/server/src/lib/score-import/import-types/common/api-kai/iidx/class-handler.test.ts index 5aad26ce3..a08a247b5 100644 --- a/server/src/lib/score-import/import-types/common/api-kai/iidx/class-handler.test.ts +++ b/server/src/lib/score-import/import-types/common/api-kai/iidx/class-handler.test.ts @@ -31,16 +31,10 @@ t.test("#CreateKaiIIDXClassProvider", async (t) => { }) ); - t.test("Should return a function with arity 5.", (t) => { - t.equal(fn.length, 5); - - t.end(); - }); - t.test("Should call the provided URL with the authentication token", (t) => { const res = fn("iidx:SP", 1, {}, logger); - t.strictSame(res, { dan: 18 }); + t.strictSame(res, { dan: "KAIDEN" }); t.end(); }); diff --git a/server/src/lib/score-import/import-types/common/api-kai/iidx/converter.test.ts b/server/src/lib/score-import/import-types/common/api-kai/iidx/converter.test.ts index 9756925c5..e4e0acf91 100644 --- a/server/src/lib/score-import/import-types/common/api-kai/iidx/converter.test.ts +++ b/server/src/lib/score-import/import-types/common/api-kai/iidx/converter.test.ts @@ -42,9 +42,6 @@ t.test("#ConvertAPIKaiIIDX", (t) => { timeAchieved: 1604171450000, service: "FLO", scoreData: { - grade: "MAX-", - - // percent: 99.87277353689568 floating point, score: 1570, lamp: "HARD CLEAR", judgements: {}, @@ -88,7 +85,6 @@ t.test("#ConvertAPIKaiIIDX", (t) => { timeAchieved: 1604171450000, service: "FLO", scoreData: { - grade: "C", score: 1570, lamp: "HARD CLEAR", judgements: {}, diff --git a/server/src/lib/score-import/import-types/common/api-kai/sdvx/class-handler.test.ts b/server/src/lib/score-import/import-types/common/api-kai/sdvx/class-handler.test.ts index 37aaa8481..2d1ac6385 100644 --- a/server/src/lib/score-import/import-types/common/api-kai/sdvx/class-handler.test.ts +++ b/server/src/lib/score-import/import-types/common/api-kai/sdvx/class-handler.test.ts @@ -30,16 +30,10 @@ t.test("#CreateKaiSDVXClassProvider", async (t) => { }) ); - t.test("Should return a function with arity 5.", (t) => { - t.equal(fn.length, 5); - - t.end(); - }); - t.test("Should call the provided URL with the authentication token", (t) => { const res = fn("sdvx:Single", 1, {}, logger); - t.strictSame(res, { dan: 9 }); + t.strictSame(res, { dan: "DAN_10" }); t.end(); }); diff --git a/server/src/lib/score-import/import-types/common/api-kai/sdvx/converter.test.ts b/server/src/lib/score-import/import-types/common/api-kai/sdvx/converter.test.ts index ca2f04351..8997517eb 100644 --- a/server/src/lib/score-import/import-types/common/api-kai/sdvx/converter.test.ts +++ b/server/src/lib/score-import/import-types/common/api-kai/sdvx/converter.test.ts @@ -46,9 +46,6 @@ t.test("#ConvertAPIKaiSDVX", (t) => { timeAchieved: 1598792891000, service: "FLO", scoreData: { - grade: "AA", - - // percent: 93.10699, floating point score: 9310699, lamp: "CLEAR", judgements: { diff --git a/server/src/lib/score-import/import-types/common/batch-manual/converter.test.ts b/server/src/lib/score-import/import-types/common/batch-manual/converter.test.ts index 2e66b4b5c..f3b5bd4cb 100644 --- a/server/src/lib/score-import/import-types/common/batch-manual/converter.test.ts +++ b/server/src/lib/score-import/import-types/common/batch-manual/converter.test.ts @@ -8,6 +8,7 @@ import deepmerge from "deepmerge"; import db from "external/mongo/db"; import CreateLogCtx from "lib/logger/logger"; import t from "tap"; +import { dmf } from "test-utils/misc"; import ResetDBState from "test-utils/resets"; import { BMSGazerChart, @@ -25,7 +26,6 @@ const baseBatchManualScore = { lamp: "HARD CLEAR" as const, matchType: "tachiSongID" as const, identifier: "1", - playtype: "SP" as const, difficulty: "ANOTHER" as const, }; @@ -414,9 +414,7 @@ t.test("#ResolveChartFromSong", (t) => { { game: "iidx", service: "foo", playtype: "SP", version: null }, importType ), - new InvalidScoreFailure( - `Invalid Difficulty for iidx SP - Expected any of BEGINNER, NORMAL, HYPER, ANOTHER, LEGGENDARIA` - ) + /Invalid Difficulty for iidx SP/u ); t.end(); @@ -481,9 +479,6 @@ t.test("#ConverterFn", (t) => { scoreData: { lamp: "HARD CLEAR", score: 500, - grade: "E", - - // percent: 31.5, -- ish, FPA is hard. judgements: {}, optional: {}, }, @@ -494,35 +489,34 @@ t.test("#ConverterFn", (t) => { t.end(); }); - t.test("Should cap pop'n grades at A if they failed.", async (t) => { + t.test("Should mount optionals", async (t) => { const res = await ConverterBatchManual( - { - score: 99_000, - lamp: "FAILED", - difficulty: "Easy", - matchType: "tachiSongID", - identifier: "1", - }, - { game: "popn", service: "foo", playtype: "9B", version: null }, + dmf(baseBatchManualScore as any, { + optional: { + bp: 123, + }, + }), + { game: "iidx", service: "foo", playtype: "SP", version: null }, importType, logger ); t.hasStrict(res, { - chart: { songID: 1, difficulty: "Easy" }, + chart: Testing511SPA, song: { id: 1 }, dryScore: { - game: "popn", + game: "iidx", service: "foo (BATCH-MANUAL)", comment: null, importType: "file/batch-manual", timeAchieved: null, scoreData: { - lamp: "FAILED", - score: 99_000, - grade: "A", + lamp: "HARD CLEAR", + score: 500, judgements: {}, - optional: {}, + optional: { + bp: 123, + }, }, scoreMeta: {}, }, @@ -531,34 +525,71 @@ t.test("#ConverterFn", (t) => { t.end(); }); - t.test("Should cap pop'n grades at A if they bordered AA.", async (t) => { + t.test("Should mount hitMeta as optionals", async (t) => { const res = await ConverterBatchManual( - { - score: 90_000, - lamp: "FAILED", - difficulty: "Easy", - matchType: "tachiSongID", - identifier: "1", - }, - { game: "popn", service: "foo", playtype: "9B", version: null }, + dmf(baseBatchManualScore as any, { + hitMeta: { + bp: 123, + }, + }), + { game: "iidx", service: "foo", playtype: "SP", version: null }, importType, logger ); t.hasStrict(res, { - chart: { songID: 1, difficulty: "Easy" }, + chart: Testing511SPA, song: { id: 1 }, dryScore: { - game: "popn", + game: "iidx", service: "foo (BATCH-MANUAL)", comment: null, importType: "file/batch-manual", timeAchieved: null, scoreData: { - lamp: "FAILED", - score: 90_000, - grade: "A", + lamp: "HARD CLEAR", + score: 500, judgements: {}, + optional: { + bp: 123, + }, + }, + scoreMeta: {}, + }, + }); + + t.end(); + }); + + t.test("Should mount judgements", async (t) => { + const res = await ConverterBatchManual( + dmf(baseBatchManualScore as any, { + judgements: { + pgreat: 13, + great: 3, + }, + }), + { game: "iidx", service: "foo", playtype: "SP", version: null }, + importType, + logger + ); + + t.hasStrict(res, { + chart: Testing511SPA, + song: { id: 1 }, + dryScore: { + game: "iidx", + service: "foo (BATCH-MANUAL)", + comment: null, + importType: "file/batch-manual", + timeAchieved: null, + scoreData: { + lamp: "HARD CLEAR", + score: 500, + judgements: { + pgreat: 13, + great: 3, + }, optional: {}, }, scoreMeta: {}, @@ -595,10 +626,8 @@ t.test("#ConverterFn", (t) => { importType: "file/batch-manual", timeAchieved: null, scoreData: { - lamp: "CLEAR", score: 920_000, - grade: "S", - percent: 10, + musicRate: 10, judgements: {}, optional: {}, }, @@ -609,77 +638,6 @@ t.test("#ConverterFn", (t) => { t.end(); }); - t.test("Should throw if the percent parameter is not given for jubeat", (t) => { - t.rejects( - () => - ConverterBatchManual( - deepmerge(baseJubeatScore, { musicRate: undefined }), - { game: "jubeat", service: "foo", playtype: "Single", version: null }, - importType, - logger - ), - { message: /The percent field must be filled out/u } - ); - - t.end(); - }); - - t.test( - "Should throw if the percent parameter is too small for jubeat when the score is reasonably high", - (t) => { - t.rejects( - () => - ConverterBatchManual( - deepmerge(baseJubeatScore, { musicRate: 0.1, score: 100_000 } as Partial< - BatchManualScore<"jubeat:Single"> - >), - { game: "jubeat", service: "foo", playtype: "Single", version: null }, - importType, - logger - ), - { - message: - "The percent you passed for this jubeat score was less than 1, but the score was above 100k. This is not possible. Have you sent percent as a number between 0 and 1?", - } - ); - - t.end(); - } - ); - - t.test( - "Should throw if the percent parameter is over 100 but the chart is not hard mode (for jubeat)", - (t) => { - t.rejects( - () => - ConverterBatchManual( - deepmerge(baseJubeatScore, { percent: 110 }), - { game: "jubeat", service: "foo", playtype: "Single", version: null }, - importType, - logger - ), - { message: /The percent field must be <= 100 for normal mode./u } - ); - - t.end(); - } - ); - - t.test("Should throw if the score parameter is invalid for jubeat", (t) => { - t.rejects( - () => - ConverterBatchManual( - deepmerge(baseJubeatScore, { score: 2_000_000 }), - { game: "jubeat", service: "foo", playtype: "Single", version: null }, - importType, - logger - ), - { message: /The score field must be a positive integer/u } - ); - - t.end(); - }); - t.test("Should produce a with timeAchieved null if timeAchieved is 0", async (t) => { const res = await ConverterBatchManual( deepmerge(baseBatchManualScore, { timeAchieved: 0 }), @@ -700,9 +658,6 @@ t.test("#ConverterFn", (t) => { scoreData: { lamp: "HARD CLEAR", score: 500, - grade: "E", - - // percent: 31.5, -- ish, FPA is hard. judgements: {}, optional: {}, }, @@ -713,22 +668,5 @@ t.test("#ConverterFn", (t) => { t.end(); }); - t.test("Should reject a score with > 100%", (t) => { - t.rejects( - () => - ConverterBatchManual( - // eslint-disable-next-line lines-around-comment - // @ts-expect-error broken deepmerge - deepmerge(baseBatchManualScore, { score: 2000 }), - { game: "iidx", service: "foo", playtype: "SP", version: null }, - importType, - logger - ), - { message: /Invalid percent/u } - ); - - t.end(); - }); - t.end(); }); diff --git a/server/src/lib/score-import/import-types/common/batch-manual/converter.ts b/server/src/lib/score-import/import-types/common/batch-manual/converter.ts index 7c30e6ec1..4df3b2b68 100644 --- a/server/src/lib/score-import/import-types/common/batch-manual/converter.ts +++ b/server/src/lib/score-import/import-types/common/batch-manual/converter.ts @@ -80,7 +80,10 @@ export const ConverterBatchManual: ConverterFunction { t.end(); }); - t.test("Valid HitMeta", (t) => { + t.test("Valid Optional", (t) => { const res = ParserFn( dm({ optional: { bp: 10, gauge: 100, gaugeHistory: null, comboBreak: 7 } }), "file/batch-manual", @@ -338,7 +338,7 @@ t.test("#ParserFn", (t) => { t.not(res.classProvider, null); - t.strictSame(res.classProvider!("iidx:SP", 1, {}, logger), { dan: IIDX_DANS.KAIDEN }); + t.strictSame(res.classProvider!("iidx:SP", 1, {}, logger), { dan: "KAIDEN" }); t.end(); }); @@ -402,7 +402,7 @@ t.test("#ParserFn", (t) => { fn, new ScoreImportFatalError( 400, - "Invalid BATCH-MANUAL: scores[0].score | Expected a positive integer. | Received 123 [type: string]." + "Invalid BATCH-MANUAL: scores[0].score | Expected an integer. | Received 123 [type: string]." ) ); @@ -511,15 +511,24 @@ t.test("#ParserFn", (t) => { t.end(); }); - t.test("Invalid HitMeta", (t) => { + t.test("Invalid optional", (t) => { const fn = () => + ParserFn(dm({ hitMeta: { not_key: 123 } }), "file/batch-manual", logger); + + t.throws(fn, mockErr("scores[0].hitMeta | Unexpected")); + + const fn2 = () => ParserFn(dm({ hitMeta: { bp: -1 } }), "file/batch-manual", logger); + + t.throws(fn2, mockErr("scores[0].hitMeta.bp")); + + const fn3 = () => ParserFn(dm({ optional: { not_key: 123 } }), "file/batch-manual", logger); - t.throws(fn, mockErr("scores[0].optional | Unexpected")); + t.throws(fn3, mockErr("scores[0].optional | Unexpected")); - const fn2 = () => ParserFn(dm({ optional: { bp: -1 } }), "file/batch-manual", logger); + const fn4 = () => ParserFn(dm({ optional: { bp: -1 } }), "file/batch-manual", logger); - t.throws(fn2, mockErr("scores[0].optional.bp | Expected a positive integer")); + t.throws(fn4, mockErr("scores[0].optional.bp")); t.end(); }); diff --git a/server/src/lib/score-import/import-types/file/eamusement-sdvx-csv/converter.test.ts b/server/src/lib/score-import/import-types/file/eamusement-sdvx-csv/converter.test.ts index 8a87ee3a3..d2e016d3d 100644 --- a/server/src/lib/score-import/import-types/file/eamusement-sdvx-csv/converter.test.ts +++ b/server/src/lib/score-import/import-types/file/eamusement-sdvx-csv/converter.test.ts @@ -40,9 +40,6 @@ t.test("#ConvertEamSDVXCSV", (t) => { scoreData: { score: 9310699, lamp: "EXCESSIVE CLEAR", - - // percent: 93.10699, floating point - grade: "AA", judgements: {}, optional: {}, }, @@ -68,9 +65,6 @@ t.test("#ConvertEamSDVXCSV", (t) => { scoreData: { score: 9310699, lamp: "EXCESSIVE CLEAR", - - // percent: 93.10699, floating point - grade: "AA", judgements: {}, optional: { exScore: 5730, diff --git a/server/src/lib/score-import/import-types/file/mer-iidx/converter.test.ts b/server/src/lib/score-import/import-types/file/mer-iidx/converter.test.ts index d5135aa16..170997bff 100644 --- a/server/src/lib/score-import/import-types/file/mer-iidx/converter.test.ts +++ b/server/src/lib/score-import/import-types/file/mer-iidx/converter.test.ts @@ -4,12 +4,7 @@ import db from "external/mongo/db"; import CreateLogCtx from "lib/logger/logger"; import t from "tap"; import ResetDBState from "test-utils/resets"; -import { - GetKTDataJSON, - LoadTachiIIDXData, - Testing511Song, - Testing511SPA, -} from "test-utils/test-data"; +import { LoadTachiIIDXData, Testing511Song, Testing511SPA } from "test-utils/test-data"; import type { MerScore } from "./types"; const logger = CreateLogCtx(__filename); @@ -46,9 +41,6 @@ t.test("#ConvertFileMerIIDX", (t) => { service: "MER", scoreData: { score: 1000, - - // percent: 63.61, approximately, fpa impossible. - grade: "B", lamp: "CLEAR", judgements: {}, optional: { @@ -81,9 +73,6 @@ t.test("#ConvertFileMerIIDX", (t) => { service: "MER", scoreData: { score: 1000, - - // percent: 63.61, approximately, fpa impossible. - grade: "B", lamp: "CLEAR", judgements: {}, optional: { @@ -126,14 +115,6 @@ t.test("#ConvertFileMerIIDX", (t) => { t.end(); }); - t.test("Invalid Percent", (t) => { - t.rejects(() => merc({ score: 9999 }), { - message: /Invalid percent/u, - }); - - t.end(); - }); - t.test("Invalid Date", (t) => { t.rejects(() => merc({ update_time: "INVALID" }), { message: /Invalid\/Unparsable score timestamp of INVALID/u, diff --git a/server/src/lib/score-import/import-types/file/solid-state-squad/converter.test.ts b/server/src/lib/score-import/import-types/file/solid-state-squad/converter.test.ts index 2dc5c68da..1eda50695 100644 --- a/server/src/lib/score-import/import-types/file/solid-state-squad/converter.test.ts +++ b/server/src/lib/score-import/import-types/file/solid-state-squad/converter.test.ts @@ -30,8 +30,6 @@ t.test("#ConvertFileS3", (t) => { importType: "file/solid-state-squad", service: "Solid State Squad", scoreData: { - // percent: 6.36, -- fpa - grade: "F", score: 100, lamp: "FULL COMBO", judgements: { @@ -159,14 +157,6 @@ t.test("#ConvertFileS3", (t) => { t.end(); }); - t.test("Should throw an invalidscore if the exscore is greater than MAX", (t) => { - t.rejects(mfile({ exscore: 10000 }), { - message: /Invalid percent of 636/u, - }); - - t.end(); - }); - t.test("Should throw an invalidscore if the date is invalid.", (t) => { t.rejects(mfile({ date: "INVALID" }), { message: /Invalid\/Unparsable score timestamp of INVALID/u, diff --git a/server/src/lib/score-import/import-types/file/wacca-mypage-scraper/converter.test.ts b/server/src/lib/score-import/import-types/file/wacca-mypage-scraper/converter.test.ts index 675313d89..707eb5b05 100644 --- a/server/src/lib/score-import/import-types/file/wacca-mypage-scraper/converter.test.ts +++ b/server/src/lib/score-import/import-types/file/wacca-mypage-scraper/converter.test.ts @@ -46,9 +46,6 @@ t.test("#ConvertMyPageScraperRecordsCSV", (t) => { scoreData: { score: 996827, lamp: "MISSLESS", - - percent: 99.6827, // floating point - grade: "SSS+", judgements: {}, optional: {}, }, @@ -60,7 +57,7 @@ t.test("#ConvertMyPageScraperRecordsCSV", (t) => { t.test("Should reject out of bounds diffIndex", (t) => { t.rejects(() => conv({ diffIndex: 4 }), { - message: /We somehow got an invalid difficulty index 4\./u, + message: /Invalid difficulty index of 4\./u, }); t.end(); diff --git a/server/src/lib/score-import/import-types/file/wacca-mypage-scraper/parser.test.ts b/server/src/lib/score-import/import-types/file/wacca-mypage-scraper/parser.test.ts index 4c6449fff..224d70ca1 100644 --- a/server/src/lib/score-import/import-types/file/wacca-mypage-scraper/parser.test.ts +++ b/server/src/lib/score-import/import-types/file/wacca-mypage-scraper/parser.test.ts @@ -137,7 +137,7 @@ t.test("#ParseMyPageScraperPlayerCSV", (t) => { // There's no good way to test that the classProvider got a valid // MyPagePlayerStage, so we just call it to see. t.strictSame(classProvider!("wacca:Single", 0, {}, logger), { - stageUp: WACCA_STAGEUPS.XII, + stageUp: "XII", }); t.end(); diff --git a/server/src/lib/score-import/import-types/ir/barbatos/converter.test.ts b/server/src/lib/score-import/import-types/ir/barbatos/converter.test.ts index e6a49c02c..90a450158 100644 --- a/server/src/lib/score-import/import-types/ir/barbatos/converter.test.ts +++ b/server/src/lib/score-import/import-types/ir/barbatos/converter.test.ts @@ -25,7 +25,6 @@ t.test("#ConverterIRBarbatos", (t) => { }; const albidaChart = { - chartID: "5088a4d0e1ee9d0cc2f625934306e45b1a60699b", difficulty: "ADV", songID: 1, @@ -59,8 +58,6 @@ t.test("#ConverterIRBarbatos", (t) => { // timeAchieved: , its Date.now() give or take lol scoreData: { score: 9000000, - percent: 90, - grade: "A+", lamp: "CLEAR", judgements: { critical: 100, @@ -104,8 +101,6 @@ t.test("#ConverterIRBarbatos", (t) => { // timeAchieved: , its Date.now() give or take lol scoreData: { score: 9000000, - percent: 90, - grade: "A+", lamp: "CLEAR", judgements: { critical: 26, diff --git a/server/src/lib/score-import/import-types/ir/fervidex/converter.test.ts b/server/src/lib/score-import/import-types/ir/fervidex/converter.test.ts index a587fe9ea..87ee003b5 100644 --- a/server/src/lib/score-import/import-types/ir/fervidex/converter.test.ts +++ b/server/src/lib/score-import/import-types/ir/fervidex/converter.test.ts @@ -6,7 +6,11 @@ import { TachifyRandom, TachifyRange, } from "./converter"; -import { InternalFailure } from "../../../framework/common/converter-failures"; +import { + InternalFailure, + InvalidScoreFailure, + SkipScoreFailure, +} from "../../../framework/common/converter-failures"; import deepmerge from "deepmerge"; import db from "external/mongo/db"; import CreateLogCtx from "lib/logger/logger"; @@ -20,7 +24,6 @@ import type { FervidexScore } from "./types"; const logger = CreateLogCtx(__filename); t.test("#SplitFervidexChartRef", (t) => { - t.strictSame(SplitFervidexChartRef("spb"), { playtype: "SP", difficulty: "BEGINNER" }); t.strictSame(SplitFervidexChartRef("spn"), { playtype: "SP", difficulty: "NORMAL" }); t.strictSame(SplitFervidexChartRef("sph"), { playtype: "SP", difficulty: "HYPER" }); t.strictSame(SplitFervidexChartRef("spa"), { playtype: "SP", difficulty: "ANOTHER" }); @@ -35,6 +38,11 @@ t.test("#SplitFervidexChartRef", (t) => { new InternalFailure(`Invalid fervidex difficulty of INVALID`) ); + t.throws( + () => SplitFervidexChartRef("spb"), + new SkipScoreFailure(`BEGINNER charts are not supported.`) + ); + t.end(); }); @@ -120,8 +128,6 @@ const baseDryScore = { importType: "ir/fervidex", scoreData: { score: 68, - percent: 4.325699745547074, - grade: "F", lamp: "FAILED", judgements: { pgreat: 34, @@ -258,38 +264,6 @@ t.test("#ConverterIRFervidex", (t) => { t.end(); }); - t.test("Should throw invalid score on percent > 100.", (t) => { - t.rejects( - ConverterIRFervidex( - // eslint-disable-next-line lines-around-comment - // @ts-expect-error eternally broken deepmerge - deepmerge(baseFervidexScore, { ex_score: 9999 }), - { version: "27" }, - "ir/fervidex", - logger - ), - /Invalid percent/giu - ); - - t.end(); - }); - - t.test("Should throw invalid score on gauge > 100.", (t) => { - t.rejects( - ConverterIRFervidex( - // eslint-disable-next-line lines-around-comment - // @ts-expect-error eternally broken deepmerge - deepmerge(baseFervidexScore, { gauge: [150] }), - { version: "27" }, - "ir/fervidex", - logger - ), - /Invalid value of gauge 150./giu - ); - - t.end(); - }); - t.test("Should convert underflow gauge to null.", async (t) => { const res = await ConverterIRFervidex( deepmerge(baseFervidexScore, { gauge: [10, 5, 249, 248] }) as FervidexScore, diff --git a/server/src/lib/score-import/import-types/ir/kshook-sv6c-static/converter.test.ts b/server/src/lib/score-import/import-types/ir/kshook-sv6c-static/converter.test.ts index e99bcec28..d26329084 100644 --- a/server/src/lib/score-import/import-types/ir/kshook-sv6c-static/converter.test.ts +++ b/server/src/lib/score-import/import-types/ir/kshook-sv6c-static/converter.test.ts @@ -30,7 +30,6 @@ t.test("#ConverterKsHookSV6CStatic", (t) => { dryScore: { scoreData: { score: 9_579_365, - grade: "AA+", lamp: "EXCESSIVE CLEAR", judgements: {}, optional: { diff --git a/server/src/lib/score-import/import-types/ir/kshook-sv6c/converter.test.ts b/server/src/lib/score-import/import-types/ir/kshook-sv6c/converter.test.ts index 90f4ecd6e..5de351b16 100644 --- a/server/src/lib/score-import/import-types/ir/kshook-sv6c/converter.test.ts +++ b/server/src/lib/score-import/import-types/ir/kshook-sv6c/converter.test.ts @@ -30,7 +30,6 @@ t.test("#ConverterIRKsHookSV6C", (t) => { dryScore: { scoreData: { score: 9_579_365, - grade: "AA+", lamp: "EXCESSIVE CLEAR", judgements: { critical: 1184, diff --git a/server/src/lib/score-mutation/delete-scores.test.ts b/server/src/lib/score-mutation/delete-scores.test.ts index bd1ce1c66..a48d76a53 100644 --- a/server/src/lib/score-mutation/delete-scores.test.ts +++ b/server/src/lib/score-mutation/delete-scores.test.ts @@ -222,7 +222,7 @@ t.test("#DeleteScore", (t) => { t.hasStrict(res, { classes: { - vfClass: SDVX_VF_CLASSES.SIENNA_II, + vfClass: "SIENNA_II", }, ratings: { VF6: 4, diff --git a/server/src/lib/showcase/get-stats.test.ts b/server/src/lib/showcase/get-stats.test.ts index 56ec15e84..484e04ba0 100644 --- a/server/src/lib/showcase/get-stats.test.ts +++ b/server/src/lib/showcase/get-stats.test.ts @@ -112,7 +112,7 @@ t.test("#EvalulateUsersStatsShowcase", (t) => { { stat: { folderID: TestingIIDXFolderSP10.folderID }, result: { - value: 0, + value: 1, outOf: 1, }, }, diff --git a/server/src/lib/showcase/showcase.test.ts b/server/src/lib/showcase/showcase.test.ts index e35534127..330bff324 100644 --- a/server/src/lib/showcase/showcase.test.ts +++ b/server/src/lib/showcase/showcase.test.ts @@ -68,20 +68,20 @@ t.test("#RemoveStaleFolderShowcaseStats", (t) => { { mode: "chart", chartID: "foo", - property: "grade", + metric: "grade", }, { mode: "folder", folderID: "NORMAL_FOLDER", gte: 1, - property: "lamp", + metric: "lamp", }, ], [ { mode: "chart", chartID: "foo", - property: "grade", + metric: "grade", }, ], ] diff --git a/server/src/lib/targets/goals.test.ts b/server/src/lib/targets/goals.test.ts index 7a4574153..4cc68f311 100644 --- a/server/src/lib/targets/goals.test.ts +++ b/server/src/lib/targets/goals.test.ts @@ -30,6 +30,9 @@ t.test("#EvaluateGoalForUser", (t) => { t.test("Should correctly evaluate against single goals.", (t) => { t.beforeEach(async () => { await db["personal-bests"].insert(TestingIIDXSPScorePB); + + // @ts-expect-error why does mongodb feel like mutating our documents! + delete TestingIIDXSPScorePB._id; }); t.test("Should correctly evaluate goals if user succeeds.", async (t) => { @@ -310,13 +313,13 @@ t.test("#HumaniseGoalProgress", (t) => { mkFakePBIIDXSP({ // @ts-expect-error faulty deepmerge types scoreData: { - score: 1230, + score: 1865, grade: "AA", - percent: 78.89, + percent: 79.97, }, }) ), - "AAA-156" + "AAA-208" ); t.end(); diff --git a/server/src/lib/targets/goals.ts b/server/src/lib/targets/goals.ts index 19585a391..3b5dd6039 100644 --- a/server/src/lib/targets/goals.ts +++ b/server/src/lib/targets/goals.ts @@ -60,19 +60,6 @@ export async function EvaluateGoalForUser( // First, we need to resolve the set of charts this // goal involves. const chartIDs = await ResolveGoalCharts(goal); - - // lets configure a "base" query for our requests. - const scoreQuery: FilterQuery = { - userID, - game: goal.game, - playtype: goal.playtype, - - // normally, this would be a VERY WORRYING line of code, but goal.criteria.key is guaranteed to be - // within a specific set of fields. - [`scoreData.${goal.criteria.key}`]: { $gte: goal.criteria.value }, - chartID: { $in: chartIDs }, - }; - const gptString = GetGPTString(goal.game, goal.playtype); const gptConfig = GetGPTConfig(gptString); const scoreConf = GetScoreMetricConf(gptConfig, goal.criteria.key); @@ -83,16 +70,32 @@ export async function EvaluateGoalForUser( ); } + let scoreDataKey; + + if (scoreConf.type === "ENUM") { + scoreDataKey = `scoreData.enumIndexes.${goal.criteria.key}`; + } else { + scoreDataKey = `scoreData.${goal.criteria.key}`; + } + + // lets configure a "base" query for our requests. + const scoreQuery: FilterQuery = { + userID, + game: goal.game, + playtype: goal.playtype, + + // normally, this would be a VERY WORRYING line of code, but goal.criteria.key is guaranteed to be + // within a specific set of fields. + [scoreDataKey]: { $gte: goal.criteria.value }, + chartID: { $in: chartIDs }, + }; + switch (goal.criteria.mode) { case "single": { const res = await db["personal-bests"].findOne(scoreQuery); - // hack, but guaranteed to work. - const outOfHuman = HumaniseGoalOutOf(gptString, goal.criteria.key, goal.criteria.value); - const key = goal.criteria.key; - if (res) { return { achieved: true, @@ -100,9 +103,9 @@ export async function EvaluateGoalForUser( progress: scoreConf.type === "ENUM" ? // @ts-expect-error this is always correct but the typesystem is rightfully concerned - res.scoreData.enumIndexes[key] + res.scoreData.enumIndexes[goal.criteria.key] : // @ts-expect-error see above - res.scoreData[key], + res.scoreData[goal.criteria.key], outOfHuman, progressHuman: HumaniseGoalProgress( gptString, @@ -123,7 +126,7 @@ export async function EvaluateGoalForUser( }; const nextBestScore = await db["personal-bests"].findOne(nextBestQuery, { - sort: { [goal.criteria.key]: -1 }, + sort: { [scoreDataKey]: -1 }, }); // user has no scores on any charts in this set. @@ -144,9 +147,9 @@ export async function EvaluateGoalForUser( progress: scoreConf.type === "ENUM" ? // @ts-expect-error this is always correct but the typesystem is rightfully concerned - nextBestScore.scoreData.enumIndexes[key] + nextBestScore.scoreData.enumIndexes[goal.criteria.key] : // @ts-expect-error see above - nextBestScore.scoreData[key], + nextBestScore.scoreData[goal.criteria.key], progressHuman: HumaniseGoalProgress( gptString, goal.criteria.key, @@ -277,7 +280,7 @@ export function HumaniseGoalOutOf(gptString: GPTString, key: GoalKeys, value: nu const gptImpl = GPT_SERVER_IMPLEMENTATIONS[gptString]; // @ts-expect-error yeah this is technically unsafe, whatever - const fmt: GoalCriteriaFormatter | undefined = gptImpl.goalCriteriaFormatters[key]; + const fmt: GoalCriteriaFormatter | undefined = gptImpl.goalOutOfFormatters[key]; if (!fmt) { throw new Error( @@ -660,12 +663,12 @@ export async function GetRelevantGoals( * Returns the set of goals where its folder contains any member * of chartIDsArr. */ -export function GetRelevantFolderGoals(goalIDs: Array, chartIDsArr: Array) { +export async function GetRelevantFolderGoals(goalIDs: Array, chartIDsArr: Array) { // Slightly black magic - this is kind of like doing an SQL join. // it's weird to do this in mongodb, but this seems like the right // way to actually handle this. - const result: Promise> = db.goals.aggregate([ + const result: Array = await db.goals.aggregate([ { $match: { "charts.type": "folder", @@ -688,6 +691,7 @@ export function GetRelevantFolderGoals(goalIDs: Array, chartIDsArr: Arra { $project: { folderCharts: 0, + _id: 0, }, }, ]); diff --git a/server/src/server/router/api/v1/games/_game/_playtype/router.test.ts b/server/src/server/router/api/v1/games/_game/_playtype/router.test.ts index ccbb69954..644398e1e 100644 --- a/server/src/server/router/api/v1/games/_game/_playtype/router.test.ts +++ b/server/src/server/router/api/v1/games/_game/_playtype/router.test.ts @@ -12,7 +12,7 @@ t.test("GET /api/v1/games/:game/:playtype", (t) => { t.test("Should return information about the game:playtype.", async (t) => { const res = await mockApi.get("/api/v1/games/iidx/SP"); - t.strictSame(res.body.body.config, GetGamePTConfig("iidx", "SP")); + t.hasStrict(GetGamePTConfig("iidx", "SP"), res.body.body.config); t.equal(res.body.body.chartCount, 1); t.equal(res.body.body.playerCount, 1); @@ -39,7 +39,7 @@ t.test("GET /api/v1/games/:game/:playtype/leaderboard", (t) => { const res = await mockApi.get("/api/v1/games/iidx/SP/leaderboard"); t.equal(res.statusCode, 200); - + t; t.equal(res.body.body.gameStats.length, 1); t.equal(res.body.body.users.length, 1); diff --git a/server/src/server/router/api/v1/games/_game/router.test.ts b/server/src/server/router/api/v1/games/_game/router.test.ts index aec9828ed..51b343b76 100644 --- a/server/src/server/router/api/v1/games/_game/router.test.ts +++ b/server/src/server/router/api/v1/games/_game/router.test.ts @@ -6,7 +6,7 @@ t.test("GET /api/v1/games/:game", (t) => { t.test("Should parse the game from the header", async (t) => { const res = await mockApi.get("/api/v1/games/iidx"); - t.strictSame(res.body.body, GetGameConfig("iidx")); + t.hasStrict(GetGameConfig("iidx"), res.body.body); t.end(); }); diff --git a/server/src/server/router/api/v1/games/router.test.ts b/server/src/server/router/api/v1/games/router.test.ts index de4955438..7c480633e 100644 --- a/server/src/server/router/api/v1/games/router.test.ts +++ b/server/src/server/router/api/v1/games/router.test.ts @@ -10,7 +10,17 @@ t.test("GET /api/v1/games", async (t) => { t.strictSame(res.body.body.supportedGames, TachiConfig.GAMES); - t.strictSame(res.body.body.configs.iidx, GetGameConfig("iidx")); + t.hasStrict( + { + ...res.body.body.configs.iidx, + // songData doesn't serialise nicely as it has functions on it. + songData: null, + }, + { + ...GetGameConfig("iidx"), + songData: null, + } + ); t.equal(Object.keys(res.body.body.configs).length, TachiConfig.GAMES.length); t.end(); diff --git a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/folders/_folderID/router.test.ts b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/folders/_folderID/router.test.ts index 8f186b58c..0ff899f9b 100644 --- a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/folders/_folderID/router.test.ts +++ b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/folders/_folderID/router.test.ts @@ -98,8 +98,10 @@ t.test("GET /api/v1/users/:userID/games/:game/:playtype/folders/:folderID/timeli }), deepmerge(TestingIIDXSPScore, { scoreData: { - lampIndex: 3, lamp: "CLEAR", + enumIndexes: { + lamp: 3, + }, }, scoreID: "OTHER_SCORE_ID_3", timeAchieved: 50, @@ -115,7 +117,7 @@ t.test("GET /api/v1/users/:userID/games/:game/:playtype/folders/:folderID/timeli ]); const res = await mockApi.get( - "/api/v1/users/1/games/iidx/SP/folders/testing_folder/timeline?criteriaType=lamp&criteriaValue=4" + "/api/v1/users/1/games/iidx/SP/folders/testing_folder/timeline?criteriaType=lamp&criteriaValue=CLEAR" ); t.equal(res.body.body.scores.length, 3); diff --git a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/folders/_folderID/router.ts b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/folders/_folderID/router.ts index 306bee515..40c96a8ef 100644 --- a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/folders/_folderID/router.ts +++ b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/folders/_folderID/router.ts @@ -108,8 +108,8 @@ router.post("/viewed", RequireSelfRequestFromUser, async (req, res) => { router.get( "/timeline", prValidate({ - criteriaName: "string", - crtiteriaValue: "string", + criteriaType: "string", + criteriaValue: "string", }), async (req, res) => { const { user, game, playtype } = GetUGPT(req); @@ -117,19 +117,28 @@ router.get( const folder = GetTachiData(req, "folderDoc"); const gptConfig = GetGamePTConfig(game, playtype); - const criteriaValue = ParseStrPositiveInt(req.query.criteriaValue); + // as asserted by prudence. + const metric = req.query.criteriaType as string; - const { songs, charts } = await GetFolderCharts(folder, {}, true); + const conf = GetScoreMetricConf(gptConfig, metric); - if (criteriaValue === null) { + if (!conf || conf.type !== "ENUM") { return res.status(400).json({ success: false, - description: `Invalid value for criteriaValue.`, + description: `Invalid metric '${metric}' passed. Expected an ENUM for this game.`, }); } - // as asserted by prudence. - const metric = req.query.criteriaName as string; + const criteriaValue = conf.values.indexOf(req.query.criteriaValue as string); + + if (criteriaValue === -1) { + return res.status(400).json({ + success: false, + description: `Invalid criteriaValue of ${req.query.criteriaValue} for ${metric}.`, + }); + } + + const { songs, charts } = await GetFolderCharts(folder, {}, true); const err = ValidateMetric(gptConfig, metric, criteriaValue); @@ -147,13 +156,11 @@ router.get( chartID: { $in: charts.map((e) => e.chartID) }, }; - const conf = GetScoreMetricConf(gptConfig, metric); - if (!conf) { // not possible? return res.status(500).json({ success: false, - description: `Invalid criteria ${req.query.criteriaName}.`, + description: `Invalid criteria ${req.query.criteriaType}.`, }); } 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 ec28f3fc0..30bae52ef 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 @@ -164,7 +164,7 @@ t.test("PATCH /api/v1/users/:userID/games/:game/:playtype/settings", (t) => { }); }); - for (const target of [0, 10, 15, 100]) { + for (const target of [0, 10, 15, 17.5, 100]) { t.test(`Should be able to update the BPI target to ${target}`, async (t) => { const res = await mockApi .patch("/api/v1/users/1/games/iidx/SP/settings") @@ -213,28 +213,13 @@ t.test("PATCH /api/v1/users/:userID/games/:game/:playtype/settings", (t) => { }); } - t.test("Should reject float BPI targets.", async (t) => { + t.test("Should reject BPI targets < -15", async (t) => { const res = await mockApi .patch("/api/v1/users/1/games/iidx/SP/settings") .set("Authorization", "Bearer api_token") .send({ gameSpecific: { - bpiTarget: 10.5, - }, - }); - - t.equal(res.statusCode, 400); - - t.end(); - }); - - t.test("Should reject negative BPI targets", async (t) => { - const res = await mockApi - .patch("/api/v1/users/1/games/iidx/SP/settings") - .set("Authorization", "Bearer api_token") - .send({ - gameSpecific: { - bpiTarget: -10, + bpiTarget: -20, }, }); 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 4f003109f..e98510e0c 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 @@ -33,9 +33,15 @@ router.patch( const gameSpecificSchema = PrudenceZodShim(gptConfig.preferences); const err = p(req.safeBody, { - preferredScoreAlg: p.optional(p.nullable(p.isIn(gptConfig.scoreRatingAlgs))), - preferredSessionAlg: p.optional(p.nullable(p.isIn(gptConfig.sessionRatingAlgs))), - preferredProfileAlg: p.optional(p.nullable(p.isIn(gptConfig.profileRatingAlgs))), + preferredScoreAlg: p.optional( + p.nullable(p.isIn(Object.keys(gptConfig.scoreRatingAlgs))) + ), + preferredSessionAlg: p.optional( + p.nullable(p.isIn(Object.keys(gptConfig.sessionRatingAlgs))) + ), + preferredProfileAlg: p.optional( + p.nullable(p.isIn(Object.keys(gptConfig.profileRatingAlgs))) + ), defaultTable: "*?string", preferredRanking: optNull(p.isIn("global", "rival")), 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 12c87a592..4e33dde7d 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 @@ -151,7 +151,7 @@ t.test("GET /api/v1/users/:userID/games/:game/:playtype/showcase/custom", (t) => t.test("Should return a custom folder evaluated stat on a user.", async (t) => { const res = await mockApi.get( - `/api/v1/users/1/games/iidx/SP/showcase/custom?mode=folder&property=grade>e=3&folderID=${TestingIIDXFolderSP10.folderID}` + `/api/v1/users/1/games/iidx/SP/showcase/custom?mode=folder&metric=grade>e=3&folderID=${TestingIIDXFolderSP10.folderID}` ); t.hasStrict(res.body.body, { @@ -166,7 +166,7 @@ t.test("GET /api/v1/users/:userID/games/:game/:playtype/showcase/custom", (t) => t.test("Should return a custom chart evaluated stat on a user.", async (t) => { const res = await mockApi.get( - `/api/v1/users/1/games/iidx/SP/showcase/custom?mode=chart&property=grade&chartID=${Testing511SPA.chartID}` + `/api/v1/users/1/games/iidx/SP/showcase/custom?mode=chart&metric=grade&chartID=${Testing511SPA.chartID}` ); t.hasStrict(res.body.body, { @@ -178,13 +178,13 @@ t.test("GET /api/v1/users/:userID/games/:game/:playtype/showcase/custom", (t) => t.test("Should reject for invalid folderID.", async (t) => { const res = await mockApi.get( - `/api/v1/users/1/games/iidx/SP/showcase/custom?mode=folder&property=grade>e=4` + `/api/v1/users/1/games/iidx/SP/showcase/custom?mode=folder&metric=grade>e=4` ); t.equal(res.statusCode, 400, "Should reject for no folderID"); const res2 = await mockApi.get( - `/api/v1/users/1/games/iidx/SP/showcase/custom?mode=folder&property=grade>e=4&folderID=foo&folderID=bar` + `/api/v1/users/1/games/iidx/SP/showcase/custom?mode=folder&metric=grade>e=4&folderID=foo&folderID=bar` ); t.equal(res2.statusCode, 400, "Should reject for non-string folderID"); @@ -194,13 +194,13 @@ t.test("GET /api/v1/users/:userID/games/:game/:playtype/showcase/custom", (t) => t.test("Should reject for invalid chartID.", async (t) => { const res = await mockApi.get( - `/api/v1/users/1/games/iidx/SP/showcase/custom?mode=chart&property=grade>e=4` + `/api/v1/users/1/games/iidx/SP/showcase/custom?mode=chart&metric=grade>e=4` ); t.equal(res.statusCode, 400, "Should reject for no chartID"); const res2 = await mockApi.get( - `/api/v1/users/1/games/iidx/SP/showcase/custom?mode=chart&property=grade&chartID=foo&chartID=bar` + `/api/v1/users/1/games/iidx/SP/showcase/custom?mode=chart&metric=grade&chartID=foo&chartID=bar` ); t.equal(res2.statusCode, 400, "Should reject for non-string chartID"); @@ -210,7 +210,7 @@ t.test("GET /api/v1/users/:userID/games/:game/:playtype/showcase/custom", (t) => t.test("Should reject for chartID that doesn't exist.", async (t) => { const res = await mockApi.get( - `/api/v1/users/1/games/iidx/SP/showcase/custom?mode=chart&property=grade&chartID=chart_does_not_exist` + `/api/v1/users/1/games/iidx/SP/showcase/custom?mode=chart&metric=grade&chartID=chart_does_not_exist` ); t.equal(res.statusCode, 400); @@ -221,7 +221,7 @@ t.test("GET /api/v1/users/:userID/games/:game/:playtype/showcase/custom", (t) => } as ChartDocument<"iidx:DP">); const res2 = await mockApi.get( - `/api/v1/users/1/games/iidx/SP/showcase/custom?mode=chart&property=grade&chartID=testing_dp_chart` + `/api/v1/users/1/games/iidx/SP/showcase/custom?mode=chart&metric=grade&chartID=testing_dp_chart` ); t.equal(res2.statusCode, 400); @@ -231,13 +231,13 @@ t.test("GET /api/v1/users/:userID/games/:game/:playtype/showcase/custom", (t) => t.test("Should reject for folderID that doesn't exist.", async (t) => { const res = await mockApi.get( - `/api/v1/users/1/games/iidx/SP/showcase/custom?mode=folder&property=grade>e=4&folderID=invalid` + `/api/v1/users/1/games/iidx/SP/showcase/custom?mode=folder&metric=grade>e=4&folderID=invalid` ); t.equal(res.statusCode, 400); const res2 = await mockApi.get( - `/api/v1/users/1/games/iidx/SP/showcase/custom?mode=folder&property=grade>e=4&folderID=${TestingIIDXFolderSP10.folderID},invalid` + `/api/v1/users/1/games/iidx/SP/showcase/custom?mode=folder&metric=grade>e=4&folderID=${TestingIIDXFolderSP10.folderID},invalid` ); t.equal(res2.statusCode, 400); @@ -247,7 +247,7 @@ t.test("GET /api/v1/users/:userID/games/:game/:playtype/showcase/custom", (t) => t.test("Should reject for invalid mode", async (t) => { const res = await mockApi.get( - `/api/v1/users/1/games/iidx/SP/showcase/custom?mode=nonsense&property=grade>e=4&chartID=foo` + `/api/v1/users/1/games/iidx/SP/showcase/custom?mode=nonsense&metric=grade>e=4&chartID=foo` ); t.equal(res.statusCode, 400); @@ -325,7 +325,7 @@ t.test("PUT /api/v1/users/:userID/games/:game/:playtype/showcase", (t) => { { mode: "chart", chartID: Testing511SPA.chartID, - property: "lamp", + metric: "lamp", }, ]); @@ -339,7 +339,7 @@ t.test("PUT /api/v1/users/:userID/games/:game/:playtype/showcase", (t) => { { mode: "chart", chartID: Testing511SPA.chartID, - property: "lamp", + metric: "lamp", }, ], "Should update preferences.stats in the database." @@ -351,7 +351,7 @@ t.test("PUT /api/v1/users/:userID/games/:game/:playtype/showcase", (t) => { { mode: "chart", chartID: Testing511SPA.chartID, - property: "lamp", + metric: "lamp", }, ], "Should return the updated preferences" @@ -368,7 +368,7 @@ t.test("PUT /api/v1/users/:userID/games/:game/:playtype/showcase", (t) => { { mode: "chart", chartID: "chart_id_does_not_exist", - property: "lamp", + metric: "lamp", }, ]); @@ -386,7 +386,7 @@ t.test("PUT /api/v1/users/:userID/games/:game/:playtype/showcase", (t) => { { mode: "chart", chartID: "testing_dp_chart", - property: "lamp", + metric: "lamp", }, ]); @@ -402,7 +402,7 @@ t.test("PUT /api/v1/users/:userID/games/:game/:playtype/showcase", (t) => { .send([ { mode: "folder", - property: "grade", + metric: "grade", gte: 4, folderID: "folder_does_not_exist", }, @@ -416,7 +416,7 @@ t.test("PUT /api/v1/users/:userID/games/:game/:playtype/showcase", (t) => { .send([ { mode: "folder", - property: "grade", + metric: "grade", gte: 4, folderID: [TestingIIDXFolderSP10.folderID, "folder_does_not_exist"], }, diff --git a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/showcase/router.ts b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/showcase/router.ts index 0f86849e6..a26baf043 100644 --- a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/showcase/router.ts +++ b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/showcase/router.ts @@ -53,24 +53,29 @@ router.get("/", async (req, res) => { * Evalulate a custom stat on this user. * * @param mode - "folder" or "chart" - * @param property - "grade" | "lamp" | "score" | "percent" and "playcount" if mode is chart. + * @param metric - "any score metric for this game (i.e. non-optional). + * Also, "playcount" if mode is chart. * @param chartID - If mode is "chart" this must contain the chartID the stat is referencing. * @param folderID - If mode is "folder" this must contain the folderID the stat is referencing. - * @param gte - If mode is "folder" this must contain the value the property must be greater than. + * @param gte - If mode is "folder" this must contain the value the metric must be greater than. * * @name GET /api/v1/users/:userID/games/:game/:playtype/showcase/custom */ router.get("/custom", async (req, res) => { const { user, game, playtype } = GetUGPT(req); + const gptConfig = GetGamePTConfig(game, playtype); + let stat: ShowcaseStatDetails; + const availableMetrics = GetScoreMetrics(gptConfig, ["DECIMAL", "ENUM", "INTEGER"]); + if (req.query.mode === "folder") { const err = p( req.query, { mode: p.is("folder"), - property: p.isIn("grade", "lamp", "score", "percent"), + metric: p.isIn(availableMetrics), folderID: "string", // lazy regex for matching strings that look like numbers @@ -103,7 +108,7 @@ router.get("/custom", async (req, res) => { stat = { mode: "folder", - metric: req.query.property as "grade" | "lamp" | "percent" | "score", + metric: req.query.metric as string, folderID, gte: Number(req.query.gte), }; @@ -112,7 +117,7 @@ router.get("/custom", async (req, res) => { req.query, { mode: p.is("chart"), - property: p.isIn("grade", "lamp", "score", "percent", "playcount"), + metric: p.isIn(...availableMetrics, "playcount"), chartID: "string", }, {}, @@ -137,7 +142,7 @@ router.get("/custom", async (req, res) => { stat = { mode: "chart", - metric: req.query.property as "grade" | "lamp" | "percent" | "playcount" | "score", + metric: req.query.metric as string, chartID: req.query.chartID as string, }; } else { @@ -202,7 +207,7 @@ router.put("/", RequireAuthedAsUser, RequirePermissions("customise_profile"), as err = p(unvalidatedStat, { chartID: "string", mode: p.is("chart"), - property: p.isIn(availableMetrics), + metric: p.isIn(...availableMetrics, "playcount"), }); } else if (unvalidatedStat.mode === "folder") { err = p(unvalidatedStat, { @@ -216,24 +221,24 @@ router.put("/", RequireAuthedAsUser, RequirePermissions("customise_profile"), as return false; }, mode: p.is("folder"), - property: p.isIn(availableMetrics), + metric: p.isIn(availableMetrics), gte: (self, parent) => { if (typeof self !== "number") { return "Expected a number."; } - if (typeof parent.property !== "string") { - return `Expected parent.property to be a string.`; + if (typeof parent.metric !== "string") { + return `Expected parent.metric to be a string.`; } const conf = - gptConfig.providedMetrics[parent.property] ?? - gptConfig.derivedMetrics[parent.property]; + gptConfig.providedMetrics[parent.metric] ?? + gptConfig.derivedMetrics[parent.metric]; if (!conf) { - return `Invalid property ${ - parent.property + return `Invalid metric ${ + parent.metric }, Expected any of ${availableMetrics.join(", ")}.`; } diff --git a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/tables/router.test.ts b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/tables/router.test.ts index 375c9cee0..fd7026cc4 100644 --- a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/tables/router.test.ts +++ b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/tables/router.test.ts @@ -27,8 +27,9 @@ t.test("GET /api/v1/users/:userID/games/:game/:playtype/tables/:tableID", (t) => t.equal(res.body.body.folders[0].folderID, "testing_folder"); t.equal(res.body.body.table.tableID, "mock_table"); t.equal(res.body.body.stats.length, 1); - t.equal(res.body.body.stats[0].lamps["EX HARD CLEAR"], 1); - t.equal(res.body.body.stats[0].grades.AAA, 1); + + t.equal(res.body.body.stats[0].lamp["EX HARD CLEAR"], 1); + t.equal(res.body.body.stats[0].grade.AAA, 1); t.end(); }); diff --git a/server/src/server/router/ir/fervidex/router.test.ts b/server/src/server/router/ir/fervidex/router.test.ts index eed98a248..069e29930 100644 --- a/server/src/server/router/ir/fervidex/router.test.ts +++ b/server/src/server/router/ir/fervidex/router.test.ts @@ -423,6 +423,7 @@ t.test("POST /ir/fervidex/score/submit", (t) => { .post("/ir/fervidex/score/submit") .set("User-Agent", "fervidex/1.3.0") .set("Authorization", "Bearer mock_token") + .set("X-Software-Model", "LDJ:J:B:A:2020092900") .send({}); t.equal(res.body.success, false, "Should not be successful"); @@ -432,6 +433,63 @@ t.test("POST /ir/fervidex/score/submit", (t) => { t.end(); }); + t.test("Should reject an invalid exscore", async (t) => { + const res = await mockApi + .post("/ir/fervidex/score/submit") + .set("User-Agent", "fervidex/1.3.0") + .set("Authorization", "Bearer mock_token") + .set("X-Software-Model", "LDJ:J:B:A:2020092900") + .send( + deepmerge(FervidexBaseScore, { + ex_score: 9999, + }) + ); + + t.equal(res.statusCode, 200, "Should pass validation."); + + await Sleep(2000); + + const imp = await db.imports.findOne({ + importID: res.body.body.importID, + }); + + t.strictSame(imp?.errors, [ + { + type: "InvalidDatapoint", + message: `Got 1 error when validating score: +Invalid value for score, EX Score cannot be greater than 1572 for this chart. Got 9999.`, + }, + ]); + + t.end(); + }); + + t.test("Should reject an invalid gauge", async (t) => { + const res = await mockApi + .post("/ir/fervidex/score/submit") + .set("User-Agent", "fervidex/1.3.0") + .set("Authorization", "Bearer mock_token") + .set("X-Software-Model", "LDJ:J:B:A:2020092900") + .send(deepmerge(FervidexBaseScore, { gauge: [150] })); + + t.equal(res.statusCode, 200, "Should pass validation."); + + await Sleep(2000); + + const imp = await db.imports.findOne({ + importID: res.body.body.importID, + }); + + t.strictSame(imp?.errors, [ + { + type: "InvalidDatapoint", + message: "Invalid value of gauge 150.", + }, + ]); + + t.end(); + }); + t.end(); }); @@ -496,6 +554,13 @@ t.test("POST /ir/fervidex/profile/submit", (t) => { > ); + await db["fer-settings"].update( + { userID: 1 }, + { + $set: { forceStaticImport: true }, + } + ); + const res = await mockApi .post("/ir/fervidex/profile/submit") .set("Authorization", "Bearer mock_token")