diff --git a/bun.lock b/bun.lock index 7174a118b..77fc21a1f 100644 --- a/bun.lock +++ b/bun.lock @@ -107,6 +107,7 @@ "react-router-dom": "catalog:", "react-select": "catalog:", "rg-stats": "catalog:", + "sql.js": "^1.14.1", "sync-fetch": "catalog:", "tachi-common": "workspace:*", "vite-plugin-html": "catalog:", @@ -119,6 +120,7 @@ "@types/react-dom": "catalog:", "@types/react-router-bootstrap": "catalog:", "@types/react-router-dom": "catalog:", + "@types/sql.js": "^1.4.11", "@typescript/native-preview": "catalog:", "@vitejs/plugin-react": "catalog:", "bootstrap-scss": "catalog:", @@ -1210,6 +1212,8 @@ "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], + "@types/emscripten": ["@types/emscripten@1.41.5", "", {}, "sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q=="], + "@types/eslint": ["@types/eslint@9.6.1", "", { "dependencies": { "@types/estree": "*", "@types/json-schema": "*" } }, "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag=="], "@types/eslint__js": ["@types/eslint__js@8.42.3", "", { "dependencies": { "@types/eslint": "*" } }, "sha512-alfG737uhmPdnvkrLdZLcEKJ/B8s9Y4hrZ+YAdzUeoArBlSUERA2E87ROfOaS4jd/C45fzOoZzidLc1IPwLqOw=="], @@ -1288,6 +1292,8 @@ "@types/serve-static": ["@types/serve-static@2.2.0", "", { "dependencies": { "@types/http-errors": "*", "@types/node": "*" } }, "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ=="], + "@types/sql.js": ["@types/sql.js@1.4.11", "", { "dependencies": { "@types/emscripten": "*", "@types/node": "*" } }, "sha512-QXIx38p2ZThJaK9vP5ZdqdlRe1FG9I8SmCZOS7FHfB/2qPAjZwkL7/vlfPg6N/oWHuuOaGg/P/IRwfP2W0kWVQ=="], + "@types/superagent": ["@types/superagent@8.1.9", "", { "dependencies": { "@types/cookiejar": "^2.1.5", "@types/methods": "^1.1.4", "@types/node": "*", "form-data": "^4.0.0" } }, "sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ=="], "@types/supertest": ["@types/supertest@2.0.11", "", { "dependencies": { "@types/superagent": "*" } }, "sha512-uci4Esokrw9qGb9bvhhSVEjd6rkny/dk5PK/Qz4yxKiyppEI+dOPlNrZBahE3i+PoKFYyDxChVXZ/ysS/nrm1Q=="], @@ -2636,6 +2642,8 @@ "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], + "sql.js": ["sql.js@1.14.1", "", {}, "sha512-gcj8zBWU5cFsi9WUP+4bFNXAyF1iRpA3LLyS/DP5xlrNzGmPIizUeBggKa8DbDwdqaKwUcTEnChtd2grWo/x/A=="], + "stable-hash": ["stable-hash@0.0.5", "", {}, "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA=="], "stable-hash-x": ["stable-hash-x@0.2.0", "", {}, "sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ=="], diff --git a/package.json b/package.json index 59b007af5..ff9d5bad3 100644 --- a/package.json +++ b/package.json @@ -176,7 +176,9 @@ "codemirror": "^6.0.2", "comlink": "^4.4.2", "fast-json-patch": "^3.1.1", - "cron-parser": "5.0.2" + "cron-parser": "5.0.2", + "@types/sql.js": "^1.4.11", + "sql.js": "^1.14.1" }, "overrides": { "@types/node": "20.19.37", diff --git a/typescript/client/package.json b/typescript/client/package.json index c608d94c8..c6cc11fd0 100644 --- a/typescript/client/package.json +++ b/typescript/client/package.json @@ -62,6 +62,7 @@ "react-router-dom": "catalog:", "react-select": "catalog:", "rg-stats": "catalog:", + "sql.js": "catalog:", "sync-fetch": "catalog:", "tachi-common": "workspace:*", "vite-plugin-html": "catalog:" @@ -74,11 +75,12 @@ "@types/react-dom": "catalog:", "@types/react-router-bootstrap": "catalog:", "@types/react-router-dom": "catalog:", + "@types/sql.js": "catalog:", + "@typescript/native-preview": "catalog:", "@vitejs/plugin-react": "catalog:", "bootstrap-scss": "catalog:", "eslint-config-tachi": "workspace:*", "sass": "catalog:", - "@typescript/native-preview": "catalog:", "typescript": "catalog:", "vite": "catalog:" }, diff --git a/typescript/client/src/app/pages/dashboard/import/LR2DBPage.tsx b/typescript/client/src/app/pages/dashboard/import/LR2DBPage.tsx index fbe0b72cb..e7c77b3d6 100644 --- a/typescript/client/src/app/pages/dashboard/import/LR2DBPage.tsx +++ b/typescript/client/src/app/pages/dashboard/import/LR2DBPage.tsx @@ -1,20 +1,33 @@ -import TISInfo from "#components/imports/TISInfo"; +import ImportSQLiteForm from "#components/imports/ImportSQLiteForm"; import useSetSubheader from "#components/layout/header/useSetSubheader"; import Divider from "#components/util/Divider"; import Muted from "#components/util/Muted"; -import { TachiConfig } from "#lib/config"; +import { convertLR2Db } from "#util/db-converters/lr2"; import React from "react"; export default function LR2DBPage() { useSetSubheader(["Import Scores", "LR2 Database File"]); + return ( <> - + { + const { k7, k14, warnings } = convertLR2Db(dbs.score!, dbs.chart!); + const results = [k7, k14].filter((r) => r !== null); + return { results, warnings }; + }} + fileInputs={[ + { key: "score", label: "LR2 Scores/.db" }, + { key: "chart", label: "LR2 song.db" }, + ]} + name="LR2 Database Import" + /> + - Note: If you submit a score on a chart that {TachiConfig.NAME} doesn't recognise, - you'll need to wait until atleast 2 other players submit scores for that chart - before it'll show up. This is to combat accidental IR spam. + Note: If you submit a score on a chart that the server doesn't recognise, you'll + need to wait until at least 2 other players submit scores for that chart before + it'll show up. This is to combat accidental IR spam. ); diff --git a/typescript/client/src/app/pages/dashboard/import/LR2orajaDBPage.tsx b/typescript/client/src/app/pages/dashboard/import/LR2orajaDBPage.tsx index 8e5d3ff9e..04076bb49 100644 --- a/typescript/client/src/app/pages/dashboard/import/LR2orajaDBPage.tsx +++ b/typescript/client/src/app/pages/dashboard/import/LR2orajaDBPage.tsx @@ -1,8 +1,8 @@ -import TISInfo from "#components/imports/TISInfo"; +import ImportSQLiteForm from "#components/imports/ImportSQLiteForm"; import useSetSubheader from "#components/layout/header/useSetSubheader"; import Divider from "#components/util/Divider"; import Muted from "#components/util/Muted"; -import { TachiConfig } from "#lib/config"; +import { convertBeatorajaDb } from "#util/db-converters/beatoraja"; import React from "react"; import { Link } from "react-router-dom"; @@ -11,18 +11,30 @@ export default function LR2orajaDBPage() { return ( <> - + { + const { k7, k14, warnings } = convertBeatorajaDb(dbs.score!, dbs.chart!); + const results = [k7, k14].filter((r) => r !== null); + return { results, warnings }; + }} + fileInputs={[ + { key: "score", label: "Beatoraja score.db" }, + { key: "chart", label: "Beatoraja songdata.db" }, + ]} + name="LR2oraja (Beatoraja) Database Import" + /> + This method is intended for syncing up with existing scores. For new scores, you - should set up the LR2oraja IR, for automatic + should set up the LR2oraja IR for automatic score uploading.
- Note: If you submit a score on a chart that {TachiConfig.NAME} doesn't recognise, - you'll need to wait until atleast 2 other players submit scores for that chart - before it'll show up. This is to combat accidental IR spam. + Note: If you submit a score on a chart that the server doesn't recognise, you'll + need to wait until at least 2 other players submit scores for that chart before + it'll show up. This is to combat accidental IR spam. ); diff --git a/typescript/client/src/app/pages/dashboard/import/USCDBPage.tsx b/typescript/client/src/app/pages/dashboard/import/USCDBPage.tsx index 28c52b9bc..8693fc1c4 100644 --- a/typescript/client/src/app/pages/dashboard/import/USCDBPage.tsx +++ b/typescript/client/src/app/pages/dashboard/import/USCDBPage.tsx @@ -1,27 +1,64 @@ -import TISInfo from "#components/imports/TISInfo"; +import ImportSQLiteForm from "#components/imports/ImportSQLiteForm"; import useSetSubheader from "#components/layout/header/useSetSubheader"; import Divider from "#components/util/Divider"; import Muted from "#components/util/Muted"; -import { TachiConfig } from "#lib/config"; -import React from "react"; -import Alert from "react-bootstrap/Alert"; +import { convertUSCDb } from "#util/db-converters/usc"; +import React, { useState } from "react"; +import { Alert, Form } from "react-bootstrap"; import { Link } from "react-router-dom"; export default function USCDBPage() { useSetSubheader(["Import Scores", "USC Database File"]); + + const [playtype, setPlaytype] = useState<"" | "Controller" | "Keyboard">(""); + return ( <> - - This method is intended for syncing up with existing scores. For new scores, you - should set up the USC IR, for automatic score + should set up the USC IR for automatic score uploading. + + { + if (playtype === "") { + throw new Error("Please select an input device."); + } + const { result, warnings } = convertUSCDb(dbs.db!, playtype); + return { results: result.scores.length > 0 ? [result] : [], warnings }; + }} + extraControls={ + + + Input Device + + + setPlaytype(e.target.value as "" | "Controller" | "Keyboard") + } + value={playtype} + > + + + + + + Keyboard and Controller players are on separate leaderboards. Selecting + the wrong one will cause problems. + + + } + extraValid={playtype !== ""} + fileInputs={[{ key: "db", label: "USC maps.db" }]} + name="USC Database Import" + /> + + - Note: If you submit a score on a chart that {TachiConfig.NAME} doesn't recognise, - you'll need to wait until atleast 2 other players submit scores for that chart - before it'll show up. This is to combat accidental IR spam. + If you submit a score on a chart that the server doesn't recognise, you'll need to + wait until at least 2 other players submit scores for that chart before it'll show + up. This is to combat accidental IR spam. ); diff --git a/typescript/client/src/components/imports/ImportSQLiteForm.tsx b/typescript/client/src/components/imports/ImportSQLiteForm.tsx new file mode 100644 index 000000000..5d81d5cdb --- /dev/null +++ b/typescript/client/src/components/imports/ImportSQLiteForm.tsx @@ -0,0 +1,271 @@ +import Divider from "#components/util/Divider"; +import useImport from "#components/util/import/useImport"; +import Loading from "#components/util/Loading"; +import { openDatabase } from "#util/db-converters/sql-loader"; +import React, { useState } from "react"; +import { Alert, Button, Form } from "react-bootstrap"; +import { type Database } from "sql.js"; + +import ImportStateRenderer from "./ImportStateRenderer"; + +export interface ConvertWarning { + message: string; + level: "error" | "warn"; +} + +export interface ConvertResult { + /** One or more Batch Manual documents to submit in sequence. */ + results: unknown[]; + warnings: ConvertWarning[]; +} + +export interface SQLiteFileInput { + /** Human-readable label shown above the file picker. */ + label: string; + /** Key used to look up the opened Database in the convert callback. */ + key: string; +} + +interface Props { + /** Heading for this import form. */ + name: string; + /** One file input (USC) or two (LR2 / Beatoraja). */ + fileInputs: SQLiteFileInput[]; + /** Optional extra controls rendered between file pickers and the import button + * (e.g. a playtype selector). Validity of those controls is signalled via + * `extraValid`; if omitted the import button doesn't wait for them. */ + extraControls?: React.ReactNode; + /** When `extraControls` is provided, set this to false to disable the import + * button until the controls have a valid value. */ + extraValid?: boolean; + /** + * Conversion function. Receives a map of `key → opened Database` and + * returns one or more Batch Manual documents plus any warnings. + * Called on the main thread – keep it reasonably fast (typical score DBs + * are a few thousand rows, so this should be fine). + */ + convert: (dbs: Record) => ConvertResult; +} + +type ConvertState = + | { message: string; phase: "error" } + | { phase: "idle" } + | { phase: "loading" } + | { phase: "preview"; results: unknown[]; warnings: ConvertWarning[] }; + +export default function ImportSQLiteForm({ + name, + fileInputs, + extraControls, + extraValid = true, + convert, +}: Props) { + // One File ref per declared file input + const [files, setFiles] = useState>(() => + Object.fromEntries(fileInputs.map((f) => [f.key, null])), + ); + + const [convertState, setConvertState] = useState({ phase: "idle" }); + const { importState: importState1, runImport: runImport1 } = useImport("/import/file", {}); + const { importState: importState2, runImport: runImport2 } = useImport("/import/file", {}); + + const allFilesProvided = fileInputs.every((f) => files[f.key] !== null); + const canConvert = + allFilesProvided && + extraValid && + convertState.phase !== "loading" && + importState1.state === "not_started" && + importState2.state === "not_started"; + + async function handleConvertAndImport() { + if (!allFilesProvided) { + return; + } + + setConvertState({ phase: "loading" }); + + const dbs: Record = {}; + try { + for (const { key } of fileInputs) { + dbs[key] = await openDatabase(files[key]!); + } + + const { results, warnings } = convert(dbs); + + if (results.length === 0) { + setConvertState({ + phase: "error", + message: "No importable scores were found in the database.", + }); + return; + } + + setConvertState({ phase: "preview", results, warnings }); + } catch (err) { + setConvertState({ + phase: "error", + message: err instanceof Error ? err.message : String(err), + }); + } finally { + for (const db of Object.values(dbs)) { + try { + db.close(); + } catch { + // ignore + } + } + } + } + + async function handleSubmit() { + if (convertState.phase !== "preview") { + return; + } + const { results } = convertState; + + const makeOptions = (body: unknown): RequestInit => { + const formData = new FormData(); + formData.append("importType", "file/batch-manual"); + formData.append( + "scoreData", + new Blob([JSON.stringify(body)], { type: "application/json" }), + "scores.json", + ); + return { + method: "POST", + headers: { "X-User-Intent": "true" }, + body: formData, + }; + }; + + if (results.length >= 1) { + await runImport1(makeOptions(results[0])); + } + if (results.length >= 2) { + await runImport2(makeOptions(results[1])); + } + } + + const bothDone = + importState1.state === "done" && + (importState2.state === "done" || importState2.state === "not_started"); + + return ( +
+

{name}

+ + + Processing your database file runs entirely in your browser. Depending on the number + of scores, this may use significant CPU for a few seconds. This is expected. + + {fileInputs.map(({ key, label }) => ( + + {label} + { + const file = e.target.files?.[0] ?? null; + setFiles((prev) => ({ ...prev, [key]: file })); + // Reset convert state when a file changes + setConvertState({ phase: "idle" }); + }} + type="file" + /> + + ))} + + {extraControls} + + {convertState.phase === "error" && ( + + Error: {convertState.phase === "error" && convertState.message} + + )} + + {convertState.phase === "preview" && ( + <> + + Ready to import +
    + {convertState.results.map((r, i) => { + const bm = r as { + meta: { game: string; playtype: string }; + scores: unknown[]; + }; + return ( +
  • + + {bm.meta.game} {bm.meta.playtype} + + : {bm.scores.length} score + {bm.scores.length !== 1 ? "s" : ""} +
  • + ); + })} +
+
+ {convertState.warnings.length > 0 && ( + + {convertState.warnings.length} warning(s): +
    + {convertState.warnings.map((w, i) => ( +
  • + {w.message} +
  • + ))} +
+
+ )} + + )} + +
+ {convertState.phase !== "preview" ? ( + + ) : !bothDone ? ( + + ) : null} +
+ + {importState1.state !== "not_started" && ( + <> + + + + )} + {importState2.state !== "not_started" && ( + <> + + + + )} +
+ ); +} diff --git a/typescript/client/src/util/db-converters/beatoraja.ts b/typescript/client/src/util/db-converters/beatoraja.ts new file mode 100644 index 000000000..ce1f76160 --- /dev/null +++ b/typescript/client/src/util/db-converters/beatoraja.ts @@ -0,0 +1,236 @@ +/** + * Port of tachi-import-scripts/src-tauri/src/backend/beatoraja.rs + * + * Converts Beatoraja score.db + chart.db into Batch Manual documents + * (one per gamemode: 7K, 14K) suitable for /ir/direct-manual/import. + */ + +import { type Database } from "sql.js"; + +import { queryAll, queryOne } from "./sql-loader"; + +const SERVICE = "tachi-import-scripts"; + +interface BeatorajaScoreRow { + sha256: string; + clear: number; + epg: number; + egr: number; + egd: number; + ebd: number; + epr: number; + ems: number; + lpg: number; + lgr: number; + lgd: number; + lbd: number; + lpr: number; + lms: number; + combo: number; + minbp: number; + random: number; + date: number; +} + +interface BeatorajaChartRow { + title: string; + subtitle: string; + feature: number; + mode: number; +} + +export interface BeatorajaConvertWarning { + message: string; + level: "error" | "warn"; +} + +interface BMSBatchManual { + meta: { + game: "bms"; + playtype: "7K" | "14K"; + service: string; + version: null; + }; + scores: unknown[]; + classes: Record; +} + +export interface BeatorajaConvertResult { + k7: BMSBatchManual | null; + k14: BMSBatchManual | null; + warnings: BeatorajaConvertWarning[]; +} + +// Chart feature bitflags (from beatoraja.rs) +const CHART_FEATURE_RANDOM = 0b100; + +function parseLamp(clear: number): string | null { + switch (clear) { + case 0: + return "NO PLAY"; + case 1: + return "FAILED"; + case 2: + case 3: + return "ASSIST CLEAR"; + case 4: + return "EASY CLEAR"; + case 5: + return "CLEAR"; + case 6: + return "HARD CLEAR"; + case 7: + return "EX HARD CLEAR"; + case 8: + case 9: + case 10: + return "FULL COMBO"; + default: + return null; + } +} + +function parseRandom(random: number): string | null { + switch (random) { + case 0: + return "NONRAN"; + case 1: + return "MIRROR"; + case 2: + return "RANDOM"; + case 3: + return "R-RANDOM"; + case 4: + return "S-RANDOM"; + default: + // H-Ran, Spiral, etc. — unfair, skip + return null; + } +} + +export function convertBeatorajaDb(scoreDb: Database, chartDb: Database): BeatorajaConvertResult { + // mode = 0 means BMS (not PMS) + const scoreRows = queryAll(scoreDb, "SELECT * FROM score WHERE mode = 0"); + + const scores7k: unknown[] = []; + const scores14k: unknown[] = []; + const warnings: BeatorajaConvertWarning[] = []; + + for (const score of scoreRows) { + const chart = queryOne( + chartDb, + "SELECT title, subtitle, feature, mode FROM song WHERE sha256 = ?", + [score.sha256], + ); + + if (!chart) { + warnings.push({ + level: "warn", + message: `Could not find a matching chart for sha256 ${score.sha256}. Skipping.`, + }); + continue; + } + + const name = `${chart.title} ${chart.subtitle}`.trim(); + + // Skip charts with #RANDOM declarations — results are non-deterministic + if (chart.feature & CHART_FEATURE_RANDOM) { + continue; + } + + if (chart.mode !== 7 && chart.mode !== 14) { + // Unknown/unsupported gamemode — silently skip + continue; + } + + const playtype = chart.mode === 7 ? "7K" : "14K"; + + const lamp = parseLamp(score.clear); + if (lamp === null) { + warnings.push({ + level: "warn", + message: `Skipping score on "${name}" — unknown lamp value ${score.clear}.`, + }); + continue; + } + + let random: string | null = null; + if (playtype === "7K") { + random = parseRandom(score.random); + if (random === null) { + warnings.push({ + level: "warn", + message: `Skipping score on "${name}" — invalid or unfair random option (${score.random}).`, + }); + continue; + } + } + + // Beatoraja stores i32::MAX or negative as "no BP data" + const bp = score.minbp === 2_147_483_647 || score.minbp < 0 ? null : score.minbp; + + const bmsScore = { + identifier: score.sha256, + matchType: "bmsChartHash", + score: (score.lpg + score.epg) * 2 + score.egr + score.lgr, + lamp, + comment: null, + timeAchieved: score.date * 1000, + optional: { + bp, + fast: score.egr + score.egd, + slow: score.lgr + score.lgd, + maxCombo: score.combo, + epg: score.epg, + egr: score.egr, + egd: score.egd, + ebd: score.ebd, + epr: score.epr, + lpg: score.lpg, + lgr: score.lgr, + lgd: score.lgd, + lbd: score.lbd, + lpr: score.lpr, + }, + scoreMeta: { + random, + inputDevice: null, + client: "lr2oraja", + gauge: null, + }, + judgements: { + pgreat: score.epg + score.lpg, + great: score.egr + score.lgr, + good: score.egd + score.lgd, + bad: score.ebd + score.lbd, + poor: score.epr + score.lpr + score.ems + score.lms, + }, + }; + + if (playtype === "7K") { + scores7k.push(bmsScore); + } else { + scores14k.push(bmsScore); + } + } + + return { + k7: + scores7k.length > 0 + ? { + meta: { game: "bms", playtype: "7K", service: SERVICE, version: null }, + scores: scores7k, + classes: {}, + } + : null, + k14: + scores14k.length > 0 + ? { + meta: { game: "bms", playtype: "14K", service: SERVICE, version: null }, + scores: scores14k, + classes: {}, + } + : null, + warnings, + }; +} diff --git a/typescript/client/src/util/db-converters/lr2.ts b/typescript/client/src/util/db-converters/lr2.ts new file mode 100644 index 000000000..c6b6ceff4 --- /dev/null +++ b/typescript/client/src/util/db-converters/lr2.ts @@ -0,0 +1,217 @@ +/** + * Port of tachi-import-scripts/src-tauri/src/backend/lr2.rs + * + * Converts LR2 score.db + chart.db into Batch Manual documents + * (one per gamemode: 7K, 14K) suitable for /ir/direct-manual/import. + */ + +import { type Database } from "sql.js"; + +import { queryAll, queryOne } from "./sql-loader"; + +const SERVICE = "tachi-import-scripts"; + +interface LR2ScoreRow { + hash: string; + clear: number; + perfect: number; + great: number; + good: number; + bad: number; + poor: number; + maxcombo: number; + minbp: number; + op_best: number; +} + +interface LR2ChartRow { + title: string; + subtitle: string | null; + mode: number | null; +} + +export interface LR2ConvertWarning { + message: string; + level: "error" | "warn"; +} + +interface BMSBatchManual { + meta: { + game: "bms"; + playtype: "7K" | "14K"; + service: string; + version: null; + }; + scores: unknown[]; + classes: Record; +} + +export interface LR2ConvertResult { + k7: BMSBatchManual | null; + k14: BMSBatchManual | null; + warnings: LR2ConvertWarning[]; +} + +function parseLamp(clear: number, name: string): string | null { + switch (clear) { + case 0: + return "NO PLAY"; + case 1: + return "FAILED"; + case 2: + return "EASY CLEAR"; + case 3: + return "CLEAR"; + case 4: + return "HARD CLEAR"; + case 5: + return "FULL COMBO"; + default: + return null; + } +} + +/** + * LR2 encodes the random option in the top digit of op_best. + * 0 = NONRAN, 1 = MIRROR, 2 = RANDOM, 3 = S-RANDOM + */ +function parseRandom(opBest: number): string | null { + if (opBest > 100) { + return null; + } + const tenths = Math.floor(opBest / 10); + switch (tenths) { + case 0: + return "NONRAN"; + case 1: + return "MIRROR"; + case 2: + return "RANDOM"; + case 3: + return "S-RANDOM"; + default: + return null; + } +} + +export function convertLR2Db(scoreDb: Database, chartDb: Database): LR2ConvertResult { + const scoreRows = queryAll(scoreDb, "SELECT * FROM score WHERE complete = 1"); + + const scores7k: unknown[] = []; + const scores14k: unknown[] = []; + const warnings: LR2ConvertWarning[] = []; + + for (const score of scoreRows) { + // MD5 hashes are 32 hex characters — skip anything that doesn't look like one + if (score.hash.length !== 32) { + continue; + } + + const chart = queryOne( + chartDb, + "SELECT title, subtitle, mode FROM song WHERE hash = ?", + [score.hash], + ); + + if (!chart) { + warnings.push({ + level: "warn", + message: `Could not find a matching chart for score hash ${score.hash}. Skipping.`, + }); + continue; + } + + const name = `${chart.title} ${chart.subtitle ?? ""}`.trim(); + + if (chart.mode !== 7 && chart.mode !== 14) { + // Unknown/unsupported gamemode — silently skip + continue; + } + + const playtype = chart.mode === 7 ? "7K" : "14K"; + + if (score.minbp < 0) { + // Likely autoscratch — skip + warnings.push({ + level: "warn", + message: `Skipping score on "${name}" — negative BP (${score.minbp}), probably autoscratch.`, + }); + continue; + } + + const lamp = parseLamp(score.clear, name); + if (lamp === null) { + warnings.push({ + level: "warn", + message: `Skipping score on "${name}" — unknown lamp value ${score.clear}.`, + }); + continue; + } + + let random: string | null = null; + if (playtype === "7K") { + random = parseRandom(score.op_best); + if (random === null) { + warnings.push({ + level: "warn", + message: `Skipping score on "${name}" — unknown random option ${score.op_best}.`, + }); + continue; + } + } + + const bmsScore = { + identifier: score.hash, + matchType: "bmsChartHash", + score: score.perfect * 2 + score.great, + lamp, + comment: null, + timeAchieved: null, + optional: { + bp: score.minbp, + maxCombo: score.maxcombo, + fast: null, + slow: null, + }, + scoreMeta: { + random, + inputDevice: null, + client: "LR2", + gauge: null, + }, + judgements: { + pgreat: score.perfect, + great: score.great, + good: score.good, + bad: score.bad, + poor: score.poor, + }, + }; + + if (playtype === "7K") { + scores7k.push(bmsScore); + } else { + scores14k.push(bmsScore); + } + } + + return { + k7: + scores7k.length > 0 + ? { + meta: { game: "bms", playtype: "7K", service: SERVICE, version: null }, + scores: scores7k, + classes: {}, + } + : null, + k14: + scores14k.length > 0 + ? { + meta: { game: "bms", playtype: "14K", service: SERVICE, version: null }, + scores: scores14k, + classes: {}, + } + : null, + warnings, + }; +} diff --git a/typescript/client/src/util/db-converters/sql-loader.ts b/typescript/client/src/util/db-converters/sql-loader.ts new file mode 100644 index 000000000..415d59745 --- /dev/null +++ b/typescript/client/src/util/db-converters/sql-loader.ts @@ -0,0 +1,49 @@ +import initSqlJs, { type BindParams, type Database, type SqlJsStatic } from "sql.js"; +// Vite resolves this to a hashed static asset URL at build time +import sqlWasmUrl from "sql.js/dist/sql-wasm.wasm?url"; + +let sqlJsPromise: Promise | null = null; + +export type { Database as SqlJsDatabase }; + +/** Lazily initialise sql.js once, returning the cached promise on subsequent calls. */ +export function getSqlJs(): Promise { + if (!sqlJsPromise) { + sqlJsPromise = initSqlJs({ + locateFile: () => sqlWasmUrl as string, + }); + } + return sqlJsPromise; +} + +/** Open a SQLite database from a browser File object. */ +export async function openDatabase(file: File): Promise { + const SQL = await getSqlJs(); + const buffer = await file.arrayBuffer(); + return new SQL.Database(new Uint8Array(buffer)); +} + +/** Pull a single row from a query, returns null if nothing found. */ +export function queryOne(db: Database, sql: string, params: BindParams = []): T | null { + const stmt = db.prepare(sql); + stmt.bind(params); + if (!stmt.step()) { + stmt.free(); + return null; + } + const row = stmt.getAsObject() as T; + stmt.free(); + return row; +} + +/** Pull all rows from a query. */ +export function queryAll(db: Database, sql: string, params: BindParams = []): T[] { + const results: T[] = []; + const stmt = db.prepare(sql); + stmt.bind(params); + while (stmt.step()) { + results.push(stmt.getAsObject() as T); + } + stmt.free(); + return results; +} diff --git a/typescript/client/src/util/db-converters/usc.ts b/typescript/client/src/util/db-converters/usc.ts new file mode 100644 index 000000000..a9134d916 --- /dev/null +++ b/typescript/client/src/util/db-converters/usc.ts @@ -0,0 +1,220 @@ +/** + * Port of tachi-import-scripts/src-tauri/src/backend/usc.rs + * + * Converts an unnamed_sdvx_clone (USC) maps.db into a Batch Manual document + * suitable for submission to /ir/direct-manual/import. + */ + +import { type Database } from "sql.js"; + +import { queryAll, queryOne } from "./sql-loader"; + +const SERVICE = "tachi-import-scripts"; + +const MIN_DB_VERSION = 19; +const MAX_DB_VERSION = 20; + +interface USCScoreRow { + score: number; + crit: number; + near: number; + miss: number; + gauge: number; + timestamp: number; + chart_hash: string; + window_perfect: number; + window_good: number; + window_hold: number; + window_miss: number; + window_slam: number; + gauge_type: number; + auto_flags: number; + mirror: boolean | number; + random: boolean | number; + early: number | null; + late: number | null; + combo: number | null; + title: string; + diff_shortname: string; +} + +const DEFAULT_WINDOWS = { perfect: 46, good: 150, hold: 150, miss: 300, slam: 84 }; +const LEGACY_WINDOWS = { perfect: 46, good: 92, hold: 138, miss: 250, slam: 84 }; +const BUGGED_WINDOWS = { perfect: 46, good: 92, hold: 138, miss: 300, slam: 84 }; + +type HitWindows = typeof DEFAULT_WINDOWS; + +function windowsEqual(a: HitWindows, b: HitWindows): boolean { + return ( + a.perfect === b.perfect && + a.good === b.good && + a.hold === b.hold && + a.miss === b.miss && + a.slam === b.slam + ); +} + +export interface USCConvertWarning { + message: string; + level: "error" | "warn"; +} + +function checkHitWindows(windows: HitWindows): { ok: boolean; warning?: USCConvertWarning } { + if (windowsEqual(windows, DEFAULT_WINDOWS) || windowsEqual(windows, LEGACY_WINDOWS)) { + return { ok: true }; + } + if (windowsEqual(windows, BUGGED_WINDOWS)) { + return { + ok: true, + warning: { + level: "warn", + message: + "Score detected with bugged hit windows. A game update caused the new hit windows to partially apply. You should go into settings and reset your hit windows.", + }, + }; + } + return { ok: false }; +} + +function getLamp(score: USCScoreRow): string { + if (score.score === 10_000_000) { + return "PERFECT ULTIMATE CHAIN"; + } + if (score.miss === 0) { + return "ULTIMATE CHAIN"; + } + if (score.gauge_type === 1) { + return score.gauge > 0 ? "EXCESSIVE CLEAR" : "FAILED"; + } + return score.gauge > 0.7 ? "CLEAR" : "FAILED"; +} + +function getNoteMod(mirror: boolean, random: boolean): string { + if (mirror && random) { + return "MIR-RAN"; + } + if (mirror) { + return "MIRROR"; + } + if (random) { + return "RANDOM"; + } + return "NORMAL"; +} + +export interface USCBatchManual { + meta: { + game: "usc"; + playtype: "Controller" | "Keyboard"; + service: string; + version: null; + }; + scores: unknown[]; + classes: Record; +} + +export function convertUSCDb( + db: Database, + playtype: "Controller" | "Keyboard", +): { result: USCBatchManual; warnings: USCConvertWarning[] } { + const versionRow = queryOne<{ version: number }>(db, "SELECT version FROM Database"); + if (!versionRow) { + throw new Error("Could not read version from Database table."); + } + + const version = versionRow.version; + + if (version < MIN_DB_VERSION) { + throw new Error( + `Your maps.db version is ${version}, which is below the minimum of ${MIN_DB_VERSION}. Update your game.`, + ); + } + if (version > MAX_DB_VERSION) { + throw new Error( + `Your maps.db version is ${version}, which is above the maximum supported version (${MAX_DB_VERSION}). Please report this so the tool can be updated.`, + ); + } + + const rows = queryAll( + db, + "SELECT * FROM Scores LEFT JOIN Charts ON Scores.chart_hash = Charts.hash", + ); + + const scores = []; + const warnings: USCConvertWarning[] = []; + + for (const row of rows) { + if (row.auto_flags !== 0) { + continue; + } + + const windows: HitWindows = { + perfect: row.window_perfect, + good: row.window_good, + hold: row.window_hold, + miss: row.window_miss, + slam: row.window_slam, + }; + + const { ok, warning } = checkHitWindows(windows); + if (warning) { + warnings.push(warning); + } + if (!ok) { + warnings.push({ + level: "error", + message: `Skipping score on "${row.title} [${row.diff_shortname}]" — non-standard hit windows detected.`, + }); + continue; + } + + const mirror = Boolean(row.mirror); + const random = Boolean(row.random); + + let gaugeMod: string; + if (row.gauge_type === 0) { + gaugeMod = "NORMAL"; + } else if (row.gauge_type === 1) { + gaugeMod = "HARD"; + } else { + warnings.push({ + level: "warn", + message: `Skipping score on "${row.title} [${row.diff_shortname}]" — unknown gauge type ${row.gauge_type}.`, + }); + continue; + } + + scores.push({ + identifier: row.chart_hash, + matchType: "uscChartHash", + score: row.score, + lamp: getLamp(row), + comment: null, + timeAchieved: row.timestamp * 1000, + optional: { + fast: row.early, + slow: row.late, + maxCombo: row.combo, + gauge: row.gauge * 100, + }, + scoreMeta: { + noteMod: getNoteMod(mirror, random), + gaugeMod, + }, + judgements: { + critical: row.crit, + near: row.near, + miss: row.miss, + }, + }); + } + + return { + result: { + meta: { game: "usc", playtype, service: SERVICE, version: null }, + scores, + classes: {}, + }, + warnings, + }; +}