diff --git a/apps/discord-bot/src/commands/ratios/ratios.command.tsx b/apps/discord-bot/src/commands/ratios/ratios.command.tsx index bdd522891..2b10e0fdc 100644 --- a/apps/discord-bot/src/commands/ratios/ratios.command.tsx +++ b/apps/discord-bot/src/commands/ratios/ratios.command.tsx @@ -38,6 +38,7 @@ import { ApiService, Command, CommandContext, + type LocalizeFunction, Page, PaginateService, PlayerArgument, @@ -57,6 +58,12 @@ import { render } from "@statsify/rendering"; const args = [PlayerArgument]; +type RatioMode = { + mode: GameModeWithSubModes; + formatted: string; + submode?: GameModeWithSubModes["submodes"][number]; +}; + @Command({ description: (t) => t("commands.ratios") }) export class RatiosCommand { public constructor( @@ -92,7 +99,7 @@ export class RatiosCommand { const filteredKits = kits .sort( (a, b) => - (blitzsg[b.api] as BlitzSGKit).exp - (blitzsg[a.api] as BlitzSGKit).exp + (blitzsg[b.mode.api] as BlitzSGKit).exp - (blitzsg[a.mode.api] as BlitzSGKit).exp ) .slice(0, 24); @@ -186,7 +193,7 @@ export class RatiosCommand { private async run( context: CommandContext, modes: GameModes, - filterModes?: (player: Player, modes: GameModeWithSubModes[]) => GameModeWithSubModes[] + filterModes?: (player: Player, modes: RatioMode[]) => RatioMode[] ) { const user = context.getUser(); const player = await this.apiService.getPlayer(context.option("player"), user); @@ -203,55 +210,84 @@ export class RatiosCommand { const ratiosPerMode = this.getRatiosPerMode(key, modes); - const allModes = ratiosPerMode.map(([mode]) => mode); + const allModes = ratiosPerMode.map(({ ratioMode }) => ratioMode); const displayedModes = filterModes ? filterModes(player, allModes) : allModes; - - const pages: Page[] = displayedModes.map((mode, index) => ({ - label: mode.formatted, - generator: async (t) => { - const background = await getBackground(...mapBackground(modes, mode.api)); - - const game = player.stats[key]; - const stats = this.getModeStats(game, mode); - - const ratios = ratiosPerMode[index][1]; - - const props: RatiosProfileProps = { - player, - skin, - background, - logo, - t, - user, - badge, - mode: { ...mode, submode: mode.submodes.length === 0 ? undefined : mode.submodes[0] }, - gameName: MODES_TO_FORMATTED.get(modes)!, - ratios: ratios.map((r) => [ - stats[r[0] as keyof typeof stats], - stats[r[1] as keyof typeof stats], - prettify(r[0]), - r[3], - ]), - }; - - const canvas = render(, getTheme(user)); - const buffer = await canvas.toBuffer("png"); + const displayedModeOrder = new Map( + displayedModes.map((mode, index) => [this.getRatioModeKey(mode), index]) + ); + const displayedRatiosPerMode = ratiosPerMode + .flatMap((ratio) => { + const index = displayedModeOrder.get(this.getRatioModeKey(ratio.ratioMode)); + return index === undefined ? [] : [{ ...ratio, index }]; + }) + .sort((a, b) => a.index - b.index); + + const getGenerator = (ratioMode: RatioMode, ratios: Ratio[]) => async (t: LocalizeFunction) => { + const background = await getBackground(...mapBackground(modes, ratioMode.mode.api)); + + const game = player.stats[key]; + const stats = this.getModeStats(game, ratioMode); + + const props: RatiosProfileProps = { + player, + skin, + background, + logo, + t, + user, + badge, + mode: { + ...ratioMode.mode, + formatted: ratioMode.formatted, + submode: ratioMode.submode, + }, + gameName: MODES_TO_FORMATTED.get(modes)!, + ratios: ratios.map((r) => [ + stats[r[0] as keyof typeof stats], + stats[r[1] as keyof typeof stats], + prettify(r[0]), + r[3], + ]), + }; + + const canvas = render(, getTheme(user)); + const buffer = await canvas.toBuffer("png"); + + return { + files: [{ name: "ratios.png", data: buffer, type: "image/png" }], + attachments: [], + }; + }; + + const pages: Page[] = this.groupRatioModes(displayedRatiosPerMode).map((group) => { + if (group.length === 1) { + const [{ ratioMode, ratios }] = group; return { - files: [{ name: "ratios.png", data: buffer, type: "image/png" }], - attachments: [], + label: ratioMode.formatted, + generator: getGenerator(ratioMode, ratios), }; - }, - })); + } + + return { + label: group[0].ratioMode.mode.formatted, + subPages: group.map(({ ratioMode, ratios }) => ({ + label: this.getSubPageLabel(ratioMode), + generator: getGenerator(ratioMode, ratios), + })), + }; + }); return this.paginateService.paginate(context, pages); } - private getModeStats(game: PlayerStats[keyof PlayerStats], mode: GameModeWithSubModes) { - if (mode.submodes.length !== 0) { - let stats = game[mode.api as keyof typeof game]; - stats = stats[mode.submodes[0].api as keyof typeof game]; - return mode.submodes[0].api === "overall" ? stats || game : stats; + private getModeStats(game: PlayerStats[keyof PlayerStats], ratioMode: RatioMode) { + const { mode, submode } = ratioMode; + + if (submode) { + const modeStats = game[mode.api as keyof typeof game]; + const submodeStats = modeStats?.[submode.api as keyof typeof modeStats]; + return submode.api === "overall" ? submodeStats || modeStats : submodeStats; } const stats = game[mode.api as keyof typeof game]; @@ -264,47 +300,96 @@ export class RatiosCommand { ) { const gameClass = Reflect.getMetadata("design:type", PlayerStats.prototype, key); - const ratioModes: [mode: GameModeWithSubModes, ratios: Ratio[]][] = []; + const ratioModes: { ratioMode: RatioMode; ratios: Ratio[] }[] = []; const gameModes = modes.getModes(); for (const mode of gameModes) { if (!mode.api) continue; - const modeClass = this.getModeClass(mode, gameClass); - if (!modeClass) continue; + for (const ratioMode of this.getRatioModes(mode)) { + const modeClass = this.getModeClass(ratioMode, gameClass); + if (!modeClass) continue; - const ratios = LEADERBOARD_RATIOS.filter(([numerator, denominator]) => { - const numeratorType = Reflect.getMetadata( - "design:type", - modeClass.prototype, - numerator - ); + const ratios = LEADERBOARD_RATIOS.filter(([numerator, denominator]) => { + const numeratorType = Reflect.getMetadata( + "design:type", + modeClass.prototype, + numerator + ); - const denominatorType = Reflect.getMetadata( - "design:type", - modeClass.prototype, - denominator - ); + const denominatorType = Reflect.getMetadata( + "design:type", + modeClass.prototype, + denominator + ); - return numeratorType === Number && denominatorType === Number; - }); + return numeratorType === Number && denominatorType === Number; + }); - if (!ratios.length) continue; + if (!ratios.length) continue; - ratioModes.push([mode, ratios]); + ratioModes.push({ ratioMode, ratios }); + } } return ratioModes; } - private getModeClass(mode: GameModeWithSubModes, gameClass: Constructor) { + private getModeClass(ratioMode: RatioMode, gameClass: Constructor) { + const { mode, submode } = ratioMode; const apiType = Reflect.getMetadata("design:type", gameClass.prototype, mode.api); const modeType = mode.api === "overall" ? apiType || gameClass : apiType; - if (mode.submodes.length === 0) return modeType; + if (!submode) return modeType; + + const submodeType = Reflect.getMetadata("design:type", modeType.prototype, submode.api); + return submode.api === "overall" ? submodeType || modeType : submodeType; + } + + private getRatioModes(mode: GameModeWithSubModes): RatioMode[] { + const baseMode = { + mode, + formatted: mode.formatted, + }; + + if (mode.submodes.length === 0) return [baseMode]; + + const submodes = mode.submodes + .filter((submode) => submode.api !== "stats" && submode.api !== "titles") + .map((submode) => ({ + mode, + submode, + formatted: this.formatSubmode(mode.formatted, submode), + })); + + return mode.api === "overall" ? [baseMode, ...submodes] : submodes; + } + + private formatSubmode(mode: string, submode: { api: string; formatted: string }) { + if (submode.api === "overall") return `${mode} Overall`; + if (submode.formatted.startsWith(mode)) return submode.formatted; + return `${mode} ${submode.formatted}`; + } + + private getRatioModeKey(ratioMode: RatioMode) { + return `${ratioMode.mode.api}:${ratioMode.submode?.api ?? ""}`; + } + + private groupRatioModes( + ratiosPerMode: { ratioMode: RatioMode; ratios: Ratio[] }[] + ) { + const groups = new Map; ratios: Ratio[] }[]>(); + + for (const ratio of ratiosPerMode) { + const group = groups.get(ratio.ratioMode.mode.api) ?? []; + group.push(ratio); + groups.set(ratio.ratioMode.mode.api, group); + } + + return [...groups.values()]; + } - const submode = mode.submodes[0].api; - const submodeType = Reflect.getMetadata("design:type", modeType.prototype, submode); - return submode === "overall" ? submodeType || modeType : submodeType; + private getSubPageLabel(ratioMode: RatioMode) { + return ratioMode.submode?.formatted ?? ratioMode.formatted; } } diff --git a/packages/schemas/src/player/gamemodes/duels/index.ts b/packages/schemas/src/player/gamemodes/duels/index.ts index 7a2d60cc7..8a89d9730 100644 --- a/packages/schemas/src/player/gamemodes/duels/index.ts +++ b/packages/schemas/src/player/gamemodes/duels/index.ts @@ -56,14 +56,26 @@ export const DUELS_MODES = new GameModes([ { api: "fours" }, ], }, - { api: "classic", hypixel: "DUELS_CLASSIC_DUEL" }, + { // Classic has submodes, but are displayed in 1 image + api: "classic", + hypixel: "DUELS_CLASSIC_DUEL", + }, { api: "combo", hypixel: "DUELS_COMBO_DUEL" }, - { api: "megawalls", formatted: "MegaWalls" }, + { + api: "megawalls", + formatted: "MegaWalls", + }, { api: "nodebuff", hypixel: "DUELS_POTION_DUEL", formatted: "NoDebuff" }, - { api: "op", formatted: "OP" }, + { // OP has submodes, but are displayed in 1 image + api: "op", + formatted: "OP", + }, { api: "quake", hypixel: "DUELS_QUAKE_DUEL" }, { api: "parkour", hypixel: "DUELS_PARKOUR_EIGHT" }, - { api: "skywars", formatted: "SkyWars" }, + { // Skywars has submodes, but are displayed in 1 image + api: "skywars", + formatted: "SkyWars", + }, { api: "spleef", submodes: [ @@ -84,7 +96,7 @@ export const DUELS_MODES = new GameModes([ ], }, { hypixel: "DUELS_MW_DUEL", formatted: "MegaWalls Solo" }, - { hypixel: "DUELS_MW_DOUBLES", formatted: "MegaWalls Doubles" }, + { hypixel: "DUELS_MW_DOUBLES", formatted: "MegaWalls Doubles" }, // Needed for MW Overall { hypixel: "DUELS_UHC_DUEL", formatted: "UHC Solo" }, { hypixel: "DUELS_UHC_DOUBLES", formatted: "UHC Doubles" }, { hypixel: "DUELS_UHC_FOUR", formatted: "UHC Fours" }, @@ -97,9 +109,9 @@ export const DUELS_MODES = new GameModes([ { hypixel: "DUELS_BRIDGE_DOUBLES", formatted: "Bridge Doubles" }, { hypixel: "DUELS_BRIDGE_THREES", formatted: "Bridge Threes" }, { hypixel: "DUELS_BRIDGE_FOUR", formatted: "Bridge Fours" }, - { hypixel: "DUELS_BRIDGE_2V2V2V2", formatted: "Bridge 2v2v2v2" }, - { hypixel: "DUELS_BRIDGE_3V3V3V3", formatted: "Bridge 3v3v3v3" }, - { hypixel: "DUELS_CAPTURE_THREES", formatted: "Bridge CTF" }, + { hypixel: "DUELS_BRIDGE_2V2V2V2", formatted: "Bridge 2v2v2v2" }, // Needed for Bridge Overall + { hypixel: "DUELS_BRIDGE_3V3V3V3", formatted: "Bridge 3v3v3v3" }, // Needed for Bridge Overall + { hypixel: "DUELS_CAPTURE_THREES", formatted: "Bridge CTF" }, // Needed for Bridge Overall { hypixel: "DUELS_BEDWARS_TWO_ONE", formatted: "BedWars Duel" }, { hypixel: "DUELS_BEDWARS_TWO_ONE_RUSH", formatted: "Bed Rush" }, { hypixel: "DUELS_SPLEEF_DUEL", formatted: "Spleef" }, diff --git a/packages/schemas/src/player/gamemodes/duels/mode.ts b/packages/schemas/src/player/gamemodes/duels/mode.ts index 57cd014a0..231239d2c 100644 --- a/packages/schemas/src/player/gamemodes/duels/mode.ts +++ b/packages/schemas/src/player/gamemodes/duels/mode.ts @@ -154,6 +154,7 @@ export class BridgeDuels { this.doubles, this.threes, this.fours, + // Needed for accurate calculation of overall stats new BridgeDuelsMode(data, "bridge_2v2v2v2"), new BridgeDuelsMode(data, "bridge_3v3v3v3"), new BridgeDuelsMode(data, "capture_threes")