diff --git a/apps/discord-bot/src/commands/ratios/ratios.command.tsx b/apps/discord-bot/src/commands/ratios/ratios.command.tsx index 854c04db9..262cdaf65 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, paginate, 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(private readonly apiService: ApiService) {} @@ -89,7 +96,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); @@ -183,7 +190,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); @@ -200,59 +207,109 @@ 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 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; + + let stats: any; + + if (submode) { + const modeStats = game[mode.api as keyof typeof game]; + const submodeStats = modeStats?.[submode.api as keyof typeof modeStats]; + stats = submode.api === "overall" ? submodeStats || modeStats : submodeStats; + } else { + const modeStats = game[mode.api as keyof typeof game]; + stats = mode.api === "overall" ? modeStats || game : modeStats; } - const stats = game[mode.api as keyof typeof game]; - return mode.api === "overall" ? stats || game : stats; + // Some modes (e.g. Duels Classic, OP, SkyWars) only display a single + // image and don't expose their ratio fields directly, instead nesting + // them under an `overall` field combining their submodes + if (stats && this.getRatios(stats.constructor).length === 0 && stats.overall) + return stats.overall; + + return stats; + } + + private getRatios(modeClass?: Constructor): Ratio[] { + if (!modeClass) return []; + + return LEADERBOARD_RATIOS.filter(([numerator, denominator]) => { + const numeratorType = Reflect.getMetadata("design:type", modeClass.prototype, numerator); + const denominatorType = Reflect.getMetadata("design:type", modeClass.prototype, denominator); + + return numeratorType === Number && denominatorType === Number; + }); } private getRatiosPerMode( @@ -261,47 +318,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; - - 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 - ); + for (const ratioMode of this.getRatioModes(mode)) { + const modeClass = this.getModeClass(ratioMode, gameClass); + if (!modeClass) continue; - return numeratorType === Number && denominatorType === Number; - }); + const ratios = this.getRatios(modeClass); + if (ratios.length === 0) continue; - if (ratios.length === 0) 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; + const resolved = (() => { + if (!submode) return modeType; + if (!modeType) return undefined; + + const submodeType = Reflect.getMetadata("design:type", modeType.prototype, submode.api); + return submode.api === "overall" ? submodeType || modeType : submodeType; + })(); + + if (!resolved) return resolved; + + // Some modes (e.g. Duels Classic, OP, SkyWars) don't expose their ratio + // fields directly, instead nesting them under an `overall` field + // combining their submodes + if (this.getRatios(resolved).length === 0) { + const overallType = Reflect.getMetadata("design:type", resolved.prototype, "overall"); + return overallType ?? resolved; + } + + return resolved; + } + + 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; } }