feat: custom playlister + ui

This commit is contained in:
zkldi
2022-12-19 11:28:30 +00:00
parent 70331d1939
commit b487b4bd48
6 changed files with 330 additions and 6 deletions
@@ -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 <ApiError error={error} />;
}
if (!data) {
return <Loading />;
}
return (
<Row>
<Col xs={12}>
<Card
header="Supported Playlists"
footer={
<div className="w-100 d-flex justify-content-center align-items-center">
<div>
Just drop this file into your <code>playlists/</code> folder.
</div>
<a
className="btn btn-outline-primary ml-4"
download={`tachi-playlists.json`}
href={`data:text/plain,${encodeURIComponent(
JSON.stringify(
data.map((e) =>
ToAPIURL(
`${
e.forSpecificUser
? `/users/${reqUser.username}`
: ""
}/games/iidx/${playtype}/playlists/${e.urlName}`
)
)
)
)}`}
>
Download
</a>
</div>
}
>
<ul>
{data.map((e) => (
<li key={e.urlName}>
{e.playlistName} <br />
{e.description}
</li>
))}
</ul>
</Card>
</Col>
</Row>
);
}
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,
};
+3 -2
View File
@@ -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<IDStrings, Array<GPTTool>> = {
"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": [],
@@ -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<PlaylistEntry>;
}
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<Array<IIDXPlaylist>>;
}
| {
forSpecificUser?: false;
getPlaylists: (playtype: Playtypes["iidx"]) => Promise<Array<IIDXPlaylist>>;
}
);
function ChartsToPlaylistFormat(
charts: Array<ChartDocument<"iidx:DP" | "iidx:SP">>
): Array<PlaylistEntry> {
const arr: Array<PlaylistEntry> = [];
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<TachiIIDXPlaylist> = [
{
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<ChartDocument<"iidx:DP" | "iidx:SP">>;
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<IIDXPlaylist> = [];
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;
},
},
];
@@ -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;
@@ -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;
@@ -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;