Merge pull request #355 from TeamNewGuys:zkldi/issue-354-Add-class-achievement-APIs

Add class achievement APIs.
This commit is contained in:
zkldi
2021-09-28 22:53:30 +01:00
committed by GitHub
10 changed files with 292 additions and 33 deletions
+1 -1
View File
@@ -74,7 +74,7 @@
"redis": "3.1.2",
"rimraf": "3.0.2",
"safe-json-stringify": "1.2.0",
"tachi-common": "0.2.19",
"tachi-common": "0.2.25",
"typescript": "4.3.4",
"winston": "3.3.3",
"winston-transport": "^4.4.0"
+4 -4
View File
@@ -55,7 +55,7 @@ specifiers:
rimraf: 3.0.2
safe-json-stringify: 1.2.0
supertest: 6.1.3
tachi-common: 0.2.19
tachi-common: 0.2.25
tap: 15.0.9
ts-node: 10.0.0
tsconfig-paths: 3.10.1
@@ -91,7 +91,7 @@ dependencies:
redis: 3.1.2
rimraf: 3.0.2
safe-json-stringify: 1.2.0
tachi-common: 0.2.19
tachi-common: 0.2.25
typescript: 4.3.4
winston: 3.3.3
winston-transport: 4.4.0
@@ -3910,8 +3910,8 @@ packages:
strip-ansi: 6.0.0
dev: true
/tachi-common/0.2.19:
resolution: {integrity: sha512-Gy4tr1XmwgpIfJqAKk6PUxTETg+4N00mLrSNBqBwKvRfINBBI6vQ5wp98PHTaCEE8h+dKkv1Uj0u0VnCRrLZig==}
/tachi-common/0.2.25:
resolution: {integrity: sha512-FSMwEWWcvP5Vw2heSD6vD7Iz1/lI+bO1HF0+0iURw5Ybj16yJZQ9wBiaIz2hotNweMhlQ73v57tj2TMUmFyG9w==}
dependencies:
monk: 7.3.4
transitivePeerDependencies:
+4 -1
View File
@@ -33,6 +33,7 @@ import {
integer,
FervidexSettingsDocument,
OrphanChart,
ClassAchievementDocument,
} from "tachi-common";
import monk, { TMiddleware } from "monk";
import CreateLogCtx from "lib/logger/logger";
@@ -177,6 +178,7 @@ const db = {
"orphan-chart-queue": monkDB.get<OrphanChart>("orphan-chart-queue"),
"password-reset-codes":
monkDB.get<{ code: string; userID: integer; createdOn: number }>("password-reset-codes"),
"class-achievements": monkDB.get<ClassAchievementDocument>("class-achievements"),
};
export type StaticDatabases =
@@ -209,7 +211,8 @@ export type StaticDatabases =
| "orphan-chart-queue"
| "password-reset-codes"
| "user-settings"
| "counters";
| "counters"
| "class-achievements";
export type Databases = StaticDatabases | `songs-${Game}` | `charts-${Game}`;
+1
View File
@@ -81,6 +81,7 @@ const staticIndexes: Partial<Record<Databases, Index[]>> = {
"user-private-information": [index({ userID: 1 }, UNIQUE), index({ email: 1 }, UNIQUE)],
"fer-settings": [index({ userID: 1 }, UNIQUE)],
counters: [index({ counterName: 1 }, UNIQUE)],
"class-achievements": [index({ game: 1, playtype: 1, timeAchieved: 1 })],
};
const indexes: Partial<Record<Databases, Index[]>> = staticIndexes;
@@ -2,7 +2,7 @@ import { UserGameStats } from "tachi-common";
import t from "tap";
import CreateLogCtx from "lib/logger/logger";
import ResetDBState from "test-utils/resets";
import { CalculateClassDeltas, UpdateUGSClasses } from "./classes";
import { ProcessClassDeltas, UpdateUGSClasses } from "./classes";
import { GitadoraColours } from "lib/constants/classes";
@@ -45,14 +45,15 @@ t.test("#UpdateUGSClasses", (t) => {
t.end();
});
t.test("#CalculateClassDeltas", (t) => {
t.test("#ProcessClassDeltas", (t) => {
t.beforeEach(ResetDBState);
t.test("Should return improved classes from null", (t) => {
const res = CalculateClassDeltas("SP", { dan: 18 }, null, 1, logger);
t.test("Should return improved classes from null", async (t) => {
const res = await ProcessClassDeltas("iidx", "SP", { dan: 18 }, null, 1, logger);
t.strictSame(res, [
{
game: "iidx",
set: "dan",
playtype: "SP",
old: null,
@@ -63,8 +64,9 @@ t.test("#CalculateClassDeltas", (t) => {
t.end();
});
t.test("Should return improved classes from null class", (t) => {
const res = CalculateClassDeltas(
t.test("Should return improved classes from null class", async (t) => {
const res = await ProcessClassDeltas(
"iidx",
"SP",
{ dan: 18 },
{ classes: {} } as UserGameStats,
@@ -74,6 +76,7 @@ t.test("#CalculateClassDeltas", (t) => {
t.strictSame(res, [
{
game: "iidx",
set: "dan",
playtype: "SP",
old: null,
@@ -84,8 +87,9 @@ t.test("#CalculateClassDeltas", (t) => {
t.end();
});
t.test("Should return improved classes", (t) => {
const res = CalculateClassDeltas(
t.test("Should return improved classes", async (t) => {
const res = await ProcessClassDeltas(
"iidx",
"SP",
{ dan: 18 },
{ classes: { dan: 17 } } as unknown as UserGameStats,
@@ -95,6 +99,7 @@ t.test("#CalculateClassDeltas", (t) => {
t.strictSame(res, [
{
game: "iidx",
set: "dan",
playtype: "SP",
old: 17,
@@ -105,8 +110,9 @@ t.test("#CalculateClassDeltas", (t) => {
t.end();
});
t.test("Should not return identical classes", (t) => {
const res = CalculateClassDeltas(
t.test("Should not return identical classes", async (t) => {
const res = await ProcessClassDeltas(
"iidx",
"SP",
{ dan: 18 },
{ classes: { dan: 18 } } as unknown as UserGameStats,
@@ -119,8 +125,9 @@ t.test("#CalculateClassDeltas", (t) => {
t.end();
});
t.test("Should not return worse classes", (t) => {
const res = CalculateClassDeltas(
t.test("Should not return worse classes", async (t) => {
const res = await ProcessClassDeltas(
"iidx",
"SP",
{ dan: 16 },
{ classes: { dan: 18 } } as unknown as UserGameStats,
@@ -1,16 +1,16 @@
import { Game, Playtypes, integer, UserGameStats, ClassDelta, IDStrings } from "tachi-common";
import { GameClasses } from "tachi-common/js/game-classes";
import deepmerge from "deepmerge";
import db from "external/mongo/db";
import { KtLogger } from "lib/logger/logger";
import { EmitWebhookEvent } from "lib/webhooks/webhooks";
import { ClassDelta, Game, IDStrings, integer, Playtypes, UserGameStats } from "tachi-common";
import { GameClasses } from "tachi-common/js/game-classes";
import { ReturnClassIfGreater } from "utils/class";
import {
CalculateChunithmColour,
CalculateGitadoraColour,
CalculateSDVXClass,
} from "./builtin-class-handlers";
import { ReturnClassIfGreater } from "utils/class";
import { RedisPub } from "external/redis/redis-IPC";
import { ClassHandler, ScoreClasses } from "./types";
import { EmitWebhookEvent } from "lib/webhooks/webhooks";
type ClassHandlerMap = {
[G in Game]:
@@ -110,17 +110,20 @@ export async function UpdateUGSClasses(
* This is for calculating scenarios where a users class has improved (i.e. they have gone from 9th dan to 10th dan).
*
* Knowing this information allows us to attach it onto the import, and also emit things on redis
* so that other services can listen for it. In the future we might allow webhooks, too.
* s**This function emits webhook events and inserts classachieved documents into the DB!**
*/
export function CalculateClassDeltas(
export async function ProcessClassDeltas(
game: Game,
playtype: Playtypes[Game],
classes: ScoreClasses,
userGameStats: UserGameStats | null,
userID: integer,
logger: KtLogger
): ClassDelta[] {
): Promise<ClassDelta[]> {
const deltas: ClassDelta[] = [];
const updateOps = [];
for (const s in classes) {
const classSet = s as keyof GameClasses<IDStrings>;
const classVal = classes[classSet];
@@ -139,6 +142,7 @@ export function CalculateClassDeltas(
let delta: ClassDelta;
if (isGreater === null) {
delta = {
game,
set: classSet,
playtype,
old: null,
@@ -146,6 +150,7 @@ export function CalculateClassDeltas(
};
} else {
delta = {
game,
set: classSet,
playtype,
old: userGameStats!.classes[classSet]!,
@@ -155,6 +160,16 @@ export function CalculateClassDeltas(
EmitWebhookEvent({ type: "class-update/v1", content: { userID, ...delta } });
updateOps.push({
userID,
classSet: delta.set,
classOldValue: delta.old,
classValue: delta.new,
game,
playtype,
timeAchieved: Date.now(),
});
deltas.push(delta);
}
} catch (err) {
@@ -162,5 +177,7 @@ export function CalculateClassDeltas(
}
}
await db["class-achievements"].insert(updateOps);
return deltas;
}
@@ -6,7 +6,7 @@ import { Game, Playtypes, integer, UserGameStats, ClassDelta } from "tachi-commo
import db from "external/mongo/db";
import { CreateGameSettings } from "lib/game-settings/create-game-settings";
import { KtLogger } from "lib/logger/logger";
import { CalculateClassDeltas, UpdateUGSClasses } from "./classes";
import { ProcessClassDeltas, UpdateUGSClasses } from "./classes";
import { CalculateRatings } from "./rating";
import { ClassHandler } from "./types";
@@ -35,7 +35,7 @@ export async function UpdateUsersGamePlaytypeStats(
logger.debug(`Calculating Class Deltas...`);
const deltas = CalculateClassDeltas(playtype, classes, userGameStats, userID, logger);
const deltas = await ProcessClassDeltas(game, playtype, classes, userGameStats, userID, logger);
logger.debug(`Had ${deltas.length} deltas.`);
@@ -1,5 +1,6 @@
import { Router } from "express";
import db from "external/mongo/db";
import { FilterQuery } from "mongodb";
import { SYMBOL_TachiData } from "lib/constants/tachi";
import CreateLogCtx from "lib/logger/logger";
import { SearchUsersRegExp } from "lib/search/search";
@@ -7,7 +8,7 @@ import { IsString } from "utils/misc";
import { ParseStrPositiveNonZeroInt } from "utils/string-checks";
import { GetUsersWithIDs } from "utils/user";
import { ValidateAndGetChart } from "./middleware";
import { FormatChart } from "tachi-common";
import { FormatChart, FolderDocument } from "tachi-common";
const logger = CreateLogCtx(__filename);
@@ -49,6 +50,44 @@ router.get("/", async (req, res) => {
});
});
/**
* Returns any folders that contain this chart.
*
* @param inactive - Also include inactive folders.
*
* @name GET /api/v1/games/:game/:playtype/charts/:chartID/folders
*/
router.get("/folders", async (req, res) => {
const chart = req[SYMBOL_TachiData]!.chartDoc!;
const folderIDs = await db["folder-chart-lookup"].find(
{
chartID: chart.chartID,
},
{
projection: {
folderID: 1,
},
}
);
const query: FilterQuery<FolderDocument> = {
folderID: { $in: folderIDs.map((e) => e.folderID) },
};
if (!req.query.inactive) {
query.inactive = false;
}
const folders = await db.folders.find(query);
return res.status(200).json({
success: true,
description: `Found ${folders.length} folders that contain this chart.`,
body: folders,
});
});
/**
* Returns the total amount of unique players that have played this chart.
*
@@ -11,7 +11,9 @@ t.test("GET /api/v1/games/:game/:playtype", (t) => {
t.strictSame(res.body.body.config, GetGamePTConfig("iidx", "SP"));
// something else needs to go here?
t.equal(res.body.body.chartCount, 1);
t.equal(res.body.body.playerCount, 1);
t.equal(res.body.body.scoreCount, 1);
t.end();
});
@@ -1,7 +1,7 @@
import { Router } from "express";
import { SYMBOL_TachiData } from "lib/constants/tachi";
import { IsString } from "utils/misc";
import { GetGamePTConfig, UserGameStats, FormatGame } from "tachi-common";
import { GetGamePTConfig, UserGameStats, FormatGame, Game, Playtypes, integer } from "tachi-common";
import { FindOptions } from "monk";
import db from "external/mongo/db";
import { CheckStrProfileAlg, ParseStrPositiveNonZeroInt } from "utils/string-checks";
@@ -11,24 +11,66 @@ import songIDRouter from "./songs/_songID/router";
import { ValidatePlaytypeFromParam } from "./middleware";
import foldersRouter from "./folders/router";
import tablesRouter from "./tables/router";
import NodeCache from "node-cache";
import { ONE_HOUR } from "lib/constants/time";
import prValidate from "server/middleware/prudence-validate";
import p from "prudence";
import { GetClassDistribution } from "utils/queries/stats";
import scoresRouter from "./scores/router";
import { GetRelevantSongsAndCharts } from "utils/db";
const router: Router = Router({ mergeParams: true });
router.use(ValidatePlaytypeFromParam);
const gptStatCache = new NodeCache();
async function GetGameStats(
game: Game,
playtype: Playtypes[Game]
): Promise<{ scoreCount: integer; playerCount: integer; chartCount: integer }> {
const cacheRes = gptStatCache.get(`${game}:${playtype}`);
if (!cacheRes) {
const [scoreCount, playerCount, chartCount] = await Promise.all([
db.scores.count({
game,
playtype,
}),
db["game-stats"].count({
game,
playtype,
}),
db.charts[game].count({ playtype }),
]);
gptStatCache.set(`${game}:${playtype}`, { scoreCount, playerCount, chartCount }, ONE_HOUR);
return { scoreCount, playerCount, chartCount };
}
return cacheRes as { scoreCount: integer; playerCount: integer; chartCount: integer };
}
/**
* Returns the configuration for this game.
* Returns the configuration for this game along with some statistics.
*
* @name GET /api/v1/games/:game/:playtype
*/
router.get("/", (req, res) => {
router.get("/", async (req, res) => {
const game = req[SYMBOL_TachiData]!.game!;
const playtype = req[SYMBOL_TachiData]!.playtype!;
const { scoreCount, playerCount, chartCount } = await GetGameStats(game, playtype);
return res.status(200).json({
success: true,
description: `Retrieved information about ${FormatGame(game, playtype)}`,
body: {
config: GetGamePTConfig(game, playtype),
scoreCount,
playerCount,
chartCount,
},
});
});
@@ -49,6 +91,13 @@ router.get("/leaderboard", async (req, res) => {
const limit = ParseStrPositiveNonZeroInt(req.query.limit) ?? 50;
if (limit > 50) {
return res.status(400).json({
success: false,
description: `Invalid limit. Limit is capped at 50.`,
});
}
let alg = gptConfig.defaultProfileRatingAlg;
if (IsString(req.query.alg)) {
const temp = CheckStrProfileAlg(game, playtype, req.query.alg);
@@ -92,11 +141,152 @@ router.get("/leaderboard", async (req, res) => {
});
});
/**
* Returns the best scores for this game.
*
* @param alg - An alternative algorithm to use instead of the gpts default.
* @param limit - How many scores to return.
*
* @name GET /api/v1/games/:game/:playtype/score-leaderboard
*/
router.get("/score-leaderboard", async (req, res) => {
const game = req[SYMBOL_TachiData]!.game!;
const playtype = req[SYMBOL_TachiData]!.playtype!;
const gptConfig = GetGamePTConfig(game, playtype);
const limit = ParseStrPositiveNonZeroInt(req.query.limit) ?? 50;
let alg = gptConfig.defaultProfileRatingAlg;
if (IsString(req.query.alg)) {
const temp = CheckStrProfileAlg(game, playtype, req.query.alg);
if (temp === null) {
return res.status(400).json({
success: false,
description: `Invalid value of ${
req.query.alg
} for alg. Expected one of ${gptConfig.profileRatingAlgs.join(", ")}`,
});
}
alg = temp;
}
const pbs = await db["personal-bests"].find(
{
game,
playtype,
},
{
sort: {
[`calculatedData.${alg}`]: -1,
},
limit,
}
);
const users = await GetUsersWithIDs(pbs.map((e) => e.userID));
const { songs, charts } = await GetRelevantSongsAndCharts(pbs, game);
return res.status(200).send({
success: true,
description: `Successfully returned ${pbs.length} pbs.`,
body: {
pbs,
songs,
charts,
users,
},
});
});
/**
* Return the distribution of players for the provided class.
*
* @param class - This should be one of the games supported classes.
*
* @name GET /api/v1/games/:game/:playtype/player-distribution
*/
router.get(
"/class-distribution",
prValidate({
class: "string",
}),
async (req, res) => {
const game = req[SYMBOL_TachiData]!.game!;
const playtype = req[SYMBOL_TachiData]!.playtype!;
const gptConfig = GetGamePTConfig(game, playtype);
const stat = req.query.class as string;
const supportedClasses = Object.keys(gptConfig.classHumanisedFormat);
if (!supportedClasses.includes(stat)) {
return res.status(400).json({
success: false,
description: `Invalid stat ${stat}. Expected any of ${supportedClasses.join(
", "
)}.`,
});
}
// @hack This is asserted above. We're just going to cast as any because we know what
// we're doing.
const distribution = await GetClassDistribution(game, playtype, stat as any);
return res.status(200).json({
success: true,
description: `Successfully retrieved distribution.`,
body: distribution,
});
}
);
/**
* Returns recent class improvements for this GPT.
*
* @param limit - How many to return. Defaults to 10, caps at 50.
*
* @name GET /api/v1/games/:game/:playtype/recent-classes
*/
router.get(
"/recent-classes",
prValidate({
limit: p.optional((self) => p.isBoundedInteger(1, 50)(Number(self))),
}),
async (req, res) => {
const game = req[SYMBOL_TachiData]!.game!;
const playtype = req[SYMBOL_TachiData]!.playtype!;
const limit = req.query.limit ? Number(req.query.limit) : 10;
const recentClasses = await db["class-achievements"].find(
{
game,
playtype,
},
{
limit,
sort: { timeAchieved: -1 },
}
);
const users = await GetUsersWithIDs(recentClasses.map((e) => e.userID));
return res.status(200).json({
success: true,
description: `Returned ${recentClasses.length} recent classes.`,
body: { classes: recentClasses, users },
});
}
);
// @todo #196 Country Leaderboards?
router.use("/charts", chartsRouter);
router.use("/songs/:songID", songIDRouter);
router.use("/folders", foldersRouter);
router.use("/tables", tablesRouter);
router.use("/scores", scoresRouter);
export default router;