diff --git a/typescript/server/src/lib/score-import/import-types/common/api-cg/sdvx/converter.test.ts b/typescript/server/src/lib/score-import/import-types/common/api-cg/sdvx/converter.test.ts index f89aafaad..3dd6a01ae 100644 --- a/typescript/server/src/lib/score-import/import-types/common/api-cg/sdvx/converter.test.ts +++ b/typescript/server/src/lib/score-import/import-types/common/api-cg/sdvx/converter.test.ts @@ -1,6 +1,8 @@ import type { DryScore } from "#lib/score-import/framework/common/types"; +import type { Versions } from "tachi-common"; import { log } from "#lib/log/log"; +import { InvalidScoreFailure } from "#lib/score-import/framework/common/converter-failures"; import { ParseDateFromString } from "#lib/score-import/framework/common/score-utils"; import DB from "#services/pg/db"; import { dmf } from "#test-utils/misc"; @@ -9,7 +11,7 @@ import { beforeEach, describe, expect, it } from "vitest"; import type { CGContext, CGSDVXScore } from "../types"; -import { ConverterAPICGSDVX } from "./converter"; +import { ConvertCGSDVXLamp, ConverterAPICGSDVX } from "./converter"; function mkInput(modifant: Partial = {}) { const validInput: CGSDVXScore = { @@ -56,7 +58,7 @@ function mkOutput(modifant: Partial> = {}): DryScore<"sdvx"> { return dmf(validOutput, modifant); } -async function seedSdvxCgFixture() { +async function seedSdvxCgFixture(chartVersions: Versions["sdvx"][] = TestingAlbidaADV.versions) { await DB.insertInto("song") .values({ id: TestingSDVXAlbidaSong.id, @@ -81,23 +83,92 @@ async function seedSdvxCgFixture() { level: TestingAlbidaADV.level, level_num: TestingAlbidaADV.levelNum, is_primary: TestingAlbidaADV.isPrimary, - versions: TestingAlbidaADV.versions, + versions: chartVersions, data: TestingAlbidaADV.data, }) .execute(); } +/** CG clearType → lamp; v6 (exceed) and v7 (nabla) permute UC/PUC/MAXXIVE (4/5/6). */ +const cgSdvxLampByVersion = [ + { gameVersion: "exceed", clearType: 1, lamp: "FAILED" }, + { gameVersion: "exceed", clearType: 2, lamp: "CLEAR" }, + { gameVersion: "exceed", clearType: 3, lamp: "EXCESSIVE CLEAR" }, + { gameVersion: "exceed", clearType: 4, lamp: "ULTIMATE CHAIN" }, + { gameVersion: "exceed", clearType: 5, lamp: "PERFECT ULTIMATE CHAIN" }, + { gameVersion: "exceed", clearType: 6, lamp: "MAXXIVE CLEAR" }, + { gameVersion: "nabla", clearType: 1, lamp: "FAILED" }, + { gameVersion: "nabla", clearType: 2, lamp: "CLEAR" }, + { gameVersion: "nabla", clearType: 3, lamp: "EXCESSIVE CLEAR" }, + { gameVersion: "nabla", clearType: 4, lamp: "MAXXIVE CLEAR" }, + { gameVersion: "nabla", clearType: 5, lamp: "ULTIMATE CHAIN" }, + { gameVersion: "nabla", clearType: 6, lamp: "PERFECT ULTIMATE CHAIN" }, +] as const satisfies ReadonlyArray<{ + clearType: number; + gameVersion: Versions["sdvx"]; + lamp: DryScore<"sdvx">["scoreData"]["lamp"]; +}>; + +/** + * v6→v7 clearType remap for UC/PUC/MAXXIVE: 4↔5↔6 cycle (same lamp, different enum id). + */ +const cgSdvxCrossVersionLampEquivalence = [ + { exceedClearType: 4, nablaClearType: 5, lamp: "ULTIMATE CHAIN" }, + { exceedClearType: 5, nablaClearType: 6, lamp: "PERFECT ULTIMATE CHAIN" }, + { exceedClearType: 6, nablaClearType: 4, lamp: "MAXXIVE CLEAR" }, +] as const satisfies ReadonlyArray<{ + exceedClearType: number; + lamp: DryScore<"sdvx">["scoreData"]["lamp"]; + nablaClearType: number; +}>; + +describe("ConvertCGSDVXLamp", () => { + it.each(cgSdvxLampByVersion)("maps clearType $clearType at $gameVersion to $lamp", ({ + gameVersion, + clearType, + lamp, + }) => { + expect(ConvertCGSDVXLamp(gameVersion, clearType)).toBe(lamp); + }); + + it.each( + cgSdvxCrossVersionLampEquivalence, + )("exceed clearType $exceedClearType and nabla clearType $nablaClearType both mean $lamp", ({ + exceedClearType, + nablaClearType, + lamp, + }) => { + expect(ConvertCGSDVXLamp("exceed", exceedClearType)).toBe(lamp); + expect(ConvertCGSDVXLamp("nabla", nablaClearType)).toBe(lamp); + }); + + it("rejects unknown clearType", () => { + expect(() => ConvertCGSDVXLamp("exceed", 99)).toThrow(InvalidScoreFailure); + expect(() => ConvertCGSDVXLamp("nabla", 0)).toThrow(InvalidScoreFailure); + }); +}); + describe("ConverterAPICGSDVX", () => { const context: CGContext = { service: "dev", userID: 1, }; - beforeEach(seedSdvxCgFixture); + beforeEach(() => seedSdvxCgFixture([...TestingAlbidaADV.versions, "nabla"])); const convert = (modifant: Partial = {}) => ConverterAPICGSDVX(mkInput(modifant), context, "api/cg-dev-sdvx", log); + const lampOnly = (lamp: DryScore<"sdvx">["scoreData"]["lamp"]) => + mkOutput({ + scoreData: { + score: 9_123_000, + lamp, + judgements: { critical: 100, near: 50, miss: 10 }, + optional: { maxCombo: 300, exScore: 1234 }, + }, + }); + it("converts valid input", async () => { const res = await convert(); @@ -111,31 +182,38 @@ describe("ConverterAPICGSDVX", () => { expect(res.dryScore).toStrictEqual(mkOutput()); }); - it("maps clearType to lamp", async () => { - const lampOnly = (lamp: DryScore<"sdvx">["scoreData"]["lamp"]) => - mkOutput({ - scoreData: { - score: 9_123_000, - lamp, - judgements: { critical: 100, near: 50, miss: 10 }, - optional: { maxCombo: 300, exScore: 1234 }, - }, - }); + it.each( + cgSdvxLampByVersion.filter((c) => c.gameVersion === "exceed"), + )("maps exceed (v6) clearType $clearType to $lamp end-to-end", async ({ clearType, lamp }) => { + await expect(convert({ version: 6, clearType })).resolves.toMatchObject({ + dryScore: lampOnly(lamp), + }); + }); - await expect(convert({ clearType: 1 })).resolves.toMatchObject({ - dryScore: lampOnly("FAILED"), + describe("nabla (v7) scores", () => { + it.each( + cgSdvxLampByVersion.filter((c) => c.gameVersion === "nabla"), + )("maps nabla clearType $clearType to $lamp end-to-end", async ({ clearType, lamp }) => { + await expect(convert({ version: 7, clearType })).resolves.toMatchObject({ + dryScore: lampOnly(lamp), + }); }); - await expect(convert({ clearType: 2 })).resolves.toMatchObject({ - dryScore: lampOnly("CLEAR"), - }); - await expect(convert({ clearType: 3 })).resolves.toMatchObject({ - dryScore: lampOnly("EXCESSIVE CLEAR"), - }); - await expect(convert({ clearType: 4 })).resolves.toMatchObject({ - dryScore: lampOnly("ULTIMATE CHAIN"), - }); - await expect(convert({ clearType: 5 })).resolves.toMatchObject({ - dryScore: lampOnly("PERFECT ULTIMATE CHAIN"), + + it.each( + cgSdvxCrossVersionLampEquivalence, + )("v7 clearType $nablaClearType matches exceed clearType $exceedClearType lamp $lamp", async ({ + exceedClearType, + nablaClearType, + lamp, + }) => { + const fromExceed = await convert({ version: 6, clearType: exceedClearType }); + const fromNabla = await convert({ version: 7, clearType: nablaClearType }); + + expect((fromExceed.dryScore as DryScore<"sdvx">).scoreData.lamp).toBe(lamp); + expect((fromNabla.dryScore as DryScore<"sdvx">).scoreData.lamp).toBe(lamp); + expect((fromNabla.dryScore as DryScore<"sdvx">).scoreData.lamp).toBe( + (fromExceed.dryScore as DryScore<"sdvx">).scoreData.lamp, + ); }); }); }); diff --git a/typescript/server/src/lib/score-import/import-types/common/api-cg/sdvx/converter.ts b/typescript/server/src/lib/score-import/import-types/common/api-cg/sdvx/converter.ts index e6784d64d..a3c2ac1bc 100644 --- a/typescript/server/src/lib/score-import/import-types/common/api-cg/sdvx/converter.ts +++ b/typescript/server/src/lib/score-import/import-types/common/api-cg/sdvx/converter.ts @@ -43,7 +43,7 @@ export const ConverterAPICGSDVX: ConverterFunction = asy throw new InternalFailure(`Song-Chart desync with song ID ${chart.song.id} (sdvx).`); } - const lamp = ConvertCGSDVXLamp(data.clearType); + const lamp = ConvertCGSDVXLamp(version, data.clearType); const timeAchieved = ParseDateFromString(data.dateTime); @@ -116,7 +116,27 @@ function ConvertVersion(ver: number): Versions["sdvx"] { * Convert CG's clearType enum into a Tachi lamp. Note that what numbers mean what are * dependent on what version of the game we're listening for. */ -function ConvertCGSDVXLamp(clearType: number): GetEnumValue<"sdvx", "lamp"> { +export function ConvertCGSDVXLamp( + gameVersion: Versions["sdvx"], + clearType: number, +): GetEnumValue<"sdvx", "lamp"> { + if (gameVersion === "nabla") { + switch (clearType) { + case 1: + return "FAILED"; + case 2: + return "CLEAR"; + case 3: + return "EXCESSIVE CLEAR"; + case 4: + return "MAXXIVE CLEAR"; + case 5: + return "ULTIMATE CHAIN"; + case 6: + return "PERFECT ULTIMATE CHAIN"; + } + } + switch (clearType) { case 1: return "FAILED";