From 216617c54a810f37b290628556ace2352a058950 Mon Sep 17 00:00:00 2001 From: zk Date: Tue, 16 Jun 2026 00:27:37 +0100 Subject: [PATCH] fix: move goal logic into tachi-common (#1673) --- .../common/src/config/game-support/_common.ts | 6 + .../common/src/config/game-support/arcaea.ts | 2 + .../common/src/config/game-support/bms.ts | 28 ++ .../src/config/game-support/chunithm.ts | 2 + .../common/src/config/game-support/ddr.ts | 4 + .../src/config/game-support/gitadora.ts | 2 + .../common/src/config/game-support/iidx.ts | 10 + .../common/src/config/game-support/itg.ts | 20 ++ .../common/src/config/game-support/jubeat.ts | 4 + .../src/config/game-support/maimai-dx.ts | 2 + .../common/src/config/game-support/maimai.ts | 2 + .../common/src/config/game-support/museca.ts | 2 + .../common/src/config/game-support/ongeki.ts | 11 + .../common/src/config/game-support/pms.ts | 4 + .../common/src/config/game-support/popn.ts | 4 + .../common/src/config/game-support/sdvx.ts | 6 + .../common/src/config/game-support/usc.ts | 4 + .../common/src/config/game-support/wacca.ts | 2 + typescript/common/src/index.ts | 2 + .../src/lib/goal-progress-formatters.ts | 303 ++++++++++++++++++ typescript/common/src/lib/goal-title.ts | 194 +++++++++++ typescript/common/src/types/metrics.ts | 21 ++ .../lib/goals/goal-title-from-seeds.ts | 137 +------- .../src/game-implementations/games/_common.ts | 81 +---- .../game-implementations/games/arcaea.test.ts | 29 +- .../src/game-implementations/games/arcaea.ts | 21 +- .../games/bms-pms.impl.test.ts | 33 +- .../src/game-implementations/games/bms-pms.ts | 42 +-- .../games/chunithm.test.ts | 30 +- .../game-implementations/games/chunithm.ts | 22 +- .../game-implementations/games/ddr.test.ts | 22 +- .../src/game-implementations/games/ddr.ts | 29 -- .../games/gitadora.test.ts | 29 +- .../game-implementations/games/gitadora.ts | 20 -- .../games/iidx.impl.test.ts | 43 ++- .../src/game-implementations/games/iidx.ts | 57 ---- .../src/game-implementations/games/itg.ts | 55 ---- .../game-implementations/games/jubeat.test.ts | 35 +- .../src/game-implementations/games/jubeat.ts | 23 -- .../game-implementations/games/maimai.test.ts | 24 +- .../src/game-implementations/games/maimai.ts | 41 --- .../games/maimaidx.test.ts | 30 +- .../game-implementations/games/maimaidx.ts | 20 -- .../game-implementations/games/museca.test.ts | 29 +- .../src/game-implementations/games/museca.ts | 21 +- .../game-implementations/games/ongeki.test.ts | 45 +-- .../src/game-implementations/games/ongeki.ts | 30 +- .../game-implementations/games/popn.test.ts | 34 +- .../src/game-implementations/games/popn.ts | 22 +- .../game-implementations/games/sdvx.test.ts | 29 +- .../src/game-implementations/games/sdvx.ts | 6 - .../game-implementations/games/usc.test.ts | 27 +- .../src/game-implementations/games/usc.ts | 6 - .../game-implementations/games/wacca.test.ts | 28 +- .../src/game-implementations/games/wacca.ts | 21 +- .../server/src/game-implementations/types.ts | 73 +---- .../server/src/lib/targets/goal-utils.ts | 121 +------ typescript/server/src/lib/targets/goals.ts | 51 ++- 58 files changed, 991 insertions(+), 1010 deletions(-) create mode 100644 typescript/common/src/lib/goal-progress-formatters.ts create mode 100644 typescript/common/src/lib/goal-title.ts diff --git a/typescript/common/src/config/game-support/_common.ts b/typescript/common/src/config/game-support/_common.ts index 7b53e4d10..d927d6566 100644 --- a/typescript/common/src/config/game-support/_common.ts +++ b/typescript/common/src/config/game-support/_common.ts @@ -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; diff --git a/typescript/common/src/config/game-support/arcaea.ts b/typescript/common/src/config/game-support/arcaea.ts index 7779478f3..6a6c836e2 100644 --- a/typescript/common/src/config/game-support/arcaea.ts +++ b/typescript/common/src/config/game-support/arcaea.ts @@ -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.", }, diff --git a/typescript/common/src/config/game-support/bms.ts b/typescript/common/src/config/game-support/bms.ts index 8f28136eb..59231fd96 100644 --- a/typescript/common/src/config/game-support/bms.ts +++ b/typescript/common/src/config/game-support/bms.ts @@ -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.`, }, }, diff --git a/typescript/common/src/config/game-support/chunithm.ts b/typescript/common/src/config/game-support/chunithm.ts index 8679e0548..e6bd861f1 100644 --- a/typescript/common/src/config/game-support/chunithm.ts +++ b/typescript/common/src/config/game-support/chunithm.ts @@ -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: { diff --git a/typescript/common/src/config/game-support/ddr.ts b/typescript/common/src/config/game-support/ddr.ts index 1a8e985cb..7326bb5c5 100644 --- a/typescript/common/src/config/game-support/ddr.ts +++ b/typescript/common/src/config/game-support/ddr.ts @@ -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. diff --git a/typescript/common/src/config/game-support/gitadora.ts b/typescript/common/src/config/game-support/gitadora.ts index b32f58548..b36ca306c 100644 --- a/typescript/common/src/config/game-support/gitadora.ts +++ b/typescript/common/src/config/game-support/gitadora.ts @@ -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.", }, diff --git a/typescript/common/src/config/game-support/iidx.ts b/typescript/common/src/config/game-support/iidx.ts index 3dc2f5305..10dec506b 100644 --- a/typescript/common/src/config/game-support/iidx.ts +++ b/typescript/common/src/config/game-support/iidx.ts @@ -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.", }, diff --git a/typescript/common/src/config/game-support/itg.ts b/typescript/common/src/config/game-support/itg.ts index 28c144903..8fd9e5658 100644 --- a/typescript/common/src/config/game-support/itg.ts +++ b/typescript/common/src/config/game-support/itg.ts @@ -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.", }, diff --git a/typescript/common/src/config/game-support/jubeat.ts b/typescript/common/src/config/game-support/jubeat.ts index 421a46a1d..07f879472 100644 --- a/typescript/common/src/config/game-support/jubeat.ts +++ b/typescript/common/src/config/game-support/jubeat.ts @@ -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.", }, diff --git a/typescript/common/src/config/game-support/maimai-dx.ts b/typescript/common/src/config/game-support/maimai-dx.ts index bbe131874..c3e7b310f 100644 --- a/typescript/common/src/config/game-support/maimai-dx.ts +++ b/typescript/common/src/config/game-support/maimai-dx.ts @@ -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.", }, diff --git a/typescript/common/src/config/game-support/maimai.ts b/typescript/common/src/config/game-support/maimai.ts index a651d0175..4cdd2993c 100644 --- a/typescript/common/src/config/game-support/maimai.ts +++ b/typescript/common/src/config/game-support/maimai.ts @@ -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.", }, diff --git a/typescript/common/src/config/game-support/museca.ts b/typescript/common/src/config/game-support/museca.ts index 8cfc5da52..10e0b5396 100644 --- a/typescript/common/src/config/game-support/museca.ts +++ b/typescript/common/src/config/game-support/museca.ts @@ -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: { diff --git a/typescript/common/src/config/game-support/ongeki.ts b/typescript/common/src/config/game-support/ongeki.ts index cfe686603..100fb6f93 100644 --- a/typescript/common/src/config/game-support/ongeki.ts +++ b/typescript/common/src/config/game-support/ongeki.ts @@ -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: { diff --git a/typescript/common/src/config/game-support/pms.ts b/typescript/common/src/config/game-support/pms.ts index 3a6dc07fe..1d4d97597 100644 --- a/typescript/common/src/config/game-support/pms.ts +++ b/typescript/common/src/config/game-support/pms.ts @@ -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.", }, }, diff --git a/typescript/common/src/config/game-support/popn.ts b/typescript/common/src/config/game-support/popn.ts index 849cba941..7cde60ddc 100644 --- a/typescript/common/src/config/game-support/popn.ts +++ b/typescript/common/src/config/game-support/popn.ts @@ -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.", }, diff --git a/typescript/common/src/config/game-support/sdvx.ts b/typescript/common/src/config/game-support/sdvx.ts index 8d629eeb8..1d8b21738 100644 --- a/typescript/common/src/config/game-support/sdvx.ts +++ b/typescript/common/src/config/game-support/sdvx.ts @@ -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.", }, }, diff --git a/typescript/common/src/config/game-support/usc.ts b/typescript/common/src/config/game-support/usc.ts index 0ab341ec8..ead9c42d3 100644 --- a/typescript/common/src/config/game-support/usc.ts +++ b/typescript/common/src/config/game-support/usc.ts @@ -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.", }, diff --git a/typescript/common/src/config/game-support/wacca.ts b/typescript/common/src/config/game-support/wacca.ts index 28341303c..c876ffc4d 100644 --- a/typescript/common/src/config/game-support/wacca.ts +++ b/typescript/common/src/config/game-support/wacca.ts @@ -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: { diff --git a/typescript/common/src/index.ts b/typescript/common/src/index.ts index 8d77329aa..75d44ab70 100644 --- a/typescript/common/src/index.ts +++ b/typescript/common/src/index.ts @@ -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"; diff --git a/typescript/common/src/lib/goal-progress-formatters.ts b/typescript/common/src/lib/goal-progress-formatters.ts new file mode 100644 index 000000000..dd6bf67bd --- /dev/null +++ b/typescript/common/src/lib/goal-progress-formatters.ts @@ -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` 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 = ( + pb: PBScoreDocument, + goalValue: number, +) => string; + +export type GPTGoalProgressFormatters = Record< + string, + GoalProgressFormatter +>; + +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; +} = { + "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, + ), + }, +}; diff --git a/typescript/common/src/lib/goal-title.ts b/typescript/common/src/lib/goal-title.ts new file mode 100644 index 000000000..ae853ecc0 --- /dev/null +++ b/typescript/common/src/lib/goal-title.ts @@ -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, 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( + gradeBoundaries: Array>, + 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}.`, + ); +} diff --git a/typescript/common/src/types/metrics.ts b/typescript/common/src/types/metrics.ts index a7b319660..943dd20b6 100644 --- a/typescript/common/src/types/metrics.ts +++ b/typescript/common/src/types/metrics.ts @@ -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 { * "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; } /** diff --git a/typescript/seeds-scripts/lib/goals/goal-title-from-seeds.ts b/typescript/seeds-scripts/lib/goals/goal-title-from-seeds.ts index bc8aead3b..1ced7e2af 100644 --- a/typescript/seeds-scripts/lib/goals/goal-title-from-seeds.ts +++ b/typescript/seeds-scripts/lib/goals/goal-title-from-seeds.ts @@ -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, 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 string>>> -> = { - "iidx-sp": { - percent: goalFmtPercent, - score: (v) => `Get a score of ${v} on`, - }, - sdvx: { - score: goalFmtScore, - }, -}; - export interface GoalTitleContext { chartsById: Map; foldersById: Map; @@ -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); } diff --git a/typescript/server/src/game-implementations/games/_common.ts b/typescript/server/src/game-implementations/games/_common.ts index 20b346afb..ef7f83d73 100644 --- a/typescript/server/src/game-implementations/games/_common.ts +++ b/typescript/server/src/game-implementations/games/_common.ts @@ -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 = (ratings) => vfClass: IsNullish(ratings.VF7) ? null : VF7ToClass(ratings.VF7), }); -export const SDVXLIKE_GOAL_FMT: GPTGoalFormatters = { - score: GoalFmtScore, -}; - -export const SDVXLIKE_GOAL_OO_FMT: GPTGoalFormatters = { - score: GoalOutOfFmtScore, -}; - -export const SDVXLIKE_GOAL_PG_FMT: GPTGoalProgressFormatters = { - score: (pb) => FmtNum(pb.scoreData.score), - lamp: (pb) => pb.scoreData.lamp, - grade: (pb, goalValue) => - GradeGoalFormatter( - SDVXLIKE_GBOUNDARIES, - pb.scoreData.grade, - pb.scoreData.score, - SDVXLIKE_GBOUNDARIES[goalValue]!.name, - ), -}; - export const SDVXLIKE_PB_MERGERS: Array> = [ CreatePBMergeFor( "largest", @@ -283,59 +257,6 @@ export const SGL_SCORE_CALCS: GPTScoreCalcs = (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( - gradeBoundaries: Array>, - 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. * diff --git a/typescript/server/src/game-implementations/games/arcaea.test.ts b/typescript/server/src/game-implementations/games/arcaea.test.ts index bfe502add..42300915a 100644 --- a/typescript/server/src/game-implementations/games/arcaea.test.ts +++ b/typescript/server/src/game-implementations/games/arcaea.test.ts @@ -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>, 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"); }); }); diff --git a/typescript/server/src/game-implementations/games/arcaea.ts b/typescript/server/src/game-implementations/games/arcaea.ts index 7b69d3581..9b28e1688 100644 --- a/typescript/server/src/game-implementations/games/arcaea.ts +++ b/typescript/server/src/game-implementations/games/arcaea.ts @@ -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", diff --git a/typescript/server/src/game-implementations/games/bms-pms.impl.test.ts b/typescript/server/src/game-implementations/games/bms-pms.impl.test.ts index 588556b59..ddcbc7107 100644 --- a/typescript/server/src/game-implementations/games/bms-pms.impl.test.ts +++ b/typescript/server/src/game-implementations/games/bms-pms.impl.test.ts @@ -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>, 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"); }); }); diff --git a/typescript/server/src/game-implementations/games/bms-pms.ts b/typescript/server/src/game-implementations/games/bms-pms.ts index 90b73cd4f..16710f098 100644 --- a/typescript/server/src/game-implementations/games/bms-pms.ts +++ b/typescript/server/src/game-implementations/games/bms-pms.ts @@ -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 = { 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, diff --git a/typescript/server/src/game-implementations/games/chunithm.test.ts b/typescript/server/src/game-implementations/games/chunithm.test.ts index 37bd2a61a..770430788 100644 --- a/typescript/server/src/game-implementations/games/chunithm.test.ts +++ b/typescript/server/src/game-implementations/games/chunithm.test.ts @@ -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>, 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"); }); }); diff --git a/typescript/server/src/game-implementations/games/chunithm.ts b/typescript/server/src/game-implementations/games/chunithm.ts index 8935f7545..c8b786769 100644 --- a/typescript/server/src/game-implementations/games/chunithm.ts +++ b/typescript/server/src/game-implementations/games/chunithm.ts @@ -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", diff --git a/typescript/server/src/game-implementations/games/ddr.test.ts b/typescript/server/src/game-implementations/games/ddr.test.ts index 3bf310333..c1dbf07f3 100644 --- a/typescript/server/src/game-implementations/games/ddr.test.ts +++ b/typescript/server/src/game-implementations/games/ddr.test.ts @@ -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>, 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"); }); }); }); diff --git a/typescript/server/src/game-implementations/games/ddr.ts b/typescript/server/src/game-implementations/games/ddr.ts index 652df3684..93552a7cb 100644 --- a/typescript/server/src/game-implementations/games/ddr.ts +++ b/typescript/server/src/game-implementations/games/ddr.ts @@ -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 = { - score: GoalFmtScore, -}; - -const DDR_GOAL_OO_FMT: GPTGoalFormatters = { - score: GoalOutOfFmtScore, -}; - -const DDR_GOAL_PG_FMT: GPTGoalProgressFormatters = { - 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> = [ (s: ScoreDocument, chart?: ChartDocument) => { if (s.scoreData.lamp === "FAILED" || !chart || IsNullish(chart.data.stepCount)) { @@ -313,9 +287,6 @@ export const DDR_IMPL: GameImplementation = { tb4: null, tb5: null, }), - goalCriteriaFormatters: DDR_GOAL_FMT, - goalProgressFormatters: DDR_GOAL_PG_FMT, - goalOutOfFormatters: DDR_GOAL_OO_FMT, pbMergeFunctions: [ CreatePBMergeFor( "largest", diff --git a/typescript/server/src/game-implementations/games/gitadora.test.ts b/typescript/server/src/game-implementations/games/gitadora.test.ts index f9b4b674f..1062b4e0d 100644 --- a/typescript/server/src/game-implementations/games/gitadora.test.ts +++ b/typescript/server/src/game-implementations/games/gitadora.test.ts @@ -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>, 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%"); }); }); diff --git a/typescript/server/src/game-implementations/games/gitadora.ts b/typescript/server/src/game-implementations/games/gitadora.ts index 3ed5da2d5..93352c76d 100644 --- a/typescript/server/src/game-implementations/games/gitadora.ts +++ b/typescript/server/src/game-implementations/games/gitadora.ts @@ -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", diff --git a/typescript/server/src/game-implementations/games/iidx.impl.test.ts b/typescript/server/src/game-implementations/games/iidx.impl.test.ts index 92d48fe39..09a452ede 100644 --- a/typescript/server/src/game-implementations/games/iidx.impl.test.ts +++ b/typescript/server/src/game-implementations/games/iidx.impl.test.ts @@ -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>, 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"); }); }); }); diff --git a/typescript/server/src/game-implementations/games/iidx.ts b/typescript/server/src/game-implementations/games/iidx.ts index cb5e2e71c..013dc38e1 100644 --- a/typescript/server/src/game-implementations/games/iidx.ts +++ b/typescript/server/src/game-implementations/games/iidx.ts @@ -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> = [ }), ]; -const IIDX_GOAL_FMT: GPTGoalFormatters = { - percent: GoalFmtPercent, - - // don't want commas - score: (v) => `Get a score of ${v} on`, -}; - -const IIDX_GOAL_OO_FMT: GPTGoalFormatters = { - percent: GoalOutOfFmtPercent, - // don't insert commas or anything. - score: (m) => m.toString(), -}; - -const IIDX_GOAL_PG_FMT: GPTGoalProgressFormatters = { - 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, diff --git a/typescript/server/src/game-implementations/games/itg.ts b/typescript/server/src/game-implementations/games/itg.ts index 84058b2a1..f712f0f28 100644 --- a/typescript/server/src/game-implementations/games/itg.ts +++ b/typescript/server/src/game-implementations/games/itg.ts @@ -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. diff --git a/typescript/server/src/game-implementations/games/jubeat.test.ts b/typescript/server/src/game-implementations/games/jubeat.test.ts index 7cdfea872..f88e417d4 100644 --- a/typescript/server/src/game-implementations/games/jubeat.test.ts +++ b/typescript/server/src/game-implementations/games/jubeat.test.ts @@ -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>, 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%"); }); }); diff --git a/typescript/server/src/game-implementations/games/jubeat.ts b/typescript/server/src/game-implementations/games/jubeat.ts index 9806eda59..c7b1d2a74 100644 --- a/typescript/server/src/game-implementations/games/jubeat.ts +++ b/typescript/server/src/game-implementations/games/jubeat.ts @@ -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. diff --git a/typescript/server/src/game-implementations/games/maimai.test.ts b/typescript/server/src/game-implementations/games/maimai.test.ts index 77ad966be..70c2575e5 100644 --- a/typescript/server/src/game-implementations/games/maimai.test.ts +++ b/typescript/server/src/game-implementations/games/maimai.test.ts @@ -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>, 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%"); }); }); diff --git a/typescript/server/src/game-implementations/games/maimai.ts b/typescript/server/src/game-implementations/games/maimai.ts index d5aa727fb..485b3fac4 100644 --- a/typescript/server/src/game-implementations/games/maimai.ts +++ b/typescript/server/src/game-implementations/games/maimai.ts @@ -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", diff --git a/typescript/server/src/game-implementations/games/maimaidx.test.ts b/typescript/server/src/game-implementations/games/maimaidx.test.ts index 2c3b2c7d5..cb18926c3 100644 --- a/typescript/server/src/game-implementations/games/maimaidx.test.ts +++ b/typescript/server/src/game-implementations/games/maimaidx.test.ts @@ -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>, 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%"); }); }); diff --git a/typescript/server/src/game-implementations/games/maimaidx.ts b/typescript/server/src/game-implementations/games/maimaidx.ts index 364831e29..f99963197 100644 --- a/typescript/server/src/game-implementations/games/maimaidx.ts +++ b/typescript/server/src/game-implementations/games/maimaidx.ts @@ -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", diff --git a/typescript/server/src/game-implementations/games/museca.test.ts b/typescript/server/src/game-implementations/games/museca.test.ts index d6074c3e3..83b3d754b 100644 --- a/typescript/server/src/game-implementations/games/museca.test.ts +++ b/typescript/server/src/game-implementations/games/museca.test.ts @@ -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>, 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"); }); }); diff --git a/typescript/server/src/game-implementations/games/museca.ts b/typescript/server/src/game-implementations/games/museca.ts index d71ea3033..52c48772d 100644 --- a/typescript/server/src/game-implementations/games/museca.ts +++ b/typescript/server/src/game-implementations/games/museca.ts @@ -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", diff --git a/typescript/server/src/game-implementations/games/ongeki.test.ts b/typescript/server/src/game-implementations/games/ongeki.test.ts index 605e5ec71..064822c61 100644 --- a/typescript/server/src/game-implementations/games/ongeki.test.ts +++ b/typescript/server/src/game-implementations/games/ongeki.test.ts @@ -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>, 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"); }); }); diff --git a/typescript/server/src/game-implementations/games/ongeki.ts b/typescript/server/src/game-implementations/games/ongeki.ts index d292a13b3..c65917694 100644 --- a/typescript/server/src/game-implementations/games/ongeki.ts +++ b/typescript/server/src/game-implementations/games/ongeki.ts @@ -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", diff --git a/typescript/server/src/game-implementations/games/popn.test.ts b/typescript/server/src/game-implementations/games/popn.test.ts index 0f8b8f955..840bb736d 100644 --- a/typescript/server/src/game-implementations/games/popn.test.ts +++ b/typescript/server/src/game-implementations/games/popn.test.ts @@ -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>, 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"); }); }); diff --git a/typescript/server/src/game-implementations/games/popn.ts b/typescript/server/src/game-implementations/games/popn.ts index 2ae0e3d72..37d050c04 100644 --- a/typescript/server/src/game-implementations/games/popn.ts +++ b/typescript/server/src/game-implementations/games/popn.ts @@ -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", diff --git a/typescript/server/src/game-implementations/games/sdvx.test.ts b/typescript/server/src/game-implementations/games/sdvx.test.ts index 18f2db19e..fff460b27 100644 --- a/typescript/server/src/game-implementations/games/sdvx.test.ts +++ b/typescript/server/src/game-implementations/games/sdvx.test.ts @@ -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>, 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"); }); }); diff --git a/typescript/server/src/game-implementations/games/sdvx.ts b/typescript/server/src/game-implementations/games/sdvx.ts index 8223cabd9..c51ee9529 100644 --- a/typescript/server/src/game-implementations/games/sdvx.ts +++ b/typescript/server/src/game-implementations/games/sdvx.ts @@ -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", diff --git a/typescript/server/src/game-implementations/games/usc.test.ts b/typescript/server/src/game-implementations/games/usc.test.ts index ccb9434e2..7f78d7ba0 100644 --- a/typescript/server/src/game-implementations/games/usc.test.ts +++ b/typescript/server/src/game-implementations/games/usc.test.ts @@ -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>, 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"); }); }); diff --git a/typescript/server/src/game-implementations/games/usc.ts b/typescript/server/src/game-implementations/games/usc.ts index 8566ace40..eb096a944 100644 --- a/typescript/server/src/game-implementations/games/usc.ts +++ b/typescript/server/src/game-implementations/games/usc.ts @@ -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, diff --git a/typescript/server/src/game-implementations/games/wacca.test.ts b/typescript/server/src/game-implementations/games/wacca.test.ts index 49f31c6a9..17ee1c161 100644 --- a/typescript/server/src/game-implementations/games/wacca.test.ts +++ b/typescript/server/src/game-implementations/games/wacca.test.ts @@ -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>, 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"); }); }); diff --git a/typescript/server/src/game-implementations/games/wacca.ts b/typescript/server/src/game-implementations/games/wacca.ts index 693779919..d68fc009c 100644 --- a/typescript/server/src/game-implementations/games/wacca.ts +++ b/typescript/server/src/game-implementations/games/wacca.ts @@ -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", diff --git a/typescript/server/src/game-implementations/types.ts b/typescript/server/src/game-implementations/types.ts index bf689775d..9005ac899 100644 --- a/typescript/server/src/game-implementations/types.ts +++ b/typescript/server/src/game-implementations/types.ts @@ -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 = ( pb: PBScoreDocumentNoRank, ) => 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 = { - [K in keyof ConfScoreMetrics[TGame] as ConfScoreMetrics[TGame][K] extends ConfEnumScoreMetric - ? never - : K]: GoalCriteriaFormatter; -}; - -type GPTEnumGoalFormatters = { - [K in keyof ConfScoreMetrics[TGame] as ConfScoreMetrics[TGame][K] extends ConfEnumScoreMetric - ? K - : never]?: GoalCriteriaFormatterEnum; -}; - -export type GPTGoalFormatters = GPTEnumGoalFormatters & - GPTRequiredGoalFormatters; - -/** - * 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 = ( - pb: PBScoreDocument, - goalValue: integer, -) => string; - -export type GPTGoalProgressFormatters = { - [K in keyof ConfScoreMetrics[TGame]]: GoalProgressFormatter; -}; - /** * Return nothing on success, and a string * indicating what the error was on failure. @@ -219,33 +175,6 @@ export interface GameImplementation { */ classDerivers: GPTClassDerivers; - /** - * 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; - - /** - * How should we format the "outOf" part of a goal? - * - * HARD CLEAR/FULL COMBO - * ^^^^^^^^ - * this bit - */ - goalOutOfFormatters: GPTGoalFormatters; - - /** - * How should we format the progress on a goal? - * - * HARD CLEAR/FULL COMBO - * ^^^^^^^^ - * this bit - */ - goalProgressFormatters: GPTGoalProgressFormatters; - /** * How should we mutate PBs (to join best lamps, lowest BPs, etc.) for this GPT? */ diff --git a/typescript/server/src/lib/targets/goal-utils.ts b/typescript/server/src/lib/targets/goal-utils.ts index f401ba4ed..30ed827d3 100644 --- a/typescript/server/src/lib/targets/goal-utils.ts +++ b/typescript/server/src/lib/targets/goal-utils.ts @@ -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( - criteria: GoalDocument["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") { diff --git a/typescript/server/src/lib/targets/goals.ts b/typescript/server/src/lib/targets/goals.ts index c70d67531..700deea59 100644 --- a/typescript/server/src/lib/targets/goals.ts +++ b/typescript/server/src/lib/targets/goals.ts @@ -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); } /**