diff --git a/server/src/lib/game-specific/custom-bms-tables.ts b/server/src/lib/game-specific/custom-bms-tables.ts index 3f41ee543..f9a1ea170 100644 --- a/server/src/lib/game-specific/custom-bms-tables.ts +++ b/server/src/lib/game-specific/custom-bms-tables.ts @@ -1,12 +1,15 @@ import CreateLogCtx from "lib/logger/logger"; -import { ServerConfig } from "lib/setup/config"; +import { GetRivalUsers } from "lib/rivals/rivals"; +import { ServerConfig, TachiConfig } from "lib/setup/config"; import { CreateSongMap } from "tachi-common"; +import { GetRelevantSongsAndCharts } from "utils/db"; import { GetFolderCharts, GetFolderNamesInOrder, GetFoldersFromTable, GetTableForIDGuaranteed, } from "utils/folder"; +import { GetRecentUGPTScores } from "utils/queries/scores"; import path from "path"; import type { BMSTableEntry, BMSTableHead } from "bms-table-loader"; import type { Request, Response } from "express-serve-static-core"; @@ -14,6 +17,7 @@ import type { ChartDocument, FolderDocument, Playtypes, + SongDocument, TableDocument, integer, } from "tachi-common"; @@ -23,6 +27,33 @@ const logger = CreateLogCtx(__filename); // Instead of just supporting existing tables, Tachi should also be able // to emit its own, custom BMS tables. These may be dynamic. +function AppendAndConvertChartsToBMSBody( + body: Array, + charts: Array>, + songMap: Map, + level: string +) { + for (const chart of charts) { + const song = songMap.get(chart.songID); + + // if we've got metadata to add... + if (song) { + body.push({ + level, + title: song.title, + artist: song.artist, + md5: chart.data.hashMD5, + }); + } else { + logger.warn(`BMS Chart md5=${chart.data.hashMD5} has no parent song.`); + body.push({ + level, + md5: chart.data.hashMD5, + }); + } + } +} + /** * Convert a table in Tachi into a bms header.json and body.json. */ @@ -59,32 +90,16 @@ export async function TachiTableToBMSTableJSON( const charts = data.charts as Array>; const songMap = CreateSongMap(data.songs); - for (const chart of charts) { - const song = songMap.get(chart.songID); - - // if we've got metadata to add... - if (song) { - body.push({ - level: folder.title, - title: song.title, - artist: song.artist, - md5: chart.data.hashMD5, - }); - } else { - logger.warn(`BMS Chart md5=${chart.data.hashMD5} has no parent song.`); - body.push({ - level: folder.title, - md5: chart.data.hashMD5, - }); - } - } + AppendAndConvertChartsToBMSBody(body, charts, songMap, folder.title); } return body; } export type TachiBMSTable = { - playtype: Playtypes["bms"]; + playtype: Playtypes["bms"] | null; // what playtype is this for? If null, this table + // is for all playtypes. + urlName: string; // what do we call this in the url? tableName: string; // what should it be called in-game? symbol: string; // what symbol should this table have? @@ -93,13 +108,16 @@ export type TachiBMSTable = { forSpecificUser: true; // if this table is user-dependent // like, say, their rivals scores or something. // then the callbacks need to recieve that info. - getLevelOrder: (userID: integer) => Promise | undefined>; - getBody: (userID: integer) => Promise>; + getLevelOrder: ( + userID: integer, + playtype: Playtypes["bms"] + ) => Promise | undefined>; + getBody: (userID: integer, playtype: Playtypes["bms"]) => Promise>; } | { forSpecificUser?: false; - getLevelOrder: () => Promise | undefined>; - getBody: () => Promise>; + getLevelOrder: (playtype: Playtypes["bms"]) => Promise | undefined>; + getBody: (playtype: Playtypes["bms"]) => Promise>; } ); @@ -136,6 +154,14 @@ function GetUserID(req: Request) { throw new Error(`No userID in params here. Is this route mounted in the right place?`); } +function GetPlaytype(req: Request) { + if ("playtype" in req.params) { + return req.params.playtype as Playtypes["bms"]; + } + + throw new Error(`No playtype in params here. Is this route mounted in the right place?`); +} + /** * Handle a request for a bms table. This endpoint should return "HTML" with the caveat * that atleast one of the lines should refer to a "bmstable" meta header. @@ -170,15 +196,16 @@ export async function HandleBMSTableHeaderRequest( try { let levelOrder; let dataUrl; + const playtype = GetPlaytype(req); if (bmsTable.forSpecificUser === true) { const userID = GetUserID(req); dataUrl = BMSTableToAbsoluteURL(bmsTable, "body", userID); - levelOrder = await bmsTable.getLevelOrder(userID); + levelOrder = await bmsTable.getLevelOrder(userID, playtype); } else { - levelOrder = await bmsTable.getLevelOrder(); + levelOrder = await bmsTable.getLevelOrder(playtype); dataUrl = BMSTableToAbsoluteURL(bmsTable, "body", null); } @@ -207,12 +234,14 @@ export async function HandleBMSTableBodyRequest( try { let body; + const playtype = GetPlaytype(req); + if (bmsTable.forSpecificUser === true) { const userID = GetUserID(req); - body = await bmsTable.getBody(userID); + body = await bmsTable.getBody(userID, playtype); } else { - body = await bmsTable.getBody(); + body = await bmsTable.getBody(playtype); } return res.status(200).send(body); @@ -263,4 +292,69 @@ export const CUSTOM_TACHI_BMS_TABLES: Array = [ return GetFolderNamesInOrder(table); }, }, + + { + urlName: "rival-info", + playtype: null, + symbol: "Rival", + tableName: `${TachiConfig.NAME} Rival Stats`, + forSpecificUser: true, + async getBody(userID, playtype) { + const rivals = await GetRivalUsers(userID, "bms", playtype); + + const body: Array = []; + + const promises = []; + + for (const rival of rivals) { + promises.push(async () => { + const scores = await GetRecentUGPTScores(rival.id, "bms", playtype); + + const data = await GetRelevantSongsAndCharts(scores, "bms"); + const charts = data.charts as unknown as Array< + ChartDocument<"bms:7K" | "bms:14K"> + >; + + const songMap = CreateSongMap(data.songs); + + AppendAndConvertChartsToBMSBody( + body, + charts, + songMap, + `${rival.username} Recent Plays` + ); + }); + + promises.push(async () => { + const scores = await GetRecentUGPTScores(rival.id, "bms", playtype); + + const data = await GetRelevantSongsAndCharts(scores, "bms"); + const charts = data.charts as unknown as Array< + ChartDocument<"bms:7K" | "bms:14K"> + >; + + const songMap = CreateSongMap(data.songs); + + AppendAndConvertChartsToBMSBody( + body, + charts, + songMap, + `${rival.username} Recent Highlights` + ); + }); + } + + await Promise.all(promises); + + return body; + }, + async getLevelOrder(userID, playtype) { + const rivals = await GetRivalUsers(userID, "bms", playtype); + + return rivals.flatMap((rival) => [ + `${rival.username} Recent Plays`, + `${rival.username} Recent Highlights`, + ]); + }, + }, ]; diff --git a/server/src/server/router/api/v1/users/_userID/games/@gameSpecificRoutes/bms/router.ts b/server/src/server/router/api/v1/users/_userID/games/@gameSpecificRoutes/bms/router.ts index 6eb77db89..22d042f6a 100644 --- a/server/src/server/router/api/v1/users/_userID/games/@gameSpecificRoutes/bms/router.ts +++ b/server/src/server/router/api/v1/users/_userID/games/@gameSpecificRoutes/bms/router.ts @@ -45,7 +45,7 @@ const FindCustomBMSTable: RequestHandler = (req, res, next) => { /** * Return some HTML for this custom table. * - * @note Since this is the GPT route, trying to fetch user specific custom tables + * @note Since this is the UGPT route, trying to fetch GPT custom tables * will result in a 404. This applies for all subsequent :tableUrlName routes. * * @name GET /api/v1/users/:userID/games/bms/:playtype/custom-tables/:tableUrlName diff --git a/server/src/server/router/api/v1/users/_userID/router.ts b/server/src/server/router/api/v1/users/_userID/router.ts index e02d3c631..a29837059 100644 --- a/server/src/server/router/api/v1/users/_userID/router.ts +++ b/server/src/server/router/api/v1/users/_userID/router.ts @@ -1,3 +1,13 @@ +import { + GetGoalSummary, + GetRecentPlaycount, + GetRecentSessions, + GetRecentlyViewedFoldersAnyGPT, +} from "utils/queries/summary"; +import { GetUser } from "utils/req-tachi-data"; +import type { ProfileRatingAlgs } from "utils/string-checks"; +import { FormatUserDoc, GetAllRankings, GetUserWithID } from "utils/user"; +import { HashPassword, PasswordCompare, ValidatePassword } from "../../auth/auth"; import apiTokensRouter from "./api-tokens/router"; import bannerRouter from "./banner/router"; import followingRouter from "./following/router"; @@ -10,7 +20,6 @@ import { GetUserFromParam, RequireSelfRequestFromUser } from "./middleware"; import notifsRouter from "./notifications/router"; import pfpRouter from "./pfp/router"; import settingsRouter from "./settings/router"; -import { HashPassword, PasswordCompare, ValidatePassword } from "../../auth/auth"; import { Router } from "express"; import db from "external/mongo/db"; import { GetRecentActivity } from "lib/activity/activity"; @@ -21,17 +30,7 @@ import p from "prudence"; import prValidate from "server/middleware/prudence-validate"; import { DeleteUndefinedProps, IsNonEmptyString, StripUrl } from "utils/misc"; import { optNullFluffStrField } from "utils/prudence"; -import { - GetGoalSummary, - GetRecentlyViewedFoldersAnyGPT, - GetRecentPlaycount, - GetRecentSessions, -} from "utils/queries/summary"; -import { GetUser } from "utils/req-tachi-data"; -import { FormatUserDoc, GetAllRankings, GetUserWithID } from "utils/user"; -import type { ActivityConstraint } from "lib/activity/activity"; -import type { IDStrings, ImportTypes, integer, UserGameStats } from "tachi-common"; -import type { ProfileRatingAlgs } from "utils/string-checks"; +import type { IDStrings, ImportTypes, UserGameStats, integer } from "tachi-common"; const logger = CreateLogCtx(__filename); diff --git a/server/src/utils/queries/scores.ts b/server/src/utils/queries/scores.ts new file mode 100644 index 000000000..39f4705e2 --- /dev/null +++ b/server/src/utils/queries/scores.ts @@ -0,0 +1,45 @@ +import db from "external/mongo/db"; +import type { Game, Playtype, integer } from "tachi-common"; + +export async function GetRecentUGPTScores( + userID: integer, + game: Game, + playtype: Playtype, + limit = 100 +) { + return db.scores.find( + { + userID, + game, + playtype, + }, + { + sort: { + timeAdded: -1, + }, + limit, + } + ); +} + +export async function GetRecentUGPTHighlights( + userID: integer, + game: Game, + playtype: Playtype, + limit = 100 +) { + return db.scores.find( + { + userID, + game, + playtype, + highlight: true, + }, + { + sort: { + timeAdded: -1, + }, + limit, + } + ); +}