mirror of
https://github.com/zkldi/Tachi.git
synced 2026-09-29 18:37:58 +03:00
Merge pull request #426 from TNG-dev:zkldi/issue-399-Split-USC-into-two-playtypes----Controller-and-Keyboard
Split USC into Controller/Keyboard.
This commit is contained in:
+1
-1
@@ -82,7 +82,7 @@
|
||||
"rimraf": "3.0.2",
|
||||
"safe-json-stringify": "1.2.0",
|
||||
"seq-logging": "^1.1.1",
|
||||
"tachi-common": "0.2.46",
|
||||
"tachi-common": "0.3.0",
|
||||
"ts-node": "10.0.0",
|
||||
"typescript": "4.3.4",
|
||||
"winston": "3.3.3",
|
||||
|
||||
Generated
+4
-4
@@ -63,7 +63,7 @@ specifiers:
|
||||
safe-json-stringify: 1.2.0
|
||||
seq-logging: ^1.1.1
|
||||
supertest: 6.1.3
|
||||
tachi-common: 0.2.46
|
||||
tachi-common: 0.3.0
|
||||
tap: 15.0.9
|
||||
ts-node: 10.0.0
|
||||
tsconfig-paths: 3.10.1
|
||||
@@ -108,7 +108,7 @@ dependencies:
|
||||
rimraf: 3.0.2
|
||||
safe-json-stringify: 1.2.0
|
||||
seq-logging: 1.1.1
|
||||
tachi-common: 0.2.46
|
||||
tachi-common: 0.3.0
|
||||
ts-node: 10.0.0_83f53b0a0c5616d3fa00ed4e30b9ce1b
|
||||
typescript: 4.3.4
|
||||
winston: 3.3.3
|
||||
@@ -4960,8 +4960,8 @@ packages:
|
||||
strip-ansi: 6.0.0
|
||||
dev: true
|
||||
|
||||
/tachi-common/0.2.46:
|
||||
resolution: {integrity: sha512-BTI3j6joaHLh+T/gapHw5lO9BlB9jSCDtaLIECHkEjY0pUBnXcbcA4GEeMjefXUD65hF04BNkg6J7R6X6Cnkmw==}
|
||||
/tachi-common/0.3.0:
|
||||
resolution: {integrity: sha512-ch7Msau97/on1d18bqRMUNUQhF9ZixzvNxMsLJNN14DBfS1AVxaoAfIRiQzWQ1jdfvwmcpP4Ge0x1w1prPZVAQ==}
|
||||
dependencies:
|
||||
monk: 7.3.4
|
||||
transitivePeerDependencies:
|
||||
|
||||
@@ -99,10 +99,15 @@ async function GenericUpsert<T>(
|
||||
}
|
||||
|
||||
const syncInstructions: SyncInstructions[] = [
|
||||
{
|
||||
pattern: /^charts-(usc|bms)$/u,
|
||||
handler: (charts: ChartDocument[], collection: ICollection<ChartDocument>, logger) =>
|
||||
GenericUpsert(charts, collection, "chartID", logger, false),
|
||||
},
|
||||
{
|
||||
pattern: /^charts-/u,
|
||||
handler: (charts: ChartDocument[], collection: ICollection<ChartDocument>, logger) =>
|
||||
GenericUpsert(charts, collection, "chartID", logger),
|
||||
GenericUpsert(charts, collection, "chartID", logger, true),
|
||||
},
|
||||
{
|
||||
pattern: /^songs-/u,
|
||||
|
||||
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";
|
||||
|
||||
+203
-32
@@ -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, {
|
||||
@@ -684,5 +715,145 @@ t.test("POST /scores", (t) => {
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Should maintain separate orphan queues for the separate playtypes.", async (t) => {
|
||||
await db["api-tokens"].insert([
|
||||
{
|
||||
userID: 2,
|
||||
identifier: "token2",
|
||||
permissions: { submit_score: true },
|
||||
token: "token2",
|
||||
fromAPIClient: null,
|
||||
},
|
||||
{
|
||||
userID: 3,
|
||||
identifier: "token3",
|
||||
permissions: { submit_score: true },
|
||||
token: "token3",
|
||||
fromAPIClient: null,
|
||||
},
|
||||
{
|
||||
userID: 4,
|
||||
identifier: "token4",
|
||||
permissions: { submit_score: true },
|
||||
token: "token4",
|
||||
fromAPIClient: null,
|
||||
},
|
||||
]);
|
||||
|
||||
await db.users.insert([
|
||||
{
|
||||
id: 2,
|
||||
username: "foo",
|
||||
usernameLowercase: "foo",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
username: "bar",
|
||||
usernameLowercase: "bar",
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
username: "baz",
|
||||
usernameLowercase: "baz",
|
||||
},
|
||||
] as PublicUserDocument[]);
|
||||
|
||||
const res = await mockApi
|
||||
.post("/ir/usc/Controller/scores")
|
||||
.set("Authorization", "Bearer token")
|
||||
.send(
|
||||
deepmerge(validRequest, {
|
||||
chart: {
|
||||
chartHash: "NEW_CHART",
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
t.equal(res.body.statusCode, 22);
|
||||
|
||||
const res2 = await mockApi
|
||||
.post("/ir/usc/Controller/scores")
|
||||
.set("Authorization", "Bearer token2")
|
||||
.send(
|
||||
deepmerge(validRequest, {
|
||||
chart: {
|
||||
chartHash: "NEW_CHART",
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
t.equal(res2.body.statusCode, 22);
|
||||
|
||||
const orphanData = await db["orphan-chart-queue"].findOne({
|
||||
"chartDoc.data.hashSHA1": "NEW_CHART",
|
||||
idString: "usc:Controller",
|
||||
});
|
||||
|
||||
t.strictSame(orphanData?.userIDs, [1, 2]);
|
||||
|
||||
const res3 = await mockApi
|
||||
.post("/ir/usc/Keyboard/scores")
|
||||
.set("Authorization", "Bearer token3")
|
||||
.send(
|
||||
deepmerge(validRequest, {
|
||||
chart: {
|
||||
chartHash: "NEW_CHART",
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
t.equal(res3.body.statusCode, 22);
|
||||
|
||||
const orphanData2 = await db["orphan-chart-queue"].findOne({
|
||||
"chartDoc.data.hashSHA1": "NEW_CHART",
|
||||
idString: "usc:Controller",
|
||||
});
|
||||
|
||||
t.strictSame(
|
||||
orphanData2?.userIDs,
|
||||
[1, 2],
|
||||
"Should not have added userID 3 to the list of userIDs."
|
||||
);
|
||||
|
||||
const res4 = await mockApi
|
||||
.post("/ir/usc/Controller/scores")
|
||||
.set("Authorization", "Bearer token4")
|
||||
.send(
|
||||
deepmerge(validRequest, {
|
||||
chart: {
|
||||
chartHash: "NEW_CHART",
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
t.equal(res4.body.statusCode, 20);
|
||||
|
||||
const orphanData3 = await db["orphan-chart-queue"].findOne({
|
||||
"chartDoc.data.hashSHA1": "NEW_CHART",
|
||||
idString: "usc:Controller",
|
||||
});
|
||||
|
||||
t.equal(orphanData3, null, "Should have removed the orphan chart from the database.");
|
||||
|
||||
const score = await db.scores.findOne({
|
||||
game: "usc",
|
||||
userID: 4,
|
||||
});
|
||||
|
||||
t.hasStrict(score, {
|
||||
scoreData: {
|
||||
score: 9_000_000,
|
||||
percent: 90,
|
||||
lamp: "FAILED",
|
||||
},
|
||||
scoreMeta: {
|
||||
noteMod: "MIRROR",
|
||||
gaugeMod: "NORMAL",
|
||||
},
|
||||
});
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.end();
|
||||
});
|
||||
@@ -0,0 +1,449 @@
|
||||
import { RequestHandler, Router } from "express";
|
||||
import db from "external/mongo/db";
|
||||
import { CDNStoreOrOverwrite } from "lib/cdn/cdn";
|
||||
import { GetUSCIRReplayURL } from "lib/cdn/url-format";
|
||||
import { ONE_MEGABYTE } from "lib/constants/filesize";
|
||||
import { SYMBOL_TachiAPIAuth, SYMBOL_TachiData } from "lib/constants/tachi";
|
||||
import { USCIR_MAX_LEADERBOARD_N } from "lib/constants/usc-ir";
|
||||
import CreateLogCtx from "lib/logger/logger";
|
||||
import { HandleOrphanQueue } from "lib/orphan-queue/orphan-queue";
|
||||
import { AssertStrAsPositiveNonZeroInt } from "lib/score-import/framework/common/string-asserts";
|
||||
import { ExpressWrappedScoreImportMain } from "lib/score-import/framework/express-wrapper";
|
||||
import { ReprocessOrphan } from "lib/score-import/framework/orphans/orphans";
|
||||
import { ServerConfig, TachiConfig } from "lib/setup/config";
|
||||
import p from "prudence";
|
||||
import { RequirePermissions } from "server/middleware/auth";
|
||||
import { CreateMulterSingleUploadMiddleware } from "server/middleware/multer-upload";
|
||||
import {
|
||||
ChartDocument,
|
||||
ImportDocument,
|
||||
PBScoreDocument,
|
||||
SuccessfulAPIResponse,
|
||||
Playtypes,
|
||||
} from "tachi-common";
|
||||
import { FormatPrError } from "utils/prudence";
|
||||
import { GetBlacklist } from "utils/queries/blacklist";
|
||||
import { AssignToReqTachiData } from "utils/req-tachi-data";
|
||||
import { USCClientChart } from "./types";
|
||||
import {
|
||||
ConvertUSCChart,
|
||||
CreatePOSTScoresResponseBody,
|
||||
TachiScoreToServerScore,
|
||||
USCChartIndexToDiff,
|
||||
} from "./usc";
|
||||
const logger = CreateLogCtx(__filename);
|
||||
|
||||
const router: Router = Router({ mergeParams: true });
|
||||
|
||||
enum STATUS_CODES {
|
||||
UNAUTH = 41,
|
||||
CHART_REFUSE = 42,
|
||||
FORBIDDEN = 43,
|
||||
NOT_FOUND = 44,
|
||||
SERVER_ERROR = 50,
|
||||
SUCCESS = 20,
|
||||
ACCEPTED = 22,
|
||||
BAD_REQ = 40,
|
||||
}
|
||||
|
||||
const ValidateUSCRequest: RequestHandler = async (req, res, next) => {
|
||||
const token = req.header("Authorization");
|
||||
|
||||
if (!token) {
|
||||
return res.status(200).json({
|
||||
statusCode: STATUS_CODES.BAD_REQ,
|
||||
description: "No auth token provided.",
|
||||
});
|
||||
}
|
||||
|
||||
const splitToken = token.split(" ");
|
||||
|
||||
if (splitToken.length !== 2 || splitToken[0] !== "Bearer") {
|
||||
return res.status(200).json({
|
||||
statusCode: STATUS_CODES.BAD_REQ,
|
||||
description: "Invalid Authorization Header. Expected Bearer <token>",
|
||||
});
|
||||
}
|
||||
|
||||
const uscAuthDoc = await db["api-tokens"].findOne({
|
||||
token: splitToken[1],
|
||||
});
|
||||
|
||||
if (!uscAuthDoc) {
|
||||
return res.status(200).json({
|
||||
statusCode: STATUS_CODES.UNAUTH,
|
||||
description: "Unauthorized.",
|
||||
});
|
||||
}
|
||||
|
||||
req[SYMBOL_TachiAPIAuth] = uscAuthDoc;
|
||||
|
||||
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
|
||||
// as the HTTP code is used to determine whether the server received the request properly,
|
||||
// rather than the result of the request.
|
||||
|
||||
/**
|
||||
* Used to check your connection to the server, and receive some basic information.
|
||||
* https://uscir.readthedocs.io/en/latest/endpoints/heartbeat.html
|
||||
* @name GET /ir/usc/:playtype
|
||||
*/
|
||||
router.get("/", (req, res) =>
|
||||
res.status(200).json({
|
||||
statusCode: STATUS_CODES.SUCCESS,
|
||||
description: "IR Request Successful.",
|
||||
body: {
|
||||
serverTime: Math.floor(Date.now() / 1000),
|
||||
serverName: TachiConfig.NAME,
|
||||
irVersion: "0.3.1-a",
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const RetrieveChart: RequestHandler = async (req, res, next) => {
|
||||
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:Controller" | "usc:Keyboard">
|
||||
| undefined,
|
||||
});
|
||||
|
||||
return next();
|
||||
};
|
||||
|
||||
/**
|
||||
* Used to check if the server will accept a score for a given chart in advance of submitting it.
|
||||
* https://uscir.readthedocs.io/en/latest/endpoints/chart-charthash.html
|
||||
* @name GET /ir/usc/:playtype/charts/:chartHash
|
||||
*/
|
||||
router.get("/charts/:chartHash", RetrieveChart, (req, res) => {
|
||||
const chart = req[SYMBOL_TachiData]!.uscChartDoc;
|
||||
|
||||
if (!chart) {
|
||||
return res.status(200).json({
|
||||
statusCode: STATUS_CODES.NOT_FOUND,
|
||||
description: "This chart is not available on the IR yet, more people need to play it!",
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(200).json({
|
||||
statusCode: STATUS_CODES.SUCCESS,
|
||||
description: "This chart is tracked by the IR.",
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Used to retrieve the current server record for the chart with the specified hash.
|
||||
* https://uscir.readthedocs.io/en/latest/endpoints/record.html
|
||||
* @name GET /ir/usc/:playtype/charts/:chartHash/record
|
||||
*/
|
||||
router.get("/charts/:chartHash/record", RetrieveChart, async (req, res) => {
|
||||
const chart = req[SYMBOL_TachiData]!.uscChartDoc;
|
||||
|
||||
if (!chart) {
|
||||
return res.status(200).json({
|
||||
statusCode: STATUS_CODES.NOT_FOUND,
|
||||
description: `This IR doesn't have any record data yet, or ${
|
||||
ServerConfig.USC_QUEUE_SIZE
|
||||
} ${
|
||||
ServerConfig.USC_QUEUE_SIZE === 1 ? "person has" : "people have"
|
||||
} not played the chart yet.`,
|
||||
});
|
||||
}
|
||||
|
||||
const serverRecord = (await db["personal-bests"].findOne({
|
||||
chartID: chart.chartID,
|
||||
"rankingData.rank": 1,
|
||||
})) as PBScoreDocument<"usc:Controller" | "usc:Keyboard"> | null;
|
||||
|
||||
if (!serverRecord) {
|
||||
return res.status(200).json({
|
||||
statusCode: STATUS_CODES.NOT_FOUND,
|
||||
description: "No server record found.",
|
||||
});
|
||||
}
|
||||
|
||||
const serverScore = await TachiScoreToServerScore(serverRecord);
|
||||
|
||||
return res.status(200).json({
|
||||
statusCode: STATUS_CODES.SUCCESS,
|
||||
description: "Retrieved score.",
|
||||
body: { record: serverScore },
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Used to retrieve some particular useful subset of the scores from the server.
|
||||
* https://uscir.readthedocs.io/en/latest/endpoints/leaderboard.html
|
||||
* @name GET /ir/usc/:playtype/charts/:chartHash/leaderboard
|
||||
*/
|
||||
router.get("/charts/:chartHash/leaderboard", RetrieveChart, async (req, res) => {
|
||||
const chart = req[SYMBOL_TachiData]!.uscChartDoc!;
|
||||
|
||||
if (!(typeof req.query.mode === "string" && ["best", "rivals"].includes(req.query.mode))) {
|
||||
return res.status(200).json({
|
||||
statusCode: STATUS_CODES.BAD_REQ,
|
||||
description: `Invalid 'mode' param - expected 'best' or 'rivals'.`,
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof req.query.n !== "string") {
|
||||
return res.status(200).json({
|
||||
statusCode: STATUS_CODES.BAD_REQ,
|
||||
description: `Invalid 'n' param - expected a positive non-zero integer less than or equal to ${USCIR_MAX_LEADERBOARD_N}.`,
|
||||
});
|
||||
}
|
||||
|
||||
let n;
|
||||
|
||||
try {
|
||||
n = AssertStrAsPositiveNonZeroInt(req.query.n, "Invalid 'N' param.");
|
||||
} catch (err) {
|
||||
return res.status(200).json({
|
||||
statusCode: STATUS_CODES.BAD_REQ,
|
||||
description: `Invalid 'n' param - expected a positive non-zero integer less than or equal to ${USCIR_MAX_LEADERBOARD_N}.`,
|
||||
});
|
||||
}
|
||||
|
||||
if (n >= USCIR_MAX_LEADERBOARD_N) {
|
||||
n = USCIR_MAX_LEADERBOARD_N;
|
||||
}
|
||||
|
||||
const mode = req.query.mode as "best" | "rivals";
|
||||
|
||||
if (mode === "rivals") {
|
||||
return res.status(200).json({
|
||||
statusCode: STATUS_CODES.BAD_REQ,
|
||||
description: "This is currently unsupported.",
|
||||
});
|
||||
}
|
||||
|
||||
const bestScores = (await db["personal-bests"].find(
|
||||
{
|
||||
chartID: chart.chartID,
|
||||
},
|
||||
{
|
||||
sort: {
|
||||
"scoreData.perecent": -1,
|
||||
},
|
||||
limit: n,
|
||||
}
|
||||
)) as PBScoreDocument<"usc:Controller" | "usc:Keyboard">[];
|
||||
|
||||
const serverScores = await Promise.all(bestScores.map(TachiScoreToServerScore));
|
||||
|
||||
return res.status(200).json({
|
||||
statusCode: STATUS_CODES.SUCCESS,
|
||||
description: `Returned ${serverScores.length} scores.`,
|
||||
body: serverScores,
|
||||
});
|
||||
});
|
||||
|
||||
const PR_USCIRChartDoc = {
|
||||
chartHash: "string",
|
||||
artist: "string",
|
||||
title: "string",
|
||||
level: p.isBoundedInteger(1, 20),
|
||||
difficulty: p.isBoundedInteger(0, 3),
|
||||
effector: "string",
|
||||
illustrator: "string",
|
||||
bpm: "string",
|
||||
};
|
||||
|
||||
/**
|
||||
* Sends a score to the server.
|
||||
* https://uscir.readthedocs.io/en/latest/endpoints/score-submit.html
|
||||
* @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,
|
||||
{},
|
||||
{
|
||||
throwOnNonObject: false,
|
||||
allowExcessKeys: true,
|
||||
}
|
||||
);
|
||||
|
||||
if (chartErr) {
|
||||
return res.status(200).json({
|
||||
statusCode: STATUS_CODES.BAD_REQ,
|
||||
description: FormatPrError(chartErr, "Invalid chart."),
|
||||
});
|
||||
}
|
||||
|
||||
const uscChart = req.body.chart as USCClientChart;
|
||||
|
||||
let chartDoc = (await db.charts.usc.findOne({
|
||||
"data.hashSHA1": uscChart.chartHash,
|
||||
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, playtype);
|
||||
|
||||
const uscChartName = `${uscChart.artist} - ${uscChart.title} (${USCChartIndexToDiff(
|
||||
uscChart.difficulty
|
||||
)})`;
|
||||
|
||||
chartDoc = await HandleOrphanQueue(
|
||||
`usc:${playtype}` as const,
|
||||
"usc",
|
||||
chart,
|
||||
song,
|
||||
{
|
||||
"chartDoc.data.hashSHA1": uscChart.chartHash,
|
||||
},
|
||||
ServerConfig.USC_QUEUE_SIZE,
|
||||
req[SYMBOL_TachiAPIAuth].userID!,
|
||||
uscChartName
|
||||
);
|
||||
|
||||
if (chartDoc) {
|
||||
const blacklist = await GetBlacklist();
|
||||
const scoresToDeorphan = await db["orphan-scores"].find({
|
||||
"context.chartHash": chartDoc.data.hashSHA1,
|
||||
"context.playtype": playtype,
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
scoresToDeorphan.map((score) => ReprocessOrphan(score, blacklist, logger))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const userID = req[SYMBOL_TachiAPIAuth]!.userID!;
|
||||
|
||||
const importRes = await ExpressWrappedScoreImportMain(userID, false, "ir/usc", [
|
||||
req.body,
|
||||
uscChart.chartHash,
|
||||
playtype,
|
||||
]);
|
||||
|
||||
// If this was an orphan chart request, return ACCEPTED,
|
||||
// since it may be unorphaned in the future
|
||||
if (!chartDoc) {
|
||||
return res.status(200).json({
|
||||
statusCode: STATUS_CODES.ACCEPTED,
|
||||
description:
|
||||
"This score has been accepted, but is waiting for more players before its parent chart is accepted.",
|
||||
});
|
||||
}
|
||||
|
||||
if (importRes.statusCode === 500) {
|
||||
return res.status(200).json({
|
||||
statusCode: STATUS_CODES.SERVER_ERROR,
|
||||
description: importRes.body.description,
|
||||
});
|
||||
} else if (importRes.statusCode !== 200) {
|
||||
return res.status(200).json({
|
||||
statusCode: STATUS_CODES.BAD_REQ,
|
||||
description: importRes.body.description,
|
||||
});
|
||||
}
|
||||
|
||||
const importDoc = (importRes.body as SuccessfulAPIResponse).body as ImportDocument;
|
||||
|
||||
try {
|
||||
const body = await CreatePOSTScoresResponseBody(userID, chartDoc, importDoc.scoreIDs[0]);
|
||||
|
||||
return res.status(200).json({
|
||||
statusCode: STATUS_CODES.SUCCESS,
|
||||
description: "Successfully imported score.",
|
||||
body,
|
||||
});
|
||||
} catch (err) {
|
||||
return res.status(200).json({
|
||||
statusCode: STATUS_CODES.SERVER_ERROR,
|
||||
description: "An internal server error has occured.",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Used to submit the replay for a given score when requested by the server.
|
||||
* https://uscir.readthedocs.io/en/latest/endpoints/replay-submit.html
|
||||
* @name POST /ir/usc/:playtype/replays
|
||||
*/
|
||||
router.post(
|
||||
"/replays",
|
||||
RequirePermissions("submit_score"),
|
||||
CreateMulterSingleUploadMiddleware("replay", ONE_MEGABYTE, logger, false),
|
||||
async (req, res) => {
|
||||
if (typeof req.body.identifier !== "string") {
|
||||
return res.status(200).json({
|
||||
statusCode: STATUS_CODES.BAD_REQ,
|
||||
description: "No Identifier Provided.",
|
||||
});
|
||||
}
|
||||
|
||||
if (!req.file) {
|
||||
return res.status(200).json({
|
||||
statusCode: STATUS_CODES.BAD_REQ,
|
||||
description: "No File Provided.",
|
||||
});
|
||||
}
|
||||
|
||||
const correspondingScore = await db.scores.findOne({
|
||||
userID: req[SYMBOL_TachiAPIAuth]!.userID!,
|
||||
scoreID: req.body.identifier,
|
||||
});
|
||||
|
||||
if (!correspondingScore) {
|
||||
return res.status(200).json({
|
||||
statusCode: STATUS_CODES.NOT_FOUND,
|
||||
description: "No score corresponds to this identifier.",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await CDNStoreOrOverwrite(
|
||||
GetUSCIRReplayURL(correspondingScore.scoreID),
|
||||
req.file.buffer
|
||||
);
|
||||
|
||||
return res.status(200).json({
|
||||
statusCode: STATUS_CODES.SUCCESS,
|
||||
description: "Saved replay.",
|
||||
body: null,
|
||||
});
|
||||
} catch (err) {
|
||||
// impossible to test pretty much.
|
||||
/* istanbul ignore next */
|
||||
logger.error(`USCIR Replay Store error.`, { err });
|
||||
/* istanbul ignore next */
|
||||
return res.status(200).json({
|
||||
statusCode: STATUS_CODES.SERVER_ERROR,
|
||||
description: "An error has occured in storing the replay.",
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export default router;
|
||||
+6
-4
@@ -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"
|
||||
);
|
||||
|
||||
+25
-20
@@ -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];
|
||||
}
|
||||
@@ -1,429 +1,8 @@
|
||||
import { RequestHandler, Router } from "express";
|
||||
import db from "external/mongo/db";
|
||||
import { CDNStoreOrOverwrite } from "lib/cdn/cdn";
|
||||
import { GetUSCIRReplayURL } from "lib/cdn/url-format";
|
||||
import { ONE_MEGABYTE } from "lib/constants/filesize";
|
||||
import { SYMBOL_TachiAPIAuth, SYMBOL_TachiData } from "lib/constants/tachi";
|
||||
import { USCIR_MAX_LEADERBOARD_N } from "lib/constants/usc-ir";
|
||||
import CreateLogCtx from "lib/logger/logger";
|
||||
import { HandleOrphanQueue } from "lib/orphan-queue/orphan-queue";
|
||||
import { AssertStrAsPositiveNonZeroInt } from "lib/score-import/framework/common/string-asserts";
|
||||
import { ExpressWrappedScoreImportMain } from "lib/score-import/framework/express-wrapper";
|
||||
import { ReprocessOrphan } from "lib/score-import/framework/orphans/orphans";
|
||||
import { ServerConfig, TachiConfig } from "lib/setup/config";
|
||||
import p from "prudence";
|
||||
import { RequirePermissions } from "server/middleware/auth";
|
||||
import { CreateMulterSingleUploadMiddleware } from "server/middleware/multer-upload";
|
||||
import {
|
||||
ChartDocument,
|
||||
ImportDocument,
|
||||
PBScoreDocument,
|
||||
SuccessfulAPIResponse,
|
||||
} from "tachi-common";
|
||||
import { FormatPrError } from "utils/prudence";
|
||||
import { GetBlacklist } from "utils/queries/blacklist";
|
||||
import { AssignToReqTachiData } from "utils/req-tachi-data";
|
||||
import { USCClientChart } from "./types";
|
||||
import {
|
||||
ConvertUSCChart,
|
||||
CreatePOSTScoresResponseBody,
|
||||
TachiScoreToServerScore,
|
||||
USCChartIndexToDiff,
|
||||
} from "./usc";
|
||||
|
||||
const logger = CreateLogCtx(__filename);
|
||||
import { Router } from "express";
|
||||
import uscirRouter from "./_playtype/router";
|
||||
|
||||
const router: Router = Router({ mergeParams: true });
|
||||
|
||||
enum STATUS_CODES {
|
||||
UNAUTH = 41,
|
||||
CHART_REFUSE = 42,
|
||||
FORBIDDEN = 43,
|
||||
NOT_FOUND = 44,
|
||||
SERVER_ERROR = 50,
|
||||
SUCCESS = 20,
|
||||
ACCEPTED = 22,
|
||||
BAD_REQ = 40,
|
||||
}
|
||||
|
||||
const ValidateUSCRequest: RequestHandler = async (req, res, next) => {
|
||||
const token = req.header("Authorization");
|
||||
|
||||
if (!token) {
|
||||
return res.status(200).json({
|
||||
statusCode: STATUS_CODES.BAD_REQ,
|
||||
description: "No auth token provided.",
|
||||
});
|
||||
}
|
||||
|
||||
const splitToken = token.split(" ");
|
||||
|
||||
if (splitToken.length !== 2 || splitToken[0] !== "Bearer") {
|
||||
return res.status(200).json({
|
||||
statusCode: STATUS_CODES.BAD_REQ,
|
||||
description: "Invalid Authorization Header. Expected Bearer <token>",
|
||||
});
|
||||
}
|
||||
|
||||
const uscAuthDoc = await db["api-tokens"].findOne({
|
||||
token: splitToken[1],
|
||||
});
|
||||
|
||||
if (!uscAuthDoc) {
|
||||
return res.status(200).json({
|
||||
statusCode: STATUS_CODES.UNAUTH,
|
||||
description: "Unauthorized.",
|
||||
});
|
||||
}
|
||||
|
||||
req[SYMBOL_TachiAPIAuth] = uscAuthDoc;
|
||||
|
||||
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
|
||||
// as the HTTP code is used to determine whether the server received the request properly,
|
||||
// rather than the result of the request.
|
||||
|
||||
/**
|
||||
* Used to check your connection to the server, and receive some basic information.
|
||||
* https://uscir.readthedocs.io/en/latest/endpoints/heartbeat.html
|
||||
* @name GET /ir/usc
|
||||
*/
|
||||
router.get("/", (req, res) =>
|
||||
res.status(200).json({
|
||||
statusCode: STATUS_CODES.SUCCESS,
|
||||
description: "IR Request Successful.",
|
||||
body: {
|
||||
serverTime: Math.floor(Date.now() / 1000),
|
||||
serverName: TachiConfig.NAME,
|
||||
irVersion: "0.3.1-a",
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const RetrieveChart: RequestHandler = async (req, res, next) => {
|
||||
const chart = await db.charts.usc.findOne({ "data.hashSHA1": req.params.chartHash });
|
||||
|
||||
AssignToReqTachiData(req, {
|
||||
uscChartDoc: (chart ?? undefined) as ChartDocument<"usc:Single"> | undefined,
|
||||
});
|
||||
|
||||
return next();
|
||||
};
|
||||
|
||||
/**
|
||||
* Used to check if the server will accept a score for a given chart in advance of submitting it.
|
||||
* https://uscir.readthedocs.io/en/latest/endpoints/chart-charthash.html
|
||||
* @name GET /ir/usc/charts/:chartHash
|
||||
*/
|
||||
router.get("/charts/:chartHash", RetrieveChart, (req, res) => {
|
||||
const chart = req[SYMBOL_TachiData]!.uscChartDoc;
|
||||
|
||||
if (!chart) {
|
||||
return res.status(200).json({
|
||||
statusCode: STATUS_CODES.NOT_FOUND,
|
||||
description: "This chart is not available on the IR yet, more people need to play it!",
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(200).json({
|
||||
statusCode: STATUS_CODES.SUCCESS,
|
||||
description: "This chart is tracked by the IR.",
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Used to retrieve the current server record for the chart with the specified hash.
|
||||
* https://uscir.readthedocs.io/en/latest/endpoints/record.html
|
||||
* @name GET /ir/usc/charts/:chartHash/record
|
||||
*/
|
||||
router.get("/charts/:chartHash/record", RetrieveChart, async (req, res) => {
|
||||
const chart = req[SYMBOL_TachiData]!.uscChartDoc;
|
||||
|
||||
if (!chart) {
|
||||
return res.status(200).json({
|
||||
statusCode: STATUS_CODES.NOT_FOUND,
|
||||
description: `This IR doesn't have any record data yet, or ${
|
||||
ServerConfig.USC_QUEUE_SIZE
|
||||
} ${
|
||||
ServerConfig.USC_QUEUE_SIZE === 1 ? "person has" : "people have"
|
||||
} not played the chart yet.`,
|
||||
});
|
||||
}
|
||||
|
||||
const serverRecord = (await db["personal-bests"].findOne({
|
||||
chartID: chart.chartID,
|
||||
"rankingData.rank": 1,
|
||||
})) as PBScoreDocument<"usc:Single"> | null;
|
||||
|
||||
if (!serverRecord) {
|
||||
return res.status(200).json({
|
||||
statusCode: STATUS_CODES.NOT_FOUND,
|
||||
description: "No server record found.",
|
||||
});
|
||||
}
|
||||
|
||||
const serverScore = await TachiScoreToServerScore(serverRecord);
|
||||
|
||||
return res.status(200).json({
|
||||
statusCode: STATUS_CODES.SUCCESS,
|
||||
description: "Retrieved score.",
|
||||
body: { record: serverScore },
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Used to retrieve some particular useful subset of the scores from the server.
|
||||
* https://uscir.readthedocs.io/en/latest/endpoints/leaderboard.html
|
||||
* @name GET /ir/usc/charts/:chartHash/leaderboard
|
||||
*/
|
||||
router.get("/charts/:chartHash/leaderboard", RetrieveChart, async (req, res) => {
|
||||
const chart = req[SYMBOL_TachiData]!.uscChartDoc!;
|
||||
|
||||
if (!(typeof req.query.mode === "string" && ["best", "rivals"].includes(req.query.mode))) {
|
||||
return res.status(200).json({
|
||||
statusCode: STATUS_CODES.BAD_REQ,
|
||||
description: `Invalid 'mode' param - expected 'best' or 'rivals'.`,
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof req.query.n !== "string") {
|
||||
return res.status(200).json({
|
||||
statusCode: STATUS_CODES.BAD_REQ,
|
||||
description: `Invalid 'n' param - expected a positive non-zero integer less than or equal to ${USCIR_MAX_LEADERBOARD_N}.`,
|
||||
});
|
||||
}
|
||||
|
||||
let n;
|
||||
|
||||
try {
|
||||
n = AssertStrAsPositiveNonZeroInt(req.query.n, "Invalid 'N' param.");
|
||||
} catch (err) {
|
||||
return res.status(200).json({
|
||||
statusCode: STATUS_CODES.BAD_REQ,
|
||||
description: `Invalid 'n' param - expected a positive non-zero integer less than or equal to ${USCIR_MAX_LEADERBOARD_N}.`,
|
||||
});
|
||||
}
|
||||
|
||||
if (n >= USCIR_MAX_LEADERBOARD_N) {
|
||||
n = USCIR_MAX_LEADERBOARD_N;
|
||||
}
|
||||
|
||||
const mode = req.query.mode as "best" | "rivals";
|
||||
|
||||
if (mode === "rivals") {
|
||||
return res.status(200).json({
|
||||
statusCode: STATUS_CODES.BAD_REQ,
|
||||
description: "This is currently unsupported.",
|
||||
});
|
||||
}
|
||||
|
||||
const bestScores = (await db["personal-bests"].find(
|
||||
{
|
||||
chartID: chart.chartID,
|
||||
},
|
||||
{
|
||||
sort: {
|
||||
"scoreData.perecent": -1,
|
||||
},
|
||||
limit: n,
|
||||
}
|
||||
)) as PBScoreDocument<"usc:Single">[];
|
||||
|
||||
const serverScores = await Promise.all(bestScores.map(TachiScoreToServerScore));
|
||||
|
||||
return res.status(200).json({
|
||||
statusCode: STATUS_CODES.SUCCESS,
|
||||
description: `Returned ${serverScores.length} scores.`,
|
||||
body: serverScores,
|
||||
});
|
||||
});
|
||||
|
||||
const PR_USCIRChartDoc = {
|
||||
chartHash: "string",
|
||||
artist: "string",
|
||||
title: "string",
|
||||
level: p.isBoundedInteger(1, 20),
|
||||
difficulty: p.isBoundedInteger(0, 3),
|
||||
effector: "string",
|
||||
illustrator: "string",
|
||||
bpm: "string",
|
||||
};
|
||||
|
||||
/**
|
||||
* Sends a score to the server.
|
||||
* https://uscir.readthedocs.io/en/latest/endpoints/score-submit.html
|
||||
* @name POST /ir/usc/scores
|
||||
*/
|
||||
router.post("/scores", RequirePermissions("submit_score"), async (req, res) => {
|
||||
const chartErr = p(
|
||||
req.body.chart,
|
||||
PR_USCIRChartDoc,
|
||||
{},
|
||||
{
|
||||
throwOnNonObject: false,
|
||||
allowExcessKeys: true,
|
||||
}
|
||||
);
|
||||
|
||||
if (chartErr) {
|
||||
return res.status(200).json({
|
||||
statusCode: STATUS_CODES.BAD_REQ,
|
||||
description: FormatPrError(chartErr, "Invalid chart."),
|
||||
});
|
||||
}
|
||||
|
||||
const uscChart = req.body.chart as USCClientChart;
|
||||
|
||||
let chartDoc = (await db.charts.usc.findOne({
|
||||
"data.hashSHA1": uscChart.chartHash,
|
||||
})) as ChartDocument<"usc:Single"> | 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 uscChartName = `${uscChart.artist} - ${uscChart.title} (${USCChartIndexToDiff(
|
||||
uscChart.difficulty
|
||||
)})`;
|
||||
|
||||
chartDoc = await HandleOrphanQueue(
|
||||
"usc:Single",
|
||||
"usc",
|
||||
chart,
|
||||
song,
|
||||
{
|
||||
"chartDoc.data.hashSHA1": uscChart.chartHash,
|
||||
},
|
||||
ServerConfig.USC_QUEUE_SIZE,
|
||||
req[SYMBOL_TachiAPIAuth].userID!,
|
||||
uscChartName
|
||||
);
|
||||
|
||||
if (chartDoc) {
|
||||
const blacklist = await GetBlacklist();
|
||||
const scoresToDeorphan = await db["orphan-scores"].find({
|
||||
"context.chartHash": chartDoc.data.hashSHA1,
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
scoresToDeorphan.map((score) => ReprocessOrphan(score, blacklist, logger))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const userID = req[SYMBOL_TachiAPIAuth]!.userID!;
|
||||
|
||||
const importRes = await ExpressWrappedScoreImportMain(userID, false, "ir/usc", [
|
||||
req.body,
|
||||
uscChart.chartHash,
|
||||
]);
|
||||
|
||||
// If this was an orphan chart request, return ACCEPTED,
|
||||
// since it may be unorphaned in the future
|
||||
if (!chartDoc) {
|
||||
return res.status(200).json({
|
||||
statusCode: STATUS_CODES.ACCEPTED,
|
||||
description:
|
||||
"This score has been accepted, but is waiting for more players before its parent chart is accepted.",
|
||||
});
|
||||
}
|
||||
|
||||
if (importRes.statusCode === 500) {
|
||||
return res.status(200).json({
|
||||
statusCode: STATUS_CODES.SERVER_ERROR,
|
||||
description: importRes.body.description,
|
||||
});
|
||||
} else if (importRes.statusCode !== 200) {
|
||||
return res.status(200).json({
|
||||
statusCode: STATUS_CODES.BAD_REQ,
|
||||
description: importRes.body.description,
|
||||
});
|
||||
}
|
||||
|
||||
const importDoc = (importRes.body as SuccessfulAPIResponse).body as ImportDocument;
|
||||
|
||||
try {
|
||||
const body = await CreatePOSTScoresResponseBody(userID, chartDoc, importDoc.scoreIDs[0]);
|
||||
|
||||
return res.status(200).json({
|
||||
statusCode: STATUS_CODES.SUCCESS,
|
||||
description: "Successfully imported score.",
|
||||
body,
|
||||
});
|
||||
} catch (err) {
|
||||
return res.status(200).json({
|
||||
statusCode: STATUS_CODES.SERVER_ERROR,
|
||||
description: "An internal server error has occured.",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Used to submit the replay for a given score when requested by the server.
|
||||
* https://uscir.readthedocs.io/en/latest/endpoints/replay-submit.html
|
||||
* @name POST /ir/usc/replays
|
||||
*/
|
||||
router.post(
|
||||
"/replays",
|
||||
RequirePermissions("submit_score"),
|
||||
CreateMulterSingleUploadMiddleware("replay", ONE_MEGABYTE, logger, false),
|
||||
async (req, res) => {
|
||||
if (typeof req.body.identifier !== "string") {
|
||||
return res.status(200).json({
|
||||
statusCode: STATUS_CODES.BAD_REQ,
|
||||
description: "No Identifier Provided.",
|
||||
});
|
||||
}
|
||||
|
||||
if (!req.file) {
|
||||
return res.status(200).json({
|
||||
statusCode: STATUS_CODES.BAD_REQ,
|
||||
description: "No File Provided.",
|
||||
});
|
||||
}
|
||||
|
||||
const correspondingScore = await db.scores.findOne({
|
||||
userID: req[SYMBOL_TachiAPIAuth]!.userID!,
|
||||
game: "usc",
|
||||
scoreID: req.body.identifier,
|
||||
});
|
||||
|
||||
if (!correspondingScore) {
|
||||
return res.status(200).json({
|
||||
statusCode: STATUS_CODES.NOT_FOUND,
|
||||
description: "No score corresponds to this identifier.",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await CDNStoreOrOverwrite(
|
||||
GetUSCIRReplayURL(correspondingScore.scoreID),
|
||||
req.file.buffer
|
||||
);
|
||||
|
||||
return res.status(200).json({
|
||||
statusCode: STATUS_CODES.SUCCESS,
|
||||
description: "Saved replay.",
|
||||
body: null,
|
||||
});
|
||||
} catch (err) {
|
||||
// impossible to test pretty much.
|
||||
/* istanbul ignore next */
|
||||
logger.error(`USCIR Replay Store error.`, { err });
|
||||
/* istanbul ignore next */
|
||||
return res.status(200).json({
|
||||
statusCode: STATUS_CODES.SERVER_ERROR,
|
||||
description: "An error has occured in storing the replay.",
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
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