mirror of
https://github.com/zkldi/Tachi.git
synced 2026-10-10 16:28:17 +03:00
Add Player Stats and a bunch of tests and groundwork for #219
This commit is contained in:
@@ -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",
|
||||
|
||||
Vendored
+1
-2
@@ -22,8 +22,7 @@
|
||||
"**/async_hooks.js",
|
||||
"**/internal/**/*",
|
||||
"**/domain.js",
|
||||
"**/events.js",
|
||||
"*"
|
||||
"**/events.js"
|
||||
],
|
||||
"smartStep": true,
|
||||
"outputCapture": "std",
|
||||
|
||||
Vendored
+2
-2
@@ -3,9 +3,9 @@
|
||||
"tasks": [
|
||||
{
|
||||
"type": "npm",
|
||||
"script": "fulltest",
|
||||
"script": "test",
|
||||
"problemMatcher": [],
|
||||
"label": "npm: fulltest",
|
||||
"label": "npm: test",
|
||||
"detail": "tap",
|
||||
"group": {
|
||||
"kind": "test",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Generated
+14
-4
@@ -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:
|
||||
|
||||
Vendored
+2
@@ -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<OrphanScoreDocument>("orphan-scores"),
|
||||
"import-locks": monkDB.get<ImportLockDocument>("import-locks"),
|
||||
tables: monkDB.get<TableDocument>("tables"),
|
||||
"game-settings": monkDB.get<UGPTSettings>("game-settings"),
|
||||
};
|
||||
|
||||
export default db;
|
||||
|
||||
Vendored
+8
-2
@@ -19,10 +19,15 @@ function index(fields: Record<string, unknown>, options?: IndexOptions) {
|
||||
const UNIQUE = { unique: true };
|
||||
|
||||
const staticIndexes: Partial<Record<ValidDatabases, Index[]>> = {
|
||||
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<Record<ValidDatabases, Index[]>> = {
|
||||
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<Record<ValidDatabases, Index[]>> = {
|
||||
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 })],
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
@@ -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}).`);
|
||||
}
|
||||
@@ -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({});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
@@ -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}.`);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
+155
@@ -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);
|
||||
+133
@@ -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<string, string> = {};
|
||||
|
||||
// @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;
|
||||
+294
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
[
|
||||
{
|
||||
"userID": 1,
|
||||
"game": "iidx",
|
||||
"playtype": "SP",
|
||||
"preferences": {
|
||||
"preferredScoreAlg": null,
|
||||
"preferredSessionAlg": null,
|
||||
"preferredProfileAlg": null,
|
||||
"stats": []
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -18,7 +18,7 @@
|
||||
"percent": 94.08396946564885,
|
||||
"esd": 10.64453125,
|
||||
"grade": "AAA",
|
||||
"gradeIndex": -1,
|
||||
"gradeIndex": 7,
|
||||
"lamp": "EX HARD CLEAR",
|
||||
"lampIndex": 6,
|
||||
"judgements": {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+32
-23
@@ -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"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user