fix: move goal logic into tachi-common (#1673)

This commit is contained in:
zk
2026-06-16 00:27:37 +01:00
committed by GitHub
parent 368bafb299
commit 216617c54a
58 changed files with 991 additions and 1010 deletions
@@ -27,18 +27,24 @@ export const FAST_SLOW_MAXCOMBO = {
type: "INTEGER",
validate: p.isPositiveInteger,
formatter: FmtNum,
goalTitleFormatter: (v) => `Get ${v} fasts in`,
goalOutOfFormatter: FmtNum,
description: "The amount of mistakes in this score that were a result of hitting early.",
},
slow: {
type: "INTEGER",
validate: p.isPositiveInteger,
formatter: FmtNum,
goalTitleFormatter: (v) => `Get ${v} slows in`,
goalOutOfFormatter: FmtNum,
description: "The amount of mistakes in this score that were a result of hitting late.",
},
maxCombo: {
type: "INTEGER",
validate: p.isPositiveInteger,
formatter: FmtNum,
goalTitleFormatter: (v) => `Get a max combo of ${v} in`,
goalOutOfFormatter: FmtNum,
description: "The largest combo in this score.",
},
} as const satisfies Record<string, ConfScoreMetric>;
@@ -52,6 +52,8 @@ export const GAME_ARCAEA_CONF = {
validate: p.isPositiveInteger,
allowFolderGoalsIf: (v: number) => v < 10_000_000,
formatter: FmtNum,
goalTitleFormatter: (v) => `Get a score of ${v.toLocaleString("en-GB")} on`,
goalOutOfFormatter: (v) => v.toLocaleString("en-GB"),
description:
"The score value. This is between 0 and 10 million, plus bonus points dependent on how many shiny PUREs you get.",
},
@@ -169,6 +169,8 @@ export const GAME_BMS_7K_CONF = {
type: "INTEGER",
chartDependentMax: true,
formatter: FmtScoreNoCommas,
goalTitleFormatter: (v) => `Get a score of ${v} on`,
goalOutOfFormatter: (v) => v.toString(),
description:
"EX Score. This should be between 0 and the maximum possible EX on this chart.",
},
@@ -206,6 +208,8 @@ export const GAME_BMS_7K_CONF = {
type: "DECIMAL",
validate: p.isBetween(0, 100),
formatter: FmtPercent,
goalTitleFormatter: (v) => `Get ${v.toFixed(2)}% on`,
goalOutOfFormatter: (v) => `${v.toFixed(2)}%`,
description: "EX Score divided by the maximum possible EX Score on this chart.",
},
},
@@ -219,12 +223,16 @@ export const GAME_BMS_7K_CONF = {
type: "INTEGER",
validate: p.isPositive,
formatter: FmtScoreNoCommas,
goalTitleFormatter: (v) => `Get a BP of ${v} in`,
goalOutOfFormatter: (v) => v.toString(),
description: "The total bads + poors in this score.",
},
gauge: {
type: "DECIMAL",
validate: p.isBetween(0, 100),
formatter: FmtPercent,
goalTitleFormatter: (v) => `Get a final gauge of ${v.toFixed(2)}% in`,
goalOutOfFormatter: (v) => `${v.toFixed(2)}%`,
description:
"The life in percent (between 0 and 100) that was on the gauge at the end of the chart.",
},
@@ -253,30 +261,40 @@ export const GAME_BMS_7K_CONF = {
type: "INTEGER",
validate: p.isPositive,
formatter: FmtScoreNoCommas,
goalTitleFormatter: (v) => `Get ${v} early PGreats in`,
goalOutOfFormatter: (v) => v.toString(),
description: `The amount of early PGreats in this score.`,
},
egr: {
type: "INTEGER",
validate: p.isPositive,
formatter: FmtScoreNoCommas,
goalTitleFormatter: (v) => `Get ${v} early greats in`,
goalOutOfFormatter: (v) => v.toString(),
description: `The amount of early greats in this score.`,
},
egd: {
type: "INTEGER",
validate: p.isPositive,
formatter: FmtScoreNoCommas,
goalTitleFormatter: (v) => `Get ${v} early goods in`,
goalOutOfFormatter: (v) => v.toString(),
description: `The amount of early goods in this score.`,
},
ebd: {
type: "INTEGER",
validate: p.isPositive,
formatter: FmtScoreNoCommas,
goalTitleFormatter: (v) => `Get ${v} early bads in`,
goalOutOfFormatter: (v) => v.toString(),
description: `The amount of early bads in this score.`,
},
epr: {
type: "INTEGER",
validate: p.isPositive,
formatter: FmtScoreNoCommas,
goalTitleFormatter: (v) => `Get ${v} early poors in`,
goalOutOfFormatter: (v) => v.toString(),
description: `The amount of early poors in this score.`,
},
@@ -284,30 +302,40 @@ export const GAME_BMS_7K_CONF = {
type: "INTEGER",
validate: p.isPositive,
formatter: FmtScoreNoCommas,
goalTitleFormatter: (v) => `Get ${v} late PGreats in`,
goalOutOfFormatter: (v) => v.toString(),
description: `The amount of late PGreats in this score.`,
},
lgr: {
type: "INTEGER",
validate: p.isPositive,
formatter: FmtScoreNoCommas,
goalTitleFormatter: (v) => `Get ${v} late greats in`,
goalOutOfFormatter: (v) => v.toString(),
description: `The amount of late greats in this score.`,
},
lgd: {
type: "INTEGER",
validate: p.isPositive,
formatter: FmtScoreNoCommas,
goalTitleFormatter: (v) => `Get ${v} late goods in`,
goalOutOfFormatter: (v) => v.toString(),
description: `The amount of late goods in this score.`,
},
lbd: {
type: "INTEGER",
validate: p.isPositive,
formatter: FmtScoreNoCommas,
goalTitleFormatter: (v) => `Get ${v} late bads in`,
goalOutOfFormatter: (v) => v.toString(),
description: `The amount of late bads in this score.`,
},
lpr: {
type: "INTEGER",
validate: p.isPositive,
formatter: FmtScoreNoCommas,
goalTitleFormatter: (v) => `Get ${v} late poors in`,
goalOutOfFormatter: (v) => v.toString(),
description: `The amount of late poors in this score.`,
},
},
@@ -54,6 +54,8 @@ export const GAME_CHUNITHM_CONF = {
type: "INTEGER",
validate: p.isBetween(0, 1_010_000),
formatter: FmtNum,
goalTitleFormatter: (v) => `Get a score of ${v.toLocaleString("en-GB")} on`,
goalOutOfFormatter: (v) => v.toLocaleString("en-GB"),
description: "The score value. This is between 0 and 1.01 million.",
},
noteLamp: {
@@ -74,6 +74,8 @@ export const GAME_DDR_SP_CONF = {
type: "INTEGER",
validate: p.isBetween(0, 1_000_000),
formatter: FmtNum,
goalTitleFormatter: (v) => `Get a score of ${v.toLocaleString("en-GB")} on`,
goalOutOfFormatter: (v) => v.toLocaleString("en-GB"),
description: "The score value. This is between 0 and 1 million.",
},
@@ -132,6 +134,8 @@ export const GAME_DDR_SP_CONF = {
type: "INTEGER",
formatter: FmtNum,
validate: p.isPositiveInteger,
goalTitleFormatter: (v) => `Get an EX score of ${v.toLocaleString("en-GB")} in`,
goalOutOfFormatter: (v) => v.toLocaleString("en-GB"),
// We want to track the best EXScore a user gets, but it is an optional
// metric.
@@ -41,6 +41,8 @@ export const GAME_GITADORA_GITA_CONF = {
type: "DECIMAL",
validate: p.isBetween(0, 100),
formatter: FmtPercent,
goalTitleFormatter: (v) => `Get ${v.toFixed(2)}% on`,
goalOutOfFormatter: (v) => `${v.toFixed(2)}%`,
description:
"The percent this score was worth. Sometimes referred to as 'Achievement Rate' in game. This is a value between 0 and 100.",
},
@@ -63,6 +63,8 @@ export const GAME_IIDX_SP_CONF = {
type: "INTEGER",
chartDependentMax: true,
formatter: FmtScoreNoCommas,
goalTitleFormatter: (v) => `Get a score of ${v} on`,
goalOutOfFormatter: (v) => v.toString(),
description:
"EX Score. This should be between 0 and the maximum possible EX on this chart.",
},
@@ -88,6 +90,8 @@ export const GAME_IIDX_SP_CONF = {
type: "DECIMAL",
validate: p.isBetween(0, 100),
formatter: FmtPercent,
goalTitleFormatter: (v) => `Get ${v.toFixed(2)}% on`,
goalOutOfFormatter: (v) => `${v.toFixed(2)}%`,
description: "EX Score divided by the maximum possible EX Score on this chart.",
},
grade: {
@@ -109,12 +113,16 @@ export const GAME_IIDX_SP_CONF = {
type: "INTEGER",
validate: p.isPositive,
formatter: FmtScoreNoCommas,
goalTitleFormatter: (v) => `Get a BP of ${v} in`,
goalOutOfFormatter: (v) => v.toString(),
description: "The total bads + poors in this score.",
},
gauge: {
type: "DECIMAL",
validate: p.isBetween(0, 100),
formatter: FmtPercent,
goalTitleFormatter: (v) => `Get a final gauge of ${v.toFixed(2)}% in`,
goalOutOfFormatter: (v) => `${v.toFixed(2)}%`,
description:
"The life in percent (between 0 and 100) that was on the gauge at the end of the chart.",
},
@@ -122,6 +130,8 @@ export const GAME_IIDX_SP_CONF = {
type: "INTEGER",
validate: p.isPositive,
formatter: FmtNum,
goalTitleFormatter: (v) => `Get ${v} combo breaks in`,
goalOutOfFormatter: (v) => v.toLocaleString("en-GB"),
description: "The amount of times combo was broken.",
},
@@ -22,6 +22,8 @@ export const GAME_ITG_STAMINA_CONF = {
type: "DECIMAL",
validate: p.isBetween(0, 100),
formatter: FmtPercent,
goalTitleFormatter: (v) => `Get ${v.toFixed(2)}% on`,
goalOutOfFormatter: (v) => `${v.toFixed(2)}%`,
description:
"The % value this score was worth. This is a number between 0 and 100. Note that negative %s, although existing in ITG, are not supported.",
},
@@ -34,6 +36,8 @@ export const GAME_ITG_STAMINA_CONF = {
type: "DECIMAL",
validate: p.isBetween(0, 100),
formatter: FmtPercent,
goalTitleFormatter: (v) => `Survive ${v.toFixed(2)}% through`,
goalOutOfFormatter: (v) => `${v.toFixed(2)}%`,
description:
"How far this user survived through the chart. For clears, this should be 100, if the user got halfway through, this should be 50, etc.",
},
@@ -78,6 +82,22 @@ export const GAME_ITG_STAMINA_CONF = {
return `Cleared with ${(v - 100).toFixed(2)}%`;
},
goalTitleFormatter: (v) => {
if (v === 100) {
return "CLEAR";
} else if (v < 100) {
return `Survive ${v.toFixed(2)}% through`;
}
return `CLEAR, and get ${(v - 100).toFixed(2)}% on`;
},
goalOutOfFormatter: (v) => {
if (v >= 100) {
return `CLEAR with ${(v - 100).toFixed(2)}%`;
}
return `${v.toFixed(2)}%`;
},
description:
"A combination of `survivedPercent` and `scorePercent`. This metric is `survivedPercent` if the player didn't clear the chart. Otherwise, it's their `scorePercent` + 100.",
},
@@ -36,12 +36,16 @@ export const GAME_JUBEAT_SINGLE_CONF = {
type: "INTEGER",
validate: p.isBetween(0, 1_000_000),
formatter: FmtNum,
goalTitleFormatter: (v) => `Get a score of ${v.toLocaleString("en-GB")} on`,
goalOutOfFormatter: (v) => v.toLocaleString("en-GB"),
description: "The score value. This is between 0 and 1 million.",
},
musicRate: {
type: "DECIMAL",
chartDependentMax: true,
formatter: FmtPercent,
goalTitleFormatter: (v) => `Get a music rate of ${v.toFixed(1)}% on`,
goalOutOfFormatter: (v) => `${v.toFixed(1)}%`,
description:
"The music rate for this score. This is between 0 and 100 on normal difficulties, and 0 and 120 for hard-mode difficulties. This should be submitted to one decimal place.",
},
@@ -109,6 +109,8 @@ export const GAME_MAIMAI_DX_CONF = {
type: "DECIMAL",
validate: p.isBetween(0, 101),
formatter: (v) => FmtPercent(v, 4),
goalTitleFormatter: (v) => `Get ${v.toFixed(4)}% on`,
goalOutOfFormatter: (v) => `${v.toFixed(4)}%`,
description:
"The percent this score was worth. Sometimes called 'rate' in game. This is between 0 and 101.",
},
@@ -66,6 +66,8 @@ export const GAME_MAIMAI_CONF = {
allowFolderGoalsIf: (v) => v >= 0 && v < 100.0,
validate: p.isPositive,
formatter: FmtPercent,
goalTitleFormatter: (v) => `Get ${v.toFixed(2)}% on`,
goalOutOfFormatter: (v) => `${v.toFixed(2)}%`,
description:
"The percent this score was worth. Sometimes called 'rate' in game. This is upper-bounded by how many BREAK notes the chart has.",
},
@@ -25,6 +25,8 @@ export const GAME_MUSECA_CONF = {
type: "INTEGER",
validate: p.isBetween(0, 1_000_000),
formatter: FmtNum,
goalTitleFormatter: (v) => `Get a score of ${v.toLocaleString("en-GB")} on`,
goalOutOfFormatter: (v) => v.toLocaleString("en-GB"),
description: "The score value. This is between 0 and 1 million.",
},
lamp: {
@@ -62,6 +62,8 @@ export const GAME_ONGEKI_CONF = {
type: "INTEGER",
validate: p.isBetween(0, 1_010_000),
formatter: FmtNum,
goalTitleFormatter: (v) => `Get a score of ${v.toLocaleString("en-GB")} on`,
goalOutOfFormatter: (v) => v.toLocaleString("en-GB"),
description:
"Known in-game as 'Technical Score'. It ranges between 0 and 1,010,000, where notes are worth 950,000, and bells 60,000.",
},
@@ -81,6 +83,8 @@ export const GAME_ONGEKI_CONF = {
type: "INTEGER",
chartDependentMax: true,
formatter: FmtNum,
goalTitleFormatter: (v) => `Get ${v.toLocaleString("en-GB")} Platinum Score on`,
goalOutOfFormatter: (v) => v.toLocaleString("en-GB"),
description:
"The Platinum Score value, similar to the scoring system used in beatmania IIDX.",
},
@@ -97,6 +101,7 @@ export const GAME_ONGEKI_CONF = {
type: "ENUM",
values: ["0-star", "1-star", "2-star", "3-star", "4-star", "5-star", "R-star"],
minimumRelevantValue: "1-star",
goalTitleFormatter: (v) => `Get ${FmtStars(v as StarEnum, false)} on`,
description: "The number of platinum stars of this score",
},
},
@@ -110,6 +115,8 @@ export const GAME_ONGEKI_CONF = {
type: "INTEGER",
chartDependentMax: true,
formatter: FmtNum,
goalTitleFormatter: (v) => `Take ${v.toLocaleString("en-GB")} damage in`,
goalOutOfFormatter: (v) => v.toLocaleString("en-GB"),
description: "The number of damage ticks received.",
partOfScoreID: true,
},
@@ -117,6 +124,8 @@ export const GAME_ONGEKI_CONF = {
type: "INTEGER",
chartDependentMax: true,
formatter: FmtNum,
goalTitleFormatter: (v) => `Get ${v.toLocaleString("en-GB")} bells in`,
goalOutOfFormatter: (v) => v.toLocaleString("en-GB"),
description: "The number of bells collected.",
partOfScoreID: true,
},
@@ -124,6 +133,8 @@ export const GAME_ONGEKI_CONF = {
type: "INTEGER",
chartDependentMax: true,
formatter: FmtNum,
goalTitleFormatter: (v) => `Get a total bell count of ${v.toLocaleString("en-GB")} in`,
goalOutOfFormatter: (v) => v.toLocaleString("en-GB"),
description: "The total number of bells.",
},
scoreGraph: {
@@ -50,6 +50,8 @@ export const GAME_PMS_CONTROLLER_CONF = {
type: "INTEGER",
chartDependentMax: true,
formatter: FmtScoreNoCommas,
goalTitleFormatter: (v) => `Get a score of ${v} on`,
goalOutOfFormatter: (v) => v.toString(),
description:
"EX Score. This should be between 0 and the maximum possible EX on this chart.",
},
@@ -87,6 +89,8 @@ export const GAME_PMS_CONTROLLER_CONF = {
type: "DECIMAL",
validate: p.isBetween(0, 100),
formatter: FmtPercent,
goalTitleFormatter: (v) => `Get ${v.toFixed(2)}% on`,
goalOutOfFormatter: (v) => `${v.toFixed(2)}%`,
description: "EX Score divided by the maximum possible EX Score on this chart.",
},
},
@@ -44,6 +44,8 @@ export const GAME_POPN_CONF = {
type: "INTEGER",
validate: p.isBetween(0, 100_000),
formatter: FmtNum,
goalTitleFormatter: (v) => `Get a score of ${v.toLocaleString("en-GB")} on`,
goalOutOfFormatter: (v) => v.toLocaleString("en-GB"),
description: "The score value.",
},
clearMedal: {
@@ -96,6 +98,8 @@ export const GAME_POPN_CONF = {
type: "DECIMAL",
validate: p.isBetween(0, 100),
formatter: FmtPercent,
goalTitleFormatter: (v) => `Get a final gauge of ${v.toFixed(2)}% in`,
goalOutOfFormatter: (v) => `${v.toFixed(2)}%`,
description:
"The gauge value this score had at the end. This is a value between 0 and 100.",
},
@@ -81,6 +81,8 @@ export const GAME_SDVX_CONF = {
type: "INTEGER",
validate: p.isBetween(0, 10_000_000),
formatter: FmtNum,
goalTitleFormatter: (v) => `Get a score of ${v.toLocaleString("en-GB")} on`,
goalOutOfFormatter: (v) => v.toLocaleString("en-GB"),
description: "The score value. This is between 0 and 10 million.",
},
lamp: {
@@ -116,6 +118,8 @@ export const GAME_SDVX_CONF = {
type: "INTEGER",
chartDependentMax: true,
formatter: FmtNum,
goalTitleFormatter: (v) => `Get an EX score of ${v.toLocaleString("en-GB")} in`,
goalOutOfFormatter: (v) => v.toLocaleString("en-GB"),
// We want to track the best EXScore a user gets, but it is an optional
// metric.
@@ -128,6 +132,8 @@ export const GAME_SDVX_CONF = {
type: "DECIMAL",
validate: p.isBetween(0, 100),
formatter: FmtPercent,
goalTitleFormatter: (v) => `Get a final gauge of ${v.toFixed(2)}% in`,
goalOutOfFormatter: (v) => `${v.toFixed(2)}%`,
description: "The amount of life in the gauge at the end of this chart.",
},
},
@@ -22,6 +22,8 @@ export const GAME_USC_CONTROLLER_CONF = {
type: "INTEGER",
validate: p.isBetween(0, 10_000_000),
formatter: FmtNum,
goalTitleFormatter: (v) => `Get a score of ${v.toLocaleString("en-GB")} on`,
goalOutOfFormatter: (v) => v.toLocaleString("en-GB"),
description: "The score value. This is between 0 and 10 million.",
},
lamp: {
@@ -56,6 +58,8 @@ export const GAME_USC_CONTROLLER_CONF = {
type: "DECIMAL",
validate: p.isBetween(0, 100),
formatter: FmtPercent,
goalTitleFormatter: (v) => `Get a final gauge of ${v.toFixed(2)}% in`,
goalOutOfFormatter: (v) => `${v.toFixed(2)}%`,
description:
"The amount of life in the gauge when this chart finished. This is between 0 and 100.",
},
@@ -53,6 +53,8 @@ export const GAME_WACCA_CONF = {
type: "INTEGER",
validate: p.isBetween(0, 1_000_000),
formatter: FmtNum,
goalTitleFormatter: (v) => `Get a score of ${v.toLocaleString("en-GB")} on`,
goalOutOfFormatter: (v) => v.toLocaleString("en-GB"),
description: "The score value. This is between 0 and 1 million.",
},
lamp: {
+2
View File
@@ -9,6 +9,8 @@ export * as StaticConfig from "./constants/import-types";
export * from "./constants/permissions";
export * from "./lib/bmstable-load";
export * from "./lib/folder-slug";
export * from "./lib/goal-progress-formatters";
export * from "./lib/goal-title";
export * as Schemas from "./lib/schemas";
export * from "./lib/zod-schemas";
export * from "./seeds/types";
@@ -0,0 +1,303 @@
/**
* Per-game formatters for displaying a user's current progress toward a goal.
*
* For ENUM metrics (lamp, grade), the formatter returns the current enum value
* string. For DECIMAL/INTEGER metrics it returns a formatted number. Grade
* metrics use grade-delta formatting (e.g. "AAA-1234").
*
* These live here rather than inside the `as const` game-config objects because
* the formatter functions close over `PBScoreDocument<TGame>` types that are
* themselves derived from the game configs — putting them inline would create a
* circular type-inference cycle.
*/
import type { V3Game } from "../types";
import type { PBScoreDocument } from "../types/documents";
import { FmtStars } from "../config/game-support/ongeki";
import {
ARCAEA_GBOUNDARIES,
CHUNITHM_GBOUNDARIES,
DDR_GBOUNDARIES,
GITADORA_GBOUNDARIES,
IIDXLIKE_GBOUNDARIES,
ITG_GBOUNDARIES,
JUBEAT_GBOUNDARIES,
MAIMAI_GBOUNDARIES,
MAIMAIDX_GBOUNDARIES,
MUSECA_GBOUNDARIES,
ONGEKI_GBOUNDARIES,
POPN_GBOUNDARIES,
SDVXLIKE_GBOUNDARIES,
WACCA_GBOUNDARIES,
} from "../constants/grade-boundaries";
import { FmtNum } from "../utils/util";
import { GradeGoalFormatter } from "./goal-title";
export type GoalProgressFormatter<TGame extends V3Game = V3Game> = (
pb: PBScoreDocument<TGame>,
goalValue: number,
) => string;
export type GPTGoalProgressFormatters<TGame extends V3Game> = Record<
string,
GoalProgressFormatter<TGame>
>;
const IIDXLIKE_GOAL_PG_FMT: GPTGoalProgressFormatters<
"bms-7k" | "bms-14k" | "iidx-dp" | "iidx-sp" | "pms-controller" | "pms-keyboard"
> = {
percent: (pb) => `${pb.scoreData.percent.toFixed(2)}%`,
// 4519 -> "4519". Don't add commas or anything.
score: (pb) => pb.scoreData.score.toString(),
lamp: (pb) => {
if (typeof pb.scoreData.optional.bp === "number") {
return `${pb.scoreData.lamp} (BP: ${pb.scoreData.optional.bp})`;
}
return pb.scoreData.lamp;
},
grade: (pb, gradeIndex) =>
GradeGoalFormatter(
IIDXLIKE_GBOUNDARIES,
pb.scoreData.grade,
pb.scoreData.percent,
IIDXLIKE_GBOUNDARIES[gradeIndex]!.name,
// use notecount to turn the percent deltas into whole ex-scores.
(deltaPercent) => {
const max = Math.floor(pb.scoreData.score / (pb.scoreData.percent / 100));
return Math.round((deltaPercent / 100) * max).toFixed(0);
},
),
};
const SDVXLIKE_GOAL_PG_FMT: GPTGoalProgressFormatters<"sdvx" | "usc-controller" | "usc-keyboard"> =
{
score: (pb) => FmtNum(pb.scoreData.score),
lamp: (pb) => pb.scoreData.lamp,
grade: (pb, gradeIndex) =>
GradeGoalFormatter(
SDVXLIKE_GBOUNDARIES,
pb.scoreData.grade,
pb.scoreData.score,
SDVXLIKE_GBOUNDARIES[gradeIndex]!.name,
),
};
const GITADORA_GOAL_PG_FMT: GPTGoalProgressFormatters<"gitadora-dora" | "gitadora-gita"> = {
lamp: (pb) => pb.scoreData.lamp,
percent: (pb) => `${pb.scoreData.percent.toFixed(2)}%`,
grade: (pb, gradeIndex) =>
GradeGoalFormatter(
GITADORA_GBOUNDARIES,
pb.scoreData.grade,
pb.scoreData.percent,
GITADORA_GBOUNDARIES[gradeIndex]!.name,
(v) => `${v.toFixed(2)}%`,
),
};
export const GAME_GOAL_PROGRESS_FORMATTERS: {
[TGame in V3Game]: GPTGoalProgressFormatters<TGame>;
} = {
"iidx-sp": IIDXLIKE_GOAL_PG_FMT,
"iidx-dp": IIDXLIKE_GOAL_PG_FMT,
"bms-7k": IIDXLIKE_GOAL_PG_FMT,
"bms-14k": IIDXLIKE_GOAL_PG_FMT,
"pms-controller": IIDXLIKE_GOAL_PG_FMT,
"pms-keyboard": IIDXLIKE_GOAL_PG_FMT,
sdvx: SDVXLIKE_GOAL_PG_FMT,
"usc-controller": SDVXLIKE_GOAL_PG_FMT,
"usc-keyboard": SDVXLIKE_GOAL_PG_FMT,
"gitadora-dora": GITADORA_GOAL_PG_FMT,
"gitadora-gita": GITADORA_GOAL_PG_FMT,
arcaea: {
score: (pb) => FmtNum(pb.scoreData.score),
lamp: (pb) => pb.scoreData.lamp,
grade: (pb, gradeIndex) =>
GradeGoalFormatter(
ARCAEA_GBOUNDARIES,
pb.scoreData.grade,
pb.scoreData.score,
ARCAEA_GBOUNDARIES[gradeIndex]!.name,
),
},
chunithm: {
grade: (pb, gradeIndex) =>
GradeGoalFormatter(
CHUNITHM_GBOUNDARIES,
pb.scoreData.grade,
pb.scoreData.score,
CHUNITHM_GBOUNDARIES[gradeIndex]!.name,
),
noteLamp: (pb) => pb.scoreData.noteLamp,
clearLamp: (pb) => pb.scoreData.clearLamp,
score: (pb) => FmtNum(pb.scoreData.score),
},
"ddr-sp": {
score: (pb) => FmtNum(pb.scoreData.score),
lamp: (pb) => pb.scoreData.lamp,
grade: (pb, gradeIndex) =>
GradeGoalFormatter(
DDR_GBOUNDARIES,
pb.scoreData.grade,
pb.scoreData.score,
DDR_GBOUNDARIES[gradeIndex]!.name,
(delta) => FmtNum(delta),
),
},
"ddr-dp": {
score: (pb) => FmtNum(pb.scoreData.score),
lamp: (pb) => pb.scoreData.lamp,
grade: (pb, gradeIndex) =>
GradeGoalFormatter(
DDR_GBOUNDARIES,
pb.scoreData.grade,
pb.scoreData.score,
DDR_GBOUNDARIES[gradeIndex]!.name,
(delta) => FmtNum(delta),
),
},
"itg-stamina": {
lamp: (pb) => {
if (pb.scoreData.lamp === "FAILED") {
return `Died ${pb.scoreData.survivedPercent.toFixed(2)}% in`;
}
return pb.scoreData.lamp;
},
scorePercent: (pb) => `${pb.scoreData.scorePercent.toFixed(2)}%`,
survivedPercent: (pb) => `${pb.scoreData.survivedPercent.toFixed(2)}%`,
finalPercent: (pb) => {
if (pb.scoreData.finalPercent < 100) {
return `Died ${pb.scoreData.survivedPercent.toFixed(2)}% in`;
}
return `${pb.scoreData.lamp} with ${pb.scoreData.scorePercent.toFixed(2)}%`;
},
grade: (pb, gradeIndex) =>
GradeGoalFormatter(
ITG_GBOUNDARIES,
pb.scoreData.grade,
pb.scoreData.scorePercent,
ITG_GBOUNDARIES[gradeIndex]!.name,
(v) => `${v.toFixed(2)}%`,
),
},
jubeat: {
score: (pb) => FmtNum(pb.scoreData.score),
musicRate: (pb) => `${pb.scoreData.musicRate.toFixed(1)}%`,
lamp: (pb) => pb.scoreData.lamp,
grade: (pb, gradeIndex) =>
GradeGoalFormatter(
JUBEAT_GBOUNDARIES,
pb.scoreData.grade,
pb.scoreData.score,
JUBEAT_GBOUNDARIES[gradeIndex]!.name,
),
},
maimai: {
percent: (pb) => `${pb.scoreData.percent.toFixed(2)}%`,
lamp: (pb) => pb.scoreData.lamp,
grade: (pb, gradeIndex) => {
if (pb.scoreData.grade === "SSS+") {
return "SSS+";
}
const goalGrade = MAIMAI_GBOUNDARIES[gradeIndex]!.name;
// Grade SSS+ is chart-dependent; we can't get exact percent delta.
if (goalGrade === "SSS+" && pb.scoreData.grade === "SSS") {
const boundary =
MAIMAI_GBOUNDARIES.find((c) => c.name === "SSS")?.lowerBound ?? 100;
const delta = pb.scoreData.percent - boundary;
return `SSS+${delta.toFixed(2)}%`;
}
return GradeGoalFormatter(
MAIMAI_GBOUNDARIES,
pb.scoreData.grade,
pb.scoreData.percent,
goalGrade,
(v) => `${v.toFixed(2)}%`,
);
},
},
maimaidx: {
percent: (pb) => `${pb.scoreData.percent.toFixed(4)}%`,
lamp: (pb) => pb.scoreData.lamp,
grade: (pb, gradeIndex) =>
GradeGoalFormatter(
MAIMAIDX_GBOUNDARIES,
pb.scoreData.grade,
pb.scoreData.percent,
MAIMAIDX_GBOUNDARIES[gradeIndex]!.name,
(v) => `${v.toFixed(4)}%`,
),
},
museca: {
score: (pb) => FmtNum(pb.scoreData.score),
lamp: (pb) => pb.scoreData.lamp,
grade: (pb, gradeIndex) =>
GradeGoalFormatter(
MUSECA_GBOUNDARIES,
pb.scoreData.grade,
pb.scoreData.score,
MUSECA_GBOUNDARIES[gradeIndex]!.name,
),
},
ongeki: {
grade: (pb, gradeIndex) =>
GradeGoalFormatter(
ONGEKI_GBOUNDARIES,
pb.scoreData.grade,
pb.scoreData.score,
ONGEKI_GBOUNDARIES[gradeIndex]?.name ?? "D",
),
noteLamp: (pb) => pb.scoreData.noteLamp,
bellLamp: (pb) => pb.scoreData.bellLamp,
score: (pb) => FmtNum(pb.scoreData.score),
platinumScore: (pb) => FmtNum(pb.scoreData.platinumScore),
platinumStars: (pb) => FmtStars(pb.scoreData.platinumStars, false),
},
popn: {
score: (pb) => FmtNum(pb.scoreData.score),
clearMedal: (pb) => pb.scoreData.clearMedal,
lamp: (pb) => pb.scoreData.lamp,
grade: (pb, gradeIndex) =>
GradeGoalFormatter(
POPN_GBOUNDARIES,
pb.scoreData.grade,
pb.scoreData.score,
POPN_GBOUNDARIES[gradeIndex]!.name,
),
},
wacca: {
score: (pb) => FmtNum(pb.scoreData.score),
lamp: (pb) => pb.scoreData.lamp,
grade: (pb, gradeIndex) =>
GradeGoalFormatter(
WACCA_GBOUNDARIES,
pb.scoreData.grade,
pb.scoreData.score,
WACCA_GBOUNDARIES[gradeIndex]!.name,
),
},
};
+194
View File
@@ -0,0 +1,194 @@
/**
* Pure, dependency-free helpers for formatting goal titles.
*
* These are shared between tachi-server (which resolves chart/folder names from
* the database) and tachi-seeds-scripts (which resolves them from in-memory seed
* maps). Neither consumer needs to re-implement the criteria-formatting or
* title-assembly logic.
*/
import type { GradeBoundary } from "../constants/grade-boundaries";
import type { GoalDocument, V3Game } from "../types";
import { GetGameConfig, GetScoreMetricConf } from "../config/config";
import { FmtNumCompact, GetGradeDeltas, staticAssertUnreachable } from "../utils/util";
// ─── Internal helpers (also exported for use by consumers) ────────────────────
export function OnlyFloatToDP(num: number, points = 2) {
if (Number.isInteger(num)) {
return num.toFixed(0);
}
return num.toFixed(points);
}
export function HumanisedJoinArray(arr: Array<string>, lastJoiner = "or") {
if (arr.length === 1) {
return arr[0]!;
}
return `${arr.slice(0, arr.length - 1).join(", ")} ${lastJoiner} ${arr[arr.length - 1]!}`;
}
// ─── Shared formatter helpers (exported for use in game configs) ──────────────
export function GoalFmtScore(val: number) {
return `Get a score of ${val.toLocaleString("en-GB")} on`;
}
export function GoalFmtPercent(val: number, dp = 2) {
return `Get ${val.toFixed(dp)}% on`;
}
export function GoalOutOfFmtScore(val: number) {
return val.toLocaleString("en-GB");
}
export function GoalOutOfFmtPercent(val: number, dp = 2) {
return `${val.toFixed(dp)}%`;
}
/**
* Given some grade boundaries and some values, format a grade delta for a goal.
*
* I.e. if the goal is to S a chart (needing 900k) and the user has 840k, return
* S-fmtNum(60_000).
*/
export function GradeGoalFormatter<G extends string>(
gradeBoundaries: Array<GradeBoundary<G>>,
scoreGrade: G,
scoreValue: number,
goalGrade: G,
formatNumFn = FmtNumCompact,
) {
const { closer, lower, upper } = GetGradeDeltas(
gradeBoundaries,
scoreGrade,
scoreValue,
formatNumFn,
);
// if upper doesn't exist, we have to return lower (this is a MAX) or something.
if (!upper) {
return lower;
}
// if the upper bound is relevant to the grade we're looking for
// i.e. the goal is to AAA a chart and the user has AA+20/AAA-100
// prefer AAA-100 instead of AA+20.
if (new RegExp(`^\\(?${goalGrade}\\)?-`, "u").exec(upper)) {
return upper;
}
// otherwise, return whichever is closer.
return closer === "lower" ? lower : upper;
}
// ─── Core formatting functions ────────────────────────────────────────────────
/**
* Formats the criteria portion of a goal title (the "Get a score of X on" part).
*
* For DECIMAL/INTEGER metrics, the `goalTitleFormatter` from the metric's game
* config is used. For ENUM metrics the raw enum string value is returned.
*/
export function FormatGoalCriteria(criteria: GoalDocument["criteria"], game: V3Game): string {
const gameConfig = GetGameConfig(game);
const conf = GetScoreMetricConf(gameConfig, criteria.key);
if (!conf) {
throw new Error(`Invalid goal criteria with key ${criteria.key}. No config exists?`);
}
if (conf.type === "ENUM") {
const v = conf.values[criteria.value];
if (v === undefined) {
throw new Error(`Invalid criteria value '${criteria.value}'.`);
}
return conf.goalTitleFormatter ? conf.goalTitleFormatter(v) : v;
}
if (conf.type === "DECIMAL" || conf.type === "INTEGER") {
return conf.goalTitleFormatter(criteria.value);
}
throw new Error(`Cannot set a goal for ${criteria.key} as it is of type ${conf.type}.`);
}
/**
* Assembles the final goal title from the already-formatted criteria string and
* dataset string (chart name / folder name / comma-joined chart list).
*
* This is pure string logic — callers are responsible for resolving chart IDs and
* folder IDs to human-readable strings before calling this.
*/
export function AssembleGoalTitle(
formattedCriteria: string,
formattedDataset: string,
criteria: GoalDocument["criteria"],
charts: GoalDocument["charts"],
): string {
switch (criteria.mode) {
case "single":
switch (charts.type) {
case "single":
return `${formattedCriteria} ${formattedDataset}`;
case "multi": {
if (charts.data.length === 2) {
return `${formattedCriteria} either ${formattedDataset}`;
}
return `${formattedCriteria} any one of ${formattedDataset}`;
}
case "folder":
return `${formattedCriteria} any chart in ${formattedDataset}`;
default:
staticAssertUnreachable(charts);
}
// eslint-disable-next-line no-fallthrough
case "absolute":
switch (charts.type) {
case "multi": {
if (criteria.countNum === charts.data.length) {
return `${formattedCriteria} ${formattedDataset}`;
}
return `${formattedCriteria} any ${criteria.countNum} of ${formattedDataset}`;
}
case "folder":
return `${formattedCriteria} ${criteria.countNum} charts in ${formattedDataset}`;
case "single":
throw new Error(
`Invalid goal — absolute mode cannot be paired with charts.type of 'single'.`,
);
default:
staticAssertUnreachable(charts);
}
// eslint-disable-next-line no-fallthrough
case "proportion": {
const propFormat = OnlyFloatToDP(criteria.countNum * 100);
switch (charts.type) {
case "multi":
return `${formattedCriteria} ${propFormat}% of ${formattedDataset}`;
case "folder":
return `${formattedCriteria} ${propFormat}% of the charts in ${formattedDataset}`;
case "single":
throw new Error(
`Invalid goal — proportion mode cannot be paired with charts.type of 'single'.`,
);
}
}
}
throw new Error(
`Unable to format goal title for mode ${(criteria as GoalDocument["criteria"]).mode}.`,
);
}
+21
View File
@@ -18,6 +18,8 @@ import type { ConfDerivedMetrics, ConfOptionalMetrics, ConfProvidedMetrics } fro
interface ConfDecimalScoreMetricNormal {
type: "DECIMAL";
formatter: (v: number) => string;
goalTitleFormatter: (value: number) => string;
goalOutOfFormatter: (value: number) => string;
validate: (v: number) => string | true;
@@ -28,6 +30,8 @@ interface ConfDecimalScoreMetricNormal {
interface ConfIntegerScoreMetricNormal {
type: "INTEGER";
formatter: (v: number) => string;
goalTitleFormatter: (value: number) => string;
goalOutOfFormatter: (value: number) => string;
validate: (v: number) => string | true;
@@ -38,6 +42,8 @@ interface ConfIntegerScoreMetricNormal {
interface ConfDecimalScoreMetricChartDependent {
type: "DECIMAL";
formatter: (v: number) => string;
goalTitleFormatter: (value: number) => string;
goalOutOfFormatter: (value: number) => string;
/**
* Is the maximum/minimum value of this metric chart dependent?
@@ -51,6 +57,8 @@ interface ConfDecimalScoreMetricChartDependent {
interface ConfIntegerScoreMetricChartDependent {
type: "INTEGER";
formatter: (v: number) => string;
goalTitleFormatter: (value: number) => string;
goalOutOfFormatter: (value: number) => string;
/**
* Is the maximum/minimum value of this metric chart dependent?
@@ -64,6 +72,8 @@ interface ConfIntegerScoreMetricChartDependent {
interface ConfDecimalScoreMetricChartDependentWithExemption {
type: "DECIMAL";
formatter: (v: number) => string;
goalTitleFormatter: (value: number) => string;
goalOutOfFormatter: (value: number) => string;
validate: (v: number) => string | true;
/**
@@ -81,6 +91,8 @@ interface ConfDecimalScoreMetricChartDependentWithExemption {
interface ConfIntegerScoreMetricChartDependentWithExemption {
type: "INTEGER";
formatter: (v: number) => string;
goalTitleFormatter: (value: number) => string;
goalOutOfFormatter: (value: number) => string;
validate: (v: number) => string | true;
/**
@@ -120,6 +132,15 @@ export interface ConfEnumScoreMetric<V extends string> {
* "You got 5 new fails/D ranks today!" etc.
*/
minimumRelevantValue: V;
/**
* Optional custom formatter for this enum metric's value when used in goal titles.
*
* When absent, the raw enum string value is used directly.
*
* @example: ongeki's platinumStars "3-star" → "★★★☆☆"
*/
goalTitleFormatter?: (value: V) => string;
}
/**
@@ -1,55 +1,17 @@
import {
ALL_GAMES,
allSupportedGameGroups,
AssembleGoalTitle,
type ChartDocument,
FormatChart,
GetGameConfig,
GetScoreMetricConf,
FormatGoalCriteria,
type GoalDocument,
HumanisedJoinArray,
type V3Game,
} from "tachi-common";
import { ReadCollection } from "../../util";
// temporary bullshit copypasted from tachi-server
// todo: move to tachi common
function humanisedJoinArray(arr: Array<string>, lastJoiner = "or") {
if (arr.length === 1) {
return arr[0]!;
}
return `${arr.slice(0, arr.length - 1).join(", ")} ${lastJoiner} ${arr[arr.length - 1]!}`;
}
function onlyFloatToDP(num: number, points = 2) {
if (Number.isInteger(num)) {
return num.toFixed(0);
}
return num.toFixed(points);
}
function goalFmtPercent(val: number, dp = 2) {
return `Get ${val.toFixed(dp)}% on`;
}
function goalFmtScore(val: number) {
return `Get a score of ${val.toLocaleString("en-GB")} on`;
}
const GOAL_CRITERIA_FORMATTERS: Partial<
Record<V3Game, Partial<Record<string, (value: number) => string>>>
> = {
"iidx-sp": {
percent: goalFmtPercent,
score: (v) => `Get a score of ${v} on`,
},
sdvx: {
score: goalFmtScore,
},
};
export interface GoalTitleContext {
chartsById: Map<string, ChartDocument>;
foldersById: Map<string, { title: string }>;
@@ -92,38 +54,6 @@ export function buildGoalTitleContext(): GoalTitleContext {
return { chartsById, foldersById };
}
function formatCriteria(criteria: GoalDocument["criteria"], game: V3Game) {
const gameConfig = GetGameConfig(game);
const conf = GetScoreMetricConf(gameConfig, criteria.key);
if (!conf) {
throw new Error(`Invalid goal criteria with key ${criteria.key}. No config exists?`);
}
if (conf.type === "ENUM") {
const fmt = GOAL_CRITERIA_FORMATTERS[game]?.[criteria.key];
const v = conf.values[criteria.value];
if (v === undefined) {
throw new Error(`Invalid criteria value '${criteria.value}'.`);
}
return fmt ? fmt(criteria.value) : v;
}
if (conf.type === "DECIMAL" || conf.type === "INTEGER") {
const fmt = GOAL_CRITERIA_FORMATTERS[game]?.[criteria.key];
if (!fmt) {
throw new Error(`No formatter defined for ${criteria.key}, yet one must exist?`);
}
return fmt(criteria.value);
}
throw new Error(`Cannot set a goal for ${criteria.key} as it is of type ${conf.type}.`);
}
function humaniseChartID(chartID: string, ctx: GoalTitleContext) {
const chart = ctx.chartsById.get(chartID);
@@ -146,11 +76,12 @@ function formatCharts(
const formattedTitles = charts.data.map((chartID) => humaniseChartID(chartID, ctx));
if (criteria.mode === "absolute" && criteria.countNum === charts.data.length) {
return humanisedJoinArray(formattedTitles, "and");
return HumanisedJoinArray(formattedTitles, "and");
}
return humanisedJoinArray(formattedTitles);
return HumanisedJoinArray(formattedTitles);
}
case "folder": {
const folder = ctx.foldersById.get(charts.data);
@@ -160,6 +91,7 @@ function formatCharts(
return `the ${folder.title} folder`;
}
default:
throw new Error(
`Invalid goal charts.type -- got ${(charts as GoalDocument["charts"]).type}, which we don't support?`,
@@ -176,59 +108,8 @@ export function createGoalTitleFromSeeds(
game: V3Game,
ctx: GoalTitleContext,
) {
const formattedCriteria = formatCriteria(criteria, game);
const formattedCriteria = FormatGoalCriteria(criteria, game);
const datasetName = formatCharts(charts, criteria, ctx);
switch (criteria.mode) {
case "single":
switch (charts.type) {
case "single":
return `${formattedCriteria} ${datasetName}`;
case "multi": {
if (charts.data.length === 2) {
return `${formattedCriteria} either ${datasetName}`;
}
return `${formattedCriteria} any one of ${datasetName}`;
}
case "folder":
return `${formattedCriteria} any chart in ${datasetName}`;
}
break;
case "absolute":
switch (charts.type) {
case "multi": {
if (criteria.countNum === charts.data.length) {
return `${formattedCriteria} ${datasetName}`;
}
return `${formattedCriteria} any ${criteria.countNum} of ${datasetName}`;
}
case "folder":
return `${formattedCriteria} ${criteria.countNum} charts in ${datasetName}`;
case "single":
throw new Error(
`Invalid goal -- absolute mode cannot be paired with a charts.type of 'single'.`,
);
}
break;
case "proportion": {
const propFormat = onlyFloatToDP(criteria.countNum * 100);
switch (charts.type) {
case "multi":
return `${formattedCriteria} ${propFormat}% of ${datasetName}`;
case "folder":
return `${formattedCriteria} ${propFormat}% of the charts in ${datasetName}`;
case "single":
throw new Error(
`Invalid goal -- proportion mode cannot be paired with a charts.type of 'single'.`,
);
}
}
}
throw new Error(`Unable to format goal title for mode ${criteria.mode}.`);
return AssembleGoalTitle(formattedCriteria, datasetName, criteria, charts);
}
@@ -2,8 +2,6 @@ import type {
ChartSpecificMetricValidator,
GPTChartSpecificMetricValidators,
GPTClassDerivers,
GPTGoalFormatters,
GPTGoalProgressFormatters,
GPTProfileCalcs,
GPTScoreCalcs,
GPTScoreDeriver,
@@ -16,15 +14,11 @@ import type {
import { CreatePBMergeFor } from "#game-implementations/utils/pb-merge";
import { ProfileAvgBestN, ProfileSumBestN } from "#game-implementations/utils/profile-calc";
import { SessionAvgBest10For } from "#game-implementations/utils/session-calc";
import { IsNullish, NumToDP } from "#utils/misc";
import { IsNullish } from "#utils/misc";
import { Volforce } from "rg-stats";
import {
type ChartDocument,
FmtNum,
FmtNumCompact,
GetGrade,
GetGradeDeltas,
type GradeBoundary,
IIDXLikeGetGrade,
type integer,
type ScoreDocument,
@@ -203,26 +197,6 @@ export const SDVXLIKE_CLASS_DERIVERS: GPTClassDerivers<SDVXLikes> = (ratings) =>
vfClass: IsNullish(ratings.VF7) ? null : VF7ToClass(ratings.VF7),
});
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,
grade: (pb, goalValue) =>
GradeGoalFormatter(
SDVXLIKE_GBOUNDARIES,
pb.scoreData.grade,
pb.scoreData.score,
SDVXLIKE_GBOUNDARIES[goalValue]!.name,
),
};
export const SDVXLIKE_PB_MERGERS: Array<PBMergeFunction<SDVXLikes>> = [
CreatePBMergeFor<SDVXLikes>(
"largest",
@@ -283,59 +257,6 @@ export const SGL_SCORE_CALCS: GPTScoreCalcs<BmsPmsGames> = (scoreData, _derivedD
}
};
export function GoalFmtPercent(val: number, dp = 2) {
return `Get ${NumToDP(val, dp)}% on`;
}
export function GoalFmtScore(val: number) {
return `Get a score of ${val.toLocaleString("en-GB")} on`;
}
export function GoalOutOfFmtPercent(val: number, dp = 2) {
return `${NumToDP(val, dp)}%`;
}
export function GoalOutOfFmtScore(val: number) {
return val.toLocaleString("en-GB");
}
/**
* Given some grade boundaries and some values, format a grade delta for a goal.
*
* I.e. if the goal is to S a chart (needing 900k) and the user has 840k, return
* S-fmtNum(60_000).
*/
export function GradeGoalFormatter<G extends string>(
gradeBoundaries: Array<GradeBoundary<G>>,
scoreGrade: G,
scoreValue: number,
goalGrade: G,
formatNumFn = FmtNumCompact,
) {
const { closer, lower, upper } = GetGradeDeltas(
gradeBoundaries,
scoreGrade,
scoreValue,
formatNumFn,
);
// if upper doesn't exist, we have to return lower (this is a MAX)
// or something.
if (!upper) {
return lower;
}
// if the upper bound is relevant to the grade we're looking for
// i.e. the goal is to AAA a chart and the user has AA+20/AAA-100
// prefer AAA-100 instead of AA+20.
if (new RegExp(`^\\(?${goalGrade}\\)?-`, "u").exec(upper)) {
return upper;
}
// otherwise, return whichever is closer.
return closer === "lower" ? lower : upper;
}
/**
* Run all of the provided validators on the given score.
*
@@ -18,6 +18,10 @@ import {
ARCAEA_GRADES,
ARCAEA_LAMPS,
type ChartDocument,
FormatGoalCriteria,
GAME_GOAL_PROGRESS_FORMATTERS,
GetGameConfig,
GetScoreMetricConf,
type MongoProvidedMetrics,
type ScoreData,
type ScoreDocument,
@@ -158,24 +162,22 @@ describe("ARCAEA_IMPL", () => {
const mockPB = mkMockPB("arcaea", chart, scoreData);
it("formats score criteria", () => {
expect(ARCAEA_IMPL.goalCriteriaFormatters.score(10_002_221)).toBe(
"Get a score of 10,002,221 on",
);
expect(
FormatGoalCriteria({ key: "score", value: 10_002_221, mode: "single" }, "arcaea"),
).toBe("Get a score of 10,002,221 on");
});
it("formats progress for grade, score, and lamp", () => {
const fmt = GAME_GOAL_PROGRESS_FORMATTERS.arcaea;
const f = (
k: keyof typeof ARCAEA_IMPL.goalProgressFormatters,
k: keyof typeof fmt,
modifant: Partial<ScoreData<"arcaea">>,
goalValue: number,
expected: string,
) =>
expect(
ARCAEA_IMPL.goalProgressFormatters[k](
dmf(mockPB, { scoreData: modifant }) as never,
goalValue,
),
).toBe(expected);
expect(fmt[k](dmf(mockPB, { scoreData: modifant }) as never, goalValue)).toBe(
expected,
);
f("grade", { grade: "EX", score: 9_897_342 }, ARCAEA_GRADES.EX_PLUS, "(EX+)-2.7K");
f("score", { score: 9_982_123 }, 10_000_000, "9,982,123");
@@ -183,8 +185,11 @@ describe("ARCAEA_IMPL", () => {
});
it("formats out-of score", () => {
expect(ARCAEA_IMPL.goalOutOfFormatters.score(10_001_003)).toBe("10,001,003");
expect(ARCAEA_IMPL.goalOutOfFormatters.score(9_983_132)).toBe("9,983,132");
const scoreMetric = GetScoreMetricConf(GetGameConfig("arcaea"), "score") as {
goalOutOfFormatter: (v: number) => string;
};
expect(scoreMetric.goalOutOfFormatter(10_001_003)).toBe("10,001,003");
expect(scoreMetric.goalOutOfFormatter(9_983_132)).toBe("9,983,132");
});
});
@@ -4,9 +4,7 @@ import { ProfileAvgBestN } from "#game-implementations/utils/profile-calc";
import { SessionAvgBest10For } from "#game-implementations/utils/session-calc";
import { IsNullish } from "#utils/misc";
import { Potential } from "rg-stats";
import { ARCAEA_GBOUNDARIES, FmtNum, GetGrade } from "tachi-common";
import { GoalFmtScore, GoalOutOfFmtScore, GradeGoalFormatter } from "./_common";
import { ARCAEA_GBOUNDARIES, GetGrade } from "tachi-common";
export const ARCAEA_IMPL: GameImplementation<"arcaea"> = {
chartSpecificValidators: {
@@ -67,23 +65,6 @@ export const ARCAEA_IMPL: GameImplementation<"arcaea"> = {
return { badge: "BLUE" };
},
goalCriteriaFormatters: {
score: GoalFmtScore,
},
goalProgressFormatters: {
score: (pb) => FmtNum(pb.scoreData.score),
lamp: (pb) => pb.scoreData.lamp,
grade: (pb, gradeIndex) =>
GradeGoalFormatter(
ARCAEA_GBOUNDARIES,
pb.scoreData.grade,
pb.scoreData.score,
ARCAEA_GBOUNDARIES[gradeIndex]!.name,
),
},
goalOutOfFormatters: {
score: GoalOutOfFmtScore,
},
pbMergeFunctions: [
CreatePBMergeFor(
"largest",
@@ -16,6 +16,10 @@ import {
type BMSGames,
type ChartDocument,
type ChartDocumentData,
FormatGoalCriteria,
GAME_GOAL_PROGRESS_FORMATTERS,
GetGameConfig,
GetScoreMetricConf,
IIDX_GRADES,
IIDX_LAMPS,
type MongoProvidedMetrics,
@@ -210,21 +214,30 @@ describe.each([
describe("goal formatters", () => {
it("criteria", () => {
expect(impl.goalCriteriaFormatters.percent(94.42123)).toBe("Get 94.42% on");
expect(impl.goalCriteriaFormatters.percent(94.426)).toBe("Get 94.43% on");
expect(impl.goalCriteriaFormatters.score(3570)).toBe("Get a score of 3570 on");
expect(impl.goalCriteriaFormatters.score(0)).toBe("Get a score of 0 on");
expect(
FormatGoalCriteria({ key: "percent", value: 94.42123, mode: "single" }, game),
).toBe("Get 94.42% on");
expect(
FormatGoalCriteria({ key: "percent", value: 94.426, mode: "single" }, game),
).toBe("Get 94.43% on");
expect(FormatGoalCriteria({ key: "score", value: 3570, mode: "single" }, game)).toBe(
"Get a score of 3570 on",
);
expect(FormatGoalCriteria({ key: "score", value: 0, mode: "single" }, game)).toBe(
"Get a score of 0 on",
);
});
it("progress", () => {
const fmt = GAME_GOAL_PROGRESS_FORMATTERS[game];
const f = (
k: keyof typeof impl.goalProgressFormatters,
k: keyof typeof fmt,
modifant: Partial<ScoreData<G>>,
goalValue: unknown,
expected: string,
) => {
expect(
impl.goalProgressFormatters[k](
fmt[k](
dmf(mockPB, {
scoreData: modifant,
}) as never,
@@ -274,8 +287,12 @@ describe.each([
});
it("outOf", () => {
expect(impl.goalOutOfFormatters.percent(94.42123)).toBe("94.42%");
expect(impl.goalOutOfFormatters.score(3570)).toBe("3570");
const gConf = GetGameConfig(game);
const toFmt = (m: string) =>
(GetScoreMetricConf(gConf, m) as { goalOutOfFormatter: (v: number) => string })
.goalOutOfFormatter;
expect(toFmt("percent")(94.42123)).toBe("94.42%");
expect(toFmt("score")(3570)).toBe("3570");
});
});
@@ -1,12 +1,9 @@
import type { GameImplementation, PBMergeFunction } from "#game-implementations/types";
import { CreatePBMergeFor } from "#game-implementations/utils/pb-merge";
import { type BMSGames, IIDXLIKE_GBOUNDARIES } from "tachi-common";
import { type BMSGames } from "tachi-common";
import {
GoalFmtPercent,
GoalOutOfFmtPercent,
GradeGoalFormatter,
IIDXLIKE_PB_RANKING_VALUES,
IIDXLIKE_SCORE_DERIVER,
IIDXLIKE_SCORE_VALIDATORS,
@@ -44,43 +41,6 @@ const BMS_IMPL: GameImplementation<BMSGames> = {
classDerivers: (_ratings) => ({}),
chartSpecificValidators: IIDXLIKE_VALIDATORS,
pbRankingValues: IIDXLIKE_PB_RANKING_VALUES,
goalCriteriaFormatters: {
percent: GoalFmtPercent,
score: (v) => `Get a score of ${v} on`,
},
goalProgressFormatters: {
percent: (pb) => `${pb.scoreData.percent.toFixed(2)}%`,
// 4519 -> "4519". Don't add commas or anything.
score: (pb) => pb.scoreData.score.toString(),
lamp: (pb) => {
// if bp exists
if (typeof pb.scoreData.optional.bp === "number") {
return `${pb.scoreData.lamp} (BP: ${pb.scoreData.optional.bp})`;
}
return pb.scoreData.lamp;
},
grade: (pb, goalValue) =>
GradeGoalFormatter(
IIDXLIKE_GBOUNDARIES,
pb.scoreData.grade,
pb.scoreData.percent,
IIDXLIKE_GBOUNDARIES[goalValue]!.name,
// use notecount to turn the percent deltas into whole ex-scores.
(deltaPercent) => {
const max = Math.floor(pb.scoreData.score / (pb.scoreData.percent / 100));
return ((deltaPercent / 100) * max).toFixed(0);
},
),
},
goalOutOfFormatters: {
percent: GoalOutOfFmtPercent,
// don't insert commas or anything.
score: (m) => m.toString(),
},
pbMergeFunctions: BMS_PMS_MERGERS,
defaultMergeRefName: "Best Score",
scoreValidators: IIDXLIKE_SCORE_VALIDATORS,
@@ -11,6 +11,10 @@ import {
CHUNITHM_CLEAR_LAMPS,
CHUNITHM_GRADES,
CHUNITHM_NOTE_LAMPS,
FormatGoalCriteria,
GAME_GOAL_PROGRESS_FORMATTERS,
GetGameConfig,
GetScoreMetricConf,
type MongoProvidedMetrics,
type ScoreData,
} from "tachi-common";
@@ -250,24 +254,22 @@ describe("CHUNITHM_IMPL", () => {
const mockPB = mkMockPB("chunithm", chart, scoreData);
it("formats criteria", () => {
expect(CHUNITHM_IMPL.goalCriteriaFormatters.score(1_008_182)).toBe(
"Get a score of 1,008,182 on",
);
expect(
FormatGoalCriteria({ key: "score", value: 1_008_182, mode: "single" }, "chunithm"),
).toBe("Get a score of 1,008,182 on");
});
it("formats progress", () => {
const fmt = GAME_GOAL_PROGRESS_FORMATTERS.chunithm;
const f = (
k: keyof typeof CHUNITHM_IMPL.goalProgressFormatters,
k: keyof typeof fmt,
modifant: Partial<ScoreData<"chunithm">>,
goalValue: number,
expected: string,
) =>
expect(
CHUNITHM_IMPL.goalProgressFormatters[k](
dmf(mockPB, { scoreData: modifant }) as never,
goalValue,
),
).toBe(expected);
expect(fmt[k](dmf(mockPB, { scoreData: modifant }) as never, goalValue)).toBe(
expected,
);
f("grade", { grade: "S+", score: 997_342 }, CHUNITHM_GRADES.SS, "SS-2.7K");
f("score", { score: 982_123 }, 1_000_000, "982,123");
@@ -276,8 +278,12 @@ describe("CHUNITHM_IMPL", () => {
});
it("formats out-of", () => {
expect(CHUNITHM_IMPL.goalOutOfFormatters.score(1_001_003)).toBe("1,001,003");
expect(CHUNITHM_IMPL.goalOutOfFormatters.score(983_132)).toBe("983,132");
const gConf = GetGameConfig("chunithm");
const scoreMetric = GetScoreMetricConf(gConf, "score") as {
goalOutOfFormatter: (v: number) => string;
};
expect(scoreMetric.goalOutOfFormatter(1_001_003)).toBe("1,001,003");
expect(scoreMetric.goalOutOfFormatter(983_132)).toBe("983,132");
});
});
@@ -5,9 +5,7 @@ import { ProfileAvgBestN } from "#game-implementations/utils/profile-calc";
import { SessionAvgBest10For } from "#game-implementations/utils/session-calc";
import { IsNullish } from "#utils/misc";
import { CHUNITHMRating } from "rg-stats";
import { CHUNITHM_GBOUNDARIES, FmtNum, GetGrade } from "tachi-common";
import { GoalFmtScore, GoalOutOfFmtScore, GradeGoalFormatter } from "./_common";
import { CHUNITHM_GBOUNDARIES, GetGrade } from "tachi-common";
export const CHUNITHM_IMPL: GameImplementation<"chunithm"> = {
chartSpecificValidators: {},
@@ -76,24 +74,6 @@ export const CHUNITHM_IMPL: GameImplementation<"chunithm"> = {
return { colour: "BLUE" };
},
goalCriteriaFormatters: {
score: GoalFmtScore,
},
goalProgressFormatters: {
grade: (pb, gradeIndex) =>
GradeGoalFormatter(
CHUNITHM_GBOUNDARIES,
pb.scoreData.grade,
pb.scoreData.score,
CHUNITHM_GBOUNDARIES[gradeIndex]!.name,
),
noteLamp: (pb) => pb.scoreData.noteLamp,
clearLamp: (pb) => pb.scoreData.clearLamp,
score: (pb) => FmtNum(pb.scoreData.score),
},
goalOutOfFormatters: {
score: GoalOutOfFmtScore,
},
pbMergeFunctions: [
CreatePBMergeFor(
"largest",
@@ -5,6 +5,10 @@ import {
type ChartDocumentData,
DDR_GRADES,
DDR_LAMPS,
FormatGoalCriteria,
GAME_GOAL_PROGRESS_FORMATTERS,
GetGameConfig,
GetScoreMetricConf,
type integer,
type MongoProvidedMetrics,
type ScoreData,
@@ -120,19 +124,24 @@ describe("DDR_IMPL", () => {
const impl = DDR_IMPL;
it("criteria", () => {
expect(impl.goalCriteriaFormatters.score(123456)).toBe("Get a score of 123,456 on");
expect(impl.goalCriteriaFormatters.score(0)).toBe("Get a score of 0 on");
expect(
FormatGoalCriteria({ key: "score", value: 123456, mode: "single" }, "ddr-sp"),
).toBe("Get a score of 123,456 on");
expect(FormatGoalCriteria({ key: "score", value: 0, mode: "single" }, "ddr-sp")).toBe(
"Get a score of 0 on",
);
});
it("progress", () => {
const fmt = GAME_GOAL_PROGRESS_FORMATTERS["ddr-sp"];
const f = (
k: keyof typeof impl.goalProgressFormatters,
k: keyof typeof fmt,
modifant: Partial<ScoreData<"ddr-dp" | "ddr-sp">>,
goalValue: integer,
expected: string,
) => {
expect(
impl.goalProgressFormatters[k](
fmt[k](
mkFakePBDDRSP({
scoreData: modifant,
} as never),
@@ -158,7 +167,10 @@ describe("DDR_IMPL", () => {
});
it("outOf", () => {
expect(impl.goalOutOfFormatters.score(123456)).toBe("123,456");
const scoreMetric = GetScoreMetricConf(GetGameConfig("ddr-sp"), "score") as {
goalOutOfFormatter: (v: number) => string;
};
expect(scoreMetric.goalOutOfFormatter(123456)).toBe("123,456");
});
});
});
@@ -1,7 +1,5 @@
import type {
GameImplementation,
GPTGoalFormatters,
GPTGoalProgressFormatters,
GPTProfileCalcs,
ScoreValidator,
} from "#game-implementations/types";
@@ -13,7 +11,6 @@ import { DDRFlare } from "rg-stats";
import {
type ChartDocument,
DDR_GBOUNDARIES,
FmtNum,
GetGameConfig,
GetGrade,
type ScoreDocument,
@@ -21,32 +18,9 @@ import {
import { CreatePBMergeFor } from "../utils/pb-merge";
import { SessionAvgBest10For } from "../utils/session-calc";
import { GoalFmtScore, GoalOutOfFmtScore, GradeGoalFormatter } from "./_common";
/** `ddr:SP` / `ddr:DP` as v3 games. */
type DDRGames = "ddr-dp" | "ddr-sp";
const DDR_GOAL_FMT: GPTGoalFormatters<DDRGames> = {
score: GoalFmtScore,
};
const DDR_GOAL_OO_FMT: GPTGoalFormatters<DDRGames> = {
score: GoalOutOfFmtScore,
};
const DDR_GOAL_PG_FMT: GPTGoalProgressFormatters<DDRGames> = {
score: (pb) => FmtNum(pb.scoreData.score),
lamp: (pb) => pb.scoreData.lamp,
grade: (pb, gradeIndex) =>
GradeGoalFormatter(
DDR_GBOUNDARIES,
pb.scoreData.grade,
pb.scoreData.score,
DDR_GBOUNDARIES[gradeIndex]!.name,
(delta) => FmtNum(delta),
),
};
export const DDR_SCORE_VALIDATORS: Array<ScoreValidator<DDRGames>> = [
(s: ScoreDocument<DDRGames>, chart?: ChartDocument<DDRGames>) => {
if (s.scoreData.lamp === "FAILED" || !chart || IsNullish(chart.data.stepCount)) {
@@ -313,9 +287,6 @@ export const DDR_IMPL: GameImplementation<DDRGames> = {
tb4: null,
tb5: null,
}),
goalCriteriaFormatters: DDR_GOAL_FMT,
goalProgressFormatters: DDR_GOAL_PG_FMT,
goalOutOfFormatters: DDR_GOAL_OO_FMT,
pbMergeFunctions: [
CreatePBMergeFor(
"largest",
@@ -8,6 +8,10 @@ import { seedUser } from "#test-utils/pg-fixtures";
import { TestingGitadoraChart, TestingGitadoraSong } from "#test-utils/test-data";
import { UnixMillisecondsToISO8601 } from "#utils/time";
import {
FormatGoalCriteria,
GAME_GOAL_PROGRESS_FORMATTERS,
GetGameConfig,
GetScoreMetricConf,
GITADORA_GRADES,
GITADORA_LAMPS,
type MongoProvidedMetrics,
@@ -166,23 +170,25 @@ describe.each([
const mockPB = mkMockPB(game, chart, scoreData);
it("criteria", () => {
expect(impl.goalCriteriaFormatters.percent(28.194)).toBe("Get 28.19% on");
expect(impl.goalCriteriaFormatters.percent(28.195)).toBe("Get 28.20% on");
expect(
FormatGoalCriteria({ key: "percent", value: 28.194, mode: "single" }, game),
).toBe("Get 28.19% on");
expect(
FormatGoalCriteria({ key: "percent", value: 28.195, mode: "single" }, game),
).toBe("Get 28.20% on");
});
it("progress", () => {
const fmt = GAME_GOAL_PROGRESS_FORMATTERS[game];
const f = (
k: keyof typeof impl.goalProgressFormatters,
k: keyof typeof fmt,
modifant: Partial<ScoreData<typeof game>>,
goalValue: number,
expected: string,
) =>
expect(
impl.goalProgressFormatters[k](
dmf(mockPB, { scoreData: modifant }) as never,
goalValue,
),
).toBe(expected);
expect(fmt[k](dmf(mockPB, { scoreData: modifant }) as never, goalValue)).toBe(
expected,
);
f("percent", { percent: 12.32 }, 30, "12.32%");
f("grade", { grade: "SS", percent: 98.19 }, GITADORA_GRADES.MAX, "MAX-1.81%");
@@ -190,7 +196,10 @@ describe.each([
});
it("outOf", () => {
expect(impl.goalOutOfFormatters.percent(28.194)).toBe("28.19%");
const percentMetric = GetScoreMetricConf(GetGameConfig(game), "percent") as {
goalOutOfFormatter: (v: number) => string;
};
expect(percentMetric.goalOutOfFormatter(28.194)).toBe("28.19%");
});
});
@@ -6,8 +6,6 @@ import { IsNullish } from "#utils/misc";
import { GITADORASkill } from "rg-stats";
import { GetGrade, GITADORA_GBOUNDARIES } from "tachi-common";
import { GoalFmtPercent, GoalOutOfFmtPercent, GradeGoalFormatter } from "./_common";
const GITADORA_IMPL: GameImplementation<"gitadora-dora" | "gitadora-gita"> = {
chartSpecificValidators: {},
scoreDeriver: (scoreData, _chart) => ({
@@ -73,24 +71,6 @@ const GITADORA_IMPL: GameImplementation<"gitadora-dora" | "gitadora-gita"> = {
return { colour: "WHITE" };
},
goalCriteriaFormatters: {
percent: GoalFmtPercent,
},
goalProgressFormatters: {
lamp: (pb) => pb.scoreData.lamp,
percent: (pb) => `${pb.scoreData.percent.toFixed(2)}%`,
grade: (pb, gradeIndex) =>
GradeGoalFormatter(
GITADORA_GBOUNDARIES,
pb.scoreData.grade,
pb.scoreData.percent,
GITADORA_GBOUNDARIES[gradeIndex]!.name,
(v) => `${v.toFixed(2)}%`,
),
},
goalOutOfFormatters: {
percent: GoalOutOfFmtPercent,
},
pbMergeFunctions: [
CreatePBMergeFor(
"largest",
@@ -3,6 +3,10 @@ import { dmf, mkFakePBIIDXSP } from "#test-utils/misc";
import { Testing511SPA, TestingIIDXSPScore } from "#test-utils/test-data";
import {
type ChartDocumentData,
FormatGoalCriteria,
GAME_GOAL_PROGRESS_FORMATTERS,
GetGameConfig,
GetScoreMetricConf,
IIDX_GRADES,
IIDX_LAMPS,
type integer,
@@ -329,23 +333,34 @@ describe("IIDX_IMPL (unit)", () => {
});
describe("goal formatters", () => {
describe.each([IIDX_SP_IMPL, IIDX_DP_IMPL] as const)("impl", (impl) => {
it("criteria", () => {
expect(impl.goalCriteriaFormatters.percent(94.42123)).toBe("Get 94.42% on");
expect(impl.goalCriteriaFormatters.percent(94.426)).toBe("Get 94.43% on");
expect(impl.goalCriteriaFormatters.score(1234)).toBe("Get a score of 1234 on");
expect(impl.goalCriteriaFormatters.score(0)).toBe("Get a score of 0 on");
});
it("criteria", () => {
expect(
FormatGoalCriteria({ key: "percent", value: 94.42123, mode: "single" }, "iidx-sp"),
).toBe("Get 94.42% on");
expect(
FormatGoalCriteria({ key: "percent", value: 94.426, mode: "single" }, "iidx-sp"),
).toBe("Get 94.43% on");
expect(
FormatGoalCriteria({ key: "score", value: 1234, mode: "single" }, "iidx-sp"),
).toBe("Get a score of 1234 on");
expect(FormatGoalCriteria({ key: "score", value: 0, mode: "single" }, "iidx-sp")).toBe(
"Get a score of 0 on",
);
});
describe.each([
[IIDX_SP_IMPL, "iidx-sp"],
[IIDX_DP_IMPL, "iidx-dp"],
] as const)("impl", (impl, game) => {
it("progress", () => {
const fmt = GAME_GOAL_PROGRESS_FORMATTERS[game];
const f = (
k: keyof typeof impl.goalProgressFormatters,
k: keyof typeof fmt,
modifant: Partial<ScoreData<"iidx-dp" | "iidx-sp">>,
goalValue: integer,
expected: string,
) => {
expect(
impl.goalProgressFormatters[k](
fmt[k](
mkFakePBIIDXSP({ scoreData: modifant } as never) as never,
goalValue,
),
@@ -406,8 +421,12 @@ describe("IIDX_IMPL (unit)", () => {
});
it("outOf", () => {
expect(impl.goalOutOfFormatters.percent(94.42123)).toBe("94.42%");
expect(impl.goalOutOfFormatters.score(1234)).toBe("1234");
const gConf = GetGameConfig(game);
const toFmt = (m: string) =>
(GetScoreMetricConf(gConf, m) as { goalOutOfFormatter: (v: number) => string })
.goalOutOfFormatter;
expect(toFmt("percent")(94.42123)).toBe("94.42%");
expect(toFmt("score")(1234)).toBe("1234");
});
});
});
@@ -1,7 +1,5 @@
import type {
GameImplementation,
GPTGoalFormatters,
GPTGoalProgressFormatters,
GPTProfileCalcs,
GPTSessionCalcs,
PBMergeFunction,
@@ -11,12 +9,8 @@ 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 {
GoalFmtPercent,
GoalOutOfFmtPercent,
GradeGoalFormatter,
IIDXLIKE_PB_RANKING_VALUES,
IIDXLIKE_SCORE_DERIVER,
IIDXLIKE_SCORE_VALIDATORS,
@@ -64,51 +58,6 @@ const IIDX_MERGERS: Array<PBMergeFunction<IIDXGames>> = [
}),
];
const IIDX_GOAL_FMT: GPTGoalFormatters<IIDXGames> = {
percent: GoalFmtPercent,
// don't want commas
score: (v) => `Get a score of ${v} on`,
};
const IIDX_GOAL_OO_FMT: GPTGoalFormatters<IIDXGames> = {
percent: GoalOutOfFmtPercent,
// don't insert commas or anything.
score: (m) => m.toString(),
};
const IIDX_GOAL_PG_FMT: GPTGoalProgressFormatters<IIDXGames> = {
percent: (pb) => `${pb.scoreData.percent.toFixed(2)}%`,
// 4519 -> "4519". Don't add commas or anything.
score: (pb) => pb.scoreData.score.toString(),
lamp: (pb) => {
// if bp exists
if (typeof pb.scoreData.optional.bp === "number") {
return `${pb.scoreData.lamp} (BP: ${pb.scoreData.optional.bp})`;
}
return pb.scoreData.lamp;
},
grade: (pb, gradeIndex) =>
GradeGoalFormatter(
IIDXLIKE_GBOUNDARIES,
pb.scoreData.grade,
pb.scoreData.percent,
IIDXLIKE_GBOUNDARIES[gradeIndex]!.name,
// use notecount to turn the percent deltas into whole ex-scores.
(deltaPercent) => {
const max = Math.floor(pb.scoreData.score / (pb.scoreData.percent / 100));
const v = (deltaPercent / 100) * max;
return Math.round(v).toFixed(0);
},
),
};
export const IIDX_SP_IMPL: GameImplementation<"iidx-sp"> = {
scoreDeriver: IIDXLIKE_SCORE_DERIVER,
chartSpecificValidators: IIDXLIKE_VALIDATORS,
@@ -172,9 +121,6 @@ export const IIDX_SP_IMPL: GameImplementation<"iidx-sp"> = {
sessionCalcs: IIDX_SESSION_CALCS,
profileCalcs: IIDX_PROFILE_CALCS,
classDerivers: (_ratings) => ({}),
goalCriteriaFormatters: IIDX_GOAL_FMT,
goalProgressFormatters: IIDX_GOAL_PG_FMT,
goalOutOfFormatters: IIDX_GOAL_OO_FMT,
pbMergeFunctions: IIDX_MERGERS,
defaultMergeRefName: "Best Score",
scoreValidators: IIDXLIKE_SCORE_VALIDATORS,
@@ -244,9 +190,6 @@ export const IIDX_DP_IMPL: GameImplementation<"iidx-dp"> = {
sessionCalcs: IIDX_SESSION_CALCS,
profileCalcs: IIDX_PROFILE_CALCS,
classDerivers: (_ratings) => ({}),
goalCriteriaFormatters: IIDX_GOAL_FMT,
goalProgressFormatters: IIDX_GOAL_PG_FMT,
goalOutOfFormatters: IIDX_GOAL_OO_FMT,
pbMergeFunctions: IIDX_MERGERS,
defaultMergeRefName: "Best Score",
scoreValidators: IIDXLIKE_SCORE_VALIDATORS,
@@ -3,12 +3,9 @@ import type { GameImplementation } from "#game-implementations/types";
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 { NumToDP } from "#utils/misc";
import { ITGHighestUnbroken } from "rg-stats";
import { GetGrade, ITG_GBOUNDARIES } from "tachi-common";
import { GoalFmtPercent, GradeGoalFormatter } from "./_common";
export const ITG_STAMINA_IMPL: GameImplementation<"itg-stamina"> = {
chartSpecificValidators: {},
scoreDeriver: (scoreData, _chart) => ({
@@ -58,58 +55,6 @@ export const ITG_STAMINA_IMPL: GameImplementation<"itg-stamina"> = {
return { highestBlock, fastest32 };
},
classDerivers: (_ratings) => ({}),
goalCriteriaFormatters: {
survivedPercent: (val) => `Survive ${NumToDP(val)}% through`,
// maybe this whole metric is silly. help.
finalPercent: (val) => {
if (val === 100) {
return "CLEAR";
} else if (val < 100) {
return `Survive ${NumToDP(val)}% through`;
}
return `CLEAR, and get ${NumToDP(val - 100)}% on`;
},
scorePercent: GoalFmtPercent,
},
goalProgressFormatters: {
lamp: (pb) => {
if (pb.scoreData.lamp === "FAILED") {
return `Died ${pb.scoreData.survivedPercent.toFixed(2)}% in`;
}
return pb.scoreData.lamp;
},
scorePercent: (pb) => `${pb.scoreData.scorePercent.toFixed(2)}%`,
survivedPercent: (pb) => `${pb.scoreData.survivedPercent.toFixed(2)}%`,
finalPercent: (pb) => {
if (pb.scoreData.finalPercent < 100) {
return `Died ${pb.scoreData.survivedPercent.toFixed(2)}% in`;
}
return `${pb.scoreData.lamp} with ${pb.scoreData.scorePercent.toFixed(2)}%`;
},
grade: (pb, gradeIndex) =>
GradeGoalFormatter(
ITG_GBOUNDARIES,
pb.scoreData.grade,
pb.scoreData.scorePercent,
ITG_GBOUNDARIES[gradeIndex]!.name,
(v) => `${v.toFixed(2)}%`,
),
},
goalOutOfFormatters: {
survivedPercent: (num) => `${NumToDP(num)}%`,
scorePercent: (num) => `${NumToDP(num)}%`,
finalPercent: (num) => {
if (num >= 100) {
return `CLEAR with ${NumToDP(num - 100)}%`;
}
return `${NumToDP(num)}%`;
},
},
pbMergeFunctions: [
// we'll pluck the best lamp, but this game has a pretty interesting concept
// for merging PBs. This is probably fine.
@@ -8,7 +8,10 @@ import { seedUser } from "#test-utils/pg-fixtures";
import { TestingJubeatChart, TestingJubeatSong } from "#test-utils/test-data";
import { UnixMillisecondsToISO8601 } from "#utils/time";
import {
FormatGoalCriteria,
GAME_GOAL_PROGRESS_FORMATTERS,
GetGameConfig,
GetScoreMetricConf,
type integer,
type MongoProvidedMetrics,
type ScoreData,
@@ -204,27 +207,25 @@ describe("JUBEAT_IMPL", () => {
const mockPB = mkMockPB("jubeat", chart, scoreData);
it("criteria", () => {
expect(JUBEAT_IMPL.goalCriteriaFormatters.score(1_008_182)).toBe(
"Get a score of 1,008,182 on",
);
expect(JUBEAT_IMPL.goalCriteriaFormatters.musicRate(93.1)).toBe(
"Get a music rate of 93.1% on",
);
expect(
FormatGoalCriteria({ key: "score", value: 1_008_182, mode: "single" }, "jubeat"),
).toBe("Get a score of 1,008,182 on");
expect(
FormatGoalCriteria({ key: "musicRate", value: 93.1, mode: "single" }, "jubeat"),
).toBe("Get a music rate of 93.1% on");
});
it("progress", () => {
const fmt = GAME_GOAL_PROGRESS_FORMATTERS.jubeat;
const f = (
k: keyof typeof JUBEAT_IMPL.goalProgressFormatters,
k: keyof typeof fmt,
modifant: Partial<ScoreData<"jubeat">>,
goalValue: integer,
expected: string,
) =>
expect(
JUBEAT_IMPL.goalProgressFormatters[k](
dmf(mockPB, { scoreData: modifant }) as never,
goalValue,
),
).toBe(expected);
expect(fmt[k](dmf(mockPB, { scoreData: modifant }) as never, goalValue)).toBe(
expected,
);
f("grade", { grade: "S", score: 927_342 }, GRADES.indexOf("S"), "SS-23K");
f("score", { score: 982_123 }, 1_000_000, "982,123");
@@ -233,8 +234,12 @@ describe("JUBEAT_IMPL", () => {
});
it("outOf", () => {
expect(JUBEAT_IMPL.goalOutOfFormatters.score(983_132)).toBe("983,132");
expect(JUBEAT_IMPL.goalOutOfFormatters.musicRate(99.1123)).toBe("99.1%");
const gConf = GetGameConfig("jubeat");
const toFmt = (m: string) =>
(GetScoreMetricConf(gConf, m) as { goalOutOfFormatter: (v: number) => string })
.goalOutOfFormatter;
expect(toFmt("score")(983_132)).toBe("983,132");
expect(toFmt("musicRate")(99.1123)).toBe("99.1%");
});
});
@@ -10,7 +10,6 @@ import { sql } from "kysely";
import { p } from "prudence";
import { Jubility } from "rg-stats";
import {
FmtNum,
GetGrade,
type integer,
JUBEAT_GBOUNDARIES,
@@ -19,8 +18,6 @@ import {
type Versions,
} from "tachi-common";
import { GoalFmtScore, GoalOutOfFmtScore, GradeGoalFormatter } from "./_common";
/**
* Best PB per (song, jubility difficulty bucket): BSC / ADV / EXT maps HARD * and normal
* difficulties together, then globally sorts by jubility and takes the top `limit` rows.
@@ -219,26 +216,6 @@ export const JUBEAT_IMPL: GameImplementation<"jubeat"> = {
return { colour: "BLACK" };
},
goalCriteriaFormatters: {
musicRate: (v) => `Get a music rate of ${v.toFixed(1)}% on`,
score: GoalFmtScore,
},
goalProgressFormatters: {
score: (pb) => FmtNum(pb.scoreData.score),
musicRate: (pb) => `${pb.scoreData.musicRate.toFixed(1)}%`,
lamp: (pb) => pb.scoreData.lamp,
grade: (pb, gradeIndex) =>
GradeGoalFormatter(
JUBEAT_GBOUNDARIES,
pb.scoreData.grade,
pb.scoreData.score,
JUBEAT_GBOUNDARIES[gradeIndex]!.name,
),
},
goalOutOfFormatters: {
musicRate: (v) => `${v.toFixed(1)}%`,
score: GoalOutOfFmtScore,
},
// musicRate is the default prop
// but we want the user's best score to count aswell.
@@ -8,7 +8,10 @@ import { seedUser } from "#test-utils/pg-fixtures";
import { TestingMaimaiChart, TestingMaimaiSong } from "#test-utils/test-data";
import { UnixMillisecondsToISO8601 } from "#utils/time";
import {
FormatGoalCriteria,
GAME_GOAL_PROGRESS_FORMATTERS,
GetGameConfig,
GetScoreMetricConf,
type integer,
type MongoProvidedMetrics,
type ScoreData,
@@ -183,22 +186,22 @@ describe("MAIMAI_IMPL", () => {
const mockPB = mkMockPB("maimai", chart, scoreData);
it("criteria", () => {
expect(MAIMAI_IMPL.goalCriteriaFormatters.percent(93.14)).toBe("Get 93.14% on");
expect(
FormatGoalCriteria({ key: "percent", value: 93.14, mode: "single" }, "maimai"),
).toBe("Get 93.14% on");
});
it("progress", () => {
const fmt = GAME_GOAL_PROGRESS_FORMATTERS.maimai;
const f = (
k: keyof typeof MAIMAI_IMPL.goalProgressFormatters,
k: keyof typeof fmt,
modifant: Partial<ScoreData<"maimai">>,
goalValue: integer,
expected: string,
) =>
expect(
MAIMAI_IMPL.goalProgressFormatters[k](
dmf(mockPB, { scoreData: modifant }) as never,
goalValue,
),
).toBe(expected);
expect(fmt[k](dmf(mockPB, { scoreData: modifant }) as never, goalValue)).toBe(
expected,
);
f("grade", { grade: "S", percent: 97.5 }, GRADES.indexOf("SS"), "(S+)-0.50%");
f("grade", { grade: "S", percent: 97.5 }, GRADES.indexOf("SSS+"), "(S+)-0.50%");
@@ -209,7 +212,10 @@ describe("MAIMAI_IMPL", () => {
});
it("outOf", () => {
expect(MAIMAI_IMPL.goalOutOfFormatters.percent(99.11)).toBe("99.11%");
const percentMetric = GetScoreMetricConf(GetGameConfig("maimai"), "percent") as {
goalOutOfFormatter: (v: number) => string;
};
expect(percentMetric.goalOutOfFormatter(99.11)).toBe("99.11%");
});
});
@@ -7,8 +7,6 @@ import { IsNullish } from "#utils/misc";
import { MaimaiRate } from "rg-stats";
import { GetGrade, MAIMAI_GBOUNDARIES } from "tachi-common";
import { GoalFmtPercent, GoalOutOfFmtPercent, GradeGoalFormatter } from "./_common";
export const MAIMAI_IMPL: GameImplementation<"maimai"> = {
chartSpecificValidators: {
percent: (percent, chart) => {
@@ -75,45 +73,6 @@ export const MAIMAI_IMPL: GameImplementation<"maimai"> = {
return { colour: "WHITE" };
},
goalCriteriaFormatters: {
percent: GoalFmtPercent,
},
goalProgressFormatters: {
percent: (pb) => `${pb.scoreData.percent.toFixed(2)}%`,
lamp: (pb) => pb.scoreData.lamp,
grade: (pb, gradeIndex) => {
if (pb.scoreData.grade === "SSS+") {
return "SSS+";
}
// gradeIndex is guaranteed to be a valid rank
const goalGrade = MAIMAI_GBOUNDARIES[gradeIndex]!.name;
// Grade SSS+ is chart-dependent, and it isn't possible to get the
// max score/max percent from only the percent.
//
// As such, if the goal is to SSS+, we have to return the current rank+delta.
if (goalGrade === "SSS+" && pb.scoreData.grade === "SSS") {
const boundary =
MAIMAI_GBOUNDARIES.find((c) => c.name === "SSS")?.lowerBound ?? 100;
const delta = pb.scoreData.percent - boundary;
return `SSS+${delta.toFixed(2)}%`;
}
return GradeGoalFormatter(
MAIMAI_GBOUNDARIES,
pb.scoreData.grade,
pb.scoreData.percent,
goalGrade,
(v) => `${v.toFixed(2)}%`,
);
},
},
goalOutOfFormatters: {
percent: GoalOutOfFmtPercent,
},
pbMergeFunctions: [
CreatePBMergeFor(
"largest",
@@ -7,7 +7,14 @@ import { dmf, mkMockPB, mkMockScore } from "#test-utils/misc";
import { seedUser } from "#test-utils/pg-fixtures";
import { TestingMaimaiDXChart, TestingMaimaiDXSong } from "#test-utils/test-data";
import { UnixMillisecondsToISO8601 } from "#utils/time";
import { GetGameConfig, type MongoProvidedMetrics, type ScoreData } from "tachi-common";
import {
FormatGoalCriteria,
GAME_GOAL_PROGRESS_FORMATTERS,
GetGameConfig,
GetScoreMetricConf,
type MongoProvidedMetrics,
type ScoreData,
} from "tachi-common";
import { beforeEach, describe, expect, it } from "vitest";
function enumMetricValues(m: {
@@ -165,22 +172,22 @@ describe("MAIMAIDX_IMPL", () => {
const mockPB = mkMockPB("maimaidx", chart, scoreData);
it("criteria", () => {
expect(MAIMAIDX_IMPL.goalCriteriaFormatters.percent(93.1415)).toBe("Get 93.1415% on");
expect(
FormatGoalCriteria({ key: "percent", value: 93.1415, mode: "single" }, "maimaidx"),
).toBe("Get 93.1415% on");
});
it("progress", () => {
const fmt = GAME_GOAL_PROGRESS_FORMATTERS.maimaidx;
const f = (
k: keyof typeof MAIMAIDX_IMPL.goalProgressFormatters,
k: keyof typeof fmt,
modifant: Partial<ScoreData<"maimaidx">>,
goalValue: number,
expected: string,
) =>
expect(
MAIMAIDX_IMPL.goalProgressFormatters[k](
dmf(mockPB, { scoreData: modifant }) as never,
goalValue,
),
).toBe(expected);
expect(fmt[k](dmf(mockPB, { scoreData: modifant }) as never, goalValue)).toBe(
expected,
);
f("grade", { grade: "S", percent: 97.5 }, GRADES.indexOf("SS"), "(S+)-0.5000%");
f("percent", { percent: 98.23 }, 1_000_000, "98.2300%");
@@ -188,7 +195,10 @@ describe("MAIMAIDX_IMPL", () => {
});
it("outOf", () => {
expect(MAIMAIDX_IMPL.goalOutOfFormatters.percent(99.1123)).toBe("99.1123%");
const percentMetric = GetScoreMetricConf(GetGameConfig("maimaidx"), "percent") as {
goalOutOfFormatter: (v: number) => string;
};
expect(percentMetric.goalOutOfFormatter(99.1123)).toBe("99.1123%");
});
});
@@ -7,8 +7,6 @@ import { IsNullish } from "#utils/misc";
import { MaimaiDXRate } from "rg-stats";
import { GetGrade, MAIMAIDX_GBOUNDARIES } from "tachi-common";
import { GoalFmtPercent, GoalOutOfFmtPercent, GradeGoalFormatter } from "./_common";
export const MAIMAIDX_IMPL: GameImplementation<"maimaidx"> = {
chartSpecificValidators: {},
scoreDeriver: (scoreData, _chart) => ({
@@ -80,24 +78,6 @@ export const MAIMAIDX_IMPL: GameImplementation<"maimaidx"> = {
return { colour: "WHITE" };
},
goalCriteriaFormatters: {
percent: (v) => GoalFmtPercent(v, 4),
},
goalProgressFormatters: {
percent: (pb) => `${pb.scoreData.percent.toFixed(4)}%`,
lamp: (pb) => pb.scoreData.lamp,
grade: (pb, gradeIndex) =>
GradeGoalFormatter(
MAIMAIDX_GBOUNDARIES,
pb.scoreData.grade,
pb.scoreData.percent,
MAIMAIDX_GBOUNDARIES[gradeIndex]!.name,
(v) => `${v.toFixed(4)}%`,
),
},
goalOutOfFormatters: {
percent: (v) => GoalOutOfFmtPercent(v, 4),
},
pbMergeFunctions: [
CreatePBMergeFor(
"largest",
@@ -8,6 +8,10 @@ import { seedUser } from "#test-utils/pg-fixtures";
import { TestingMusecaChart, TestingMusecaSong } from "#test-utils/test-data";
import { UnixMillisecondsToISO8601 } from "#utils/time";
import {
FormatGoalCriteria,
GAME_GOAL_PROGRESS_FORMATTERS,
GetGameConfig,
GetScoreMetricConf,
type MongoProvidedMetrics,
MUSECA_GRADES,
MUSECA_LAMPS,
@@ -120,24 +124,22 @@ describe("MUSECA_IMPL", () => {
const mockPB = mkMockPB("museca", chart, scoreData);
it("formats score criteria", () => {
expect(MUSECA_IMPL.goalCriteriaFormatters.score(908_182)).toBe(
"Get a score of 908,182 on",
);
expect(
FormatGoalCriteria({ key: "score", value: 908_182, mode: "single" }, "museca"),
).toBe("Get a score of 908,182 on");
});
it("formats progress", () => {
const fmt = GAME_GOAL_PROGRESS_FORMATTERS.museca;
const f = (
k: keyof typeof MUSECA_IMPL.goalProgressFormatters,
k: keyof typeof fmt,
modifant: Partial<ScoreData<"museca">>,
goalValue: number,
expected: string,
) =>
expect(
MUSECA_IMPL.goalProgressFormatters[k](
dmf(mockPB, { scoreData: modifant }) as never,
goalValue,
),
).toBe(expected);
expect(fmt[k](dmf(mockPB, { scoreData: modifant }) as never, goalValue)).toBe(
expected,
);
f("grade", { grade: "傑", score: 997_342 }, MUSECA_GRADES., "傑G-2.7K");
f("score", { score: 982_123 }, 1_000_000, "982,123");
@@ -145,8 +147,11 @@ describe("MUSECA_IMPL", () => {
});
it("formats out-of score", () => {
expect(MUSECA_IMPL.goalOutOfFormatters.score(901_003)).toBe("901,003");
expect(MUSECA_IMPL.goalOutOfFormatters.score(983_132)).toBe("983,132");
const scoreMetric = GetScoreMetricConf(GetGameConfig("museca"), "score") as {
goalOutOfFormatter: (v: number) => string;
};
expect(scoreMetric.goalOutOfFormatter(901_003)).toBe("901,003");
expect(scoreMetric.goalOutOfFormatter(983_132)).toBe("983,132");
});
});
@@ -4,9 +4,7 @@ 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";
import { FmtNum, GetGrade, MUSECA_GBOUNDARIES } from "tachi-common";
import { GoalFmtScore, GoalOutOfFmtScore, GradeGoalFormatter } from "./_common";
import { GetGrade, MUSECA_GBOUNDARIES } from "tachi-common";
export const MUSECA_IMPL: GameImplementation<"museca"> = {
chartSpecificValidators: {},
@@ -31,23 +29,6 @@ export const MUSECA_IMPL: GameImplementation<"museca"> = {
curatorSkill: await ProfileSumBestN("curatorSkill", 20)(game, userID),
}),
classDerivers: (_ratings) => ({}),
goalCriteriaFormatters: {
score: GoalFmtScore,
},
goalProgressFormatters: {
score: (pb) => FmtNum(pb.scoreData.score),
lamp: (pb) => pb.scoreData.lamp,
grade: (pb, gradeIndex) =>
GradeGoalFormatter(
MUSECA_GBOUNDARIES,
pb.scoreData.grade,
pb.scoreData.score,
MUSECA_GBOUNDARIES[gradeIndex]!.name,
),
},
goalOutOfFormatters: {
score: GoalOutOfFmtScore,
},
pbMergeFunctions: [
CreatePBMergeFor(
"largest",
@@ -8,6 +8,10 @@ import { seedUser } from "#test-utils/pg-fixtures";
import { TestingOngekiChart, TestingOngekiScorePB, TestingOngekiSong } from "#test-utils/test-data";
import { UnixMillisecondsToISO8601 } from "#utils/time";
import {
FormatGoalCriteria,
GAME_GOAL_PROGRESS_FORMATTERS,
GetGameConfig,
GetScoreMetricConf,
type MongoProvidedMetrics,
ONGEKI_BELL_LAMPS,
ONGEKI_GRADES,
@@ -302,30 +306,29 @@ describe("ONGEKI_IMPL", () => {
const mockPB = mkMockPB("ongeki", chart, scoreData);
it("criteria", () => {
expect(ONGEKI_IMPL.goalCriteriaFormatters.score(1_008_182)).toBe(
"Get a score of 1,008,182 on",
);
expect(ONGEKI_IMPL.goalCriteriaFormatters.platinumScore(1500)).toBe(
"Get 1,500 Platinum Score on",
);
expect(ONGEKI_IMPL.goalCriteriaFormatters.platinumStars!("3-star")).toBe(
"Get ★★★☆☆ on",
);
expect(
FormatGoalCriteria({ key: "score", value: 1_008_182, mode: "single" }, "ongeki"),
).toBe("Get a score of 1,008,182 on");
expect(
FormatGoalCriteria({ key: "platinumScore", value: 1500, mode: "single" }, "ongeki"),
).toBe("Get 1,500 Platinum Score on");
// "3-star" is at index 3 in the platinumStars enum values
expect(
FormatGoalCriteria({ key: "platinumStars", value: 3, mode: "single" }, "ongeki"),
).toBe("Get ★★★☆☆ on");
});
it("progress", () => {
const fmt = GAME_GOAL_PROGRESS_FORMATTERS.ongeki;
const f = (
k: keyof typeof ONGEKI_IMPL.goalProgressFormatters,
k: keyof typeof fmt,
modifant: Partial<ScoreData<"ongeki">>,
goalValue: number,
expected: string,
) =>
expect(
ONGEKI_IMPL.goalProgressFormatters[k](
dmf(mockPB, { scoreData: modifant }) as never,
goalValue,
),
).toBe(expected);
expect(fmt[k](dmf(mockPB, { scoreData: modifant }) as never, goalValue)).toBe(
expected,
);
f("grade", { grade: "S", score: 987_342 }, ONGEKI_GRADES.S, "SS-2.7K");
f("noteLamp", { noteLamp: "CLEAR" }, ONGEKI_NOTE_LAMPS.CLEAR, "CLEAR");
@@ -335,9 +338,13 @@ describe("ONGEKI_IMPL", () => {
});
it("outOf", () => {
expect(ONGEKI_IMPL.goalOutOfFormatters.score(1_001_003)).toBe("1,001,003");
expect(ONGEKI_IMPL.goalOutOfFormatters.score(983_132)).toBe("983,132");
expect(ONGEKI_IMPL.goalOutOfFormatters.platinumScore(1234)).toBe("1,234");
const gConf = GetGameConfig("ongeki");
const toFmt = (m: string) =>
(GetScoreMetricConf(gConf, m) as { goalOutOfFormatter: (v: number) => string })
.goalOutOfFormatter;
expect(toFmt("score")(1_001_003)).toBe("1,001,003");
expect(toFmt("score")(983_132)).toBe("983,132");
expect(toFmt("platinumScore")(1234)).toBe("1,234");
});
});
@@ -5,10 +5,8 @@ import { ProfileAvgBestN } from "#game-implementations/utils/profile-calc";
import { SessionAvgBest10For } from "#game-implementations/utils/session-calc";
import { IsNullish } from "#utils/misc";
import { ONGEKIRating } from "rg-stats";
import { type ChartDocument, FmtNum, GetGrade, ONGEKI_GBOUNDARIES } from "tachi-common";
import { FmtStars, StarEnum, StarEnumToInt } from "tachi-common/config/game-support/ongeki";
import { GoalFmtScore, GoalOutOfFmtScore, GradeGoalFormatter } from "./_common";
import { type ChartDocument, GetGrade, ONGEKI_GBOUNDARIES } from "tachi-common";
import { StarEnumToInt } from "tachi-common/config/game-support/ongeki";
const isUnranked = (chart: ChartDocument<"ongeki">) => {
const ig = chart.data.inGameID;
@@ -158,30 +156,6 @@ export const ONGEKI_IMPL: GameImplementation<"ongeki"> = {
return { colour: "BLUE" };
},
goalCriteriaFormatters: {
score: GoalFmtScore,
platinumScore: (val: number) => `Get ${val.toLocaleString("en-GB")} Platinum Score on`,
platinumStars: (val: string) => `Get ${FmtStars(val as StarEnum, false)} on`,
},
goalProgressFormatters: {
grade: (pb, gradeIndex) =>
GradeGoalFormatter(
ONGEKI_GBOUNDARIES,
pb.scoreData.grade,
pb.scoreData.score,
ONGEKI_GBOUNDARIES[gradeIndex]?.name ?? "D",
),
noteLamp: (pb) => pb.scoreData.noteLamp,
bellLamp: (pb) => pb.scoreData.bellLamp,
score: (pb) => FmtNum(pb.scoreData.score),
platinumScore: (pb) => FmtNum(pb.scoreData.platinumScore),
platinumStars: (pb) => FmtStars(pb.scoreData.platinumStars, false),
},
goalOutOfFormatters: {
score: GoalOutOfFmtScore,
platinumScore: GoalOutOfFmtScore,
platinumStars: (v: any) => FmtStars(v, false),
},
pbMergeFunctions: [
CreatePBMergeFor(
"largest",
@@ -7,7 +7,14 @@ import { dmf, mkMockPB, mkMockScore } from "#test-utils/misc";
import { seedUser } from "#test-utils/pg-fixtures";
import { TestingPopnChart, TestingPopnSong } from "#test-utils/test-data";
import { UnixMillisecondsToISO8601 } from "#utils/time";
import { GetGameConfig, type MongoProvidedMetrics, type ScoreData } from "tachi-common";
import {
FormatGoalCriteria,
GAME_GOAL_PROGRESS_FORMATTERS,
GetGameConfig,
GetScoreMetricConf,
type MongoProvidedMetrics,
type ScoreData,
} from "tachi-common";
import { beforeEach, describe, expect, it } from "vitest";
function enumMetricValues(m: {
@@ -176,24 +183,22 @@ describe("POPN_IMPL", () => {
const mockPB = mkMockPB("popn", chart, scoreData);
it("criteria", () => {
expect(POPN_IMPL.goalCriteriaFormatters.score(908_182)).toBe(
"Get a score of 908,182 on",
);
expect(
FormatGoalCriteria({ key: "score", value: 908_182, mode: "single" }, "popn"),
).toBe("Get a score of 908,182 on");
});
it("progress", () => {
const fmt = GAME_GOAL_PROGRESS_FORMATTERS.popn;
const f = (
k: keyof typeof POPN_IMPL.goalProgressFormatters,
k: keyof typeof fmt,
modifant: Partial<ScoreData<"popn">>,
goalValue: number,
expected: string,
) =>
expect(
POPN_IMPL.goalProgressFormatters[k](
dmf(mockPB, { scoreData: modifant }) as never,
goalValue,
),
).toBe(expected);
expect(fmt[k](dmf(mockPB, { scoreData: modifant }) as never, goalValue)).toBe(
expected,
);
f("grade", { grade: "AAA", score: 95_342 }, GRADES.indexOf("S"), "S-2.7K");
f("score", { score: 98_123 }, 100_000, "98,123");
@@ -201,8 +206,11 @@ describe("POPN_IMPL", () => {
});
it("outOf", () => {
expect(POPN_IMPL.goalOutOfFormatters.score(901_003)).toBe("901,003");
expect(POPN_IMPL.goalOutOfFormatters.score(983_132)).toBe("983,132");
const scoreMetric = GetScoreMetricConf(GetGameConfig("popn"), "score") as {
goalOutOfFormatter: (v: number) => string;
};
expect(scoreMetric.goalOutOfFormatter(901_003)).toBe("901,003");
expect(scoreMetric.goalOutOfFormatter(983_132)).toBe("983,132");
});
});
@@ -6,9 +6,7 @@ import { ProfileAvgBestN } from "#game-implementations/utils/profile-calc";
import { SessionAvgBest10For } from "#game-implementations/utils/session-calc";
import { IsNullish } from "#utils/misc";
import { PopnClassPoints } from "rg-stats";
import { FmtNum, GetGrade, POPN_GBOUNDARIES } from "tachi-common";
import { GoalFmtScore, GoalOutOfFmtScore, GradeGoalFormatter } from "./_common";
import { GetGrade, POPN_GBOUNDARIES } from "tachi-common";
export function PopnClearMedalToLamp(
clearMedal: GetEnumValue<"popn", "clearMedal">,
@@ -88,24 +86,6 @@ export const POPN_IMPL: GameImplementation<"popn"> = {
return { class: "GOD" };
},
goalCriteriaFormatters: {
score: GoalFmtScore,
},
goalProgressFormatters: {
score: (pb) => FmtNum(pb.scoreData.score),
clearMedal: (pb) => pb.scoreData.clearMedal,
lamp: (pb) => pb.scoreData.lamp,
grade: (pb, gradeIndex) =>
GradeGoalFormatter(
POPN_GBOUNDARIES,
pb.scoreData.grade,
pb.scoreData.score,
POPN_GBOUNDARIES[gradeIndex]!.name,
),
},
goalOutOfFormatters: {
score: GoalOutOfFmtScore,
},
pbMergeFunctions: [
CreatePBMergeFor(
"largest",
@@ -7,7 +7,16 @@ import { dmf, mkMockPB, mkMockScore } from "#test-utils/misc";
import { seedUser } from "#test-utils/pg-fixtures";
import { TestingSDVXAlbidaChart, TestingSDVXAlbidaSong } from "#test-utils/test-data";
import { UnixMillisecondsToISO8601 } from "#utils/time";
import { type MongoProvidedMetrics, type ScoreData, SDVX_GRADES, SDVX_LAMPS } from "tachi-common";
import {
FormatGoalCriteria,
GAME_GOAL_PROGRESS_FORMATTERS,
GetGameConfig,
GetScoreMetricConf,
type MongoProvidedMetrics,
type ScoreData,
SDVX_GRADES,
SDVX_LAMPS,
} from "tachi-common";
import { beforeEach, describe, expect, it } from "vitest";
const chart = TestingSDVXAlbidaChart;
@@ -186,20 +195,21 @@ describe("SDVX_IMPL", () => {
const mockPB = mkMockPB("sdvx", chart, scoreData);
it("formats score criteria", () => {
expect(SDVX_IMPL.goalCriteriaFormatters.score(908_182)).toBe(
"Get a score of 908,182 on",
);
expect(
FormatGoalCriteria({ key: "score", value: 908_182, mode: "single" }, "sdvx"),
).toBe("Get a score of 908,182 on");
});
it("formats progress for grade, score, and lamp", () => {
const fmt = GAME_GOAL_PROGRESS_FORMATTERS.sdvx;
const f = (
k: keyof typeof SDVX_IMPL.goalProgressFormatters,
k: keyof typeof fmt,
modifant: Partial<ScoreData<"sdvx">>,
goalValue: number,
expected: string,
) =>
expect(
SDVX_IMPL.goalProgressFormatters[k](
fmt[k](
dmf(mockPB, {
scoreData: modifant,
}) as never,
@@ -213,8 +223,11 @@ describe("SDVX_IMPL", () => {
});
it("formats out-of score", () => {
expect(SDVX_IMPL.goalOutOfFormatters.score(901_003)).toBe("901,003");
expect(SDVX_IMPL.goalOutOfFormatters.score(983_132)).toBe("983,132");
const scoreMetric = GetScoreMetricConf(GetGameConfig("sdvx"), "score") as {
goalOutOfFormatter: (v: number) => string;
};
expect(scoreMetric.goalOutOfFormatter(901_003)).toBe("901,003");
expect(scoreMetric.goalOutOfFormatter(983_132)).toBe("983,132");
});
});
@@ -5,9 +5,6 @@ import { CreatePBMergeFor } from "#game-implementations/utils/pb-merge";
import {
SDVXLIKE_CLASS_DERIVERS,
SDVXLIKE_DEFAULT_MERGE_NAME,
SDVXLIKE_GOAL_FMT,
SDVXLIKE_GOAL_OO_FMT,
SDVXLIKE_GOAL_PG_FMT,
SDVXLIKE_PROFILE_CALCS,
SDVXLIKE_SCORE_CALCS,
SDVXLIKE_SCORE_DERIVER,
@@ -43,9 +40,6 @@ export const SDVX_IMPL: GameImplementation<"sdvx"> = {
return true;
},
},
goalCriteriaFormatters: SDVXLIKE_GOAL_FMT,
goalProgressFormatters: SDVXLIKE_GOAL_PG_FMT,
goalOutOfFormatters: SDVXLIKE_GOAL_OO_FMT,
pbMergeFunctions: [
CreatePBMergeFor(
"largest",
@@ -7,7 +7,16 @@ import { dmf, mkMockPB, mkMockScore } from "#test-utils/misc";
import { seedUser } from "#test-utils/pg-fixtures";
import { TestingUSCChart, TestingUSCSong } from "#test-utils/test-data";
import { UnixMillisecondsToISO8601 } from "#utils/time";
import { type MongoProvidedMetrics, type ScoreData, SDVX_GRADES, USC_LAMPS } from "tachi-common";
import {
FormatGoalCriteria,
GAME_GOAL_PROGRESS_FORMATTERS,
GetGameConfig,
GetScoreMetricConf,
type MongoProvidedMetrics,
type ScoreData,
SDVX_GRADES,
USC_LAMPS,
} from "tachi-common";
import { beforeEach, describe, expect, it } from "vitest";
const baseMetrics: MongoProvidedMetrics["usc-controller" | "usc-keyboard"] = {
@@ -176,18 +185,21 @@ describe.each([
const mockPB = mkMockPB(game, chart, scoreData);
it("formats score criteria", () => {
expect(impl.goalCriteriaFormatters.score(908_182)).toBe("Get a score of 908,182 on");
expect(FormatGoalCriteria({ key: "score", value: 908_182, mode: "single" }, game)).toBe(
"Get a score of 908,182 on",
);
});
it("formats progress for grade, score, and lamp", () => {
const fmt = GAME_GOAL_PROGRESS_FORMATTERS[game];
const f = (
k: keyof typeof impl.goalProgressFormatters,
k: keyof typeof fmt,
modifant: Partial<ScoreData<typeof game>>,
goalValue: number,
expected: string,
) =>
expect(
impl.goalProgressFormatters[k](
fmt[k](
dmf(mockPB, {
scoreData: modifant,
}) as never,
@@ -201,8 +213,11 @@ describe.each([
});
it("formats out-of score", () => {
expect(impl.goalOutOfFormatters.score(901_003)).toBe("901,003");
expect(impl.goalOutOfFormatters.score(983_132)).toBe("983,132");
const scoreMetric = GetScoreMetricConf(GetGameConfig(game), "score") as {
goalOutOfFormatter: (v: number) => string;
};
expect(scoreMetric.goalOutOfFormatter(901_003)).toBe("901,003");
expect(scoreMetric.goalOutOfFormatter(983_132)).toBe("983,132");
});
});
@@ -3,9 +3,6 @@ import type { GameImplementation } from "#game-implementations/types";
import {
SDVXLIKE_CLASS_DERIVERS,
SDVXLIKE_DEFAULT_MERGE_NAME,
SDVXLIKE_GOAL_FMT,
SDVXLIKE_GOAL_OO_FMT,
SDVXLIKE_GOAL_PG_FMT,
SDVXLIKE_PB_MERGERS,
SDVXLIKE_PROFILE_CALCS,
SDVXLIKE_SCORE_CALCS,
@@ -29,9 +26,6 @@ const USC_IMPL: GameImplementation<"usc-controller" | "usc-keyboard"> = {
tb5: null,
}),
chartSpecificValidators: {},
goalCriteriaFormatters: SDVXLIKE_GOAL_FMT,
goalProgressFormatters: SDVXLIKE_GOAL_PG_FMT,
goalOutOfFormatters: SDVXLIKE_GOAL_OO_FMT,
pbMergeFunctions: SDVXLIKE_PB_MERGERS,
defaultMergeRefName: SDVXLIKE_DEFAULT_MERGE_NAME,
scoreValidators: SDVXLIKE_SCORE_VALIDATORS,
@@ -11,7 +11,10 @@ import { seedUser } from "#test-utils/pg-fixtures";
import { TestingWaccaPupaExp, TestingWaccaPupaSong } from "#test-utils/test-data";
import { UnixMillisecondsToISO8601 } from "#utils/time";
import {
FormatGoalCriteria,
GAME_GOAL_PROGRESS_FORMATTERS,
GetGameConfig,
GetScoreMetricConf,
type MongoProvidedMetrics,
type ScoreData,
type ScoreDocument,
@@ -166,24 +169,22 @@ describe("WACCA_IMPL", () => {
const mockPB = mkMockPB("wacca", chart, scoreData);
it("criteria", () => {
expect(WACCA_IMPL.goalCriteriaFormatters.score(908_182)).toBe(
"Get a score of 908,182 on",
);
expect(
FormatGoalCriteria({ key: "score", value: 908_182, mode: "single" }, "wacca"),
).toBe("Get a score of 908,182 on");
});
it("progress", () => {
const fmt = GAME_GOAL_PROGRESS_FORMATTERS.wacca;
const f = (
k: keyof typeof WACCA_IMPL.goalProgressFormatters,
k: keyof typeof fmt,
modifant: Partial<ScoreData<"wacca">>,
goalValue: number,
expected: string,
) =>
expect(
WACCA_IMPL.goalProgressFormatters[k](
dmf(mockPB, { scoreData: modifant }) as never,
goalValue,
),
).toBe(expected);
expect(fmt[k](dmf(mockPB, { scoreData: modifant }) as never, goalValue)).toBe(
expected,
);
f("grade", { grade: "S", score: 917_342 }, GRADES.indexOf("SS"), "(S+)-13K");
f("score", { score: 982_123 }, 1_000_000, "982,123");
@@ -191,8 +192,11 @@ describe("WACCA_IMPL", () => {
});
it("outOf", () => {
expect(WACCA_IMPL.goalOutOfFormatters.score(901_003)).toBe("901,003");
expect(WACCA_IMPL.goalOutOfFormatters.score(983_132)).toBe("983,132");
const scoreMetric = GetScoreMetricConf(GetGameConfig("wacca"), "score") as {
goalOutOfFormatter: (v: number) => string;
};
expect(scoreMetric.goalOutOfFormatter(901_003)).toBe("901,003");
expect(scoreMetric.goalOutOfFormatter(983_132)).toBe("983,132");
});
});
@@ -5,9 +5,7 @@ import { ProfileSumBestN } from "#game-implementations/utils/profile-calc";
import { SessionAvgBest10For } from "#game-implementations/utils/session-calc";
import { IsNullish } from "#utils/misc";
import { WACCARate } from "rg-stats";
import { FmtNum, GetGrade, WACCA_GBOUNDARIES } from "tachi-common";
import { GoalFmtScore, GoalOutOfFmtScore, GradeGoalFormatter } from "./_common";
import { GetGrade, WACCA_GBOUNDARIES } from "tachi-common";
export const WACCA_IMPL: GameImplementation<"wacca"> = {
chartSpecificValidators: {},
@@ -58,23 +56,6 @@ export const WACCA_IMPL: GameImplementation<"wacca"> = {
return { colour: "ASH" };
},
goalCriteriaFormatters: {
score: GoalFmtScore,
},
goalProgressFormatters: {
score: (pb) => FmtNum(pb.scoreData.score),
lamp: (pb) => pb.scoreData.lamp,
grade: (pb, gradeIndex) =>
GradeGoalFormatter(
WACCA_GBOUNDARIES,
pb.scoreData.grade,
pb.scoreData.score,
WACCA_GBOUNDARIES[gradeIndex]!.name,
),
},
goalOutOfFormatters: {
score: GoalOutOfFmtScore,
},
pbMergeFunctions: [
CreatePBMergeFor(
"largest",
@@ -2,11 +2,9 @@ import type { PBScoreDocumentNoRank } from "#lib/score-import/framework/pb/creat
import type {
ChartDocument,
ClassConfigs,
ConfScoreMetrics,
integer,
MongoDerivedMetrics,
PBReference,
PBScoreDocument,
ProfileRatingAlgorithms,
ScoreData,
ScoreDocument,
@@ -16,7 +14,7 @@ import type {
V3Game,
} from "tachi-common";
import type { DerivedClassConfig } from "tachi-common/types/game-config-utils";
import type { AllConfMetrics, ConfEnumScoreMetric } from "tachi-common/types/metrics";
import type { AllConfMetrics } from "tachi-common/types/metrics";
/**
* Validate this chart-specific metric. This should return a string representing an
@@ -135,48 +133,6 @@ export type PBRankingValuesFunction<TGame extends V3Game> = (
pb: PBScoreDocumentNoRank<TGame>,
) => RankingValues;
/**
* 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 receive 3600 as its
* argument.
*/
export type GoalCriteriaFormatter = (num: number) => string;
/**
* After all, why not? Why shouldn't enum metrics allow formatters?
*/
export type GoalCriteriaFormatterEnum = (str: string) => string;
type GPTRequiredGoalFormatters<TGame extends V3Game> = {
[K in keyof ConfScoreMetrics[TGame] as ConfScoreMetrics[TGame][K] extends ConfEnumScoreMetric<any>
? never
: K]: GoalCriteriaFormatter;
};
type GPTEnumGoalFormatters<TGame extends V3Game> = {
[K in keyof ConfScoreMetrics[TGame] as ConfScoreMetrics[TGame][K] extends ConfEnumScoreMetric<any>
? K
: never]?: GoalCriteriaFormatterEnum;
};
export type GPTGoalFormatters<TGame extends V3Game> = GPTEnumGoalFormatters<TGame> &
GPTRequiredGoalFormatters<TGame>;
/**
* Given a user's PB and the value of the goal, return a string representing this
* user's progress through this goal.
*
* This only applies to "single" goals, i.e. goals on a single chart.
*/
export type GoalProgressFormatter<TGame extends V3Game> = (
pb: PBScoreDocument<TGame>,
goalValue: integer,
) => string;
export type GPTGoalProgressFormatters<TGame extends V3Game> = {
[K in keyof ConfScoreMetrics[TGame]]: GoalProgressFormatter<TGame>;
};
/**
* Return nothing on success, and a string
* indicating what the error was on failure.
@@ -219,33 +175,6 @@ export interface GameImplementation<TGame extends V3Game> {
*/
classDerivers: GPTClassDerivers<TGame>;
/**
* When creating a goal, how should we format the title?
*
* Get a score of 1234 on 5.1.1 SP ANOTHER
* ^^^^^^^^^^^^^^^^^^^^^^
* this bit
*/
goalCriteriaFormatters: GPTGoalFormatters<TGame>;
/**
* How should we format the "outOf" part of a goal?
*
* HARD CLEAR/FULL COMBO
* ^^^^^^^^
* this bit
*/
goalOutOfFormatters: GPTGoalFormatters<TGame>;
/**
* How should we format the progress on a goal?
*
* HARD CLEAR/FULL COMBO
* ^^^^^^^^
* this bit
*/
goalProgressFormatters: GPTGoalProgressFormatters<TGame>;
/**
* How should we mutate PBs (to join best lamps, lowest BPs, etc.) for this GPT?
*/
+10 -111
View File
@@ -1,13 +1,13 @@
import type { GoalCriteriaFormatter, GoalCriteriaFormatterEnum } from "#game-implementations/types";
import { GAME_IMPLEMENTATIONS } from "#game-implementations/game-implementations";
import { GetChartByIdForGame } from "#lib/db-formats/chart";
import { LoadFolderDocumentById } from "#lib/db-formats/folders";
import { GetFolderChartIDs } from "#lib/folders/folders";
import { HumaniseChartID } from "#utils/db";
import { HumanisedJoinArray, OnlyFloatToDP } from "#utils/misc";
import { HumanisedJoinArray, staticAssertUnreachable } from "#utils/misc";
import {
AssembleGoalTitle,
FormatGame,
FormatGoalCriteria,
GetGameConfig,
GetScoreMetricConf,
type GoalDocument,
@@ -19,70 +19,10 @@ export async function CreateGoalTitle(
criteria: GoalDocument["criteria"],
game: V3Game,
) {
const formattedCriteria = FormatCriteria(criteria, game);
const formattedCriteria = FormatGoalCriteria(criteria, game);
const datasetName = await FormatCharts(charts, criteria);
// Formatting this stuff into english is hard and excruciatingly manual.
switch (criteria.mode) {
case "single":
switch (charts.type) {
case "single":
return `${formattedCriteria} ${datasetName}`;
case "multi": {
if (charts.data.length === 2) {
// CLEAR either A or B
return `${formattedCriteria} either ${datasetName}`;
}
// CLEAR any of A, B or C.
return `${formattedCriteria} any one of ${datasetName}`;
}
case "folder":
return `${formattedCriteria} any chart in ${datasetName}`;
}
// Eslint can't figure out that the above switches are safely exhastive. Ah well.
// eslint-disable-next-line no-fallthrough
case "absolute":
switch (charts.type) {
case "multi": {
// CLEAR all of A, B and C
if (criteria.countNum === charts.data.length) {
return `${formattedCriteria} ${datasetName}`;
}
// CLEAR any 2 of A, B or C
return `${formattedCriteria} any ${criteria.countNum} of ${datasetName}`;
}
case "folder":
return `${formattedCriteria} ${criteria.countNum} charts in ${datasetName}`;
case "single":
throw new Error(
`Invalid goal -- absolute mode cannot be paired with a charts.type of 'single'.`,
);
}
// See above about switch exhaustivity
// eslint-disable-next-line no-fallthrough
case "proportion": {
const propFormat = OnlyFloatToDP(criteria.countNum * 100);
switch (charts.type) {
case "multi":
return `${formattedCriteria} ${propFormat}% of ${datasetName}`;
case "folder":
return `${formattedCriteria} ${propFormat}% of the charts in ${datasetName}`;
case "single":
throw new Error(
`Invalid goal -- absolute mode cannot be paired with a charts.type of 'single'.`,
);
}
}
}
return AssembleGoalTitle(formattedCriteria, datasetName, criteria, charts);
}
async function FormatCharts(charts: GoalDocument["charts"], criteria: GoalDocument["criteria"]) {
@@ -118,52 +58,10 @@ async function FormatCharts(charts: GoalDocument["charts"], criteria: GoalDocume
}
default:
throw new Error(
`Invalid goal charts.type -- got ${
(charts as GoalDocument["charts"]).type
}, which we don't support?`,
);
staticAssertUnreachable(charts);
}
}
function FormatCriteria<TGame extends V3Game>(
criteria: GoalDocument<TGame>["criteria"],
game: TGame,
) {
const gameConfig = GetGameConfig(game);
const conf = GetScoreMetricConf(gameConfig, criteria.key);
if (!conf) {
throw new Error(`Invalid goal criteria with key ${criteria.key}. No config exists?`);
}
if (conf.type === "ENUM") {
const fmt: GoalCriteriaFormatterEnum | undefined =
// @ts-expect-error it still thinks criteria.key might be a symbol.
GAME_IMPLEMENTATIONS[game].goalCriteriaFormatters[criteria.key];
const v = conf.values[criteria.value];
if (v === undefined) {
throw new Error(`Invalid criteria value '${criteria.value}'.`);
}
return fmt ? fmt(v) : v;
} else if (conf.type === "DECIMAL" || conf.type === "INTEGER") {
const fmt: GoalCriteriaFormatter | undefined =
// @ts-expect-error it still thinks criteria.key might be a symbol.
GAME_IMPLEMENTATIONS[game].goalCriteriaFormatters[criteria.key];
if (!fmt) {
throw new Error(`No formatter defined for ${criteria.key}, yet one must exist?`);
}
return fmt(criteria.value);
}
throw new Error(`Cannot set a goal for ${criteria.key} as it is of type ${conf.type}.`);
}
/**
* Given a goals' charts and criteria properties, evaluate whether those two make
* any sense at all. There are certain combinations that are illegal, or values that
@@ -225,9 +123,7 @@ export async function ValidateGoalChartsAndCriteria(
}
default:
// @ts-expect-error Charts is stated to be never here, but if we get to this point it's
// effectively unknown
throw new Error(`Invalid goal.charts.type of ${charts.type}.`);
staticAssertUnreachable(charts);
}
// Validating criteria.mode against countNum.
@@ -315,6 +211,9 @@ export async function ValidateGoalChartsAndCriteria(
case "GRAPH":
case "NULLABLE_GRAPH":
throw new Error(`Cannot set a goal on ${criteria.key} as it's a graph metric.`);
default:
staticAssertUnreachable(config);
}
if (charts.type === "single" && criteria.mode !== "single") {
+23 -28
View File
@@ -1,6 +1,3 @@
import type { GoalCriteriaFormatter } from "#game-implementations/types";
import { GAME_IMPLEMENTATIONS } from "#game-implementations/game-implementations";
import { SubscribeFailReasons } from "#lib/constants/err-codes";
import { SELECT_GOAL, SELECT_GOAL_SUB_WITH_GOAL_GAME } from "#lib/db-formats/goal";
import { SELECT_QUEST, SELECT_QUEST_SUB_WITH_QUEST_GAME } from "#lib/db-formats/quest";
@@ -24,6 +21,7 @@ import fjsh from "fast-json-stable-hash";
import { sql } from "kysely";
import {
FormatGame,
GAME_GOAL_PROGRESS_FORMATTERS,
GetGameConfig,
GetScoreMetricConf,
type GoalDocument,
@@ -224,10 +222,8 @@ export function HumaniseGoalProgress(
goalValue: integer,
userPB: PBScoreDocument,
): string {
const gptImpl = GAME_IMPLEMENTATIONS[game];
// @ts-expect-error yeah this might fail, i know.
const formatter = gptImpl.goalProgressFormatters[key];
const formatters = GAME_GOAL_PROGRESS_FORMATTERS[game];
const formatter = formatters[key];
if (!formatter) {
throw new Error(
@@ -235,7 +231,8 @@ export function HumaniseGoalProgress(
);
}
return formatter(userPB, goalValue);
// formatter is game-specific; userPB is the correct type at runtime
return (formatter as (pb: PBScoreDocument, goalValue: number) => string)(userPB, goalValue);
}
/**
@@ -244,7 +241,6 @@ export function HumaniseGoalProgress(
*/
export function HumaniseGoalOutOf(v3Game: V3Game, key: GoalKeys, value: number) {
const gameConfig = GetGameConfig(v3Game);
const metricConf = GetScoreMetricConf(gameConfig, key);
if (!metricConf) {
@@ -253,29 +249,28 @@ export function HumaniseGoalOutOf(v3Game: V3Game, key: GoalKeys, value: number)
);
}
const gptImpl = GAME_IMPLEMENTATIONS[v3Game];
if (metricConf.type === "ENUM") {
// ENUM metrics optionally provide a goalOutOfFormatter; fall back to the enum string.
const fmt = (metricConf as { goalOutOfFormatter?: (v: number) => string })
.goalOutOfFormatter;
// @ts-expect-error yeah this is technically unsafe, whatever
const fmt: GoalCriteriaFormatter | undefined = gptImpl.goalOutOfFormatters[key];
if (!fmt) {
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}. (${v3Game})`,
);
}
return val;
if (fmt) {
return fmt(value);
}
throw new Error(
`Invalid metric '${key}' passed to format outOf, as no goalCriteriaFormatter exists for it.`,
);
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}. (${v3Game})`,
);
}
return val;
}
return fmt(value);
// DECIMAL and INTEGER metrics always have goalOutOfFormatter (required by type).
return (metricConf as { goalOutOfFormatter: (v: number) => string }).goalOutOfFormatter(value);
}
/**