fix: goals on calculatedData (#1697)

* fix: bad server.yml

* feat: goals on calculatedData
This commit is contained in:
zk
2026-06-19 22:03:04 +01:00
committed by GitHub
parent 9bfb13f561
commit 6ed20dfe26
30 changed files with 303 additions and 78 deletions
+11 -2
View File
@@ -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<<TAGS_EOF"
echo "ghcr.io/zkldi/tachi:main"
echo "ghcr.io/zkldi/tachi:latest"
echo "TAGS_EOF"
} >> "$GITHUB_OUTPUT"
else
echo "value=ghcr.io/zkldi/tachi:main-dev" >> "$GITHUB_OUTPUT"
{
echo "value<<TAGS_EOF"
echo "ghcr.io/zkldi/tachi:main-dev"
echo "TAGS_EOF"
} >> "$GITHUB_OUTPUT"
fi
- name: Build and push unified server image
@@ -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 */}
<div className="d-flex flex-wrap gap-2 mb-3">
{availableMetrics.map((metricKey) => (
<button
className={`btn btn-sm ${
criteria.key === metricKey ? "btn-primary" : "btn-outline-secondary"
!isCalculatedKey && criteria.key === metricKey
? "btn-primary"
: "btn-outline-secondary"
}`}
key={metricKey}
onClick={() => {
@@ -345,6 +353,7 @@ function CriteriaPicker({
...criteria,
key: metricKey,
value: defaultValue,
source: "score",
});
}}
type="button"
@@ -352,6 +361,33 @@ function CriteriaPicker({
{UppercaseFirst(metricKey)}
</button>
))}
{calculatedMetrics.length > 0 && (
<>
<span className="text-body-secondary align-self-center small px-1">|</span>
{calculatedMetrics.map((calcKey) => (
<button
className={`btn btn-sm ${
isCalculatedKey && criteria.key === calcKey
? "btn-info"
: "btn-outline-info"
}`}
key={calcKey}
onClick={() =>
setCriteria({
...criteria,
key: calcKey,
value: 0,
source: "calculated",
})
}
title="Rating algorithm"
type="button"
>
{UppercaseFirst(calcKey)}
</button>
))}
</>
)}
</div>
{/* 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 (
<span className="text-danger small">Unknown rating algorithm: {criteria.key}</span>
);
}
return (
<Form.Control
min={0}
onChange={(e) => onChange(Number(e.target.value))}
step="0.01"
style={{ width: "120px" }}
type="number"
value={criteria.value}
/>
);
}
const conf = GetScoreMetricConf(gameConfig, criteria.key);
if (!conf) {
+12
View File
@@ -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];
}
@@ -83,6 +83,7 @@ export const GAME_ARCAEA_CONF = {
potential: {
description: "Potential as it is implemented in Arcaea.",
formatter: ToDecimalPlaces(2),
canSetGoalsOn: true,
},
},
sessionRatingAlgs: {
@@ -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: {
@@ -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: {
@@ -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,
},
},
@@ -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." },
},
@@ -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,
},
},
@@ -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,
},
},
@@ -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." },
},
@@ -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: {
@@ -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: {
@@ -64,6 +64,7 @@ export const GAME_MUSECA_CONF = {
curatorSkill: {
description: "Curator Skill as it's implemented in-game.",
formatter: NoDecimalPlace,
canSetGoalsOn: true,
},
},
sessionRatingAlgs: {
@@ -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: {
@@ -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: {
@@ -108,6 +108,7 @@ export const GAME_POPN_CONF = {
scoreRatingAlgs: {
classPoints: {
description: "Class Points as they're implemented in game.",
canSetGoalsOn: false,
},
},
@@ -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: {
@@ -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: {
@@ -98,6 +98,7 @@ export const GAME_WACCA_CONF = {
scoreRatingAlgs: {
rate: {
description: "Rating as it's implemented in game.",
canSetGoalsOn: true,
},
},
profileRatingAlgs: {
+16 -1
View File
@@ -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<G extends string>(
*/
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) {
+9
View File
@@ -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<TGame extends V3Game = V3Game> extends GoalCriteria<TGame> {
@@ -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
+2 -1
View File
@@ -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<TGame extends V3Game> {
* server config. By defining them here, the typesystem will enforce that you
* implement them elsewhere.
*/
scoreRatingAlgs: Record<ScoreRatingAlgorithms[TGame], RatingAlgorithmConfig>;
scoreRatingAlgs: Record<ScoreRatingAlgorithms[TGame], ScoreRatingAlgorithmConfig>;
/**
* What rating algorithms may a session have attached onto it for this game?
+2 -1
View File
@@ -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<string, RatingAlgorithmConfig>;
scoreRatingAlgs: Record<string, ScoreRatingAlgorithmConfig>;
sessionRatingAlgs: Record<string, RatingAlgorithmConfig>;
supportedMatchTypes: ReadonlyArray<MatchTypes>;
versions: Record<string, string>;
@@ -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(),
}),
]);
+7 -4
View File
@@ -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;
@@ -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<string, number | null | undefined>)[
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);
+69 -45
View File
@@ -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") {
+48 -16
View File
@@ -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<string, number | null | undefined>)[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) {