From 6ed20dfe26a9b9b89fbdb28b5df0e49d96afa16c Mon Sep 17 00:00:00 2001 From: zk Date: Fri, 19 Jun 2026 22:03:04 +0100 Subject: [PATCH] fix: goals on calculatedData (#1697) * fix: bad server.yml * feat: goals on calculatedData --- .github/workflows/server.yml | 13 +- .../src/components/targets/GoalBuilder.tsx | 62 +++++++++- typescript/common/src/config/config.ts | 12 ++ .../common/src/config/game-support/arcaea.ts | 1 + .../common/src/config/game-support/bms.ts | 1 + .../src/config/game-support/chunithm.ts | 1 + .../common/src/config/game-support/ddr.ts | 1 + .../src/config/game-support/gitadora.ts | 7 +- .../common/src/config/game-support/iidx.ts | 4 + .../common/src/config/game-support/itg.ts | 2 + .../common/src/config/game-support/jubeat.ts | 7 +- .../src/config/game-support/maimai-dx.ts | 6 +- .../common/src/config/game-support/maimai.ts | 6 +- .../common/src/config/game-support/museca.ts | 1 + .../common/src/config/game-support/ongeki.ts | 3 + .../common/src/config/game-support/pms.ts | 1 + .../common/src/config/game-support/popn.ts | 1 + .../common/src/config/game-support/sdvx.ts | 3 + .../common/src/config/game-support/usc.ts | 2 + .../common/src/config/game-support/wacca.ts | 1 + typescript/common/src/lib/goal-title.ts | 17 ++- typescript/common/src/types/documents.ts | 9 ++ .../common/src/types/game-config-utils.ts | 10 ++ typescript/common/src/types/game-config.ts | 3 +- typescript/common/src/types/internals.ts | 3 +- .../common/src/types/seeds-documents-zod.ts | 2 + typescript/server/src/actions/add-goal.ts | 11 +- .../server/src/lib/targets/goal-pb-queries.ts | 13 +- .../server/src/lib/targets/goal-utils.ts | 114 +++++++++++------- typescript/server/src/lib/targets/goals.ts | 64 +++++++--- 30 files changed, 303 insertions(+), 78 deletions(-) diff --git a/.github/workflows/server.yml b/.github/workflows/server.yml index bbc447532..398dfa9e6 100644 --- a/.github/workflows/server.yml +++ b/.github/workflows/server.yml @@ -176,9 +176,18 @@ jobs: id: floating_tags run: | if [ "$GITHUB_REF" = "refs/heads/main" ]; then - echo "value=ghcr.io/zkldi/tachi:main\nghcr.io/zkldi/tachi:latest" >> "$GITHUB_OUTPUT" + { + echo "value<> "$GITHUB_OUTPUT" else - echo "value=ghcr.io/zkldi/tachi:main-dev" >> "$GITHUB_OUTPUT" + { + echo "value<> "$GITHUB_OUTPUT" fi - name: Build and push unified server image diff --git a/typescript/client/src/components/targets/GoalBuilder.tsx b/typescript/client/src/components/targets/GoalBuilder.tsx index d5347f12b..075f645c5 100644 --- a/typescript/client/src/components/targets/GoalBuilder.tsx +++ b/typescript/client/src/components/targets/GoalBuilder.tsx @@ -30,6 +30,7 @@ import { GetGameConfig, GetScoreMetricConf, GetScoreMetrics, + GetScoreRatingAlgConf, type GoalDocument, type SongDocument, } from "tachi-common"; @@ -323,15 +324,22 @@ function CriteriaPicker({ } & GameProps) { const gameConfig = GetGameConfig(game); const availableMetrics = GetScoreMetrics(gameConfig, ["ENUM", "DECIMAL", "INTEGER"]); + const calculatedMetrics = Object.entries(gameConfig.scoreRatingAlgs) + .filter(([, conf]) => conf.canSetGoalsOn) + .map(([key]) => key); + + const isCalculatedKey = criteria.source === "calculated"; return ( <> - {/* Metric pills */} + {/* Score metric pills */}
{availableMetrics.map((metricKey) => ( ))} + {calculatedMetrics.length > 0 && ( + <> + | + {calculatedMetrics.map((calcKey) => ( + + ))} + + )}
{/* Value row */} @@ -404,6 +440,28 @@ function CriteriaValuePicker({ onChange: (value: number) => void; } & GameProps) { const gameConfig = GetGameConfig(game); + + if (criteria.source === "calculated") { + const calcConf = GetScoreRatingAlgConf(gameConfig, criteria.key); + + if (!calcConf) { + return ( + Unknown rating algorithm: {criteria.key} + ); + } + + return ( + onChange(Number(e.target.value))} + step="0.01" + style={{ width: "120px" }} + type="number" + value={criteria.value} + /> + ); + } + const conf = GetScoreMetricConf(gameConfig, criteria.key); if (!conf) { diff --git a/typescript/common/src/config/config.ts b/typescript/common/src/config/config.ts index e6cd61327..f3afd51b9 100644 --- a/typescript/common/src/config/config.ts +++ b/typescript/common/src/config/config.ts @@ -9,6 +9,7 @@ import type { SpecificGameConfig, V3Game, } from "../types/game-config"; +import type { ScoreRatingAlgorithmConfig } from "../types/game-config-utils"; import type { INTERNAL_GAME_CONFIG, INTERNAL_GAME_GROUP_CONFIG } from "../types/internals"; import type { ConfEnumScoreMetric, ConfScoreMetric } from "../types/metrics"; @@ -344,3 +345,14 @@ export function ValidateMetric(gameConfig: GameConfig, metricName: string, metri export function GetScoreMetricConf(gameConfig: GameConfig, metric: string) { return gameConfig.providedMetrics[metric] ?? gameConfig.derivedMetrics[metric]; } + +/** + * Returns the rating algorithm config for a `scoreRatingAlgs` key, or `undefined` + * if no such algorithm exists for this game. + */ +export function GetScoreRatingAlgConf( + gameConfig: GameConfig, + key: string, +): ScoreRatingAlgorithmConfig | undefined { + return gameConfig.scoreRatingAlgs[key]; +} diff --git a/typescript/common/src/config/game-support/arcaea.ts b/typescript/common/src/config/game-support/arcaea.ts index 6a6c836e2..adc5a80ab 100644 --- a/typescript/common/src/config/game-support/arcaea.ts +++ b/typescript/common/src/config/game-support/arcaea.ts @@ -83,6 +83,7 @@ export const GAME_ARCAEA_CONF = { potential: { description: "Potential as it is implemented in Arcaea.", formatter: ToDecimalPlaces(2), + canSetGoalsOn: true, }, }, sessionRatingAlgs: { diff --git a/typescript/common/src/config/game-support/bms.ts b/typescript/common/src/config/game-support/bms.ts index 59231fd96..8f117c2f3 100644 --- a/typescript/common/src/config/game-support/bms.ts +++ b/typescript/common/src/config/game-support/bms.ts @@ -345,6 +345,7 @@ export const GAME_BMS_7K_CONF = { description: "A clearing algorithm that rewards you points based on how difficult an easy clear or hard clear was.", formatter: FormatSieglindeBMS, + canSetGoalsOn: false, }, }, sessionRatingAlgs: { diff --git a/typescript/common/src/config/game-support/chunithm.ts b/typescript/common/src/config/game-support/chunithm.ts index e6bd861f1..44e36ee34 100644 --- a/typescript/common/src/config/game-support/chunithm.ts +++ b/typescript/common/src/config/game-support/chunithm.ts @@ -118,6 +118,7 @@ export const GAME_CHUNITHM_CONF = { description: "The rating value of this score. This is identical to the system used in game.", formatter: ToDecimalPlaces(2), + canSetGoalsOn: true, }, }, sessionRatingAlgs: { diff --git a/typescript/common/src/config/game-support/ddr.ts b/typescript/common/src/config/game-support/ddr.ts index 7326bb5c5..eead1512c 100644 --- a/typescript/common/src/config/game-support/ddr.ts +++ b/typescript/common/src/config/game-support/ddr.ts @@ -154,6 +154,7 @@ export const GAME_DDR_SP_CONF = { flareSkill: { description: "Flare Skill as it's implemented in DDR World.", formatter: FmtScoreNoCommas, + canSetGoalsOn: false, }, }, diff --git a/typescript/common/src/config/game-support/gitadora.ts b/typescript/common/src/config/game-support/gitadora.ts index b36ca306c..fbcf04a16 100644 --- a/typescript/common/src/config/game-support/gitadora.ts +++ b/typescript/common/src/config/game-support/gitadora.ts @@ -70,7 +70,12 @@ export const GAME_GITADORA_GITA_CONF = { ...FAST_SLOW_MAXCOMBO, }, - scoreRatingAlgs: { skill: { description: "Skill Rating as it's implemented in game." } }, + scoreRatingAlgs: { + skill: { + description: "Skill Rating as it's implemented in game.", + canSetGoalsOn: true, + }, + }, sessionRatingAlgs: { skill: { description: "The average of your best 10 skill ratings this session." }, }, diff --git a/typescript/common/src/config/game-support/iidx.ts b/typescript/common/src/config/game-support/iidx.ts index 10dec506b..236ee1aad 100644 --- a/typescript/common/src/config/game-support/iidx.ts +++ b/typescript/common/src/config/game-support/iidx.ts @@ -182,17 +182,21 @@ export const GAME_IIDX_SP_CONF = { ktLampRating: { description: "A rating system that values your clear lamps on charts. Tierlist information is taken into account.", + canSetGoalsOn: false, }, ktLampRatingHC: { description: "Your hard clear performance. Tierlist information is taken into account.", + canSetGoalsOn: false, }, ktLampRatingEXHC: { description: "Your EX-hard clear performance. Tierlist information is taken into account.", + canSetGoalsOn: false, }, BPI: { description: "A rating system for Kaiden level play. Only applies to 11s and 12s. A BPI of 0 states the score is equal to the Kaiden Average for that chart. A BPI of 100 is equal to the world record.", + canSetGoalsOn: true, }, }, diff --git a/typescript/common/src/config/game-support/itg.ts b/typescript/common/src/config/game-support/itg.ts index 8fd9e5658..f60def2a0 100644 --- a/typescript/common/src/config/game-support/itg.ts +++ b/typescript/common/src/config/game-support/itg.ts @@ -118,10 +118,12 @@ export const GAME_ITG_STAMINA_CONF = { blockRating: { description: "How much this clear is worth.", formatter: NoDecimalPlace, + canSetGoalsOn: false, }, fastest32: { description: "The fastest BPM this score streamed 32 measures straight for.", formatter: NoDecimalPlace, + canSetGoalsOn: false, }, }, diff --git a/typescript/common/src/config/game-support/jubeat.ts b/typescript/common/src/config/game-support/jubeat.ts index 07f879472..990cc1789 100644 --- a/typescript/common/src/config/game-support/jubeat.ts +++ b/typescript/common/src/config/game-support/jubeat.ts @@ -79,7 +79,12 @@ export const GAME_JUBEAT_SINGLE_CONF = { }, }, - scoreRatingAlgs: { jubility: { description: "Jubility as it's implemented in game." } }, + scoreRatingAlgs: { + jubility: { + description: "Jubility as it's implemented in game.", + canSetGoalsOn: true, + }, + }, sessionRatingAlgs: { jubility: { description: "The average of your best 10 jubilities this session." }, }, diff --git a/typescript/common/src/config/game-support/maimai-dx.ts b/typescript/common/src/config/game-support/maimai-dx.ts index c3e7b310f..16c0a6a5b 100644 --- a/typescript/common/src/config/game-support/maimai-dx.ts +++ b/typescript/common/src/config/game-support/maimai-dx.ts @@ -165,7 +165,11 @@ export const GAME_MAIMAI_DX_CONF = { }, scoreRatingAlgs: { - rate: { description: "Rating as it's implemented in game.", formatter: NoDecimalPlace }, + rate: { + description: "Rating as it's implemented in game.", + formatter: NoDecimalPlace, + canSetGoalsOn: true, + }, }, sessionRatingAlgs: { rate: { diff --git a/typescript/common/src/config/game-support/maimai.ts b/typescript/common/src/config/game-support/maimai.ts index 4cdd2993c..58d0cc05c 100644 --- a/typescript/common/src/config/game-support/maimai.ts +++ b/typescript/common/src/config/game-support/maimai.ts @@ -109,7 +109,11 @@ export const GAME_MAIMAI_CONF = { optionalMetrics: FAST_SLOW_MAXCOMBO, scoreRatingAlgs: { - rate: { description: "Rating as it's implemented in game.", formatter: ToDecimalPlaces(2) }, + rate: { + description: "Rating as it's implemented in game.", + formatter: ToDecimalPlaces(2), + canSetGoalsOn: true, + }, }, sessionRatingAlgs: { rate: { diff --git a/typescript/common/src/config/game-support/museca.ts b/typescript/common/src/config/game-support/museca.ts index 10e0b5396..597c9a561 100644 --- a/typescript/common/src/config/game-support/museca.ts +++ b/typescript/common/src/config/game-support/museca.ts @@ -64,6 +64,7 @@ export const GAME_MUSECA_CONF = { curatorSkill: { description: "Curator Skill as it's implemented in-game.", formatter: NoDecimalPlace, + canSetGoalsOn: true, }, }, sessionRatingAlgs: { diff --git a/typescript/common/src/config/game-support/ongeki.ts b/typescript/common/src/config/game-support/ongeki.ts index 100fb6f93..94b2f07dc 100644 --- a/typescript/common/src/config/game-support/ongeki.ts +++ b/typescript/common/src/config/game-support/ongeki.ts @@ -164,14 +164,17 @@ export const GAME_ONGEKI_CONF = { rating: { description: "Technical Score-based rating as it's implemented in bright MEMORY.", formatter: ToDecimalPlaces(2), + canSetGoalsOn: true, }, scoreRating: { description: "Technical Score-based rating as it's implemented in Re:Fresh.", formatter: ToDecimalPlaces(3), + canSetGoalsOn: true, }, starRating: { description: "Platinum Stars-based rating as it's implemented in Re:Fresh.", formatter: ToDecimalPlaces(3), + canSetGoalsOn: false, }, }, sessionRatingAlgs: { diff --git a/typescript/common/src/config/game-support/pms.ts b/typescript/common/src/config/game-support/pms.ts index 1d4d97597..4303fc80b 100644 --- a/typescript/common/src/config/game-support/pms.ts +++ b/typescript/common/src/config/game-support/pms.ts @@ -107,6 +107,7 @@ export const GAME_PMS_CONTROLLER_CONF = { description: "A clearing algorithm that rewards you points based on how difficult an easy clear or hard clear was.", formatter: FormatSieglindePMS, + canSetGoalsOn: false, }, }, sessionRatingAlgs: { diff --git a/typescript/common/src/config/game-support/popn.ts b/typescript/common/src/config/game-support/popn.ts index 7cde60ddc..ca37dee1e 100644 --- a/typescript/common/src/config/game-support/popn.ts +++ b/typescript/common/src/config/game-support/popn.ts @@ -108,6 +108,7 @@ export const GAME_POPN_CONF = { scoreRatingAlgs: { classPoints: { description: "Class Points as they're implemented in game.", + canSetGoalsOn: false, }, }, diff --git a/typescript/common/src/config/game-support/sdvx.ts b/typescript/common/src/config/game-support/sdvx.ts index 1d8b21738..5357cec92 100644 --- a/typescript/common/src/config/game-support/sdvx.ts +++ b/typescript/common/src/config/game-support/sdvx.ts @@ -142,10 +142,13 @@ export const GAME_SDVX_CONF = { VF6: { description: "VOLFORCE as it is implemented in SDVX6.", formatter: ToDecimalPlaces(3), + // off, fuck that + canSetGoalsOn: false, }, VF7: { description: "VOLFORCE as it is implemented in SDVX7.", formatter: ToDecimalPlaces(3), + canSetGoalsOn: true, }, }, sessionRatingAlgs: { diff --git a/typescript/common/src/config/game-support/usc.ts b/typescript/common/src/config/game-support/usc.ts index ead9c42d3..4b91074be 100644 --- a/typescript/common/src/config/game-support/usc.ts +++ b/typescript/common/src/config/game-support/usc.ts @@ -69,10 +69,12 @@ export const GAME_USC_CONTROLLER_CONF = { VF6: { description: "VOLFORCE as it is implemented in SDVX6.", formatter: ToDecimalPlaces(3), + canSetGoalsOn: false, }, VF7: { description: "VOLFORCE as it is implemented in SDVX7.", formatter: ToDecimalPlaces(3), + canSetGoalsOn: true, }, }, sessionRatingAlgs: { diff --git a/typescript/common/src/config/game-support/wacca.ts b/typescript/common/src/config/game-support/wacca.ts index c876ffc4d..19076baec 100644 --- a/typescript/common/src/config/game-support/wacca.ts +++ b/typescript/common/src/config/game-support/wacca.ts @@ -98,6 +98,7 @@ export const GAME_WACCA_CONF = { scoreRatingAlgs: { rate: { description: "Rating as it's implemented in game.", + canSetGoalsOn: true, }, }, profileRatingAlgs: { diff --git a/typescript/common/src/lib/goal-title.ts b/typescript/common/src/lib/goal-title.ts index ae853ecc0..e627ea574 100644 --- a/typescript/common/src/lib/goal-title.ts +++ b/typescript/common/src/lib/goal-title.ts @@ -10,7 +10,7 @@ import type { GradeBoundary } from "../constants/grade-boundaries"; import type { GoalDocument, V3Game } from "../types"; -import { GetGameConfig, GetScoreMetricConf } from "../config/config"; +import { GetGameConfig, GetScoreMetricConf, GetScoreRatingAlgConf } from "../config/config"; import { FmtNumCompact, GetGradeDeltas, staticAssertUnreachable } from "../utils/util"; // ─── Internal helpers (also exported for use by consumers) ──────────────────── @@ -95,6 +95,21 @@ export function GradeGoalFormatter( */ export function FormatGoalCriteria(criteria: GoalDocument["criteria"], game: V3Game): string { const gameConfig = GetGameConfig(game); + + if (criteria.source === "calculated") { + const calcConf = GetScoreRatingAlgConf(gameConfig, criteria.key); + + if (!calcConf) { + throw new Error( + `Invalid goal criteria with key ${criteria.key}. No rating algorithm config exists for ${game}?`, + ); + } + + const fmt = calcConf.formatter ?? OnlyFloatToDP; + + return `Get a ${criteria.key} of ${fmt(criteria.value)} on`; + } + const conf = GetScoreMetricConf(gameConfig, criteria.key); if (!conf) { diff --git a/typescript/common/src/types/documents.ts b/typescript/common/src/types/documents.ts index f54967f01..15b8b4760 100644 --- a/typescript/common/src/types/documents.ts +++ b/typescript/common/src/types/documents.ts @@ -51,6 +51,15 @@ interface GoalCriteria<_TGame extends V3Game = V3Game> { key: string; value: number; + + /** + * Where the metric value is read from on the PB. + * - `"score"` (default, backwards-compatible): reads from `pb.scoreData` — + * covers `providedMetrics` and `derivedMetrics`. + * - `"calculated"`: reads from `pb.calculatedData` — covers `scoreRatingAlgs` + * (e.g. BPI, rating). + */ + source?: "calculated" | "score"; } export interface GoalSingleCriteria extends GoalCriteria { diff --git a/typescript/common/src/types/game-config-utils.ts b/typescript/common/src/types/game-config-utils.ts index 75c01824f..6903c750e 100644 --- a/typescript/common/src/types/game-config-utils.ts +++ b/typescript/common/src/types/game-config-utils.ts @@ -15,6 +15,16 @@ export interface RatingAlgorithmConfig { formatter?: (value: number) => string; } +/** + * Config for a **score**-level rating algorithm (i.e. an entry in `scoreRatingAlgs`). + * + * Extends the base with `canSetGoalsOn`, which gates whether users are allowed to + * create goals targeting this algorithm (e.g. "get a BPI of X on this chart"). + */ +export interface ScoreRatingAlgorithmConfig extends RatingAlgorithmConfig { + canSetGoalsOn: boolean; +} + export interface ProfileRatingAlgorithmConfig extends RatingAlgorithmConfig { /** * Which score rating algorithms should be mentioned in the footnote diff --git a/typescript/common/src/types/game-config.ts b/typescript/common/src/types/game-config.ts index 9cacc360e..c503bfd9e 100644 --- a/typescript/common/src/types/game-config.ts +++ b/typescript/common/src/types/game-config.ts @@ -8,6 +8,7 @@ import type { FixedDifficulties, ProfileRatingAlgorithmConfig, RatingAlgorithmConfig, + ScoreRatingAlgorithmConfig, } from "./game-config-utils"; import type { INTERNAL_GAME_CONFIG } from "./internals"; import type { @@ -478,7 +479,7 @@ export interface SpecificGameConfig { * server config. By defining them here, the typesystem will enforce that you * implement them elsewhere. */ - scoreRatingAlgs: Record; + scoreRatingAlgs: Record; /** * What rating algorithms may a session have attached onto it for this game? diff --git a/typescript/common/src/types/internals.ts b/typescript/common/src/types/internals.ts index 86e8872ad..3f25099a6 100644 --- a/typescript/common/src/types/internals.ts +++ b/typescript/common/src/types/internals.ts @@ -6,6 +6,7 @@ import type { DifficultyConfig, ProfileRatingAlgorithmConfig, RatingAlgorithmConfig, + ScoreRatingAlgorithmConfig, } from "./game-config-utils"; import type { ConfScoreMetric } from "./metrics"; @@ -61,7 +62,7 @@ export type INTERNAL_GAME_CONFIG = Readonly<{ scoreMeta: ZodObject; - scoreRatingAlgs: Record; + scoreRatingAlgs: Record; sessionRatingAlgs: Record; supportedMatchTypes: ReadonlyArray; versions: Record; diff --git a/typescript/common/src/types/seeds-documents-zod.ts b/typescript/common/src/types/seeds-documents-zod.ts index cd4d42f50..c4692c4aa 100644 --- a/typescript/common/src/types/seeds-documents-zod.ts +++ b/typescript/common/src/types/seeds-documents-zod.ts @@ -218,11 +218,13 @@ const SEEDS_GOAL_CRITERIA_SCHEMA = z.union([ countNum: z.number(), key: z.string(), mode: z.union([z.literal("absolute"), z.literal("proportion")]), + source: z.enum(["score", "calculated"]).optional(), value: z.number(), }), z.strictObject({ key: z.string(), mode: z.literal("single"), + source: z.enum(["score", "calculated"]).optional(), value: z.number(), }), ]); diff --git a/typescript/server/src/actions/add-goal.ts b/typescript/server/src/actions/add-goal.ts index e69a9be0d..e2983db0a 100644 --- a/typescript/server/src/actions/add-goal.ts +++ b/typescript/server/src/actions/add-goal.ts @@ -33,10 +33,13 @@ export const ACTION_AddGoal = MakeAction("ADD_GOAL", async (taker, input) => { const gameConfig = GetGameConfig(game); - const validCriteria = [ - ...Object.keys(gameConfig.providedMetrics), - ...Object.keys(gameConfig.derivedMetrics), - ]; + const isCalculated = (criteria as { source?: string }).source === "calculated"; + + const validCriteria = isCalculated + ? Object.entries(gameConfig.scoreRatingAlgs) + .filter(([, conf]) => conf.canSetGoalsOn) + .map(([key]) => key) + : [...Object.keys(gameConfig.providedMetrics), ...Object.keys(gameConfig.derivedMetrics)]; const criteriaKey = (criteria as { key?: string }).key; diff --git a/typescript/server/src/lib/targets/goal-pb-queries.ts b/typescript/server/src/lib/targets/goal-pb-queries.ts index 54eef3895..e432fd3f2 100644 --- a/typescript/server/src/lib/targets/goal-pb-queries.ts +++ b/typescript/server/src/lib/targets/goal-pb-queries.ts @@ -34,8 +34,17 @@ export async function LoadPbsForUserOnChartsForGoal( export function getGoalMetricValueFromPb( pb: PBScoreDocument, criteriaKey: GoalDocument["criteria"]["key"], - scoreConf: GoalMetricConf, + scoreConf: GoalMetricConf | null, ): number | null { + // null scoreConf signals a calculated-data goal — read from pb.calculatedData + if (scoreConf === null) { + const v = (pb.calculatedData as Record)[ + criteriaKey as string + ]; + + return typeof v === "number" ? v : null; + } + if (scoreConf.type === "ENUM") { const v = pb.scoreData.enumIndexes[criteriaKey as keyof typeof pb.scoreData.enumIndexes]; @@ -51,7 +60,7 @@ export function pbMeetsGoalThreshold( pb: PBScoreDocument, criteriaKey: GoalDocument["criteria"]["key"], threshold: number, - scoreConf: GoalMetricConf, + scoreConf: GoalMetricConf | null, ): boolean { const v = getGoalMetricValueFromPb(pb, criteriaKey, scoreConf); diff --git a/typescript/server/src/lib/targets/goal-utils.ts b/typescript/server/src/lib/targets/goal-utils.ts index 30ed827d3..53b046f50 100644 --- a/typescript/server/src/lib/targets/goal-utils.ts +++ b/typescript/server/src/lib/targets/goal-utils.ts @@ -10,6 +10,7 @@ import { FormatGoalCriteria, GetGameConfig, GetScoreMetricConf, + GetScoreRatingAlgConf, type GoalDocument, type V3Game, } from "tachi-common"; @@ -153,67 +154,90 @@ export async function ValidateGoalChartsAndCriteria( // checking whether the key and value make sense const gameConfig = GetGameConfig(game); - const config = GetScoreMetricConf(gameConfig, criteria.key); + if (criteria.source === "calculated") { + const calcConf = GetScoreRatingAlgConf(gameConfig, criteria.key); - if (!config) { - throw new Error(`Invalid criteria.key for ${FormatGame(game)} (Got ${criteria.key}).`); - } + if (!calcConf) { + throw new Error( + `Invalid criteria.key '${criteria.key}' for ${FormatGame(game)}: not a score rating algorithm.`, + ); + } - const gptImpl = GAME_IMPLEMENTATIONS[game]; + if (!calcConf.canSetGoalsOn) { + throw new Error( + `Cannot set goals on '${criteria.key}' for ${FormatGame(game)}: this rating algorithm does not support goals.`, + ); + } - switch (config.type) { - case "DECIMAL": - case "INTEGER": { - const allowFolderGoals = - config.chartDependentMax !== true || config.allowFolderGoalsIf?.(criteria.value); + if (typeof criteria.value !== "number" || criteria.value < 0) { + throw new Error( + `Invalid value ${criteria.value} for calculated metric ${criteria.key}: must be a non-negative number.`, + ); + } + } else { + const config = GetScoreMetricConf(gameConfig, criteria.key); - if (!allowFolderGoals && charts.type !== "single") { - throw new Error( - `Creating ${criteria.key} goals on multiple charts where the maximum value is relative to the chart is a terrible idea, and has been disabled.`, - ); - } + if (!config) { + throw new Error(`Invalid criteria.key for ${FormatGame(game)} (Got ${criteria.key}).`); + } - let err; + const gptImpl = GAME_IMPLEMENTATIONS[game]; - if (!allowFolderGoals) { - const chart = await GetChartByIdForGame(game, charts.data as string); + switch (config.type) { + case "DECIMAL": + case "INTEGER": { + const allowFolderGoals = + config.chartDependentMax !== true || + config.allowFolderGoalsIf?.(criteria.value); - if (!chart) { + if (!allowFolderGoals && charts.type !== "single") { throw new Error( - `Chart ${charts.data} was removed from the database while a goal was being validated on it?`, + `Creating ${criteria.key} goals on multiple charts where the maximum value is relative to the chart is a terrible idea, and has been disabled.`, ); } - // @ts-expect-error this is fine leave me alone - err = gptImpl.chartSpecificValidators[criteria.key](criteria.value, chart); - } else { - // @ts-expect-error if allowFolderGoals is true, validate has to exist, and tsc's opinion has no weight here. - err = config.validate(criteria.value); + let err; + + if (!allowFolderGoals) { + const chart = await GetChartByIdForGame(game, charts.data as string); + + if (!chart) { + throw new Error( + `Chart ${charts.data} was removed from the database while a goal was being validated on it?`, + ); + } + + // @ts-expect-error this is fine leave me alone + err = gptImpl.chartSpecificValidators[criteria.key](criteria.value, chart); + } else { + // @ts-expect-error if allowFolderGoals is true, validate has to exist, and tsc's opinion has no weight here. + err = config.validate(criteria.value); + } + + if (err !== true) { + throw new Error(`Invalid value ${criteria.value} for ${criteria.key}, ${err}`); + } + + break; } - if (err !== true) { - throw new Error(`Invalid value ${criteria.value} for ${criteria.key}, ${err}`); + case "ENUM": { + if (!config.values[criteria.value]) { + throw new Error( + `Invalid value of ${criteria.value} for ${criteria.key} goal. No such ${criteria.key} exists at that index.`, + ); + } + + break; } - break; + case "GRAPH": + case "NULLABLE_GRAPH": + throw new Error(`Cannot set a goal on ${criteria.key} as it's a graph metric.`); + + default: + staticAssertUnreachable(config); } - - case "ENUM": { - if (!config.values[criteria.value]) { - throw new Error( - `Invalid value of ${criteria.value} for ${criteria.key} goal. No such ${criteria.key} exists at that index.`, - ); - } - - break; - } - - 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 a514ecaf8..0e4cd7e40 100644 --- a/typescript/server/src/lib/targets/goals.ts +++ b/typescript/server/src/lib/targets/goals.ts @@ -24,9 +24,11 @@ import { GAME_GOAL_PROGRESS_FORMATTERS, GetGameConfig, GetScoreMetricConf, + GetScoreRatingAlgConf, type GoalDocument, type GoalSubscriptionDocument, type integer, + OnlyFloatToDP, type PBScoreDocument, type QuestDocument, type QuestSubscriptionDocument, @@ -70,9 +72,12 @@ export async function EvaluateGoalForUser( const chartIDs = await ResolveGoalCharts(goal); const v3Game = goal.game; const gameConfig = GetGameConfig(v3Game); - const scoreConf = GetScoreMetricConf(gameConfig, goal.criteria.key); + const isCalculated = goal.criteria.source === "calculated"; - if (!scoreConf) { + // null scoreConf signals a calculated-data goal; getGoalMetricValueFromPb handles this. + const scoreConf = isCalculated ? null : GetScoreMetricConf(gameConfig, goal.criteria.key); + + if (!isCalculated && !scoreConf) { throw new Error( `Invalid goal.criteria.key, got '${goal.criteria.key}', but no config exists for this metric for ${v3Game}.`, ); @@ -82,7 +87,12 @@ export async function EvaluateGoalForUser( switch (goal.criteria.mode) { case "single": { - const outOfHuman = HumaniseGoalOutOf(v3Game, goal.criteria.key, goal.criteria.value); + const outOfHuman = HumaniseGoalOutOf( + v3Game, + goal.criteria.key, + goal.criteria.value, + goal.criteria.source, + ); const qualifying = pbs.filter((pb) => pbMeetsGoalThreshold(pb, goal.criteria.key, goal.criteria.value, scoreConf), @@ -94,18 +104,14 @@ export async function EvaluateGoalForUser( return { achieved: true, outOf: goal.criteria.value, - progress: - scoreConf.type === "ENUM" - ? // @ts-expect-error narrow - res.scoreData.enumIndexes[goal.criteria.key] - : // @ts-expect-error narrow - res.scoreData[goal.criteria.key], + progress: getGoalMetricValueFromPb(res, goal.criteria.key, scoreConf), outOfHuman, progressHuman: HumaniseGoalProgress( v3Game, goal.criteria.key, goal.criteria.value, res, + goal.criteria.source, ), }; } @@ -134,17 +140,13 @@ export async function EvaluateGoalForUser( achieved: false, outOf: goal.criteria.value, outOfHuman, - progress: - scoreConf.type === "ENUM" - ? // @ts-expect-error narrow - nextBestScore.scoreData.enumIndexes[goal.criteria.key] - : // @ts-expect-error narrow - nextBestScore.scoreData[goal.criteria.key], + progress: getGoalMetricValueFromPb(nextBestScore, goal.criteria.key, scoreConf), progressHuman: HumaniseGoalProgress( v3Game, goal.criteria.key, goal.criteria.value, nextBestScore, + goal.criteria.source, ), }; } @@ -221,7 +223,17 @@ export function HumaniseGoalProgress( key: GoalKeys, goalValue: integer, userPB: PBScoreDocument, + source?: "calculated" | "score", ): string { + if (source === "calculated") { + const gameConfig = GetGameConfig(game); + const calcConf = GetScoreRatingAlgConf(gameConfig, key); + const fmt = calcConf?.formatter ?? OnlyFloatToDP; + const v = (userPB.calculatedData as Record)[key]; + + return typeof v === "number" ? fmt(v) : "NO DATA"; + } + const formatters = GAME_GOAL_PROGRESS_FORMATTERS[game]; const formatter = formatters[key]; @@ -239,8 +251,28 @@ export function HumaniseGoalProgress( * Turn a goal's "outOf" (i.e. HARD CLEAR; AAA or score=2450) into a human-understandable * string. */ -export function HumaniseGoalOutOf(v3Game: V3Game, key: GoalKeys, value: number) { +export function HumaniseGoalOutOf( + v3Game: V3Game, + key: GoalKeys, + value: number, + source?: "calculated" | "score", +) { const gameConfig = GetGameConfig(v3Game); + + if (source === "calculated") { + const calcConf = GetScoreRatingAlgConf(gameConfig, key); + + if (!calcConf) { + throw new Error( + `Attempted to format outOf for calculated metric '${key}' when no such rating algorithm exists for ${v3Game}.`, + ); + } + + const fmt = calcConf.formatter ?? OnlyFloatToDP; + + return fmt(value); + } + const metricConf = GetScoreMetricConf(gameConfig, key); if (!metricConf) {