From 2114b43664fb0a6ec28eba9a9ce681a00a883675 Mon Sep 17 00:00:00 2001 From: zkldi <20380519+zkldi@users.noreply.github.com> Date: Mon, 19 Dec 2022 16:42:25 +0000 Subject: [PATCH] feat: insights infrastructure + jubility as poc --- .../games/_game/_playtype/ScoresPage.tsx | 6 +- .../_game/_playtype/insights/InsightsPage.tsx | 80 ++++++++++++ .../games/_game/_playtype/tools/ToolsPage.tsx | 2 +- client/src/app/routes/UserRoutes.tsx | 4 + .../tools-insights/ToolsAndInsights.tsx | 67 ++++++++++ .../insights/JubilityBreakdownInsight.tsx | 123 ++++++++++++++++++ .../tools/BMSCustomTablesTool.tsx | 4 +- .../tools/IIDXEamusementExportTool.tsx | 4 +- .../tools/IIDXPlaylistsTool.tsx | 4 +- client/src/components/tools/Tools.tsx | 37 ------ client/src/components/user/UGPTHeader.tsx | 18 ++- client/src/types/ugpt.ts | 2 +- .../framework/user-game-stats/rating.ts | 18 ++- .../@gameSpecificRoutes/jubeat/router.ts | 35 +++++ .../games/@gameSpecificRoutes/router.ts | 2 + 15 files changed, 348 insertions(+), 58 deletions(-) create mode 100644 client/src/app/pages/dashboard/users/games/_game/_playtype/insights/InsightsPage.tsx create mode 100644 client/src/components/tools-insights/ToolsAndInsights.tsx create mode 100644 client/src/components/tools-insights/insights/JubilityBreakdownInsight.tsx rename client/src/components/{ => tools-insights}/tools/BMSCustomTablesTool.tsx (94%) rename client/src/components/{ => tools-insights}/tools/IIDXEamusementExportTool.tsx (94%) rename client/src/components/{ => tools-insights}/tools/IIDXPlaylistsTool.tsx (95%) delete mode 100644 client/src/components/tools/Tools.tsx create mode 100644 server/src/server/router/api/v1/users/_userID/games/@gameSpecificRoutes/jubeat/router.ts diff --git a/client/src/app/pages/dashboard/users/games/_game/_playtype/ScoresPage.tsx b/client/src/app/pages/dashboard/users/games/_game/_playtype/ScoresPage.tsx index 4ccd21aac..ce0c66ec5 100644 --- a/client/src/app/pages/dashboard/users/games/_game/_playtype/ScoresPage.tsx +++ b/client/src/app/pages/dashboard/users/games/_game/_playtype/ScoresPage.tsx @@ -144,9 +144,9 @@ function AlgSelector({ function useFetchPBs(url: string, reqUser: UserDocument) { const { data, error } = useQuery(url, async () => { const res = await APIFetchV1<{ - pbs: PBScoreDocument<"iidx:SP">[]; - charts: ChartDocument<"iidx:SP">[]; - songs: SongDocument<"iidx">[]; + pbs: PBScoreDocument[]; + charts: ChartDocument[]; + songs: SongDocument[]; }>(url); if (!res.success) { diff --git a/client/src/app/pages/dashboard/users/games/_game/_playtype/insights/InsightsPage.tsx b/client/src/app/pages/dashboard/users/games/_game/_playtype/insights/InsightsPage.tsx new file mode 100644 index 000000000..2d542ef16 --- /dev/null +++ b/client/src/app/pages/dashboard/users/games/_game/_playtype/insights/InsightsPage.tsx @@ -0,0 +1,80 @@ +import useSetSubheader from "components/layout/header/useSetSubheader"; +import Card from "components/layout/page/Card"; +import { GetGPTInsights } from "components/tools-insights/ToolsAndInsights"; +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 InsightsPage({ reqUser, game, playtype }: UGPT) { + const gameConfig = GetGameConfig(game); + useSetSubheader( + ["Users", reqUser.username, "Games", gameConfig.name, playtype, "Insights"], + [reqUser, game, playtype], + `${reqUser.username}'s ${FormatGame(game, playtype)} Insights` + ); + + const insights = GetGPTInsights(game, playtype); + + if (insights.length === 0) { + return ( +
+ {FormatGame(game, playtype)} has no insights. How'd you get here! +
+ ); + } + + return ( + + + {insights.map((tool) => ( + + + + < Back to all insights... + + } + > + {tool.description} + + + + {tool.component({ reqUser, game, playtype })} + + ))} + + + {insights.map((tool) => ( + + + + View + + + } + > + {tool.description} + + + ))} + + + + ); +} 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 index db0a0ab17..db29333a6 100644 --- 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 @@ -1,6 +1,6 @@ import useSetSubheader from "components/layout/header/useSetSubheader"; import Card from "components/layout/page/Card"; -import { GetGPTTools } from "components/tools/Tools"; +import { GetGPTTools } from "components/tools-insights/ToolsAndInsights"; import Divider from "components/util/Divider"; import LinkButton from "components/util/LinkButton"; import React from "react"; diff --git a/client/src/app/routes/UserRoutes.tsx b/client/src/app/routes/UserRoutes.tsx index 6db0c6657..c3005c20d 100644 --- a/client/src/app/routes/UserRoutes.tsx +++ b/client/src/app/routes/UserRoutes.tsx @@ -32,6 +32,7 @@ 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 InsightsPage from "app/pages/dashboard/users/games/_game/_playtype/insights/InsightsPage"; import ScoresPage from "../pages/dashboard/users/games/_game/_playtype/ScoresPage"; import UserPage from "../pages/dashboard/users/UserPage"; @@ -267,6 +268,9 @@ function UserGamePlaytypeRoutes({ reqUser, game }: { reqUser: UserDocument; game + + + diff --git a/client/src/components/tools-insights/ToolsAndInsights.tsx b/client/src/components/tools-insights/ToolsAndInsights.tsx new file mode 100644 index 000000000..9457e5a1d --- /dev/null +++ b/client/src/components/tools-insights/ToolsAndInsights.tsx @@ -0,0 +1,67 @@ +import { Game, GetIDString, IDStrings, Playtype } from "tachi-common"; +import { GPTToolOrInsight } from "types/ugpt"; +import { BMSCustomTablesTool } from "./tools/BMSCustomTablesTool"; +import { IIDXEamusementExportTool } from "./tools/IIDXEamusementExportTool"; +import { IIDXPlaylistsTool } from "./tools/IIDXPlaylistsTool"; +import { JubilityBreakdownInsight } from "./insights/JubilityBreakdownInsight"; + +// 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, IIDXPlaylistsTool], + "iidx:SP": [IIDXEamusementExportTool, IIDXPlaylistsTool], + "itg:Stamina": [], + "jubeat:Single": [], + "museca:Single": [], + "pms:Controller": [], + "pms:Keyboard": [], + "popn:9B": [], + "sdvx:Single": [], + "usc:Controller": [], + "usc:Keyboard": [], + "wacca:Single": [], +}; + +// An insight is a page for a unique kind of data viz that tachi (generically) does not +// pick up. Good examples of this would be something like viewing what goes into your +// jubility. +const GPT_INSIGHTS: Record> = { + "bms:7K": [], + "bms:14K": [], + "chunithm:Single": [], + "gitadora:Dora": [], + "gitadora:Gita": [], + "iidx:DP": [], + "iidx:SP": [], + "itg:Stamina": [], + "jubeat:Single": [JubilityBreakdownInsight], + "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]; +} + +export function GetGPTInsights(game: Game, playtype: Playtype) { + const idString = GetIDString(game, playtype); + + return GPT_INSIGHTS[idString]; +} diff --git a/client/src/components/tools-insights/insights/JubilityBreakdownInsight.tsx b/client/src/components/tools-insights/insights/JubilityBreakdownInsight.tsx new file mode 100644 index 000000000..7642c27aa --- /dev/null +++ b/client/src/components/tools-insights/insights/JubilityBreakdownInsight.tsx @@ -0,0 +1,123 @@ +import { CreateChartMap } from "util/data"; +import { NumericSOV } from "util/sorts"; +import Card from "components/layout/page/Card"; +import PBTable from "components/tables/pbs/PBTable"; +import ApiError from "components/util/ApiError"; +import Divider from "components/util/Divider"; +import Loading from "components/util/Loading"; +import useApiQuery from "components/util/query/useApiQuery"; +import usePreferredRanking from "components/util/usePreferredRanking"; +import React from "react"; +import { Col, Row } from "react-bootstrap"; +import { ChartDocument, CreateSongMap, PBScoreDocument, SongDocument } from "tachi-common"; +import { UGPT } from "types/react"; +import { PBDataset } from "types/tables"; +import { GPTToolOrInsight } from "types/ugpt"; + +function Component({ game, playtype, reqUser }: UGPT) { + const { data, error } = useApiQuery<{ + songs: Array; + charts: Array; + pickUp: Array; + other: Array; + }>(`/users/${reqUser.id}/games/${game}/${playtype}/jubility`); + + const preferredRanking = usePreferredRanking(); + + if (error) { + return ; + } + + if (!data) { + return ; + } + + const songMap = CreateSongMap(data.songs); + const chartMap = CreateChartMap(data.charts); + + const pickUpDataset: PBDataset = []; + const normalDataset: PBDataset = []; + + const sortedJubilities = [...data.pickUp, ...data.other] + .map((e) => e.calculatedData.jubility) + .sort(NumericSOV((x) => x ?? -Infinity, true)); + + for (const pb of data.pickUp) { + const song = songMap.get(pb.songID); + const chart = chartMap.get(pb.chartID); + + if (!song) { + continue; + } + + if (!chart) { + continue; + } + + pickUpDataset.push({ + ...pb, + __related: { + chart, + song, + index: sortedJubilities.indexOf(pb.calculatedData.jubility), + }, + }); + } + + for (const pb of data.other) { + const song = songMap.get(pb.songID); + const chart = chartMap.get(pb.chartID); + + if (!song) { + continue; + } + + if (!chart) { + continue; + } + + normalDataset.push({ + ...pb, + __related: { + chart, + song, + index: sortedJubilities.indexOf(pb.calculatedData.jubility), + }, + }); + } + + return ( + + + + + + + + + + + + ); +} + +export const JubilityBreakdownInsight: GPTToolOrInsight = { + name: "Jubility Breakdown", + urlPath: "jubility", + description: `See what PBs are going into profile jubility!`, + component: Component, +}; diff --git a/client/src/components/tools/BMSCustomTablesTool.tsx b/client/src/components/tools-insights/tools/BMSCustomTablesTool.tsx similarity index 94% rename from client/src/components/tools/BMSCustomTablesTool.tsx rename to client/src/components/tools-insights/tools/BMSCustomTablesTool.tsx index ed03c899e..f9ae732ce 100644 --- a/client/src/components/tools/BMSCustomTablesTool.tsx +++ b/client/src/components/tools-insights/tools/BMSCustomTablesTool.tsx @@ -8,7 +8,7 @@ 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 { GPTToolOrInsight } from "types/ugpt"; import Icon from "components/util/Icon"; import Divider from "components/util/Divider"; @@ -66,7 +66,7 @@ function Component({ game, playtype, reqUser }: UGPT) { ); } -export const BMSCustomTablesTool: GPTTool = { +export const BMSCustomTablesTool: GPTToolOrInsight = { name: `${TachiConfig.name} BMS Tables`, urlPath: "custom-tables", description: `${TachiConfig.name} has its own BMS tables that you can use in-game!`, diff --git a/client/src/components/tools/IIDXEamusementExportTool.tsx b/client/src/components/tools-insights/tools/IIDXEamusementExportTool.tsx similarity index 94% rename from client/src/components/tools/IIDXEamusementExportTool.tsx rename to client/src/components/tools-insights/tools/IIDXEamusementExportTool.tsx index 6f952d511..38361564e 100644 --- a/client/src/components/tools/IIDXEamusementExportTool.tsx +++ b/client/src/components/tools-insights/tools/IIDXEamusementExportTool.tsx @@ -8,7 +8,7 @@ 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"; +import { GPTToolOrInsight } from "types/ugpt"; function Component({ game, playtype, reqUser }: UGPT) { const { data, error } = useApiQuery( @@ -60,7 +60,7 @@ function Component({ game, playtype, reqUser }: UGPT) { ); } -export const IIDXEamusementExportTool: GPTTool = { +export const IIDXEamusementExportTool: GPTToolOrInsight = { name: `e-amusement CSV Export`, urlPath: "eam-csv-export", description: `Export your ${TachiConfig.name} scores into e-amusement format.`, diff --git a/client/src/components/tools/IIDXPlaylistsTool.tsx b/client/src/components/tools-insights/tools/IIDXPlaylistsTool.tsx similarity index 95% rename from client/src/components/tools/IIDXPlaylistsTool.tsx rename to client/src/components/tools-insights/tools/IIDXPlaylistsTool.tsx index c386b965a..bf71786e1 100644 --- a/client/src/components/tools/IIDXPlaylistsTool.tsx +++ b/client/src/components/tools-insights/tools/IIDXPlaylistsTool.tsx @@ -8,7 +8,7 @@ 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 { GPTToolOrInsight } from "types/ugpt"; import Icon from "components/util/Icon"; import Divider from "components/util/Divider"; @@ -76,7 +76,7 @@ function Component({ game, playtype, reqUser }: UGPT) { ); } -export const IIDXPlaylistsTool: GPTTool = { +export const IIDXPlaylistsTool: GPTToolOrInsight = { name: `${TachiConfig.name} IIDX Playlists`, urlPath: "custom-tables", description: `${TachiConfig.name} has its own IIDX playlists that you can use in-game via playlister!`, diff --git a/client/src/components/tools/Tools.tsx b/client/src/components/tools/Tools.tsx deleted file mode 100644 index 599c6b2e7..000000000 --- a/client/src/components/tools/Tools.tsx +++ /dev/null @@ -1,37 +0,0 @@ -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. -// 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, IIDXPlaylistsTool], - "iidx:SP": [IIDXEamusementExportTool, IIDXPlaylistsTool], - "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 255d1509c..658d6111a 100644 --- a/client/src/components/user/UGPTHeader.tsx +++ b/client/src/components/user/UGPTHeader.tsx @@ -10,11 +10,10 @@ import React, { useContext } from "react"; import { Button } from "react-bootstrap"; import { Game, Playtype, UserDocument } from "tachi-common"; import { UGPTStatsReturn } from "types/api-returns"; -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 { GetGPTInsights, GetGPTTools } from "components/tools-insights/ToolsAndInsights"; import ProfileBadges from "./ProfileBadges"; import ProfilePicture from "./ProfilePicture"; import RankingData from "./UGPTRankingData"; @@ -201,10 +200,21 @@ export function UGPTBottomNav({ Sessions , + ]; + + if (GetGPTInsights(game, playtype).length > 0) { + navItems.push( + + Insights + + ); + } + + navItems.push( Leaderboard - , - ]; + + ); if (isRequestedUser) { navItems.push( diff --git a/client/src/types/ugpt.ts b/client/src/types/ugpt.ts index b41602d1e..85bbfc247 100644 --- a/client/src/types/ugpt.ts +++ b/client/src/types/ugpt.ts @@ -1,7 +1,7 @@ import React from "react"; import { UGPT } from "./react"; -export interface GPTTool { +export interface GPTToolOrInsight { urlPath: string; name: string; description: React.ReactChild; diff --git a/server/src/lib/score-import/framework/user-game-stats/rating.ts b/server/src/lib/score-import/framework/user-game-stats/rating.ts index b2f1b2b44..94491bac5 100644 --- a/server/src/lib/score-import/framework/user-game-stats/rating.ts +++ b/server/src/lib/score-import/framework/user-game-stats/rating.ts @@ -390,12 +390,7 @@ async function GetBestJubilityOnSongs( const CURRENT_JUBEAT_HOT_VERSION: GPTSupportedVersions["jubeat:Single"] = "festo"; -async function CalculateJubility( - game: Game, - playtype: Playtype, - userID: integer, - _logger: KtLogger -): Promise { +export async function GetPBsForJubility(userID: integer) { const hotSongs = await db.songs.jubeat.find( { "data.displayVersion": CURRENT_JUBEAT_HOT_VERSION }, { projection: { id: 1 } } @@ -415,6 +410,17 @@ async function CalculateJubility( GetBestJubilityOnSongs(coldSongIDs, userID, "jubeat", "Single", 30), ]); + return { bestHotScores, bestScores }; +} + +async function CalculateJubility( + game: Game, + playtype: Playtype, + userID: integer, + _logger: KtLogger +): Promise { + const { bestHotScores, bestScores } = await GetPBsForJubility(userID); + let jubility = 0; jubility = jubility + bestHotScores.reduce((a, e) => a + (e.calculatedData.jubility ?? 0), 0); diff --git a/server/src/server/router/api/v1/users/_userID/games/@gameSpecificRoutes/jubeat/router.ts b/server/src/server/router/api/v1/users/_userID/games/@gameSpecificRoutes/jubeat/router.ts new file mode 100644 index 000000000..97c2cd005 --- /dev/null +++ b/server/src/server/router/api/v1/users/_userID/games/@gameSpecificRoutes/jubeat/router.ts @@ -0,0 +1,35 @@ +import { Router } from "express"; +import { GetPBsForJubility } from "lib/score-import/framework/user-game-stats/rating"; +import { GetRelevantSongsAndCharts } from "utils/db"; +import { GetUser } from "utils/req-tachi-data"; + +const router: Router = Router({ mergeParams: true }); + +/** + * Retrieve the PBs that went into this users jubility ranking. + * + * @name GET /api/v1/users/:userID/games/jubeat/Single/jubility + */ +router.get("/Single/jubility", async (req, res) => { + const user = GetUser(req); + + const { bestHotScores, bestScores } = await GetPBsForJubility(user.id); + + const { songs, charts } = await GetRelevantSongsAndCharts( + [...bestHotScores, ...bestScores], + "jubeat" + ); + + return res.status(200).json({ + success: true, + description: `Retrieved scores that went into this users jubility.`, + body: { + songs, + charts, + pickUp: bestHotScores, + other: bestScores, + }, + }); +}); + +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 d1081d5ed..4da9a2ced 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 @@ -4,11 +4,13 @@ import bmsRouter from "./bms/router"; import iidxRouter from "./iidx/router"; +import jubeatRouter from "./jubeat/router"; import { Router } from "express"; const router: Router = Router({ mergeParams: true }); router.use("/bms", bmsRouter); router.use("/iidx", iidxRouter); +router.use("/jubeat", jubeatRouter); export default router;