diff --git a/server/src/game-implementations/games/bms-pms.ts b/server/src/game-implementations/games/bms-pms.ts index 7c4cf582b..3730ce132 100644 --- a/server/src/game-implementations/games/bms-pms.ts +++ b/server/src/game-implementations/games/bms-pms.ts @@ -47,7 +47,13 @@ const BMS_IMPL: GPTServerImplementation = { pb.scoreData.percent, IIDXLIKE_GBOUNDARIES[goalValue]!.name, // use notecount to turn the percent deltas into whole ex-scores. - (v) => Math.floor((v / 100) * pb.scoreData.score).toFixed(0) + (deltaPercent) => { + const chartNotecount = Math.floor( + pb.scoreData.score / (pb.scoreData.percent / 100) + ); + + return (deltaPercent * (chartNotecount * 2)).toFixed(0); + } ), }, }; diff --git a/server/src/game-implementations/games/iidx.ts b/server/src/game-implementations/games/iidx.ts index 247d0cfce..bf9f5d5f9 100644 --- a/server/src/game-implementations/games/iidx.ts +++ b/server/src/game-implementations/games/iidx.ts @@ -60,7 +60,7 @@ const IIDX_GOAL_PG_FMT: GPTGoalProgressFormatters<"iidx:DP" | "iidx:SP"> = { return pb.scoreData.lamp; }, - grade: (pb, gradeIndex, chart) => + grade: (pb, gradeIndex) => GradeGoalFormatter( IIDXLIKE_GBOUNDARIES, pb.scoreData.grade, @@ -68,7 +68,13 @@ const IIDX_GOAL_PG_FMT: GPTGoalProgressFormatters<"iidx:DP" | "iidx:SP"> = { IIDXLIKE_GBOUNDARIES[gradeIndex]!.name, // use notecount to turn the percent deltas into whole ex-scores. - (v) => Math.floor(v * chart.data.notecount * 2).toFixed(0) + (deltaPercent) => { + const chartNotecount = Math.floor( + pb.scoreData.score / (pb.scoreData.percent / 100) + ); + + return (deltaPercent * (chartNotecount * 2)).toFixed(0); + } ), }; diff --git a/server/src/game-implementations/types.ts b/server/src/game-implementations/types.ts index ab58e13af..93ebb8a79 100644 --- a/server/src/game-implementations/types.ts +++ b/server/src/game-implementations/types.ts @@ -110,10 +110,6 @@ export type GPTDerivers = { * Format a goal into a string. If a function is provided, it's called with this goals * criteria value, so a goal of "get 3600 on $CHART" would recieve 3600 as its * argument. - * - * Optionally, undefined can be returned to signify this failed somehow. - * This is convenient for things like enum lookups which never fail in practice - * but the typesystem thinks they might be undefined. */ export type GoalCriteriaFormatter = (num: number) => string; diff --git a/server/src/lib/targets/goals.ts b/server/src/lib/targets/goals.ts index f222a0bc7..e9abde3e5 100644 --- a/server/src/lib/targets/goals.ts +++ b/server/src/lib/targets/goals.ts @@ -4,9 +4,9 @@ import fjsh from "fast-json-stable-hash"; import { GPT_SERVER_IMPLEMENTATIONS } from "game-implementations/game-implementations"; import { SubscribeFailReasons } from "lib/constants/err-codes"; import CreateLogCtx from "lib/logger/logger"; -import { FormatGame, GetGPTString, GetGamePTConfig } from "tachi-common"; +import { FormatGame, GetGPTConfig, GetGPTString, GetScoreMetricConf } from "tachi-common"; import { GetFolderChartIDs } from "utils/folder"; -import { IsNullish } from "utils/misc"; +import type { GoalCriteriaFormatter } from "game-implementations/types"; import type { KtLogger } from "lib/logger/logger"; import type { FilterQuery } from "mongodb"; import type { @@ -18,6 +18,7 @@ import type { Playtype, QuestDocument, QuestSubscriptionDocument, + GPTString, } from "tachi-common"; const logger = CreateLogCtx(__filename); @@ -71,29 +72,25 @@ export async function EvaluateGoalForUser( chartID: { $in: chartIDs }, }; + const gptString = GetGPTString(goal.game, goal.playtype); + switch (goal.criteria.mode) { case "single": { const res = await db["personal-bests"].findOne(scoreQuery); // hack, but guaranteed to work. - const scoreDataKey = goal.criteria.key; - const outOfHuman = HumaniseGoalOutOf( - goal.game, - goal.playtype, - goal.criteria.key, - goal.criteria.value - ); + const outOfHuman = HumaniseGoalOutOf(gptString, goal.criteria.key, goal.criteria.value); if (res) { return { achieved: true, outOf: goal.criteria.value, - progress: res.scoreData[scoreDataKey], + // @ts-expect-error completely ok, as it'll definitely be on these props. + progress: res.scoreData[goal.criteria.key], outOfHuman, progressHuman: HumaniseGoalProgress( - goal.game, - goal.playtype, + gptString, goal.criteria.key, goal.criteria.value, res @@ -129,10 +126,10 @@ export async function EvaluateGoalForUser( achieved: false, outOf: goal.criteria.value, outOfHuman, + // @ts-expect-error completely ok, as it'll definitely be on these props. progress: nextBestScore.scoreData[scoreDataKey], progressHuman: HumaniseGoalProgress( - goal.game, - goal.playtype, + gptString, goal.criteria.key, goal.criteria.value, nextBestScore @@ -212,159 +209,64 @@ type GoalKeys = GoalDocument["criteria"]["key"]; * IIDX lamp goals. */ export function HumaniseGoalProgress( - game: Game, - playtype: Playtype, + gptString: GPTString, key: GoalKeys, goalValue: integer, userPB: PBScoreDocument ): string { - switch (key) { - case "scoreData.gradeIndex": { - // for iidx, pms and bms we expect grade deltas (rarely) formatted like - // AAA-1520 - // however, for other games (namely those that run with larger numbers) - // we want to format this stuff like AA-172k. - const fmtFn = - game === "iidx" || game === "pms" || game === "bms" - ? undefined - : (num: number) => Intl.NumberFormat("en", { notation: "compact" }).format(num); + const gptImpl = GPT_SERVER_IMPLEMENTATIONS[gptString]; - const goalGrade = GetGamePTConfig(game, playtype).grades[goalValue]; + // @ts-expect-error yeah this might fail, i know. + const formatter = gptImpl.goalProgressFormatters[key]; - if (!goalGrade) { - throw new Error( - `Invalid Goal -- Tried to get a grade with index '${goalValue}', but no such grade exists for ${FormatGame( - game, - playtype - )}` - ); - } - - const { lower, upper, closer } = GenericFormatGradeDelta( - game, - playtype, - userPB.scoreData.score, - userPB.scoreData.percent, - userPB.scoreData.grade, - fmtFn - ); - - // If this goal is, say, AAA $chart, and the user's deltas are AA+40, AAA-100 - // instead of picking the one with less delta from the grade (AA+40) - // pick the one closest to the target grade. - // Because sometimes this function wraps the grade operand in brackets - // (see (MAX-)-50), we need a regexp for this. - // eslint-disable-next-line @typescript-eslint/prefer-optional-chain - if (upper && new RegExp(`\\(?${goalGrade}`, "u").exec(upper)) { - return upper; - } - - // for some reason, our TS compiler disagrees that this is non-nullable. - // but my IDE thinks it is. Who knows. - // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion - return closer === "lower" ? lower : upper!; - } - - case "scoreData.lampIndex": { - switch (game) { - case "iidx": - case "bms": - case "pms": { - // @ts-expect-error This is guaranteed to exist, we're going to ignore it. - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const maybeBP: number | null | undefined = userPB.scoreData.optional.bp; - - // render BP if it exists - if (!IsNullish(maybeBP)) { - return `${userPB.scoreData.lamp} (BP: ${maybeBP})`; - } - - break; - } - - case "itg": { - // @ts-expect-error This is guaranteed to exist, we're going to ignore it. - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const maybeDiedAt: number | null | undefined = userPB.scoreData.optional.diedAt; - - // render diedAt if it exists as "FAILED (Died 25% in)" - if (!IsNullish(maybeDiedAt)) { - return `${userPB.scoreData.lamp} (Died ${maybeDiedAt.toFixed(0)}% in)`; - } - - break; - } - - // no special logic needed for these games, just render the lamp. - case "chunithm": - case "gitadora": - case "jubeat": - case "museca": - case "popn": - case "sdvx": - case "usc": - case "wacca": - case "maimaidx": - } - - // otherwise, just roll on. - return userPB.scoreData.lamp; - } - - case [`scoreData.${gptConfig.defaultMetric}`]: - return `${userPB.scoreData.percent.toFixed(2)}%`; - case "scoreData.score": - return userPB.scoreData.score.toLocaleString(); - default: - throw new Error(`Broken goal - invalid key ${key}.`); + if (!formatter) { + throw new Error( + `Attempted to format progress for metric '${key}' when no such score metric exists for ${gptString}.` + ); } + + return formatter(userPB, goalValue); } /** * Turn a goal's "outOf" (i.e. HARD CLEAR; AAA or score=2450) into a human-understandable * string. */ -export function HumaniseGoalOutOf(game: Game, playtype: Playtype, key: GoalKeys, value: number) { - const gptImpl = GPT_SERVER_IMPLEMENTATIONS[GetGPTString(game, playtype)]; +export function HumaniseGoalOutOf(gptString: GPTString, key: GoalKeys, value: number) { + const gptConf = GetGPTConfig(gptString); - switch (key) { - case "scoreData.gradeIndex": { - const grade = gptConfig.grades[value]; + const metricConf = GetScoreMetricConf(gptConf, key); - if (!grade) { - throw new Error( - `Invalid goal: Requested a grade with index '${value}' for ${FormatGame( - game, - playtype - )}, yet none existed?` - ); - } - - return grade; - } - - case "scoreData.lampIndex": { - const lamp = gptConfig.lamps[value]; - - if (!lamp) { - throw new Error( - `Invalid goal: Requested a lamp with index '${value}' for ${FormatGame( - game, - playtype - )}, yet none existed?` - ); - } - - return lamp; - } - - case [`scoreData.${gptConfig.defaultMetric}`]: - return `${value.toFixed(2)}%`; - case "scoreData.score": - return value.toLocaleString(); - default: - throw new Error(`Broken goal - invalid key ${key}.`); + if (!metricConf) { + throw new Error( + `Attempted to format outOf for metric '${key}' when no such score metric exists for ${gptString}.` + ); } + + if (metricConf.type === "ENUM") { + const val = metricConf.values[value]; + + if (val === undefined) { + throw new Error( + `Attempted to format outOf for metric '${key}' but no such enum exists at index ${value}. (${gptString})` + ); + } + + return val; + } + + const gptImpl = GPT_SERVER_IMPLEMENTATIONS[gptString]; + + // @ts-expect-error yeah this is technically unsafe, whatever + const fmt: GoalCriteriaFormatter | undefined = gptImpl.goalCriteriaFormatters[key]; + + if (!fmt) { + throw new Error( + `Invalid metric '${key}' passed to format outOf, as no goalCriteriaFormatter exists for it.` + ); + } + + return fmt(value); } /**