mirror of
https://github.com/zkldi/Tachi.git
synced 2026-10-09 15:19:50 +03:00
Merge pull request #391 from TNG-dev:zkldi/issue-390-KShook-Support
KsHook Support
This commit is contained in:
Vendored
+36
-1
@@ -20,7 +20,6 @@
|
||||
REDIRECT_URI: "https://example.com",
|
||||
},
|
||||
ARC_AUTH_TOKEN: "unused",
|
||||
TYPE: "omni",
|
||||
ENABLE_SERVER_HTTPS: false,
|
||||
RUN_OWN_CDN: true,
|
||||
CDN_WEB_LOCATION: "/cdn",
|
||||
@@ -30,4 +29,40 @@
|
||||
INVITE_CAP: 100,
|
||||
BETA_USER_BONUS: 5,
|
||||
},
|
||||
TACHI_CONFIG: {
|
||||
TYPE: "omni",
|
||||
NAME: "Tachi Testing",
|
||||
GAMES: [
|
||||
"iidx",
|
||||
"museca",
|
||||
"maimai",
|
||||
"sdvx",
|
||||
"ddr",
|
||||
"bms",
|
||||
"chunithm",
|
||||
"usc",
|
||||
],
|
||||
IMPORT_TYPES: [
|
||||
"file/eamusement-iidx-csv",
|
||||
"file/batch-manual",
|
||||
"file/solid-state-squad",
|
||||
"file/mer-iidx",
|
||||
"file/pli-iidx-csv",
|
||||
"ir/direct-manual",
|
||||
"ir/barbatos",
|
||||
"ir/fervidex",
|
||||
"ir/fervidex-static",
|
||||
"ir/beatoraja",
|
||||
"ir/chunitachi",
|
||||
"ir/usc",
|
||||
"ir/kshook-sv3c",
|
||||
"api/arc-iidx",
|
||||
"api/arc-sdvx",
|
||||
"api/eag-iidx",
|
||||
"api/eag-sdvx",
|
||||
"api/flo-iidx",
|
||||
"api/flo-sdvx",
|
||||
"api/min-sdvx",
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
+1
-1
@@ -79,7 +79,7 @@
|
||||
"redis": "3.1.2",
|
||||
"rimraf": "3.0.2",
|
||||
"safe-json-stringify": "1.2.0",
|
||||
"tachi-common": "0.2.36",
|
||||
"tachi-common": "0.2.38",
|
||||
"ts-node": "10.0.0",
|
||||
"typescript": "4.3.4",
|
||||
"winston": "3.3.3",
|
||||
|
||||
Generated
+4
-4
@@ -61,7 +61,7 @@ specifiers:
|
||||
rimraf: 3.0.2
|
||||
safe-json-stringify: 1.2.0
|
||||
supertest: 6.1.3
|
||||
tachi-common: 0.2.36
|
||||
tachi-common: 0.2.38
|
||||
tap: 15.0.9
|
||||
ts-node: 10.0.0
|
||||
tsconfig-paths: 3.10.1
|
||||
@@ -104,7 +104,7 @@ dependencies:
|
||||
redis: 3.1.2
|
||||
rimraf: 3.0.2
|
||||
safe-json-stringify: 1.2.0
|
||||
tachi-common: 0.2.36
|
||||
tachi-common: 0.2.38
|
||||
ts-node: 10.0.0_83f53b0a0c5616d3fa00ed4e30b9ce1b
|
||||
typescript: 4.3.4
|
||||
winston: 3.3.3
|
||||
@@ -4260,8 +4260,8 @@ packages:
|
||||
strip-ansi: 6.0.0
|
||||
dev: true
|
||||
|
||||
/tachi-common/0.2.36:
|
||||
resolution: {integrity: sha512-4/enfkwE8HA4x4vrbNMrhLx2j/8JGnEMwAO5b0zSM150chDZiK30vXOmDW1IHSqxoIRZVuIyRqP7aEyUbf7FKA==}
|
||||
/tachi-common/0.2.38:
|
||||
resolution: {integrity: sha512-hsiutwz/TKjYRcV6TsFDhpPHUalKDF2IVJGX5K9UuAAynU2K/wJ5qHG3xWH4pEjYz0B0Vx263HGY2johCEDAAA==}
|
||||
dependencies:
|
||||
monk: 7.3.4
|
||||
transitivePeerDependencies:
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import monk from "monk";
|
||||
import { ServerConfig } from "lib/setup/config";
|
||||
import { Environment, ServerConfig } from "lib/setup/config";
|
||||
import CreateLogCtx from "lib/logger/logger";
|
||||
|
||||
const logger = CreateLogCtx(__filename);
|
||||
|
||||
logger.info(`Connecting to Old KTDB at ${ServerConfig.MONGO_CONNECTION_URL}/kamaitachidb.`);
|
||||
logger.info(`Connecting to Old KTDB at ${Environment.mongoUrl}/kamaitachidb.`);
|
||||
|
||||
export const oldKTDB = monk(`${ServerConfig.MONGO_CONNECTION_URL}/kamaitachidb`);
|
||||
export const oldKTDB = monk(`${Environment.mongoUrl}/kamaitachidb`);
|
||||
|
||||
oldKTDB
|
||||
.then(() => {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { UserGoalDocument } from "tachi-common";
|
||||
import db from "external/mongo/db";
|
||||
import CreateLogCtx from "../../src/common/logger";
|
||||
import CreateLogCtx from "lib/logger/logger";
|
||||
import MigrateRecords from "./migrate";
|
||||
|
||||
const logger = CreateLogCtx(__filename);
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import db from "external/mongo/db";
|
||||
import CreateLogCtx from "lib/logger/logger";
|
||||
import { ServerTypeInfo } from "lib/setup/config";
|
||||
import { TachiConfig } from "lib/setup/config";
|
||||
|
||||
const logger = CreateLogCtx(__filename);
|
||||
|
||||
if (require.main === module) {
|
||||
(async () => {
|
||||
for (const game of ServerTypeInfo.supportedGames) {
|
||||
for (const game of TachiConfig.GAMES) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const res = await db.charts[game].update(
|
||||
{ tierlistInfo: { $exists: false } },
|
||||
|
||||
@@ -6,7 +6,7 @@ import deepEqual from "deep-equal";
|
||||
import { monkDB } from "external/mongo/db";
|
||||
import fs from "fs";
|
||||
import CreateLogCtx, { KtLogger } from "lib/logger/logger";
|
||||
import { ServerTypeInfo } from "lib/setup/config";
|
||||
import { TachiConfig } from "lib/setup/config";
|
||||
import { BulkWriteOperation } from "mongodb";
|
||||
import { ICollection } from "monk";
|
||||
import os from "os";
|
||||
@@ -119,7 +119,7 @@ const syncInstructions: SyncInstructions[] = [
|
||||
const bwriteOps: BulkWriteOperation<FolderDocument>[] = [];
|
||||
|
||||
for (const folder of folders) {
|
||||
if (!ServerTypeInfo.supportedGames.includes(folder.game)) {
|
||||
if (!TachiConfig.GAMES.includes(folder.game)) {
|
||||
continue; // Skip things for games we don't care about.
|
||||
}
|
||||
|
||||
@@ -162,7 +162,7 @@ const syncInstructions: SyncInstructions[] = [
|
||||
const bwriteOps: BulkWriteOperation<TableDocument>[] = [];
|
||||
|
||||
for (const table of tables) {
|
||||
if (!ServerTypeInfo.supportedGames.includes(table.game)) {
|
||||
if (!TachiConfig.GAMES.includes(table.game)) {
|
||||
continue; // Skip things for games we don't care about.
|
||||
}
|
||||
|
||||
@@ -218,9 +218,9 @@ async function SynchroniseDBWithSeeds() {
|
||||
if (collectionName.startsWith("songs-") || collectionName.startsWith("charts-")) {
|
||||
const game = collectionName.split("-")[1];
|
||||
|
||||
if (!ServerTypeInfo.supportedGames.includes(game as any)) {
|
||||
if (!TachiConfig.GAMES.includes(game as any)) {
|
||||
spawnLogger.info(
|
||||
`Skipping ${collectionName} (${game}) as it isn't for ${ServerTypeInfo.name}.`
|
||||
`Skipping ${collectionName} (${game}) as it isn't for ${TachiConfig.NAME}.`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
Vendored
+2
-2
@@ -2,7 +2,7 @@
|
||||
import monk, { IMonkManager } from "monk";
|
||||
import { IndexOptions } from "mongodb";
|
||||
import CreateLogCtx from "lib/logger/logger";
|
||||
import { ServerTypeInfo } from "lib/setup/config";
|
||||
import { TachiConfig } from "lib/setup/config";
|
||||
import { ONE_DAY } from "lib/constants/time";
|
||||
import { Databases } from "./db";
|
||||
|
||||
@@ -87,7 +87,7 @@ const staticIndexes: Partial<Record<Databases, Index[]>> = {
|
||||
|
||||
const indexes: Partial<Record<Databases, Index[]>> = staticIndexes;
|
||||
|
||||
for (const game of ServerTypeInfo.supportedGames) {
|
||||
for (const game of TachiConfig.GAMES) {
|
||||
if (indexes[`charts-${game}` as Databases]) {
|
||||
indexes[`charts-${game}` as Databases]!.push(
|
||||
index({ chartID: 1 }, UNIQUE),
|
||||
|
||||
Vendored
+2
-2
@@ -10,7 +10,7 @@ import { allIDStrings, allImportTypes } from "tachi-common/js/config/static-conf
|
||||
import p, { PrudenceSchema } from "prudence";
|
||||
import { Databases } from "./db";
|
||||
import { AllPermissions } from "server/middleware/auth";
|
||||
import { ServerTypeInfo } from "lib/setup/config";
|
||||
import { TachiConfig } from "lib/setup/config";
|
||||
import { IsValidGame, IsValidPlaytype } from "utils/misc";
|
||||
import { optNull } from "utils/prudence";
|
||||
|
||||
@@ -32,7 +32,7 @@ function prSchemaify(schema: PrudenceSchema) {
|
||||
};
|
||||
}
|
||||
|
||||
const games = ServerTypeInfo.supportedGames;
|
||||
const games = TachiConfig.GAMES;
|
||||
const isValidPlaytype = (self: unknown, parent: Record<string, unknown>) => {
|
||||
if (!parent.game || typeof parent.game !== "string" || !IsValidGame(parent.game)) {
|
||||
throw new Error(`Invalid Schema, need game to base IsValidPlaytype off of.`);
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
import redis from "redis";
|
||||
import CreateLogCtx from "lib/logger/logger";
|
||||
import { ServerConfig } from "lib/setup/config";
|
||||
import { ServerConfig, TachiConfig } from "lib/setup/config";
|
||||
|
||||
/**
|
||||
* This code has been stubbed out! It doesn't really have a use at the moment.
|
||||
@@ -33,7 +33,7 @@ const SubCallbacks: Partial<SubCallbacks> = {};
|
||||
const SubClient = redis.createClient();
|
||||
const PubClient = redis.createClient();
|
||||
|
||||
const PREFIX = ServerConfig.TYPE.toUpperCase(); // KTCHI or BTCHI
|
||||
const PREFIX = TachiConfig.NAME.toUpperCase();
|
||||
|
||||
export function RedisPub<T extends RedisIPCChannels>(channel: T, data: RedisIPCData[T]) {
|
||||
PubClient.publish(`${PREFIX}-${channel}`, JSON.stringify(data));
|
||||
|
||||
@@ -8,3 +8,5 @@ export const REV_2DXBMS = "Z";
|
||||
export const REV_OMNIMIX = "X";
|
||||
|
||||
export const EXT_HEROIC_VERSE = "2020092900";
|
||||
|
||||
export const MODEL_SDVX3_KONASTE = "QCV";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Email HTML is a hellish mess of IE5 era nonsense.
|
||||
// Good luck.
|
||||
|
||||
import { ServerConfig, ServerTypeInfo } from "lib/setup/config";
|
||||
import { ServerConfig, TachiConfig, TachiServerConfig } from "lib/setup/config";
|
||||
|
||||
export function EmailFormatResetPassword(username: string, resetCode: string, ipAddr: string) {
|
||||
return MainHTMLWrapper(
|
||||
@@ -23,7 +23,7 @@ export function MainHTMLWrapper(innerHTML: string) {
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="Content-Type" content="text/html charset=UTF-8" />
|
||||
<title>${ServerTypeInfo.name}</title>
|
||||
<title>${TachiConfig.NAME}</title>
|
||||
</head>
|
||||
<body>
|
||||
${innerHTML}
|
||||
|
||||
@@ -29,7 +29,7 @@ import { DryScore } from "../common/types";
|
||||
import { OrphanScore } from "../orphans/orphans";
|
||||
|
||||
/**
|
||||
* Processes the iterable data into the Kamaitachi database.
|
||||
* Processes the iterable data into the Tachi database.
|
||||
* @param userID - The user this score import was for.
|
||||
* @param iterableData - The data to iterate upon.
|
||||
* @param ConverterFunction - The function needed to convert the data into an IntermediateScore
|
||||
@@ -254,7 +254,7 @@ export async function ProcessSuccessfulConverterReturn(
|
||||
}
|
||||
|
||||
/**
|
||||
* Hydrates and inserts a score to the Kamaitachi database.
|
||||
* Hydrates and inserts a score to the Tachi database.
|
||||
* @param userID - The user this score is from.
|
||||
* @param dryScore - The score that is to be hydrated and inserted.
|
||||
* @param chart - The chart this score is on.
|
||||
|
||||
@@ -2,7 +2,7 @@ import { KtLogger } from "lib/logger/logger";
|
||||
import ScoreImportFatalError from "../../../framework/score-importing/score-import-error";
|
||||
import nodeFetch from "utils/fetch";
|
||||
import { VERSION_STR } from "lib/constants/version";
|
||||
import { ServerTypeInfo } from "lib/setup/config";
|
||||
import { TachiConfig } from "lib/setup/config";
|
||||
|
||||
/**
|
||||
* A Kai Reauth function is an async function that returns a string
|
||||
@@ -70,7 +70,7 @@ export async function* TraverseKaiAPI(
|
||||
res = await fetch(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"User-Agent": `${ServerTypeInfo.name}/${VERSION_STR}`,
|
||||
"User-Agent": `${TachiConfig.NAME}/${VERSION_STR}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
@@ -127,7 +127,7 @@ export async function* TraverseKaiAPI(
|
||||
|
||||
throw new ScoreImportFatalError(
|
||||
500,
|
||||
`Recieved no _links prop from ${url}. This is not an error with ${ServerTypeInfo.name}.`
|
||||
`Recieved no _links prop from ${url}. This is not an error with ${TachiConfig.NAME}.`
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
import deepmerge from "deepmerge";
|
||||
import { FormatPrError } from "utils/prudence";
|
||||
import { ParserFunctionReturns } from "../types";
|
||||
import { ServerTypeInfo } from "lib/setup/config";
|
||||
import { TachiConfig } from "lib/setup/config";
|
||||
|
||||
const optNull = (v: ValidSchemaValue) => p.optional(p.nullable(v));
|
||||
|
||||
@@ -172,7 +172,7 @@ const PR_BatchManualScore = (game: Game, playtype: Playtypes[Game]): PrudenceSch
|
||||
const PR_BatchManual = (game: Game, playtype: Playtypes[Game]): PrudenceSchema => ({
|
||||
meta: {
|
||||
service: p.isBoundedString(3, 15),
|
||||
game: p.isIn(ServerTypeInfo.supportedGames),
|
||||
game: p.isIn(TachiConfig.GAMES),
|
||||
playtype: p.is(playtype),
|
||||
version: "*?string",
|
||||
},
|
||||
@@ -222,12 +222,10 @@ export function ParseBatchManualFromObject(
|
||||
);
|
||||
}
|
||||
|
||||
if (!ServerTypeInfo.supportedGames.includes(possiblyGame)) {
|
||||
if (!TachiConfig.GAMES.includes(possiblyGame)) {
|
||||
throw new ScoreImportFatalError(
|
||||
400,
|
||||
`Invalid game ${possiblyGame} - expected any of ${ServerTypeInfo.supportedGames.join(
|
||||
", "
|
||||
)}.`
|
||||
`Invalid game ${possiblyGame} - expected any of ${TachiConfig.GAMES.join(", ")}.`
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ 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";
|
||||
export interface ImportTypeDataMap {
|
||||
"file/eamusement-iidx-csv": IIDXEamusementCSVData;
|
||||
"file/batch-manual": BatchManualScore;
|
||||
@@ -37,6 +38,7 @@ export interface ImportTypeDataMap {
|
||||
"ir/chunitachi": BatchManualScore;
|
||||
"ir/beatoraja": BeatorajaScore;
|
||||
"ir/usc": USCClientScore;
|
||||
"ir/kshook-sv3c": KsHookSV3CScore;
|
||||
|
||||
// These aren't placeholder values - the data is yielded in a way that
|
||||
// the value of these is legitimately unknown at convert time.
|
||||
@@ -63,6 +65,7 @@ export interface ImportTypeContextMap {
|
||||
"ir/chunitachi": BatchManualContext;
|
||||
"ir/beatoraja": BeatorajaContext;
|
||||
"ir/usc": IRUSCContext;
|
||||
"ir/kshook-sv3c": EmptyObject;
|
||||
|
||||
"api/arc-iidx": EmptyObject;
|
||||
"api/arc-sdvx": EmptyObject;
|
||||
|
||||
@@ -12,6 +12,7 @@ import { ConverterIRBarbatos } from "./ir/barbatos/converter";
|
||||
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 { ConverterIRUSC } from "./ir/usc/converter";
|
||||
|
||||
export type ConverterMap = {
|
||||
@@ -40,4 +41,5 @@ export const Converters: ConverterMap = {
|
||||
"ir/direct-manual": ConverterBatchManual,
|
||||
"ir/usc": ConverterIRUSC,
|
||||
"ir/chunitachi": ConverterBatchManual,
|
||||
"ir/kshook-sv3c": ConverterIRKsHookSV3C,
|
||||
};
|
||||
|
||||
@@ -33,11 +33,6 @@ t.test("#ConverterIRBarbatos", (t) => {
|
||||
playtype: "Single",
|
||||
levelNum: 10,
|
||||
level: "10",
|
||||
flags: {
|
||||
"IN BASE GAME": true,
|
||||
OMNIMIX: false,
|
||||
"N-1": false,
|
||||
},
|
||||
data: {
|
||||
inGameID: 1,
|
||||
},
|
||||
|
||||
@@ -12,7 +12,7 @@ import { BeatorajaContext, BeatorajaScore, BeatorajaChart } from "./types";
|
||||
import { ChartDocument, SongDocument } from "tachi-common";
|
||||
import { HandleOrphanQueue } from "lib/orphan-queue/orphan-queue";
|
||||
import { Random20Hex } from "utils/misc";
|
||||
import { ServerConfig, ServerTypeInfo } from "lib/setup/config";
|
||||
import { ServerConfig, TachiConfig } from "lib/setup/config";
|
||||
|
||||
const LAMP_LOOKUP = {
|
||||
NoPlay: "NO PLAY",
|
||||
@@ -56,9 +56,7 @@ export const ConverterIRBeatoraja: ConverterFunction<BeatorajaScore, BeatorajaCo
|
||||
// check, remember to change the entire score import
|
||||
// framework and database to be able to handle variable notecounts.
|
||||
logger.verbose(`Declined to orphan chart ${chartName} as it has #RANDOM declarations.`);
|
||||
throw new InvalidScoreFailure(
|
||||
`${ServerTypeInfo.name} will not support #RANDOM charts.`
|
||||
);
|
||||
throw new InvalidScoreFailure(`${TachiConfig.NAME} will not support #RANDOM charts.`);
|
||||
}
|
||||
|
||||
const idString = context.chart.mode === "BEAT_7K" ? "bms:7K" : "bms:14K";
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import CreateLogCtx from "lib/logger/logger";
|
||||
import t from "tap";
|
||||
import ResetDBState from "test-utils/resets";
|
||||
import { TestingKsHookSV3CScore } from "test-utils/test-data";
|
||||
import { ConverterIRKsHookSV3C } from "./converter";
|
||||
|
||||
const logger = CreateLogCtx(__filename);
|
||||
|
||||
t.test("#ConverterIRKsHookSV3C", (t) => {
|
||||
t.beforeEach(ResetDBState);
|
||||
|
||||
t.test("Should match a score with its song and chart.", async (t) => {
|
||||
const res = await ConverterIRKsHookSV3C(
|
||||
TestingKsHookSV3CScore,
|
||||
{},
|
||||
"ir/kshook-sv3c",
|
||||
logger
|
||||
);
|
||||
|
||||
t.hasStrict(res, {
|
||||
song: {
|
||||
id: 1,
|
||||
},
|
||||
chart: {
|
||||
data: {
|
||||
inGameID: 1,
|
||||
},
|
||||
difficulty: "ADV",
|
||||
},
|
||||
dryScore: {
|
||||
scoreData: {
|
||||
score: 9_579_365,
|
||||
grade: "AA+",
|
||||
lamp: "EXCESSIVE CLEAR",
|
||||
judgements: {
|
||||
critical: 1184,
|
||||
near: 46,
|
||||
miss: 30,
|
||||
},
|
||||
hitMeta: {
|
||||
slow: 10,
|
||||
fast: 36,
|
||||
maxCombo: 158,
|
||||
},
|
||||
},
|
||||
game: "sdvx",
|
||||
importType: "ir/kshook-sv3c",
|
||||
scoreMeta: {},
|
||||
},
|
||||
});
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Should throw an error if song or chart can't be found.", (t) => {
|
||||
t.rejects(
|
||||
() =>
|
||||
ConverterIRKsHookSV3C(
|
||||
Object.assign({}, TestingKsHookSV3CScore, { music_id: 10000 }),
|
||||
{},
|
||||
"ir/kshook-sv3c",
|
||||
logger
|
||||
),
|
||||
"Should throw a KTDataNotFoundError if chart can't be found."
|
||||
);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.end();
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import { FindSDVXChartOnInGameIDVersion } from "utils/queries/charts";
|
||||
import { EmptyObject } from "utils/types";
|
||||
import { ConverterFunction } from "../../common/types";
|
||||
import { KsHookSV3CScore } from "./types";
|
||||
import { Lamps } from "tachi-common";
|
||||
import {
|
||||
InternalFailure,
|
||||
KTDataNotFoundFailure,
|
||||
} from "lib/score-import/framework/common/converter-failures";
|
||||
import { FindSongOnID } from "utils/queries/songs";
|
||||
import { GenericGetGradeAndPercent } from "lib/score-import/framework/common/score-utils";
|
||||
import { DryScore } from "lib/score-import/framework/common/types";
|
||||
|
||||
export const ConverterIRKsHookSV3C: ConverterFunction<KsHookSV3CScore, EmptyObject> = async (
|
||||
data,
|
||||
context,
|
||||
importType,
|
||||
logger
|
||||
) => {
|
||||
const diff = ConvertDifficulty(data.difficulty);
|
||||
|
||||
const chart = await FindSDVXChartOnInGameIDVersion(data.music_id, diff, "konaste");
|
||||
|
||||
if (!chart) {
|
||||
throw new KTDataNotFoundFailure(
|
||||
`Could not find chart with songID ${data.music_id} (${diff} for Konaste).`,
|
||||
importType,
|
||||
data,
|
||||
context
|
||||
);
|
||||
}
|
||||
|
||||
const song = await FindSongOnID("sdvx", chart.songID);
|
||||
|
||||
if (!song) {
|
||||
logger.severe(`Song ${chart.songID} (sdvx) has no parent song?`);
|
||||
throw new InternalFailure(`Song ${chart.songID} (sdvx) has no parent song?`);
|
||||
}
|
||||
|
||||
const { percent, grade } = GenericGetGradeAndPercent("sdvx", data.score, chart);
|
||||
|
||||
const dryScore: DryScore<"sdvx:Single"> = {
|
||||
game: "sdvx",
|
||||
service: "kshook SV3C",
|
||||
comment: null,
|
||||
importType: "ir/kshook-sv3c",
|
||||
timeAchieved: Date.now(),
|
||||
scoreData: {
|
||||
score: data.score,
|
||||
percent,
|
||||
grade,
|
||||
lamp: ConvertLamp(data.clear),
|
||||
judgements: {
|
||||
critical: data.critical,
|
||||
near: data.near,
|
||||
miss: data.error,
|
||||
},
|
||||
hitMeta: {
|
||||
fast: data.early,
|
||||
slow: data.late,
|
||||
gauge: data.gauge,
|
||||
maxCombo: data.max_chain,
|
||||
},
|
||||
},
|
||||
scoreMeta: {},
|
||||
};
|
||||
|
||||
return { song, chart, dryScore };
|
||||
};
|
||||
|
||||
function ConvertLamp(clear: KsHookSV3CScore["clear"]): Lamps["sdvx:Single"] {
|
||||
if (clear === "CLEAR_PLAYED") {
|
||||
return "FAILED";
|
||||
} else if (clear === "CLEAR_EFFECTIVE") {
|
||||
return "CLEAR";
|
||||
} else if (clear === "CLEAR_EXCESSIVE") {
|
||||
return "EXCESSIVE CLEAR";
|
||||
} else if (clear === "CLEAR_ULTIMATE_CHAIN") {
|
||||
return "ULTIMATE CHAIN";
|
||||
}
|
||||
|
||||
return "PERFECT ULTIMATE CHAIN";
|
||||
}
|
||||
|
||||
function ConvertDifficulty(
|
||||
diff: KsHookSV3CScore["difficulty"]
|
||||
): "NOV" | "ADV" | "EXH" | "ANY_INF" | "MXM" {
|
||||
if (diff === "DIFFICULTY_NOVICE") {
|
||||
return "NOV";
|
||||
} else if (diff === "DIFFICULTY_ADVANCED") {
|
||||
return "ADV";
|
||||
} else if (diff === "DIFFICULTY_EXHAUST") {
|
||||
return "EXH";
|
||||
} else if (diff === "DIFFICULTY_INFINITE") {
|
||||
return "ANY_INF";
|
||||
}
|
||||
|
||||
return "MXM";
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { SDVXDans } from "lib/constants/classes";
|
||||
import CreateLogCtx from "lib/logger/logger";
|
||||
import t from "tap";
|
||||
import { TestingKsHookSV3CScore } from "test-utils/test-data";
|
||||
import { ConvertSkillLevel, ParseKsHookSV3C } from "./parser";
|
||||
import deepmerge from "deepmerge";
|
||||
|
||||
const logger = CreateLogCtx(__filename);
|
||||
|
||||
t.test("#ConvertSkillLevel", (t) => {
|
||||
t.equal(ConvertSkillLevel("SKILL_LEVEL_01"), SDVXDans.DAN_1);
|
||||
t.equal(ConvertSkillLevel("SKILL_LEVEL_02"), SDVXDans.DAN_2);
|
||||
t.equal(ConvertSkillLevel("SKILL_LEVEL_03"), SDVXDans.DAN_3);
|
||||
t.equal(ConvertSkillLevel("SKILL_LEVEL_04"), SDVXDans.DAN_4);
|
||||
t.equal(ConvertSkillLevel("SKILL_LEVEL_05"), SDVXDans.DAN_5);
|
||||
t.equal(ConvertSkillLevel("SKILL_LEVEL_06"), SDVXDans.DAN_6);
|
||||
t.equal(ConvertSkillLevel("SKILL_LEVEL_07"), SDVXDans.DAN_7);
|
||||
t.equal(ConvertSkillLevel("SKILL_LEVEL_08"), SDVXDans.DAN_8);
|
||||
t.equal(ConvertSkillLevel("SKILL_LEVEL_09"), SDVXDans.DAN_9);
|
||||
t.equal(ConvertSkillLevel("SKILL_LEVEL_10"), SDVXDans.DAN_10);
|
||||
t.equal(ConvertSkillLevel("SKILL_LEVEL_11"), SDVXDans.DAN_11);
|
||||
t.equal(ConvertSkillLevel("SKILL_LEVEL_12"), SDVXDans.INF);
|
||||
t.equal(ConvertSkillLevel("SKILL_LEVEL_NONE"), null);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("#ParseKsHookSV3C", (t) => {
|
||||
const assertFail = (data: any, message: string) => {
|
||||
t.throws(() => ParseKsHookSV3C(data, logger), message);
|
||||
};
|
||||
|
||||
const assertSuccess = (data: any, message: string) => {
|
||||
try {
|
||||
t.doesNotThrow(() => ParseKsHookSV3C(data, logger), message);
|
||||
|
||||
const res = ParseKsHookSV3C(data, logger);
|
||||
|
||||
t.equal(res.game, "sdvx");
|
||||
t.strictSame(res.context, {});
|
||||
t.ok(Array.isArray(res.iterable));
|
||||
t.type(res.classHandler, "function");
|
||||
} catch (err) {
|
||||
t.fail(`[${message}] ${err.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const dm = (data: any) => deepmerge(TestingKsHookSV3CScore, data);
|
||||
|
||||
assertSuccess(TestingKsHookSV3CScore, "Should parse a valid score.");
|
||||
assertSuccess(
|
||||
dm({ unexpectedField: "foo" }),
|
||||
"Should allow excess keys that we do not recognise."
|
||||
);
|
||||
|
||||
assertFail({}, "Should reject an empty object");
|
||||
assertFail(dm({ skill_level: "invalid_skill_level" }), "Should reject invalid skill levels.");
|
||||
assertFail(dm({ appeal_id: 0.5 }), "Should reject non integer appeal_id.");
|
||||
assertFail(dm({ appeal_id: -1 }), "Should reject negative integer appeal_id.");
|
||||
assertFail(dm({ clear: "invalid_clear" }), "Should reject invalid clears.");
|
||||
assertFail(dm({ difficulty: "invalid_difficulty" }), "Should reject invalid difficulties.");
|
||||
|
||||
assertFail(dm({ early: -1 }), "Should reject negative early counts.");
|
||||
assertFail(dm({ late: -1 }), "Should reject negative late counts.");
|
||||
assertFail(dm({ early: 1.5 }), "Should reject non-integer early counts.");
|
||||
assertFail(dm({ late: 1.5 }), "Should reject non-integer late counts.");
|
||||
|
||||
assertFail(dm({ gauge: -1 }), "Should reject negative gauge values.");
|
||||
assertFail(dm({ gauge: 101 }), "Should reject gauge values over 100.");
|
||||
assertSuccess(dm({ gauge: 0 }), "Should allow gauge values of 0.");
|
||||
assertSuccess(dm({ gauge: 100 }), "Should allow gauge values of 100.");
|
||||
|
||||
assertFail(dm({ grade: "invalid_grade" }), "Should reject invalid grades.");
|
||||
|
||||
assertFail(dm({ max_chain: -1 }), "Should reject negative max_chains.");
|
||||
assertFail(dm({ max_chain: 100.5 }), "Should reject non-integer max_chains.");
|
||||
|
||||
assertFail(dm({ btn_rate: -1 }), "Should reject negative btn_rates.");
|
||||
assertFail(dm({ btn_rate: 200.1 }), "Should reject btn_rates over 200.");
|
||||
assertSuccess(dm({ btn_rate: 0 }), "Should allow btn_rate values of 0.");
|
||||
assertSuccess(dm({ btn_rate: 200 }), "Should allow btn_rate values of 200.");
|
||||
assertFail(dm({ vol_rate: -1 }), "Should reject negative vol_rates.");
|
||||
assertFail(dm({ vol_rate: 200.1 }), "Should reject vol_rates over 200.");
|
||||
assertSuccess(dm({ vol_rate: 0 }), "Should allow vol_rate values of 0.");
|
||||
assertSuccess(dm({ vol_rate: 200 }), "Should allow vol_rate values of 200.");
|
||||
assertFail(dm({ long_rate: -1 }), "Should reject negative long_rates.");
|
||||
assertFail(dm({ long_rate: 200.1 }), "Should reject long_rates over 200.");
|
||||
assertSuccess(dm({ long_rate: 0 }), "Should allow long_rate values of 0.");
|
||||
assertSuccess(dm({ long_rate: 200 }), "Should allow long_rate values of 200.");
|
||||
|
||||
assertFail(dm({ player_name: null }), "Should reject non-string player_names.");
|
||||
assertFail(dm({ rate: "invalid_rate" }), "Should reject invalid rates.");
|
||||
|
||||
assertFail(dm({ skill_frame: "invalid_skill_frame" }), "Should reject invalid skill_frames.");
|
||||
assertFail(dm({ skill_level: "invalid_skill_level" }), "Should reject invalid skill_levels.");
|
||||
|
||||
assertFail(dm({ track_no: -1 }), "Should reject negative track_no's.");
|
||||
assertFail(dm({ track_no: 50.5 }), "Should reject non-int track_no's.");
|
||||
|
||||
t.end();
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
import { SDVXDans } from "lib/constants/classes";
|
||||
import { KtLogger } from "lib/logger/logger";
|
||||
import ScoreImportFatalError from "lib/score-import/framework/score-importing/score-import-error";
|
||||
import p, { PrudenceSchema } from "prudence";
|
||||
import { FormatPrError } from "utils/prudence";
|
||||
import { EmptyObject } from "utils/types";
|
||||
import { ParserFunctionReturns } from "../../common/types";
|
||||
import { KsHookSV3CScore } from "./types";
|
||||
|
||||
const PR_KsHookSV3C: PrudenceSchema = {
|
||||
appeal_id: p.isPositiveInteger,
|
||||
clear: p.isIn(
|
||||
"CLEAR_PLAYED",
|
||||
"CLEAR_EFFECTIVE",
|
||||
"CLEAR_EXCESSIVE",
|
||||
"CLEAR_ULTIMATE_CHAIN",
|
||||
"CLEAR_PERFECT"
|
||||
),
|
||||
difficulty: p.isIn(
|
||||
"DIFFICULTY_NOVICE",
|
||||
"DIFFICULTY_ADVANCED",
|
||||
"DIFFICULTY_EXHAUST",
|
||||
"DIFFICULTY_INFINITE",
|
||||
"DIFFICULTY_MAXIMUM"
|
||||
),
|
||||
early: p.isPositiveInteger,
|
||||
late: p.isPositiveInteger,
|
||||
gauge: p.isBetween(0, 100),
|
||||
grade: p.isIn(
|
||||
"GRADE_D",
|
||||
"GRADE_C",
|
||||
"GRADE_B",
|
||||
"GRADE_A",
|
||||
"GRADE_A_PLUS",
|
||||
"GRADE_AA",
|
||||
"GRADE_AA_PLUS",
|
||||
"GRADE_AAA",
|
||||
"GRADE_AAA_PLUS",
|
||||
"GRADE_S"
|
||||
),
|
||||
max_chain: p.isPositiveInteger,
|
||||
music_id: p.isPositiveInteger,
|
||||
|
||||
btn_rate: p.isBetween(0, 200),
|
||||
long_rate: p.isBetween(0, 200),
|
||||
vol_rate: p.isBetween(0, 200),
|
||||
|
||||
player_name: "string",
|
||||
rate: p.isIn("RATE_EFFECTIVE", "RATE_PERMISSIVE", "RATE_EXCESSIVE", "RATE_BLASTIVE"),
|
||||
|
||||
skill_frame: p.isIn(
|
||||
"SKILL_FRAME_NONE",
|
||||
"SKILL_FRAME_SILVER",
|
||||
"SKILL_FRAME_GOLD",
|
||||
"SKILL_FRAME_GOLD_HALO"
|
||||
),
|
||||
skill_level: p.isIn(
|
||||
"SKILL_LEVEL_NONE",
|
||||
"SKILL_LEVEL_01",
|
||||
"SKILL_LEVEL_02",
|
||||
"SKILL_LEVEL_03",
|
||||
"SKILL_LEVEL_04",
|
||||
"SKILL_LEVEL_05",
|
||||
"SKILL_LEVEL_06",
|
||||
"SKILL_LEVEL_07",
|
||||
"SKILL_LEVEL_08",
|
||||
"SKILL_LEVEL_09",
|
||||
"SKILL_LEVEL_10",
|
||||
"SKILL_LEVEL_11",
|
||||
"SKILL_LEVEL_12"
|
||||
),
|
||||
track_no: p.isPositiveInteger,
|
||||
};
|
||||
|
||||
export function ParseKsHookSV3C(
|
||||
body: Record<string, unknown>,
|
||||
logger: KtLogger
|
||||
): ParserFunctionReturns<KsHookSV3CScore, EmptyObject> {
|
||||
// Ignore excess keys, as SV3C might add more features in the future.
|
||||
const err = p(body, PR_KsHookSV3C, undefined, { allowExcessKeys: true });
|
||||
|
||||
if (err) {
|
||||
throw new ScoreImportFatalError(400, FormatPrError(err));
|
||||
}
|
||||
|
||||
const score = body as unknown as KsHookSV3CScore;
|
||||
|
||||
return {
|
||||
game: "sdvx",
|
||||
iterable: [score],
|
||||
context: {},
|
||||
classHandler: () => {
|
||||
const skillLevel = ConvertSkillLevel(score.skill_level);
|
||||
|
||||
// If null, the player doesn't have a dan.
|
||||
if (!skillLevel) {
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
dan: skillLevel,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function ConvertSkillLevel(skill: KsHookSV3CScore["skill_level"]): SDVXDans | null {
|
||||
switch (skill) {
|
||||
case "SKILL_LEVEL_01":
|
||||
return SDVXDans.DAN_1;
|
||||
case "SKILL_LEVEL_02":
|
||||
return SDVXDans.DAN_2;
|
||||
case "SKILL_LEVEL_03":
|
||||
return SDVXDans.DAN_3;
|
||||
case "SKILL_LEVEL_04":
|
||||
return SDVXDans.DAN_4;
|
||||
case "SKILL_LEVEL_05":
|
||||
return SDVXDans.DAN_5;
|
||||
case "SKILL_LEVEL_06":
|
||||
return SDVXDans.DAN_6;
|
||||
case "SKILL_LEVEL_07":
|
||||
return SDVXDans.DAN_7;
|
||||
case "SKILL_LEVEL_08":
|
||||
return SDVXDans.DAN_8;
|
||||
case "SKILL_LEVEL_09":
|
||||
return SDVXDans.DAN_9;
|
||||
case "SKILL_LEVEL_10":
|
||||
return SDVXDans.DAN_10;
|
||||
case "SKILL_LEVEL_11":
|
||||
return SDVXDans.DAN_11;
|
||||
case "SKILL_LEVEL_12":
|
||||
return SDVXDans.INF;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { integer } from "tachi-common";
|
||||
|
||||
export interface KsHookSV3CScore {
|
||||
appeal_id: integer;
|
||||
clear:
|
||||
| "CLEAR_PLAYED"
|
||||
| "CLEAR_EFFECTIVE"
|
||||
| "CLEAR_EXCESSIVE"
|
||||
| "CLEAR_ULTIMATE_CHAIN"
|
||||
| "CLEAR_PERFECT";
|
||||
difficulty: `DIFFICULTY_${"NOVICE" | "ADVANCED" | "EXHAUST" | "INFINITE" | "MAXIMUM"}`;
|
||||
early: integer;
|
||||
gauge: integer;
|
||||
grade: `GRADE_${
|
||||
| "D"
|
||||
| "C"
|
||||
| "B"
|
||||
| "A"
|
||||
| "A_PLUS"
|
||||
| "AA"
|
||||
| "AA_PLUS"
|
||||
| "AAA"
|
||||
| "AAA_PLUS"
|
||||
| "S"}`;
|
||||
late: integer;
|
||||
max_chain: integer;
|
||||
music_id: integer;
|
||||
|
||||
btn_rate: integer;
|
||||
long_rate: integer;
|
||||
vol_rate: integer;
|
||||
|
||||
critical: integer;
|
||||
near: integer;
|
||||
error: integer;
|
||||
|
||||
player_name: string;
|
||||
rate: `RATE_${"EFFECTIVE" | "PERMISSIVE" | "EXCESSIVE" | "BLASTIVE"}`;
|
||||
score: integer;
|
||||
|
||||
skill_frame: `SKILL_FRAME_${"NONE" | "SILVER" | "GOLD" | "GOLD_HALO"}`;
|
||||
skill_level: `SKILL_LEVEL_${
|
||||
| "NONE"
|
||||
| "01"
|
||||
| "02"
|
||||
| "03"
|
||||
| "04"
|
||||
| "05"
|
||||
| "06"
|
||||
| "07"
|
||||
| "08"
|
||||
| "09"
|
||||
| "10"
|
||||
| "11"
|
||||
| "12"}`;
|
||||
skill_name: integer;
|
||||
track_no: integer;
|
||||
}
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
} from "tachi-common";
|
||||
import { EscapeStringRegexp } from "utils/misc";
|
||||
import { GetOnlineCutoff } from "utils/user";
|
||||
import { ServerTypeInfo } from "lib/setup/config";
|
||||
import { TachiConfig } from "lib/setup/config";
|
||||
|
||||
const logger = CreateLogCtx(__filename);
|
||||
|
||||
@@ -164,7 +164,7 @@ async function SearchAllGamesSingleGame(game: Game, search: string) {
|
||||
export async function SearchAllGamesSongs(search: string) {
|
||||
const promises = [];
|
||||
|
||||
for (const game of ServerTypeInfo.supportedGames) {
|
||||
for (const game of TachiConfig.GAMES) {
|
||||
promises.push(SearchAllGamesSingleGame(game, search));
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import fs from "fs";
|
||||
import JSON5 from "json5";
|
||||
import { SendMailOptions } from "nodemailer";
|
||||
import p from "prudence";
|
||||
import { integer, StaticConfig } from "tachi-common";
|
||||
import { integer, StaticConfig, Game, ImportTypes } from "tachi-common";
|
||||
import { FormatPrError } from "utils/prudence";
|
||||
|
||||
dotenv.config(); // imports things like NODE_ENV from a local .env file if one is present.
|
||||
@@ -45,7 +45,7 @@ export interface OAuth2Info {
|
||||
REDIRECT_URI: string;
|
||||
}
|
||||
|
||||
export interface TachiConfig {
|
||||
export interface TachiServerConfig {
|
||||
MONGO_DATABASE_NAME: string;
|
||||
LOG_LEVEL: "debug" | "verbose" | "info" | "warn" | "error" | "severe" | "crit";
|
||||
CAPTCHA_SECRET_KEY: string;
|
||||
@@ -58,11 +58,9 @@ export interface TachiConfig {
|
||||
EAG_OAUTH2_INFO?: OAuth2Info;
|
||||
MIN_OAUTH2_INFO?: OAuth2Info;
|
||||
ARC_AUTH_TOKEN?: string;
|
||||
TYPE: "ktchi" | "btchi" | "omni";
|
||||
ENABLE_SERVER_HTTPS?: boolean;
|
||||
RUN_OWN_CDN?: boolean;
|
||||
CLIENT_DEV_SERVER?: string | null;
|
||||
SERVER_TYPE_INFO: StaticConfig.ServerConfig;
|
||||
RATE_LIMIT: integer;
|
||||
OAUTH_CLIENT_CAP: integer;
|
||||
OPTIONS_ALWAYS_SUCCEEDS?: boolean;
|
||||
@@ -88,6 +86,12 @@ export interface TachiConfig {
|
||||
INVITE_CAP: integer;
|
||||
BETA_USER_BONUS: integer;
|
||||
};
|
||||
TACHI_CONFIG: {
|
||||
NAME: string;
|
||||
TYPE: "ktchi" | "btchi" | "omni";
|
||||
GAMES: Game[];
|
||||
IMPORT_TYPES: ImportTypes[];
|
||||
};
|
||||
}
|
||||
|
||||
const isValidOauth2 = p.optional({
|
||||
@@ -112,7 +116,6 @@ const err = p(config, {
|
||||
ENABLE_SERVER_HTTPS: "*boolean",
|
||||
RUN_OWN_CDN: "*boolean",
|
||||
CLIENT_DEV_SERVER: "*?string",
|
||||
TYPE: p.isIn("ktchi", "btchi", "omni"),
|
||||
RATE_LIMIT: p.optional(p.isPositiveInteger),
|
||||
OAUTH_CLIENT_CAP: p.optional(p.isPositiveInteger),
|
||||
OPTIONS_ALWAYS_SUCCEEDS: "*boolean",
|
||||
@@ -136,30 +139,28 @@ const err = p(config, {
|
||||
INVITE_CAP: p.isPositiveInteger,
|
||||
BETA_USER_BONUS: p.isPositiveInteger,
|
||||
}),
|
||||
TACHI_CONFIG: {
|
||||
NAME: "string",
|
||||
TYPE: p.isIn("ktchi", "btchi", "omni"),
|
||||
GAMES: [p.isIn(StaticConfig.allSupportedGames)],
|
||||
IMPORT_TYPES: [p.isIn(StaticConfig.allImportTypes)],
|
||||
},
|
||||
});
|
||||
|
||||
if (err) {
|
||||
throw FormatPrError(err, "Invalid conf.json5 file.");
|
||||
}
|
||||
|
||||
if (config.TYPE === "ktchi") {
|
||||
config.SERVER_TYPE_INFO = StaticConfig.KTCHI_CONFIG;
|
||||
} else if (config.TYPE === "btchi") {
|
||||
config.SERVER_TYPE_INFO = StaticConfig.BTCHI_CONFIG;
|
||||
} else if (config.TYPE === "omni") {
|
||||
config.SERVER_TYPE_INFO = StaticConfig.OMNI_CONFIG;
|
||||
}
|
||||
|
||||
const tachiConfig = config as TachiConfig;
|
||||
const tachiServerConfig = config as TachiServerConfig;
|
||||
|
||||
// default rate limit 500
|
||||
tachiConfig.RATE_LIMIT ??= 500;
|
||||
tachiConfig.OAUTH_CLIENT_CAP ??= 15;
|
||||
tachiConfig.USC_QUEUE_SIZE ??= 3;
|
||||
tachiConfig.BEATORAJA_QUEUE_SIZE ??= 3;
|
||||
tachiServerConfig.RATE_LIMIT ??= 500;
|
||||
tachiServerConfig.OAUTH_CLIENT_CAP ??= 15;
|
||||
tachiServerConfig.USC_QUEUE_SIZE ??= 3;
|
||||
tachiServerConfig.BEATORAJA_QUEUE_SIZE ??= 3;
|
||||
|
||||
export const ServerTypeInfo = tachiConfig.SERVER_TYPE_INFO;
|
||||
export const ServerConfig = tachiConfig;
|
||||
export const TachiConfig = tachiServerConfig.TACHI_CONFIG;
|
||||
export const ServerConfig = tachiServerConfig;
|
||||
|
||||
// Environment Variable Validation
|
||||
|
||||
|
||||
+2
-2
@@ -5,7 +5,7 @@ import { InitSequenceDocs } from "external/mongo/sequence-docs";
|
||||
import fs from "fs";
|
||||
import https from "https";
|
||||
import CreateLogCtx from "lib/logger/logger";
|
||||
import { Environment, ServerConfig, ServerTypeInfo } from "lib/setup/config";
|
||||
import { Environment, ServerConfig, TachiConfig, TachiServerConfig } from "lib/setup/config";
|
||||
import path from "path";
|
||||
import server from "server/server";
|
||||
import { InitaliseFolderChartLookup } from "utils/folder";
|
||||
@@ -13,7 +13,7 @@ import { FormatVersion } from "./lib/constants/version";
|
||||
|
||||
const logger = CreateLogCtx(__filename);
|
||||
|
||||
logger.info(`Booting ${ServerTypeInfo.name} - ${FormatVersion()} [ENV: ${Environment.nodeEnv}]`);
|
||||
logger.info(`Booting ${TachiConfig.NAME} - ${FormatVersion()} [ENV: ${Environment.nodeEnv}]`);
|
||||
logger.info(`Log level is set to ${ServerConfig.LOG_LEVEL}.`);
|
||||
|
||||
logger.info(`Loading sequence documents...`);
|
||||
|
||||
@@ -22,6 +22,8 @@ export const AllPermissions: Record<APIPermissions, true> = {
|
||||
delete_score: true,
|
||||
};
|
||||
|
||||
export const SetRequestPermissions: RequestHandler = CreateSetRequestPermissions("description");
|
||||
|
||||
/**
|
||||
* Sets the permissions for this request, alongside the user that is making the request.
|
||||
*
|
||||
@@ -36,61 +38,73 @@ export const AllPermissions: Record<APIPermissions, true> = {
|
||||
*
|
||||
* This is set on req[SYMBOL_TachiAPIAuth].
|
||||
*/
|
||||
export const SetRequestPermissions: RequestHandler = async (req, res, next) => {
|
||||
if (req.session?.tachi?.user.id) {
|
||||
function CreateSetRequestPermissions(errorKeyName: string): RequestHandler {
|
||||
return async (req, res, next) => {
|
||||
if (req.session?.tachi?.user.id) {
|
||||
req[SYMBOL_TachiAPIAuth] = {
|
||||
userID: req.session.tachi.user.id,
|
||||
identifier: `Session-Key ${req.session.tachi.user.id}`,
|
||||
token: null,
|
||||
permissions: AllPermissions,
|
||||
};
|
||||
return next();
|
||||
}
|
||||
|
||||
const header = req.header("Authorization");
|
||||
|
||||
// if no auth was attempted, default to the guest token.
|
||||
if (!header) {
|
||||
req[SYMBOL_TachiAPIAuth] = GuestToken;
|
||||
return next();
|
||||
}
|
||||
|
||||
const { token, type } = SplitAuthorizationHeader(header);
|
||||
|
||||
if (type !== "Bearer") {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
[errorKeyName]: "Invalid Authorization Type - Expected Bearer.",
|
||||
});
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
[errorKeyName]: "Invalid token.",
|
||||
});
|
||||
}
|
||||
|
||||
const apiTokenData = await db["api-tokens"].findOne({
|
||||
token,
|
||||
});
|
||||
|
||||
if (!apiTokenData) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
[errorKeyName]:
|
||||
"The provided API token does not correspond with any key in the database.",
|
||||
});
|
||||
}
|
||||
|
||||
req[SYMBOL_TachiAPIAuth] = {
|
||||
userID: req.session.tachi.user.id,
|
||||
identifier: `Session-Key ${req.session.tachi.user.id}`,
|
||||
token: null,
|
||||
permissions: AllPermissions,
|
||||
userID: apiTokenData.userID,
|
||||
token,
|
||||
permissions: apiTokenData.permissions,
|
||||
identifier: apiTokenData.identifier,
|
||||
};
|
||||
|
||||
return next();
|
||||
}
|
||||
|
||||
const header = req.header("Authorization");
|
||||
|
||||
// if no auth was attempted, default to the guest token.
|
||||
if (!header) {
|
||||
req[SYMBOL_TachiAPIAuth] = GuestToken;
|
||||
return next();
|
||||
}
|
||||
|
||||
const { token, type } = SplitAuthorizationHeader(header);
|
||||
|
||||
if (type !== "Bearer") {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
description: "Invalid Authorization Type - Expected Bearer.",
|
||||
});
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
description: "Invalid token.",
|
||||
});
|
||||
}
|
||||
|
||||
const apiTokenData = await db["api-tokens"].findOne({
|
||||
token,
|
||||
});
|
||||
|
||||
if (!apiTokenData) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
description: "The provided API token does not correspond with any key in the database.",
|
||||
});
|
||||
}
|
||||
|
||||
req[SYMBOL_TachiAPIAuth] = {
|
||||
userID: apiTokenData.userID,
|
||||
token,
|
||||
permissions: apiTokenData.permissions,
|
||||
identifier: apiTokenData.identifier,
|
||||
};
|
||||
}
|
||||
|
||||
return next();
|
||||
};
|
||||
/**
|
||||
* An identical implementation of SetRequestPermissions, but returns
|
||||
* fervidex-style errors (error, instead of description).
|
||||
*
|
||||
* @see SetRequestPermissions
|
||||
*/
|
||||
export const SetFervidexStyleRequestPermissions: RequestHandler =
|
||||
CreateSetRequestPermissions("error");
|
||||
|
||||
/**
|
||||
* Returns a middleware that enforces the request has the necessary permissions.
|
||||
@@ -144,22 +158,28 @@ export const RequirePermissions =
|
||||
return next();
|
||||
};
|
||||
|
||||
export const RequireNotGuest: RequestHandler = (req, res, next) => {
|
||||
if (!req[SYMBOL_TachiAPIAuth]) {
|
||||
logger.error(`RequirePermissions middleware was hit without any TachiAPIData?`);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
description: "An internal error has occured.",
|
||||
});
|
||||
}
|
||||
const CreateRequireNotGuest =
|
||||
(errorKeyName: string): RequestHandler =>
|
||||
(req, res, next) => {
|
||||
if (!req[SYMBOL_TachiAPIAuth]) {
|
||||
logger.error(`RequirePermissions middleware was hit without any TachiAPIData?`);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
description: "An internal error has occured.",
|
||||
});
|
||||
}
|
||||
|
||||
if (!req[SYMBOL_TachiAPIAuth].userID) {
|
||||
logger.info(`Request to ${req.method} ${req.url} was attempted by guest.`);
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
description: "This endpoint requires authentication.",
|
||||
});
|
||||
}
|
||||
if (req[SYMBOL_TachiAPIAuth].userID === null) {
|
||||
logger.info(`Request to ${req.method} ${req.url} was attempted by guest.`);
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
[errorKeyName]: "This endpoint requires authentication.",
|
||||
});
|
||||
}
|
||||
|
||||
return next();
|
||||
};
|
||||
return next();
|
||||
};
|
||||
|
||||
export const RequireNotGuest: RequestHandler = CreateRequireNotGuest("description");
|
||||
|
||||
export const FervidexStyleRequireNotGuest: RequestHandler = CreateRequireNotGuest("error");
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import RateLimitRedis from "rate-limit-redis";
|
||||
import { Environment, ServerConfig } from "lib/setup/config";
|
||||
import { Environment, ServerConfig, TachiConfig } from "lib/setup/config";
|
||||
import rateLimit from "express-rate-limit";
|
||||
import CreateLogCtx from "lib/logger/logger";
|
||||
import { RedisClient } from "external/redis/redis";
|
||||
@@ -8,7 +8,7 @@ const logger = CreateLogCtx(__filename);
|
||||
|
||||
const store =
|
||||
Environment.nodeEnv === "production" || Environment.nodeEnv === "staging"
|
||||
? new RateLimitRedis({ prefix: `${ServerConfig.TYPE}-RL:`, client: RedisClient })
|
||||
? new RateLimitRedis({ prefix: `${TachiConfig.NAME}-RL:`, client: RedisClient })
|
||||
: undefined; // undefined forces a default to an in-memory store
|
||||
|
||||
export function ClearTestingRateLimitCache() {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ServerConfig } from "lib/setup/config";
|
||||
import { TachiConfig } from "lib/setup/config";
|
||||
import { RequestHandler } from "express";
|
||||
|
||||
/**
|
||||
@@ -7,7 +7,7 @@ import { RequestHandler } from "express";
|
||||
* this restriction is bypassed.
|
||||
*/
|
||||
export const RequireBokutachi: RequestHandler = (req, res, next) => {
|
||||
if (ServerConfig.TYPE === "btchi" || ServerConfig.TYPE === "omni") {
|
||||
if (TachiConfig.TYPE === "btchi" || TachiConfig.TYPE === "omni") {
|
||||
return next();
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ export const RequireBokutachi: RequestHandler = (req, res, next) => {
|
||||
* this restriction is bypassed.
|
||||
*/
|
||||
export const RequireKamaitachi: RequestHandler = (req, res, next) => {
|
||||
if (ServerConfig.TYPE === "ktchi" || ServerConfig.TYPE === "omni") {
|
||||
if (TachiConfig.TYPE === "ktchi" || TachiConfig.TYPE === "omni") {
|
||||
return next();
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import CreateLogCtx, { ChangeRootLogLevel, GetLogLevel } from "lib/logger/logger
|
||||
import prValidate from "server/middleware/prudence-validate";
|
||||
import { GetUserWithID } from "utils/user";
|
||||
import { ONE_MINUTE } from "lib/constants/time";
|
||||
import { ServerConfig, ServerTypeInfo } from "lib/setup/config";
|
||||
import { ServerConfig, TachiConfig } from "lib/setup/config";
|
||||
import { Game, UserAuthLevels } from "tachi-common";
|
||||
|
||||
import db from "external/mongo/db";
|
||||
@@ -115,7 +115,7 @@ router.post(
|
||||
"/deprimarify",
|
||||
prValidate({
|
||||
chartID: "*string",
|
||||
game: p.isIn(ServerTypeInfo.supportedGames),
|
||||
game: p.isIn(TachiConfig.GAMES),
|
||||
songID: p.optional(p.isPositiveNonZeroInteger),
|
||||
}),
|
||||
async (req, res) => {
|
||||
|
||||
@@ -26,7 +26,7 @@ import { SendEmail } from "lib/email/client";
|
||||
import { EmailFormatResetPassword, EmailFormatVerifyEmail } from "lib/email/formats";
|
||||
import { Random20Hex } from "utils/misc";
|
||||
import { Environment, ServerConfig } from "lib/setup/config";
|
||||
import { integer } from ".pnpm/tachi-common@0.2.36/node_modules/tachi-common";
|
||||
import { integer } from "tachi-common";
|
||||
|
||||
const logger = CreateLogCtx(__filename);
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { RequestHandler } from "express";
|
||||
import { ServerTypeInfo } from "lib/setup/config";
|
||||
import { TachiConfig } from "lib/setup/config";
|
||||
import { IsValidGame } from "utils/misc";
|
||||
import { AssignToReqTachiData } from "utils/req-tachi-data";
|
||||
|
||||
@@ -9,7 +9,7 @@ export const ValidateGameFromParam: RequestHandler = (req, res, next) => {
|
||||
success: false,
|
||||
description: `Invalid/unsupported game ${
|
||||
req.params.game
|
||||
} - Expected any of ${ServerTypeInfo.supportedGames.join(", ")}`,
|
||||
} - Expected any of ${TachiConfig.GAMES.join(", ")}`,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { GetGameConfig } from "tachi-common";
|
||||
import t from "tap";
|
||||
import { ServerTypeInfo } from "lib/setup/config";
|
||||
import { TachiConfig } from "lib/setup/config";
|
||||
|
||||
import mockApi from "test-utils/mock-api";
|
||||
|
||||
@@ -9,10 +9,10 @@ t.test("GET /api/v1/games", async (t) => {
|
||||
// and also returns configs properly.
|
||||
const res = await mockApi.get("/api/v1/games");
|
||||
|
||||
t.strictSame(res.body.body.supportedGames, ServerTypeInfo.supportedGames);
|
||||
t.strictSame(res.body.body.supportedGames, TachiConfig.GAMES);
|
||||
|
||||
t.strictSame(res.body.body.configs.iidx, GetGameConfig("iidx"));
|
||||
t.equal(Object.keys(res.body.body.configs).length, ServerTypeInfo.supportedGames.length);
|
||||
t.equal(Object.keys(res.body.body.configs).length, TachiConfig.GAMES.length);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Router } from "express";
|
||||
import { ServerTypeInfo } from "lib/setup/config";
|
||||
import { TachiConfig } from "lib/setup/config";
|
||||
import { GetGameConfig } from "tachi-common";
|
||||
import gameRouter from "./_game/router";
|
||||
|
||||
@@ -13,15 +13,13 @@ const router: Router = Router({ mergeParams: true });
|
||||
*/
|
||||
router.get("/", (req, res) => {
|
||||
// this line is a bit too 'smart' for its own good, but whatever.
|
||||
const configs = Object.fromEntries(
|
||||
ServerTypeInfo.supportedGames.map((e) => [e, GetGameConfig(e)])
|
||||
);
|
||||
const configs = Object.fromEntries(TachiConfig.GAMES.map((e) => [e, GetGameConfig(e)]));
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: `Returned support information for ${ServerTypeInfo.supportedGames.length} game(s).`,
|
||||
description: `Returned support information for ${TachiConfig.GAMES.length} game(s).`,
|
||||
body: {
|
||||
supportedGames: ServerTypeInfo.supportedGames,
|
||||
supportedGames: TachiConfig.GAMES,
|
||||
configs,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -14,7 +14,7 @@ import ParseBatchManual from "lib/score-import/import-types/file/batch-manual/pa
|
||||
import { ParseSolidStateXML } from "lib/score-import/import-types/file/solid-state-squad/parser";
|
||||
import { ParseMerIIDX } from "lib/score-import/import-types/file/mer-iidx/parser";
|
||||
import ParsePLIIIDXCSV from "lib/score-import/import-types/file/pli-iidx-csv/parser";
|
||||
import { ServerTypeInfo } from "lib/setup/config";
|
||||
import { TachiConfig } from "lib/setup/config";
|
||||
import { RequirePermissions } from "server/middleware/auth";
|
||||
import { ParseEagIIDX } from "lib/score-import/import-types/api/eag-iidx/parser";
|
||||
import { ParseEagSDVX } from "lib/score-import/import-types/api/eag-sdvx/parser";
|
||||
@@ -34,7 +34,7 @@ const ParseMultipartScoredata = CreateMulterSingleUploadMiddleware(
|
||||
logger
|
||||
);
|
||||
|
||||
const fileImportTypes = ServerTypeInfo.supportedImportTypes.filter((e) => e.startsWith("file/"));
|
||||
const fileImportTypes = TachiConfig.IMPORT_TYPES.filter((e) => e.startsWith("file/"));
|
||||
|
||||
/**
|
||||
* Import scores from a file. Expects the post request to be multipart, and to provide a scoreData file.
|
||||
@@ -83,7 +83,7 @@ router.post(
|
||||
}
|
||||
);
|
||||
|
||||
const apiImportTypes = ServerTypeInfo.supportedImportTypes.filter((e) => e.startsWith("api/"));
|
||||
const apiImportTypes = TachiConfig.IMPORT_TYPES.filter((e) => e.startsWith("api/"));
|
||||
|
||||
/**
|
||||
* Import scores from another API. This typically will perform a full sync.
|
||||
|
||||
@@ -1,20 +1,19 @@
|
||||
import t from "tap";
|
||||
|
||||
import { CreateFakeAuthCookie } from "test-utils/fake-auth";
|
||||
import { CreateFakeAuthCookie, InsertFakeTokenWithAllPerms } from "test-utils/fake-auth";
|
||||
import ResetDBState from "test-utils/resets";
|
||||
import mockApi from "test-utils/mock-api";
|
||||
import { TestingBarbatosScore } from "test-utils/test-data";
|
||||
import db from "external/mongo/db";
|
||||
|
||||
t.test("POST /ir/barbatos/score/submit", async (t) => {
|
||||
const cookie = await CreateFakeAuthCookie(mockApi);
|
||||
|
||||
t.test("POST /ir/barbatos/score/submit", (t) => {
|
||||
t.beforeEach(ResetDBState);
|
||||
t.beforeEach(InsertFakeTokenWithAllPerms("mock_token"));
|
||||
|
||||
t.test("Should import a valid score", async (t) => {
|
||||
const res = await mockApi
|
||||
.post("/ir/barbatos/score/submit")
|
||||
.set("Cookie", cookie)
|
||||
.set("Authorization", "Bearer mock_token")
|
||||
.send(TestingBarbatosScore);
|
||||
|
||||
t.equal(res.body.success, true, "Should be successful");
|
||||
@@ -31,7 +30,10 @@ t.test("POST /ir/barbatos/score/submit", async (t) => {
|
||||
});
|
||||
|
||||
t.test("Should reject an invalid body", async (t) => {
|
||||
const res = await mockApi.post("/ir/barbatos/score/submit").set("Cookie", cookie).send({});
|
||||
const res = await mockApi
|
||||
.post("/ir/barbatos/score/submit")
|
||||
.set("Authorization", "Bearer mock_token")
|
||||
.send({});
|
||||
|
||||
t.equal(res.body.success, false, "Should not be successful.");
|
||||
t.equal(res.status, 400, "Should return 400.");
|
||||
@@ -39,5 +41,24 @@ t.test("POST /ir/barbatos/score/submit", async (t) => {
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Should require authorisation.", async (t) => {
|
||||
const res = await mockApi.post("/ir/barbatos/score/submit").send(TestingBarbatosScore);
|
||||
|
||||
t.equal(res.statusCode, 401, "Should return 401 for no authorization header.");
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Should require valid authorisation.", async (t) => {
|
||||
const res = await mockApi
|
||||
.post("/ir/barbatos/score/submit")
|
||||
.set("Authorization", "Bearer invalid_token")
|
||||
.send(TestingBarbatosScore);
|
||||
|
||||
t.equal(res.statusCode, 401, "Should return 401 for invalid authorization header.");
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
@@ -230,6 +230,30 @@ t.test("POST /ir/beatoraja/submit-score", (t) => {
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Should require authentication.", async (t) => {
|
||||
const res = await mockApi
|
||||
.post("/ir/beatoraja/submit-score")
|
||||
.set("X-TachiIR-Version", "2.0.0")
|
||||
.send(scoreReq);
|
||||
|
||||
t.equal(res.status, 401);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Should reject non-corresponding tokens.", async (t) => {
|
||||
const res = await mockApi
|
||||
.post("/ir/beatoraja/submit-score")
|
||||
.set("X-TachiIR-Version", "2.0.0")
|
||||
.set("Authorization", "Bearer invalid_token")
|
||||
|
||||
.send(scoreReq);
|
||||
|
||||
t.equal(res.status, 401);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import CreateLogCtx, { KtLogger } from "lib/logger/logger";
|
||||
import { ExpressWrappedScoreImportMain } from "lib/score-import/framework/express-wrapper";
|
||||
import { ParseBeatorajaSingle } from "lib/score-import/import-types/ir/beatoraja/parser";
|
||||
import { ServerConfig } from "lib/setup/config";
|
||||
import { RequireNotGuest } from "server/middleware/auth";
|
||||
import { UpdateClassIfGreater } from "utils/class";
|
||||
import { GetUserWithIDGuaranteed } from "utils/user";
|
||||
import { ValidateIRClientVersion } from "./auth";
|
||||
@@ -21,7 +22,7 @@ router.use(ValidateIRClientVersion);
|
||||
*
|
||||
* @name POST /ir/beatoraja/submit-score
|
||||
*/
|
||||
router.post("/submit-score", async (req, res) => {
|
||||
router.post("/submit-score", RequireNotGuest, async (req, res) => {
|
||||
const userDoc = await GetUserWithIDGuaranteed(req[SYMBOL_TachiAPIAuth]!.userID!);
|
||||
|
||||
const ParserFunction = (logger: KtLogger) => ParseBeatorajaSingle(req.body, userDoc.id, logger);
|
||||
@@ -91,7 +92,7 @@ router.post("/submit-score", async (req, res) => {
|
||||
*
|
||||
* @name POST /ir/beatoraja/submit-course
|
||||
*/
|
||||
router.post("/submit-course", async (req, res) => {
|
||||
router.post("/submit-course", RequireNotGuest, async (req, res) => {
|
||||
const charts = req.body.course?.charts;
|
||||
|
||||
if (
|
||||
|
||||
@@ -63,5 +63,24 @@ t.test("POST /ir/chunitachi/import", (t) => {
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Should require authentication.", async (t) => {
|
||||
const res = await mockApi.post("/ir/chunitachi/import").send(chunitachiBody);
|
||||
|
||||
t.equal(res.statusCode, 401);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Should require a valid auth token.", async (t) => {
|
||||
const res = await mockApi
|
||||
.post("/ir/chunitachi/import")
|
||||
.set("Authorization", "Bearer invalid_token")
|
||||
.send(chunitachiBody);
|
||||
|
||||
t.equal(res.statusCode, 401);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
@@ -57,5 +57,26 @@ t.test("POST /ir/direct-manual/import", async (t) => {
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Should require authentication.", async (t) => {
|
||||
const res = await mockApi
|
||||
.post("/ir/direct-manual/import")
|
||||
.send(GetKTDataJSON("./batch-manual/small-file.json"));
|
||||
|
||||
t.equal(res.statusCode, 401);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Should require a valid auth token.", async (t) => {
|
||||
const res = await mockApi
|
||||
.post("/ir/direct-manual/import")
|
||||
.set("Authorization", "Bearer invalid_token")
|
||||
.send(GetKTDataJSON("./batch-manual/small-file.json"));
|
||||
|
||||
t.equal(res.statusCode, 401);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
@@ -132,6 +132,29 @@ function TestHeaders(url: string, data: any) {
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Should require authorization.", async (t) => {
|
||||
const res = await mockApi
|
||||
.post(url)
|
||||
.set("X-Software-Model", "LDJ:J:B:A:2020092900")
|
||||
.set("User-Agent", "fervidex/1.3.0")
|
||||
.send(data);
|
||||
|
||||
t.equal(res.status, 401, "Should return 401.");
|
||||
t.type(res.body.error, "string", "Should have an error message.");
|
||||
|
||||
const res2 = await mockApi
|
||||
.post(url)
|
||||
.set("Authorization", "Bearer invalid_token")
|
||||
.set("X-Software-Model", "LDJ:J:B:A:2020092900")
|
||||
.set("User-Agent", "fervidex/1.3.0")
|
||||
.send(data);
|
||||
|
||||
t.equal(res2.status, 401, "Should return 401.");
|
||||
t.type(res2.body.error, "string", "Should have an error message.");
|
||||
|
||||
t.end();
|
||||
});
|
||||
}
|
||||
|
||||
t.test("POST /ir/fervidex/class/submit", (t) => {
|
||||
@@ -308,6 +331,8 @@ t.test("POST /ir/fervidex/score/submit", (t) => {
|
||||
|
||||
t.equal(res.body.success, false, "Should not be successful");
|
||||
|
||||
t.type(res.body.error, "string", "Should have an error prop that is a string.");
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
|
||||
@@ -100,7 +100,7 @@ const RequireInf2ModelHeaderOrForceStatic: RequestHandler = async (req, res, nex
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
logger.debug(err);
|
||||
logger.info(`Invalid softID from ${req[SYMBOL_TachiAPIAuth].userID!}.`, { err });
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: `Invalid X-Software-Model.`,
|
||||
@@ -193,7 +193,7 @@ router.use(
|
||||
);
|
||||
|
||||
/**
|
||||
* Submits all of a users data to Kamaitachi. This data is extremely minimal,
|
||||
* Submits all of a users data to Tachi. This data is extremely minimal,
|
||||
* as only a users Lamp and Score are sent. As such, this is not the prefered
|
||||
* way of syncing scores outside of INF2, where there is no other way to
|
||||
* retrieve scores.
|
||||
@@ -215,13 +215,21 @@ router.post("/profile/submit", RequireInf2ModelHeaderOrForceStatic, async (req,
|
||||
(logger) => ParseFervidexStatic(req.body, headers, logger)
|
||||
);
|
||||
|
||||
if (!responseData.body.success) {
|
||||
// in-air rewrite description to error.
|
||||
// @ts-expect-error Hack!
|
||||
responseData.body.error = responseData.body.description;
|
||||
// @ts-expect-error Hack!
|
||||
delete responseData.body.description;
|
||||
}
|
||||
|
||||
return res.status(responseData.statusCode).json(responseData.body);
|
||||
});
|
||||
|
||||
/**
|
||||
* Submits a single score to Kamaitachi. In contrast to profile/submit, this
|
||||
* Submits a single score to Tachi. In contrast to profile/submit, this
|
||||
* sends the most data (and most accurate data) of any score hook.
|
||||
* As such, this is the preferred way of submitting IIDX scores to Kamaitachi.
|
||||
* As such, this is the preferred way of submitting IIDX scores to Tachi.
|
||||
*
|
||||
* @name POST /ir/fervidex/score/submit
|
||||
*/
|
||||
@@ -248,11 +256,19 @@ router.post("/score/submit", ValidateModelHeader, async (req, res) => {
|
||||
(logger) => ParseFervidexSingle(req.body, headers, logger)
|
||||
);
|
||||
|
||||
if (!responseData.body.success) {
|
||||
// in-air rewrite description to error.
|
||||
// @ts-expect-error Hack!
|
||||
responseData.body.error = responseData.body.description;
|
||||
// @ts-expect-error Hack!
|
||||
delete responseData.body.description;
|
||||
}
|
||||
|
||||
return res.status(responseData.statusCode).json(responseData.body);
|
||||
});
|
||||
|
||||
/**
|
||||
* Submits the result of a class to Kamaitachi. This contains the dan played
|
||||
* Submits the result of a class to Tachi. This contains the dan played
|
||||
* and whether it was achieved.
|
||||
*
|
||||
* @name POST /ir/fervidex/class/submit
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Router } from "express";
|
||||
import sv3cRouter from "./sv3c/router";
|
||||
|
||||
const router: Router = Router({ mergeParams: true });
|
||||
|
||||
router.use("/sv3c", sv3cRouter);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,97 @@
|
||||
import t from "tap";
|
||||
import { InsertFakeTokenWithAllPerms } from "test-utils/fake-auth";
|
||||
import mockApi from "test-utils/mock-api";
|
||||
import ResetDBState from "test-utils/resets";
|
||||
import deepmerge from "deepmerge";
|
||||
import { TestingKsHookSV3CScore } from "test-utils/test-data";
|
||||
|
||||
t.test("POST /ir/kshook/sv3c/score/save", (t) => {
|
||||
t.beforeEach(ResetDBState);
|
||||
t.beforeEach(InsertFakeTokenWithAllPerms("mock_token"));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
const validSubmit = (data: object) =>
|
||||
mockApi
|
||||
.post("/ir/kshook/sv3c/score/save")
|
||||
.set("Authorization", "Bearer mock_token")
|
||||
.set("User-Agent", "kshook/0.1.0")
|
||||
.set("X-Software-Model", "QCV:J:C:A:2021100600")
|
||||
.send(data);
|
||||
|
||||
t.test("Should import a valid score to the database.", async (t) => {
|
||||
const res = await validSubmit(TestingKsHookSV3CScore);
|
||||
|
||||
t.equal(res.status, 200);
|
||||
t.equal(res.body.success, true);
|
||||
t.equal(res.body.body.scoreIDs.length, 1, "Should import one score.");
|
||||
t.equal(res.body.body.errors.length, 0, "Should have 0 failed scores.");
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Should reject invalid scores to the database.", async (t) => {
|
||||
const res = await validSubmit({});
|
||||
|
||||
t.equal(res.status, 400, "Should return 400 for an empty object.");
|
||||
t.type(res.body.error, "string", "Should attach an error message.");
|
||||
|
||||
const res2 = await validSubmit(
|
||||
deepmerge(TestingKsHookSV3CScore, {
|
||||
clear: "INVALID_CLEAR_TYPE",
|
||||
})
|
||||
);
|
||||
|
||||
t.equal(res2.status, 400, "Should return 400 for an invalid clear type.");
|
||||
t.type(res2.body.error, "string", "Should attach an error message.");
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Should reject scores with invalid software models.", async (t) => {
|
||||
const res = await mockApi
|
||||
.post("/ir/kshook/sv3c/score/save")
|
||||
.set("Authorization", "Bearer mock_token")
|
||||
.set("User-Agent", "kshook/0.1.0")
|
||||
.set("X-Software-Model", "LDJ:J:C:A:2021100600")
|
||||
.send(TestingKsHookSV3CScore);
|
||||
|
||||
t.equal(res.status, 400, "Should reject an import with invalid software model.");
|
||||
t.type(res.body.error, "string", "Should have an error message.");
|
||||
|
||||
const res2 = await mockApi
|
||||
.post("/ir/kshook/sv3c/score/save")
|
||||
.set("Authorization", "Bearer mock_token")
|
||||
.set("User-Agent", "kshook/0.1.0")
|
||||
.send(TestingKsHookSV3CScore);
|
||||
|
||||
t.equal(res2.status, 400, "Should reject an import with no software model.");
|
||||
t.type(res2.body.error, "string", "Should have an error message.");
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Should reject scores with invalid auth.", async (t) => {
|
||||
const res = await mockApi
|
||||
.post("/ir/kshook/sv3c/score/save")
|
||||
.set("Authorization", "Bearer foo")
|
||||
.set("User-Agent", "kshook/0.1.0")
|
||||
.set("X-Software-Model", "QCV:J:C:A:2021100600")
|
||||
.send(TestingKsHookSV3CScore);
|
||||
|
||||
t.equal(res.status, 401, "Should reject an import with invalid authentication.");
|
||||
t.type(res.body.error, "string", "Should have an error message.");
|
||||
|
||||
const res2 = await mockApi
|
||||
.post("/ir/kshook/sv3c/score/save")
|
||||
.set("User-Agent", "kshook/0.1.0")
|
||||
.set("X-Software-Model", "QCV:J:C:A:2021100600")
|
||||
.send(TestingKsHookSV3CScore);
|
||||
|
||||
t.equal(res2.status, 401, "Should reject an import with no authentication.");
|
||||
t.type(res2.body.error, "string", "Should have an error message.");
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.end();
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { Router, RequestHandler } from "express";
|
||||
import { MODEL_SDVX3_KONASTE } from "lib/constants/ea3id";
|
||||
import { SYMBOL_TachiAPIAuth } from "lib/constants/tachi";
|
||||
import CreateLogCtx from "lib/logger/logger";
|
||||
import { ExpressWrappedScoreImportMain } from "lib/score-import/framework/express-wrapper";
|
||||
import { ParseKsHookSV3C } from "lib/score-import/import-types/ir/kshook-sv3c/parser";
|
||||
import { ParseEA3SoftID } from "utils/ea3id";
|
||||
import { GetUserWithIDGuaranteed } from "utils/user";
|
||||
|
||||
const router: Router = Router({ mergeParams: true });
|
||||
|
||||
const logger = CreateLogCtx(__filename);
|
||||
|
||||
const ValidateHeaders: RequestHandler = (req, res, next) => {
|
||||
const agent = req.header("User-Agent");
|
||||
|
||||
if (!agent) {
|
||||
logger.debug(
|
||||
`Rejected KsHook client with no agent from user ${req[SYMBOL_TachiAPIAuth].userID!}.`
|
||||
);
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: `Invalid User-Agent.`,
|
||||
});
|
||||
}
|
||||
|
||||
if (!agent.startsWith("kshook/")) {
|
||||
logger.info(
|
||||
`Rejected KsHook client with invalid agent ${agent} from user ${req[SYMBOL_TachiAPIAuth]
|
||||
.userID!}.`
|
||||
);
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: `Invalid User-Agent ${agent} - expected KsHook client.`,
|
||||
});
|
||||
}
|
||||
|
||||
// We don't currently need to check the version or anything i don't think.
|
||||
// We should be good.
|
||||
|
||||
const softID = req.header("X-Software-Model");
|
||||
|
||||
if (!softID) {
|
||||
logger.debug(
|
||||
`Recieved request without X-Software-Model from ${req[SYMBOL_TachiAPIAuth].userID!}.`
|
||||
);
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: `Invalid X-Software-Model.`,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const modelInfo = ParseEA3SoftID(softID);
|
||||
|
||||
if (modelInfo.model !== MODEL_SDVX3_KONASTE) {
|
||||
logger.info(
|
||||
`Recieved unexpected softID ${softID}. Expected ${MODEL_SDVX3_KONASTE} as model.`
|
||||
);
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: `Invalid softID ${softID}.`,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
logger.info(`Invalid softID from ${req[SYMBOL_TachiAPIAuth].userID!}.`, { err });
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: `Invalid X-Software-Model.`,
|
||||
});
|
||||
}
|
||||
|
||||
return next();
|
||||
};
|
||||
|
||||
router.use(ValidateHeaders);
|
||||
|
||||
/**
|
||||
* Saves a SDVX Konaste score.
|
||||
*
|
||||
* @name POST /ir/kshook/sv3c/score/save
|
||||
*/
|
||||
router.post("/score/save", async (req, res) => {
|
||||
const userDoc = await GetUserWithIDGuaranteed(req[SYMBOL_TachiAPIAuth].userID!);
|
||||
|
||||
const responseData = await ExpressWrappedScoreImportMain(
|
||||
userDoc,
|
||||
true,
|
||||
"ir/kshook-sv3c",
|
||||
(logger) => ParseKsHookSV3C(req.body, logger)
|
||||
);
|
||||
|
||||
if (!responseData.body.success) {
|
||||
// in-air rewrite description to error.
|
||||
// @ts-expect-error Hack!
|
||||
responseData.body.error = responseData.body.description;
|
||||
// @ts-expect-error Hack!
|
||||
delete responseData.body.description;
|
||||
}
|
||||
|
||||
return res.status(responseData.statusCode).json(responseData.body);
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -5,14 +5,21 @@ import directManualIR from "./direct-manual/router";
|
||||
import fervidexIR from "./fervidex/router";
|
||||
import uscIR from "./usc/router";
|
||||
import beatorajaIR from "./beatoraja/router";
|
||||
import ksHookIR from "./kshook/router";
|
||||
import { RequireBokutachi, RequireKamaitachi } from "../../middleware/type-require";
|
||||
import { SetRequestPermissions } from "../../middleware/auth";
|
||||
import {
|
||||
FervidexStyleRequireNotGuest,
|
||||
RequireNotGuest,
|
||||
SetFervidexStyleRequestPermissions,
|
||||
SetRequestPermissions,
|
||||
} from "../../middleware/auth";
|
||||
|
||||
const router: Router = Router({ mergeParams: true });
|
||||
|
||||
// Common IRs
|
||||
|
||||
router.use("/direct-manual", SetRequestPermissions, directManualIR);
|
||||
router.use("/direct-manual", SetRequestPermissions, RequireNotGuest, directManualIR);
|
||||
router.use("/kshook", SetFervidexStyleRequestPermissions, FervidexStyleRequireNotGuest, ksHookIR);
|
||||
|
||||
// Bokutachi IRs
|
||||
|
||||
@@ -24,9 +31,14 @@ router.use("/beatoraja", SetRequestPermissions, RequireBokutachi, beatorajaIR);
|
||||
|
||||
// Kamaitachi IRs
|
||||
|
||||
router.use(SetRequestPermissions);
|
||||
router.use("/barbatos", RequireKamaitachi, barbatosIR);
|
||||
router.use("/chunitachi", RequireKamaitachi, chunitachiIR);
|
||||
router.use("/fervidex", RequireKamaitachi, fervidexIR);
|
||||
router.use("/barbatos", SetRequestPermissions, RequireNotGuest, RequireKamaitachi, barbatosIR);
|
||||
router.use("/chunitachi", SetRequestPermissions, RequireNotGuest, RequireKamaitachi, chunitachiIR);
|
||||
router.use(
|
||||
"/fervidex",
|
||||
SetFervidexStyleRequestPermissions,
|
||||
FervidexStyleRequireNotGuest,
|
||||
RequireKamaitachi,
|
||||
fervidexIR
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -29,7 +29,7 @@ import { GetUSCIRReplayURL } from "lib/cdn/url-format";
|
||||
import { FormatPrError } from "utils/prudence";
|
||||
import { USCClientChart } from "./types";
|
||||
import { HandleOrphanQueue } from "lib/orphan-queue/orphan-queue";
|
||||
import { ServerConfig } from "lib/setup/config";
|
||||
import { ServerConfig, TachiConfig } from "lib/setup/config";
|
||||
|
||||
const logger = CreateLogCtx(__filename);
|
||||
|
||||
@@ -98,7 +98,7 @@ router.get("/", (req, res) =>
|
||||
description: "IR Request Successful.",
|
||||
body: {
|
||||
serverTime: Math.floor(Date.now() / 1000),
|
||||
serverName: "Bokutachi",
|
||||
serverName: TachiConfig.NAME,
|
||||
irVersion: "0.3.1-a",
|
||||
},
|
||||
})
|
||||
|
||||
@@ -21,7 +21,7 @@ export const TACHI_LAMP_TO_USC: Record<
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts a Kamaitachi Score to the ServerScoreDocument
|
||||
* Converts a Tachi Score to the ServerScoreDocument
|
||||
* as specified in the USCIR spec. This function silently
|
||||
* returns sentinel values in the case that certain
|
||||
* fields are null.
|
||||
|
||||
@@ -3,7 +3,7 @@ import "express-async-errors";
|
||||
import expressSession from "express-session";
|
||||
import { integer } from "tachi-common";
|
||||
import { RedisClient } from "external/redis/redis";
|
||||
import { Environment, ServerConfig } from "lib/setup/config";
|
||||
import { Environment, ServerConfig, TachiConfig } from "lib/setup/config";
|
||||
import connectRedis from "connect-redis";
|
||||
import helmet from "helmet";
|
||||
import CreateLogCtx from "lib/logger/logger";
|
||||
@@ -19,7 +19,7 @@ if (Environment.nodeEnv !== "test") {
|
||||
host: "localhost",
|
||||
port: 6379,
|
||||
client: RedisClient,
|
||||
prefix: ServerConfig.TYPE,
|
||||
prefix: TachiConfig.NAME,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ const userSessionMiddleware = expressSession({
|
||||
// append node_env onto the end of the session name
|
||||
// so we can separate tokens under the same URL.
|
||||
// say for staging.kamaitachi.xyz
|
||||
name: `${ServerConfig.TYPE}_${Environment.nodeEnv}_session`,
|
||||
name: `${TachiConfig.NAME} (${Environment.nodeEnv}) SESSION`,
|
||||
secret: ServerConfig.SESSION_SECRET,
|
||||
store,
|
||||
resave: true,
|
||||
|
||||
@@ -1,29 +1,24 @@
|
||||
[
|
||||
{
|
||||
"rgcID": null,
|
||||
"chartID": "5088a4d0e1ee9d0cc2f625934306e45b1a60699b",
|
||||
"difficulty": "ADV",
|
||||
"songID": 1,
|
||||
"playtype": "Single",
|
||||
"levelNum": 10,
|
||||
"level": "10",
|
||||
"flags": {
|
||||
"IN BASE GAME": true,
|
||||
"OMNIMIX": false,
|
||||
"N-1": false
|
||||
},
|
||||
"data": {
|
||||
"inGameID": 1,
|
||||
"uscEquiv": null,
|
||||
"arcChartID": "EOZ7FixCDpv"
|
||||
},
|
||||
"isPrimary": true,
|
||||
"versions": [
|
||||
"booth",
|
||||
"inf",
|
||||
"gw",
|
||||
"heaven",
|
||||
"vivid"
|
||||
]
|
||||
}
|
||||
{
|
||||
"rgcID": null,
|
||||
"chartID": "5088a4d0e1ee9d0cc2f625934306e45b1a60699b",
|
||||
"difficulty": "ADV",
|
||||
"songID": 1,
|
||||
"playtype": "Single",
|
||||
"levelNum": 10,
|
||||
"level": "10",
|
||||
"data": {
|
||||
"inGameID": 1,
|
||||
"arcChartID": "EOZ7FixCDpv"
|
||||
},
|
||||
"isPrimary": true,
|
||||
"versions": [
|
||||
"booth",
|
||||
"inf",
|
||||
"gw",
|
||||
"heaven",
|
||||
"vivid",
|
||||
"konaste"
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
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";
|
||||
|
||||
const file = (name: string) => path.join(__dirname, "/test-data", name);
|
||||
|
||||
@@ -38,6 +39,8 @@ export const TestingIIDXSPScore = GetKTDataJSON(
|
||||
"./tachi/iidx-score.json"
|
||||
) as ScoreDocument<"iidx:SP">;
|
||||
|
||||
export const TestingKsHookSV3CScore = GetKTDataJSON("./kshook-sv3c/base.json") as KsHookSV3CScore;
|
||||
|
||||
export const Testing511SPA = GetKTDataJSON("./tachi/iidx-511spa.json") as ChartDocument<"iidx:SP">;
|
||||
|
||||
export const Testing511Song = GetKTDataJSON("./tachi/iidx-511-song.json") as SongDocument<"iidx">;
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"appeal_id": 2001,
|
||||
"btn_rate": 182,
|
||||
"clear": "CLEAR_EXCESSIVE",
|
||||
"critical": 1184,
|
||||
"difficulty": "DIFFICULTY_ADVANCED",
|
||||
"early": 36,
|
||||
"error": 30,
|
||||
"gauge": 71,
|
||||
"grade": "GRADE_AA_PLUS",
|
||||
"late": 10,
|
||||
"long_rate": 195,
|
||||
"max_chain": 158,
|
||||
"music_id": 1,
|
||||
"near": 46,
|
||||
"player_name": "EE",
|
||||
"rate": "RATE_EXCESSIVE",
|
||||
"score": 9579365,
|
||||
"skill_frame": "SKILL_FRAME_NONE",
|
||||
"skill_level": "SKILL_LEVEL_NONE",
|
||||
"skill_name": 5,
|
||||
"track_no": 0,
|
||||
"vol_rate": 193
|
||||
}
|
||||
@@ -1,27 +1,22 @@
|
||||
{
|
||||
"rgcID": null,
|
||||
"chartID": "5088a4d0e1ee9d0cc2f625934306e45b1a60699b",
|
||||
"difficulty": "ADV",
|
||||
"songID": 1,
|
||||
"playtype": "Single",
|
||||
"levelNum": 10,
|
||||
"level": "10",
|
||||
"flags": {
|
||||
"IN BASE GAME": true,
|
||||
"OMNIMIX": false,
|
||||
"N-1": false
|
||||
},
|
||||
"data": {
|
||||
"inGameID": 1,
|
||||
"uscEquiv": null,
|
||||
"arcChartID": "EOZ7FixCDpv"
|
||||
},
|
||||
"isPrimary": true,
|
||||
"versions": [
|
||||
"booth",
|
||||
"inf",
|
||||
"gw",
|
||||
"heaven",
|
||||
"vivid"
|
||||
]
|
||||
"rgcID": null,
|
||||
"chartID": "5088a4d0e1ee9d0cc2f625934306e45b1a60699b",
|
||||
"difficulty": "ADV",
|
||||
"songID": 1,
|
||||
"playtype": "Single",
|
||||
"levelNum": 10,
|
||||
"level": "10",
|
||||
"data": {
|
||||
"inGameID": 1,
|
||||
"arcChartID": "EOZ7FixCDpv"
|
||||
},
|
||||
"isPrimary": true,
|
||||
"versions": [
|
||||
"booth",
|
||||
"inf",
|
||||
"gw",
|
||||
"heaven",
|
||||
"vivid",
|
||||
"konaste"
|
||||
]
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import crypto from "crypto";
|
||||
import { ServerTypeInfo } from "lib/setup/config";
|
||||
import { TachiConfig } from "lib/setup/config";
|
||||
import { Game, Playtypes, GetGameConfig, GamePTConfig } from "tachi-common";
|
||||
|
||||
// https://github.com/sindresorhus/escape-string-regexp/blob/main/index.js
|
||||
@@ -62,7 +62,7 @@ export function HasOwnProperty<T>(obj: T, key: string | number | symbol): key is
|
||||
}
|
||||
|
||||
export function IsValidGame(str: string): str is Game {
|
||||
return !!ServerTypeInfo.supportedGames.includes(str as Game);
|
||||
return !!TachiConfig.GAMES.includes(str as Game);
|
||||
}
|
||||
|
||||
export function IsValidPlaytype(game: Game, str: string): str is Playtypes[Game] {
|
||||
|
||||
Reference in New Issue
Block a user