Merge pull request #801 from TNG-dev/zkldi/gpt-utils

This commit is contained in:
zkldi
2022-12-19 10:20:30 +00:00
committed by GitHub
17 changed files with 597 additions and 80 deletions
@@ -126,6 +126,8 @@ function UserHistory({
<div className="row d-flex justify-content-center mb-4">
<div className="col-12 col-md-3 align-self-center text-center">
<Select noMarginBottom setValue={setDuration} value={duration}>
<option value="week">Past Week</option>
<option value="month">Past Month</option>
<option value="3mo">Past 3 Months</option>
<option value="year">Past Year</option>
</Select>
@@ -0,0 +1,80 @@
import useSetSubheader from "components/layout/header/useSetSubheader";
import Card from "components/layout/page/Card";
import { GetGPTTools } from "components/tools/Tools";
import Divider from "components/util/Divider";
import LinkButton from "components/util/LinkButton";
import React from "react";
import { Col, Row } from "react-bootstrap";
import { Link, Route, Switch } from "react-router-dom";
import { FormatGame, GetGameConfig } from "tachi-common";
import { UGPT } from "types/react";
export default function ToolsPage({ reqUser, game, playtype }: UGPT) {
const gameConfig = GetGameConfig(game);
useSetSubheader(
["Users", reqUser.username, "Games", gameConfig.name, playtype, "Tools"],
[reqUser, game, playtype],
`${reqUser.username}'s ${FormatGame(game, playtype)} Tools`
);
const tools = GetGPTTools(game, playtype);
if (tools.length === 0) {
return (
<div className="text-center w-100">
{FormatGame(game, playtype)} has no tools. How'd you get here!
</div>
);
}
return (
<Row>
<Switch>
{tools.map((tool) => (
<Route
key={tool.urlPath}
exact
path={`/u/:userID/games/:game/:playtype/tools/${tool.urlPath}`}
>
<Col xs={12} className="mt-4">
<Card
header={tool.name}
footer={
<Link
to={`/u/${reqUser.username}/games/${game}/${playtype}/tools`}
className="text-muted text-hover-white"
>
&lt; Back to all tools...
</Link>
}
>
{tool.description}
</Card>
<Divider />
</Col>
<Col xs={12}>{tool.component({ reqUser, game, playtype })}</Col>
</Route>
))}
<Route exact path="/u/:userID/games/:game/:playtype/tools/">
{tools.map((tool) => (
<Col key={tool.urlPath} xs={12} className="my-4" lg={6}>
<Card
header={tool.name}
footer={
<div className="d-flex w-100 justify-content-end">
<LinkButton to={`tools/${tool.urlPath}`}>
Use Tool
</LinkButton>
</div>
}
>
{tool.description}
</Card>
</Col>
))}
</Route>
</Switch>
</Row>
);
}
+9 -3
View File
@@ -31,6 +31,7 @@ import { FormatGame, Game, GetGameConfig, UserDocument, UserGameStats } from "ta
import { UGPTStatsReturn } from "types/api-returns";
import UserImportsPage from "app/pages/dashboard/users/UserImportsPage";
import { TargetsContextProvider } from "context/TargetsContext";
import ToolsPage from "app/pages/dashboard/users/games/_game/_playtype/tools/ToolsPage";
import ScoresPage from "../pages/dashboard/users/games/_game/_playtype/ScoresPage";
import UserPage from "../pages/dashboard/users/UserPage";
@@ -64,9 +65,9 @@ export default function UserRoutes() {
return null;
}
// redirect to the users actual name.
if (userID.match(/^[0-9]+$/u)) {
const split = history.location.pathname.match(/^(\/u)\/([0-9]+)(.*)$/u);
// redirect to the users actual name if using a user ID or "me"
if (userID.match(/^([0-9]+|me)$/u)) {
const split = history.location.pathname.match(/^(\/u)\/([0-9]+|me)(.*)$/u);
if (!split) {
return (
@@ -229,6 +230,8 @@ function UserGamePlaytypeRoutes({ reqUser, game }: { reqUser: UserDocument; game
header={`${reqUser.username}'s ${FormatGame(game, playtype)} Profile`}
footer={
<UGPTBottomNav
game={game}
playtype={playtype}
isRequestedUser={reqUser.id === user?.id}
baseUrl={`/u/${reqUser.username}/games/${game}/${playtype}`}
/>
@@ -261,6 +264,9 @@ function UserGamePlaytypeRoutes({ reqUser, game }: { reqUser: UserDocument; game
<Route exact path="/u/:userID/games/:game/:playtype/leaderboard">
<LeaderboardsPage reqUser={reqUser} game={game} playtype={playtype} />
</Route>
<Route path="/u/:userID/games/:game/:playtype/tools">
<ToolsPage reqUser={reqUser} game={game} playtype={playtype} />
</Route>
<RequireAuthAsUserParam>
<Route exact path="/u/:userID/games/:game/:playtype/settings">
<UGPTSettingsPage reqUser={reqUser} game={game} playtype={playtype} />
@@ -0,0 +1,74 @@
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<{
forSpecificUser?: boolean;
urlName: string;
tableName: string;
symbol: string;
description: string;
}>
>(`/games/${game}/${playtype}/custom-tables`);
if (error) {
return <ApiError error={error} />;
}
if (!data) {
return <Loading />;
}
return (
<Row>
<Col xs={12}>
Just paste these URLs into your BMS client of choice. For LR2oraja, this is on the
setup screen.
<Divider />
</Col>
{data.map((tbl) => {
const TABLE_URL = ToAPIURL(
`${
tbl.forSpecificUser ? `/users/${reqUser.username}` : ""
}/games/bms/${playtype}/custom-tables/${tbl.urlName}`
);
return (
<Col key={tbl.urlName} xs={12} lg={6}>
<Card className="my-4" header={tbl.tableName}>
{tbl.description}
<br />
<br />
URL: <code>{TABLE_URL}</code>
<Icon
type="link"
onClick={() => {
CopyToClipboard(TABLE_URL);
}}
/>
</Card>
</Col>
);
})}
</Row>
);
}
export const BMSCustomTablesTool: GPTTool = {
name: `${TachiConfig.name} BMS Tables`,
urlPath: "custom-tables",
description: `${TachiConfig.name} has its own BMS tables that you can use in-game!`,
component: Component,
};
@@ -0,0 +1,68 @@
import { CopyToClipboard } from "util/misc";
import ApiError from "components/util/ApiError";
import DebugContent from "components/util/DebugContent";
import Divider from "components/util/Divider";
import Loading from "components/util/Loading";
import useApiQuery from "components/util/query/useApiQuery";
import { TachiConfig } from "lib/config";
import React from "react";
import { Alert, Button, Col, Row } from "react-bootstrap";
import { UGPT } from "types/react";
import { GPTTool } from "types/ugpt";
function Component({ game, playtype, reqUser }: UGPT) {
const { data, error } = useApiQuery<string>(
`/users/${reqUser.id}/games/${game}/${playtype}/eamusement-csv`
);
if (error) {
return <ApiError error={error} />;
}
if (!data) {
return <Loading />;
}
return (
<Row>
<Col xs={12}>
<Alert variant="warning">
Hey! Please be aware that this format is <strong>lossy</strong>. You should use
this to integrate with other tools that only support eamusement CSVs.
<br />
Tachi offers more fully fledged score exporting if you're looking to actually
get your scores elsewhere.
</Alert>
<Divider />
</Col>
<Col xs={12}>
<textarea
readOnly
className="w-100 text-monospace"
style={{ height: "400px" }}
value={data}
/>
<Divider />
<div className="d-flex w-100 justify-content-center" style={{ gap: "10px" }}>
<Button variant="outline-info" onClick={() => CopyToClipboard(data)}>
Copy to Clipboard
</Button>
<a
className="btn btn-outline-primary"
download={`iidx-eam-${reqUser.username}.csv`}
href={`data:text/plain,${encodeURIComponent(data)}`}
>
Download
</a>
</div>
</Col>
</Row>
);
}
export const IIDXEamusementExportTool: GPTTool = {
name: `e-amusement CSV Export`,
urlPath: "eam-csv-export",
description: `Export your ${TachiConfig.name} scores into e-amusement format.`,
component: Component,
};
+36
View File
@@ -0,0 +1,36 @@
import { Game, GetIDString, IDStrings, Playtype } from "tachi-common";
import { GPTTool } from "types/ugpt";
import { BMSCustomTablesTool } from "./BMSCustomTablesTool";
import { IIDXEamusementExportTool } from "./IIDXEamusementExportTool";
// 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.
// Tools are **NOT** displayed on someone elses profile. For that, you want
// Insights (Fancy score renderers, basically).
// What tools does each game support?
const GPT_TOOLS: Record<IDStrings, Array<GPTTool>> = {
"bms:7K": [BMSCustomTablesTool],
"bms:14K": [BMSCustomTablesTool],
"chunithm:Single": [],
"gitadora:Dora": [],
"gitadora:Gita": [],
"iidx:DP": [IIDXEamusementExportTool],
"iidx:SP": [IIDXEamusementExportTool],
"itg:Stamina": [],
"jubeat:Single": [],
"museca:Single": [],
"pms:Controller": [],
"pms:Keyboard": [],
"popn:9B": [],
"sdvx:Single": [],
"usc:Controller": [],
"usc:Keyboard": [],
"wacca:Single": [],
};
export function GetGPTTools(game: Game, playtype: Playtype) {
const idString = GetIDString(game, playtype);
return GPT_TOOLS[idString];
}
+19 -1
View File
@@ -14,6 +14,7 @@ import { UserContext } from "context/UserContext";
import useLUGPTSettings from "components/util/useLUGPTSettings";
import { UserSettingsContext } from "context/UserSettingsContext";
import FollowUserButton from "components/util/FollowUserButton";
import { GetGPTTools } from "components/tools/Tools";
import ProfileBadges from "./ProfileBadges";
import ProfilePicture from "./ProfilePicture";
import RankingData from "./UGPTRankingData";
@@ -179,9 +180,13 @@ export function UGPTHeaderBody({
export function UGPTBottomNav({
baseUrl,
isRequestedUser,
game,
playtype,
}: {
baseUrl: string;
isRequestedUser: boolean;
game: Game;
playtype: Playtype;
}) {
const navItems = [
<NavItem key="overview" to={`${baseUrl}/`}>
@@ -208,7 +213,20 @@ export function UGPTBottomNav({
</NavItem>,
<NavItem key="targets" to={`${baseUrl}/targets`}>
Goals & Quests
</NavItem>,
</NavItem>
);
const tools = GetGPTTools(game, playtype);
if (tools.length > 0) {
navItems.push(
<NavItem key="targets" to={`${baseUrl}/tools`}>
Tools
</NavItem>
);
}
navItems.push(
<NavItem key="settings" to={`${baseUrl}/settings`}>
Settings
</NavItem>
+9
View File
@@ -0,0 +1,9 @@
import React from "react";
import { UGPT } from "./react";
export interface GPTTool {
urlPath: string;
name: string;
description: React.ReactChild;
component: (ugpt: UGPT) => JSX.Element;
}
+5
View File
@@ -28,6 +28,7 @@ import type {
IDStrings,
JudgementLookup,
Lamps,
Playtype,
Playtypes,
ScoreCalculatedDataLookup,
SessionCalculatedDataLookup,
@@ -2085,3 +2086,7 @@ export function GetGamePTConfig<I extends IDStrings = IDStrings>(
return GAME_PT_CONFIGS[idString] as unknown as GamePTConfig<I>;
}
export function GetIDString(game: Game, playtype: Playtype): IDStrings {
return `${game}:${playtype}` as IDStrings;
}
+10
View File
@@ -322,6 +322,16 @@ export function CreateSongMap<G extends Game = Game>(songs: Array<SongDocument<G
return songMap;
}
export function CreateChartMap<I extends IDStrings = IDStrings>(charts: Array<ChartDocument<I>>) {
const chartMap = new Map<string, ChartDocument<I>>();
for (const chart of charts) {
chartMap.set(chart.chartID, chart);
}
return chartMap;
}
/**
* Formats a PrudenceError into something a little more readable.
* @param err - The prudence error to format.
+1 -35
View File
@@ -330,12 +330,9 @@ importers:
github-bot:
specifiers:
'@octokit/app': ^13.1.0
'@octokit/webhooks': 10.1.5
'@types/body-parser': ^1.19.2
'@types/express': 4.17.13
'@types/node': 16.11.7
'@types/node-fetch': 2.5.12
body-parser: ^1.20.0
dotenv: 16.0.0
express: 4.17.2
node-fetch: 2.6.7
@@ -343,9 +340,6 @@ importers:
typescript: 4.6.3
dependencies:
'@octokit/app': 13.1.0
'@octokit/webhooks': 10.1.5
'@types/body-parser': 1.19.2
body-parser: 1.20.0
dotenv: 16.0.0
express: 4.17.2
node-fetch: 2.6.7
@@ -4913,26 +4907,6 @@ packages:
- supports-color
dev: true
/body-parser/1.20.0:
resolution: {integrity: sha512-DfJ+q6EPcGKZD1QWUjSpqp+Q7bDQTsQIF4zfUAtZ6qk+H/3/QRhg9CEp39ss+/T2vw0+HaidC0ecJj/DRLIaKg==}
engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
dependencies:
bytes: 3.1.2
content-type: 1.0.4
debug: 2.6.9
depd: 2.0.0
destroy: 1.2.0
http-errors: 2.0.0
iconv-lite: 0.4.24
on-finished: 2.4.1
qs: 6.10.3
raw-body: 2.5.1
type-is: 1.6.18
unpipe: 1.0.0
transitivePeerDependencies:
- supports-color
dev: false
/body-parser/1.20.1:
resolution: {integrity: sha512-jWi7abTbYwajOytWCQc37VulmWiRae5RyTpaCyDcS5/lMdtwSz5lOpDE67srw/HYe35f1z3fDQw+3txg7gNtWw==}
engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
@@ -6346,7 +6320,6 @@ packages:
dependencies:
ms: 2.1.2
supports-color: 6.1.0
dev: true
/decamelize/1.2.0:
resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
@@ -7929,7 +7902,7 @@ packages:
debug:
optional: true
dependencies:
debug: 4.3.4
debug: 4.3.4_supports-color@6.1.0
/for-in/1.0.2:
resolution: {integrity: sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ==}
@@ -12336,13 +12309,6 @@ packages:
engines: {node: '>=0.6.0', teleport: '>=0.2.0'}
dev: false
/qs/6.10.3:
resolution: {integrity: sha512-wr7M2E0OFRfIfJZjKGieI8lBKb7fRCH4Fv5KNPEs7gJ8jadvotdsS08PzOKR7opXhZ/Xkjtt3WF9g38drmyRqQ==}
engines: {node: '>=0.6'}
dependencies:
side-channel: 1.0.4
dev: false
/qs/6.11.0:
resolution: {integrity: sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==}
engines: {node: '>=0.6'}
@@ -103,6 +103,7 @@ export type TachiBMSTable = {
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?
description: string;
} & (
| {
forSpecificUser: true; // if this table is user-dependent
@@ -265,6 +266,8 @@ export const CUSTOM_TACHI_BMS_TABLES: Array<TachiBMSTable> = [
playtype: "7K",
symbol: "sgl-",
tableName: "Sieglinde EC",
description:
"Folders for the 'Sieglinde' rating algorithm. These are rough estimates of how hard it is to EASY CLEAR a given chart.",
async getBody() {
const table = await GetTableForIDGuaranteed("bms-7K-sgl-EC");
@@ -281,6 +284,8 @@ export const CUSTOM_TACHI_BMS_TABLES: Array<TachiBMSTable> = [
playtype: "7K",
symbol: "sgl-",
tableName: "Sieglinde HC",
description:
"Folders for the 'Sieglinde' rating algorithm. These are rough estimates of how hard it is to HARD CLEAR a given chart.",
async getBody() {
const table = await GetTableForIDGuaranteed("bms-7K-sgl-HC");
@@ -299,6 +304,7 @@ export const CUSTOM_TACHI_BMS_TABLES: Array<TachiBMSTable> = [
symbol: "Rival",
tableName: `${TachiConfig.NAME} Rival Stats`,
forSpecificUser: true,
description: `Folders for your rivals on ${TachiConfig.NAME}. This includes things like their recent highlights and plays.`,
async getBody(userID, playtype) {
const rivals = await GetRivalUsers(userID, "bms", playtype);
@@ -7,38 +7,38 @@ import type { EamusementScoreData, IIDXEamusementCSVContext, IIDXEamusementCSVDa
import type { KtLogger } from "lib/logger/logger";
import type { integer, GPTSupportedVersions, Playtypes } from "tachi-common";
const EAM_VERSION_NAMES: Record<string, integer> = {
"1st&substream": 1,
"2nd style": 2,
"3rd style": 3,
"4th style": 4,
"5th style": 5,
"6th style": 6,
"7th style": 7,
"8th style": 8,
"9th style": 9,
"10th style": 10,
"IIDX RED": 11,
"HAPPY SKY": 12,
DistorteD: 13,
GOLD: 14,
"DJ TROOPERS": 15,
EMPRESS: 16,
SIRIUS: 17,
"Resort Anthem": 18,
Lincle: 19,
tricoro: 20,
SPADA: 21,
PENDUAL: 22,
copula: 23,
SINOBUZ: 24,
"CANNON BALLERS": 25,
Rootage: 26,
"HEROIC VERSE": 27,
BISTROVER: 28,
CastHour: 29,
RESIDENT: 30,
};
export enum EAM_VERSION_NAMES {
"1st&substream" = 1,
"2nd style" = 2,
"3rd style" = 3,
"4th style" = 4,
"5th style" = 5,
"6th style" = 6,
"7th style" = 7,
"8th style" = 8,
"9th style" = 9,
"10th style" = 10,
"IIDX RED" = 11,
"HAPPY SKY" = 12,
"DistorteD" = 13,
"GOLD" = 14,
"DJ TROOPERS" = 15,
"EMPRESS" = 16,
"SIRIUS" = 17,
"Resort Anthem" = 18,
"Lincle" = 19,
"tricoro" = 20,
"SPADA" = 21,
"PENDUAL" = 22,
"copula" = 23,
"SINOBUZ" = 24,
"CANNON BALLERS" = 25,
"Rootage" = 26,
"HEROIC VERSE" = 27,
"BISTROVER" = 28,
"CastHour" = 29,
"RESIDENT" = 30,
}
const PRE_HV_HEADER_COUNT = 27;
const HV_HEADER_COUNT = 41;
@@ -121,11 +121,12 @@ export function IIDXCSVParse(csvBuffer: Buffer, playtype: Playtypes["iidx"], log
let gameVersion = 0;
for (const cells of rawRows) {
const version = cells[0];
const version = cells[0]!;
const title = cells[1]!.trim();
const timestamp = cells[rawHeaders.length - 1]!.trim();
const versionNum = EAM_VERSION_NAMES[version!];
// @ts-expect-error this indexing is fine. grow up.
const versionNum = EAM_VERSION_NAMES[version] as number | undefined;
if (versionNum === undefined) {
logger.info(`Invalid/Unsupported EAM_VERSION_NAME ${version}.`);
@@ -7,6 +7,7 @@ import {
} from "lib/game-specific/custom-bms-tables";
import { AssignToReqTachiData, GetTachiData } from "utils/req-tachi-data";
import type { RequestHandler } from "express";
import type { TachiBMSTable } from "lib/game-specific/custom-bms-tables";
const router: Router = Router({ mergeParams: true });
@@ -42,6 +43,35 @@ const FindCustomBMSTable: RequestHandler = (req, res, next) => {
next();
};
/**
* List all custom BMS tables this instance of Tachi is emitting.
*
* @name GET /api/v1/games/bms/:playtype/custom-tables
*/
router.get("/:playtype/custom-tables", (req, res) => {
const tables: Array<
Pick<TachiBMSTable, "description" | "forSpecificUser" | "symbol" | "tableName" | "urlName">
> = [];
for (const table of CUSTOM_TACHI_BMS_TABLES.filter(
(e) => e.playtype === req.params.playtype || e.playtype === null
)) {
tables.push({
forSpecificUser: table.forSpecificUser,
urlName: table.urlName,
tableName: table.tableName,
symbol: table.symbol,
description: table.description,
});
}
return res.status(200).json({
success: true,
description: `Found ${tables.length} custom table(s).`,
body: tables,
});
});
/**
* Return some HTML for this custom table.
*
@@ -0,0 +1,188 @@
import { Router } from "express";
import db from "external/mongo/db";
import { EAM_VERSION_NAMES } from "lib/score-import/import-types/common/eamusement-iidx-csv/parser";
import {
AggressiveRateLimitMiddleware,
HyperAggressiveRateLimitMiddleware,
} from "server/middleware/rate-limiter";
import { GetUser } from "utils/req-tachi-data";
import type {
ChartDocument,
Grades,
Lamps,
PBScoreDocument,
SongDocument,
integer,
} from "tachi-common";
const router: Router = Router({ mergeParams: true });
const EAMUSEMENT_CSV_HEADER = `バージョン,タイトル,ジャンル,アーティスト,プレー回数,BEGINNER 難易度,BEGINNER スコア,BEGINNER PGreat,BEGINNER Great,BEGINNER ミスカウント,BEGINNER クリアタイプ,BEGINNER DJ LEVEL,NORMAL 難易度,NORMAL スコア,NORMAL PGreat,NORMAL Great,NORMAL ミスカウント,NORMAL クリアタイプ,NORMAL DJ LEVEL,HYPER 難易度,HYPER スコア,HYPER PGreat,HYPER Great,HYPER ミスカウント,HYPER クリアタイプ,HYPER DJ LEVEL,ANOTHER 難易度,ANOTHER スコア,ANOTHER PGreat,ANOTHER Great,ANOTHER ミスカウント,ANOTHER クリアタイプ,ANOTHER DJ LEVEL,LEGGENDARIA 難易度,LEGGENDARIA スコア,LEGGENDARIA PGreat,LEGGENDARIA Great,LEGGENDARIA ミスカウント,LEGGENDARIA クリアタイプ,LEGGENDARIA DJ LEVEL,最終プレー日時`;
function ConvertEamGrade(grade: Grades["iidx:DP" | "iidx:SP"]) {
// eamusement has no concept of max or max-.
if (grade === "MAX" || grade === "MAX-") {
return "AAA";
}
return grade;
}
function ConvertEamLamp(lamp: Lamps["iidx:DP" | "iidx:SP"]) {
if (lamp === "FULL COMBO") {
return "FULLCOMBO CLEAR"; // weird, but ok
}
return lamp;
}
/**
* Retrieve this users PBs in eamusement CSV format.
*
* @name GET /api/v1/users/:userID/games/iidx/:playtype/eamusement-csv
*/
router.get("/:playtype/eamusement-csv", AggressiveRateLimitMiddleware, async (req, res) => {
const game = "iidx";
if (req.params.playtype !== "SP" && req.params.playtype !== "DP") {
return res.status(400).json({
success: false,
description: `Invalid playtype. Expected SP or DP.`,
});
}
const playtype = req.params.playtype;
const user = GetUser(req);
const pbData: Array<{
_id: integer;
pbs: Array<PBScoreDocument<"iidx:DP" | "iidx:SP">>;
song: SongDocument<"iidx">;
}> = await db["personal-bests"].aggregate([
{
$match: {
userID: user.id,
game,
playtype,
isPrimary: true,
},
},
{
$group: {
// group our scores on the song
_id: "$songID",
pbs: { $push: "$$ROOT" },
},
},
{
$lookup: {
from: "songs-iidx",
foreignField: "id",
localField: "_id",
as: "song",
},
},
{
$unwind: "$song",
},
]);
const rows = [EAMUSEMENT_CSV_HEADER];
// get all relevant charts
const charts = await db.charts.iidx.find({
songID: { $in: pbData.map((e) => e.song.id) },
difficulty: { $in: ["BEGINNER", "NORMAL", "HYPER", "ANOTHER", "LEGGENDARIA"] },
});
// get a lookup table for songID + difficulty -> chart.
const chartMap = new Map<string, ChartDocument>();
for (const chart of charts) {
chartMap.set(`${chart.songID}-${chart.difficulty}`, chart);
}
for (const { pbs, song } of pbData) {
let version = "UNKNOWN";
const tachiVer = song.data.displayVersion;
if (tachiVer !== null) {
// @ts-expect-error We're abusing enums which already aren't meant
// for this kind of lookup task. Ah well!
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
version = EAM_VERSION_NAMES[tachiVer] ?? tachiVer;
}
const row = [
version,
song.title,
song.data.genre,
song.artist,
"0", // always 0, who cares?
];
let lastPlayed = 0;
for (const difficulty of [
"BEGINNER",
"NORMAL",
"HYPER",
"ANOTHER",
"LEGGENDARIA",
] as const) {
const chart = chartMap.get(`${song.id}-${difficulty}`);
let pb;
// this song might not have a beginner/normal/hyper/another/legg
if (chart) {
// try and find the user's PB
pb = pbs.find((e) => e.chartID === chart.chartID);
}
if (pb) {
row.push(
chart ? chart.level : "0",
pb.scoreData.score.toString(), // ex
pb.scoreData.judgements.pgreat?.toString() ?? "0", // pgreat
pb.scoreData.judgements.great?.toString() ?? "0", // great
pb.scoreData.hitMeta.bp?.toString() ?? "0", // BP
ConvertEamLamp(pb.scoreData.lamp), // lamp
ConvertEamGrade(pb.scoreData.grade) // grade
);
if (pb.timeAchieved !== null && lastPlayed < pb.timeAchieved) {
lastPlayed = pb.timeAchieved;
}
} else {
row.push(
chart ? chart.level : "0",
"0", // ex
"0", // pgreat
"0", // great
"---", // BP
"NO PLAY", // lamp
"---" // grade
);
}
}
// last played. This will be 1970-01-01 if this user has never played this chart
// with a timestamp.
row.push(new Date(lastPlayed).toISOString());
// IIDX uses a "naive" CSV format. that is to say -- there's no escaping.
// God forbid a song title like "19, november" get output here, because it will
// just break the format. That's what the official site does though.
// bug-for-bug compatibility!
// at the very least, we'll replace , with \,. That should be fine.
rows.push(row.map((e) => e.replace(/,/gu, "\\,")).join(","));
}
return res.status(200).json({
success: true,
description: `Created e-amusement CSV.`,
body: rows.join("\n"),
});
});
export default router;
@@ -3,10 +3,12 @@
// for certain games.
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;
@@ -12,7 +12,7 @@ import { RequireAuthedAsUser, RequireSelfRequestFromUser } from "../../../middle
import { Router } from "express";
import db from "external/mongo/db";
import { CreateActivityRouteHandler } from "lib/activity/activity";
import { ONE_MONTH, ONE_YEAR } from "lib/constants/time";
import { ONE_MONTH, ONE_WEEK, ONE_YEAR } from "lib/constants/time";
import CreateLogCtx from "lib/logger/logger";
import p from "prudence";
import prValidate from "server/middleware/prudence-validate";
@@ -103,17 +103,33 @@ router.get("/", async (req, res) => {
router.get(
"/history",
prValidate({
duration: p.optional(p.isIn("3mo", "year")),
duration: p.optional(p.isIn("week", "month", "3mo", "year")),
}),
async (req, res) => {
const duration = req.query.duration as "3mo" | "year" | undefined;
const duration = req.query.duration as "3mo" | "month" | "week" | "year" | undefined;
let time = Date.now();
if (duration === "year") {
time = time - ONE_YEAR;
} else {
time = time - ONE_MONTH * 3;
switch (duration) {
case "year": {
time = time - ONE_YEAR;
break;
}
// if the user doesn't define anything, default to 3month
case "3mo":
case undefined: {
time = time - ONE_MONTH * 3;
break;
}
case "month": {
time = time - ONE_MONTH;
break;
}
case "week":
time = time - ONE_WEEK;
}
const { game, playtype, user } = GetUGPT(req);