Merge pull request #244 from zkldi/zkldi/issue-242-Ranking/Playcount-Snapshots

zkldi/issue-242-Ranking/Playcount-Snapshots
This commit is contained in:
zkldi
2021-07-24 05:07:35 +00:00
committed by GitHub
19 changed files with 345 additions and 80 deletions
+2
View File
@@ -25,6 +25,7 @@
"@types/range-parser": "1.2.3",
"@types/rate-limit-redis": "1.7.1",
"@types/rimraf": "3.0.0",
"@types/safe-json-stringify": "1.1.2",
"@types/supertest": "2.0.11",
"@types/tap": "15.0.4",
"@types/uuid": "8.3.0",
@@ -67,6 +68,7 @@
"rate-limit-redis": "2.1.0",
"redis": "3.1.2",
"rimraf": "3.0.2",
"safe-json-stringify": "1.2.0",
"tachi-common": "github:zkldi/tachi-common",
"typescript": "4.3.4",
"winston": "3.3.3"
+16 -4
View File
@@ -13,6 +13,7 @@ specifiers:
'@types/range-parser': 1.2.3
'@types/rate-limit-redis': 1.7.1
'@types/rimraf': 3.0.0
'@types/safe-json-stringify': 1.1.2
'@types/supertest': 2.0.11
'@types/tap': 15.0.4
'@types/uuid': 8.3.0
@@ -48,6 +49,7 @@ specifiers:
rate-limit-redis: 2.1.0
redis: 3.1.2
rimraf: 3.0.2
safe-json-stringify: 1.2.0
supertest: 6.1.3
tachi-common: github:zkldi/tachi-common
tap: 15.0.9
@@ -81,7 +83,8 @@ dependencies:
rate-limit-redis: 2.1.0
redis: 3.1.2
rimraf: 3.0.2
tachi-common: github.com/zkldi/tachi-common/62ea387173a0ee7019adf3371b90f6328081ae92_ts-node@10.0.0+typescript@4.3.4
safe-json-stringify: 1.2.0
tachi-common: github.com/zkldi/tachi-common/21c3bb4cc4407601a902e049fb1e1b774b793e40_ts-node@10.0.0+typescript@4.3.4
typescript: 4.3.4
winston: 3.3.3
@@ -98,6 +101,7 @@ devDependencies:
'@types/range-parser': 1.2.3
'@types/rate-limit-redis': 1.7.1
'@types/rimraf': 3.0.0
'@types/safe-json-stringify': 1.1.2
'@types/supertest': 2.0.11
'@types/tap': 15.0.4
'@types/uuid': 8.3.0
@@ -632,6 +636,10 @@ packages:
'@types/node': 15.12.4
dev: true
/@types/safe-json-stringify/1.1.2:
resolution: {integrity: sha512-Hj/LZMBXFH3Qj9sNmNu6syBpkZqBaM00HgP7naf9CnZhB3kdxmrenWUD2cY6vvTWjuBM3NPOLknPKDUOQuTWXA==}
dev: true
/@types/serve-static/1.13.9:
resolution: {integrity: sha512-ZFqF6qa48XsPdjXV5Gsz0Zqmux2PerNd3a/ktL45mHpa19cuMi/cL8tcxdAx497yRh+QtYPuofjT9oWw9P7nkA==}
dependencies:
@@ -3422,6 +3430,10 @@ packages:
/safe-buffer/5.2.1:
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
/safe-json-stringify/1.2.0:
resolution: {integrity: sha512-gH8eh2nZudPQO6TytOvbxnuhYBOvDBBLW52tz5q6X58lJcd/tkmqFR+5Z9adS8aJtURSXWThWy/xJtJwixErvg==}
dev: false
/safer-buffer/2.1.2:
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
@@ -4214,9 +4226,9 @@ packages:
engines: {node: '>=6'}
dev: true
github.com/zkldi/tachi-common/62ea387173a0ee7019adf3371b90f6328081ae92_ts-node@10.0.0+typescript@4.3.4:
resolution: {tarball: https://codeload.github.com/zkldi/tachi-common/tar.gz/62ea387173a0ee7019adf3371b90f6328081ae92}
id: github.com/zkldi/tachi-common/62ea387173a0ee7019adf3371b90f6328081ae92
github.com/zkldi/tachi-common/21c3bb4cc4407601a902e049fb1e1b774b793e40_ts-node@10.0.0+typescript@4.3.4:
resolution: {tarball: https://codeload.github.com/zkldi/tachi-common/tar.gz/21c3bb4cc4407601a902e049fb1e1b774b793e40}
id: github.com/zkldi/tachi-common/21c3bb4cc4407601a902e049fb1e1b774b793e40
name: tachi-common
version: 0.1.0
dependencies:
+2
View File
@@ -14,6 +14,7 @@ import {
PrivateUserDocument,
ScoreDocument,
KaiAuthDocument,
UserGameStatsSnapshot,
SessionDocument,
AnySongDocument,
AnyChartDocument,
@@ -143,6 +144,7 @@ const db = {
"import-locks": monkDB.get<ImportLockDocument>("import-locks"),
tables: monkDB.get<TableDocument>("tables"),
"game-settings": monkDB.get<UGPTSettings>("game-settings"),
"game-stats-snapshots": monkDB.get<UserGameStatsSnapshot>("game-stats-snapshots"),
};
export default db;
+1
View File
@@ -79,6 +79,7 @@ const staticIndexes: Partial<Record<ValidDatabases, Index[]>> = {
"bms-course-lookup": [index({ md5sums: 1 }, UNIQUE)],
"api-tokens": [index({ token: 1 }, UNIQUE), index({ userID: 1 })],
tables: [index({ tableID: 1 }, UNIQUE), index({ game: 1, playtype: 1 })],
"game-stats-snapshots": [index({ timestamp: 1, userID: 1, game: 1, playtype: 1 }, UNIQUE)],
};
const indexes: Partial<Record<ValidDatabases, Index[]>> = staticIndexes;
+11
View File
@@ -0,0 +1,11 @@
{
// this format is just an example - @ereti will be designing the jobs system and will *definitely* want something
// better. This only exists as a rough example.
prefix: "tsnode -r tsconfig-paths/register",
jobs: [{
path: "ugs-snapshot.ts",
name: "Snapshot User Game Stats",
// especially this part - i don't expect any machine to parse this text.
interval: "daily at midnight"
}]
}
+88
View File
@@ -0,0 +1,88 @@
import { UserGameStats, UserGameStatsSnapshot } from "tachi-common";
import db from "external/mongo/db";
import CreateLogCtx from "lib/logger/logger";
import { GetUsersRanking } from "utils/user";
import { GetMilisecondsSince } from "utils/misc";
const logger = CreateLogCtx(__filename);
// get the time of this midnight. it's possible this script eclipses itself when weird timezone
// nonsense happens. we'll have to see.
const currentTime = new Date().setUTCHours(0, 0, 0, 0);
let batchWrite: UserGameStatsSnapshot[] = [];
// This code is intentionally *very* robust, and handles a lot of unanticipated failures
// because if it breaks, we brick the database.
(async () => {
const timeStart = process.hrtime.bigint();
const alreadyExists = await db["game-stats-snapshots"].findOne({ timestamp: currentTime });
if (alreadyExists) {
logger.severe(
`FATAL IN UGS-SNAPSHOT - There already exists snapshots at this time. Has this script been ran twice?`
);
process.exit(1);
}
logger.info(`Snapshotting UserGameStats.`);
try {
await db["game-stats"]
.find({})
// @ts-expect-error faulty TS types
.each(async (ugs: UserGameStats, { pause, resume }) => {
pause();
logger.debug(`Snapshotting ${ugs.userID} ${ugs.playtype} ${ugs.game}.`);
const [playcount, ranking] = await Promise.all([
db.scores.count({ userID: ugs.userID, playtype: ugs.playtype, game: ugs.game }),
GetUsersRanking(ugs),
]);
const ugsSnapshot: UserGameStatsSnapshot = {
...ugs,
playcount,
ranking,
timestamp: currentTime,
};
batchWrite.push(ugsSnapshot);
if (batchWrite.length >= 500) {
logger.verbose(`Flushed batch.`);
await db["game-stats-snapshots"].insert(batchWrite);
batchWrite = [];
}
resume();
});
if (batchWrite.length) {
await db["game-stats-snapshots"].insert(batchWrite);
}
logger.info(
`Successfully snapshotted all data as of ${new Date(
currentTime
).toString()}. Took ${GetMilisecondsSince(timeStart)} ms.`
);
process.exit(0);
} catch (err) {
// if we panic, we need to revert whatever we did.
logger.severe(`FATAL IN UGS-SNAPSHOT - Possibly failed midway through snapshotting.`, {
err,
});
logger.info(`Removing all snapshots at this timestamp (${currentTime}).`);
await db["game-stats-snapshots"].remove({ timestamp: currentTime });
logger.info(`Removed.`);
process.exit(1);
}
})();
+3 -2
View File
@@ -1,6 +1,7 @@
import winston, { format, transports, Logger, LeveledLogMethod } from "winston";
import { EscapeStringRegexp } from "../../utils/misc";
import { LOG_LEVEL } from "../setup/config";
import SafeJSONStringify from "safe-json-stringify";
export type KtLogger = Logger & { severe: LeveledLogMethod };
@@ -23,7 +24,7 @@ const formatExcessProperties = (meta: Record<string, unknown>) => {
return "";
}
return ` ${JSON.stringify(meta)}`;
return ` ${SafeJSONStringify(meta)}`;
};
const formatExcessPropertiesNoStack = (meta: Record<string, unknown>, omitKeys: string[] = []) => {
@@ -49,7 +50,7 @@ const formatExcessPropertiesNoStack = (meta: Record<string, unknown>, omitKeys:
return "";
}
return ` ${JSON.stringify(realMeta)}`;
return ` ${SafeJSONStringify(realMeta)}`;
};
const tachiPrintf = format.printf(
@@ -1,6 +1,6 @@
import deepmerge from "deepmerge";
import t from "tap";
import { EvaluateUGPTStat } from "./evaluator";
import { EvaluateShowcaseStat } from "./evaluator";
import { CloseAllConnections } from "../../test-utils/close-connections";
import ResetDBState from "../../test-utils/resets";
import {
@@ -19,9 +19,9 @@ 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("#EvaluateShowcaseStat", (t) => {
t.test("Should evaluate a folder stat.", async (t) => {
const data = await EvaluateUGPTStat(
const data = await EvaluateShowcaseStat(
{
folderID: TestingIIDXFolderSP10.folderID,
mode: "folder",
@@ -40,7 +40,7 @@ t.test("#EvaluateUGPTStat", (t) => {
});
t.test("Should evaluate a multi-folder stat.", async (t) => {
const data = await EvaluateUGPTStat(
const data = await EvaluateShowcaseStat(
{
folderID: [TestingIIDXFolderSP10.folderID],
mode: "folder",
@@ -59,7 +59,7 @@ t.test("#EvaluateUGPTStat", (t) => {
});
t.test("Should evaluate a chart stat.", async (t) => {
const data = await EvaluateUGPTStat(
const data = await EvaluateShowcaseStat(
{
chartID: Testing511SPA.chartID,
mode: "chart",
@@ -76,7 +76,7 @@ t.test("#EvaluateUGPTStat", (t) => {
});
t.test("Should return null if the user has no score on this chart.", async (t) => {
const data = await EvaluateUGPTStat(
const data = await EvaluateShowcaseStat(
{
chartID: "nonsense",
mode: "chart",
@@ -1,25 +1,25 @@
import { integer, UGPTStatDetails, UGPTStatChart, UGPTStatFolder } from "tachi-common";
import { integer, ShowcaseStatDetails, ShowcaseStatChart, ShowcaseStatFolder } from "tachi-common";
import db from "../../external/mongo/db";
import { GetFolderChartIDs } from "../../utils/folder";
export function EvaluateUGPTStat(
details: UGPTStatDetails,
export function EvaluateShowcaseStat(
details: ShowcaseStatDetails,
userID: integer
): Promise<{
value: number | null;
outOf?: number;
}> {
if (details.mode === "chart") {
return EvaluateUGPTChartStat(details, userID);
return EvaluateShowcaseChartStat(details, userID);
} else if (details.mode === "folder") {
return EvaluateUGPTFolderStat(details, userID);
return EvaluateShowcaseFolderStat(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) {
async function EvaluateShowcaseChartStat(details: ShowcaseStatChart, userID: integer) {
// requires special handling
if (details.property === "playcount") {
return { value: await db.scores.count({ chartID: details.chartID, userID }) };
@@ -41,7 +41,7 @@ async function EvaluateUGPTChartStat(details: UGPTStatChart, userID: integer) {
return { value: pb.scoreData[scProp] };
}
async function EvaluateUGPTFolderStat(details: UGPTStatFolder, userID: integer) {
async function EvaluateShowcaseFolderStat(details: ShowcaseStatFolder, userID: integer) {
let chartIDs;
if (Array.isArray(details.folderID)) {
chartIDs = (await Promise.all(details.folderID.map(GetFolderChartIDs))).flat(1);
@@ -1,10 +1,10 @@
import db from "external/mongo/db";
import CreateLogCtx from "lib/logger/logger";
import { Game, UGPTStatDetails } from "tachi-common";
import { Game, ShowcaseStatDetails } from "tachi-common";
const logger = CreateLogCtx(__filename);
export async function GetRelatedStatDocuments(stat: UGPTStatDetails, game: Game) {
export async function GetRelatedStatDocuments(stat: ShowcaseStatDetails, game: Game) {
if (stat.mode === "chart") {
const chart = await db.charts[game].findOne({ chartID: stat.chartID });
@@ -8,7 +8,7 @@ import {
TestingIIDXSPScorePB,
} from "../../test-utils/test-data";
import { IIDX_LAMPS } from "../constants/game";
import { EvaluateUsersGPTStats } from "./get-stats";
import { EvaluateUsersStatsShowcase } from "./get-stats";
import deepmerge from "deepmerge";
import { CreateFolderChartLookup } from "../../utils/folder";
@@ -47,9 +47,9 @@ t.beforeEach(async () => {
await db["personal-bests"].insert(deepmerge(TestingIIDXSPScorePB, {}));
});
t.test("#EvalulateUsersGPTStats", (t) => {
t.test("#EvalulateUsersStatsShowcase", (t) => {
t.test("Should evaluate a user's preferred stats.", async (t) => {
const res = await EvaluateUsersGPTStats(1, "iidx", "SP");
const res = await EvaluateUsersStatsShowcase(1, "iidx", "SP");
t.hasStrict(res, [
{
@@ -89,7 +89,7 @@ t.test("#EvalulateUsersGPTStats", (t) => {
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.rejects(() => EvaluateUsersStatsShowcase(1, "iidx", "SP"));
t.end();
});
@@ -105,7 +105,7 @@ t.test("#EvalulateUsersGPTStats", (t) => {
})
);
const res = await EvaluateUsersGPTStats(2, "iidx", "SP", 1);
const res = await EvaluateUsersStatsShowcase(2, "iidx", "SP", 1);
t.hasStrict(res, [
{
@@ -1,17 +1,17 @@
import db from "../../external/mongo/db";
import { integer, Game, Playtypes, UGPTStatDetails } from "tachi-common";
import { integer, Game, Playtypes, ShowcaseStatDetails } from "tachi-common";
import CreateLogCtx from "../logger/logger";
import { EvaluateUGPTStat } from "./evaluator";
import { EvaluateShowcaseStat } from "./evaluator";
import { GetRelatedStatDocuments } from "./get-related";
const logger = CreateLogCtx(__filename);
/**
* Evaluate a users set GPT Stats.
* @param projectUserStats - Optionally, provide another users ID here. Their set stats will be
* Evaluate a users set Stats Showcase.
* @param projectUserStats - Optionally, provide another users ID here. Their stats showcase will be
* used instead.
*/
export async function EvaluateUsersGPTStats(
export async function EvaluateUsersStatsShowcase(
userID: integer,
game: Game,
playtype: Playtypes[Game],
@@ -26,11 +26,11 @@ export async function EvaluateUsersGPTStats(
if (!settings) {
logger.error(
`User ${getSettingsID} has no game-settings, yet a call to EvalulateUsersGPTStats was made.`
`User ${getSettingsID} has no game-settings, yet a call to EvaluateUsersStatsShowcase was made.`
);
throw new Error(
`User ${getSettingsID} has no game-settings, yet a call to EvalulateUsersGPTStats was made.`
`User ${getSettingsID} has no game-settings, yet a call to EvaluateUsersStatsShowcase was made.`
);
}
@@ -41,9 +41,9 @@ export async function EvaluateUsersGPTStats(
return results;
}
async function EvaluateStats(details: UGPTStatDetails, userID: integer, game: Game) {
async function EvaluateStats(details: ShowcaseStatDetails, userID: integer, game: Game) {
const [result, related] = await Promise.all([
EvaluateUGPTStat(details, userID),
EvaluateShowcaseStat(details, userID),
GetRelatedStatDocuments(details, game),
]);
@@ -1,5 +1,6 @@
import CreateLogCtx from "lib/logger/logger";
import { RequestHandler, Response } from "express-serve-static-core";
import { SYMBOL_TachiAPIAuth } from "lib/constants/tachi";
const logger = CreateLogCtx(__filename);
@@ -41,6 +42,8 @@ export const RequestLoggerMiddleware: RequestHandler = (req, res, next) => {
statusCode: res.statusCode,
requestQuery: req.query,
requestBody: safeBody,
from: req[SYMBOL_TachiAPIAuth]?.userID ?? null,
fromIp: req.ip,
};
if (res.statusCode < 400) {
@@ -20,7 +20,7 @@ import {
} from "../../../../../../../../../test-utils/test-data";
import deepmerge from "deepmerge";
t.test("GET /api/v1/users/:userID/games/:game/:playtype/", (t) => {
t.test("GET /api/v1/users/:userID/games/:game/:playtype", (t) => {
t.beforeEach(ResetDBState);
t.test("Should return a users statistics for that game.", async (t) => {
@@ -110,6 +110,52 @@ t.test("GET /api/v1/users/:userID/games/:game/:playtype/", (t) => {
t.end();
});
t.test("GET /api/v1/users/:userID/games/:game/:playtype/history", (t) => {
t.beforeEach(ResetDBState);
t.test("Should return a users history snapshots for that gpt.", async (t) => {
await db["game-stats-snapshots"].insert([
{
userID: 1,
game: "iidx",
playtype: "SP",
ranking: 5,
playcount: 100,
classes: {},
ratings: {},
timestamp: 1234,
},
]);
const res = await mockApi.get("/api/v1/users/test_zkldi/games/iidx/SP/history");
res.body.body[0].timestamp = Math.floor(res.body.body[0].timestamp / 100_000); // by default, it's set to the current time. we can't
// test that nicely, so lets round it to the nearest 100 seconds.
t.strictSame(res.body.body, [
{
ranking: 1,
playcount: 1,
classes: {},
ratings: {},
// close enough, right?
timestamp: Math.floor(Date.now() / 100_000),
},
{
ranking: 5,
playcount: 100,
classes: {},
ratings: {},
timestamp: 1234,
},
]);
t.end();
});
t.end();
});
t.test("GET /api/v1/users/:userID/games/:game/:playtype/goals", (t) => {
t.beforeEach(ResetDBState);
@@ -2,7 +2,12 @@ import { Router } from "express";
import db from "../../../../../../../../../external/mongo/db";
import { SYMBOL_TachiData } from "../../../../../../../../../lib/constants/tachi";
import { GetRelevantSongsAndCharts } from "../../../../../../../../../utils/db";
import { GetUsersRanking, GetUsersWithIDs } from "../../../../../../../../../utils/user";
import {
GetUGPTPlaycount,
GetUsersRanking,
GetUsersRankingAndOutOf,
GetUsersWithIDs,
} from "../../../../../../../../../utils/user";
import { CheckUserPlayedGamePlaytype } from "./middleware";
import { FilterQuery } from "mongodb";
import {
@@ -11,6 +16,7 @@ import {
GetGamePTConfig,
integer,
PBScoreDocument,
UserGameStatsSnapshot,
} from "tachi-common";
import { SearchGameSongsAndCharts } from "../../../../../../../../../lib/search/search";
import { FilterChartsAndSongs } from "../../../../../../../../../utils/scores";
@@ -20,9 +26,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 showcaseRouter from "./showcase/router";
import settingsRouter from "./settings/router";
const router: Router = Router({ mergeParams: true });
router.use(CheckUserPlayedGamePlaytype);
@@ -69,7 +74,7 @@ router.get("/", async (req, res) => {
},
}
),
GetUsersRanking(stats),
GetUsersRankingAndOutOf(stats),
]);
return res.status(200).json({
@@ -85,6 +90,51 @@ router.get("/", async (req, res) => {
});
});
/**
* Returns a users game-stats for the past 90 days.
* @name GET /api/v1/users/:userID/games/:game/:playtype/history
*/
router.get("/history", async (req, res) => {
const user = req[SYMBOL_TachiData]!.requestedUser!;
const stats = req[SYMBOL_TachiData]!.requestedUserGameStats!;
const game = req[SYMBOL_TachiData]!.game!;
const playtype = req[SYMBOL_TachiData]!.playtype!;
const snapshots = (await db["game-stats-snapshots"].find(
{
userID: user.id,
game,
playtype,
},
{
sort: {
timestamp: -1,
},
// avoid sending so much garbage.
projection: {
userID: 0,
game: 0,
playtype: 0,
},
limit: 90,
}
)) as Omit<UserGameStatsSnapshot, "userID" | "game" | "playtype">[];
const currentSnapshot: Omit<UserGameStatsSnapshot, "userID" | "game" | "playtype"> = {
classes: stats.classes,
ratings: stats.ratings,
timestamp: Date.now(), // lazy, should probably be this midnight
playcount: await GetUGPTPlaycount(user.id, game, playtype),
ranking: await GetUsersRanking(stats),
};
return res.status(200).json({
success: true,
description: `Successfully returned history for the past ${snapshots.length} days.`,
body: [currentSnapshot, ...snapshots],
});
});
/**
* Returns a user's set goals for this game.
* @param unachieved - If set, achieved goals will be hidden.
@@ -386,7 +436,7 @@ router.get("/leaderboard-adjacent", async (req, res) => {
...below.map((e) => e.userID),
]);
const thisUsersRanking = await GetUsersRanking(thisUsersStats);
const thisUsersRanking = await GetUsersRankingAndOutOf(thisUsersStats);
return res.status(200).json({
success: true,
@@ -404,7 +454,7 @@ 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("/showcase", showcaseRouter);
router.use("/settings", settingsRouter);
router.use("/folders/:folderID", foldersFolderIDRouter);
@@ -42,9 +42,9 @@ t.beforeEach(async () => {
await db["personal-bests"].insert(deepmerge(TestingIIDXSPScorePB, {}));
});
t.test("GET /api/v1/users/:userID/games/:game/:playtype/stats", (t) => {
t.test("GET /api/v1/users/:userID/games/:game/:playtype/showcase", (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");
const res = await mockApi.get("/api/v1/users/1/games/iidx/SP/showcase");
t.hasStrict(res.body.body, [
{
@@ -95,7 +95,7 @@ t.test("GET /api/v1/users/:userID/games/:game/:playtype/stats", (t) => {
id: 2,
} as PrivateUserDocument);
const res = await mockApi.get("/api/v1/users/1/games/iidx/SP/stats?projectUser=2");
const res = await mockApi.get("/api/v1/users/1/games/iidx/SP/showcase?projectUser=2");
t.hasStrict(res.body.body, [
{
@@ -113,7 +113,7 @@ t.test("GET /api/v1/users/:userID/games/:game/:playtype/stats", (t) => {
});
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");
const res = await mockApi.get("/api/v1/users/1/games/bms/7K/showcase");
t.equal(res.statusCode, 404);
@@ -125,15 +125,17 @@ t.test("GET /api/v1/users/:userID/games/:game/:playtype/stats", (t) => {
t.end();
});
t.test("GET /api/v1/users/:userID/games/:game/:playtype/stats/custom", (t) => {
t.test("GET /api/v1/users/:userID/games/:game/:playtype/showcase/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}`
`/api/v1/users/1/games/iidx/SP/showcase/custom?mode=folder&prop=grade&gte=3&folderID=${TestingIIDXFolderSP10.folderID}`
);
t.strictSame(res.body.body, {
value: 1,
outOf: 1,
t.hasStrict(res.body.body, {
result: {
value: 1,
outOf: 1,
},
});
t.end();
@@ -141,11 +143,11 @@ t.test("GET /api/v1/users/:userID/games/:game/:playtype/stats/custom", (t) => {
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}`
`/api/v1/users/1/games/iidx/SP/showcase/custom?mode=chart&prop=grade&chartID=${Testing511SPA.chartID}`
);
t.strictSame(res.body.body, {
value: IIDX_GRADES.AAA,
t.hasStrict(res.body.body, {
result: { value: IIDX_GRADES.AAA },
});
t.end();
@@ -153,13 +155,13 @@ t.test("GET /api/v1/users/:userID/games/:game/:playtype/stats/custom", (t) => {
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`
`/api/v1/users/1/games/iidx/SP/showcase/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`
`/api/v1/users/1/games/iidx/SP/showcase/custom?mode=folder&prop=grade&gte=4&folderID=foo&folderID=bar`
);
t.equal(res2.statusCode, 400, "Should reject for non-string folderID");
@@ -169,13 +171,13 @@ t.test("GET /api/v1/users/:userID/games/:game/:playtype/stats/custom", (t) => {
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`
`/api/v1/users/1/games/iidx/SP/showcase/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`
`/api/v1/users/1/games/iidx/SP/showcase/custom?mode=chart&prop=grade&chartID=foo&chartID=bar`
);
t.equal(res2.statusCode, 400, "Should reject for non-string chartID");
@@ -185,7 +187,7 @@ t.test("GET /api/v1/users/:userID/games/:game/:playtype/stats/custom", (t) => {
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`
`/api/v1/users/1/games/iidx/SP/showcase/custom?mode=nonsense&prop=grade&gte=4&chartID=foo`
);
t.equal(res.statusCode, 400);
@@ -197,7 +199,7 @@ t.test("GET /api/v1/users/:userID/games/:game/:playtype/stats/custom", (t) => {
});
// @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("PUT /api/v1/users/:userID/games/:game/:playtype/showcase", (t) => {
t.test("Requires the user to be authed as the requested user.", async (t) => {
await db["api-tokens"].insert({
userID: 2,
@@ -209,7 +211,7 @@ t.test("PUT /api/v1/users/:userID/games/:game/:playtype/stats", (t) => {
});
const res = await mockApi
.put("/api/v1/users/1/games/iidx/SP/stats")
.put("/api/v1/users/1/games/iidx/SP/showcase")
.set("Authorization", `Bearer alt_token`);
t.equal(res.statusCode, 403);
@@ -228,7 +230,7 @@ t.test("PUT /api/v1/users/:userID/games/:game/:playtype/stats", (t) => {
});
const res = await mockApi
.put("/api/v1/users/1/games/iidx/SP/stats")
.put("/api/v1/users/1/games/iidx/SP/showcase")
.set("Authorization", `Bearer alt_token`);
t.equal(res.statusCode, 403);
@@ -247,7 +249,7 @@ t.test("PUT /api/v1/users/:userID/games/:game/:playtype/stats", (t) => {
});
const res = await mockApi
.put("/api/v1/users/1/games/iidx/SP/stats")
.put("/api/v1/users/1/games/iidx/SP/showcase")
.set("Authorization", `Bearer alt_token`)
.send([
{
@@ -1,15 +1,15 @@
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 { SYMBOL_TachiData } from "lib/constants/tachi";
import { EvaluateUsersStatsShowcase } from "lib/showcase/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";
import { GetRelatedStatDocuments } from "lib/ugpt-stat/get-related";
import { FormatPrError } from "utils/prudence";
import { ShowcaseStatDetails, GetGamePTConfig } from "tachi-common";
import { EvaluateShowcaseStat } from "lib/showcase/evaluator";
import db from "external/mongo/db";
import { RequirePermissions } from "server/middleware/auth";
import { RequireAuthedAsUser } from "server/router/api/v1/users/_userID/middleware";
import { GetRelatedStatDocuments } from "lib/showcase/get-related";
const router: Router = Router({ mergeParams: true });
/**
@@ -17,7 +17,7 @@ const router: Router = Router({ mergeParams: true });
*
* @param projectUser - Project another user's stats instead of their set stats.
*
* @name GET /api/v1/users/:userID/games/:game/:playtype/stats
* @name GET /api/v1/users/:userID/games/:game/:playtype/showcase
*/
router.get("/", async (req, res) => {
const user = req[SYMBOL_TachiData]!.requestedUser!;
@@ -39,7 +39,7 @@ router.get("/", async (req, res) => {
projectUser = user.id;
}
const results = await EvaluateUsersGPTStats(user.id, game, playtype, projectUser);
const results = await EvaluateUsersStatsShowcase(user.id, game, playtype, projectUser);
return res.status(200).json({
success: true,
@@ -59,13 +59,13 @@ router.get("/", async (req, res) => {
* @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
* @name GET /api/v1/users/:userID/games/:game/:playtype/showcase/custom
*/
router.get("/custom", async (req, res) => {
const user = req[SYMBOL_TachiData]!.requestedUser!;
const game = req[SYMBOL_TachiData]!.game!;
let stat: UGPTStatDetails;
let stat: ShowcaseStatDetails;
if (req.query.mode === "folder") {
const err = p(
@@ -127,7 +127,7 @@ router.get("/custom", async (req, res) => {
});
}
const result = await EvaluateUGPTStat(stat, user.id);
const result = await EvaluateShowcaseStat(stat, user.id);
const related = await GetRelatedStatDocuments(stat, game);
@@ -141,7 +141,7 @@ router.get("/custom", async (req, res) => {
/**
* Replaces a user's preferred stats.
*
* @name PUT /api/v1/users/:userID/games/:game/:playtype/stats
* @name PUT /api/v1/users/:userID/games/:game/:playtype/showcase
*/
router.put("/", RequireAuthedAsUser, RequirePermissions("customise_profile"), async (req, res) => {
@@ -0,0 +1 @@
[]
+47 -1
View File
@@ -1,7 +1,15 @@
import { integer, PublicUserDocument, UserGameStats, GetGamePTConfig } from "tachi-common";
import {
integer,
PublicUserDocument,
UserGameStats,
GetGamePTConfig,
Game,
Playtypes,
} from "tachi-common";
import { FindOneResult } from "monk";
import db from "../external/mongo/db";
import CreateLogCtx from "../lib/logger/logger";
const logger = CreateLogCtx(__filename);
export const OMIT_PRIVATE_USER_RETURNS = {
@@ -156,6 +164,44 @@ export function FormatUserDoc(userdoc: PublicUserDocument) {
export async function GetUsersRanking(stats: UserGameStats) {
const gptConfig = GetGamePTConfig(stats.game, stats.playtype);
const aggRes = await db["game-stats"].aggregate([
{
$match: {
game: stats.game,
playtype: stats.playtype,
},
},
{
$group: {
_id: null,
ranking: {
$sum: {
$cond: {
if: {
$gt: [
`$ratings.${gptConfig.defaultProfileRatingAlg}`,
stats.ratings[gptConfig.defaultProfileRatingAlg],
],
},
then: 1,
else: 0,
},
},
},
},
},
]);
return (aggRes[0].ranking + 1) as integer;
}
export function GetUGPTPlaycount(userID: integer, game: Game, playtype: Playtypes[Game]) {
return db.scores.count({ userID, game, playtype });
}
export async function GetUsersRankingAndOutOf(stats: UserGameStats) {
const gptConfig = GetGamePTConfig(stats.game, stats.playtype);
const aggRes = await db["game-stats"].aggregate([
{
$match: {