diff --git a/server/src/game-implementations/games/_common.ts b/server/src/game-implementations/games/_common.ts index 523a6ecc1..c5396bd22 100644 --- a/server/src/game-implementations/games/_common.ts +++ b/server/src/game-implementations/games/_common.ts @@ -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 = (exScore, chart) => { if (exScore < 0) { @@ -58,7 +66,23 @@ export const IIDXLIKE_DERIVERS: GPTDerivers = { }, }; -export const IIDXLIKE_VALIDATORS: GPTMetricValidators = { score: EX_SCORE_CHECK }; +export const IIDXLIKE_VALIDATORS: GPTChartSpecificMetricValidators = { + score: EX_SCORE_CHECK, +}; + +export const IIDXLIKE_SCORE_VALIDATORS: Array> = [ + (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> = [ export const SDVXLIKE_DEFAULT_MERGE_NAME = "Best Score"; +export const SDVXLIKE_SCORE_VALIDATORS: Array> = [ + (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 = (scoreData, chart) => { const ecValue = chart.data.sglEC ?? 0; const hcValue = chart.data.sglHC ?? 0; @@ -281,3 +327,29 @@ export function GradeGoalFormatter( // 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( + validators: Array>, + score: ScoreDocument +) { + const errs = []; + + for (const validator of validators) { + const err = validator(score); + + if (err !== undefined) { + errs.push(err); + } + } + + if (errs.length === 0) { + return; + } + + return errs; +} diff --git a/server/src/game-implementations/games/bms-pms.test.ts b/server/src/game-implementations/games/bms-pms.test.ts index dd4a96110..b36a73ab7 100644 --- a/server/src/game-implementations/games/bms-pms.test.ts +++ b/server/src/game-implementations/games/bms-pms.test.ts @@ -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` ); diff --git a/server/src/game-implementations/games/bms-pms.ts b/server/src/game-implementations/games/bms-pms.ts index 3e8dac50e..d1c9d45be 100644 --- a/server/src/game-implementations/games/bms-pms.ts +++ b/server/src/game-implementations/games/bms-pms.ts @@ -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> = [ const BMS_IMPL: GPTServerImplementation = { 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 = { }, pbMergeFunctions: BMS_PMS_MERGERS, defaultMergeRefName: "Best Score", + scoreValidators: IIDXLIKE_SCORE_VALIDATORS, }; export const BMS_14K_IMPL: GPTServerImplementation<"bms:14K"> = BMS_IMPL; diff --git a/server/src/game-implementations/games/chunithm.ts b/server/src/game-implementations/games/chunithm.ts index 06297a5dd..c52b3a634 100644 --- a/server/src/game-implementations/games/chunithm.ts +++ b/server/src/game-implementations/games/chunithm.ts @@ -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."; + } + } + }, + ], }; diff --git a/server/src/game-implementations/games/gitadora.ts b/server/src/game-implementations/games/gitadora.ts index 250217846..99725ac82 100644 --- a/server/src/game-implementations/games/gitadora.ts +++ b/server/src/game-implementations/games/gitadora.ts @@ -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; diff --git a/server/src/game-implementations/games/iidx.test.ts b/server/src/game-implementations/games/iidx.test.ts index 0b998ca54..05d8b2e96 100644 --- a/server/src/game-implementations/games/iidx.test.ts +++ b/server/src/game-implementations/games/iidx.test.ts @@ -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` ); } diff --git a/server/src/game-implementations/games/iidx.ts b/server/src/game-implementations/games/iidx.ts index 4246603d1..b52112349 100644 --- a/server/src/game-implementations/games/iidx.ts +++ b/server/src/game-implementations/games/iidx.ts @@ -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, }; diff --git a/server/src/game-implementations/games/itg.ts b/server/src/game-implementations/games/itg.ts index 089a00b0e..e2a838355 100644 --- a/server/src/game-implementations/games/itg.ts +++ b/server/src/game-implementations/games/itg.ts @@ -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."; + } + } + }, + ], }; diff --git a/server/src/game-implementations/games/jubeat.test.ts b/server/src/game-implementations/games/jubeat.test.ts index aa7e63400..d8ce7f013 100644 --- a/server/src/game-implementations/games/jubeat.test.ts +++ b/server/src/game-implementations/games/jubeat.test.ts @@ -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", diff --git a/server/src/game-implementations/games/jubeat.ts b/server/src/game-implementations/games/jubeat.ts index fdef19c11..63b3cf919 100644 --- a/server/src/game-implementations/games/jubeat.ts +++ b/server/src/game-implementations/games/jubeat.ts @@ -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."; + } + }, + ], }; diff --git a/server/src/game-implementations/games/maimaidx.ts b/server/src/game-implementations/games/maimaidx.ts index c4d0a38fb..843343613 100644 --- a/server/src/game-implementations/games/maimaidx.ts +++ b/server/src/game-implementations/games/maimaidx.ts @@ -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+"; + } + }, + ], }; diff --git a/server/src/game-implementations/games/museca.ts b/server/src/game-implementations/games/museca.ts index 1395be4da..aa972e3d1 100644 --- a/server/src/game-implementations/games/museca.ts +++ b/server/src/game-implementations/games/museca.ts @@ -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."; + } + }, + ], }; diff --git a/server/src/game-implementations/games/popn.ts b/server/src/game-implementations/games/popn.ts index d288a5be9..42e1a29e5 100644 --- a/server/src/game-implementations/games/popn.ts +++ b/server/src/game-implementations/games/popn.ts @@ -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: + } + }, + ], }; diff --git a/server/src/game-implementations/games/sdvx.test.ts b/server/src/game-implementations/games/sdvx.test.ts index 8dce1ce75..afed5227b 100644 --- a/server/src/game-implementations/games/sdvx.test.ts +++ b/server/src/game-implementations/games/sdvx.test.ts @@ -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` ); diff --git a/server/src/game-implementations/games/sdvx.ts b/server/src/game-implementations/games/sdvx.ts index 95c738582..30e17dcdf 100644 --- a/server/src/game-implementations/games/sdvx.ts +++ b/server/src/game-implementations/games/sdvx.ts @@ -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, }; diff --git a/server/src/game-implementations/games/usc.ts b/server/src/game-implementations/games/usc.ts index 7075df7ac..0972d9053 100644 --- a/server/src/game-implementations/games/usc.ts +++ b/server/src/game-implementations/games/usc.ts @@ -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 = { 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 = { 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; diff --git a/server/src/game-implementations/games/wacca.test.ts b/server/src/game-implementations/games/wacca.test.ts index 1b5f0f687..f78b6cbea 100644 --- a/server/src/game-implementations/games/wacca.test.ts +++ b/server/src/game-implementations/games/wacca.test.ts @@ -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>) => + 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(); }); diff --git a/server/src/game-implementations/games/wacca.ts b/server/src/game-implementations/games/wacca.ts index 07db05f5d..8f6de3a11 100644 --- a/server/src/game-implementations/games/wacca.ts +++ b/server/src/game-implementations/games/wacca.ts @@ -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."; + } + } + }, + ], }; diff --git a/server/src/game-implementations/types.ts b/server/src/game-implementations/types.ts index 6addc0db8..b4e00e774 100644 --- a/server/src/game-implementations/types.ts +++ b/server/src/game-implementations/types.ts @@ -109,7 +109,7 @@ export type PBMergeFunction = ( * The only metrics that need validators are those that have `chartDependentMax` set. * Otherwise, a validator is built into the ConfScoreMetric. */ -export type GPTMetricValidators = { +export type GPTChartSpecificMetricValidators = { [M in keyof AllConfMetrics[GPT] as AllConfMetrics[GPT][M] extends ChartDependentMax ? M : never]: ChartSpecificMetricValidator; @@ -159,12 +159,35 @@ export type GPTGoalProgressFormatters = { [K in keyof ConfScoreMetrics[GPT]]: GoalProgressFormatter; }; +/** + * Return nothing on success, and a string + * indicating what the error was on failure. + */ +export type ScoreValidator = ( + score: ScoreDocument +) => string | undefined; + export interface GPTServerImplementation { - validators: GPTMetricValidators; + /** + * For any chart-dependent metrics, such as EX Score for IIDX, how should we + * validate they're correct? + */ + chartSpecificValidators: GPTChartSpecificMetricValidators; + + /** + * How should we derive the derived metrics for this game? + */ derivers: GPTDerivers; + + // assorted calculator functions scoreCalcs: GPTScoreCalculators; sessionCalcs: GPTSessionCalculators; profileCalcs: GPTProfileCalculators; + + /** + * 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; /** @@ -204,6 +227,17 @@ export interface GPTServerImplementation { * 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>; } export type GPTImplementations = { diff --git a/server/src/lib/score-import/framework/score-importing/validate-score.ts b/server/src/lib/score-import/framework/score-importing/validate-score.ts index be5340a0e..00a1b9f17 100644 --- a/server/src/lib/score-import/framework/score-importing/validate-score.ts +++ b/server/src/lib/score-import/framework/score-importing/validate-score.ts @@ -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 = []; @@ -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); } diff --git a/server/src/lib/targets/goal-utils.ts b/server/src/lib/targets/goal-utils.ts index f1008dc71..06a3f2453 100644 --- a/server/src/lib/targets/goal-utils.ts +++ b/server/src/lib/targets/goal-utils.ts @@ -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); } diff --git a/server/src/server/router/ir/fervidex/router.test.ts b/server/src/server/router/ir/fervidex/router.test.ts index 8f8b05edc..c0f6ad53c 100644 --- a/server/src/server/router/ir/fervidex/router.test.ts +++ b/server/src/server/router/ir/fervidex/router.test.ts @@ -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.`, }, ]); diff --git a/server/src/test-utils/single-process-snapshot.ts b/server/src/test-utils/single-process-snapshot.ts index 2b63e7bfb..e89a37d2f 100644 --- a/server/src/test-utils/single-process-snapshot.ts +++ b/server/src/test-utils/single-process-snapshot.ts @@ -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}`); } } diff --git a/server/src/test-utils/snapshots/snapshot-data.json b/server/src/test-utils/snapshots/snapshot-data.json index c7827f53c..cd23323d7 100644 --- a/server/src/test-utils/snapshots/snapshot-data.json +++ b/server/src/test-utils/snapshots/snapshot-data.json @@ -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." + ] } \ No newline at end of file