mirror of
https://github.com/zkldi/Tachi.git
synced 2026-10-06 22:07:58 +03:00
checkpoint: 34 (100% tests passing)
do you guys even realise how insane this is
This commit is contained in:
@@ -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]);
|
||||
|
||||
|
||||
@@ -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.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({}),
|
||||
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>>;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
+6
-3
@@ -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,
|
||||
|
||||
+1
-4
@@ -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,
|
||||
|
||||
@@ -112,7 +112,7 @@ t.test("#EvalulateUsersStatsShowcase", (t) => {
|
||||
{
|
||||
stat: { folderID: TestingIIDXFolderSP10.folderID },
|
||||
result: {
|
||||
value: 0,
|
||||
value: 1,
|
||||
outOf: 1,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
]
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
+4
-2
@@ -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);
|
||||
|
||||
+18
-11
@@ -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}.`,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+3
-18
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
+9
-3
@@ -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")),
|
||||
|
||||
|
||||
+18
-18
@@ -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"],
|
||||
},
|
||||
|
||||
+19
-14
@@ -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(", ")}.`;
|
||||
}
|
||||
|
||||
|
||||
+3
-2
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user