Compare commits

...
8 changed files with 1124 additions and 20 deletions
@@ -57,7 +57,7 @@ public class WorldEditListener implements Listener {
private static final Set<String> commands = new HashSet<>(); private static final Set<String> commands = new HashSet<>();
private static final Set<String> commandExclusions = new HashSet<>(); private static final Set<String> commandExclusions = new HashSet<>();
private static final String[] shortcutCommands = {"//1", "//2", "//90", "//-90", "//180", "//p", "//c", "//flopy", "//floppy", "//flopyp", "//floppyp", "//u", "//r"}; private static final String[] shortcutCommands = {"//1", "//2", "//90", "//-90", "//180", "//p", "//c", "//flopy", "//floppy", "//flopyp", "//floppyp", "//u", "//r", "//download", "/download"};
public static boolean isWorldEditCommand(String command) { public static boolean isWorldEditCommand(String command) {
for (String shortcut : shortcutCommands) { for (String shortcut : shortcutCommands) {
+1
View File
@@ -18,6 +18,7 @@ dependencies {
implementation("com.github.ajalt.clikt:clikt:5.0.3") implementation("com.github.ajalt.clikt:clikt:5.0.3")
implementation("com.github.ajalt.mordant:mordant:3.0.2") implementation("com.github.ajalt.mordant:mordant:3.0.2")
implementation(libs.logback) implementation(libs.logback)
implementation("org.yaml:snakeyaml:2.2")
implementation("org.mariadb.jdbc:mariadb-java-client:3.3.1") implementation("org.mariadb.jdbc:mariadb-java-client:3.3.1")
implementation(libs.exposedCore) implementation(libs.exposedCore)
+62 -9
View File
@@ -14,9 +14,11 @@ import com.github.ajalt.clikt.parameters.types.file
import com.github.ajalt.clikt.parameters.types.long import com.github.ajalt.clikt.parameters.types.long
import com.github.ajalt.clikt.parameters.types.path import com.github.ajalt.clikt.parameters.types.path
import com.sun.security.auth.module.UnixSystem import com.sun.security.auth.module.UnixSystem
import org.yaml.snakeyaml.Yaml
import java.io.File import java.io.File
import kotlin.io.path.absolute import kotlin.io.path.absolute
import kotlin.io.path.absolutePathString import kotlin.io.path.absolutePathString
import kotlin.random.Random
const val LOG4J_CONFIG = """<?xml version="1.0" encoding="UTF-8"?> const val LOG4J_CONFIG = """<?xml version="1.0" encoding="UTF-8"?>
<Configuration status="WARN" packages="com.mojang.util"> <Configuration status="WARN" packages="com.mojang.util">
@@ -69,32 +71,42 @@ class DevCommand : CliktCommand("dev") {
override fun run() { override fun run() {
val args = mutableListOf<String>() val args = mutableListOf<String>()
val serverDirectory = File(workingDir, server) var serverDir = resolveServerDirectory(server)
val serverDir =
if (serverDirectory.exists() && serverDirectory.isDirectory) serverDirectory else File(workingDir, server)
if (isVelocity(server)) { if (isVelocity(server)) {
runServer( runServer(
args, jvmArgs, listOf( args, jvmArgs, listOf(
jar?.absolutePath jar?.absolutePath
?: File("/jar/Velocity.jar").absolutePath ?: File("/jars/Velocity.jar").absolutePath
), serverDir ), serverDir
) )
} else { } else {
setLogConfig(args) setLogConfig(args)
val version = findVersion(server) val version = findVersion(server)
?: throw CliktError("Unknown Server Version") ?: throw CliktError("Unknown Server Version")
val gameModeTemplate = if (serverDir.isDirectory) null else loadGameModeTemplate(server)
if (gameModeTemplate != null) {
serverDir = gameModeTemplate.serverDir
args += "-Dconfig=$server.yml"
}
val worldFile = world?.absolute()?.toFile() val worldFile = world?.absolute()?.toFile()
?: File(serverDir, "devtempworld") ?: File(workingDir, "devtempworld")
val jarFile = jar?.absolutePath var jarFile = jar?.absolutePath
?: additionalVersions[server]?.let { supportedVersionJars[it] } ?: additionalVersions[server]?.let { supportedVersionJars[it] }
?: supportedVersionJars[version] ?: supportedVersionJars[version]
?: throw CliktError("Unknown Server Version") ?: throw CliktError("Unknown Server Version")
if (gameModeTemplate != null) {
jarFile = if (gameModeTemplate.spigot) {
jarFile.replace("paper", "spigot")
} else {
jarFile.replace("spigot", "paper")
}
}
if (!worldFile.exists()) { if (!worldFile.exists()) {
val templateFile = File(serverDir, "Bauwelt") val templateFile = gameModeTemplate?.worldTemplate ?: File(serverDir, "Bauwelt")
if (!templateFile.exists()) { if (!templateFile.exists()) {
throw CliktError("World Template not found!") throw CliktError("Could not find world template: ${templateFile.absolutePath}")
} }
templateFile.copyRecursively(worldFile) templateFile.copyRecursively(worldFile)
} }
@@ -123,6 +135,12 @@ class DevCommand : CliktCommand("dev") {
} }
} }
data class GameModeTemplate(
val serverDir: File,
val worldTemplate: File,
val spigot: Boolean
)
val jvmDefaultParams = arrayOf( val jvmDefaultParams = arrayOf(
"-Xmx1G", "-Xmx1G",
"-Xgc:excessiveGCratio=80", "-Xgc:excessiveGCratio=80",
@@ -165,6 +183,41 @@ class DevCommand : CliktCommand("dev") {
fun isVelocity(server: String): Boolean = fun isVelocity(server: String): Boolean =
server.endsWith("Velocity") server.endsWith("Velocity")
fun resolveServerDirectory(server: String): File {
val localServer = File(workingDir, server)
if (localServer.isDirectory) {
return localServer
}
return File("/servers", server)
}
fun loadGameModeTemplate(server: String): GameModeTemplate? {
val configFile = File("/configs/GameModes/$server.yml")
if (!configFile.exists()) {
throw CliktError("Server/GameMode not found")
}
val document = configFile.reader().use { reader ->
Yaml().load<Map<String, Any?>>(reader)
} ?: throw CliktError("GameMode config is empty: ${configFile.absolutePath}")
val serverConfig = document["Server"] as? Map<*, *>
?: throw CliktError("GameMode config is missing Server section: ${configFile.absolutePath}")
val folder = serverConfig["Folder"] as? String
?: throw CliktError("GameMode config is missing Server.Folder: ${configFile.absolutePath}")
val maps = (serverConfig["Maps"] as? List<*>)
?.filterIsInstance<String>()
?.takeIf { it.isNotEmpty() }
?: throw CliktError("GameMode config is missing Server.Maps: ${configFile.absolutePath}")
val serverDir = File("/servers", folder)
val worldTemplate = File(File(serverDir, "arenas"), maps[Random.nextInt(maps.size)])
return GameModeTemplate(
serverDir = serverDir,
worldTemplate = worldTemplate,
spigot = serverConfig["Spigot"] == true
)
}
fun setLogConfig(args: MutableList<String>) { fun setLogConfig(args: MutableList<String>) {
args += "-DlogPath=${workingDir.absolutePath}/logs" args += "-DlogPath=${workingDir.absolutePath}/logs"
args += "-Dlog4j.configurationFile=${log4jConfig.absolutePath}" args += "-Dlog4j.configurationFile=${log4jConfig.absolutePath}"
@@ -190,4 +243,4 @@ class DevCommand : CliktCommand("dev") {
Runtime.getRuntime().addShutdownHook(Thread { if (process.isAlive) process.destroyForcibly() }) Runtime.getRuntime().addShutdownHook(Thread { if (process.isAlive) process.destroyForcibly() })
process.waitFor() process.waitFor()
} }
} }
@@ -1,7 +1,7 @@
/* /*
* This file is a part of the SteamWar software. * This file is a part of the SteamWar software.
* *
* Copyright (C) 2025 SteamWar.de-Serverteam * Copyright (C) 2026 SteamWar.de-Serverteam
* *
* This program is free software: you can redistribute it and/or modify * This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by * it under the terms of the GNU Affero General Public License as published by
@@ -31,17 +31,16 @@ import de.steamwar.linkage.Linked;
import net.md_5.bungee.api.ChatMessageType; import net.md_5.bungee.api.ChatMessageType;
import org.bukkit.GameMode; import org.bukkit.GameMode;
import org.bukkit.Material; import org.bukkit.Material;
import org.bukkit.Tag;
import org.bukkit.block.Block; import org.bukkit.block.Block;
import org.bukkit.block.data.type.Dispenser; import org.bukkit.block.data.type.Dispenser;
import org.bukkit.block.data.type.DriedGhast;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
import org.bukkit.entity.TNTPrimed; import org.bukkit.entity.TNTPrimed;
import org.bukkit.event.EventHandler; import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority; import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener; import org.bukkit.event.Listener;
import org.bukkit.event.block.BlockBreakEvent; import org.bukkit.event.block.*;
import org.bukkit.event.block.BlockDispenseEvent;
import org.bukkit.event.block.BlockFromToEvent;
import org.bukkit.event.block.BlockPlaceEvent;
import org.bukkit.event.entity.EntityExplodeEvent; import org.bukkit.event.entity.EntityExplodeEvent;
import org.bukkit.event.entity.FoodLevelChangeEvent; import org.bukkit.event.entity.FoodLevelChangeEvent;
import org.bukkit.event.entity.PlayerDeathEvent; import org.bukkit.event.entity.PlayerDeathEvent;
@@ -258,4 +257,16 @@ public class Permanent implements Listener {
event.setCancelled(true); event.setCancelled(true);
FightSystem.getMessage().sendPrefixless("NO_BLOCK_BREAK", event.getPlayer(), ChatMessageType.ACTION_BAR); FightSystem.getMessage().sendPrefixless("NO_BLOCK_BREAK", event.getPlayer(), ChatMessageType.ACTION_BAR);
} }
/**
* Prevents Dried Ghast from spawning and plants from growing
*/
@EventHandler
public void onBlockGrow(BlockGrowEvent event) {
var type = event.getBlock().getType();
if (event.getBlock().getBlockData() instanceof DriedGhast || Tag.CROPS.isTagged(type) || Tag.SAPLINGS.isTagged(type)) {
event.setCancelled(true);
}
}
} }
+3 -3
View File
@@ -38,11 +38,11 @@ widener {
fromCatalog(libs.paperapi) fromCatalog(libs.paperapi)
} }
tasks.register<DevServer>("DevLobby20") { tasks.register<DevServer>("DevLobby") {
group = "run" group = "run"
description = "Run a 1.20 Dev Lobby" description = "Run a Dev Lobby"
dependsOn(":SpigotCore:shadowJar") dependsOn(":SpigotCore:shadowJar")
dependsOn(":LobbySystem:jar") dependsOn(":LobbySystem:jar")
template = "Lobby20" template = "Lobby21"
worldName = "Lobby" worldName = "Lobby"
} }
@@ -59,8 +59,6 @@ dependencies {
compileOnly(libs.netty) compileOnly(libs.netty)
compileOnly(libs.brigadier) compileOnly(libs.brigadier)
compileOnly(libs.fastutil) compileOnly(libs.fastutil)
implementation(libs.anvilgui)
} }
widener { widener {
@@ -0,0 +1,890 @@
package net.wesjd.anvilgui;
import lombok.Getter;
import lombok.NonNull;
import net.md_5.bungee.api.chat.BaseComponent;
import net.wesjd.anvilgui.version.VersionWrapper;
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 = VersionWrapper.INSTANCE;
/**
* 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 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<Integer> interactableSlots;
/** An {@link Consumer} that is called when the anvil GUI is close */
private final Consumer<StateSnapshot> 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
*/
@Getter
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 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,
Set<Integer> interactableSlots,
Consumer<StateSnapshot> closeListener,
boolean concurrentClickHandlerExecution,
ClickHandler clickHandler) {
this.plugin = plugin;
this.player = player;
this.mainThreadExecutor = mainThreadExecutor;
this.titleComponent = titleComponent;
this.initialContents = initialContents;
this.preventClose = preventClose;
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);
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, new ItemStack(Material.AIR));
}
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(@NonNull String literalTitle, boolean preserveRenameText) {
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(@NonNull String json, boolean preserveRenameText) {
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) {
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);
}
}
/**
* 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(true);
if (clickHandlerRunning && !concurrentClickHandlerExecution) {
// A click handler is running, don't launch another one
return;
}
final CompletableFuture<List<ResponseAction>> actionsFuture =
clickHandler.apply(rawSlot, StateSnapshot.fromAnvilGUI(AnvilGUI.this));
final Consumer<List<ResponseAction>> 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)) {
event.setCancelled(true);
}
}
@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<StateSnapshot> 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 set of integers containing the slot numbers that should be modifiable by the user. */
private Set<Integer> 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(@NonNull Executor executor) {
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;
}
/**
* 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<Integer> 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(@NonNull Consumer<StateSnapshot> closeListener) {
this.closeListener = closeListener;
return this;
}
/**
* Do an action when a slot is clicked in the inventory
* <p>
* 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(@NonNull ClickHandler clickHandler) {
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.
* <p>
* 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(@NonNull BiFunction<Integer, StateSnapshot, List<ResponseAction>> clickHandler) {
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(@NonNull Plugin plugin) {
this.plugin = plugin;
return this;
}
/**
* Sets the initial item-text that is displayed to the user.
* <br><br>
* 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(@NonNull String text) {
this.itemText = text;
return this;
}
/**
* Sets the AnvilGUI title that is to be displayed to the user.
* <br>
* 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(@NonNull String title) {
this.titleComponent = WRAPPER.literalChatComponent(title);
return this;
}
/**
* Sets the AnvilGUI title that is to be displayed to the user.
* <br>
* 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(@NonNull String json) {
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(@NonNull ItemStack item) {
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(@NonNull Player player) {
if (plugin == null) {
throw new IllegalStateException("An AnvilGUI plugin has not been set!");
}
if (clickHandler == null) {
throw new IllegalStateException("An AnvilGUI clickHandler has not been set!");
}
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,
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<Integer, StateSnapshot, CompletableFuture<List<ResponseAction>>> {}
/** 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<AnvilGUI, Player> {
/**
* 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 <code>null</code>
*/
static ResponseAction replaceInputText(@NonNull String text) {
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(@NonNull String literalTitle, boolean preserveRenameText) {
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(@NonNull String json, boolean preserveRenameText) {
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(@NonNull Inventory otherInventory) {
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(@NonNull Runnable runnable) {
return (anvilgui, player) -> runnable.run();
}
}
/**
* Represents a response when the player clicks the output item in the anvil GUI
* @deprecated Since 1.6.2, use {@link ResponseAction}
*/
@Deprecated
public static class Response {
/**
* Returns an {@link Response} object for when the anvil GUI is to close
* @return An {@link Response} object for when the anvil GUI is to display text to the user
* @deprecated Since 1.6.2, use {@link ResponseAction#close()}
*/
public static List<ResponseAction> close() {
return Arrays.asList(ResponseAction.close());
}
/**
* Returns an {@link Response} object for when the anvil GUI is to display text to the user
*
* @param text The text that is to be displayed to the user
* @return A list containing the {@link ResponseAction} for legacy compat
* @deprecated Since 1.6.2, use {@link ResponseAction#replaceInputText(String)}
*/
public static List<ResponseAction> text(String text) {
return Arrays.asList(ResponseAction.replaceInputText(text));
}
/**
* Returns an {@link Response} object for when the GUI should open the provided inventory
*
* @param inventory The inventory to open
* @return A list containing the {@link ResponseAction} for legacy compat
* @deprecated Since 1.6.2, use {@link ResponseAction#openInventory(Inventory)}
*/
public static List<ResponseAction> openInventory(Inventory inventory) {
return Arrays.asList(ResponseAction.openInventory(inventory));
}
}
/**
* 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() : "";
}
}
}
@@ -0,0 +1,151 @@
package net.wesjd.anvilgui.version;
import net.minecraft.core.BlockPos;
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.AnvilMenu;
import net.minecraft.world.inventory.ContainerLevelAccess;
import net.minecraft.world.inventory.MenuType;
import net.minecraft.world.inventory.Slot;
import org.bukkit.craftbukkit.CraftWorld;
import org.bukkit.craftbukkit.entity.CraftPlayer;
import org.bukkit.craftbukkit.event.CraftEventFactory;
import org.bukkit.craftbukkit.util.CraftChatMessage;
import org.bukkit.entity.Player;
import org.bukkit.event.inventory.InventoryCloseEvent;
import org.bukkit.inventory.Inventory;
public final class VersionWrapper {
public static final VersionWrapper INSTANCE = new VersionWrapper();
/**
* Interface implemented by the custom NMS AnvilContainer used to interact with it directly
*/
public interface AnvilContainerWrapper {
/**
* Retrieves the raw text that has been entered into the Anvil at the moment
* <br><br>
* 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();
}
private static class AnvilContainer extends AnvilMenu implements AnvilContainerWrapper {
public AnvilContainer(Player player, int containerId, Component guiTitle) {
super(
containerId,
((CraftPlayer) player).getHandle().getInventory(),
ContainerLevelAccess.create(((CraftWorld) player.getWorld()).getHandle(), new BlockPos(0, 0, 0))
);
this.checkReachable = false;
setTitle(guiTitle);
}
public int getContainerId() {
return this.containerId;
}
public String getRenameText() {
return this.itemName;
}
public void setRenameText(String text) {
// If an item is present in the left input slot change its hover name to the literal text.
Slot inputLeft = getSlot(0);
if (inputLeft.hasItem()) {
inputLeft.getItem()
.set(DataComponents.CUSTOM_NAME, Component.literal(text));
}
}
public Inventory getBukkitInventory() {
return this.getBukkitView().getTopInventory();
}
}
private int getRealNextContainerId(Player player) {
return toNMS(player).nextContainerCounter();
}
/**
* Turns a {@link Player} into an NMS one
*
* @param player The player to be converted
* @return the NMS EntityPlayer
*/
private ServerPlayer toNMS(Player player) {
return ((CraftPlayer) player).getHandle();
}
public int getNextContainerId(Player player, AnvilContainerWrapper container) {
return ((AnvilContainer) container).getContainerId();
}
public void handleInventoryCloseEvent(Player player) {
CraftEventFactory.handleInventoryCloseEvent(toNMS(player), InventoryCloseEvent.Reason.UNKNOWN);
toNMS(player).doCloseContainer();
}
public void sendPacketOpenWindow(Player player, int containerId, Object inventoryTitle) {
toNMS(player).connection.send(new ClientboundOpenScreenPacket(containerId, MenuType.ANVIL, (Component) inventoryTitle));
}
public void sendPacketCloseWindow(Player player, int containerId) {
toNMS(player).connection.send(new ClientboundContainerClosePacket(containerId));
}
public void sendPacketExperienceChange(Player player, int experienceLevel) {
toNMS(player).connection.send(new ClientboundSetExperiencePacket(0f, 0, experienceLevel));
}
public void setActiveContainerDefault(Player player) {
toNMS(player).containerMenu = toNMS(player).inventoryMenu;
}
public void setActiveContainer(Player player, AnvilContainerWrapper container) {
toNMS(player).containerMenu = (AnvilMenu) container;
}
public void setActiveContainerId(AnvilContainerWrapper container, int containerId) {
}
public void addActiveContainerSlotListener(AnvilContainerWrapper container, Player player) {
toNMS(player).initMenu((AnvilMenu) container);
}
public AnvilContainerWrapper newContainerAnvil(Player player, Object title) {
return new AnvilContainer(player, getRealNextContainerId(player), (Component) title);
}
public Object literalChatComponent(String content) {
return Component.literal(content);
}
public Object jsonChatComponent(String json) {
return CraftChatMessage.fromJSON(json);
}
}