diff --git a/client/src/components/tools/IIDXPlaylistsTool.tsx b/client/src/components/tools/IIDXPlaylistsTool.tsx new file mode 100644 index 000000000..c386b965a --- /dev/null +++ b/client/src/components/tools/IIDXPlaylistsTool.tsx @@ -0,0 +1,84 @@ +import { ToAPIURL } from "util/api"; +import { CopyToClipboard } from "util/misc"; +import Card from "components/layout/page/Card"; +import ApiError from "components/util/ApiError"; +import Loading from "components/util/Loading"; +import useApiQuery from "components/util/query/useApiQuery"; +import { TachiConfig } from "lib/config"; +import React from "react"; +import { Col, Row } from "react-bootstrap"; +import { UGPT } from "types/react"; +import { GPTTool } from "types/ugpt"; +import Icon from "components/util/Icon"; +import Divider from "components/util/Divider"; + +function Component({ game, playtype, reqUser }: UGPT) { + const { data, error } = useApiQuery< + Array<{ + urlName: string; + forSpecificUser?: boolean; + playlistName: string; + description: string; + }> + >(`/games/${game}/${playtype}/playlists`); + + if (error) { + return ; + } + + if (!data) { + return ; + } + + return ( + + + +
+ Just drop this file into your playlists/ folder. +
+ + ToAPIURL( + `${ + e.forSpecificUser + ? `/users/${reqUser.username}` + : "" + }/games/iidx/${playtype}/playlists/${e.urlName}` + ) + ) + ) + )}`} + > + Download + + + } + > +
    + {data.map((e) => ( +
  • + {e.playlistName}
    + {e.description} +
  • + ))} +
+
+ +
+ ); +} + +export const IIDXPlaylistsTool: GPTTool = { + name: `${TachiConfig.name} IIDX Playlists`, + urlPath: "custom-tables", + description: `${TachiConfig.name} has its own IIDX playlists that you can use in-game via playlister!`, + component: Component, +}; diff --git a/client/src/components/tools/Tools.tsx b/client/src/components/tools/Tools.tsx index 1dd58e594..599c6b2e7 100644 --- a/client/src/components/tools/Tools.tsx +++ b/client/src/components/tools/Tools.tsx @@ -2,6 +2,7 @@ import { Game, GetIDString, IDStrings, Playtype } from "tachi-common"; import { GPTTool } from "types/ugpt"; import { BMSCustomTablesTool } from "./BMSCustomTablesTool"; import { IIDXEamusementExportTool } from "./IIDXEamusementExportTool"; +import { IIDXPlaylistsTool } from "./IIDXPlaylistsTool"; // A tool is something that users can use, like exporting to a specific format // or having things integrate with the game in a neat way. @@ -15,8 +16,8 @@ const GPT_TOOLS: Record> = { "chunithm:Single": [], "gitadora:Dora": [], "gitadora:Gita": [], - "iidx:DP": [IIDXEamusementExportTool], - "iidx:SP": [IIDXEamusementExportTool], + "iidx:DP": [IIDXEamusementExportTool, IIDXPlaylistsTool], + "iidx:SP": [IIDXEamusementExportTool, IIDXPlaylistsTool], "itg:Stamina": [], "jubeat:Single": [], "museca:Single": [], diff --git a/server/src/lib/game-specific/iidx-playlists.ts b/server/src/lib/game-specific/iidx-playlists.ts new file mode 100644 index 000000000..cd156cff9 --- /dev/null +++ b/server/src/lib/game-specific/iidx-playlists.ts @@ -0,0 +1,138 @@ +import db from "external/mongo/db"; +import CreateLogCtx from "lib/logger/logger"; +import { PoyashiBPI } from "rg-stats"; +import type { integer, ChartDocument, Playtypes } from "tachi-common"; + +const logger = CreateLogCtx(__filename); + +interface PlaylistEntry { + entry_id: integer; + difficulty: "another" | "beginner" | "hyper" | "leggendaria" | "normal"; + bar_style?: "event" | "lightning" | "rainbow"; +} + +export interface IIDXPlaylist { + name: string; + play_style: "DP" | "SP"; + charts: Array; +} + +export type TachiIIDXPlaylist = { + playtype: Playtypes["iidx"] | null; // What playtype is this for? If null, this playlist is for any. + + urlName: string; // what should we call this playlist in the url? + playlistName: string; // what should this playlist be called in-game? + description: string; +} & ( + | { + forSpecificUser: true; // playlist is user dependent. + // like, say, their rivals scores or something. + // then the callbacks need to recieve that info. + getPlaylists: ( + userID: integer, + playtype: Playtypes["iidx"] + ) => Promise>; + } + | { + forSpecificUser?: false; + getPlaylists: (playtype: Playtypes["iidx"]) => Promise>; + } +); + +function ChartsToPlaylistFormat( + charts: Array> +): Array { + const arr: Array = []; + + for (const chart of charts) { + let inGameID: number; + + // welp. pick the latest one. + + if (Array.isArray(chart.data.inGameID)) { + const el = chart.data.inGameID.at(-1); + + if (el === undefined) { + // wut, chart has an empty array of ingameids? + logger.warn(`Chart '${chart.chartID}' has an empty array of inGameIDs.`); + continue; + } + + inGameID = el; + } else { + inGameID = chart.data.inGameID; + } + + // skip 2dxtra, this excludes things like "All Scratch ANOTHER". + if (chart.data["2dxtraSet"] !== null) { + continue; + } + + arr.push({ + entry_id: inGameID, + + // @ts-expect-error yeah, i know. it'll be fine. + difficulty: chart.difficulty.toLowerCase(), + + // highlight individual difference charts as "event". + bar_style: + chart.tierlistInfo["kt-HC"]?.individualDifference === true ? "event" : undefined, + }); + } + + return arr; +} + +export const CUSTOM_TACHI_IIDX_PLAYLISTS: Array = [ + { + urlName: "aaa-bpi", + playtype: null, + description: "Folders for how much an AAA is worth in BPI.", + playlistName: "AAA BPIs", + async getPlaylists(playtype) { + const cutoffs = [-10, -5, 0, 5, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100]; + + const bounds: Array<[number, number]> = []; + + for (let i = 0; i < cutoffs.length - 1; i++) { + bounds.push([cutoffs[i], cutoffs[i + 1]] as [number, number]); + } + + const charts = (await db.charts.iidx.find({ + "data.kaidenAverage": { $ne: null }, + })) as Array>; + + const entries = []; + + for (const chart of charts) { + // what's the least EX Score you can have for an AAA on this chart? + const AAA_EX = Math.ceil(chart.data.notecount * 2 * (8 / 9)); + + entries.push({ + chart, + bpi: PoyashiBPI.calculate( + AAA_EX, + chart.data.kaidenAverage!, + chart.data.worldRecord!, + chart.data.notecount * 2, + chart.data.bpiCoefficient + ), + }); + } + + const playlists: Array = []; + + for (const [lower, upper] of bounds) { + playlists.push({ + name: `AAA BPI ${lower}~${upper}`, + play_style: playtype, + charts: ChartsToPlaylistFormat( + entries.filter((e) => e.bpi <= upper && e.bpi > lower).map((e) => e.chart) + ), + }); + } + + return playlists; + }, + }, +]; diff --git a/server/src/server/router/api/v1/games/@gameSpecificRoutes/iidx/router.ts b/server/src/server/router/api/v1/games/@gameSpecificRoutes/iidx/router.ts new file mode 100644 index 000000000..7a41700ee --- /dev/null +++ b/server/src/server/router/api/v1/games/@gameSpecificRoutes/iidx/router.ts @@ -0,0 +1,67 @@ +import { Router } from "express"; +import { CUSTOM_TACHI_IIDX_PLAYLISTS } from "lib/game-specific/iidx-playlists"; +import type { TachiIIDXPlaylist } from "lib/game-specific/iidx-playlists"; + +const router: Router = Router({ mergeParams: true }); + +/** + * List all the playlists we have available. + * + * @name GET /api/v1/games/iidx/:playtype/playlists + */ + +router.get("/:playtype/playlists", (req, res) => { + const playlists = CUSTOM_TACHI_IIDX_PLAYLISTS.filter( + (e) => e.playtype === null || e.playtype === req.params.playtype + ); + + const body = []; + + for (const playlist of playlists) { + body.push({ + forSpecificUser: playlist.forSpecificUser, + urlName: playlist.urlName, + playlistName: playlist.playlistName, + description: playlist.description, + }); + } + + return res.status(200).json({ + success: true, + description: `Found ${playlists.length} playlist(s)`, + body: playlists, + }); +}); + +/** + * Retrieve this playlist. + * + * @name GET /api/v1/games/iidx/:playtype/playlists/:playlistID + */ +router.get("/:playtype/playlists/:playlistID", async (req, res) => { + const playlist: TachiIIDXPlaylist | undefined = CUSTOM_TACHI_IIDX_PLAYLISTS.find( + (e) => + (e.playtype === null || e.playtype === req.params.playtype) && + e.urlName === req.params.playlistID + ); + + if (!playlist) { + return res.status(404).json({ + success: false, + description: `No such playlist '${req.params.playlistID}' exists for '${req.params.playtype}'.`, + }); + } + + if (playlist.forSpecificUser === true) { + return res.status(404).json({ + success: false, + description: `This playlist is for a specific user. Use the /users/:userID endpoint instead.`, + }); + } + + const body = await playlist.getPlaylists(req.params.playtype as "DP" | "SP"); + + return res.status(200).json(body); +}); + +export default router; diff --git a/server/src/server/router/api/v1/games/@gameSpecificRoutes/router.ts b/server/src/server/router/api/v1/games/@gameSpecificRoutes/router.ts index 1e6f40286..858de19e1 100644 --- a/server/src/server/router/api/v1/games/@gameSpecificRoutes/router.ts +++ b/server/src/server/router/api/v1/games/@gameSpecificRoutes/router.ts @@ -2,10 +2,12 @@ // and playtypes. This is for things like - say - custom BMS tables. import bmsRouter from "./bms/router"; +import iidxRouter from "./iidx/router"; import { Router } from "express"; const router: Router = Router({ mergeParams: true }); router.use("/bms", bmsRouter); +router.use("/iidx", iidxRouter); export default router; diff --git a/server/src/server/router/api/v1/users/_userID/games/@gameSpecificRoutes/iidx/router.ts b/server/src/server/router/api/v1/users/_userID/games/@gameSpecificRoutes/iidx/router.ts index 7d7d11926..f6cfe8c72 100644 --- a/server/src/server/router/api/v1/users/_userID/games/@gameSpecificRoutes/iidx/router.ts +++ b/server/src/server/router/api/v1/users/_userID/games/@gameSpecificRoutes/iidx/router.ts @@ -1,11 +1,10 @@ import { Router } from "express"; import db from "external/mongo/db"; +import { CUSTOM_TACHI_IIDX_PLAYLISTS } from "lib/game-specific/iidx-playlists"; import { EAM_VERSION_NAMES } from "lib/score-import/import-types/common/eamusement-iidx-csv/parser"; -import { - AggressiveRateLimitMiddleware, - HyperAggressiveRateLimitMiddleware, -} from "server/middleware/rate-limiter"; +import { AggressiveRateLimitMiddleware } from "server/middleware/rate-limiter"; import { GetUser } from "utils/req-tachi-data"; +import type { TachiIIDXPlaylist } from "lib/game-specific/iidx-playlists"; import type { ChartDocument, Grades, @@ -185,4 +184,37 @@ router.get("/:playtype/eamusement-csv", AggressiveRateLimitMiddleware, async (re }); }); +/** + * Retrieve this playlist. + * + * @name GET /api/v1/users/:userID/games/iidx/:playtype/playlists/:playlistID + */ +router.get("/:playtype/playlists/:playlistID", async (req, res) => { + const user = GetUser(req); + + const playlist: TachiIIDXPlaylist | undefined = CUSTOM_TACHI_IIDX_PLAYLISTS.find( + (e) => + (e.playtype === null || e.playtype === req.params.playtype) && + e.urlName === req.params.playlistID + ); + + if (!playlist) { + return res.status(404).json({ + success: false, + description: `No such playlist '${req.params.playlistID}' exists for '${req.params.playtype}'.`, + }); + } + + if (playlist.forSpecificUser !== true) { + return res.status(404).json({ + success: false, + description: `This playlist is not for a specific user. Use the /games/:game endpoint instead.`, + }); + } + + const body = await playlist.getPlaylists(user.id, req.params.playtype as "DP" | "SP"); + + return res.status(200).json(body); +}); + export default router;