mirror of
https://github.com/zkldi/Tachi.git
synced 2026-09-29 10:28:04 +03:00
Merge pull request #591 from TNG-dev/release-v2.0.5
This commit is contained in:
+2
-2
@@ -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",
|
||||
|
||||
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.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);
|
||||
});
|
||||
}
|
||||
Vendored
+2
@@ -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) => {
|
||||
|
||||
@@ -8,4 +8,6 @@ export const BMS_TABLES = {
|
||||
satellite: "sl",
|
||||
dpNormal: "δ",
|
||||
dpInsane: "★",
|
||||
scratch: "h◎",
|
||||
ln: "◆",
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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.`,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -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!;
|
||||
|
||||
+3
@@ -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,
|
||||
|
||||
+6
-1
@@ -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];
|
||||
|
||||
+2
@@ -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;
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -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">;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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>);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user