From 56bfa86b778864ff3314891c75ccbd7d6dbbba0f Mon Sep 17 00:00:00 2001 From: Cody Date: Tue, 12 May 2026 12:45:34 -0600 Subject: [PATCH 1/3] Add target calculation commands - Add `/target` and `/calculate` subcommands for game stat goals - Support stat and ratio targets with autocomplete and setback estimates - Add localized command and argument strings --- .../src/commands/target/target.command.ts | 522 ++++++++++++++++++ locales/en-US/default.json | 32 ++ 2 files changed, 554 insertions(+) create mode 100644 apps/discord-bot/src/commands/target/target.command.ts diff --git a/apps/discord-bot/src/commands/target/target.command.ts b/apps/discord-bot/src/commands/target/target.command.ts new file mode 100644 index 000000000..638ef2436 --- /dev/null +++ b/apps/discord-bot/src/commands/target/target.command.ts @@ -0,0 +1,522 @@ +/** + * Copyright (c) Statsify + * + * This source code is licensed under the GNU GPL v3 license found in the + * LICENSE file in the root directory of this source tree. + * https://github.com/Statsify/statsify/blob/main/LICENSE + */ + +import { + type APIApplicationCommandOptionChoice, + ApplicationCommandOptionType, +} from "discord-api-types/v10"; +import { + AbstractArgument, + ApiService, + Command, + CommandContext, + EmbedBuilder, + ErrorMessage, + type LocalizationString, + PlayerArgument, + SubCommand, +} from "@statsify/discord"; +import { + type Constructor, + prettify, + removeFormatting, +} from "@statsify/util"; +import { Container } from "typedi"; +import { + LEADERBOARD_RATIOS, + MetadataScanner, + type Player, + PlayerStats, + type Ratio, +} from "@statsify/schemas"; +import { STATUS_COLORS } from "@statsify/logger"; + +type GameKey = keyof PlayerStats; + +interface TargetStat { + denominator?: string; + denominatorName?: string; + key: string; + name: string; + numerator?: string; + numeratorName?: string; + ratio?: Ratio; + type: "ratio" | "stat"; +} + +const apiService = Container.get(ApiService); +const DEFAULT_SETBACK = 15; + +const GAMES: [GameKey, name: string, group?: string][] = [ + ["arcade", "Arcade"], + ["arenabrawl", "Arena Brawl", "classic"], + ["bedwars", "BedWars"], + ["blitzsg", "BlitzSG"], + ["buildbattle", "Build Battle"], + ["challenges", "Challenges"], + ["copsandcrims", "Cops and Crims"], + ["duels", "Duels"], + ["general", "General"], + ["megawalls", "MegaWalls"], + ["murdermystery", "Murder Mystery"], + ["paintball", "Paintball", "classic"], + ["parkour", "Parkour"], + ["pit", "Pit"], + ["quake", "Quake", "classic"], + ["quests", "Quests"], + ["skywars", "SkyWars"], + ["smashheroes", "Smash Heroes"], + ["speeduhc", "Speed UHC"], + ["tntgames", "TNT Games"], + ["turbokartracers", "Turbo Kart Racers", "classic"], + ["uhc", "UHC"], + ["vampirez", "VampireZ", "classic"], + ["walls", "Walls", "classic"], + ["warlords", "Warlords"], + ["woolgames", "WoolGames"], +]; + +const GAME_NAMES = new Map(GAMES.map(([key, name]) => [key, name])); + +const getGameClass = (game: GameKey) => + Reflect.getMetadata("design:type", PlayerStats.prototype, game) as Constructor; + +const statCache = new Map(); + +const targetArgs = (game: GameKey) => [ + new TargetStatArgument(game), + new TargetArgument(), + new PlayerArgument(), + new SetbackArgument(), +]; + +class TargetStatArgument extends AbstractArgument { + public autocomplete = true; + public description: LocalizationString; + public name = "stat"; + public required = true; + public type = ApplicationCommandOptionType.String; + private readonly game: GameKey; + + public constructor(game: GameKey) { + super(); + this.description = (t) => t("arguments.target-stat"); + this.game = game; + } + + public autocompleteHandler( + context: CommandContext + ): APIApplicationCommandOptionChoice[] { + const currentValue = context.option(this.name, "").toLowerCase(); + const stats = getTargetStats(this.game); + + const filtered = currentValue ? + stats.filter((stat) => + [stat.key, stat.name] + .some((value) => value.toLowerCase().includes(currentValue)) + ) : + stats; + + return filtered + .slice(0, 25) + .map((stat) => ({ name: stat.name.slice(0, 100), value: stat.key })); + } +} + +class TargetArgument extends AbstractArgument { + public description: LocalizationString; + public min_value = 0; + public name = "target"; + public required = true; + public type = ApplicationCommandOptionType.Number; + + public constructor() { + super(); + this.description = (t) => t("arguments.target"); + } +} + +class SetbackArgument extends AbstractArgument { + public description: LocalizationString; + public min_value = 0; + public name = "setback"; + public required = false; + public type = ApplicationCommandOptionType.Integer; + + public constructor() { + super(); + this.description = (t) => t("arguments.target-setback"); + } +} + +@Command({ description: (t) => t("commands.target") }) +export class TargetCommand { + @SubCommand({ description: (t) => t("commands.target-arcade"), args: targetArgs("arcade") }) + public arcade(context: CommandContext) { + return runTarget(context, "arcade"); + } + + @SubCommand({ description: (t) => t("commands.target-arenabrawl"), args: targetArgs("arenabrawl"), group: "classic" }) + public arenabrawl(context: CommandContext) { + return runTarget(context, "arenabrawl"); + } + + @SubCommand({ description: (t) => t("commands.target-bedwars"), args: targetArgs("bedwars") }) + public bedwars(context: CommandContext) { + return runTarget(context, "bedwars"); + } + + @SubCommand({ description: (t) => t("commands.target-blitzsg"), args: targetArgs("blitzsg") }) + public blitzsg(context: CommandContext) { + return runTarget(context, "blitzsg"); + } + + @SubCommand({ description: (t) => t("commands.target-buildbattle"), args: targetArgs("buildbattle") }) + public buildbattle(context: CommandContext) { + return runTarget(context, "buildbattle"); + } + + @SubCommand({ description: (t) => t("commands.target-challenges"), args: targetArgs("challenges") }) + public challenges(context: CommandContext) { + return runTarget(context, "challenges"); + } + + @SubCommand({ description: (t) => t("commands.target-copsandcrims"), args: targetArgs("copsandcrims") }) + public copsandcrims(context: CommandContext) { + return runTarget(context, "copsandcrims"); + } + + @SubCommand({ description: (t) => t("commands.target-duels"), args: targetArgs("duels") }) + public duels(context: CommandContext) { + return runTarget(context, "duels"); + } + + @SubCommand({ description: (t) => t("commands.target-general"), args: targetArgs("general") }) + public general(context: CommandContext) { + return runTarget(context, "general"); + } + + @SubCommand({ description: (t) => t("commands.target-megawalls"), args: targetArgs("megawalls") }) + public megawalls(context: CommandContext) { + return runTarget(context, "megawalls"); + } + + @SubCommand({ description: (t) => t("commands.target-murdermystery"), args: targetArgs("murdermystery") }) + public murdermystery(context: CommandContext) { + return runTarget(context, "murdermystery"); + } + + @SubCommand({ description: (t) => t("commands.target-paintball"), args: targetArgs("paintball"), group: "classic" }) + public paintball(context: CommandContext) { + return runTarget(context, "paintball"); + } + + @SubCommand({ description: (t) => t("commands.target-parkour"), args: targetArgs("parkour") }) + public parkour(context: CommandContext) { + return runTarget(context, "parkour"); + } + + @SubCommand({ description: (t) => t("commands.target-pit"), args: targetArgs("pit") }) + public pit(context: CommandContext) { + return runTarget(context, "pit"); + } + + @SubCommand({ description: (t) => t("commands.target-quake"), args: targetArgs("quake"), group: "classic" }) + public quake(context: CommandContext) { + return runTarget(context, "quake"); + } + + @SubCommand({ description: (t) => t("commands.target-quests"), args: targetArgs("quests") }) + public quests(context: CommandContext) { + return runTarget(context, "quests"); + } + + @SubCommand({ description: (t) => t("commands.target-skywars"), args: targetArgs("skywars") }) + public skywars(context: CommandContext) { + return runTarget(context, "skywars"); + } + + @SubCommand({ description: (t) => t("commands.target-smashheroes"), args: targetArgs("smashheroes") }) + public smashheroes(context: CommandContext) { + return runTarget(context, "smashheroes"); + } + + @SubCommand({ description: (t) => t("commands.target-speeduhc"), args: targetArgs("speeduhc") }) + public speeduhc(context: CommandContext) { + return runTarget(context, "speeduhc"); + } + + @SubCommand({ description: (t) => t("commands.target-tntgames"), args: targetArgs("tntgames") }) + public tntgames(context: CommandContext) { + return runTarget(context, "tntgames"); + } + + @SubCommand({ description: (t) => t("commands.target-turbokartracers"), args: targetArgs("turbokartracers"), group: "classic" }) + public turbokartracers(context: CommandContext) { + return runTarget(context, "turbokartracers"); + } + + @SubCommand({ description: (t) => t("commands.target-uhc"), args: targetArgs("uhc") }) + public uhc(context: CommandContext) { + return runTarget(context, "uhc"); + } + + @SubCommand({ description: (t) => t("commands.target-vampirez"), args: targetArgs("vampirez"), group: "classic" }) + public vampirez(context: CommandContext) { + return runTarget(context, "vampirez"); + } + + @SubCommand({ description: (t) => t("commands.target-walls"), args: targetArgs("walls"), group: "classic" }) + public walls(context: CommandContext) { + return runTarget(context, "walls"); + } + + @SubCommand({ description: (t) => t("commands.target-warlords"), args: targetArgs("warlords") }) + public warlords(context: CommandContext) { + return runTarget(context, "warlords"); + } + + @SubCommand({ description: (t) => t("commands.target-woolgames"), args: targetArgs("woolgames") }) + public woolgames(context: CommandContext) { + return runTarget(context, "woolgames"); + } +} + +@Command({ name: "calculate", description: (t) => t("commands.calculate") }) +export class CalculateCommand extends TargetCommand {} + +async function runTarget(context: CommandContext, game: GameKey) { + const user = context.getUser(); + const player = await apiService.getPlayer(context.option("player"), user); + const target = context.option("target"); + const setback = context.option("setback", DEFAULT_SETBACK); + const stat = resolveTargetStat(game, context.option("stat")); + const gameStats = player.stats[game] as unknown as Record; + const level = getLevel(gameStats); + + if (stat.type === "ratio") { + return buildRatioResponse(player, game, gameStats, stat, target, setback, level); + } + + return buildStatResponse(player, game, gameStats, stat, target, level); +} + +function buildRatioResponse( + player: Player, + game: GameKey, + gameStats: Record, + stat: TargetStat, + target: number, + setback: number, + level?: string +) { + const numerator = getNumber(gameStats, stat.numerator!); + const denominator = getNumber(gameStats, stat.denominator!); + const current = denominator === 0 ? numerator : numerator / denominator; + const needed = Math.max(0, Math.ceil(target * denominator - numerator)); + const neededWithSetback = Math.max( + 0, + Math.ceil(target * (denominator + setback) - numerator) + ); + const numeratorName = stat.numeratorName!; + const denominatorName = singularize(stat.denominatorName!); + + const lines = [ + `Current: **${formatDecimal(current)} ${stat.name}**`, + `Needed: **${formatInteger(needed)} ${numeratorName}** without another ${denominatorName}`, + ]; + + if (setback > 0) { + lines.push( + `Or: **${formatInteger(neededWithSetback)} ${numeratorName}** if you take **${formatInteger(setback)} ${stat.denominatorName}**` + ); + } + + return { + embeds: [ + baseEmbed(player, game, level) + .title(`To reach ${formatDecimal(target)} ${stat.name}:`) + .description(lines.join("\n")), + ], + }; +} + +function buildStatResponse( + player: Player, + game: GameKey, + gameStats: Record, + stat: TargetStat, + target: number, + level?: string +) { + const current = getNumber(gameStats, stat.key); + const needed = Math.max(0, Math.ceil(target - current)); + const statName = statNameLower(stat.name); + + return { + embeds: [ + baseEmbed(player, game, level) + .title(`To reach ${formatTarget(target)} ${stat.name}:`) + .description( + [ + `Current: **${formatTarget(current)} ${stat.name}**`, + `Needed: **${formatInteger(needed)} ${statName}**${needed === 0 ? " (target reached)" : ""}`, + ].join("\n") + ), + ], + }; +} + +function baseEmbed(player: Player, game: GameKey, level?: string) { + const titleParts = [player.displayName]; + if (level) titleParts.push(level); + + return new EmbedBuilder() + .author(titleParts.join(" ")) + .footer(GAME_NAMES.get(game)!) + .color(STATUS_COLORS.info); +} + +function getTargetStats(game: GameKey) { + if (statCache.has(game)) return statCache.get(game)!; + + const metadata = MetadataScanner.scan(getGameClass(game)); + const numberFields = metadata + .filter(([, { type }]) => type.type === Number) + .map(([key, { leaderboard }]) => ({ + key, + name: cleanName(leaderboard.fieldName || leaderboard.name || prettify(key)), + })); + + const byKey = new Map(numberFields.map((field) => [field.key, field])); + const ratioKeys = new Set(LEADERBOARD_RATIOS.map((ratio) => ratio[2])); + const ratios: TargetStat[] = []; + + for (const [numerator, denominator, ratioKey, prettyName] of LEADERBOARD_RATIOS) { + for (const field of numberFields) { + if (lastPathPart(field.key) !== ratioKey) continue; + + const parent = parentPath(field.key); + const numeratorKey = pathWithParent(parent, numerator); + const denominatorKey = pathWithParent(parent, denominator); + const numeratorField = byKey.get(numeratorKey); + const denominatorField = byKey.get(denominatorKey); + + if (!numeratorField || !denominatorField) continue; + + ratios.push({ + denominator: denominatorKey, + denominatorName: statNameLower(denominatorField.name), + key: field.key, + name: parent === "overall" || !parent ? prettyName : `${cleanName(parent)} ${prettyName}`, + numerator: numeratorKey, + numeratorName: statNameLower(numeratorField.name), + ratio: [numerator, denominator, ratioKey, prettyName], + type: "ratio", + }); + } + } + + const stats: TargetStat[] = [ + ...ratios, + ...numberFields + .filter((field) => !ratioKeys.has(lastPathPart(field.key))) + .map((field) => ({ ...field, type: "stat" as const })), + ]; + + statCache.set(game, stats); + return stats; +} + +function resolveTargetStat(game: GameKey, input: string) { + const stats = getTargetStats(game); + const normalized = input.toLowerCase(); + const exact = stats.find((stat) => stat.key === input); + if (exact) return exact; + + const overall = stats.find( + (stat) => + stat.key.toLowerCase() === `overall.${normalized}` || + stat.name.toLowerCase() === normalized + ); + if (overall) return overall; + + const fallback = stats.find( + (stat) => + lastPathPart(stat.key).toLowerCase() === normalized || + stat.name.toLowerCase().includes(normalized) + ); + + if (!fallback) { + throw new ErrorMessage( + "Target stat not found", + `I couldn't find \`${input}\` for ${GAME_NAMES.get(game)}. Use the stat autocomplete to pick a supported target.` + ); + } + + return fallback; +} + +function getLevel(gameStats: Record) { + const formatted = gameStats.levelFormatted || gameStats.naturalLevelFormatted; + if (typeof formatted === "string") return removeFormatting(formatted); + + const level = gameStats.level; + if (typeof level === "number") return `Level ${formatDecimal(level)}`; + + return undefined; +} + +function getNumber(data: Record, path: string) { + const value = path + .split(".") + .reduce((acc, key) => (acc as Record | undefined)?.[key], data); + + return typeof value === "number" && Number.isFinite(value) ? value : 0; +} + +function cleanName(value: string) { + return removeFormatting(value) + .replace(/\s+/g, " ") + .trim(); +} + +function statNameLower(value: string) { + return cleanName(value).toLowerCase(); +} + +function singularize(value: string) { + return value.endsWith("s") ? value.slice(0, -1) : value; +} + +function formatDecimal(value: number) { + return value.toLocaleString("en-US", { + maximumFractionDigits: 2, + minimumFractionDigits: 2, + }); +} + +function formatInteger(value: number) { + return value.toLocaleString("en-US", { maximumFractionDigits: 0 }); +} + +function formatTarget(value: number) { + return Number.isInteger(value) ? formatInteger(value) : formatDecimal(value); +} + +function lastPathPart(path: string) { + return path.split(".").at(-1)!; +} + +function parentPath(path: string) { + return path.split(".").slice(0, -1).join("."); +} + +function pathWithParent(parent: string, key: string) { + return parent ? `${parent}.${key}` : key; +} diff --git a/locales/en-US/default.json b/locales/en-US/default.json index b66f3e884..5e94dcc89 100644 --- a/locales/en-US/default.json +++ b/locales/en-US/default.json @@ -14,6 +14,9 @@ "server": "A Minecraft server name or a server IP", "tags-content": "The content of the tag", "tags-name": "The name of the tag", + "target": "The goal you want to reach", + "target-setback": "How many denominator stats to include in the alternate calculation", + "target-stat": "The stat or ratio you want to target", "text": "A message", "user": "Choose a Discord user" }, @@ -31,6 +34,7 @@ "buildbattle": "$t(commands.hypixel-command, { \"name\": \"Build Battle\" })", "cape": "View someone's Minecraft and Optifine capes", "challenges": "$t(commands.hypixel-command, { \"name\": \"Challenge\" })", + "calculate": "$t(commands.target)", "quests-command": "View your {{name}} questing stats", "quests": "$t(commands.hypixel-command, { \"name\": \"Questing\" })", "quests-overall": "$t(commands.quests-command, { \"name\": \"Overall\" })", @@ -212,6 +216,34 @@ "tags-delete": "Delete a support tag", "tags-rename": "Rename a support tag", "text": "Generate Minecraft text", + "target": "Calculate what you need to reach a stat target", + "target-arcade": "$t(commands.target-command, { \"name\": \"Arcade\" })", + "target-arenabrawl": "$t(commands.target-command, { \"name\": \"Arena Brawl\" })", + "target-bedwars": "$t(commands.target-command, { \"name\": \"BedWars\" })", + "target-blitzsg": "$t(commands.target-command, { \"name\": \"BlitzSG\" })", + "target-buildbattle": "$t(commands.target-command, { \"name\": \"Build Battle\" })", + "target-command": "Calculate a {{name}} stat target", + "target-challenges": "$t(commands.target-command, { \"name\": \"Challenges\" })", + "target-copsandcrims": "$t(commands.target-command, { \"name\": \"Cops and Crims\" })", + "target-duels": "$t(commands.target-command, { \"name\": \"Duels\" })", + "target-general": "$t(commands.target-command, { \"name\": \"General\" })", + "target-megawalls": "$t(commands.target-command, { \"name\": \"MegaWalls\" })", + "target-murdermystery": "$t(commands.target-command, { \"name\": \"Murder Mystery\" })", + "target-paintball": "$t(commands.target-command, { \"name\": \"Paintball\" })", + "target-parkour": "$t(commands.target-command, { \"name\": \"Parkour\" })", + "target-pit": "$t(commands.target-command, { \"name\": \"Pit\" })", + "target-quake": "$t(commands.target-command, { \"name\": \"Quake\" })", + "target-quests": "$t(commands.target-command, { \"name\": \"Quests\" })", + "target-skywars": "$t(commands.target-command, { \"name\": \"SkyWars\" })", + "target-smashheroes": "$t(commands.target-command, { \"name\": \"Smash Heroes\" })", + "target-speeduhc": "$t(commands.target-command, { \"name\": \"Speed UHC\" })", + "target-tntgames": "$t(commands.target-command, { \"name\": \"TNT Games\" })", + "target-turbokartracers": "$t(commands.target-command, { \"name\": \"Turbo Kart Racers\" })", + "target-uhc": "$t(commands.target-command, { \"name\": \"UHC\" })", + "target-vampirez": "$t(commands.target-command, { \"name\": \"VampireZ\" })", + "target-walls": "$t(commands.target-command, { \"name\": \"Walls\" })", + "target-warlords": "$t(commands.target-command, { \"name\": \"Warlords\" })", + "target-woolgames": "$t(commands.target-command, { \"name\": \"WoolGames\" })", "theme": "Change your theme for every profile", "theme-boxes": "Change the appearance of the profile boxes", "theme-font": "Change the font of the profiles", From 9b509d9cb534e209e0cdf853504aa7981a3cc5f3 Mon Sep 17 00:00:00 2001 From: Robert Date: Tue, 12 May 2026 13:00:50 -0600 Subject: [PATCH 2/3] Revert "Add target calculation commands" (#849) This reverts commit 56bfa86b778864ff3314891c75ccbd7d6dbbba0f. --- .../src/commands/target/target.command.ts | 522 ------------------ locales/en-US/default.json | 32 -- 2 files changed, 554 deletions(-) delete mode 100644 apps/discord-bot/src/commands/target/target.command.ts diff --git a/apps/discord-bot/src/commands/target/target.command.ts b/apps/discord-bot/src/commands/target/target.command.ts deleted file mode 100644 index 638ef2436..000000000 --- a/apps/discord-bot/src/commands/target/target.command.ts +++ /dev/null @@ -1,522 +0,0 @@ -/** - * Copyright (c) Statsify - * - * This source code is licensed under the GNU GPL v3 license found in the - * LICENSE file in the root directory of this source tree. - * https://github.com/Statsify/statsify/blob/main/LICENSE - */ - -import { - type APIApplicationCommandOptionChoice, - ApplicationCommandOptionType, -} from "discord-api-types/v10"; -import { - AbstractArgument, - ApiService, - Command, - CommandContext, - EmbedBuilder, - ErrorMessage, - type LocalizationString, - PlayerArgument, - SubCommand, -} from "@statsify/discord"; -import { - type Constructor, - prettify, - removeFormatting, -} from "@statsify/util"; -import { Container } from "typedi"; -import { - LEADERBOARD_RATIOS, - MetadataScanner, - type Player, - PlayerStats, - type Ratio, -} from "@statsify/schemas"; -import { STATUS_COLORS } from "@statsify/logger"; - -type GameKey = keyof PlayerStats; - -interface TargetStat { - denominator?: string; - denominatorName?: string; - key: string; - name: string; - numerator?: string; - numeratorName?: string; - ratio?: Ratio; - type: "ratio" | "stat"; -} - -const apiService = Container.get(ApiService); -const DEFAULT_SETBACK = 15; - -const GAMES: [GameKey, name: string, group?: string][] = [ - ["arcade", "Arcade"], - ["arenabrawl", "Arena Brawl", "classic"], - ["bedwars", "BedWars"], - ["blitzsg", "BlitzSG"], - ["buildbattle", "Build Battle"], - ["challenges", "Challenges"], - ["copsandcrims", "Cops and Crims"], - ["duels", "Duels"], - ["general", "General"], - ["megawalls", "MegaWalls"], - ["murdermystery", "Murder Mystery"], - ["paintball", "Paintball", "classic"], - ["parkour", "Parkour"], - ["pit", "Pit"], - ["quake", "Quake", "classic"], - ["quests", "Quests"], - ["skywars", "SkyWars"], - ["smashheroes", "Smash Heroes"], - ["speeduhc", "Speed UHC"], - ["tntgames", "TNT Games"], - ["turbokartracers", "Turbo Kart Racers", "classic"], - ["uhc", "UHC"], - ["vampirez", "VampireZ", "classic"], - ["walls", "Walls", "classic"], - ["warlords", "Warlords"], - ["woolgames", "WoolGames"], -]; - -const GAME_NAMES = new Map(GAMES.map(([key, name]) => [key, name])); - -const getGameClass = (game: GameKey) => - Reflect.getMetadata("design:type", PlayerStats.prototype, game) as Constructor; - -const statCache = new Map(); - -const targetArgs = (game: GameKey) => [ - new TargetStatArgument(game), - new TargetArgument(), - new PlayerArgument(), - new SetbackArgument(), -]; - -class TargetStatArgument extends AbstractArgument { - public autocomplete = true; - public description: LocalizationString; - public name = "stat"; - public required = true; - public type = ApplicationCommandOptionType.String; - private readonly game: GameKey; - - public constructor(game: GameKey) { - super(); - this.description = (t) => t("arguments.target-stat"); - this.game = game; - } - - public autocompleteHandler( - context: CommandContext - ): APIApplicationCommandOptionChoice[] { - const currentValue = context.option(this.name, "").toLowerCase(); - const stats = getTargetStats(this.game); - - const filtered = currentValue ? - stats.filter((stat) => - [stat.key, stat.name] - .some((value) => value.toLowerCase().includes(currentValue)) - ) : - stats; - - return filtered - .slice(0, 25) - .map((stat) => ({ name: stat.name.slice(0, 100), value: stat.key })); - } -} - -class TargetArgument extends AbstractArgument { - public description: LocalizationString; - public min_value = 0; - public name = "target"; - public required = true; - public type = ApplicationCommandOptionType.Number; - - public constructor() { - super(); - this.description = (t) => t("arguments.target"); - } -} - -class SetbackArgument extends AbstractArgument { - public description: LocalizationString; - public min_value = 0; - public name = "setback"; - public required = false; - public type = ApplicationCommandOptionType.Integer; - - public constructor() { - super(); - this.description = (t) => t("arguments.target-setback"); - } -} - -@Command({ description: (t) => t("commands.target") }) -export class TargetCommand { - @SubCommand({ description: (t) => t("commands.target-arcade"), args: targetArgs("arcade") }) - public arcade(context: CommandContext) { - return runTarget(context, "arcade"); - } - - @SubCommand({ description: (t) => t("commands.target-arenabrawl"), args: targetArgs("arenabrawl"), group: "classic" }) - public arenabrawl(context: CommandContext) { - return runTarget(context, "arenabrawl"); - } - - @SubCommand({ description: (t) => t("commands.target-bedwars"), args: targetArgs("bedwars") }) - public bedwars(context: CommandContext) { - return runTarget(context, "bedwars"); - } - - @SubCommand({ description: (t) => t("commands.target-blitzsg"), args: targetArgs("blitzsg") }) - public blitzsg(context: CommandContext) { - return runTarget(context, "blitzsg"); - } - - @SubCommand({ description: (t) => t("commands.target-buildbattle"), args: targetArgs("buildbattle") }) - public buildbattle(context: CommandContext) { - return runTarget(context, "buildbattle"); - } - - @SubCommand({ description: (t) => t("commands.target-challenges"), args: targetArgs("challenges") }) - public challenges(context: CommandContext) { - return runTarget(context, "challenges"); - } - - @SubCommand({ description: (t) => t("commands.target-copsandcrims"), args: targetArgs("copsandcrims") }) - public copsandcrims(context: CommandContext) { - return runTarget(context, "copsandcrims"); - } - - @SubCommand({ description: (t) => t("commands.target-duels"), args: targetArgs("duels") }) - public duels(context: CommandContext) { - return runTarget(context, "duels"); - } - - @SubCommand({ description: (t) => t("commands.target-general"), args: targetArgs("general") }) - public general(context: CommandContext) { - return runTarget(context, "general"); - } - - @SubCommand({ description: (t) => t("commands.target-megawalls"), args: targetArgs("megawalls") }) - public megawalls(context: CommandContext) { - return runTarget(context, "megawalls"); - } - - @SubCommand({ description: (t) => t("commands.target-murdermystery"), args: targetArgs("murdermystery") }) - public murdermystery(context: CommandContext) { - return runTarget(context, "murdermystery"); - } - - @SubCommand({ description: (t) => t("commands.target-paintball"), args: targetArgs("paintball"), group: "classic" }) - public paintball(context: CommandContext) { - return runTarget(context, "paintball"); - } - - @SubCommand({ description: (t) => t("commands.target-parkour"), args: targetArgs("parkour") }) - public parkour(context: CommandContext) { - return runTarget(context, "parkour"); - } - - @SubCommand({ description: (t) => t("commands.target-pit"), args: targetArgs("pit") }) - public pit(context: CommandContext) { - return runTarget(context, "pit"); - } - - @SubCommand({ description: (t) => t("commands.target-quake"), args: targetArgs("quake"), group: "classic" }) - public quake(context: CommandContext) { - return runTarget(context, "quake"); - } - - @SubCommand({ description: (t) => t("commands.target-quests"), args: targetArgs("quests") }) - public quests(context: CommandContext) { - return runTarget(context, "quests"); - } - - @SubCommand({ description: (t) => t("commands.target-skywars"), args: targetArgs("skywars") }) - public skywars(context: CommandContext) { - return runTarget(context, "skywars"); - } - - @SubCommand({ description: (t) => t("commands.target-smashheroes"), args: targetArgs("smashheroes") }) - public smashheroes(context: CommandContext) { - return runTarget(context, "smashheroes"); - } - - @SubCommand({ description: (t) => t("commands.target-speeduhc"), args: targetArgs("speeduhc") }) - public speeduhc(context: CommandContext) { - return runTarget(context, "speeduhc"); - } - - @SubCommand({ description: (t) => t("commands.target-tntgames"), args: targetArgs("tntgames") }) - public tntgames(context: CommandContext) { - return runTarget(context, "tntgames"); - } - - @SubCommand({ description: (t) => t("commands.target-turbokartracers"), args: targetArgs("turbokartracers"), group: "classic" }) - public turbokartracers(context: CommandContext) { - return runTarget(context, "turbokartracers"); - } - - @SubCommand({ description: (t) => t("commands.target-uhc"), args: targetArgs("uhc") }) - public uhc(context: CommandContext) { - return runTarget(context, "uhc"); - } - - @SubCommand({ description: (t) => t("commands.target-vampirez"), args: targetArgs("vampirez"), group: "classic" }) - public vampirez(context: CommandContext) { - return runTarget(context, "vampirez"); - } - - @SubCommand({ description: (t) => t("commands.target-walls"), args: targetArgs("walls"), group: "classic" }) - public walls(context: CommandContext) { - return runTarget(context, "walls"); - } - - @SubCommand({ description: (t) => t("commands.target-warlords"), args: targetArgs("warlords") }) - public warlords(context: CommandContext) { - return runTarget(context, "warlords"); - } - - @SubCommand({ description: (t) => t("commands.target-woolgames"), args: targetArgs("woolgames") }) - public woolgames(context: CommandContext) { - return runTarget(context, "woolgames"); - } -} - -@Command({ name: "calculate", description: (t) => t("commands.calculate") }) -export class CalculateCommand extends TargetCommand {} - -async function runTarget(context: CommandContext, game: GameKey) { - const user = context.getUser(); - const player = await apiService.getPlayer(context.option("player"), user); - const target = context.option("target"); - const setback = context.option("setback", DEFAULT_SETBACK); - const stat = resolveTargetStat(game, context.option("stat")); - const gameStats = player.stats[game] as unknown as Record; - const level = getLevel(gameStats); - - if (stat.type === "ratio") { - return buildRatioResponse(player, game, gameStats, stat, target, setback, level); - } - - return buildStatResponse(player, game, gameStats, stat, target, level); -} - -function buildRatioResponse( - player: Player, - game: GameKey, - gameStats: Record, - stat: TargetStat, - target: number, - setback: number, - level?: string -) { - const numerator = getNumber(gameStats, stat.numerator!); - const denominator = getNumber(gameStats, stat.denominator!); - const current = denominator === 0 ? numerator : numerator / denominator; - const needed = Math.max(0, Math.ceil(target * denominator - numerator)); - const neededWithSetback = Math.max( - 0, - Math.ceil(target * (denominator + setback) - numerator) - ); - const numeratorName = stat.numeratorName!; - const denominatorName = singularize(stat.denominatorName!); - - const lines = [ - `Current: **${formatDecimal(current)} ${stat.name}**`, - `Needed: **${formatInteger(needed)} ${numeratorName}** without another ${denominatorName}`, - ]; - - if (setback > 0) { - lines.push( - `Or: **${formatInteger(neededWithSetback)} ${numeratorName}** if you take **${formatInteger(setback)} ${stat.denominatorName}**` - ); - } - - return { - embeds: [ - baseEmbed(player, game, level) - .title(`To reach ${formatDecimal(target)} ${stat.name}:`) - .description(lines.join("\n")), - ], - }; -} - -function buildStatResponse( - player: Player, - game: GameKey, - gameStats: Record, - stat: TargetStat, - target: number, - level?: string -) { - const current = getNumber(gameStats, stat.key); - const needed = Math.max(0, Math.ceil(target - current)); - const statName = statNameLower(stat.name); - - return { - embeds: [ - baseEmbed(player, game, level) - .title(`To reach ${formatTarget(target)} ${stat.name}:`) - .description( - [ - `Current: **${formatTarget(current)} ${stat.name}**`, - `Needed: **${formatInteger(needed)} ${statName}**${needed === 0 ? " (target reached)" : ""}`, - ].join("\n") - ), - ], - }; -} - -function baseEmbed(player: Player, game: GameKey, level?: string) { - const titleParts = [player.displayName]; - if (level) titleParts.push(level); - - return new EmbedBuilder() - .author(titleParts.join(" ")) - .footer(GAME_NAMES.get(game)!) - .color(STATUS_COLORS.info); -} - -function getTargetStats(game: GameKey) { - if (statCache.has(game)) return statCache.get(game)!; - - const metadata = MetadataScanner.scan(getGameClass(game)); - const numberFields = metadata - .filter(([, { type }]) => type.type === Number) - .map(([key, { leaderboard }]) => ({ - key, - name: cleanName(leaderboard.fieldName || leaderboard.name || prettify(key)), - })); - - const byKey = new Map(numberFields.map((field) => [field.key, field])); - const ratioKeys = new Set(LEADERBOARD_RATIOS.map((ratio) => ratio[2])); - const ratios: TargetStat[] = []; - - for (const [numerator, denominator, ratioKey, prettyName] of LEADERBOARD_RATIOS) { - for (const field of numberFields) { - if (lastPathPart(field.key) !== ratioKey) continue; - - const parent = parentPath(field.key); - const numeratorKey = pathWithParent(parent, numerator); - const denominatorKey = pathWithParent(parent, denominator); - const numeratorField = byKey.get(numeratorKey); - const denominatorField = byKey.get(denominatorKey); - - if (!numeratorField || !denominatorField) continue; - - ratios.push({ - denominator: denominatorKey, - denominatorName: statNameLower(denominatorField.name), - key: field.key, - name: parent === "overall" || !parent ? prettyName : `${cleanName(parent)} ${prettyName}`, - numerator: numeratorKey, - numeratorName: statNameLower(numeratorField.name), - ratio: [numerator, denominator, ratioKey, prettyName], - type: "ratio", - }); - } - } - - const stats: TargetStat[] = [ - ...ratios, - ...numberFields - .filter((field) => !ratioKeys.has(lastPathPart(field.key))) - .map((field) => ({ ...field, type: "stat" as const })), - ]; - - statCache.set(game, stats); - return stats; -} - -function resolveTargetStat(game: GameKey, input: string) { - const stats = getTargetStats(game); - const normalized = input.toLowerCase(); - const exact = stats.find((stat) => stat.key === input); - if (exact) return exact; - - const overall = stats.find( - (stat) => - stat.key.toLowerCase() === `overall.${normalized}` || - stat.name.toLowerCase() === normalized - ); - if (overall) return overall; - - const fallback = stats.find( - (stat) => - lastPathPart(stat.key).toLowerCase() === normalized || - stat.name.toLowerCase().includes(normalized) - ); - - if (!fallback) { - throw new ErrorMessage( - "Target stat not found", - `I couldn't find \`${input}\` for ${GAME_NAMES.get(game)}. Use the stat autocomplete to pick a supported target.` - ); - } - - return fallback; -} - -function getLevel(gameStats: Record) { - const formatted = gameStats.levelFormatted || gameStats.naturalLevelFormatted; - if (typeof formatted === "string") return removeFormatting(formatted); - - const level = gameStats.level; - if (typeof level === "number") return `Level ${formatDecimal(level)}`; - - return undefined; -} - -function getNumber(data: Record, path: string) { - const value = path - .split(".") - .reduce((acc, key) => (acc as Record | undefined)?.[key], data); - - return typeof value === "number" && Number.isFinite(value) ? value : 0; -} - -function cleanName(value: string) { - return removeFormatting(value) - .replace(/\s+/g, " ") - .trim(); -} - -function statNameLower(value: string) { - return cleanName(value).toLowerCase(); -} - -function singularize(value: string) { - return value.endsWith("s") ? value.slice(0, -1) : value; -} - -function formatDecimal(value: number) { - return value.toLocaleString("en-US", { - maximumFractionDigits: 2, - minimumFractionDigits: 2, - }); -} - -function formatInteger(value: number) { - return value.toLocaleString("en-US", { maximumFractionDigits: 0 }); -} - -function formatTarget(value: number) { - return Number.isInteger(value) ? formatInteger(value) : formatDecimal(value); -} - -function lastPathPart(path: string) { - return path.split(".").at(-1)!; -} - -function parentPath(path: string) { - return path.split(".").slice(0, -1).join("."); -} - -function pathWithParent(parent: string, key: string) { - return parent ? `${parent}.${key}` : key; -} diff --git a/locales/en-US/default.json b/locales/en-US/default.json index 5e94dcc89..b66f3e884 100644 --- a/locales/en-US/default.json +++ b/locales/en-US/default.json @@ -14,9 +14,6 @@ "server": "A Minecraft server name or a server IP", "tags-content": "The content of the tag", "tags-name": "The name of the tag", - "target": "The goal you want to reach", - "target-setback": "How many denominator stats to include in the alternate calculation", - "target-stat": "The stat or ratio you want to target", "text": "A message", "user": "Choose a Discord user" }, @@ -34,7 +31,6 @@ "buildbattle": "$t(commands.hypixel-command, { \"name\": \"Build Battle\" })", "cape": "View someone's Minecraft and Optifine capes", "challenges": "$t(commands.hypixel-command, { \"name\": \"Challenge\" })", - "calculate": "$t(commands.target)", "quests-command": "View your {{name}} questing stats", "quests": "$t(commands.hypixel-command, { \"name\": \"Questing\" })", "quests-overall": "$t(commands.quests-command, { \"name\": \"Overall\" })", @@ -216,34 +212,6 @@ "tags-delete": "Delete a support tag", "tags-rename": "Rename a support tag", "text": "Generate Minecraft text", - "target": "Calculate what you need to reach a stat target", - "target-arcade": "$t(commands.target-command, { \"name\": \"Arcade\" })", - "target-arenabrawl": "$t(commands.target-command, { \"name\": \"Arena Brawl\" })", - "target-bedwars": "$t(commands.target-command, { \"name\": \"BedWars\" })", - "target-blitzsg": "$t(commands.target-command, { \"name\": \"BlitzSG\" })", - "target-buildbattle": "$t(commands.target-command, { \"name\": \"Build Battle\" })", - "target-command": "Calculate a {{name}} stat target", - "target-challenges": "$t(commands.target-command, { \"name\": \"Challenges\" })", - "target-copsandcrims": "$t(commands.target-command, { \"name\": \"Cops and Crims\" })", - "target-duels": "$t(commands.target-command, { \"name\": \"Duels\" })", - "target-general": "$t(commands.target-command, { \"name\": \"General\" })", - "target-megawalls": "$t(commands.target-command, { \"name\": \"MegaWalls\" })", - "target-murdermystery": "$t(commands.target-command, { \"name\": \"Murder Mystery\" })", - "target-paintball": "$t(commands.target-command, { \"name\": \"Paintball\" })", - "target-parkour": "$t(commands.target-command, { \"name\": \"Parkour\" })", - "target-pit": "$t(commands.target-command, { \"name\": \"Pit\" })", - "target-quake": "$t(commands.target-command, { \"name\": \"Quake\" })", - "target-quests": "$t(commands.target-command, { \"name\": \"Quests\" })", - "target-skywars": "$t(commands.target-command, { \"name\": \"SkyWars\" })", - "target-smashheroes": "$t(commands.target-command, { \"name\": \"Smash Heroes\" })", - "target-speeduhc": "$t(commands.target-command, { \"name\": \"Speed UHC\" })", - "target-tntgames": "$t(commands.target-command, { \"name\": \"TNT Games\" })", - "target-turbokartracers": "$t(commands.target-command, { \"name\": \"Turbo Kart Racers\" })", - "target-uhc": "$t(commands.target-command, { \"name\": \"UHC\" })", - "target-vampirez": "$t(commands.target-command, { \"name\": \"VampireZ\" })", - "target-walls": "$t(commands.target-command, { \"name\": \"Walls\" })", - "target-warlords": "$t(commands.target-command, { \"name\": \"Warlords\" })", - "target-woolgames": "$t(commands.target-command, { \"name\": \"WoolGames\" })", "theme": "Change your theme for every profile", "theme-boxes": "Change the appearance of the profile boxes", "theme-font": "Change the font of the profiles", From 8ca4ea957526e85e47fe5d01343a2f2df46cc36a Mon Sep 17 00:00:00 2001 From: Cody Date: Sun, 31 May 2026 05:56:16 -0600 Subject: [PATCH 3/3] feat: centralize command metadata --- .gitmodules | 2 +- .../src/commands/arcade/arcade.command.tsx | 9 +- .../src/commands/base.hypixel-command.ts | 8 +- .../commands/historical/session.command.tsx | 13 +- .../player-leaderboard.argument.ts | 54 ++++- .../player-leaderboard.command.ts | 2 +- .../src/commands/ratios/ratios.command.tsx | 5 + locales/en-US/default.json | 40 +++ .../components/select-menu.builder.ts | 2 +- .../discord/src/services/paginate.service.ts | 20 +- packages/schemas/src/game/game-modes.ts | 66 ++++- .../src/player/gamemodes/arcade/index.ts | 229 +++++++++++++++--- 12 files changed, 386 insertions(+), 64 deletions(-) diff --git a/.gitmodules b/.gitmodules index 5e3e9437a..43b90d09d 100644 --- a/.gitmodules +++ b/.gitmodules @@ -3,4 +3,4 @@ url = https://github.com/Statsify/public-assets [submodule "assets/private"] path = assets/private - url = https://github.com/Statsify/assets \ No newline at end of file + url = https://github.com/Statsify/assets diff --git a/apps/discord-bot/src/commands/arcade/arcade.command.tsx b/apps/discord-bot/src/commands/arcade/arcade.command.tsx index bc3452fea..ac984457e 100644 --- a/apps/discord-bot/src/commands/arcade/arcade.command.tsx +++ b/apps/discord-bot/src/commands/arcade/arcade.command.tsx @@ -42,15 +42,12 @@ export class ArcadeCommand extends BaseHypixelCommand { } export function getArcadeModeEmojis(modes: GameModeWithSubModes[]): ModeEmoji[] { - return modes.map((mode) => (t) => t(`emojis:arcade.${mode.api}`)); + return modes.map(({ emoji }) => emoji ? (t) => t(emoji) : undefined); } export function getArcadeSubModeEmojis>( - mode: M, + _mode: M, submodes: SubModeForMode[] ): ModeEmoji[] { - if (mode === "zombies") - return submodes.map((submode) => (t) => t(`emojis:zombies.${submode.api}`)); - - return []; + return submodes.map(({ emoji }) => emoji ? (t) => t(emoji) : undefined); } diff --git a/apps/discord-bot/src/commands/base.hypixel-command.ts b/apps/discord-bot/src/commands/base.hypixel-command.ts index 5020e4ba6..05c45f36b 100644 --- a/apps/discord-bot/src/commands/base.hypixel-command.ts +++ b/apps/discord-bot/src/commands/base.hypixel-command.ts @@ -46,6 +46,8 @@ export interface ProfileData { } export type ModeEmoji = LocalizationString | false | undefined; +const metadataString = (key?: string): LocalizationString | undefined => + key ? (t) => t(key) : undefined; export interface BaseHypixelCommand { getPreProfileData?(player: Player): K | Promise; @@ -89,7 +91,8 @@ export abstract class BaseHypixelCommand { const pageInput = { label: mode.formatted, - emoji: emojis[index], + description: metadataString(mode.description), + emoji: emojis[index] ?? metadataString(mode.emoji), }; const filteredSubmodes = this.filterSubmodes?.(player, mode) ?? mode.submodes; @@ -127,7 +130,8 @@ export abstract class BaseHypixelCommand ({ label: submode.formatted, - emoji: submodeEmojis[index], + description: metadataString(submode.description), + emoji: submodeEmojis[index] ?? metadataString(submode.emoji), generator: async (t) => { const background = await getBackground(...mapBackground(this.modes, mode.api, submode.api as ApiSubModeForMode)); diff --git a/apps/discord-bot/src/commands/historical/session.command.tsx b/apps/discord-bot/src/commands/historical/session.command.tsx index c4e23aaaa..56f7b28f8 100644 --- a/apps/discord-bot/src/commands/historical/session.command.tsx +++ b/apps/discord-bot/src/commands/historical/session.command.tsx @@ -43,6 +43,7 @@ import { Command, CommandContext, EmbedBuilder, + LocalizationString, Page, PaginateService, PlayerArgument, @@ -86,6 +87,9 @@ import { render } from "@statsify/rendering"; import type { BaseProfileProps, ModeEmoji } from "#commands/base.hypixel-command"; import type { HistoricalTimeData } from "#components"; +const metadataString = (key?: string): LocalizationString | undefined => + key ? (t) => t(key) : undefined; + @Command({ description: "session stats" }) export class SessionCommand { public constructor( @@ -390,7 +394,8 @@ export class SessionCommand { if (submodes.length === 0) return { label: mode.formatted, - emoji: modeEmojis[index], + description: metadataString(mode.description), + emoji: modeEmojis[index] ?? metadataString(mode.emoji), generator: async (t) => { const background = await getBackground(...mapBackground(modes, mode.api)); @@ -438,7 +443,8 @@ export class SessionCommand { const subPages = submodes.map((submode, index): SubPage => ({ label: submode.formatted, - emoji: submodeEmojis[index], + description: metadataString(submode.description), + emoji: submodeEmojis[index] ?? metadataString(submode.emoji), generator: async (t) => { const background = await getBackground(...mapBackground(modes, mode.api, submode.api as ApiSubModeForMode)); @@ -473,7 +479,8 @@ export class SessionCommand { return { label: mode.formatted, - emoji: modeEmojis[index], + description: metadataString(mode.description), + emoji: modeEmojis[index] ?? metadataString(mode.emoji), subPages, }; }); diff --git a/apps/discord-bot/src/commands/leaderboards/player-leaderboard.argument.ts b/apps/discord-bot/src/commands/leaderboards/player-leaderboard.argument.ts index a6c2e730e..9e94187df 100644 --- a/apps/discord-bot/src/commands/leaderboards/player-leaderboard.argument.ts +++ b/apps/discord-bot/src/commands/leaderboards/player-leaderboard.argument.ts @@ -14,18 +14,21 @@ import { import { AbstractArgument, CommandContext, LocalizationString } from "@statsify/discord"; import { ClassMetadata, + type GameModes, LeaderboardScanner, METADATA_KEY, PlayerStats, } from "@statsify/schemas"; import { removeFormatting } from "@statsify/util"; +type LeaderboardChoice = APIApplicationCommandOptionChoice & { aliases?: string[] }; + const entries = Object.entries( Reflect.getMetadata(METADATA_KEY, PlayerStats.prototype) as ClassMetadata ); const FUSE_OPTIONS = { - keys: ["name", "key"], + keys: ["name", "value", "aliases"], includeScore: false, shouldSort: true, isCaseSensitive: false, @@ -41,7 +44,7 @@ const fields = entries.reduce((acc, [prefix, value]) => { const fuse = new Fuse(list, FUSE_OPTIONS); return { ...acc, [prefix]: [fuse, list] }; -}, {} as Record, APIApplicationCommandOptionChoice[]]>); +}, {} as Record, LeaderboardChoice[]]>); export class PlayerLeaderboardArgument extends AbstractArgument { public name = "leaderboard"; @@ -50,9 +53,22 @@ export class PlayerLeaderboardArgument extends AbstractArgument { public required = true; public autocomplete = true; - public constructor(private prefix: keyof PlayerStats) { + private fuse: Fuse; + private list: LeaderboardChoice[]; + + public constructor(private prefix: keyof PlayerStats, modes?: GameModes) { super(); this.description = (t) => t("arguments.player-leaderboard"); + + const [, baseList] = fields[this.prefix]; + this.list = modes ? + baseList.map((choice) => ({ + ...choice, + aliases: this.getAliases(choice.value as string, modes), + })) : + baseList; + + this.fuse = new Fuse(this.list, FUSE_OPTIONS); } public autocompleteHandler( @@ -60,13 +76,33 @@ export class PlayerLeaderboardArgument extends AbstractArgument { ): APIApplicationCommandOptionChoice[] { const currentValue = context.option(this.name, "").toLowerCase(); - const [fuse, list] = fields[this.prefix]; + if (!currentValue) return this.toChoices(this.list.slice(0, 25)); + + return this.toChoices( + this.fuse + .search(currentValue) + .map((result) => result.item) + .slice(0, 25) + ); + } - if (!currentValue) return list.slice(0, 25); + private getAliases(key: string, modes: GameModes): string[] { + const [modeKey, submodeKey] = key.split("."); + const aliases = new Set(); + + const mode = modes.getModes().find(({ api }) => api === modeKey); + + if (!mode) return []; + + mode.aliases.forEach((alias: string) => aliases.add(alias)); + mode.submodes + .find(({ api }) => api === submodeKey) + ?.aliases.forEach((alias: string) => aliases.add(alias)); + + return [...aliases]; + } - return fuse - .search(currentValue) - .map((result) => result.item) - .slice(0, 25); + private toChoices(choices: LeaderboardChoice[]): APIApplicationCommandOptionChoice[] { + return choices.map(({ name, value }) => ({ name, value })); } } diff --git a/apps/discord-bot/src/commands/leaderboards/player-leaderboard.command.ts b/apps/discord-bot/src/commands/leaderboards/player-leaderboard.command.ts index 1a9159e80..6c0db00cd 100644 --- a/apps/discord-bot/src/commands/leaderboards/player-leaderboard.command.ts +++ b/apps/discord-bot/src/commands/leaderboards/player-leaderboard.command.ts @@ -63,7 +63,7 @@ export class PlayerLeaderboardCommand extends BaseLeaderboardCommand { @SubCommand({ description: (t) => t("commands.leaderboard-arcade"), - args: [new PlayerLeaderboardArgument("arcade")], + args: [new PlayerLeaderboardArgument("arcade", ARCADE_MODES)], }) public arcade(context: CommandContext) { return this.run(context, "arcade", ARCADE_MODES); diff --git a/apps/discord-bot/src/commands/ratios/ratios.command.tsx b/apps/discord-bot/src/commands/ratios/ratios.command.tsx index bdd522891..907ca9cef 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, + LocalizationString, Page, PaginateService, PlayerArgument, @@ -56,6 +57,8 @@ import { getTheme } from "#themes"; import { render } from "@statsify/rendering"; const args = [PlayerArgument]; +const metadataString = (key?: string): LocalizationString | undefined => + key ? (t) => t(key) : undefined; @Command({ description: (t) => t("commands.ratios") }) export class RatiosCommand { @@ -208,6 +211,8 @@ export class RatiosCommand { const pages: Page[] = displayedModes.map((mode, index) => ({ label: mode.formatted, + description: metadataString(mode.description), + emoji: metadataString(mode.emoji), generator: async (t) => { const background = await getBackground(...mapBackground(modes, mode.api)); diff --git a/locales/en-US/default.json b/locales/en-US/default.json index b66f3e884..df4b0118f 100644 --- a/locales/en-US/default.json +++ b/locales/en-US/default.json @@ -19,6 +19,46 @@ }, "commands": { "arcade": "$t(commands.hypixel-command, { \"name\": \"Arcade\" })", + "options": { + "arcade": { + "overall": "Overall Arcade stats", + "blockingDead": "Blocking Dead stats", + "bountyHunters": "Bounty Hunters stats", + "creeperAttack": "Creeper Attack stats", + "disasters": "Disasters stats", + "dragonWars": "Dragon Wars stats", + "dropper": "Dropper stats", + "enderSpleef": "Ender Spleef stats", + "farmHunt": "Farm Hunt stats", + "football": "Football stats", + "galaxyWars": "Galaxy Wars stats", + "hideAndSeek": "Hide and Seek stats", + "holeInTheWall": "Hole in the Wall stats", + "hypixelSays": "Hypixel Says stats", + "miniWalls": "Mini Walls stats", + "partyGames": "Party Games stats", + "pixelPainters": "Pixel Painters stats", + "pixelParty": "Pixel Party stats", + "seasonal": "Seasonal Arcade stats", + "throwOut": "Throw Out stats", + "zombies": "Zombies stats", + "submodes": { + "overall": "Overall stats", + "survivals": "Survivals stats", + "deaths": "Deaths stats", + "bestTimes": "Best time stats", + "completions": "Completion stats", + "roundWins": "Round win stats", + "zombies": { + "overall": "Overall Zombies stats", + "deadEnd": "Dead End stats", + "badBlood": "Bad Blood stats", + "alienArcadium": "Alien Arcadium stats", + "prison": "Prison stats" + } + } + } + }, "arenabrawl": "$t(commands.hypixel-command, { \"name\": \"Arena Brawl\" })", "available": "Check the availability of a Minecraft username", "badge": "Change your badge for every profile", diff --git a/packages/discord/src/messages/components/select-menu.builder.ts b/packages/discord/src/messages/components/select-menu.builder.ts index 91ed4d569..c1777833f 100644 --- a/packages/discord/src/messages/components/select-menu.builder.ts +++ b/packages/discord/src/messages/components/select-menu.builder.ts @@ -51,7 +51,7 @@ export class SelectMenuOptionBuilder { return { label: translateField(locale, this.#label), value: this.#value, - description: translateField(locale, this.#description), + description: this.#description ? translateField(locale, this.#description) : undefined, emoji: this.#emoji ? parseEmoji(this.#emoji, locale) : undefined, default: this.#defaultValue, }; diff --git a/packages/discord/src/services/paginate.service.ts b/packages/discord/src/services/paginate.service.ts index d53215e53..5164d590a 100644 --- a/packages/discord/src/services/paginate.service.ts +++ b/packages/discord/src/services/paginate.service.ts @@ -33,10 +33,11 @@ type PaginateInteractionContentGenerator = ( export type Page = PageInput & ({ subPages: SubPage[] } | { generator: PaginateInteractionContentGenerator }); export type SubPage = PageInput & { generator: PaginateInteractionContentGenerator }; -interface PageInput { - label: LocalizationString; - emoji?: LocalizationString | false; -} +interface PageInput { + label: LocalizationString; + description?: LocalizationString; + emoji?: LocalizationString | false; +} type PageId = `${number}|${number}`; @@ -245,11 +246,12 @@ class PageController { if (pages.length > 5) { const menu = new SelectMenuBuilder(); - pages.forEach((page, index) => { - const option = new SelectMenuOptionBuilder().label(page.label).value(`${index}`); - if (page.emoji) option.emoji(page.emoji); - menu.option(option); - }); + pages.forEach((page, index) => { + const option = new SelectMenuOptionBuilder().label(page.label).value(`${index}`); + if (page.description) option.description(page.description); + if (page.emoji) option.emoji(page.emoji); + menu.option(option); + }); menu.activeOption(selected); this.#menu = menu; diff --git a/packages/schemas/src/game/game-modes.ts b/packages/schemas/src/game/game-modes.ts index e499b8486..f42bc97b1 100644 --- a/packages/schemas/src/game/game-modes.ts +++ b/packages/schemas/src/game/game-modes.ts @@ -8,11 +8,28 @@ import { prettify } from "@statsify/util"; +export interface CommandOptionMetadata { + label: string; + value: Value; + emoji?: string; + aliases: string[]; + description?: string; +} + +type OptionMetadata = { + aliases?: readonly string[]; + description?: string; + emoji?: string; +}; + export type GameMode = { [Key in ApiModeFromGameModes]: { api: Key; formatted: string; hypixel?: string; + aliases: string[]; + description?: string; + emoji?: string; submode: [SubModeForMode] extends [never] ? undefined : SubModeForMode; } }[ApiModeFromGameModes]; @@ -22,11 +39,14 @@ export type GameModeWithSubModes = { api: Key; formatted: string; hypixel?: string; + aliases: string[]; + description?: string; + emoji?: string; submodes: SubModeForMode[]; } }[ApiModeFromGameModes]; -export type Mode = { +export type Mode = ({ hypixel: string; formatted: string; } | { @@ -34,9 +54,9 @@ export type Mode = { api: string; formatted?: string; submodes?: SubMode[]; -}; +}) & OptionMetadata; -type SubMode = { api: string; formatted?: string }; +type SubMode = { api: string; formatted?: string } & OptionMetadata; export class GameModes { private modes: GameModeWithSubModes[]; @@ -47,7 +67,16 @@ export class GameModes { hypixel: m.hypixel, api: m.api, formatted: m.formatted ?? prettify(m.api), - submodes: m.submodes?.map((sm) => ({ api: sm.api, formatted: sm.formatted ?? prettify(sm.api) })) ?? [], + aliases: [...(m.aliases ?? [])], + description: m.description, + emoji: m.emoji, + submodes: m.submodes?.map((sm) => ({ + api: sm.api, + formatted: sm.formatted ?? prettify(sm.api), + aliases: [...(sm.aliases ?? [])], + description: sm.description, + emoji: sm.emoji, + })) ?? [], })) as GameModeWithSubModes[]; this.hypixelModes = Object.fromEntries( @@ -72,6 +101,28 @@ export class GameModes { return this.modes; } + public getModeOptions(): CommandOptionMetadata>[] { + return this.modes.map((mode) => ({ + label: mode.formatted, + value: mode.api, + emoji: mode.emoji, + aliases: mode.aliases, + description: mode.description, + })); + } + + public getSubModeOptions>( + mode: M + ): CommandOptionMetadata>[] { + return (this.modes.find((m) => m.api === mode)?.submodes ?? []).map((submode) => ({ + label: submode.formatted, + value: submode.api as ApiSubModeForMode, + emoji: submode.emoji, + aliases: submode.aliases, + description: submode.description, + })); + } + public getHypixelModes() { return this.hypixelModes; } @@ -86,7 +137,12 @@ type ExtractSubModes> = Extr export type SubModeForMode> = [ExtractSubModes] extends [never] ? never : - ExtractSubModes & { formatted: string }; + ExtractSubModes & { + aliases: string[]; + description?: string; + emoji?: string; + formatted: string; + }; export type ApiSubModeForMode> = NeverToUndefined["api"]>; diff --git a/packages/schemas/src/player/gamemodes/arcade/index.ts b/packages/schemas/src/player/gamemodes/arcade/index.ts index 95d6a41b2..c98c6b7bb 100644 --- a/packages/schemas/src/player/gamemodes/arcade/index.ts +++ b/packages/schemas/src/player/gamemodes/arcade/index.ts @@ -34,50 +34,225 @@ import { add } from "@statsify/math"; import type { APIData } from "@statsify/util"; export const ARCADE_MODES = new GameModes([ - { api: "overall" }, - { api: "blockingDead", hypixel: "DAYONE" }, - { api: "bountyHunters", hypixel: "ONEINTHEQUIVER" }, - { api: "creeperAttack", hypixel: "DEFENDER" }, + { + api: "overall", + aliases: ["arcade"], + description: "commands.options.arcade.overall", + emoji: "emojis:arcade.overall", + }, + { + api: "blockingDead", + hypixel: "DAYONE", + aliases: ["bd"], + description: "commands.options.arcade.blockingDead", + emoji: "emojis:arcade.blockingDead", + }, + { + api: "bountyHunters", + hypixel: "ONEINTHEQUIVER", + aliases: ["bh", "oitq"], + description: "commands.options.arcade.bountyHunters", + emoji: "emojis:arcade.bountyHunters", + }, + { + api: "creeperAttack", + hypixel: "DEFENDER", + aliases: ["ca"], + description: "commands.options.arcade.creeperAttack", + emoji: "emojis:arcade.creeperAttack", + }, { api: "disasters", hypixel: "DISASTERS", - submodes: [{ api: "overall" }, { api: "survivals" }, { api: "deaths" }], + aliases: [], + description: "commands.options.arcade.disasters", + emoji: "emojis:arcade.disasters", + submodes: [ + { + api: "overall", + aliases: [], + description: "commands.options.arcade.submodes.overall", + }, + { + api: "survivals", + aliases: ["surv"], + description: "commands.options.arcade.submodes.survivals", + }, + { + api: "deaths", + aliases: [], + description: "commands.options.arcade.submodes.deaths", + }, + ], + }, + { + api: "dragonWars", + hypixel: "DRAGONWARS2", + aliases: ["dw"], + description: "commands.options.arcade.dragonWars", + emoji: "emojis:arcade.dragonWars", }, - { api: "dragonWars", hypixel: "DRAGONWARS2" }, { api: "dropper", hypixel: "DROPPER", + aliases: [], + description: "commands.options.arcade.dropper", + emoji: "emojis:arcade.dropper", submodes: [ - { api: "overall" }, - { api: "bestTimes" }, - { api: "completions" }, + { + api: "overall", + aliases: [], + description: "commands.options.arcade.submodes.overall", + }, + { + api: "bestTimes", + aliases: ["times"], + description: "commands.options.arcade.submodes.bestTimes", + }, + { + api: "completions", + aliases: ["comps"], + description: "commands.options.arcade.submodes.completions", + }, ], }, - { api: "enderSpleef", hypixel: "ENDER" }, - { api: "farmHunt", hypixel: "FARM_HUNT" }, - { api: "football", hypixel: "SOCCER" }, - { api: "galaxyWars", hypixel: "STARWARS" }, - { api: "hideAndSeek" }, - { api: "holeInTheWall", hypixel: "HOLE_IN_THE_WALL" }, - { api: "hypixelSays", hypixel: "SIMON_SAYS" }, - { api: "miniWalls", hypixel: "MINI_WALLS" }, + { + api: "enderSpleef", + hypixel: "ENDER", + aliases: ["es"], + description: "commands.options.arcade.enderSpleef", + emoji: "emojis:arcade.enderSpleef", + }, + { + api: "farmHunt", + hypixel: "FARM_HUNT", + aliases: ["fh"], + description: "commands.options.arcade.farmHunt", + emoji: "emojis:arcade.farmHunt", + }, + { + api: "football", + hypixel: "SOCCER", + aliases: ["soccer"], + description: "commands.options.arcade.football", + emoji: "emojis:arcade.football", + }, + { + api: "galaxyWars", + hypixel: "STARWARS", + aliases: ["gw", "starwars"], + description: "commands.options.arcade.galaxyWars", + emoji: "emojis:arcade.galaxyWars", + }, + { + api: "hideAndSeek", + aliases: ["hns"], + description: "commands.options.arcade.hideAndSeek", + emoji: "emojis:arcade.hideAndSeek", + }, + { + api: "holeInTheWall", + hypixel: "HOLE_IN_THE_WALL", + aliases: ["hitw"], + description: "commands.options.arcade.holeInTheWall", + emoji: "emojis:arcade.holeInTheWall", + }, + { + api: "hypixelSays", + hypixel: "SIMON_SAYS", + aliases: ["simonSays", "hs"], + description: "commands.options.arcade.hypixelSays", + emoji: "emojis:arcade.hypixelSays", + }, + { + api: "miniWalls", + hypixel: "MINI_WALLS", + aliases: ["mw"], + description: "commands.options.arcade.miniWalls", + emoji: "emojis:arcade.miniWalls", + }, { api: "partyGames", hypixel: "PARTY", - submodes: [{ api: "overall" }, { api: "roundWins" }], + aliases: ["pg"], + description: "commands.options.arcade.partyGames", + emoji: "emojis:arcade.partyGames", + submodes: [ + { + api: "overall", + aliases: [], + description: "commands.options.arcade.submodes.overall", + }, + { + api: "roundWins", + aliases: ["rounds"], + description: "commands.options.arcade.submodes.roundWins", + }, + ], + }, + { + api: "pixelPainters", + hypixel: "DRAW_THEIR_THING", + aliases: ["ppaint"], + description: "commands.options.arcade.pixelPainters", + emoji: "emojis:arcade.pixelPainters", + }, + { + api: "pixelParty", + hypixel: "PIXEL_PARTY", + aliases: ["pp"], + description: "commands.options.arcade.pixelParty", + emoji: "emojis:arcade.pixelParty", + }, + { + api: "seasonal", + aliases: [], + description: "commands.options.arcade.seasonal", + emoji: "emojis:arcade.seasonal", + }, + { + api: "throwOut", + hypixel: "THROW_OUT", + aliases: ["to"], + description: "commands.options.arcade.throwOut", + emoji: "emojis:arcade.throwOut", }, - { api: "pixelPainters", hypixel: "DRAW_THEIR_THING" }, - { api: "pixelParty", hypixel: "PIXEL_PARTY" }, - { api: "seasonal" }, - { api: "throwOut", hypixel: "THROW_OUT" }, { api: "zombies", + aliases: ["zb"], + description: "commands.options.arcade.zombies", + emoji: "emojis:arcade.zombies", submodes: [ - { api: "overall" }, - { api: "deadEnd" }, - { api: "badBlood" }, - { api: "alienArcadium" }, - { api: "prison" }, + { + api: "overall", + aliases: [], + description: "commands.options.arcade.submodes.zombies.overall", + emoji: "emojis:zombies.overall", + }, + { + api: "deadEnd", + aliases: ["de"], + description: "commands.options.arcade.submodes.zombies.deadEnd", + emoji: "emojis:zombies.deadEnd", + }, + { + api: "badBlood", + aliases: ["bb"], + description: "commands.options.arcade.submodes.zombies.badBlood", + emoji: "emojis:zombies.badBlood", + }, + { + api: "alienArcadium", + aliases: ["aa"], + description: "commands.options.arcade.submodes.zombies.alienArcadium", + emoji: "emojis:zombies.alienArcadium", + }, + { + api: "prison", + aliases: [], + description: "commands.options.arcade.submodes.zombies.prison", + emoji: "emojis:zombies.prison", + }, ], },