checkpoint: 25

This commit is contained in:
zkldi
2023-01-19 16:39:38 +00:00
parent 1b567bb2bd
commit 7341a1bc1b
52 changed files with 694 additions and 613 deletions
+4 -3
View File
@@ -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<string, ConfScoreMetric>;
+16 -15
View File
@@ -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: {
+2 -1
View File
@@ -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"],
+2 -1
View File
@@ -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"],
+12 -10
View File
@@ -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",
+8 -4
View File
@@ -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",
+4 -13
View File
@@ -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]),
+2 -1
View File
@@ -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+"],
+2 -1
View File
@@ -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"],
+1 -1
View File
@@ -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",
+3 -2
View File
@@ -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: {
+4 -3
View File
@@ -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: {
+3 -2
View File
@@ -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: {
+2 -1
View File
@@ -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"],
+2 -1
View File
@@ -471,7 +471,8 @@ export interface PBReference {
}
export interface PBScoreDocument<GPT extends GPTString = GPTString> {
composedFrom: Array<PBReference>;
// guaranteed to atleast have one element.
composedFrom: [PBReference, ...Array<PBReference>];
rankingData: {
rank: integer;
outOf: integer;
+47 -18
View File
@@ -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<GPT extends GPTString> = (
metric: number,
chart: ChartDocument<GPT>
) => string | true;
export type IntegerMetricValidator<GPT extends GPTString> = (
metric: integer,
chart: ChartDocument<GPT>
) => string | true;
export type GraphMetricValidator<GPT extends GPTString> = (
metric: Array<number>,
chart: ChartDocument<GPT>
) => 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<V extends string> {
*/
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<R extends Record<string, ConfScoreMetric>> =
/**
* 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]>
> = AllMetrics[GPT][MetricName] extends ConfEnumScoreMetric<infer EnumValues> ? EnumValues : never;
MetricName extends ExtractEnumMetricNames<AllConfMetrics[GPT]>
> = AllConfMetrics[GPT][MetricName] extends ConfEnumScoreMetric<infer EnumValues>
? EnumValues
: never;
/**
* Turn a record of ConfigScoreMetrics into their actual literal values.
+1 -1
View File
@@ -34,7 +34,7 @@ const staticIndexes: Partial<Record<Databases, Array<Index>>> = {
],
"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
@@ -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: {} },
};
@@ -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<G extends string>(grades: Array<GradeBoundary<G>>, 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<IIDXLikes> = (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<IIDXLikes> = {
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<IIDXLikes> = { score: EX_SCORE_CHECK };
type SDVXLikes = GPTStrings["sdvx" | "usc"];
export const SDVXLIKE_DERIVERS: GPTDerivers<SDVXLikes> = {
grade: ({ score }) => GetGrade(SDVXLIKE_GBOUNDARIES, score),
};
@@ -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),
},
};
@@ -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),
},
};
@@ -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);
},
},
};
@@ -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),
},
};
@@ -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),
},
};
@@ -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),
},
};
@@ -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;
},
},
};
@@ -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),
},
};
+44
View File
@@ -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<GPT extends GPTString> = (
metric: number,
chart: ChartDocument<GPT>
) => 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<GPT extends GPTString> = {
[M in keyof AllConfMetrics[GPT] as AllConfMetrics[GPT][M] extends ChartDependentMax
? M
: never]: ChartSpecificMetricValidator<GPT>;
};
export type GPTDerivers<GPT extends GPTString> = {
// @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<ConfDerivedMetrics[GPT][K], GPT>;
};
export interface GPTServerImplementation<GPT extends GPTString> {
validators: GPTMetricValidators<GPT>;
derivers: GPTDerivers<GPT>;
}
export type GPTImplementations = {
[GPT in GPTString]: GPTServerImplementation<GPT>;
};
+5 -3
View File
@@ -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<BulkWriteUpdateOneOperation<PBScoreDocument>> = [];
@@ -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({
@@ -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<GPTStrings["bms" | "pms"]> = (dryScore, chart) => {
const SGLCalc: ScoreCalculator<GPTStrings["bms" | "pms"]> = (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<GPTStrings["bms" | "pms"]> = (dryScore, chart) =>
}
};
const VF6Calc: ScoreCalculator<GPTStrings["sdvx" | "usc"]> = (dryScore, chart) =>
Volforce.calculateVF6(dryScore.scoreData.score, dryScore.scoreData.lamp, chart.levelNum);
const VF6Calc: ScoreCalculator<GPTStrings["sdvx" | "usc"]> = (scoreData, chart) =>
Volforce.calculateVF6(scoreData.score, scoreData.lamp, chart.levelNum);
const BPICalc: ScoreCalculator<GPTStrings["iidx"]> = (dryScore, chart) => {
const BPICalc: ScoreCalculator<GPTStrings["iidx"]> = (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<GPTStrings["iidx"]> = (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<GPT extends GPTString>(
dryScore: DryScore<GPT>,
game: Game,
dryScoreData: DryScoreData<GPT>,
chart: ChartDocument<GPT>
) {
const gptString = GetGPTString(dryScore.game, chart.playtype);
const gptString = GetGPTString(game, chart.playtype);
const calcData: Record<string, number | null> = {};
for (const [key, fn] of Object.entries(SCORE_CALCULATORS[gptString])) {
calcData[key] = fn(dryScore, chart);
calcData[key] = fn(dryScoreData, chart);
}
return calcData;
@@ -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<GPT extends GPTString> = (
dryScore: DryScore<GPT>,
scoreData: DryScoreData<GPT>,
chart: ChartDocument<GPT>
) => number | 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<G extends string>(grades: Array<GradeBoundary<G>>, 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}.`);
}
@@ -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<IIDXLikes> = {
percent: ({ score }, chart) => calculateIIDXLikePercent(score, chart.data.notecount),
grade: ({ score }, chart) => {
const percent = calculateIIDXLikePercent(score, chart.data.notecount);
return GetGrade(IIDXLIKE_GBOUNDARIES, percent);
},
};
@@ -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";
}
}
@@ -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<SDVXLikes> = {
grade: ({ score }) => GetGrade(SDVXLIKE_GBOUNDARIES, score),
};
@@ -1,12 +0,0 @@
import type { ConfDerivedMetrics, GPTString } from "tachi-common";
import type { ScoreMetricDeriver } from "tachi-common/types/metrics";
export type GPTDerivers<GPT extends GPTString> = {
// @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<ConfDerivedMetrics[GPT][K], GPT>;
};
@@ -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<GPT extends GPTString = GPTString> = Omit<
@@ -26,10 +28,12 @@ export type PBScoreDocumentNoRank<GPT extends GPTString = GPTString> = Omit<
export async function CreatePBDoc(
gpt: GPTString,
userID: integer,
chartID: string,
chart: ChartDocument,
logger: KtLogger,
asOfTimestamp?: number
) {
const chartID = chart.chartID;
const query: FilterQuery<ScoreDocument> = {
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,
},
}
@@ -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<boolean> {
// 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<boolean> {
// @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<ScoreDocument> = {
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<GPTStringWithBP>,
scorePB: ScoreDocument<GPTStringWithBP>,
lampPB: ScoreDocument<GPTStringWithBP>,
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<ScoreDocument> = {
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 }];
}
@@ -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<GPTString, string> = {
"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",
};
@@ -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.
*/
@@ -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<GPT extends GPTString>(
metric: MetricKeys<GPT>,
name: string,
applicator: (base: PBScoreDocument<GPT>, score: ScoreDocument<GPT>) => void
applicator: (base: PBScoreDocumentNoRank<GPT>, score: ScoreDocument<GPT>) => void
): PBMergeFunction<GPT> {
return async (userID, chartID, asOfTimestamp, base) => {
const bestScoreFor = (await db.scores.findOne(
@@ -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<GPT>;
};
/**
* 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<GPT extends GPTString>(
metrics: DryScoreData<GPT>,
chart: ChartDocument<GPT>
) {
const deriverImplementation: GPTDerivers<GPT> = GPT_DERIVERS[gpt];
const deriverImplementation: GPTDerivers<GPT> = GPT_SERVER_IMPLEMENTATIONS[gpt].derivers;
const derivedMetrics: Record<string, MetricValue> = {};
@@ -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";
+1 -1
View File
@@ -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}`];
}
}
+28 -39
View File
@@ -137,7 +137,7 @@ function FormatCriteria<GPT extends GPTString>(
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") {
+2 -2
View File
@@ -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();
@@ -50,7 +50,7 @@ router.post(
{
criteria: {
key: p.isIn(
"scoreData.percent",
[`scoreData.${gptConfig.defaultMetric}`],
"scoreData.lampIndex",
"scoreData.gradeIndex",
"scoreData.score"
@@ -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",
},
@@ -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;
@@ -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,
}
@@ -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,