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}
+
+
+ );
+};
diff --git a/assets/private b/assets/private
index 1ed6d877f..7bd3861f9 160000
--- a/assets/private
+++ b/assets/private
@@ -1 +1 @@
-Subproject commit 1ed6d877fe5cc3bc80ade9d7654752edc8ba0a3e
+Subproject commit 7bd3861f93050e270dff5666aac33dd4081aa22f
diff --git a/assets/public b/assets/public
index 25cf23500..cf87904f4 160000
--- a/assets/public
+++ b/assets/public
@@ -1 +1 @@
-Subproject commit 25cf2350076387df9e8a37388ae48f4a931e9966
+Subproject commit cf87904f476e88e8bb31fa47f4dc2a569628ddbd
diff --git a/locales/en-US/default.json b/locales/en-US/default.json
index b66f3e884..f80141b29 100644
--- a/locales/en-US/default.json
+++ b/locales/en-US/default.json
@@ -45,6 +45,7 @@
"delete-player": "Delete a player from Statsify",
"duels": "$t(commands.hypixel-command, { \"name\": \"Duels\" })",
"events": "$t(commands.hypixel-command, { \"name\": \"Events\" })",
+ "fishing": "$t(commands.hypixel-command, { \"name\": \"Fishing\" })",
"force-unverify": "Forcefully unverify a player",
"force-verify": "Forcefully verify a player",
"game-counts": "View Hypixel current game counts",
diff --git a/packages/schemas/src/game/constants.ts b/packages/schemas/src/game/constants.ts
index acf3dd705..3ed37a38a 100644
--- a/packages/schemas/src/game/constants.ts
+++ b/packages/schemas/src/game/constants.ts
@@ -48,7 +48,7 @@ export const GameIdMapping = {
} as const;
export const GameCodeMapping = Object.fromEntries(
- Object.entries(GameIdMapping).map(([code, id]) => [id, code])
+ Object.entries(GameIdMapping).map(([code, id]) => [id, code]),
) as Record;
export enum FormattedGame {
@@ -60,6 +60,7 @@ export enum FormattedGame {
COPS_AND_CRIMS = "§6Cops §fand §aCrims§f",
DUELS = "§bDuels§f",
EVENTS = "§eEvents§f",
+ FISHING = "§bFishing§f",
GENERAL = "§fGeneral§f",
HOUSING = "§9Housing§f",
MEGAWALLS = "§7MegaWalls§f",
@@ -90,7 +91,7 @@ export enum FormattedGame {
REPLAY = "§fReplay§f",
IDLE = "§fIdle§f",
LIMBO = "§fLimbo§f",
- SMP = "§fSMP§f"
+ SMP = "§fSMP§f",
}
/**
diff --git a/packages/schemas/src/player/gamemodes/general/fishing.ts b/packages/schemas/src/player/gamemodes/general/fishing.ts
new file mode 100644
index 000000000..c4fa66f6d
--- /dev/null
+++ b/packages/schemas/src/player/gamemodes/general/fishing.ts
@@ -0,0 +1,1023 @@
+/**
+ * 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 { Field } from "#metadata";
+import { add } from "@statsify/math";
+import type { APIData } from "@statsify/util";
+
+export const FISHING_ENVIRONMENTS = ["water", "lava", "ice"] as const;
+export type FishingEnvironment = (typeof FISHING_ENVIRONMENTS)[number];
+export type FishingEvent = "halloween" | "christmas" | "easter" | "summer";
+
+export const FISHING_EVENTS: FishingEvent[] = [
+ "halloween",
+ "christmas",
+ "easter",
+ "summer",
+];
+const FISHING_FIRST_YEAR = 2022;
+const isYearKey = (key: string) => /^\d{4}$/.test(key);
+
+const toNumber = (value: unknown) => (typeof value === "number" ? value : 0);
+const hasPackage = (packages: string[], id: string) => packages.includes(id);
+const totalPermanentStat = (
+ permanent: APIData,
+ stat: "treasure" | "junk" | "plant" | "creature"
+) =>
+ add(
+ ...FISHING_ENVIRONMENTS.map((environment) =>
+ toNumber(permanent[environment]?.[stat])
+ )
+ );
+
+interface FishingSpecialFishData {
+ id: string;
+ source: string;
+ environment: FishingEnvironment;
+}
+
+export const FISHING_SPECIAL_FISH: FishingSpecialFishData[] = [
+ {
+ id: "puffer_emoji",
+ source: "Anytime",
+ environment: "water",
+ },
+ { id: "nemo", source: "Anytime", environment: "water" },
+ {
+ id: "knockback_slimeball",
+ source: "Anytime",
+ environment: "water",
+ },
+ {
+ id: "hot_potato",
+ source: "Anytime",
+ environment: "water",
+ },
+ {
+ id: "fish_monger_suit_helmet",
+ source: "Anytime",
+ environment: "water",
+ },
+ {
+ id: "fish_monger_suit_chestplate",
+ source: "Anytime",
+ environment: "water",
+ },
+ {
+ id: "fish_monger_suit_leggings",
+ source: "Anytime",
+ environment: "water",
+ },
+ {
+ id: "fish_monger_suit_boots",
+ source: "Anytime",
+ environment: "water",
+ },
+ { id: "barnacle", source: "Anytime", environment: "water" },
+ {
+ id: "leviathan",
+ source: "Anytime",
+ environment: "water",
+ },
+ {
+ id: "star_eater_scales",
+ source: "Anytime",
+ environment: "water",
+ },
+ {
+ id: "rubber_duck",
+ source: "Anytime",
+ environment: "water",
+ },
+ {
+ id: "oops_the_fish",
+ source: "Summer",
+ environment: "water",
+ },
+ { id: "shark", source: "Summer", environment: "water" },
+ { id: "sea_bass", source: "Summer", environment: "water" },
+ {
+ id: "sunscreen",
+ source: "Summer",
+ environment: "water",
+ },
+ {
+ id: "pile_of_sand",
+ source: "Summer",
+ environment: "water",
+ },
+ {
+ id: "mahi-mahi",
+ source: "Summer",
+ environment: "water",
+ },
+ {
+ id: "mahi_mahi",
+ source: "Summer",
+ environment: "water",
+ },
+ {
+ id: "lucent_bee_hive",
+ source: "Summer",
+ environment: "water",
+ },
+ {
+ id: "spook_the_fish",
+ source: "Halloween",
+ environment: "water",
+ },
+ {
+ id: "chocolate_bar",
+ source: "Halloween",
+ environment: "water",
+ },
+ {
+ id: "pumpkin_spice_latte",
+ source: "Halloween",
+ environment: "water",
+ },
+ { id: "angler", source: "Halloween", environment: "water" },
+ {
+ id: "pumpkin_pie",
+ source: "Halloween",
+ environment: "water",
+ },
+ { id: "eyeball", source: "Halloween", environment: "water" },
+ {
+ id: "wayfinders_compass",
+ source: "Halloween",
+ environment: "water",
+ },
+ {
+ id: "molten_iron",
+ source: "Halloween",
+ environment: "lava",
+ },
+ {
+ id: "regular_fish",
+ source: "Halloween",
+ environment: "lava",
+ },
+ {
+ id: "lava_shark",
+ source: "Halloween",
+ environment: "lava",
+ },
+ {
+ id: "chill_the_fish_3",
+ source: "Holiday",
+ environment: "water",
+ },
+ {
+ id: "frozen_fish",
+ source: "Holiday",
+ environment: "ice",
+ },
+ {
+ id: "festival_pufferfish_hat",
+ source: "Holiday",
+ environment: "water",
+ },
+ { id: "eggnog", source: "Holiday", environment: "water" },
+ {
+ id: "dawning_snowball",
+ source: "Holiday",
+ environment: "ice",
+ },
+ {
+ id: "frozen_meal",
+ source: "Holiday",
+ environment: "ice",
+ },
+ {
+ id: "festive_lights",
+ source: "Holiday",
+ environment: "ice",
+ },
+ {
+ id: "egg_the_fish",
+ source: "Easter",
+ environment: "water",
+ },
+ {
+ id: "cracked_egg",
+ source: "Easter",
+ environment: "water",
+ },
+ { id: "raw_ham", source: "Easter", environment: "water" },
+ { id: "carrot", source: "Easter", environment: "water" },
+ {
+ id: "soggy_hot_cross_bun",
+ source: "Easter",
+ environment: "water",
+ },
+ {
+ id: "clay_ball",
+ source: "Easter",
+ environment: "water",
+ },
+ { id: "rose", source: "Easter", environment: "water" },
+ {
+ id: "cherry_blossom",
+ source: "Easter",
+ environment: "water",
+ },
+ {
+ id: "poisonous_potato",
+ source: "Fishing Friday",
+ environment: "water",
+ },
+ {
+ id: "golden_apple",
+ source: "Fishing Friday",
+ environment: "water",
+ },
+ {
+ id: "burnt_plant",
+ source: "Dense Vegetation",
+ environment: "lava",
+ },
+];
+
+interface FishingMythicalData {
+ id: string;
+ name: string;
+ rarity: string;
+ maxWeightCap: number;
+}
+
+export const FISHING_MYTHICAL_FISH: FishingMythicalData[] = [
+ { id: "helios", name: "Ember of Helios", rarity: "Common", maxWeightCap: 15 },
+ { id: "selene", name: "Dust of Selene", rarity: "Common", maxWeightCap: 15 },
+ { id: "nyx", name: "Shadow of Nyx", rarity: "Uncommon", maxWeightCap: 25 },
+ {
+ id: "aphrodite",
+ name: "Heart of Aphrodite",
+ rarity: "Uncommon",
+ maxWeightCap: 25,
+ },
+ { id: "zeus", name: "Spark of Zeus", rarity: "Rare", maxWeightCap: 40 },
+ {
+ id: "demeter",
+ name: "Spirit of Demeter",
+ rarity: "Rare",
+ maxWeightCap: 40,
+ },
+ {
+ id: "archimedes",
+ name: "Automaton of Daedalus",
+ rarity: "Ultra Rare",
+ maxWeightCap: 0,
+ },
+ {
+ id: "hades",
+ name: "Wrath Of Hades",
+ rarity: "Ultra Rare",
+ maxWeightCap: 0,
+ },
+];
+
+interface FishingRodData {
+ id: string;
+ requirement: string;
+}
+
+export const FISHING_RODS: FishingRodData[] = [
+ {
+ id: "fishing_rod_3000",
+ requirement: "Default fishing rod",
+ },
+ {
+ id: "fishing_rod_inaugural_ice",
+ requirement: "Holidays 2022 limited item",
+ },
+ {
+ id: "fishing_rod_springtime",
+ requirement: "Spring Fishing Reward",
+ },
+ {
+ id: "fishing_rod_haunted",
+ requirement: "Halloween Fishing Reward",
+ },
+ {
+ id: "fishing_rod_festive",
+ requirement: "Holidays Fishing Reward",
+ },
+ {
+ id: "fishing_rod_solar",
+ requirement: "Summer Fishing Reward",
+ },
+ {
+ id: "fishing_rod_overgrown",
+ requirement: "Poisonous Potato, Golden Apple, Burnt Plant",
+ },
+ {
+ id: "fishing_rod_zoologist",
+ requirement: "Catch 1 squid during Creatures modifier",
+ },
+];
+
+interface FishingHookTrailData {
+ id: string;
+ requirement: string;
+}
+
+export const FISHING_HOOK_TRAILS: FishingHookTrailData[] = [
+ {
+ id: "mainlobby_fishing_emerald",
+ requirement: "Catch 500 Mythical Fish",
+ },
+ {
+ id: "mainlobby_fishing_sparkle",
+ requirement: "Catch 20 Special Fish",
+ },
+ {
+ id: "mainlobby_fishing_treasure_sheen",
+ requirement: "Catch 5,000 Treasure Items",
+ },
+ {
+ id: "mainlobby_fishing_beloved_junk",
+ requirement: "Catch 5,000 Junk Items",
+ },
+ {
+ id: "mainlobby_fishing_archimedes_trail",
+ requirement: "Catch Automaton of Daedalus 1 time",
+ },
+ {
+ id: "mainlobby_fishing_hades_hook",
+ requirement: "Catch Wrath Of Hades 5 times",
+ },
+ {
+ id: "mainlobby_fishing_helios_breath",
+ requirement: "Event Shop",
+ },
+ {
+ id: "mainlobby_fishing_organic_material",
+ requirement: "Catch 1,000 Plants",
+ },
+ {
+ id: "mainlobby_fishing_creature_catch",
+ requirement: "Catch 1,000 Creatures",
+ },
+ {
+ id: "mainlobby_fishing_neptune_grace",
+ requirement: "Event Shop",
+ },
+ {
+ id: "mainlobby_fishing_ominous_rain",
+ requirement: "Event Shop",
+ },
+];
+
+type FishingCatchCategory =
+ | "fish" |
+ "treasure" |
+ "junk" |
+ "plant" |
+ "creature";
+
+interface FishingItemData {
+ id: string;
+}
+
+const FISHING_INDIVIDUAL_FISH: FishingItemData[] = [
+ { id: "salmon" },
+ { id: "clownfish" },
+ { id: "cooked_salmon" },
+ { id: "charred_pufferfish" },
+ { id: "cooked_cod" },
+ { id: "pufferfish" },
+ { id: "cod" },
+ { id: "trout" },
+ { id: "pike" },
+ { id: "perch" },
+ { id: "kelp" },
+];
+
+const FISHING_INDIVIDUAL_TREASURE: FishingItemData[] = [
+ { id: "eye_of_ender" },
+ { id: "molten_gold" },
+ { id: "blaze_powder" },
+ { id: "gold_sword" },
+ { id: "name_tag" },
+ { id: "enchanted_book" },
+ { id: "diamond" },
+ { id: "compass" },
+ { id: "gold_pickaxe" },
+ { id: "emerald" },
+ { id: "enchanted_fishing_rod" },
+ { id: "enchanted_bow" },
+ { id: "saddle" },
+ { id: "diamond_sword" },
+ { id: "magma_cream" },
+ { id: "blaze_rod" },
+ { id: "chainmail_chestplate" },
+ { id: "iron_sword" },
+ { id: "nautilus_shell" },
+];
+
+const FISHING_INDIVIDUAL_JUNK: FishingItemData[] = [
+ { id: "charcoal" },
+ { id: "soggy_paper" },
+ { id: "ink_sac" },
+ { id: "broken_fishing_rod" },
+ { id: "water_bottle" },
+ { id: "bowl" },
+ { id: "rotten_flesh" },
+ { id: "string" },
+ { id: "rabbit_hide" },
+ { id: "leather" },
+ { id: "lily_pad" },
+ { id: "bone" },
+ { id: "leather_boots" },
+ { id: "tripwire_hook" },
+ { id: "stick" },
+ { id: "coal" },
+ { id: "fermented_spider_eye" },
+ { id: "burned_flesh" },
+ { id: "steak" },
+ { id: "nether_brick" },
+ { id: "lava_bucket" },
+ { id: "clump_of_leaves" },
+ { id: "frozen_flesh" },
+ { id: "snowball" },
+ { id: "ice_shard" },
+];
+
+const FISHING_INDIVIDUAL_PLANT: FishingItemData[] = [
+ { id: "kelp" },
+ { id: "bamboo" },
+ { id: "dried_kelp" },
+ { id: "glow_berries" },
+ { id: "melon" },
+ { id: "potato" },
+ { id: "sweet_berries" },
+ { id: "wheat" },
+ { id: "frozen_kelp" },
+ { id: "baked_potato" },
+ { id: "charred_berries" },
+ { id: "nether_wart" },
+ { id: "glistering_melon" },
+ { id: "warped_roots" },
+];
+
+const FISHING_INDIVIDUAL_CREATURE: FishingItemData[] = [
+ { id: "chicken" },
+ { id: "cow" },
+ { id: "creeper" },
+ { id: "pig" },
+ { id: "sheep" },
+ { id: "skeleton" },
+ { id: "slime" },
+ { id: "spider" },
+ { id: "squid" },
+ { id: "zombie" },
+ { id: "blaze" },
+ { id: "cave_spider" },
+ { id: "magma_cube" },
+ { id: "pig_zombie" },
+];
+
+export const FISHING_INDIVIDUAL_ITEMS: Record<
+ FishingCatchCategory,
+ FishingItemData[]
+> = {
+ fish: FISHING_INDIVIDUAL_FISH,
+ treasure: FISHING_INDIVIDUAL_TREASURE,
+ junk: FISHING_INDIVIDUAL_JUNK,
+ plant: FISHING_INDIVIDUAL_PLANT,
+ creature: FISHING_INDIVIDUAL_CREATURE,
+};
+
+export class FishingEnvironmentStats {
+ @Field()
+ public fish: number;
+
+ @Field()
+ public junk: number;
+
+ @Field()
+ public treasure: number;
+
+ @Field()
+ public plant: number;
+
+ @Field()
+ public creature: number;
+
+ @Field()
+ public mythical: number;
+
+ @Field()
+ public total: number;
+
+ public constructor(data: APIData = {}) {
+ this.fish = toNumber(data.fish);
+ this.junk = toNumber(data.junk);
+ this.treasure = toNumber(data.treasure);
+ this.plant = toNumber(data.plant);
+ this.creature = toNumber(data.creature);
+ this.mythical = toNumber(data.orb);
+ this.total = add(
+ this.fish,
+ this.junk,
+ this.treasure,
+ this.plant,
+ this.creature,
+ this.mythical
+ );
+ }
+}
+
+export class FishingEnchantment {
+ @Field()
+ public level: number;
+
+ @Field({ leaderboard: { enabled: false } })
+ public enabled: boolean;
+
+ public constructor(data: APIData = {}, fallbackToggle?: boolean) {
+ this.level = toNumber(data.level);
+ this.enabled =
+ typeof data.toggle === "boolean" ?
+ data.toggle :
+ (fallbackToggle ?? false);
+ }
+}
+
+export class FishingEnchantments {
+ @Field()
+ public luck: FishingEnchantment;
+
+ @Field()
+ public collector: FishingEnchantment;
+
+ @Field()
+ public dumpsterDiver: FishingEnchantment;
+
+ @Field()
+ public vulcansBlessing: FishingEnchantment;
+
+ @Field()
+ public neptunesFury: FishingEnchantment;
+
+ @Field()
+ public lure: FishingEnchantment;
+
+ @Field()
+ public mythicalHook: FishingEnchantment;
+
+ @Field()
+ public herbivore: FishingEnchantment;
+
+ @Field()
+ public landLine: FishingEnchantment;
+
+ public constructor(
+ data: APIData = {},
+ mainLobby: APIData = {},
+ globalFishing: APIData = {}
+ ) {
+ this.luck = new FishingEnchantment(
+ data.luck,
+ mainLobby.fishing_enchant_LUCK_toggle
+ );
+ this.collector = new FishingEnchantment(
+ data.collector,
+ mainLobby.fishing_enchant_COLLECTOR_toggle
+ );
+ this.dumpsterDiver = new FishingEnchantment(
+ data.dumpster_diver,
+ mainLobby.fishing_enchant_DUMPSTER_DIVER_toggle
+ );
+ this.vulcansBlessing = new FishingEnchantment({
+ level:
+ data.vulcans_blessing?.level ??
+ globalFishing.enchants?.vulcans_blessing?.level,
+ toggle: data.vulcans_blessing?.toggle,
+ });
+ this.neptunesFury = new FishingEnchantment(data.neptunes_fury);
+ this.lure = new FishingEnchantment(
+ data.lure,
+ mainLobby.fishing_enchant_LURE_toggle
+ );
+ this.mythicalHook = new FishingEnchantment(data.mythical_hook);
+ this.herbivore = new FishingEnchantment(data.herbivore);
+ this.landLine = new FishingEnchantment(data.land_line);
+ }
+}
+
+export class FishingMythicalFish {
+ public catches?: number;
+
+ @Field()
+ public maxWeight?: number;
+
+ public constructor(catches: number, weight: number) {
+ if (catches > 0) this.catches = catches;
+ if (weight > 0) this.maxWeight = weight;
+ }
+}
+
+export class FishingCollectionItem {
+ @Field({ leaderboard: { enabled: false }, store: { default: false } })
+ public unlocked?: boolean;
+
+ @Field({ leaderboard: { enabled: false }, store: { default: false } })
+ public active?: boolean;
+
+ public constructor({
+ unlocked = false,
+ active = false,
+ }: {
+ unlocked?: boolean;
+ active?: boolean;
+ }) {
+ if (unlocked) this.unlocked = true;
+ if (active) this.active = true;
+ }
+}
+
+export class FishingUnlockableItem {
+ @Field({ leaderboard: { enabled: false }, store: { default: false } })
+ public unlocked?: boolean;
+
+ public constructor(unlocked = false) {
+ if (unlocked) this.unlocked = true;
+ }
+}
+
+export class FishingSeasonalEvent {
+ @Field()
+ public water: FishingEnvironmentStats;
+
+ @Field()
+ public lava: FishingEnvironmentStats;
+
+ @Field()
+ public ice: FishingEnvironmentStats;
+
+ @Field()
+ public total: number;
+
+ public constructor(data: APIData = {}) {
+ this.water = new FishingEnvironmentStats(data.water);
+ this.lava = new FishingEnvironmentStats(data.lava);
+ this.ice = new FishingEnvironmentStats(data.ice);
+ this.total = add(
+ ...FISHING_ENVIRONMENTS.map((environment) => this[environment].total)
+ );
+ }
+}
+
+export class FishingSeasonalYear {
+ @Field({ leaderboard: { enabled: false } })
+ public year: string;
+
+ @Field()
+ public halloween: FishingSeasonalEvent;
+
+ @Field()
+ public christmas: FishingSeasonalEvent;
+
+ @Field()
+ public easter: FishingSeasonalEvent;
+
+ @Field()
+ public summer: FishingSeasonalEvent;
+
+ @Field()
+ public total: number;
+
+ public constructor(year: string = "", data: APIData = {}) {
+ this.year = year;
+ this.halloween = new FishingSeasonalEvent(data.halloween);
+ this.christmas = new FishingSeasonalEvent(data.christmas);
+ this.easter = new FishingSeasonalEvent(data.easter);
+ this.summer = new FishingSeasonalEvent(data.summer);
+ this.total = add(...FISHING_EVENTS.map((event) => this[event].total));
+ }
+}
+
+export class FishingSeasonal {
+ @Field({ type: () => [FishingSeasonalYear], leaderboard: { enabled: false } })
+ public years: FishingSeasonalYear[];
+
+ @Field()
+ public halloween: number;
+
+ @Field()
+ public christmas: number;
+
+ @Field()
+ public easter: number;
+
+ @Field()
+ public summer: number;
+
+ @Field()
+ public total: number;
+
+ public constructor(data: APIData = {}) {
+ const yearKeys = Object.keys(data)
+ .filter((key) => isYearKey(key) && Number.parseInt(key, 10) >= FISHING_FIRST_YEAR)
+ .sort((a, b) => Number.parseInt(a, 10) - Number.parseInt(b, 10));
+
+ this.years = yearKeys.map(
+ (year) => new FishingSeasonalYear(year, data[year])
+ ).filter((year) => year.total > 0);
+ this.halloween = add(...this.years.map((year) => year.halloween.total));
+ this.christmas = add(...this.years.map((year) => year.christmas.total));
+ this.easter = add(...this.years.map((year) => year.easter.total));
+ this.summer = add(...this.years.map((year) => year.summer.total));
+ this.total = add(this.halloween, this.christmas, this.easter, this.summer);
+ }
+}
+
+export class FishingIndividualCatch {
+ public catches?: number;
+
+ public constructor(catches: number) {
+ if (catches > 0) this.catches = catches;
+ }
+}
+
+const getIndividualCatches = (
+ category: FishingCatchCategory,
+ data: APIData = {}
+) =>
+ FISHING_INDIVIDUAL_ITEMS[category].map(
+ (item) => new FishingIndividualCatch(toNumber(data[category]?.[item.id]))
+ );
+
+export class FishingIndividualCatches {
+ @Field({ type: () => [FishingIndividualCatch], leaderboard: { enabled: false } })
+ public fish: FishingIndividualCatch[];
+
+ @Field({ type: () => [FishingIndividualCatch], leaderboard: { enabled: false } })
+ public treasure: FishingIndividualCatch[];
+
+ @Field({ type: () => [FishingIndividualCatch], leaderboard: { enabled: false } })
+ public junk: FishingIndividualCatch[];
+
+ @Field({ type: () => [FishingIndividualCatch], leaderboard: { enabled: false } })
+ public plant: FishingIndividualCatch[];
+
+ @Field({ type: () => [FishingIndividualCatch], leaderboard: { enabled: false } })
+ public creature: FishingIndividualCatch[];
+
+ public constructor(data: APIData = {}) {
+ this.fish = getIndividualCatches("fish", data);
+ this.treasure = getIndividualCatches("treasure", data);
+ this.junk = getIndividualCatches("junk", data);
+ this.plant = getIndividualCatches("plant", data);
+ this.creature = getIndividualCatches("creature", data);
+ }
+}
+
+export class FishingFireproofing {
+ @Field()
+ public scales: number;
+
+ @Field()
+ public sealant: number;
+
+ @Field()
+ public flame: number;
+
+ public constructor(data: APIData = {}) {
+ this.scales = toNumber(data.scales);
+ this.sealant = toNumber(data.sealant);
+ this.flame = toNumber(data.flame);
+ }
+}
+
+export class Fishing {
+ @Field()
+ public totalCatches: number;
+
+ @Field()
+ public fish: number;
+
+ @Field()
+ public junk: number;
+
+ @Field()
+ public treasure: number;
+
+ @Field()
+ public plant: number;
+
+ @Field()
+ public creature: number;
+
+ @Field()
+ public mythical: number;
+
+ @Field()
+ public special: number;
+
+ @Field()
+ public rods: number;
+
+ @Field()
+ public hookTrails: number;
+
+ @Field()
+ public water: FishingEnvironmentStats;
+
+ @Field()
+ public lava: FishingEnvironmentStats;
+
+ @Field()
+ public ice: FishingEnvironmentStats;
+
+ @Field({ type: () => [FishingMythicalFish], leaderboard: { enabled: false } })
+ public mythicals: FishingMythicalFish[];
+
+ @Field({ type: () => [FishingUnlockableItem], leaderboard: { enabled: false } })
+ public specialFish: FishingUnlockableItem[];
+
+ @Field({ type: () => [FishingCollectionItem], leaderboard: { enabled: false } })
+ public fishingRods: FishingCollectionItem[];
+
+ @Field({ type: () => [FishingCollectionItem], leaderboard: { enabled: false } })
+ public hookTrailCollection: FishingCollectionItem[];
+
+ @Field()
+ public seasonal: FishingSeasonal;
+
+ @Field()
+ public enchants: FishingEnchantments;
+
+ @Field()
+ public fireproofing: FishingFireproofing;
+
+ @Field()
+ public individual: FishingIndividualCatches;
+
+ @Field({ leaderboard: { enabled: false } })
+ public activeFishingRod: string;
+
+ @Field({ leaderboard: { enabled: false } })
+ public activeFishHookTrail: string;
+
+ public constructor(
+ mainLobby: APIData = {},
+ _achievements: APIData = {},
+ globalFishing: APIData = {}
+ ) {
+ const fishing = mainLobby.fishing ?? {};
+ const packages = mainLobby.packages ?? [];
+ const permanent = fishing.stats?.permanent ?? {};
+
+ this.water = new FishingEnvironmentStats(permanent.water);
+ this.lava = new FishingEnvironmentStats(permanent.lava);
+ this.ice = new FishingEnvironmentStats(permanent.ice);
+
+ const environments = FISHING_ENVIRONMENTS.map(
+ (environment) => this[environment]
+ );
+
+ this.fish = add(...environments.map((environment) => environment.fish));
+ this.junk = add(...environments.map((environment) => environment.junk));
+ this.treasure = add(
+ ...environments.map((environment) => environment.treasure)
+ );
+ this.plant = add(...environments.map((environment) => environment.plant));
+ this.creature = add(
+ ...environments.map((environment) => environment.creature)
+ );
+ this.mythical = add(
+ ...FISHING_MYTHICAL_FISH.map((mythical) =>
+ toNumber(fishing.orbs?.[mythical.id])
+ )
+ );
+
+ this.specialFish = FISHING_SPECIAL_FISH.map(
+ (fish) => new FishingUnlockableItem(fishing.special_fish?.[fish.id] ?? false)
+ );
+ this.special = this.specialFish.filter((fish) => fish.unlocked).length;
+
+ this.mythicals = FISHING_MYTHICAL_FISH.map(
+ (mythical) =>
+ new FishingMythicalFish(
+ toNumber(fishing.orbs?.[mythical.id]),
+ toNumber(fishing.orbs?.weight?.[mythical.id])
+ )
+ );
+
+ this.totalCatches = add(
+ this.fish,
+ this.junk,
+ this.treasure,
+ this.plant,
+ this.creature,
+ this.mythical,
+ this.special
+ );
+
+ this.activeFishingRod = fishing.activeFishingRod ?? "N/A";
+ this.activeFishHookTrail =
+ fishing.activeFishHookTrail ?? mainLobby.activeFishHookTrail ?? "N/A";
+
+ this.fishingRods = this.getFishingRods(fishing, packages);
+ this.hookTrailCollection = this.getHookTrails(fishing, packages, permanent);
+ this.rods = this.fishingRods.filter((rod) => rod.unlocked).length;
+ this.hookTrails = this.hookTrailCollection.filter(
+ (trail) => trail.unlocked
+ ).length;
+
+ this.seasonal = new FishingSeasonal(fishing.stats);
+ this.enchants = new FishingEnchantments(
+ fishing.enchants,
+ mainLobby,
+ globalFishing
+ );
+
+ this.fireproofing = new FishingFireproofing(fishing.fireproofing);
+ this.individual = new FishingIndividualCatches(permanent.individual);
+ }
+
+ private getFishingRods(fishing: APIData, packages: string[]) {
+ const specialFish = fishing.special_fish ?? {};
+ const creatures = fishing.stats?.permanent?.individual?.creature ?? {};
+ const inauguralIceCatches = new FishingEnvironmentStats(
+ fishing.stats?.["2022"]?.christmas?.ice
+ ).total;
+ const hasOvergrownRequirements =
+ specialFish.poisonous_potato &&
+ specialFish.golden_apple &&
+ specialFish.burnt_plant;
+ const hasZoologistRequirements = toNumber(creatures.squid) > 0;
+
+ return FISHING_RODS.map((rod) => {
+ const unlocked =
+ rod.id === "fishing_rod_3000" ||
+ hasPackage(packages, rod.id) ||
+ (rod.id === "fishing_rod_inaugural_ice" && inauguralIceCatches > 0) ||
+ (rod.id === "fishing_rod_overgrown" && hasOvergrownRequirements) ||
+ (rod.id === "fishing_rod_zoologist" && hasZoologistRequirements);
+
+ return new FishingCollectionItem({
+ unlocked,
+ active: fishing.activeFishingRod === rod.id,
+ });
+ });
+ }
+
+ private getHookTrails(
+ fishing: APIData,
+ packages: string[],
+ permanent: APIData
+ ) {
+ const totalTreasure = totalPermanentStat(permanent, "treasure");
+ const totalJunk = totalPermanentStat(permanent, "junk");
+ const totalPlant = totalPermanentStat(permanent, "plant");
+ const totalCreature = totalPermanentStat(permanent, "creature");
+
+ return FISHING_HOOK_TRAILS.map((trail) => {
+ const unlockedByStats = (() => {
+ switch (trail.id) {
+ case "mainlobby_fishing_emerald":
+ return this.mythical >= 500;
+
+ case "mainlobby_fishing_sparkle":
+ return this.special >= 20;
+
+ case "mainlobby_fishing_treasure_sheen":
+ return totalTreasure >= 5000;
+
+ case "mainlobby_fishing_beloved_junk":
+ return totalJunk >= 5000;
+
+ case "mainlobby_fishing_archimedes_trail":
+ return toNumber(fishing.orbs?.archimedes) >= 1;
+
+ case "mainlobby_fishing_hades_hook":
+ return toNumber(fishing.orbs?.hades) >= 5;
+
+ case "mainlobby_fishing_organic_material":
+ return totalPlant >= 1000;
+
+ case "mainlobby_fishing_creature_catch":
+ return totalCreature >= 1000;
+
+ default:
+ return false;
+ }
+ })();
+ const unlocked = hasPackage(packages, trail.id) || unlockedByStats;
+
+ return new FishingCollectionItem({
+ unlocked,
+ active: this.activeFishHookTrail === trail.id,
+ });
+ });
+ }
+}
diff --git a/packages/schemas/src/player/gamemodes/general/index.ts b/packages/schemas/src/player/gamemodes/general/index.ts
index f3e6dd85e..b455d9b61 100644
--- a/packages/schemas/src/player/gamemodes/general/index.ts
+++ b/packages/schemas/src/player/gamemodes/general/index.ts
@@ -8,6 +8,7 @@
import { Bingo } from "./bingo.js";
import { Events } from "./events.js";
+import { Fishing } from "./fishing.js";
import { type ExtractGameModes, GameModes } from "#game";
import { Field } from "#metadata";
import { getNetworkLevel } from "./util.js";
@@ -79,6 +80,9 @@ export class General {
@Field()
public bingo: Bingo;
+ @Field()
+ public fishing: Fishing;
+
public constructor(data: APIData, legacy: APIData) {
this.achievementPoints = data.achievementPoints;
@@ -98,8 +102,14 @@ export class General {
this.events = new Events(data.seasonal);
this.bingo = new Bingo(data.seasonal);
+ this.fishing = new Fishing(
+ data.stats?.MainLobby ?? {},
+ data.achievements ?? {},
+ data.fishing ?? {}
+ );
}
}
export * from "./events.js";
export * from "./bingo.js";
+export * from "./fishing.js";
diff --git a/packages/schemas/src/player/stats.ts b/packages/schemas/src/player/stats.ts
index 60fa3b0e6..5d8f46170 100644
--- a/packages/schemas/src/player/stats.ts
+++ b/packages/schemas/src/player/stats.ts
@@ -15,6 +15,7 @@ import {
Challenges,
CopsAndCrims,
Duels,
+ Fishing,
General,
MegaWalls,
MurderMystery,
@@ -91,6 +92,9 @@ export class PlayerStats {
@Field({ leaderboard: { fieldName: `${FormattedGame.GENERAL} -` } })
public general: General;
+ @Field({ leaderboard: { fieldName: `${FormattedGame.FISHING} -` } })
+ public fishing: Fishing;
+
@Field({ leaderboard: { fieldName: FormattedGame.MEGAWALLS } })
public megawalls: MegaWalls;
@@ -225,21 +229,22 @@ export class PlayerStats {
this.buildbattle = new BuildBattle(stats.BuildBattle ?? {}, achievements);
this.challenges = new Challenges(
data?.challenges?.all_time ?? {},
- achievements
+ achievements,
);
this.copsandcrims = new CopsAndCrims(stats.MCGO ?? {});
this.duels = new Duels(stats.Duels ?? {});
this.general = new General(data, legacy);
+ this.fishing = this.general.fishing;
this.megawalls = new MegaWalls(stats.Walls3 ?? {});
this.murdermystery = new MurderMystery(
stats.MurderMystery ?? {},
- achievements
+ achievements,
);
this.paintball = new Paintball(stats.Paintball ?? {}, legacy);
this.parkour = new Parkour(data.parkourCompletions ?? {});
this.pit = new Pit(
stats.Pit?.profile ?? {},
- stats.Pit?.pit_stats_ptl ?? {}
+ stats.Pit?.pit_stats_ptl ?? {},
);
this.quake = new Quake(stats.Quake ?? {}, achievements, legacy);
this.quests = new Quests(data.quests ?? {});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index e289028fb..753407b8f 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -490,19 +490,6 @@ importers:
stackblur-canvas:
specifier: ^2.7.0
version: 2.7.0
- devDependencies:
- '@commitlint/cli':
- specifier: ^19.4.1
- version: 19.8.1(@types/node@24.3.0)(typescript@5.9.2)
- '@commitlint/config-conventional':
- specifier: ^19.4.1
- version: 19.8.1
- commitizen:
- specifier: ^4.3.0
- version: 4.3.1(@types/node@24.3.0)(typescript@5.9.2)
- cz-conventional-changelog:
- specifier: ^3.3.0
- version: 3.3.0(@types/node@24.3.0)(typescript@5.9.2)
assets/public:
dependencies:
@@ -701,6 +688,8 @@ importers:
packages/skin-renderer: {}
+ packages/skin-renderer/pkg: {}
+
packages/util:
dependencies:
'@swc/helpers':
@@ -9933,8 +9922,8 @@ snapshots:
'@typescript-eslint/parser': 8.41.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.2)
eslint: 9.34.0(jiti@2.6.1)
eslint-import-resolver-node: 0.3.9
- eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.34.0(jiti@2.6.1))
- eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.41.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.34.0(jiti@2.6.1))
+ eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.41.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.2))(eslint@9.34.0(jiti@2.6.1)))(eslint@9.34.0(jiti@2.6.1))
+ eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.41.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.41.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.2))(eslint@9.34.0(jiti@2.6.1)))(eslint@9.34.0(jiti@2.6.1)))(eslint@9.34.0(jiti@2.6.1))
eslint-plugin-jsx-a11y: 6.10.2(eslint@9.34.0(jiti@2.6.1))
eslint-plugin-react: 7.37.5(eslint@9.34.0(jiti@2.6.1))
eslint-plugin-react-hooks: 5.2.0(eslint@9.34.0(jiti@2.6.1))
@@ -9953,7 +9942,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
- eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.34.0(jiti@2.6.1)):
+ eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.41.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.2))(eslint@9.34.0(jiti@2.6.1)))(eslint@9.34.0(jiti@2.6.1)):
dependencies:
'@nolyfill/is-core-module': 1.0.39
debug: 4.4.1
@@ -9964,22 +9953,22 @@ snapshots:
tinyglobby: 0.2.14
unrs-resolver: 1.11.1
optionalDependencies:
- eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.41.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.34.0(jiti@2.6.1))
+ eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.41.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.41.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.2))(eslint@9.34.0(jiti@2.6.1)))(eslint@9.34.0(jiti@2.6.1)))(eslint@9.34.0(jiti@2.6.1))
transitivePeerDependencies:
- supports-color
- eslint-module-utils@2.12.1(@typescript-eslint/parser@8.41.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.34.0(jiti@2.6.1)):
+ eslint-module-utils@2.12.1(@typescript-eslint/parser@8.41.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.41.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.2))(eslint@9.34.0(jiti@2.6.1)))(eslint@9.34.0(jiti@2.6.1)))(eslint@9.34.0(jiti@2.6.1)):
dependencies:
debug: 3.2.7
optionalDependencies:
'@typescript-eslint/parser': 8.41.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.2)
eslint: 9.34.0(jiti@2.6.1)
eslint-import-resolver-node: 0.3.9
- eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.34.0(jiti@2.6.1))
+ eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.41.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.2))(eslint@9.34.0(jiti@2.6.1)))(eslint@9.34.0(jiti@2.6.1))
transitivePeerDependencies:
- supports-color
- eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.41.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.34.0(jiti@2.6.1)):
+ eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.41.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.41.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.2))(eslint@9.34.0(jiti@2.6.1)))(eslint@9.34.0(jiti@2.6.1)))(eslint@9.34.0(jiti@2.6.1)):
dependencies:
'@rtsao/scc': 1.1.0
array-includes: 3.1.9
@@ -9990,7 +9979,7 @@ snapshots:
doctrine: 2.1.0
eslint: 9.34.0(jiti@2.6.1)
eslint-import-resolver-node: 0.3.9
- eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.41.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.34.0(jiti@2.6.1))
+ eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.41.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.41.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.2))(eslint@9.34.0(jiti@2.6.1)))(eslint@9.34.0(jiti@2.6.1)))(eslint@9.34.0(jiti@2.6.1))
hasown: 2.0.2
is-core-module: 2.16.1
is-glob: 4.0.3