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", + }, ], },