Merge pull request #591 from TNG-dev/release-v2.0.5

This commit is contained in:
zkldi
2021-12-13 19:09:53 +00:00
committed by GitHub
45 changed files with 723 additions and 35 deletions
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "tachi-server",
"version": "2.0.4",
"version": "2.0.5",
"description": "A score tracking server.",
"main": "js/index.js",
"private": true,
@@ -83,7 +83,7 @@
"rimraf": "3.0.2",
"safe-json-stringify": "1.2.0",
"seq-logging": "^1.1.1",
"tachi-common": "0.3.14",
"tachi-common": "0.3.19",
"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.3.14
tachi-common: 0.3.19
tap: 15.0.9
ts-node: 10.0.0
tsconfig-paths: 3.10.1
@@ -107,7 +107,7 @@ dependencies:
rimraf: 3.0.2
safe-json-stringify: 1.2.0
seq-logging: 1.1.1
tachi-common: 0.3.14
tachi-common: 0.3.19
ts-node: 10.0.0_83f53b0a0c5616d3fa00ed4e30b9ce1b
typescript: 4.3.4
winston: 3.3.3
@@ -5387,8 +5387,8 @@ packages:
strip-ansi: 6.0.0
dev: true
/tachi-common/0.3.14:
resolution: {integrity: sha512-LjUItf/Lp4xHdqhH1ZBE/4Czvco2WwUs2OgHIDkYSot+Wy1F/K2xycgJp6jLkXdJeeJWVO4hSIUQakzeqdhS1A==}
/tachi-common/0.3.19:
resolution: {integrity: sha512-N3/n9emh2X9pA+3jQ1UfkLX9V8hk/oHGE1GPlkReRmyIR9gRAxhf3uZCiorPqibSkPzx9LrGLJcO52FR5NQ8oA==}
dependencies:
monk: 7.3.4
transitivePeerDependencies:
@@ -8,7 +8,7 @@ import path from "path";
import { ChartDocument, Difficulties, IIDXBPIData } from "tachi-common";
import { FindChartWithPTDFVersion } from "utils/queries/charts";
import { FindSongOnTitle } from "utils/queries/songs";
import { RecalcAllScores } from "../__KT_DATABASE_MIGRATION/state-sync/recalc-all-scores";
import { RecalcAllScores } from "../state-sync/recalc-all-scores";
const program = new Command();
@@ -0,0 +1,49 @@
import db from "external/mongo/db";
import CreateLogCtx from "lib/logger/logger";
import { GetGradeFromPercent } from "lib/score-import/framework/common/score-utils";
import UpdateScore from "lib/score-mutation/update-score";
import { ChartDocument, GetGamePTConfig } from "tachi-common";
const logger = CreateLogCtx(__filename);
if (require.main === module) {
let i = 0;
db.scores
.find({ game: "chunithm", "scoreData.grade": "SS" })
// @ts-expect-error dumb
.each(async (oldScore, { pause, resume }) => {
pause();
const chart = (await db.charts.chunithm.findOne({
chartID: oldScore.chartID,
})) as ChartDocument<"chunithm:Single">;
if (!chart) {
logger.severe(`Chart ${oldScore.chartID} has no chart?`);
return resume();
}
const newScore = JSON.parse(JSON.stringify(oldScore));
newScore.scoreData.grade = GetGradeFromPercent(
"chunithm",
oldScore.playtype,
oldScore.scoreData.percent
);
newScore.scoreData.gradeIndex = GetGamePTConfig(
"chunithm",
oldScore.playtype
).grades.indexOf(newScore.scoreData.grade);
await UpdateScore(oldScore, newScore);
i++;
resume();
})
.then(() => {
logger.info(`Fixed ${i} bad scores.`);
process.exit(0);
});
}
+2
View File
@@ -373,6 +373,7 @@ export const DatabaseSchemas: Record<Databases, ValidatorFunction> = {
],
(self) => Array.isArray(self) && self.length <= 6
),
scoreBucket: p.isIn(null, "grade", "lamp"),
gameSpecific:
game === "iidx"
? {
@@ -528,6 +529,7 @@ export const DatabaseSchemas: Record<Databases, ValidatorFunction> = {
developerMode: "boolean",
advancedMode: "boolean",
contentiousContent: "boolean",
deletableScores: "boolean",
},
}),
sessions: (self) => {
+2
View File
@@ -8,4 +8,6 @@ export const BMS_TABLES = {
satellite: "sl",
dpNormal: "δ",
dpInsane: "★",
scratch: "h◎",
ln: "◆",
};
+1 -1
View File
@@ -4,7 +4,7 @@
const MAJOR = 2;
const MINOR = 0;
const PATCH = 4;
const PATCH = 5;
// As is with all front-facing zkldi projects, the version names for tachi-server
// are from an album I like. In this case, the album is Portishead - Dummy.
@@ -41,6 +41,7 @@ export async function CreateGameSettings(userID: integer, game: Game, playtype:
preferredProfileAlg: null,
preferredSessionAlg: null,
preferredScoreAlg: null,
scoreBucket: null,
stats: [],
gameSpecific,
},
+16
View File
@@ -84,6 +84,22 @@ const registeredTables: BMSTablesDataset[] = [
playtype: "14K",
url: "https://stellabms.xyz/dp/score.json",
},
{
name: "Scratch 3rd",
description: "The 7K Sara 3 table.",
humanisedPrefix: "scr",
prefix: BMS_TABLES.scratch,
playtype: "7K",
url: "http://minddnim.web.fc2.com/sara/3rd_hard/json/data.json",
},
{
name: "LN",
description: "The 7K LN table.",
humanisedPrefix: "ln",
prefix: BMS_TABLES.ln,
playtype: "7K",
url: "http://flowermaster.web.fc2.com/lrnanido/gla/score.json",
},
];
/* eslint-disable no-await-in-loop */
@@ -21,6 +21,7 @@ import { BeatorajaContext, BeatorajaScore } from "../ir/beatoraja/types";
import { FervidexStaticContext, FervidexStaticScore } from "../ir/fervidex-static/types";
import { FervidexContext, FervidexScore } from "../ir/fervidex/types";
import { KsHookSV3CContext, KsHookSV3CScore } from "../ir/kshook-sv3c/types";
import { LR2HookContext, LR2HookScore } from "../ir/lr2hook/types";
import { IRUSCContext } from "../ir/usc/types";
import { KaiContext } from "./api-kai/types";
import { BatchManualContext } from "./batch-manual/types";
@@ -41,6 +42,7 @@ export interface ImportTypeDataMap {
"ir/beatoraja": BeatorajaScore;
"ir/usc": USCClientScore;
"ir/kshook-sv3c": KsHookSV3CScore;
"ir/lr2hook": LR2HookScore;
// These aren't placeholder values - the data is yielded in a way that
// the value of these is legitimately unknown at convert time.
@@ -68,6 +70,7 @@ export interface ImportTypeContextMap {
"ir/beatoraja": BeatorajaContext;
"ir/usc": IRUSCContext;
"ir/kshook-sv3c": KsHookSV3CContext;
"ir/lr2hook": LR2HookContext;
"api/arc-iidx": EmptyObject;
"api/arc-sdvx": EmptyObject;
@@ -14,6 +14,7 @@ import { ConverterIRBeatoraja } from "./ir/beatoraja/converter";
import { ConverterIRFervidexStatic } from "./ir/fervidex-static/converter";
import { ConverterIRFervidex } from "./ir/fervidex/converter";
import { ConverterIRKsHookSV3C } from "./ir/kshook-sv3c/converter";
import { ConverterLR2Hook } from "./ir/lr2hook/converter";
import { ConverterIRUSC } from "./ir/usc/converter";
export type ConverterMap = {
@@ -43,4 +44,5 @@ export const Converters: ConverterMap = {
"ir/direct-manual": ConverterBatchManual,
"ir/usc": ConverterIRUSC,
"ir/kshook-sv3c": ConverterIRKsHookSV3C,
"ir/lr2hook": ConverterLR2Hook,
};
@@ -68,6 +68,10 @@ const SUPPORTED_BEATORAJA_CLIENTS = [
"LR2oraja 0.8.2",
"LR2oraja 0.8.1",
"LR2oraja 0.8.0",
"LR2oraja(rekidai.info) 0.8.3",
"LR2oraja(rekidai.info) 0.8.2",
"LR2oraja(rekidai.info) 0.8.1",
"LR2oraja(rekidai.info) 0.8.0",
];
export function ParseBeatorajaSingle(
@@ -0,0 +1,61 @@
import CreateLogCtx from "lib/logger/logger";
import t from "tap";
import ResetDBState from "test-utils/resets";
import { TestingLR2HookScore } from "test-utils/test-data";
import { ConverterLR2Hook } from "./converter";
const logger = CreateLogCtx(__filename);
t.test("#ConverterLR2Hook", (t) => {
t.beforeEach(ResetDBState);
t.test("Should match a score with its song and chart.", async (t) => {
const res = await ConverterLR2Hook(
TestingLR2HookScore,
{ timeReceived: 10 },
"ir/lr2hook",
logger
);
t.hasStrict(res, {
song: {
id: 27339,
},
chart: {
chartID: "88eb6cc5683e2740cbd07f588a5f3db1db8d467b",
data: {
hashMD5: TestingLR2HookScore.md5,
},
},
dryScore: {
scoreData: {
score: TestingLR2HookScore.scoreData.exScore,
},
game: "bms",
importType: "ir/lr2hook",
scoreMeta: {
client: "LR2",
},
},
});
t.end();
});
t.test("Should throw an error if song or chart can't be found.", (t) => {
t.rejects(
() =>
ConverterLR2Hook(
Object.assign({}, TestingLR2HookScore, { md5: "nonsense_md5" }),
{ timeReceived: 10 },
"ir/lr2hook",
logger
),
"Should throw a KTDataNotFoundError if chart can't be found."
);
t.end();
});
t.end();
});
@@ -0,0 +1,103 @@
import { Lamps, ScoreDocument } from "tachi-common";
import {
InternalFailure,
InvalidScoreFailure,
KTDataNotFoundFailure,
} from "lib/score-import/framework/common/converter-failures";
import { GenericGetGradeAndPercent } from "lib/score-import/framework/common/score-utils";
import { DryScore } from "lib/score-import/framework/common/types";
import { FindBMSChartOnHash } from "utils/queries/charts";
import { FindSongOnID } from "utils/queries/songs";
import { ConverterFunction } from "../../common/types";
import { LR2HookContext, LR2HookScore } from "./types";
export const ConverterLR2Hook: ConverterFunction<LR2HookScore, LR2HookContext> = async (
data,
context,
importType,
logger
) => {
const chart = await FindBMSChartOnHash(data.md5);
if (!chart) {
throw new KTDataNotFoundFailure(
`Could not find chart with md5 ${data.md5}.`,
importType,
data,
context
);
}
const song = await FindSongOnID("bms", chart.songID);
if (!song) {
logger.severe(`Song ${chart.songID} (bms) has no parent song?`);
throw new InternalFailure(`Song ${chart.songID} (bms) has no parent song?`);
}
const { percent, grade } = GenericGetGradeAndPercent("bms", data.scoreData.exScore, chart);
const dryScore: DryScore<"bms:7K" | "bms:14K"> = {
game: "bms",
service: "LR2Hook",
comment: null,
importType: "ir/lr2hook",
timeAchieved: context.timeReceived,
scoreData: {
score: data.scoreData.exScore,
percent,
grade,
lamp: ConvertLamp(data.scoreData.lamp),
judgements: {
pgreat: data.scoreData.pgreat,
great: data.scoreData.great,
good: data.scoreData.good,
bad: data.scoreData.bad,
poor: data.scoreData.poor,
},
hitMeta: {
bp: data.scoreData.bad + data.scoreData.poor,
maxCombo: data.scoreData.maxCombo,
gauge: data.scoreData.hpGraph[999] ?? 0,
},
},
scoreMeta: {
random: chart.playtype === "7K" ? ConvertRandom(data.playerData.random) : null,
client: "LR2",
},
};
return { song, chart, dryScore };
};
function ConvertRandom(
random: LR2HookScore["playerData"]["random"]
): ScoreDocument<"bms:7K">["scoreMeta"]["random"] {
switch (random) {
case "NONRAN":
return "NONRAN";
case "MIRROR":
return "MIRROR";
case "RAN":
return "RANDOM";
case "S-RAN":
return "S-RANDOM";
}
}
function ConvertLamp(lamp: LR2HookScore["scoreData"]["lamp"]): Lamps["bms:7K" | "bms:14K"] {
switch (lamp) {
case "EASY":
return "EASY CLEAR";
case "FAIL":
return "FAILED";
case "FULL COMBO":
return "FULL COMBO";
case "HARD":
return "HARD CLEAR";
case "NO PLAY":
return "NO PLAY";
case "NORMAL":
return "CLEAR";
}
}
@@ -0,0 +1,109 @@
import CreateLogCtx from "lib/logger/logger";
import t from "tap";
import { ParseLR2Hook } from "./parser";
import deepmerge from "deepmerge";
import { TestingLR2HookScore } from "test-utils/test-data";
import { ApplyNTimes } from "utils/misc";
const logger = CreateLogCtx(__filename);
t.test("#ParseLR2Hook", (t) => {
const assertFail = (data: any, message: string) => {
t.throws(() => ParseLR2Hook(data, logger), message);
};
const assertSuccess = (data: any, message: string) => {
try {
t.doesNotThrow(() => ParseLR2Hook(data, logger), message);
const res = ParseLR2Hook(data, logger);
t.equal(res.game, "bms");
t.type(res.context.timeReceived, "number");
t.ok(Array.isArray(res.iterable));
t.equal(res.classHandler, null);
} catch (err) {
t.fail(`[${message}] ${err.message}`);
}
};
const dm = (data: any) => deepmerge(TestingLR2HookScore, data);
const dms = (data: any) =>
deepmerge(
TestingLR2HookScore,
{ scoreData: data },
{
arrayMerge: (a, b) => b,
}
);
assertSuccess(TestingLR2HookScore, "Should parse a valid score.");
assertSuccess(
dm({ unexpectedField: "foo" }),
"Should allow excess keys that we do not recognise."
);
assertSuccess(
dm({ scoreData: { unexpectedField: "foo" } }),
"Should allow excess keys inside scoreData that we do not recognise."
);
assertSuccess(
dm({ playerData: { unexpectedField: "foo" } }),
"Should allow excess keys inside playerData that we do not recognise."
);
assertFail(
dm({ playerData: { autoScr: true } }),
"Should reject scores where autoScr is set to true."
);
assertFail(
dm({ playerData: { random: "H-RAN" } }),
"Should reject scores where random is set to H-RAN."
);
assertFail(
dm({ playerData: { random: "ALLSCR" } }),
"Should reject scores where random is set to ALLSCR"
);
assertFail({}, "Should reject an empty object");
for (const key of [
"pgreat",
"good",
"bad",
"poor",
"great",
"maxCombo",
"exScore",
"notesTotal",
"notesPlayed",
]) {
assertFail(dms({ [key]: -1 }), `Should reject negative ${key}`);
assertFail(dms({ [key]: 0.5 }), `Should reject decimal ${key}`);
assertFail(dms({ [key]: "0" }), `Should reject string ${key}`);
assertFail(dms({ [key]: null }), `Should reject nonsense ${key}`);
}
assertFail(dms({ lamp: "UNKNOWN_LAMP" }), "Should reject unknown lamp.");
assertFail(dms({ lamp: null }), "Should reject null lamp.");
assertFail(dms({ lamp: undefined }), "Should reject no lamp.");
assertFail(
dms({ hpGraph: ApplyNTimes(999, () => 50) }),
"Should disallow hp graph with <1000 elements."
);
assertFail(dms({ hpGraph: [] }), "Should disallow hp graph with 0 elements.");
assertFail(
dms({ hpGraph: ApplyNTimes(1001, () => 50) }),
"Should disallow hp graph with >1000 elements."
);
assertFail(
dms({ hpGraph: ApplyNTimes(1000, () => 101) }),
"Should disallow hp graph with elements larger than 100."
);
assertFail(
dms({ hpGraph: ApplyNTimes(1000, () => -1) }),
"Should disallow hp graph with negative element values."
);
t.end();
});
@@ -0,0 +1,82 @@
import { KtLogger } from "lib/logger/logger";
import { ParserFunctionReturns } from "../../common/types";
import { LR2HookContext, LR2HookScore } from "./types";
import p, { PrudenceSchema } from "prudence";
import ScoreImportFatalError from "lib/score-import/framework/score-importing/score-import-error";
import { FormatPrError } from "utils/prudence";
const SUPPORTED_RANDOMS = ["NONRAN", "MIRROR", "RAN", "S-RAN"];
const PR_LR2Hook: PrudenceSchema = {
md5: "string",
playerData: {
autoScr: p.is(false),
// don't really need this, but it's
// { "ALL", "SINGLE", "7K", "5K", "DOUBLE", "14K", "10K", "9K" };
gameMode: p.any,
// ALLSCR and H-RAN may also be sent, but we don't support them.
random: p.isIn(SUPPORTED_RANDOMS),
},
scoreData: {
pgreat: p.isPositiveInteger,
great: p.isPositiveInteger,
good: p.isPositiveInteger,
bad: p.isPositiveInteger,
poor: p.isPositiveInteger,
maxCombo: p.isPositiveInteger,
exScore: p.isPositiveInteger,
moneyScore: p.isPositiveInteger,
notesTotal: p.isPositiveInteger,
notesPlayed: p.isPositiveInteger,
lamp: p.isIn("NO PLAY", "FAIL", "EASY", "NORMAL", "HARD", "FULL COMBO"),
hpGraph: (self) => {
if (!Array.isArray(self)) {
return "Expected an array";
}
if (self.length !== 1000) {
return "Expected an array with length 1000.";
}
if (self.some((x) => x < 0 || x > 100 || !Number.isInteger(x))) {
return "Expected an array of 1000 integers between 0 and 100.";
}
return true;
},
},
};
export function ParseLR2Hook(
body: Record<string, unknown>,
logger: KtLogger
): ParserFunctionReturns<LR2HookScore, LR2HookContext> {
// Ignore excess keys, as lr2hook is likely to add more features in the future.
const err = p(
body,
PR_LR2Hook,
{
playerData: {
autoScr:
"Auto Scratch cannot be turned on, as it is treated as an EASY CLEAR by LR2.",
random: `Expected any of ${SUPPORTED_RANDOMS.join(
", "
)}. Note that ALLSCR and H-RAN are not supported!`,
},
},
{ allowExcessKeys: true }
);
if (err) {
throw new ScoreImportFatalError(400, FormatPrError(err));
}
const score = body as unknown as LR2HookScore;
return {
game: "bms",
iterable: [score],
context: {
timeReceived: Date.now(),
},
classHandler: null,
};
}
@@ -0,0 +1,28 @@
import { integer } from "tachi-common";
export interface LR2HookScore {
md5: string;
playerData: {
autoScr: boolean;
gameMode: unknown;
random: "NONRAN" | "MIRROR" | "RAN" | "S-RAN";
};
scoreData: {
pgreat: integer;
great: integer;
good: integer;
bad: integer;
poor: integer;
maxCombo: integer;
exScore: integer;
moneyScore: integer;
notesTotal: integer;
notesPlayed: integer;
lamp: "NO PLAY" | "FAIL" | "EASY" | "NORMAL" | "HARD" | "FULL COMBO";
hpGraph: integer[];
};
}
export interface LR2HookContext {
timeReceived: number;
}
@@ -17,6 +17,7 @@ import ParseDirectManual from "./ir/direct-manual/parser";
import { ParseFervidexStatic } from "./ir/fervidex-static/parser";
import { ParseFervidexSingle } from "./ir/fervidex/parser";
import { ParseKsHookSV3C } from "./ir/kshook-sv3c/parser";
import { ParseLR2Hook } from "./ir/lr2hook/parser";
import { ParseIRUSC } from "./ir/usc/parser";
export const Parsers = {
@@ -42,4 +43,5 @@ export const Parsers = {
"ir/direct-manual": ParseDirectManual,
"ir/usc": ParseIRUSC,
"ir/kshook-sv3c": ParseKsHookSV3C,
"ir/lr2hook": ParseLR2Hook,
};
@@ -26,6 +26,7 @@ t.test("#EvalulateUsersStatsShowcase", (t) => {
preferredProfileAlg: null,
preferredScoreAlg: null,
preferredSessionAlg: null,
scoreBucket: null,
stats: [
{
folderID: TestingIIDXFolderSP10.folderID,
@@ -8,11 +8,11 @@ import { GetUserWithID } from "utils/user";
import { ONE_MINUTE } from "lib/constants/time";
import { ServerConfig, TachiConfig } from "lib/setup/config";
import { Game, UserAuthLevels } from "tachi-common";
import db from "external/mongo/db";
import { DeleteScore } from "lib/score-mutation/delete-scores";
import { UpdateAllPBs } from "utils/calculations/recalc-scores";
import { RecalcAllScores, UpdateAllPBs } from "utils/calculations/recalc-scores";
import DestroyUserGamePlaytypeData from "utils/reset-state/destroy-ugpt";
import { RecalcSessions } from "utils/calculations/recalc-sessions";
const logger = CreateLogCtx(__filename);
@@ -264,7 +264,7 @@ router.post("/delete-score", prValidate({ scoreID: "string" }), async (req, res)
/**
* Destroys a users UGPT profile and forces a site recalc.
*
* @POST /api/v1/admin/destroy-ugpt
* @name POST /api/v1/admin/destroy-ugpt
*/
router.post(
"/destroy-ugpt",
@@ -302,7 +302,7 @@ router.post(
/**
* Destroy a chart and all of its scores (and sessions).
*
* name @POST /api/v1/admin/destroy-chart
* @name POST /api/v1/admin/destroy-chart
*/
router.post(
"/destroy-chart",
@@ -370,4 +370,21 @@ router.post(
}
);
/**
* Perform a site recalc on this set of scores.
*
* @name POST /api/v1/admin/recalc
*/
router.post("/recalc", async (req, res) => {
const filter = req.body ?? {};
await RecalcAllScores(filter);
await RecalcSessions(filter);
return res.status(200).json({
success: true,
description: `Recalced scores.`,
body: {},
});
});
export default router;
@@ -81,6 +81,7 @@ export const DEFAULT_USER_SETTINGS: UserSettings["preferences"] = {
advancedMode: false,
invisible: false,
contentiousContent: false,
deletableScores: false,
};
export function HashPassword(plaintext: string) {
@@ -76,11 +76,11 @@ router.get("/", async (req, res) => {
});
/**
* Returns user-game-stats for this game in batches of 100.
* Returns user-game-stats for this game in batches of 500.
* This is sorted by the games default-sorting-statistic.
*
* @param alg - An alternative algorithm to use instead of the gpts default.
* @param limit - How many users to return at most. Defaults (and is limited to) 50.
* @param limit - How many users to return at most. Defaults (and is limited to) 500.
*
* @name GET /api/v1/games/:game/:playtype/leaderboard
*/
@@ -89,12 +89,12 @@ router.get("/leaderboard", async (req, res) => {
const playtype = req[SYMBOL_TachiData]!.playtype!;
const gptConfig = GetGamePTConfig(game, playtype);
const limit = ParseStrPositiveNonZeroInt(req.query.limit) ?? 50;
const limit = ParseStrPositiveNonZeroInt(req.query.limit) ?? 100;
if (limit > 50) {
if (limit > 500) {
return res.status(400).json({
success: false,
description: `Invalid limit. Limit is capped at 50.`,
description: `Invalid limit. Limit is capped at 500.`,
});
}
@@ -114,7 +114,7 @@ router.get("/recent", async (req, res) => {
/**
* Returns a user's most recent session.
*
* @name GET /api/v1/users/:userID/games/:game/:playtype/sessions/recent
* @name GET /api/v1/users/:userID/games/:game/:playtype/sessions/last
*/
router.get("/last", async (req, res) => {
const user = req[SYMBOL_TachiData]!.requestedUser!;
@@ -18,6 +18,7 @@ t.test("GET /api/v1/users/:userID/games/:game/:playtype/settings", (t) => {
preferredScoreAlg: null,
preferredSessionAlg: null,
preferredProfileAlg: null,
scoreBucket: null,
stats: [],
gameSpecific: {
display2DXTra: false,
@@ -62,6 +63,7 @@ t.test("PATCH /api/v1/users/:userID/games/:game/:playtype/settings", (t) => {
preferredScoreAlg: "ktRating",
preferredSessionAlg: null,
preferredProfileAlg: null,
scoreBucket: null,
stats: [],
gameSpecific: {
display2DXTra: false,
@@ -113,6 +115,7 @@ t.test("PATCH /api/v1/users/:userID/games/:game/:playtype/settings", (t) => {
preferredScoreAlg: "ktRating",
preferredSessionAlg: null,
preferredProfileAlg: null,
scoreBucket: null,
stats: [],
gameSpecific: {
display2DXTra: true,
@@ -5,7 +5,7 @@ import CreateLogCtx from "lib/logger/logger";
import p from "prudence";
import { RequirePermissions } from "server/middleware/auth";
import { GetGamePTConfig } from "tachi-common";
import { FormatPrError } from "utils/prudence";
import { FormatPrError, optNull } from "utils/prudence";
import { FormatUserDoc } from "utils/user";
import { RequireAuthedAsUser } from "../../../../middleware";
@@ -37,6 +37,7 @@ router.patch(
preferredProfileAlg: p.optional(p.nullable(p.isIn(gptConfig.profileRatingAlgs))),
// This is a pretty stupid IIFE level hack, ah well.
gameSpecific: p.any,
scoreBucket: optNull(p.isIn("grade", "lamp")),
});
if (err) {
@@ -80,6 +81,10 @@ router.patch(
}
}
if (req.body.scoreBucket !== undefined) {
updateQuery[`preferences.scoreBucket`] = req.body.scoreBucket;
}
if (req.body.gameSpecific) {
for (const key in req.body.gameSpecific) {
updateQuery[`preferences.gameSpecific.${key}`] = req.body.gameSpecific[key];
@@ -21,6 +21,7 @@ const SetFolders = async () => {
preferredProfileAlg: null,
preferredScoreAlg: null,
preferredSessionAlg: null,
scoreBucket: null,
stats: [
{
folderID: TestingIIDXFolderSP10.folderID,
@@ -82,6 +83,7 @@ t.test("GET /api/v1/users/:userID/games/:game/:playtype/showcase", (t) => {
preferredProfileAlg: null,
preferredScoreAlg: null,
preferredSessionAlg: null,
scoreBucket: null,
stats: [
{
mode: "folder",
@@ -19,6 +19,7 @@ t.test("GET /api/v1/users/:userID/settings", (t) => {
developerMode: true,
advancedMode: false,
contentiousContent: false,
deletableScores: false,
},
});
@@ -46,6 +47,7 @@ t.test("PATCH /api/v1/users/:userID/settings", async (t) => {
developerMode: false,
advancedMode: false,
contentiousContent: false,
deletableScores: false,
},
});
@@ -58,6 +60,7 @@ t.test("PATCH /api/v1/users/:userID/settings", async (t) => {
developerMode: false,
advancedMode: false,
contentiousContent: false,
deletableScores: false,
},
});
@@ -80,6 +83,7 @@ t.test("PATCH /api/v1/users/:userID/settings", async (t) => {
developerMode: true,
advancedMode: false,
contentiousContent: false,
deletableScores: false,
},
},
"User Settings should be unmodified."
@@ -55,6 +55,7 @@ router.patch(
developerMode: "*boolean",
contentiousContent: "*boolean",
advancedMode: "*boolean",
deletableScores: "*boolean",
}),
async (req, res) => {
const user = req[SYMBOL_TachiData]!.requestedUser!;
@@ -64,6 +65,7 @@ router.patch(
developerMode: req.body.developerMode,
contentiousContent: req.body.contentiousContent,
advancedMode: req.body.advancedMode,
deletableScores: req.body.deletableScores,
};
DeleteUndefinedProps(preferences);
@@ -6,6 +6,17 @@ import ResetDBState from "test-utils/resets";
t.test("GET /ir/beatoraja/charts/:chartSHA256/scores", (t) => {
t.beforeEach(ResetDBState);
t.beforeEach(() =>
db["api-tokens"].insert({
userID: 1,
identifier: "Mock API Beatoraja Token",
permissions: {
submit_score: true,
},
token: "mock_token",
fromAPIClient: null,
})
);
const GAZER_SHA256 = "195fe1be5c3e74fccd04dc426e05f8a9cfa8a1059c339d0a23e99f63661f0b7d";
const GAZER_CHARTID = "88eb6cc5683e2740cbd07f588a5f3db1db8d467b";
@@ -35,6 +46,7 @@ t.test("GET /ir/beatoraja/charts/:chartSHA256/scores", (t) => {
const res = await mockApi
.get(`/ir/beatoraja/charts/${GAZER_SHA256}/scores`)
.set("Authorization", "Bearer mock_token")
.set("X-TachiIR-Version", "v2.0.0");
t.equal(res.status, 200);
@@ -45,6 +57,7 @@ t.test("GET /ir/beatoraja/charts/:chartSHA256/scores", (t) => {
t.test("Should return 404 if chart doesnt exist", async (t) => {
const res = await mockApi
.get(`/ir/beatoraja/charts/INVALID/scores`)
.set("Authorization", "Bearer mock_token")
.set("X-TachiIR-Version", "v2.0.0");
t.equal(res.status, 404);
@@ -0,0 +1,61 @@
import db from "external/mongo/db";
import t from "tap";
import mockApi from "test-utils/mock-api";
import ResetDBState from "test-utils/resets";
import { TestingLR2HookScore } from "test-utils/test-data";
t.test("POST /ir/lr2hook/import", (t) => {
t.beforeEach(ResetDBState);
t.beforeEach(async () => {
await db["api-tokens"].insert({
token: "foo",
permissions: { submit_score: true },
fromAPIClient: null,
identifier: "foo",
userID: 1,
});
});
t.test("Should import a score if the API token has the right permissions.", async (t) => {
const res = await mockApi
.post("/ir/lr2hook/import")
.set("Authorization", "Bearer foo")
.send(TestingLR2HookScore);
t.equal(res.statusCode, 200);
t.end();
});
t.test("Should import a score if the API token has the right permissions.", async (t) => {
await db["api-tokens"].insert({
token: "bar",
permissions: { submit_score: false },
fromAPIClient: null,
identifier: "bar",
userID: 1,
});
const res = await mockApi
.post("/ir/lr2hook/import")
.set("Authorization", "Bearer bar")
.send(TestingLR2HookScore);
t.equal(res.statusCode, 403);
t.end();
});
t.test("Should import a score if the API token has the right permissions.", async (t) => {
const res = await mockApi
.post("/ir/lr2hook/import")
.set("Authorization", "Bearer unknown token")
.send(TestingLR2HookScore);
t.equal(res.statusCode, 401);
t.end();
});
t.end();
});
@@ -0,0 +1,26 @@
import { Router } from "express";
import { SYMBOL_TachiAPIAuth } from "lib/constants/tachi";
import { ExpressWrappedScoreImportMain } from "lib/score-import/framework/express-wrapper";
import { RequirePermissions } from "server/middleware/auth";
const router: Router = Router({ mergeParams: true });
router.use(RequirePermissions("submit_score"));
/**
* Import a score with the LR2Hook Format.
*
* @name POST /ir/lr2hook/import
*/
router.post("/import", async (req, res) => {
const importRes = await ExpressWrappedScoreImportMain(
req[SYMBOL_TachiAPIAuth].userID!,
false,
"ir/lr2hook",
[req.body]
);
return res.status(importRes.statusCode).json(importRes.body);
});
export default router;
+10 -2
View File
@@ -12,13 +12,13 @@ import directManualIR from "./direct-manual/router";
import fervidexIR from "./fervidex/router";
import ksHookIR from "./kshook/router";
import uscIR from "./usc/router";
import lr2hookIR from "./lr2hook/router";
const router: Router = Router({ mergeParams: true });
// Common IRs
router.use("/direct-manual", SetRequestPermissions, RequireNotGuest, directManualIR);
router.use("/kshook", SetFervidexStyleRequestPermissions, FervidexStyleRequireNotGuest, ksHookIR);
// Bokutachi IRs
@@ -26,10 +26,18 @@ router.use("/kshook", SetFervidexStyleRequestPermissions, FervidexStyleRequireNo
// auth, because the USCIR spec requires a different set of response
// codes for auth.
router.use("/usc", RequireBokutachi, uscIR);
router.use("/beatoraja", SetRequestPermissions, RequireBokutachi, beatorajaIR);
router.use("/beatoraja", SetRequestPermissions, RequireNotGuest, RequireBokutachi, beatorajaIR);
router.use("/lr2hook", SetRequestPermissions, RequireNotGuest, RequireBokutachi, lr2hookIR);
// Kamaitachi IRs
router.use(
"/kshook",
RequireKamaitachi,
SetFervidexStyleRequestPermissions,
FervidexStyleRequireNotGuest,
ksHookIR
);
router.use("/barbatos", SetRequestPermissions, RequireNotGuest, RequireKamaitachi, barbatosIR);
router.use(
"/fervidex",
@@ -71,17 +71,18 @@ t.test("GET /ir/usc/Keyboard", async (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);
t.beforeEach(async () => {
await db["api-tokens"].insert({
userID: 1,
identifier: "USC Token",
permissions: {
submit_score: true,
},
token: "foo",
fromAPIClient: null,
});
});
TestAuth("/ir/usc/Controller");
@@ -7,6 +7,7 @@
"preferredScoreAlg": null,
"preferredSessionAlg": null,
"preferredProfileAlg": null,
"scoreBucket": null,
"stats": [],
"gameSpecific": {
"display2DXTra": false
@@ -5,7 +5,8 @@
"developerMode": true,
"invisible": false,
"advancedMode": false,
"contentiousContent": false
"contentiousContent": false,
"deletableScores": false
}
}
]
+25
View File
@@ -10,6 +10,7 @@ import {
import { DryScore } from "lib/score-import/framework/common/types";
import { BarbatosScore } from "lib/score-import/import-types/ir/barbatos/types";
import { KsHookSV3CScore } from "lib/score-import/import-types/ir/kshook-sv3c/types";
import { LR2HookScore } from "lib/score-import/import-types/ir/lr2hook/types";
import path from "path";
import { USCClientScore } from "server/router/ir/usc/_playtype/types";
import {
@@ -22,6 +23,7 @@ import {
SongDocument,
UserGoalDocument,
} from "tachi-common";
import { ApplyNTimes, RFA } from "utils/misc";
const file = (name: string) => path.join(__dirname, "/test-data", name);
@@ -41,6 +43,29 @@ export const TestingIIDXSPScore = GetKTDataJSON(
export const TestingKsHookSV3CScore = GetKTDataJSON("./kshook-sv3c/base.json") as KsHookSV3CScore;
export const TestingLR2HookScore: LR2HookScore = {
md5: "38616b85332037cc12924f2ae2840262",
playerData: {
autoScr: false,
gameMode: "7K",
random: "RAN",
},
scoreData: {
pgreat: 1000,
great: 500,
good: 100,
bad: 50,
poor: 25,
exScore: 2500,
maxCombo: 50,
moneyScore: 150_000,
notesTotal: 2256,
notesPlayed: 2256,
lamp: "HARD",
hpGraph: ApplyNTimes(1000, () => RFA([100, 50, 80, 0])),
},
};
export const Testing511SPA = GetKTDataJSON("./tachi/iidx-511spa.json") as ChartDocument<"iidx:SP">;
export const Testing511Song = GetKTDataJSON("./tachi/iidx-511-song.json") as SongDocument<"iidx">;
+45 -1
View File
@@ -4,7 +4,7 @@ import { ChartDocument, FolderDocument } from "tachi-common";
import t from "tap";
import ResetDBState from "test-utils/resets";
import { Testing511SPA } from "test-utils/test-data";
import { GetFolderChartIDs, ResolveFolderToCharts } from "./folder";
import { GetFolderChartIDs, ResolveFolderToCharts, TransposeFolderData } from "./folder";
t.todo("#CreateFolderChartLookup");
t.todo("#GetFolderCharts");
@@ -198,3 +198,47 @@ t.test("#GetFolderChartIDs", (t) => {
t.end();
});
t.test("#TransposeFolderData", (t) => {
t.strictSame(
TransposeFolderData({
"foo¬bar": 1,
}),
{
"foo.bar": 1,
},
"Should transpose single keys."
);
t.strictSame(
TransposeFolderData({
"foo¬bar": 1,
"foo¬baz": 2,
"foo~bar": 2,
"foo~baz": 2,
}),
{
"foo.bar": 1,
"foo.baz": 2,
foo$bar: 2,
foo$baz: 2,
},
"Should transpose multiple keys."
);
t.strictSame(
TransposeFolderData({
"foo¬bar¬baz": 1,
"foo¬bar~baz": 1,
"foo~bar~baz": 1,
}),
{
"foo.bar$baz": 1,
"foo.bar.baz": 1,
foo$bar$baz: 1,
},
"Should transpose multiple items in one key."
);
t.end();
});
+1 -1
View File
@@ -93,7 +93,7 @@ export function TransposeFolderData(obj: Record<string, unknown>) {
const transposedObj: Record<string, unknown> = {};
for (const key in obj) {
const transposedKey = key.replace(/~/u, "$").replace(/¬/u, ".");
const transposedKey = key.replace(/~/gu, "$").replace(/¬/gu, ".");
if (typeof obj[key] === "object" && obj[key]) {
transposedObj[transposedKey] = TransposeFolderData(obj[key] as Record<string, unknown>);
+10 -1
View File
@@ -37,7 +37,7 @@ export function MStoS(ms: number) {
/**
* Random From Array - Selects a random value from an array.
*/
export function RFA(arr: unknown[]) {
export function RFA<T>(arr: T[]): T {
return arr[Math.floor(Math.random() * arr.length)];
}
@@ -133,3 +133,12 @@ export function Sleep(ms: number) {
export function GetTimeXHoursAgo(hours: integer) {
return Date.now() - ONE_HOUR * hours;
}
export function ApplyNTimes<T>(n: integer, fn: (i: integer) => T): T[] {
const arr = [];
for (let i = 0; i < n; i++) {
arr.push(fn(i));
}
return arr;
}