diff --git a/.gitignore b/.gitignore index 077cc42..89b7f3d 100644 --- a/.gitignore +++ b/.gitignore @@ -29,4 +29,5 @@ bin/ run/ ### Private ### -TODO \ No newline at end of file +TODO +src/main/java/net/ \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index bf7009e..7f90f12 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -36,7 +36,7 @@ dependencies { compileOnly("me.clip:placeholderapi:2.11.5") compileOnly("net.kyori:adventure-api:4.17.0") compileOnly("xyz.xenondevs.invui:invui-core:$invuiVersion") - compileOnly("net.wesjd:anvilgui:1.10.4-SNAPSHOT") + //compileOnly("net.wesjd:anvilgui:1.10.4-SNAPSHOT") //implementation("xyz.xenondevs.invui:inventory-access-r22:$invuiVersion:remapped-mojang") diff --git a/src/main/java/com/comphenix/protocol/utility/LICENSE b/src/main/java/com/comphenix/protocol/utility/LICENSE new file mode 100644 index 0000000..2673e3f --- /dev/null +++ b/src/main/java/com/comphenix/protocol/utility/LICENSE @@ -0,0 +1,13 @@ +Copyright (C) 2012 Kristian S. Stangeland + +This program is free software; you can redistribute it and/or modify it under the terms of the +GNU General Public License as published by the Free Software Foundation; either version 2 of +the License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +See the GNU General Public License for more details. + +You should have received a copy of the GNU General Public License along with this program; +if not, write to the Free Software Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA +02111-1307 USA diff --git a/src/main/java/com/comphenix/protocol/utility/MinecraftVersion.java b/src/main/java/com/comphenix/protocol/utility/MinecraftVersion.java new file mode 100644 index 0000000..0f3cd7b --- /dev/null +++ b/src/main/java/com/comphenix/protocol/utility/MinecraftVersion.java @@ -0,0 +1,468 @@ +/* + * ProtocolLib - Bukkit server library that allows access to the Minecraft protocol. + * Copyright (C) 2012 Kristian S. Stangeland + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 2 of + * the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with this program; + * if not, write to the Free Software Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA + * 02111-1307 USA + */ + +package com.comphenix.protocol.utility; + +import com.comphenix.protocol.ProtocolLibrary; +import com.google.common.collect.ComparisonChain; +import com.google.common.collect.Ordering; +import org.bukkit.Bukkit; +import org.bukkit.Server; + +import java.io.Serializable; +import java.text.SimpleDateFormat; +import java.util.Locale; +import java.util.Objects; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Determine the current Minecraft version. + * + * @author Kristian + */ +public final class MinecraftVersion implements Comparable, Serializable { + /** + * Version 1.21.4 - the garden awakens drop + */ + public static final MinecraftVersion v1_21_4 = new MinecraftVersion("1.21.4"); + + /** + * Version 1.21.2 - the bundles of bravery drop + */ + public static final MinecraftVersion v1_21_2 = new MinecraftVersion("1.21.2"); + + /** + * Version 1.21.0 - the tricky trials update + */ + public static final MinecraftVersion v1_21_0 = new MinecraftVersion("1.21.0"); + + /** + * Version 1.20.5 - the cookie and transfer packet update + */ + public static final MinecraftVersion v1_20_5 = new MinecraftVersion("1.20.5"); + + /** + * Version 1.20.4 - the decorated pot update + */ + public static final MinecraftVersion v1_20_4 = new MinecraftVersion("1.20.4"); + + /** + * Version 1.20.2 - the update that added the configuration protocol phase. + */ + public static final MinecraftVersion CONFIG_PHASE_PROTOCOL_UPDATE = new MinecraftVersion("1.20.2"); + /** + * Version 1.20 - the trails and tails update + */ + public static final MinecraftVersion TRAILS_AND_TAILS = new MinecraftVersion("1.20"); + + /** + * Version 1.19.4 - the rest of the feature preview + */ + public static final MinecraftVersion FEATURE_PREVIEW_2 = new MinecraftVersion("1.19.4"); + + /** + * Version 1.19.3 - introducing feature preview + */ + public static final MinecraftVersion FEATURE_PREVIEW_UPDATE = new MinecraftVersion("1.19.3"); + /** + * Version 1.19 - the wild update + */ + public static final MinecraftVersion WILD_UPDATE = new MinecraftVersion("1.19"); + /** + * Version 1.18 - caves and cliffs part 2 + */ + public static final MinecraftVersion CAVES_CLIFFS_2 = new MinecraftVersion("1.18"); + /** + * Version 1.17 - caves and cliffs part 1 + */ + public static final MinecraftVersion CAVES_CLIFFS_1 = new MinecraftVersion("1.17"); + /** + * Version 1.16.4 + */ + public static final MinecraftVersion NETHER_UPDATE_4 = new MinecraftVersion("1.16.4"); + /** + * Version 1.16.2 - breaking change to the nether update + */ + public static final MinecraftVersion NETHER_UPDATE_2 = new MinecraftVersion("1.16.2"); + /** + * Version 1.16.0 - the nether update + */ + public static final MinecraftVersion NETHER_UPDATE = new MinecraftVersion("1.16"); + /** + * Version 1.15 - the bee update + */ + public static final MinecraftVersion BEE_UPDATE = new MinecraftVersion("1.15"); + /** + * Version 1.14 - village and pillage update. + */ + public static final MinecraftVersion VILLAGE_UPDATE = new MinecraftVersion("1.14"); + /** + * Version 1.13 - update aquatic. + */ + public static final MinecraftVersion AQUATIC_UPDATE = new MinecraftVersion("1.13"); + /** + * Version 1.12 - the world of color update. + */ + public static final MinecraftVersion COLOR_UPDATE = new MinecraftVersion("1.12"); + /** + * Version 1.11 - the exploration update. + */ + public static final MinecraftVersion EXPLORATION_UPDATE = new MinecraftVersion("1.11"); + /** + * Version 1.10 - the frostburn update. + */ + public static final MinecraftVersion FROSTBURN_UPDATE = new MinecraftVersion("1.10"); + /** + * Version 1.9 - the combat update. + */ + public static final MinecraftVersion COMBAT_UPDATE = new MinecraftVersion("1.9"); + /** + * Version 1.8 - the "bountiful" update. + */ + public static final MinecraftVersion BOUNTIFUL_UPDATE = new MinecraftVersion("1.8"); + /** + * Version 1.7.8 - the update that changed the skin format (and distribution - R.I.P. player disguise) + */ + public static final MinecraftVersion SKIN_UPDATE = new MinecraftVersion("1.7.8"); + /** + * Version 1.7.2 - the update that changed the world. + */ + public static final MinecraftVersion WORLD_UPDATE = new MinecraftVersion("1.7.2"); + /** + * Version 1.6.1 - the horse update. + */ + public static final MinecraftVersion HORSE_UPDATE = new MinecraftVersion("1.6.1"); + /** + * Version 1.5.0 - the redstone update. + */ + public static final MinecraftVersion REDSTONE_UPDATE = new MinecraftVersion("1.5.0"); + /** + * Version 1.4.2 - the scary update (Wither Boss). + */ + public static final MinecraftVersion SCARY_UPDATE = new MinecraftVersion("1.4.2"); + + /** + * The latest release version of minecraft. + */ + public static final MinecraftVersion LATEST = v1_21_4; + + // used when serializing + private static final long serialVersionUID = -8695133558996459770L; + + /** + * Regular expression used to parse version strings. + */ + private static final Pattern VERSION_PATTERN = Pattern.compile(".*\\(.*MC.\\s*([a-zA-z0-9\\-.]+).*"); + + /** + * The current version of minecraft, lazy initialized by MinecraftVersion.currentVersion() + */ + private static MinecraftVersion currentVersion; + + private final int major; + private final int minor; + private final int build; + // The development stage + private final String development; + + // Snapshot? + private final SnapshotVersion snapshot; + private volatile Boolean atCurrentOrAbove; + + /** + * Determine the current Minecraft version. + * + * @param server - the Bukkit server that will be used to examine the MC version. + */ + public MinecraftVersion(Server server) { + this(extractVersion(server.getVersion())); + } + + /** + * Construct a version object from the format major.minor.build, or the snapshot format. + * + * @param versionOnly - the version in text form. + */ + public MinecraftVersion(String versionOnly) { + this(versionOnly, true); + } + + /** + * Construct a version format from the standard release version or the snapshot verison. + * + * @param versionOnly - the version. + * @param parseSnapshot - TRUE to parse the snapshot, FALSE otherwise. + */ + private MinecraftVersion(String versionOnly, boolean parseSnapshot) { + String[] section = versionOnly.split("-"); + SnapshotVersion snapshot = null; + int[] numbers = new int[3]; + + try { + numbers = this.parseVersion(section[0]); + } catch (NumberFormatException cause) { + // Skip snapshot parsing + if (!parseSnapshot) { + throw cause; + } + + try { + // Determine if the snapshot is newer than the current release version + snapshot = new SnapshotVersion(section[0]); + SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd", Locale.US); + + MinecraftVersion latest = new MinecraftVersion(ProtocolLibrary.MAXIMUM_MINECRAFT_VERSION, false); + boolean newer = snapshot.getSnapshotDate().compareTo( + format.parse(ProtocolLibrary.MINECRAFT_LAST_RELEASE_DATE)) > 0; + + numbers[0] = latest.getMajor(); + numbers[1] = latest.getMinor() + (newer ? 1 : -1); + } catch (Exception e) { + throw new IllegalStateException("Cannot parse " + section[0], e); + } + } + + this.major = numbers[0]; + this.minor = numbers[1]; + this.build = numbers[2]; + this.development = section.length > 1 ? section[1] : (snapshot != null ? "snapshot" : null); + this.snapshot = snapshot; + } + + /** + * Construct a version object directly. + * + * @param major - major version number. + * @param minor - minor version number. + * @param build - build version number. + */ + public MinecraftVersion(int major, int minor, int build) { + this(major, minor, build, null); + } + + /** + * Construct a version object directly. + * + * @param major - major version number. + * @param minor - minor version number. + * @param build - build version number. + * @param development - development stage. + */ + public MinecraftVersion(int major, int minor, int build, String development) { + this.major = major; + this.minor = minor; + this.build = build; + this.development = development; + this.snapshot = null; + } + + /** + * Extract the Minecraft version from CraftBukkit itself. + * + * @param text - the server version in text form. + * @return The underlying MC version. + * @throws IllegalStateException If we could not parse the version string. + */ + public static String extractVersion(String text) { + Matcher version = VERSION_PATTERN.matcher(text); + + if (version.matches() && version.group(1) != null) { + return version.group(1); + } else { + throw new IllegalStateException("Cannot parse version String '" + text + "'"); + } + } + + /** + * Parse the given server version into a Minecraft version. + * + * @param serverVersion - the server version. + * @return The resulting Minecraft version. + */ + public static MinecraftVersion fromServerVersion(String serverVersion) { + return new MinecraftVersion(extractVersion(serverVersion)); + } + + public static MinecraftVersion getCurrentVersion() { + if (currentVersion == null) { + currentVersion = fromServerVersion(Bukkit.getVersion()); + } + + return currentVersion; + } + + public static void setCurrentVersion(MinecraftVersion version) { + currentVersion = version; + } + + private static boolean atOrAbove(MinecraftVersion version) { + return getCurrentVersion().isAtLeast(version); + } + + private int[] parseVersion(String version) { + String[] elements = version.split("\\."); + int[] numbers = new int[3]; + + // Make sure it's even a valid version + if (elements.length < 1) { + throw new IllegalStateException("Corrupt MC version: " + version); + } + + // The String 1 or 1.2 is interpreted as 1.0.0 and 1.2.0 respectively. + for (int i = 0; i < Math.min(numbers.length, elements.length); i++) { + numbers[i] = Integer.parseInt(elements[i].trim()); + } + return numbers; + } + + /** + * Major version number + * + * @return Current major version number. + */ + public int getMajor() { + return this.major; + } + + /** + * Minor version number + * + * @return Current minor version number. + */ + public int getMinor() { + return this.minor; + } + + /** + * Build version number + * + * @return Current build version number. + */ + public int getBuild() { + return this.build; + } + + /** + * Retrieve the development stage. + * + * @return Development stage, or NULL if this is a release. + */ + public String getDevelopmentStage() { + return this.development; + } + + /** + * Retrieve the snapshot version, or NULL if this is a release. + * + * @return The snapshot version. + */ + public SnapshotVersion getSnapshot() { + return this.snapshot; + } + + /** + * Determine if this version is a snapshot. + * + * @return The snapshot version. + */ + public boolean isSnapshot() { + return this.snapshot != null; + } + + /** + * Checks if this version is at or above the current version the server is running. + * + * @return true if this version is equal or newer than the server version, false otherwise. + */ + public boolean atOrAbove() { + if (this.atCurrentOrAbove == null) { + this.atCurrentOrAbove = atOrAbove(this); + } + + return this.atCurrentOrAbove; + } + + /** + * Retrieve the version String (major.minor.build) only. + * + * @return A normal version string. + */ + public String getVersion() { + if (this.getDevelopmentStage() == null) { + return String.format("%s.%s.%s", this.getMajor(), this.getMinor(), this.getBuild()); + } else { + return String.format("%s.%s.%s-%s%s", this.getMajor(), this.getMinor(), this.getBuild(), + this.getDevelopmentStage(), this.isSnapshot() ? this.snapshot : ""); + } + } + + @Override + public int compareTo(MinecraftVersion o) { + if (o == null) { + return 1; + } + + return ComparisonChain.start() + .compare(this.getMajor(), o.getMajor()) + .compare(this.getMinor(), o.getMinor()) + .compare(this.getBuild(), o.getBuild()) + .compare(this.getDevelopmentStage(), o.getDevelopmentStage(), Ordering.natural().nullsLast()) + .compare(this.getSnapshot(), o.getSnapshot(), Ordering.natural().nullsFirst()) + .result(); + } + + public boolean isAtLeast(MinecraftVersion other) { + if (other == null) { + return false; + } + + return this.compareTo(other) >= 0; + } + + @Override + public boolean equals(Object obj) { + if (obj == null) { + return false; + } + if (obj == this) { + return true; + } + + if (obj instanceof MinecraftVersion) { + MinecraftVersion other = (MinecraftVersion) obj; + + return this.getMajor() == other.getMajor() && + this.getMinor() == other.getMinor() && + this.getBuild() == other.getBuild() && + Objects.equals(this.getDevelopmentStage(), other.getDevelopmentStage()); + } + + return false; + } + + @Override + public int hashCode() { + return Objects.hash(this.getMajor(), this.getMinor(), this.getBuild()); + } + + @Override + public String toString() { + // Convert to a String that we can parse back again + return String.format("(MC: %s)", this.getVersion()); + } +} diff --git a/src/main/java/com/comphenix/protocol/utility/SnapshotVersion.java b/src/main/java/com/comphenix/protocol/utility/SnapshotVersion.java new file mode 100644 index 0000000..dc4cdfd --- /dev/null +++ b/src/main/java/com/comphenix/protocol/utility/SnapshotVersion.java @@ -0,0 +1,150 @@ +/* + * ProtocolLib - Bukkit server library that allows access to the Minecraft protocol. + * Copyright (C) 2012 Kristian S. Stangeland + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 2 of + * the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with this program; + * if not, write to the Free Software Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA + * 02111-1307 USA + */ + +package com.comphenix.protocol.utility; + +import com.google.common.collect.ComparisonChain; + +import java.io.Serial; +import java.io.Serializable; +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.Calendar; +import java.util.Date; +import java.util.Locale; +import java.util.Objects; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Used to parse a snapshot version. + * + * @author Kristian + */ +public class SnapshotVersion implements Comparable, Serializable { + + @Serial + private static final long serialVersionUID = 2778655372579322310L; + private static final Pattern SNAPSHOT_PATTERN = Pattern.compile("(\\d{2}w\\d{2})([a-z])"); + + private final Date snapshotDate; + private final int snapshotWeekVersion; + + private transient String rawString; + + public SnapshotVersion(String version) { + Matcher matcher = SNAPSHOT_PATTERN.matcher(version.trim()); + + if (matcher.matches()) { + try { + this.snapshotDate = getDateFormat().parse(matcher.group(1)); + this.snapshotWeekVersion = matcher.group(2).charAt(0) - 'a'; + this.rawString = version; + } catch (ParseException e) { + throw new IllegalArgumentException("Date implied by snapshot version is invalid.", e); + } + } else { + throw new IllegalArgumentException("Cannot parse " + version + " as a snapshot version."); + } + } + + /** + * Retrieve the snapshot date parser. + *

+ * We have to create a new instance of SimpleDateFormat every time as it is not thread safe. + * + * @return The date formatter. + */ + private static SimpleDateFormat getDateFormat() { + SimpleDateFormat format = new SimpleDateFormat("yy'w'ww", Locale.US); + format.setLenient(false); + return format; + } + + /** + * Retrieve the snapshot version within a week, starting at zero. + * + * @return The weekly version + */ + public int getSnapshotWeekVersion() { + return this.snapshotWeekVersion; + } + + /** + * Retrieve the week this snapshot was released. + * + * @return The week. + */ + public Date getSnapshotDate() { + return this.snapshotDate; + } + + /** + * Retrieve the raw snapshot string (yy'w'ww[a-z]). + * + * @return The snapshot string. + */ + public String getSnapshotString() { + if (this.rawString == null) { + // It's essential that we use the same locale + Calendar current = Calendar.getInstance(Locale.US); + current.setTime(this.snapshotDate); + this.rawString = String.format("%02dw%02d%s", + current.get(Calendar.YEAR) % 100, + current.get(Calendar.WEEK_OF_YEAR), + (char) ('a' + this.snapshotWeekVersion)); + } + return this.rawString; + } + + @Override + public int compareTo(SnapshotVersion o) { + if (o == null) { + return 1; + } + + return ComparisonChain.start() + .compare(this.snapshotDate, o.getSnapshotDate()) + .compare(this.snapshotWeekVersion, o.getSnapshotWeekVersion()) + .result(); + } + + @Override + public boolean equals(Object obj) { + if (obj == this) { + return true; + } + + if (obj instanceof SnapshotVersion) { + SnapshotVersion other = (SnapshotVersion) obj; + return Objects.equals(this.snapshotDate, other.getSnapshotDate()) + && this.snapshotWeekVersion == other.getSnapshotWeekVersion(); + } + + return false; + } + + @Override + public int hashCode() { + return Objects.hash(this.snapshotDate, this.snapshotWeekVersion); + } + + @Override + public String toString() { + return this.getSnapshotString(); + } +} diff --git a/src/main/java/net/wesjd/anvilgui/AnvilGUI.java b/src/main/java/net/wesjd/anvilgui/AnvilGUI.java new file mode 100644 index 0000000..6beaacc --- /dev/null +++ b/src/main/java/net/wesjd/anvilgui/AnvilGUI.java @@ -0,0 +1,889 @@ +package net.wesjd.anvilgui; + +import net.md_5.bungee.api.chat.BaseComponent; +import net.wesjd.anvilgui.version.VersionWrapper; +import net.wesjd.anvilgui.version.VersionMatcher; +import org.apache.commons.lang.Validate; +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.HandlerList; +import org.bukkit.event.Listener; +import org.bukkit.event.inventory.*; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +import org.bukkit.plugin.Plugin; + +import java.util.*; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; +import java.util.function.BiConsumer; +import java.util.function.BiFunction; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.logging.Level; + +/** + * An anvil gui, used for gathering a user's input + * + * @author Wesley Smith + * @since 1.0 + */ +public class AnvilGUI { + + /** + * The local {@link VersionWrapper} object for the server's version + */ + private static final VersionWrapper WRAPPER = new VersionMatcher().match(); + + /** + * The variable containing an item with air. Used when the item would be null. + * To keep the heap clean, this object only gets iniziaised once + */ + private static final ItemStack AIR = new ItemStack(Material.AIR); + /** + * If the given ItemStack is null, return an air ItemStack, otherwise return the given ItemStack + * + * @param stack The ItemStack to check + * @return air or the given ItemStack + */ + private static ItemStack itemNotNull(ItemStack stack) { + return stack == null ? AIR : stack; + } + + /** + * The {@link Plugin} that this anvil GUI is associated with + */ + private final Plugin plugin; + /** + * The player who has the GUI open + */ + private final Player player; + /** + * An {@link Executor} that executes tasks on the main server thread + */ + private final Executor mainThreadExecutor; + /** + * The title of the anvil inventory + */ + private final Object titleComponent; + /** + * The initial contents of the inventory + */ + private final ItemStack[] initialContents; + /** + * A state that decides where the anvil GUI is able to get closed by the user + */ + private final boolean preventClose; + /** + * A state that decides whether compatibility with Geyser software is enabled + */ + private final boolean geyserCompatibility; + /** + * A set of slot numbers that are permitted to be interacted with by the user. An interactable + * slot is one that is able to be minipulated by the player, i.e. clicking and picking up an item, + * placing in a new one, etc. + */ + private final Set interactableSlots; + + /** An {@link Consumer} that is called when the anvil GUI is close */ + private final Consumer closeListener; + /** A flag that decides whether the async click handler can be run concurrently */ + private final boolean concurrentClickHandlerExecution; + /** An {@link BiFunction} that is called when a slot is clicked */ + private final ClickHandler clickHandler; + + /** + * The container id of the inventory, used for NMS methods + */ + private int containerId; + + /** + * The inventory that is used on the Bukkit side of things + */ + private Inventory inventory; + /** + * The listener holder class + */ + private final ListenUp listener = new ListenUp(); + + /** + * Represents the state of the inventory being open + */ + private boolean open; + + /** + * The actual container backing the Anvil GUI + */ + private VersionWrapper.AnvilContainerWrapper container; + + /** + * Create an AnvilGUI + * + * @param plugin A {@link org.bukkit.plugin.java.JavaPlugin} instance + * @param player The {@link Player} to open the inventory for + * @param mainThreadExecutor An {@link Executor} that executes on the main server thread + * @param titleComponent What to have the text already set to + * @param initialContents The initial contents of the inventory + * @param preventClose Whether to prevent the inventory from closing + * @param geyserCompatibility Whether to enable compatibility with Geyser software + * @param closeListener A {@link Consumer} when the inventory closes + * @param concurrentClickHandlerExecution Flag to allow concurrent execution of the click handler + * @param clickHandler A {@link ClickHandler} that is called when the player clicks a slot + */ + private AnvilGUI( + Plugin plugin, + Player player, + Executor mainThreadExecutor, + Object titleComponent, + ItemStack[] initialContents, + boolean preventClose, + boolean geyserCompatibility, + Set interactableSlots, + Consumer closeListener, + boolean concurrentClickHandlerExecution, + ClickHandler clickHandler) { + this.plugin = plugin; + this.player = player; + this.mainThreadExecutor = mainThreadExecutor; + this.titleComponent = titleComponent; + this.initialContents = initialContents; + this.preventClose = preventClose; + this.geyserCompatibility = geyserCompatibility; + this.interactableSlots = Collections.unmodifiableSet(interactableSlots); + this.closeListener = closeListener; + this.concurrentClickHandlerExecution = concurrentClickHandlerExecution; + this.clickHandler = clickHandler; + } + + /** + * Opens the anvil GUI + */ + private void openInventory() { + Bukkit.getPluginManager().registerEvents(listener, plugin); + + container = WRAPPER.newContainerAnvil(player, titleComponent); + + inventory = container.getBukkitInventory(); + // We need to use setItem instead of setContents because a Minecraft ContainerAnvil + // contains two separate inventories: the result inventory and the ingredients inventory. + // The setContents method only updates the ingredients inventory unfortunately, + // but setItem handles the index going into the result inventory. + for (int i = 0; i < initialContents.length; i++) { + inventory.setItem(i, initialContents[i]); + } + + containerId = WRAPPER.getNextContainerId(player, container); + WRAPPER.handleInventoryCloseEvent(player); + WRAPPER.sendPacketOpenWindow(player, containerId, titleComponent); + WRAPPER.setActiveContainer(player, container); + WRAPPER.setActiveContainerId(container, containerId); + WRAPPER.addActiveContainerSlotListener(container, player); + + open = true; + } + + /** + * Closes the inventory if it's open. + */ + public void closeInventory() { + closeInventory(true); + } + + /** + * Closes the inventory if it's open, only sending the close inventory packets if the arg is true + * + * @param sendClosePacket Whether to send the close inventory event, packet, etc + */ + private void closeInventory(boolean sendClosePacket) { + if (!open) { + return; + } + + open = false; + + HandlerList.unregisterAll(listener); + + if (sendClosePacket) { + WRAPPER.handleInventoryCloseEvent(player); + WRAPPER.setActiveContainerDefault(player); + WRAPPER.sendPacketCloseWindow(player, containerId); + } + + if (closeListener != null) { + closeListener.accept(StateSnapshot.fromAnvilGUI(this)); + } + } + + /** + * Updates the title of the AnvilGUI to the new one. + * + * @param literalTitle The title to use as literal text + * @param preserveRenameText Whether to preserve the entered rename text + * @throws IllegalArgumentException when literalTitle is null + * @see Builder#title(String) + */ + public void setTitle(String literalTitle, boolean preserveRenameText) { + Validate.notNull(literalTitle, "literalTitle cannot be null"); + setTitle(WRAPPER.literalChatComponent(literalTitle), preserveRenameText); + } + + /** + * Updates the title of the AnvilGUI to the new one. + * + * @param json The json used to parse into a rich chat component + * @param preserveRenameText Whether to preserve the entered rename text + * @throws IllegalArgumentException when json is null + * @see Builder#jsonTitle(String) + */ + public void setJsonTitle(String json, boolean preserveRenameText) { + Validate.notNull(json, "json cannot be null"); + setTitle(WRAPPER.jsonChatComponent(json), preserveRenameText); + } + + /** + * Updates the title of the AnvilGUI to the new one. + * + * @param title The title as a NMS ChatComponent + * @param preserveRenameText Whether to preserve the entered rename text + */ + private void setTitle(Object title, boolean preserveRenameText) { + if (!WRAPPER.isCustomTitleSupported()) { + return; + } + String renameText = container.getRenameText(); + WRAPPER.sendPacketOpenWindow(player, containerId, title); + if (preserveRenameText) { + // The renameText field is marked as @Nullable in newer versions + container.setRenameText(renameText == null ? "" : renameText); + } + } + + /** + * Returns the Bukkit inventory for this anvil gui + * + * @return the {@link Inventory} for this anvil gui + */ + public Inventory getInventory() { + return inventory; + } + + /** + * Simply holds the listeners for the GUI + */ + private class ListenUp implements Listener { + + /** + * Boolean storing the running status of the latest click handler to prevent double execution. + * All accesses to this boolean will be from the main server thread, except for the rare event + * that the plugin is disabled and the mainThreadExecutor throws an exception + */ + private boolean clickHandlerRunning = false; + + @EventHandler + public void onInventoryClick(InventoryClickEvent event) { + if (!event.getInventory().equals(inventory)) { + return; + } + + final int rawSlot = event.getRawSlot(); + // ignore items dropped outside the window + if (rawSlot == -999) return; + + final Player clicker = (Player) event.getWhoClicked(); + final Inventory clickedInventory = event.getClickedInventory(); + + if (clickedInventory != null) { + if (clickedInventory.equals(clicker.getInventory())) { + // prevent players from merging items from the anvil inventory + if (event.getClick().equals(ClickType.DOUBLE_CLICK)) { + event.setCancelled(true); + return; + } + // prevent shift moving items from players inv to the anvil inventory + if (event.isShiftClick()) { + event.setCancelled(true); + return; + } + } + // prevent players from swapping items in the anvil gui + if ((event.getCursor() != null && event.getCursor().getType() != Material.AIR) + && !interactableSlots.contains(rawSlot) + && event.getClickedInventory().equals(inventory)) { + event.setCancelled(true); + return; + } + } + + if (rawSlot < 3 && rawSlot >= 0 || event.getAction().equals(InventoryAction.MOVE_TO_OTHER_INVENTORY)) { + event.setCancelled(!interactableSlots.contains(rawSlot)); + if (clickHandlerRunning && !concurrentClickHandlerExecution) { + // A click handler is running, don't launch another one + return; + } + + final CompletableFuture> actionsFuture = + clickHandler.apply(rawSlot, StateSnapshot.fromAnvilGUI(AnvilGUI.this)); + + final Consumer> actionsConsumer = actions -> { + for (final ResponseAction action : actions) { + action.accept(AnvilGUI.this, clicker); + } + }; + + if (actionsFuture.isDone()) { + // Fast-path without scheduling if clickHandler is performed in sync + // Because the future is already completed, .join() will not block the server thread + actionsFuture.thenAccept(actionsConsumer).join(); + } else { + clickHandlerRunning = true; + // If the plugin is disabled and the Executor throws an exception, the exception will be passed to + // the .handle method + actionsFuture + .thenAcceptAsync(actionsConsumer, mainThreadExecutor) + .handle((results, exception) -> { + if (exception != null) { + plugin.getLogger() + .log( + Level.SEVERE, + "An exception occurred in the AnvilGUI clickHandler", + exception); + } + // Whether an exception occurred or not, set running to false + clickHandlerRunning = false; + return null; + }); + } + } + } + + @EventHandler + public void onInventoryDrag(InventoryDragEvent event) { + if (event.getInventory().equals(inventory)) { + for (int slot : Slot.values()) { + if (event.getRawSlots().contains(slot)) { + event.setCancelled(!interactableSlots.contains(slot)); + break; + } + } + } + } + + @EventHandler + public void onInventoryClose(InventoryCloseEvent event) { + if (open && event.getInventory().equals(inventory)) { + closeInventory(false); + if (preventClose) { + mainThreadExecutor.execute(AnvilGUI.this::openInventory); + } + } + } + } + + /** A builder class for an {@link AnvilGUI} object */ + public static class Builder { + + /** An {@link Executor} that executes tasks on the main server thread */ + private Executor mainThreadExecutor; + /** An {@link Consumer} that is called when the anvil GUI is close */ + private Consumer closeListener; + /** A flag that decides whether the async click handler can be run concurrently */ + private boolean concurrentClickHandlerExecution = false; + /** An {@link Function} that is called when a slot in the inventory has been clicked */ + private ClickHandler clickHandler; + /** A state that decides where the anvil GUI is able to be closed by the user */ + private boolean preventClose = false; + /** A state that determines whether support for Geyser software is enabled */ + private boolean geyserCompatibility = true; + /** A set of integers containing the slot numbers that should be modifiable by the user. */ + private Set interactableSlots = Collections.emptySet(); + /** The {@link Plugin} that this anvil GUI is associated with */ + private Plugin plugin; + /** The text that will be displayed to the user */ + private Object titleComponent = WRAPPER.literalChatComponent("Repair & Name"); + /** The starting text on the item */ + private String itemText; + /** An {@link ItemStack} to be put in the left input slot */ + private ItemStack itemLeft; + /** An {@link ItemStack} to be put in the right input slot */ + private ItemStack itemRight; + /** An {@link ItemStack} to be placed in the output slot */ + private ItemStack itemOutput; + + /** + * Set a custom main server thread executor. Useful for plugins targeting Folia. + * + * @param executor The executor to run tasks on + * @return The {@link Builder} instance + * @throws IllegalArgumentException when the executor is null + */ + public Builder mainThreadExecutor(Executor executor) { + Validate.notNull(executor, "Executor cannot be null"); + this.mainThreadExecutor = executor; + return this; + } + + /** + * Prevents the closing of the anvil GUI by the user + * + * @return The {@link Builder} instance + */ + public Builder preventClose() { + preventClose = true; + return this; + } + + /** + * Disables compatibility with Geyser software + */ + public Builder disableGeyserCompat() { + geyserCompatibility = false; + return this; + } + + /** + * Permit the user to modify (take items in and out) the slot numbers provided. + * + * @param slots A varags param for the slot numbers. You can avoid relying on magic constants by using + * the {@link AnvilGUI.Slot} class. + * @return The {@link Builder} instance + */ + public Builder interactableSlots(int... slots) { + final Set newValue = new HashSet<>(); + for (int slot : slots) { + newValue.add(slot); + } + interactableSlots = newValue; + return this; + } + + /** + * Listens for when the inventory is closed + * + * @param closeListener An {@link Consumer} that is called when the anvil GUI is closed + * @return The {@link Builder} instance + * @throws IllegalArgumentException when the closeListener is null + */ + public Builder onClose(Consumer closeListener) { + Validate.notNull(closeListener, "closeListener cannot be null"); + this.closeListener = closeListener; + return this; + } + + /** + * Do an action when a slot is clicked in the inventory + *

+ * The ClickHandler is only called when the previous execution of the ClickHandler has finished. + * To alter this behaviour use {@link #allowConcurrentClickHandlerExecution()} + * + * @param clickHandler A {@link ClickHandler} that is called when the user clicks a slot. The + * {@link Integer} is the slot number corresponding to {@link Slot}, the + * {@link StateSnapshot} contains information about the current state of the anvil, + * and the response is a {@link CompletableFuture} that will eventually return a + * list of {@link ResponseAction} to execute in the order that they are supplied. + * @return The {@link Builder} instance + * @throws IllegalArgumentException when the function supplied is null + */ + public Builder onClickAsync(ClickHandler clickHandler) { + Validate.notNull(clickHandler, "click function cannot be null"); + this.clickHandler = clickHandler; + return this; + } + + /** + * By default, the {@link #onClickAsync(ClickHandler) async click handler} will not run concurrently + * and instead wait for the previous {@link CompletableFuture} to finish before executing it again. + *

+ * If this trait is desired, it can be enabled by calling this method but may lead to inconsistent + * behaviour if not handled properly. + * + * @return The {@link Builder} instance + */ + public Builder allowConcurrentClickHandlerExecution() { + this.concurrentClickHandlerExecution = true; + return this; + } + + /** + * Do an action when a slot is clicked in the inventory + * + * @param clickHandler A {@link BiFunction} that is called when the user clicks a slot. The + * {@link Integer} is the slot number corresponding to {@link Slot}, the + * {@link StateSnapshot} contains information about the current state of the anvil, + * and the response is a list of {@link ResponseAction} to execute in the order + * that they are supplied. + * @return The {@link Builder} instance + * @throws IllegalArgumentException when the function supplied is null + */ + public Builder onClick(BiFunction> clickHandler) { + Validate.notNull(clickHandler, "click function cannot be null"); + this.clickHandler = + (slot, stateSnapshot) -> CompletableFuture.completedFuture(clickHandler.apply(slot, stateSnapshot)); + return this; + } + + /** + * Sets the plugin for the {@link AnvilGUI} + * + * @param plugin The {@link Plugin} this anvil GUI is associated with + * @return The {@link Builder} instance + * @throws IllegalArgumentException if the plugin is null + */ + public Builder plugin(Plugin plugin) { + Validate.notNull(plugin, "Plugin cannot be null"); + this.plugin = plugin; + return this; + } + + /** + * Sets the initial item-text that is displayed to the user. + *

+ * If the usage of Adventure Components is desired, you must create an item, set the displayname of it + * and put it into the AnvilGUI via {@link #itemLeft(ItemStack)} manually. + * + * @param text The initial name of the item in the anvil + * @return The {@link Builder} instance + * @throws IllegalArgumentException if the text is null + */ + public Builder text(String text) { + Validate.notNull(text, "Text cannot be null"); + this.itemText = text; + return this; + } + + /** + * Sets the AnvilGUI title that is to be displayed to the user. + *
+ * The provided title will be treated as literal text. + * + * @param title The title that is to be displayed to the user + * @return The {@link Builder} instance + * @throws IllegalArgumentException if the title is null + */ + public Builder title(String title) { + Validate.notNull(title, "title cannot be null"); + this.titleComponent = WRAPPER.literalChatComponent(title); + return this; + } + + /** + * Sets the AnvilGUI title that is to be displayed to the user. + *
+ * The provided json will be parsed into rich chat components. + * + * @param json The title that is to be displayed to the user + * @return The {@link Builder} instance + * @throws IllegalArgumentException if the title is null + * @see net.md_5.bungee.chat.ComponentSerializer#toString(BaseComponent) + */ + public Builder jsonTitle(String json) { + Validate.notNull(json, "json cannot be null"); + this.titleComponent = WRAPPER.jsonChatComponent(json); + return this; + } + + /** + * Sets the {@link ItemStack} to be put in the first slot + * + * @param item The {@link ItemStack} to be put in the first slot + * @return The {@link Builder} instance + * @throws IllegalArgumentException if the {@link ItemStack} is null + */ + public Builder itemLeft(ItemStack item) { + Validate.notNull(item, "item cannot be null"); + this.itemLeft = item; + return this; + } + + /** + * Sets the {@link ItemStack} to be put in the second slot + * + * @param item The {@link ItemStack} to be put in the second slot + * @return The {@link Builder} instance + */ + public Builder itemRight(ItemStack item) { + this.itemRight = item; + return this; + } + + /** + * Sets the {@link ItemStack} to be put in the output slot + * + * @param item The {@link ItemStack} to be put in the output slot + * @return The {@link Builder} instance + */ + public Builder itemOutput(ItemStack item) { + this.itemOutput = item; + return this; + } + + /** + * Creates the anvil GUI and opens it for the player + * + * @param player The {@link Player} the anvil GUI should open for + * @return The {@link AnvilGUI} instance from this builder + * @throws IllegalArgumentException when the onClick function, plugin, or player is null + */ + public AnvilGUI open(Player player) { + Validate.notNull(plugin, "Plugin cannot be null"); + Validate.notNull(clickHandler, "click handler cannot be null"); + Validate.notNull(player, "Player cannot be null"); + + if (itemText != null) { + if (itemLeft == null) { + itemLeft = new ItemStack(Material.PAPER); + } + + ItemMeta paperMeta = itemLeft.getItemMeta(); + paperMeta.setDisplayName(itemText); + itemLeft.setItemMeta(paperMeta); + } + + // If no executor is specified, execute all tasks with the BukkitScheduler + if (mainThreadExecutor == null) { + mainThreadExecutor = task -> Bukkit.getScheduler().runTask(plugin, task); + } + + final AnvilGUI anvilGUI = new AnvilGUI( + plugin, + player, + mainThreadExecutor, + titleComponent, + new ItemStack[] {itemLeft, itemRight, itemOutput}, + preventClose, + geyserCompatibility, + interactableSlots, + closeListener, + concurrentClickHandlerExecution, + clickHandler); + anvilGUI.openInventory(); + return anvilGUI; + } + } + + /** + * A handler that is called when the user clicks a slot. The + * {@link Integer} is the slot number corresponding to {@link Slot}, the + * {@link StateSnapshot} contains information about the current state of the anvil, + * and the response is a {@link CompletableFuture} that will eventually return a + * list of {@link ResponseAction} to execute in the order that they are supplied. + */ + @FunctionalInterface + public interface ClickHandler extends BiFunction>> {} + + /** An action to run in response to a player clicking the output slot in the GUI. This interface is public + * and permits you, the developer, to add additional response features easily to your custom AnvilGUIs. */ + @FunctionalInterface + public interface ResponseAction extends BiConsumer { + + /** + * Replace the input text box value with the provided text value. + * + * Before using this method, it must be verified by the caller that items are either in + * {@link Slot#INPUT_LEFT} or {@link Slot#OUTPUT} present. + * + * @param text The text to write in the input box + * @return The {@link ResponseAction} to achieve the text replacement + * @throws IllegalArgumentException when the text is null + * @throws IllegalStateException when the slots {@link Slot#INPUT_LEFT} and {@link Slot#OUTPUT} are null + */ + static ResponseAction replaceInputText(String text) { + Validate.notNull(text, "text cannot be null"); + return (anvilgui, player) -> { + ItemStack item = anvilgui.getInventory().getItem(Slot.OUTPUT); + if (item == null) { + // Fallback on left input slot if player hasn't typed anything yet + item = anvilgui.getInventory().getItem(Slot.INPUT_LEFT); + } + if (item == null) { + throw new IllegalStateException( + "replaceInputText can only be used if slots OUTPUT or INPUT_LEFT are not empty"); + } + + final ItemStack cloned = item.clone(); + final ItemMeta meta = cloned.getItemMeta(); + meta.setDisplayName(text); + cloned.setItemMeta(meta); + anvilgui.getInventory().setItem(Slot.INPUT_LEFT, cloned); + }; + } + + /** + * Updates the title of the AnvilGUI to the new one. + * + * @param literalTitle The title to use as literal text + * @param preserveRenameText Whether to preserve the entered rename text + * @throws IllegalArgumentException when literalTitle is null + * @see Builder#title(String) + */ + static ResponseAction updateTitle(String literalTitle, boolean preserveRenameText) { + Validate.notNull(literalTitle, "literalTitle cannot be null"); + return (anvilGUI, player) -> anvilGUI.setTitle(literalTitle, preserveRenameText); + } + + /** + * Updates the title of the AnvilGUI to the new one. + * + * @param json The json used to parse into a rich chat component + * @param preserveRenameText Whether to preserve the entered rename text + * @throws IllegalArgumentException when json is null + * @see Builder#jsonTitle(String) + */ + static ResponseAction updateJsonTitle(String json, boolean preserveRenameText) { + Validate.notNull(json, "json cannot be null"); + return (anvilGUI, player) -> anvilGUI.setJsonTitle(json, preserveRenameText); + } + + /** + * Open another inventory + * @param otherInventory The inventory to open + * @return The {@link ResponseAction} to achieve the inventory open + * @throws IllegalArgumentException when the otherInventory is null + */ + static ResponseAction openInventory(Inventory otherInventory) { + Validate.notNull(otherInventory, "otherInventory cannot be null"); + return (anvilgui, player) -> player.openInventory(otherInventory); + } + + /** + * Close the AnvilGUI + * @return The {@link ResponseAction} to achieve closing the AnvilGUI + */ + static ResponseAction close() { + return (anvilgui, player) -> anvilgui.closeInventory(); + } + + /** + * Run the provided runnable + * @param runnable The runnable to run + * @return The {@link ResponseAction} to achieve running the runnable + * @throws IllegalArgumentException when the runnable is null + */ + static ResponseAction run(Runnable runnable) { + Validate.notNull(runnable, "runnable cannot be null"); + return (anvilgui, player) -> runnable.run(); + } + } + + /** + * Class wrapping the magic constants of slot numbers in an anvil GUI + */ + public static class Slot { + + private static final int[] values = new int[] {Slot.INPUT_LEFT, Slot.INPUT_RIGHT, Slot.OUTPUT}; + + /** + * The slot on the far left, where the first input is inserted. An {@link ItemStack} is always inserted + * here to be renamed + */ + public static final int INPUT_LEFT = 0; + /** + * Not used, but in a real anvil you are able to put the second item you want to combine here + */ + public static final int INPUT_RIGHT = 1; + /** + * The output slot, where an item is put when two items are combined from {@link #INPUT_LEFT} and + * {@link #INPUT_RIGHT} or {@link #INPUT_LEFT} is renamed + */ + public static final int OUTPUT = 2; + + /** + * Get all anvil slot values + * + * @return The array containing all possible anvil slots + */ + public static int[] values() { + return values; + } + } + + /** Represents a snapshot of the state of an AnvilGUI */ + public static final class StateSnapshot { + + /** + * Create an {@link StateSnapshot} from the current state of an {@link AnvilGUI} + * @param anvilGUI The instance to take the snapshot of + * @return The snapshot + */ + private static StateSnapshot fromAnvilGUI(AnvilGUI anvilGUI) { + final Inventory inventory = anvilGUI.getInventory(); + return new StateSnapshot( + itemNotNull(inventory.getItem(Slot.INPUT_LEFT)).clone(), + itemNotNull(inventory.getItem(Slot.INPUT_RIGHT)).clone(), + itemNotNull(inventory.getItem(Slot.OUTPUT)).clone(), + anvilGUI.player); + } + + /** + * The {@link ItemStack} in the anvilGui slots + */ + private final ItemStack leftItem, rightItem, outputItem; + + /** + * The {@link Player} that clicked the output slot + */ + private final Player player; + + /** + * The event parameter constructor + * @param leftItem The left item in the combine slot of the anvilGUI + * @param rightItem The right item in the combine slot of the anvilGUI + * @param outputItem The item that would have been outputted, when the items would have been combined + * @param player The player that clicked the output slot + */ + public StateSnapshot(ItemStack leftItem, ItemStack rightItem, ItemStack outputItem, Player player) { + this.leftItem = leftItem; + this.rightItem = rightItem; + this.outputItem = outputItem; + this.player = player; + } + + /** + * It returns the item in the left combine slot of the gui + * + * @return The leftItem + */ + public ItemStack getLeftItem() { + return leftItem; + } + + /** + * It returns the item in the right combine slot of the gui + * + * @return The rightItem + */ + public ItemStack getRightItem() { + return rightItem; + } + + /** + * It returns the output item that would have been the result + * by combining the left and right one + * + * @return The outputItem + */ + public ItemStack getOutputItem() { + return outputItem; + } + + /** + * It returns the player that clicked onto the output slot + * + * @return The player + */ + public Player getPlayer() { + return player; + } + + /** + * It returns the text the player typed into the rename field + * + * @return The text of the rename field + */ + public String getText() { + return outputItem.hasItemMeta() ? outputItem.getItemMeta().getDisplayName() : ""; + } + } +} diff --git a/src/main/java/net/wesjd/anvilgui/LICENSE b/src/main/java/net/wesjd/anvilgui/LICENSE new file mode 100644 index 0000000..c735359 --- /dev/null +++ b/src/main/java/net/wesjd/anvilgui/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2016 Wesley Smith + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/src/main/java/net/wesjd/anvilgui/version/PaperWrapper1_21_R4.java b/src/main/java/net/wesjd/anvilgui/version/PaperWrapper1_21_R4.java new file mode 100644 index 0000000..11bc655 --- /dev/null +++ b/src/main/java/net/wesjd/anvilgui/version/PaperWrapper1_21_R4.java @@ -0,0 +1,135 @@ +package net.wesjd.anvilgui.version; + +import net.minecraft.core.BlockPos; +import net.minecraft.core.RegistryAccess; +import net.minecraft.core.component.DataComponentPatch; +import net.minecraft.core.component.DataComponents; +import net.minecraft.network.chat.Component; +import net.minecraft.network.protocol.game.ClientboundContainerClosePacket; +import net.minecraft.network.protocol.game.ClientboundOpenScreenPacket; +import net.minecraft.network.protocol.game.ClientboundSetExperiencePacket; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.inventory.*; +import org.bukkit.craftbukkit.entity.CraftPlayer; +import org.bukkit.entity.Player; +import org.bukkit.inventory.Inventory; +import org.jetbrains.annotations.NotNull; + +public final class PaperWrapper1_21_R4 implements VersionWrapper { + private int getRealNextContainerId(Player player) { + return toNMS(player).nextContainerCounter(); + } + + + private ServerPlayer toNMS(Player player) { + return ((CraftPlayer) player).getHandle(); + } + + @Override + public int getNextContainerId(Player player, AnvilContainerWrapper container) { + return ((AnvilContainer) container).containerId; + } + + @Override + public void handleInventoryCloseEvent(Player player) { + //CraftEventFactory.handleInventoryCloseEvent(toNMS(player)); + toNMS(player).doCloseContainer(); // q -> doCloseContainer + } + + @Override + public void sendPacketOpenWindow(Player player, int containerId, Object inventoryTitle) { + toNMS(player).connection.send(new ClientboundOpenScreenPacket(containerId, MenuType.ANVIL, Component.literal(inventoryTitle.toString()))); + } + + @Override + public void sendPacketCloseWindow(Player player, int containerId) { + toNMS(player).connection.send(new ClientboundContainerClosePacket(containerId)); + } + + @Override + public void sendPacketExperienceChange(Player player, int experienceLevel) { + toNMS(player).connection.send(new ClientboundSetExperiencePacket(0f, 0, experienceLevel)); + } + + @Override + public void setActiveContainerDefault(Player player) { + toNMS(player).containerMenu = toNMS(player).inventoryMenu; // cd -> containerMenu, cc -> inventoryMenu + } + + @Override + public void setActiveContainer(Player player, AnvilContainerWrapper container) { + toNMS(player).containerMenu = (AbstractContainerMenu) container; + } + + @Override + public void setActiveContainerId(AnvilContainerWrapper container, int containerId) { } + + @Override + public void addActiveContainerSlotListener(AnvilContainerWrapper container, Player player) { + toNMS(player).initMenu((AbstractContainerMenu) container); + } + + @Override + public AnvilContainerWrapper newContainerAnvil(Player player, Object title) { + return new AnvilContainer(player, getRealNextContainerId(player), Component.literal(title.toString())); + } + + @Override + public Object literalChatComponent(String content) { + return Component.literal(content); // IChatBaseComponent.b -> Component.literal + } + + @Override + public Object jsonChatComponent(String json) { + return Component.Serializer.toJson(Component.literal(json), RegistryAccess.EMPTY); + } + + private static class AnvilContainer extends AnvilMenu implements AnvilContainerWrapper { + public AnvilContainer(Player player, int containerId, Component guiTitle) { + super( + containerId, + ((CraftPlayer) player).getHandle().getInventory(), + ContainerLevelAccess.create(((CraftPlayer) player).getHandle().level(), BlockPos.ZERO) + ); + this.checkReachable = false; + setTitle(guiTitle); + } + + @Override + public void createResult() { + // If the output is empty, copy the left input into the output + Slot output = getSlot(2); // b -> getSlot + if (!output.hasItem()) { // h -> hasItem + output.set(getSlot(0).getItem().copy()); // f -> set, g -> getItem, v -> copy + } + + this.cost.set(0); // y -> cost, a -> set + + // Sync to the client + this.sendAllDataToRemote(); // b -> sendAllDataToRemote + this.broadcastChanges(); // d -> broadcastChanges + } + + @Override + public boolean setItemName(@NotNull String itemName) { + Slot inputLeft = getSlot(0); + if (inputLeft.hasItem()) { + inputLeft + .getItem() + .applyComponents(DataComponentPatch + .builder() + .set(DataComponents.CUSTOM_NAME, Component.literal(itemName)) + .build() + ); + return true; + } + + return false; + } + + @Override + public Inventory getBukkitInventory() { + return this.getBukkitView().getTopInventory(); + } + } +} diff --git a/src/main/java/net/wesjd/anvilgui/version/VersionMatcher.java b/src/main/java/net/wesjd/anvilgui/version/VersionMatcher.java new file mode 100644 index 0000000..c4f1bcc --- /dev/null +++ b/src/main/java/net/wesjd/anvilgui/version/VersionMatcher.java @@ -0,0 +1,51 @@ +package net.wesjd.anvilgui.version; + +import org.bukkit.Bukkit; + +import java.util.HashMap; +import java.util.Map; + +/** + * Matches the server's NMS version to its {@link VersionWrapper} + * + * @author Wesley Smith + * @since 1.2.1 + */ +public class VersionMatcher { + /** Maps a Minecraft version string to the corresponding revision string */ + private static final Map VERSION_TO_REVISION = new HashMap() { + { + this.put("1.21.4", "1_21_R4"); + } + }; + /* This needs to be updated to reflect the newest available version wrapper */ + private static final String FALLBACK_REVISION = "1_21_R4"; + + /** + * Matches the server version to it's {@link VersionWrapper} + * + * @return The {@link VersionWrapper} for this server + * @throws IllegalStateException If the version wrapper failed to be instantiated or is unable to be found + */ + public VersionWrapper match() { + String craftBukkitPackage = Bukkit.getServer().getClass().getPackage().getName(); + + String rVersion; + if (!craftBukkitPackage.contains(".v")) { // cb package not relocated (i.e. paper 1.20.5+) + final String version = Bukkit.getBukkitVersion().split("-")[0]; + rVersion = VERSION_TO_REVISION.getOrDefault(version, FALLBACK_REVISION); + } else { + rVersion = craftBukkitPackage.split("\\.")[3].substring(1); + } + + try { + return (VersionWrapper) Class.forName(getClass().getPackage().getName() + ".PaperWrapper" + rVersion) + .getDeclaredConstructor() + .newInstance(); + } catch (ClassNotFoundException exception) { + throw new IllegalStateException("AnvilGUI does not support server version \"" + rVersion + "\"", exception); + } catch (ReflectiveOperationException exception) { + throw new IllegalStateException("Failed to instantiate version wrapper for version " + rVersion, exception); + } + } +} diff --git a/src/main/java/net/wesjd/anvilgui/version/VersionWrapper.java b/src/main/java/net/wesjd/anvilgui/version/VersionWrapper.java new file mode 100644 index 0000000..5efe2c5 --- /dev/null +++ b/src/main/java/net/wesjd/anvilgui/version/VersionWrapper.java @@ -0,0 +1,150 @@ +package net.wesjd.anvilgui.version; + +import org.bukkit.entity.Player; +import org.bukkit.inventory.Inventory; + +/** + * Wraps versions to be able to easily use different NMS server versions + * + * @author Wesley Smith + * @since 1.0 + */ +public interface VersionWrapper { + + /** + * Gets the next available NMS container id for the player + * + * @param player The player to get the next container id of + * @param container The container that a new id is being generated for + * @return The next available NMS container id + */ + int getNextContainerId(Player player, AnvilContainerWrapper container); + + /** + * Closes the current inventory for the player + * + * @param player The player that needs their current inventory closed + */ + void handleInventoryCloseEvent(Player player); + + /** + * Sends PacketPlayOutOpenWindow to the player with the container id and window title + * + * @param player The player to send the packet to + * @param containerId The container id to open + * @param inventoryTitle The title of the inventory to be opened (only works in Minecraft 1.14 and above) + */ + void sendPacketOpenWindow(Player player, int containerId, Object inventoryTitle); + + /** + * Sends PacketPlayOutCloseWindow to the player with the container id + * + * @param player The player to send the packet to + * @param containerId The container id to close + */ + void sendPacketCloseWindow(Player player, int containerId); + + /** + * Sends PacketPlayOutExperience to the player with the experience level + * + * @param player The player to send the packet to + * @param experienceLevel The experience level to set + */ + void sendPacketExperienceChange(Player player, int experienceLevel); + + /** + * Sets the NMS player's active container to the default one + * + * @param player The player to set the active container of + */ + void setActiveContainerDefault(Player player); + + /** + * Sets the NMS player's active container to the one supplied + * + * @param player The player to set the active container of + * @param container The container to set as active + */ + void setActiveContainer(Player player, AnvilContainerWrapper container); + + /** + * Sets the supplied windowId of the supplied Container + * + * @param container The container to set the windowId of + * @param containerId The new windowId + */ + void setActiveContainerId(AnvilContainerWrapper container, int containerId); + + /** + * Adds a slot listener to the supplied container for the player + * + * @param container The container to add the slot listener to + * @param player The player to have as a listener + */ + void addActiveContainerSlotListener(AnvilContainerWrapper container, Player player); + + /** + * Creates a new ContainerAnvil + * + * @param player The player to get the container of + * @param title The title of the anvil inventory + * @return The Container instance + */ + AnvilContainerWrapper newContainerAnvil(Player player, Object title); + + /** + * Checks if the current Minecraft version actually supports custom titles + * + * @return The current supported state + */ + default boolean isCustomTitleSupported() { + return true; + } + + /** + * Creates a new chat component that does not handle the content in any special way + * + * @param content The content to display + * @return Version-specific ChatComponent instance + */ + Object literalChatComponent(String content); + + /** + * Creates a new rich chat component from the provided json + * + * @param json The component to parse + * @return Version-specific ChatComponent instance + */ + Object jsonChatComponent(String json); + + /** + * Interface implemented by the custom NMS AnvilContainer used to interact with it directly + */ + interface AnvilContainerWrapper { + + /** + * Retrieves the raw text that has been entered into the Anvil at the moment + *

+ * This field is marked as public in the Minecraft AnvilContainer only from Minecraft 1.11 and upwards + * + * @return The raw text in the rename field + */ + default String getRenameText() { + return null; + } + + /** + * Sets the provided text as the literal hovername of the item in the left input slot + * + * @param text The text to set + */ + default void setRenameText(String text) {} + + /** + * Gets the {@link Inventory} wrapper of the NMS container + * + * @return The inventory of the NMS container + */ + Inventory getBukkitInventory(); + } +} diff --git a/src/main/java/xyz/ineanto/nicko/Nicko.java b/src/main/java/xyz/ineanto/nicko/Nicko.java index fcd8a3c..83ec229 100644 --- a/src/main/java/xyz/ineanto/nicko/Nicko.java +++ b/src/main/java/xyz/ineanto/nicko/Nicko.java @@ -3,7 +3,6 @@ package xyz.ineanto.nicko; import com.comphenix.protocol.utility.MinecraftVersion; import org.bukkit.Bukkit; import org.bukkit.Material; -import org.bukkit.command.PluginCommand; import org.bukkit.plugin.java.JavaPlugin; import xyz.ineanto.nicko.appearance.random.RandomNameFetcher; import xyz.ineanto.nicko.command.NickoCommand; @@ -21,6 +20,7 @@ import xyz.ineanto.nicko.storage.PlayerDataStore; import xyz.ineanto.nicko.storage.json.JSONStorage; import xyz.ineanto.nicko.storage.map.MapCache; import xyz.ineanto.nicko.storage.name.PlayerNameStore; +import xyz.xenondevs.invui.InvUI; import xyz.xenondevs.invui.gui.structure.Structure; import xyz.xenondevs.invui.item.builder.ItemBuilder; import xyz.xenondevs.invui.item.impl.SimpleItem; @@ -30,8 +30,6 @@ import java.io.IOException; public class Nicko extends JavaPlugin { private static Nicko plugin; - private final boolean unitTesting; - private MojangAPI mojangAPI; private PlayerDataStore dataStore; private ConfigurationManager configurationManager; @@ -40,19 +38,6 @@ public class Nicko extends JavaPlugin { private PlayerNameStore nameStore; private RandomNameFetcher nameFetcher; - public Nicko() { - this.unitTesting = false; - } - - /** - * Used by MockBukkit - */ - protected Nicko(Configuration configuration) { - this.unitTesting = true; - this.configuration = configuration; - getLogger().info("Unit Testing Mode enabled."); - } - @Override public void onEnable() { plugin = this; @@ -96,41 +81,37 @@ public class Nicko extends JavaPlugin { dataStore.setCache(cache); } - if (!unitTesting) { - nameStore = new PlayerNameStore(); - mojangAPI = new MojangAPI(); - nameFetcher = new RandomNameFetcher(this); + nameStore = new PlayerNameStore(); + mojangAPI = new MojangAPI(); + nameFetcher = new RandomNameFetcher(this); - new ConfigurationMigrator(this).migrate(); + new ConfigurationMigrator(this).migrate(); + InvUI.getInstance().setPlugin(this); - if (configuration.isCustomLocale()) { - try { - CustomLanguage.dumpIntoFile(Language.ENGLISH); - customLanguage = new CustomLanguage(); - new CustomLocaleMigrator(this, customLanguage).migrate(); - getLogger().info("Successfully loaded the custom locale."); - } catch (IOException e) { - getLogger().severe("Failed to load the custom locale!"); - } + if (configuration.isCustomLocale()) { + try { + CustomLanguage.dumpIntoFile(Language.ENGLISH); + customLanguage = new CustomLanguage(); + new CustomLocaleMigrator(this, customLanguage).migrate(); + getLogger().info("Successfully loaded the custom locale."); + } catch (IOException e) { + getLogger().severe("Failed to load the custom locale!"); } - - final PluginCommand command = getCommand("nicko"); - if (command != null) { - command.setExecutor(new NickoCommand()); - } - - Structure.addGlobalIngredient('#', new SimpleItem(new ItemBuilder(Material.AIR))); - Structure.addGlobalIngredient('%', new SimpleItem(new ItemBuilder(Material.BLACK_STAINED_GLASS_PANE).setDisplayName(" "))); - - if (Bukkit.getPluginManager().getPlugin("PlaceholderAPI") != null) { - getLogger().info("Enabling PlaceHolderAPI support..."); - new NickoExpansion(this).register(); - } - - getServer().getPluginManager().registerEvents(new PlayerJoinListener(), this); - getServer().getPluginManager().registerEvents(new PlayerQuitListener(), this); } + registerCommand("nicko", new NickoCommand()); + + Structure.addGlobalIngredient('#', new SimpleItem(new ItemBuilder(Material.AIR))); + Structure.addGlobalIngredient('%', new SimpleItem(new ItemBuilder(Material.BLACK_STAINED_GLASS_PANE).setDisplayName(" "))); + + if (Bukkit.getPluginManager().getPlugin("PlaceholderAPI") != null) { + getLogger().info("Enabling PlaceHolderAPI support..."); + new NickoExpansion(this).register(); + } + + getServer().getPluginManager().registerEvents(new PlayerJoinListener(), this); + getServer().getPluginManager().registerEvents(new PlayerQuitListener(), this); + getLogger().info("Nicko has been enabled."); } @@ -145,9 +126,7 @@ public class Nicko extends JavaPlugin { } } - if (!unitTesting) { - nameStore.clearStoredNames(); - } + nameStore.clearStoredNames(); getLogger().info("Nicko (Bukkit) has been disabled."); } diff --git a/src/main/java/xyz/ineanto/nicko/command/NickoCommand.java b/src/main/java/xyz/ineanto/nicko/command/NickoCommand.java index 118696e..6665761 100644 --- a/src/main/java/xyz/ineanto/nicko/command/NickoCommand.java +++ b/src/main/java/xyz/ineanto/nicko/command/NickoCommand.java @@ -1,28 +1,29 @@ package xyz.ineanto.nicko.command; -import org.bukkit.command.Command; -import org.bukkit.command.CommandExecutor; +import io.papermc.paper.command.brigadier.BasicCommand; +import io.papermc.paper.command.brigadier.CommandSourceStack; import org.bukkit.command.CommandSender; +import org.bukkit.entity.Entity; import org.bukkit.entity.Player; -import org.jetbrains.annotations.NotNull; +import org.jspecify.annotations.Nullable; import xyz.ineanto.nicko.gui.HomeGUI; -import xyz.ineanto.nicko.language.PlayerLanguage; -import xyz.ineanto.nicko.language.LanguageKey; -public class NickoCommand implements CommandExecutor { +public class NickoCommand implements BasicCommand { @Override - public boolean onCommand(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, String[] args) { - if (sender instanceof Player player) { - if (player.isOp() || player.hasPermission("nicko.use") || player.hasPermission("nicko.*")) { - new HomeGUI(player).open(); - } else { - final PlayerLanguage playerLanguage = new PlayerLanguage(player); - player.sendMessage(playerLanguage.translate(LanguageKey.Error.PERMISSION, true)); - } - return false; - } + public void execute(CommandSourceStack stack, String[] strings) { + final Entity executor = stack.getExecutor(); + final Player player = (Player) executor; - sender.sendMessage("This plugin can only be used in-game. Sorry!"); - return false; + new HomeGUI(player).open(); + } + + @Override + public boolean canUse(CommandSender sender) { + return sender instanceof Player && sender.isOp() || sender.hasPermission(permission()); + } + + @Override + public @Nullable String permission() { + return "nicko.use"; } } diff --git a/src/main/java/xyz/ineanto/nicko/loader/NickoPluginLoader.java b/src/main/java/xyz/ineanto/nicko/loader/NickoPluginLoader.java index 1f0f511..eaac594 100644 --- a/src/main/java/xyz/ineanto/nicko/loader/NickoPluginLoader.java +++ b/src/main/java/xyz/ineanto/nicko/loader/NickoPluginLoader.java @@ -11,10 +11,12 @@ public class NickoPluginLoader implements PluginLoader { @Override public void classloader(PluginClasspathBuilder pluginClasspathBuilder) { final MavenLibraryResolver resolver = new MavenLibraryResolver(); + resolver.addRepository(new RemoteRepository.Builder("xenondevs", "default", "https://repo.xenondevs.xyz/releases/").build()); - resolver.addRepository(new RemoteRepository.Builder("wesjd", "default", "https://repo.codemc.io/repository/maven-snapshots/").build()); + resolver.addRepository(new RemoteRepository.Builder("codemc", "default", "https://repo.codemc.io/repository/maven-snapshots/").build()); resolver.addDependency(new Dependency(new DefaultArtifact("xyz.xenondevs.invui:invui:pom:1.44"), null)); - resolver.addDependency(new Dependency(new DefaultArtifact("net:wesjd:anvilgui:1.10.4-SNAPSHOT"), null)); + //resolver.addDependency(new Dependency(new DefaultArtifact("net.wesjd:anvilgui:1.10.4-SNAPSHOT"), null)); + pluginClasspathBuilder.addLibrary(resolver); } } diff --git a/src/main/resources/plugin.yml b/src/main/resources/paper-plugin.yml similarity index 63% rename from src/main/resources/plugin.yml rename to src/main/resources/paper-plugin.yml index 08b70ce..048a12e 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/paper-plugin.yml @@ -1,17 +1,19 @@ name: Nicko main: xyz.ineanto.nicko.Nicko +loader: xyz.ineanto.nicko.loader.NickoPluginLoader version: ${version} author: Ineanto description: "The feature packed, next generation disguise plugin for Minecraft." -api-version: 1.21 +api-version: "1.21" softdepend: [ PlaceholderAPI ] depend: - ProtocolLib -load: POSTWORLD -commands: - nicko: - description: "Opens Nicko's GUI." - permission: nicko.use + +# Suppose we require ProtocolLib to be loaded for our plugin +AnvilGUI: + join-classpath: true + required: true + permissions: nicko.*: default: op