checkpoint: 34 (100% tests passing)

do you guys even realise how insane this is
This commit is contained in:
zkldi
2023-01-19 16:39:42 +00:00
parent 2ff1dc1c29
commit cbc83c74e4
72 changed files with 684 additions and 729 deletions
@@ -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]);
+14
View File
@@ -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.`);
}
}
}
+1 -1
View File
@@ -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({
+2 -2
View File
@@ -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({
+1 -1
View File
@@ -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({}),
+1 -1
View File
@@ -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"],
+1 -1
View File
@@ -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(),
+2 -1
View File
@@ -14,7 +14,8 @@ export function MakeGrades<G extends string>(rec: Record<G, number>) {
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;
}
+3 -2
View File
@@ -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)));
}
}
+5 -1
View File
@@ -30,6 +30,11 @@ export type BatchManualScore<GPT extends GPTString = GPTString> = ExtractMetrics
judgements?: Record<Judgements[GPT], integer>;
timeAchieved?: number | null;
optional?: AllFieldsNullableOptional<ExtractMetrics<ConfOptionalMetrics[GPT]>>;
/**
* @deprecated Use `optional` instead.
*/
hitMeta?: AllFieldsNullableOptional<ExtractMetrics<ConfOptionalMetrics[GPT]>>;
scoreMeta?: Partial<ScoreMeta[GPT]>;
} & (
| {
@@ -47,7 +52,6 @@ export interface BatchManual<GPT extends GPTString = GPTString> {
game: GPTStringToGame[GPT];
playtype: GPTStringToPlaytype[GPT];
service: string;
version?: Versions[GPT];
};
scores: Array<BatchManualScore<GPT>>;
+1 -1
View File
@@ -444,7 +444,7 @@ export type QuestSubscriptionDocument = {
export type ScoreData<GPT extends GPTString = GPTString> = DerivedMetrics[GPT] &
ProvidedMetrics[GPT] & {
judgements: Partial<Record<Judgements[GPT], integer | null>>;
optional: Partial<OptionalMetrics[GPT]> & {
optional: OptionalMetrics[GPT] & {
enumIndexes: OptionalEnumIndexes<GPT>;
};
enumIndexes: ScoreEnumIndexes<GPT>;
+37
View File
@@ -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();
});
+2 -1
View File
@@ -252,6 +252,7 @@ function RelativeGradeDelta<G extends string>(
gradeBoundaries: Array<GradeBoundary<G>>,
scoreGrade: G,
scoreValue: number,
// Positive number means higher grade, etc.
relativeIndex: number
) {
const gradeBoundary =
@@ -267,7 +268,7 @@ function RelativeGradeDelta<G extends string>(
function AbsoluteGradeDelta<G extends string>(gradeBoundary: GradeBoundary<G>, scoreValue: number) {
return {
grade: gradeBoundary.name,
delta: gradeBoundary.lowerBound - scoreValue,
delta: scoreValue - gradeBoundary.lowerBound,
};
}
@@ -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<SDVXLikes> = {
},
};
export const SDVXLIKE_GOAL_FMT: GPTGoalCriteriaFormatters<SDVXLikes> = {
export const SDVXLIKE_GOAL_FMT: GPTGoalFormatters<SDVXLikes> = {
score: GoalFmtScore,
};
export const SDVXLIKE_GOAL_OO_FMT: GPTGoalFormatters<SDVXLikes> = {
score: GoalOutOfFmtScore,
};
export const SDVXLIKE_GOAL_PG_FMT: GPTGoalProgressFormatters<SDVXLikes> = {
score: (pb) => FmtNum(pb.scoreData.score),
lamp: (pb) => pb.scoreData.lamp,
@@ -216,6 +222,14 @@ export const SDVXLIKE_GOAL_PG_FMT: GPTGoalProgressFormatters<SDVXLikes> = {
),
};
export const SDVXLIKE_PB_MERGERS: Array<PBMergeFunction<SDVXLikes>> = [
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<GPTStrings["bms" | "pms"]> = (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<G extends string>(
// 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;
}
@@ -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<PBMergeFunction<GPTStrings["bms" | "pms"]>> = [
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<GPTStrings["bms" | "pms"]> = {
}
),
},
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;
@@ -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",
};
@@ -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;
+39 -6
View File
@@ -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<PBMergeFunction<GPTStrings["iidx"]>> = [
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",
};
@@ -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",
};
@@ -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",
};
@@ -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",
};
@@ -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",
};
+13 -1
View File
@@ -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",
};
+14 -1
View File
@@ -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,
};
@@ -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<GPTStrings["usc"]> = {
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;
+11 -1
View File
@@ -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",
};
+33 -2
View File
@@ -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<GPT extends GPTString> = {
: 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<GPT extends GPTString> = (
userID: integer,
chartID: string,
asOfTimestamp: number | null,
existingPB: PBScoreDocumentNoRank<GPT>
) => Promise<PBReference | null>;
/**
* 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<GPT extends GPTString> = {
export type GPTGoalFormatters<GPT extends GPTString> = {
[K in keyof ConfScoreMetrics[GPT] as ConfScoreMetrics[GPT][K] extends ConfEnumScoreMetric<
infer _
>
@@ -147,8 +166,20 @@ export interface GPTServerImplementation<GPT extends GPTString> {
sessionCalcs: GPTSessionCalculators<GPT>;
profileCalcs: GPTProfileCalculators<GPT>;
classDerivers: GPTClassDerivers<GPT>;
goalCriteriaFormatters: GPTGoalCriteriaFormatters<GPT>;
goalCriteriaFormatters: GPTGoalFormatters<GPT>;
goalOutOfFormatters: GPTGoalFormatters<GPT>;
goalProgressFormatters: GPTGoalProgressFormatters<GPT>;
/**
* How should we mutate PBs (to join best lamps, lowest BPs, etc.) for this GPT?
*/
pbMergeFunctions: Array<PBMergeFunction<GPT>>;
/**
* 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 = {
@@ -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<GPT extends GPTString>(
direction: "largest" | "smallest",
metric: MetricKeys<GPT>,
name: string,
applicator: (base: PBScoreDocumentNoRank<GPT>, score: ScoreDocument<GPT>) => void
@@ -67,7 +70,7 @@ export function CreatePBMergeFor<GPT extends GPTString>(
),
{
sort: {
[`scoreData.${metric as string}`]: -1,
[`scoreData.${metric as string}`]: direction === "largest" ? -1 : 1,
},
}
)) as ScoreDocument<GPT> | null;
@@ -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,
@@ -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,
},
@@ -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();
@@ -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);
@@ -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<PBMergeFunction<GPTStrings["bms" | "pms"]>> = [
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;
}),
];
@@ -1,24 +0,0 @@
import { CreatePBMergeFor } from "../utils";
import type { PBMergeFunction } from "../types";
import type { GPTStrings } from "tachi-common";
export const IIDX_MERGERS: Array<PBMergeFunction<GPTStrings["iidx"]>> = [
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;
}),
];
@@ -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<PBMergeFunction<GPT>>;
};
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<GPTString, string> = {
"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",
};
@@ -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<GPT extends GPTString> = (
userID: integer,
chartID: string,
asOfTimestamp: number | null,
existingPB: PBScoreDocumentNoRank<GPT>
) => Promise<PBReference | null>;
@@ -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, {
@@ -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();
});
@@ -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;
}
@@ -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();
});
@@ -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();
});
@@ -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: {},
@@ -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();
});
@@ -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: {
@@ -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<BatchManualScore>(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<BatchManualScore>(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();
});
@@ -80,7 +80,10 @@ export const ConverterBatchManual: ConverterFunction<BatchManualScore, BatchManu
scoreData: {
...metrics,
judgements: data.judgements ?? {},
optional: data.optional ?? {},
// if hitMeta is provided and optional is not provided, use hitMeta.
// this is for compatibility with old import methods.
optional: data.optional ?? data.hitMeta ?? {},
},
scoreMeta: data.scoreMeta ?? {},
};
@@ -256,7 +256,7 @@ t.test("#ParserFn", (t) => {
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();
});
@@ -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,
@@ -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,
@@ -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,
@@ -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();
@@ -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();
@@ -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,
@@ -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,
@@ -30,7 +30,6 @@ t.test("#ConverterKsHookSV6CStatic", (t) => {
dryScore: {
scoreData: {
score: 9_579_365,
grade: "AA+",
lamp: "EXCESSIVE CLEAR",
judgements: {},
optional: {
@@ -30,7 +30,6 @@ t.test("#ConverterIRKsHookSV6C", (t) => {
dryScore: {
scoreData: {
score: 9_579_365,
grade: "AA+",
lamp: "EXCESSIVE CLEAR",
judgements: {
critical: 1184,
@@ -222,7 +222,7 @@ t.test("#DeleteScore", (t) => {
t.hasStrict(res, {
classes: {
vfClass: SDVX_VF_CLASSES.SIENNA_II,
vfClass: "SIENNA_II",
},
ratings: {
VF6: 4,
+1 -1
View File
@@ -112,7 +112,7 @@ t.test("#EvalulateUsersStatsShowcase", (t) => {
{
stat: { folderID: TestingIIDXFolderSP10.folderID },
result: {
value: 0,
value: 1,
outOf: 1,
},
},
+3 -3
View File
@@ -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",
},
],
]
+6 -3
View File
@@ -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();
+29 -25
View File
@@ -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<PBScoreDocument> = {
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<PBScoreDocument> = {
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<string>, chartIDsArr: Array<string>) {
export async function GetRelevantFolderGoals(goalIDs: Array<string>, chartIDsArr: Array<string>) {
// 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<Array<GoalDocument>> = db.goals.aggregate([
const result: Array<GoalDocument> = await db.goals.aggregate([
{
$match: {
"charts.type": "folder",
@@ -688,6 +691,7 @@ export function GetRelevantFolderGoals(goalIDs: Array<string>, chartIDsArr: Arra
{
$project: {
folderCharts: 0,
_id: 0,
},
},
]);
@@ -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);
@@ -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();
});
@@ -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();
@@ -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);
@@ -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}.`,
});
}
@@ -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,
},
});
@@ -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")),
@@ -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&gte=3&folderID=${TestingIIDXFolderSP10.folderID}`
`/api/v1/users/1/games/iidx/SP/showcase/custom?mode=folder&metric=grade&gte=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&gte=4`
`/api/v1/users/1/games/iidx/SP/showcase/custom?mode=folder&metric=grade&gte=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&gte=4&folderID=foo&folderID=bar`
`/api/v1/users/1/games/iidx/SP/showcase/custom?mode=folder&metric=grade&gte=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&gte=4`
`/api/v1/users/1/games/iidx/SP/showcase/custom?mode=chart&metric=grade&gte=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&gte=4&folderID=invalid`
`/api/v1/users/1/games/iidx/SP/showcase/custom?mode=folder&metric=grade&gte=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&gte=4&folderID=${TestingIIDXFolderSP10.folderID},invalid`
`/api/v1/users/1/games/iidx/SP/showcase/custom?mode=folder&metric=grade&gte=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&gte=4&chartID=foo`
`/api/v1/users/1/games/iidx/SP/showcase/custom?mode=nonsense&metric=grade&gte=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"],
},
@@ -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(", ")}.`;
}
@@ -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();
});
@@ -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")