feat: gpt-specific invariants

This commit is contained in:
zkldi
2023-01-22 23:02:37 +00:00
parent e6de4628b7
commit 1b3a3df752
24 changed files with 504 additions and 47 deletions
@@ -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,
};
+43 -1
View File
@@ -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.";
}
},
],
};
+80 -1
View File
@@ -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,
};
+3 -1
View File
@@ -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();
});
+40 -1
View File
@@ -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.";
}
}
},
],
};
+36 -2
View File
@@ -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);
}
+1 -1
View File
@@ -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."
]
}