mirror of
https://github.com/zkldi/Tachi.git
synced 2026-10-01 19:38:13 +03:00
Completely refactor USC support to use Controller/Keyboard playtypes.
This commit is contained in:
Vendored
+10
-6
@@ -831,12 +831,16 @@ export const DatabaseSchemas: Record<Databases, ValidatorFunction> = {
|
||||
arcChartID: "?string",
|
||||
})
|
||||
),
|
||||
"charts-usc": prSchemaify(
|
||||
PR_ChartDocument("usc", "Single", {
|
||||
hashSHA1: p.or("string", ["string"]),
|
||||
isOfficial: "boolean",
|
||||
})
|
||||
),
|
||||
"charts-usc": (self) => {
|
||||
const playtype = getPlaytype("usc", self);
|
||||
|
||||
return prSchemaify(
|
||||
PR_ChartDocument("usc", playtype, {
|
||||
hashSHA1: p.or("string", ["string"]),
|
||||
isOfficial: "boolean",
|
||||
})
|
||||
)(self);
|
||||
},
|
||||
goals: prSchemaify({
|
||||
game: p.isIn(games),
|
||||
playtype: isValidPlaytype,
|
||||
|
||||
@@ -43,14 +43,27 @@ t.test("#CreateCalculatedData", async (t) => {
|
||||
);
|
||||
|
||||
const uscRes = await CreateCalculatedData(
|
||||
{ game: "usc", playtype: "Single" } as ScoreDocument,
|
||||
{ data: { isOfficial: false }, playtype: "Single" } as ChartDocument,
|
||||
{ game: "usc", playtype: "Controller" } as ScoreDocument,
|
||||
{ data: { isOfficial: false }, playtype: "Controller" } as ChartDocument,
|
||||
null,
|
||||
logger
|
||||
);
|
||||
|
||||
t.strictSame(uscRes, { VF6: null }, "Should return null if chart was not an official.");
|
||||
|
||||
const uscKbRes = await CreateCalculatedData(
|
||||
{ game: "usc", playtype: "Keyboard" } as ScoreDocument,
|
||||
{ data: { isOfficial: false }, playtype: "Keyboard" } as ChartDocument,
|
||||
null,
|
||||
logger
|
||||
);
|
||||
|
||||
t.strictSame(
|
||||
uscKbRes,
|
||||
{ VF6: null },
|
||||
"Should return null if chart was not an official (Keyboard)."
|
||||
);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
@@ -318,10 +331,10 @@ t.test("#CalculateDataForGamePT", (t) => {
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("usc:Single", async (t) => {
|
||||
t.test("usc:Controller", async (t) => {
|
||||
const res = await CalculateDataForGamePT(
|
||||
"usc",
|
||||
"Single",
|
||||
"Controller",
|
||||
Testing511SPA,
|
||||
TestingIIDXSPDryScore, // fake! this is an iidx score. but we're testing
|
||||
null,
|
||||
@@ -333,12 +346,32 @@ t.test("#CalculateDataForGamePT", (t) => {
|
||||
{
|
||||
VF6: "?number",
|
||||
},
|
||||
"Response should contain nulled keys for usc:Single"
|
||||
"Response should contain nulled keys for usc:Controller"
|
||||
);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("usc:Keyboard", async (t) => {
|
||||
const res = await CalculateDataForGamePT(
|
||||
"usc",
|
||||
"Keyboard",
|
||||
Testing511SPA,
|
||||
TestingIIDXSPDryScore, // fake! this is an iidx score. but we're testing
|
||||
null,
|
||||
logger
|
||||
);
|
||||
|
||||
prAssert(
|
||||
res,
|
||||
{
|
||||
VF6: "?number",
|
||||
},
|
||||
"Response should contain nulled keys for usc:Keyboard"
|
||||
);
|
||||
|
||||
t.end();
|
||||
});
|
||||
// t.test("jubeat:Single", async (t) => {
|
||||
// const res = await CalculateDataForGamePT(
|
||||
// "jubeat",
|
||||
|
||||
@@ -88,7 +88,8 @@ const CalculatedDataFunctions: CalculatedDataFunctions = {
|
||||
// Single: CalculateDataJubeat,
|
||||
// },
|
||||
usc: {
|
||||
Single: CalculateDataSDVXorUSC,
|
||||
Controller: CalculateDataSDVXorUSC,
|
||||
Keyboard: CalculateDataSDVXorUSC,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -187,9 +188,12 @@ function CalculateDataSDVXorUSC(
|
||||
dryScore: DryScore,
|
||||
chart: ChartDocument,
|
||||
logger: KtLogger
|
||||
): CalculatedData<"sdvx:Single" | "usc:Single"> {
|
||||
): CalculatedData<"sdvx:Single" | "usc:Keyboard" | "usc:Controller"> {
|
||||
// for usc, unofficial charts currently have no VF6 value.
|
||||
if (dryScore.game === "usc" && !(chart as ChartDocument<"usc:Single">).data.isOfficial) {
|
||||
if (
|
||||
dryScore.game === "usc" &&
|
||||
!(chart as ChartDocument<"usc:Controller" | "usc:Keyboard">).data.isOfficial
|
||||
) {
|
||||
return { VF6: null };
|
||||
}
|
||||
|
||||
|
||||
@@ -112,7 +112,15 @@ const CalculatedDataFunctions: CalculatedDataFunctions = {
|
||||
// }),
|
||||
// },
|
||||
usc: {
|
||||
Single: (scd) => {
|
||||
Controller: (scd) => {
|
||||
const VF6 = AvgBest10Map(scd, "VF6");
|
||||
|
||||
return {
|
||||
VF6,
|
||||
ProfileVF6: VF6 === null ? null : VF6 * 50,
|
||||
};
|
||||
},
|
||||
Keyboard: (scd) => {
|
||||
const VF6 = AvgBest10Map(scd, "VF6");
|
||||
|
||||
return {
|
||||
|
||||
@@ -28,18 +28,26 @@ t.test("#CalculateRatings", (t) => {
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Should return VF4 and VF5 for SDVX", async (t) => {
|
||||
t.test("Should return VF6 for SDVX", async (t) => {
|
||||
const res = await CalculateRatings("sdvx", "Single", 1, logger);
|
||||
|
||||
t.strictSame(res, { VF6: 0 }, "Should return VF4 and VF5 keys.");
|
||||
t.strictSame(res, { VF6: 0 }, "Should return VF6 keys.");
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Should return VF4 and VF5 for USC", async (t) => {
|
||||
const res = await CalculateRatings("usc", "Single", 1, logger);
|
||||
t.test("Should return VF6 for USC:Keyboard", async (t) => {
|
||||
const res = await CalculateRatings("usc", "Keyboard", 1, logger);
|
||||
|
||||
t.strictSame(res, { VF6: 0 }, "Should return VF4 and VF5 keys.");
|
||||
t.strictSame(res, { VF6: 0 }, "Should return VF6 keys.");
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Should return VF6 for USC:Controller", async (t) => {
|
||||
const res = await CalculateRatings("usc", "Controller", 1, logger);
|
||||
|
||||
t.strictSame(res, { VF6: 0 }, "Should return VF6 keys.");
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
@@ -87,7 +87,10 @@ const RatingFunctions: RatingFunctions = {
|
||||
}),
|
||||
},
|
||||
usc: {
|
||||
Single: async (g, p, u) => ({
|
||||
Keyboard: async (g, p, u) => ({
|
||||
VF6: await LazySumN("VF6", 50)(g, p, u),
|
||||
}),
|
||||
Controller: async (g, p, u) => ({
|
||||
VF6: await LazySumN("VF6", 50)(g, p, u),
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -20,10 +20,10 @@ import {
|
||||
import { ConverterFailure } from "../../framework/common/converter-failures";
|
||||
import { DryScore } from "../../framework/common/types";
|
||||
import { BeatorajaContext, BeatorajaScore } from "../ir/beatoraja/types";
|
||||
import { USCClientScore } from "server/router/ir/usc/types";
|
||||
import { IRUSCContext } from "../ir/usc/types";
|
||||
import { ClassHandler } from "../../framework/user-game-stats/types";
|
||||
import { KsHookSV3CScore } from "../ir/kshook-sv3c/types";
|
||||
import { USCClientScore } from "server/router/ir/usc/_playtype/types";
|
||||
|
||||
export interface ImportTypeDataMap {
|
||||
"file/eamusement-iidx-csv": IIDXEamusementCSVData;
|
||||
|
||||
@@ -5,8 +5,7 @@ import d from "deepmerge";
|
||||
import { uscChart, uscScore } from "test-utils/test-data";
|
||||
import CreateLogCtx from "lib/logger/logger";
|
||||
import ResetDBState from "test-utils/resets";
|
||||
|
||||
import { USCClientScore } from "server/router/ir/usc/types";
|
||||
import { USCClientScore } from "server/router/ir/usc/_playtype/types";
|
||||
|
||||
const logger = CreateLogCtx(__filename);
|
||||
|
||||
@@ -78,7 +77,7 @@ t.test("#DeriveNoteMod", (t) => {
|
||||
const dm = (p: Partial<USCClientScore>) =>
|
||||
ConverterIRUSC(
|
||||
d(uscScore, p),
|
||||
{ chartHash: uscChart.data.hashSHA1 as string },
|
||||
{ chartHash: uscChart.data.hashSHA1 as string, playtype: "Controller" },
|
||||
"ir/usc",
|
||||
logger
|
||||
);
|
||||
|
||||
@@ -1,16 +1,4 @@
|
||||
import { USCClientScore } from "server/router/ir/usc/types";
|
||||
import { FindSongOnID } from "utils/queries/songs";
|
||||
import { KtLogger } from "lib/logger/logger";
|
||||
import {
|
||||
InternalFailure,
|
||||
InvalidScoreFailure,
|
||||
KTDataNotFoundFailure,
|
||||
} from "../../../framework/common/converter-failures";
|
||||
import { GenericGetGradeAndPercent } from "../../../framework/common/score-utils";
|
||||
import { IRUSCContext } from "./types";
|
||||
import { Lamps } from "tachi-common";
|
||||
import { ConverterFunction } from "../../common/types";
|
||||
import { DryScore } from "../../../framework/common/types";
|
||||
import db from "external/mongo/db";
|
||||
import {
|
||||
USC_DEFAULT_HOLD,
|
||||
USC_DEFAULT_MISS,
|
||||
@@ -18,7 +6,19 @@ import {
|
||||
USC_DEFAULT_PERFECT,
|
||||
USC_DEFAULT_SLAM,
|
||||
} from "lib/constants/usc-ir";
|
||||
import db from "external/mongo/db";
|
||||
import { KtLogger } from "lib/logger/logger";
|
||||
import { USCClientScore } from "server/router/ir/usc/_playtype/types";
|
||||
import { Lamps } from "tachi-common";
|
||||
import { FindSongOnID } from "utils/queries/songs";
|
||||
import {
|
||||
InternalFailure,
|
||||
InvalidScoreFailure,
|
||||
KTDataNotFoundFailure,
|
||||
} from "../../../framework/common/converter-failures";
|
||||
import { GenericGetGradeAndPercent } from "../../../framework/common/score-utils";
|
||||
import { DryScore } from "../../../framework/common/types";
|
||||
import { ConverterFunction } from "../../common/types";
|
||||
import { IRUSCContext } from "./types";
|
||||
|
||||
/**
|
||||
* Interprets the "note mod" used based on the USC score.
|
||||
@@ -38,7 +38,10 @@ export function DeriveNoteMod(data: USCClientScore): "NORMAL" | "MIRROR" | "RAND
|
||||
/**
|
||||
* Determines the lamp of a USC score.
|
||||
*/
|
||||
export function DeriveLamp(scoreDoc: USCClientScore, logger: KtLogger): Lamps["usc:Single"] {
|
||||
export function DeriveLamp(
|
||||
scoreDoc: USCClientScore,
|
||||
logger: KtLogger
|
||||
): Lamps["usc:Keyboard" | "usc:Controller"] {
|
||||
if (scoreDoc.score === 10_000_000) {
|
||||
return "PERFECT ULTIMATE CHAIN";
|
||||
} else if (scoreDoc.error === 0) {
|
||||
@@ -78,6 +81,7 @@ export const ConverterIRUSC: ConverterFunction<USCClientScore, IRUSCContext> = a
|
||||
|
||||
const chartDoc = await db.charts.usc.findOne({
|
||||
"data.hashSHA1": context.chartHash,
|
||||
playtype: context.playtype,
|
||||
});
|
||||
|
||||
if (!chartDoc) {
|
||||
@@ -98,7 +102,7 @@ export const ConverterIRUSC: ConverterFunction<USCClientScore, IRUSCContext> = a
|
||||
|
||||
const { grade, percent } = GenericGetGradeAndPercent("usc", data.score, chartDoc);
|
||||
|
||||
const dryScore: DryScore<"usc:Single"> = {
|
||||
const dryScore: DryScore<"usc:Controller" | "usc:Keyboard"> = {
|
||||
comment: null,
|
||||
game: "usc",
|
||||
importType,
|
||||
|
||||
@@ -10,6 +10,7 @@ t.test("#ParseIRUSC", (t) => {
|
||||
const res = ParseIRUSC(
|
||||
{ score: uscScore } as unknown as Record<string, unknown>,
|
||||
uscChart.data.hashSHA1 as string,
|
||||
"Controller",
|
||||
logger
|
||||
);
|
||||
|
||||
@@ -25,7 +26,7 @@ t.test("#ParseIRUSC", (t) => {
|
||||
});
|
||||
|
||||
t.test("Should reject empty bodies", (t) => {
|
||||
t.throws(() => ParseIRUSC({}, uscChart.data.hashSHA1 as string, logger), {
|
||||
t.throws(() => ParseIRUSC({}, uscChart.data.hashSHA1 as string, "Controller", logger), {
|
||||
statusCode: 400,
|
||||
message: /invalid usc score/iu,
|
||||
});
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { KtLogger } from "lib/logger/logger";
|
||||
import p, { PrudenceSchema } from "prudence";
|
||||
import { USCClientScore } from "server/router/ir/usc/types";
|
||||
import { FormatPrError } from "utils/prudence";
|
||||
import ScoreImportFatalError from "../../../framework/score-importing/score-import-error";
|
||||
import { ParserFunctionReturns } from "../../common/types";
|
||||
import { IRUSCContext } from "./types";
|
||||
import { Playtypes } from "tachi-common";
|
||||
import { USCClientScore } from "server/router/ir/usc/_playtype/types";
|
||||
|
||||
const PR_USCIRScore: PrudenceSchema = {
|
||||
score: p.isBoundedInteger(0, 10_000_000),
|
||||
@@ -24,6 +25,7 @@ const PR_USCIRScore: PrudenceSchema = {
|
||||
export function ParseIRUSC(
|
||||
body: Record<string, unknown>,
|
||||
chartHash: string,
|
||||
playtype: Playtypes["usc"],
|
||||
logger: KtLogger
|
||||
): ParserFunctionReturns<USCClientScore, IRUSCContext> {
|
||||
const err = p(
|
||||
@@ -40,6 +42,7 @@ export function ParseIRUSC(
|
||||
return {
|
||||
context: {
|
||||
chartHash,
|
||||
playtype,
|
||||
},
|
||||
game: "usc",
|
||||
iterable: [body.score] as USCClientScore[],
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { Playtypes } from "tachi-common";
|
||||
|
||||
export interface IRUSCContext {
|
||||
chartHash: string;
|
||||
playtype: Playtypes["usc"];
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import { ParseEagSDVX } from "./api/eag-sdvx/parser";
|
||||
import { ParseFloIIDX } from "./api/flo-iidx/parser";
|
||||
import { ParseFloSDVX } from "./api/flo-sdvx/parser";
|
||||
import { ParseMinSDVX } from "./api/min-sdvx/parser";
|
||||
import { ParseBatchManualFromObject } from "./common/batch-manual/parser";
|
||||
import ParseBatchManual from "./file/batch-manual/parser";
|
||||
import ParseEamusementIIDXCSV from "./file/eamusement-iidx-csv/parser";
|
||||
import { ParseMerIIDX } from "./file/mer-iidx/parser";
|
||||
|
||||
@@ -39,22 +39,23 @@ function TestAuth(url: string) {
|
||||
});
|
||||
}
|
||||
|
||||
t.test("GET /ir/usc", async (t) => {
|
||||
// Due to how this works, as long as these tests pass, the two IRs work identically.
|
||||
t.test("GET /ir/usc/Keyboard", async (t) => {
|
||||
t.beforeEach(ResetDBState);
|
||||
|
||||
await db["api-tokens"].insert({
|
||||
userID: 1,
|
||||
identifier: "USC Token",
|
||||
permissions: {
|
||||
submit_score: true,
|
||||
},
|
||||
token: "foo",
|
||||
token: "bar",
|
||||
fromAPIClient: null,
|
||||
});
|
||||
|
||||
t.beforeEach(ResetDBState);
|
||||
TestAuth("/ir/usc/Controller");
|
||||
|
||||
TestAuth("/ir/usc");
|
||||
|
||||
const res = await mockApi.get("/ir/usc").set("Authorization", "Bearer foo");
|
||||
const res = await mockApi.get("/ir/usc/Controller").set("Authorization", "Bearer bar");
|
||||
|
||||
t.equal(res.body.statusCode, 20, "Should return 20");
|
||||
t.match(
|
||||
@@ -69,13 +70,43 @@ t.test("GET /ir/usc", async (t) => {
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("GET /ir/usc/charts/:chartHash", (t) => {
|
||||
t.test("GET /ir/usc/Controller", async (t) => {
|
||||
await db["api-tokens"].insert({
|
||||
userID: 1,
|
||||
identifier: "USC Token",
|
||||
permissions: {
|
||||
submit_score: true,
|
||||
},
|
||||
token: "foo",
|
||||
fromAPIClient: null,
|
||||
});
|
||||
|
||||
t.beforeEach(ResetDBState);
|
||||
|
||||
TestAuth("/ir/usc/Controller");
|
||||
|
||||
const res = await mockApi.get("/ir/usc/Controller").set("Authorization", "Bearer foo");
|
||||
|
||||
t.equal(res.body.statusCode, 20, "Should return 20");
|
||||
t.match(
|
||||
res.body.body,
|
||||
{
|
||||
serverName: /tachi/iu,
|
||||
irVersion: /^[0-9]\.[0-9]\.[0-9](-a)?$/iu,
|
||||
},
|
||||
"Should return the right body."
|
||||
);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("GET /ir/usc/Controller/charts/:chartHash", (t) => {
|
||||
t.beforeEach(ResetDBState);
|
||||
t.beforeEach(InsertFakeUSCAuth);
|
||||
|
||||
t.test("Should return 20 if the chartHash matches a chart.", async (t) => {
|
||||
const res = await mockApi
|
||||
.get("/ir/usc/charts/USC_CHART_HASH")
|
||||
.get("/ir/usc/Controller/charts/USC_CHART_HASH")
|
||||
.set("Authorization", "Bearer foo");
|
||||
|
||||
t.equal(res.body.statusCode, 20, "Should return 20");
|
||||
@@ -85,7 +116,7 @@ t.test("GET /ir/usc/charts/:chartHash", (t) => {
|
||||
|
||||
t.test("Should return 44 if the chartHash doesn't match a chart.", async (t) => {
|
||||
const res = await mockApi
|
||||
.get("/ir/usc/charts/INVALID_HASH")
|
||||
.get("/ir/usc/Controller/charts/INVALID_HASH")
|
||||
.set("Authorization", "Bearer foo");
|
||||
|
||||
t.equal(res.body.statusCode, 44, "Should return 44");
|
||||
@@ -105,7 +136,7 @@ const USC_SCORE_PB: PBScoreDocument = {
|
||||
songID: 1,
|
||||
userID: 1,
|
||||
timeAchieved: 0,
|
||||
playtype: "Single",
|
||||
playtype: "Controller",
|
||||
game: "usc",
|
||||
highlight: false,
|
||||
composedFrom: {
|
||||
@@ -135,14 +166,14 @@ const USC_SCORE_PB: PBScoreDocument = {
|
||||
},
|
||||
};
|
||||
|
||||
t.test("GET /ir/usc/:chartHash/record", (t) => {
|
||||
t.test("GET /ir/usc/Controller/:chartHash/record", (t) => {
|
||||
t.beforeEach(ResetDBState);
|
||||
t.beforeEach(InsertFakeUSCAuth);
|
||||
TestAuth("/ir/usc/:chartHash/record");
|
||||
TestAuth("/ir/usc/Controller/:chartHash/record");
|
||||
|
||||
t.test("Should return 44 if the chartHash doesn't match a chart.", async (t) => {
|
||||
const res = await mockApi
|
||||
.get("/ir/usc/charts/INVALID_HASH/record")
|
||||
.get("/ir/usc/Controller/charts/INVALID_HASH/record")
|
||||
.set("Authorization", "Bearer foo");
|
||||
|
||||
t.equal(res.body.statusCode, 44, "Should return 44");
|
||||
@@ -152,7 +183,7 @@ t.test("GET /ir/usc/:chartHash/record", (t) => {
|
||||
|
||||
t.test("Should return 44 if there are no scores on the chart.", async (t) => {
|
||||
const res = await mockApi
|
||||
.get("/ir/usc/charts/USC_CHART_HASH/record")
|
||||
.get("/ir/usc/Controller/charts/USC_CHART_HASH/record")
|
||||
.set("Authorization", "Bearer foo");
|
||||
|
||||
t.equal(res.body.statusCode, 44, "Should return 44");
|
||||
@@ -175,7 +206,7 @@ t.test("GET /ir/usc/:chartHash/record", (t) => {
|
||||
} as ScoreDocument);
|
||||
|
||||
const res = await mockApi
|
||||
.get("/ir/usc/charts/USC_CHART_HASH/record")
|
||||
.get("/ir/usc/Controller/charts/USC_CHART_HASH/record")
|
||||
.set("Authorization", "Bearer foo");
|
||||
|
||||
t.equal(res.body.statusCode, 20, "Should return 20");
|
||||
@@ -206,17 +237,17 @@ t.test("GET /ir/usc/:chartHash/record", (t) => {
|
||||
t.test("GET /charts/:chartHash/leaderboard", (t) => {
|
||||
t.beforeEach(ResetDBState);
|
||||
t.beforeEach(InsertFakeUSCAuth);
|
||||
TestAuth("/ir/usc/:chartHash/leaderboard");
|
||||
TestAuth("/ir/usc/Controller/:chartHash/leaderboard");
|
||||
|
||||
t.test("Should return 40 if mode is invalid", async (t) => {
|
||||
const res = await mockApi
|
||||
.get("/ir/usc/charts/USC_CHART_HASH/leaderboard")
|
||||
.get("/ir/usc/Controller/charts/USC_CHART_HASH/leaderboard")
|
||||
.set("Authorization", "Bearer foo");
|
||||
|
||||
t.equal(res.body.statusCode, 40);
|
||||
|
||||
const res2 = await mockApi
|
||||
.get("/ir/usc/charts/USC_CHART_HASH/leaderboard?mode=invalid")
|
||||
.get("/ir/usc/Controller/charts/USC_CHART_HASH/leaderboard?mode=invalid")
|
||||
.set("Authorization", "Bearer foo");
|
||||
|
||||
t.equal(res2.body.statusCode, 40);
|
||||
@@ -226,13 +257,13 @@ t.test("GET /charts/:chartHash/leaderboard", (t) => {
|
||||
|
||||
t.test("Should return 40 if N is invalid", async (t) => {
|
||||
const res = await mockApi
|
||||
.get("/ir/usc/charts/USC_CHART_HASH/leaderboard?mode=best")
|
||||
.get("/ir/usc/Controller/charts/USC_CHART_HASH/leaderboard?mode=best")
|
||||
.set("Authorization", "Bearer foo");
|
||||
|
||||
t.equal(res.body.statusCode, 40);
|
||||
|
||||
const res2 = await mockApi
|
||||
.get("/ir/usc/charts/USC_CHART_HASH/leaderboard?mode=best&n=foo")
|
||||
.get("/ir/usc/Controller/charts/USC_CHART_HASH/leaderboard?mode=best&n=foo")
|
||||
.set("Authorization", "Bearer foo");
|
||||
|
||||
t.equal(res2.body.statusCode, 40);
|
||||
@@ -242,7 +273,7 @@ t.test("GET /charts/:chartHash/leaderboard", (t) => {
|
||||
|
||||
t.test("Should return empty arr for mode = best if no scores", async (t) => {
|
||||
const res = await mockApi
|
||||
.get("/ir/usc/charts/USC_CHART_HASH/leaderboard?mode=best&n=5")
|
||||
.get("/ir/usc/Controller/charts/USC_CHART_HASH/leaderboard?mode=best&n=5")
|
||||
.set("Authorization", "Bearer foo");
|
||||
|
||||
t.equal(res.body.statusCode, 20);
|
||||
@@ -282,7 +313,7 @@ t.test("GET /charts/:chartHash/leaderboard", (t) => {
|
||||
] as ScoreDocument[]);
|
||||
|
||||
const res = await mockApi
|
||||
.get("/ir/usc/charts/USC_CHART_HASH/leaderboard?mode=best&n=2")
|
||||
.get("/ir/usc/Controller/charts/USC_CHART_HASH/leaderboard?mode=best&n=2")
|
||||
.set("Authorization", "Bearer foo");
|
||||
|
||||
t.equal(res.body.statusCode, 20);
|
||||
@@ -318,7 +349,7 @@ t.test("GET /charts/:chartHash/leaderboard", (t) => {
|
||||
);
|
||||
|
||||
const res2 = await mockApi
|
||||
.get("/ir/usc/charts/USC_CHART_HASH/leaderboard?mode=best&n=1")
|
||||
.get("/ir/usc/Controller/charts/USC_CHART_HASH/leaderboard?mode=best&n=1")
|
||||
.set("Authorization", "Bearer foo");
|
||||
|
||||
t.equal(res2.body.statusCode, 20);
|
||||
@@ -362,7 +393,7 @@ t.test("POST /replays", (t) => {
|
||||
const replayFile = GetKTDataBuffer("./usc/replayfile.urf");
|
||||
|
||||
const res = await mockApi
|
||||
.post("/ir/usc/replays")
|
||||
.post("/ir/usc/Controller/replays")
|
||||
.field("identifier", "MOCK_IDENTIFIER")
|
||||
.attach("replay", replayFile, "replay.urf")
|
||||
.set("Authorization", "Bearer foo");
|
||||
@@ -392,7 +423,7 @@ t.test("POST /replays", (t) => {
|
||||
const replayFile = GetKTDataBuffer("./usc/replayfile.urf");
|
||||
|
||||
const res = await mockApi
|
||||
.post("/ir/usc/replays")
|
||||
.post("/ir/usc/Controller/replays")
|
||||
.attach("replay", replayFile, "replay.urf")
|
||||
.set("Authorization", "Bearer foo");
|
||||
|
||||
@@ -416,7 +447,7 @@ t.test("POST /replays", (t) => {
|
||||
// const replayFile = GetKTDataBuffer("./usc/replayfile.urf");
|
||||
|
||||
const res = await mockApi
|
||||
.post("/ir/usc/replays")
|
||||
.post("/ir/usc/Controller/replays")
|
||||
.field("identifier", "MOCK_IDENTIFIER")
|
||||
// .attach("replay", replayFile, "replay.urf")
|
||||
.set("Authorization", "Bearer foo");
|
||||
@@ -441,7 +472,7 @@ t.test("POST /replays", (t) => {
|
||||
const replayFile = GetKTDataBuffer("./usc/replayfile.urf");
|
||||
|
||||
const res = await mockApi
|
||||
.post("/ir/usc/replays")
|
||||
.post("/ir/usc/Controller/replays")
|
||||
.field("identifier", "INVALID_IDENTIFIER")
|
||||
.attach("replay", replayFile, "replay.urf")
|
||||
.set("Authorization", "Bearer foo");
|
||||
@@ -508,7 +539,7 @@ t.test("POST /scores", (t) => {
|
||||
|
||||
t.test("Should submit a score from a valid request.", async (t) => {
|
||||
const res = await mockApi
|
||||
.post("/ir/usc/scores")
|
||||
.post("/ir/usc/Controller/scores")
|
||||
.set("Authorization", "Bearer token")
|
||||
.send(validRequest);
|
||||
|
||||
@@ -535,7 +566,7 @@ t.test("POST /scores", (t) => {
|
||||
|
||||
t.test("Should orphan a score and return 22 if chart has never been seen.", async (t) => {
|
||||
const res = await mockApi
|
||||
.post("/ir/usc/scores")
|
||||
.post("/ir/usc/Controller/scores")
|
||||
.set("Authorization", "Bearer token")
|
||||
.send(
|
||||
deepmerge(validRequest, {
|
||||
@@ -614,7 +645,7 @@ t.test("POST /scores", (t) => {
|
||||
] as PublicUserDocument[]);
|
||||
|
||||
const res = await mockApi
|
||||
.post("/ir/usc/scores")
|
||||
.post("/ir/usc/Controller/scores")
|
||||
.set("Authorization", "Bearer token")
|
||||
.send(
|
||||
deepmerge(validRequest, {
|
||||
@@ -627,7 +658,7 @@ t.test("POST /scores", (t) => {
|
||||
t.equal(res.body.statusCode, 22);
|
||||
|
||||
const res2 = await mockApi
|
||||
.post("/ir/usc/scores")
|
||||
.post("/ir/usc/Controller/scores")
|
||||
.set("Authorization", "Bearer token2")
|
||||
.send(
|
||||
deepmerge(validRequest, {
|
||||
@@ -646,7 +677,7 @@ t.test("POST /scores", (t) => {
|
||||
t.strictSame(orphanData?.userIDs, [1, 2]);
|
||||
|
||||
const res3 = await mockApi
|
||||
.post("/ir/usc/scores")
|
||||
.post("/ir/usc/Controller/scores")
|
||||
.set("Authorization", "Bearer token3")
|
||||
.send(
|
||||
deepmerge(validRequest, {
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
ImportDocument,
|
||||
PBScoreDocument,
|
||||
SuccessfulAPIResponse,
|
||||
Playtypes,
|
||||
} from "tachi-common";
|
||||
import { FormatPrError } from "utils/prudence";
|
||||
import { GetBlacklist } from "utils/queries/blacklist";
|
||||
@@ -30,7 +31,6 @@ import {
|
||||
TachiScoreToServerScore,
|
||||
USCChartIndexToDiff,
|
||||
} from "./usc";
|
||||
|
||||
const logger = CreateLogCtx(__filename);
|
||||
|
||||
const router: Router = Router({ mergeParams: true });
|
||||
@@ -81,6 +81,17 @@ const ValidateUSCRequest: RequestHandler = async (req, res, next) => {
|
||||
return next();
|
||||
};
|
||||
|
||||
router.use((req, res, next) => {
|
||||
if (req.params.playtype !== "Keyboard" && req.params.playtype !== "Controller") {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
description: "Invalid playtype. Expected Keyboard or Controller.",
|
||||
});
|
||||
}
|
||||
|
||||
return next();
|
||||
});
|
||||
|
||||
router.use(ValidateUSCRequest);
|
||||
// This is an implementation of the USCIR spec as per https://uscir.readthedocs.io.
|
||||
// This specification always returns 200 OK, regardless of whether the result was okay
|
||||
@@ -105,10 +116,15 @@ router.get("/", (req, res) =>
|
||||
);
|
||||
|
||||
const RetrieveChart: RequestHandler = async (req, res, next) => {
|
||||
const chart = await db.charts.usc.findOne({ "data.hashSHA1": req.params.chartHash });
|
||||
const chart = await db.charts.usc.findOne({
|
||||
"data.hashSHA1": req.params.chartHash,
|
||||
playtype: req.params.playtype as Playtypes["usc"],
|
||||
});
|
||||
|
||||
AssignToReqTachiData(req, {
|
||||
uscChartDoc: (chart ?? undefined) as ChartDocument<"usc:Single"> | undefined,
|
||||
uscChartDoc: (chart ?? undefined) as
|
||||
| ChartDocument<"usc:Controller" | "usc:Keyboard">
|
||||
| undefined,
|
||||
});
|
||||
|
||||
return next();
|
||||
@@ -157,7 +173,7 @@ router.get("/charts/:chartHash/record", RetrieveChart, async (req, res) => {
|
||||
const serverRecord = (await db["personal-bests"].findOne({
|
||||
chartID: chart.chartID,
|
||||
"rankingData.rank": 1,
|
||||
})) as PBScoreDocument<"usc:Single"> | null;
|
||||
})) as PBScoreDocument<"usc:Controller" | "usc:Keyboard"> | null;
|
||||
|
||||
if (!serverRecord) {
|
||||
return res.status(200).json({
|
||||
@@ -231,7 +247,7 @@ router.get("/charts/:chartHash/leaderboard", RetrieveChart, async (req, res) =>
|
||||
},
|
||||
limit: n,
|
||||
}
|
||||
)) as PBScoreDocument<"usc:Single">[];
|
||||
)) as PBScoreDocument<"usc:Controller" | "usc:Keyboard">[];
|
||||
|
||||
const serverScores = await Promise.all(bestScores.map(TachiScoreToServerScore));
|
||||
|
||||
@@ -259,6 +275,8 @@ const PR_USCIRChartDoc = {
|
||||
* @name POST /ir/usc/:playtype/scores
|
||||
*/
|
||||
router.post("/scores", RequirePermissions("submit_score"), async (req, res) => {
|
||||
const playtype = req.params.playtype as Playtypes["usc"];
|
||||
|
||||
const chartErr = p(
|
||||
req.body.chart,
|
||||
PR_USCIRChartDoc,
|
||||
@@ -280,21 +298,22 @@ router.post("/scores", RequirePermissions("submit_score"), async (req, res) => {
|
||||
|
||||
let chartDoc = (await db.charts.usc.findOne({
|
||||
"data.hashSHA1": uscChart.chartHash,
|
||||
})) as ChartDocument<"usc:Single"> | null;
|
||||
playtype,
|
||||
})) as ChartDocument<"usc:Controller" | "usc:Keyboard"> | null;
|
||||
|
||||
// If the chart doesn't exist, call HandleOrphanQueue.
|
||||
// If this chart has never been seen before, orphan it.
|
||||
// If this chart is already orphaned, increase its unique player
|
||||
// playcount.
|
||||
if (!chartDoc) {
|
||||
const { song, chart } = ConvertUSCChart(uscChart);
|
||||
const { song, chart } = ConvertUSCChart(uscChart, playtype);
|
||||
|
||||
const uscChartName = `${uscChart.artist} - ${uscChart.title} (${USCChartIndexToDiff(
|
||||
uscChart.difficulty
|
||||
)})`;
|
||||
|
||||
chartDoc = await HandleOrphanQueue(
|
||||
"usc:Single",
|
||||
`usc:${playtype}` as const,
|
||||
"usc",
|
||||
chart,
|
||||
song,
|
||||
@@ -310,6 +329,7 @@ router.post("/scores", RequirePermissions("submit_score"), async (req, res) => {
|
||||
const blacklist = await GetBlacklist();
|
||||
const scoresToDeorphan = await db["orphan-scores"].find({
|
||||
"context.chartHash": chartDoc.data.hashSHA1,
|
||||
"context.playtype": playtype,
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
@@ -323,6 +343,7 @@ router.post("/scores", RequirePermissions("submit_score"), async (req, res) => {
|
||||
const importRes = await ExpressWrappedScoreImportMain(userID, false, "ir/usc", [
|
||||
req.body,
|
||||
uscChart.chartHash,
|
||||
playtype,
|
||||
]);
|
||||
|
||||
// If this was an orphan chart request, return ACCEPTED,
|
||||
@@ -391,7 +412,6 @@ router.post(
|
||||
|
||||
const correspondingScore = await db.scores.findOne({
|
||||
userID: req[SYMBOL_TachiAPIAuth]!.userID!,
|
||||
game: "usc",
|
||||
scoreID: req.body.identifier,
|
||||
});
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import t from "tap";
|
||||
import ResetDBState from "test-utils/resets";
|
||||
import { CreatePOSTScoresResponseBody, TachiScoreToServerScore } from "./usc";
|
||||
|
||||
const mockScorePB: PBScoreDocument<"usc:Single"> = {
|
||||
const mockScorePB: PBScoreDocument<"usc:Controller" | "usc:Keyboard"> = {
|
||||
chartID: "USC_CHART_ID",
|
||||
calculatedData: {
|
||||
VF6: 0,
|
||||
@@ -17,7 +17,7 @@ const mockScorePB: PBScoreDocument<"usc:Single"> = {
|
||||
game: "usc",
|
||||
highlight: false,
|
||||
isPrimary: true,
|
||||
playtype: "Single",
|
||||
playtype: "Controller",
|
||||
rankingData: {
|
||||
outOf: 2,
|
||||
rank: 1,
|
||||
@@ -240,7 +240,9 @@ const mockUserDocs = [2, 3, 4, 5, 6, 7, 8, 9, 10].map((e) => ({
|
||||
t.test("#CreatePOSTScoresResponseBody", async (t) => {
|
||||
t.beforeEach(ResetDBState);
|
||||
|
||||
const chartDoc = (await db.charts.usc.findOne()) as ChartDocument<"usc:Single">;
|
||||
const chartDoc = (await db.charts.usc.findOne()) as ChartDocument<
|
||||
"usc:Controller" | "usc:Keyboard"
|
||||
>;
|
||||
|
||||
t.test("Should correctly return POSTScoresResponseBody", async (t) => {
|
||||
await db.scores.insert(mockScoreDocument);
|
||||
@@ -257,7 +259,7 @@ t.test("#CreatePOSTScoresResponseBody", async (t) => {
|
||||
|
||||
const res = await CreatePOSTScoresResponseBody(
|
||||
1,
|
||||
chartDoc as ChartDocument<"usc:Single">,
|
||||
chartDoc as ChartDocument<"usc:Controller" | "usc:Keyboard">,
|
||||
"USER_1_SCORE_PB"
|
||||
);
|
||||
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
import db from "external/mongo/db";
|
||||
import { USCIR_ADJACENT_SCORE_N } from "lib/constants/usc-ir";
|
||||
import CreateLogCtx from "lib/logger/logger";
|
||||
import { ChartDocument, integer, PBScoreDocument, ScoreDocument, SongDocument } from "tachi-common";
|
||||
import {
|
||||
ChartDocument,
|
||||
integer,
|
||||
PBScoreDocument,
|
||||
Playtypes,
|
||||
ScoreDocument,
|
||||
SongDocument,
|
||||
} from "tachi-common";
|
||||
import { MStoS, Random20Hex } from "utils/misc";
|
||||
import { GetPBOnChart, GetServerRecordOnChart } from "utils/scores";
|
||||
import { USCClientChart, USCServerScore } from "./types";
|
||||
|
||||
const logger = CreateLogCtx(__filename);
|
||||
|
||||
export const TACHI_LAMP_TO_USC: Record<
|
||||
PBScoreDocument<"usc:Single">["scoreData"]["lamp"],
|
||||
PBScoreDocument<"usc:Controller" | "usc:Keyboard">["scoreData"]["lamp"],
|
||||
USCServerScore["lamp"]
|
||||
> = {
|
||||
// we don't do NO PLAY, so its not handled.
|
||||
@@ -27,7 +33,7 @@ export const TACHI_LAMP_TO_USC: Record<
|
||||
* fields are null.
|
||||
*/
|
||||
export async function TachiScoreToServerScore(
|
||||
tachiScore: PBScoreDocument<"usc:Single">
|
||||
tachiScore: PBScoreDocument<"usc:Controller" | "usc:Keyboard">
|
||||
): Promise<USCServerScore> {
|
||||
// @optimisable
|
||||
// Repeated calls to this may pre-emptively provide usernames
|
||||
@@ -54,7 +60,7 @@ export async function TachiScoreToServerScore(
|
||||
|
||||
const scorePB = (await db.scores.findOne({
|
||||
scoreID: tachiScore.composedFrom.scorePB,
|
||||
})) as ScoreDocument<"usc:Single"> | null;
|
||||
})) as ScoreDocument<"usc:Controller" | "usc:Keyboard"> | null;
|
||||
|
||||
if (!scorePB) {
|
||||
logger.severe(
|
||||
@@ -82,13 +88,12 @@ export async function TachiScoreToServerScore(
|
||||
|
||||
export async function CreatePOSTScoresResponseBody(
|
||||
userID: integer,
|
||||
chartDoc: ChartDocument<"usc:Single">,
|
||||
chartDoc: ChartDocument<"usc:Controller" | "usc:Keyboard">,
|
||||
scoreID: string
|
||||
): Promise<POSTScoresResponseBody> {
|
||||
const scorePB = (await GetPBOnChart(
|
||||
userID,
|
||||
chartDoc.chartID
|
||||
)) as PBScoreDocument<"usc:Single"> | null;
|
||||
const scorePB = (await GetPBOnChart(userID, chartDoc.chartID)) as PBScoreDocument<
|
||||
"usc:Controller" | "usc:Keyboard"
|
||||
> | null;
|
||||
|
||||
if (!scorePB) {
|
||||
logger.severe(`Score was imported for chart, but no ScorePB was available on this chart?`, {
|
||||
@@ -100,9 +105,9 @@ export async function CreatePOSTScoresResponseBody(
|
||||
);
|
||||
}
|
||||
|
||||
const ktServerRecord = (await GetServerRecordOnChart(
|
||||
chartDoc.chartID
|
||||
)) as PBScoreDocument<"usc:Single"> | null;
|
||||
const ktServerRecord = (await GetServerRecordOnChart(chartDoc.chartID)) as PBScoreDocument<
|
||||
"usc:Controller" | "usc:Keyboard"
|
||||
> | null;
|
||||
|
||||
// this is impossible to trigger without making a race-condition.
|
||||
/* istanbul ignore next */
|
||||
@@ -133,7 +138,7 @@ export async function CreatePOSTScoresResponseBody(
|
||||
limit: USCIR_ADJACENT_SCORE_N,
|
||||
sort: { "rankingData.rank": -1 },
|
||||
}
|
||||
)) as PBScoreDocument<"usc:Single">[];
|
||||
)) as PBScoreDocument<"usc:Controller" | "usc:Keyboard">[];
|
||||
|
||||
// The specification enforces that we return them in
|
||||
// ascending order, though, so we reverse this after
|
||||
@@ -159,7 +164,7 @@ export async function CreatePOSTScoresResponseBody(
|
||||
limit: USCIR_ADJACENT_SCORE_N,
|
||||
sort: { "rankingData.rank": 1 },
|
||||
}
|
||||
)) as PBScoreDocument<"usc:Single">[];
|
||||
)) as PBScoreDocument<"usc:Controller" | "usc:Keyboard">[];
|
||||
|
||||
const [score, serverRecord, adjacentAbove, adjacentBelow] = await Promise.all([
|
||||
TachiScoreToServerScore(scorePB),
|
||||
@@ -170,7 +175,7 @@ export async function CreatePOSTScoresResponseBody(
|
||||
|
||||
const originalScore = (await db.scores.findOne({
|
||||
scoreID,
|
||||
})) as ScoreDocument<"usc:Single">;
|
||||
})) as ScoreDocument<"usc:Controller" | "usc:Keyboard">;
|
||||
|
||||
if (!originalScore) {
|
||||
logger.severe(
|
||||
@@ -202,14 +207,14 @@ export interface POSTScoresResponseBody {
|
||||
sendReplay: string;
|
||||
}
|
||||
|
||||
export function ConvertUSCChart(uscChartDoc: USCClientChart) {
|
||||
const chart: ChartDocument<"usc:Single"> = {
|
||||
export function ConvertUSCChart(uscChartDoc: USCClientChart, playtype: Playtypes["usc"]) {
|
||||
const chart: ChartDocument<"usc:Controller" | "usc:Keyboard"> = {
|
||||
chartID: Random20Hex(),
|
||||
difficulty: USCChartIndexToDiff(uscChartDoc.difficulty),
|
||||
isPrimary: true,
|
||||
level: "?",
|
||||
levelNum: 0,
|
||||
playtype: "Single",
|
||||
playtype,
|
||||
rgcID: null,
|
||||
songID: 0,
|
||||
versions: [],
|
||||
@@ -234,6 +239,6 @@ export function ConvertUSCChart(uscChartDoc: USCClientChart) {
|
||||
|
||||
export function USCChartIndexToDiff(
|
||||
index: 0 | 1 | 2 | 3
|
||||
): ChartDocument<"usc:Single">["difficulty"] {
|
||||
): ChartDocument<"usc:Controller" | "usc:Keyboard">["difficulty"] {
|
||||
return (["NOV", "ADV", "EXH", "INF"] as const)[index];
|
||||
}
|
||||
|
||||
@@ -3,17 +3,6 @@ import uscirRouter from "./_playtype/router";
|
||||
|
||||
const router: Router = Router({ mergeParams: true });
|
||||
|
||||
router.use((req, res, next) => {
|
||||
if (req.params.playtype !== "Keyboard" && req.params.playtype !== "Controller") {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
description: "Invalid playtype. Expected Keyboard or Controller.",
|
||||
});
|
||||
}
|
||||
|
||||
return next();
|
||||
});
|
||||
|
||||
router.use("/:playtype", uscirRouter);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"chartID": "USC_CHART_ID",
|
||||
"difficulty": "NOV",
|
||||
"songID": 1,
|
||||
"playtype": "Single",
|
||||
"playtype": "Controller",
|
||||
"levelNum": 1,
|
||||
"level": "1",
|
||||
"flags": {
|
||||
|
||||
@@ -20,8 +20,8 @@ import {
|
||||
} from "lib/constants/usc-ir";
|
||||
import { DryScore } from "lib/score-import/framework/common/types";
|
||||
import { BarbatosScore } from "lib/score-import/import-types/ir/barbatos/types";
|
||||
import { USCClientScore } from "../server/router/ir/usc/types";
|
||||
import { KsHookSV3CScore } from "lib/score-import/import-types/ir/kshook-sv3c/types";
|
||||
import { USCClientScore } from "server/router/ir/usc/_playtype/types";
|
||||
|
||||
const file = (name: string) => path.join(__dirname, "/test-data", name);
|
||||
|
||||
@@ -200,12 +200,12 @@ export const barbScore: BarbatosScore = {
|
||||
song_id: 1,
|
||||
};
|
||||
|
||||
export const uscChart: ChartDocument<"usc:Single"> = {
|
||||
export const uscChart: ChartDocument<"usc:Controller" | "usc:Keyboard"> = {
|
||||
rgcID: null,
|
||||
chartID: "USC_CHART_ID",
|
||||
difficulty: "NOV",
|
||||
songID: 1,
|
||||
playtype: "Single",
|
||||
playtype: "Keyboard",
|
||||
levelNum: 1,
|
||||
level: "1",
|
||||
data: {
|
||||
|
||||
@@ -11,7 +11,7 @@ export async function GetAdjacentAbove(userPB: PBScoreDocument, size = 5) {
|
||||
limit: size,
|
||||
sort: { "rankingData.rank": -1 },
|
||||
}
|
||||
)) as PBScoreDocument<"usc:Single">[];
|
||||
)) as PBScoreDocument<"usc:Controller" | "usc:Keyboard">[];
|
||||
|
||||
return adjAbove;
|
||||
}
|
||||
@@ -26,7 +26,7 @@ export async function GetAdjacentBelow(userPB: PBScoreDocument, size = 5) {
|
||||
limit: size,
|
||||
sort: { "rankingData.rank": 1 },
|
||||
}
|
||||
)) as PBScoreDocument<"usc:Single">[];
|
||||
)) as PBScoreDocument<"usc:Controller" | "usc:Keyboard">[];
|
||||
|
||||
return adjAbove;
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ export type EmptyObject = Record<string, never>;
|
||||
* Data that may be monkey-patched onto req.tachi. This holds things such as middleware results.
|
||||
*/
|
||||
export interface TachiRequestData {
|
||||
uscChartDoc?: ChartDocument<"usc:Single">;
|
||||
uscChartDoc?: ChartDocument<"usc:Controller" | "usc:Keyboard">;
|
||||
|
||||
beatorajaChartDoc?: ChartDocument<"bms:7K" | "bms:14K">;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user