mirror of
https://github.com/zkldi/Tachi.git
synced 2026-10-12 17:28:31 +03:00
feat: gpt-specific invariants
This commit is contained in:
@@ -17,14 +17,22 @@ import type {
|
||||
GPTDerivers,
|
||||
GPTGoalFormatters,
|
||||
GPTGoalProgressFormatters,
|
||||
GPTMetricValidators,
|
||||
GPTChartSpecificMetricValidators,
|
||||
GPTProfileCalculators,
|
||||
GPTScoreCalculators,
|
||||
GPTSessionCalculators,
|
||||
PBMergeFunction,
|
||||
ScoreCalculator,
|
||||
ScoreValidator,
|
||||
} from "game-implementations/types";
|
||||
import type { GPTStrings, GradeBoundary, integer, SpecificUserGameStats } from "tachi-common";
|
||||
import type {
|
||||
GPTStrings,
|
||||
GradeBoundary,
|
||||
integer,
|
||||
SpecificUserGameStats,
|
||||
GPTString,
|
||||
ScoreDocument,
|
||||
} from "tachi-common";
|
||||
|
||||
export const EX_SCORE_CHECK: ChartSpecificMetricValidator<IIDXLikes> = (exScore, chart) => {
|
||||
if (exScore < 0) {
|
||||
@@ -58,7 +66,23 @@ export const IIDXLIKE_DERIVERS: GPTDerivers<IIDXLikes> = {
|
||||
},
|
||||
};
|
||||
|
||||
export const IIDXLIKE_VALIDATORS: GPTMetricValidators<IIDXLikes> = { score: EX_SCORE_CHECK };
|
||||
export const IIDXLIKE_VALIDATORS: GPTChartSpecificMetricValidators<IIDXLikes> = {
|
||||
score: EX_SCORE_CHECK,
|
||||
};
|
||||
|
||||
export const IIDXLIKE_SCORE_VALIDATORS: Array<ScoreValidator<IIDXLikes>> = [
|
||||
(s) => {
|
||||
const { pgreat, great } = s.scoreData.judgements;
|
||||
|
||||
if (IsNullish(pgreat) || IsNullish(great)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (pgreat * 2 + great !== s.scoreData.score) {
|
||||
return `Expected PGreat*2 + Great to equal EX score. Got ${pgreat}*2 + ${great} but that wasn't equal to the EX score of ${s.scoreData.score}.`;
|
||||
}
|
||||
},
|
||||
];
|
||||
|
||||
type SDVXLikes = GPTStrings["sdvx" | "usc"];
|
||||
|
||||
@@ -212,6 +236,28 @@ export const SDVXLIKE_PB_MERGERS: Array<PBMergeFunction<SDVXLikes>> = [
|
||||
|
||||
export const SDVXLIKE_DEFAULT_MERGE_NAME = "Best Score";
|
||||
|
||||
export const SDVXLIKE_SCORE_VALIDATORS: Array<ScoreValidator<SDVXLikes>> = [
|
||||
(s) => {
|
||||
if (s.scoreData.lamp === "PERFECT ULTIMATE CHAIN" && s.scoreData.score !== 10_000_000) {
|
||||
return "Cannot have a PERFECT ULTIMATE CHAIN without a perfect score.";
|
||||
}
|
||||
},
|
||||
(s) => {
|
||||
const { near, miss } = s.scoreData.judgements;
|
||||
|
||||
if (s.scoreData.lamp === "PERFECT ULTIMATE CHAIN" && (miss ?? 0) + (near ?? 0) > 0) {
|
||||
return "Cannot have a PERFECT ULTIMATE CHAIN with any nears or misses.";
|
||||
} else if (s.scoreData.lamp === "ULTIMATE CHAIN" && (miss ?? 0) > 0) {
|
||||
return "Cannot have an ULTIMATE CHAIN with non-zero miss count.";
|
||||
}
|
||||
},
|
||||
(s) => {
|
||||
if (s.scoreData.lamp === "ULTIMATE CHAIN" && s.scoreData.score < 5_000_000) {
|
||||
return "Cannot have an ULTIMATE CHAIN with a score less than 5m.";
|
||||
}
|
||||
},
|
||||
];
|
||||
|
||||
export const SGLCalc: ScoreCalculator<GPTStrings["bms" | "pms"]> = (scoreData, chart) => {
|
||||
const ecValue = chart.data.sglEC ?? 0;
|
||||
const hcValue = chart.data.sglHC ?? 0;
|
||||
@@ -281,3 +327,29 @@ export function GradeGoalFormatter<G extends string>(
|
||||
// otherwise, return whichever is closer.
|
||||
return closer === "lower" ? lower : upper;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run all of the provided validators on the given score.
|
||||
*
|
||||
* @returns undefined on success, an array of error messages (strings) on failure.
|
||||
*/
|
||||
export function RunValidators<GPT extends GPTString>(
|
||||
validators: Array<ScoreValidator<GPT>>,
|
||||
score: ScoreDocument<GPT>
|
||||
) {
|
||||
const errs = [];
|
||||
|
||||
for (const validator of validators) {
|
||||
const err = validator(score);
|
||||
|
||||
if (err !== undefined) {
|
||||
errs.push(err);
|
||||
}
|
||||
}
|
||||
|
||||
if (errs.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
return errs;
|
||||
}
|
||||
|
||||
@@ -143,18 +143,18 @@ for (const [game, playtype, impl] of [
|
||||
});
|
||||
|
||||
t.test("Validators", (t) => {
|
||||
t.equal(impl.validators.score(1000, chart as any), true);
|
||||
t.equal(impl.validators.score(0, chart as any), true);
|
||||
t.equal(impl.validators.score(chart.data.notecount * 2, chart as any), true);
|
||||
t.equal(impl.chartSpecificValidators.score(1000, chart as any), true);
|
||||
t.equal(impl.chartSpecificValidators.score(0, chart as any), true);
|
||||
t.equal(impl.chartSpecificValidators.score(chart.data.notecount * 2, chart as any), true);
|
||||
|
||||
TestSnapshot(
|
||||
t,
|
||||
impl.validators.score(-1, chart as any),
|
||||
impl.chartSpecificValidators.score(-1, chart as any),
|
||||
`EX Score Validator ${playtype}: negative`
|
||||
);
|
||||
TestSnapshot(
|
||||
t,
|
||||
impl.validators.score(chart.data.notecount * 2 + 1, chart as any),
|
||||
impl.chartSpecificValidators.score(chart.data.notecount * 2 + 1, chart as any),
|
||||
`EX Score Validator ${playtype}: excessive`
|
||||
);
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import {
|
||||
GoalFmtPercent,
|
||||
GoalFmtScore,
|
||||
GoalOutOfFmtPercent,
|
||||
GradeGoalFormatter,
|
||||
IIDXLIKE_DERIVERS,
|
||||
IIDXLIKE_SCORE_VALIDATORS,
|
||||
IIDXLIKE_VALIDATORS,
|
||||
SGLCalc,
|
||||
} from "./_common";
|
||||
@@ -33,7 +33,7 @@ const BMS_PMS_MERGERS: Array<PBMergeFunction<GPTStrings["bms" | "pms"]>> = [
|
||||
|
||||
const BMS_IMPL: GPTServerImplementation<GPTStrings["bms" | "pms"]> = {
|
||||
derivers: IIDXLIKE_DERIVERS,
|
||||
validators: IIDXLIKE_VALIDATORS,
|
||||
chartSpecificValidators: IIDXLIKE_VALIDATORS,
|
||||
scoreCalcs: { sieglinde: SGLCalc },
|
||||
sessionCalcs: { sieglinde: SessionAvgBest10For("sieglinde") },
|
||||
profileCalcs: { sieglinde: ProfileAvgBestN("sieglinde", 20) },
|
||||
@@ -77,6 +77,7 @@ const BMS_IMPL: GPTServerImplementation<GPTStrings["bms" | "pms"]> = {
|
||||
},
|
||||
pbMergeFunctions: BMS_PMS_MERGERS,
|
||||
defaultMergeRefName: "Best Score",
|
||||
scoreValidators: IIDXLIKE_SCORE_VALIDATORS,
|
||||
};
|
||||
|
||||
export const BMS_14K_IMPL: GPTServerImplementation<"bms:14K"> = BMS_IMPL;
|
||||
|
||||
@@ -8,7 +8,7 @@ import { IsNullish } from "utils/misc";
|
||||
import type { GPTServerImplementation } from "game-implementations/types";
|
||||
|
||||
export const CHUNITHM_IMPL: GPTServerImplementation<"chunithm:Single"> = {
|
||||
validators: {},
|
||||
chartSpecificValidators: {},
|
||||
derivers: {
|
||||
grade: ({ score }) => GetGrade(CHUNITHM_GBOUNDARIES, score),
|
||||
},
|
||||
@@ -72,4 +72,40 @@ export const CHUNITHM_IMPL: GPTServerImplementation<"chunithm:Single"> = {
|
||||
}),
|
||||
],
|
||||
defaultMergeRefName: "Best Score",
|
||||
scoreValidators: [
|
||||
(s) => {
|
||||
if (s.scoreData.lamp === "ALL JUSTICE CRITICAL" && s.scoreData.score !== 1_010_000) {
|
||||
return "An ALL JUSTICE CRITICAL must have a score of 1.01 million.";
|
||||
}
|
||||
|
||||
if (s.scoreData.lamp !== "ALL JUSTICE CRITICAL" && s.scoreData.score === 1_010_000) {
|
||||
return "A score of 1.01 million must have a lamp of ALL JUSTICE CRITICAL.";
|
||||
}
|
||||
},
|
||||
(s) => {
|
||||
let { attack, justice, miss } = s.scoreData.judgements;
|
||||
|
||||
justice ??= 0;
|
||||
attack ??= 0;
|
||||
miss ??= 0;
|
||||
|
||||
if (s.scoreData.lamp === "ALL JUSTICE CRITICAL") {
|
||||
if (attack + justice + miss > 0) {
|
||||
return "Cannot have an ALL JUSTICE CRITICAL with any non-jcrit judgements.";
|
||||
}
|
||||
}
|
||||
|
||||
if (s.scoreData.lamp === "ALL JUSTICE") {
|
||||
if (attack + miss > 0) {
|
||||
return "Cannot have an ALL JUSTICE if not all hits were justice or better.";
|
||||
}
|
||||
}
|
||||
|
||||
if (s.scoreData.lamp === "FULL COMBO") {
|
||||
if (miss > 0) {
|
||||
return "Cannot have a FULL COMBO if the score has misses.";
|
||||
}
|
||||
}
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -37,7 +37,7 @@ async function CalculateGitadoraSkill(game: Game, playtype: Playtype, userID: in
|
||||
}
|
||||
|
||||
const GITADORA_IMPL: GPTServerImplementation<"gitadora:Dora" | "gitadora:Gita"> = {
|
||||
validators: {},
|
||||
chartSpecificValidators: {},
|
||||
derivers: {
|
||||
grade: ({ percent }) => GetGrade(GITADORA_GBOUNDARIES, percent),
|
||||
},
|
||||
@@ -115,6 +115,7 @@ const GITADORA_IMPL: GPTServerImplementation<"gitadora:Dora" | "gitadora:Gita">
|
||||
}),
|
||||
],
|
||||
defaultMergeRefName: "Best Percent",
|
||||
scoreValidators: [],
|
||||
};
|
||||
|
||||
export const GITADORA_GITA_IMPL: GPTServerImplementation<"gitadora:Gita"> = GITADORA_IMPL;
|
||||
|
||||
@@ -102,21 +102,21 @@ t.test("IIDX Implementation", (t) => {
|
||||
["SP", IIDX_SP_IMPL],
|
||||
["DP", IIDX_DP_IMPL],
|
||||
] as const) {
|
||||
t.equal(impl.validators.score(1000, Testing511SPA as any), true);
|
||||
t.equal(impl.validators.score(0, Testing511SPA as any), true);
|
||||
t.equal(impl.chartSpecificValidators.score(1000, Testing511SPA as any), true);
|
||||
t.equal(impl.chartSpecificValidators.score(0, Testing511SPA as any), true);
|
||||
t.equal(
|
||||
impl.validators.score(Testing511SPA.data.notecount * 2, Testing511SPA as any),
|
||||
impl.chartSpecificValidators.score(Testing511SPA.data.notecount * 2, Testing511SPA as any),
|
||||
true
|
||||
);
|
||||
|
||||
TestSnapshot(
|
||||
t,
|
||||
impl.validators.score(-1, Testing511SPA as any),
|
||||
impl.chartSpecificValidators.score(-1, Testing511SPA as any),
|
||||
`EX Score Validator ${playtype}: negative`
|
||||
);
|
||||
TestSnapshot(
|
||||
t,
|
||||
impl.validators.score(Testing511SPA.data.notecount * 2 + 1, Testing511SPA as any),
|
||||
impl.chartSpecificValidators.score(Testing511SPA.data.notecount * 2 + 1, Testing511SPA as any),
|
||||
`EX Score Validator ${playtype}: excessive`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
GoalOutOfFmtPercent,
|
||||
GradeGoalFormatter,
|
||||
IIDXLIKE_DERIVERS,
|
||||
IIDXLIKE_SCORE_VALIDATORS,
|
||||
IIDXLIKE_VALIDATORS,
|
||||
} from "./_common";
|
||||
import { CreatePBMergeFor } from "game-implementations/utils/pb-merge";
|
||||
@@ -10,12 +11,14 @@ import { ProfileAvgBestN } from "game-implementations/utils/profile-calc";
|
||||
import { SessionAvgBest10For } from "game-implementations/utils/session-calc";
|
||||
import { PoyashiBPI } from "rg-stats";
|
||||
import { IIDXLIKE_GBOUNDARIES } from "tachi-common";
|
||||
import { IsNullish } from "utils/misc";
|
||||
import type {
|
||||
GPTGoalFormatters,
|
||||
GPTGoalProgressFormatters,
|
||||
GPTServerImplementation,
|
||||
PBMergeFunction,
|
||||
ScoreCalculator,
|
||||
ScoreValidator,
|
||||
} from "game-implementations/types";
|
||||
import type { GPTStrings } from "tachi-common";
|
||||
|
||||
@@ -110,7 +113,7 @@ const IIDX_GOAL_PG_FMT: GPTGoalProgressFormatters<"iidx:DP" | "iidx:SP"> = {
|
||||
|
||||
export const IIDX_SP_IMPL: GPTServerImplementation<"iidx:SP"> = {
|
||||
derivers: IIDXLIKE_DERIVERS,
|
||||
validators: IIDXLIKE_VALIDATORS,
|
||||
chartSpecificValidators: IIDXLIKE_VALIDATORS,
|
||||
scoreCalcs: {
|
||||
BPI: BPICalc,
|
||||
ktLampRating: (scoreData, chart) => {
|
||||
@@ -144,11 +147,12 @@ export const IIDX_SP_IMPL: GPTServerImplementation<"iidx:SP"> = {
|
||||
goalOutOfFormatters: IIDX_GOAL_OO_FMT,
|
||||
pbMergeFunctions: IIDX_MERGERS,
|
||||
defaultMergeRefName: "Best Score",
|
||||
scoreValidators: IIDXLIKE_SCORE_VALIDATORS,
|
||||
};
|
||||
|
||||
export const IIDX_DP_IMPL: GPTServerImplementation<"iidx:DP"> = {
|
||||
derivers: IIDXLIKE_DERIVERS,
|
||||
validators: IIDXLIKE_VALIDATORS,
|
||||
chartSpecificValidators: IIDXLIKE_VALIDATORS,
|
||||
scoreCalcs: {
|
||||
BPI: BPICalc,
|
||||
ktLampRating: (scoreData, chart) => {
|
||||
@@ -175,4 +179,5 @@ export const IIDX_DP_IMPL: GPTServerImplementation<"iidx:DP"> = {
|
||||
goalOutOfFormatters: IIDX_GOAL_OO_FMT,
|
||||
pbMergeFunctions: IIDX_MERGERS,
|
||||
defaultMergeRefName: "Best Score",
|
||||
scoreValidators: IIDXLIKE_SCORE_VALIDATORS,
|
||||
};
|
||||
|
||||
@@ -8,7 +8,7 @@ import { NumToDP } from "utils/misc";
|
||||
import type { GPTServerImplementation } from "game-implementations/types";
|
||||
|
||||
export const ITG_STAMINA_IMPL: GPTServerImplementation<"itg:Stamina"> = {
|
||||
validators: {},
|
||||
chartSpecificValidators: {},
|
||||
derivers: {
|
||||
finalPercent: (metrics) => {
|
||||
// *important*
|
||||
@@ -136,4 +136,46 @@ export const ITG_STAMINA_IMPL: GPTServerImplementation<"itg:Stamina"> = {
|
||||
|
||||
// this name sucks, what should we do instead? TODO.
|
||||
defaultMergeRefName: "Best Result",
|
||||
|
||||
scoreValidators: [
|
||||
(s) => {
|
||||
if (s.scoreData.lamp !== "FAILED" && s.scoreData.survivedPercent < 100) {
|
||||
return "Cannot clear a chart that you didn't survive 100% of.";
|
||||
}
|
||||
},
|
||||
(s) => {
|
||||
let { fantastic, excellent, great, decent, wayoff, miss } = s.scoreData.judgements;
|
||||
|
||||
fantastic ??= 0;
|
||||
excellent ??= 0;
|
||||
great ??= 0;
|
||||
decent ??= 0;
|
||||
wayoff ??= 0;
|
||||
miss ??= 0;
|
||||
|
||||
if (s.scoreData.lamp === "QUINT") {
|
||||
if (fantastic + excellent + great + decent + wayoff + miss > 0) {
|
||||
return "Cannot have a QUINT with any fantastic (or worse) judgements.";
|
||||
}
|
||||
}
|
||||
|
||||
if (s.scoreData.lamp === "QUAD") {
|
||||
if (excellent + great + decent + wayoff + miss > 0) {
|
||||
return "Cannot have a QUAD with any excellent (or worse) judgements.";
|
||||
}
|
||||
}
|
||||
|
||||
if (s.scoreData.lamp === "FULL EXCELLENT COMBO") {
|
||||
if (great + decent + wayoff + miss > 0) {
|
||||
return "Cannot have a FULL EXCELLENT COMBO with any great (or worse) judgements.";
|
||||
}
|
||||
}
|
||||
|
||||
if (s.scoreData.lamp === "FULL COMBO") {
|
||||
if (decent + wayoff + miss > 0) {
|
||||
return "Cannot have a FULL COMBO with any combo breaks.";
|
||||
}
|
||||
}
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -61,18 +61,18 @@ t.test("Jubeat Implementation", (t) => {
|
||||
|
||||
t.test("Music Rate Validator", (t) => {
|
||||
t.equal(
|
||||
JUBEAT_IMPL.validators.musicRate(80, TestingJubeatChart),
|
||||
JUBEAT_IMPL.chartSpecificValidators.musicRate(80, TestingJubeatChart),
|
||||
true,
|
||||
"normal music rate"
|
||||
);
|
||||
t.equal(JUBEAT_IMPL.validators.musicRate(0, TestingJubeatChart), true, "0 music rate");
|
||||
t.equal(JUBEAT_IMPL.chartSpecificValidators.musicRate(0, TestingJubeatChart), true, "0 music rate");
|
||||
t.equal(
|
||||
JUBEAT_IMPL.validators.musicRate(100, TestingJubeatChart),
|
||||
JUBEAT_IMPL.chartSpecificValidators.musicRate(100, TestingJubeatChart),
|
||||
true,
|
||||
"perfect music rate"
|
||||
);
|
||||
t.equal(
|
||||
JUBEAT_IMPL.validators.musicRate(
|
||||
JUBEAT_IMPL.chartSpecificValidators.musicRate(
|
||||
120,
|
||||
dmf(TestingJubeatChart, {
|
||||
difficulty: "HARD ADV",
|
||||
@@ -84,12 +84,12 @@ t.test("Jubeat Implementation", (t) => {
|
||||
|
||||
TestSnapshot(
|
||||
t,
|
||||
JUBEAT_IMPL.validators.musicRate(-1, TestingJubeatChart),
|
||||
JUBEAT_IMPL.chartSpecificValidators.musicRate(-1, TestingJubeatChart),
|
||||
`Jubeat Music Rate Validator: negative`
|
||||
);
|
||||
TestSnapshot(
|
||||
t,
|
||||
JUBEAT_IMPL.validators.musicRate(
|
||||
JUBEAT_IMPL.chartSpecificValidators.musicRate(
|
||||
100.1,
|
||||
dmf(TestingJubeatChart, {
|
||||
difficulty: "ADV",
|
||||
@@ -99,7 +99,7 @@ t.test("Jubeat Implementation", (t) => {
|
||||
);
|
||||
TestSnapshot(
|
||||
t,
|
||||
JUBEAT_IMPL.validators.musicRate(
|
||||
JUBEAT_IMPL.chartSpecificValidators.musicRate(
|
||||
120.1,
|
||||
dmf(TestingJubeatChart, {
|
||||
difficulty: "HARD ADV",
|
||||
|
||||
@@ -137,7 +137,7 @@ async function CalculateJubility(game: Game, playtype: Playtype, userID: integer
|
||||
}
|
||||
|
||||
export const JUBEAT_IMPL: GPTServerImplementation<"jubeat:Single"> = {
|
||||
validators: {
|
||||
chartSpecificValidators: {
|
||||
musicRate: (rate, chart) => {
|
||||
switch (chart.difficulty) {
|
||||
case "BSC":
|
||||
@@ -227,4 +227,44 @@ export const JUBEAT_IMPL: GPTServerImplementation<"jubeat:Single"> = {
|
||||
}),
|
||||
],
|
||||
defaultMergeRefName: "Best Music Rate",
|
||||
scoreValidators: [
|
||||
(s) => {
|
||||
if (s.scoreData.lamp === "EXCELLENT" && s.scoreData.score !== 1_000_000) {
|
||||
return `An EXCELLENT lamp must be accompanied with a score of 1 million.`;
|
||||
}
|
||||
|
||||
if (s.scoreData.lamp !== "EXCELLENT" && s.scoreData.score === 1_000_000) {
|
||||
return `A score of 1 million must be accompanied with an EXCELLENT lamp.`;
|
||||
}
|
||||
},
|
||||
(s) => {
|
||||
let { good, great, miss, poor } = s.scoreData.judgements;
|
||||
|
||||
great ??= 0;
|
||||
good ??= 0;
|
||||
poor ??= 0;
|
||||
miss ??= 0;
|
||||
|
||||
if (s.scoreData.lamp === "EXCELLENT") {
|
||||
if (good + great + miss + poor > 0) {
|
||||
return "An EXCELLENT lamp can't have any non-perfect judgements.";
|
||||
}
|
||||
}
|
||||
|
||||
if (s.scoreData.lamp === "FULL COMBO") {
|
||||
if (miss > 0) {
|
||||
return "A FULL COMBO cannot have any misses.";
|
||||
}
|
||||
}
|
||||
},
|
||||
(s) => {
|
||||
if (s.scoreData.score < 700_000 && s.scoreData.lamp !== "FAILED") {
|
||||
return "A score of <700k must be a fail.";
|
||||
}
|
||||
|
||||
if (s.scoreData.score >= 700_000 && s.scoreData.lamp === "FAILED") {
|
||||
return "A score >=700k must be a clear.";
|
||||
}
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -68,7 +68,7 @@ async function CalculateMaimaiDXRate(game: Game, playtype: Playtype, userID: int
|
||||
}
|
||||
|
||||
export const MAIMAIDX_IMPL: GPTServerImplementation<"maimaidx:Single"> = {
|
||||
validators: {},
|
||||
chartSpecificValidators: {},
|
||||
derivers: {
|
||||
grade: ({ percent }) => GetGrade(MAIMAIDX_GBOUNDARIES, percent),
|
||||
},
|
||||
@@ -137,4 +137,15 @@ export const MAIMAIDX_IMPL: GPTServerImplementation<"maimaidx:Single"> = {
|
||||
}),
|
||||
],
|
||||
defaultMergeRefName: "Best Percent",
|
||||
scoreValidators: [
|
||||
(s) => {
|
||||
if (s.scoreData.lamp === "ALL PERFECT+" && s.scoreData.percent !== 101) {
|
||||
return "Cannot have an ALL PERFECT+ without 101%.";
|
||||
}
|
||||
|
||||
if (s.scoreData.lamp !== "ALL PERFECT+" && s.scoreData.percent === 101) {
|
||||
return "A score of 101% should be an ALL PERFECT+";
|
||||
}
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -7,7 +7,7 @@ import { FmtNum, GetGrade, MUSECA_GBOUNDARIES } from "tachi-common";
|
||||
import type { GPTServerImplementation } from "game-implementations/types";
|
||||
|
||||
export const MUSECA_IMPL: GPTServerImplementation<"museca:Single"> = {
|
||||
validators: {},
|
||||
chartSpecificValidators: {},
|
||||
derivers: {
|
||||
grade: ({ score }) => GetGrade(MUSECA_GBOUNDARIES, score),
|
||||
},
|
||||
@@ -42,4 +42,38 @@ export const MUSECA_IMPL: GPTServerImplementation<"museca:Single"> = {
|
||||
}),
|
||||
],
|
||||
defaultMergeRefName: "Best Score",
|
||||
scoreValidators: [
|
||||
(s) => {
|
||||
if (s.scoreData.lamp === "PERFECT CONNECT ALL" && s.scoreData.score !== 1_000_000) {
|
||||
return "A PERFECT CONNECT ALL must have a score of 1 million.";
|
||||
}
|
||||
|
||||
if (s.scoreData.score === 1_000_000 && s.scoreData.lamp !== "PERFECT CONNECT ALL") {
|
||||
return "A perfect score of 1 million must have a lamp of PERFECT CONNECT ALL.";
|
||||
}
|
||||
},
|
||||
(s) => {
|
||||
let { miss, near } = s.scoreData.judgements;
|
||||
|
||||
miss ??= 0;
|
||||
near ??= 0;
|
||||
|
||||
if (s.scoreData.lamp === "PERFECT CONNECT ALL" && miss + near > 0) {
|
||||
return "Cannot have a PERFECT CONNECT ALL with any nears or misses.";
|
||||
}
|
||||
|
||||
if (s.scoreData.lamp === "CONNECT ALL" && miss > 0) {
|
||||
return "Cannot have a CONNECT ALL with any misses.";
|
||||
}
|
||||
},
|
||||
(s) => {
|
||||
if (s.scoreData.score < 800_000 && s.scoreData.lamp !== "FAILED") {
|
||||
return "A score of <800k must be a fail.";
|
||||
}
|
||||
|
||||
if (s.scoreData.score >= 800_000 && s.scoreData.lamp === "FAILED") {
|
||||
return "A score of >=800k must be a clear.";
|
||||
}
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -32,7 +32,7 @@ export function PopnClearMedalToLamp(
|
||||
}
|
||||
|
||||
export const POPN_9B_IMPL: GPTServerImplementation<"popn:9B"> = {
|
||||
validators: {},
|
||||
chartSpecificValidators: {},
|
||||
derivers: {
|
||||
lamp: ({ clearMedal }) => PopnClearMedalToLamp(clearMedal),
|
||||
grade: ({ score, clearMedal }) => {
|
||||
@@ -114,4 +114,83 @@ export const POPN_9B_IMPL: GPTServerImplementation<"popn:9B"> = {
|
||||
}),
|
||||
],
|
||||
defaultMergeRefName: "Best Score",
|
||||
scoreValidators: [
|
||||
(s) => {
|
||||
const { bad, good } = s.scoreData.judgements;
|
||||
|
||||
if (s.scoreData.lamp === "PERFECT") {
|
||||
const mistakes = (bad ?? 0) + (good ?? 0);
|
||||
|
||||
if (mistakes > 0) {
|
||||
return "Cannot have a PERFECT lamp with any bads or goods.";
|
||||
}
|
||||
} else if (s.scoreData.lamp === "FULL COMBO") {
|
||||
const mistakes = bad ?? 0;
|
||||
|
||||
if (mistakes > 0) {
|
||||
return "Cannot have a FULL COMBO lamp with any bads.";
|
||||
}
|
||||
}
|
||||
},
|
||||
// clear medal bad/good checks.
|
||||
(s) => {
|
||||
let { bad, good } = s.scoreData.judgements;
|
||||
|
||||
bad ??= 0;
|
||||
good ??= 0;
|
||||
|
||||
switch (s.scoreData.clearMedal) {
|
||||
case "fullComboStar": {
|
||||
if (good > 5 || good < 1) {
|
||||
return "Must have between 1-5 goods for a full combo star.";
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case "fullComboDiamond": {
|
||||
if (good > 20 || good < 6) {
|
||||
return "Must have between 6-20 goods for a full combo diamond.";
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case "fullComboCircle": {
|
||||
if (good < 21) {
|
||||
return "Must have >21 goods for a full combo circle.";
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case "clearStar": {
|
||||
if (bad > 5 || bad < 1) {
|
||||
return "Must have between 1-5 bads for a clear star.";
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case "clearDiamond": {
|
||||
if (bad > 20 || bad < 6) {
|
||||
return "Must have between 6-20 bads for a clear diamond.";
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case "clearCircle": {
|
||||
if (bad < 21) {
|
||||
return "Must have between >21 bads for a clear circle.";
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
// can't validate the fails since we don't have the gauge info.
|
||||
default:
|
||||
}
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -34,12 +34,12 @@ const mockPB = mkMockPB("sdvx", "Single", TestingSDVXAlbidaChart, scoreData);
|
||||
|
||||
t.test("SDVX Implementation", (t) => {
|
||||
t.test("EX Score Validator", (t) => {
|
||||
t.equal(SDVX_IMPL.validators.exScore(1000, TestingSDVXAlbidaChart), true);
|
||||
t.equal(SDVX_IMPL.validators.exScore(0, TestingSDVXAlbidaChart), true);
|
||||
t.equal(SDVX_IMPL.chartSpecificValidators.exScore(1000, TestingSDVXAlbidaChart), true);
|
||||
t.equal(SDVX_IMPL.chartSpecificValidators.exScore(0, TestingSDVXAlbidaChart), true);
|
||||
|
||||
TestSnapshot(
|
||||
t,
|
||||
SDVX_IMPL.validators.exScore(-1, TestingSDVXAlbidaChart),
|
||||
SDVX_IMPL.chartSpecificValidators.exScore(-1, TestingSDVXAlbidaChart),
|
||||
`EX Score Validator SDVX: negative`
|
||||
);
|
||||
|
||||
|
||||
@@ -8,13 +8,14 @@ import {
|
||||
SDVXLIKE_PB_MERGERS,
|
||||
SDVXLIKE_PROFILE_CALCS,
|
||||
SDVXLIKE_SCORE_CALCS,
|
||||
SDVXLIKE_SCORE_VALIDATORS,
|
||||
SDVXLIKE_SESSION_CALCS,
|
||||
} from "./_common";
|
||||
import type { GPTServerImplementation } from "game-implementations/types";
|
||||
|
||||
export const SDVX_IMPL: GPTServerImplementation<"sdvx:Single"> = {
|
||||
derivers: SDVXLIKE_DERIVERS,
|
||||
validators: {
|
||||
chartSpecificValidators: {
|
||||
exScore: (exScore, chart) => {
|
||||
if (exScore < 0) {
|
||||
return `EX Score must be non-negative. Got ${exScore}`;
|
||||
@@ -37,4 +38,5 @@ export const SDVX_IMPL: GPTServerImplementation<"sdvx:Single"> = {
|
||||
goalOutOfFormatters: SDVXLIKE_GOAL_OO_FMT,
|
||||
pbMergeFunctions: SDVXLIKE_PB_MERGERS,
|
||||
defaultMergeRefName: SDVXLIKE_DEFAULT_MERGE_NAME,
|
||||
scoreValidators: SDVXLIKE_SCORE_VALIDATORS,
|
||||
};
|
||||
|
||||
@@ -9,13 +9,14 @@ import {
|
||||
SDVXLIKE_GOAL_OO_FMT,
|
||||
SDVXLIKE_DEFAULT_MERGE_NAME,
|
||||
SDVXLIKE_PB_MERGERS,
|
||||
SDVXLIKE_SCORE_VALIDATORS,
|
||||
} from "./_common";
|
||||
import type { GPTServerImplementation } from "game-implementations/types";
|
||||
import type { GPTStrings } from "tachi-common";
|
||||
|
||||
const USC_IMPL: GPTServerImplementation<GPTStrings["usc"]> = {
|
||||
derivers: SDVXLIKE_DERIVERS,
|
||||
validators: {},
|
||||
chartSpecificValidators: {},
|
||||
scoreCalcs: SDVXLIKE_SCORE_CALCS,
|
||||
sessionCalcs: SDVXLIKE_SESSION_CALCS,
|
||||
profileCalcs: SDVXLIKE_PROFILE_CALCS,
|
||||
@@ -25,6 +26,7 @@ const USC_IMPL: GPTServerImplementation<GPTStrings["usc"]> = {
|
||||
goalOutOfFormatters: SDVXLIKE_GOAL_OO_FMT,
|
||||
pbMergeFunctions: SDVXLIKE_PB_MERGERS,
|
||||
defaultMergeRefName: SDVXLIKE_DEFAULT_MERGE_NAME,
|
||||
scoreValidators: SDVXLIKE_SCORE_VALIDATORS,
|
||||
};
|
||||
|
||||
export const USC_KEYBOARD_IMPL: GPTServerImplementation<"usc:Keyboard"> = USC_IMPL;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { RunValidators } from "./_common";
|
||||
import { WACCA_IMPL } from "./wacca";
|
||||
import db from "external/mongo/db";
|
||||
import CreateLogCtx from "lib/logger/logger";
|
||||
@@ -6,8 +7,10 @@ import { WACCA_SINGLE_CONF } from "tachi-common/config/game-support/wacca";
|
||||
import t from "tap";
|
||||
import { dmf, mkMockPB, mkMockScore } from "test-utils/misc";
|
||||
import ResetDBState from "test-utils/resets";
|
||||
import { TestSnapshot } from "test-utils/single-process-snapshot";
|
||||
import { TestingWaccaPupaExp } from "test-utils/test-data";
|
||||
import type { ProvidedMetrics, ScoreData } from "tachi-common";
|
||||
import type { ProvidedMetrics, ScoreData, ScoreDocument } from "tachi-common";
|
||||
import type { DeepPartial } from "utils/types";
|
||||
|
||||
const GRADES = WACCA_SINGLE_CONF.derivedMetrics.grade.values;
|
||||
const LAMPS = WACCA_SINGLE_CONF.providedMetrics.lamp.values;
|
||||
@@ -167,5 +170,42 @@ t.test("WACCA Implementation", (t) => {
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Score Validations", (t) => {
|
||||
const f = (s: DeepPartial<ScoreDocument<"wacca:Single">>) =>
|
||||
RunValidators(WACCA_IMPL.scoreValidators, dmf(mockScore, s));
|
||||
|
||||
t.strictSame(f({ scoreData: { lamp: "ALL MARVELOUS", score: 1_000_000 } }), undefined);
|
||||
t.strictSame(f({ scoreData: { lamp: "FULL COMBO", judgements: { miss: 0 } } }), undefined);
|
||||
|
||||
TestSnapshot(
|
||||
t,
|
||||
f({ scoreData: { lamp: "ALL MARVELOUS", score: 999_999 } }),
|
||||
"WACCA IMPL Validators: Should disallow ALL MARVELOUS with non-perfect score."
|
||||
);
|
||||
TestSnapshot(
|
||||
t,
|
||||
f({ scoreData: { lamp: "FULL COMBO", judgements: { miss: 1 } } }),
|
||||
`WACCA IMPL Validators: Should disallow FC with miss`
|
||||
);
|
||||
|
||||
TestSnapshot(
|
||||
t,
|
||||
f({ scoreData: { lamp: "ALL MARVELOUS", judgements: { good: 1 } } }),
|
||||
"WACCA IMPL Validators: Should disallow ALL MARVELOUS with non-perfect good judgements."
|
||||
);
|
||||
TestSnapshot(
|
||||
t,
|
||||
f({ scoreData: { lamp: "ALL MARVELOUS", judgements: { great: 1 } } }),
|
||||
"WACCA IMPL Validators: Should disallow ALL MARVELOUS with non-perfect good judgements."
|
||||
);
|
||||
TestSnapshot(
|
||||
t,
|
||||
f({ scoreData: { lamp: "ALL MARVELOUS", judgements: { miss: 1 } } }),
|
||||
"WACCA IMPL Validators: Should disallow ALL MARVELOUS with non-perfect good judgements."
|
||||
);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
@@ -68,7 +68,7 @@ async function CalculateWACCARate(game: Game, playtype: Playtype, userID: intege
|
||||
}
|
||||
|
||||
export const WACCA_IMPL: GPTServerImplementation<"wacca:Single"> = {
|
||||
validators: {},
|
||||
chartSpecificValidators: {},
|
||||
derivers: {
|
||||
grade: ({ score }) => GetGrade(WACCA_GBOUNDARIES, score),
|
||||
},
|
||||
@@ -132,4 +132,43 @@ export const WACCA_IMPL: GPTServerImplementation<"wacca:Single"> = {
|
||||
}),
|
||||
],
|
||||
defaultMergeRefName: "Best Score",
|
||||
|
||||
scoreValidators: [
|
||||
(s) => {
|
||||
if (s.scoreData.lamp === "ALL MARVELOUS" && s.scoreData.score !== 1_000_000) {
|
||||
return `ALL MARVELOUS scores must have a perfect score. Got ${s.scoreData.score} instead.`;
|
||||
}
|
||||
|
||||
// goes both ways
|
||||
if (s.scoreData.score === 1_000_000 && s.scoreData.lamp !== "ALL MARVELOUS") {
|
||||
return `Perfect scores of 1 million must have ALL MARVELOUS as their lamp. Got ${s.scoreData.lamp} instead.`;
|
||||
}
|
||||
},
|
||||
(s) => {
|
||||
const { miss } = s.scoreData.judgements;
|
||||
|
||||
if (miss === null || miss === undefined || miss === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (s.scoreData.lamp === "FULL COMBO") {
|
||||
return "Cannot have a FULL COMBO with misses.";
|
||||
}
|
||||
|
||||
if (miss > 5 && s.scoreData.lamp === "MISSLESS") {
|
||||
return "Cannot have a MISSLESS lamp with >5 misses.";
|
||||
}
|
||||
},
|
||||
(s) => {
|
||||
const { miss, good, great } = s.scoreData.judgements;
|
||||
|
||||
if (s.scoreData.lamp === "ALL MARVELOUS") {
|
||||
const mistakes = (miss ?? 0) + (good ?? 0) + (great ?? 0);
|
||||
|
||||
if (mistakes > 0) {
|
||||
return "Cannot have an ALL MARVELOUS if all judgements were not marvelous.";
|
||||
}
|
||||
}
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -109,7 +109,7 @@ export type PBMergeFunction<GPT extends GPTString> = (
|
||||
* 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> = {
|
||||
export type GPTChartSpecificMetricValidators<GPT extends GPTString> = {
|
||||
[M in keyof AllConfMetrics[GPT] as AllConfMetrics[GPT][M] extends ChartDependentMax
|
||||
? M
|
||||
: never]: ChartSpecificMetricValidator<GPT>;
|
||||
@@ -159,12 +159,35 @@ export type GPTGoalProgressFormatters<GPT extends GPTString> = {
|
||||
[K in keyof ConfScoreMetrics[GPT]]: GoalProgressFormatter<GPT>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Return nothing on success, and a string
|
||||
* indicating what the error was on failure.
|
||||
*/
|
||||
export type ScoreValidator<GPT extends GPTString> = (
|
||||
score: ScoreDocument<GPT>
|
||||
) => string | undefined;
|
||||
|
||||
export interface GPTServerImplementation<GPT extends GPTString> {
|
||||
validators: GPTMetricValidators<GPT>;
|
||||
/**
|
||||
* For any chart-dependent metrics, such as EX Score for IIDX, how should we
|
||||
* validate they're correct?
|
||||
*/
|
||||
chartSpecificValidators: GPTChartSpecificMetricValidators<GPT>;
|
||||
|
||||
/**
|
||||
* How should we derive the derived metrics for this game?
|
||||
*/
|
||||
derivers: GPTDerivers<GPT>;
|
||||
|
||||
// assorted calculator functions
|
||||
scoreCalcs: GPTScoreCalculators<GPT>;
|
||||
sessionCalcs: GPTSessionCalculators<GPT>;
|
||||
profileCalcs: GPTProfileCalculators<GPT>;
|
||||
|
||||
/**
|
||||
* For any "derived" classes for this game (i.e. classes that are the function
|
||||
* of the user's state), how should they work?
|
||||
*/
|
||||
classDerivers: GPTClassDerivers<GPT>;
|
||||
|
||||
/**
|
||||
@@ -204,6 +227,17 @@ export interface GPTServerImplementation<GPT extends GPTString> {
|
||||
* metric. What should that be called?
|
||||
*/
|
||||
defaultMergeRefName: string;
|
||||
|
||||
/**
|
||||
* There are various things that should be true about scores for each game
|
||||
* that aren't already checked. This is for invariants that can't be tested
|
||||
* otherwise, like judgements properly summing up to the EX Score for IIDX, etc.
|
||||
*
|
||||
* These are for checking things that are *obviously* incorrect. For more subtle
|
||||
* incorrectnesses, like working out what the minimum possible full combo score
|
||||
* could be (which might be chart dependent, etc), don't bother.
|
||||
*/
|
||||
scoreValidators: Array<ScoreValidator<GPT>>;
|
||||
}
|
||||
|
||||
export type GPTImplementations = {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { InvalidScoreFailure } from "../common/converter-failures";
|
||||
import { GPT_SERVER_IMPLEMENTATIONS } from "game-implementations/game-implementations";
|
||||
import { RunValidators } from "game-implementations/games/_common";
|
||||
import { GetGPTConfig, GetGPTString } from "tachi-common";
|
||||
import type { ChartDocument, GPTString, ScoreDocument } from "tachi-common";
|
||||
import type { ConfScoreMetric } from "tachi-common/types/metrics";
|
||||
@@ -14,6 +15,7 @@ import type { ConfScoreMetric } from "tachi-common/types/metrics";
|
||||
export function ValidateScore(score: ScoreDocument, chart: ChartDocument): void {
|
||||
const gptString = GetGPTString(score.game, score.playtype);
|
||||
const gptConfig = GetGPTConfig(gptString);
|
||||
const gptImpl = GPT_SERVER_IMPLEMENTATIONS[gptString];
|
||||
|
||||
const errs: Array<string> = [];
|
||||
|
||||
@@ -47,6 +49,12 @@ export function ValidateScore(score: ScoreDocument, chart: ChartDocument): void
|
||||
true
|
||||
);
|
||||
|
||||
const moreErrors = RunValidators(gptImpl.scoreValidators as any, score);
|
||||
|
||||
if (moreErrors) {
|
||||
errs.push(...moreErrors);
|
||||
}
|
||||
|
||||
if (errs.length > 0) {
|
||||
const errorStr = errs.length === 1 ? "error" : "errors";
|
||||
|
||||
@@ -102,7 +110,7 @@ function ValidateMetrics(
|
||||
|
||||
if (conf.chartDependentMax) {
|
||||
// @ts-expect-error hack, this is fine. don't worry.
|
||||
err = gptImpl.validators[metric](scoreVal, chart);
|
||||
err = gptImpl.chartSpecificValidators[metric](scoreVal, chart);
|
||||
} else {
|
||||
err = conf.validate(scoreVal);
|
||||
}
|
||||
|
||||
@@ -306,7 +306,7 @@ export async function ValidateGoalChartsAndCriteria(
|
||||
}
|
||||
|
||||
// @ts-expect-error this is fine leave me alone
|
||||
err = gptImpl.validators[criteria.key](criteria.value, chart);
|
||||
err = gptImpl.chartSpecificValidators[criteria.key](criteria.value, chart);
|
||||
} else {
|
||||
err = config.validate(criteria.value);
|
||||
}
|
||||
|
||||
@@ -456,9 +456,10 @@ t.test("POST /ir/fervidex/score/submit", (t) => {
|
||||
t.strictSame(imp?.errors, [
|
||||
{
|
||||
type: "InvalidDatapoint",
|
||||
message: `Got 2 errors when validating score:
|
||||
message: `Got 3 errors when validating score:
|
||||
Invalid value for score, EX Score cannot be greater than 1572 for this chart. Got 9999.
|
||||
Invalid value for percent, Expected a number between 0 and 100. Got 636.0687022900763.`,
|
||||
Invalid value for percent, Expected a number between 0 and 100. Got 636.0687022900763.
|
||||
Expected PGreat*2 + Great to equal EX score. Got 34*2 + 0 but that wasn't equal to the EX score of 9999.`,
|
||||
},
|
||||
]);
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ export function WriteSnapshotData() {
|
||||
|
||||
const snapshotData = ReadSnapshotData();
|
||||
|
||||
export function TestSnapshot(t: Tap.Test, value: boolean | number | string, testName: string) {
|
||||
export function TestSnapshot(t: Tap.Test, value: any, testName: string) {
|
||||
if (IsNonEmptyString(process.env.TAP_SNAPSHOT)) {
|
||||
snapshotData[testName] = value;
|
||||
WriteSnapshotData();
|
||||
@@ -31,6 +31,6 @@ export function TestSnapshot(t: Tap.Test, value: boolean | number | string, test
|
||||
return t.fail(`No snapshot exists for ${testName}. Have you ran pnpm snap?`);
|
||||
}
|
||||
|
||||
t.equal(value, snapshotData[testName], `Snapshot: ${testName}`);
|
||||
t.strictSame(value, snapshotData[testName], `Snapshot: ${testName}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,5 +47,15 @@
|
||||
"EX Score Validator Controller: negative": "EX Score cannot be negative.",
|
||||
"EX Score Validator Controller: excessive": "EX Score cannot be greater than 4512 for this chart.",
|
||||
"EX Score Validator Keyboard: negative": "EX Score cannot be negative.",
|
||||
"EX Score Validator Keyboard: excessive": "EX Score cannot be greater than 4512 for this chart."
|
||||
"EX Score Validator Keyboard: excessive": "EX Score cannot be greater than 4512 for this chart.",
|
||||
"WACCA IMPL Validators: Should disallow ALL MARVELOUS with non-perfect score.": [
|
||||
"ALL MARVELOUS scores must have a perfect score. Got 999999 instead."
|
||||
],
|
||||
"WACCA IMPL Validators: Should disallow FC with miss": [
|
||||
"Cannot have a FULL COMBO with misses."
|
||||
],
|
||||
"WACCA IMPL Validators: Should disallow ALL MARVELOUS with non-perfect good judgements.": [
|
||||
"ALL MARVELOUS scores must have a perfect score. Got 953000 instead.",
|
||||
"Cannot have an ALL MARVELOUS if all judgements were not marvelous."
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user