* checkpoint

* server compiles again

* fmt

* checkpoint

* last one out, get the lights

* euston station

* like, mostly good. I'm under the ocean right now.

* fix: some nonsense AI stuff

* fix: new eslint rules, and cleanup imports across the board

* feat: folder slug rewrite, email logic change, too

* fix: register must use txn not db

* checkpoint
This commit is contained in:
zk
2026-04-20 23:11:16 +01:00
committed by GitHub
parent edda03e916
commit 2bc4c3a628
1073 changed files with 52259 additions and 56015 deletions
+4 -3
View File
@@ -95,9 +95,10 @@ export function MakeActionGuts({
const inputParsed = inputSchema.safeParse(input);
if (!inputParsed.success) {
throw new Error(
`Action ${kind} received invalid input: ${inputParsed.error.message}`,
);
const msg = inputParsed.error.issues
.map((i) => `${i.path.join(".")}: ${i.message}`)
.join("; ");
throw new ExpectedErr(400, msg || `Action ${kind} received invalid input.`);
}
retval = await actionBodyFn(taker, input);
+1 -3
View File
@@ -7,13 +7,11 @@
"license": "MIT",
"scripts": {
"build": "tsgo -b",
"start": "pnpm build && node -r ts-node/register -r tsconfig-paths/register js/main.js",
"start-no-build": "node -r ts-node/register -r tsconfig-paths/register js/main.js",
"lint": "eslint .",
"lint-fix": "eslint . --fix",
"typecheck": "tsgo --noEmit",
"test": "vitest",
"bench": "vitest bench --passWithNoTests"
"bench": "vitest bench"
},
"devDependencies": {
"@types/express": "catalog:",
+1 -1
View File
@@ -21,7 +21,7 @@ export const ActionSignatures = {
session_count: z.number().int(),
error_count: z.number().int(),
user_id: z.number().int(),
game: z.string(),
games: z.string().array(),
}),
},
} satisfies Record<string, ActionSignature>;
+9 -10
View File
@@ -1,4 +1,4 @@
import type { MONGO_ImportDocument } from "tachi-common";
import type { ImportDocument } from "tachi-common";
import db from "#services/pg/db";
import { createTestAccount } from "#test-utils/db";
@@ -13,11 +13,12 @@ vi.mock("../utils/api-requests");
const { PerformScoreImport } = await import("../utils/api-requests");
function makeImportDoc(overrides: Partial<MONGO_ImportDocument> = {}): MONGO_ImportDocument {
function makeImportDoc(overrides: Partial<ImportDocument> = {}): ImportDocument {
return {
importID: "import-abc-123",
userID: 1,
game: "iidx-sp",
gameGroup: "iidx",
games: ["iidx-sp"],
scoreIDs: ["s1", "s2"],
createdSessions: [{ sessionID: "sess1", type: "Created" }],
errors: [],
@@ -25,7 +26,7 @@ function makeImportDoc(overrides: Partial<MONGO_ImportDocument> = {}): MONGO_Imp
goalProgress: [],
questProgress: [],
...overrides,
} as unknown as MONGO_ImportDocument;
} as unknown as ImportDocument;
}
describe("ACTION_Sync", () => {
@@ -50,7 +51,7 @@ describe("ACTION_Sync", () => {
session_count: 1,
error_count: 0,
user_id: 1,
game: "iidx-sp",
games: ["iidx-sp"],
});
});
@@ -82,7 +83,7 @@ describe("ACTION_Sync", () => {
it("throws ExpectedErr when PerformScoreImport returns an error string", async () => {
vi.mocked(PerformScoreImport).mockResolvedValue(
"Your API key has expired." as unknown as MONGO_ImportDocument,
"Your API key has expired." as unknown as ImportDocument,
);
await expect(ACTION_Sync(taker, syncInput)).rejects.toMatchObject({
@@ -93,7 +94,7 @@ describe("ACTION_Sync", () => {
it("the ExpectedErr thrown on failure is an ExpectedErr instance", async () => {
vi.mocked(PerformScoreImport).mockResolvedValue(
"Import failed." as unknown as MONGO_ImportDocument,
"Import failed." as unknown as ImportDocument,
);
await expect(ACTION_Sync(taker, syncInput)).rejects.toBeInstanceOf(ExpectedErr);
@@ -120,9 +121,7 @@ describe("ACTION_Sync", () => {
});
it("writes a BAD action row when PerformScoreImport returns an error string", async () => {
vi.mocked(PerformScoreImport).mockResolvedValue(
"Bad import." as unknown as MONGO_ImportDocument,
);
vi.mocked(PerformScoreImport).mockResolvedValue("Bad import." as unknown as ImportDocument);
await expect(ACTION_Sync(taker, syncInput)).rejects.toBeInstanceOf(ExpectedErr);
+1 -1
View File
@@ -20,7 +20,7 @@ export const ACTION_Sync = MakeAction(
session_count: importDoc.createdSessions.length,
error_count: importDoc.errors.length,
user_id: importDoc.userID,
game: importDoc.game,
games: importDoc.games,
};
},
);
+3 -3
View File
@@ -1,4 +1,4 @@
import type { GameGroup, integer, TachiServerCoreConfig } from "tachi-common";
import type { GameGroup, integer, TachiServerCoreConfig, V3Game } from "tachi-common";
import { log } from "bliss/log";
import { config } from "dotenv";
@@ -49,7 +49,7 @@ export interface ProcessEnvironment {
OAUTH_CLIENT_SECRET: string;
DISCORD_TOKEN: string;
DISCORD_SERVER_ID: string;
DISCORD_GAME_CHANNELS: Partial<Record<GameGroup, string>>;
DISCORD_GAME_CHANNELS: Partial<Record<V3Game, string>>;
DISCORD_ADMIN_USERS: string[];
DISCORD_APPROVED_ROLE: string | undefined;
DISCORD_LIMBO_CHANNEL: string | undefined;
@@ -135,7 +135,7 @@ export const ServerConfig = await GetServerConfig();
// General warnings for config misuse.
// This warns people if their parent server supports games that they aren't acknowledging.
for (const game of ServerConfig.GAMES) {
for (const game of ServerConfig.GAME_GROUPS) {
if (!Object.prototype.hasOwnProperty.call(Env.DISCORD_GAME_CHANNELS, game)) {
log.warn(
`${ServerConfig.NAME} declares support for ${game}, but no channel is mapped to it. Set DISCORD_GAME_CHANNELS to include it.`,
+3 -3
View File
@@ -1,4 +1,4 @@
import type { MONGO_APITokenDocument, MONGO_UserDocument, WebhookEvents } from "tachi-common";
import type { APITokenDocument, UserDocument, WebhookEvents } from "tachi-common";
import { log } from "#utils/log";
import { HandleQuestAchievedV1 } from "#webhook-handlers/quest-achieved";
@@ -58,7 +58,7 @@ app.get("/oauth/callback", async (req, res) => {
return res.status(400).send("Bad Request.");
}
const tokenRes = await TachiServerV1Request<MONGO_APITokenDocument>(
const tokenRes = await TachiServerV1Request<APITokenDocument>(
RequestTypes.POST,
"/oauth/token",
null,
@@ -84,7 +84,7 @@ app.get("/oauth/callback", async (req, res) => {
const discordID = req.query.context;
const apiToken = tokenRes.body.token!;
const whoamiRes = await TachiServerV1Get<MONGO_UserDocument>("/users/me", apiToken);
const whoamiRes = await TachiServerV1Get<UserDocument>("/users/me", apiToken);
if (!whoamiRes.success) {
log.error({ discordID }, "Failed to request user with token we just got?");
@@ -66,7 +66,7 @@ const command: SlashCommand = {
.addField("Errors", result.error_count.toString(), true)
.addField(
"Your Profile",
`${Env.TACHI_SERVER_LOCATION}/u/${result.user_id}/games/${result.game}`,
`${Env.TACHI_SERVER_LOCATION}/u/${result.user_id}/games/${result.games[0]}`,
);
} catch (e) {
if (ExpectedErr.is(e)) {
+28 -37
View File
@@ -1,19 +1,18 @@
import type { CommandInteraction } from "discord.js";
import type {
GameGroup,
integer,
MONGO_ChartDocument,
MONGO_GoalDocument,
MONGO_ImportDocument,
MONGO_PBScoreDocument,
MONGO_QuestDocument,
MONGO_SongDocument,
MONGO_UserDocument,
Playtype,
} from "tachi-common";
import { Env } from "#config";
import { log } from "#utils/log";
import {
type ChartDocument,
type GoalDocument,
type ImportDocument,
type integer,
type PBScoreDocument,
type QuestDocument,
type SongDocument,
type UserDocument,
type V3Game,
} from "tachi-common";
import type { ImportDeferred, ImportPollStatus, UGPTStats } from "./return-types";
@@ -21,7 +20,7 @@ import { RequestTypes, TachiServerV1Get, TachiServerV1Request } from "./fetch-ta
import { Sleep } from "./misc";
export async function GetUserInfo(userID: string | integer) {
const res = await TachiServerV1Get<MONGO_UserDocument>(`/users/${userID}`, null);
const res = await TachiServerV1Get<UserDocument>(`/users/${userID}`, null);
if (!res.success) {
throw new Error(`Failed to fetch user with userID ${userID}.`);
@@ -30,22 +29,19 @@ export async function GetUserInfo(userID: string | integer) {
return res.body;
}
export async function GetUGPTStats(userID: string | integer, game: GameGroup, playtype: Playtype) {
const res = await TachiServerV1Get<UGPTStats>(
`/users/${userID}/games/${game}/${playtype}`,
null,
);
export async function GetUGPTStats(userID: string | integer, game: V3Game) {
const res = await TachiServerV1Get<UGPTStats>(`/users/${userID}/games/${game}`, null);
if (!res.success) {
throw new Error(`Failed to fetch UGPT stats for userID ${userID}, ${game}, ${playtype}.`);
throw new Error(`Failed to fetch UGPT stats for userID ${userID}, ${game}.`);
}
return res.body;
}
export async function GetGoalWithID(goalID: string, game: GameGroup, playtype: Playtype) {
const res = await TachiServerV1Get<{ goal: MONGO_GoalDocument }>(
`/games/${game}/${playtype}/targets/goals/${goalID}`,
export async function GetGoalWithID(goalID: string, game: V3Game) {
const res = await TachiServerV1Get<{ goal: GoalDocument }>(
`/games/${game}/targets/goals/${goalID}`,
null,
);
@@ -56,9 +52,9 @@ export async function GetGoalWithID(goalID: string, game: GameGroup, playtype: P
return res.body.goal;
}
export async function GetQuestWithID(questID: string, game: GameGroup, playtype: Playtype) {
const res = await TachiServerV1Get<{ quest: MONGO_QuestDocument }>(
`/games/${game}/${playtype}/targets/quests/${questID}`,
export async function GetQuestWithID(questID: string, game: V3Game) {
const res = await TachiServerV1Get<{ quest: QuestDocument }>(
`/games/${game}/targets/quests/${questID}`,
null,
);
@@ -69,14 +65,9 @@ export async function GetQuestWithID(questID: string, game: GameGroup, playtype:
return res.body.quest;
}
export async function GetChartInfoForUser(
userID: string | integer,
chartID: string,
game: GameGroup,
playtype: Playtype,
) {
const res = await TachiServerV1Get<{ chart: MONGO_ChartDocument; song: MONGO_SongDocument }>(
`/games/${game}/${playtype}/charts/${chartID}`,
export async function GetChartInfoForUser(userID: string | integer, chartID: string, game: V3Game) {
const res = await TachiServerV1Get<{ chart: ChartDocument; song: SongDocument }>(
`/games/${game}/charts/${chartID}`,
null,
);
@@ -84,8 +75,8 @@ export async function GetChartInfoForUser(
throw new Error(`Failed to fetch song/chart with chartID ${chartID}.`);
}
const pbRes = await TachiServerV1Get<{ chart: MONGO_ChartDocument; pb: MONGO_PBScoreDocument }>(
`/users/${userID}/games/${game}/${playtype}/pbs/${chartID}`,
const pbRes = await TachiServerV1Get<{ chart: ChartDocument; pb: PBScoreDocument }>(
`/users/${userID}/games/${game}/pbs/${chartID}`,
null,
);
@@ -104,7 +95,7 @@ export async function PerformScoreImport(
body: Record<string, unknown>,
interaction?: CommandInteraction,
) {
const initRes = await TachiServerV1Request<ImportDeferred | MONGO_ImportDocument>(
const initRes = await TachiServerV1Request<ImportDeferred | ImportDocument>(
RequestTypes.POST,
url,
authToken,
@@ -122,7 +113,7 @@ export async function PerformScoreImport(
// this server does not defer imports to a scorequeue
if (initRes.statusCode === 200) {
const result = initRes.body as MONGO_ImportDocument;
const result = initRes.body as ImportDocument;
return result;
} else if (initRes.statusCode === 202) {
+10 -8
View File
@@ -1,23 +1,21 @@
import type { CommandInteraction } from "discord.js";
import type { PrivDiscordUserMap } from "tachi-db";
import { type GameGroup, type MONGO_UserDocument, type Playtype } from "tachi-common";
import { IsValidGame, type UserDocument, type V3Game } from "tachi-common";
import type { Emittable } from "../slash-commands/types";
import { GetUserInfo } from "./api-requests";
import { ParseGPT } from "./misc";
/**
* Utility parser for getting the game, playtype and requesting user, since this is
* Utility parser for getting the game and requesting user, since this is
* a common pattern in the bot.
*/
export async function GetGPTAndUser(
export async function GetGameAndUser(
interaction: CommandInteraction,
requestingUser: PrivDiscordUserMap,
): Promise<
| { content: { game: GameGroup; playtype: Playtype; userDoc: MONGO_UserDocument }; error: null }
| { error: Emittable }
{ content: { game: V3Game; userDoc: UserDocument }; error: null } | { error: Emittable }
> {
const userID = interaction.options.getString("other_user") ?? requestingUser.user_id.toString();
@@ -33,7 +31,11 @@ export async function GetGPTAndUser(
return { error: `This user does not exist.` };
}
const { game, playtype } = ParseGPT(interaction.options.getString("game", true));
const game = interaction.options.getString("game", true);
return { error: null, content: { userDoc, game, playtype } };
if (!IsValidGame(game)) {
return { error: `Invalid game: ${game}.` };
}
return { error: null, content: { userDoc, game } };
}
+4 -4
View File
@@ -1,4 +1,4 @@
import type { integer, MONGO_ImportDocument, MONGO_UserDocument } from "tachi-common";
import type { ImportDocument, integer, UserDocument } from "tachi-common";
import { MessageEmbed } from "discord.js";
@@ -18,7 +18,7 @@ export function CreateEmbed(userID?: integer) {
return embed;
}
export function CreateImportEmbed(importDoc: MONGO_ImportDocument) {
export function CreateImportEmbed(importDoc: ImportDocument) {
return CreateEmbed()
.setTitle(
`Imported ${importDoc.scoreIDs.length} ${Pluralise(
@@ -30,11 +30,11 @@ export function CreateImportEmbed(importDoc: MONGO_ImportDocument) {
.addField("Errors", importDoc.errors.length.toString(), true)
.addField(
"Your Profile",
`${Env.TACHI_SERVER_LOCATION}/u/${importDoc.userID}/games/${importDoc.game}`,
`${Env.TACHI_SERVER_LOCATION}/u/${importDoc.userID}/games/${importDoc.games[0]}`,
);
}
export function CreateUserEmbed(userDoc: MONGO_UserDocument) {
export function CreateUserEmbed(userDoc: UserDocument) {
return CreateEmbed()
.setTitle(`${userDoc.username} (ID: ${userDoc.id})`)
.setThumbnail(PrependTachiUrl(`/users/${userDoc.id}/pfp`))
+10 -43
View File
@@ -4,15 +4,11 @@ import type { ClassInfo } from "tachi-common/types/game-config-utils";
import _ from "lodash";
import { DateTime } from "luxon";
import {
type ChartDocument,
type Classes,
type GameGroup,
type GameGroupConfig,
GetGameGroupConfig,
GetGamePTConfig,
type GPTString,
GetGameConfig,
type integer,
type MONGO_ChartDocument,
type Playtype,
type V3Game,
} from "tachi-common";
import { Env } from "../config";
@@ -40,22 +36,6 @@ export function Entries<K extends string, V>(rec: Partial<Record<K, V>>): Array<
return Object.entries(rec) as Array<[K, V]>;
}
export function ParseGPT(str: string) {
const spl = str.split(":");
const game = spl[0] as GameGroup;
const playtype = spl[1] as Playtype;
// this is an interesting way of game checking noah
const gameConfig = GetGameGroupConfig(game) as GameGroupConfig | undefined;
if (!gameConfig?.playtypes.includes(playtype)) {
throw new Error(`Invalid GPT Combination '${str}'.`);
}
return { game, playtype };
}
export function UppercaseFirst(str: string) {
if (!str[0]) {
return "";
@@ -72,24 +52,17 @@ export function FormatDate(ms: number) {
return DateTime.fromMillis(ms).toLocaleString(DateTime.DATE_HUGE);
}
export function FormatClass(
game: GameGroup,
playtype: Playtype,
classSet: Classes[GPTString],
classValue: string,
) {
const gptConfig = GetGamePTConfig(game, playtype);
export function FormatClass(game: V3Game, classSet: Classes[V3Game], classValue: string) {
const gameConfig = GetGameConfig(game);
// @ts-expect-error hacky access
const classInfo: ClassInfo = gptConfig.classes[classSet]?.values?.find?.(
const classInfo: ClassInfo = gameConfig.classes[classSet]?.values?.find?.(
(k) => k.id === classValue,
);
if (!classInfo) {
throw new Error(
`Couldn't find a class at index ${classValue} for ${game} ${playtype} ${classSet}?`,
);
throw new Error(`Couldn't find a class at index ${classValue} for ${game} ${classSet}?`);
}
return classInfo.display as string;
@@ -98,7 +71,7 @@ export function FormatClass(
/**
* Given a game, return the discord channel it's associated with.
*/
export function GetGameChannel(client: Client, game: GameGroup) {
export function GetGameChannel(client: Client, game: V3Game) {
const gameChannelID = Env.DISCORD_GAME_CHANNELS[game];
if (!gameChannelID) {
@@ -145,14 +118,8 @@ export function GetLimboChannel(client: Client) {
/**
* Given a chart and a game, return a link to the site for that chart.
*/
export function CreateChartLink(chart: MONGO_ChartDocument, game: GameGroup) {
if (chart.isPrimary) {
return `${Env.TACHI_SERVER_LOCATION}/games/${game}/${chart.playtype}/songs/${
chart.songID
}/${encodeURIComponent(chart.difficulty)}`;
}
return `${Env.TACHI_SERVER_LOCATION}/games/${game}/${chart.playtype}/songs/${chart.songID}/${chart.chartID}`;
export function CreateChartLink(chart: ChartDocument) {
return `${Env.TACHI_SERVER_LOCATION}/games/${chart.game}/charts/${chart.chartID}`;
}
export function ConvertInputIntoGenerousRegex(input: string) {
+9 -9
View File
@@ -1,25 +1,25 @@
import type { SlashCommandStringOption } from "@discordjs/builders";
import { FormatGameGroup, GetGameGroupConfig } from "tachi-common";
import { FormatGame, GetGameGroupConfig } from "tachi-common";
import { ServerConfig } from "../config";
/**
* Game Playtype options. Frequently used by things that might need
* Game options. Frequently used by things that might need
* game specific listening.
*/
const GPTChoices: Array<[string, string]> = [];
const GameChoices: Array<[string, string]> = [];
for (const game of ServerConfig.GAMES) {
const gameConfig = GetGameGroupConfig(game);
for (const gameGroup of ServerConfig.GAME_GROUPS) {
const gameGroupConfig = GetGameGroupConfig(gameGroup);
for (const playtype of gameConfig.playtypes) {
GPTChoices.push([FormatGameGroup(game, playtype), `${game}:${playtype}`]);
for (const game of gameGroupConfig.games) {
GameChoices.push([FormatGame(game), game]);
}
}
export const GPTOptions = (str: SlashCommandStringOption) =>
str.setName("game").setDescription("Pick the relevant game.").addChoices(GPTChoices);
export const GameOptions = (str: SlashCommandStringOption) =>
str.setName("game").setDescription("Pick the relevant game.").addChoices(GameChoices);
export function MakeRequired(fn: (str: SlashCommandStringOption) => SlashCommandStringOption) {
return (str: SlashCommandStringOption) => fn(str).setRequired(true);
+14 -14
View File
@@ -1,13 +1,13 @@
import type {
APIPermissions,
GPTString,
ChartDocument,
ImportDocument,
integer,
MONGO_ChartDocument,
MONGO_ImportDocument,
MONGO_ScoreDocument,
MONGO_SongDocument,
MONGO_UserGameStats,
ProfileRatingAlgorithms,
ScoreDocument,
SongDocument,
UserGameStats,
V3Game,
} from "tachi-common";
export interface ServerStatus {
@@ -25,7 +25,7 @@ export interface ImportDeferred {
export type ImportPollStatus =
| {
import: MONGO_ImportDocument;
import: ImportDocument;
importStatus: "completed";
}
| {
@@ -36,15 +36,15 @@ export type ImportPollStatus =
};
};
export interface UGPTStats<GPT extends GPTString = GPTString> {
gameStats: MONGO_UserGameStats;
firstScore: MONGO_ScoreDocument;
mostRecentScore: MONGO_ScoreDocument;
export interface UGPTStats<TGame extends V3Game = V3Game> {
gameStats: UserGameStats;
firstScore: ScoreDocument;
mostRecentScore: ScoreDocument;
totalScores: integer;
rankingData: Record<ProfileRatingAlgorithms[GPT], { outOf: integer; ranking: integer }>;
rankingData: Record<ProfileRatingAlgorithms[TGame], { outOf: integer; ranking: integer }>;
}
export interface ChartQueryReturns {
charts: Array<{ __playcount: integer } & MONGO_ChartDocument>;
songs: Array<MONGO_SongDocument>;
charts: Array<{ __playcount: integer } & ChartDocument>;
songs: Array<SongDocument>;
}
@@ -1,12 +1,10 @@
import { log } from "#utils/log";
import {
type Classes,
FormatGameGroup,
type GameGroup,
GetGamePTConfig,
type GPTString,
FormatGame,
GetGameConfig,
type integer,
type Playtype,
type V3Game,
type WebhookEventClassUpdateV1,
} from "tachi-common";
@@ -20,7 +18,7 @@ import { FormatClass, GetGameChannel } from "../utils/misc";
export async function HandleClassUpdateV1(
event: WebhookEventClassUpdateV1["content"],
): Promise<integer> {
const { game, playtype } = event;
const { game } = event;
let channel;
@@ -33,7 +31,7 @@ export async function HandleClassUpdateV1(
return 500;
}
if (!ShouldRenderUpdate(game, playtype, event.set, event.new)) {
if (!ShouldRenderUpdate(game, event.set, event.new)) {
log.info(
`Not rendering class update ${event.set}: ${event.old} -> ${event.new} (not relevant).`,
);
@@ -42,10 +40,10 @@ export async function HandleClassUpdateV1(
const userDoc = await GetUserInfo(event.userID);
const minimumNecessaryScores = GetMinimumScores(game, playtype, event.set);
const minimumNecessaryScores = GetMinimumScores(game, event.set);
if (minimumNecessaryScores !== null) {
const { totalScores } = await GetUGPTStats(userDoc.id, game, playtype);
const { totalScores } = await GetUGPTStats(userDoc.id, game);
// Do not render if the user hasn't hit the score cap.
if (totalScores < minimumNecessaryScores) {
@@ -56,19 +54,15 @@ export async function HandleClassUpdateV1(
}
}
const newClass = FormatClass(game, playtype, event.set, event.new);
const newClass = FormatClass(game, event.set, event.new);
const embed = CreateEmbed()
.setTitle(
`${userDoc.username} just achieved ${newClass} in ${FormatGameGroup(game, playtype)}!`,
)
.setURL(`${Env.TACHI_SERVER_LOCATION}/users/${userDoc.username}/games/${game}/${playtype}`)
.setTitle(`${userDoc.username} just achieved ${newClass} in ${FormatGame(game)}!`)
.setURL(`${Env.TACHI_SERVER_LOCATION}/u/${userDoc.username}/games/${game}`)
.setThumbnail(PrependTachiUrl(`/users/${userDoc.id}/pfp`));
if (event.old !== null) {
embed.setDescription(
`(This was raised from ${FormatClass(game, playtype, event.set, event.old)}.)`,
);
embed.setDescription(`(This was raised from ${FormatClass(game, event.set, event.old)}.)`);
}
await channel.send({ embeds: [embed] });
@@ -79,17 +73,12 @@ export async function HandleClassUpdateV1(
/**
* Returns Whether this class update is notable enough to be rendered or not.
*/
function ShouldRenderUpdate(
game: GameGroup,
playtype: Playtype,
classSet: Classes[GPTString],
classValue: string,
) {
const config = GetGamePTConfig(game, playtype);
function ShouldRenderUpdate(game: V3Game, classSet: Classes[V3Game], classValue: string) {
const config = GetGameConfig(game);
const classSpec = config.classes[classSet];
if (classSpec === undefined) {
log.error(`Invalid class ${classSet} for ${game} ${playtype}`);
log.error(`Invalid class ${classSet} for ${game}`);
return false;
}
@@ -103,28 +92,24 @@ function ShouldRenderUpdate(
const minimumId = ids.indexOf(classSpec.minimumRelevantValue);
if (currentId < 0) {
log.error(`Invalid classValue ${classValue} for ${game} ${playtype}`);
log.error(`Invalid classValue ${classValue} for ${game}`);
return false;
}
if (minimumId < 0) {
log.error(`Invalid minimum classValue ${classValue} for ${game} ${playtype}`);
log.error(`Invalid minimum classValue ${classValue} for ${game}`);
return false;
}
return currentId >= minimumId;
}
function GetMinimumScores(
game: GameGroup,
playtype: Playtype,
classSet: Classes[GPTString],
): integer | null {
const config = GetGamePTConfig(game, playtype);
function GetMinimumScores(game: V3Game, classSet: Classes[V3Game]): integer | null {
const config = GetGameConfig(game);
const classSpec = config.classes[classSet];
if (classSpec === undefined) {
log.error(`Invalid class ${classSet} for ${game} ${playtype}`);
log.error(`Invalid class ${classSet} for ${game}`);
return null;
}
@@ -2,9 +2,9 @@ import { Env } from "#config";
import { PrependTachiUrl } from "#utils/fetch-tachi";
import { log } from "#utils/log";
import {
GetGameGroupConfig,
FormatGame,
type GoalDocument,
type integer,
type MONGO_GoalDocument,
type WebhookEventGoalAchievedV1,
} from "tachi-common";
@@ -32,19 +32,15 @@ export async function HandleGoalAchievedV1(
const userDoc = await GetUserInfo(event.userID);
const goalDocuments = await Promise.all(
event.goals.map((e) => GetGoalWithID(e.goalID, game, e.playtype)),
event.goals.map((e) => GetGoalWithID(e.goalID, e.game)),
);
const goalMap = new Map<string, MONGO_GoalDocument>();
const goalMap = new Map<string, GoalDocument>();
for (const goalDoc of goalDocuments) {
goalMap.set(goalDoc.goalID, goalDoc);
}
const gameConfig = GetGameGroupConfig(game);
const shouldShowPlaytype = gameConfig.playtypes.length !== 1;
const embed = CreateEmbed(userDoc.id)
.setTitle(
`${userDoc.username} just achieved ${event.goals.length} ${Pluralise(
@@ -66,7 +62,7 @@ export async function HandleGoalAchievedV1(
: `${e.old.progressHuman}/${e.old.outOfHuman} -> ${e.new.progressHuman}/${e.new.outOfHuman}`;
return {
name: `${goal.name}${shouldShowPlaytype ? ` (${e.playtype})` : ""}`,
name: `${goal.name} (${FormatGame(e.game)})`,
value,
};
}),
@@ -1,7 +1,7 @@
import { Env } from "#config";
import { PrependTachiUrl } from "#utils/fetch-tachi";
import { log } from "#utils/log";
import { GetGameGroupConfig, type integer, type WebhookEventQuestAchievedV1 } from "tachi-common";
import { FormatGame, type integer, type WebhookEventQuestAchievedV1 } from "tachi-common";
import { client } from "../main";
import { GetQuestWithID, GetUserInfo } from "../utils/api-requests";
@@ -11,7 +11,7 @@ import { GetGameChannel } from "../utils/misc";
export async function HandleQuestAchievedV1(
event: WebhookEventQuestAchievedV1["content"],
): Promise<integer> {
const { game, playtype } = event;
const { game } = event;
let channel;
@@ -26,16 +26,13 @@ export async function HandleQuestAchievedV1(
const userDoc = await GetUserInfo(event.userID);
const quest = await GetQuestWithID(event.questID, game, playtype);
const gameConfig = GetGameGroupConfig(game);
const shouldShowPlaytype = gameConfig.playtypes.length > 1 ? ` (${playtype})` : "";
const quest = await GetQuestWithID(event.questID, game);
const embed = CreateEmbed(userDoc.id)
.setThumbnail(PrependTachiUrl(`/users/${userDoc.id}/pfp`))
.setURL(`${Env.TACHI_SERVER_LOCATION}/u/${userDoc.username}`)
.setTitle(
`${userDoc.username} just completed the ${quest.name}${shouldShowPlaytype} quest!`,
`${userDoc.username} just completed the ${quest.name} (${FormatGame(game)}) quest!`,
);
await channel.send({ embeds: [embed] });
+4
View File
@@ -17,6 +17,8 @@ export default defineConfig({
},
test: {
passWithNoTests: true,
// Static env vars. POSTGRES_URL is set dynamically per-worker in vitest.setup.ts
// so each worker gets its own isolated database.
env: {
@@ -38,7 +40,9 @@ export default defineConfig({
pool: "forks",
coverage: {
enabled: true,
provider: "v8",
reporter: ["text", "html", "clover", "json", "lcov"],
include: ["src/actions/**"],
},
},
+5 -4
View File
@@ -11,6 +11,7 @@
*/
import crypto from "node:crypto";
import { allImportTypes } from "tachi-common/constants/import-types";
const WORKER_ID = crypto.randomUUID().slice(0, 8);
const WORKER_DB_NAME = `tachi_bot_test_${WORKER_ID}`;
@@ -35,10 +36,10 @@ globalThis.fetch = (url: string | Request | URL): Promise<Response> => {
success: true,
body: {
NAME: "Test Tachi",
GAMES: [],
VERSIONS: {},
SUPPORTS_OAUTH2: false,
OAUTH_CLIENT_ID: null,
TYPE: "boku",
SIGNUPS_ENABLED: true,
GAME_GROUPS: ["iidx"],
IMPORT_TYPES: [...allImportTypes],
},
}),
} as unknown as Response);
@@ -8,7 +8,7 @@ import { TachiConfig } from "#lib/config";
import { APIFetchV1 } from "#util/api";
import React, { useContext, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { type MONGO_APITokenDocument, type MONGO_TachiAPIClientDocument } from "tachi-common";
import { type APITokenDocument, type TachiAPIClientDocument } from "tachi-common";
export default function ClientFileFlowPage() {
const { clientID } = useParams<{ clientID: string }>();
@@ -17,7 +17,7 @@ export default function ClientFileFlowPage() {
const { user } = useContext(UserContext);
const { data, error } = useApiQuery<Omit<MONGO_TachiAPIClientDocument, "clientSecret">>(
const { data, error } = useApiQuery<Omit<TachiAPIClientDocument, "clientSecret">>(
`/clients/${clientID}`,
);
@@ -60,7 +60,7 @@ export default function ClientFileFlowPage() {
<button
className="btn btn-success"
onClick={async () => {
const res = await APIFetchV1<MONGO_APITokenDocument>(
const res = await APIFetchV1<APITokenDocument>(
`/users/${user?.id}/api-tokens/create`,
{
method: "POST",
@@ -10,7 +10,7 @@ import { Alert, Button, Form } from "react-bootstrap";
import ReCAPTCHA from "react-google-recaptcha";
import toast from "react-hot-toast";
import { Link, useHistory } from "react-router-dom";
import { type MONGO_UserDocument } from "tachi-common";
import { type UserDocument } from "tachi-common";
export default function LoginPage() {
useSetSubheader("Login");
@@ -83,7 +83,7 @@ export default function LoginPage() {
return;
}
const userRJ = await APIFetchV1<MONGO_UserDocument>("/users/me");
const userRJ = await APIFetchV1<UserDocument>("/users/me");
if (userRJ.statusCode === 403) {
setErr("You are banned.");
@@ -8,7 +8,7 @@ import useQueryString from "#components/util/useQueryString";
import { APIFetchV1 } from "#util/api";
import React from "react";
import { Link } from "react-router-dom";
import { type integer, type MONGO_TachiAPIClientDocument } from "tachi-common";
import { type integer, type TachiAPIClientDocument } from "tachi-common";
export default function OAuthRequestAuthPage() {
const params = useQueryString();
@@ -32,7 +32,7 @@ export default function OAuthRequestAuthPage() {
}
function OAuthRequestAuthLoader({ clientID }: { clientID: string }) {
const { error, data } = useApiQuery<Omit<MONGO_TachiAPIClientDocument, "clientSecret">>(
const { error, data } = useApiQuery<Omit<TachiAPIClientDocument, "clientSecret">>(
`/clients/${clientID}`,
);
@@ -61,7 +61,7 @@ function OAuthRequestAuthLoader({ clientID }: { clientID: string }) {
function OAuthRequestAuthMain({
client,
}: {
client: Omit<MONGO_TachiAPIClientDocument, "clientSecret">;
client: Omit<TachiAPIClientDocument, "clientSecret">;
}) {
const params = useQueryString();
@@ -13,7 +13,7 @@ import { Alert, Button, Form } from "react-bootstrap";
import ReCAPTCHA from "react-google-recaptcha";
import toast from "react-hot-toast";
import { Link, useHistory } from "react-router-dom";
import { type MONGO_UserDocument } from "tachi-common";
import { type UserDocument } from "tachi-common";
// seconds it takes for a user to actually read the rules.
const RULES_READ_TIME = Number(import.meta.env.VITE_RULES_READ_TIME) || 30;
@@ -62,7 +62,7 @@ export default function RegisterPage() {
return;
}
const rj = await APIFetchV1<MONGO_UserDocument>(
const rj = await APIFetchV1<UserDocument>(
"/auth/register",
{
method: "POST",
@@ -54,22 +54,11 @@ function resultVariant(result: string): "danger" | "secondary" | "success" | "wa
return "secondary";
}
function JsonCell({ label, value }: { label: string; value: unknown }) {
if (value === null || value === undefined) {
return <span className="text-body-secondary">—</span>;
function formatJson(value: unknown): string {
if (value === undefined) {
return "";
}
const json = JSON.stringify(value, null, 2);
return (
<details>
<summary className="small text-body-secondary">{label}</summary>
<pre
className="small mb-0 mt-1 p-2 bg-body-secondary rounded"
style={{ maxWidth: "20rem" }}
>
{json}
</pre>
</details>
);
return JSON.stringify(value, null, 2);
}
export default function AdminActionsPage() {
@@ -78,8 +67,22 @@ export default function AdminActionsPage() {
const history = useHistory();
const apiUrl = `/admin/actions${location.search}`;
const [expandedIds, setExpandedIds] = React.useState(() => new Set<string>());
const { data, error, isLoading } = useApiQuery<ActionsResponse>(apiUrl);
function toggleIoExpanded(rowId: string) {
setExpandedIds((prev) => {
const next = new Set(prev);
if (next.has(rowId)) {
next.delete(rowId);
} else {
next.add(rowId);
}
return next;
});
}
if (error) {
return <p className="text-danger">Failed to load actions.</p>;
}
@@ -159,50 +162,102 @@ export default function AdminActionsPage() {
<th>Result</th>
<th>User</th>
<th>IP</th>
<th>Input</th>
<th>Output</th>
<th className="text-nowrap">Input / output</th>
</tr>
</thead>
<tbody>
{actions.items.length === 0 ? (
<tr>
<td className="text-body-secondary" colSpan={8}>
<td className="text-body-secondary" colSpan={7}>
No actions found.
</td>
</tr>
) : (
actions.items.map((action) => (
<tr key={action.row_id}>
<td className="small text-nowrap">
{MillisToSince(Date.parse(action.ts_start))}
</td>
<td className="font-monospace small">
{durationMs(action.ts_start, action.ts_end)}
</td>
<td className="small">{action.kind}</td>
<td>
<Badge bg={resultVariant(action.result)}>
{action.result}
</Badge>
</td>
<td>
{action.username ? (
<Link to={`/u/${action.username}`}>
{action.username}
</Link>
) : (
<span className="text-body-secondary">—</span>
actions.items.map((action) => {
const ioOpen = expandedIds.has(action.row_id);
return (
<React.Fragment key={action.row_id}>
<tr>
<td className="small text-nowrap">
{MillisToSince(Date.parse(action.ts_start))}
</td>
<td className="font-monospace small">
{durationMs(action.ts_start, action.ts_end)}
</td>
<td className="small">{action.kind}</td>
<td>
<Badge bg={resultVariant(action.result)}>
{action.result}
</Badge>
</td>
<td>
{action.username ? (
<Link to={`/u/${action.username}`}>
{action.username}
</Link>
) : (
<span className="text-body-secondary">—</span>
)}
</td>
<td className="font-monospace small">
{action.ip ?? "—"}
</td>
<td className="align-middle">
<Button
aria-expanded={ioOpen}
className="text-nowrap"
onClick={() => toggleIoExpanded(action.row_id)}
size="sm"
type="button"
variant="outline-secondary"
>
{ioOpen ? "Hide I/O" : "Show I/O"}
</Button>
</td>
</tr>
{ioOpen && (
<tr className="table-light">
<td className="border-top-0 p-3" colSpan={7}>
<div className="d-flex flex-column flex-lg-row gap-3 align-items-stretch">
<div className="flex-fill d-flex flex-column min-w-0">
<Form.Label className="small mb-1 text-body-secondary">
Input
</Form.Label>
<Form.Control
as="textarea"
className="font-monospace small"
readOnly
rows={14}
spellCheck={false}
value={formatJson(action.input)}
/>
</div>
<div
aria-hidden
className="d-flex align-items-center justify-content-center px-1 text-body-secondary fs-4"
>
→
</div>
<div className="flex-fill d-flex flex-column min-w-0">
<Form.Label className="small mb-1 text-body-secondary">
Output
</Form.Label>
<Form.Control
as="textarea"
className="font-monospace small"
readOnly
rows={14}
spellCheck={false}
value={formatJson(action.output)}
/>
</div>
</div>
</td>
</tr>
)}
</td>
<td className="font-monospace small">{action.ip ?? "—"}</td>
<td>
<JsonCell label="Input" value={action.input} />
</td>
<td>
<JsonCell label="Output" value={action.output} />
</td>
</tr>
))
</React.Fragment>
);
})
)}
</tbody>
</Table>
@@ -12,14 +12,14 @@ export default function AdminDestructivePage() {
const [deleteSessionId, setDeleteSessionId] = useState("");
const [ugptUserId, setUgptUserId] = useState("");
const [ugptGame, setUgptGame] = useState<GameGroup>(TachiConfig.GAMES[0]);
const [ugptGame, setUgptGame] = useState<GameGroup>(TachiConfig.GAME_GROUPS[0]);
const ugptGameConfig = useMemo(() => GetGameGroupConfig(ugptGame), [ugptGame]);
const [ugptPlaytype, setUgptPlaytype] = useState<string>(
() => GetGameGroupConfig(TachiConfig.GAMES[0]).playtypes[0],
() => GetGameGroupConfig(TachiConfig.GAME_GROUPS[0]).playtypes[0],
);
const [destroyChartId, setDestroyChartId] = useState("");
const [destroyChartGame, setDestroyChartGame] = useState<GameGroup>(TachiConfig.GAMES[0]);
const [destroyChartGame, setDestroyChartGame] = useState<GameGroup>(TachiConfig.GAME_GROUPS[0]);
function confirmDelete(message: string): boolean {
return window.confirm(message);
@@ -138,7 +138,7 @@ export default function AdminDestructivePage() {
}}
value={ugptGame}
>
{TachiConfig.GAMES.map((g) => (
{TachiConfig.GAME_GROUPS.map((g) => (
<option key={g} value={g}>
{g}
</option>
@@ -208,7 +208,7 @@ export default function AdminDestructivePage() {
onChange={(e) => setDestroyChartGame(e.target.value as GameGroup)}
value={destroyChartGame}
>
{TachiConfig.GAMES.map((g) => (
{TachiConfig.GAME_GROUPS.map((g) => (
<option key={g} value={g}>
{g}
</option>
@@ -3,7 +3,12 @@ import { TachiConfig } from "#lib/config";
import { APIFetchV1 } from "#util/api";
import React, { useState } from "react";
import { Button, Card, Col, Form, Row } from "react-bootstrap";
import { FormatGameGroup, type GameGroup, GetGameGroupConfig } from "tachi-common";
import {
FormatGame,
type GameGroup,
GetGameGroupConfig,
LEGACY_GameGroupPTToGame,
} from "tachi-common";
export default function AdminOperationsPage() {
useSetSubheader(["Admin", "Operations"]);
@@ -46,7 +51,7 @@ export default function AdminOperationsPage() {
value={announcementGame === "" ? "" : announcementGame}
>
<option value="">— Site-wide —</option>
{TachiConfig.GAMES.map((g) => (
{TachiConfig.GAME_GROUPS.map((g) => (
<option key={g} value={g}>
{g}
</option>
@@ -63,7 +68,12 @@ export default function AdminOperationsPage() {
<option value="">—</option>
{announcementGameConfig.playtypes.map((pt) => (
<option key={pt} value={pt}>
{FormatGameGroup(announcementGame as GameGroup, pt)}
{FormatGame(
LEGACY_GameGroupPTToGame(
announcementGame as GameGroup,
pt,
),
)}
</option>
))}
</Form.Select>
@@ -24,7 +24,7 @@ import React, { useContext, useMemo } from "react";
import Alert from "react-bootstrap/Alert";
import Stack from "react-bootstrap/Stack";
import { Link, Route, Switch } from "react-router-dom";
import { type MONGO_UserDocument } from "tachi-common";
import { type UserDocument } from "tachi-common";
import SupportBanner from "./misc/SupportBanner";
@@ -42,7 +42,7 @@ export function DashboardPage() {
return <DashboardLoggedIn user={user} />;
}
function DashboardLoggedIn({ user }: { user: MONGO_UserDocument }) {
function DashboardLoggedIn({ user }: { user: UserDocument }) {
const splash = useMemo(() => RFA(heySplashes), []);
return (
@@ -78,7 +78,7 @@ function DashboardLoggedIn({ user }: { user: MONGO_UserDocument }) {
);
}
function RecentInfo({ user }: { user: MONGO_UserDocument }) {
function RecentInfo({ user }: { user: UserDocument }) {
const { data, error } = useApiQuery<UserRecentSummary>(`/users/${user.id}/recent-summary`);
if (error) {
@@ -91,7 +91,7 @@ function RecentInfo({ user }: { user: MONGO_UserDocument }) {
const folderInfoMap = new Map();
for (const folderInfo of data.recentFolderStats) {
folderInfoMap.set(folderInfo.folderID, folderInfo);
folderInfoMap.set(folderInfo.slug, folderInfo);
}
const goalMap = CreateGoalMap(data.recentGoals);
@@ -35,31 +35,36 @@ import Stack from "react-bootstrap/Stack";
import { useQuery } from "react-query";
import { Link, Route, Switch } from "react-router-dom";
import {
type ChartDocument,
FormatDifficulty,
GameToGameGroup,
GetGameGroupConfig,
type integer,
type MONGO_ChartDocument,
type MONGO_PBScoreDocument,
type MONGO_SongDocument,
type MONGO_UserDocument,
type PBScoreDocument,
type SongDocument,
type UserDocument,
} from "tachi-common";
// Wrapper around the chart leaderboard UI; `chart` comes from the `/charts/:chartID` route.
export default function GPTChartPage({
chart,
game,
song,
playtype,
}: {
chart: MONGO_ChartDocument | null;
song: MONGO_SongDocument;
chart: ChartDocument | null;
song: SongDocument;
} & GamePT) {
const formatSongTitle = `${song.artist} - ${song.title}`;
const formatDiff = chart ? FormatDifficulty(chart, game) : "Loading...";
const formatDiff = chart ? FormatDifficulty(chart) : "Loading...";
useSetSubheader(
["Games", GetGameGroupConfig(game).name, playtype, "Songs", formatSongTitle, formatDiff],
[game, playtype, chart],
[
"Games",
GetGameGroupConfig(GameToGameGroup(game)).name,
"Songs",
formatSongTitle,
formatDiff,
],
[game, chart],
`${formatSongTitle} (${formatDiff})`,
);
@@ -67,7 +72,7 @@ export default function GPTChartPage({
return <Loading />;
}
return <InternalGPTChartPage chart={chart} game={game} playtype={playtype} song={song} />;
return <InternalGPTChartPage chart={chart} game={game} song={song} />;
}
interface ChartPBData {
@@ -81,10 +86,9 @@ function InternalGPTChartPage({
chart,
game,
song,
playtype,
}: {
chart: MONGO_ChartDocument;
song: MONGO_SongDocument;
chart: ChartDocument;
song: SongDocument;
} & GamePT) {
const { user } = useContext(UserContext);
@@ -92,7 +96,7 @@ function InternalGPTChartPage({
["PBInfo", chart.chartID],
async () => {
const lRes = await APIFetchV1<ChartPBLeaderboardReturn>(
`/games/${game}/${playtype}/charts/${chart.chartID}/pbs`,
`/games/${game}/charts/${chart.chartID}/pbs`,
);
if (!lRes.success) {
@@ -100,7 +104,7 @@ function InternalGPTChartPage({
}
const pRes = await APIFetchV1<{ count: integer }>(
`/games/${game}/${playtype}/charts/${chart.chartID}/playcount`,
`/games/${game}/charts/${chart.chartID}/playcount`,
);
if (!pRes.success) {
@@ -109,11 +113,11 @@ function InternalGPTChartPage({
if (user) {
const nRes = await APIFetchV1<UGPTChartLeaderboardAdjacent>(
`/users/${user.id}/games/${game}/${playtype}/pbs/${chart.chartID}/leaderboard-adjacent`,
`/users/${user.id}/games/${game}/pbs/${chart.chartID}/leaderboard-adjacent`,
);
const rRes = await APIFetchV1<ChartRivalsReturn>(
`/users/${user.id}/games/${game}/${playtype}/pbs/${chart.chartID}/rivals`,
`/users/${user.id}/games/${game}/pbs/${chart.chartID}/rivals`,
);
const returnValue: ChartPBData = {
@@ -163,11 +167,10 @@ function InternalGPTChartPage({
}
if (user) {
// Add current user, since there's no guarantee they are returned from either API.
userMap.set(user.id, user);
}
const base = CreateChartLink(chart, game);
const base = CreateChartLink(chart);
return (
<>
@@ -203,16 +206,15 @@ function InternalGPTChartPage({
</Row>
<div className="mt-4">
<Switch>
<Route exact path="/games/:game/:playtype/charts/:chartID/targets">
<ChartTargetInfo {...{ chart, game, playtype, song, user: user! }} />
<Route exact path="/games/:game/charts/:chartID/targets">
<ChartTargetInfo {...{ chart, game, song, user: user! }} />
</Route>
<Route exact path="/games/:game/:playtype/charts/:chartID">
<Route exact path="/games/:game/charts/:chartID">
<ChartLeaderboardTable
{...{
data,
game,
playtype,
user,
userMap,
chart,
@@ -222,12 +224,11 @@ function InternalGPTChartPage({
/>
</Route>
<Route exact path="/games/:game/:playtype/charts/:chartID/me">
<Route exact path="/games/:game/charts/:chartID/me">
<ChartLeaderboardTable
{...{
data,
game,
playtype,
user,
userMap,
chart,
@@ -237,12 +238,11 @@ function InternalGPTChartPage({
/>
</Route>
<Route exact path="/games/:game/:playtype/charts/:chartID/rivals">
<Route exact path="/games/:game/charts/:chartID/rivals">
<ChartLeaderboardTable
{...{
data,
game,
playtype,
user,
userMap,
chart,
@@ -260,21 +260,19 @@ function InternalGPTChartPage({
function ChartTargetInfo({
user,
game,
playtype,
chart,
song,
}: {
chart: MONGO_ChartDocument;
song: MONGO_SongDocument;
user: MONGO_UserDocument;
chart: ChartDocument;
song: SongDocument;
user: UserDocument;
} & GamePT) {
const { reloadTargets } = useContext(TargetsContext);
const [shouldReload, setShouldReload] = useState(0);
const { error, data } = useApiQuery<GoalsOnChartReturn>(
`/users/${user.id ?? ""}/games/${game}/${playtype}/targets/on-chart/${chart.chartID}`,
`/users/${user.id ?? ""}/games/${game}/targets/on-chart/${chart.chartID}`,
undefined,
// force a reload of this data when the user adds a new goal
[shouldReload.toString()],
);
@@ -287,11 +285,9 @@ function ChartTargetInfo({
data,
error,
game,
playtype,
reqUser: user,
song,
onGoalSet: () => {
// reload local query, then reload global targets.
setShouldReload(shouldReload + 1);
reloadTargets();
},
@@ -306,24 +302,23 @@ function ChartLeaderboardTable({
userMap,
user: _user,
game,
playtype,
mode,
chart,
song,
}: {
chart: MONGO_ChartDocument;
chart: ChartDocument;
data: ChartPBData;
mode: "adjacent" | "leaderboard" | "rivals";
song: MONGO_SongDocument;
user: MONGO_UserDocument | null;
userMap: Map<integer, MONGO_UserDocument>;
song: SongDocument;
user: UserDocument | null;
userMap: Map<integer, UserDocument>;
} & GamePT) {
const { settings } = useLUGPTSettings();
const dataset: PBDataset = useMemo(() => {
const ds: PBDataset = [];
let pbs: Array<MONGO_PBScoreDocument> = [];
let pbs: Array<PBScoreDocument> = [];
if (mode === "leaderboard") {
pbs = data.leaderboard.pbs;
} else if (mode === "adjacent") {
@@ -358,7 +353,6 @@ function ChartLeaderboardTable({
defaultRankingViewMode={mode === "rivals" ? "global-no-switch" : "both-if-self"}
game={game}
key={mode}
playtype={playtype}
showChart={false}
showUser
/>
@@ -371,20 +365,14 @@ function TopShowcase({
userMap,
chart,
}: {
chart: MONGO_ChartDocument;
chart: ChartDocument;
data: ChartPBData;
user: MONGO_UserDocument | null;
userMap: Map<integer, MONGO_UserDocument>;
user: UserDocument | null;
userMap: Map<integer, UserDocument>;
}) {
// We have a couple of conditions.
// User is #1: col-12 #1,
// User has played: col-6 col-6,
// User has not played: col-12 #1,
const bestPlay = data.leaderboard.pbs[0]!;
const bestUser = userMap.get(bestPlay.userID)!;
// User hasn't played, or isn't logged in or something.
if (user?.id === bestPlay.userID) {
return (
<Col xs={12}>
@@ -428,28 +416,29 @@ function PlayCard({
name,
chart,
}: {
chart: MONGO_ChartDocument;
chart: ChartDocument;
name: string;
pb: MONGO_PBScoreDocument;
user: MONGO_UserDocument;
pb: PBScoreDocument;
user: UserDocument;
}) {
const {
breakpoint: { isLg },
} = useContext(WindowContext);
const pbGame = pb.game;
return (
<Card cardBodyClassName="vstack gap-4" header={name}>
<Stack
className="flex-grow-1 align-items-lg-start align-items-center justify-content-around"
direction={isLg ? "horizontal" : "vertical"}
>
<ProfilePicture toGPT={{ game: pb.game, playtype: pb.playtype }} user={user} />
<ProfilePicture toGPT={{ game: pbGame }} user={user} />
<div
className="d-flex flex-column align-self-stretch justify-content-between align-items-center"
style={{ maxHeight: 128, minWidth: 256 }}
>
<Link
className="text-decoration-none fs-4 fw-bold text-break text-center"
to={`/u/${user.username}/games/${pb.game}/${pb.playtype}`}
to={`/u/${user.username}/games/${pbGame}`}
>
{user.username}
</Link>
@@ -465,7 +454,7 @@ function PlayCard({
<Col>
<MiniTable colSpan={100} headers={["PB Info"]}>
<tr>
<ScoreCoreCells chart={chart} game={pb.game} score={pb} short />
<ScoreCoreCells chart={chart} game={pbGame} score={pb} short />
</tr>
</MiniTable>
<div className="text-center">
@@ -14,18 +14,19 @@ import { NumericSOV, StrSOV } from "#util/sorts";
import React, { useState } from "react";
import { Col, Row } from "react-bootstrap";
import {
FormatGameGroup,
type ChartDocument,
FormatGame,
GameToGameGroup,
GetGameGroupConfig,
type integer,
type MONGO_ChartDocument,
type MONGO_SongDocument,
type SongDocument,
} from "tachi-common";
export default function GPTChartsPage({ game, playtype }: GamePT) {
export default function GPTChartsPage({ game }: GamePT) {
useSetSubheader(
["Games", GetGameGroupConfig(game).name, playtype, "Songs"],
[game, playtype],
`${FormatGameGroup(game, playtype)} Songs`,
["Games", GetGameGroupConfig(GameToGameGroup(game)).name, "Songs"],
[game],
`${FormatGame(game)} Songs`,
);
const [search, setSearch] = useState("");
@@ -37,19 +38,19 @@ export default function GPTChartsPage({ game, playtype }: GamePT) {
<Divider />
</Col>
<Col xs={12}>
<SearchSongsTable game={game} playtype={playtype} search={search} />
<SearchSongsTable game={game} search={search} />
</Col>
</Row>
);
}
function SearchSongsTable({ game, playtype, search }: { search: string } & GamePT) {
function SearchSongsTable({ game, search }: { search: string } & GamePT) {
const params = new URLSearchParams({ search });
const { data, error } = useApiQuery<{
charts: ({ __playcount: integer } & MONGO_ChartDocument)[];
songs: MONGO_SongDocument[];
}>(`/games/${game}/${playtype}/charts${search !== "" ? `?${params.toString()}` : ""}`);
charts: ({ __playcount: integer } & ChartDocument)[];
songs: SongDocument[];
}>(`/games/${game}/charts${search !== "" ? `?${params.toString()}` : ""}`);
if (error) {
return <ApiError error={error} />;
@@ -67,7 +68,7 @@ function SearchSongsTable({ game, playtype, search }: { search: string } & GameP
dataset.push({
...chart,
__related: {
song: songMap.get(chart.songID)!,
song: songMap.get(chart.song.id)!,
},
});
}
@@ -76,7 +77,7 @@ function SearchSongsTable({ game, playtype, search }: { search: string } & GameP
<>
{search === "" && (
<div className="w-100 text-center">
<h4>Displaying the most played charts for {FormatGameGroup(game, playtype)}</h4>
<h4>Displaying the most played charts for {FormatGame(game)}</h4>
<Divider />
</div>
)}
@@ -7,33 +7,30 @@ import { type GamePT } from "#types/react";
import React from "react";
import {
type Classes,
FormatGameGroup,
FormatGame,
GameToGameGroup,
GetGameConfig,
GetGameGroupConfig,
GetGamePTConfig,
type GPTString,
type V3Game,
} from "tachi-common";
export default function GPTDevInfo({ game, playtype }: GamePT) {
export default function GPTDevInfo({ game }: GamePT) {
useSetSubheader(
["Games", GetGameGroupConfig(game).name, playtype, "Dev Info"],
[game, playtype],
`${FormatGameGroup(game, playtype)} Dev Info`,
["Games", GetGameGroupConfig(GameToGameGroup(game)).name, "Dev Info"],
[game],
`${FormatGame(game)} Dev Info`,
);
const gameConfig = GetGameGroupConfig(game);
const gptConfig = GetGamePTConfig(game, playtype);
const gameGroupConfig = GetGameConfig(game);
return (
<>
<Card header="Game Configuration">
<DebugContent data={gameConfig} />
</Card>
<Card className="mt-4" header="GPT Configuration">
<DebugContent data={gptConfig} />
<Card header="GPT Configuration">
<DebugContent data={gameGroupConfig} />
</Card>
<Card className="mt-4" header="Class Badges">
<div className="d-flex w-100 justify-content-center" style={{ gap: "30px" }}>
{Object.entries(gptConfig.classes).map(([classSet, conf]) => (
{Object.entries(gameGroupConfig.classes).map(([classSet, conf]) => (
<div key={classSet}>
<MiniTable colSpan={2} headers={[classSet]}>
{conf.values.map((e) => (
@@ -41,10 +38,9 @@ export default function GPTDevInfo({ game, playtype }: GamePT) {
<td>{e.id}</td>
<td>
<ClassBadge
classSet={classSet as Classes[GPTString]}
classSet={classSet as Classes[V3Game]}
classValue={e.id}
game={game}
playtype={playtype}
/>
</td>
</tr>
@@ -22,16 +22,19 @@ import React, { useState } from "react";
import { Col, Form, Row } from "react-bootstrap";
import {
type AnyProfileRatingAlg,
FormatGameGroup,
type Classes,
FormatGame,
GameToGameGroup,
GetGameConfig,
GetGameGroupConfig,
GetGamePTConfig,
type V3Game,
} from "tachi-common";
export default function GPTLeaderboardsPage({ game, playtype }: GamePT) {
export default function GPTLeaderboardsPage({ game }: GamePT) {
useSetSubheader(
["Games", GetGameGroupConfig(game).name, playtype, "Leaderboards"],
[game, playtype],
`${FormatGameGroup(game, playtype)} Leaderboards`,
["Games", GetGameGroupConfig(GameToGameGroup(game)).name, "Leaderboards"],
[game],
`${FormatGame(game)} Leaderboards`,
);
const [mode, setMode] = useState<"profile" | "score">("profile");
@@ -51,39 +54,35 @@ export default function GPTLeaderboardsPage({ game, playtype }: GamePT) {
<Col xs={12}>
<Divider />
{mode === "profile" ? (
<ProfileLeaderboard game={game} playtype={playtype} />
<ProfileLeaderboard game={game} />
) : (
<ScoreLeaderboard
game={game}
playtype={playtype}
url={`/games/${game}/${playtype}/pb-leaderboard`}
/>
<ScoreLeaderboard game={game} url={`/games/${game}/pb-leaderboard`} />
)}
</Col>
</Row>
);
}
function ProfileLeaderboard({ game, playtype }: GamePT) {
const gptConfig = GetGamePTConfig(game, playtype);
function ProfileLeaderboard({ game }: GamePT) {
const gameConfig = GetGameConfig(game);
const defaultAlg = useProfileRatingAlg(game, playtype);
const defaultAlg = useProfileRatingAlg(game);
const [alg, setAlg] = useState(defaultAlg);
const SelectComponent =
Object.keys(gptConfig.profileRatingAlgs).length > 1 ? (
Object.keys(gameConfig.profileRatingAlgs).length > 1 ? (
<Form.Select onChange={(e) => setAlg(e.target.value as any)} value={alg}>
{Object.keys(gptConfig.profileRatingAlgs).map((e) => (
{Object.keys(gameConfig.profileRatingAlgs).map((e) => (
<option key={e} value={e}>
{FormatGPTProfileRatingName(game, playtype, e)}
{FormatGPTProfileRatingName(game, e)}
</option>
))}
</Form.Select>
) : null;
const { data, error } = useApiQuery<UserLeaderboardReturns>(
`/games/${game}/${playtype}/leaderboard?alg=${alg}&limit=500`,
`/games/${game}/leaderboard?alg=${alg}&limit=500`,
);
if (error) {
@@ -128,11 +127,13 @@ function ProfileLeaderboard({ game, playtype }: GamePT) {
headers={[
["Ranking", "Rank", NumericSOV((x) => x.__related.index)],
["User", "User", StrSOV((x) => x.__related.user.username)],
...(Object.keys(gptConfig.profileRatingAlgs) as Array<AnyProfileRatingAlg>).map(
...(
Object.keys(gameConfig.profileRatingAlgs) as Array<AnyProfileRatingAlg>
).map(
(e) =>
[
FormatGPTProfileRatingName(game, playtype, e),
FormatGPTProfileRatingName(game, playtype, e),
FormatGPTProfileRatingName(game, e),
FormatGPTProfileRatingName(game, e),
NumericSOV((x) => x.ratings[e] ?? -Infinity),
] as Header<UGSDataset[0]>,
),
@@ -141,13 +142,13 @@ function ProfileLeaderboard({ game, playtype }: GamePT) {
rowFunction={(r) => (
<tr>
<IndexCell index={r.__related.index} />
<UserCell game={game} playtype={playtype} user={r.__related.user} />
<UserCell game={game} user={r.__related.user} />
{(
Object.keys(gptConfig.profileRatingAlgs) as Array<AnyProfileRatingAlg>
Object.keys(gameConfig.profileRatingAlgs) as Array<AnyProfileRatingAlg>
).map((e) => (
<td key={e}>
{r.ratings[e]
? FormatGPTProfileRating(game, playtype, e, r.ratings[e]!)
? FormatGPTProfileRating(game, e, r.ratings[e]!)
: "No Data."}
</td>
))}
@@ -161,11 +162,10 @@ function ProfileLeaderboard({ game, playtype }: GamePT) {
([k, v]) =>
v && (
<ClassBadge
classSet={k as keyof typeof r.classes}
classSet={k as Classes[V3Game]}
classValue={v}
game={game}
key={k}
playtype={playtype}
/>
),
)
@@ -2,14 +2,14 @@ import Activity from "#components/activity/Activity";
import useSetSubheader from "#components/layout/header/useSetSubheader";
import { type GamePT } from "#types/react";
import React from "react";
import { FormatGameGroup, GetGameGroupConfig } from "tachi-common";
import { FormatGame, GameToGameGroup, GetGameGroupConfig } from "tachi-common";
export default function GPTMainPage({ game, playtype }: GamePT) {
export default function GPTMainPage({ game }: GamePT) {
useSetSubheader(
["Games", GetGameGroupConfig(game).name, playtype],
[game, playtype],
FormatGameGroup(game, playtype),
["Games", GetGameGroupConfig(GameToGameGroup(game)).name],
[game],
FormatGame(game),
);
return <Activity url={`/games/${game}/${playtype}/activity`} />;
return <Activity url={`/games/${game}/activity`} />;
}
@@ -2,13 +2,13 @@ import ImportFileInfo from "#components/imports/ImportFileInfo";
import useSetSubheader from "#components/layout/header/useSetSubheader";
import { TachiConfig } from "#lib/config";
import { p } from "prudence";
import React, { useState } from "react";
import React from "react";
import {
type BatchManual,
FormatGameGroup,
FormatGame,
FormatPrError,
GetGameGroupConfig,
GetGamePTConfig,
LEGACY_GameGroupPTToGame,
} from "tachi-common";
import { PR_BATCH_MANUAL } from "tachi-common/lib/schemas";
@@ -23,23 +23,23 @@ export default function BatchManualPage() {
parseFunction={(text: string) => {
const data: BatchManual = JSON.parse(text);
const gameConfig = GetGameGroupConfig(data.meta.game);
const gameGroupConfig = GetGameGroupConfig(data.meta.game);
if (!gameConfig) {
if (!gameGroupConfig) {
throw new Error(
`Invalid game ${data.meta.game}. Expected any of ${TachiConfig.GAMES}.`,
`Invalid game ${data.meta.game}. Expected any of ${TachiConfig.GAME_GROUPS}.`,
);
}
const gptConfig = GetGamePTConfig(data.meta.game, data.meta.playtype);
if (!gptConfig) {
if (!gameGroupConfig.playtypes.includes(data.meta.playtype as any)) {
throw new Error(
`Invalid Playtype ${data.meta.playtype}. Expected any of ${gameConfig.playtypes}.`,
`Invalid Playtype ${data.meta.playtype}. Expected any of ${gameGroupConfig.playtypes}.`,
);
}
const err = p(data, PR_BATCH_MANUAL(data.meta.game, data.meta.playtype));
const game = LEGACY_GameGroupPTToGame(data.meta.game, data.meta.playtype);
const err = p(data, PR_BATCH_MANUAL(game));
if (err) {
throw new Error(FormatPrError(err, "Invalid BATCH-MANUAL: "));
@@ -48,7 +48,7 @@ export default function BatchManualPage() {
return {
valid: true,
info: {
Game: FormatGameGroup(data.meta.game, data.meta.playtype),
Game: FormatGame(game),
Scores: data.scores.length,
},
};
@@ -23,10 +23,10 @@ import {
GetGameGroupConfig,
type ImportTypes,
type integer,
type MONGO_UserDocument,
type UserDocument,
} from "tachi-common";
export default function ImportPage({ user }: { user: MONGO_UserDocument }) {
export default function ImportPage({ user }: { user: UserDocument }) {
useSetSubheader(["Import Scores"]);
const [game, setGame] = useState<GameGroup | null>(null);
@@ -66,7 +66,7 @@ export default function ImportPage({ user }: { user: MONGO_UserDocument }) {
value={game ?? ""}
>
<option value="">Please select a game.</option>
{TachiConfig.GAMES.map((e) => (
{TachiConfig.GAME_GROUPS.map((e) => (
<option key={e} value={e}>
{GetGameGroupConfig(e).name}
</option>
@@ -89,7 +89,7 @@ function ShowRecentImports() {
return <InnerShowRecentImports user={user} />;
}
function InnerShowRecentImports({ user }: { user: MONGO_UserDocument }) {
function InnerShowRecentImports({ user }: { user: UserDocument }) {
const { data, error } = useApiQuery<{ count: integer; importType: ImportTypes }[]>(
`/users/${user.id}/recent-imports`,
);
@@ -5,9 +5,9 @@ import useApiQuery from "#components/util/query/useApiQuery";
import { TachiConfig } from "#lib/config";
import { ONE_DAY } from "#util/constants/time";
import React, { useEffect, useState } from "react";
import { type integer, type MONGO_UserDocument } from "tachi-common";
import { type integer, type UserDocument } from "tachi-common";
export default function SupportBanner({ user }: { user: MONGO_UserDocument }) {
export default function SupportBanner({ user }: { user: UserDocument }) {
const { data, error } = useApiQuery<{ scores: integer; sessions: integer }>(
`/users/${user.id}/stats`,
);
@@ -19,13 +19,12 @@ import React, { useContext, useEffect, useLayoutEffect, useState } from "react";
import { Badge, Col, Form, Row } from "react-bootstrap";
import { Link, useHistory, useLocation } from "react-router-dom";
import {
FormatGameGroup,
type GPTString,
type ChartDocument,
FormatGame,
type integer,
type MONGO_ChartDocument,
type MONGO_SongDocument,
type MONGO_UserDocument,
SplitGPT,
type SongDocument,
type UserDocument,
type V3Game,
} from "tachi-common";
function parseSearchParams(search: string) {
@@ -48,48 +47,40 @@ export default function SearchPage() {
const [hasPlayedGame, setHasPlayedGame] = useState(initialHasPlayed);
useLayoutEffect(() => {
const { q, hasPlayedGame: h } = parseSearchParams(location.search);
setSearch(q);
setHasPlayedGame(h);
const params = parseSearchParams(location.search);
setSearch(params.q);
setHasPlayedGame(params.hasPlayedGame);
}, [location.search]);
useEffect(() => {
const next = new URLSearchParams();
if (search) {
next.set("q", search);
const qs = `?q=${encodeURIComponent(search)}&hasPlayedGame=${hasPlayedGame}`;
if (location.search !== qs) {
history.replace(`/search${qs}`);
}
if (!hasPlayedGame) {
next.set("hasPlayedGame", "false");
}
const nextStr = next.toString();
const current = new URLSearchParams(history.location.search).toString();
if (nextStr !== current) {
history.replace({
pathname: location.pathname,
search: nextStr ? `?${nextStr}` : "",
});
}
}, [search, hasPlayedGame, history, location.pathname]);
}, [search, hasPlayedGame]);
return (
<Row>
<Col xs={12}>
<DebounceSearch
autoFocus
committedSearch={search}
placeholder="Search songs, users..."
setSearch={setSearch}
/>
{user && (
<div className="w-100 mt-4 ms-1">
<Form.Check
checked={hasPlayedGame}
label="Hide games you haven't played?"
onChange={(e) => setHasPlayedGame(e.target.checked)}
/>
</div>
)}
<Divider />
<Card className="mt-8" header="Search Tachi">
<DebounceSearch
autoFocus
className="form-control-lg"
committedSearch={search}
placeholder="Search songs, users..."
setSearch={setSearch}
/>
{user && (
<div className="w-100 mt-4 ms-1">
<Form.Check
checked={hasPlayedGame}
label="Hide games you haven't played?"
onChange={(e) => setHasPlayedGame(e.target.checked)}
/>
</div>
)}
<Divider />
</Card>
</Col>
<Col xs={12}>
<SearchResults hasPlayedGame={hasPlayedGame} search={search} />
@@ -100,7 +91,7 @@ export default function SearchPage() {
function SearchResults({ search, hasPlayedGame }: { hasPlayedGame: boolean; search: string }) {
const { data, error } = useTachiSearch(search, hasPlayedGame);
const [mode, setMode] = useState<"users" | GPTString | null>(null);
const [mode, setMode] = useState<"users" | V3Game | null>(null);
useEffect(() => {
if (data) {
@@ -109,7 +100,7 @@ function SearchResults({ search, hasPlayedGame }: { hasPlayedGame: boolean; sear
.filter((k) => k[1].length > 0);
if (thingsWithCharts.length > 0) {
setMode(thingsWithCharts[0][0] as GPTString);
setMode(thingsWithCharts[0][0] as V3Game);
} else if (data.users.length > 0) {
setMode("users");
} else {
@@ -161,13 +152,11 @@ function SearchResults({ search, hasPlayedGame }: { hasPlayedGame: boolean; sear
return null;
}
const gpt = g as GPTString;
const [game, playtype] = SplitGPT(gpt);
const game = g as V3Game;
return (
<SelectButton id={gpt} key={gpt} setValue={setMode} value={mode}>
{FormatGameGroup(game, playtype)}
<SelectButton id={game} key={game} setValue={setMode} value={mode}>
{FormatGame(game)}
<Badge bg="secondary" className="ms-2 text-light">
{charts.length}
</Badge>
@@ -198,7 +187,7 @@ function SearchResults({ search, hasPlayedGame }: { hasPlayedGame: boolean; sear
{mode === "users" ? (
<UsersView users={data.users} />
) : (
<ChartView charts={data.charts[mode]!} gpt={mode} />
<ChartView charts={data.charts[mode]!} game={mode} />
)}
</Col>
)}
@@ -208,17 +197,15 @@ function SearchResults({ search, hasPlayedGame }: { hasPlayedGame: boolean; sear
function ChartView({
charts,
gpt,
game,
}: {
charts: Array<{
chart: MONGO_ChartDocument;
chart: ChartDocument;
playcount: integer;
song: MONGO_SongDocument;
song: SongDocument;
}>;
gpt: GPTString;
game: V3Game;
}) {
const [game, playtype] = SplitGPT(gpt);
return (
<TachiTable
dataset={charts}
@@ -248,52 +235,72 @@ function ChartView({
);
}
function UsersView({ users }: { users: Array<MONGO_UserDocument> }) {
function UsersView({ users }: { users: Array<UserDocument> }) {
return (
<Row>
<div
className="w-100 d-flex"
style={{
flexWrap: "wrap",
}}
></div>
{users.map((user) => (
<Col key={user.id} lg={6} xs={12}>
<Card className="mb-4">
<div className="d-flex h-100">
<div>
<ProfilePicture user={user} />
</div>
<div
className="ms-4 d-flex w-100 h-100"
style={{
flexWrap: "wrap",
flexDirection: "column",
}}
>
<div>
<h4>
<Link
className="text-decoration-none"
to={`/u/${user.username}`}
>
{user.username}
</Link>
</h4>
</div>
<div>
<Muted>{user.status ?? "I haven't set my status."}</Muted>
</div>
{Date.now() - user.lastSeen < ONE_MINUTE * 5 && (
<div className="mt-2">
<Badge bg="success">ONLINE</Badge>
</div>
)}
</div>
</div>
</Card>
</Col>
))}
</Row>
<TachiTable
dataset={users}
defaultSortMode="Last Seen"
entryName="Users"
headers={[
["User", "User", StrSOV((x) => x.username)],
["Status", "Status", StrSOV((x) => x.status ?? "")],
["Last Seen", "Last Seen", NumericSOV((x) => x.lastSeen)],
]}
rowFunction={(u) => (
<tr>
<td>
<Link to={`/u/${u.username}`}>
<ProfilePicture size="sm" user={u} />
{u.username}
</Link>
</td>
<td>
<Muted>{u.status ?? "No status."}</Muted>
</td>
<td>
{u.lastSeen === 0
? "Never"
: new Date(u.lastSeen).toLocaleDateString("en-GB", {
timeZone: "UTC",
year: "2-digit",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
timeZoneName: "short",
})}
</td>
</tr>
)}
searchFunctions={{
username: (x) => x.username,
status: (x) => x.status,
lastSeen: (x) => {
if (x.lastSeen === 0) {
return null;
}
return new Date(x.lastSeen).toLocaleString("en-GB", {
timeZone: "UTC",
timeZoneName: "short",
});
},
}}
/>
);
}
function LastSeenCell({ time }: { time: number }) {
if (time === 0) {
return <td>Never</td>;
}
const now = Date.now();
if (now - time < ONE_MINUTE) {
return <td>Just Now</td>;
}
const d = new Date(time);
return <td>{d.toLocaleDateString("en-GB")}</td>;
}
@@ -7,10 +7,10 @@ import { isCardIDValid } from "#util/misc";
import React, { useState } from "react";
import { Button, Col, Form } from "react-bootstrap";
import { Link } from "react-router-dom";
import { type MONGO_FervidexSettingsDocument, type MONGO_UserDocument } from "tachi-common";
import { type FervidexSettingsDocument, type UserDocument } from "tachi-common";
export default function FervidexIntegrationPage({ reqUser }: { reqUser: MONGO_UserDocument }) {
const { data: settings, error } = useApiQuery<MONGO_FervidexSettingsDocument | null>(
export default function FervidexIntegrationPage({ reqUser }: { reqUser: UserDocument }) {
const { data: settings, error } = useApiQuery<FervidexSettingsDocument | null>(
`/users/${reqUser.id}/integrations/fervidex/settings`,
);
@@ -48,12 +48,10 @@ function FervidexForm({
reqUser,
settings,
}: {
reqUser: MONGO_UserDocument;
settings: MONGO_FervidexSettingsDocument | null;
reqUser: UserDocument;
settings: FervidexSettingsDocument | null;
}) {
const [formSettings, setFormSettings] = useState<
Omit<MONGO_FervidexSettingsDocument, "userID">
>(
const [formSettings, setFormSettings] = useState<Omit<FervidexSettingsDocument, "userID">>(
settings
? { forceStaticImport: settings.forceStaticImport, cards: settings.cards }
: {
@@ -7,13 +7,13 @@ import React, { useState } from "react";
import { Button, Col, Form } from "react-bootstrap";
import { Link } from "react-router-dom";
import {
type MONGO_FervidexSettingsDocument,
type MONGO_KsHookSettingsDocument,
type MONGO_UserDocument,
type FervidexSettingsDocument,
type KsHookSettingsDocument,
type UserDocument,
} from "tachi-common";
export default function KsHookSV6CIntegrationPage({ reqUser }: { reqUser: MONGO_UserDocument }) {
const { data: settings, error } = useApiQuery<MONGO_FervidexSettingsDocument | null>(
export default function KsHookSV6CIntegrationPage({ reqUser }: { reqUser: UserDocument }) {
const { data: settings, error } = useApiQuery<FervidexSettingsDocument | null>(
`/users/${reqUser.id}/integrations/kshook-sv6c/settings`,
);
@@ -51,10 +51,10 @@ function KsHookSV6CForm({
reqUser,
settings,
}: {
reqUser: MONGO_UserDocument;
settings: MONGO_KsHookSettingsDocument | null;
reqUser: UserDocument;
settings: KsHookSettingsDocument | null;
}) {
const [formSettings, setFormSettings] = useState<Omit<MONGO_KsHookSettingsDocument, "userID">>(
const [formSettings, setFormSettings] = useState<Omit<KsHookSettingsDocument, "userID">>(
settings ? { forceStaticImport: settings.forceStaticImport } : { forceStaticImport: false },
);
@@ -8,7 +8,7 @@ import { UserContext } from "#context/UserContext";
import { APIFetchV1 } from "#util/api";
import React, { useContext, useEffect, useMemo } from "react";
import { Button, Col, Row } from "react-bootstrap";
import { type MONGO_UserDocument } from "tachi-common";
import { type UserDocument } from "tachi-common";
export default function NotificationsPage() {
const { user } = useContext(UserContext);
@@ -22,7 +22,7 @@ export default function NotificationsPage() {
return <NotificationsInnerPage user={user} />;
}
function NotificationsInnerPage({ user }: { user: MONGO_UserDocument }) {
function NotificationsInnerPage({ user }: { user: UserDocument }) {
const { notifications, reload } = useContext(NotificationsContext);
const unread = useMemo(() => {
@@ -1,9 +1,9 @@
import useSetSubheader from "#components/layout/header/useSetSubheader";
import UGPTProfiles from "#components/user/UGPTProfiles";
import React from "react";
import { type MONGO_UserDocument } from "tachi-common";
import { type UserDocument } from "tachi-common";
export default function UserGamesPage({ reqUser }: { reqUser: MONGO_UserDocument }) {
export default function UserGamesPage({ reqUser }: { reqUser: UserDocument }) {
useSetSubheader(
["Users", reqUser.username, "Games"],
[reqUser],
@@ -10,13 +10,9 @@ import SelectLinkButton from "#components/util/SelectLinkButton";
import { type FailedImportDataset, type ImportDataset } from "#types/tables";
import React, { useMemo, useState } from "react";
import { Route, Switch } from "react-router-dom";
import {
type ImportTrackerFailed,
type MONGO_ImportDocument,
type MONGO_UserDocument,
} from "tachi-common";
import { type ImportDocument, type ImportTrackerFailed, type UserDocument } from "tachi-common";
export default function UserImportsPage({ reqUser }: { reqUser: MONGO_UserDocument }) {
export default function UserImportsPage({ reqUser }: { reqUser: UserDocument }) {
useSetSubheader(
["Users", reqUser.username, "Imports"],
[reqUser],
@@ -73,9 +69,9 @@ function ViewRecentImports({
reqUser,
}: {
params: URLSearchParams;
reqUser: MONGO_UserDocument;
reqUser: UserDocument;
}) {
const { data, error } = useApiQuery<Array<MONGO_ImportDocument>>(
const { data, error } = useApiQuery<Array<ImportDocument>>(
`/users/${reqUser.id}/imports?${params.toString()}`,
);
@@ -102,7 +98,7 @@ function ViewRecentFailedImports({
reqUser,
}: {
params: URLSearchParams;
reqUser: MONGO_UserDocument;
reqUser: UserDocument;
}) {
const { data, error } = useApiQuery<Array<ImportTrackerFailed>>(
`/users/${reqUser.id}/imports/failed?${params.toString()}`,
@@ -20,18 +20,18 @@ import { Alert, Button, Col, Form, Modal, Row } from "react-bootstrap";
import { Link, Route, Switch } from "react-router-dom";
import {
type APIPermissions,
type APITokenDocument,
type CGCardInfo,
type integer,
type MONGO_APITokenDocument,
type MONGO_CGCardInfo,
type MONGO_MytCardInfo,
type MONGO_TachiAPIClientDocument,
type MONGO_UserDocument,
type MytCardInfo,
type TachiAPIClientDocument,
type UserDocument,
} from "tachi-common";
import FervidexIntegrationPage from "./FervidexIntegrationPage";
import KsHookSV6CIntegrationPage from "./KsHookSV6CIntegrationPage";
export default function UserIntegrationsPage({ reqUser }: { reqUser: MONGO_UserDocument }) {
export default function UserIntegrationsPage({ reqUser }: { reqUser: UserDocument }) {
useSetSubheader(
["Users", reqUser.username, "Integrations"],
[reqUser],
@@ -102,9 +102,9 @@ function OAuthClientPage() {
}
function OAuthClientInfo() {
const { data, error } = useApiQuery<MONGO_TachiAPIClientDocument[]>("/clients");
const { data, error } = useApiQuery<TachiAPIClientDocument[]>("/clients");
const [clients, setClients] = useState<MONGO_TachiAPIClientDocument[]>([]);
const [clients, setClients] = useState<TachiAPIClientDocument[]>([]);
useEffect(() => {
setClients(data ?? []);
@@ -144,11 +144,7 @@ function OAuthClientInfo() {
);
}
function CreateNewOAuthClient({
setClients,
}: {
setClients: SetState<MONGO_TachiAPIClientDocument[]>;
}) {
function CreateNewOAuthClient({ setClients }: { setClients: SetState<TachiAPIClientDocument[]> }) {
const [show, setShow] = useState(false);
const [name, setName] = useState("");
const [redirectUri, setRedirectUri] = useState("");
@@ -310,9 +306,9 @@ function CreateNewOAuthClient({
}
interface OAuthClientProps {
client: MONGO_TachiAPIClientDocument;
clients: MONGO_TachiAPIClientDocument[];
setClients: SetState<MONGO_TachiAPIClientDocument[]>;
client: TachiAPIClientDocument;
clients: TachiAPIClientDocument[];
setClients: SetState<TachiAPIClientDocument[]>;
}
function OAuthClientRow({ client, clients, setClients }: OAuthClientProps) {
@@ -392,7 +388,7 @@ function OAuthClientRow({ client, clients, setClients }: OAuthClientProps) {
<div className="mt-8">
<Button
onClick={async () => {
const res = await APIFetchV1<MONGO_TachiAPIClientDocument>(
const res = await APIFetchV1<TachiAPIClientDocument>(
`/clients/${client.clientID}/reset-secret`,
{
method: "POST",
@@ -506,7 +502,7 @@ function EditClientModal({
onSubmit={async (e) => {
e.preventDefault();
const res = await APIFetchV1<MONGO_TachiAPIClientDocument>(
const res = await APIFetchV1<TachiAPIClientDocument>(
`/clients/${client.clientID}`,
{
method: "PATCH",
@@ -621,7 +617,7 @@ function EditClientModal({
);
}
function ServicesPage({ reqUser }: { reqUser: MONGO_UserDocument }) {
function ServicesPage({ reqUser }: { reqUser: UserDocument }) {
if (TachiConfig.TYPE === "boku") {
return (
<Row className="text-center">
@@ -761,13 +757,11 @@ function KAIIntegrationStatus({
);
}
function APIKeysPage({ reqUser }: { reqUser: MONGO_UserDocument }) {
const [apiKeys, setApiKeys] = useState<MONGO_APITokenDocument[]>([]);
function APIKeysPage({ reqUser }: { reqUser: UserDocument }) {
const [apiKeys, setApiKeys] = useState<APITokenDocument[]>([]);
const [showModal, setShowModal] = useState(false);
const { data, error } = useApiQuery<MONGO_APITokenDocument[]>(
`/users/${reqUser.id}/api-tokens`,
);
const { data, error } = useApiQuery<APITokenDocument[]>(`/users/${reqUser.id}/api-tokens`);
useEffect(() => {
if (data) {
@@ -827,9 +821,9 @@ function CreateAPIKeyModal({
apiKeys,
setApiKeys,
}: {
apiKeys: MONGO_APITokenDocument[];
reqUser: MONGO_UserDocument;
setApiKeys: SetState<MONGO_APITokenDocument[]>;
apiKeys: APITokenDocument[];
reqUser: UserDocument;
setApiKeys: SetState<APITokenDocument[]>;
setShowModal: SetState<boolean>;
showModal: boolean;
}) {
@@ -843,7 +837,7 @@ function CreateAPIKeyModal({
<form
onSubmit={async (e) => {
e.preventDefault();
const res = await APIFetchV1<MONGO_APITokenDocument>(
const res = await APIFetchV1<APITokenDocument>(
`/users/${reqUser.id}/api-tokens/create`,
{
method: "POST",
@@ -914,9 +908,9 @@ function APIKeyRow({
setApiKeys,
apiKeys,
}: {
apiKey: MONGO_APITokenDocument;
apiKeys: MONGO_APITokenDocument[];
setApiKeys: SetState<MONGO_APITokenDocument[]>;
apiKey: APITokenDocument;
apiKeys: APITokenDocument[];
setApiKeys: SetState<APITokenDocument[]>;
}) {
const [show, setShow] = useState(false);
const [sure, setSure] = useState(false);
@@ -962,7 +956,7 @@ function APIKeyRow({
function CGIntegrationInfo({ cgType, userID }: { cgType: "dev" | "gan" | "nag"; userID: integer }) {
const [reload, shouldReloadCardInfo] = useReducer((x) => x + 1, 0);
const { data, error } = useApiQuery<MONGO_CGCardInfo | null>(
const { data, error } = useApiQuery<CGCardInfo | null>(
`/users/${userID}/integrations/cg/${cgType}`,
undefined,
[reload],
@@ -1007,7 +1001,7 @@ function CGIntegrationInfo({ cgType, userID }: { cgType: "dev" | "gan" | "nag";
function MytIntegrationInfo({ userID }: { userID: integer }) {
const [reload, shouldReloadCardInfo] = useReducer((x) => x + 1, 0);
const { data, error } = useApiQuery<MONGO_MytCardInfo | null>(
const { data, error } = useApiQuery<MytCardInfo | null>(
`/users/${userID}/integrations/myt`,
undefined,
[reload],
@@ -14,12 +14,12 @@ import { Button, Col, Row } from "react-bootstrap";
import { Link } from "react-router-dom";
import {
type integer,
type MONGO_InviteCodeDocument,
type MONGO_UserDocument,
type InviteCodeDocument,
UserAuthLevels,
type UserDocument,
} from "tachi-common";
export default function UserInvitesPage({ reqUser }: { reqUser: MONGO_UserDocument }) {
export default function UserInvitesPage({ reqUser }: { reqUser: UserDocument }) {
useSetSubheader(
["Users", reqUser.username, "Invites"],
[reqUser],
@@ -91,10 +91,10 @@ export default function UserInvitesPage({ reqUser }: { reqUser: MONGO_UserDocume
);
}
function InviteList({ reqUser }: { reqUser: MONGO_UserDocument }) {
function InviteList({ reqUser }: { reqUser: UserDocument }) {
const { data, error } = useApiQuery<{
consumers: MONGO_UserDocument[];
invites: MONGO_InviteCodeDocument[];
consumers: UserDocument[];
invites: InviteCodeDocument[];
}>(`/users/${reqUser.id}/invites`);
if (error) {
@@ -9,10 +9,10 @@ import { APIFetchV1 } from "#util/api";
import React, { useContext, useEffect, useState } from "react";
import { Button } from "react-bootstrap";
import ReactMarkdown from "react-markdown";
import { type MONGO_UserDocument } from "tachi-common";
import { type UserDocument } from "tachi-common";
interface Props {
reqUser: MONGO_UserDocument;
reqUser: UserDocument;
}
export default function UserPage({ reqUser }: Props) {
@@ -19,14 +19,10 @@ import Form from "react-bootstrap/Form";
import InputGroup from "react-bootstrap/InputGroup";
import Stack from "react-bootstrap/Stack";
import toast from "react-hot-toast";
import {
type integer,
type MONGO_UserDocument,
type MONGO_UserSettingsDocument,
} from "tachi-common";
import { type integer, type UserDocument, type UserSettingsDocument } from "tachi-common";
interface Props {
reqUser: MONGO_UserDocument;
reqUser: UserDocument;
}
export default function UserSettingsDocumentPage({ reqUser }: Props) {
@@ -101,7 +97,7 @@ export default function UserSettingsDocumentPage({ reqUser }: Props) {
);
}
export function AccountSettings({ reqUser }: { reqUser: MONGO_UserDocument }) {
export function AccountSettings({ reqUser }: { reqUser: UserDocument }) {
const [page, setPage] = useState<"email" | "password" | "username">("email");
return (
@@ -129,7 +125,7 @@ export function AccountSettings({ reqUser }: { reqUser: MONGO_UserDocument }) {
);
}
function ChangeEmailForm({ reqUser }: { reqUser: MONGO_UserDocument }) {
function ChangeEmailForm({ reqUser }: { reqUser: UserDocument }) {
const formikEmail = useFormik({
initialValues: {
"!password": "",
@@ -137,7 +133,7 @@ function ChangeEmailForm({ reqUser }: { reqUser: MONGO_UserDocument }) {
confEmail: "",
},
onSubmit: async (values) => {
const r = await APIFetchV1<MONGO_UserSettingsDocument>(
const r = await APIFetchV1<UserSettingsDocument>(
`/users/${reqUser.id}/change-email`,
{
method: "POST",
@@ -217,7 +213,7 @@ function ChangeEmailForm({ reqUser }: { reqUser: MONGO_UserDocument }) {
);
}
function ChangePasswordForm({ reqUser }: { reqUser: MONGO_UserDocument }) {
function ChangePasswordForm({ reqUser }: { reqUser: UserDocument }) {
const formikPassword = useFormik({
initialValues: {
"!oldPassword": "",
@@ -225,7 +221,7 @@ function ChangePasswordForm({ reqUser }: { reqUser: MONGO_UserDocument }) {
confPass: "",
},
onSubmit: async (values) => {
const r = await APIFetchV1<MONGO_UserSettingsDocument>(
const r = await APIFetchV1<UserSettingsDocument>(
`/users/${reqUser.id}/change-password`,
{
method: "POST",
@@ -305,7 +301,7 @@ function ChangePasswordForm({ reqUser }: { reqUser: MONGO_UserDocument }) {
);
}
function ChangeUsernameForm({ reqUser }: { reqUser: MONGO_UserDocument }) {
function ChangeUsernameForm({ reqUser }: { reqUser: UserDocument }) {
const lastUsernameChange = useApiQuery<
{ canChange: false; nextAvailableChange: integer | null } | { canChange: true }
>(`/users/${reqUser.id}/last-username-change`);
@@ -367,7 +363,7 @@ function ChangeUsernameForm({ reqUser }: { reqUser: MONGO_UserDocument }) {
}
const handle = window.setTimeout(async () => {
const usernameQuery = await APIFetchV1<MONGO_UserDocument | null>(
const usernameQuery = await APIFetchV1<UserDocument | null>(
`/users/${nameChangeFormik.values.newUsername}`,
);
@@ -447,7 +443,7 @@ function ChangeUsernameForm({ reqUser }: { reqUser: MONGO_UserDocument }) {
);
}
function PreferencesForm({ reqUser }: { reqUser: MONGO_UserDocument }) {
function PreferencesForm({ reqUser }: { reqUser: UserDocument }) {
const { settings, setSettings } = useContext(UserSettingsContext);
const theme = getStoredTheme() || "system";
const [themeSetting, setThemeSetting] = useState<"system" | Themes>(theme);
@@ -461,7 +457,7 @@ function PreferencesForm({ reqUser }: { reqUser: MONGO_UserDocument }) {
deletableScores: settings?.preferences.deletableScores ?? false,
},
onSubmit: async (values) => {
const res = await APIFetchV1<MONGO_UserSettingsDocument>(
const res = await APIFetchV1<UserSettingsDocument>(
`/users/${reqUser.id}/settings`,
{
method: "PATCH",
@@ -535,7 +531,7 @@ function PreferencesForm({ reqUser }: { reqUser: MONGO_UserDocument }) {
);
}
function ImageForm({ reqUser }: { reqUser: MONGO_UserDocument }) {
function ImageForm({ reqUser }: { reqUser: UserDocument }) {
const [pfp, setPfp] = useState<File | undefined>();
const pfpInput = useRef<HTMLInputElement>(null);
@@ -611,7 +607,7 @@ function ImageForm({ reqUser }: { reqUser: MONGO_UserDocument }) {
);
}
function SocialMediaForm({ reqUser }: { reqUser: MONGO_UserDocument }) {
function SocialMediaForm({ reqUser }: { reqUser: UserDocument }) {
const placeholders = {
discord: "Discord Username",
twitter: "Twitter Handle",
@@ -638,7 +634,7 @@ function SocialMediaForm({ reqUser }: { reqUser: MONGO_UserDocument }) {
valuesClone[vx] = values[vx] || null;
}
const rj = await APIFetchV1<MONGO_UserDocument>(
const rj = await APIFetchV1<UserDocument>(
"/users/me",
{
method: "PATCH",
@@ -716,7 +712,7 @@ function FileUploadController({
reset,
}: {
file?: File;
reqUser: MONGO_UserDocument;
reqUser: UserDocument;
reset: () => void;
setFile: SetState<File | undefined>;
type: "banner" | "pfp";
@@ -17,14 +17,15 @@ import { useQuery } from "react-query";
import {
type Classes,
COLOUR_SET,
FormatGameGroup,
FormatGame,
GameToGameGroup,
GetGameConfig,
GetGameGroupConfig,
GetGamePTConfig,
type GPTString,
type integer,
type MONGO_UserDocument,
type MONGO_UserGameStats,
type ProfileRatingAlgorithms,
type UserDocument,
type UserGameStats,
type V3Game,
} from "tachi-common";
interface LeaderboardsData {
@@ -32,18 +33,23 @@ interface LeaderboardsData {
leaderboard: GPTLeaderboard;
}
export default function LeaderboardsPage({ reqUser, game, playtype }: UGPT) {
const gameConfig = GetGameGroupConfig(game);
export default function LeaderboardsPage({ reqUser, game }: UGPT) {
useSetSubheader(
["Users", reqUser.username, "Games", gameConfig.name, playtype, "Leaderboard"],
[reqUser, game, playtype],
`${reqUser.username}'s ${FormatGameGroup(game, playtype)} Leaderboard`,
[
"Users",
reqUser.username,
"Games",
GetGameGroupConfig(GameToGameGroup(game)).name,
"Leaderboard",
],
[reqUser, game],
`${reqUser.username}'s ${FormatGame(game)} Leaderboard`,
);
const defaultRating = useProfileRatingAlg(game, playtype);
const defaultRating = useProfileRatingAlg(game);
const [alg, setAlg] = useState(defaultRating);
const url = `/users/${reqUser.id}/games/${game}/${playtype}/leaderboard-adjacent?alg=${alg}`;
const url = `/users/${reqUser.id}/games/${game}/leaderboard-adjacent?alg=${alg}`;
const { data, error } = useQuery<LeaderboardsData, UnsuccessfulAPIFetchResponse>(
url,
@@ -55,7 +61,7 @@ export default function LeaderboardsPage({ reqUser, game, playtype }: UGPT) {
}
const lRes = await APIFetchV1<GPTLeaderboard>(
`/games/${game}/${playtype}/leaderboard?limit=3&alg=${alg}`,
`/games/${game}/leaderboard?limit=3&alg=${alg}`,
);
if (!lRes.success) {
@@ -71,7 +77,7 @@ export default function LeaderboardsPage({ reqUser, game, playtype }: UGPT) {
return (
<LoadingWrapper {...{ dataset: data, error }}>
<LeaderboardsPageContent {...{ reqUser, game, playtype, data: data!, alg, setAlg }} />
<LeaderboardsPageContent {...{ reqUser, game, data: data!, alg, setAlg }} />
</LoadingWrapper>
);
}
@@ -79,20 +85,19 @@ export default function LeaderboardsPage({ reqUser, game, playtype }: UGPT) {
function LeaderboardsPageContent({
reqUser,
game,
playtype,
data,
alg,
}: {
alg: ProfileRatingAlgorithms[GPTString];
alg: ProfileRatingAlgorithms[V3Game];
data: LeaderboardsData;
reqUser: MONGO_UserDocument;
setAlg: SetState<ProfileRatingAlgorithms[GPTString]>;
reqUser: UserDocument;
setAlg: SetState<ProfileRatingAlgorithms[V3Game]>;
} & GamePT) {
const { stats, leaderboard } = data;
const gptConfig = GetGamePTConfig(game, playtype);
const gameConfig = GetGameConfig(game);
const userMap = new Map<integer, MONGO_UserDocument>();
const userMap = new Map<integer, UserDocument>();
for (const u of stats.users) {
userMap.set(u.id, u);
@@ -102,12 +107,11 @@ function LeaderboardsPageContent({
userMap.set(u.id, u);
}
// hack - we aren't returned from this api call for some reason.
userMap.set(reqUser.id, reqUser);
const bestNearbyUser = stats.thisUsersRanking.ranking - stats.above.length - 1;
function LeaderboardRow({ s, i }: { i: integer; s: MONGO_UserGameStats }) {
function LeaderboardRow({ s, i }: { i: integer; s: UserGameStats }) {
return (
<tr
style={{
@@ -125,18 +129,15 @@ function LeaderboardsPageContent({
)}
</td>
<td>
<GentleLink
to={`/u/${userMap.get(s.userID)!.username}/games/${game}/${playtype}`}
>
<GentleLink to={`/u/${userMap.get(s.userID)!.username}/games/${game}`}>
{userMap.get(s.userID)?.username}
</GentleLink>
</td>
<td>
{IsNotNullish(s.ratings[alg])
? FormatGPTProfileRating(game, playtype, alg, s.ratings[alg]!)
? FormatGPTProfileRating(game, alg, s.ratings[alg]!)
: "No Data."}
</td>
{/* temp */}
<td>
{Object.entries(s.classes).length
? Object.entries(s.classes)
@@ -145,11 +146,10 @@ function LeaderboardsPageContent({
([k, v]) =>
v && (
<ClassBadge
classSet={k as Classes[GPTString]}
classSet={k as Classes[V3Game]}
classValue={v}
game={game}
key={`${k}:${v}`}
playtype={playtype}
/>
),
)
@@ -163,7 +163,7 @@ function LeaderboardsPageContent({
<Card
cardBodyClassName="overflow-x-auto d-flex flex-column justify-content-center p-4"
footer={
<LinkButton className="float-end" to={`/games/${game}/${playtype}/leaderboards`}>
<LinkButton className="float-end" to={`/games/${game}/leaderboards`}>
View Global Leaderboards
</LinkButton>
}
@@ -171,12 +171,7 @@ function LeaderboardsPageContent({
>
<MiniTable
className="text-center"
headers={[
"Position",
"User",
FormatGPTProfileRatingName(game, playtype, alg),
"Classes",
]}
headers={["Position", "User", FormatGPTProfileRatingName(game, alg), "Classes"]}
>
<>
{bestNearbyUser >= 1 &&
@@ -20,50 +20,39 @@ import { FormatDate, MillisToSince } from "#util/time";
import { DateTime } from "luxon";
import React, { useMemo, useState } from "react";
import FormSelect from "react-bootstrap/FormSelect";
import {
FormatGameGroup,
type GameGroup,
GetGameGroupConfig,
GetGamePTConfig,
type MONGO_UserGameStats,
type Playtype,
} from "tachi-common";
import { FormatGame, GetGameConfig, type UserGameStats, type V3Game } from "tachi-common";
export default function OverviewPage({ reqUser, game, playtype }: UGPT) {
const gameConfig = GetGameGroupConfig(game);
export default function OverviewPage({ reqUser, game }: UGPT) {
useSetSubheader(
["Users", reqUser.username, "Games", gameConfig.name, playtype],
[reqUser, game, playtype],
`${reqUser.username}'s ${FormatGameGroup(game, playtype)} Overview`,
["Users", reqUser.username, "Games", FormatGame(game)],
[reqUser, game],
`${reqUser.username}'s ${FormatGame(game)} Overview`,
);
return (
<React.Fragment key={`${game}:${playtype}`}>
<UGPTStatShowcase game={game} playtype={playtype} reqUser={reqUser} />
<RankingInfo game={game} playtype={playtype} reqUser={reqUser} />
<RecentActivity game={game} playtype={playtype} reqUser={reqUser} />
<React.Fragment key={game}>
<UGPTStatShowcase game={game} reqUser={reqUser} />
<RankingInfo game={game} reqUser={reqUser} />
<RecentActivity game={game} reqUser={reqUser} />
</React.Fragment>
);
}
function RecentActivity({ reqUser, game, playtype }: UGPT) {
function RecentActivity({ reqUser, game }: UGPT) {
return (
<div className="mt-4">
<Activity
handleNoActivity={null}
url={`/users/${reqUser.id}/games/${game}/${playtype}/activity`}
/>
<Activity handleNoActivity={null} url={`/users/${reqUser.id}/games/${game}/activity`} />
</div>
);
}
type RankingDurations = "3mo" | "year";
type RankingDurations = "3mo" | "all" | "month" | "week" | "year";
function RankingInfo({ reqUser, game, playtype }: UGPT) {
function RankingInfo({ reqUser, game }: UGPT) {
const [duration, setDuration] = useState<RankingDurations>("3mo");
const { data, error } = useApiQuery<UGPTHistory>(
`/users/${reqUser.id}/games/${game}/${playtype}/history?duration=${duration}`,
`/users/${reqUser.id}/games/${game}/history?duration=${duration}`,
);
return (
@@ -75,7 +64,6 @@ function RankingInfo({ reqUser, game, playtype }: UGPT) {
data={data}
duration={duration}
game={game}
playtype={playtype}
setDuration={setDuration}
/>
) : (
@@ -88,7 +76,6 @@ function RankingInfo({ reqUser, game, playtype }: UGPT) {
function UserHistory({
data,
game,
playtype,
duration,
setDuration,
}: {
@@ -96,23 +83,23 @@ function UserHistory({
duration: RankingDurations;
setDuration: SetState<RankingDurations>;
} & GamePT) {
const gptConfig = GetGamePTConfig(game, playtype);
const gameConfig = GetGameConfig(game);
const [mode, setMode] = useState<"playcount" | "ranking" | "rating">("rating");
const preferredRating = useProfileRatingAlg(game, playtype);
const preferredRating = useProfileRatingAlg(game);
const [rating, setRating] = useState<keyof MONGO_UserGameStats["ratings"]>(preferredRating);
const [rating, setRating] = useState<keyof UserGameStats["ratings"]>(preferredRating);
const propName = useMemo(() => {
if (mode === "rating" && rating) {
return FormatGPTProfileRatingName(game, playtype, rating);
return FormatGPTProfileRatingName(game, rating);
} else if (mode === "ranking") {
return `${FormatGPTProfileRatingName(game, playtype, rating)} Ranking`;
return `${FormatGPTProfileRatingName(game, rating)} Ranking`;
}
return UppercaseFirst(mode);
}, [mode, rating, game, playtype]);
}, [mode, rating, game]);
const currentPropValue = useMemo(() => {
if (mode === "rating" && rating) {
@@ -122,7 +109,7 @@ function UserHistory({
return "N/A";
}
return FormatGPTProfileRating(game, playtype, rating, ratingValue);
return FormatGPTProfileRating(game, rating, ratingValue);
} else if (mode === "ranking") {
return (
<>
@@ -133,7 +120,7 @@ function UserHistory({
}
return data[0].playcount;
}, [mode, rating, data, game, playtype]);
}, [mode, rating, data, game]);
return (
<>
@@ -144,6 +131,7 @@ function UserHistory({
<option value="month">Past Month</option>
<option value="3mo">Past 3 Months</option>
<option value="year">Past Year</option>
<option value="all">All Time</option>
</Select>
</div>
<div className="col-12 col-md-6 align-self-center">
@@ -170,19 +158,17 @@ function UserHistory({
<Divider className="mt-6 mb-2" />
{mode === "ranking" ? (
<>
{Object.keys(gptConfig.profileRatingAlgs).length > 1 && (
{Object.keys(gameConfig.profileRatingAlgs).length > 1 && (
<div className="col-12 offset-md-4 col-md-4 mt-4">
<FormSelect
onChange={(e) =>
setRating(
e.target.value as keyof MONGO_UserGameStats["ratings"],
)
setRating(e.target.value as keyof UserGameStats["ratings"])
}
value={rating}
>
{Object.keys(gptConfig.profileRatingAlgs).map((e) => (
{Object.keys(gameConfig.profileRatingAlgs).map((e) => (
<option key={e} value={e}>
{FormatGPTProfileRatingName(game, playtype, e)}
{FormatGPTProfileRatingName(game, e)}
</option>
))}
</FormSelect>
@@ -225,26 +211,24 @@ function UserHistory({
/>
) : (
<>
{Object.keys(gptConfig.profileRatingAlgs).length > 1 && (
{Object.keys(gameConfig.profileRatingAlgs).length > 1 && (
<div className="col-12 offset-md-4 col-md-4 mt-4">
<FormSelect
onChange={(e) =>
setRating(
e.target.value as keyof MONGO_UserGameStats["ratings"],
)
setRating(e.target.value as keyof UserGameStats["ratings"])
}
value={rating}
>
{Object.keys(gptConfig.profileRatingAlgs).map((e) => (
{Object.keys(gameConfig.profileRatingAlgs).map((e) => (
<option key={e} value={e}>
{FormatGPTProfileRatingName(game, playtype, e)}
{FormatGPTProfileRatingName(game, e)}
</option>
))}
</FormSelect>
</div>
)}
<RatingTimeline {...{ game, playtype, data, rating }} />
<RatingTimeline {...{ game, data, rating }} />
</>
)}
</>
@@ -253,14 +237,12 @@ function UserHistory({
function RatingTimeline({
game,
playtype,
data,
rating,
}: {
data: UGPTHistory;
game: GameGroup;
playtype: Playtype;
rating: keyof MONGO_UserGameStats["ratings"];
game: V3Game;
rating: keyof UserGameStats["ratings"];
}) {
const ratingDataset = [
{ id: rating, data: data.map((e) => ({ x: e.timestamp, y: e.ratings[rating] })) },
@@ -270,13 +252,13 @@ function RatingTimeline({
<TimelineChart
axisBottom={{
format: (x) => DateTime.fromJSDate(x).toLocaleString(DateTime.DATE_FULL),
tickValues: 3, // temp
tickValues: 3,
}}
axisLeft={{
tickSize: 5,
tickPadding: 5,
tickRotation: 0,
format: (y) => (y ? FormatGPTProfileRating(game, playtype, rating, y) : "N/A"),
format: (y) => (y ? FormatGPTProfileRating(game, rating, y) : "N/A"),
}}
data={ratingDataset}
height="30rem"
@@ -285,14 +267,9 @@ function RatingTimeline({
<ChartTooltip>
<div>
{p.point.data.y
? FormatGPTProfileRating(
game,
playtype,
rating,
p.point.data.y as number,
)
? FormatGPTProfileRating(game, rating, p.point.data.y as number)
: "N/A"}{" "}
{FormatGPTProfileRatingName(game, playtype, rating)}
{FormatGPTProfileRatingName(game, rating)}
</div>
<small className="text-body-secondary">
{MillisToSince(+p.point.data.xFormatted)}
@@ -308,13 +285,13 @@ function RankingTimeline({
rating,
}: {
data: UGPTHistory;
rating: keyof MONGO_UserGameStats["ratings"];
rating: keyof UserGameStats["ratings"];
}) {
return (
<TimelineChart
axisBottom={{
format: (x) => DateTime.fromJSDate(x).toLocaleString(DateTime.DATE_FULL),
tickValues: 3, // temp
tickValues: 3,
}}
axisLeft={{
tickSize: 5,
@@ -15,41 +15,45 @@ import React, { useState } from "react";
import { Col, Form, Row } from "react-bootstrap";
import { Route, Switch } from "react-router-dom";
import {
FormatGameGroup,
type GameGroup,
type ChartDocument,
FormatGame,
GameToGameGroup,
GetGameConfig,
GetGameGroupConfig,
GetGamePTConfig,
type GPTString,
type MONGO_ChartDocument,
type MONGO_PBScoreDocument,
type MONGO_ScoreDocument,
type MONGO_SongDocument,
type MONGO_UserDocument,
type PBScoreDocument,
type ScoreDocument,
type ScoreRatingAlgorithms,
type SongDocument,
type UnsuccessfulAPIResponse,
type UserDocument,
type V3Game,
} from "tachi-common";
export default function ScoresPage({
reqUser,
game,
playtype,
}: {
reqUser: MONGO_UserDocument;
reqUser: UserDocument;
} & GamePT) {
const gameConfig = GetGameGroupConfig(game);
const gptConfig = GetGamePTConfig(game, playtype);
const gameConfig = GetGameConfig(game);
const defaultRating = useScoreRatingAlg(game, playtype);
const defaultRating = useScoreRatingAlg(game);
const [alg, setAlg] = useState(defaultRating);
useSetSubheader(
["Users", reqUser.username, "Games", gameConfig.name, playtype, "Scores"],
[
"Users",
reqUser.username,
"Games",
GetGameGroupConfig(GameToGameGroup(game)).name,
"Scores",
],
[reqUser],
`${reqUser.username}'s ${FormatGameGroup(game, playtype)} Scores`,
`${reqUser.username}'s ${FormatGame(game)} Scores`,
);
const base = useUGPTBase({ reqUser, game, playtype });
const base = useUGPTBase({ reqUser, game });
return (
<Row xs={{ cols: 1 }}>
@@ -71,39 +75,37 @@ export default function ScoresPage({
</Col>
<Col className="d-flex flex-column gap-4">
<Switch>
<Route exact path="/u/:userID/games/:game/:playtype/scores">
<Route exact path="/u/:userID/games/:game/scores">
<>
{Object.keys(gptConfig.scoreRatingAlgs).length > 1 && (
<AlgSelector {...{ alg, setAlg, game, playtype }} />
{Object.keys(gameConfig.scoreRatingAlgs).length > 1 && (
<AlgSelector {...{ alg, setAlg, game }} />
)}
<PBsOverview
url={`/users/${reqUser.id}/games/${game}/${playtype}/pbs/best?alg=${alg}`}
{...{ reqUser, game, playtype, alg }}
url={`/users/${reqUser.id}/games/${game}/pbs/best?alg=${alg}`}
{...{ reqUser, game, alg }}
/>
</>
</Route>
<Route path="/u/:userID/games/:game/:playtype/scores/history">
<ScoresOverview {...{ reqUser, game, playtype }} />
<Route path="/u/:userID/games/:game/scores/history">
<ScoresOverview {...{ reqUser, game }} />
</Route>
<Route path="/u/:userID/games/:game/:playtype/scores/all">
<Route path="/u/:userID/games/:game/scores/all">
<PBsOverview
game={game}
indexCol={false}
key="all-pbs"
playtype={playtype}
reqUser={reqUser}
url={`/users/${reqUser.id}/games/${game}/${playtype}/pbs/all`}
url={`/users/${reqUser.id}/games/${game}/pbs/all`}
/>
</Route>
<Route path="/u/:userID/games/:game/:playtype/scores/most-played">
<Route path="/u/:userID/games/:game/scores/most-played">
<PBsOverview
game={game}
indexCol
key="most-played-pbs"
playtype={playtype}
reqUser={reqUser}
showPlaycount
url={`/users/${reqUser.id}/games/${game}/${playtype}/most-played`}
url={`/users/${reqUser.id}/games/${game}/most-played`}
/>
</Route>
</Switch>
@@ -114,21 +116,20 @@ export default function ScoresPage({
function AlgSelector({
game,
playtype,
alg,
setAlg,
}: {
alg: ScoreRatingAlgorithms[GPTString];
setAlg: SetState<ScoreRatingAlgorithms[GPTString]>;
alg: ScoreRatingAlgorithms[V3Game];
setAlg: SetState<ScoreRatingAlgorithms[V3Game]>;
} & GamePT) {
const gptConfig = GetGamePTConfig(game, playtype);
const gameConfig = GetGameConfig(game);
return (
<Form.Group className="d-flex flex-column gap-1">
<div>Best 100 PBs according to</div>
<Form.Select onChange={(e) => setAlg(e.target.value as any)} value={alg}>
{Object.keys(gptConfig.scoreRatingAlgs).map((e) => (
{Object.keys(gameConfig.scoreRatingAlgs).map((e) => (
<option key={e} value={e}>
{FormatGPTScoreRatingName(game, playtype, e)}
{FormatGPTScoreRatingName(game, e)}
</option>
))}
</Form.Select>
@@ -136,11 +137,11 @@ function AlgSelector({
);
}
function useFetchPBs(url: string, reqUser: MONGO_UserDocument) {
function useFetchPBs(url: string, reqUser: UserDocument) {
const { data, error } = useApiQuery<{
charts: MONGO_ChartDocument[];
pbs: MONGO_PBScoreDocument[];
songs: MONGO_SongDocument[];
charts: ChartDocument[];
pbs: PBScoreDocument[];
songs: SongDocument[];
}>(url);
return {
@@ -152,15 +153,14 @@ function useFetchPBs(url: string, reqUser: MONGO_UserDocument) {
function PBsOverview({
reqUser,
game,
playtype,
indexCol = true,
showPlaycount = false,
url,
alg,
}: {
alg?: ScoreRatingAlgorithms[GPTString];
alg?: ScoreRatingAlgorithms[V3Game];
indexCol?: boolean;
reqUser: MONGO_UserDocument;
reqUser: UserDocument;
showPlaycount?: boolean;
url: string;
} & GamePT) {
@@ -184,27 +184,22 @@ function PBsOverview({
defaultRankingViewMode={preferredRanking}
game={game}
indexCol={indexCol}
playtype={playtype}
showPlaycount={showPlaycount}
/>
</LoadingWrapper>
) : (
<PBsSearch {...{ reqUser, game, playtype, search }} />
<PBsSearch {...{ reqUser, game, search }} />
)}
</div>
</div>
);
}
function FormatData<
D extends MONGO_PBScoreDocument | MONGO_ScoreDocument,
GPT extends GPTString = GPTString,
G extends GameGroup = GameGroup,
>(
function FormatData<D extends PBScoreDocument | ScoreDocument>(
d: D[],
songs: MONGO_SongDocument<G>[],
charts: MONGO_ChartDocument<GPT>[],
reqUser: MONGO_UserDocument,
songs: SongDocument[],
charts: ChartDocument[],
reqUser: UserDocument,
) {
const songMap = new Map();
const chartMap = new Map();
@@ -230,11 +225,11 @@ function FormatData<
return data;
}
function useFetchScores(url: string, reqUser: MONGO_UserDocument) {
function useFetchScores(url: string, reqUser: UserDocument) {
const { data, error } = useApiQuery<{
charts: MONGO_ChartDocument[];
scores: MONGO_ScoreDocument[];
songs: MONGO_SongDocument[];
charts: ChartDocument[];
scores: ScoreDocument[];
songs: SongDocument[];
}>(url);
return {
@@ -246,37 +241,30 @@ function useFetchScores(url: string, reqUser: MONGO_UserDocument) {
function PBsSearch({
reqUser,
game,
playtype,
search,
alg,
}: {
alg?: ScoreRatingAlgorithms[GPTString];
reqUser: MONGO_UserDocument;
alg?: ScoreRatingAlgorithms[V3Game];
reqUser: UserDocument;
search: string;
} & GamePT) {
const { data, error } = useFetchPBs(
`/users/${reqUser.id}/games/${game}/${playtype}/pbs?search=${search}`,
`/users/${reqUser.id}/games/${game}/pbs?search=${search}`,
reqUser,
);
return (
<LoadingWrapper style={{ height: 500 }} {...{ error, dataset: data }}>
<PBTable
alg={alg}
dataset={data!}
game={game}
indexCol={false}
playtype={playtype as "DP" | "SP"}
/>
<PBTable alg={alg} dataset={data!} game={game} indexCol={false} />
</LoadingWrapper>
);
}
function ScoresOverview({ reqUser, game, playtype }: UGPT) {
function ScoresOverview({ reqUser, game }: UGPT) {
const [search, setSearch] = useState("");
const { data, error } = useFetchScores(
`/users/${reqUser.id}/games/${game}/${playtype}/scores/recent`,
`/users/${reqUser.id}/games/${game}/scores/recent`,
reqUser,
);
@@ -292,10 +280,10 @@ function ScoresOverview({ reqUser, game, playtype }: UGPT) {
<div className="col-12 mt-4">
{search === "" ? (
<LoadingWrapper style={{ height: 500 }} {...{ dataset: data, error }}>
<ScoreTable dataset={data!} game={game} playtype={playtype as any} />
<ScoreTable dataset={data!} game={game} />
</LoadingWrapper>
) : (
<ScoresSearch {...{ reqUser, game, playtype, search }} />
<ScoresSearch {...{ reqUser, game, search }} />
)}
</div>
</div>
@@ -305,17 +293,16 @@ function ScoresOverview({ reqUser, game, playtype }: UGPT) {
function ScoresSearch({
reqUser,
game,
playtype,
search,
}: { reqUser: MONGO_UserDocument; search: string } & GamePT) {
}: { reqUser: UserDocument; search: string } & GamePT) {
const { data, error } = useFetchScores(
`/users/${reqUser.id}/games/${game}/${playtype}/scores?search=${search}`,
`/users/${reqUser.id}/games/${game}/scores?search=${search}`,
reqUser,
);
return (
<LoadingWrapper style={{ height: 500 }} {...{ error, dataset: data }}>
<ScoreTable dataset={data!} game={game} playtype={playtype as any} />
<ScoreTable dataset={data!} game={game} />
</LoadingWrapper>
);
}
@@ -15,35 +15,40 @@ import { NumericSOV } from "#util/sorts";
import React, { useState } from "react";
import { useQuery } from "react-query";
import {
FormatGameGroup,
FormatGame,
GameToGameGroup,
GetGameGroupConfig,
type MONGO_SessionDocument,
type MONGO_UserDocument,
type SessionDocument,
type SessionScoreInfo,
type UnsuccessfulAPIResponse,
type UserDocument,
} from "tachi-common";
export default function SessionsPage({ reqUser, game, playtype }: UGPT) {
export default function SessionsPage({ reqUser, game }: UGPT) {
const [sessionSet, setSessionSet] = useState<"best" | "highlighted" | "recent">("best");
const [search, setSearch] = useState("");
const gameConfig = GetGameGroupConfig(game);
useSetSubheader(
["Users", reqUser.username, "Games", gameConfig.name, playtype, "Sessions"],
[
"Users",
reqUser.username,
"Games",
GetGameGroupConfig(GameToGameGroup(game)).name,
"Sessions",
],
[reqUser],
`${reqUser.username}'s ${FormatGameGroup(game, playtype)} Sessions`,
`${reqUser.username}'s ${FormatGame(game)} Sessions`,
);
const baseUrl = `/users/${reqUser.id}/games/${game}/${playtype}/sessions`;
const baseUrl = `/users/${reqUser.id}/games/${game}/sessions`;
const rating = useSessionRatingAlg(game, playtype);
const rating = useSessionRatingAlg(game);
const { data, error } = useQuery<SessionDataset, UnsuccessfulAPIResponse>(
`${baseUrl}/${sessionSet}`,
async () => {
const res = await APIFetchV1<
({ __scoreInfo: Array<SessionScoreInfo> } & MONGO_SessionDocument)[]
({ __scoreInfo: Array<SessionScoreInfo> } & SessionDocument)[]
>(`${baseUrl}/${sessionSet}`);
if (!res.success) {
@@ -70,7 +75,7 @@ export default function SessionsPage({ reqUser, game, playtype }: UGPT) {
<div className="row">
<div className="col-12">
<SessionCalendar
url={`/users/${reqUser.id}/games/${game}/${playtype}/sessions/calendar`}
url={`/users/${reqUser.id}/games/${game}/sessions/calendar`}
user={reqUser}
/>
<Divider />
@@ -113,12 +118,11 @@ export default function SessionsPage({ reqUser, game, playtype }: UGPT) {
dataset={data!}
game={game}
indexCol={sessionSet === "best"}
playtype={playtype}
reqUser={reqUser}
/>
</LoadingWrapper>
) : (
<SearchSessionsTable {...{ game, playtype, reqUser, baseUrl, search }} />
<SearchSessionsTable {...{ game, reqUser, baseUrl, search }} />
)}
</div>
</div>
@@ -128,14 +132,13 @@ export default function SessionsPage({ reqUser, game, playtype }: UGPT) {
function SearchSessionsTable({
search,
game,
playtype,
reqUser,
baseUrl,
}: { baseUrl: string; reqUser: MONGO_UserDocument; search: string } & GamePT) {
}: { baseUrl: string; reqUser: UserDocument; search: string } & GamePT) {
const { data, error } = useQuery<SessionDataset, UnsuccessfulAPIResponse>(
`${baseUrl}?search=${search}`,
async () => {
const res = await APIFetchV1<MONGO_SessionDocument[]>(`${baseUrl}?search=${search}`);
const res = await APIFetchV1<SessionDocument[]>(`${baseUrl}?search=${search}`);
if (!res.success) {
throw res;
@@ -152,12 +155,7 @@ function SearchSessionsTable({
return (
<LoadingWrapper {...{ error, dataset: data }}>
<GenericSessionTable
dataset={data!}
game={game}
playtype={playtype}
reqUser={reqUser}
/>
<GenericSessionTable dataset={data!} game={game} reqUser={reqUser} />
</LoadingWrapper>
);
}
@@ -17,9 +17,9 @@ import { CreateChartMap, CreateScoreIDMap, CreateSongMap } from "#util/data";
import React, { useContext, useMemo, useState } from "react";
import { Badge, Button, Col, Row } from "react-bootstrap";
import { Redirect, useParams } from "react-router-dom";
import { GetGameGroupConfig } from "tachi-common";
import { GameToGameGroup, GetGameGroupConfig } from "tachi-common";
export default function SpecificSessionPage({ reqUser, game, playtype }: UGPT) {
export default function SpecificSessionPage({ reqUser, game }: UGPT) {
const { sessionID } = useParams<{ sessionID: string }>();
const { data, error } = useApiQuery<SessionReturns>(`/sessions/${sessionID}`);
@@ -32,22 +32,20 @@ export default function SpecificSessionPage({ reqUser, game, playtype }: UGPT) {
return <Loading />;
}
if (
data.user.id !== reqUser.id ||
game !== data.session.game ||
playtype !== data.session.playtype
) {
const sessionV3Game = data.session.game;
if (data.user.id !== reqUser.id || game !== sessionV3Game) {
return (
<Redirect
to={`/u/${data.user.username}/games/${data.session.game}/${data.session.playtype}/sessions/${sessionID}`}
to={`/u/${data.user.username}/games/${sessionV3Game}/sessions/${sessionID}`}
/>
);
}
return <SessionPage {...{ data, game, playtype, reqUser }} />;
return <SessionPage {...{ data, game, reqUser }} />;
}
function SessionPage({ data, game, playtype }: { data: SessionReturns } & UGPT) {
function SessionPage({ data, game }: { data: SessionReturns } & UGPT) {
const { settings } = useContext(UserSettingsContext);
const [sessionData, setSessionData] = useState(data);
@@ -60,12 +58,11 @@ function SessionPage({ data, game, playtype }: { data: SessionReturns } & UGPT)
"Users",
user.username,
"Games",
GetGameGroupConfig(game).name,
playtype,
GetGameGroupConfig(GameToGameGroup(game)).name,
"Sessions",
session.name,
],
[session.name, game, playtype, user],
[session.name, game, user],
`${user.username}: ${session.name}`,
);
@@ -30,31 +30,38 @@ import { Alert, Button, Col, Form, Row } from "react-bootstrap";
import { Link } from "react-router-dom";
import {
BMS_TABLES,
FormatGameGroup,
FormatGame,
GameToGameGroup,
GetGameConfig,
GetGameGroupConfig,
GetGamePTConfig,
GetScoreMetrics,
type MONGO_TableDocument,
type MONGO_UGPTSettingsDocument,
type MONGO_UserDocument,
type ShowcaseStatDetails,
type TableDocument,
type UGPTSettingsDocument,
type UserDocument,
} from "tachi-common";
export default function UGPTSettingsPage({ reqUser, game, playtype }: UGPT) {
export default function UGPTSettingsPage({ reqUser, game }: UGPT) {
const query = useQueryString();
const [page, setPage] = useState<"manage" | "preferences" | "showcase">(
query.get("showcase") ? "showcase" : "preferences",
);
const gameConfig = GetGameGroupConfig(game);
const gameConfig = GetGameConfig(game);
useSetSubheader(
["Users", reqUser.username, "Games", gameConfig.name, playtype, "Settings"],
[
"Users",
reqUser.username,
"Games",
GetGameGroupConfig(GameToGameGroup(game)).name,
"Settings",
],
[reqUser],
`${reqUser.username}'s ${FormatGameGroup(game, playtype)} Settings`,
`${reqUser.username}'s ${FormatGame(game)} Settings`,
);
const UGPT = { reqUser, game, playtype };
const UGPT = { reqUser, game };
const { loggedInData } = useContext(UGPTContext);
if (!loggedInData) {
@@ -139,33 +146,32 @@ export default function UGPTSettingsPage({ reqUser, game, playtype }: UGPT) {
function PreferencesForm({
reqUser,
game,
playtype,
loggedInData,
}: { loggedInData: { settings: MONGO_UGPTSettingsDocument } & UGPTData } & UGPT) {
}: { loggedInData: { settings: UGPTSettingsDocument } & UGPTData } & UGPT) {
const { setLoggedInData } = useContext(UGPTContext);
const settings = loggedInData.settings;
const gptConfig = GetGamePTConfig(game, playtype);
const gameConfig = GetGameConfig(game);
const formik = useFormik({
initialValues: {
preferredScoreAlg:
settings.preferences.preferredScoreAlg || gptConfig.defaultScoreRatingAlg,
settings.preferences.preferredScoreAlg || gameConfig.defaultScoreRatingAlg,
preferredProfileAlg:
settings.preferences.preferredProfileAlg || gptConfig.defaultProfileRatingAlg,
settings.preferences.preferredProfileAlg || gameConfig.defaultProfileRatingAlg,
preferredSessionAlg:
settings.preferences.preferredSessionAlg || gptConfig.defaultSessionRatingAlg,
settings.preferences.preferredSessionAlg || gameConfig.defaultSessionRatingAlg,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
gameSpecific: settings.preferences.gameSpecific as any,
defaultTable: settings.preferences.defaultTable,
preferredDefaultEnum:
settings.preferences.preferredDefaultEnum ?? gptConfig.preferredDefaultEnum,
settings.preferences.preferredDefaultEnum ?? gameConfig.preferredDefaultEnum,
preferredRanking: settings.preferences.preferredRanking ?? "global",
},
onSubmit: async (values) => {
const rj = await APIFetchV1<MONGO_UserDocument>(
`/users/${reqUser.id}/games/${game}/${playtype}/settings`,
const rj = await APIFetchV1<UserDocument>(
`/users/${reqUser.id}/games/${game}/settings`,
{
method: "PATCH",
body: JSON.stringify(values),
@@ -180,7 +186,7 @@ function PreferencesForm({
if (rj.success) {
setLoggedInData({
...loggedInData,
settings: deepmerge(settings as MONGO_UGPTSettingsDocument, {
settings: deepmerge(settings as UGPTSettingsDocument, {
preferences: values,
}),
});
@@ -188,8 +194,8 @@ function PreferencesForm({
},
});
const { data: tables, error } = useApiQuery<MONGO_TableDocument[]>(
`/games/${game}/${playtype}/tables?showInactive=true`,
const { data: tables, error } = useApiQuery<TableDocument[]>(
`/games/${game}/tables?showInactive=true`,
);
if (error) {
@@ -208,7 +214,7 @@ function PreferencesForm({
return (
<Form className="d-flex flex-column gap-4" onSubmit={formik.handleSubmit}>
{Object.keys(gptConfig.scoreRatingAlgs).length > 1 && (
{Object.keys(gameConfig.scoreRatingAlgs).length > 1 && (
<Form.Group className={formGroupClassNames}>
<Form.Label>Preferred Score Algorithm</Form.Label>
<Form.Select
@@ -216,9 +222,9 @@ function PreferencesForm({
onChange={formik.handleChange}
value={formik.values.preferredScoreAlg}
>
{Object.keys(gptConfig.scoreRatingAlgs).map((e) => (
{Object.keys(gameConfig.scoreRatingAlgs).map((e) => (
<option key={e} value={e}>
{FormatGPTScoreRatingName(game, playtype, e)}
{FormatGPTScoreRatingName(game, e)}
</option>
))}
</Form.Select>
@@ -228,7 +234,7 @@ function PreferencesForm({
</Form.Text>
</Form.Group>
)}
{Object.keys(gptConfig.sessionRatingAlgs).length > 1 && (
{Object.keys(gameConfig.sessionRatingAlgs).length > 1 && (
<Form.Group className={formGroupClassNames}>
<Form.Label>Preferred Session Algorithm</Form.Label>
<Form.Select
@@ -236,9 +242,9 @@ function PreferencesForm({
onChange={formik.handleChange}
value={formik.values.preferredSessionAlg}
>
{Object.keys(gptConfig.sessionRatingAlgs).map((e) => (
{Object.keys(gameConfig.sessionRatingAlgs).map((e) => (
<option key={e} value={e}>
{FormatGPTSessionRatingName(game, playtype, e)}
{FormatGPTSessionRatingName(game, e)}
</option>
))}
</Form.Select>
@@ -248,7 +254,7 @@ function PreferencesForm({
</Form.Text>
</Form.Group>
)}
{Object.keys(gptConfig.profileRatingAlgs).length > 1 && (
{Object.keys(gameConfig.profileRatingAlgs).length > 1 && (
<Form.Group className={formGroupClassNames}>
<Form.Label>Preferred Profile Algorithm</Form.Label>
<Form.Select
@@ -256,9 +262,9 @@ function PreferencesForm({
onChange={formik.handleChange}
value={formik.values.preferredProfileAlg}
>
{Object.keys(gptConfig.profileRatingAlgs).map((e) => (
{Object.keys(gameConfig.profileRatingAlgs).map((e) => (
<option key={e} value={e}>
{FormatGPTProfileRatingName(game, playtype, e)}
{FormatGPTProfileRatingName(game, e)}
</option>
))}
</Form.Select>
@@ -275,7 +281,7 @@ function PreferencesForm({
onChange={formik.handleChange}
value={formik.values.preferredDefaultEnum}
>
{GetScoreMetrics(gptConfig, "ENUM").map((e) => (
{GetScoreMetrics(gameConfig, "ENUM").map((e) => (
<option key={e} value={e}>
{UppercaseFirst(e)}
</option>
@@ -322,7 +328,7 @@ function PreferencesForm({
What folders would you like to see by default?
</Form.Text>
</Form.Group>
{game === "iidx" && (
{(game === "iidx-sp" || game === "iidx-dp") && (
<>
<Form.Group className={formGroupClassNames}>
<Form.Check
@@ -356,7 +362,7 @@ function PreferencesForm({
</Form.Group>
</>
)}
{(game === "sdvx" || game === "usc") && (
{(game === "sdvx" || game === "usc-controller" || game === "usc-keyboard") && (
<Form.Group className={formGroupClassNames}>
<Form.Label>VF6 Target</Form.Label>
<Form.Control
@@ -379,11 +385,11 @@ function PreferencesForm({
</Form.Text>
</Form.Group>
)}
{game === "bms" && (
{(game === "bms-7k" || game === "bms-14k") && (
<Form.Group className={formGroupClassNames}>
<Form.Label>Preferred Tables</Form.Label>
{BMS_TABLES.filter((e) => e.playtype === playtype).map((e) => (
{BMS_TABLES.filter((e) => e.game === game).map((e) => (
<Form.Check
checked={
formik.values.gameSpecific.displayTables?.includes(e.prefix) ??
@@ -394,9 +400,9 @@ function PreferencesForm({
onChange={(event) => {
const base: Array<string> =
formik.values.gameSpecific.displayTables ??
BMS_TABLES.filter(
(e) => e.playtype === playtype && !e.notDefault,
).map((e) => e.prefix);
BMS_TABLES.filter((e) => e.game === game && !e.notDefault).map(
(e) => e.prefix,
);
if (event.target.checked) {
formik.setFieldValue("gameSpecific.displayTables", [
@@ -429,9 +435,8 @@ function PreferencesForm({
function ShowcaseForm({
reqUser,
game,
playtype,
loggedInData,
}: { loggedInData: { settings: MONGO_UGPTSettingsDocument } & UGPTData } & UGPT) {
}: { loggedInData: { settings: UGPTSettingsDocument } & UGPTData } & UGPT) {
const { setLoggedInData } = useContext(UGPTContext);
const settings = loggedInData.settings;
@@ -440,11 +445,11 @@ function ShowcaseForm({
const [show, setShow] = useState(false);
const SaveChanges = async () => {
const r = await APIFetchV1<MONGO_UGPTSettingsDocument>(
`/users/${reqUser.id}/games/${game}/${playtype}/showcase`,
const r = await APIFetchV1<UGPTSettingsDocument>(
`/users/${reqUser.id}/games/${game}/showcase`,
{
method: "PUT",
body: JSON.stringify(stats),
body: JSON.stringify({ showcase: stats }),
headers: { "Content-Type": "application/json" },
},
true,
@@ -478,13 +483,12 @@ function ShowcaseForm({
</Button>
</div>
)}
<RenderCurrentStats {...{ reqUser, game, playtype, stats, setStats }} />
<RenderCurrentStats {...{ reqUser, game, stats, setStats }} />
<UGPTStatCreator
game={game}
onCreate={(stat) => {
setStats([...stats, stat]);
}}
playtype={playtype}
reqUser={reqUser}
setShow={setShow}
show={show}
@@ -498,7 +502,6 @@ function RenderCurrentStats({
setStats,
reqUser,
game,
playtype,
}: {
setStats: SetState<ShowcaseStatDetails[]>;
stats: ShowcaseStatDetails[];
@@ -519,7 +522,7 @@ function RenderCurrentStats({
<Row className="w-100 row-gap-4" lg={{ cols: 2 }}>
{stats.map((e, i) => (
<Col className="d-flex flex-column gap-4" key={i}>
<UGPTStatContainer game={game} playtype={playtype} reqUser={reqUser} stat={e} />
<UGPTStatContainer game={game} reqUser={reqUser} stat={e} />
<Button className="w-100" onClick={() => RemoveStatAtIndex(i)} variant="danger">
Delete
</Button>
@@ -529,7 +532,7 @@ function RenderCurrentStats({
);
}
function ManageAccount({ reqUser, game, playtype }: UGPT) {
function ManageAccount({ reqUser, game }: UGPT) {
const [password, setPassword] = useState("");
const [deleting, setDeleting] = useState(false);
@@ -549,13 +552,12 @@ function ManageAccount({ reqUser, game, playtype }: UGPT) {
<Col className="mt-8" xs={12}>
<h3>Completely Wipe Profile</h3>
If you've <i>really</i> messed up, you can wipe your entire profile for{" "}
{FormatGameGroup(game, playtype)}.
{FormatGame(game)}.
<br />
<Alert className="mt-4" style={{ fontSize: "1.5rem" }} variant="warning">
It is very important to know that this is <b>NOT REVERSIBLE.</b> Wiping your
profile will <b>COMPLETELY DELETE</b> all of your{" "}
{FormatGameGroup(game, playtype)} scores from our server. We will not be able to
retrieve them.
profile will <b>COMPLETELY DELETE</b> all of your {FormatGame(game)} scores from
our server. We will not be able to retrieve them.
</Alert>
<br />
<Form.Group>
@@ -573,16 +575,13 @@ function ManageAccount({ reqUser, game, playtype }: UGPT) {
onClick={async () => {
if (
confirm(
`You are really about to delete all of your ${FormatGameGroup(
game,
playtype,
)} scores. This is your last chance to turn back.`,
`You are really about to delete all of your ${FormatGame(game)} scores. This is your last chance to turn back.`,
)
) {
setDeleting(true);
const res = await APIFetchV1(
`/users/${reqUser.id}/games/${game}/${playtype}`,
`/users/${reqUser.id}/games/${game}`,
{
method: "DELETE",
body: JSON.stringify({ "!password": password }),
@@ -597,14 +596,13 @@ function ManageAccount({ reqUser, game, playtype }: UGPT) {
setDeleting(false);
if (res.success) {
// bye!
window.location.href = "/";
}
}
}}
variant="outline-danger"
>
Yes, I want to delete my {FormatGameGroup(game, playtype)} account.
Yes, I want to delete my {FormatGame(game)} account.
</Button>
{deleting && (
<>
@@ -21,28 +21,24 @@ import Form from "react-bootstrap/Form";
import Row from "react-bootstrap/Row";
import Stack from "react-bootstrap/Stack";
import { Link } from "react-router-dom";
import { type MONGO_FolderDocument, type MONGO_UserDocument } from "tachi-common";
import { type FolderDocument, type UserDocument } from "tachi-common";
export default function RivalCompareFolderPage({
reqUser,
game,
playtype,
folder,
}: {
folder: MONGO_FolderDocument;
reqUser: MONGO_UserDocument;
folder: FolderDocument;
reqUser: UserDocument;
} & GamePT) {
const { settings } = useLUGPTSettings();
const { user } = useContext(UserContext);
const [selectedUser, setSelectedUser] = useState<MONGO_UserDocument | null>(null);
const [selectedUser, setSelectedUser] = useState<UserDocument | null>(null);
const [show, setShow] = useState(false);
// honestly don't care if this errors or not
const { data, error } = useApiQuery<Array<MONGO_UserDocument>>(
`/users/${settings?.userID}/games/${game}/${playtype}/rivals`,
undefined,
// [settings?.rivals.join(",")]
const { data, error } = useApiQuery<Array<UserDocument>>(
`/users/${settings?.userID}/games/${game}/rivals`,
);
if (!data && !error) {
@@ -51,9 +47,6 @@ export default function RivalCompareFolderPage({
let suggestUsers = data ?? [];
// if the user viewing this page is logged in and *is not*
// the person this page is for
// show them in the suggestions.
if (user && user.id !== reqUser.id && data) {
suggestUsers = [...suggestUsers, user];
}
@@ -65,7 +58,7 @@ export default function RivalCompareFolderPage({
<>
<Col className="d-flex justify-content-center flex-wrap" xs={12}>
{suggestUsers.map((e) => (
<UserIcon game={game} key={e.id} playtype={playtype} user={e}>
<UserIcon game={game} key={e.id} user={e}>
<Button
onClick={() => setSelectedUser(e)}
variant={
@@ -91,7 +84,7 @@ export default function RivalCompareFolderPage({
excludeSet={[reqUser.id]}
setShow={setShow}
show={show}
url={`/games/${game}/${playtype}/players`}
url={`/games/${game}/players`}
/>
<Button
className={suggestUsers.length === 0 ? "d-flex mx-auto" : ""}
@@ -107,7 +100,6 @@ export default function RivalCompareFolderPage({
<FolderCompare
folder={folder}
game={game}
playtype={playtype}
reqUser={reqUser}
withUser={selectedUser}
/>
@@ -120,26 +112,24 @@ export default function RivalCompareFolderPage({
function FolderCompare({
reqUser,
game,
playtype,
withUser,
folder,
}: {
folder: MONGO_FolderDocument;
reqUser: MONGO_UserDocument;
withUser: MONGO_UserDocument;
folder: FolderDocument;
reqUser: UserDocument;
withUser: UserDocument;
} & GamePT) {
const { data: baseData, error: baseError } = useApiQuery<UGPTFolderReturns>(
`/users/${reqUser.id}/games/${game}/${playtype}/folders/${folder.folderID}`,
`/users/${reqUser.id}/games/${game}/folders/${folder.slug}`,
);
const { data: compareData, error: compareError } = useApiQuery<UGPTFolderReturns>(
`/users/${withUser.id}/games/${game}/${playtype}/folders/${folder.folderID}`,
`/users/${withUser.id}/games/${game}/folders/${folder.slug}`,
);
const [shouldIncludeNotPlayed, setShouldIncludeNotPlayed] = useState(false);
const dataset = useMemo(() => {
// i *LOVE* the rules of hooks! they're so convenient!
if (!baseData || !compareData) {
return [];
}
@@ -153,7 +143,7 @@ function FolderCompare({
chart,
base: basePBLookup.get(chart.chartID) ?? null,
compare: comparePBLookup.get(chart.chartID) ?? null,
song: songMap.get(chart.songID)!,
song: songMap.get(chart.song.id)!,
}));
if (!shouldIncludeNotPlayed) {
@@ -177,14 +167,9 @@ function FolderCompare({
return (
<Stack gap={4}>
<Row lg={{ cols: 2 }} xs={{ cols: 1 }}>
<UserCard game={game} playtype={playtype} user={reqUser} />
<UserCard game={game} playtype={playtype} user={withUser} />
<UserCard game={game} user={reqUser} />
<UserCard game={game} user={withUser} />
</Row>
{/* <CompareCard
dataset={dataset}
baseUsername={reqUser.username}
otherUsername={withUser.username}
/> */}
<Form.Check
checked={shouldIncludeNotPlayed}
label="Include charts without plays?"
@@ -196,16 +181,13 @@ function FolderCompare({
compareUser={withUser.username}
dataset={dataset}
game={game}
playtype={playtype}
/>
</Stack>
);
}
function UserCard({ user, game, playtype }: { user: MONGO_UserDocument } & GamePT) {
const { data, error } = useApiQuery<UGPTStatsReturn>(
`/users/${user.username}/games/${game}/${playtype}`,
);
function UserCard({ user, game }: { user: UserDocument } & GamePT) {
const { data, error } = useApiQuery<UGPTStatsReturn>(`/users/${user.username}/games/${game}`);
if (error) {
return <ApiError error={error} />;
@@ -217,7 +199,7 @@ function UserCard({ user, game, playtype }: { user: MONGO_UserDocument } & GameP
<div className="d-flex flex-column">
<Link
className="fw-bold fs-4 text-center text-lg-start"
to={`/u/${user.username}/games/${game}/${playtype}`}
to={`/u/${user.username}/games/${game}`}
>
{user.username}
</Link>
@@ -5,26 +5,25 @@ import Divider from "#components/util/Divider";
import Loading from "#components/util/Loading";
import useApiQuery from "#components/util/query/useApiQuery";
import { TargetsContext } from "#context/TargetsContext";
import { type GoalsOnChartReturn, type GoalsOnFolderReturn } from "#types/api-returns";
import { type GoalsOnFolderReturn } from "#types/api-returns";
import { type UGPT } from "#types/react";
import { CreateGoalSubDataset, CreateUserMap } from "#util/data";
import React, { useContext, useReducer, useState } from "react";
import { Button, Col } from "react-bootstrap";
import { type MONGO_FolderDocument } from "tachi-common";
import { type FolderDocument } from "tachi-common";
export default function FolderQuestsPage({
folder,
game,
playtype,
reqUser,
}: {
folder: MONGO_FolderDocument;
folder: FolderDocument;
} & UGPT) {
const [refresh, forceRefresh] = useReducer((x) => x + 1, 0);
const { reloadTargets } = useContext(TargetsContext);
const { data, error } = useApiQuery<GoalsOnChartReturn>(
`/users/${reqUser.id}/games/${game}/${playtype}/targets/on-folder/${folder.folderID}`,
const { data, error } = useApiQuery<GoalsOnFolderReturn>(
`/users/${reqUser.id}/games/${game}/targets/on-folder/${folder.slug}`,
undefined,
[refresh],
);
@@ -40,15 +39,11 @@ export default function FolderQuestsPage({
</Col>
<Divider />
{error && <ApiError error={error} />}
{data ? (
<FolderQuestsInner {...{ reqUser, game, playtype, folder, data }} />
) : (
<Loading />
)}
{data ? <FolderQuestsInner {...{ reqUser, game, folder, data }} /> : <Loading />}
{show && (
<SetNewGoalModal
{...{ game, playtype, reqUser, show, setShow }}
{...{ game, reqUser, show, setShow }}
onNewGoalSet={() => {
forceRefresh();
reloadTargets();
@@ -63,20 +58,13 @@ export default function FolderQuestsPage({
function FolderQuestsInner({
reqUser,
game,
playtype,
folder,
data,
}: {
data: GoalsOnFolderReturn;
folder: MONGO_FolderDocument;
folder: FolderDocument;
} & UGPT) {
const userMap = CreateUserMap([reqUser]);
return (
<GoalSubInfo
dataset={CreateGoalSubDataset(data, userMap)}
game={game}
playtype={playtype}
/>
);
return <GoalSubInfo dataset={CreateGoalSubDataset(data, userMap)} game={game} />;
}
@@ -17,21 +17,20 @@ import { APIFetchV1 } from "#util/api";
import { Reverse, UppercaseFirst } from "#util/misc";
import React, { useContext, useMemo, useState } from "react";
import {
GetGamePTConfig,
GetGPTString,
type FolderDocument,
GetGameConfig,
GetScoreMetricConf,
GetScoreMetrics,
type MONGO_FolderDocument,
} from "tachi-common";
import { type ConfEnumScoreMetric } from "tachi-common/types/metrics";
export default function FoldersSearch({ reqUser, game, playtype }: UGPT) {
export default function FoldersSearch({ reqUser, game }: UGPT) {
const [search, setSearch] = useState("");
const params = useMemo(() => new URLSearchParams({ search }), [search]);
const { data, error } = useApiQuery<UGPTFolderSearch>(
`/users/${reqUser.id}/games/${game}/${playtype}/folders?${params.toString()}`,
`/users/${reqUser.id}/games/${game}/folders?${params.toString()}`,
);
let body = <></>;
@@ -44,7 +43,7 @@ export default function FoldersSearch({ reqUser, game, playtype }: UGPT) {
const statMap = new Map();
for (const stat of data.stats) {
statMap.set(stat.folderID, stat);
statMap.set(stat.slug, stat);
}
body = (
@@ -55,10 +54,9 @@ export default function FoldersSearch({ reqUser, game, playtype }: UGPT) {
{data.folders.map((e) => (
<FolderInfoComponent
folder={e}
folderStats={statMap.get(e.folderID)!}
folderStats={statMap.get(e.slug)!}
game={game}
key={e.folderID}
playtype={playtype}
key={e.slug}
reqUser={reqUser}
/>
))}
@@ -81,21 +79,20 @@ export default function FoldersSearch({ reqUser, game, playtype }: UGPT) {
export function FolderInfoComponent({
reqUser,
game,
playtype,
folderStats,
folder,
}: { folder: MONGO_FolderDocument; folderStats: FolderStatsInfo } & UGPT) {
const gptConfig = GetGamePTConfig(game, playtype);
const gptImpl = GPT_CLIENT_IMPLEMENTATIONS[GetGPTString(game, playtype)];
}: { folder: FolderDocument; folderStats: FolderStatsInfo } & UGPT) {
const gameConfig = GetGameConfig(game);
const gptImpl = GPT_CLIENT_IMPLEMENTATIONS[game];
const preferredDefaultEnum = useBucket(game, playtype);
const preferredDefaultEnum = useBucket(game);
const [metric, setMetric] = useState<string>(preferredDefaultEnum);
const base = useUGPTBase({ reqUser, game, playtype });
const base = useUGPTBase({ reqUser, game });
const dataset = useMemo(() => {
const conf = GetScoreMetricConf(gptConfig, metric) as ConfEnumScoreMetric<string>;
const conf = GetScoreMetricConf(gameConfig, metric) as ConfEnumScoreMetric<string>;
return (
<DistributionTable
@@ -119,12 +116,12 @@ export function FolderInfoComponent({
onClick={() => {
if (user?.id === reqUser.id) {
APIFetchV1(
`/users/${reqUser.id}/games/${game}/${playtype}/folders/${folder.folderID}/viewed`,
`/users/${reqUser.id}/games/${game}/folders/${folder.slug}/viewed`,
{ method: "POST" },
);
}
}}
to={`${base}/folders/${folder.folderID}`}
to={`${base}/folders/${folder.slug}`}
variant="outline-info"
>
View
@@ -136,7 +133,7 @@ export function FolderInfoComponent({
<div className="row text-center">
<div className="col-12">
<div className="btn-group">
{GetScoreMetrics(gptConfig, "ENUM").map((e) => (
{GetScoreMetrics(gameConfig, "ENUM").map((e) => (
<SelectButton id={e} key={e} setValue={setMetric} value={metric}>
{/* @ts-expect-error this access is legal zzz */}
<Icon type={gptImpl.enumIcons[e]} /> {UppercaseFirst(e)}s
@@ -2,7 +2,6 @@ import { BarChartTooltip } from "#components/charts/ChartTooltip";
import Card from "#components/layout/page/Card";
import TachiTable from "#components/tables/components/TachiTable";
import ApiError from "#components/util/ApiError";
import DebugContent from "#components/util/DebugContent";
import Divider from "#components/util/Divider";
import Icon from "#components/util/Icon";
import LinkButton from "#components/util/LinkButton";
@@ -25,29 +24,24 @@ import React, { useContext, useEffect, useMemo, useState } from "react";
import Form from "react-bootstrap/Form";
import InputGroup from "react-bootstrap/InputGroup";
import {
type GameGroup,
type GamePTConfig,
GetGamePTConfig,
GetGPTString,
type FolderDocument,
type GameConfig,
GetGameConfig,
GetScoreMetricConf,
GetScoreMetrics,
type MONGO_FolderDocument,
type MONGO_TableDocument,
type MONGO_UserDocument,
type Playtype,
type TableDocument,
type UserDocument,
type V3Game,
} from "tachi-common";
import { type ConfEnumScoreMetric } from "tachi-common/types/metrics";
interface Props {
reqUser: MONGO_UserDocument;
game: GameGroup;
playtype: Playtype;
reqUser: UserDocument;
game: V3Game;
}
export default function FolderTablePage({ reqUser, game, playtype }: Props) {
const { data, error } = useApiQuery<MONGO_TableDocument[]>(
`/games/${game}/${playtype}/tables?showInactive=true`,
);
export default function FolderTablePage({ reqUser, game }: Props) {
const { data, error } = useApiQuery<TableDocument[]>(`/games/${game}/tables?showInactive=true`);
const { settings } = useLUGPTSettings();
@@ -114,24 +108,19 @@ export default function FolderTablePage({ reqUser, game, playtype }: Props) {
</Form.Select>
</InputGroup>
<Divider />
{table && <TableFolderViewer {...{ reqUser, game, playtype, table }} />}
{table && <TableFolderViewer {...{ reqUser, game, table }} />}
</>
);
}
interface UGPTFolderStats {
folder: MONGO_FolderDocument;
folder: FolderDocument;
stats: FolderStatsInfo;
}
function TableFolderViewer({
reqUser,
game,
playtype,
table,
}: { table: MONGO_TableDocument } & Props) {
function TableFolderViewer({ reqUser, game, table }: { table: TableDocument } & Props) {
const { data, error } = useApiQuery<UGPTTableReturns>(
`/users/${reqUser.id}/games/${game}/${playtype}/tables/${table.tableID}`,
`/users/${reqUser.id}/games/${game}/tables/${table.tableID}`,
);
const [dataMap, setDataMap] = useState<Map<string, UGPTFolderStats>>(new Map());
@@ -141,13 +130,13 @@ function TableFolderViewer({
if (data) {
const statMap = new Map();
for (const stat of data.stats) {
statMap.set(stat.folderID, stat);
statMap.set(stat.slug, stat);
}
const newMap = new Map();
for (const folder of data.folders) {
const stats = statMap.get(folder.folderID)!;
newMap.set(folder.folderID, { folder, stats });
const stats = statMap.get(folder.slug)!;
newMap.set(folder.slug, { folder, stats });
}
setDataMap(newMap);
setHasLoadedFolderMap(true);
@@ -166,13 +155,7 @@ function TableFolderViewer({
<>
<TableBarChart dataMap={dataMap} table={table} />
<Divider />
<TableFolderTable
dataMap={dataMap}
game={game}
playtype={playtype}
reqUser={reqUser}
table={table}
/>
<TableFolderTable dataMap={dataMap} game={game} reqUser={reqUser} table={table} />
</>
);
}
@@ -182,10 +165,9 @@ function TableFolderTable({
dataMap,
reqUser,
game,
playtype,
}: {
dataMap: Map<string, UGPTFolderStats>;
table: MONGO_TableDocument;
table: TableDocument;
} & Props) {
const dataset = useMemo(() => {
const arr = [];
@@ -224,12 +206,12 @@ function TableFolderTable({
onClick={() => {
if (user?.id === reqUser.id) {
APIFetchV1(
`/users/${reqUser.id}/games/${game}/${playtype}/folders/${data.folder.folderID}/viewed`,
`/users/${reqUser.id}/games/${game}/folders/${data.folder.slug}/viewed`,
{ method: "POST" },
);
}
}}
to={`/u/${reqUser.username}/games/${game}/${playtype}/folders/${data.folder.folderID}`}
to={`/u/${reqUser.username}/games/${game}/folders/${data.folder.slug}`}
>
View Breakdown
</LinkButton>
@@ -250,23 +232,23 @@ function TableBarChart({
dataMap,
}: {
dataMap: Map<string, UGPTFolderStats>;
table: MONGO_TableDocument;
table: TableDocument;
}) {
const gptConfig = GetGamePTConfig(table.game, table.playtype);
const gptImpl = GPT_CLIENT_IMPLEMENTATIONS[GetGPTString(table.game, table.playtype)];
const tableGame = table.game;
const gameConfig = GetGameConfig(tableGame);
const gptImpl = GPT_CLIENT_IMPLEMENTATIONS[tableGame];
const bucket = useBucket(table.game, table.playtype);
const bucket = useBucket(tableGame);
const [enumMetric, setEnumMetric] = useState<string>(bucket);
// @ts-expect-error hack!
const colours = useMemo(() => gptImpl.enumColours[enumMetric], [enumMetric]);
const colours = useMemo(() => (gptImpl.enumColours as any)[enumMetric], [enumMetric]);
const dataset = useMemo(() => {
const arr = [];
for (const folderID of table.folders) {
const data = dataMap.get(folderID)!;
for (const folderSlug of table.folders) {
const data = dataMap.get(folderSlug)!;
if (!data) {
continue;
@@ -301,7 +283,7 @@ function TableBarChart({
<div className="row">
<div className="col-12 d-flex justify-content-center">
<div className="btn-group">
{GetScoreMetrics(gptConfig, "ENUM").map((e) => (
{GetScoreMetrics(gameConfig, "ENUM").map((e) => (
<SelectButton
id={e}
key={e}
@@ -327,7 +309,7 @@ function TableBarChart({
<OverviewBarChart
colours={colours}
dataset={dataset}
gptConfig={gptConfig}
gameConfig={gameConfig}
// This hack is necessary because otherwise nivo gets caught in a render loop and dies
key={enumMetric}
mode={enumMetric}
@@ -340,14 +322,14 @@ function TableBarChart({
}
function OverviewBarChart({
gptConfig,
gameConfig,
mode: metric,
dataset,
colours,
}: {
colours: any;
dataset: any;
gptConfig: GamePTConfig;
gameConfig: GameConfig;
mode: string;
}) {
const longestTitle = useMemo(
@@ -360,7 +342,7 @@ function OverviewBarChart({
[dataset],
);
const conf = GetScoreMetricConf(gptConfig, metric) as ConfEnumScoreMetric<string>;
const conf = GetScoreMetricConf(gameConfig, metric) as ConfEnumScoreMetric<string>;
return (
<ResponsiveBar
@@ -1,5 +1,4 @@
import useSetSubheader from "#components/layout/header/useSetSubheader";
import Divider from "#components/util/Divider";
import Icon from "#components/util/Icon";
import SelectLinkButton from "#components/util/SelectLinkButton";
import useUGPTBase from "#components/util/useUGPTBase";
@@ -8,32 +7,36 @@ import { UserContext } from "#context/UserContext";
import { type UGPT } from "#types/react";
import React, { useContext } from "react";
import { Route, Switch } from "react-router-dom";
import { FormatGameGroup, GetGameGroupConfig } from "tachi-common";
import { FormatGame, GameToGameGroup, GetGameGroupConfig } from "tachi-common";
import FolderSelectPage from "./FolderSelectPage";
import FolderTablePage from "./FolderTablePage";
import RecentFoldersPage from "./RecentFoldersPage";
import SpecificFolderPage from "./SpecificFolderPage";
export default function FoldersMainPage({ reqUser, game, playtype }: UGPT) {
const gameConfig = GetGameGroupConfig(game);
export default function FoldersMainPage({ reqUser, game }: UGPT) {
const { user } = useContext(UserContext);
const { ugs } = useContext(AllLUGPTStatsContext);
useSetSubheader(
["Users", reqUser.username, "Games", gameConfig.name, playtype, "Folders"],
[reqUser, game, playtype],
`${reqUser.username}'s ${FormatGameGroup(game, playtype)} Folders`,
[
"Users",
reqUser.username,
"Games",
GetGameGroupConfig(GameToGameGroup(game)).name,
"Folders",
],
[reqUser, game],
`${reqUser.username}'s ${FormatGame(game)} Folders`,
);
const base = useUGPTBase({ reqUser, game, playtype });
const base = useUGPTBase({ reqUser, game });
return (
<div className="row">
<div className="col-12 text-center">
<div className="btn-group d-flex justify-content-center mb-8">
{user && ugs?.find((x) => x.game === game && x.playtype === playtype) && (
{user && ugs?.find((x) => x.game === game) && (
<SelectLinkButton className="text-wrap" to={`${base}/folders/recent`}>
<Icon type="clock" />{" "}
{user.id === reqUser.id
@@ -52,17 +55,17 @@ export default function FoldersMainPage({ reqUser, game, playtype }: UGPT) {
</div>
<div className="col-12">
<Switch>
<Route exact path="/u/:userID/games/:game/:playtype/folders">
<FolderTablePage {...{ reqUser, game, playtype }} />
<Route exact path="/u/:userID/games/:game/folders">
<FolderTablePage {...{ reqUser, game }} />
</Route>
<Route exact path="/u/:userID/games/:game/:playtype/folders/search">
<FolderSelectPage {...{ reqUser, game, playtype }} />
<Route exact path="/u/:userID/games/:game/folders/search">
<FolderSelectPage {...{ reqUser, game }} />
</Route>
<Route exact path="/u/:userID/games/:game/:playtype/folders/recent">
<RecentFoldersPage {...{ reqUser, game, playtype }} />
<Route exact path="/u/:userID/games/:game/folders/recent">
<RecentFoldersPage {...{ reqUser, game }} />
</Route>
<Route path="/u/:userID/games/:game/:playtype/folders/:folderID">
<SpecificFolderPage {...{ reqUser, game, playtype }} />
<Route path="/u/:userID/games/:game/folders/:folderSlug">
<SpecificFolderPage {...{ reqUser, game }} />
</Route>
</Switch>
</div>
@@ -8,29 +8,29 @@ import React, { useContext } from "react";
import { Row } from "react-bootstrap";
import { Link } from "react-router-dom";
import {
type MONGO_FolderDocument,
type MONGO_RecentlyViewedFolderDocument,
type MONGO_UserDocument,
type FolderDocument,
type RecentlyViewedFolderDocument,
type UserDocument,
} from "tachi-common";
import { FolderInfoComponent } from "./FolderSelectPage";
export default function RecentFoldersPage({ reqUser, game, playtype }: UGPT) {
export default function RecentFoldersPage({ reqUser, game }: UGPT) {
const { user } = useContext(UserContext);
if (!user) {
return <>Hey, you're not logged in. How did you get here!</>;
}
return <Inner game={game} playtype={playtype} reqUser={reqUser} user={user} />;
return <Inner game={game} reqUser={reqUser} user={user} />;
}
function Inner({ reqUser, game, playtype, user }: { user: MONGO_UserDocument } & UGPT) {
function Inner({ reqUser, game, user }: { user: UserDocument } & UGPT) {
const { data, error } = useApiQuery<{
folders: MONGO_FolderDocument[];
folders: FolderDocument[];
stats: FolderStatsInfo[];
views: MONGO_RecentlyViewedFolderDocument[];
}>(`/users/${user.id}/games/${game}/${playtype}/folders/recent`);
views: RecentlyViewedFolderDocument[];
}>(`/users/${user.id}/games/${game}/folders/recent`);
if (error) {
return <ApiError error={error} />;
@@ -44,9 +44,7 @@ function Inner({ reqUser, game, playtype, user }: { user: MONGO_UserDocument } &
return (
<div className="text-center">
Looks like you've not recently interacted with any folders.{" "}
<Link to={`/u/${user.username}/games/${game}/${playtype}/folders`}>
Go do that!
</Link>
<Link to={`/u/${user.username}/games/${game}/folders`}>Go do that!</Link>
</div>
);
}
@@ -56,9 +54,8 @@ function Inner({ reqUser, game, playtype, user }: { user: MONGO_UserDocument } &
for (const recent of data.views) {
dataset.push({
view: recent,
// Is it really O(n^2) if the input is capped at 4?
folder: data.folders.find((x) => x.folderID === recent.folderID),
stats: data.stats.find((x) => x.folderID === recent.folderID),
folder: data.folders.find((x) => x.slug === recent.slug),
stats: data.stats.find((x) => x.slug === recent.slug),
});
}
@@ -66,9 +63,7 @@ function Inner({ reqUser, game, playtype, user }: { user: MONGO_UserDocument } &
<Row>
{dataset.map((e) => {
if (e.folder === undefined || e.stats === undefined) {
return (
<>Failed to load folder {e.view.folderID}. This is a bug. Report this!</>
);
return <>Failed to load folder {e.view.slug}. This is a bug. Report this!</>;
}
return (
@@ -76,8 +71,7 @@ function Inner({ reqUser, game, playtype, user }: { user: MONGO_UserDocument } &
folder={e.folder}
folderStats={e.stats}
game={game}
key={e.folder.folderID}
playtype={playtype}
key={e.folder.slug}
reqUser={reqUser}
/>
);
@@ -32,19 +32,16 @@ import React, { useContext, useEffect, useMemo, useState } from "react";
import { Col, Form, Row } from "react-bootstrap";
import { Link, Route, Switch, useParams } from "react-router-dom";
import {
type ChartDocument,
COLOUR_SET,
FormatDifficultyShort,
type GameGroup,
GetGamePTConfig,
GetGPTString,
GetGameConfig,
GetScoreEnumConfs,
type GPTString,
type integer,
type MONGO_ChartDocument,
type MONGO_ScoreDocument,
type MONGO_SongDocument,
type MONGO_UserDocument,
type Playtype,
type ScoreDocument,
type SongDocument,
type UserDocument,
type V3Game,
} from "tachi-common";
import { type ConfEnumScoreMetric } from "tachi-common/types/metrics";
@@ -52,16 +49,15 @@ import FolderComparePage from "./FolderComparePage";
import FolderQuestsPage from "./FolderQuestsPage";
interface Props {
reqUser: MONGO_UserDocument;
game: GameGroup;
playtype: Playtype;
reqUser: UserDocument;
game: V3Game;
}
export default function SpecificFolderPage({ reqUser, game, playtype }: Props) {
const { folderID } = useParams<{ folderID: string }>();
export default function SpecificFolderPage({ reqUser, game }: Props) {
const { folderSlug } = useParams<{ folderSlug: string }>();
const { data, error } = useApiQuery<UGPTFolderReturns>(
`/users/${reqUser.id}/games/${game}/${playtype}/folders/${folderID}`,
`/users/${reqUser.id}/games/${game}/folders/${folderSlug}`,
);
const folderDataset = useMemo(() => {
@@ -79,7 +75,7 @@ export default function SpecificFolderPage({ reqUser, game, playtype }: Props) {
...chart,
__related: {
pb: pbMap.get(chart.chartID) ?? null,
song: songMap.get(chart.songID)!,
song: songMap.get(chart.song.id)!,
user: reqUser,
},
});
@@ -100,13 +96,12 @@ export default function SpecificFolderPage({ reqUser, game, playtype }: Props) {
folderDataset={folderDataset}
folderTitle={data.folder.title}
game={game}
playtype={playtype}
reqUser={reqUser}
/>
);
}, [folderDataset]);
const base = `${useUGPTBase({ reqUser, game, playtype })}/folders/${folderID}`;
const base = `${useUGPTBase({ reqUser, game })}/folders/${folderSlug}`;
if (error) {
return <ApiError error={error} />;
@@ -116,7 +111,7 @@ export default function SpecificFolderPage({ reqUser, game, playtype }: Props) {
return <Loading />;
}
const gptImpl = GPT_CLIENT_IMPLEMENTATIONS[`${game}:${playtype}` as GPTString];
const gptImpl = GPT_CLIENT_IMPLEMENTATIONS[game];
return (
<div className="row">
@@ -131,8 +126,10 @@ export default function SpecificFolderPage({ reqUser, game, playtype }: Props) {
</SelectLinkButton>
{gptImpl.ratingSystems.length !== 0 &&
// temp: tierlist view sucks for BMS and PMS
game !== "bms" &&
game !== "pms" && (
game !== "bms-7k" &&
game !== "bms-14k" &&
game !== "pms-controller" &&
game !== "pms-keyboard" && (
<SelectLinkButton className="text-wrap" to={`${base}/tierlist`}>
<Icon type="sort-alpha-up" /> Tierlist View
</SelectLinkButton>
@@ -154,40 +151,24 @@ export default function SpecificFolderPage({ reqUser, game, playtype }: Props) {
<div className="col-12">
<Switch>
<Route exact path={base}>
<FolderTable dataset={folderDataset} game={game} playtype={playtype} />
<FolderTable dataset={folderDataset} game={game} />
</Route>
<Route exact path={`${base}/tierlist`}>
<TierlistBreakdown
data={data}
folderDataset={folderDataset}
game={game}
playtype={playtype}
reqUser={reqUser}
/>
</Route>
<Route exact path={`${base}/timeline`}>
<TimelineView
folderID={folderID}
game={game}
playtype={playtype}
reqUser={reqUser}
/>
<TimelineView folderSlug={folderSlug} game={game} reqUser={reqUser} />
</Route>
<Route exact path={`${base}/compare`}>
<FolderComparePage
folder={data.folder}
game={game}
playtype={playtype}
reqUser={reqUser}
/>
<FolderComparePage folder={data.folder} game={game} reqUser={reqUser} />
</Route>
<Route exact path={`${base}/targets`}>
<FolderQuestsPage
folder={data.folder}
game={game}
playtype={playtype}
reqUser={reqUser}
/>
<FolderQuestsPage folder={data.folder} game={game} reqUser={reqUser} />
</Route>
</Switch>
</div>
@@ -195,11 +176,11 @@ export default function SpecificFolderPage({ reqUser, game, playtype }: Props) {
);
}
function TimelineView({ game, playtype, reqUser, folderID }: { folderID: string } & Props) {
const gptConfig = GetGamePTConfig(game, playtype);
const enumConfs = GetScoreEnumConfs(gptConfig);
function TimelineView({ game, reqUser, folderSlug }: { folderSlug: string } & Props) {
const gameConfig = GetGameConfig(game);
const enumConfs = GetScoreEnumConfs(gameConfig);
const [selectedEnum, setSelectedEnum] = useState<string>(gptConfig.preferredDefaultEnum);
const [selectedEnum, setSelectedEnum] = useState<string>(gameConfig.preferredDefaultEnum);
const [enumConf, setEnumConf] = useState<ConfEnumScoreMetric<string>>(enumConfs[selectedEnum]!);
const [value, setValue] = useState<string>(enumConf.minimumRelevantValue);
@@ -237,9 +218,7 @@ function TimelineView({ game, playtype, reqUser, folderID }: { folderID: string
</div>
</Card>
<hr />
<TimelineMain
{...{ reqUser, game, playtype, folderID, enumMetric: selectedEnum, value }}
/>
<TimelineMain {...{ reqUser, game, folderSlug, enumMetric: selectedEnum, value }} />
</>
);
}
@@ -247,23 +226,22 @@ function TimelineView({ game, playtype, reqUser, folderID }: { folderID: string
function TimelineMain({
reqUser,
game,
playtype,
folderID,
folderSlug,
enumMetric: enumMetric,
value,
}: {
enumMetric: string;
folderID: string;
folderSlug: string;
value: string;
} & Props) {
const { data, error } = useApiQuery<{
charts: MONGO_ChartDocument[];
scores: MONGO_ScoreDocument[];
songs: MONGO_SongDocument[];
charts: ChartDocument[];
scores: ScoreDocument[];
songs: SongDocument[];
}>(
`/users/${
reqUser.id
}/games/${game}/${playtype}/folders/${folderID}/timeline?criteriaValue=${encodeURIComponent(
}/games/${game}/folders/${folderSlug}/timeline?criteriaValue=${encodeURIComponent(
value,
)}&criteriaType=${encodeURIComponent(enumMetric)}`,
);
@@ -369,10 +347,10 @@ function TimelineElement({
index: integer;
scoreData: {
__related: {
chart: MONGO_ChartDocument;
song: MONGO_SongDocument;
chart: ChartDocument;
song: SongDocument;
};
} & MONGO_ScoreDocument;
} & ScoreDocument;
}) {
return (
<div className="timeline-item">
@@ -423,24 +401,20 @@ type InfoProps = {
folderDataset: FolderDataset;
} & Props;
function TierlistBreakdown({ game, folderDataset, playtype, reqUser }: InfoProps) {
const gptImpl = GPT_CLIENT_IMPLEMENTATIONS[`${game}:${playtype}` as GPTString];
function TierlistBreakdown({ game, folderDataset, reqUser }: InfoProps) {
const gptImpl = GPT_CLIENT_IMPLEMENTATIONS[game];
const [tierlist, setTierlist] = useState<string>(gptImpl.ratingSystems[0].name);
const [useFancyColour, setUseFancyColour] = useState(false);
const [forceGridView, setForceGridView] = useState(false);
const playerStats = useMemo(
() => FolderDatasetAchievedStatus(folderDataset, game, playtype, tierlist),
() => FolderDatasetAchievedStatus(folderDataset, game, tierlist),
[tierlist],
);
// @ts-expect-error Typescript doesn't like our unioning here for some reason
// and i can't be bothered to figure it out
const tierlistImpl: GPTRatingSystem<GPTString> = gptImpl.ratingSystems.find(
// @ts-expect-error Typescript doesn't like our unioning here for some reason
// and i can't be bothered to figure it out
(rs: GPTRatingSystem<GPTString>) => rs.name === tierlist,
const tierlistImpl = (gptImpl.ratingSystems as GPTRatingSystem<V3Game>[]).find(
(rs) => rs.name === tierlist,
);
if (!tierlistImpl) {
@@ -501,7 +475,6 @@ function TierlistBreakdown({ game, folderDataset, playtype, reqUser }: InfoProps
forceGridView={forceGridView}
game={game}
playerStats={playerStats}
playtype={playtype}
reqUser={reqUser}
tierlistImpl={tierlistImpl}
useFancyColour={useFancyColour}
@@ -514,7 +487,6 @@ function TierlistBreakdown({ game, folderDataset, playtype, reqUser }: InfoProps
function TierlistInfoLadder({
playerStats,
game,
playtype,
reqUser,
folderDataset,
tierlistImpl,
@@ -523,11 +495,10 @@ function TierlistInfoLadder({
}: {
folderDataset: FolderDataset;
forceGridView: boolean;
game: GameGroup;
game: V3Game;
playerStats: Record<string, { score: string | null; status: AchievedStatuses }>;
playtype: Playtype;
reqUser: MONGO_UserDocument;
tierlistImpl: GPTRatingSystem<GPTString>;
reqUser: UserDocument;
tierlistImpl: GPTRatingSystem<V3Game>;
useFancyColour: boolean;
}) {
const buckets: TierlistInfo[][] = useMemo(() => {
@@ -609,7 +580,6 @@ function TierlistInfoLadder({
{...{
bucket,
game,
playtype,
reqUser,
useFancyColour,
tierlistImpl,
@@ -625,7 +595,6 @@ function TierlistInfoLadder({
function TierlistBucket({
bucket,
game,
playtype,
reqUser,
useFancyColour,
forceGridView,
@@ -633,10 +602,9 @@ function TierlistBucket({
}: {
bucket: TierlistInfo[];
forceGridView: boolean;
game: GameGroup;
playtype: Playtype;
reqUser: MONGO_UserDocument;
tierlistImpl: GPTRatingSystem<GPTString>;
game: V3Game;
reqUser: UserDocument;
tierlistImpl: GPTRatingSystem<V3Game>;
useFancyColour: boolean;
}) {
const {
@@ -653,7 +621,6 @@ function TierlistBucket({
game={game}
i={i}
key={`${tierlistInfo.chart.chartID}-${tierlistInfo.text}`}
playtype={playtype}
reqUser={reqUser}
tierlistImpl={tierlistImpl}
tierlistInfo={tierlistInfo}
@@ -673,7 +640,6 @@ function TierlistBucket({
game={game}
i={i}
key={`${tierlistInfo.chart.chartID}-${tierlistInfo.text}`}
playtype={playtype}
reqUser={reqUser}
tierlistImpl={tierlistImpl}
tierlistInfo={tierlistInfo}
@@ -687,7 +653,6 @@ function TierlistBucket({
function TierlistInfoBucketValues({
tierlistInfo,
game,
playtype,
reqUser,
useFancyColour,
forceGridView,
@@ -695,11 +660,10 @@ function TierlistInfoBucketValues({
}: {
bucket: TierlistInfo[];
forceGridView: boolean;
game: GameGroup;
game: V3Game;
i: integer;
playtype: Playtype;
reqUser: MONGO_UserDocument;
tierlistImpl: GPTRatingSystem<GPTString>;
reqUser: UserDocument;
tierlistImpl: GPTRatingSystem<V3Game>;
tierlistInfo: TierlistInfo;
useFancyColour: boolean;
}) {
@@ -716,7 +680,7 @@ function TierlistInfoBucketValues({
let colourCss: string | undefined;
if (useFancyColour) {
const gptImpl = GPT_CLIENT_IMPLEMENTATIONS[GetGPTString(game, playtype)];
const gptImpl = GPT_CLIENT_IMPLEMENTATIONS[game];
// @ts-expect-error lol
colourCss = gptImpl.enumColours[tierlistImpl.enumName][tierlistInfo.score];
@@ -736,7 +700,7 @@ function TierlistInfoBucketValues({
<tr>
<DifficultyCell alwaysShort chart={tierlistInfo.chart} game={game} noTierlist />
<td className="text-start">
<Link className="text-decoration-none" to={CreateChartLink(data, game)}>
<Link className="text-decoration-none" to={CreateChartLink(data)}>
{tierlistInfo.chart.__related.song.title}
</Link>{" "}
<br />
@@ -771,10 +735,10 @@ function TierlistInfoBucketValues({
className={`${colourClass} bg-opacity-50 rounded p-2`}
style={{ backgroundColor: colourCss ? ChangeOpacity(colourCss, 0.5) : undefined }}
>
<Link className="text-decoration-none" to={CreateChartLink(data, game)}>
<Link className="text-decoration-none" to={CreateChartLink(data)}>
{data.__related.song.title}
</Link>{" "}
{FormatDifficultyShort(data, game)}
{FormatDifficultyShort(data)}
<Divider className="my-2" />
{tierlistInfo.value} ({tierlistInfo.text ?? "No Info"})
{tierlistInfo.idvDiff && (
@@ -851,17 +815,11 @@ enum AchievedStatuses {
SCORE_BASED,
}
function FolderDatasetAchievedStatus(
folderDataset: FolderDataset,
game: GameGroup,
playtype: Playtype,
tierlist: string,
) {
function FolderDatasetAchievedStatus(folderDataset: FolderDataset, game: V3Game, tierlist: string) {
const tierlistInfo: Record<string, { score: string | null; status: AchievedStatuses }> = {};
const fn = GPT_CLIENT_IMPLEMENTATIONS[`${game}:${playtype}` as GPTString].ratingSystems.find(
// @ts-expect-error i'm sick of this language and i'm sick of type hacks
(e: GPTRatingSystem<GPTString>) => e.name === tierlist,
const fn = (GPT_CLIENT_IMPLEMENTATIONS[game].ratingSystems as GPTRatingSystem<V3Game>[]).find(
(e) => e.name === tierlist,
)?.achievementFn;
for (const data of folderDataset) {
@@ -871,8 +829,7 @@ function FolderDatasetAchievedStatus(
if (!data.__related.pb) {
achieved = AchievedStatuses.NOT_PLAYED;
} else if (fn) {
// @ts-expect-error i'm sick of this language and i'm sick of type hacks
const v = fn(data.__related.pb);
const v = fn(data.__related.pb as any);
achieved = v[1] ? AchievedStatuses.ACHIEVED : AchievedStatuses.FAILED;
score = typeof v[0] === "number" ? v[0].toString() : v[0];
@@ -4,7 +4,7 @@ import { type UGPT } from "#types/react";
import React from "react";
import { Link } from "react-router-dom";
export default function RivalsActivityPage({ reqUser, game, playtype }: UGPT) {
export default function RivalsActivityPage({ reqUser, game }: UGPT) {
const { settings } = useLUGPTSettings();
if (!settings) {
@@ -15,12 +15,10 @@ export default function RivalsActivityPage({ reqUser, game, playtype }: UGPT) {
return (
<div className="text-center">
You have no rivals set.{" "}
<Link to={`/u/${reqUser.id}/games/${game}/${playtype}/rivals/manage`}>
Go set some!
</Link>
<Link to={`/u/${reqUser.id}/games/${game}/rivals/manage`}>Go set some!</Link>
</div>
);
}
return <Activity url={`/users/${reqUser.id}/games/${game}/${playtype}/rivals/activity`} />;
return <Activity url={`/users/${reqUser.id}/games/${game}/rivals/activity`} />;
}
@@ -7,36 +7,32 @@ import useLUGPTSettings from "#components/util/useLUGPTSettings";
import useUGPTBase from "#components/util/useUGPTBase";
import React from "react";
import { Col, Row } from "react-bootstrap";
import { Redirect, Route, Switch } from "react-router-dom";
import { Route, Switch } from "react-router-dom";
import {
FormatGameGroup,
type GameGroup,
FormatGame,
GameToGameGroup,
GetGameGroupConfig,
type MONGO_UserDocument,
type Playtype,
type UserDocument,
type V3Game,
} from "tachi-common";
import RivalsActivityPage from "./RivalsActivityPage";
import RivalsManagePage from "./RivalsManagePage";
export default function RivalsMainPage({
reqUser,
game,
playtype,
}: {
game: GameGroup;
playtype: Playtype;
reqUser: MONGO_UserDocument;
}) {
const gameConfig = GetGameGroupConfig(game);
export default function RivalsMainPage({ reqUser, game }: { game: V3Game; reqUser: UserDocument }) {
useSetSubheader(
["Users", reqUser.username, "Games", gameConfig.name, playtype, "Rivals"],
[reqUser, game, playtype],
`${reqUser.username}'s ${FormatGameGroup(game, playtype)} Rivals`,
[
"Users",
reqUser.username,
"Games",
GetGameGroupConfig(GameToGameGroup(game)).name,
"Rivals",
],
[reqUser, game],
`${reqUser.username}'s ${FormatGame(game)} Rivals`,
);
const base = useUGPTBase({ reqUser, game, playtype });
const base = useUGPTBase({ reqUser, game });
const { settings } = useLUGPTSettings();
@@ -48,11 +44,6 @@ export default function RivalsMainPage({
<Row>
<Col className="text-center" xs={12}>
<div className="btn-group d-flex justify-content-center">
{/* this ui sucks and i don't like it. come up with something better? */}
{/* <SelectLinkButton to={`${base}/rivals/pb-leaderboard`}>
<Icon type="sort-amount-up" />
Rival's Bests
</SelectLinkButton> */}
<SelectLinkButton to={`${base}/rivals`}>
<Icon type="list" /> Rival Activity
</SelectLinkButton>
@@ -64,20 +55,19 @@ export default function RivalsMainPage({
</Col>
<Col xs={12}>
<Switch>
<Route exact path="/u/:userID/games/:game/:playtype/rivals">
<RivalsActivityPage game={game} playtype={playtype} reqUser={reqUser} />
<Route exact path="/u/:userID/games/:game/rivals">
<RivalsActivityPage game={game} reqUser={reqUser} />
</Route>
<Route exact path="/u/:userID/games/:game/:playtype/rivals/manage">
<RivalsManagePage game={game} playtype={playtype} reqUser={reqUser} />
<Route exact path="/u/:userID/games/:game/rivals/manage">
<RivalsManagePage game={game} reqUser={reqUser} />
</Route>
<Route exact path="/u/:userID/games/:game/:playtype/rivals/pb-leaderboard">
<Route exact path="/u/:userID/games/:game/rivals/pb-leaderboard">
<ScoreLeaderboard
game={game}
playtype={playtype}
refreshDeps={[`rivals-pb-leaderboard-${settings.rivals.join(",")}`]}
url={`/users/${reqUser.id}/games/${game}/${playtype}/rivals/pb-leaderboard`}
url={`/users/${reqUser.id}/games/${game}/rivals/pb-leaderboard`}
/>
</Route>
</Switch>
@@ -16,35 +16,38 @@ import React, { useContext, useState } from "react";
import { Alert, Button, Col } from "react-bootstrap";
import { Prompt } from "react-router-dom";
import {
FormatGameGroup,
type GameGroup,
FormatGame,
GameToGameGroup,
GetGameGroupConfig,
type integer,
type MONGO_UserDocument,
type Playtype,
type UserDocument,
type V3Game,
} from "tachi-common";
export default function RivalsManagePage({
reqUser,
game,
playtype,
}: {
game: GameGroup;
playtype: Playtype;
reqUser: MONGO_UserDocument;
game: V3Game;
reqUser: UserDocument;
}) {
const gameConfig = GetGameGroupConfig(game);
useSetSubheader(
["Users", reqUser.username, "Games", gameConfig.name, playtype, "Rivals", "Manage"],
[reqUser, game, playtype],
`Managing ${reqUser.username}'s ${FormatGameGroup(game, playtype)} Rivals`,
[
"Users",
reqUser.username,
"Games",
GetGameGroupConfig(GameToGameGroup(game)).name,
"Rivals",
"Manage",
],
[reqUser, game],
`Managing ${reqUser.username}'s ${FormatGame(game)} Rivals`,
);
const { settings } = useLUGPTSettings();
const { data, error } = useApiQuery<MONGO_UserDocument[]>(
`/users/${reqUser.id}/games/${game}/${playtype}/rivals`,
const { data, error } = useApiQuery<UserDocument[]>(
`/users/${reqUser.id}/games/${game}/rivals`,
{},
[`fetch-rivals-${settings?.rivals.join(",")}`],
);
@@ -53,9 +56,7 @@ export default function RivalsManagePage({
data: challengers,
isLoading: cIsLoading,
error: cError,
} = useApiQuery<MONGO_UserDocument[]>(
`/users/${reqUser.id}/games/${game}/${playtype}/rivals/challengers`,
);
} = useApiQuery<UserDocument[]>(`/users/${reqUser.id}/games/${game}/rivals/challengers`);
if (error) {
<ApiError error={error} />;
@@ -78,7 +79,6 @@ export default function RivalsManagePage({
challengers={challengers}
game={game}
initialRivals={data}
playtype={playtype}
reqUser={reqUser}
/>
);
@@ -87,15 +87,13 @@ export default function RivalsManagePage({
function RivalsOverviewPage({
reqUser,
game,
playtype,
initialRivals,
challengers,
}: {
challengers: Array<MONGO_UserDocument>;
game: GameGroup;
initialRivals: Array<MONGO_UserDocument>;
playtype: Playtype;
reqUser: MONGO_UserDocument;
challengers: Array<UserDocument>;
game: V3Game;
initialRivals: Array<UserDocument>;
reqUser: UserDocument;
}) {
const { user } = useContext(UserContext);
@@ -123,7 +121,6 @@ function RivalsOverviewPage({
return (
<>
{/* kind of a stupid way to check whether the array has changed or not, but who cares. */}
{isRequestingUser && currentRivals.toString() !== rivals.toString() && (
<Alert className="vstack" variant="warning">
<Prompt
@@ -136,7 +133,7 @@ function RivalsOverviewPage({
<Button
onClick={async () => {
const res = await APIFetchV1(
`/users/${reqUser.id}/games/${game}/${playtype}/rivals`,
`/users/${reqUser.id}/games/${game}/rivals`,
{
method: "PUT",
body: JSON.stringify({
@@ -168,7 +165,7 @@ function RivalsOverviewPage({
<Card header={`${isRequestingUser ? "Your" : `${reqUser.username}'s`} Rivals`}>
<Col className="d-flex justify-content-center flex-wrap" xs={12}>
{rivals.map((e) => (
<UserIcon game={game} key={e.id} playtype={playtype} user={e}>
<UserIcon game={game} key={e.id} user={e}>
<Button
onClick={() => setRivals(rivals.filter((u) => u.id !== e.id))}
variant="outline-danger"
@@ -209,7 +206,6 @@ function RivalsOverviewPage({
} else {
setRivals([...rivals, user]);
// if we're now at max rivals, exit.
if (rivals.length + 1 >= MAX_RIVALS) {
setShow(false);
}
@@ -219,7 +215,7 @@ function RivalsOverviewPage({
excludeSet={rivals.map((e) => e.id)}
setShow={setShow}
show={show}
url={`/games/${game}/${playtype}/players`}
url={`/games/${game}/players`}
/>
</>
)}
@@ -246,7 +242,7 @@ function RivalsOverviewPage({
>
<Col className="d-flex justify-content-center flex-wrap" xs={12}>
{challengers.map((e) => (
<UserIcon game={game} key={e.id} playtype={playtype} user={e}>
<UserIcon game={game} key={e.id} user={e}>
{isRequestingUser &&
!rivals.map((e) => e.id).includes(e.id) &&
(rivals.length < MAX_RIVALS ? (
@@ -7,21 +7,25 @@ import { type UGPT } from "#types/react";
import React from "react";
import { Col, Row } from "react-bootstrap";
import { Route, Switch } from "react-router-dom";
import { FormatGameGroup, GetGameGroupConfig } from "tachi-common";
import { FormatGame, GameToGameGroup, GetGameGroupConfig } from "tachi-common";
import UGPTGoalsPage from "./UGPTGoalsPage";
import UGPTQuestsPage from "./UGPTQuestsPage";
export default function TargetsPage({ reqUser, game, playtype }: UGPT) {
const gameConfig = GetGameGroupConfig(game);
export default function TargetsPage({ reqUser, game }: UGPT) {
useSetSubheader(
["Users", reqUser.username, "Games", gameConfig.name, playtype, "Goals & Quests"],
[reqUser, game, playtype],
`${reqUser.username}'s ${FormatGameGroup(game, playtype)} Goals & Quests`,
[
"Users",
reqUser.username,
"Games",
GetGameGroupConfig(GameToGameGroup(game)).name,
"Goals & Quests",
],
[reqUser, game],
`${reqUser.username}'s ${FormatGame(game)} Goals & Quests`,
);
const base = useUGPTBase({ reqUser, game, playtype });
const base = useUGPTBase({ reqUser, game });
return (
<Row>
@@ -38,11 +42,11 @@ export default function TargetsPage({ reqUser, game, playtype }: UGPT) {
</Col>
<Col xs={12}>
<Switch>
<Route exact path="/u/:userID/games/:game/:playtype/targets/goals">
<UGPTGoalsPage {...{ reqUser, game, playtype }} />
<Route exact path="/u/:userID/games/:game/targets/goals">
<UGPTGoalsPage {...{ reqUser, game }} />
</Route>
<Route exact path="/u/:userID/games/:game/:playtype/targets">
<UGPTQuestsPage {...{ reqUser, game, playtype }} />
<Route exact path="/u/:userID/games/:game/targets">
<UGPTQuestsPage {...{ reqUser, game }} />
</Route>
</Switch>
</Col>
@@ -14,16 +14,16 @@ import { CreateGoalSubDataset } from "#util/data";
import React, { useContext, useReducer, useState } from "react";
import { Button, Col } from "react-bootstrap";
import { Link } from "react-router-dom";
import { FormatGameGroup } from "tachi-common";
import { FormatGame } from "tachi-common";
export default function UGPTGoalsPage({ reqUser, game, playtype }: UGPT) {
export default function UGPTGoalsPage({ reqUser, game }: UGPT) {
const [show, setShow] = useState(false);
const [showDelete, setShowDelete] = useState(false);
const { reloadTargets } = useContext(TargetsContext);
const [refresh, refetchGoals] = useReducer((x) => x + 1, 0);
const { data, error } = useApiQuery<AllUGPTGoalsReturn>(
`/users/${reqUser.id}/games/${game}/${playtype}/targets/goals`,
`/users/${reqUser.id}/games/${game}/targets/goals`,
undefined,
[refresh.toString()],
);
@@ -61,13 +61,10 @@ export default function UGPTGoalsPage({ reqUser, game, playtype }: UGPT) {
</Button>
<div>
Looking for goal recommendations?{" "}
<Link to={`/games/${game}/${playtype}/quests`}>
Check out {FormatGameGroup(game, playtype)}'s Quests
</Link>
.
<Link to={`/games/${game}/quests`}>Check out {FormatGame(game)}'s Quests</Link>.
</div>
<Divider />
<GoalSubInfo dataset={dataset} game={game} playtype={playtype} />
<GoalSubInfo dataset={dataset} game={game} />
</Col>
{show && (
<AddNewGoalForQuestModal
@@ -75,7 +72,7 @@ export default function UGPTGoalsPage({ reqUser, game, playtype }: UGPT) {
noNote
onCreate={async (rawGoal) => {
await APIFetchV1(
`/users/${reqUser.id}/games/${game}/${playtype}/targets/goals/add-goal`,
`/users/${reqUser.id}/games/${game}/targets/goals/add-goal`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -91,7 +88,6 @@ export default function UGPTGoalsPage({ reqUser, game, playtype }: UGPT) {
refetchGoals();
reloadTargets();
}}
playtype={playtype}
setShow={setShow}
show={show}
/>
@@ -101,7 +97,7 @@ export default function UGPTGoalsPage({ reqUser, game, playtype }: UGPT) {
dataset={dataset}
onDelete={async (goalID) => {
await APIFetchV1(
`/users/${reqUser.id}/games/${game}/${playtype}/targets/goals/${goalID}`,
`/users/${reqUser.id}/games/${game}/targets/goals/${goalID}`,
{
method: "DELETE",
},
@@ -12,19 +12,19 @@ import React, { useMemo, useState } from "react";
import { Col, Row } from "react-bootstrap";
import { Link } from "react-router-dom";
import {
type MONGO_GoalDocument,
type MONGO_QuestDocument,
type MONGO_QuestSubscriptionDocument,
type GoalDocument,
type QuestDocument,
type QuestSubscriptionDocument,
} from "tachi-common";
export default function UGPTQuestsPage({ reqUser, game, playtype }: UGPT) {
export default function UGPTQuestsPage({ reqUser, game }: UGPT) {
const [show, setShow] = useState<"achieved" | "all" | "unachieved">("all");
const { data, error } = useApiQuery<{
goals: Array<MONGO_GoalDocument>;
quests: Array<MONGO_QuestDocument>;
questSubs: Array<MONGO_QuestSubscriptionDocument>;
}>(`/users/${reqUser.id}/games/${game}/${playtype}/targets/quests`);
goals: Array<GoalDocument>;
quests: Array<QuestDocument>;
questSubs: Array<QuestSubscriptionDocument>;
}>(`/users/${reqUser.id}/games/${game}/targets/quests`);
const questsToShow = useMemo(() => {
if (!data || error) {
@@ -33,27 +33,22 @@ export default function UGPTQuestsPage({ reqUser, game, playtype }: UGPT) {
const questSubMap = CreateQuestSubMap(data.questSubs);
// slice and sort based on progress
let base = data.quests.slice(0).sort(
NumericSOV((quest) => {
const sub = questSubMap.get(quest.questID);
if (!sub) {
return -Infinity; // shouldn't happen
return -Infinity;
}
// sink achieved things below goals in progress
if (sub.achieved) {
return -100;
}
// since this is always ostensibly positive, the magic numbers -99 and
// -100 should be fine.
return sub.progress / GetGoalIDsFromQuest(quest).length;
}, true),
);
// filter on "show" setting
switch (show) {
case "all":
break;
@@ -98,7 +93,7 @@ export default function UGPTQuestsPage({ reqUser, game, playtype }: UGPT) {
<div className="text-center">
Looks like you have no quests set.
<br />
<Link to={`/games/${game}/${playtype}/quests`}>Go set some!</Link>
<Link to={`/games/${game}/quests`}>Go set some!</Link>
</div>
</Col>
)}
@@ -8,34 +8,39 @@ import { type UGPT } from "#types/react";
import React, { useContext } from "react";
import { Col, Row } from "react-bootstrap";
import { Link, Route, Switch } from "react-router-dom";
import { FormatGameGroup, GetGameGroupConfig } from "tachi-common";
import { FormatGame, GameToGameGroup, GetGameGroupConfig } from "tachi-common";
export default function UGPTUtilsPage({ reqUser, game, playtype }: UGPT) {
const gameConfig = GetGameGroupConfig(game);
export default function UGPTUtilsPage({ reqUser, game }: UGPT) {
const { user } = useContext(UserContext);
const isViewingOwnProfile = user?.id === reqUser.id;
const utils = GetGPTUtils(game, playtype);
const pageName = GetGPTUtilsName(game, playtype, isViewingOwnProfile);
const utils = GetGPTUtils(game);
const pageName = GetGPTUtilsName(game, isViewingOwnProfile);
useSetSubheader(
["Users", reqUser.username, "Games", gameConfig.name, playtype, pageName ?? "Utils"],
[reqUser, game, playtype],
`${reqUser.username}'s ${FormatGameGroup(game, playtype)} ${pageName ?? "Utils"}`,
[
"Users",
reqUser.username,
"Games",
GetGameGroupConfig(GameToGameGroup(game)).name,
pageName ?? "Utils",
],
[reqUser, game],
`${reqUser.username}'s ${FormatGame(game)} ${pageName ?? "Utils"}`,
);
return (
<Row>
<Switch>
<Route exact path="/u/:userID/games/:game/:playtype/utils">
<Route exact path="/u/:userID/games/:game/utils">
{utils.map((util) => (
<Col className="my-4" key={util.urlPath} lg={6} xs={12}>
<Card
footer={
<div className="d-flex w-100 justify-content-end">
<LinkButton
to={`/u/${reqUser.username}/games/${game}/${playtype}/utils/${util.urlPath}`}
to={`/u/${reqUser.username}/games/${game}/utils/${util.urlPath}`}
>
View
</LinkButton>
@@ -53,14 +58,14 @@ export default function UGPTUtilsPage({ reqUser, game, playtype }: UGPT) {
<Route
exact
key={tool.urlPath}
path={`/u/:userID/games/:game/:playtype/utils/${tool.urlPath}`}
path={`/u/:userID/games/:game/utils/${tool.urlPath}`}
>
<Col className="mt-4" xs={12}>
<Card
footer={
<Link
className="text-body-secondary text-hover-white"
to={`/u/${reqUser.username}/games/${game}/${playtype}/utils`}
to={`/u/${reqUser.username}/games/${game}/utils`}
>
&lt; Back to all tools...
</Link>
@@ -71,7 +76,7 @@ export default function UGPTUtilsPage({ reqUser, game, playtype }: UGPT) {
</Card>
<Divider />
</Col>
<Col xs={12}>{tool.component({ reqUser, game, playtype })}</Col>
<Col xs={12}>{tool.component({ reqUser, game })}</Col>
</Route>
))}
</Switch>
@@ -17,7 +17,7 @@ const LOCAL_QUEST_KEY = "LOCAL_QUESTS";
const PR_LOCAL_QUESTS_SCHEMA: PrudenceSchema = {
json: [
{
game: p.isIn(TachiConfig.GAMES),
game: p.isIn(TachiConfig.GAME_GROUPS),
playtype: (self, parent) => {
const gameConfig = GetGameGroupConfig(parent.game as any);
+112 -153
View File
@@ -3,7 +3,6 @@ import GPTChartsPage from "#app/pages/dashboard/games/_game/_playtype/GPTChartsP
import GPTDevInfo from "#app/pages/dashboard/games/_game/_playtype/GPTDevInfo";
import GPTLeaderboardsPage from "#app/pages/dashboard/games/_game/_playtype/GPTLeaderboardsPage";
import GPTMainPage from "#app/pages/dashboard/games/_game/_playtype/GPTMainPage";
import PlaytypeSelect from "#app/pages/dashboard/games/_game/PlaytypeSelect";
import { ErrorPage } from "#app/pages/ErrorPage";
import ChartInfoFormat from "#components/game/charts/ChartInfoFormat";
import { GPTBottomNav } from "#components/game/GPTHeader";
@@ -28,7 +27,7 @@ import { GPT_CLIENT_IMPLEMENTATIONS } from "#lib/game-implementations";
import { type SongsReturn } from "#types/api-returns";
import { type GamePT, type SetState } from "#types/react";
import { ToCDNURL } from "#util/api";
import { IsSupportedGame, IsSupportedPlaytype } from "#util/asserts";
import { IsSupportedGame } from "#util/asserts";
import { ChangeOpacity } from "#util/color-opacity";
import { CreateChartLink } from "#util/data";
import { SelectRightChart } from "#util/misc";
@@ -37,109 +36,97 @@ import React, { useContext, useEffect, useState } from "react";
import { Col, Row } from "react-bootstrap";
import { Redirect, Route, Switch, useParams } from "react-router-dom";
import {
type ChartDocument,
COLOUR_SET,
FormatDifficulty,
type GameGroup,
GetGameGroupConfig,
GetGamePTConfig,
GetGPTString,
type MONGO_ChartDocument,
type MONGO_SongDocument,
type GamesForGroup,
GameToGameGroup,
GetGameConfig,
type SongDocument,
type V3Game,
} from "tachi-common";
import { type FixedDifficulties } from "tachi-common/types/game-config-utils";
export default function GameRoutes() {
const { game } = useParams<{ game: string }>();
const { game: gameParam } = useParams<{ game: string }>();
const { setBackground } = useContext(BackgroundContext);
useEffect(() => {
setBackground(ToCDNURL(`/game-banners/${game}`));
}, [game, setBackground]);
if (!IsSupportedGame(game)) {
return <ErrorPage customMessage={`The game ${game} is not supported.`} statusCode={404} />;
}
const gameConfig = GetGameGroupConfig(game);
setBackground(ToCDNURL(`/game-banners/${gameParam}`));
}, [gameParam, setBackground]);
// Support legacy URLs like /games/iidx/SP → redirect to /games/iidx-sp
return (
<Switch>
<Route exact path="/games/:game">
{gameConfig.playtypes.length === 1 ? (
<Redirect to={`/games/${game}/${gameConfig.playtypes[0]}`} />
) : (
<PlaytypeSelect
base={`/games/${game}`}
game={game}
subheaderCrumbs={["Games", gameConfig.name]}
subheaderTitle={`${gameConfig.name} Playtype Select`}
/>
)}
</Route>
<Route path="/games/:game/:playtype">
<UGPTContextProvider>
<TargetsContextProvider>
<GamePlaytypeRoutes game={game} />
</TargetsContextProvider>
</UGPTContextProvider>
</Route>
<Route path="*">
<ErrorPage statusCode={404} />
<Route path="/games/:game">
<V3GameRoutes />
</Route>
</Switch>
);
}
function GamePlaytypeRoutes({ game }: { game: GameGroup }) {
const { playtype } = useParams<{ playtype: string }>();
function V3GameRoutes() {
const { game: gameParam } = useParams<{ game: string }>();
const { setBackground } = useContext(BackgroundContext);
if (!IsSupportedPlaytype(game, playtype)) {
useEffect(() => {
setBackground(ToCDNURL(`/game-banners/${gameParam}`));
}, [gameParam, setBackground]);
if (!IsSupportedGame(gameParam)) {
return (
<ErrorPage
customMessage={`The playtype ${playtype} is not supported.`}
statusCode={400}
/>
<ErrorPage customMessage={`The game ${gameParam} is not supported.`} statusCode={404} />
);
}
const game = gameParam;
return (
<UGPTContextProvider>
<TargetsContextProvider>
<GameV3Routes game={game} />
</TargetsContextProvider>
</UGPTContextProvider>
);
}
function GameV3Routes({ game }: { game: V3Game }) {
return (
<>
<div className="card">
<GPTBottomNav baseUrl={`/games/${game}/${playtype}`} />
<GPTBottomNav baseUrl={`/games/${game}`} />
</div>
<Divider />
<Switch>
<Route exact path="/games/:game/:playtype">
<GPTMainPage game={game} playtype={playtype} />
<Route exact path="/games/:game">
<GPTMainPage game={game} />
</Route>
<Route exact path="/games/:game/:playtype/songs">
<Redirect to={`/games/${game}/${playtype}/charts`} />
<Route exact path="/games/:game/songs">
<Redirect to={`/games/${game}/charts`} />
</Route>
<Route exact path="/games/:game/:playtype/charts">
<GPTChartsPage game={game} playtype={playtype} />
<Route exact path="/games/:game/charts">
<GPTChartsPage game={game} />
</Route>
<Route path="/games/:game/:playtype/charts/:chartID">
<ChartPageRoutes game={game} playtype={playtype} />
<Route path="/games/:game/charts/:chartID">
<ChartPageRoutes game={game} />
</Route>
<Route path="/games/:game/:playtype/songs/:songID">
<SongChartRedirectRoutes game={game} playtype={playtype} />
<Route path="/games/:game/songs/:songID">
<SongChartRedirectRoutes game={game} />
</Route>
<Route path="/games/:game/:playtype/(quests|questlines|goals)">
<GPTQuestRoutes game={game} playtype={playtype} />
<Route path="/games/:game/(quests|questlines|goals)">
<GPTQuestRoutes game={game} />
</Route>
<Route exact path="/games/:game/:playtype/leaderboards">
<GPTLeaderboardsPage game={game} playtype={playtype} />
<Route exact path="/games/:game/leaderboards">
<GPTLeaderboardsPage game={game} />
</Route>
<Route exact path="/games/:game/:playtype/dev-info">
<GPTDevInfo game={game} playtype={playtype} />
<Route exact path="/games/:game/dev-info">
<GPTDevInfo game={game} />
</Route>
<Route path="*">
@@ -150,46 +137,46 @@ function GamePlaytypeRoutes({ game }: { game: GameGroup }) {
);
}
function GPTQuestRoutes({ game, playtype }: GamePT) {
function GPTQuestRoutes({ game }: GamePT) {
return (
<>
<Switch>
<Route exact path="/games/:game/:playtype/quests">
<QuestsPage game={game} playtype={playtype} />
<Route exact path="/games/:game/quests">
<QuestsPage game={game} />
</Route>
<Route exact path="/games/:game/:playtype/questlines">
<Redirect to={`/games/${game}/${playtype}/quests`} />
<Route exact path="/games/:game/questlines">
<Redirect to={`/games/${game}/quests`} />
</Route>
<Route exact path="/games/:game/:playtype/questlines/:questlineID">
<QuestlinePage game={game} playtype={playtype} />
<Route exact path="/games/:game/questlines/:questlineID">
<QuestlinePage game={game} />
</Route>
<Route exact path="/games/:game/:playtype/quests/:questID">
<QuestPage game={game} playtype={playtype} />
<Route exact path="/games/:game/quests/:questID">
<QuestPage game={game} />
</Route>
<Route exact path="/games/:game/:playtype/goals">
<Redirect to={`/games/${game}/${playtype}/quests`} />
<Route exact path="/games/:game/goals">
<Redirect to={`/games/${game}/quests`} />
</Route>
</Switch>
</>
);
}
function ChartPageRoutes({ game, playtype }: GamePT) {
function ChartPageRoutes({ game }: GamePT) {
const { chartID } = useParams<{ chartID: string }>();
const { data: singleData, error: chartErr } = useApiQuery<{
chart: MONGO_ChartDocument;
song: MONGO_SongDocument;
}>(`/games/${game}/${playtype}/charts/${chartID}`);
chart: ChartDocument;
song: SongDocument;
}>(`/games/${game}/charts/${chartID}`);
const songLegacyId = singleData?.song.id;
const { data: songsData, error: songsErr } = useApiQuery<SongsReturn>(
songLegacyId !== undefined ? `/games/${game}/${playtype}/songs/${songLegacyId}` : "",
songLegacyId !== undefined ? `/games/${game}/songs/${songLegacyId}` : "",
undefined,
undefined,
songLegacyId === undefined,
@@ -197,7 +184,7 @@ function ChartPageRoutes({ game, playtype }: GamePT) {
const { settings } = useContext(UserSettingsContext);
const [activeChart, setActiveChart] = useState<MONGO_ChartDocument | null>(null);
const [activeChart, setActiveChart] = useState<ChartDocument | null>(null);
useEffect(() => {
const c = songsData?.charts.find((x) => x.chartID === chartID);
@@ -216,10 +203,7 @@ function ChartPageRoutes({ game, playtype }: GamePT) {
if (songsData.charts.length === 0) {
return (
<ErrorPage
customMessage={"This song has no charts for this playtype."}
statusCode={404}
/>
<ErrorPage customMessage={"This song has no charts for this game."} statusCode={404} />
);
}
@@ -227,18 +211,12 @@ function ChartPageRoutes({ game, playtype }: GamePT) {
<>
<SongInfoHeader
game={game}
playtype={playtype}
{...songsData}
activeChart={activeChart}
setActiveChart={setActiveChart}
/>
<Divider />
<GPTChartPage
chart={activeChart}
game={game}
playtype={playtype}
song={songsData.song}
/>
<GPTChartPage chart={activeChart} game={game} song={songsData.song} />
{settings?.preferences.developerMode && (
<>
<Divider />
@@ -251,10 +229,10 @@ function ChartPageRoutes({ game, playtype }: GamePT) {
);
}
function SongChartRedirectRoutes({ game, playtype }: GamePT) {
function SongChartRedirectRoutes({ game }: GamePT) {
const { songID } = useParams<{ songID: string }>();
const { data, error } = useApiQuery<SongsReturn>(`/games/${game}/${playtype}/songs/${songID}`);
const { data, error } = useApiQuery<SongsReturn>(`/games/${game}/songs/${songID}`);
if (error) {
return <ErrorPage customMessage={error.description} statusCode={error.statusCode} />;
@@ -266,14 +244,11 @@ function SongChartRedirectRoutes({ game, playtype }: GamePT) {
if (data.charts.length === 0) {
return (
<ErrorPage
customMessage={"This song has no charts for this playtype."}
statusCode={404}
/>
<ErrorPage customMessage={"This song has no charts for this game."} statusCode={404} />
);
}
if (data.charts.every((c) => c.playtype !== playtype)) {
if (data.charts.every((c) => c.game !== game)) {
const c = data.charts[0];
if (!c.chartID) {
@@ -285,17 +260,17 @@ function SongChartRedirectRoutes({ game, playtype }: GamePT) {
);
}
return <Redirect to={`/games/${game}/${c.playtype}/charts/${c.chartID}`} />;
return <Redirect to={`/games/${c.game}/charts/${c.chartID}`} />;
}
return (
<Switch>
<Route exact path="/games/:game/:playtype/songs/:songID">
<SongSongIdOnlyRedirect charts={data.charts} game={game} playtype={playtype} />
<Route exact path="/games/:game/songs/:songID">
<SongSongIdOnlyRedirect charts={data.charts} game={game} />
</Route>
<Route path="/games/:game/:playtype/songs/:songID/:difficulty">
<SongDifficultyRedirect data={data} game={game} playtype={playtype} />
<Route path="/games/:game/songs/:songID/:difficulty">
<SongDifficultyRedirect data={data} game={game} />
</Route>
<Route path="*">
@@ -305,11 +280,7 @@ function SongChartRedirectRoutes({ game, playtype }: GamePT) {
);
}
function SongSongIdOnlyRedirect({
charts,
game,
playtype,
}: { charts: MONGO_ChartDocument[] } & GamePT) {
function SongSongIdOnlyRedirect({ charts, game }: { charts: ChartDocument[] } & GamePT) {
const hardest = charts.slice(0).sort(NumericSOV((x) => x.levelNum, true))[0];
if (!hardest.chartID) {
@@ -321,52 +292,53 @@ function SongSongIdOnlyRedirect({
);
}
return <Redirect to={`/games/${game}/${playtype}/charts/${hardest.chartID}`} />;
return <Redirect to={`/games/${game}/charts/${hardest.chartID}`} />;
}
function SongDifficultyRedirect({ data, game, playtype }: { data: SongsReturn } & GamePT) {
function SongDifficultyRedirect({ data, game }: { data: SongsReturn } & GamePT) {
const { difficulty: d } = useParams<{ difficulty: string }>();
const difficulty = decodeURIComponent(d);
const gptConfig = GetGamePTConfig(game, playtype);
const chart = SelectRightChart(gptConfig, difficulty, data.charts);
const gameConfig = GetGameConfig(game);
const chart = SelectRightChart(gameConfig, difficulty, data.charts);
if (!chart?.chartID) {
return <ErrorPage customMessage={"Could not resolve this chart."} statusCode={404} />;
}
return <Redirect to={`/games/${game}/${chart.playtype}/charts/${chart.chartID}`} />;
return <Redirect to={`/games/${chart.game}/charts/${chart.chartID}`} />;
}
function SongInfoHeader({
game,
playtype,
song,
charts,
activeChart,
setActiveChart,
}: {
activeChart: MONGO_ChartDocument | null;
setActiveChart: SetState<MONGO_ChartDocument | null>;
activeChart: ChartDocument | null;
setActiveChart: SetState<ChartDocument | null>;
} & GamePT &
SongsReturn) {
const gptConfig = GetGamePTConfig(game, playtype);
const gameConfig = GetGameConfig(game);
const sortedCharts = charts.slice(0).sort(
gptConfig.difficulties.type === "DYNAMIC"
gameConfig.difficulties.type === "DYNAMIC"
? StrSOV((x) => x.difficulty)
: NumericSOV((x) =>
// can TS *really* not infer this?
(gptConfig.difficulties as FixedDifficulties<string>).order.indexOf(
(gameConfig.difficulties as FixedDifficulties<string>).order.indexOf(
x.difficulty,
),
),
);
const gameGroup = GameToGameGroup(game);
return (
<Card header="Song Info">
<Row className="align-items-center justify-content-evenly">
{game !== "bms" && game !== "pms" && (
{gameGroup !== "bms" && gameGroup !== "pms" && (
<Col className="text-center" lg={3} xs={12}>
{/* empty padding :) */}
</Col>
@@ -374,7 +346,7 @@ function SongInfoHeader({
<Col className="text-center" lg={4} xs={12}>
<SongChartInfoFormat {...{ game, song, chart: activeChart }} />
</Col>
{game !== "bms" && game !== "pms" && (
{gameGroup !== "bms" && gameGroup !== "pms" && (
<Col className="text-center" lg={3} xs={12}>
<h5>Charts</h5>
<hr />
@@ -382,13 +354,12 @@ function SongInfoHeader({
className="btn-group-vertical d-flex justify-content-center"
role="group"
>
{game === "iidx" ? (
{gameGroup === "iidx" ? (
<IIDXDifficultyList
{...{
activeChart,
charts: sortedCharts,
game,
playtype,
setActiveChart,
song,
}}
@@ -399,7 +370,6 @@ function SongInfoHeader({
activeChart,
charts: sortedCharts,
game,
playtype,
setActiveChart,
song,
}}
@@ -411,12 +381,7 @@ function SongInfoHeader({
{activeChart && (
<Col xs={12}>
<hr />
<ChartInfoFormat
chart={activeChart}
game={game}
playtype={playtype}
song={song}
/>
<ChartInfoFormat chart={activeChart} game={game} song={song} />
</Col>
)}
</Row>
@@ -425,9 +390,9 @@ function SongInfoHeader({
}
type Props = {
activeChart: MONGO_ChartDocument | null;
setActiveChart: SetState<MONGO_ChartDocument | null>;
} & { song: MONGO_SongDocument } & GamePT;
activeChart: ChartDocument | null;
setActiveChart: SetState<ChartDocument | null>;
} & { song: SongDocument } & GamePT;
const ITG_COLOUR_LOOKUP = {
Beginner: COLOUR_SET.paleBlue,
@@ -441,13 +406,13 @@ const ITG_COLOUR_LOOKUP = {
function DifficultyButton({
chart,
game,
playtype,
setActiveChart,
activeChart,
}: { chart: MONGO_ChartDocument } & Props) {
const gptImpl = GPT_CLIENT_IMPLEMENTATIONS[GetGPTString(game, playtype)];
}: { chart: ChartDocument } & Props) {
const gptImpl = GPT_CLIENT_IMPLEMENTATIONS[game];
const diffTag = chart.difficulty;
const gameGroup = GameToGameGroup(game);
return (
<LinkButton
@@ -462,7 +427,7 @@ function DifficultyButton({
gptImpl.difficultyColours[diffTag],
activeChart?.chartID === chart.chartID ? 0.4 : 0.2,
)
: game === "itg"
: gameGroup === "itg"
? ChangeOpacity(
// @ts-expect-error hack!
ITG_COLOUR_LOOKUP[chart.data.difficultyTag],
@@ -470,19 +435,19 @@ function DifficultyButton({
)
: undefined,
}}
to={CreateChartLink(chart, game)}
to={CreateChartLink(chart)}
variant="secondary"
>
<div
className={activeChart?.chartID === chart.chartID ? "fw-bolder" : ""}
style={{
color:
game === "ongeki" && diffTag === "LUNATIC"
gameGroup === "ongeki" && diffTag === "LUNATIC"
? "light-dark(rgba(140, 30, 40, 1), rgba(255, 180, 180, 1))"
: undefined,
}}
>
{FormatDifficulty(chart, game)}
{FormatDifficulty(chart)}
{chart.isPrimary ? (
""
) : (
@@ -502,9 +467,8 @@ function DifficultyList({
activeChart,
setActiveChart,
game,
playtype,
}: {
charts: MONGO_ChartDocument[];
charts: ChartDocument[];
} & Props) {
return (
<>
@@ -514,7 +478,6 @@ function DifficultyList({
chart={e}
game={game}
key={e.chartID}
playtype={playtype}
setActiveChart={setActiveChart}
song={song}
/>
@@ -533,16 +496,15 @@ function IIDXDifficultyList({
activeChart,
setActiveChart,
game,
playtype,
}: {
charts: MONGO_ChartDocument[];
charts: ChartDocument[];
} & Props) {
const { settings } = useLUGPTSettings<"iidx:DP" | "iidx:SP">();
const { settings } = useLUGPTSettings<GamesForGroup["iidx"]>();
const [set, setSet] = useState<"All Scratch" | "Kichiku" | "Kiraku" | null>(null);
if (
!(activeChart as MONGO_ChartDocument<"iidx:DP" | "iidx:SP">)?.data["2dxtraSet"] &&
!(activeChart as ChartDocument<GamesForGroup["iidx"]>)?.data["2dxtraSet"] &&
!settings?.preferences.gameSpecific.display2DXTra
) {
return (
@@ -550,14 +512,12 @@ function IIDXDifficultyList({
{...{
charts: charts.filter(
// @ts-expect-error hack
(e: MONGO_ChartDocument<"iidx:DP" | "iidx:SP">) =>
e.data["2dxtraSet"] === null,
(e: ChartDocument<GamesForGroup["iidx"]>) => e.data["2dxtraSet"] === null,
),
song,
activeChart,
setActiveChart,
game,
playtype,
}}
/>
);
@@ -583,14 +543,13 @@ function IIDXDifficultyList({
{...{
charts: charts.filter(
// @ts-expect-error hack
(e: MONGO_ChartDocument<"iidx:DP" | "iidx:SP">) =>
(e: ChartDocument<GamesForGroup["iidx"]>) =>
set ? e.difficulty.startsWith(set) : e.data["2dxtraSet"] === null,
),
song,
activeChart,
setActiveChart,
game,
playtype,
}}
/>
</>
+47 -94
View File
@@ -1,4 +1,3 @@
import PlaytypeSelect from "#app/pages/dashboard/games/_game/PlaytypeSelect";
import FoldersMainPage from "#app/pages/dashboard/users/games/_game/_playtype/folders/FoldersMainPage";
import LeaderboardsPage from "#app/pages/dashboard/users/games/_game/_playtype/LeaderboardsPage";
import OverviewPage from "#app/pages/dashboard/users/games/_game/_playtype/OverviewPage";
@@ -27,18 +26,11 @@ import { UserContext } from "#context/UserContext";
import { UserSettingsContext } from "#context/UserSettingsContext";
import { type UGPTStatsReturn } from "#types/api-returns";
import { APIFetchV1, type APIFetchV1Return, ToAPIURL } from "#util/api";
import { IsSupportedGame, IsSupportedPlaytype } from "#util/asserts";
import { IsSupportedGame } from "#util/asserts";
import React, { useContext, useEffect } from "react";
import { useQuery } from "react-query";
import { Redirect, Route, Switch, useHistory, useParams } from "react-router-dom";
import {
FormatGameGroup,
type GameGroup,
GetGameGroupConfig,
type MONGO_UserDocument,
type MONGO_UserGameStats,
type Playtype,
} from "tachi-common";
import { FormatGame, type UserDocument, type UserGameStats, type V3Game } from "tachi-common";
import ScoresPage from "../pages/dashboard/users/games/_game/_playtype/ScoresPage";
import UserPage from "../pages/dashboard/users/UserPage";
@@ -48,7 +40,7 @@ export default function UserRoutes() {
const { userID } = useParams<{ userID: string }>();
const history = useHistory();
const { data: reqUser, error } = useApiQuery<MONGO_UserDocument>(`/users/${params.userID}`);
const { data: reqUser, error } = useApiQuery<UserDocument>(`/users/${params.userID}`);
const { setBackground } = useContext(BackgroundContext);
useEffect(() => {
@@ -105,7 +97,7 @@ export default function UserRoutes() {
);
}
function UserProfileRoutes({ reqUser }: { reqUser: MONGO_UserDocument }) {
function UserProfileRoutes({ reqUser }: { reqUser: UserDocument }) {
const { settings } = useContext(UserSettingsContext);
return (
@@ -150,81 +142,43 @@ function UserProfileRoutes({ reqUser }: { reqUser: MONGO_UserDocument }) {
);
}
function UserGameRoutes({ reqUser }: { reqUser: MONGO_UserDocument }) {
const { game } = useParams<{ game: string }>();
if (!IsSupportedGame(game)) {
return <ErrorPage customMessage={`The game ${game} is not supported.`} statusCode={400} />;
}
const gameConfig = GetGameGroupConfig(game);
function UserGameRoutes({ reqUser }: { reqUser: UserDocument }) {
return (
<Switch>
<Route exact path="/u/:userID/games/:game">
{gameConfig.playtypes.length === 1 ? (
<Redirect
to={`/u/${reqUser.username}/games/${game}/${gameConfig.playtypes[0]}`}
/>
) : (
<PlaytypeSelect
base={`/u/${reqUser.username}/games/${game}`}
game={game}
subheaderCrumbs={["Users", reqUser.username, "Games", gameConfig.name]}
subheaderTitle={`${reqUser.username} ${gameConfig.name} Playtype Select`}
/>
)}
</Route>
<Route path="/u/:userID/games/:game/:playtype">
<UGPTContextProvider>
<TargetsContextProvider>
<UserGamePlaytypeRoutes game={game} reqUser={reqUser} />
</TargetsContextProvider>
</UGPTContextProvider>
<Route path="/u/:userID/games/:game">
<V3UserGameRoutes reqUser={reqUser} />
</Route>
</Switch>
);
}
function UserGamePlaytypeRoutes({
reqUser,
game,
}: {
game: GameGroup;
reqUser: MONGO_UserDocument;
}) {
const { playtype } = useParams<{ playtype: string }>();
function V3UserGameRoutes({ reqUser }: { reqUser: UserDocument }) {
const { game: gameParam } = useParams<{ game: string }>();
if (!IsSupportedPlaytype(game, playtype)) {
if (!IsSupportedGame(gameParam)) {
return (
<ErrorPage
customMessage={`The playtype ${playtype} is not supported.`}
statusCode={400}
/>
<ErrorPage customMessage={`The game ${gameParam} is not supported.`} statusCode={400} />
);
}
return <Inner game={game} playtype={playtype} reqUser={reqUser} />;
const game = gameParam;
return (
<UGPTContextProvider>
<TargetsContextProvider>
<Inner game={game} reqUser={reqUser} />
</TargetsContextProvider>
</UGPTContextProvider>
);
}
function Inner({
reqUser,
game,
playtype,
}: {
game: GameGroup;
playtype: Playtype;
reqUser: MONGO_UserDocument;
}) {
function Inner({ reqUser, game }: { game: V3Game; reqUser: UserDocument }) {
const { user } = useContext(UserContext);
const { data, error } = useQuery<UGPTStatsReturn, APIFetchV1Return<MONGO_UserGameStats>>(
[reqUser.id, game, playtype],
const { data, error } = useQuery<UGPTStatsReturn, APIFetchV1Return<UserGameStats>>(
[reqUser.id, game],
async () => {
const res = await APIFetchV1<UGPTStatsReturn>(
`/users/${reqUser.id}/games/${game}/${playtype}`,
);
const res = await APIFetchV1<UGPTStatsReturn>(`/users/${reqUser.id}/games/${game}`);
if (!res.success) {
console.error(res);
@@ -255,47 +209,46 @@ function Inner({
<LayoutHeaderContainer
footer={
<UGPTBottomNav
baseUrl={`/u/${reqUser.username}/games/${game}/${playtype}`}
baseUrl={`/u/${reqUser.username}/games/${game}`}
game={game}
isRequestedUser={reqUser.id === user?.id}
playtype={playtype}
/>
}
header={`${reqUser.username}'s ${FormatGameGroup(game, playtype)} Profile`}
header={`${reqUser.username}'s ${FormatGame(game)} Profile`}
>
<UGPTHeaderBody game={game} playtype={playtype} reqUser={reqUser} stats={stats} />
<UGPTHeaderBody game={game} reqUser={reqUser} stats={stats} />
</LayoutHeaderContainer>
<Switch>
<Route exact path="/u/:userID/games/:game/:playtype">
<OverviewPage game={game} playtype={playtype} reqUser={reqUser} />
<Route exact path="/u/:userID/games/:game">
<OverviewPage game={game} reqUser={reqUser} />
</Route>
<Route path="/u/:userID/games/:game/:playtype/scores">
<ScoresPage game={game} playtype={playtype} reqUser={reqUser} />
<Route path="/u/:userID/games/:game/scores">
<ScoresPage game={game} reqUser={reqUser} />
</Route>
<Route path="/u/:userID/games/:game/:playtype/folders">
<FoldersMainPage game={game} playtype={playtype} reqUser={reqUser} />
<Route path="/u/:userID/games/:game/folders">
<FoldersMainPage game={game} reqUser={reqUser} />
</Route>
<Route exact path="/u/:userID/games/:game/:playtype/sessions">
<SessionsPage game={game} playtype={playtype} reqUser={reqUser} />
<Route exact path="/u/:userID/games/:game/sessions">
<SessionsPage game={game} reqUser={reqUser} />
</Route>
<Route path="/u/:userID/games/:game/:playtype/sessions/:sessionID">
<SpecificSessionPage game={game} playtype={playtype} reqUser={reqUser} />
<Route path="/u/:userID/games/:game/sessions/:sessionID">
<SpecificSessionPage game={game} reqUser={reqUser} />
</Route>
<Route path="/u/:userID/games/:game/:playtype/rivals">
<RivalsMainPage game={game} playtype={playtype} reqUser={reqUser} />
<Route path="/u/:userID/games/:game/rivals">
<RivalsMainPage game={game} reqUser={reqUser} />
</Route>
<Route path="/u/:userID/games/:game/:playtype/targets">
<TargetsPage game={game} playtype={playtype} reqUser={reqUser} />
<Route path="/u/:userID/games/:game/targets">
<TargetsPage game={game} reqUser={reqUser} />
</Route>
<Route exact path="/u/:userID/games/:game/:playtype/leaderboard">
<LeaderboardsPage game={game} playtype={playtype} reqUser={reqUser} />
<Route exact path="/u/:userID/games/:game/leaderboard">
<LeaderboardsPage game={game} reqUser={reqUser} />
</Route>
<Route path="/u/:userID/games/:game/:playtype/utils">
<UGPTUtilsPage game={game} playtype={playtype} reqUser={reqUser} />
<Route path="/u/:userID/games/:game/utils">
<UGPTUtilsPage game={game} reqUser={reqUser} />
</Route>
<RequireAuthAsUserParam>
<Route exact path="/u/:userID/games/:game/:playtype/settings">
<UGPTSettingsPage game={game} playtype={playtype} reqUser={reqUser} />
<Route exact path="/u/:userID/games/:game/settings">
<UGPTSettingsPage game={game} reqUser={reqUser} />
</Route>
</RequireAuthAsUserParam>
<Route path="*">
@@ -38,10 +38,10 @@ import { Button, Col, Row } from "react-bootstrap";
import { Link } from "react-router-dom";
import {
FormatChart,
FormatGameGroup,
GetGamePTConfig,
FormatGame,
GetGameConfig,
GetScoreEnumConfs,
type MONGO_UserDocument,
type UserDocument,
} from "tachi-common";
// Records activity for a group of users on a GPT. Also used for single users.
@@ -57,7 +57,7 @@ export default function Activity({
url: string;
}) {
const [clumped, setClumped] = useState<ClumpedActivity>([]);
const [users, setUsers] = useState<Array<MONGO_UserDocument>>([]);
const [users, setUsers] = useState<Array<UserDocument>>([]);
const [shouldShowGame, setShouldShowGame] = useState(false);
const [exhausted, setExhausted] = useState(false);
@@ -132,7 +132,7 @@ function ActivityInner({
exhausted: boolean;
fetchMoreFrom: (start: number) => void;
shouldShowGame: boolean;
users: Array<MONGO_UserDocument>;
users: Array<UserDocument>;
}) {
const userMap = CreateUserMap(users);
@@ -250,11 +250,11 @@ function ScoresActivity({
}: {
data: ClumpedActivityScores;
shouldShowGame: boolean;
user: MONGO_UserDocument;
user: UserDocument;
}) {
const { game, playtype } = data.scores[0];
const game = data.scores[0].game;
const prettyGame = shouldShowGame ? `${FormatGameGroup(game, playtype)} ` : "";
const prettyGame = shouldShowGame ? `${FormatGame(game)} ` : "";
const [show, setShow] = useState(false);
@@ -264,12 +264,7 @@ function ScoresActivity({
if (data.scores.length === 1) {
const score0 = data.scores[0];
subMessage = `a ${prettyGame}score on ${FormatChart(
score0.game,
score0.__related.song,
score0.__related.chart,
true,
)}`;
subMessage = `a ${prettyGame}score on ${FormatChart(score0.__related.chart)}`;
if (score0.comment) {
mutedText = `"${score0.comment}"`;
@@ -278,9 +273,7 @@ function ScoresActivity({
subMessage = `${data.scores.length} ${prettyGame}scores`;
mutedText = TruncateString(
data.scores
.map((e) => FormatChart(e.game, e.__related.song, e.__related.chart, true))
.join(", "),
data.scores.map((e) => FormatChart(e.__related.chart)).join(", "),
100,
);
}
@@ -301,7 +294,7 @@ function ScoresActivity({
<div className="timeline-content-inner" onClick={() => setShow(!show)}>
<div className="timeline-content-title">
<span className="me-2">
<ProfilePicture size="sm" toGPT={{ game, playtype }} user={user} />
<ProfilePicture size="sm" toGPT={{ game }} user={user} />
</span>
<Icon
style={{
@@ -310,8 +303,7 @@ function ScoresActivity({
type={`chevron-${show ? "down" : "right"}`}
/>
<span className="ms-2" style={{ fontSize: "1.15rem" }}>
<UGPTLink game={game} playtype={playtype} reqUser={user} /> highlighted{" "}
{subMessage}!
<UGPTLink game={game} reqUser={user} /> highlighted {subMessage}!
</span>
{mutedText && (
<>
@@ -333,12 +325,7 @@ function ScoresActivity({
{show && (
<>
<Divider />
<ScoreTable
dataset={dataset}
game={game}
noTopDisplayStr
playtype={playtype}
/>
<ScoreTable dataset={dataset} game={game} noTopDisplayStr />
</>
)}
</div>
@@ -353,11 +340,11 @@ function GoalActivity({
}: {
data: ClumpedActivityGoalAchievement;
shouldShowGame: boolean;
user: MONGO_UserDocument;
user: UserDocument;
}) {
const { game, playtype } = data.goals[0];
const game = data.goals[0].game;
const prettyGame = shouldShowGame ? `${FormatGameGroup(game, playtype)} ` : "";
const prettyGame = shouldShowGame ? `${FormatGame(game)} ` : "";
const [show, setShow] = useState(false);
@@ -368,7 +355,7 @@ function GoalActivity({
const goal0 = data.goals[0];
subMessage = `${goal0.__related.goal.name}${
shouldShowGame ? ` in ${FormatGameGroup(game, playtype)}` : ""
shouldShowGame ? ` in ${FormatGame(game)}` : ""
}!`;
} else {
subMessage = `${data.goals.length} ${prettyGame}goals`;
@@ -383,7 +370,7 @@ function GoalActivity({
<div className="timeline-content-inner" onClick={() => setShow(!show)}>
<div className="timeline-content-title">
<span className="me-2">
<ProfilePicture size="sm" toGPT={{ game, playtype }} user={user} />
<ProfilePicture size="sm" toGPT={{ game }} user={user} />
</span>
<Icon
style={{
@@ -392,8 +379,7 @@ function GoalActivity({
type={`chevron-${show ? "down" : "right"}`}
/>
<span className="ms-2" style={{ fontSize: "1.15rem" }}>
<UGPTLink game={game} playtype={playtype} reqUser={user} /> achieved{" "}
{subMessage}!
<UGPTLink game={game} reqUser={user} /> achieved {subMessage}!
</span>
{mutedText && (
<>
@@ -438,11 +424,11 @@ function QuestActivity({
}: {
data: ClumpedActivityQuestAchievement;
shouldShowGame: boolean;
user: MONGO_UserDocument;
user: UserDocument;
}) {
const { game, playtype } = data.quest;
const game = data.quest.game;
const prettyGame = shouldShowGame ? FormatGameGroup(game, playtype) : "";
const prettyGame = shouldShowGame ? FormatGame(game) : "";
return (
<div className="timeline-item timeline-hover my-4">
@@ -452,13 +438,12 @@ function QuestActivity({
<div className="timeline-content-title">
<span style={{ fontSize: "1.15rem" }}>
<span className="me-2">
<ProfilePicture size="sm" toGPT={{ game, playtype }} user={user} />
<ProfilePicture size="sm" toGPT={{ game }} user={user} />
</span>
<UGPTLink game={game} playtype={playtype} reqUser={user} /> completed
the{" "}
<UGPTLink game={game} reqUser={user} /> completed the{" "}
<Link
className="text-decoration-none"
to={`/games/${game}/${playtype}/quests/${data.quest.questID}`}
to={`/games/${game}/quests/${data.quest.questID}`}
>
{data.quest.name}
</Link>{" "}
@@ -486,17 +471,16 @@ function SessionActivity({
}: {
data: ClumpedActivitySession;
shouldShowGame: boolean;
user: MONGO_UserDocument;
user: UserDocument;
}) {
const [show, setShow] = useState(false);
const { user: loggedInUser } = useContext(UserContext);
const prettyGame = shouldShowGame ? `${FormatGameGroup(data.game, data.playtype)} ` : "";
const game = data.game;
const prettyGame = shouldShowGame ? `${FormatGame(game)} ` : "";
const isProbablyActive = Date.now() - data.timeEnded < ONE_HOUR;
const { game, playtype } = data;
return (
<div className="timeline-item timeline-hover">
<div className={`timeline-badge bg-${data.highlight ? "warning" : "secondary"}`}></div>
@@ -504,7 +488,7 @@ function SessionActivity({
<div className="timeline-content-inner" onClick={() => setShow(!show)}>
<div className="timeline-content-title">
<span className="me-2">
<ProfilePicture size="sm" toGPT={{ game, playtype }} user={user} />
<ProfilePicture size="sm" toGPT={{ game }} user={user} />
</span>
<Icon
style={{
@@ -520,7 +504,7 @@ function SessionActivity({
}}
>
{/* worst string formatting ever */}
<UGPTLink game={data.game} playtype={data.playtype} reqUser={user} />{" "}
<UGPTLink game={game} reqUser={user} />{" "}
{isProbablyActive
? user.id === loggedInUser?.id
? "are having"
@@ -564,7 +548,8 @@ function SessionShower({ sessionID }: { sessionID: string }) {
const scoreMap = CreateScoreIDMap(data.scores);
const gptConfig = GetGamePTConfig(data.session.game, data.session.playtype);
const sessionGame = data.session.game;
const gameConfig = GetGameConfig(sessionGame);
const raises = data.scoreInfo.filter((e) => {
const score = scoreMap.get(e.scoreID);
@@ -574,7 +559,7 @@ function SessionShower({ sessionID }: { sessionID: string }) {
return false;
}
const enumMetrics = GetScoreEnumConfs(gptConfig);
const enumMetrics = GetScoreEnumConfs(gameConfig);
// for all enum metrics, check if this score beats the minimum relevant enum
// and is a raise.
@@ -601,7 +586,7 @@ function SessionShower({ sessionID }: { sessionID: string }) {
<div className="mb-4">This session had no raises.</div>
<div>
<LinkButton
to={`/u/${data.user.username}/games/${data.session.game}/${data.session.playtype}/sessions/${sessionID}`}
to={`/u/${data.user.username}/games/${sessionGame}/sessions/${sessionID}`}
variant="outline-primary"
>
View Full Session
@@ -620,7 +605,7 @@ function SessionShower({ sessionID }: { sessionID: string }) {
</Col>
<div className="d-flex w-100 justify-content-center">
<LinkButton
to={`/u/${data.user.username}/games/${data.session.game}/${data.session.playtype}/sessions/${sessionID}`}
to={`/u/${data.user.username}/games/${sessionGame}/sessions/${sessionID}`}
variant="outline-primary"
>
View Full Session
@@ -637,7 +622,7 @@ function ClassAchievementActivity({
}: {
data: ClumpedActivityClassAchievement;
shouldShowGame: boolean;
user: MONGO_UserDocument;
user: UserDocument;
}) {
return (
<div className="timeline-item timeline-hover">
@@ -645,35 +630,39 @@ function ClassAchievementActivity({
<div className="timeline-content">
<div className="timeline-content-inner">
<div className="timeline-content-title">
<span className="me-2">
<ProfilePicture
size="sm"
toGPT={{ game: data.game, playtype: data.playtype }}
user={user}
/>
</span>
<UGPTLink game={data.game} playtype={data.playtype} reqUser={user} />{" "}
achieved{" "}
<ClassBadge
classSet={data.classSet}
classValue={data.classValue}
game={data.game}
playtype={data.playtype}
/>
{shouldShowGame && ` in ${FormatGameGroup(data.game, data.playtype)}`}!
{data.classOldValue !== null && (
<>
{" "}
(Raised from{" "}
<ClassBadge
classSet={data.classSet}
classValue={data.classOldValue}
game={data.game}
playtype={data.playtype}
/>
)
</>
)}
{(() => {
const classGame = data.game;
return (
<>
<span className="me-2">
<ProfilePicture
size="sm"
toGPT={{ game: classGame }}
user={user}
/>
</span>
<UGPTLink game={classGame} reqUser={user} /> achieved{" "}
<ClassBadge
classSet={data.classSet}
classValue={data.classValue}
game={classGame}
/>
{shouldShowGame && ` in ${FormatGame(classGame)}`}!
{data.classOldValue !== null && (
<>
{" "}
(Raised from{" "}
<ClassBadge
classSet={data.classSet}
classValue={data.classOldValue}
game={classGame}
/>
)
</>
)}
</>
);
})()}
</div>
<div className="timeline-content-timestamp">
@@ -689,15 +678,11 @@ function ClassAchievementActivity({
);
}
function UGPTLink({ reqUser, game, playtype }: UGPT) {
// currently
function UGPTLink({ reqUser, game }: UGPT) {
const { user } = useContext(UserContext);
return (
<Link
className="text-decoration-none fw-bold"
to={`/u/${reqUser.username}/games/${game}/${playtype}`}
>
<Link className="text-decoration-none fw-bold" to={`/u/${reqUser.username}/games/${game}`}>
{user?.id === reqUser.id ? "You" : reqUser.username}
{reqUser?.isSupporter ? (
<>
@@ -73,11 +73,7 @@ const getScoreYAxisNotch = (game: GameGroup) => (s: number) => {
};
const strokeColor = (
type:
| "BELLS"
| Difficulties["chunithm:Single"]
| Difficulties["maimaidx:Single"]
| Difficulties["ongeki:Single"],
type: "BELLS" | Difficulties["chunithm"] | Difficulties["maimaidx"] | Difficulties["ongeki"],
) => {
const isLight = getTheme() === "light";
@@ -170,10 +166,7 @@ export default function GekichumaiScoreChart({
duration,
}: {
data: Serie[];
difficulty:
| Difficulties["chunithm:Single"]
| Difficulties["maimaidx:Single"]
| Difficulties["ongeki:Single"];
difficulty: Difficulties["chunithm"] | Difficulties["maimaidx"] | Difficulties["ongeki"];
duration: number;
game: "chunithm" | "maimaidx" | "ongeki";
height?: number | string;
@@ -188,18 +181,18 @@ export default function GekichumaiScoreChart({
if (type === "Score") {
if (game === "chunithm") {
color =
GPT_CLIENT_IMPLEMENTATIONS["chunithm:Single"].difficultyColours[
difficulty as Difficulties["chunithm:Single"]
GPT_CLIENT_IMPLEMENTATIONS.chunithm.difficultyColours[
difficulty as Difficulties["chunithm"]
];
} else if (game === "ongeki") {
color =
GPT_CLIENT_IMPLEMENTATIONS["ongeki:Single"].difficultyColours[
difficulty as Difficulties["ongeki:Single"]
GPT_CLIENT_IMPLEMENTATIONS.ongeki.difficultyColours[
difficulty as Difficulties["ongeki"]
];
} else if (game === "maimaidx") {
color =
GPT_CLIENT_IMPLEMENTATIONS["maimaidx:Single"].difficultyColours[
difficulty as Difficulties["maimaidx:Single"]
GPT_CLIENT_IMPLEMENTATIONS.maimaidx.difficultyColours[
difficulty as Difficulties["maimaidx"]
];
}
} else if (type === "Bells") {
@@ -1,11 +1,11 @@
import Activity from "#components/activity/Activity";
import { AllLUGPTStatsContext } from "#context/AllLUGPTStatsContext";
import React, { useContext } from "react";
import { type MONGO_UserDocument } from "tachi-common";
import { type UserDocument } from "tachi-common";
import { DashboardLoggedInNoScores } from "./DashboardLoggedInNoScores";
export default function DashboardActivity({ user }: { user: MONGO_UserDocument }) {
export default function DashboardActivity({ user }: { user: UserDocument }) {
const { ugs } = useContext(AllLUGPTStatsContext);
if (ugs?.length === 0) {
@@ -2,9 +2,9 @@ import Divider from "#components/util/Divider";
import LinkButton from "#components/util/LinkButton";
import { TachiConfig } from "#lib/config";
import React from "react";
import { type MONGO_UserDocument } from "tachi-common";
import { type UserDocument } from "tachi-common";
export function DashboardLoggedInNoScores({ user }: { user: MONGO_UserDocument }) {
export function DashboardLoggedInNoScores({ user }: { user: UserDocument }) {
return (
<div>
<span className="display-4">
@@ -4,11 +4,10 @@ import { type GamePT } from "#types/react";
import { UppercaseFirst } from "#util/misc";
import React from "react";
import { Badge } from "react-bootstrap";
import { type Classes, GetGamePTConfig, GetGPTString, type GPTString } from "tachi-common";
import { type Classes, GetGameConfig, type V3Game } from "tachi-common";
export default function ClassBadge<GPT extends GPTString = GPTString>({
export default function ClassBadge<GPT extends V3Game = V3Game>({
game,
playtype,
classSet,
classValue,
showSetOnHover = true,
@@ -18,12 +17,10 @@ export default function ClassBadge<GPT extends GPTString = GPTString>({
showSetOnHover?: boolean;
} & GamePT) {
const classStyle =
// @ts-expect-error hepl i'm trapped in a type factory
GPT_CLIENT_IMPLEMENTATIONS[GetGPTString(game, playtype)].classColours[classSet][classValue];
// @ts-expect-error complex indexed types
GPT_CLIENT_IMPLEMENTATIONS[game].classColours[classSet][classValue];
const data = GetGamePTConfig(game, playtype).classes[classSet].values.find(
(e) => e.id === classValue,
);
const data = GetGameConfig(game).classes[classSet].values.find((e) => e.id === classValue);
if (!data) {
return (
@@ -64,7 +61,9 @@ export default function ClassBadge<GPT extends GPTString = GPTString>({
} else if (data.hoverText) {
return <QuickTooltip tooltipContent={data.hoverText}>{badgeComponent}</QuickTooltip>;
} else if (showSetOnHover) {
<QuickTooltip tooltipContent={UppercaseFirst(classSet)}>{badgeComponent}</QuickTooltip>;
return (
<QuickTooltip tooltipContent={UppercaseFirst(classSet)}>{badgeComponent}</QuickTooltip>
);
}
return badgeComponent;
@@ -14,7 +14,7 @@ import { NumericSOV } from "#util/sorts";
import { ResponsiveBar } from "@nivo/bar";
import React, { useState } from "react";
import Form from "react-bootstrap/Form";
import { COLOUR_SET, CreateSongMap, GetGamePTConfig, type integer } from "tachi-common";
import { COLOUR_SET, CreateSongMap, GetGameConfig, type integer } from "tachi-common";
const USER_COLOURS = [
COLOUR_SET.blue,
@@ -27,22 +27,21 @@ const USER_COLOURS = [
export default function ScoreLeaderboard({
game,
playtype,
url,
refreshDeps = [],
}: { refreshDeps?: Array<string>; url: string } & GamePT) {
const gptConfig = GetGamePTConfig(game, playtype);
const gameConfig = GetGameConfig(game);
const defaultAlg = useScoreRatingAlg(game, playtype);
const defaultAlg = useScoreRatingAlg(game);
const [alg, setAlg] = useState(defaultAlg);
const SelectComponent =
Object.keys(gptConfig.scoreRatingAlgs).length > 1 ? (
Object.keys(gameConfig.scoreRatingAlgs).length > 1 ? (
<Form.Select onChange={(e) => setAlg(e.target.value as any)} value={alg}>
{Object.keys(gptConfig.scoreRatingAlgs).map((e) => (
{Object.keys(gameConfig.scoreRatingAlgs).map((e) => (
<option key={e} value={e}>
{FormatGPTScoreRatingName(game, playtype, e)}
{FormatGPTScoreRatingName(game, e)}
</option>
))}
</Form.Select>
@@ -102,7 +101,6 @@ export default function ScoreLeaderboard({
defaultRankingViewMode="both-if-self"
game={game}
indexCol
playtype={playtype}
showChart
showUser
/>
@@ -111,15 +109,12 @@ export default function ScoreLeaderboard({
}
function DistributionChart({ dataset }: { dataset: PBDataset }) {
// username -> scores in the top N
const dist: Record<string, integer> = {};
for (const pb of dataset) {
const key = pb.__related.user?.username;
if (key === undefined) {
// shouldn't be possible, but lets not have the ui crash
// because of something so menial
continue;
}
@@ -15,42 +15,41 @@ import React, { useContext } from "react";
import { Col, Row } from "react-bootstrap";
import { Link } from "react-router-dom";
import {
type ChartDocument,
type FolderDocument,
FormatDifficultySearch,
type GameGroup,
GameToGameGroup,
GetGameConfig,
GetGameGroupConfig,
GetGamePTConfig,
GetGPTString,
type MONGO_ChartDocument,
type MONGO_FolderDocument,
type MONGO_SongDocument,
type SongDocument,
type V3Game,
} from "tachi-common";
export default function ChartInfoFormat({
song,
chart,
game,
playtype,
}: { chart: MONGO_ChartDocument; song: MONGO_SongDocument } & GamePT) {
const gptImpl = GPT_CLIENT_IMPLEMENTATIONS[GetGPTString(game, playtype)];
}: { chart: ChartDocument; song: SongDocument } & GamePT) {
const gptImpl = GPT_CLIENT_IMPLEMENTATIONS[game];
const ratingSystems = gptImpl.ratingSystems;
const { data, error } = useApiQuery<MONGO_FolderDocument[]>(
`/games/${game}/${playtype}/charts/${chart.chartID}/folders`,
const { data, error } = useApiQuery<FolderDocument[]>(
`/games/${game}/charts/${chart.chartID}/folders`,
);
const { user } = useContext(UserContext);
const { ugs } = useContext(AllLUGPTStatsContext);
if (error) {
<ApiError error={error} />;
return <ApiError error={error} />;
}
if (!data) {
return <Loading />;
}
const versions = Object.keys(GetGamePTConfig(game, playtype).versions);
const versions = Object.keys(GetGameConfig(game).versions);
return (
<Row
@@ -72,11 +71,11 @@ export default function ChartInfoFormat({
: 0,
)
.map((e) => (
<li key={e.folderID}>
<li key={e.slug}>
{user && ugs ? (
<Link
className="text-decoration-none"
to={`/u/${user.username}/games/${game}/${playtype}/folders/${e.folderID}`}
to={`/u/${user.username}/games/${game}/folders/${e.slug}`}
>
{e.title}
</Link>
@@ -145,12 +144,12 @@ function ChartInfoMiddle({
song,
chart,
}: {
chart: MONGO_ChartDocument;
game: GameGroup;
song: MONGO_SongDocument;
chart: ChartDocument;
game: V3Game;
song: SongDocument;
}) {
if (game === "bms") {
const bmsChart = chart as MONGO_ChartDocument<"bms:7K" | "bms:14K">;
if (game === "bms-7k" || game === "bms-14k") {
const bmsChart = chart as ChartDocument<"bms-7k" | "bms-14k">;
return (
<>
@@ -167,8 +166,8 @@ function ChartInfoMiddle({
</ExternalLink>
</>
);
} else if (game === "pms") {
const pmsChart = chart as MONGO_ChartDocument<"pms:Controller" | "pms:Keyboard">;
} else if (game === "pms-controller" || game === "pms-keyboard") {
const pmsChart = chart as ChartDocument<"pms-controller" | "pms-keyboard">;
return (
<>
@@ -181,11 +180,11 @@ function ChartInfoMiddle({
);
}
const gameConfig = GetGameGroupConfig(game);
const gameGroupConfig = GetGameGroupConfig(GameToGameGroup(game));
const diff = FormatDifficultySearch(chart, game);
const diff = FormatDifficultySearch(chart);
const gameName =
game === "ongeki" ? "オンゲキ" : game === "maimaidx" ? "maimai" : gameConfig.name;
game === "ongeki" ? "オンゲキ" : game === "maimaidx" ? "maimai" : gameGroupConfig.name;
const formattedTitle = song.title.replace(/-/gu, " ");
let search = `${gameName} ${formattedTitle}`;
@@ -8,12 +8,7 @@ import { type GamePT } from "#types/react";
import { type FolderDataset } from "#types/tables";
import { UppercaseFirst } from "#util/misc";
import React, { useMemo, useState } from "react";
import {
GetGamePTConfig,
GetScoreEnumConfs,
type GPTString,
type MONGO_UserDocument,
} from "tachi-common";
import { GetGameConfig, GetScoreEnumConfs, type UserDocument } from "tachi-common";
import FolderMinimap from "./FolderMinimap";
import FolderScoreAverages from "./FolderScoreAverages";
@@ -21,25 +16,24 @@ import FolderScoreDistributionChart from "./FolderScoreDistributionChart";
export default function FolderInfoHeader({
game,
playtype,
reqUser,
folderDataset,
folderTitle,
}: {
folderDataset: FolderDataset;
folderTitle: string;
reqUser: MONGO_UserDocument;
reqUser: UserDocument;
} & GamePT) {
const preferredDefaultEnum = useBucket(game, playtype);
const preferredDefaultEnum = useBucket(game);
const [currentGraph, setCurrentGraph] = useState<string>(`${preferredDefaultEnum}-stats`);
const enumGraphs = ["minimap", "stats"];
const gptConfig = GetGamePTConfig(game, playtype);
const gptImpl = GPT_CLIENT_IMPLEMENTATIONS[`${game}:${playtype}` as GPTString];
const gameConfig = GetGameConfig(game);
const gptImpl = GPT_CLIENT_IMPLEMENTATIONS[game];
const enumConf = GetScoreEnumConfs(gptConfig);
const enumConf = GetScoreEnumConfs(gameConfig);
const [metric, type] = useMemo(() => currentGraph.split("-"), [currentGraph]);
@@ -82,7 +76,6 @@ export default function FolderInfoHeader({
<FolderScoreDistributionChart
folderDataset={folderDataset}
game={game}
playtype={playtype}
view={metric}
/>
) : type === "minimap" ? (
@@ -90,16 +83,10 @@ export default function FolderInfoHeader({
enumMetric={metric}
folderDataset={folderDataset}
game={game}
playtype={playtype}
reqUser={reqUser}
/>
) : (
<FolderScoreAverages
folderDataset={folderDataset}
game={game}
playtype={playtype}
reqUser={reqUser}
/>
<FolderScoreAverages folderDataset={folderDataset} game={game} reqUser={reqUser} />
)}
</Card>
);
@@ -16,28 +16,27 @@ import React, { useMemo } from "react";
import { Link } from "react-router-dom";
import {
FormatChart,
type GameGroup,
type GamePTConfig,
GetGamePTConfig,
GetGPTString,
type GameConfig,
GetGameConfig,
GetScoreMetricConf,
IIDX_LAMPS,
type MONGO_SessionDocument,
type MONGO_UserDocument,
type SessionDocument,
type SessionScoreInfo,
type UserDocument,
type V3Game,
} from "tachi-common";
type Props = {
enumMetric: string;
folderDataset: FolderDataset;
reqUser: MONGO_UserDocument;
reqUser: UserDocument;
} & GamePT;
export default function FolderMinimap(props: Props) {
const { data, isLoading, error } = useApiQuery<{
scoreInfo: Array<SessionScoreInfo>;
session: MONGO_SessionDocument;
}>(`/users/${props.reqUser.id}/games/${props.game}/${props.playtype}/sessions/last`);
session: SessionDocument;
}>(`/users/${props.reqUser.id}/games/${props.game}/sessions/last`);
const session = useMemo(() => {
if (error && error.statusCode === 404) {
@@ -71,17 +70,16 @@ export default function FolderMinimap(props: Props) {
function FolderMinimapMain({
game,
playtype,
folderDataset,
enumMetric,
recentSession,
}: {
recentSession: { scoreInfo: Array<SessionScoreInfo>; session: MONGO_SessionDocument } | null;
recentSession: { scoreInfo: Array<SessionScoreInfo>; session: SessionDocument } | null;
} & Props) {
const gptConfig = GetGamePTConfig(game, playtype);
const gameConfig = GetGameConfig(game);
const getterFn = useMemo<(f: FolderDataset[0]) => number | undefined>(() => {
const conf = GetScoreMetricConf(gptConfig, enumMetric);
const conf = GetScoreMetricConf(gameConfig, enumMetric);
if (!conf) {
return () => 0; // wut
@@ -133,7 +131,7 @@ function FolderMinimapMain({
data={d}
enumMetric={enumMetric}
game={game}
gptConfig={gptConfig}
gameConfig={gameConfig}
key={d.chartID}
wasRecent={
(d.__related.pb &&
@@ -154,18 +152,18 @@ function FolderMinimapMain({
function MinimapElement({
data,
gptConfig,
gameConfig,
enumMetric,
game,
wasRecent,
}: {
data: FolderDataset[0];
enumMetric: string;
game: GameGroup;
gptConfig: GamePTConfig;
game: V3Game;
gameConfig: GameConfig;
wasRecent: boolean;
}) {
const gptImpl = GPT_CLIENT_IMPLEMENTATIONS[GetGPTString(game, data.playtype)];
const gptImpl = GPT_CLIENT_IMPLEMENTATIONS[game];
let icon = "level-up-alt";
@@ -188,14 +186,14 @@ function MinimapElement({
// @ts-expect-error unhinged dynamic access (i dont care)
return gptImpl.enumColours[enumMetric][data.__related.pb.scoreData[enumMetric]];
}, [data.__related.pb, gptConfig, enumMetric]);
}, [data.__related.pb, gameConfig, enumMetric]);
return (
<QuickTooltip
max
tooltipContent={
<div className="w-100">
<span>{FormatChart(game, data.__related.song, data)}</span>
<span>{FormatChart(data)}</span>
<Divider />
{wasRecent && (
<>
@@ -222,7 +220,7 @@ function MinimapElement({
</div>
}
>
<Link to={CreateChartLink(data, game)}>
<Link to={CreateChartLink(data)}>
<div
className={`scoreinfo-grid-minimap-element ${
wasRecent ? "scoreinfo-grid-minimap-element-recent" : ""
@@ -8,7 +8,7 @@ import { type FolderDataset } from "#types/tables";
import { ToFixedFloor, UppercaseFirst } from "#util/misc";
import { NumericSOV } from "#util/sorts";
import React, { useMemo } from "react";
import { GetGamePTConfig } from "tachi-common";
import { GetGameConfig } from "tachi-common";
import {
type ConfDecimalScoreMetric,
type ConfIntegerScoreMetric,
@@ -17,13 +17,12 @@ import {
export default function FolderScoreAverages({
folderDataset,
game,
playtype,
}: { folderDataset: FolderDataset } & UGPT) {
const gptConfig = GetGamePTConfig(game, playtype);
const gameConfig = GetGameConfig(game);
const metrics = {
...gptConfig.providedMetrics,
...gptConfig.derivedMetrics,
...gameConfig.providedMetrics,
...gameConfig.derivedMetrics,
};
const entries = Object.entries(metrics);
@@ -3,7 +3,7 @@ import { type GamePT } from "#types/react";
import { type FolderDataset } from "#types/tables";
import { CountElements, Reverse } from "#util/misc";
import React, { useMemo } from "react";
import { GetGamePTConfig, GetGPTString, GetScoreMetricConf } from "tachi-common";
import { GetGameConfig, GetScoreMetricConf } from "tachi-common";
import { type ConfEnumScoreMetric } from "tachi-common/types/metrics";
import FolderDistributionTable from "./FolderDistributionTable";
@@ -13,16 +13,11 @@ type Props = {
view: string;
} & GamePT;
export default function FolderScoreDistributionChart({
game,
playtype,
folderDataset,
view: metric,
}: Props) {
const gptConfig = GetGamePTConfig(game, playtype);
const conf = GetScoreMetricConf(gptConfig, metric) as ConfEnumScoreMetric<string>;
export default function FolderScoreDistributionChart({ game, folderDataset, view: metric }: Props) {
const gameConfig = GetGameConfig(game);
const conf = GetScoreMetricConf(gameConfig, metric) as ConfEnumScoreMetric<string>;
const gptImpl = GPT_CLIENT_IMPLEMENTATIONS[GetGPTString(game, playtype)];
const gptImpl = GPT_CLIENT_IMPLEMENTATIONS[game];
const values = useMemo(
// @ts-expect-error hack
@@ -1,20 +1,15 @@
import { DisplayLevelNum } from "#components/tables/cells/DifficultyCell";
import React from "react";
import {
FormatDifficulty,
type GameGroup,
type MONGO_ChartDocument,
type MONGO_SongDocument,
} from "tachi-common";
import { type ChartDocument, FormatDifficulty, type SongDocument, type V3Game } from "tachi-common";
export default function DefaultSongChartInfoFormat({
song,
chart,
game,
}: {
chart: MONGO_ChartDocument | null;
game: GameGroup;
song: MONGO_SongDocument;
chart: ChartDocument | null;
game: V3Game;
song: SongDocument;
}) {
return (
<>
@@ -23,7 +18,7 @@ export default function DefaultSongChartInfoFormat({
</h4>
{chart && (
<>
<h5>({FormatDifficulty(chart, game)})</h5>
<h5>({FormatDifficulty(chart)})</h5>
<h6>
<DisplayLevelNum
game={game}
@@ -1,35 +1,30 @@
import { FormatTables } from "#util/misc";
import React from "react";
import {
FormatDifficulty,
type GameGroup,
type MONGO_ChartDocument,
type MONGO_SongDocument,
} from "tachi-common";
import { type ChartDocument, FormatDifficulty, type SongDocument, type V3Game } from "tachi-common";
export default function IIDXStyleSongChartInfoFormat({
song,
chart,
game,
}: {
chart: MONGO_ChartDocument | null;
game: GameGroup;
song: MONGO_SongDocument<"bms" | "chunithm" | "iidx" | "maimaidx" | "ongeki" | "pms" | "popn">;
chart: ChartDocument | null;
game: V3Game;
song: SongDocument<"bms" | "chunithm" | "iidx" | "maimaidx" | "ongeki" | "pms" | "popn">;
}) {
return (
<>
<h4>{song.data.genre}</h4>
<h4 style={{ fontSize: "2.5rem", fontWeight: "bold" }}>{song.title}</h4>
<h4>{song.artist}</h4>
{chart && <h5>({LevelText(chart, game)})</h5>}
{chart && <h5>({LevelText(chart)})</h5>}
</>
);
}
function LevelText(chart: MONGO_ChartDocument, game: GameGroup) {
function LevelText(chart: ChartDocument) {
if ("tableFolders" in chart.data) {
const hasLevel = Object.keys(chart.data.tableFolders).length > 0;
return hasLevel ? FormatTables(chart.data.tableFolders) : "No Level";
}
return FormatDifficulty(chart, game);
return FormatDifficulty(chart);
}
@@ -1,7 +1,7 @@
import { DisplayLevelNum } from "#components/tables/cells/DifficultyCell";
import Muted from "#components/util/Muted";
import React from "react";
import { type GameGroup, type MONGO_ChartDocument, type MONGO_SongDocument } from "tachi-common";
import { type ChartDocument, GameToGameGroup, type SongDocument, type V3Game } from "tachi-common";
import DefaultSongChartInfoFormat from "./DefaultSongChartInfoFormat";
import IIDXStyleSongChartInfoFormat from "./IIDXStyleSongChartInfoFormat";
@@ -11,27 +11,29 @@ export default function SongChartInfoFormat({
chart,
game,
}: {
chart: MONGO_ChartDocument | null;
game: GameGroup;
song: MONGO_SongDocument;
chart: ChartDocument | null;
game: V3Game;
song: SongDocument;
}) {
if (["bms", "iidx", "pms", "popn"].includes(game)) {
const gameGroup = GameToGameGroup(game);
if (["bms", "iidx", "pms", "popn"].includes(gameGroup)) {
return (
<IIDXStyleSongChartInfoFormat
{...{
song: song as MONGO_SongDocument<"bms" | "iidx" | "pms" | "popn">,
song: song as SongDocument<"bms" | "iidx" | "pms" | "popn">,
chart,
game,
}}
/>
);
}
if (game === "ongeki" || game === "chunithm" || game === "maimaidx") {
if (gameGroup === "ongeki" || gameGroup === "chunithm" || gameGroup === "maimaidx") {
return (
<>
<IIDXStyleSongChartInfoFormat
{...{
song: song as MONGO_SongDocument<"chunithm" | "maimaidx" | "ongeki">,
song: song as SongDocument<"chunithm" | "maimaidx" | "ongeki">,
chart,
game,
}}
@@ -10,24 +10,16 @@ import { CreateGoalMap } from "#util/data";
import React from "react";
import { Col } from "react-bootstrap";
import { Link, useParams } from "react-router-dom";
import { GetGameGroupConfig } from "tachi-common";
import { FormatGame } from "tachi-common";
export default function QuestPage({ game, playtype }: GamePT) {
export default function QuestPage({ game }: GamePT) {
const { questID } = useParams<{ questID: string }>();
const { data, error } = useApiQuery<QuestReturn>(
`/games/${game}/${playtype}/targets/quests/${questID}`,
);
const { data, error } = useApiQuery<QuestReturn>(`/games/${game}/targets/quests/${questID}`);
useSetSubheader(
[
"Games",
GetGameGroupConfig(game).name,
playtype,
"Quests",
data ? data.quest.name : "Loading...",
],
[game, playtype, data],
["Games", FormatGame(game), "Quests", data ? data.quest.name : "Loading..."],
[game, data],
data ? data.quest.name : "Loading...",
);
@@ -46,7 +38,7 @@ export default function QuestPage({ game, playtype }: GamePT) {
return (
<div>
<Col xs={12}>
<Link to={`/games/${game}/${playtype}/quests`}>Go back to all quests...</Link>
<Link to={`/games/${game}/quests`}>Go back to all quests...</Link>
<Divider />
<Quest goals={goalMap} quest={quest} />
</Col>
@@ -12,24 +12,18 @@ import { CreateQuestMap } from "#util/misc";
import React from "react";
import { Col, Row } from "react-bootstrap";
import { Link, useParams } from "react-router-dom";
import { GetGameGroupConfig } from "tachi-common";
import { FormatGame } from "tachi-common";
export default function QuestlinePage({ game, playtype }: GamePT) {
export default function QuestlinePage({ game }: GamePT) {
const { questlineID } = useParams<{ questlineID: string }>();
const { data, error } = useApiQuery<QuestlineReturn>(
`/games/${game}/${playtype}/targets/questlines/${questlineID}`,
`/games/${game}/targets/questlines/${questlineID}`,
);
useSetSubheader(
[
"Games",
GetGameGroupConfig(game).name,
playtype,
"Quests",
data ? data.questline.name : "Loading...",
],
[game, playtype, data],
["Games", FormatGame(game), "Quests", data ? data.questline.name : "Loading..."],
[game, data],
data ? data.questline.name : "Loading...",
);
@@ -48,7 +42,7 @@ export default function QuestlinePage({ game, playtype }: GamePT) {
return (
<Row>
<Col xs={12}>
<Link to={`/games/${game}/${playtype}/quests`}>Go back to all questlines...</Link>
<Link to={`/games/${game}/quests`}>Go back to all questlines...</Link>
<Divider />
<Questline questline={data.questline} quests={questMap} />
<Divider />
@@ -12,33 +12,28 @@ import React from "react";
import { Col, Row } from "react-bootstrap";
import { Link } from "react-router-dom";
import {
FormatGameGroup,
GetGameGroupConfig,
type MONGO_GoalDocument,
type MONGO_QuestDocument,
type MONGO_QuestlineDocument,
FormatGame,
type GoalDocument,
type QuestDocument,
type QuestlineDocument,
} from "tachi-common";
export default function QuestsPage({ game, playtype }: GamePT) {
useSetSubheader(
["Games", GetGameGroupConfig(game).name, playtype, "Quests"],
[game, playtype],
`${FormatGameGroup(game, playtype)} Quests`,
);
export default function QuestsPage({ game }: GamePT) {
useSetSubheader(["Games", FormatGame(game), "Quests"], [game], `${FormatGame(game)} Quests`);
return (
<div>
<QuestlineSelector game={game} playtype={playtype} />
<QuestlineSelector game={game} />
</div>
);
}
function QuestlineSelector({ game, playtype }: GamePT) {
function QuestlineSelector({ game }: GamePT) {
const { data, error } = useApiQuery<{
questlines: Array<MONGO_QuestlineDocument>;
standalone: Array<MONGO_QuestDocument>;
standaloneGoals: Array<MONGO_GoalDocument>;
}>(`/games/${game}/${playtype}/targets/questlines`);
questlines: Array<QuestlineDocument>;
standalone: Array<QuestDocument>;
standaloneGoals: Array<GoalDocument>;
}>(`/games/${game}/targets/questlines`);
if (error) {
return <ApiError error={error} />;
@@ -1,5 +1,5 @@
import { type GPTUtility } from "#types/ugpt";
import { type GameGroup, type GPTString, type Playtype } from "tachi-common";
import { type V3Game } from "tachi-common";
import { JubilityBreakdownInsight } from "./insights/JubilityBreakdownInsight";
import {
@@ -11,46 +11,39 @@ import { BMSSieglindeInfoTool } from "./tools/BMSSieglindeInfoTool";
import { IIDXEamusementExportTool } from "./tools/IIDXEamusementExportTool";
import { IIDXPlaylistsTool } from "./tools/IIDXPlaylistsTool";
// What utils does each game support?
const GPT_UTILS: Record<GPTString, Array<GPTUtility>> = {
"arcaea:Touch": [],
"bms:7K": [BMSCustomTablesTool, BMSSieglindeInfoTool],
"bms:14K": [BMSCustomTablesTool, BMSSieglindeInfoTool],
"chunithm:Single": [],
"gitadora:Dora": [],
"gitadora:Gita": [],
"iidx:DP": [IIDXEamusementExportTool, IIDXPlaylistsTool],
"iidx:SP": [IIDXEamusementExportTool, IIDXPlaylistsTool],
"itg:Stamina": [],
"jubeat:Single": [JubilityBreakdownInsight],
"museca:Single": [],
"pms:Controller": [],
"pms:Keyboard": [],
"popn:9B": [],
"sdvx:Single": [],
"usc:Controller": [],
"usc:Keyboard": [],
"wacca:Single": [],
"maimaidx:Single": [],
"maimai:Single": [],
"ongeki:Single": [ONGEKIRefreshBreakdownInsight, ONGEKIClassicBreakdownInsight],
"ddr:SP": [],
"ddr:DP": [],
const GPT_UTILS: Record<V3Game, Array<GPTUtility>> = {
arcaea: [],
"bms-7k": [BMSCustomTablesTool, BMSSieglindeInfoTool],
"bms-14k": [BMSCustomTablesTool, BMSSieglindeInfoTool],
chunithm: [],
"gitadora-dora": [],
"gitadora-gita": [],
"iidx-dp": [IIDXEamusementExportTool, IIDXPlaylistsTool],
"iidx-sp": [IIDXEamusementExportTool, IIDXPlaylistsTool],
"itg-stamina": [],
jubeat: [JubilityBreakdownInsight],
museca: [],
"pms-controller": [],
"pms-keyboard": [],
popn: [],
sdvx: [],
"usc-controller": [],
"usc-keyboard": [],
wacca: [],
maimaidx: [],
maimai: [],
ongeki: [ONGEKIRefreshBreakdownInsight, ONGEKIClassicBreakdownInsight],
"ddr-sp": [],
"ddr-dp": [],
};
export function GetGPTUtils(game: GameGroup, playtype: Playtype) {
const idString = `${game}:${playtype}` as GPTString;
return GPT_UTILS[idString];
export function GetGPTUtils(game: V3Game) {
return GPT_UTILS[game];
}
export function GetGPTUtilsName(game: GameGroup, playtype: Playtype, isViewingOwnProfile: boolean) {
const utils = GetGPTUtils(game, playtype);
export function GetGPTUtilsName(game: V3Game, isViewingOwnProfile: boolean) {
const utils = GetGPTUtils(game);
// things for personal use only are called "tools"
// things everyone might want to view about a person are called "insights".
// an example of an insight would be a jubility breakdown
// an example of a tool would be a eamusement csv export thing.
const tools = utils.filter((e) => e.personalUseOnly);
const insights = utils.filter((e) => e.personalUseOnly !== true);
@@ -14,19 +14,19 @@ import { NumericSOV } from "#util/sorts";
import React from "react";
import { Col, Row } from "react-bootstrap";
import {
type ChartDocument,
CreateSongMap,
type MONGO_ChartDocument,
type MONGO_PBScoreDocument,
type MONGO_SongDocument,
type PBScoreDocument,
type SongDocument,
} from "tachi-common";
function Component({ game, playtype, reqUser }: UGPT) {
function Component({ game, reqUser }: UGPT) {
const { data, error } = useApiQuery<{
charts: Array<MONGO_ChartDocument>;
other: Array<MONGO_PBScoreDocument>;
pickUp: Array<MONGO_PBScoreDocument>;
songs: Array<MONGO_SongDocument>;
}>(`/users/${reqUser.id}/games/${game}/${playtype}/jubility`);
charts: Array<ChartDocument>;
other: Array<PBScoreDocument>;
pickUp: Array<PBScoreDocument>;
songs: Array<SongDocument>;
}>(`/users/${reqUser.id}/games/${game}/jubility`);
const preferredRanking = usePreferredRanking();
@@ -107,7 +107,6 @@ function Component({ game, playtype, reqUser }: UGPT) {
defaultRankingViewMode={preferredRanking}
game={game}
indexCol
playtype={playtype}
/>
</Card>
<Divider />
@@ -123,7 +122,6 @@ function Component({ game, playtype, reqUser }: UGPT) {
defaultRankingViewMode={preferredRanking}
game={game}
indexCol
playtype={playtype}
/>
</Card>
</Col>
@@ -20,27 +20,26 @@ import React from "react";
import { Col, Row } from "react-bootstrap";
import {
type AnyScoreRatingAlg,
type ChartDocument,
COLOUR_SET,
CreateSongMap,
FmtNum,
type GameGroup,
type GPTString,
type integer,
type MONGO_ChartDocument,
type MONGO_PBScoreDocument,
type MONGO_SongDocument,
type PBScoreDocument,
type SongDocument,
type V3Game,
} from "tachi-common";
function ComponentClassic({ game, playtype, reqUser }: UGPT) {
if (game !== "ongeki" || playtype !== "Single") {
throw new Error("Game or playtype is not ongeki:Single");
function ComponentClassic({ game, reqUser }: UGPT) {
if (game !== "ongeki") {
throw new Error("Game is not ongeki");
}
const { data, error } = useApiQuery<{
charts: Array<MONGO_ChartDocument<"ongeki:Single">>;
pbs: Array<MONGO_PBScoreDocument<"ongeki:Single">>;
songs: Array<MONGO_SongDocument<"ongeki">>;
}>(`/users/${reqUser.id}/games/${game}/${playtype}/pbs/best?alg=rating`);
charts: Array<ChartDocument<"ongeki">>;
pbs: Array<PBScoreDocument<"ongeki">>;
songs: Array<SongDocument<"ongeki">>;
}>(`/users/${reqUser.id}/games/${game}/pbs/best?alg=rating`);
if (error) {
return <ApiError error={error} />;
@@ -50,8 +49,8 @@ function ComponentClassic({ game, playtype, reqUser }: UGPT) {
return <Loading />;
}
const flatDataset: PBDataset<"ongeki:Single"> = CreateFlatDataset(data, "rating", 45);
const compoundDataset = ColumnMerge<"ongeki:Single">(flatDataset, 3);
const flatDataset: PBDataset<"ongeki"> = CreateFlatDataset<"ongeki">(data, "rating", 45);
const compoundDataset = ColumnMerge<"ongeki">(flatDataset, 3);
const classicRating =
Math.floor(
@@ -94,22 +93,22 @@ function ComponentClassic({ game, playtype, reqUser }: UGPT) {
);
}
function ComponentRefresh({ game, playtype, reqUser }: UGPT) {
if (game !== "ongeki" || playtype !== "Single") {
throw new Error("Game or playtype is not ongeki:Single");
function ComponentRefresh({ game, reqUser }: UGPT) {
if (game !== "ongeki") {
throw new Error("Game is not ongeki");
}
const query1 = useApiQuery<{
charts: Array<MONGO_ChartDocument<"ongeki:Single">>;
pbs: Array<MONGO_PBScoreDocument<"ongeki:Single">>;
songs: Array<MONGO_SongDocument<"ongeki">>;
}>(`/users/${reqUser.id}/games/${game}/${playtype}/pbs/best?alg=scoreRating`);
charts: Array<ChartDocument<"ongeki">>;
pbs: Array<PBScoreDocument<"ongeki">>;
songs: Array<SongDocument<"ongeki">>;
}>(`/users/${reqUser.id}/games/${game}/pbs/best?alg=scoreRating`);
const query2 = useApiQuery<{
charts: Array<MONGO_ChartDocument<"ongeki:Single">>;
pbs: Array<MONGO_PBScoreDocument<"ongeki:Single">>;
songs: Array<MONGO_SongDocument<"ongeki">>;
}>(`/users/${reqUser.id}/games/${game}/${playtype}/pbs/best?alg=starRating`);
charts: Array<ChartDocument<"ongeki">>;
pbs: Array<PBScoreDocument<"ongeki">>;
songs: Array<SongDocument<"ongeki">>;
}>(`/users/${reqUser.id}/games/${game}/pbs/best?alg=starRating`);
if (query1.error) {
return <ApiError error={query1.error} />;
@@ -123,19 +122,19 @@ function ComponentRefresh({ game, playtype, reqUser }: UGPT) {
return <Loading />;
}
const flatDatasetScore: PBDataset<"ongeki:Single"> = CreateFlatDataset(
const flatDatasetScore: PBDataset<"ongeki"> = CreateFlatDataset<"ongeki">(
query1.data,
"scoreRating",
60,
);
const flatDatasetStar: PBDataset<"ongeki:Single"> = CreateFlatDataset(
const flatDatasetStar: PBDataset<"ongeki"> = CreateFlatDataset<"ongeki">(
query2.data,
"starRating",
51,
);
const datasetScore = ColumnMerge<"ongeki:Single">(flatDatasetScore, 3);
const datasetStar = ColumnMerge<"ongeki:Single">(flatDatasetStar, 3);
const datasetScore = ColumnMerge<"ongeki">(flatDatasetScore, 3);
const datasetStar = ColumnMerge<"ongeki">(flatDatasetStar, 3);
const scoreR1k = Math.floor(
flatDatasetScore.reduce(
@@ -228,13 +227,13 @@ function CompactRow({
lampField,
}: {
count?: number;
game: GameGroup;
lampField: (pb: MONGO_PBScoreDocument<"ongeki:Single">) => string | JSX.Element;
pbs: PBDataset<"ongeki:Single">[0][];
ratingField: (pb: MONGO_PBScoreDocument<"ongeki:Single">) => string | JSX.Element;
game: V3Game;
lampField: (pb: PBScoreDocument<"ongeki">) => string | JSX.Element;
pbs: PBDataset<"ongeki">[0][];
ratingField: (pb: PBScoreDocument<"ongeki">) => string | JSX.Element;
scoreField: (
pb: MONGO_PBScoreDocument<"ongeki:Single">,
chart: MONGO_ChartDocument<"ongeki:Single">,
pb: PBScoreDocument<"ongeki">,
chart: ChartDocument<"ongeki">,
) => string | JSX.Element;
}) {
return (
@@ -273,15 +272,15 @@ function CompactCell({
ratingField,
lampField,
}: {
chart: MONGO_ChartDocument<"ongeki:Single">;
lampField: (pb: MONGO_PBScoreDocument<"ongeki:Single">) => string | JSX.Element;
pb: MONGO_PBScoreDocument<"ongeki:Single">;
ratingField: (pb: MONGO_PBScoreDocument<"ongeki:Single">) => string | JSX.Element;
chart: ChartDocument<"ongeki">;
lampField: (pb: PBScoreDocument<"ongeki">) => string | JSX.Element;
pb: PBScoreDocument<"ongeki">;
ratingField: (pb: PBScoreDocument<"ongeki">) => string | JSX.Element;
scoreField: (
pb: MONGO_PBScoreDocument<"ongeki:Single">,
chart: MONGO_ChartDocument<"ongeki:Single">,
pb: PBScoreDocument<"ongeki">,
chart: ChartDocument<"ongeki">,
) => string | JSX.Element;
song: MONGO_SongDocument;
song: SongDocument;
}) {
// Third-party scripts may find this useful
const className = `c-${chart.data.inGameID}`;
@@ -290,7 +289,7 @@ function CompactCell({
<td className={className} style={{ width: "300px" }}>
<div className="d-flex flex-column gap-2" style={{ textAlign: "left" }}>
<div>
<GentleLink to={CreateChartLink(chart, "ongeki")}>
<GentleLink to={CreateChartLink(chart)}>
<span style={{ fontSize: "120%" }}>{song.title}</span>
</GentleLink>
</div>
@@ -339,7 +338,7 @@ function ShortLamp({
let text1 = "";
let color2 = COLOUR_SET.gray;
let text2 = "";
const color3 = GPT_CLIENT_IMPLEMENTATIONS["ongeki:Single"].enumColours.grade[grade];
const color3 = GPT_CLIENT_IMPLEMENTATIONS.ongeki.enumColours.grade[grade];
if (noteLamp === "ALL BREAK+") {
color1 = COLOUR_SET.vibrantBlue;
@@ -388,14 +387,14 @@ function ShortLamp({
);
}
function CreateFlatDataset<T extends GPTString>(data: any, alg: AnyScoreRatingAlg, count: number) {
function CreateFlatDataset<T extends V3Game>(data: any, alg: AnyScoreRatingAlg, count: number) {
const flatDataset: PBDataset<T> = [];
const songMap = CreateSongMap(data.songs);
const chartMap = CreateChartMap<T>(data.charts);
const sortedRatings = data.pbs
.map((e: MONGO_PBScoreDocument) => e.calculatedData[alg])
.map((e: PBScoreDocument) => e.calculatedData[alg])
.sort(NumericSOV((x: number) => x ?? -Infinity, true));
for (const pb of data.pbs.slice(0, count)) {
@@ -419,7 +418,7 @@ function CreateFlatDataset<T extends GPTString>(data: any, alg: AnyScoreRatingAl
return flatDataset;
}
function ColumnMerge<T extends GPTString>(flatDataset: PBDataset<T>, columns: number) {
function ColumnMerge<T extends V3Game>(flatDataset: PBDataset<T>, columns: number) {
const compoundDataset: PBDataset<T>[] = [[]];
for (const d of flatDataset) {
const back = compoundDataset[compoundDataset.length - 1];
@@ -12,7 +12,7 @@ import { CopyToClipboard } from "#util/misc";
import React from "react";
import { Col, Row } from "react-bootstrap";
function Component({ game, playtype, reqUser }: UGPT) {
function Component({ game, reqUser }: UGPT) {
const { data, error } = useApiQuery<
Array<{
description: string;
@@ -21,7 +21,7 @@ function Component({ game, playtype, reqUser }: UGPT) {
tableName: string;
urlName: string;
}>
>(`/games/${game}/${playtype}/custom-tables`);
>(`/games/${game}/custom-tables`);
if (error) {
return <ApiError error={error} />;
@@ -42,7 +42,7 @@ function Component({ game, playtype, reqUser }: UGPT) {
const TABLE_URL = ToAPIURL(
`${
tbl.forSpecificUser ? `/users/${reqUser.username}` : ""
}/games/bms/${playtype}/custom-tables/${tbl.urlName}`,
}/games/${game}/custom-tables/${tbl.urlName}`,
);
return (
@@ -13,18 +13,18 @@ import { CreateSongMap } from "#util/data";
import { NumericSOV, StrSOV } from "#util/sorts";
import React from "react";
import { Col, Row } from "react-bootstrap";
import { BMS_TABLES, type MONGO_ChartDocument, type MONGO_SongDocument } from "tachi-common";
import { BMS_TABLES, type ChartDocument, type SongDocument } from "tachi-common";
import { FormatSieglindeBMS } from "tachi-common/config/game-support/bms";
type DatasetElement = { __related: { song: MONGO_SongDocument } } & MONGO_ChartDocument<
"bms:7K" | "bms:14K"
>;
type BMSGames = "bms-7k" | "bms-14k";
function Component({ game, playtype }: UGPT) {
type DatasetElement = { __related: { song: SongDocument } } & ChartDocument<BMSGames>;
function Component({ game }: UGPT) {
const { data, error } = useApiQuery<{
charts: Array<MONGO_ChartDocument<"bms:7K" | "bms:14K">>;
songs: Array<MONGO_SongDocument<"bms">>;
}>(`/games/${game}/${playtype}/sieglinde-charts`);
charts: Array<ChartDocument<BMSGames>>;
songs: Array<SongDocument<"bms">>;
}>(`/games/${game}/sieglinde-charts`);
if (error) {
return <ApiError error={error} />;
@@ -39,10 +39,10 @@ function Component({ game, playtype }: UGPT) {
const songMap = CreateSongMap(data.songs);
for (const chart of data.charts) {
const song = songMap.get(chart.songID);
const song = songMap.get(chart.song.id);
if (!song) {
console.warn(`Couldn't find a parent song for ${chart.songID}? Skipping.`);
console.warn(`Couldn't find a parent song for ${chart.song.id}? Skipping.`);
continue;
}
@@ -54,6 +54,8 @@ function Component({ game, playtype }: UGPT) {
});
}
const bmsGame = (game === "bms-7k" || game === "bms-14k" ? game : "bms-7k") as BMSGames;
return (
<Row>
<Col xs={12}>
@@ -75,32 +77,45 @@ function Component({ game, playtype }: UGPT) {
defaultSortMode="EASY CLEAR Sieglinde"
entryName="Charts"
headers={[
ChartHeader("bms", (k) => k as MONGO_ChartDocument),
ChartHeader(bmsGame, (k) => k as ChartDocument),
["Song", "Song", StrSOV((x) => x.__related.song.title)],
[
"EASY CLEAR Sieglinde",
"EC sgl.",
NumericSOV((x) => x.data.sglEC ?? -Infinity),
NumericSOV(
(x) => (x as ChartDocument<"bms-7k">).data.sglEC ?? -Infinity,
),
],
[
"HARD CLEAR Sieglinde",
"EC sgl.",
NumericSOV((x) => x.data.sglHC ?? -Infinity),
NumericSOV(
(x) => (x as ChartDocument<"bms-7k">).data.sglHC ?? -Infinity,
),
],
]}
rowFunction={(d) => (
<tr>
<BMSOrPMSDifficultyCell chart={d} game="bms" />
<TitleCell chart={d} game="bms" song={d.__related.song} />
<td>{FormatSieglindeBMS(d.data.sglEC ?? 0)}</td>
<td>{FormatSieglindeBMS(d.data.sglHC ?? 0)}</td>
<BMSOrPMSDifficultyCell
chart={d as ChartDocument<BMSGames>}
game={bmsGame}
/>
<TitleCell chart={d} game={bmsGame} song={d.__related.song} />
<td>
{FormatSieglindeBMS((d as ChartDocument<"bms-7k">).data.sglEC ?? 0)}
</td>
<td>
{FormatSieglindeBMS((d as ChartDocument<"bms-7k">).data.sglHC ?? 0)}
</td>
</tr>
)}
searchFunctions={Object.fromEntries(
BMS_TABLES.map((e) => [
e.asciiPrefix,
(d: DatasetElement) =>
!!Object.keys(d.data.tableFolders).find((k) => k === e.prefix),
!!Object.keys(
(d as ChartDocument<"bms-7k">).data.tableFolders,
).find((k) => k === e.prefix),
]),
)}
/>
@@ -9,9 +9,9 @@ import { CopyToClipboard } from "#util/misc";
import React from "react";
import { Alert, Button, Col, Row } from "react-bootstrap";
function Component({ game, playtype, reqUser }: UGPT) {
function Component({ game, reqUser }: UGPT) {
const { data, error } = useApiQuery<string>(
`/users/${reqUser.id}/games/${game}/${playtype}/eamusement-csv`,
`/users/${reqUser.id}/games/${game}/eamusement-csv`,
);
if (error) {
@@ -9,7 +9,7 @@ import { ToAPIURL } from "#util/api";
import React from "react";
import { Col, Row } from "react-bootstrap";
function Component({ game, playtype, reqUser }: UGPT) {
function Component({ game, reqUser }: UGPT) {
const { data, error } = useApiQuery<
Array<{
description: string;
@@ -17,7 +17,7 @@ function Component({ game, playtype, reqUser }: UGPT) {
playlistName: string;
urlName: string;
}>
>(`/games/${game}/${playtype}/playlists`);
>(`/games/${game}/playlists`);
if (error) {
return <ApiError error={error} />;
@@ -47,7 +47,7 @@ function Component({ game, playtype, reqUser }: UGPT) {
e.forSpecificUser
? `/users/${reqUser.username}`
: ""
}/games/iidx/${playtype}/playlists/${e.urlName}`,
}/games/${game}/playlists/${e.urlName}`,
),
),
),
@@ -12,7 +12,7 @@ import { type SetState } from "#types/react";
import { APIFetchV1 } from "#util/api";
import React, { useContext, useMemo, useReducer, useState } from "react";
import { Button, Form } from "react-bootstrap";
import { type APIImportTypes, GetGameGroupConfig, type MONGO_CGCardInfo } from "tachi-common";
import { type APIImportTypes, type CGCardInfo, GetGameGroupConfig } from "tachi-common";
import ImportStateRenderer from "./ImportStateRenderer";
@@ -37,7 +37,7 @@ export default function CGIntegrationPage({ cgType, game }: Props) {
}
// eslint-disable-next-line react-hooks/rules-of-hooks
const { data, error } = useApiQuery<MONGO_CGCardInfo | null>(
const { data, error } = useApiQuery<CGCardInfo | null>(
`/users/${user.id}/integrations/cg/${cgType}`,
undefined,
[reload],
@@ -16,24 +16,23 @@ import React, { useContext, useEffect, useMemo, useState } from "react";
import { Alert, ButtonGroup } from "react-bootstrap";
import { Link } from "react-router-dom";
import {
type GameGroup,
type MONGO_ChartDocument,
type MONGO_ImportDocument,
type MONGO_ScoreDocument,
type MONGO_SessionDocument,
type MONGO_SongDocument,
type MONGO_UserDocument,
type MONGO_UserGameStats,
type Playtype,
type ChartDocument,
type ImportDocument,
type ScoreDocument,
type SessionDocument,
type SongDocument,
type UserDocument,
type UserGameStats,
type V3Game,
} from "tachi-common";
interface Data {
import: MONGO_ImportDocument;
scores: MONGO_ScoreDocument[];
charts: MONGO_ChartDocument[];
songs: MONGO_SongDocument[];
sessions: MONGO_SessionDocument[];
user: MONGO_UserDocument;
import: ImportDocument;
scores: ScoreDocument[];
charts: ChartDocument[];
songs: SongDocument[];
sessions: SessionDocument[];
user: UserDocument;
}
export default function ImportInfo({
@@ -54,7 +53,7 @@ export default function ImportInfo({
return;
}
APIFetchV1<MONGO_UserGameStats[]>(`/users/${user!.id}/game-stats`).then((r) => {
APIFetchV1<UserGameStats[]>(`/users/${user!.id}/game-stats`).then((r) => {
if (!r.success) {
console.warn(`Can't update user stats post-import. ${r.description}`);
return;
@@ -160,7 +159,7 @@ function SessionTab({ data }: { data: Data }) {
const dataset = [];
const sessionMap: Map<string, MONGO_SessionDocument> = new Map();
const sessionMap: Map<string, SessionDocument> = new Map();
for (const session of data.sessions) {
sessionMap.set(session.sessionID, session);
@@ -187,7 +186,7 @@ function SessionTab({ data }: { data: Data }) {
<td>
<Link
className="text-decoration-none"
to={`/u/${r.session.userID}/games/${r.session.game}/${r.session.playtype}/sessions/${r.session.sessionID}`}
to={`/u/${r.session.userID}/games/${r.session.game}/sessions/${r.session.sessionID}`}
>
{r.session.name}
</Link>
@@ -203,22 +202,22 @@ function SessionTab({ data }: { data: Data }) {
function ScoreTab({ data }: { data: Data }) {
const importDoc = data.import;
if (importDoc.playtypes.length === 0) {
if (importDoc.games.length === 0) {
return (
<div className="row mt-4">
<span className="w-100 text-center">No scores...</span>
</div>
);
} else if (importDoc.playtypes.length > 1) {
const datasets = [];
} else if (importDoc.games.length > 1) {
const datasets: { data: ScoreDataset; game: V3Game }[] = [];
for (const playtype of importDoc.playtypes) {
for (const game of importDoc.games) {
const scoreDataset: ScoreDataset = [];
const songMap = CreateSongMap(data.songs);
const chartMap = CreateChartMap(data.charts);
for (const [i, score] of data.scores.filter((e) => e.playtype === playtype).entries()) {
for (const [i, score] of data.scores.filter((e) => e.game === game).entries()) {
scoreDataset.push({
...score,
__related: {
@@ -230,12 +229,14 @@ function ScoreTab({ data }: { data: Data }) {
});
}
datasets.push({ playtype, data: scoreDataset });
datasets.push({ game, data: scoreDataset });
}
return <MultiPlaytypeScoreTable datasets={datasets} game={importDoc.game} />;
return <MultiPlaytypeScoreTable datasets={datasets} />;
}
const game = importDoc.games[0];
const scoreDataset: ScoreDataset = [];
const songMap = CreateSongMap(data.songs);
@@ -253,21 +254,17 @@ function ScoreTab({ data }: { data: Data }) {
});
}
return (
<ScoreTable
dataset={scoreDataset}
game={importDoc.game}
playtype={importDoc.playtypes[0]}
/>
);
return <ScoreTable dataset={scoreDataset} game={game} />;
}
type ScoreDatasets = { data: ScoreDataset; playtype: Playtype }[];
function MultiPlaytypeScoreTable({
datasets,
}: {
datasets: { data: ScoreDataset; game: V3Game }[];
}) {
const [selectedGame, setSelectedGame] = useState<V3Game>(datasets[0].game);
function MultiPlaytypeScoreTable({ datasets, game }: { datasets: ScoreDatasets; game: GameGroup }) {
const [playtype, setPlaytype] = useState<Playtype>(datasets[0].playtype);
const content = useMemo(() => datasets.find((e) => e.playtype === playtype)!, [playtype]);
const content = useMemo(() => datasets.find((e) => e.game === selectedGame)!, [selectedGame]);
return (
<div className="row">
@@ -275,18 +272,18 @@ function MultiPlaytypeScoreTable({ datasets, game }: { datasets: ScoreDatasets;
<div className="btn-group">
{datasets.map((e) => (
<SelectButton
id={e.playtype}
key={e.playtype}
setValue={setPlaytype}
value={playtype}
id={e.game}
key={e.game}
setValue={setSelectedGame}
value={selectedGame}
>
{e.playtype}
{e.game}
</SelectButton>
))}
</div>
</div>
<ScoreTable dataset={content.data} game={game} playtype={content.playtype} />
<ScoreTable dataset={content.data} game={content.game} />
</div>
);
}
@@ -12,7 +12,7 @@ import { type SetState } from "#types/react";
import { APIFetchV1 } from "#util/api";
import React, { useContext, useMemo, useReducer, useState } from "react";
import { Button, Form } from "react-bootstrap";
import { type APIImportTypes, GetGameGroupConfig, type MONGO_MytCardInfo } from "tachi-common";
import { type APIImportTypes, GetGameGroupConfig, type MytCardInfo } from "tachi-common";
import ImportStateRenderer from "./ImportStateRenderer";
@@ -35,7 +35,7 @@ export default function MytIntegrationPage({ game }: Props) {
}
// eslint-disable-next-line react-hooks/rules-of-hooks
const { data, error } = useApiQuery<MONGO_MytCardInfo | null>(
const { data, error } = useApiQuery<MytCardInfo | null>(
`/users/${user.id}/integrations/myt`,
undefined,
[reload],
@@ -3,7 +3,7 @@ import QuickDropdown from "#components/ui/QuickDropdown";
import { TachiConfig } from "#lib/config";
import { type SetState } from "#types/react";
import React from "react";
import { FormatGameGroup, GetGameGroupConfig } from "tachi-common";
import { FormatGame, GetGameGroupConfig, LEGACY_GameGroupPTToGame } from "tachi-common";
export default function GlobalInfoDropdown({
className,
@@ -18,7 +18,7 @@ export default function GlobalInfoDropdown({
}) {
const links = [];
for (const game of TachiConfig.GAMES) {
for (const game of TachiConfig.GAME_GROUPS) {
const gameConfig = GetGameGroupConfig(game);
for (const playtype of gameConfig.playtypes) {
@@ -26,9 +26,9 @@ export default function GlobalInfoDropdown({
<DropdownNavLink
key={`${game}:${playtype}`}
onClick={() => setState?.(false)}
to={`/games/${game}/${playtype}`}
to={`/games/${LEGACY_GameGroupPTToGame(game, playtype)}`}
>
{FormatGameGroup(game, playtype)}
{FormatGame(LEGACY_GameGroupPTToGame(game, playtype))}
</DropdownNavLink>,
);
}
@@ -5,7 +5,7 @@ import { UserSettingsContext } from "#context/UserSettingsContext";
import { type SetState } from "#types/react";
import React, { useContext, useEffect } from "react";
import Nav from "react-bootstrap/Nav";
import { type MONGO_UserGameStats } from "tachi-common";
import { type UserGameStats } from "tachi-common";
import GlobalInfoDropdown from "./GlobalInfoDropdown";
import ImportScoresDropdown from "./ImportScoresDropdown";
@@ -26,7 +26,7 @@ export function HeaderMenu({
const { ugs, setUGS } = useContext(AllLUGPTStatsContext);
const { settings } = useContext(UserSettingsContext);
const { data, error } = useApiQuery<MONGO_UserGameStats[]>(
const { data, error } = useApiQuery<UserGameStats[]>(
// We should generate a valid url just in case the skip somehow fails
`/users/${user?.id ?? "me"}/game-stats`,
undefined,
@@ -18,7 +18,7 @@ export default function ImportScoresLink({
}) {
const links = [];
for (const game of TachiConfig.GAMES) {
for (const game of TachiConfig.GAME_GROUPS) {
const gameConfig = GetGameGroupConfig(game);
links.push(
@@ -1,14 +1,8 @@
import DropdownNavLink from "#components/ui/DropdownNavLink";
import QuickDropdown from "#components/ui/QuickDropdown";
import { TachiConfig } from "#lib/config";
import { type SetState } from "#types/react";
import React from "react";
import {
FormatGameGroup,
GetGameGroupConfig,
type MONGO_UserDocument,
type MONGO_UserGameStats,
} from "tachi-common";
import { ALL_GAMES, FormatGame, type UserDocument, type UserGameStats } from "tachi-common";
export default function UGPTDropdown({
user,
@@ -22,37 +16,32 @@ export default function UGPTDropdown({
menuClassName?: string;
setState?: SetState<boolean>;
style?: React.CSSProperties;
ugs: MONGO_UserGameStats[];
user: MONGO_UserDocument;
ugs: UserGameStats[];
user: UserDocument;
}) {
const userProfileLinks = [];
if (user && ugs && ugs.length !== 0) {
const ugsMap = new Map();
for (const s of ugs) {
ugsMap.set(`${s.game}:${s.playtype}`, s);
}
const ugsMap = new Map(ugs.map((s) => [s.game, s] as const));
for (const game of TachiConfig.GAMES) {
for (const playtype of GetGameGroupConfig(game).playtypes) {
const e = ugsMap.get(`${game}:${playtype}`);
for (const game of ALL_GAMES) {
const e = ugsMap.get(game);
if (!e) {
continue;
}
userProfileLinks.push(
<DropdownNavLink
key={`${e.game}:${e.playtype}`}
onClick={() => {
setState?.(false);
}}
to={`/u/${user.username}/games/${e.game}/${e.playtype}`}
>
{FormatGameGroup(e.game, e.playtype)}
</DropdownNavLink>,
);
if (!e) {
continue;
}
userProfileLinks.push(
<DropdownNavLink
key={e.game}
onClick={() => {
setState?.(false);
}}
to={`/u/${user.username}/games/${e.game}`}
>
{FormatGame(e.game)}
</DropdownNavLink>,
);
}
}
@@ -1,7 +1,7 @@
import ProfilePicture from "#components/user/ProfilePicture";
import LinkButton from "#components/util/LinkButton";
import React from "react";
import { type MONGO_UserDocument } from "tachi-common/types/documents";
import { type UserDocument } from "tachi-common/types/documents";
import { SearchButton } from "./SearchButton";
import { UserNotificationButton } from "./UserNotificationButton";
@@ -12,7 +12,7 @@ export default function UserArea({
dropdownMenuStyle,
}: {
dropdownMenuStyle?: React.CSSProperties;
user: MONGO_UserDocument | null;
user: UserDocument | null;
}) {
return (
<div className="d-flex align-items-center gap-2">

Some files were not shown because too many files have changed in this diff Show More