fix: handle lamp re-arrangement from cg (#1624)

This commit is contained in:
zk
2026-06-02 20:08:37 +01:00
committed by GitHub
parent 77b925a0eb
commit df19c693d7
2 changed files with 127 additions and 29 deletions
@@ -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<CGSDVXScore> = {}) {
const validInput: CGSDVXScore = {
@@ -56,7 +58,7 @@ function mkOutput(modifant: Partial<DryScore<"sdvx">> = {}): 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<CGSDVXScore> = {}) =>
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,
);
});
});
});
@@ -43,7 +43,7 @@ export const ConverterAPICGSDVX: ConverterFunction<CGSDVXScore, CGContext> = 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";