mirror of
https://github.com/zkldi/Tachi.git
synced 2026-09-29 02:18:09 +03:00
Merge pull request #244 from zkldi/zkldi/issue-242-Ranking/Playcount-Snapshots
zkldi/issue-242-Ranking/Playcount-Snapshots
This commit is contained in:
@@ -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"
|
||||
|
||||
Generated
+16
-4
@@ -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:
|
||||
|
||||
Vendored
+2
@@ -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;
|
||||
|
||||
Vendored
+1
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
}]
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
})();
|
||||
@@ -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(
|
||||
|
||||
+6
-6
@@ -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 });
|
||||
|
||||
+5
-5
@@ -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);
|
||||
|
||||
|
||||
+23
-21
@@ -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>e=3&folderID=${TestingIIDXFolderSP10.folderID}`
|
||||
`/api/v1/users/1/games/iidx/SP/showcase/custom?mode=folder&prop=grade>e=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>e=4`
|
||||
`/api/v1/users/1/games/iidx/SP/showcase/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`
|
||||
`/api/v1/users/1/games/iidx/SP/showcase/custom?mode=folder&prop=grade>e=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>e=4`
|
||||
`/api/v1/users/1/games/iidx/SP/showcase/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`
|
||||
`/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>e=4&chartID=foo`
|
||||
`/api/v1/users/1/games/iidx/SP/showcase/custom?mode=nonsense&prop=grade>e=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([
|
||||
{
|
||||
+16
-16
@@ -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 @@
|
||||
[]
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user