feat: CG IIDX API import method (#1715)

* wip

* yay no errors

* refactor a bit, support ghostGauge

* missCount is null from the api, not -1, if player died

* improve ghostGuage parsing

* add tests

* I knew I was missing something with nullable misscount

* add new import methods to .env.example
This commit is contained in:
GRIM657
2026-07-13 21:20:44 +01:00
committed by GitHub
parent fd694e2da9
commit 79b9350cd5
17 changed files with 807 additions and 28 deletions
@@ -19,9 +19,11 @@ const choices: Array<[string, string]> = (
["CG DEV MUSECA", "api/cg-dev-museca"],
["CG DEV Pop'n", "api/cg-dev-popn"],
["CG DEV Jubeat", "api/cg-dev-jubeat"],
["CG DEV IIDX", "api/cg-dev-iidx"],
["CG SDVX", "api/cg-prod-sdvx"],
["CG MUSECA", "api/cg-prod-museca"],
["CG Pop'n", "api/cg-prod-popn"],
["CG IIDX", "api/cg-prod-iidx"],
["MYT CHUNITHM", "api/myt-chunithm"],
["MYT MAIMAI DX", "api/myt-maimaidx"],
["MYT ONGEKI", "api/myt-ongeki"],
@@ -164,6 +164,9 @@ function ImportInfoDisplayer({ game }: { game: GameGroup }) {
/>,
<ImportTypeInfoCard importType="api/flo-iidx" key="api/flo-iidx" />,
<ImportTypeInfoCard importType="api/eag-iidx" key="api/eag-iidx" />,
<ImportTypeInfoCard importType="api/cg-gan-iidx" key="api/cg-gan-iidx" />,
<ImportTypeInfoCard importType="api/cg-nag-iidx" key="api/cg-nag-iidx" />,
<ImportTypeInfoCard importType="api/cg-dev-iidx" key="api/cg-dev-iidx" />,
<ImportInfoCard
desc="Use your data from a CG instance (Dev/GAN/NAG)."
href="kt-cg-iidx-importer"
@@ -695,6 +698,36 @@ function ImportTypeInfoCard({
name="CG GAN Integration"
/>
);
case "api/cg-dev-iidx":
return (
<ImportInfoCard
desc="Pull your IIDX scores from the CG Dev Network."
href="cg-dev-iidx"
key="cg-dev-iidx"
moreInfo="Note: All networks are reduced to their first three letters for anonymity reasons."
name="CG Dev Integration"
/>
);
case "api/cg-nag-iidx":
return (
<ImportInfoCard
desc="Pull your IIDX scores from the NAG Network."
href="cg-nag-iidx"
key="cg-nag-iidx"
moreInfo="Note: All networks are reduced to their first three letters for anonymity reasons."
name="CG NAG Integration"
/>
);
case "api/cg-gan-iidx":
return (
<ImportInfoCard
desc="Pull your IIDX scores from the GAN Network."
href="cg-gan-iidx"
key="cg-gan-iidx"
moreInfo="Note: All networks are reduced to their first three letters for anonymity reasons."
name="CG GAN Integration"
/>
);
case "api/myt-chunithm":
return (
<ImportInfoCard
@@ -213,6 +213,9 @@ export default function ImportRoutes() {
<Route exact path="/import/cg-dev-jubeat">
<CGIntegrationPage cgType="dev" game="jubeat" />
</Route>
<Route exact path="/import/cg-dev-iidx">
<CGIntegrationPage cgType="dev" game="iidx" />
</Route>
<Route exact path="/import/cg-nag-sdvx">
<CGIntegrationPage cgType="nag" game="sdvx" />
@@ -226,6 +229,9 @@ export default function ImportRoutes() {
<Route exact path="/import/cg-nag-jubeat">
<CGIntegrationPage cgType="nag" game="jubeat" />
</Route>
<Route exact path="/import/cg-nag-iidx">
<CGIntegrationPage cgType="nag" game="iidx" />
</Route>
<Route exact path="/import/cg-gan-sdvx">
<CGIntegrationPage cgType="gan" game="sdvx" />
@@ -239,6 +245,9 @@ export default function ImportRoutes() {
<Route exact path="/import/cg-gan-jubeat">
<CGIntegrationPage cgType="gan" game="jubeat" />
</Route>
<Route exact path="/import/cg-gan-iidx">
<CGIntegrationPage cgType="gan" game="iidx" />
</Route>
<Route exact path="/import/kt-cg-iidx-importer">
<IIDXCGSiteImportPage />
@@ -18,7 +18,7 @@ import ImportStateRenderer from "./ImportStateRenderer";
interface Props {
cgType: "dev" | "gan" | "nag";
game: "jubeat" | "museca" | "popn" | "sdvx";
game: "iidx" | "jubeat" | "museca" | "popn" | "sdvx";
}
export default function CGIntegrationPage({ cgType, game }: Props) {
@@ -39,14 +39,17 @@ const API_IMPORT_TYPES: Record<APIImportTypes, true> = {
"api/myt-maimaidx": true,
"api/myt-ongeki": true,
"api/myt-wacca": true,
"api/cg-dev-iidx": true,
"api/cg-dev-jubeat": true,
"api/cg-dev-museca": true,
"api/cg-dev-popn": true,
"api/cg-dev-sdvx": true,
"api/cg-nag-iidx": true,
"api/cg-nag-jubeat": true,
"api/cg-nag-museca": true,
"api/cg-nag-popn": true,
"api/cg-nag-sdvx": true,
"api/cg-gan-iidx": true,
"api/cg-gan-jubeat": true,
"api/cg-gan-museca": true,
"api/cg-gan-popn": true,
+2 -2
View File
@@ -20,9 +20,9 @@ export type APIImportTypes =
| "api/myt-wacca"
// cg has dev and prod supported
// with four games.
// with five games.
// this typescript feature for stringliteral expansion is kinda neat.
| `api/cg-${"dev" | "gan" | "nag"}-${"jubeat" | "museca" | "popn" | "sdvx"}`;
| `api/cg-${"dev" | "gan" | "nag"}-${"iidx" | "jubeat" | "museca" | "popn" | "sdvx"}`;
export type IRImportTypes =
| "ir/barbatos"
@@ -3,46 +3,49 @@
/** Represents the enum public.import_type */
type ImportType =
| 'file/batch-manual'
| 'file/eamusement-iidx-csv'
| 'file/eamusement-sdvx-csv'
| 'file/mypagescraper-player-csv'
| 'file/mypagescraper-records-csv'
| 'file/pli-iidx-csv'
| 'file/solid-state-squad'
| 'api/arc-iidx'
| 'api/arc-sdvx'
| 'api/mer-iidx'
| 'api/cg-dev-iidx'
| 'api/cg-dev-jubeat'
| 'api/cg-dev-museca'
| 'api/cg-dev-popn'
| 'api/cg-dev-sdvx'
| 'api/cg-gan-iidx'
| 'api/cg-gan-jubeat'
| 'api/cg-gan-museca'
| 'api/cg-gan-popn'
| 'api/cg-gan-sdvx'
| 'api/cg-nag-iidx'
| 'api/cg-nag-jubeat'
| 'api/cg-nag-museca'
| 'api/cg-nag-popn'
| 'api/cg-nag-sdvx'
| 'api/eag-iidx'
| 'api/eag-sdvx'
| 'api/flo-iidx'
| 'api/flo-sdvx'
| 'api/mer-iidx'
| 'api/min-sdvx'
| 'api/myt-chunithm'
| 'api/myt-maimaidx'
| 'api/myt-ongeki'
| 'api/myt-wacca'
| 'api/cg-dev-jubeat'
| 'api/cg-dev-museca'
| 'api/cg-dev-popn'
| 'api/cg-dev-sdvx'
| 'api/cg-gan-jubeat'
| 'api/cg-gan-museca'
| 'api/cg-gan-popn'
| 'api/cg-gan-sdvx'
| 'api/cg-nag-jubeat'
| 'api/cg-nag-museca'
| 'api/cg-nag-popn'
| 'api/cg-nag-sdvx'
| 'file/batch-manual'
| 'file/eamusement-iidx-csv'
| 'file/eamusement-sdvx-csv'
| 'file/import-class'
| 'file/mypagescraper-player-csv'
| 'file/mypagescraper-records-csv'
| 'file/pli-iidx-csv'
| 'file/solid-state-squad'
| 'ir/barbatos'
| 'ir/beatoraja'
| 'ir/direct-manual'
| 'ir/fervidex-static'
| 'ir/fervidex'
| 'ir/kshook-sv6c-static'
| 'ir/fervidex-static'
| 'ir/kshook-sv6c'
| 'ir/kshook-sv6c-static'
| 'ir/lr2hook'
| 'ir/usc'
| 'file/import-class';
| 'ir/usc';
export default ImportType;
+1 -1
View File
@@ -49,7 +49,7 @@ TACHI_CDN_SAVE_LOCATION_PRIVATE_REGION=us-east-1
TACHI_NAME=Tachi Local Dev
TACHI_TYPE=omni
TACHI_GAME_GROUPS=iidx,museca,sdvx,bms,chunithm,usc,wacca,popn,jubeat,pms,maimai,maimaidx,arcaea,ongeki,ddr
TACHI_IMPORT_TYPES=file/eamusement-iidx-csv,file/batch-manual,file/import-class,file/solid-state-squad,file/pli-iidx-csv,ir/direct-manual,ir/barbatos,ir/fervidex,ir/fervidex-static,ir/beatoraja,ir/usc,ir/kshook-sv6c,api/eag-iidx,api/eag-sdvx,api/flo-iidx,api/flo-sdvx,api/min-sdvx
TACHI_IMPORT_TYPES=file/eamusement-iidx-csv,file/batch-manual,file/import-class,file/solid-state-squad,file/pli-iidx-csv,ir/direct-manual,ir/barbatos,ir/fervidex,ir/fervidex-static,ir/beatoraja,ir/usc,ir/kshook-sv6c,api/cg-dev-iidx,api/cg-nag-iidx,api/cg-gan-iidx,api/eag-iidx,api/eag-sdvx,api/flo-iidx,api/flo-sdvx,api/min-sdvx
# TACHI_GITHUB_APP_ID=
# TACHI_GITHUB_APP_BASE64_PRIVATE_KEY=
@@ -0,0 +1,208 @@
import { log } from "#lib/log/log";
import DB from "#services/pg/db";
import { Testing511Song, Testing511SPA } from "#test-utils/test-data";
import deepmerge from "deepmerge";
import { describe, expect, it } from "vitest";
import type { CGContext, CGIIDXScore } from "../types";
import { ConverterAPICGIIDX } from "./converter";
const LEG_SONG_ID = "s_cg_iidx_leg_test";
const LEG_CHART_ID = "c_cg_iidx_leg_test";
const iidxScore: CGIIDXScore = {
version: 33,
internalId: 1000,
difficulty: "SPA",
exScore: 1464,
clearType: 4,
perfectCount: 686,
greatCount: 92,
missCount: 8,
dead: 0,
ghost: "131316191818171a171717181817191617191718151913131917131717171817191818191818181918181a18181719171419171810141814191211161a131519",
ghostGauge:
"4c044c044c044c044c044c049a0402056a05060688060a075c071208940816096409b209000a340a9c0ad00a380bba0b560c8a0cbe0c0c0d740dc20d100e440eac0e140f620fb00ffe0f4c109a100211501191116112c9127f138813881388138813881388138813881388138813881388138813881388138813881388138813881388138813881388138813881388138813881388138813881388138813881388138813881388138813881388138813881388138813881388138813881388138813881388138813881388138813881388138813881388138813881388138813881388138813881388138813881388138813881388138813881388138813881388138813881388138813881388135813721388138813881388138813881388138813de128813881388138813881388138813cc114e121113881388138813881388138813881388138813881388138813881388138813881300000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
option1: 72584948351232,
option2: 0,
dateTime: "2025-11-29 14:07:09",
};
const context: CGContext = {
service: "dev",
userID: 1,
};
async function seed511Another() {
await DB.insertInto("song")
.values({
id: Testing511Song.id,
legacy_id: 1,
game_group: "iidx",
title: Testing511Song.title,
artist: Testing511Song.artist,
search_terms: Testing511Song.searchTerms,
alt_titles: Testing511Song.altTitles,
data: Testing511Song.data,
fts_document: "",
})
.execute();
await DB.insertInto("chart")
.values({
id: Testing511SPA.chartID,
legacy_id: Testing511SPA.chartID,
game: "iidx-sp",
song_id: Testing511Song.id,
difficulty: "ANOTHER",
level: Testing511SPA.level,
level_num: Testing511SPA.levelNum,
is_primary: Testing511SPA.isPrimary,
versions: Testing511SPA.versions,
data: Testing511SPA.data,
})
.execute();
}
async function seedLeggendariaFixture() {
await DB.insertInto("song")
.values({
id: LEG_SONG_ID,
legacy_id: 24_011,
game_group: "iidx",
title: "冬椿 ft. Kanae Asaba",
artist: "artist",
search_terms: [],
alt_titles: [],
data: { displayVersion: "1", genre: "TEST" },
fts_document: "",
})
.execute();
await DB.insertInto("chart")
.values({
id: LEG_CHART_ID,
legacy_id: LEG_CHART_ID,
game: "iidx-sp",
song_id: LEG_SONG_ID,
difficulty: "LEGGENDARIA",
level: "12",
level_num: 12,
is_primary: true,
versions: ["33"],
data: {
inGameID: 24_011,
notecount: 100,
"2dxtraSet": null,
},
})
.execute();
}
describe("ConverterAPICGIIDX", () => {
it("returns song, chart, and dryScore for valid input", async () => {
await seed511Another();
const res = await ConverterAPICGIIDX(iidxScore, context, "api/cg-dev-iidx", log);
expect(res.song).toMatchObject(Testing511Song);
expect(res.chart).toMatchObject({
chartID: Testing511SPA.chartID,
difficulty: "ANOTHER",
});
expect(res.dryScore).toStrictEqual({
comment: null,
game: "iidx-sp",
importType: "api/cg-dev-iidx",
timeAchieved: 1764425229000,
service: "CG Dev",
scoreData: {
score: 1464,
lamp: "CLEAR",
judgements: {
pgreat: 686,
great: 92,
},
optional: {
bp: 8,
gaugeHistory: [
22, 22, 22, 22, 22, 22, 23.56, 25.64, 27.72, 30.84, 33.44, 36.04, 37.68,
41.32, 43.92, 46.52, 48.08, 49.64, 51.2, 52.24, 54.32, 55.36, 57.44, 60.04,
63.16, 64.2, 65.24, 66.8, 68.88, 70.44, 72, 73.04, 75.12, 77.2, 78.76,
80.32, 81.88, 83.44, 85, 87.08, 88.64, 89.94, 94.1, 96.18, 99.82, 100, 100,
100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100,
100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100,
100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100,
100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100,
100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100,
100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 99.04,
99.56, 100, 100, 100, 100, 100, 100, 100, 100, 96.6, 100, 100, 100, 100,
100, 100, 100, 91.12, 93.72, 97.62, 100, 100, 100, 100, 100, 100, 100, 100,
100, 100, 100, 100, 100, 100, 100, 100,
],
},
},
scoreMeta: {
random: "S-RANDOM",
},
});
});
it("converts legacy Leggendaria music_id via lookup table", async () => {
await seed511Another();
await seedLeggendariaFixture();
const res = await ConverterAPICGIIDX(
deepmerge(iidxScore, { internalId: 24101 } as Partial<CGIIDXScore>),
context,
"api/cg-dev-iidx",
log,
);
expect(res.song).toMatchObject({ title: "冬椿 ft. Kanae Asaba" });
expect(res.chart).toMatchObject({
difficulty: "LEGGENDARIA",
data: {
inGameID: 24_011,
},
});
expect(res.dryScore.game).toBe("iidx-sp");
const sd = res.dryScore.scoreData as { lamp: string; score: number };
expect(sd.score).toBe(1464);
expect(sd.lamp).toBe("CLEAR");
});
it("throws when chart is missing", async () => {
await seed511Another();
await expect(
ConverterAPICGIIDX(
deepmerge(iidxScore, { internalId: 0 } as Partial<CGIIDXScore>),
context,
"api/cg-dev-iidx",
log,
),
).rejects.toMatchObject({
failureType: "SongOrChartNotFound",
message: /Could not find chart with songID 0 \(iidx-sp ANOTHER - Version 33\)/u,
});
});
it("rejects invalid music_id type", async () => {
await seed511Another();
await expect(
ConverterAPICGIIDX(
deepmerge(iidxScore, { internalId: "foo" }),
context,
"api/cg-dev-iidx",
log,
),
).rejects.toMatchObject({
message: expect.stringMatching(
/.*invalid input syntax for type integer.*foo/iu,
) as string,
});
});
});
@@ -0,0 +1,436 @@
import type { DryScore } from "#lib/score-import/framework/common/types";
import type { ConverterFunction } from "#lib/score-import/import-types/common/types";
import type { Difficulties, integer, ScoreMeta, Versions } from "tachi-common";
import type { GetEnumValue } from "tachi-common/types/metrics";
import {
InternalFailure,
InvalidScoreFailure,
SkipScoreFailure,
SongOrChartNotFoundFailure,
} from "#lib/score-import/framework/common/converter-failures";
import { ParseDateFromString } from "#lib/score-import/framework/common/score-utils";
import { FindIIDXChartOnInGameIDVersion } from "#utils/queries/charts";
import { FindSongOnID } from "#utils/queries/songs";
import type { CGContext, CGIIDXScore } from "../types";
import { FormatCGService } from "../util";
export const ConverterAPICGIIDX: ConverterFunction<CGIIDXScore, CGContext> = async (
data,
processContext,
importType,
log,
) => {
const version = ConvertVersion(data.version);
const playtype = data.difficulty.startsWith("S") ? "SP" : "DP";
const game = playtype === "SP" ? "iidx-sp" : "iidx-dp";
let musicID = data.internalId;
if (data.difficulty === "SPB") {
throw new SkipScoreFailure(`We don't support BEGINNER charts. Sorry!`);
}
let difficulty = ConvertDifficulty(data.difficulty);
if (OldLeggendariaConversionTable[musicID] !== undefined) {
musicID = OldLeggendariaConversionTable[musicID]!;
// This is now definitely a leggendaria.
difficulty = "LEGGENDARIA";
}
const chart = await FindIIDXChartOnInGameIDVersion(game, musicID, difficulty, version);
if (!chart) {
throw new SongOrChartNotFoundFailure(
`Could not find chart with songID ${musicID} (${playtype} ${difficulty} - Version ${version})`,
importType,
data,
processContext,
);
}
const song = await FindSongOnID("iidx", chart.song.id);
if (!song) {
log.fatal(`Song-Chart desync with song ID ${chart.song.id} (iidx).`);
throw new InternalFailure(`Song-Chart desync with song ID ${chart.song.id} (iidx).`);
}
const dryScore: DryScore<"iidx-dp" | "iidx-sp"> = {
comment: null,
game: game,
importType,
timeAchieved: ParseDateFromString(data.dateTime),
service: FormatCGService(processContext.service),
scoreData: {
score: data.exScore,
lamp: ConvertLamp(data.clearType),
judgements: {
pgreat: data.perfectCount,
great: data.greatCount,
},
optional: {
bp: data.missCount,
gaugeHistory: ConvertGhostGauge(data.ghostGauge),
},
},
scoreMeta: ConvertOptions(data),
};
return { song, chart, dryScore };
};
// LEGGENDARIAs got turned into a real difficulty as of HEROIC VERSE.
// This service still sends the old songIDs when old scores are requested,
// so we need to convert it up.
const OldLeggendariaConversionTable: Record<integer, integer> = {
// RUGGED ASH† -> RUGGED ASH
1100: 1017,
// Clione† -> Clione
4100: 4005,
// ABSOLUTE† -> ABSOLUTE
4101: 4001,
// RIDE ON THE LIGHT (HI GREAT MIX) † -> RIDE ON THE LIGHT (HI GREAT MIX)
5100: 5014,
// RED ZONE† -> RED ZONE
11100: 11032,
// spiral galaxy† -> spiral galaxy
11101: 11012,
// Little Little Princess† -> Little Little Princess
12100: 12002,
// CONTRACT† -> CONTRACT
13100: 13010,
// VANESSA† -> VANESSA
14100: 14009,
// KAMAITACHI† -> KAMAITACHI
14101: 14046,
// ICARUS† -> ICARUS
15101: 15023,
// THE DEEP STRIKER† -> THE DEEP STRIKER
15102: 15007,
// Blue Rain† -> Blue Rain
15104: 15004,
// Wanna Party?† -> Wanna Party?
15105: 15045,
// 凛として咲く花の如く† -> 凛として咲く花の如く
16101: 16050,
// THANK YOU FOR PLAYING† -> THANK YOU FOR PLAYING
16102: 16045,
// naughty girl@Queen's Palace† -> naughty girl@Queen's Palace
16103: 16031,
// Kung-fu Empire† -> Kung-fu Empire
16104: 16015,
// SOLID STATE SQUAD† -> SOLID STATE SQUAD
17101: 17060,
// Golden Palms† -> Golden Palms
18100: 18025,
// おおきなこえで† -> おおきなこえで
18103: 18011,
// QUANTUM TELEPORTATION† -> QUANTUM TELEPORTATION
19100: 19063,
// 朧† -> 朧
20103: 20100,
// 仮想空間の旅人たち† -> 仮想空間の旅人たち
20104: 20039,
// LUV CAN SAVE U† -> LUV CAN SAVE U
20105: 20068,
// Howling† -> Howling
20106: 20024,
// 龍と少女とデコヒーレンス† -> 龍と少女とデコヒーレンス
20107: 20019,
// Close the World feat.a☆ru†LEGGENDARIA -> Close the World feat. a☆ru
21100: 21012,
// Sigmund†LEGGENDARIA -> Sigmund
21101: 21059,
// Ancient Scapes†LEGGENDARIA -> Ancient Scapes
21102: 21069,
// invoker†LEGGENDARIA -> invoker
21103: 21073,
// Feel The Beat†LEGGENDARIA -> Feel The Beat
21104: 21052,
// 疾風迅雷†LEGGENDARIA -> 疾風迅雷
21105: 21048,
// Verflucht†LEGGENDARIA -> Verflucht
21106: 21050,
// 廿† -> 廿
21107: 21029,
// CHRONO DIVER -NORNIR-† -> CHRONO DIVER -NORNIR-
22101: 22008,
// chrono diver -fragment-† -> chrono diver -fragment-
22102: 22013,
// 恋は白帯、サンシロー† -> 恋は白帯、サンシロー
22103: 22024,
// Beat Radiance† -> Beat Radiance
22104: 22027,
// 超青少年ノ為ノ超多幸ナ超古典的超舞曲† -> 超青少年ノ為ノ超多幸ナ超古典的超舞曲
22105: 22031,
// EBONY & IVORY† -> EBONY & IVORY
22106: 22089,
// Cosmic Cat† -> Cosmic Cat
22107: 22006,
// Damage Per Second† -> Damage Per Second
23100: 23054,
// STARLIGHT DANCEHALL† -> STARLIGHT DANCEHALL
23101: 23031,
// Amazing Mirage† -> Amazing Mirage
24100: 24041,
// 冬椿 ft. Kanae Asaba† -> 冬椿 ft. Kanae Asaba
24101: 24011,
};
function ConvertOptions(data: CGIIDXScore): ScoreMeta["iidx-dp"] | ScoreMeta["iidx-sp"] {
if (data.version < 23) {
return {}; // Unknown if bits are the same in older versions
}
const opt1 = BigInt(data.option1);
const opt2 = BigInt(data.option2);
const isModern = data.version >= 27;
type Without<T, U> = { [P in Exclude<keyof T, keyof U>]?: never };
type XOR<T, U> = (T | Without<U, T>) & (U | Without<T, U>);
type randoms = NonNullable<ScoreMeta["iidx-sp"]["random"]>;
type gauges = NonNullable<ScoreMeta["iidx-sp"]["gauge"]>;
const RANDOM_MASKS: Partial<Record<randoms, bigint>> = isModern
? {
RANDOM: 1n << 39n,
"R-RANDOM": 1n << 40n,
"S-RANDOM": 1n << 41n,
MIRROR: 1n << 42n,
}
: {
RANDOM: 1n << 18n,
"R-RANDOM": 1n << 19n,
"S-RANDOM": 1n << 20n,
MIRROR: 1n << 21n,
};
const GAUGE_MASKS: Partial<Record<gauges, bigint>> = isModern
? {
"ASSISTED EASY": 1n << 35n,
EASY: 1n << 36n,
HARD: 1n << 37n,
"EX-HARD": 1n << 38n,
}
: {
"ASSISTED EASY": 1n << 14n,
EASY: 1n << 15n,
HARD: 1n << 16n,
"EX-HARD": 1n << 17n,
};
// TODO: find bits for AUTO SCRATCH and LEGACY NOTE
// Helper to find which string keys match the bitmask
const getActiveOptions = (
val: bigint,
masks: Record<string, bigint>,
): Array<XOR<gauges, randoms>> => {
const options = [];
for (const [name, mask] of Object.entries(masks)) {
if ((val & mask) !== 0n) {
options.push(name);
}
}
return options;
};
const scoreMeta: Partial<ScoreMeta["iidx-dp" | "iidx-sp"]> = {};
// for Random options, if no options are selected we know NONRAN is used.
// and we know only one option can be selected.
scoreMeta.random = "NONRAN";
const randomOptions = getActiveOptions(opt1, RANDOM_MASKS);
if (randomOptions.length > 0) {
scoreMeta.random = randomOptions[0] as randoms;
}
// For DP random is an array [p1 side, p2 side]
if (data.difficulty.startsWith("D")) {
scoreMeta.random = [scoreMeta.random, "NONRAN"];
const rightSide = getActiveOptions(opt2, RANDOM_MASKS);
if (rightSide.length > 0) {
scoreMeta.random[1] = rightSide[0] as randoms;
}
}
// for Gauge options, if no options are selected we do NOT know the gauge type
// like Random only one Gauge can be selected
const gaugeOptions = getActiveOptions(opt1, GAUGE_MASKS);
if (gaugeOptions.length > 0) {
scoreMeta.gauge = gaugeOptions[0] as gauges;
}
return scoreMeta;
}
function ConvertGhostGauge(rawHex: string): Array<number> | null {
// Sanity check, all ghostGauge data that we have seen is 1,536 characters.
if (rawHex.length !== 1536) {
return null;
}
const graph = [];
const MAX_VAL = 5000;
let failed = false;
for (let i = 0; i < rawHex.length; i += 4) {
const chunk = rawHex.slice(i, i + 4);
const bigEndianHex = chunk.slice(2, 4) + chunk.slice(0, 2);
const rawValue = parseInt(bigEndianHex, 16);
if (rawValue > MAX_VAL) {
failed = true; // We've encountered a status flag seemingly indicating death.
graph.push(0.0);
break;
}
const percentage = (rawValue / MAX_VAL) * 100;
graph.push(Number(percentage.toFixed(2)));
}
// Remove padding 0s at the end of a normal play.
if (!failed) {
let lastDataIndex = -1;
for (let i = graph.length - 1; i >= 0; i--) {
if (graph[i] > 0) {
lastDataIndex = i;
break;
}
}
return lastDataIndex === -1 ? null : graph.slice(0, lastDataIndex + 1);
}
return graph;
}
function ConvertDifficulty(difficulty: string): Difficulties["iidx-dp" | "iidx-sp"] {
switch (difficulty.charAt(2)) {
case "N":
return "NORMAL";
case "H":
return "HYPER";
case "A":
return "ANOTHER";
case "L":
return "LEGGENDARIA";
}
throw new InvalidScoreFailure(`Unknown difficulty ${difficulty}.`);
}
function ConvertLamp(lamp: integer): GetEnumValue<"iidx-dp" | "iidx-sp", "lamp"> {
switch (lamp) {
case 0:
return "NO PLAY";
case 1:
return "FAILED";
case 2:
return "ASSIST CLEAR";
case 3:
return "EASY CLEAR";
case 4:
return "CLEAR";
case 5:
return "HARD CLEAR";
case 6:
return "EX HARD CLEAR";
case 7:
return "FULL COMBO";
}
throw new InvalidScoreFailure(`Unknown lamp value ${lamp}.`);
}
function ConvertVersion(version: integer): Versions["iidx-dp" | "iidx-sp"] {
switch (version) {
case 20:
return "20";
case 21:
return "21";
case 22:
return "22";
case 23:
return "23";
case 24:
return "24";
case 25:
return "25";
case 26:
return "26";
case 27:
return "27-omni";
case 28:
return "28-omni";
case 29:
return "29-omni";
case 30:
return "30-omni";
case 31:
return "31-omni";
case 32:
return "32-omni";
case 33:
return "33";
}
throw new InvalidScoreFailure(`Unsupported version ${version}.`);
}
@@ -11,6 +11,7 @@ import { type APIImportTypes, FormatPrError, type integer, type V3Game } from "t
import type { ParserFunctionReturns } from "../types";
import type {
CGContext,
CGIIDXScore,
CGJubeatScore,
CGMusecaScore,
CGPopnScore,
@@ -22,6 +23,29 @@ import type {
import { FetchCGScores } from "./traverse-api";
import { CGGameToTachiGame, FormatCGService } from "./util";
const PR_CG_IIDX = {
internalId: p.isPositiveInteger,
difficulty: "string",
version: p.isPositiveInteger,
exScore: p.isPositiveInteger,
clearType: p.isPositiveInteger,
perfectCount: p.isPositiveInteger,
greatCount: p.isPositiveInteger,
// missCount is null when it's not recorded, so we have to allow that.
missCount: p.or(p.isPositiveInteger, p.is(null)),
// unused
dead: p.any,
option1: p.isPositiveInteger,
option2: p.isPositiveInteger,
ghost: p.any,
ghostGauge: p.any,
dateTime: "string",
};
const PR_CG_JUBEAT = {
internalId: p.isPositiveInteger,
difficulty: p.isPositiveInteger,
@@ -97,6 +121,7 @@ const PR_CG_POPN = {
// given a CG game, what should the returned data look like?
const CG_SCHEMAS: Record<CGSupportedGames, PrudenceSchema> = {
iidx: PR_CG_IIDX,
jb: PR_CG_JUBEAT,
msc: PR_CG_MUSECA,
sdvx: PR_CG_SDVX,
@@ -169,16 +194,19 @@ export function CreateCGParser<T extends { dateTime: string }>(
};
}
export const ParseCGDevIIDX = CreateCGParser<CGIIDXScore>("iidx", "dev", "api/cg-dev-iidx");
export const ParseCGDevMuseca = CreateCGParser<CGMusecaScore>("msc", "dev", "api/cg-dev-museca");
export const ParseCGDevSDVX = CreateCGParser<CGSDVXScore>("sdvx", "dev", "api/cg-dev-sdvx");
export const ParseCGDevJubeat = CreateCGParser<CGJubeatScore>("jb", "dev", "api/cg-dev-jubeat");
export const ParseCGDevPopn = CreateCGParser<CGPopnScore>("popn", "dev", "api/cg-dev-popn");
export const ParseCGGanIIDX = CreateCGParser<CGIIDXScore>("iidx", "dev", "api/cg-gan-iidx");
export const ParseCGGanMuseca = CreateCGParser<CGMusecaScore>("msc", "gan", "api/cg-gan-museca");
export const ParseCGGanSDVX = CreateCGParser<CGSDVXScore>("sdvx", "gan", "api/cg-gan-sdvx");
export const ParseCGGanJubeat = CreateCGParser<CGJubeatScore>("jb", "gan", "api/cg-gan-jubeat");
export const ParseCGGanPopn = CreateCGParser<CGPopnScore>("popn", "gan", "api/cg-gan-popn");
export const ParseCGNagIIDX = CreateCGParser<CGIIDXScore>("iidx", "dev", "api/cg-nag-iidx");
export const ParseCGNagMuseca = CreateCGParser<CGMusecaScore>("msc", "nag", "api/cg-nag-museca");
export const ParseCGNagSDVX = CreateCGParser<CGSDVXScore>("sdvx", "nag", "api/cg-nag-sdvx");
export const ParseCGNagJubeat = CreateCGParser<CGJubeatScore>("jb", "nag", "api/cg-nag-jubeat");
@@ -1,6 +1,7 @@
import type { integer } from "tachi-common";
export type CGSupportedGames =
| "iidx"
| "jb" // jubeat
| "msc" // museca
| "popn"
@@ -24,6 +25,23 @@ export interface CGErrorResponse {
message: string;
}
export interface CGIIDXScore {
version: integer;
internalId: integer;
difficulty: string;
exScore: integer;
clearType: integer;
perfectCount: integer;
greatCount: integer;
missCount: integer | null;
dead: integer;
ghost: string;
ghostGauge: string;
option1: integer;
option2: integer;
dateTime: string;
}
export interface CGMusecaScore {
internalId: integer;
difficulty: integer;
@@ -8,6 +8,7 @@ export function CGGameToTachiGame(cgGame: CGSupportedGames): GameGroup {
return "jubeat";
case "msc":
return "museca";
case "iidx":
case "popn":
case "sdvx":
return cgGame;
@@ -32,6 +32,7 @@ import type { LR2HookContext, LR2HookScore } from "../ir/lr2hook/types";
import type { IRUSCContext } from "../ir/usc/types";
import type {
CGContext,
CGIIDXScore,
CGJubeatScore,
CGMusecaScore,
CGPopnScore,
@@ -78,16 +79,19 @@ export interface ImportTypeDataMap {
"api/cg-dev-museca": CGMusecaScore;
"api/cg-dev-popn": CGPopnScore;
"api/cg-dev-jubeat": CGJubeatScore;
"api/cg-dev-iidx": CGIIDXScore;
"api/cg-nag-sdvx": CGSDVXScore;
"api/cg-nag-museca": CGMusecaScore;
"api/cg-nag-popn": CGPopnScore;
"api/cg-nag-jubeat": CGJubeatScore;
"api/cg-nag-iidx": CGIIDXScore;
"api/cg-gan-sdvx": CGSDVXScore;
"api/cg-gan-museca": CGMusecaScore;
"api/cg-gan-popn": CGPopnScore;
"api/cg-gan-jubeat": CGJubeatScore;
"api/cg-gan-iidx": CGIIDXScore;
}
export interface ImportTypeContextMap {
@@ -124,14 +128,17 @@ export interface ImportTypeContextMap {
"api/cg-dev-sdvx": CGContext;
"api/cg-dev-museca": CGContext;
"api/cg-dev-popn": CGContext;
"api/cg-dev-iidx": CGContext;
"api/cg-nag-jubeat": CGContext;
"api/cg-nag-sdvx": CGContext;
"api/cg-nag-museca": CGContext;
"api/cg-nag-popn": CGContext;
"api/cg-nag-iidx": CGContext;
"api/cg-gan-jubeat": CGContext;
"api/cg-gan-sdvx": CGContext;
"api/cg-gan-museca": CGContext;
"api/cg-gan-popn": CGContext;
"api/cg-gan-iidx": CGContext;
}
export interface OrphanScoreDocument<T extends ImportTypes = ImportTypes> {
@@ -8,6 +8,7 @@ import ConvertAPIMytChunithm from "./api/myt-chunithm/converter";
import ConvertAPIMytMaimaiDx from "./api/myt-maimaidx/converter";
import ConvertAPIMytOngeki from "./api/myt-ongeki/converter";
import ConvertAPIMytWACCA from "./api/myt-wacca/converter";
import { ConverterAPICGIIDX } from "./common/api-cg/iidx/converter";
import { ConverterAPICGJubeat } from "./common/api-cg/jb/converter";
import { ConverterAPICGMuseca } from "./common/api-cg/museca/converter";
import { ConverterAPICGPopn } from "./common/api-cg/popn/converter";
@@ -70,12 +71,15 @@ export const Converters: ConverterMap = {
"api/cg-dev-museca": ConverterAPICGMuseca,
"api/cg-dev-popn": ConverterAPICGPopn,
"api/cg-dev-jubeat": ConverterAPICGJubeat,
"api/cg-dev-iidx": ConverterAPICGIIDX,
"api/cg-nag-sdvx": ConverterAPICGSDVX,
"api/cg-nag-museca": ConverterAPICGMuseca,
"api/cg-nag-popn": ConverterAPICGPopn,
"api/cg-nag-jubeat": ConverterAPICGJubeat,
"api/cg-nag-iidx": ConverterAPICGIIDX,
"api/cg-gan-sdvx": ConverterAPICGSDVX,
"api/cg-gan-museca": ConverterAPICGMuseca,
"api/cg-gan-popn": ConverterAPICGPopn,
"api/cg-gan-jubeat": ConverterAPICGJubeat,
"api/cg-gan-iidx": ConverterAPICGIIDX,
};
@@ -8,14 +8,17 @@ import ParseMytMaimaiDx from "./api/myt-maimaidx/parser";
import ParseMytOngeki from "./api/myt-ongeki/parser";
import ParseMytWACCA from "./api/myt-wacca/parser";
import {
ParseCGDevIIDX,
ParseCGDevJubeat,
ParseCGDevMuseca,
ParseCGDevPopn,
ParseCGDevSDVX,
ParseCGGanIIDX,
ParseCGGanJubeat,
ParseCGGanMuseca,
ParseCGGanPopn,
ParseCGGanSDVX,
ParseCGNagIIDX,
ParseCGNagJubeat,
ParseCGNagMuseca,
ParseCGNagPopn,
@@ -61,16 +64,19 @@ export const Parsers = {
"api/cg-dev-popn": ParseCGDevPopn,
"api/cg-dev-museca": ParseCGDevMuseca,
"api/cg-dev-jubeat": ParseCGDevJubeat,
"api/cg-dev-iidx": ParseCGDevIIDX,
"api/cg-nag-sdvx": ParseCGNagSDVX,
"api/cg-nag-popn": ParseCGNagPopn,
"api/cg-nag-museca": ParseCGNagMuseca,
"api/cg-nag-jubeat": ParseCGNagJubeat,
"api/cg-nag-iidx": ParseCGNagIIDX,
"api/cg-gan-sdvx": ParseCGGanSDVX,
"api/cg-gan-popn": ParseCGGanPopn,
"api/cg-gan-museca": ParseCGGanMuseca,
"api/cg-gan-jubeat": ParseCGGanJubeat,
"api/cg-gan-iidx": ParseCGGanIIDX,
"api/myt-chunithm": ParseMytChunithm,
"api/myt-maimaidx": ParseMytMaimaiDx,
@@ -636,6 +636,12 @@ export const Testing511SPA: ChartDocument<"iidx-sp"> = {
"26-omni",
"27",
"26",
"25",
"24",
"23",
"22",
"21",
"20",
"inf",
"16-cs",
"12-cs",
@@ -643,6 +649,21 @@ export const Testing511SPA: ChartDocument<"iidx-sp"> = {
"8-cs",
"7-cs",
"bmus",
"28",
"28-omni",
"29",
"27-2dxtra",
"28-2dxtra",
"30",
"29-omni",
"30-omni",
"31",
"30-2dxtra",
"31-omni",
"32",
"31-2dxtra",
"33",
"32-omni",
],
};