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:
zkldi
2021-11-13 09:23:03 +00:00
committed by GitHub
26 changed files with 817 additions and 537 deletions
+1 -1
View File
@@ -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",
+4 -4
View File
@@ -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,
+10 -6
View File
@@ -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, {
@@ -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;
@@ -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 -424
View File
@@ -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": {
+3 -3
View File
@@ -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: {
+2 -2
View File
@@ -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;
}
+1 -1
View File
@@ -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">;