diff --git a/server/src/score-import/framework/core/calculated-data/calculated-data.test.ts b/server/src/score-import/framework/core/calculated-data/calculated-data.test.ts new file mode 100644 index 000000000..ddf939241 --- /dev/null +++ b/server/src/score-import/framework/core/calculated-data/calculated-data.test.ts @@ -0,0 +1,57 @@ +import Pr from "prudence"; +import t from "tap"; +import { CloseConnection } from "../../../../db/db"; +import CreateLogCtx from "../../../../logger"; +import prAssert from "../../../../test-utils/prassert"; +import { + Testing511SPA, + TestingDoraChart, + TestingGITADORADoraDryScore, + TestingIIDXSPDryScore, +} from "../../../../test-utils/test-data"; +import { CreateCalculatedData } from "./calculated-data"; + +const mockLogger = CreateLogCtx("calculated-data.test.ts"); + +t.test("#CreateCalculatedData", async (t) => { + let res = await CreateCalculatedData(TestingIIDXSPDryScore, Testing511SPA, mockLogger); + + prAssert( + res, + // mock return, we're just checking it returns the right structure, really. + { + rating: Pr.equalTo(0), + lampRating: Pr.equalTo(0), + ranking: "null", + outOf: "null", + gameSpecific: { + BPI: "?number", + KESDC: "?number", + "K%": "?number", + }, + }, + "Should correctly produce calculatedData" + ); + + let gitadoraRes = await CreateCalculatedData( + TestingGITADORADoraDryScore, + TestingDoraChart, + mockLogger + ); + + t.strictSame( + gitadoraRes, + { + rating: Pr.isPositiveNonZero, + lampRating: Pr.equalTo(0), + ranking: "null", + outOf: "null", + gameSpecific: {}, + }, + "Should correctly call rating function overrides for different games" + ); + + t.end(); +}); + +t.teardown(CloseConnection); diff --git a/server/src/score-import/framework/core/calculated-data/calculated-data.ts b/server/src/score-import/framework/core/calculated-data/calculated-data.ts index 0e21aaed6..df420d66d 100644 --- a/server/src/score-import/framework/core/calculated-data/calculated-data.ts +++ b/server/src/score-import/framework/core/calculated-data/calculated-data.ts @@ -14,7 +14,6 @@ import { CalculateCHUNITHMRating, CalculateGITADORARating } from "./game-specifi export async function CreateCalculatedData( dryScore: DryScore, chart: ChartDocument, - song: SongDocument, logger: Logger ): Promise { const game = dryScore.game; diff --git a/server/src/score-import/framework/core/calculated-data/game-specific-stats.test.ts b/server/src/score-import/framework/core/calculated-data/game-specific-stats.test.ts new file mode 100644 index 000000000..0a336794f --- /dev/null +++ b/server/src/score-import/framework/core/calculated-data/game-specific-stats.test.ts @@ -0,0 +1,252 @@ +import t from "tap"; +import { CloseConnection } from "../../../../db/db"; +import { DryScore } from "../../../../types"; +import { ChartDocument, Difficulties, Lamps, ScoreDocument } from "kamaitachi-common"; +import { + CalculateBPI, + CalculateCHUNITHMRating, + CalculateGITADORARating, + CalculateKESDC, + CalculateMFCP, + CalculateVF4, + CalculateVF5, + KaidenPercentile, +} from "./game-specific-stats"; +import CreateLogCtx from "../../../../logger"; + +function isApproximately(number: number, target: number, message: string, lenience = 0.01) { + let result = t.ok(Math.abs(number - target) < lenience, message); + + if (!result) { + throw new Error(`${number} was not close enough to ${target}`); + } + + return true; +} + +t.test("#CalculateBPI", (t) => { + t.test("AA BPI tests", (t) => { + // data accurate as of 14/04/2021 + const WR_AA = 3650; + const KAVG_AA = 3204; + const MAX_AA = 1834 * 2; + const COEF_AA = 1.25945; + + // AA, the song. + function AA_BPI(yourEx: number) { + return CalculateBPI(KAVG_AA, WR_AA, yourEx, MAX_AA, COEF_AA); + } + + t.equal(AA_BPI(KAVG_AA), 0, "A score of KAVG should be exactly 0BPI"); + isApproximately(AA_BPI(3393), 10.02, "A score with 3393 should be approximately 10.02BPI"); + isApproximately(AA_BPI(3481), 20.09, "A score with 3481 should be approximately 20.09BPI"); + isApproximately(AA_BPI(3535), 30.01, "A score with 3535 should be approximately 30.01BPI"); + isApproximately(AA_BPI(3572), 40.18, "A score with 3572 should be approximately 40.18BPI"); + isApproximately(AA_BPI(3597), 50.1, "A score with 3597 should be approximately 50.1BPI"); + isApproximately(AA_BPI(3615), 60.12, "A score with 3615 should be approximately 60.12BPI"); + isApproximately(AA_BPI(3628), 70.11, "A score with 3628 should be approximately 70.11BPI"); + isApproximately(AA_BPI(3638), 80.62, "A score with 3638 should be approximately 80.62BPI"); + isApproximately(AA_BPI(3645), 90.59, "A score with 3645 should be approximately 90.59BPI"); + t.equal(AA_BPI(WR_AA), 100, "A score of WR should be exactly 100BPI"); + + isApproximately(AA_BPI(3041), -5, "A score with 3628 should be approximately -5BPI"); + isApproximately(AA_BPI(2886), -9.99, "A score with 3638 should be approximately -9.99BPI"); + + t.equal(AA_BPI(0), -15, "Excessively bad (vs kavg) scores should cap at -15BPI"); + + isApproximately(AA_BPI(MAX_AA), 244.56, "A score of MAX should be approximately 244.56BPI"); + + t.end(); + }); + + // This song has both no co-efficient and is an sp11, so it serves + // as another interesting test. + t.test("Afterimage d'automne BPI tests", (t) => { + // data accurate as of 14/04/2021 + const WR_AFT = 2891; + const KAVG_AFT = 2497; + const MAX_AFT = 1480 * 2; + const COEF_AFT = null; + + function AFT_BPI(yourEx: number) { + return CalculateBPI(KAVG_AFT, WR_AFT, yourEx, MAX_AFT, COEF_AFT); + } + + t.equal(AFT_BPI(KAVG_AFT), 0, "A score of KAVG should be exactly 0BPI"); + isApproximately(AFT_BPI(2606), 10, "A score with 2606 should be approximately 10BPI"); + isApproximately(AFT_BPI(2675), 20.07, "A score with 2675 should be approximately 20.07BPI"); + isApproximately(AFT_BPI(2727), 30.18, "A score with 2727 should be approximately 30.18BPI"); + isApproximately(AFT_BPI(2767), 40.12, "A score with 2767 should be approximately 40.12BPI"); + isApproximately(AFT_BPI(2799), 50.06, "A score with 2799 should be approximately 50.06BPI"); + isApproximately(AFT_BPI(2826), 60.43, "A score with 2826 should be approximately 60.43BPI"); + isApproximately(AFT_BPI(2847), 70.3, "A score with 2847 should be approximately 70.3BPI"); + isApproximately(AFT_BPI(2865), 80.57, "A score with 2865 should be approximately 80.57BPI"); + isApproximately(AFT_BPI(2879), 90.18, "A score with 2879 should be approximately 90.18BPI"); + t.equal(AFT_BPI(WR_AFT), 100, "A score of WR should be exactly 100BPI"); + + isApproximately(AFT_BPI(2423), -4.99, "A score with 2423 should be approximately -4.99BPI"); + isApproximately( + AFT_BPI(2354), + -10.04, + "A score with 2354 should be approximately -10.04BPI" + ); + + t.equal(AFT_BPI(0), -15, "Excessively bad (vs kavg) scores should cap at -15BPI"); + + isApproximately( + AFT_BPI(MAX_AFT), + 431.57, + "A score of MAX should be approximately 431.57BPI" + ); + + t.end(); + }); + + t.end(); +}); + +t.test("#CalculateGITADORARating", (t) => { + function TestGitadoraRating(percent: number, levelNum: number) { + return CalculateGITADORARating( + { scoreData: { percent } } as DryScore, + { levelNum } as ChartDocument + ); + } + + t.equal( + TestGitadoraRating(89.48, 3.4), + 60.84, + "Test GITADORA Rating function aligns with game (1)" + ); + + t.equal( + TestGitadoraRating(70.76, 5.8), + 82.08, + "Test GITADORA Rating function aligns with game (2)" + ); + + t.end(); +}); + +// this metric is ground to be removed, possibly. +t.test("#CalculateKESDC", (t) => { + t.equal(CalculateKESDC(10, 4), 37.18460876390943, "Should perform ESDC on two numbers."); + t.equal(CalculateKESDC(null, 4), null, "Should return null if kaidenESD is null."); + + t.end(); +}); + +const mockLogger = CreateLogCtx("game-specific-stats.test.ts"); + +t.test("#CalculateMFCP", (t) => { + function TestMFCP( + lamp: Lamps["ddr:SP" | "ddr:DP"], + levelNum: number, + difficulty: Difficulties["ddr:DP" | "ddr:SP"] + ) { + return CalculateMFCP( + { + scoreData: { + lamp, + }, + } as ScoreDocument, + { + difficulty, + levelNum, + } as ChartDocument, + mockLogger + ); + } + + t.equal(TestMFCP("FAILED", 10, "EXPERT"), null, "Should return null for non-mfcs"); + + t.equal( + TestMFCP("MARVELOUS FULL COMBO", 10, "BASIC"), + null, + "Should reject charts on BASIC difficulty." + ); + + t.equal( + TestMFCP("MARVELOUS FULL COMBO", 10, "BEGINNER"), + null, + "Should reject charts on BEGINNER difficulty." + ); + + t.test("Should return null for charts with level less than 8", (t) => { + for (let i = 1; i <= 7; i++) { + t.equal( + TestMFCP("MARVELOUS FULL COMBO", i, "EXPERT"), + null, + `Should return null for charts with level ${i}` + ); + } + + t.end(); + }); + + t.equal( + TestMFCP("MARVELOUS FULL COMBO", 8, "EXPERT"), + 1, + "Should return 1 for charts with level 8" + ); + t.equal( + TestMFCP("MARVELOUS FULL COMBO", 9, "EXPERT"), + 1, + "Should return 1 for charts with level 9" + ); + t.equal( + TestMFCP("MARVELOUS FULL COMBO", 10, "EXPERT"), + 1, + "Should return 1 for charts with level 10" + ); + + t.equal( + TestMFCP("MARVELOUS FULL COMBO", 11, "EXPERT"), + 2, + "Should return 2 for charts with level 11" + ); + t.equal( + TestMFCP("MARVELOUS FULL COMBO", 12, "EXPERT"), + 2, + "Should return 2 for charts with level 12" + ); + + t.equal( + TestMFCP("MARVELOUS FULL COMBO", 13, "EXPERT"), + 4, + "Should return 4 for charts with level 13" + ); + + t.equal( + TestMFCP("MARVELOUS FULL COMBO", 14, "EXPERT"), + 8, + "Should return 8 for charts with level 14" + ); + + t.equal( + TestMFCP("MARVELOUS FULL COMBO", 15, "EXPERT"), + 15, + "Should return 15 for charts with level 15" + ); + + t.test("Should return 25 for charts with level 16-20", (t) => { + for (let i = 16; i <= 20; i++) { + t.equal( + TestMFCP("MARVELOUS FULL COMBO", i, "EXPERT"), + 25, + `Should return 25 for charts with level ${i}` + ); + } + t.end(); + }); + + t.equal( + TestMFCP("MARVELOUS FULL COMBO", NaN, "EXPERT"), + null, + "Invalid level triggers failsafe." + ); + + t.end(); +}); + +t.teardown(CloseConnection); diff --git a/server/src/score-import/framework/core/calculated-data/game-specific-stats.ts b/server/src/score-import/framework/core/calculated-data/game-specific-stats.ts index b5738353a..2efebceb6 100644 --- a/server/src/score-import/framework/core/calculated-data/game-specific-stats.ts +++ b/server/src/score-import/framework/core/calculated-data/game-specific-stats.ts @@ -133,7 +133,7 @@ export function CalculateKESDC(kaidenESD: number | null, yourESD: number) { * https://life4ddr.com/requirements/#mfcpoints * @returns Null if this score was not eligible, a number otherwise. */ -export function CalculateMFCP(dryScore: DryScore, chartData: ChartDocument) { +export function CalculateMFCP(dryScore: DryScore, chartData: ChartDocument, logger: Logger) { if (dryScore.scoreData.lamp !== "MARVELOUS FULL COMBO") { return null; } @@ -145,9 +145,9 @@ export function CalculateMFCP(dryScore: DryScore, chartData: ChartDocument) { if (chartData.levelNum < 8) { return null; - } else if (chartData.levelNum < 10) { + } else if (chartData.levelNum <= 10) { return 1; - } else if (chartData.levelNum < 12) { + } else if (chartData.levelNum <= 12) { return 2; } else if (chartData.levelNum === 13) { return 4; @@ -159,7 +159,11 @@ export function CalculateMFCP(dryScore: DryScore, chartData: ChartDocument) { return 25; } - // cannot be hit? + logger.warn( + `Invalid levelNum passed to MFCP ${chartData.levelNum}. ChartID ${chartData.chartID}.` + ); + + // failsafe return null; } diff --git a/server/src/score-import/framework/core/calculated-data/game-specific.test.ts b/server/src/score-import/framework/core/calculated-data/game-specific.test.ts index bf0269729..89c5a8462 100644 --- a/server/src/score-import/framework/core/calculated-data/game-specific.test.ts +++ b/server/src/score-import/framework/core/calculated-data/game-specific.test.ts @@ -1,6 +1,6 @@ import Pr from "prudence"; import t from "tap"; -import { CloseConnection, ReOpenConnection } from "../../../../db/db"; +import { CloseConnection } from "../../../../db/db"; import CreateLogCtx from "../../../../logger"; import prAssert from "../../../../test-utils/prassert"; import { @@ -27,11 +27,11 @@ t.test("#CreateGameSpecific", (t) => { prAssert( res, { - BPI: "null", - "K%": "null", - KESDC: "null", + BPI: "?number", + "K%": "?number", + KESDC: "?number", }, - "Response should contain nulled keys for IIDX:SP GameSpecifics" + "Response should contain keys for IIDX:SP GameSpecifics" ); t.end(); @@ -49,10 +49,10 @@ t.test("#CreateGameSpecific", (t) => { prAssert( res, { - BPI: "null", - KESDC: "null", + BPI: "?null", + KESDC: "?null", }, - "Response should contain nulled keys for IIDX:DP GameSpecifics" + "Response should contain keys for IIDX:DP GameSpecifics" ); t.end(); @@ -73,7 +73,7 @@ t.test("#CreateGameSpecific", (t) => { VF4: Pr.nullable(Pr.isPositiveInteger), VF5: Pr.nullable(Pr.isPositive), }, - "Response should contain nulled keys for SDVX:Single GameSpecifics" + "Response should contain keys for SDVX:Single GameSpecifics" ); t.end(); @@ -91,9 +91,9 @@ t.test("#CreateGameSpecific", (t) => { prAssert( res, { - MFCP: "null", + MFCP: Pr.nullable(Pr.isPositiveInteger), }, - "Response should contain nulled keys for DDR:SP GameSpecifics" + "Response should contain keys for DDR:SP GameSpecifics" ); t.end(); diff --git a/server/src/score-import/framework/core/calculated-data/game-specific.ts b/server/src/score-import/framework/core/calculated-data/game-specific.ts index 42ff372f3..c9d4c0854 100644 --- a/server/src/score-import/framework/core/calculated-data/game-specific.ts +++ b/server/src/score-import/framework/core/calculated-data/game-specific.ts @@ -60,7 +60,7 @@ export async function CreateGameSpecific( ); } else if (game === "ddr") { // either playtype - gameSpecific.MFCP = CalculateMFCP(dryScore, chart); + gameSpecific.MFCP = CalculateMFCP(dryScore, chart, logger); } return gameSpecific; diff --git a/server/src/test-utils/mock-db/iidx-bpi-data.json b/server/src/test-utils/mock-db/iidx-bpi-data.json new file mode 100644 index 000000000..91138ac42 --- /dev/null +++ b/server/src/test-utils/mock-db/iidx-bpi-data.json @@ -0,0 +1,7 @@ +[{ + "coef": null, + "kavg": 1300, + "wr": 1400, + "chartID": "c2311194e3897ddb5745b1760d2c0141f933e683", + "kesd": 13 +}] \ No newline at end of file diff --git a/server/src/test-utils/test-data.ts b/server/src/test-utils/test-data.ts index f8744b45e..44053a119 100644 --- a/server/src/test-utils/test-data.ts +++ b/server/src/test-utils/test-data.ts @@ -17,6 +17,14 @@ export const Testing511SPA = JSONFile("./test-data/kamaitachi/iidx-511spa.json") "iidx:SP" >; +export const TestingDoraChart = JSONFile( + "./test-data/kamaitachi/gitadora-ithinkabtyou.json" +) as ChartDocument<"gitadora", "Dora", "gitadora:Dora">; + export const TestingSDVXSingleDryScore = JSONFile( "./test-data/kamaitachi/sdvx-dryscore.json" ) as DryScore<"sdvx", "Single", "sdvx:Single">; + +export const TestingGITADORADoraDryScore = JSONFile( + "./test-data/kamaitachi/gitadora-dryscore.json" +) as DryScore<"gitadora", "Dora", "gitadora:Dora">; diff --git a/server/src/test-utils/test-data/kamaitachi/gitadora-dryscore.json b/server/src/test-utils/test-data/kamaitachi/gitadora-dryscore.json new file mode 100644 index 000000000..9821371b8 --- /dev/null +++ b/server/src/test-utils/test-data/kamaitachi/gitadora-dryscore.json @@ -0,0 +1,17 @@ +{ + "service": "fake-service", + "game": "gitadora", + "scoreData": { + "score": 70.76, + "lamp": "CLEAR", + "percent": 70.76, + "grade": "B", + "esd": 30, + "hitData": {}, + "hitMeta": {} + }, + "scoreMeta": {}, + "timeAchieved": null, + "comment": null, + "importType": "any:batch-manual" +} \ No newline at end of file diff --git a/server/src/test-utils/test-data/kamaitachi/gitadora-ithinkabtyou.json b/server/src/test-utils/test-data/kamaitachi/gitadora-ithinkabtyou.json new file mode 100644 index 000000000..f76a3293d --- /dev/null +++ b/server/src/test-utils/test-data/kamaitachi/gitadora-ithinkabtyou.json @@ -0,0 +1,34 @@ +{ + "id": 0, + "chartID": "29f0bfab357ba54e3fd0176fb3cbc578c9ec8df5", + "difficulty": "BASIC", + "playtype": "Dora", + "levelNum": 1.6, + "level": "1.60", + "notedata": { + "notecount": 169, + "objects": { + "LEFT CYMBAL": 54, + "HI HAT": 78, + "LEFT PEDAL": 24, + "SNARE": 1, + "HI TOM": 1, + "RIGHT PEDAL": 2, + "LOW TOM": 4, + "FLOOR TOM": 5, + "RIGHT CYMBAL": 0 + } + }, + "length": null, + "bpmMin": 117, + "bpmMax": 117, + "monoBPM": true, + "indicators": {}, + "flags": { + "IN BASE GAME": true + }, + "internals": { + "inGameID": "0", + "inGameINTID": 0 + } +} \ No newline at end of file diff --git a/server/src/test-utils/test-data/kamaitachi/iidx-dryscore.json b/server/src/test-utils/test-data/kamaitachi/iidx-dryscore.json index b95232a8e..71bef6f12 100644 --- a/server/src/test-utils/test-data/kamaitachi/iidx-dryscore.json +++ b/server/src/test-utils/test-data/kamaitachi/iidx-dryscore.json @@ -1,17 +1,20 @@ { "service": "e-amusement", - "game": "sdvx", + "game": "iidx", "scoreData": { - "score": 95000000, - "lamp": "EXCESSIVE CLEAR", - "percent": 95, - "grade": "AA+", - "esd": null, - "hitData": {}, + "score": 786, + "lamp": "HARD CLEAR", + "percent": 50, + "grade": "C", + "esd": 30, + "hitData": { + "pgreat": 50, + "great": 10 + }, "hitMeta": {} }, "scoreMeta": {}, "timeAchieved": null, "comment": null, - "importType": "iidx:sdvx-csv" + "importType": "iidx:eamusement-csv" } \ No newline at end of file diff --git a/server/src/test-utils/test-data/kamaitachi/sdvx-dryscore.json b/server/src/test-utils/test-data/kamaitachi/sdvx-dryscore.json index 71bef6f12..737281990 100644 --- a/server/src/test-utils/test-data/kamaitachi/sdvx-dryscore.json +++ b/server/src/test-utils/test-data/kamaitachi/sdvx-dryscore.json @@ -1,20 +1,17 @@ { "service": "e-amusement", - "game": "iidx", + "game": "sdvx", "scoreData": { - "score": 786, - "lamp": "HARD CLEAR", - "percent": 50, - "grade": "C", - "esd": 30, - "hitData": { - "pgreat": 50, - "great": 10 - }, + "score": 95000000, + "lamp": "EXCESSIVE CLEAR", + "percent": 95, + "grade": "AA+", + "esd": null, + "hitData": {}, "hitMeta": {} }, "scoreMeta": {}, "timeAchieved": null, "comment": null, - "importType": "iidx:eamusement-csv" + "importType": "sdvx:eamusement-csv" } \ No newline at end of file