diff --git a/typescript/bot/src/slash-commands/commands/sync.ts b/typescript/bot/src/slash-commands/commands/sync.ts index fcbbf9e5d..731ab327f 100644 --- a/typescript/bot/src/slash-commands/commands/sync.ts +++ b/typescript/bot/src/slash-commands/commands/sync.ts @@ -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"], diff --git a/typescript/client/src/app/pages/dashboard/import/ImportPage.tsx b/typescript/client/src/app/pages/dashboard/import/ImportPage.tsx index 8549f7450..738a3b228 100644 --- a/typescript/client/src/app/pages/dashboard/import/ImportPage.tsx +++ b/typescript/client/src/app/pages/dashboard/import/ImportPage.tsx @@ -164,6 +164,9 @@ function ImportInfoDisplayer({ game }: { game: GameGroup }) { />, , , + , + , + , ); + case "api/cg-dev-iidx": + return ( + + ); + case "api/cg-nag-iidx": + return ( + + ); + case "api/cg-gan-iidx": + return ( + + ); case "api/myt-chunithm": return ( + + + @@ -226,6 +229,9 @@ export default function ImportRoutes() { + + + @@ -239,6 +245,9 @@ export default function ImportRoutes() { + + + diff --git a/typescript/client/src/components/imports/CGIntegrationPage.tsx b/typescript/client/src/components/imports/CGIntegrationPage.tsx index 4d52af609..cc618f889 100644 --- a/typescript/client/src/components/imports/CGIntegrationPage.tsx +++ b/typescript/client/src/components/imports/CGIntegrationPage.tsx @@ -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) { diff --git a/typescript/common/src/constants/import-types.ts b/typescript/common/src/constants/import-types.ts index dcd812481..591475d5c 100644 --- a/typescript/common/src/constants/import-types.ts +++ b/typescript/common/src/constants/import-types.ts @@ -39,14 +39,17 @@ const API_IMPORT_TYPES: Record = { "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, diff --git a/typescript/common/src/types/import-types.ts b/typescript/common/src/types/import-types.ts index 6e0b8b655..11577dfbf 100644 --- a/typescript/common/src/types/import-types.ts +++ b/typescript/common/src/types/import-types.ts @@ -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" diff --git a/typescript/db/src/generated/public/ImportType.ts b/typescript/db/src/generated/public/ImportType.ts index a8bdeda43..f1c5dc502 100644 --- a/typescript/db/src/generated/public/ImportType.ts +++ b/typescript/db/src/generated/public/ImportType.ts @@ -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; diff --git a/typescript/server/.env.example b/typescript/server/.env.example index ea1d02dca..b47edbf7c 100644 --- a/typescript/server/.env.example +++ b/typescript/server/.env.example @@ -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= diff --git a/typescript/server/src/lib/score-import/import-types/common/api-cg/iidx/converter.test.ts b/typescript/server/src/lib/score-import/import-types/common/api-cg/iidx/converter.test.ts new file mode 100644 index 000000000..0ca342267 --- /dev/null +++ b/typescript/server/src/lib/score-import/import-types/common/api-cg/iidx/converter.test.ts @@ -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), + 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), + 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, + }); + }); +}); diff --git a/typescript/server/src/lib/score-import/import-types/common/api-cg/iidx/converter.ts b/typescript/server/src/lib/score-import/import-types/common/api-cg/iidx/converter.ts new file mode 100644 index 000000000..80b4d0f9b --- /dev/null +++ b/typescript/server/src/lib/score-import/import-types/common/api-cg/iidx/converter.ts @@ -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 = 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 = { + // 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 = { [P in Exclude]?: never }; + type XOR = (T | Without) & (U | Without); + + type randoms = NonNullable; + type gauges = NonNullable; + + const RANDOM_MASKS: Partial> = 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> = 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, + ): Array> => { + const options = []; + + for (const [name, mask] of Object.entries(masks)) { + if ((val & mask) !== 0n) { + options.push(name); + } + } + + return options; + }; + + const scoreMeta: Partial = {}; + + // 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 | 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}.`); +} diff --git a/typescript/server/src/lib/score-import/import-types/common/api-cg/parsers.ts b/typescript/server/src/lib/score-import/import-types/common/api-cg/parsers.ts index fed67bbb0..a2f8b4101 100644 --- a/typescript/server/src/lib/score-import/import-types/common/api-cg/parsers.ts +++ b/typescript/server/src/lib/score-import/import-types/common/api-cg/parsers.ts @@ -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 = { + iidx: PR_CG_IIDX, jb: PR_CG_JUBEAT, msc: PR_CG_MUSECA, sdvx: PR_CG_SDVX, @@ -169,16 +194,19 @@ export function CreateCGParser( }; } +export const ParseCGDevIIDX = CreateCGParser("iidx", "dev", "api/cg-dev-iidx"); export const ParseCGDevMuseca = CreateCGParser("msc", "dev", "api/cg-dev-museca"); export const ParseCGDevSDVX = CreateCGParser("sdvx", "dev", "api/cg-dev-sdvx"); export const ParseCGDevJubeat = CreateCGParser("jb", "dev", "api/cg-dev-jubeat"); export const ParseCGDevPopn = CreateCGParser("popn", "dev", "api/cg-dev-popn"); +export const ParseCGGanIIDX = CreateCGParser("iidx", "dev", "api/cg-gan-iidx"); export const ParseCGGanMuseca = CreateCGParser("msc", "gan", "api/cg-gan-museca"); export const ParseCGGanSDVX = CreateCGParser("sdvx", "gan", "api/cg-gan-sdvx"); export const ParseCGGanJubeat = CreateCGParser("jb", "gan", "api/cg-gan-jubeat"); export const ParseCGGanPopn = CreateCGParser("popn", "gan", "api/cg-gan-popn"); +export const ParseCGNagIIDX = CreateCGParser("iidx", "dev", "api/cg-nag-iidx"); export const ParseCGNagMuseca = CreateCGParser("msc", "nag", "api/cg-nag-museca"); export const ParseCGNagSDVX = CreateCGParser("sdvx", "nag", "api/cg-nag-sdvx"); export const ParseCGNagJubeat = CreateCGParser("jb", "nag", "api/cg-nag-jubeat"); diff --git a/typescript/server/src/lib/score-import/import-types/common/api-cg/types.ts b/typescript/server/src/lib/score-import/import-types/common/api-cg/types.ts index 0aedb51b4..0bbf5a0e1 100644 --- a/typescript/server/src/lib/score-import/import-types/common/api-cg/types.ts +++ b/typescript/server/src/lib/score-import/import-types/common/api-cg/types.ts @@ -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; diff --git a/typescript/server/src/lib/score-import/import-types/common/api-cg/util.ts b/typescript/server/src/lib/score-import/import-types/common/api-cg/util.ts index 684883c53..36a7b0db3 100644 --- a/typescript/server/src/lib/score-import/import-types/common/api-cg/util.ts +++ b/typescript/server/src/lib/score-import/import-types/common/api-cg/util.ts @@ -8,6 +8,7 @@ export function CGGameToTachiGame(cgGame: CGSupportedGames): GameGroup { return "jubeat"; case "msc": return "museca"; + case "iidx": case "popn": case "sdvx": return cgGame; diff --git a/typescript/server/src/lib/score-import/import-types/common/types.ts b/typescript/server/src/lib/score-import/import-types/common/types.ts index 5dba13873..4762bf03c 100644 --- a/typescript/server/src/lib/score-import/import-types/common/types.ts +++ b/typescript/server/src/lib/score-import/import-types/common/types.ts @@ -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 { diff --git a/typescript/server/src/lib/score-import/import-types/converters.ts b/typescript/server/src/lib/score-import/import-types/converters.ts index 66845d291..02a6a7100 100644 --- a/typescript/server/src/lib/score-import/import-types/converters.ts +++ b/typescript/server/src/lib/score-import/import-types/converters.ts @@ -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, }; diff --git a/typescript/server/src/lib/score-import/import-types/parsers.ts b/typescript/server/src/lib/score-import/import-types/parsers.ts index b91f671b8..7ff7ac050 100644 --- a/typescript/server/src/lib/score-import/import-types/parsers.ts +++ b/typescript/server/src/lib/score-import/import-types/parsers.ts @@ -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, diff --git a/typescript/server/src/test-utils/test-data.ts b/typescript/server/src/test-utils/test-data.ts index 2bcd29a10..939955a4d 100644 --- a/typescript/server/src/test-utils/test-data.ts +++ b/typescript/server/src/test-utils/test-data.ts @@ -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", ], };