diff --git a/apps/discord-bot/src/commands/fishing/fishing.command.tsx b/apps/discord-bot/src/commands/fishing/fishing.command.tsx new file mode 100644 index 000000000..672cf8595 --- /dev/null +++ b/apps/discord-bot/src/commands/fishing/fishing.command.tsx @@ -0,0 +1,124 @@ +/** + * 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 { + ApiService, + Command, + CommandContext, + type LocalizeFunction, + type Page, + PaginateService, + PlayerArgument, +} from "@statsify/discord"; +import { type FishingPageData, FishingProfile } from "./fishing.profile.js"; +import { type FishingSeasonalYear, GENERAL_MODES } from "@statsify/schemas"; +import { arrayGroup } from "@statsify/util"; +import { getBackground, getLogo } from "@statsify/assets"; +import { getTheme } from "#themes"; +import { mapBackground } from "#constants"; +import { render } from "@statsify/rendering"; + +const seasonalPageLabel = (years: FishingSeasonalYear[]) => + years.length === 1 ? + years[0].year : + `${years[0].year}-${years.at(-1)!.year}`; + +@Command({ description: (t) => t("commands.fishing"), args: [PlayerArgument] }) +export class FishingCommand { + public constructor( + private readonly apiService: ApiService, + private readonly paginateService: PaginateService + ) {} + + public async run(context: CommandContext) { + const user = context.getUser(); + const player = await this.apiService.getPlayer( + context.option("player"), + user + ); + const fishing = player.stats.general.fishing; + + const [logo, skin, badge] = await Promise.all([ + getLogo(user), + this.apiService.getPlayerSkin(player.uuid, user), + this.apiService.getUserBadge(player.uuid), + ]); + + const seasonalPages = arrayGroup( + fishing.seasonal.years.filter((year) => year.total > 0).toReversed(), + 3 + ).map((years) => ({ + label: seasonalPageLabel(years), + seasonalYears: years, + })); + + const pages: FishingPageData[] = [ + { id: "overview", label: "Overview" }, + { id: "mythicals", label: "Mythicals" }, + { id: "specialsOne", label: "Special Fish I" }, + { id: "specialsTwo", label: "Special Fish II" }, + { id: "collections", label: "Collections" }, + { id: "catchesOne", label: "Catches I" }, + { id: "catchesTwo", label: "Catches II" }, + { id: "environments", label: "Environments" }, + { id: "seasonal", label: "Seasonal" }, + ...(seasonalPages.length > 0 ? + [{ id: "yearly" as const, label: "Yearly" }] : + []), + ]; + + const renderPage = async ( + t: LocalizeFunction, + pageData: FishingPageData, + page: number, + seasonalYears = pageData.seasonalYears + ) => { + const background = await getBackground( + ...mapBackground(GENERAL_MODES, GENERAL_MODES.getApiModes()[0]) + ); + + return render( + , + getTheme(user) + ); + }; + + return this.paginateService.paginate( + context, + pages.map((pageData, page): Page => { + if (pageData.id === "yearly") { + return { + label: pageData.label, + subPages: seasonalPages.map(({ label, seasonalYears }) => ({ + label, + generator: (t) => renderPage(t, pageData, page, seasonalYears), + })), + }; + } + + return { + label: pageData.label, + generator: (t) => renderPage(t, pageData, page), + }; + }) + ); + } +} diff --git a/apps/discord-bot/src/commands/fishing/fishing.profile.tsx b/apps/discord-bot/src/commands/fishing/fishing.profile.tsx new file mode 100644 index 000000000..45552d7e2 --- /dev/null +++ b/apps/discord-bot/src/commands/fishing/fishing.profile.tsx @@ -0,0 +1,852 @@ +/** + * 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 { + Container, + Footer, + Header, + Multiline, + SidebarItem, + Table, +} from "#components"; +import { + FISHING_ENVIRONMENTS, + FISHING_EVENTS, + FISHING_HOOK_TRAILS, + FISHING_INDIVIDUAL_ITEMS, + FISHING_MYTHICAL_FISH, + FISHING_RODS, + FISHING_SPECIAL_FISH, + type FishingEnvironment, + type FishingEnvironmentStats, + type FishingEvent, + type FishingIndividualCatch, + type FishingSeasonalYear, + FormattedGame, +} from "@statsify/schemas"; +import { add } from "@statsify/math"; +import { arrayGroup, prettify } from "@statsify/util"; +import type { BaseProfileProps } from "#commands/base.hypixel-command"; + +export type FishingPage = + | "overview" | + "mythicals" | + "specialsOne" | + "specialsTwo" | + "collections" | + "catchesOne" | + "catchesTwo" | + "environments" | + "seasonal" | + "yearly"; + +export interface FishingPageData { + id: FishingPage; + label: string; + seasonalYears?: FishingSeasonalYear[]; +} + +const FISHING_PAGE_TITLES: Record = { + overview: "Stats", + mythicals: "Mythicals", + specialsOne: "Special Fish", + specialsTwo: "Special Fish", + collections: "Collections", + catchesOne: "Catches", + catchesTwo: "Catches", + environments: "Environments", + seasonal: "Seasonal", + yearly: "Yearly", +}; + +interface FishingProfileProps extends BaseProfileProps { + page: FishingPage; + pageNumber: number; + pageCount: number; + seasonalYears?: FishingSeasonalYear[]; +} + +interface FishingCollectionTableProps { + items: FishingCollectionDisplayItem[]; + columns?: number; + compact?: boolean; +} + +interface FishingCollectionDisplayItem { + name: string; + source: string; + environment: string; + requirement: string; + unlocked: boolean; + active: boolean; +} + +interface FishingCollectionState { + unlocked?: boolean; + active?: boolean; +} + +interface FishingCatchDisplayItem { + name: string; + catches: number; +} + +const statusColor = (unlocked: boolean) => (unlocked ? "§a" : "§c"); + +const FISHING_EVENT_COLORS: Record = { + halloween: "§5", + christmas: "§c", + easter: "§b", + summer: "§e", +}; + +const FISHING_EVENT_NAMES: Record = { + halloween: "Halloween", + christmas: "Holiday", + easter: "Easter", + summer: "Summer", +}; + +const FISHING_ENVIRONMENT_COLORS: Record = { + water: "§9", + lava: "§c", + ice: "§b", +}; + +const prettifyFishingId = (id: string) => + id === "N/A" ? + "N/A" : + prettify( + id + .replace("mainlobby_fishing_", "") + .replace("fishing_rod_", "") + .replaceAll("-", "_") + ); + +const formatPercent = (value: number) => `${(value * 100).toFixed(1)}%`; + +const formatFishingEnvironment = (environment: string) => + `${FISHING_ENVIRONMENT_COLORS[environment.toLowerCase()] ?? "§7"}${prettify(environment)}`; + +const collectionItems = < + T extends { + id: string; + source?: string; + environment?: string; + requirement?: string; + } +>( + data: T[], + items: FishingCollectionState[] +): FishingCollectionDisplayItem[] => + data.map((item, index) => ({ + name: prettifyFishingId(item.id), + source: item.source ?? "N/A", + environment: item.environment ?? "N/A", + requirement: item.requirement ?? "N/A", + unlocked: items[index]?.unlocked ?? false, + active: items[index]?.active ?? false, + })); + +const catchItems = ( + category: keyof typeof FISHING_INDIVIDUAL_ITEMS, + items: FishingIndividualCatch[] +): FishingCatchDisplayItem[] => + FISHING_INDIVIDUAL_ITEMS[category].map((item, index) => ({ + name: prettifyFishingId(item.id), + catches: items[index]?.catches ?? 0, + })); + +const FishingCollectionTable = ({ + items, + columns = 3, + compact = false, +}: FishingCollectionTableProps) => ( + + {arrayGroup(items, columns).map((row) => ( + + {row.map((item) => ( + + + §l{statusColor(item.unlocked)} + {item.active ? "§n" : ""} + {item.name} + + + {statusColor(item.unlocked)} + {item.unlocked ? "Unlocked" : "Locked"} + {compact ? + "" : + ` §7- ${item.source === "N/A" ? item.requirement : item.source}`} + + {!compact && item.environment !== "N/A" ? + ( + §7Found In: {formatFishingEnvironment(item.environment)} + ) : + ( + <> + )} + + ))} + + ))} + +); + +const FishingOverview = ({ + t, + player, +}: Pick) => { + const { fishing } = player.stats.general; + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; + +const FishingMythicals = ({ + t, + player, +}: Pick) => { + const { fishing } = player.stats.general; + + return ( + + {arrayGroup(fishing.mythicals, 3).map((row, rowIndex) => ( + + {row.map((mythical, index) => { + const data = FISHING_MYTHICAL_FISH[rowIndex * 3 + index]; + const catches = mythical.catches ?? 0; + const maxWeight = mythical.maxWeight ?? 0; + const percentage = + fishing.mythical > 0 ? catches / fishing.mythical : 0; + const maxed = data.maxWeightCap > 0 && maxWeight >= data.maxWeightCap; + + return ( + + + + {[ + `§7Rarity: §b${data.rarity}`, + `§7Catches: §6${t(catches)}`, + `§7Share: §6${formatPercent(percentage)}`, + `§7Max Weight: §6${maxWeight ? `${t(maxWeight)}kg` : "N/A"}${maxed ? " §aMaxed" : ""}`, + ].join("\n")} + + + + ); + })} + + ))} + + ); +}; + +const FishingSpecials = ({ + player, + page, +}: Pick) => { + const { fishing } = player.stats.general; + const offset = page === "specialsOne" ? 0 : 24; + + return ( + + ); +}; + +const FishingCollections = ({ + player, +}: Pick) => { + const { fishing } = player.stats.general; + + return ( + + + + + + + + + ); +}; + +interface CatchSectionProps { + title: string; + items: FishingCatchDisplayItem[]; + color: string; + columns: number; + total: number; + t: FishingProfileProps["t"]; +} + +const CatchSection = ({ + title, + items, + color, + columns, + total, + t, +}: CatchSectionProps) => ( + + {arrayGroup(items, columns).map((row) => ( + + {row.map((item) => ( + + ))} + + ))} + +); + +const FishingCatchesOne = ({ + t, + player, +}: Pick) => { + const { fishing } = player.stats.general; + const { individual } = fishing; + + return ( + + + + + + ); +}; + +const FishingCatchesTwo = ({ + t, + player, +}: Pick) => { + const { fishing } = player.stats.general; + const { individual } = fishing; + + return ( + + + + + ); +}; + +const sumEnvironmentStats = (environments: FishingEnvironmentStats[]) => ({ + fish: add(...environments.map((environment) => environment.fish)), + junk: add(...environments.map((environment) => environment.junk)), + treasure: add(...environments.map((environment) => environment.treasure)), + plant: add(...environments.map((environment) => environment.plant)), + creature: add(...environments.map((environment) => environment.creature)), + mythical: add(...environments.map((environment) => environment.mythical)), +}); + +const seasonalEventSummary = ( + event: FishingEvent, + seasonalYears: FishingSeasonalYear[] +) => { + const environments = seasonalYears.flatMap((year) => + FISHING_ENVIRONMENTS.map((environment) => year[event][environment]) + ); + + return { + ...sumEnvironmentStats(environments), + total: add(...seasonalYears.map((year) => year[event].total)), + }; +}; + +const seasonalSummary = (year: FishingSeasonalYear) => { + const environments = FISHING_EVENTS.flatMap((event) => + FISHING_ENVIRONMENTS.map((environment) => year[event][environment]) + ); + const events = FISHING_EVENTS.map((event) => year[event]); + const getEnvironmentTotal = (environment: FishingEnvironment) => + add(...events.map((event) => event[environment].total)); + + return { + ...sumEnvironmentStats(environments), + water: getEnvironmentTotal("water"), + lava: getEnvironmentTotal("lava"), + ice: getEnvironmentTotal("ice"), + total: year.total, + }; +}; + +const FishingEnvironments = ({ + t, + player, +}: Pick) => { + const { fishing } = player.stats.general; + + return ( + + + {FISHING_ENVIRONMENTS.map((environment) => { + const environmentName = prettify(environment); + const stats = fishing[environment]; + + return ( + + + + + + + ); + })} + + + ); +}; + +const FishingSeasonal = ({ + t, + player, +}: Pick) => { + const { fishing } = player.stats.general; + const seasonalYears = fishing.seasonal.years.filter((year) => year.total > 0); + const seasonalEnvironments = seasonalYears.flatMap((year) => + FISHING_EVENTS.flatMap((event) => + FISHING_ENVIRONMENTS.map((environment) => year[event][environment]) + ) + ); + const seasonal = sumEnvironmentStats(seasonalEnvironments); + const seasonalEventRows = arrayGroup(FISHING_EVENTS, 2).map((events) => ( + + {events.map((event) => { + const eventSeasonal = seasonalEventSummary(event, seasonalYears); + const color = FISHING_EVENT_COLORS[event]; + + return ( + + + + + + + + + + + + + ); + })} + + )); + + return ( + + {[ + + + + + + + + + , + ...seasonalEventRows, + ]} + + ); +}; + +interface FishingSeasonalYearCardProps { + year: FishingSeasonalYear; + t: FishingProfileProps["t"]; +} + +const FishingSeasonalYearCard = ({ year, t }: FishingSeasonalYearCardProps) => { + const summary = seasonalSummary(year); + + return ( + + + {FISHING_EVENTS.map((event) => ( + + ))} + + + + + + + + + ); +}; + +const FishingYearly = ({ + t, + player, + seasonalYears, +}: Pick) => { + const { seasonal } = player.stats.general.fishing; + const years = + seasonalYears ?? seasonal.years.filter((year) => year.total > 0); + + if (years.length === 0) { + return ( + + + §7No seasonal fishing data yet. + + + ); + } + + return ( + + {[ + + + {FISHING_EVENTS.map((event) => ( + + ))} + + , + ...years.map((year) => ), + ]} + + ); +}; + +export const FishingProfile = ({ + skin, + player, + background, + logo, + user, + badge, + t, + time, + page, + pageNumber, + pageCount, + seasonalYears, +}: FishingProfileProps) => { + const { fishing } = player.stats.general; + + const sidebar: SidebarItem[] = [ + ["Total Catches", t(fishing.totalCatches), "§b"], + ["Mythicals", t(fishing.mythical), "§6"], + ["Special Fish", `${t(fishing.special)}/${t(FISHING_SPECIAL_FISH.length)}`, "§d"], + ["Rods", `${t(fishing.rods)}/${t(FISHING_RODS.length)}`, "§a"], + ["Hook Trails", `${t(fishing.hookTrails)}/${t(FISHING_HOOK_TRAILS.length)}`, "§6"], + ["Active Rod", prettifyFishingId(fishing.activeFishingRod), "§a"], + ["Active Trail", prettifyFishingId(fishing.activeFishHookTrail), "§6"], + ]; + + const title = `§l${FormattedGame.FISHING} §f${FISHING_PAGE_TITLES[page]}`; + + let content: JSX.Element; + + switch (page) { + case "overview": + content = ; + + break; + + case "mythicals": + content = ; + + break; + + case "specialsOne": + case "specialsTwo": + content = ; + + break; + + case "collections": + content = ; + + break; + + case "catchesOne": + content = ; + + break; + + case "catchesTwo": + content = ; + + break; + + case "environments": + content = ; + + break; + + case "seasonal": + content = ; + + break; + + case "yearly": + content = ( + + ); + + break; + + // No default + } + + return ( + +
+ {content} +