diff --git a/COMMANDS.md b/COMMANDS.md
index 149e535..e750ab1 100644
--- a/COMMANDS.md
+++ b/COMMANDS.md
@@ -120,6 +120,24 @@ Link your Minecraft account so you no longer need to specify your player name! I
+---
+### /farm info
+Get info on a specific farm design including, angles, speeds, tutorials and more.
+
+**Usage:** `/farm info` \
+`[design]: `
+
+
+
+---
+### /farm search
+Search for farm designs for a specific crop.
+
+**Usage:** `/farm search` \
+`[crop]: `
+
+
+
## Info Commands
### /info
diff --git a/biome.json b/biome.json
index aac81d3..b0f5ee4 100644
--- a/biome.json
+++ b/biome.json
@@ -1,5 +1,5 @@
{
- "$schema": "https://biomejs.dev/schemas/2.0.0/schema.json",
+ "$schema": "https://biomejs.dev/schemas/2.0.2/schema.json",
"vcs": { "enabled": false, "clientKind": "git", "useIgnoreFile": false },
"files": {
"ignoreUnknown": false,
diff --git a/package.json b/package.json
index 01e9a19..166ab84 100644
--- a/package.json
+++ b/package.json
@@ -32,7 +32,7 @@
"cron": "^3.5.0",
"date-fns": "^4.1.0",
"discord.js": "^14.20.0",
- "farming-weight": "^0.9.4",
+ "farming-weight": "^0.9.5",
"openapi-fetch": "^0.14.0",
"redis": "^4.7.1",
"utf-8-validate": "^6.0.5",
diff --git a/src/autocomplete/crops.ts b/src/autocomplete/crops.ts
new file mode 100644
index 0000000..ce9b873
--- /dev/null
+++ b/src/autocomplete/crops.ts
@@ -0,0 +1,29 @@
+import { EliteSlashCommandOption, SlashCommandOptionType } from 'classes/commands/options.js';
+import { CROP_ARRAY } from 'classes/Util.js';
+import { AutocompleteInteraction } from 'discord.js';
+import { getCropDisplayName } from 'farming-weight';
+
+export const eliteCropOption: EliteSlashCommandOption = {
+ name: 'crop',
+ description: 'The crop to get results for.',
+ type: SlashCommandOptionType.String,
+ required: true,
+ autocomplete,
+};
+
+export async function autocomplete(interaction: AutocompleteInteraction) {
+ if (interaction.responded) return;
+
+ const option = interaction.options.getFocused(true);
+ const options = CROP_ARRAY.map((v, i) => ({
+ name: getCropDisplayName(v),
+ value: i.toString(),
+ }));
+ if (!options) return;
+
+ const input = option.value.toLowerCase();
+
+ const filtered = options.filter((opt) => opt.name.toLowerCase().startsWith(input));
+
+ await interaction.respond(filtered);
+}
diff --git a/src/classes/components.ts b/src/classes/components.ts
index d8e64df..424239d 100644
--- a/src/classes/components.ts
+++ b/src/classes/components.ts
@@ -6,6 +6,7 @@ import {
ButtonStyle,
ContainerBuilder,
Interaction,
+ MediaGalleryItemBuilder,
SectionBuilder,
SeparatorSpacingSize,
TextDisplayBuilder,
@@ -66,6 +67,30 @@ export class EliteContainer extends ContainerBuilder {
return this;
}
+ addImage(url: string, altText?: string) {
+ const item = new MediaGalleryItemBuilder().setURL(url);
+
+ if (altText) {
+ item.setDescription(altText);
+ }
+
+ this.addMediaGalleryComponents((media) => media.addItems(item));
+ return this;
+ }
+
+ addImages(images: { url: string; altText?: string }[]) {
+ const items = images.map((image) => {
+ const item = new MediaGalleryItemBuilder().setURL(image.url);
+ if (image.altText) {
+ item.setDescription(image.altText);
+ }
+ return item;
+ });
+
+ this.addMediaGalleryComponents((media) => media.addItems(...items));
+ return this;
+ }
+
addFooter(separator = true, backButton = '') {
if (separator) {
this.addSeparator();
@@ -109,6 +134,19 @@ export class EliteContainer extends ContainerBuilder {
return this;
}
+ addImageSection(url: string, ...textComponents: string[]) {
+ const section = new SectionBuilder().setId(10000 + Math.floor(Math.random() * 90000));
+
+ if (textComponents.length > 0) {
+ section.addTextDisplayComponents(...textComponents.map((text) => new TextDisplayBuilder().setContent(text)));
+ }
+
+ section.setThumbnailAccessory((a) => a.setURL(url));
+
+ this.addSectionComponents(section);
+ return this;
+ }
+
addCollapsible({ collapsed, expanded, opened, radio, header }: CollapsibleSectionData) {
const sectionId = 10000 + Math.floor(Math.random() * 90000);
const data = {
diff --git a/src/commands/farm/command.ts b/src/commands/farm/command.ts
new file mode 100644
index 0000000..3f24f19
--- /dev/null
+++ b/src/commands/farm/command.ts
@@ -0,0 +1,11 @@
+import { CommandAccess, CommandGroup, CommandType } from '../../classes/commands/index.js';
+
+const command = new CommandGroup({
+ name: 'farm',
+ description: 'Commands to view farm designs',
+ execute: () => undefined,
+ access: CommandAccess.Everywhere,
+ type: CommandType.Group,
+});
+
+export default command;
diff --git a/src/commands/farm/info.ts b/src/commands/farm/info.ts
new file mode 100644
index 0000000..7faf883
--- /dev/null
+++ b/src/commands/farm/info.ts
@@ -0,0 +1,379 @@
+import { createCanvas } from '@napi-rs/canvas';
+import {
+ ActionRowBuilder,
+ AttachmentBuilder,
+ AutocompleteInteraction,
+ ButtonBuilder,
+ ButtonStyle,
+ ChatInputCommandInteraction,
+ MessageActionRowComponentBuilder,
+ MessageFlags,
+ StringSelectMenuBuilder,
+ StringSelectMenuOptionBuilder,
+} from 'discord.js';
+import { Direction, FARM_DESIGNS, FarmDesignInfo, MinecraftVersion, ResourceType } from 'farming-weight';
+import { UserSettings } from '../../api/elite.js';
+import { CommandAccess, CommandType, EliteCommand, SlashCommandOptionType } from '../../classes/commands/index.js';
+import { EliteContainer } from '../../classes/components.js';
+import { ErrorEmbed, NotYoursReply } from '../../classes/embeds.js';
+
+const command = new EliteCommand({
+ name: 'info',
+ description: 'Get info about a farm design!',
+ access: CommandAccess.Everywhere,
+ type: CommandType.Slash,
+ subCommand: true,
+ options: {
+ design: {
+ name: 'design',
+ description: 'Select a farm design!',
+ type: SlashCommandOptionType.String,
+ required: true,
+ autocomplete: autocomplete,
+ },
+ },
+ execute: execute,
+});
+
+async function autocomplete(interaction: AutocompleteInteraction) {
+ if (interaction.responded) return;
+
+ const option = interaction.options.getFocused(true);
+ const options = Object.entries(FARM_DESIGNS).map(([key, data]) => ({
+ name: data.name,
+ value: key,
+ }));
+ if (!options) return;
+
+ const input = option.value.toLowerCase();
+
+ const filtered = options.filter((opt) => opt.name.toLowerCase().startsWith(input));
+
+ await interaction.respond(filtered);
+}
+
+export default command;
+
+interface FarmSettings {
+ active: boolean;
+ direction: Direction;
+ version: MinecraftVersion;
+}
+
+const noDesign = ErrorEmbed('Design Not Found!').setDescription(
+ "The design you're looking for doesn't exist! If you believe this to be a mistake or want a design added, make a suggestion in the discord.",
+);
+
+export async function execute(
+ interaction: ChatInputCommandInteraction,
+ settings?: UserSettings,
+ designOverride?: string,
+ skipDefer?: boolean,
+) {
+ if (!skipDefer) await interaction.deferReply();
+
+ const farmSettings: FarmSettings = {
+ active: true,
+ direction: 'South',
+ version: '1.8.9',
+ };
+
+ const designId = designOverride ?? interaction.options.getString('design', false) ?? '';
+ const design = FARM_DESIGNS[designId];
+
+ if (!design) {
+ await interaction.editReply({
+ embeds: [noDesign],
+ allowedMentions: { repliedUser: false },
+ });
+ return;
+ }
+
+ const components = await getFarmInfoComponents(design, farmSettings, settings);
+
+ const yaw = await fixDesignAngle(design.angle.yaw, farmSettings.direction);
+ const image = pitchAndYawImage({ pitch: design.angle.pitch, yaw }, farmSettings.direction);
+
+ const reply = await interaction.editReply({
+ components,
+ allowedMentions: { repliedUser: false },
+ flags: [MessageFlags.IsComponentsV2],
+ files: [image],
+ });
+
+ const collector = reply.createMessageComponentCollector({
+ time: 120_000,
+ });
+
+ collector.on('collect', async (inter) => {
+ if (inter.user.id !== interaction.user.id) {
+ return NotYoursReply(inter);
+ }
+
+ collector.resetTimer();
+
+ if (inter.isStringSelectMenu()) {
+ if (inter.customId === 'design-direction') {
+ farmSettings.direction = inter.values[0] as Direction;
+ } else if (inter.customId === 'design-version') {
+ farmSettings.version = inter.values[0] as MinecraftVersion;
+ }
+ } else if (inter.isButton()) {
+ if (inter.customId === 'design-settings') {
+ farmSettings.active = !farmSettings.active;
+ }
+ }
+
+ const components = await getFarmInfoComponents(design, farmSettings, settings);
+
+ await inter.update({
+ components,
+ files: [
+ pitchAndYawImage(
+ { pitch: design.angle.pitch, yaw: await fixDesignAngle(design.angle.yaw, farmSettings.direction) },
+ farmSettings.direction,
+ ),
+ ],
+ });
+
+ return;
+ });
+}
+
+async function getFarmInfoComponents(
+ design: FarmDesignInfo,
+ farmSettings: FarmSettings,
+ settings?: UserSettings,
+): Promise<(EliteContainer | ActionRowBuilder)[]> {
+ const components: (EliteContainer | ActionRowBuilder)[] = [];
+
+ const resources = design.resources
+ ?.filter((r) => r.type !== ResourceType.Schematic)
+ .map((r) => {
+ let source: string;
+ if (r.type === ResourceType.Garden) {
+ source = `\`/visit ${r.source}\``;
+ } else {
+ source = r.source;
+ }
+ return `**${ResourceType[r.type]}**: ${source}`;
+ })
+ .join('\n');
+
+ const replacedBy = design.replacedBy
+ ?.map((d) => {
+ return FARM_DESIGNS[d].name;
+ })
+ .join('\n');
+
+ const notes = design.notes
+ ?.map((n) => {
+ return '- ' + n;
+ })
+ .join('\n');
+
+ const speed = design.speed.soulSand ? design.speed[farmSettings.version] : design.speed['1.8.9'];
+
+ const yaw = await fixDesignAngle(design.angle.yaw, farmSettings.direction);
+
+ const laneTime = Math.round((480 / (20 / design.laneDepth)) * 10) / 10;
+ const laneTimeMinutes = Math.floor(laneTime / 60);
+ const laneTimeSeconds = laneTime % 60;
+
+ const FarmDesignInfoComponent = new EliteContainer(settings)
+ .addTitle(`# ${design.name}`)
+ .addDescription(
+ `**Yaw**: ${yaw}, **Pitch**: ${design.angle.pitch}\n**Speed**: ${speed ?? '1.21 speed has not yet been determined'}${design.speed.depthStrider ? `\n**Depth Strider level**: ${design.speed.depthStrider}` : ''}`,
+ )
+ .addSeparator()
+ .addText(`**Max BPS**: \`${design.bps}\``)
+ .addImage(
+ 'attachment://yaw-pitch.webp',
+ `Farm with [Yaw: ${yaw}, Pitch: ${design.angle.pitch}] while facing ${farmSettings.direction}`,
+ )
+ .addSeparator()
+ .addDescription(
+ `**bps**: ${design.bps}\n**Lane Time**: ${laneTimeMinutes !== 0 ? `${laneTimeMinutes}m ${laneTimeSeconds}s` : laneTimeSeconds + 's'}`,
+ );
+
+ if (replacedBy) {
+ FarmDesignInfoComponent.addSeparator().addDescription(`**Design is outdated, use one of these**:\n${replacedBy}`);
+ }
+
+ if (resources) {
+ const youtube = design.resources?.find((r) => r.type === ResourceType.Video)?.source;
+ if (youtube) {
+ const id = youtube.split('/').pop();
+ FarmDesignInfoComponent.addImageSection(`https://img.youtube.com/vi/${id}/mqdefault.jpg`, resources);
+ } else {
+ FarmDesignInfoComponent.addSeparator().addDescription(resources);
+ }
+ }
+
+ if (notes) {
+ FarmDesignInfoComponent.addSeparator().addDescription(notes);
+ }
+
+ if (design.authors) {
+ const authors = design.authors
+ .map((author) => {
+ if (author.url) {
+ return `[${author.name}](${author.url})`;
+ } else {
+ return author.name;
+ }
+ })
+ .join(', ');
+
+ FarmDesignInfoComponent.addSeparator().addDescription(`-# **Authors**: ${authors}`);
+ }
+
+ FarmDesignInfoComponent.addFooter();
+
+ components.push(FarmDesignInfoComponent);
+
+ if (farmSettings.active) {
+ const settingsComponent = new EliteContainer(settings)
+ .addTitle('# Settings')
+ .addActionRowComponents(
+ new ActionRowBuilder().addComponents(
+ new StringSelectMenuBuilder()
+ .setCustomId('design-direction')
+ .setPlaceholder('Select the direction your farm faces')
+ .addOptions(
+ new StringSelectMenuOptionBuilder()
+ .setLabel('North')
+ .setValue('North')
+ .setDefault(farmSettings.direction == 'North'),
+ new StringSelectMenuOptionBuilder()
+ .setLabel('South')
+ .setValue('South')
+ .setDefault(farmSettings.direction == 'South'),
+ new StringSelectMenuOptionBuilder()
+ .setLabel('East')
+ .setValue('East')
+ .setDefault(farmSettings.direction == 'East'),
+ new StringSelectMenuOptionBuilder()
+ .setLabel('West')
+ .setValue('West')
+ .setDefault(farmSettings.direction == 'West'),
+ ),
+ ),
+ )
+ .addActionRowComponents(
+ new ActionRowBuilder().addComponents(
+ new StringSelectMenuBuilder()
+ .setCustomId('design-version')
+ .setPlaceholder('Select Minecaft version')
+ .addOptions(
+ new StringSelectMenuOptionBuilder()
+ .setLabel('1.8.9')
+ .setValue('1.8.9')
+ .setDefault(farmSettings.version == '1.8.9'),
+ new StringSelectMenuOptionBuilder()
+ .setLabel('1.21')
+ .setValue('1.21')
+ .setDefault(farmSettings.version == '1.21'),
+ ),
+ ),
+ );
+
+ components.push(settingsComponent);
+ }
+
+ const settingsButton = new ActionRowBuilder().addComponents(
+ new ButtonBuilder()
+ .setStyle(ButtonStyle.Secondary)
+ .setLabel(`${farmSettings.active ? 'Close' : 'Open'} Settings`)
+ .setCustomId('design-settings'),
+ );
+
+ components.push(settingsButton);
+
+ return components;
+}
+
+async function fixDesignAngle(designYaw: number, direction: Direction): Promise {
+ const yaw = designYaw + directionYawOffset('South', direction);
+
+ return normalizeAngle(yaw);
+}
+
+function directionYawOffset(from: Direction, to: Direction): number {
+ const yawMap: Record = {
+ North: 180,
+ East: -90,
+ South: 0,
+ West: 90,
+ };
+
+ return normalizeAngle(yawMap[to] - yawMap[from]);
+}
+
+function normalizeAngle(angle: number) {
+ return ((angle + 180) % 360) - 180;
+}
+
+function pitchAndYawImage(angle: { pitch: number; yaw: number }, direction: Direction = 'South') {
+ const bgWidth = 1200;
+ const bgHeight = 100;
+
+ const canvas = createCanvas(bgWidth, bgHeight);
+ const ctx = canvas.getContext('2d');
+
+ ctx.fillStyle = '#84baff';
+ ctx.rect(0, 0, bgWidth, bgHeight);
+ ctx.fill();
+
+ ctx.fillStyle = '#ffffff';
+ ctx.font = '65px "Open Sans"';
+ ctx.fillText(`F3 Angle: (${angle.yaw.toFixed(1)} / ${angle.pitch.toFixed(1)})`, 10, 72);
+
+ // Draw a basic compass shape in the right middle
+ ctx.fillStyle = '#bfbfbf';
+ ctx.beginPath();
+ ctx.arc(bgWidth - 60, bgHeight / 2, bgHeight / 2 - 10, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.lineWidth = 4;
+ ctx.strokeStyle = '#ffffff';
+ ctx.stroke();
+
+ // 2. A rounded red line from the center of the circle to the top
+ ctx.strokeStyle = '#a20508';
+ ctx.beginPath();
+ switch (direction) {
+ case 'North':
+ ctx.moveTo(bgWidth - 60, bgHeight / 2);
+ ctx.lineTo(bgWidth - 60, bgHeight / 2 - 25);
+ break;
+ case 'East':
+ ctx.moveTo(bgWidth - 60, bgHeight / 2);
+ ctx.lineTo(bgWidth - 60 + 25, bgHeight / 2);
+ break;
+ case 'South':
+ ctx.moveTo(bgWidth - 60, bgHeight / 2);
+ ctx.lineTo(bgWidth - 60, bgHeight / 2 + 25);
+ break;
+ case 'West':
+ ctx.moveTo(bgWidth - 60, bgHeight / 2);
+ ctx.lineTo(bgWidth - 60 - 25, bgHeight / 2);
+ break;
+ }
+ ctx.lineWidth = 4;
+ ctx.lineCap = 'round';
+ ctx.stroke();
+
+ ctx.fillStyle = '#FFFFFF';
+ ctx.font = '14px "Open Sans"';
+
+ ctx.fillText('N', bgWidth - 64, bgHeight / 2 - 25);
+ ctx.fillText('E', bgWidth - 64 + 30, bgHeight / 2 + 4);
+ ctx.fillText('S', bgWidth - 64, bgHeight / 2 + 35);
+ ctx.fillText('W', bgWidth - 64 - 30, bgHeight / 2 + 4);
+
+ const attachment = new AttachmentBuilder(canvas.toBuffer('image/webp'), {
+ name: `yaw-pitch.webp`,
+ });
+
+ return attachment;
+}
diff --git a/src/commands/farm/search.ts b/src/commands/farm/search.ts
new file mode 100644
index 0000000..4cfa996
--- /dev/null
+++ b/src/commands/farm/search.ts
@@ -0,0 +1,75 @@
+import { ButtonBuilder, ButtonStyle, ChatInputCommandInteraction, MessageFlags, SectionBuilder } from 'discord.js';
+import { FARM_DESIGNS, getCropDisplayName } from 'farming-weight';
+import { UserSettings } from '../../api/elite.js';
+import { eliteCropOption } from '../../autocomplete/crops.js';
+import { CommandAccess, CommandType, EliteCommand } from '../../classes/commands/index.js';
+import { EliteContainer } from '../../classes/components.js';
+import { NotYoursReply } from '../../classes/embeds.js';
+import { CROP_ARRAY } from '../../classes/Util.js';
+import { execute as farmInfoCommand } from './info.js';
+
+const command = new EliteCommand({
+ name: 'search',
+ description: 'Search for a farm design!',
+ access: CommandAccess.Everywhere,
+ type: CommandType.Slash,
+ subCommand: true,
+ options: {
+ crop: eliteCropOption,
+ },
+ execute: execute,
+});
+
+export default command;
+
+async function execute(interaction: ChatInputCommandInteraction, settings?: UserSettings) {
+ await interaction.deferReply();
+
+ const crop = CROP_ARRAY[parseInt(interaction.options.getString('crop', false)!)];
+
+ const farms = Object.fromEntries(
+ Object.entries(FARM_DESIGNS)
+ .filter(([, farm]) => farm.crops.includes(crop))
+ .sort(([, a], [, b]) => b.bps - a.bps),
+ );
+
+ const component = new EliteContainer(settings).addTitle(`# Farm designs for ${getCropDisplayName(crop)}`);
+
+ Object.entries(farms).forEach(([id, data], i) => {
+ if (i !== 0) {
+ component.addSeparator();
+ }
+
+ component
+ .addText(`### ${data.name}`)
+ .addSectionComponents(
+ new SectionBuilder()
+ .addTextDisplayComponents((t) => t.setContent(`Max BPS: \`${data.bps}\``))
+ .setButtonAccessory(new ButtonBuilder().setStyle(ButtonStyle.Secondary).setLabel('View').setCustomId(id)),
+ );
+ });
+
+ const reply = await interaction.editReply({
+ components: [component],
+ allowedMentions: { repliedUser: false },
+ flags: [MessageFlags.IsComponentsV2],
+ });
+
+ const collector = reply.createMessageComponentCollector({
+ time: 120_000,
+ });
+
+ collector.on('collect', async (inter) => {
+ if (inter.user.id !== interaction.user.id) {
+ return NotYoursReply(inter);
+ }
+
+ collector.resetTimer();
+
+ await farmInfoCommand(interaction, settings, inter.customId, true);
+
+ collector.stop();
+
+ return;
+ });
+}
diff --git a/src/events/interactionCreate.ts b/src/events/interactionCreate.ts
index 9df23fb..1ec5b63 100644
--- a/src/events/interactionCreate.ts
+++ b/src/events/interactionCreate.ts
@@ -98,9 +98,13 @@ async function OnAutocompleteInteraction(interaction: AutocompleteInteraction) {
if (interaction.responded) return;
const command = GetCommand(interaction.commandName);
- if (!command || command instanceof CommandGroup) return;
+ if (!command) return;
try {
+ if (command instanceof CommandGroup) {
+ await command.autocomplete(interaction);
+ return;
+ }
const auto = command.getAutocomplete(interaction);
await auto?.(interaction);
} catch (error) {