mirror of
https://github.com/zkldi/Tachi.git
synced 2026-10-05 13:28:08 +03:00
fix: goals on calculatedData (#1697)
* fix: bad server.yml * feat: goals on calculatedData
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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(),
|
||||
}),
|
||||
]);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user