diff --git a/server/.eslintrc b/server/.eslintrc index 52b6e79bd..f32bc7e66 100644 --- a/server/.eslintrc +++ b/server/.eslintrc @@ -43,6 +43,8 @@ "argsIgnorePattern": "logger" } ], + // breaks with baseUrl + "import/no-unresolved": "off", "require-await": "warn", "no-await-in-loop": "warn", "no-unused-labels": "error", diff --git a/server/.vscode/launch.json b/server/.vscode/launch.json index 1a63a0208..7ba84a1f1 100644 --- a/server/.vscode/launch.json +++ b/server/.vscode/launch.json @@ -22,8 +22,7 @@ "**/async_hooks.js", "**/internal/**/*", "**/domain.js", - "**/events.js", - "*" + "**/events.js" ], "smartStep": true, "outputCapture": "std", diff --git a/server/.vscode/tasks.json b/server/.vscode/tasks.json index bd94b140d..843b1bca9 100644 --- a/server/.vscode/tasks.json +++ b/server/.vscode/tasks.json @@ -3,9 +3,9 @@ "tasks": [ { "type": "npm", - "script": "fulltest", + "script": "test", "problemMatcher": [], - "label": "npm: fulltest", + "label": "npm: test", "detail": "tap", "group": { "kind": "test", diff --git a/server/package.json b/server/package.json index be380fe98..aeb0b77e3 100644 --- a/server/package.json +++ b/server/package.json @@ -40,6 +40,7 @@ "supertest": "6.1.3", "tap": "15.0.9", "ts-node": "10.0.0", + "tsconfig-paths": "3.10.1", "why-is-node-running": "2.2.0" }, "dependencies": { diff --git a/server/pnpm-lock.yaml b/server/pnpm-lock.yaml index 8aff7d798..81d7374f5 100644 --- a/server/pnpm-lock.yaml +++ b/server/pnpm-lock.yaml @@ -52,6 +52,7 @@ specifiers: tachi-common: github:zkldi/tachi-common tap: 15.0.9 ts-node: 10.0.0 + tsconfig-paths: 3.10.1 typescript: 4.3.4 why-is-node-running: 2.2.0 winston: 3.3.3 @@ -80,7 +81,7 @@ dependencies: rate-limit-redis: 2.1.0 redis: 3.1.2 rimraf: 3.0.2 - tachi-common: github.com/zkldi/tachi-common/7f0ff9c3905018070c291ae07f3f937d142f925d_ts-node@10.0.0+typescript@4.3.4 + tachi-common: github.com/zkldi/tachi-common/362c3375c5f11b998aaedc34e4f14b62873b2ac7_ts-node@10.0.0+typescript@4.3.4 typescript: 4.3.4 winston: 3.3.3 @@ -112,6 +113,7 @@ devDependencies: supertest: 6.1.3 tap: 15.0.9_ts-node@10.0.0+typescript@4.3.4 ts-node: 10.0.0_83f53b0a0c5616d3fa00ed4e30b9ce1b + tsconfig-paths: 3.10.1 why-is-node-running: 2.2.0 packages: @@ -3931,6 +3933,14 @@ packages: yn: 3.1.1 dev: true + /tsconfig-paths/3.10.1: + resolution: {integrity: sha512-rETidPDgCpltxF7MjBZlAFPUHv5aHH2MymyPvh+vEyWAED4Eb/WeMbsnD/JDr4OKPOA1TssDHgIcpTN5Kh0p6Q==} + dependencies: + json5: 2.2.0 + minimist: 1.2.5 + strip-bom: 3.0.0 + dev: true + /tsconfig-paths/3.9.0: resolution: {integrity: sha512-dRcuzokWhajtZWkQsDVKbWyY+jgcLC5sqJhg2PSgf4ZkH2aHPvaOY8YWGhmjb68b5qqTfasSsDO9k7RUiEmZAw==} dependencies: @@ -4204,9 +4214,9 @@ packages: engines: {node: '>=6'} dev: true - github.com/zkldi/tachi-common/7f0ff9c3905018070c291ae07f3f937d142f925d_ts-node@10.0.0+typescript@4.3.4: - resolution: {tarball: https://codeload.github.com/zkldi/tachi-common/tar.gz/7f0ff9c3905018070c291ae07f3f937d142f925d} - id: github.com/zkldi/tachi-common/7f0ff9c3905018070c291ae07f3f937d142f925d + github.com/zkldi/tachi-common/362c3375c5f11b998aaedc34e4f14b62873b2ac7_ts-node@10.0.0+typescript@4.3.4: + resolution: {tarball: https://codeload.github.com/zkldi/tachi-common/tar.gz/362c3375c5f11b998aaedc34e4f14b62873b2ac7} + id: github.com/zkldi/tachi-common/362c3375c5f11b998aaedc34e4f14b62873b2ac7 name: tachi-common version: 0.1.0 dependencies: diff --git a/server/src/external/mongo/db.ts b/server/src/external/mongo/db.ts index fcd47fc5c..99e435bce 100644 --- a/server/src/external/mongo/db.ts +++ b/server/src/external/mongo/db.ts @@ -24,6 +24,7 @@ import { UserMilestoneDocument, BMSCourseDocument, ImportLockDocument, + UGPTSettings, } from "tachi-common"; import monk, { TMiddleware } from "monk"; import { MONGO_CONNECTION_URL, MONGO_DATABASE_NAME } from "../../lib/setup/config"; @@ -141,6 +142,7 @@ const db = { "orphan-scores": monkDB.get("orphan-scores"), "import-locks": monkDB.get("import-locks"), tables: monkDB.get("tables"), + "game-settings": monkDB.get("game-settings"), }; export default db; diff --git a/server/src/external/mongo/indexes.ts b/server/src/external/mongo/indexes.ts index b6c79eb75..f1baa9d15 100644 --- a/server/src/external/mongo/indexes.ts +++ b/server/src/external/mongo/indexes.ts @@ -19,10 +19,15 @@ function index(fields: Record, options?: IndexOptions) { const UNIQUE = { unique: true }; const staticIndexes: Partial> = { - scores: [index({ scoreID: 1 }, UNIQUE)], + scores: [ + index({ scoreID: 1 }, UNIQUE), + index({ chartID: 1, userID: 1 }), + index({ game: 1, playtype: 1, userID: 1 }), + ], "personal-bests": [ index({ chartID: 1, userID: 1 }, UNIQUE), index({ chartID: 1, "scoreData.percent": 1 }), + index({ game: 1, playtype: 1, userID: 1 }), ], sessions: [ // lol @@ -30,6 +35,7 @@ const staticIndexes: Partial> = { index({ name: "text" }), ], "game-stats": [index({ userID: 1, game: 1, playtype: 1 }, UNIQUE)], + "game-settings": [index({ userID: 1, game: 1, playtype: 1 }, UNIQUE)], "folder-chart-lookup": [index({ chartID: 1, folderID: 1 }, UNIQUE)], "tierlist-data": [ index({ tierlistDataID: 1 }, UNIQUE), @@ -71,7 +77,7 @@ const staticIndexes: Partial> = { index({ "data.hashSHA256": 1 }), ], "bms-course-lookup": [index({ md5sums: 1 }, UNIQUE)], - "api-tokens": [index({ token: 1 }, UNIQUE)], + "api-tokens": [index({ token: 1 }, UNIQUE), index({ userID: 1 })], tables: [index({ tableID: 1 }, UNIQUE), index({ game: 1, playtype: 1 })], }; diff --git a/server/src/lib/game-settings/create-game-settings.test.ts b/server/src/lib/game-settings/create-game-settings.test.ts new file mode 100644 index 000000000..4b95f6e97 --- /dev/null +++ b/server/src/lib/game-settings/create-game-settings.test.ts @@ -0,0 +1,35 @@ +import t from "tap"; +import db from "../../external/mongo/db"; +import { CloseAllConnections } from "../../test-utils/close-connections"; +import ResetDBState from "../../test-utils/resets"; +import { CreateGameSettings } from "./create-game-settings"; + +t.beforeEach(ResetDBState); + +t.test("#CreateGameSettings", (t) => { + t.test("Should create a new user's settings.", async (t) => { + await CreateGameSettings(1, "bms", "7K"); + + const data = await db["game-settings"].findOne({ + userID: 1, + game: "bms", + playtype: "7K", + }); + + t.not(data, null); + + t.end(); + }); + + t.test("Should throw an error if the user already has game-settings.", async (t) => { + await CreateGameSettings(1, "bms", "7K"); + + t.rejects(() => CreateGameSettings(1, "bms", "7K")); + + t.end(); + }); + + t.end(); +}); + +t.teardown(CloseAllConnections); diff --git a/server/src/lib/game-settings/create-game-settings.ts b/server/src/lib/game-settings/create-game-settings.ts new file mode 100644 index 000000000..f590486ac --- /dev/null +++ b/server/src/lib/game-settings/create-game-settings.ts @@ -0,0 +1,44 @@ +import { integer, Game, Playtypes } from "tachi-common"; + +import db from "../../external/mongo/db"; +import CreateLogCtx from "../logger/logger"; + +const logger = CreateLogCtx(__filename); + +/** + * Create GameSettings for a UGPT (which contains their preferences). + */ +export async function CreateGameSettings(userID: integer, game: Game, playtype: Playtypes[Game]) { + const exists = await db["game-settings"].findOne({ + userID, + game, + playtype, + }); + + if (exists) { + logger.error( + `Cannot create ${userID} ${game} ${playtype} game-settings as one already exists?` + ); + + throw new Error( + `Cannot create ${userID} ${game} ${playtype} game-settings as one already exists?` + ); + } + + // @TODO: #238 set sane defaults for game-settings stats. + // const gptConfig = GetGamePTConfig(game, playtype); + + await db["game-settings"].insert({ + userID, + game, + playtype, + preferences: { + preferredProfileAlg: null, + preferredSessionAlg: null, + preferredScoreAlg: null, + stats: [], + }, + }); + + logger.info(`Created game settings for ${userID} (${game} ${playtype}).`); +} diff --git a/server/src/lib/score-import/framework/user-game-stats/update-ugs.test.ts b/server/src/lib/score-import/framework/user-game-stats/update-ugs.test.ts index 119fe494c..521536fe6 100644 --- a/server/src/lib/score-import/framework/user-game-stats/update-ugs.test.ts +++ b/server/src/lib/score-import/framework/user-game-stats/update-ugs.test.ts @@ -14,29 +14,42 @@ const logger = CreateLogCtx(__filename); t.test("#UpdateUsersGamePlaytypeStats", (t) => { t.beforeEach(ResetDBState); - t.test("Should create new UserGameStats if the user has none", async (t) => { - await db["game-stats"].remove({}); + t.test( + "Should create new UserGameStats and UserGameSettings if the user has none", + async (t) => { + await db["game-stats"].remove({}); + await db["game-settings"].remove({}); - const res = await UpdateUsersGamePlaytypeStats("iidx", "SP", 1, null, logger); + const res = await UpdateUsersGamePlaytypeStats("iidx", "SP", 1, null, logger); - t.strictSame(res, [], "Should return an empty object"); + t.strictSame(res, [], "Should return an empty object"); - const gs = await db["game-stats"].findOne(); + const gs = await db["game-stats"].findOne(); - t.hasStrict( - gs, - { + t.hasStrict( + gs, + { + game: "iidx", + playtype: "SP", + userID: 1, + ratings: { ktRating: 0, ktLampRating: 0 }, + classes: {}, + }, + "Should insert an appropriate game-stats object" + ); + + const settings = await db["game-settings"].findOne(); + + t.hasStrict(settings, { game: "iidx", playtype: "SP", userID: 1, - ratings: { ktRating: 0, ktLampRating: 0 }, - classes: {}, - }, - "Should insert an appropriate game-stats object" - ); + preferences: {}, + }); - t.end(); - }); + t.end(); + } + ); t.test("Should update UserGameStats if the user has one", async (t) => { await db["game-stats"].remove({}); diff --git a/server/src/lib/score-import/framework/user-game-stats/update-ugs.ts b/server/src/lib/score-import/framework/user-game-stats/update-ugs.ts index b2f9680c5..6ebcca0b1 100644 --- a/server/src/lib/score-import/framework/user-game-stats/update-ugs.ts +++ b/server/src/lib/score-import/framework/user-game-stats/update-ugs.ts @@ -4,6 +4,7 @@ import { Game, Playtypes, integer, UserGameStats, ClassDelta } from "tachi-common"; import db from "../../../../external/mongo/db"; +import { CreateGameSettings } from "../../../game-settings/create-game-settings"; import { KtLogger } from "../../../logger/logger"; import { CalculateClassDeltas, UpdateUGSClasses } from "./classes"; import { CalculateRatings } from "./rating"; @@ -64,6 +65,7 @@ export async function UpdateUsersGamePlaytypeStats( logger.info(`Created new gamestats for ${game} (${playtype})`); await db["game-stats"].insert(newStats); + await CreateGameSettings(userID, game, playtype); } return deltas; diff --git a/server/src/lib/score-import/import-types/DOCUMENTATION b/server/src/lib/score-import/import-types/DOCUMENTATION deleted file mode 100644 index e1766774b..000000000 --- a/server/src/lib/score-import/import-types/DOCUMENTATION +++ /dev/null @@ -1,52 +0,0 @@ -This is temporary documentation for the import mechanisms in this folder. - -# ImportType - -An import type is a string in the following form: - -(mode)/(name). - -## ImportType Modes - -- `file`: A file is uploaded to the server, and data is drew from a buffer. -- `ir`: The data is sent to us in a HTTP Request Body. -- `api`: We request the data on the users behalf from another service. - -## ImportType Names - -- `file/eamusement-iidx-csv`: An IIDX e-amusement CSV file. -- `file/batch-manual`: A JSON file that follows our BATCH-MANUAL specification. - -- `ir/direct-manual`: A request body containing JSON in BATCH-MANUAL form. - -# Parsers - -Parser functions convert the input data into an Iterable. This iterable may -take any form, such as an Array or a Generator which yields paginated API returns. - -They also return some information about the import, such as the game and -playtypes involved. It will also return a Converter Function. - -The iterable itself can contain any style of data. In general, one element -of the iterable will likely correlate with one score. However, in some -scenarios, one element of the iterable may contain multiple scores, such as -iterating over the rows of an IIDX E-amusement CSV, where one row contains -up to 5 separate scores. - -# Converters - -Converter functions are applied upon every member of the iterable. These -convert a member of the iterable into a Dry Score, which is a partial -Kamaitachi Score. - -Converter functions may return the Dry Score, or any of the 'Score Failures' - -A specific set of fail states for a score import, such as the score being -invalid or the appropriate song/chart data for it not existing. - -Converter functions may also return an array of the previous results, in the -case that a single element of the iterable corresponded to multiple scores. - -# Common - -This is for common code - `ir/direct-manual` and `file/batch-manual` use very -similar code paths, so code here is pulled out into a common folder. \ No newline at end of file diff --git a/server/src/lib/score-import/import-types/common/api-kai/traverse-api.ts b/server/src/lib/score-import/import-types/common/api-kai/traverse-api.ts index 6dbc08c06..d4def06d3 100644 --- a/server/src/lib/score-import/import-types/common/api-kai/traverse-api.ts +++ b/server/src/lib/score-import/import-types/common/api-kai/traverse-api.ts @@ -30,6 +30,7 @@ export async function* TraverseKaiAPI( if (origin !== baseUrl) { logger.severe(`${baseUrl} attempted SSRF with url ${url}?`); + throw new ScoreImportFatalError(500, `${baseUrl} returned invalid data.`); } @@ -56,7 +57,7 @@ export async function* TraverseKaiAPI( } if (json._links === null || typeof json._links !== "object") { - logger.error(`Recieved invalid JSON from ${url}. Invalid _links.`); + logger.error(`Recieved invalid JSON from ${url}. Invalid _links.`, { body: json }); throw new ScoreImportFatalError( 500, @@ -70,12 +71,18 @@ export async function* TraverseKaiAPI( // exit the loop after this, we're on the last page. fetchMoreData = false; } else { - logger.error(`Recieved invalid response from ${url}. Invalid _links._next.`); + logger.error(`Recieved invalid response from ${url}. Invalid _links._next.`, { + body: json, + }); throw new ScoreImportFatalError(500, `Recieved invalid _links._next prop from ${url}.`); } if (!Array.isArray(json._items)) { + logger.error(`Recieved invalid response from ${url}. Invalid _items.`, { + body: json, + }); + throw new ScoreImportFatalError(500, `Recieved invalid _items from ${url}.`); } diff --git a/server/src/lib/ugpt-stat/evaluator.test.ts b/server/src/lib/ugpt-stat/evaluator.test.ts new file mode 100644 index 000000000..b1d81a466 --- /dev/null +++ b/server/src/lib/ugpt-stat/evaluator.test.ts @@ -0,0 +1,98 @@ +import deepmerge from "deepmerge"; +import t from "tap"; +import { EvaluateUGPTStat } from "./evaluator"; +import { CloseAllConnections } from "../../test-utils/close-connections"; +import ResetDBState from "../../test-utils/resets"; +import { + Testing511SPA, + TestingIIDXFolderSP10, + TestingIIDXSPScorePB, +} from "../../test-utils/test-data"; +import { IIDX_LAMPS } from "../constants/game"; +import { CreateFolderChartLookup } from "../../utils/folder"; +import db from "../../external/mongo/db"; + +/* eslint-disable no-return-await */ +// causes a race condition otherwise due to weird tap interaction + +t.beforeEach(ResetDBState); +t.beforeEach(async () => await CreateFolderChartLookup(TestingIIDXFolderSP10)); +t.beforeEach(async () => await db["personal-bests"].insert(deepmerge(TestingIIDXSPScorePB, {}))); + +t.test("#EvaluateUGPTStat", (t) => { + t.test("Should evaluate a folder stat.", async (t) => { + const data = await EvaluateUGPTStat( + { + folderID: TestingIIDXFolderSP10.folderID, + mode: "folder", + property: "lamp", + gte: IIDX_LAMPS.HARD_CLEAR, + }, + 1 + ); + + t.strictSame(data, { + value: 1, + outOf: 1, + }); + + t.end(); + }); + + t.test("Should evaluate a multi-folder stat.", async (t) => { + const data = await EvaluateUGPTStat( + { + folderID: [TestingIIDXFolderSP10.folderID], + mode: "folder", + property: "lamp", + gte: IIDX_LAMPS.HARD_CLEAR, + }, + 1 + ); + + t.strictSame(data, { + value: 1, + outOf: 1, + }); + + t.end(); + }); + + t.test("Should evaluate a chart stat.", async (t) => { + const data = await EvaluateUGPTStat( + { + chartID: Testing511SPA.chartID, + mode: "chart", + property: "score", + }, + 1 + ); + + t.strictSame(data, { + value: 1479, + }); + + t.end(); + }); + + t.test("Should return null if the user has no score on this chart.", async (t) => { + const data = await EvaluateUGPTStat( + { + chartID: "nonsense", + mode: "chart", + property: "score", + }, + 1 + ); + + t.strictSame(data, { + value: null, + }); + + t.end(); + }); + + t.end(); +}); + +t.teardown(CloseAllConnections); diff --git a/server/src/lib/ugpt-stat/evaluator.ts b/server/src/lib/ugpt-stat/evaluator.ts new file mode 100644 index 000000000..8054858de --- /dev/null +++ b/server/src/lib/ugpt-stat/evaluator.ts @@ -0,0 +1,88 @@ +import { integer, UGPTStatDetails, UGPTStatChart, UGPTStatFolder } from "tachi-common"; +import db from "../../external/mongo/db"; +import { GetFolderChartIDs } from "../../utils/folder"; + +export function EvaluateUGPTStat( + details: UGPTStatDetails, + userID: integer +): Promise<{ + value: number | null; + outOf?: number; +}> { + if (details.mode === "chart") { + return EvaluateUGPTChartStat(details, userID); + } else if (details.mode === "folder") { + return EvaluateUGPTFolderStat(details, userID); + } + + // @ts-expect-error This should never happen anyway. + throw new Error(`Invalid mode of ${details.mode} as details mode?`); +} + +async function EvaluateUGPTChartStat(details: UGPTStatChart, userID: integer) { + // requires special handling + if (details.property === "playcount") { + return { value: await db.scores.count({ chartID: details.chartID, userID }) }; + } + + const mongoProp = PropToMongoProp(details.property); + + const pb = await db["personal-bests"].findOne( + { chartID: details.chartID, userID }, + { projection: { [mongoProp]: 1 } } + ); + + if (!pb) { + return { value: null }; + } + + const scProp = PropToScoreDataProp(details.property); + + return { value: pb.scoreData[scProp] }; +} + +async function EvaluateUGPTFolderStat(details: UGPTStatFolder, userID: integer) { + let chartIDs; + if (Array.isArray(details.folderID)) { + chartIDs = (await Promise.all(details.folderID.map(GetFolderChartIDs))).flat(1); + } else { + chartIDs = await GetFolderChartIDs(details.folderID); + } + + const mongoProp = PropToMongoProp(details.property); + + const value = await db["personal-bests"].count({ + userID, + // @optimisable - This is slightly inefficent, maybe we can use relational-style querying? + chartID: { $in: chartIDs }, + [mongoProp]: { $gte: details.gte }, + }); + + return { value, outOf: chartIDs.length }; +} + +function PropToMongoProp(prop: "score" | "lamp" | "grade" | "percent") { + switch (prop) { + case "score": + return "scoreData.score"; + case "lamp": + return "scoreData.lampIndex"; + case "grade": + return "scoreData.gradeIndex"; + case "percent": + return "scoreData.percent"; + } +} + +function PropToScoreDataProp(prop: "score" | "lamp" | "grade" | "percent") { + switch (prop) { + case "score": + return "score"; + case "lamp": + return "lampIndex"; + case "grade": + return "gradeIndex"; + case "percent": + return "percent"; + } +} diff --git a/server/src/lib/ugpt-stat/get-stats.test.ts b/server/src/lib/ugpt-stat/get-stats.test.ts new file mode 100644 index 000000000..2b2f2b97f --- /dev/null +++ b/server/src/lib/ugpt-stat/get-stats.test.ts @@ -0,0 +1,115 @@ +import t from "tap"; +import db from "../../external/mongo/db"; +import { CloseAllConnections } from "../../test-utils/close-connections"; +import ResetDBState from "../../test-utils/resets"; +import { + TestingIIDXFolderSP10, + Testing511SPA, + TestingIIDXSPScorePB, +} from "../../test-utils/test-data"; +import { IIDX_LAMPS } from "../constants/game"; +import { EvaluateUsersGPTStats } from "./get-stats"; +import deepmerge from "deepmerge"; +import { CreateFolderChartLookup } from "../../utils/folder"; + +t.beforeEach(ResetDBState); + +t.beforeEach(async () => { + await CreateFolderChartLookup(TestingIIDXFolderSP10); + + await db["game-settings"].remove({}); + + await db["game-settings"].insert({ + userID: 1, + game: "iidx", + playtype: "SP", + preferences: { + preferredProfileAlg: null, + preferredScoreAlg: null, + preferredSessionAlg: null, + stats: [ + { + folderID: TestingIIDXFolderSP10.folderID, + mode: "folder", + property: "lamp", + gte: IIDX_LAMPS.HARD_CLEAR, + }, + { + chartID: Testing511SPA.chartID, + mode: "chart", + property: "score", + }, + ], + }, + }); + + await db["personal-bests"].insert(deepmerge(TestingIIDXSPScorePB, {})); +}); + +t.test("#EvalulateUsersGPTStats", (t) => { + t.test("Should evaluate a user's preferred stats.", async (t) => { + const res = await EvaluateUsersGPTStats(1, "iidx", "SP"); + + t.hasStrict(res, [ + { + stat: { folderID: TestingIIDXFolderSP10.folderID }, + value: { + value: 1, + outOf: 1, + }, + }, + { + stat: { chartID: Testing511SPA.chartID }, + value: { + value: 1479, + }, + }, + ]); + + t.end(); + }); + + t.test("Should throw an error if the user does not have game-settings.", async (t) => { + await db["game-settings"].remove({}); + + t.rejects(() => EvaluateUsersGPTStats(1, "iidx", "SP")); + + t.end(); + }); + + t.test("Should project another users stats onto the given user if set.", async (t) => { + await db["personal-bests"].insert( + deepmerge(TestingIIDXSPScorePB, { + userID: 2, + scoreData: { + score: 300, + lampIndex: IIDX_LAMPS.CLEAR, + }, + }) + ); + + const res = await EvaluateUsersGPTStats(2, "iidx", "SP", 1); + + t.hasStrict(res, [ + { + stat: { folderID: TestingIIDXFolderSP10.folderID }, + value: { + value: 0, + outOf: 1, + }, + }, + { + stat: { chartID: Testing511SPA.chartID }, + value: { + value: 300, + }, + }, + ]); + + t.end(); + }); + + t.end(); +}); + +t.teardown(CloseAllConnections); diff --git a/server/src/lib/ugpt-stat/get-stats.ts b/server/src/lib/ugpt-stat/get-stats.ts new file mode 100644 index 000000000..e7629e9a8 --- /dev/null +++ b/server/src/lib/ugpt-stat/get-stats.ts @@ -0,0 +1,43 @@ +import db from "../../external/mongo/db"; +import { integer, Game, Playtypes } from "tachi-common"; +import CreateLogCtx from "../logger/logger"; +import { EvaluateUGPTStat } from "./evaluator"; + +const logger = CreateLogCtx(__filename); + +/** + * Evaluate a users set GPT Stats. + * @param projectUserStats - Optionally, provide another users ID here. Their set stats will be + * used instead. + */ +export async function EvaluateUsersGPTStats( + userID: integer, + game: Game, + playtype: Playtypes[Game], + projectUserStats?: integer +) { + const getSettingsID = projectUserStats ?? userID; + const settings = await db["game-settings"].findOne({ + userID: getSettingsID, + game, + playtype, + }); + + if (!settings) { + logger.error( + `User ${getSettingsID} has no game-settings, yet a call to EvalulateUsersGPTStats was made.` + ); + + throw new Error( + `User ${getSettingsID} has no game-settings, yet a call to EvalulateUsersGPTStats was made.` + ); + } + + const results = await Promise.all( + settings.preferences.stats.map((details) => + EvaluateUGPTStat(details, userID).then((v) => ({ stat: details, value: v })) + ) + ); + + return results; +} diff --git a/server/src/server/middleware/request-logger.ts b/server/src/server/middleware/request-logger.ts new file mode 100644 index 000000000..a98c17893 --- /dev/null +++ b/server/src/server/middleware/request-logger.ts @@ -0,0 +1,43 @@ +import CreateLogCtx from "lib/logger/logger"; +import { RequestHandler, Response } from "express-serve-static-core"; + +const logger = CreateLogCtx(__filename); + +// https://stackoverflow.com/a/64546368/11885828 +// Taken from Jonathan Turnock - This is an *incredibly* nice +// solution for post-request express logging! + +const ResSendInteceptor = (res: Response, send: Response["send"]) => (content: unknown) => { + // @ts-expect-error general monkeypatching error + res.contentBody = content; + res.send = send; + res.send(content); +}; + +export const RequestLoggerMiddleware: RequestHandler = (req, res, next) => { + logger.debug(`Received request ${req.method} ${req.url}.`, { + query: req.query, + body: req.body, + }); + + // @ts-expect-error we're doing some wacky monkey patching + res.send = ResSendInteceptor(res, res.send); + + res.on("finish", () => { + const contents = { + // @ts-expect-error we're doing some monkey patching + body: res.contentBody, + statusCode: res.statusCode, + }; + + if (res.statusCode < 400) { + logger.verbose(`(${req.method} ${req.url}) Returned`, contents); + } else if (res.statusCode < 500) { + logger.info(`(${req.method} ${req.url}) Returned`, contents); + } else { + logger.error(`(${req.method} ${req.url}) Returned`, contents); + } + }); + + return next(); +}; diff --git a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/router.ts b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/router.ts index 14de59f13..b43437a8b 100644 --- a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/router.ts +++ b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/router.ts @@ -20,6 +20,8 @@ import pbsRouter from "./pbs/router"; import sessionsRouter from "./sessions/router"; import foldersFolderIDRouter from "./folders/_folderID/router"; import tablesRouter from "./tables/router"; +import statsRouter from "./stats/router"; +import settingsRouter from "./settings/router"; const router: Router = Router({ mergeParams: true }); @@ -402,6 +404,8 @@ router.get("/leaderboard-adjacent", async (req, res) => { router.use("/pbs", pbsRouter); router.use("/sessions", sessionsRouter); router.use("/tables", tablesRouter); +router.use("/stats", statsRouter); +router.use("/settings", settingsRouter); router.use("/folders/:folderID", foldersFolderIDRouter); export default router; diff --git a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/settings/router.test.ts b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/settings/router.test.ts new file mode 100644 index 000000000..fb788d4fa --- /dev/null +++ b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/settings/router.test.ts @@ -0,0 +1,155 @@ +/* eslint-disable no-await-in-loop */ +import db from "external/mongo/db"; +import { AllPermissions } from "server/middleware/auth"; +import t from "tap"; +import { CloseAllConnections } from "test-utils/close-connections"; +import mockApi from "test-utils/mock-api"; +import ResetDBState from "test-utils/resets"; + +t.beforeEach(ResetDBState); + +t.test("GET /api/v1/users/:userID/games/:game/:playtype/settings", (t) => { + t.test("Should return a user's settings.", async (t) => { + const res = await mockApi.get("/api/v1/users/1/games/iidx/SP/settings"); + + t.strictSame(res.body.body, { + userID: 1, + game: "iidx", + playtype: "SP", + preferences: { + preferredScoreAlg: null, + preferredSessionAlg: null, + preferredProfileAlg: null, + stats: [], + }, + }); + + t.end(); + }); + + t.end(); +}); + +t.test("PATCH /api/v1/users/:userID/games/:game/:playtype/settings", (t) => { + t.test("Should update a user's settings.", async (t) => { + await db["api-tokens"].insert({ + userID: 1, + identifier: "api_token", + permissions: { + customise_profile: true, + }, + token: "api_token", + }); + + const res = await mockApi + .patch("/api/v1/users/1/games/iidx/SP/settings") + .set("Authorization", "Bearer api_token") + .send({ + preferredScoreAlg: "ktRating", + }); + + t.strictSame( + res.body.body, + { + userID: 1, + game: "iidx", + playtype: "SP", + preferences: { + preferredScoreAlg: "ktRating", + preferredSessionAlg: null, + preferredProfileAlg: null, + stats: [], + }, + }, + "Should only update the mutated properties." + ); + + const data = await db["game-settings"].findOne({ + userID: 1, + game: "iidx", + playtype: "SP", + }); + + t.equal(data?.preferences.preferredScoreAlg, "ktRating"); + + t.end(); + }); + + t.test("Requires the user to be authed as the requested user.", async (t) => { + await db["api-tokens"].insert({ + userID: 2, + identifier: "api_token", + permissions: { + customise_profile: true, + }, + token: "api_token", + }); + + const res = await mockApi + .patch("/api/v1/users/1/games/iidx/SP/settings") + .set("Authorization", `Bearer api_token`); + + t.equal(res.statusCode, 403); + + t.end(); + }); + + t.test("Requires the permission customise_profile", async (t) => { + await db["api-tokens"].insert({ + userID: 1, + identifier: "api_token", + permissions: { + customise_profile: false, + }, + token: "api_token", + }); + + const res = await mockApi + .patch("/api/v1/users/1/games/iidx/SP/settings") + .set("Authorization", `Bearer api_token`); + + t.equal(res.statusCode, 403); + + t.end(); + }); + + t.test("Should reject invalid values.", async (t) => { + await db["api-tokens"].insert({ + userID: 1, + identifier: "foo", + permissions: { + customise_profile: true, + }, + token: "api_token", + }); + + for (const key of [ + "preferredScoreAlg", + "preferredSessionAlg", + "preferredProfileAlg", + ] as const) { + const res = await mockApi + .patch("/api/v1/users/1/games/iidx/SP/settings") + .set("Authorization", "Bearer api_token") + .send({ + [key]: "nonsense", + }); + + t.equal(res.statusCode, 400); + + const data = await db["game-settings"].findOne({ + userID: 1, + game: "iidx", + playtype: "SP", + }); + + t.equal(data?.preferences[key], null, "Should be unmodified."); + } + + t.end(); + }); + + t.end(); +}); + +t.teardown(CloseAllConnections); diff --git a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/settings/router.ts b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/settings/router.ts new file mode 100644 index 000000000..2e6c09210 --- /dev/null +++ b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/settings/router.ts @@ -0,0 +1,133 @@ +import { GetGamePTConfig } from "tachi-common"; +import { Router } from "express"; +import { SYMBOL_TachiData } from "../../../../../../../../../../lib/constants/tachi"; +import { RequirePermissions } from "../../../../../../../../../middleware/auth"; +import { RequireAuthedAsUser } from "../../../../middleware"; +import p from "prudence"; +import { FormatPrError } from "../../../../../../../../../../utils/prudence"; +import db from "../../../../../../../../../../external/mongo/db"; +import CreateLogCtx from "../../../../../../../../../../lib/logger/logger"; +import { FormatUserDoc } from "../../../../../../../../../../utils/user"; + +const logger = CreateLogCtx(__filename); + +const router: Router = Router({ mergeParams: true }); + +/** + * Update your settings. + * + * @param - See the prudence middleware. + * + * @name PATCH /api/v1/users/:userID/games/:game/:playtype/settings + */ +router.patch( + "/", + RequireAuthedAsUser, + RequirePermissions("customise_profile"), + async (req, res) => { + const user = req[SYMBOL_TachiData]!.requestedUser!; + const game = req[SYMBOL_TachiData]!.game!; + const playtype = req[SYMBOL_TachiData]!.playtype!; + + const gptConfig = GetGamePTConfig(game, playtype); + + const err = p(req.body, { + preferredScoreAlg: p.optional(p.nullable(p.isIn(gptConfig.scoreRatingAlgs))), + preferredSessionAlg: p.optional(p.nullable(p.isIn(gptConfig.sessionRatingAlgs))), + preferredProfileAlg: p.optional(p.nullable(p.isIn(gptConfig.profileRatingAlgs))), + }); + + if (err) { + return res.status(400).json({ + success: false, + description: FormatPrError(err, "Invalid game-settings."), + }); + } + + const updateQuery: Record = {}; + + // @warning Slightly icky dynamic prop assignment instead of copypasta. + for (const key of ["Score", "Session", "Profile"]) { + const k = `preferred${key}Alg`; + + if (req.body[k] !== undefined) { + updateQuery[`preferences.${k}`] = req.body[k]; + } + } + + if (Object.keys(updateQuery).length === 0) { + const settings = await db["game-settings"].findOne({ + userID: user.id, + game, + playtype, + }); + + return res.status(200).json({ + success: true, + description: `Nothing has been modified, successfully.`, + body: settings, + }); + } + + await db["game-settings"].update( + { + userID: user.id, + game, + playtype, + }, + { + $set: updateQuery, + } + ); + + const settings = await db["game-settings"].findOne({ + userID: user.id, + game, + playtype, + }); + + if (!settings) { + logger.error( + `User ${FormatUserDoc( + user + )} has no game-settings, but has played ${game} ${playtype}?` + ); + + return res.status(500).json({ + success: false, + description: `An internal error has occured. Do not repeat this request.`, + }); + } + + return res.status(200).json({ + success: true, + description: `Updated settings.`, + body: settings, + }); + } +); + +/** + * Returns this user's settings. + * + * @name GET /api/v1/users/:userID/games/:game/:playtype/settings + */ +router.get("/", async (req, res) => { + const user = req[SYMBOL_TachiData]!.requestedUser!; + const game = req[SYMBOL_TachiData]!.game!; + const playtype = req[SYMBOL_TachiData]!.playtype!; + + const settings = await db["game-settings"].findOne({ + userID: user.id, + game, + playtype, + }); + + return res.status(200).json({ + success: true, + description: `Returned ${FormatUserDoc(user)}'s settings.`, + body: settings, + }); +}); + +export default router; diff --git a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/stats/router.test.ts b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/stats/router.test.ts new file mode 100644 index 000000000..8b40e100f --- /dev/null +++ b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/stats/router.test.ts @@ -0,0 +1,294 @@ +import db from "external/mongo/db"; +import { IIDX_GRADES, IIDX_LAMPS } from "lib/constants/game"; +import t from "tap"; +import { CloseAllConnections } from "test-utils/close-connections"; +import mockApi from "test-utils/mock-api"; +import ResetDBState from "test-utils/resets"; +import { TestingIIDXFolderSP10, Testing511SPA, TestingIIDXSPScorePB } from "test-utils/test-data"; +import { CreateFolderChartLookup } from "utils/folder"; +import deepmerge from "deepmerge"; +import { PrivateUserDocument } from "tachi-common"; + +t.beforeEach(ResetDBState); +t.beforeEach(async () => { + await CreateFolderChartLookup(TestingIIDXFolderSP10); + + await db["game-settings"].remove({}); + + await db["game-settings"].insert({ + userID: 1, + game: "iidx", + playtype: "SP", + preferences: { + preferredProfileAlg: null, + preferredScoreAlg: null, + preferredSessionAlg: null, + stats: [ + { + folderID: TestingIIDXFolderSP10.folderID, + mode: "folder", + property: "lamp", + gte: IIDX_LAMPS.HARD_CLEAR, + }, + { + chartID: Testing511SPA.chartID, + mode: "chart", + property: "score", + }, + ], + }, + }); + + await db["personal-bests"].insert(deepmerge(TestingIIDXSPScorePB, {})); +}); + +t.test("GET /api/v1/users/:userID/games/:game/:playtype/stats", (t) => { + t.test("Should return the evaluated stats for this user.", async (t) => { + const res = await mockApi.get("/api/v1/users/1/games/iidx/SP/stats"); + + t.hasStrict(res.body.body, [ + { + stat: { + mode: "folder", + }, + value: { + value: 1, + outOf: 1, + }, + }, + { + stat: { + mode: "chart", + }, + value: { + value: 1479, + }, + }, + ]); + + t.end(); + }); + + t.test("Should project another users stats over this one if projectUser is set.", async (t) => { + await db["game-settings"].insert({ + userID: 2, + game: "iidx", + playtype: "SP", + preferences: { + preferredProfileAlg: null, + preferredScoreAlg: null, + preferredSessionAlg: null, + stats: [ + { + mode: "folder", + folderID: TestingIIDXFolderSP10.folderID, + property: "score", + gte: 1480, + }, + ], + }, + }); + + // hack + await db.users.insert({ + username: "test_acc", + id: 2, + } as PrivateUserDocument); + + const res = await mockApi.get("/api/v1/users/1/games/iidx/SP/stats?projectUser=2"); + + t.hasStrict(res.body.body, [ + { + stat: { + mode: "folder", + }, + value: { + value: 0, + outOf: 1, + }, + }, + ]); + + t.end(); + }); + + t.test("Should return 404 if the user has not played this game.", async (t) => { + const res = await mockApi.get("/api/v1/users/1/games/bms/7K/stats"); + + t.equal(res.statusCode, 404); + + t.match(res.body.description, /not played/iu); + + t.end(); + }); + + t.end(); +}); + +t.test("GET /api/v1/users/:userID/games/:game/:playtype/stats/custom", (t) => { + t.test("Should return a custom folder evaluated stat on a user.", async (t) => { + const res = await mockApi.get( + `/api/v1/users/1/games/iidx/SP/stats/custom?mode=folder&prop=grade>e=3&folderID=${TestingIIDXFolderSP10.folderID}` + ); + + t.strictSame(res.body.body, { + value: 1, + outOf: 1, + }); + + t.end(); + }); + + t.test("Should return a custom chart evaluated stat on a user.", async (t) => { + const res = await mockApi.get( + `/api/v1/users/1/games/iidx/SP/stats/custom?mode=chart&prop=grade&chartID=${Testing511SPA.chartID}` + ); + + t.strictSame(res.body.body, { + value: IIDX_GRADES.AAA, + }); + + t.end(); + }); + + t.test("Should reject for invalid folderID.", async (t) => { + const res = await mockApi.get( + `/api/v1/users/1/games/iidx/SP/stats/custom?mode=folder&prop=grade>e=4` + ); + + t.equal(res.statusCode, 400, "Should reject for no folderID"); + + const res2 = await mockApi.get( + `/api/v1/users/1/games/iidx/SP/stats/custom?mode=folder&prop=grade>e=4&folderID=foo&folderID=bar` + ); + + t.equal(res2.statusCode, 400, "Should reject for non-string folderID"); + + t.end(); + }); + + t.test("Should reject for invalid chartID.", async (t) => { + const res = await mockApi.get( + `/api/v1/users/1/games/iidx/SP/stats/custom?mode=chart&prop=grade>e=4` + ); + + t.equal(res.statusCode, 400, "Should reject for no chartID"); + + const res2 = await mockApi.get( + `/api/v1/users/1/games/iidx/SP/stats/custom?mode=chart&prop=grade&chartID=foo&chartID=bar` + ); + + t.equal(res2.statusCode, 400, "Should reject for non-string chartID"); + + t.end(); + }); + + t.test("Should reject for invalid mode", async (t) => { + const res = await mockApi.get( + `/api/v1/users/1/games/iidx/SP/stats/custom?mode=nonsense&prop=grade>e=4&chartID=foo` + ); + + t.equal(res.statusCode, 400); + + t.end(); + }); + + t.end(); +}); + +// @todo #239 PUT UGPT-Stats needs some tests for input validation. +t.test("PUT /api/v1/users/:userID/games/:game/:playtype/stats", (t) => { + t.test("Requires the user to be authed as the requested user.", async (t) => { + await db["api-tokens"].insert({ + userID: 2, + identifier: "alt_token", + permissions: { + customise_profile: true, + }, + token: "alt_token", + }); + + const res = await mockApi + .put("/api/v1/users/1/games/iidx/SP/stats") + .set("Authorization", `Bearer alt_token`); + + t.equal(res.statusCode, 403); + + t.end(); + }); + + t.test("Requires the permission customise_profile", async (t) => { + await db["api-tokens"].insert({ + userID: 1, + identifier: "alt_token", + permissions: { + customise_profile: false, + }, + token: "alt_token", + }); + + const res = await mockApi + .put("/api/v1/users/1/games/iidx/SP/stats") + .set("Authorization", `Bearer alt_token`); + + t.equal(res.statusCode, 403); + + t.end(); + }); + + t.test("Should replace a user's preferences.stats with the contained stats.", async (t) => { + await db["api-tokens"].insert({ + userID: 1, + identifier: "alt_token", + permissions: { + customise_profile: true, + }, + token: "alt_token", + }); + + const res = await mockApi + .put("/api/v1/users/1/games/iidx/SP/stats") + .set("Authorization", `Bearer alt_token`) + .send([ + { + mode: "chart", + chartID: Testing511SPA.chartID, + property: "lamp", + }, + ]); + + t.equal(res.statusCode, 200); + + const data = await db["game-settings"].findOne({ userID: 1, game: "iidx", playtype: "SP" }); + + t.hasStrict( + data?.preferences.stats, + [ + { + mode: "chart", + chartID: Testing511SPA.chartID, + property: "lamp", + }, + ], + "Should update preferences.stats in the database." + ); + + t.hasStrict( + res.body.body.preferences.stats, + [ + { + mode: "chart", + chartID: Testing511SPA.chartID, + property: "lamp", + }, + ], + "Should return the updated preferences" + ); + + t.end(); + }); + + t.end(); +}); + +t.teardown(CloseAllConnections); diff --git a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/stats/router.ts b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/stats/router.ts new file mode 100644 index 000000000..6e1e3f932 --- /dev/null +++ b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/stats/router.ts @@ -0,0 +1,245 @@ +import { Router } from "express"; +import { SYMBOL_TachiData } from "../../../../../../../../../../lib/constants/tachi"; +import { EvaluateUsersGPTStats } from "../../../../../../../../../../lib/ugpt-stat/get-stats"; +import { ResolveUser } from "../../../../../../../../../../utils/user"; +import p from "prudence"; +import { FormatPrError } from "../../../../../../../../../../utils/prudence"; +import { UGPTStatDetails, GetGamePTConfig } from "tachi-common"; +import { EvaluateUGPTStat } from "../../../../../../../../../../lib/ugpt-stat/evaluator"; +import db from "../../../../../../../../../../external/mongo/db"; +import { RequirePermissions } from "../../../../../../../../../middleware/auth"; +import { RequireAuthedAsUser } from "../../../../middleware"; +const router: Router = Router({ mergeParams: true }); + +/** + * Evaluate this users set stats. + * + * @param projectUser - Project another user's stats instead of their set stats. + * + * @name GET /api/v1/users/:userID/games/:game/:playtype/stats + */ +router.get("/", async (req, res) => { + const user = req[SYMBOL_TachiData]!.requestedUser!; + const game = req[SYMBOL_TachiData]!.game!; + const playtype = req[SYMBOL_TachiData]!.playtype!; + + let projectUser; + + if (typeof req.query.projectUser === "string") { + const user = await ResolveUser(req.query.projectUser); + + if (!user) { + return res.status(404).json({ + success: false, + description: `The projected user ${req.query.projectUser} does not exist.`, + }); + } + + projectUser = user.id; + } + + const results = await EvaluateUsersGPTStats(user.id, game, playtype, projectUser); + + return res.status(200).json({ + success: true, + description: `Evaluated ${results.length} stats.`, + body: results, + }); +}); + +/** + * Evalulate a custom stat on this user. + * + * @param mode - "folder" or "chart" + * @param prop - "grade" | "lamp" | "score" | "percent" and "playcount" if mode is chart. + * @param chartID - If mode is "chart" this must contain the chartID the stat is referencing. + * @param gte - If mode is "folder" this must contain the value the property must be greater than. + * + * @TODO: #237 This custom stat code accepts charts and folders from any game and any playtype - technically, + * this is breaking rest quite painfully! + * + * @name GET /api/v1/users/:userID/games/:game/:playtype/stats/custom + */ +router.get("/custom", async (req, res) => { + const user = req[SYMBOL_TachiData]!.requestedUser!; + + let stat: UGPTStatDetails; + + if (req.query.mode === "folder") { + const err = p( + req.query, + { + mode: p.is("folder"), + prop: p.isIn("grade", "lamp", "score", "percent"), + folderID: "string", + // lazy regex for matching strings that look like numbers + gte: p.regex(/^[0-9]*(.[0-9])?$/u), + }, + {}, + { allowExcessKeys: true } + ); + + if (err) { + return res.status(400).json({ + success: false, + description: FormatPrError(err, "Invalid folder stat"), + }); + } + + const folderIDs = (req.query.folderID as string).split(","); + + stat = { + mode: "folder", + property: req.query.prop as "grade" | "lamp" | "score" | "percent", + folderID: folderIDs.length === 1 ? (req.query.folderID as string) : folderIDs, + gte: Number(req.query.gte), + }; + } else if (req.query.mode === "chart") { + const err = p( + req.query, + { + mode: p.is("chart"), + prop: p.isIn("grade", "lamp", "score", "percent", "playcount"), + chartID: "string", + }, + {}, + { allowExcessKeys: true } + ); + + if (err) { + return res.status(400).json({ + success: false, + description: FormatPrError(err, "Invalid chart stat"), + }); + } + + stat = { + mode: "chart", + property: req.query.prop as "grade" | "lamp" | "score" | "percent" | "playcount", + chartID: req.query.chartID as string, + }; + } else { + return res.status(400).json({ + success: false, + description: `Invalid stat mode - expected either 'chart' or 'folder'.`, + }); + } + + const result = await EvaluateUGPTStat(stat, user.id); + + return res.status(200).json({ + success: true, + description: `Evaluated Stat for ${user.username}`, + body: result, + }); +}); + +/** + * Replaces a user's preferred stats. + * + * @name PUT /api/v1/users/:userID/games/:game/:playtype/stats + */ + +router.put("/", RequireAuthedAsUser, RequirePermissions("customise_profile"), async (req, res) => { + const user = req[SYMBOL_TachiData]!.requestedUser!; + const game = req[SYMBOL_TachiData]!.game!; + const playtype = req[SYMBOL_TachiData]!.playtype!; + + const gptConfig = GetGamePTConfig(game, playtype); + + if (!Array.isArray(req.body)) { + return res.status(400).json({ + success: false, + description: `No stats provided, or was not an array.`, + }); + } + + if (req.body.length > 6) { + return res.status(400).json({ + success: false, + description: `You are only allowed 6 stats at once.`, + }); + } + + for (const stat of req.body) { + let err; + if (stat?.mode === "chart") { + err = p(stat, { + chartID: "string", + mode: p.is("chart"), + property: p.isIn("grade", "lamp", "score", "percent", "playcount"), + }); + } else if (stat?.mode === "folder") { + err = p(stat, { + folderID: (self) => { + if (typeof self === "string") { + return true; + } else if (Array.isArray(self)) { + return self.length <= 6 && self.every((r) => typeof r === "string"); + } + + return false; + }, + mode: p.is("folder"), + property: p.isIn("grade", "lamp", "score", "percent"), + // @ts-expect-error todo: investigate this weird prudence typeerror + gte: (self, parent) => { + if (typeof self !== "number") { + return "Expected a number."; + } + + if (parent.property === "grade") { + return !!gptConfig.grades[self]; + } else if (parent.property === "lamp") { + return !!gptConfig.lamps[self]; + } else if (parent.property === "score") { + return p.isPositive(self); + } else if (parent.property === "percent") { + return p.isBetween(0, gptConfig.percentMax); + } + + return `Invalid property of ${parent.property}`; + }, + }); + } else { + return res.status(400).json({ + success: false, + description: `Invalid stat - Expected ${stat?.mode} to be 'chart' or 'folder'.`, + }); + } + + if (err) { + return res.status(400).json({ + success: false, + description: FormatPrError(err, "Invalid stat."), + }); + } + } + + await db["game-settings"].update( + { + userID: user.id, + game, + playtype, + }, + { + $set: { + "preferences.stats": req.body, + }, + } + ); + + const newSettings = await db["game-settings"].findOne({ + userID: user.id, + game, + playtype, + }); + + return res.status(200).json({ + success: true, + description: `Overwrote preferences.stats.`, + body: newSettings, + }); +}); + +export default router; diff --git a/server/src/server/server.ts b/server/src/server/server.ts index 887111d3e..b508e511d 100644 --- a/server/src/server/server.ts +++ b/server/src/server/server.ts @@ -101,6 +101,7 @@ app.use((req, res, next) => { import mainRouter from "./router/router"; import { SYMBOL_TachiAPIAuth } from "../lib/constants/tachi"; +import { RequestLoggerMiddleware } from "./middleware/request-logger"; app.use("/", mainRouter); @@ -168,4 +169,6 @@ const MAIN_ERR_HANDLER: express.ErrorRequestHandler = (err, req, res, next) => { app.use(MAIN_ERR_HANDLER); +app.use(RequestLoggerMiddleware); + export default app; diff --git a/server/src/test-utils/mock-db/game-settings.json b/server/src/test-utils/mock-db/game-settings.json new file mode 100644 index 000000000..b0857ff71 --- /dev/null +++ b/server/src/test-utils/mock-db/game-settings.json @@ -0,0 +1,13 @@ +[ + { + "userID": 1, + "game": "iidx", + "playtype": "SP", + "preferences": { + "preferredScoreAlg": null, + "preferredSessionAlg": null, + "preferredProfileAlg": null, + "stats": [] + } + } +] \ No newline at end of file diff --git a/server/src/test-utils/test-data/tachi/iidx-scorepb.json b/server/src/test-utils/test-data/tachi/iidx-scorepb.json index 04aaa56cd..016de3380 100644 --- a/server/src/test-utils/test-data/tachi/iidx-scorepb.json +++ b/server/src/test-utils/test-data/tachi/iidx-scorepb.json @@ -18,7 +18,7 @@ "percent": 94.08396946564885, "esd": 10.64453125, "grade": "AAA", - "gradeIndex": -1, + "gradeIndex": 7, "lamp": "EX HARD CLEAR", "lampIndex": 6, "judgements": { diff --git a/server/src/utils/class.ts b/server/src/utils/class.ts index 446a57092..4d21de81e 100644 --- a/server/src/utils/class.ts +++ b/server/src/utils/class.ts @@ -3,6 +3,7 @@ import db from "../external/mongo/db"; import CreateLogCtx from "../lib/logger/logger"; import { GameClassSets } from "tachi-common/js/game-classes"; import { RedisPub } from "../external/redis/redis-IPC"; +import { CreateGameSettings } from "../lib/game-settings/create-game-settings"; const logger = CreateLogCtx(__filename); @@ -62,7 +63,10 @@ export async function UpdateClassIfGreater( [classSet]: classVal, }, }); - logger.info(`Created new player gamestats for ${userID} ${game} (${playtype})`); + + logger.info(`Created new player gamestats for ${userID} (${game} ${playtype})`); + + await CreateGameSettings(userID, game, playtype); } if (isGreater === null) { diff --git a/server/tsconfig.json b/server/tsconfig.json index 908ae8242..d18985e1b 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -2,28 +2,37 @@ // THIS IS USED FOR VSCODE SO IT INTEGRATES NICELY // IF YOU USE THIS TO BUILD, IT WILL ALSO BUILD THE TEST FILES!! // SO, DONT DO THAT, USE TSCONFIG.BUILD.JSON FOR THE BUILD. - { - "compilerOptions": { - "target": "ES2020", - "module": "CommonJS", - "lib": [ - "ES2019" - ], - "declaration": true, - "strict": true, - "allowJs": false, - "esModuleInterop": true, - "sourceMap": true, - "outDir": "./js", - "typeRoots": [ "@types", "node_modules/@types" ] - }, - "include": [ - "src/**/*.ts", "scripts/single-use/counters.ts", - ], - "exclude": [ - "node_modules", - // "src/test-utils", - // "src/**/*.test.ts" - ] + "compilerOptions": { + "target": "ES2020", + "module": "CommonJS", + "lib": [ + "ES2019" + ], + "declaration": true, + "strict": true, + "allowJs": false, + "esModuleInterop": true, + "sourceMap": true, + "outDir": "./js", + "baseUrl": "src", + "typeRoots": [ + "@types", + "node_modules/@types" + ] + }, + "ts-node": { + "require": [ + "tsconfig-paths/register" + ] + }, + "include": [ + "src/**/*.ts", + "scripts/single-use/counters.ts", + ], + "exclude": [ + "node_modules", + // "src/test-utils", + // "src/**/*.test.ts" + ] } \ No newline at end of file