mirror of
https://github.com/zkldi/Tachi.git
synced 2026-09-30 02:48:01 +03:00
feat: kill tis (#1528)
This commit is contained in:
@@ -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=="],
|
||||
|
||||
+3
-1
@@ -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",
|
||||
|
||||
@@ -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:"
|
||||
},
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<TISInfo name="LR2 Database" />
|
||||
<ImportSQLiteForm
|
||||
convert={(dbs) => {
|
||||
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/<username>.db" },
|
||||
{ key: "chart", label: "LR2 song.db" },
|
||||
]}
|
||||
name="LR2 Database Import"
|
||||
/>
|
||||
|
||||
<Divider />
|
||||
<Muted>
|
||||
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.
|
||||
</Muted>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<TISInfo name="LR2Oraja Database" />
|
||||
<ImportSQLiteForm
|
||||
convert={(dbs) => {
|
||||
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"
|
||||
/>
|
||||
|
||||
<Divider />
|
||||
<Muted>
|
||||
This method is intended for syncing up with existing scores. For new scores, you
|
||||
should set up the <Link to="/import/beatoraja-ir">LR2oraja IR</Link>, for automatic
|
||||
should set up the <Link to="/import/beatoraja-ir">LR2oraja IR</Link> for automatic
|
||||
score uploading.
|
||||
</Muted>
|
||||
<br />
|
||||
<Muted>
|
||||
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.
|
||||
</Muted>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<TISInfo name="USC Database" />
|
||||
<Divider />
|
||||
<Alert variant="info">
|
||||
This method is intended for syncing up with existing scores. For new scores, you
|
||||
should set up the <Link to="/import/usc-ir">USC IR</Link>, for automatic score
|
||||
should set up the <Link to="/import/usc-ir">USC IR</Link> for automatic score
|
||||
uploading.
|
||||
</Alert>
|
||||
|
||||
<ImportSQLiteForm
|
||||
convert={(dbs) => {
|
||||
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={
|
||||
<Form.Group>
|
||||
<Form.Label>
|
||||
<strong>Input Device</strong>
|
||||
</Form.Label>
|
||||
<Form.Select
|
||||
onChange={(e) =>
|
||||
setPlaytype(e.target.value as "" | "Controller" | "Keyboard")
|
||||
}
|
||||
value={playtype}
|
||||
>
|
||||
<option value="">Please select…</option>
|
||||
<option value="Controller">Controller</option>
|
||||
<option value="Keyboard">Keyboard</option>
|
||||
</Form.Select>
|
||||
<Form.Text className="text-muted">
|
||||
Keyboard and Controller players are on separate leaderboards. Selecting
|
||||
the wrong one will cause problems.
|
||||
</Form.Text>
|
||||
</Form.Group>
|
||||
}
|
||||
extraValid={playtype !== ""}
|
||||
fileInputs={[{ key: "db", label: "USC maps.db" }]}
|
||||
name="USC Database Import"
|
||||
/>
|
||||
|
||||
<Divider />
|
||||
<Muted>
|
||||
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.
|
||||
</Muted>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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<string, Database>) => 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<Record<string, File | null>>(() =>
|
||||
Object.fromEntries(fileInputs.map((f) => [f.key, null])),
|
||||
);
|
||||
|
||||
const [convertState, setConvertState] = useState<ConvertState>({ 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<string, Database> = {};
|
||||
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 (
|
||||
<div className="vstack gap-3">
|
||||
<h2 className="text-center">{name}</h2>
|
||||
|
||||
<Alert variant="warning">
|
||||
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.
|
||||
</Alert>
|
||||
{fileInputs.map(({ key, label }) => (
|
||||
<Form.Group key={key}>
|
||||
<Form.Label>{label}</Form.Label>
|
||||
<input
|
||||
accept=".db"
|
||||
className="form-control"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0] ?? null;
|
||||
setFiles((prev) => ({ ...prev, [key]: file }));
|
||||
// Reset convert state when a file changes
|
||||
setConvertState({ phase: "idle" });
|
||||
}}
|
||||
type="file"
|
||||
/>
|
||||
</Form.Group>
|
||||
))}
|
||||
|
||||
{extraControls}
|
||||
|
||||
{convertState.phase === "error" && (
|
||||
<Alert variant="danger">
|
||||
<strong>Error:</strong> {convertState.phase === "error" && convertState.message}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{convertState.phase === "preview" && (
|
||||
<>
|
||||
<Alert variant="info">
|
||||
<strong>Ready to import</strong>
|
||||
<ul className="mb-0 mt-1">
|
||||
{convertState.results.map((r, i) => {
|
||||
const bm = r as {
|
||||
meta: { game: string; playtype: string };
|
||||
scores: unknown[];
|
||||
};
|
||||
return (
|
||||
<li key={i}>
|
||||
<strong>
|
||||
{bm.meta.game} {bm.meta.playtype}
|
||||
</strong>
|
||||
: {bm.scores.length} score
|
||||
{bm.scores.length !== 1 ? "s" : ""}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</Alert>
|
||||
{convertState.warnings.length > 0 && (
|
||||
<Alert variant="warning">
|
||||
<strong>{convertState.warnings.length} warning(s):</strong>
|
||||
<ul className="mb-0 mt-1">
|
||||
{convertState.warnings.map((w, i) => (
|
||||
<li
|
||||
className={w.level === "error" ? "text-danger" : ""}
|
||||
key={i}
|
||||
>
|
||||
{w.message}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Alert>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="text-center">
|
||||
{convertState.phase !== "preview" ? (
|
||||
<Button
|
||||
disabled={!canConvert}
|
||||
onClick={handleConvertAndImport}
|
||||
variant="primary"
|
||||
>
|
||||
{convertState.phase === "loading" ? (
|
||||
<>
|
||||
<Loading /> Reading database…
|
||||
</>
|
||||
) : (
|
||||
"Read Database"
|
||||
)}
|
||||
</Button>
|
||||
) : !bothDone ? (
|
||||
<Button
|
||||
disabled={
|
||||
importState1.state === "waiting_init" ||
|
||||
importState1.state === "waiting_processing" ||
|
||||
importState2.state === "waiting_init" ||
|
||||
importState2.state === "waiting_processing"
|
||||
}
|
||||
onClick={handleSubmit}
|
||||
variant="success"
|
||||
>
|
||||
Submit Scores
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{importState1.state !== "not_started" && (
|
||||
<>
|
||||
<Divider />
|
||||
<ImportStateRenderer state={importState1} />
|
||||
</>
|
||||
)}
|
||||
{importState2.state !== "not_started" && (
|
||||
<>
|
||||
<Divider />
|
||||
<ImportStateRenderer state={importState2} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<string, never>;
|
||||
}
|
||||
|
||||
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<BeatorajaScoreRow>(scoreDb, "SELECT * FROM score WHERE mode = 0");
|
||||
|
||||
const scores7k: unknown[] = [];
|
||||
const scores14k: unknown[] = [];
|
||||
const warnings: BeatorajaConvertWarning[] = [];
|
||||
|
||||
for (const score of scoreRows) {
|
||||
const chart = queryOne<BeatorajaChartRow>(
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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<string, never>;
|
||||
}
|
||||
|
||||
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<LR2ScoreRow>(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<LR2ChartRow>(
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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<SqlJsStatic> | null = null;
|
||||
|
||||
export type { Database as SqlJsDatabase };
|
||||
|
||||
/** Lazily initialise sql.js once, returning the cached promise on subsequent calls. */
|
||||
export function getSqlJs(): Promise<SqlJsStatic> {
|
||||
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<Database> {
|
||||
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<T>(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<T>(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;
|
||||
}
|
||||
@@ -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<string, never>;
|
||||
}
|
||||
|
||||
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<USCScoreRow>(
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user