Add Player Stats and a bunch of tests and groundwork for #219

This commit is contained in:
zkldi
2021-07-22 23:53:08 +01:00
parent f66d4040ae
commit 5c7e7c68c1
28 changed files with 1420 additions and 104 deletions
+2
View File
@@ -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",
+1 -2
View File
@@ -22,8 +22,7 @@
"**/async_hooks.js",
"**/internal/**/*",
"**/domain.js",
"**/events.js",
"*"
"**/events.js"
],
"smartStep": true,
"outputCapture": "std",
+2 -2
View File
@@ -3,9 +3,9 @@
"tasks": [
{
"type": "npm",
"script": "fulltest",
"script": "test",
"problemMatcher": [],
"label": "npm: fulltest",
"label": "npm: test",
"detail": "tap",
"group": {
"kind": "test",
+1
View File
@@ -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": {
+14 -4
View File
@@ -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:
+2
View File
@@ -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;
+8 -2
View File
@@ -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);
+88
View File
@@ -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";
}
}
+115
View File
@@ -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);
+43
View File
@@ -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;
@@ -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);
@@ -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;
@@ -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&gte=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&gte=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&gte=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&gte=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&gte=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;
+3
View File
@@ -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": {
+5 -1
View File
@@ -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
View File
@@ -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"
]
}