diff --git a/common/src/config/game-support/_common.ts b/common/src/config/game-support/_common.ts index 917c56b8a..717a29a88 100644 --- a/common/src/config/game-support/_common.ts +++ b/common/src/config/game-support/_common.ts @@ -2,6 +2,7 @@ * Common stuff for that all games might want to inherit. */ +import { p } from "prudence"; import type { ConfScoreMetric } from "../../types/metrics"; /** @@ -19,7 +20,7 @@ import type { ConfScoreMetric } from "../../types/metrics"; * as that necessitates that you either can't miss, or can't hit. */ export const FAST_SLOW_MAXCOMBO = { - fast: { type: "INTEGER" }, - slow: { type: "INTEGER" }, - maxCombo: { type: "INTEGER" }, + fast: { type: "INTEGER", validate: p.isPositiveInteger }, + slow: { type: "INTEGER", validate: p.isPositiveInteger }, + maxCombo: { type: "INTEGER", validate: p.isPositiveInteger }, } as const satisfies Record; diff --git a/common/src/config/game-support/bms.ts b/common/src/config/game-support/bms.ts index 51129db71..af00dc4b8 100644 --- a/common/src/config/game-support/bms.ts +++ b/common/src/config/game-support/bms.ts @@ -1,5 +1,6 @@ import { FAST_SLOW_MAXCOMBO } from "./_common"; import { ClassValue, zodNonNegativeInt } from "../config-utils"; +import { p } from "prudence"; import { z } from "zod"; import type { INTERNAL_GAME_CONFIG, INTERNAL_GAME_PT_CONFIG } from "../../types/internals"; @@ -134,7 +135,7 @@ const RANDOM_SCHEMA = z.enum(["MIRROR", "NONRAN", "R-RANDOM", "RANDOM", "S-RANDO export const BMS_7K_CONF = { providedMetrics: { - score: { type: "INTEGER" }, + score: { type: "INTEGER", chartDependentMax: true }, lamp: { type: "ENUM", @@ -162,7 +163,7 @@ export const BMS_7K_CONF = { // if #RANDOM is to ever be supported, the user's percent would become // a *mandatory* metric, as a chart's notecount can be completely unknown. // However, supporting #RANDOM is an awful pain, so I don't really care. - percent: { type: "DECIMAL" }, + percent: { type: "DECIMAL", validate: p.isBetween(0, 100) }, }, defaultMetric: "percent", @@ -170,19 +171,19 @@ export const BMS_7K_CONF = { optionalMetrics: { ...FAST_SLOW_MAXCOMBO, - bp: { type: "INTEGER" }, - gauge: { type: "DECIMAL" }, - gaugeHistory: { type: "GRAPH" }, - epg: { type: "INTEGER" }, - egr: { type: "INTEGER" }, - egd: { type: "INTEGER" }, - ebd: { type: "INTEGER" }, - epr: { type: "INTEGER" }, - lpg: { type: "INTEGER" }, - lgr: { type: "INTEGER" }, - lgd: { type: "INTEGER" }, - lbd: { type: "INTEGER" }, - lpr: { type: "INTEGER" }, + bp: { type: "INTEGER", validate: p.isPositive }, + gauge: { type: "DECIMAL", validate: p.isBetween(0, 100) }, + gaugeHistory: { type: "GRAPH", validate: p.isBetween(0, 100) }, + epg: { type: "INTEGER", validate: p.isPositive }, + egr: { type: "INTEGER", validate: p.isPositive }, + egd: { type: "INTEGER", validate: p.isPositive }, + ebd: { type: "INTEGER", validate: p.isPositive }, + epr: { type: "INTEGER", validate: p.isPositive }, + lpg: { type: "INTEGER", validate: p.isPositive }, + lgr: { type: "INTEGER", validate: p.isPositive }, + lgd: { type: "INTEGER", validate: p.isPositive }, + lbd: { type: "INTEGER", validate: p.isPositive }, + lpr: { type: "INTEGER", validate: p.isPositive }, }, scoreRatingAlgs: { diff --git a/common/src/config/game-support/chunithm.ts b/common/src/config/game-support/chunithm.ts index 0a2721b3f..deca04e73 100644 --- a/common/src/config/game-support/chunithm.ts +++ b/common/src/config/game-support/chunithm.ts @@ -1,5 +1,6 @@ import { FAST_SLOW_MAXCOMBO } from "./_common"; import { ClassValue, zodNonNegativeInt } from "../config-utils"; +import { p } from "prudence"; import { z } from "zod"; import type { INTERNAL_GAME_CONFIG, INTERNAL_GAME_PT_CONFIG } from "../../types/internals"; @@ -28,7 +29,7 @@ export const CHUNITHMColours = [ export const CHUNITHM_SINGLE_CONF = { providedMetrics: { - score: { type: "INTEGER" }, + score: { type: "INTEGER", validate: p.isBetween(0, 1_010_000) }, lamp: { type: "ENUM", values: ["FAILED", "CLEAR", "FULL COMBO", "ALL JUSTICE", "ALL JUSTICE CRITICAL"], diff --git a/common/src/config/game-support/gitadora.ts b/common/src/config/game-support/gitadora.ts index 0be9deac0..a1084c8f3 100644 --- a/common/src/config/game-support/gitadora.ts +++ b/common/src/config/game-support/gitadora.ts @@ -1,5 +1,6 @@ import { FAST_SLOW_MAXCOMBO } from "./_common"; import { ClassValue, zodNonNegativeInt } from "../config-utils"; +import { p } from "prudence"; import { z } from "zod"; import type { INTERNAL_GAME_CONFIG, INTERNAL_GAME_PT_CONFIG } from "../../types/internals"; @@ -35,7 +36,7 @@ const GitadoraColours = [ export const GITADORA_GITA_CONF = { providedMetrics: { - percent: { type: "DECIMAL" }, + percent: { type: "DECIMAL", validate: p.isBetween(0, 100) }, lamp: { type: "ENUM", values: ["FAILED", "CLEAR", "FULL COMBO", "EXCELLENT"], diff --git a/common/src/config/game-support/iidx.ts b/common/src/config/game-support/iidx.ts index 9d7ee4827..eb65ec3ef 100644 --- a/common/src/config/game-support/iidx.ts +++ b/common/src/config/game-support/iidx.ts @@ -1,5 +1,6 @@ import { FAST_SLOW_MAXCOMBO } from "./_common"; import { ClassValue, zodNonNegativeInt, zodTierlistData } from "../config-utils"; +import { p } from "prudence"; import { z } from "zod"; import type { INTERNAL_GAME_CONFIG, INTERNAL_GAME_PT_CONFIG } from "../../types/internals"; @@ -49,7 +50,7 @@ const RANDOM_SCHEMA = z.enum(["NONRAN", "MIRROR", "R-RANDOM", "RANDOM", "S-RANDO export const IIDX_SP_CONF = { providedMetrics: { - score: { type: "INTEGER" }, + score: { type: "INTEGER", chartDependentMax: true }, lamp: { type: "ENUM", values: [ @@ -69,6 +70,7 @@ export const IIDX_SP_CONF = { derivedMetrics: { percent: { type: "DECIMAL", + validate: p.isBetween(0, 100), }, grade: { type: "ENUM", @@ -83,21 +85,21 @@ export const IIDX_SP_CONF = { optionalMetrics: { ...FAST_SLOW_MAXCOMBO, - bp: { type: "INTEGER" }, - gauge: { type: "DECIMAL" }, - comboBreak: { type: "INTEGER" }, + bp: { type: "INTEGER", validate: p.isPositive }, + gauge: { type: "DECIMAL", validate: p.isBetween(0, 100) }, + comboBreak: { type: "INTEGER", validate: p.isPositive }, // The players history for the gauge type they were playing on. // this may fall into "NULL" if the user fails. - gaugeHistory: { type: "NULLABLE_GRAPH" }, - scoreHistory: { type: "NULLABLE_GRAPH" }, + gaugeHistory: { type: "NULLABLE_GRAPH", validate: p.isBetween(0, 100) }, + scoreHistory: { type: "NULLABLE_GRAPH", validate: p.isBetween(0, 100) }, // if "GSM" is enabled (via fervidex.dll) then all graphs // are sent. we should store all of them. - gsmEasy: { type: "NULLABLE_GRAPH" }, - gsmNormal: { type: "NULLABLE_GRAPH" }, - gsmHard: { type: "NULLABLE_GRAPH" }, - gsmEXHard: { type: "NULLABLE_GRAPH" }, + gsmEasy: { type: "NULLABLE_GRAPH", validate: p.isBetween(0, 100) }, + gsmNormal: { type: "NULLABLE_GRAPH", validate: p.isBetween(0, 100) }, + gsmHard: { type: "NULLABLE_GRAPH", validate: p.isBetween(0, 100) }, + gsmEXHard: { type: "NULLABLE_GRAPH", validate: p.isBetween(0, 100) }, }, defaultScoreRatingAlg: "ktLampRating", diff --git a/common/src/config/game-support/itg.ts b/common/src/config/game-support/itg.ts index 04e8760e3..127340a59 100644 --- a/common/src/config/game-support/itg.ts +++ b/common/src/config/game-support/itg.ts @@ -1,4 +1,5 @@ import { NoDecimalPlace, zodNonNegativeInt } from "../config-utils"; +import { p } from "prudence"; import { z } from "zod"; import type { INTERNAL_GAME_CONFIG, INTERNAL_GAME_PT_CONFIG } from "../../types/internals"; @@ -14,13 +15,13 @@ export const ITG_CONF = { export const ITG_STAMINA_CONF = { providedMetrics: { - scorePercent: { type: "DECIMAL" }, + scorePercent: { type: "DECIMAL", validate: p.isBetween(0, 100) }, // How far through the chart did they get? // 100 means they cleared. // 50 means they got halfway through. // 0 means they died instantly etc. - survivedPercent: { type: "DECIMAL" }, + survivedPercent: { type: "DECIMAL", validate: p.isBetween(0, 100) }, lamp: { type: "ENUM", @@ -46,11 +47,14 @@ export const ITG_STAMINA_CONF = { // #1 CLEARED with 30% (130) // #2 FAILED 95% in with 30% (95) // #3 FAILED 40% in with 70% (40) - finalPercent: { type: "DECIMAL" }, + // + // NOTE that in our ITG implementation we don't allow for negative percents + // as it breaks this silly metric. Also negative percents are stupid. + finalPercent: { type: "DECIMAL", validate: p.isBetween(0, 200) }, }, optionalMetrics: { - lifebarHistory: { type: "GRAPH" }, + lifebarHistory: { type: "GRAPH", validate: p.isBetween(0, 100) }, }, defaultMetric: "finalPercent", diff --git a/common/src/config/game-support/jubeat.ts b/common/src/config/game-support/jubeat.ts index 6dc010a08..c0bfa996f 100644 --- a/common/src/config/game-support/jubeat.ts +++ b/common/src/config/game-support/jubeat.ts @@ -1,5 +1,6 @@ import { FAST_SLOW_MAXCOMBO } from "./_common"; import { ClassValue, zodNonNegativeInt } from "../config-utils"; +import { p } from "prudence"; import { z } from "zod"; import type { INTERNAL_GAME_CONFIG, INTERNAL_GAME_PT_CONFIG } from "../../types/internals"; @@ -27,8 +28,8 @@ const JubeatColours = [ export const JUBEAT_SINGLE_CONF = { providedMetrics: { - score: { type: "INTEGER" }, - musicRate: { type: "DECIMAL" }, + score: { type: "INTEGER", validate: p.isBetween(0, 1_000_000) }, + musicRate: { type: "DECIMAL", chartDependentMax: true }, lamp: { type: "ENUM", values: ["FAILED", "CLEAR", "FULL COMBO", "EXCELLENT"], @@ -90,17 +91,7 @@ export const JUBEAT_SINGLE_CONF = { orderedJudgements: ["perfect", "great", "good", "poor", "miss"], - versions: [ - "jubeat", - "ripples", - "knit", - "copious", - "saucer", - "prop", - "Qubell", - "clan", - "festo", - ], + versions: ["jubeat", "ripples", "knit", "copious", "saucer", "prop", "Qubell", "clan", "festo"], chartData: z.strictObject({ inGameID: z.union([z.array(zodNonNegativeInt), zodNonNegativeInt]), diff --git a/common/src/config/game-support/maimai-dx.ts b/common/src/config/game-support/maimai-dx.ts index 3abdc9db1..a84373e66 100644 --- a/common/src/config/game-support/maimai-dx.ts +++ b/common/src/config/game-support/maimai-dx.ts @@ -1,5 +1,6 @@ import { FAST_SLOW_MAXCOMBO } from "./_common"; import { ClassValue, NoDecimalPlace } from "../config-utils"; +import { p } from "prudence"; import { z } from "zod"; import type { INTERNAL_GAME_CONFIG, INTERNAL_GAME_PT_CONFIG } from "../../types/internals"; @@ -56,7 +57,7 @@ const MaimaiDXColours = [ export const MAIMAI_DX_SINGLE_CONF = { providedMetrics: { - percent: { type: "DECIMAL" }, + percent: { type: "DECIMAL", validate: p.isBetween(0, 101) }, lamp: { type: "ENUM", values: ["FAILED", "CLEAR", "FULL COMBO", "FULL COMBO+", "ALL PERFECT", "ALL PERFECT+"], diff --git a/common/src/config/game-support/museca.ts b/common/src/config/game-support/museca.ts index 13720e7a1..fc0490ad3 100644 --- a/common/src/config/game-support/museca.ts +++ b/common/src/config/game-support/museca.ts @@ -1,5 +1,6 @@ import { FAST_SLOW_MAXCOMBO } from "./_common"; import { NoDecimalPlace, zodNonNegativeInt } from "../config-utils"; +import { p } from "prudence"; import { z } from "zod"; import type { INTERNAL_GAME_CONFIG, INTERNAL_GAME_PT_CONFIG } from "../../types/internals"; @@ -16,7 +17,7 @@ export const MUSECA_CONF = { export const MUSECA_SINGLE_CONF = { providedMetrics: { - score: { type: "INTEGER" }, + score: { type: "INTEGER", validate: p.isBetween(0, 1_000_000) }, lamp: { type: "ENUM", values: ["FAILED", "CLEAR", "CONNECT ALL", "PERFECT CONNECT ALL"], diff --git a/common/src/config/game-support/pms.ts b/common/src/config/game-support/pms.ts index 64066f752..1f82dab4b 100644 --- a/common/src/config/game-support/pms.ts +++ b/common/src/config/game-support/pms.ts @@ -41,7 +41,7 @@ function FormatSieglindePMS(sgl: number): string { export const PMS_CONTROLLER_CONF = { providedMetrics: { - score: { type: "INTEGER" }, + score: { type: "INTEGER", chartDependentMax: true }, lamp: { type: "ENUM", diff --git a/common/src/config/game-support/popn.ts b/common/src/config/game-support/popn.ts index ea34e1d02..57256b2f0 100644 --- a/common/src/config/game-support/popn.ts +++ b/common/src/config/game-support/popn.ts @@ -1,5 +1,6 @@ import { FAST_SLOW_MAXCOMBO } from "./_common"; import { ClassValue, zodNonNegativeInt } from "../config-utils"; +import { p } from "prudence"; import { z } from "zod"; import type { INTERNAL_GAME_CONFIG, INTERNAL_GAME_PT_CONFIG } from "../../types/internals"; @@ -27,7 +28,7 @@ const PopnClasses = [ export const POPN_9B_CONF = { providedMetrics: { - score: { type: "INTEGER" }, + score: { type: "INTEGER", validate: p.isBetween(0, 100_000) }, clearMedal: { type: "ENUM", values: [ @@ -69,7 +70,7 @@ export const POPN_9B_CONF = { optionalMetrics: { ...FAST_SLOW_MAXCOMBO, - gauge: { type: "INTEGER" }, + gauge: { type: "INTEGER", validate: p.isBetween(0, 100) }, }, scoreRatingAlgs: { diff --git a/common/src/config/game-support/sdvx.ts b/common/src/config/game-support/sdvx.ts index 3d96e19a7..590164ccb 100644 --- a/common/src/config/game-support/sdvx.ts +++ b/common/src/config/game-support/sdvx.ts @@ -1,5 +1,6 @@ import { FAST_SLOW_MAXCOMBO } from "./_common"; import { ClassValue, ToDecimalPlaces, zodNonNegativeInt, zodTierlistData } from "../config-utils"; +import { p } from "prudence"; import { z } from "zod"; import type { INTERNAL_GAME_CONFIG, INTERNAL_GAME_PT_CONFIG } from "../../types/internals"; @@ -72,7 +73,7 @@ export const SDVXVFClasses = [ export const SDVX_SINGLE_CONF = { providedMetrics: { - score: { type: "INTEGER" }, + score: { type: "INTEGER", validate: p.isBetween(0, 10_000_000) }, lamp: { type: "ENUM", values: [ @@ -99,8 +100,8 @@ export const SDVX_SINGLE_CONF = { optionalMetrics: { ...FAST_SLOW_MAXCOMBO, - exScore: { type: "INTEGER" }, - gauge: { type: "DECIMAL" }, + exScore: { type: "INTEGER", chartDependentMax: true }, + gauge: { type: "DECIMAL", validate: p.isBetween(0, 100) }, }, scoreRatingAlgs: { diff --git a/common/src/config/game-support/usc.ts b/common/src/config/game-support/usc.ts index a6964d364..559883ea3 100644 --- a/common/src/config/game-support/usc.ts +++ b/common/src/config/game-support/usc.ts @@ -1,6 +1,7 @@ import { FAST_SLOW_MAXCOMBO } from "./_common"; import { SDVXVFClasses } from "./sdvx"; import { ToDecimalPlaces } from "../config-utils"; +import { p } from "prudence"; import { z } from "zod"; import type { INTERNAL_GAME_CONFIG, INTERNAL_GAME_PT_CONFIG } from "../../types/internals"; @@ -13,7 +14,7 @@ export const USC_CONF = { export const USC_CONTROLLER_CONF = { providedMetrics: { - score: { type: "INTEGER" }, + score: { type: "INTEGER", validate: p.isBetween(0, 10_000_000) }, lamp: { type: "ENUM", values: [ @@ -40,7 +41,7 @@ export const USC_CONTROLLER_CONF = { optionalMetrics: { ...FAST_SLOW_MAXCOMBO, - gauge: { type: "DECIMAL" }, + gauge: { type: "DECIMAL", validate: p.isBetween(0, 100) }, }, scoreRatingAlgs: { diff --git a/common/src/config/game-support/wacca.ts b/common/src/config/game-support/wacca.ts index 096c9ca6e..83a933624 100644 --- a/common/src/config/game-support/wacca.ts +++ b/common/src/config/game-support/wacca.ts @@ -1,5 +1,6 @@ import { FAST_SLOW_MAXCOMBO } from "./_common"; import { ClassValue } from "../config-utils"; +import { p } from "prudence"; import { z } from "zod"; import type { INTERNAL_GAME_CONFIG, INTERNAL_GAME_PT_CONFIG } from "../../types/internals"; @@ -46,7 +47,7 @@ export const WaccaColours = [ export const WACCA_SINGLE_CONF = { providedMetrics: { - score: { type: "INTEGER" }, + score: { type: "INTEGER", validate: p.isBetween(0, 1_000_000) }, lamp: { type: "ENUM", values: ["FAILED", "CLEAR", "MISSLESS", "FULL COMBO", "ALL MARVELOUS"], diff --git a/common/src/types/documents.ts b/common/src/types/documents.ts index 4368fec7f..0ad0362f0 100644 --- a/common/src/types/documents.ts +++ b/common/src/types/documents.ts @@ -471,7 +471,8 @@ export interface PBReference { } export interface PBScoreDocument { - composedFrom: Array; + // guaranteed to atleast have one element. + composedFrom: [PBReference, ...Array]; rankingData: { rank: integer; outOf: integer; diff --git a/common/src/types/metrics.ts b/common/src/types/metrics.ts index a71833edb..b2b4edb71 100644 --- a/common/src/types/metrics.ts +++ b/common/src/types/metrics.ts @@ -20,27 +20,48 @@ import type { // - Additional: They **may** exist. We want to store them if they exist, but don't // mandate their existence (i.e. fast/slow/maxCombo) -export type DecimalMetricValidator = ( - metric: number, - chart: ChartDocument -) => string | true; -export type IntegerMetricValidator = ( - metric: integer, - chart: ChartDocument -) => string | true; -export type GraphMetricValidator = ( - metric: Array, - chart: ChartDocument -) => string | true; - -export interface ConfDecimalScoreMetric { +interface ConfDecimalScoreMetricNormal { type: "DECIMAL"; + + validate: (v: number) => string | true; } -export interface ConfIntegerScoreMetric { +interface ConfIntegerScoreMetricNormal { type: "INTEGER"; + + validate: (v: number) => string | true; } +interface ConfDecimalScoreMetricChartDependent { + type: "DECIMAL"; + + /** + * Is the maximum/minimum value of this metric chart dependent? + * + * @example: IIDX's EX Score is upperbounded at 2x the chart's notecount. + */ + chartDependentMax: true; +} + +interface ConfIntegerScoreMetricChartDependent { + type: "INTEGER"; + + /** + * Is the maximum/minimum value of this metric chart dependent? + * + * @example: IIDX's EX Score is upperbounded at 2x the chart's notecount. + */ + chartDependentMax: true; +} + +export type ConfDecimalScoreMetric = + | ConfDecimalScoreMetricChartDependent + | ConfDecimalScoreMetricNormal; + +export type ConfIntegerScoreMetric = + | ConfIntegerScoreMetricChartDependent + | ConfIntegerScoreMetricNormal; + /** * A metric for a score that represents an enum. * @@ -60,6 +81,9 @@ export interface ConfEnumScoreMetric { */ export interface ConfGraphScoreMetric { type: "GRAPH"; + + validate: (v: number) => string | true; + size?: (v: number) => string | true; } /** @@ -67,6 +91,9 @@ export interface ConfGraphScoreMetric { */ export interface ConfNullableGraphScoreMetric { type: "NULLABLE_GRAPH"; + + validate: (v: number) => string | true; + size?: (v: number) => string | true; } export type ConfScoreMetric = @@ -116,7 +143,7 @@ export type ExtractEnumMetricNames> = /** * What are all the metrics available for this GPT? */ -export type AllMetrics = { +export type AllConfMetrics = { [GPT in GPTString]: ConfDerivedMetrics[GPT] & ConfOptionalMetrics[GPT] & ConfProvidedMetrics[GPT]; @@ -129,8 +156,10 @@ export type AllMetrics = { */ export type GetEnumValue< GPT extends GPTString, - MetricName extends ExtractEnumMetricNames -> = AllMetrics[GPT][MetricName] extends ConfEnumScoreMetric ? EnumValues : never; + MetricName extends ExtractEnumMetricNames +> = AllConfMetrics[GPT][MetricName] extends ConfEnumScoreMetric + ? EnumValues + : never; /** * Turn a record of ConfigScoreMetrics into their actual literal values. diff --git a/server/src/external/mongo/indexes.ts b/server/src/external/mongo/indexes.ts index f9b01be7a..0361dfa55 100644 --- a/server/src/external/mongo/indexes.ts +++ b/server/src/external/mongo/indexes.ts @@ -34,7 +34,7 @@ const staticIndexes: Partial>> = { ], "personal-bests": [ index({ chartID: 1, userID: 1 }, UNIQUE), - index({ chartID: 1, "scoreData.percent": 1 }), + index({ chartID: 1, [`scoreData.${gptConfig.defaultMetric}`]: 1 }), index({ userID: 1, game: 1, playtype: 1 }), // activity diff --git a/server/src/game-implementations/game-implementations.ts b/server/src/game-implementations/game-implementations.ts new file mode 100644 index 000000000..f160f6e28 --- /dev/null +++ b/server/src/game-implementations/game-implementations.ts @@ -0,0 +1,67 @@ +import { IIDXLIKE_DERIVERS, IIDXLIKE_VALIDATORS, SDVXLIKE_DERIVERS } from "./games/_common"; +import { CHUNITHM_IMPL } from "./games/chunithm"; +import { GITADORA_DORA_IMPL, GITADORA_GITA_IMPL } from "./games/gitadora"; +import { ITG_STAMINA_IMPL } from "./games/itg"; +import { JUBEAT_IMPL } from "./games/jubeat"; +import { MAIMAIDX_IMPL } from "./games/maimaidx"; +import { MUSECA_IMPL } from "./games/museca"; +import { POPN_9B_IMPL } from "./games/popn"; +import { WACCA_IMPL } from "./games/wacca"; +import type { GPTImplementations } from "./types"; + +/** + * Server-Specific implementation details for games. These handle things like validating + * input for chart-specific metrics (i.e EXScore in IIDX is upper-bounded by + * a chart's notecount * 2) and also instructions on how to derive metrics from + * the provided metrics. + * + * Basically, anything that can't be done in the common config, specific to the server. + */ +export const GPT_SERVER_IMPLEMENTATIONS: GPTImplementations = { + "bms:14K": { + derivers: IIDXLIKE_DERIVERS, + validators: IIDXLIKE_VALIDATORS, + }, + "bms:7K": { + derivers: IIDXLIKE_DERIVERS, + validators: IIDXLIKE_VALIDATORS, + }, + "pms:Controller": { + derivers: IIDXLIKE_DERIVERS, + validators: IIDXLIKE_VALIDATORS, + }, + "pms:Keyboard": { + derivers: IIDXLIKE_DERIVERS, + validators: IIDXLIKE_VALIDATORS, + }, + "iidx:SP": { + derivers: IIDXLIKE_DERIVERS, + validators: IIDXLIKE_VALIDATORS, + }, + "iidx:DP": { + derivers: IIDXLIKE_DERIVERS, + validators: IIDXLIKE_VALIDATORS, + }, + + "wacca:Single": WACCA_IMPL, + "chunithm:Single": CHUNITHM_IMPL, + "gitadora:Dora": GITADORA_DORA_IMPL, + "gitadora:Gita": GITADORA_GITA_IMPL, + "itg:Stamina": ITG_STAMINA_IMPL, + "jubeat:Single": JUBEAT_IMPL, + "maimaidx:Single": MAIMAIDX_IMPL, + "museca:Single": MUSECA_IMPL, + "popn:9B": POPN_9B_IMPL, + "sdvx:Single": { + derivers: SDVXLIKE_DERIVERS, + validators: { + exScore: (exScore, chart) => { + // gotta figure this out somehow? + throw new Error(`Unimplemented.`); + }, + }, + }, + + "usc:Controller": { derivers: SDVXLIKE_DERIVERS, validators: {} }, + "usc:Keyboard": { derivers: SDVXLIKE_DERIVERS, validators: {} }, +}; diff --git a/server/src/game-implementations/games/_common.ts b/server/src/game-implementations/games/_common.ts new file mode 100644 index 000000000..315023f4d --- /dev/null +++ b/server/src/game-implementations/games/_common.ts @@ -0,0 +1,66 @@ +import { InternalFailure } from "lib/score-import/framework/common/converter-failures"; +import { SDVXLIKE_GBOUNDARIES, IIDXLIKE_GBOUNDARIES } from "tachi-common"; +import type { + ChartSpecificMetricValidator, + GPTDerivers, + GPTMetricValidators, +} from "game-implementations/types"; +import type { GradeBoundary, GPTStrings, integer } from "tachi-common"; + +/** + * Util for getting a games' grade for a given score. + */ +export function GetGrade(grades: Array>, score: number): G { + // sort grades going downwards in their boundaries. + const descendingGrades = grades.slice(0).sort((a, b) => b.lowerBound - a.lowerBound); + + for (const { name, lowerBound } of descendingGrades) { + if (score >= lowerBound) { + return name; + } + } + + throw new InternalFailure(`Could not resolve grade for score ${score}.`); +} + +export const EX_SCORE_CHECK: ChartSpecificMetricValidator = (exScore, chart) => { + if (exScore < 0) { + return `EX Score cannot be negative. Got ${exScore}.`; + } + + if (exScore > chart.data.notecount * 2) { + return `EX Score cannot be greater than ${ + chart.data.notecount * 2 + } for this chart. Got ${exScore}.`; + } + + return true; +}; + +function calculateIIDXLikePercent(exScore: integer, notecount: integer) { + return exScore / (notecount * 2); +} + +type IIDXLikes = GPTStrings["bms" | "iidx" | "pms"]; + +/** + * Derivers for both IIDX SP and DP. + * + * and BMS. and PMS. They use the same things. + */ +export const IIDXLIKE_DERIVERS: GPTDerivers = { + percent: ({ score }, chart) => calculateIIDXLikePercent(score, chart.data.notecount), + grade: ({ score }, chart) => { + const percent = calculateIIDXLikePercent(score, chart.data.notecount); + + return GetGrade(IIDXLIKE_GBOUNDARIES, percent); + }, +}; + +export const IIDXLIKE_VALIDATORS: GPTMetricValidators = { score: EX_SCORE_CHECK }; + +type SDVXLikes = GPTStrings["sdvx" | "usc"]; + +export const SDVXLIKE_DERIVERS: GPTDerivers = { + grade: ({ score }) => GetGrade(SDVXLIKE_GBOUNDARIES, score), +}; diff --git a/server/src/game-implementations/games/chunithm.ts b/server/src/game-implementations/games/chunithm.ts new file mode 100644 index 000000000..becff5aa6 --- /dev/null +++ b/server/src/game-implementations/games/chunithm.ts @@ -0,0 +1,10 @@ +import { GetGrade } from "./_common"; +import { CHUNITHM_GBOUNDARIES } from "tachi-common"; +import type { GPTServerImplementation } from "game-implementations/types"; + +export const CHUNITHM_IMPL: GPTServerImplementation<"chunithm:Single"> = { + validators: {}, + derivers: { + grade: ({ score }) => GetGrade(CHUNITHM_GBOUNDARIES, score), + }, +}; diff --git a/server/src/game-implementations/games/gitadora.ts b/server/src/game-implementations/games/gitadora.ts new file mode 100644 index 000000000..18b38ed1c --- /dev/null +++ b/server/src/game-implementations/games/gitadora.ts @@ -0,0 +1,17 @@ +import { GetGrade } from "./_common"; +import { GITADORA_GBOUNDARIES } from "tachi-common"; +import type { GPTServerImplementation } from "game-implementations/types"; + +export const GITADORA_GITA_IMPL: GPTServerImplementation<"gitadora:Gita"> = { + validators: {}, + derivers: { + grade: ({ percent }) => GetGrade(GITADORA_GBOUNDARIES, percent), + }, +}; + +export const GITADORA_DORA_IMPL: GPTServerImplementation<"gitadora:Dora"> = { + validators: {}, + derivers: { + grade: ({ percent }) => GetGrade(GITADORA_GBOUNDARIES, percent), + }, +}; diff --git a/server/src/game-implementations/games/itg.ts b/server/src/game-implementations/games/itg.ts new file mode 100644 index 000000000..b7f5f91df --- /dev/null +++ b/server/src/game-implementations/games/itg.ts @@ -0,0 +1,27 @@ +import { GetGrade } from "./_common"; +import { ITG_GBOUNDARIES } from "tachi-common"; +import type { GPTServerImplementation } from "game-implementations/types"; + +export const ITG_STAMINA_IMPL: GPTServerImplementation<"itg:Stamina"> = { + validators: {}, + derivers: { + finalPercent: (metrics) => { + // *important* + // don't check if metrics.survivedPercent === 100, as due to floating + // point inaccuracies, it's possible to have a 100% fail + // (on extremely long charts, for example) + if (metrics.lamp === "FAILED") { + return metrics.survivedPercent; + } + + return 100 + metrics.scorePercent; + }, + grade: ({ scorePercent, lamp }) => { + if (lamp === "FAILED") { + return "F"; + } + + return GetGrade(ITG_GBOUNDARIES, scorePercent); + }, + }, +}; diff --git a/server/src/game-implementations/games/jubeat.ts b/server/src/game-implementations/games/jubeat.ts new file mode 100644 index 000000000..59c171344 --- /dev/null +++ b/server/src/game-implementations/games/jubeat.ts @@ -0,0 +1,25 @@ +import { GetGrade } from "./_common"; +import { p } from "prudence"; +import { JUBEAT_GBOUNDARIES } from "tachi-common"; +import type { GPTServerImplementation } from "game-implementations/types"; + +export const JUBEAT_IMPL: GPTServerImplementation<"jubeat:Single"> = { + validators: { + musicRate: (rate, chart) => { + switch (chart.difficulty) { + case "BSC": + case "ADV": + case "EXT": + return p.isBetween(0, 100)(rate); + + case "HARD BSC": + case "HARD ADV": + case "HARD EXT": + return p.isBetween(0, 120)(rate); + } + }, + }, + derivers: { + grade: ({ score }) => GetGrade(JUBEAT_GBOUNDARIES, score), + }, +}; diff --git a/server/src/game-implementations/games/maimaidx.ts b/server/src/game-implementations/games/maimaidx.ts new file mode 100644 index 000000000..1c6d86e8f --- /dev/null +++ b/server/src/game-implementations/games/maimaidx.ts @@ -0,0 +1,10 @@ +import { GetGrade } from "./_common"; +import { MAIMAIDX_GBOUNDARIES } from "tachi-common"; +import type { GPTServerImplementation } from "game-implementations/types"; + +export const MAIMAIDX_IMPL: GPTServerImplementation<"maimaidx:Single"> = { + validators: {}, + derivers: { + grade: ({ percent }) => GetGrade(MAIMAIDX_GBOUNDARIES, percent), + }, +}; diff --git a/server/src/game-implementations/games/museca.ts b/server/src/game-implementations/games/museca.ts new file mode 100644 index 000000000..c106f8542 --- /dev/null +++ b/server/src/game-implementations/games/museca.ts @@ -0,0 +1,10 @@ +import { GetGrade } from "./_common"; +import { MUSECA_GBOUNDARIES } from "tachi-common"; +import type { GPTServerImplementation } from "game-implementations/types"; + +export const MUSECA_IMPL: GPTServerImplementation<"museca:Single"> = { + validators: {}, + derivers: { + grade: ({ score }) => GetGrade(MUSECA_GBOUNDARIES, score), + }, +}; diff --git a/server/src/game-implementations/games/popn.ts b/server/src/game-implementations/games/popn.ts new file mode 100644 index 000000000..8f213cdf9 --- /dev/null +++ b/server/src/game-implementations/games/popn.ts @@ -0,0 +1,47 @@ +import { GetGrade } from "./_common"; +import { POPN_GBOUNDARIES } from "tachi-common"; +import type { GPTServerImplementation } from "game-implementations/types"; +import type { GetEnumValue } from "tachi-common/types/metrics"; + +export function PopnClearMedalToLamp( + clearMedal: GetEnumValue<"popn:9B", "clearMedal"> +): GetEnumValue<"popn:9B", "lamp"> { + switch (clearMedal) { + case "perfect": + return "PERFECT"; + case "fullComboCircle": + case "fullComboDiamond": + case "fullComboStar": + return "FULL COMBO"; + case "clearCircle": + case "clearDiamond": + case "clearStar": + return "CLEAR"; + case "easyClear": + return "EASY CLEAR"; + case "failedCircle": + case "failedDiamond": + case "failedStar": + return "FAILED"; + } +} + +export const POPN_9B_IMPL: GPTServerImplementation<"popn:9B"> = { + validators: {}, + derivers: { + lamp: ({ clearMedal }) => PopnClearMedalToLamp(clearMedal), + grade: ({ score, clearMedal }) => { + const gradeString = GetGrade(POPN_GBOUNDARIES, score); + + // lol double-calc + const lamp = PopnClearMedalToLamp(clearMedal); + + // grades are kneecapped at "A" if you failed. + if (score >= 90_000 && lamp === "FAILED") { + return "A"; + } + + return gradeString; + }, + }, +}; diff --git a/server/src/game-implementations/games/usc.ts b/server/src/game-implementations/games/usc.ts new file mode 100644 index 000000000..e69de29bb diff --git a/server/src/game-implementations/games/wacca.ts b/server/src/game-implementations/games/wacca.ts new file mode 100644 index 000000000..f76b4fc6e --- /dev/null +++ b/server/src/game-implementations/games/wacca.ts @@ -0,0 +1,10 @@ +import { GetGrade } from "./_common"; +import { WACCA_GBOUNDARIES } from "tachi-common"; +import type { GPTServerImplementation } from "game-implementations/types"; + +export const WACCA_IMPL: GPTServerImplementation<"wacca:Single"> = { + validators: {}, + derivers: { + grade: ({ score }) => GetGrade(WACCA_GBOUNDARIES, score), + }, +}; diff --git a/server/src/game-implementations/types.ts b/server/src/game-implementations/types.ts new file mode 100644 index 000000000..08230fca2 --- /dev/null +++ b/server/src/game-implementations/types.ts @@ -0,0 +1,44 @@ +import type { ChartDocument, ConfDerivedMetrics, GPTString } from "tachi-common"; +import type { AllConfMetrics, ScoreMetricDeriver } from "tachi-common/types/metrics"; + +/** + * Validate this chart-specific metric. This should return a string representing an + * error message on failure, and null on success. + */ +export type ChartSpecificMetricValidator = ( + metric: number, + chart: ChartDocument +) => string | true; + +interface ChartDependentMax { + chartDependentMax: true; +} + +/** + * The only metrics that need validators are those that have `chartDependentMax` set. + * Otherwise, a validator is built into the ConfScoreMetric. + */ +export type GPTMetricValidators = { + [M in keyof AllConfMetrics[GPT] as AllConfMetrics[GPT][M] extends ChartDependentMax + ? M + : never]: ChartSpecificMetricValidator; +}; + +export type GPTDerivers = { + // @ts-expect-error This *might* be a bug in the typescript compiler + // as this works for all GPT inputs normally. + // Possibly some generic nonsense but like... + + // can you really blame them for this not working? + // can you? LOOK at what we're doing. + [K in keyof ConfDerivedMetrics[GPT]]: ScoreMetricDeriver; +}; + +export interface GPTServerImplementation { + validators: GPTMetricValidators; + derivers: GPTDerivers; +} + +export type GPTImplementations = { + [GPT in GPTString]: GPTServerImplementation; +}; diff --git a/server/src/lib/rivals/rivals.ts b/server/src/lib/rivals/rivals.ts index 34cde9edd..0d2f57e56 100644 --- a/server/src/lib/rivals/rivals.ts +++ b/server/src/lib/rivals/rivals.ts @@ -3,7 +3,7 @@ import { SetRivalsFailReasons } from "lib/constants/err-codes"; import CreateLogCtx from "lib/logger/logger"; import { SendSetRivalNotification } from "lib/notifications/notification-wrappers"; import { ServerConfig } from "lib/setup/config"; -import { FormatGame } from "tachi-common"; +import { FormatGame, GetGamePTConfig } from "tachi-common"; import { ArrayDiff } from "utils/misc"; import { GetUsersWithIDs, GetUserWithIDGuaranteed } from "utils/user"; import type { BulkWriteUpdateOneOperation } from "mongodb"; @@ -233,12 +233,14 @@ export async function GetChallengerUsers(userID: integer, game: Game, playtype: * this sucks though. */ export async function UpdatePlayersRivalRankings(userID: integer, game: Game, playtype: Playtype) { + const gptConfig = GetGamePTConfig(game, playtype); + const rivalIDs = await GetRivalIDs(userID, game, playtype); // get all of this user's chartIDs so we know what to update const userPBs = (await db["personal-bests"].find( { userID, game, playtype }, - { projection: { chartID: 1, "scoreData.percent": 1 } } + { projection: { chartID: 1, [`scoreData.${gptConfig.defaultMetric}`]: 1 } } )) as Array<{ chartID: string; scoreData: { percent: number } }>; const bwrite: Array> = []; @@ -249,7 +251,7 @@ export async function UpdatePlayersRivalRankings(userID: integer, game: Game, pl (await db["personal-bests"].count({ chartID: pb.chartID, userID: { $in: rivalIDs }, - "scoreData.percent": { $gt: pb.scoreData.percent }, + [`scoreData.${gptConfig.defaultMetric}`]: { $gt: pb.scoreData.percent }, })) + 1; bwrite.push({ diff --git a/server/src/lib/score-import/framework/calculated-data/score.ts b/server/src/lib/score-import/framework/calculated-data/score.ts index e6d758d1d..ed02ce194 100644 --- a/server/src/lib/score-import/framework/calculated-data/score.ts +++ b/server/src/lib/score-import/framework/calculated-data/score.ts @@ -12,18 +12,18 @@ import { WACCARate, } from "rg-stats"; import { GetGPTString } from "tachi-common"; -import type { DryScore } from "../common/types"; +import type { DryScoreData } from "../common/types"; import type { ScoreCalculator, GPTScoreCalculators } from "./types"; -import type { ChartDocument, GPTString, GPTStrings } from "tachi-common"; +import type { ChartDocument, GPTString, GPTStrings, Game } from "tachi-common"; -const SkillCalc: ScoreCalculator<"gitadora:Dora" | "gitadora:Gita"> = (dryScore, chart) => - GITADORASkill.calculate(dryScore.scoreData.percent, chart.levelNum); +const SkillCalc: ScoreCalculator<"gitadora:Dora" | "gitadora:Gita"> = (scoreData, chart) => + GITADORASkill.calculate(scoreData.percent, chart.levelNum); -const SGLCalc: ScoreCalculator = (dryScore, chart) => { +const SGLCalc: ScoreCalculator = (scoreData, chart) => { const ecValue = chart.data.sglEC ?? 0; const hcValue = chart.data.sglHC ?? 0; - switch (dryScore.scoreData.lamp) { + switch (scoreData.lamp) { case "FULL COMBO": case "EX HARD CLEAR": case "HARD CLEAR": @@ -36,16 +36,16 @@ const SGLCalc: ScoreCalculator = (dryScore, chart) => } }; -const VF6Calc: ScoreCalculator = (dryScore, chart) => - Volforce.calculateVF6(dryScore.scoreData.score, dryScore.scoreData.lamp, chart.levelNum); +const VF6Calc: ScoreCalculator = (scoreData, chart) => + Volforce.calculateVF6(scoreData.score, scoreData.lamp, chart.levelNum); -const BPICalc: ScoreCalculator = (dryScore, chart) => { +const BPICalc: ScoreCalculator = (scoreData, chart) => { if (chart.data.kaidenAverage === null || chart.data.worldRecord === null) { return null; } return PoyashiBPI.calculate( - dryScore.scoreData.score, + scoreData.score, chart.data.kaidenAverage, chart.data.worldRecord, chart.data.notecount * 2, @@ -62,7 +62,7 @@ const BPICalc: ScoreCalculator = (dryScore, chart) => { export const SCORE_CALCULATORS: GPTScoreCalculators = { "iidx:SP": { BPI: BPICalc, - ktLampRating: (dryScore, chart) => { + ktLampRating: (scoreData, chart) => { // if chart has no ncValue, use the rating of the chart instead. const ncValue = chart.data.ncTier?.value ?? chart.levelNum; @@ -72,7 +72,7 @@ export const SCORE_CALCULATORS: GPTScoreCalculators = { const hcValue = Math.max(chart.data.hcTier?.value ?? 0, ncValue); const exhcValue = Math.max(chart.data.exhcTier?.value ?? 0, hcValue); - switch (dryScore.scoreData.lamp) { + switch (scoreData.lamp) { case "FULL COMBO": case "EX HARD CLEAR": return exhcValue; @@ -87,11 +87,11 @@ export const SCORE_CALCULATORS: GPTScoreCalculators = { }, "iidx:DP": { BPI: BPICalc, - ktLampRating: (dryScore, chart) => { + ktLampRating: (scoreData, chart) => { // if chart has no tier, use the rating of the chart instead. const ecValue = chart.data.dpTier?.value ?? chart.levelNum; - switch (dryScore.scoreData.lamp) { + switch (scoreData.lamp) { case "FULL COMBO": case "EX HARD CLEAR": case "HARD CLEAR": @@ -117,38 +117,31 @@ export const SCORE_CALCULATORS: GPTScoreCalculators = { "usc:Keyboard": { VF6: VF6Calc }, "maimaidx:Single": { - rate: (dryScore, chart) => - MaimaiDXRate.calculate(dryScore.scoreData.percent, chart.levelNum), + rate: (scoreData, chart) => MaimaiDXRate.calculate(scoreData.percent, chart.levelNum), }, "museca:Single": { - curatorSkill: (dryScore, chart) => - CuratorSkill.calculate(dryScore.scoreData.score, chart.levelNum), + curatorSkill: (scoreData, chart) => CuratorSkill.calculate(scoreData.score, chart.levelNum), }, "chunithm:Single": { - rating: (dryScore, chart) => - CHUNITHMRating.calculate(dryScore.scoreData.score, chart.levelNum), + rating: (scoreData, chart) => CHUNITHMRating.calculate(scoreData.score, chart.levelNum), }, "jubeat:Single": { - jubility: (dryScore, chart) => - Jubility.calculate( - dryScore.scoreData.score, - dryScore.scoreData.musicRate, - chart.levelNum - ), + jubility: (scoreData, chart) => + Jubility.calculate(scoreData.score, scoreData.musicRate, chart.levelNum), }, "itg:Stamina": { - blockRating: (dryScore, chart) => { - if (dryScore.scoreData.lamp === "FAILED") { + blockRating: (scoreData, chart) => { + if (scoreData.lamp === "FAILED") { return null; } return chart.levelNum; }, - fastest32: (dryScore, chart) => { + fastest32: (scoreData, chart) => { const diedAtMeasure = - dryScore.scoreData.lamp === "FAILED" - ? (dryScore.scoreData.survivedPercent / 100) * chart.data.notesPerMeasure.length + scoreData.lamp === "FAILED" + ? (scoreData.survivedPercent / 100) * chart.data.notesPerMeasure.length : null; const fastest32 = ITGHighestUnbroken.calculateFromNPSPerMeasure( @@ -176,17 +169,17 @@ export const SCORE_CALCULATORS: GPTScoreCalculators = { }, "popn:9B": { - classPoints: (dryScore, chart) => + classPoints: (scoreData, chart) => PopnClassPoints.calculate( - dryScore.scoreData.score, - PopnClearMedalToLamp(dryScore.scoreData.clearMedal), + scoreData.score, + PopnClearMedalToLamp(scoreData.clearMedal), chart.levelNum ), }, "sdvx:Single": { VF6: VF6Calc }, "wacca:Single": { - rate: (dryScore, chart) => WACCARate.calculate(dryScore.scoreData.score, chart.levelNum), + rate: (scoreData, chart) => WACCARate.calculate(scoreData.score, chart.levelNum), }, }; @@ -195,15 +188,16 @@ export const SCORE_CALCULATORS: GPTScoreCalculators = { * @param scores - All of the scores in this session. */ export function CreateScoreCalcData( - dryScore: DryScore, + game: Game, + dryScoreData: DryScoreData, chart: ChartDocument ) { - const gptString = GetGPTString(dryScore.game, chart.playtype); + const gptString = GetGPTString(game, chart.playtype); const calcData: Record = {}; for (const [key, fn] of Object.entries(SCORE_CALCULATORS[gptString])) { - calcData[key] = fn(dryScore, chart); + calcData[key] = fn(dryScoreData, chart); } return calcData; diff --git a/server/src/lib/score-import/framework/calculated-data/types.ts b/server/src/lib/score-import/framework/calculated-data/types.ts index 17cbb8285..6a1f82795 100644 --- a/server/src/lib/score-import/framework/calculated-data/types.ts +++ b/server/src/lib/score-import/framework/calculated-data/types.ts @@ -1,4 +1,4 @@ -import type { DryScore } from "../common/types"; +import type { DryScoreData } from "../common/types"; import type { KtLogger } from "lib/logger/logger"; import type { ChartDocument, @@ -16,7 +16,7 @@ import type { import type { DerivedClassConfig, ProvidedClassConfig } from "tachi-common/types/game-config-utils"; export type ScoreCalculator = ( - dryScore: DryScore, + scoreData: DryScoreData, chart: ChartDocument ) => number | null; diff --git a/server/src/lib/score-import/framework/derivers/common-utils.ts b/server/src/lib/score-import/framework/derivers/common-utils.ts deleted file mode 100644 index 194ec4fe8..000000000 --- a/server/src/lib/score-import/framework/derivers/common-utils.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { InternalFailure } from "../common/converter-failures"; -import type { GradeBoundary } from "tachi-common"; - -/** - * Util for getting a games' grade for a given score. - */ -export function GetGrade(grades: Array>, score: number): G { - // sort grades going downwards in their boundaries. - const descendingGrades = grades.slice(0).sort((a, b) => b.lowerBound - a.lowerBound); - - for (const { name, lowerBound } of descendingGrades) { - if (score >= lowerBound) { - return name; - } - } - - throw new InternalFailure(`Could not resolve grade for score ${score}.`); -} diff --git a/server/src/lib/score-import/framework/derivers/games/iidx-like.ts b/server/src/lib/score-import/framework/derivers/games/iidx-like.ts deleted file mode 100644 index a9ee62196..000000000 --- a/server/src/lib/score-import/framework/derivers/games/iidx-like.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { GetGrade } from "../common-utils"; -import { IIDXLIKE_GBOUNDARIES } from "tachi-common"; -import type { GPTDerivers } from "../types"; -import type { GPTStrings, integer } from "tachi-common"; - -function calculateIIDXLikePercent(exScore: integer, notecount: integer) { - return exScore / (notecount * 2); -} - -type IIDXLikes = GPTStrings["bms" | "iidx" | "pms"]; - -/** - * Derivers for both IIDX SP and DP. - * - * and BMS. and PMS. They use the same things. - */ -export const IIDXLIKE_DERIVERS: GPTDerivers = { - percent: ({ score }, chart) => calculateIIDXLikePercent(score, chart.data.notecount), - grade: ({ score }, chart) => { - const percent = calculateIIDXLikePercent(score, chart.data.notecount); - - return GetGrade(IIDXLIKE_GBOUNDARIES, percent); - }, -}; diff --git a/server/src/lib/score-import/framework/derivers/games/popn.ts b/server/src/lib/score-import/framework/derivers/games/popn.ts deleted file mode 100644 index 3bd108e27..000000000 --- a/server/src/lib/score-import/framework/derivers/games/popn.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { GetEnumValue } from "tachi-common/types/metrics"; - -export function PopnClearMedalToLamp( - clearMedal: GetEnumValue<"popn:9B", "clearMedal"> -): GetEnumValue<"popn:9B", "lamp"> { - switch (clearMedal) { - case "perfect": - return "PERFECT"; - case "fullComboCircle": - case "fullComboDiamond": - case "fullComboStar": - return "FULL COMBO"; - case "clearCircle": - case "clearDiamond": - case "clearStar": - return "CLEAR"; - case "easyClear": - return "EASY CLEAR"; - case "failedCircle": - case "failedDiamond": - case "failedStar": - return "FAILED"; - } -} diff --git a/server/src/lib/score-import/framework/derivers/games/sdvx-like.ts b/server/src/lib/score-import/framework/derivers/games/sdvx-like.ts deleted file mode 100644 index b145f63f9..000000000 --- a/server/src/lib/score-import/framework/derivers/games/sdvx-like.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { GetGrade } from "../common-utils"; -import { SDVXLIKE_GBOUNDARIES } from "tachi-common"; -import type { GPTDerivers } from "../types"; -import type { GPTStrings } from "tachi-common"; - -type SDVXLikes = GPTStrings["sdvx" | "usc"]; - -export const SDVXLIKE_DERIVERS: GPTDerivers = { - grade: ({ score }) => GetGrade(SDVXLIKE_GBOUNDARIES, score), -}; diff --git a/server/src/lib/score-import/framework/derivers/types.ts b/server/src/lib/score-import/framework/derivers/types.ts deleted file mode 100644 index 4cef9f457..000000000 --- a/server/src/lib/score-import/framework/derivers/types.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { ConfDerivedMetrics, GPTString } from "tachi-common"; -import type { ScoreMetricDeriver } from "tachi-common/types/metrics"; - -export type GPTDerivers = { - // @ts-expect-error This *might* be a bug in the typescript compiler - // as this works for all GPT inputs normally. - // Possibly some generic nonsense but like... - - // can you really blame them for this not working? - // can you? LOOK at what we're doing. - [K in keyof ConfDerivedMetrics[GPT]]: ScoreMetricDeriver; -}; diff --git a/server/src/lib/score-import/framework/pb/create-pb-doc.ts b/server/src/lib/score-import/framework/pb/create-pb-doc.ts index 2a1db3a39..ba143911e 100644 --- a/server/src/lib/score-import/framework/pb/create-pb-doc.ts +++ b/server/src/lib/score-import/framework/pb/create-pb-doc.ts @@ -1,8 +1,9 @@ -import { GPT_PB_MERGE_FNS } from "./mergers/mergers"; -import { CreateEnumIndexes } from "../derivers/derivers"; +import { GPT_PB_DEFAULT_REF_NAMES, GPT_PB_MERGE_FNS } from "./mergers/mergers"; +import { CreateScoreCalcData } from "../calculated-data/score"; +import { CreateEnumIndexes } from "../score-importing/derivers"; import db from "external/mongo/db"; import { GetEveryonesRivalIDs } from "lib/rivals/rivals"; -import { GetGPTConfig } from "tachi-common"; +import { GetGPTConfig, GetGamePTConfig } from "tachi-common"; import type { KtLogger } from "lib/logger/logger"; import type { BulkWriteUpdateOneOperation, FilterQuery } from "mongodb"; import type { @@ -12,6 +13,7 @@ import type { Playtype, ScoreDocument, integer, + ChartDocument, } from "tachi-common"; export type PBScoreDocumentNoRank = Omit< @@ -26,10 +28,12 @@ export type PBScoreDocumentNoRank = Omit< export async function CreatePBDoc( gpt: GPTString, userID: integer, - chartID: string, + chart: ChartDocument, logger: KtLogger, asOfTimestamp?: number ) { + const chartID = chart.chartID; + const query: FilterQuery = { userID, chartID, @@ -65,7 +69,12 @@ export async function CreatePBDoc( } const pbDoc: PBScoreDocumentNoRank = { - composedFrom: [INITIAL_REFERENCE], + composedFrom: [ + { + name: GPT_PB_DEFAULT_REF_NAMES[gpt], + scoreID: defaultMetricPB.scoreID, + }, + ], chartID: defaultMetricPB.chartID, userID, songID: defaultMetricPB.songID, @@ -97,12 +106,15 @@ export async function CreatePBDoc( } } - // update any enum indexes that + // update any enum indexes that might've been altered const { indexes, optionalIndexes } = CreateEnumIndexes(gpt, pbDoc.scoreData, logger); pbDoc.scoreData.enumIndexes = indexes; pbDoc.scoreData.optional.enumIndexes = optionalIndexes; + // Recalc info about this score (incase things have changed). + pbDoc.calculatedData = CreateScoreCalcData(pbDoc.game, pbDoc.scoreData, chart); + // finally, return our full pbDoc, that does NOT have the ranking props. // (We will add those later) return pbDoc; @@ -112,11 +124,13 @@ export async function CreatePBDoc( * Updates rankings on a given chart. */ export async function UpdateChartRanking(game: Game, playtype: Playtype, chartID: string) { + const gptConfig = GetGamePTConfig(game, playtype); + const scores = await db["personal-bests"].find( { chartID }, { sort: { - "scoreData.percent": -1, + [`scoreData.${gptConfig.defaultMetric}`]: -1, timeAchieved: 1, }, } diff --git a/server/src/lib/score-import/framework/pb/game-specific-merge.ts b/server/src/lib/score-import/framework/pb/game-specific-merge.ts deleted file mode 100644 index 3d53fc58b..000000000 --- a/server/src/lib/score-import/framework/pb/game-specific-merge.ts +++ /dev/null @@ -1,227 +0,0 @@ -/* eslint-disable require-atomic-updates */ -import { InternalFailure } from "../common/converter-failures"; -import db from "external/mongo/db"; -import { Volforce } from "rg-stats"; -import { DeleteUndefinedProps } from "utils/misc"; -import { FindChartWithChartID } from "utils/queries/charts"; -import type { KtLogger } from "lib/logger/logger"; -import type { FilterQuery } from "mongodb"; -import type { PBScoreDocument, ScoreDocument } from "tachi-common"; - -export async function IIDXMergeFn( - pbDoc: PBScoreDocument<"iidx:DP" | "iidx:SP">, - scorePB: ScoreDocument<"iidx:DP" | "iidx:SP">, - lampPB: ScoreDocument<"iidx:DP" | "iidx:SP">, - logger: KtLogger, - asOfTimestamp?: number -): Promise { - // lampRating needs to be updated. - pbDoc.calculatedData.ktLampRating = lampPB.calculatedData.ktLampRating; - - // Update lamp related iidx-specific info from the lampPB. - pbDoc.scoreData.optional.gsmEasy = lampPB.scoreData.optional.gsmEasy; - pbDoc.scoreData.optional.gsmNormal = lampPB.scoreData.optional.gsmNormal; - pbDoc.scoreData.optional.gsmHard = lampPB.scoreData.optional.gsmHard; - pbDoc.scoreData.optional.gsmEXHard = lampPB.scoreData.optional.gsmEXHard; - - pbDoc.scoreData.optional.gauge = lampPB.scoreData.optional.gauge; - pbDoc.scoreData.optional.gaugeHistory = lampPB.scoreData.optional.gaugeHistory; - - pbDoc.scoreData.optional.comboBreak = lampPB.scoreData.optional.comboBreak; - - DeleteUndefinedProps(pbDoc.scoreData.optional); - - await MergeBPPB(pbDoc, scorePB, lampPB, logger, asOfTimestamp); - - return true; -} - -export function PopnMergeFn( - pbDoc: PBScoreDocument<"popn:9B">, - scorePB: ScoreDocument<"popn:9B">, - lampPB: ScoreDocument<"popn:9B">, - _logger: KtLogger -) { - pbDoc.scoreData.optional.specificClearType = lampPB.scoreData.optional.specificClearType; - - return true; -} - -export async function BMSMergeFn( - pbDoc: PBScoreDocument<"bms:7K" | "bms:14K">, - scorePB: ScoreDocument<"bms:7K" | "bms:14K">, - lampPB: ScoreDocument<"bms:7K" | "bms:14K">, - logger: KtLogger, - asOfTimestamp?: number -) { - pbDoc.calculatedData.sieglinde = lampPB.calculatedData.sieglinde; - - pbDoc.scoreData.optional.gaugeHistory = lampPB.scoreData.optional.gaugeHistory; - pbDoc.scoreData.optional.gauge = lampPB.scoreData.optional.gauge; - - await MergeBPPB(pbDoc, scorePB, lampPB, logger, asOfTimestamp); - - return true; -} - -export async function PMSMergeFn( - pbDoc: PBScoreDocument<"pms:Controller" | "pms:Keyboard">, - scorePB: ScoreDocument<"pms:Controller" | "pms:Keyboard">, - lampPB: ScoreDocument<"pms:Controller" | "pms:Keyboard">, - logger: KtLogger, - asOfTimestamp?: number -) { - pbDoc.calculatedData.sieglinde = lampPB.calculatedData.sieglinde; - - await MergeBPPB(pbDoc, scorePB, lampPB, logger, asOfTimestamp); - - return true; -} - -/** - * This function recalculates and applies VF6 to the PB document. - * - * This is near-identical to the SDVXMergeFn. See that. - */ -export async function USCMergeFn( - pbDoc: PBScoreDocument<"usc:Controller" | "usc:Keyboard">, - scorePB: ScoreDocument<"usc:Controller" | "usc:Keyboard">, - lampPB: ScoreDocument<"usc:Controller" | "usc:Keyboard">, - logger: KtLogger -) { - // @optimisable - see SDVXMergeFn - const chart = await FindChartWithChartID("usc", pbDoc.chartID); - - if (!chart) { - logger.severe(`Chart ${pbDoc.chartID} disappeared underfoot?`); - throw new InternalFailure(`Chart ${pbDoc.chartID} disappeared underfoot?`); - } - - pbDoc.calculatedData.VF6 = Volforce.calculateVF6( - pbDoc.scoreData.score, - pbDoc.scoreData.lamp, - chart.levelNum - ); - - return true; -} - -/** - * This function recalculates and applies VF6 to the PB document. - * - * SDVX cannot just select the larger volforce - instead, volforce has to be - * re-calculated for any different permutation of scorePB + lampPB. - */ -export async function SDVXMergeFn( - pbDoc: PBScoreDocument<"sdvx:Single">, - scorePB: ScoreDocument<"sdvx:Single">, - lampPB: ScoreDocument<"sdvx:Single">, - logger: KtLogger, - asOfTimestamp?: number -): Promise { - // @optimisable - // This is a re-fetch, but it's difficult to pass the chart all - // the way down here due to how chartIDs (set) works. :( - const chart = await FindChartWithChartID("sdvx", pbDoc.chartID); - - if (!chart) { - logger.severe(`Chart ${pbDoc.chartID} disappeared underfoot?`); - throw new InternalFailure(`Chart ${pbDoc.chartID} disappeared underfoot?`); - } - - pbDoc.calculatedData.VF6 = Volforce.calculateVF6( - pbDoc.scoreData.score, - pbDoc.scoreData.lamp, - chart.levelNum - ); - - const query: FilterQuery = { - chartID: pbDoc.chartID, - "scoreData.optional.exScore": { $type: "number" }, - }; - - if (asOfTimestamp !== undefined) { - query.timeAchieved = { $lt: asOfTimestamp }; - } - - // find the users score with the highest exScore - const bestExScore = (await db.scores.findOne(query, { - sort: { - "scoreData.optional.exScore": -1, - }, - })) as ScoreDocument<"sdvx:Single"> | null; - - if (!bestExScore) { - pbDoc.scoreData.optional.exScore = undefined; - } else { - pbDoc.scoreData.optional.exScore = bestExScore.scoreData.optional.exScore; - - pbDoc.composedFrom.other = [{ name: "exScorePB", scoreID: bestExScore.scoreID }]; - } - - return true; -} - -type GPTStringWithBP = - | "bms:7K" - | "bms:14K" - | "iidx:DP" - | "iidx:SP" - | "pms:Controller" - | "pms:Keyboard"; - -/** - * Given typical PB-Merge information, fetch the best `bp` for this user's scores - * on this chart and merge it with the `pbDoc` if it's large enough. - * - * @returns NOTHING, mutates original input. - */ -async function MergeBPPB( - pbDoc: PBScoreDocument, - scorePB: ScoreDocument, - lampPB: ScoreDocument, - logger: KtLogger, - asOfTimestamp: number | undefined -) { - // bad+poor PB document. This is a weird, third indepdenent metric that IIDX players sometimes care about. - const query: FilterQuery = { - userID: scorePB.userID, - chartID: scorePB.chartID, - "scoreData.optional.bp": { $exists: true }, - }; - - if (asOfTimestamp !== undefined) { - query.timeAchieved = { $lt: asOfTimestamp }; - } - - const bpPB = (await db.scores.findOne(query, { - sort: { - // bp 0 is the best BP, bp 1 is worse, so on - "scoreData.optional.bp": 1, - }, - })) as ScoreDocument<"iidx:DP" | "iidx:SP"> | null; - - if (!bpPB) { - logger.verbose( - `Could not find BP PB for ${scorePB.userID} ${scorePB.chartID} in PB joining. User likely has no scores with BP defined.`, - { pbDoc } - ); - - // this isn't actually an error! we just don't have to do anything. - return; - } - - // by default scorePB is chosen for optional fields, so, we can skip any assignments here by returning here. - if (bpPB.scoreID === scorePB.scoreID) { - logger.debug(`Skipped merging BP PB as scorePB was also BP PB.`); - return true; - } else if (bpPB.scoreID === lampPB.scoreID) { - pbDoc.scoreData.optional.bp = lampPB.scoreData.optional.bp; - logger.debug(`Skipped adding BP PB as composedFrom because lampPB was also BP PB.`); - return; - } - - pbDoc.scoreData.optional.bp = bpPB.scoreData.optional.bp; - - pbDoc.composedFrom.other = [{ name: "Best BP", scoreID: bpPB.scoreID }]; -} diff --git a/server/src/lib/score-import/framework/pb/mergers/mergers.ts b/server/src/lib/score-import/framework/pb/mergers/mergers.ts index b44da80a8..0b6502702 100644 --- a/server/src/lib/score-import/framework/pb/mergers/mergers.ts +++ b/server/src/lib/score-import/framework/pb/mergers/mergers.ts @@ -1,5 +1,6 @@ import { BMS_PMS_MERGERS } from "./games/bms"; import { IIDX_MERGERS } from "./games/iidx"; +import { CreatePBMergeFor } from "./utils"; import type { PBMergeFunction } from "./types"; import type { GPTString } from "tachi-common"; @@ -16,5 +17,113 @@ export const GPT_PB_MERGE_FNS: GPTPBMergeFNs = { "pms:Controller": BMS_PMS_MERGERS, "pms:Keyboard": BMS_PMS_MERGERS, - "chunithm:Single": [], + "chunithm:Single": [ + CreatePBMergeFor("enumIndexes.lamp", "Best Lamp", (base, score) => { + base.scoreData.lamp = score.scoreData.lamp; + }), + ], + "sdvx:Single": [ + CreatePBMergeFor("enumIndexes.lamp", "Best Lamp", (base, score) => { + base.scoreData.lamp = score.scoreData.lamp; + }), + CreatePBMergeFor("optional.exScore", "Best EX Score", (base, score) => { + base.scoreData.optional.exScore = score.scoreData.optional.exScore; + }), + ], + + // musicRate is the default prop + // but we want the user's best score to count aswell. + "jubeat:Single": [ + CreatePBMergeFor("enumIndexes.lamp", "Best Lamp", (base, score) => { + base.scoreData.lamp = score.scoreData.lamp; + }), + CreatePBMergeFor("score", "Best Score", (base, score) => { + base.scoreData.score = score.scoreData.score; + }), + ], + + "maimaidx:Single": [ + CreatePBMergeFor("enumIndexes.lamp", "Best Lamp", (base, score) => { + base.scoreData.lamp = score.scoreData.lamp; + }), + ], + + "museca:Single": [ + CreatePBMergeFor("enumIndexes.lamp", "Best Lamp", (base, score) => { + base.scoreData.lamp = score.scoreData.lamp; + }), + ], + + "popn:9B": [ + CreatePBMergeFor("enumIndexes.clearMedal", "Best Clear", (base, score) => { + base.scoreData.clearMedal = score.scoreData.clearMedal; + // these are directly related. pluck both. + base.scoreData.lamp = score.scoreData.lamp; + }), + ], + + "wacca:Single": [ + CreatePBMergeFor("enumIndexes.lamp", "Best Lamp", (base, score) => { + base.scoreData.lamp = score.scoreData.lamp; + }), + ], + + "gitadora:Dora": [ + CreatePBMergeFor("enumIndexes.lamp", "Best Lamp", (base, score) => { + base.scoreData.lamp = score.scoreData.lamp; + }), + ], + "gitadora:Gita": [ + CreatePBMergeFor("enumIndexes.lamp", "Best Lamp", (base, score) => { + base.scoreData.lamp = score.scoreData.lamp; + }), + ], + + "itg:Stamina": [ + // we'll pluck the best lamp, but this game has a pretty interesting concept + // for merging PBs. This is probably fine. + CreatePBMergeFor("enumIndexes.lamp", "Best Lamp", (base, score) => { + base.scoreData.lamp = score.scoreData.lamp; + }), + ], + + "usc:Controller": [ + CreatePBMergeFor("enumIndexes.lamp", "Best Lamp", (base, score) => { + base.scoreData.lamp = score.scoreData.lamp; + }), + ], + "usc:Keyboard": [ + CreatePBMergeFor("enumIndexes.lamp", "Best Lamp", (base, score) => { + base.scoreData.lamp = score.scoreData.lamp; + }), + ], +}; + +/** + * What should the name be for the first reference on a PB? + * + * A PB is initialised from the user's best score on the defaultMetric, so in essence + * this is a name for that default metric. + */ +export const GPT_PB_DEFAULT_REF_NAMES: Record = { + "bms:14K": "Best Score", + "bms:7K": "Best Score", + "chunithm:Single": "Best Score", + "gitadora:Dora": "Best Percent", + "gitadora:Gita": "Best Percent", + "iidx:DP": "Best Score", + "iidx:SP": "Best Score", + "jubeat:Single": "Best Music Rate", + "maimaidx:Single": "Best Percent", + "museca:Single": "Best Score", + "pms:Controller": "Best Score", + "pms:Keyboard": "Best Score", + "popn:9B": "Best Score", + "sdvx:Single": "Best Score", + "usc:Controller": "Best Score", + "usc:Keyboard": "Best Score", + "wacca:Single": "Best Score", + + // this name sucks, what should we do instead? TODO. + "itg:Stamina": "Best Result", }; diff --git a/server/src/lib/score-import/framework/pb/mergers/types.ts b/server/src/lib/score-import/framework/pb/mergers/types.ts index 9c11bef96..c1a2813a0 100644 --- a/server/src/lib/score-import/framework/pb/mergers/types.ts +++ b/server/src/lib/score-import/framework/pb/mergers/types.ts @@ -6,6 +6,8 @@ import type { GPTString, PBReference, integer } from "tachi-common"; * They are expected to mutate the existingPB to add/change whatever * properties they feel like should be merged. * + * @note Don't worry about updating enumIndexes. Those are updated for you. + * * They should then return some information (a name and a scoreID) to indicate * what this PB is composed of. */ diff --git a/server/src/lib/score-import/framework/pb/mergers/utils.ts b/server/src/lib/score-import/framework/pb/mergers/utils.ts index 82b1d1288..493163047 100644 --- a/server/src/lib/score-import/framework/pb/mergers/utils.ts +++ b/server/src/lib/score-import/framework/pb/mergers/utils.ts @@ -1,13 +1,13 @@ import db from "external/mongo/db"; +import type { PBScoreDocumentNoRank } from "../create-pb-doc"; import type { PBMergeFunction } from "./types"; import type { FilterQuery } from "mongodb"; import type { - GPTString, ConfDerivedMetrics, - ConfProvidedMetrics, - PBScoreDocument, - ScoreDocument, ConfOptionalMetrics, + ConfProvidedMetrics, + GPTString, + ScoreDocument, } from "tachi-common"; import type { ExtractEnumMetricNames } from "tachi-common/types/metrics"; @@ -48,12 +48,12 @@ export function HandleAsOf( * Utility for making a PB merge function. In short, get the best score this user has * on this chart for the stated metric, then run the applicator if a score was found. * - * @note Don't worry about updating enumIndexes. Those are updated for you. + * @note Don't worry about updating enumIndexes. Those are updated for you,. */ export function CreatePBMergeFor( metric: MetricKeys, name: string, - applicator: (base: PBScoreDocument, score: ScoreDocument) => void + applicator: (base: PBScoreDocumentNoRank, score: ScoreDocument) => void ): PBMergeFunction { return async (userID, chartID, asOfTimestamp, base) => { const bestScoreFor = (await db.scores.findOne( diff --git a/server/src/lib/score-import/framework/derivers/derivers.ts b/server/src/lib/score-import/framework/score-importing/derivers.ts similarity index 54% rename from server/src/lib/score-import/framework/derivers/derivers.ts rename to server/src/lib/score-import/framework/score-importing/derivers.ts index 09143266b..1174e9525 100644 --- a/server/src/lib/score-import/framework/derivers/derivers.ts +++ b/server/src/lib/score-import/framework/score-importing/derivers.ts @@ -1,27 +1,6 @@ -/* eslint-disable @typescript-eslint/strict-boolean-expressions */ -/* eslint-disable @typescript-eslint/no-unsafe-call */ -/* eslint-disable @typescript-eslint/no-unsafe-assignment */ -/* eslint-disable @typescript-eslint/no-unsafe-member-access */ -/* eslint-disable @typescript-eslint/no-unsafe-argument */ -// ^ ts eslint currently gets very confused about the complexity on show here -// sorry! - -import { GetGrade } from "./common-utils"; -import { IIDXLIKE_DERIVERS } from "./games/iidx-like"; -import { PopnClearMedalToLamp } from "./games/popn"; -import { SDVXLIKE_DERIVERS } from "./games/sdvx-like"; import { InternalFailure } from "../common/converter-failures"; -import { - CHUNITHM_GBOUNDARIES, - GITADORA_GBOUNDARIES, - GetGPTConfig, - ITG_GBOUNDARIES, - JUBEAT_GBOUNDARIES, - MAIMAIDX_GBOUNDARIES, - MUSECA_GBOUNDARIES, - POPN_GBOUNDARIES, - WACCA_GBOUNDARIES, -} from "tachi-common"; +import { GPT_SERVER_IMPLEMENTATIONS } from "game-implementations/game-implementations"; +import { GetGPTConfig } from "tachi-common"; import type { DryScore, DryScoreData } from "../common/types"; import type { GPTDerivers } from "./types"; import type { KtLogger } from "lib/logger/logger"; @@ -29,93 +8,13 @@ import type { ChartDocument, DerivedMetrics, GPTString, - ScoreData, - integer, OptionalEnumIndexes, + ScoreData, ScoreEnumIndexes, + integer, } from "tachi-common"; import type { MetricValue } from "tachi-common/types/metrics"; -type AllGPTDerivers = { - [GPT in GPTString]: GPTDerivers; -}; - -/** - * How do we derive the "derivedMetrics" for each game? - */ -const GPT_DERIVERS: AllGPTDerivers = { - // these games quite literally *all* work the same way. - "bms:14K": IIDXLIKE_DERIVERS, - "bms:7K": IIDXLIKE_DERIVERS, - "pms:Controller": IIDXLIKE_DERIVERS, - "pms:Keyboard": IIDXLIKE_DERIVERS, - "iidx:SP": IIDXLIKE_DERIVERS, - "iidx:DP": IIDXLIKE_DERIVERS, - - // same stuff. - "sdvx:Single": SDVXLIKE_DERIVERS, - "usc:Controller": SDVXLIKE_DERIVERS, - "usc:Keyboard": SDVXLIKE_DERIVERS, - - "chunithm:Single": { - grade: ({ score }) => GetGrade(CHUNITHM_GBOUNDARIES, score), - }, - "wacca:Single": { - grade: ({ score }) => GetGrade(WACCA_GBOUNDARIES, score), - }, - "jubeat:Single": { - grade: ({ score }) => GetGrade(JUBEAT_GBOUNDARIES, score), - }, - "gitadora:Dora": { - grade: ({ percent }) => GetGrade(GITADORA_GBOUNDARIES, percent), - }, - "gitadora:Gita": { - grade: ({ percent }) => GetGrade(GITADORA_GBOUNDARIES, percent), - }, - "itg:Stamina": { - finalPercent: (metrics) => { - // *important* - // don't check if metrics.survivedPercent === 100, as due to floating - // point inaccuracies, it's possible to have a 100% fail - // (on extremely long charts, for example) - if (metrics.lamp === "FAILED") { - return metrics.survivedPercent; - } - - return 100 + metrics.scorePercent; - }, - grade: ({ scorePercent, lamp }) => { - if (lamp === "FAILED") { - return "F"; - } - - return GetGrade(ITG_GBOUNDARIES, scorePercent); - }, - }, - "maimaidx:Single": { - grade: ({ percent }) => GetGrade(MAIMAIDX_GBOUNDARIES, percent), - }, - "museca:Single": { - grade: ({ score }) => GetGrade(MUSECA_GBOUNDARIES, score), - }, - "popn:9B": { - lamp: ({ clearMedal }) => PopnClearMedalToLamp(clearMedal), - grade: ({ score, clearMedal }) => { - const gradeString = GetGrade(POPN_GBOUNDARIES, score); - - // lol double-calc - const lamp = PopnClearMedalToLamp(clearMedal); - - // grades are kneecapped at "A" if you failed. - if (score >= 90_000 && lamp === "FAILED") { - return "A"; - } - - return gradeString; - }, - }, -}; - /** * Given the providedMetrics and chart this score is on, derive the rest of the metrics * we want to store. @@ -125,7 +24,7 @@ function DeriveMetrics( metrics: DryScoreData, chart: ChartDocument ) { - const deriverImplementation: GPTDerivers = GPT_DERIVERS[gpt]; + const deriverImplementation: GPTDerivers = GPT_SERVER_IMPLEMENTATIONS[gpt].derivers; const derivedMetrics: Record = {}; diff --git a/server/src/lib/score-import/framework/score-importing/hydrate-score.ts b/server/src/lib/score-import/framework/score-importing/hydrate-score.ts index ce45eb1b0..8a7ba94a1 100644 --- a/server/src/lib/score-import/framework/score-importing/hydrate-score.ts +++ b/server/src/lib/score-import/framework/score-importing/hydrate-score.ts @@ -1,5 +1,5 @@ import { CreateScoreCalcData } from "../calculated-data/score"; -import { CreateFullScoreData } from "../derivers/derivers"; +import { CreateFullScoreData } from "./derivers"; import { GetGPTString } from "tachi-common"; import type { DryScore } from "../common/types"; import type { KtLogger } from "lib/logger/logger"; diff --git a/server/src/lib/showcase/evaluator.ts b/server/src/lib/showcase/evaluator.ts index 09a14f11d..ccc27d066 100644 --- a/server/src/lib/showcase/evaluator.ts +++ b/server/src/lib/showcase/evaluator.ts @@ -79,7 +79,7 @@ function PropToMongoProp(prop: "grade" | "lamp" | "percent" | "score") { case "grade": return "scoreData.gradeIndex"; case "percent": - return "scoreData.percent"; + return [`scoreData.${gptConfig.defaultMetric}`]; } } diff --git a/server/src/lib/targets/goal-utils.ts b/server/src/lib/targets/goal-utils.ts index 69cef827d..1f81c7193 100644 --- a/server/src/lib/targets/goal-utils.ts +++ b/server/src/lib/targets/goal-utils.ts @@ -137,7 +137,7 @@ function FormatCriteria( return gptConfig.grades[criteria.value]; case "scoreData.lampIndex": return gptConfig.lamps[criteria.value]; - case "scoreData.percent": + case [`scoreData.${gptConfig.defaultMetric}`]: return `Get ${FormatMaxDP(criteria.value)}% on`; case "scoreData.score": return `Get a score of ${criteria.value.toLocaleString("en-GB")} on`; @@ -250,55 +250,44 @@ export async function ValidateGoalChartsAndCriteria( // checking whether the key and value make sense const gptConfig = GetGamePTConfig(game, playtype); - if (criteria.key === "scoreData.gradeIndex" && !gptConfig.grades[criteria.value]) { - throw new Error( - `Invalid value of ${criteria.value} for grade goal. No such grade exists at that index.` - ); - } else if (criteria.key === "scoreData.lampIndex" && !gptConfig.lamps[criteria.value]) { - throw new Error( - `Invalid value of ${criteria.value} for lamp goal. No such lamp exists at that index.` - ); - } else if ( - criteria.key === "scoreData.percent" && - (criteria.value <= 0 || criteria.value > gptConfig.percentMax) - ) { - throw new Error( - `Invalid value of ${criteria.value} for percent goal. Percents must be between 0 and ${gptConfig.percentMax}.` - ); - } else if (criteria.key === "scoreData.score") { - if (criteria.value < 0) { - throw new Error(`Invalid score value for goal. Can't be negative.`); - } + const config = + gptConfig.providedMetrics[criteria.key] ?? gptConfig.derivedMetrics[criteria.key]; - // troublemaker games where score is relative to notecount - if (game === "iidx" || game === "bms" || game === "pms") { - if (charts.type !== "single") { + if (!config) { + throw new Error( + `Invalid criteria.key for ${FormatGame(game, playtype)} (Got ${criteria.key}).` + ); + } + + switch (config.type) { + case "DECIMAL": + case "INTEGER": { + if (config.chartDependentMax && charts.type !== "single") { throw new Error( - `Invalid key for ${game} with multiple charts. Creating score goals on multiple charts where score is relative to notecount is a terrible idea, and has been disabled.` + `Creating ${criteria.key} goals on multiple charts where the maximum value is relative to the chart is a terrible idea, and has been disabled.` ); } - const relatedChart = (await db.anyCharts[game].findOne({ - playtype, - chartID: charts.data, - })) as ChartDocument< - "bms:7K" | "bms:14K" | "iidx:DP" | "iidx:SP" | "pms:Controller" | "pms:Keyboard" - >; + // TODO VALIDATORS - const notecount = relatedChart.data.notecount; + break; + } - if (criteria.value > notecount * 2) { + case "ENUM": { + if (!config.values[criteria.value]) { throw new Error( - `Invalid value of ${ - criteria.value - } for goal. Maximum score possible on this chart is ${notecount * 2}.` + `Invalid value of ${criteria.value} for ${criteria.key} goal. No such ${criteria.key} exists at that index.` ); } - } else if (GenericCalculatePercent(game, criteria.value) >= gptConfig.percentMax) { - throw new Error( - `Score of ${criteria.value} is too large for ${FormatGame(game, playtype)}.` - ); + + // TODO VALIDATORS + + break; } + + case "GRAPH": + case "NULLABLE_GRAPH": + throw new Error(`Cannot set a goal on ${criteria.key} as it's a graph metric.`); } if (charts.type === "single" && criteria.mode !== "single") { diff --git a/server/src/lib/targets/goals.ts b/server/src/lib/targets/goals.ts index d35b6db69..3d37e7d9d 100644 --- a/server/src/lib/targets/goals.ts +++ b/server/src/lib/targets/goals.ts @@ -314,7 +314,7 @@ export function HumaniseGoalProgress( return userPB.scoreData.lamp; } - case "scoreData.percent": + case [`scoreData.${gptConfig.defaultMetric}`]: return `${userPB.scoreData.percent.toFixed(2)}%`; case "scoreData.score": return userPB.scoreData.score.toLocaleString(); @@ -361,7 +361,7 @@ export function HumaniseGoalOutOf(game: Game, playtype: Playtype, key: GoalKeys, return lamp; } - case "scoreData.percent": + case [`scoreData.${gptConfig.defaultMetric}`]: return `${value.toFixed(2)}%`; case "scoreData.score": return value.toLocaleString(); diff --git a/server/src/server/router/api/v1/games/_game/_playtype/targets/goals/router.ts b/server/src/server/router/api/v1/games/_game/_playtype/targets/goals/router.ts index 3cd9fa92b..2480c4648 100644 --- a/server/src/server/router/api/v1/games/_game/_playtype/targets/goals/router.ts +++ b/server/src/server/router/api/v1/games/_game/_playtype/targets/goals/router.ts @@ -50,7 +50,7 @@ router.post( { criteria: { key: p.isIn( - "scoreData.percent", + [`scoreData.${gptConfig.defaultMetric}`], "scoreData.lampIndex", "scoreData.gradeIndex", "scoreData.score" diff --git a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/goals/router.test.ts b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/goals/router.test.ts index 6194d079f..ddb9e7a89 100644 --- a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/goals/router.test.ts +++ b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/goals/router.test.ts @@ -79,7 +79,7 @@ t.test("POST /api/v1/users/:userID/games/:game/:playtype/targets/add-goal", asyn const baseInput = { criteria: { - key: "scoreData.percent", + key: [`scoreData.${gptConfig.defaultMetric}`], value: 80, mode: "single", }, diff --git a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/goals/router.ts b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/goals/router.ts index 8b42ba967..d514ca6e5 100644 --- a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/goals/router.ts +++ b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/goals/router.ts @@ -14,6 +14,7 @@ import { GetParentQuests } from "lib/targets/quests"; import { p } from "prudence"; import { RequirePermissions } from "server/middleware/auth"; import prValidate from "server/middleware/prudence-validate"; +import { GetGamePTConfig } from "tachi-common"; import { GetGoalForIDGuaranteed } from "utils/db"; import { AssignToReqTachiData, GetTachiData, GetUGPT } from "utils/req-tachi-data"; import type { RequestHandler } from "express"; @@ -88,15 +89,10 @@ router.post( RequirePermissions("manage_targets"), prValidate({ criteria: { - key: p.isIn( - "scoreData.percent", - "scoreData.lampIndex", - "scoreData.gradeIndex", - "scoreData.score" - ), - // we do proper validation on this later. + key: "string", value: p.gte(0), + mode: p.isIn("single", "absolute", "proportion"), countNum: (self, parent) => { if (parent.mode === "single") { @@ -156,6 +152,20 @@ router.post( }); } + const gptConfig = GetGamePTConfig(game, playtype); + + const validCriteria = [ + ...Object.keys(gptConfig.providedMetrics), + Object.keys(gptConfig.derivedMetrics), + ]; + + if (!validCriteria.includes(req.body.criteria.key)) { + return res.status(400).json({ + success: false, + description: `Invalid criteria, expected any of ${validCriteria.join(", ")}.`, + }); + } + const data = req.safeBody as GoalCreationBody; let goal; diff --git a/server/src/server/router/ir/usc/_playtype/router.ts b/server/src/server/router/ir/usc/_playtype/router.ts index b072c25cd..3225905bd 100644 --- a/server/src/server/router/ir/usc/_playtype/router.ts +++ b/server/src/server/router/ir/usc/_playtype/router.ts @@ -13,6 +13,7 @@ import { ServerConfig, TachiConfig } from "lib/setup/config"; import { p } from "prudence"; import { RejectIfBanned, RequirePermissions } from "server/middleware/auth"; import { CreateMulterSingleUploadMiddleware } from "server/middleware/multer-upload"; +import { GetGamePTConfig } from "tachi-common"; import { FormatPrError } from "utils/prudence"; import { AssignToReqTachiData, GetTachiData } from "utils/req-tachi-data"; import type { USCClientChart } from "./types"; @@ -225,13 +226,15 @@ router.get("/charts/:chartHash/leaderboard", RetrieveChart, async (req, res) => }); } + const gptConfig = GetGamePTConfig("usc", chart.playtype); + const bestScores = (await db["personal-bests"].find( { chartID: chart.chartID, }, { sort: { - "scoreData.percent": -1, + [`scoreData.${gptConfig.defaultMetric}`]: -1, }, limit: n, } diff --git a/server/src/server/router/ir/usc/_playtype/usc.ts b/server/src/server/router/ir/usc/_playtype/usc.ts index 69956728a..b08bcbb54 100644 --- a/server/src/server/router/ir/usc/_playtype/usc.ts +++ b/server/src/server/router/ir/usc/_playtype/usc.ts @@ -59,17 +59,19 @@ export async function TachiScoreToServerScore( ); } + const firstScoreID = tachiScore.composedFrom[0].scoreID; + const scorePB = (await db.scores.findOne({ - scoreID: tachiScore.composedFrom.scorePB, + scoreID: firstScoreID, })) as ScoreDocument<"usc:Controller" | "usc:Keyboard"> | null; if (!scorePB) { logger.severe( - `Score ${tachiScore.composedFrom.scorePB} does not exist, but is referenced in ${tachiScore.userID}'s PBDoc on ${tachiScore.chartID}?` + `Score ${firstScoreID} does not exist, but is referenced in ${tachiScore.userID}'s PBDoc on ${tachiScore.chartID}?` ); throw new Error( - `Score ${tachiScore.composedFrom.scorePB} does not exist, but is referenced in ${tachiScore.userID}'s PBDoc on ${tachiScore.chartID}?` + `Score ${firstScoreID} does not exist, but is referenced in ${tachiScore.userID}'s PBDoc on ${tachiScore.chartID}?` ); } @@ -191,7 +193,9 @@ export async function CreatePOSTScoresResponseBody( score, serverRecord, isServerRecord: scorePB.userID === ktServerRecord.userID, - isPB: scorePB.composedFrom.scorePB === scoreID, + // it's a pb if the score is equal to what the user has as their best. + // lamps notwithstanding. + isPB: scorePB.scoreData.score === score.score, sendReplay: originalScore.scoreID, adjacentAbove, adjacentBelow,