diff --git a/client/src/app/pages/dashboard/users/games/_game/_playtype/OverviewPage.tsx b/client/src/app/pages/dashboard/users/games/_game/_playtype/OverviewPage.tsx
index d1580f416..d52c6f9bf 100644
--- a/client/src/app/pages/dashboard/users/games/_game/_playtype/OverviewPage.tsx
+++ b/client/src/app/pages/dashboard/users/games/_game/_playtype/OverviewPage.tsx
@@ -126,6 +126,8 @@ function UserHistory({
+ Past Week
+ Past Month
Past 3 Months
Past Year
diff --git a/client/src/app/pages/dashboard/users/games/_game/_playtype/tools/ToolsPage.tsx b/client/src/app/pages/dashboard/users/games/_game/_playtype/tools/ToolsPage.tsx
new file mode 100644
index 000000000..db0a0ab17
--- /dev/null
+++ b/client/src/app/pages/dashboard/users/games/_game/_playtype/tools/ToolsPage.tsx
@@ -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 (
+
+ {FormatGame(game, playtype)} has no tools. How'd you get here!
+
+ );
+ }
+
+ return (
+
+
+ {tools.map((tool) => (
+
+
+
+ < Back to all tools...
+
+ }
+ >
+ {tool.description}
+
+
+
+ {tool.component({ reqUser, game, playtype })}
+
+ ))}
+
+
+ {tools.map((tool) => (
+
+
+
+ Use Tool
+
+
+ }
+ >
+ {tool.description}
+
+
+ ))}
+
+
+
+ );
+}
diff --git a/client/src/app/routes/UserRoutes.tsx b/client/src/app/routes/UserRoutes.tsx
index 13744f40d..6db0c6657 100644
--- a/client/src/app/routes/UserRoutes.tsx
+++ b/client/src/app/routes/UserRoutes.tsx
@@ -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={
@@ -261,6 +264,9 @@ function UserGamePlaytypeRoutes({ reqUser, game }: { reqUser: UserDocument; game
+
+
+
diff --git a/client/src/components/tools/BMSCustomTablesTool.tsx b/client/src/components/tools/BMSCustomTablesTool.tsx
new file mode 100644
index 000000000..ed03c899e
--- /dev/null
+++ b/client/src/components/tools/BMSCustomTablesTool.tsx
@@ -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 ;
+ }
+
+ if (!data) {
+ return ;
+ }
+
+ return (
+
+
+ Just paste these URLs into your BMS client of choice. For LR2oraja, this is on the
+ setup screen.
+
+
+ {data.map((tbl) => {
+ const TABLE_URL = ToAPIURL(
+ `${
+ tbl.forSpecificUser ? `/users/${reqUser.username}` : ""
+ }/games/bms/${playtype}/custom-tables/${tbl.urlName}`
+ );
+
+ return (
+
+
+ {tbl.description}
+
+
+ URL: {TABLE_URL}
+ {
+ CopyToClipboard(TABLE_URL);
+ }}
+ />
+
+
+ );
+ })}
+
+ );
+}
+
+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,
+};
diff --git a/client/src/components/tools/IIDXEamusementExportTool.tsx b/client/src/components/tools/IIDXEamusementExportTool.tsx
new file mode 100644
index 000000000..6f952d511
--- /dev/null
+++ b/client/src/components/tools/IIDXEamusementExportTool.tsx
@@ -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(
+ `/users/${reqUser.id}/games/${game}/${playtype}/eamusement-csv`
+ );
+
+ if (error) {
+ return ;
+ }
+
+ if (!data) {
+ return ;
+ }
+
+ return (
+
+
+
+ Hey! Please be aware that this format is lossy . You should use
+ this to integrate with other tools that only support eamusement CSVs.
+
+ Tachi offers more fully fledged score exporting if you're looking to actually
+ get your scores elsewhere.
+
+
+
+
+
+
+
+
CopyToClipboard(data)}>
+ Copy to Clipboard
+
+
+ Download
+
+
+
+
+ );
+}
+
+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,
+};
diff --git a/client/src/components/tools/Tools.tsx b/client/src/components/tools/Tools.tsx
new file mode 100644
index 000000000..1dd58e594
--- /dev/null
+++ b/client/src/components/tools/Tools.tsx
@@ -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> = {
+ "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];
+}
diff --git a/client/src/components/user/UGPTHeader.tsx b/client/src/components/user/UGPTHeader.tsx
index e824b1f44..255d1509c 100644
--- a/client/src/components/user/UGPTHeader.tsx
+++ b/client/src/components/user/UGPTHeader.tsx
@@ -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 = [
@@ -208,7 +213,20 @@ export function UGPTBottomNav({
,
Goals & Quests
- ,
+
+ );
+
+ const tools = GetGPTTools(game, playtype);
+
+ if (tools.length > 0) {
+ navItems.push(
+
+ Tools
+
+ );
+ }
+
+ navItems.push(
Settings
diff --git a/client/src/types/ugpt.ts b/client/src/types/ugpt.ts
new file mode 100644
index 000000000..b41602d1e
--- /dev/null
+++ b/client/src/types/ugpt.ts
@@ -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;
+}
diff --git a/common/src/config/config.ts b/common/src/config/config.ts
index 66ca0d2c5..a9951eda0 100644
--- a/common/src/config/config.ts
+++ b/common/src/config/config.ts
@@ -28,6 +28,7 @@ import type {
IDStrings,
JudgementLookup,
Lamps,
+ Playtype,
Playtypes,
ScoreCalculatedDataLookup,
SessionCalculatedDataLookup,
@@ -2085,3 +2086,7 @@ export function GetGamePTConfig(
return GAME_PT_CONFIGS[idString] as unknown as GamePTConfig;
}
+
+export function GetIDString(game: Game, playtype: Playtype): IDStrings {
+ return `${game}:${playtype}` as IDStrings;
+}
diff --git a/common/src/utils/util.ts b/common/src/utils/util.ts
index db7016e88..847449236 100644
--- a/common/src/utils/util.ts
+++ b/common/src/utils/util.ts
@@ -322,6 +322,16 @@ export function CreateSongMap(songs: Array(charts: Array>) {
+ const chartMap = new Map>();
+
+ 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.
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index b9b9f6e98..33ec68df1 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -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'}
diff --git a/server/src/lib/game-specific/custom-bms-tables.ts b/server/src/lib/game-specific/custom-bms-tables.ts
index f9a1ea170..b40e25166 100644
--- a/server/src/lib/game-specific/custom-bms-tables.ts
+++ b/server/src/lib/game-specific/custom-bms-tables.ts
@@ -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 = [
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 = [
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 = [
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);
diff --git a/server/src/lib/score-import/import-types/common/eamusement-iidx-csv/parser.ts b/server/src/lib/score-import/import-types/common/eamusement-iidx-csv/parser.ts
index 4a3f5b5eb..1f9412db9 100644
--- a/server/src/lib/score-import/import-types/common/eamusement-iidx-csv/parser.ts
+++ b/server/src/lib/score-import/import-types/common/eamusement-iidx-csv/parser.ts
@@ -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 = {
- "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}.`);
diff --git a/server/src/server/router/api/v1/games/@gameSpecificRoutes/bms/router.ts b/server/src/server/router/api/v1/games/@gameSpecificRoutes/bms/router.ts
index 8e281857a..05b05b7f2 100644
--- a/server/src/server/router/api/v1/games/@gameSpecificRoutes/bms/router.ts
+++ b/server/src/server/router/api/v1/games/@gameSpecificRoutes/bms/router.ts
@@ -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
+ > = [];
+
+ 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.
*
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
new file mode 100644
index 000000000..7d7d11926
--- /dev/null
+++ b/server/src/server/router/api/v1/users/_userID/games/@gameSpecificRoutes/iidx/router.ts
@@ -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>;
+ 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();
+
+ 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;
diff --git a/server/src/server/router/api/v1/users/_userID/games/@gameSpecificRoutes/router.ts b/server/src/server/router/api/v1/users/_userID/games/@gameSpecificRoutes/router.ts
index f9f63fd48..d1081d5ed 100644
--- a/server/src/server/router/api/v1/users/_userID/games/@gameSpecificRoutes/router.ts
+++ b/server/src/server/router/api/v1/users/_userID/games/@gameSpecificRoutes/router.ts
@@ -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;
diff --git a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/router.ts b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/router.ts
index c75ba1d72..750a0ce47 100644
--- a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/router.ts
+++ b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/router.ts
@@ -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);