diff --git a/README.md b/README.md index adfacde..85d2330 100644 --- a/README.md +++ b/README.md @@ -36,8 +36,8 @@ Then add dependencies: ```kotlin dependencies { - implementation("gg.moonrise.engine:plugin-engine-paper:1.1.1") - // or: implementation("gg.moonrise.engine:plugin-engine-common:1.1.1") + implementation("gg.moonrise.engine:plugin-engine-paper:1.1.2") + // or: implementation("gg.moonrise.engine:plugin-engine-common:1.1.2") } ``` @@ -61,7 +61,7 @@ dependencies { gg.moonrise.engine plugin-engine-paper - 1.1.1 + 1.1.2 ``` @@ -182,7 +182,7 @@ public final class ExampleMenu extends ChestMenu { } ``` -Menus are cached per-player and handled by `PlayerInventoryController`. +Menus are backed by Bukkit `InventoryHolder` instances and handled by `PlayerInventoryController`. ## Messages and placeholders diff --git a/common/build.gradle.kts b/common/build.gradle.kts index 9dca211..5afff6b 100644 --- a/common/build.gradle.kts +++ b/common/build.gradle.kts @@ -6,7 +6,7 @@ plugins { var id = "plugin-engine-common" var domain = "gg.moonrise.engine" -var apiVersion = "1.1.1" +var apiVersion = "1.1.2" repositories { mavenCentral() @@ -50,6 +50,9 @@ java { toolchain { languageVersion.set(JavaLanguageVersion.of(21)) } + + withSourcesJar() + withJavadocJar() } tasks.shadowJar { @@ -65,9 +68,9 @@ tasks.test { publishing { publications { create("mavenJava") { - artifact(tasks.shadowJar) { - builtBy(tasks.shadowJar) - } + from(components["shadow"]) + artifact(tasks.named("sourcesJar")) + artifact(tasks.named("javadocJar")) groupId = domain artifactId = id diff --git a/common/src/main/java/gg/moonrise/engine/message/Message.java b/common/src/main/java/gg/moonrise/engine/message/Message.java index 1b4bb7f..c193962 100644 --- a/common/src/main/java/gg/moonrise/engine/message/Message.java +++ b/common/src/main/java/gg/moonrise/engine/message/Message.java @@ -24,7 +24,7 @@ public static Message of(String content) { } /** - * Creates a Message object from multiple lines of strings, joining them with the delimiter. + * Creates a Message object from multiple lines of strings, joining them with the {@code } delimiter. * @param lines the lines of strings to join * @return a Message object containing the joined content */ @@ -33,7 +33,7 @@ public static Message of(String... lines) { } /** - * Creates a Message object from a list of strings, joining them with the delimiter. + * Creates a Message object from a list of strings, joining them with the {@code } delimiter. * @param lines the list of strings to join * @return a Message object containing the joined content */ diff --git a/common/src/main/java/gg/moonrise/engine/util/OptionalBool.java b/common/src/main/java/gg/moonrise/engine/util/OptionalBool.java index bec9ce6..5fdd90c 100644 --- a/common/src/main/java/gg/moonrise/engine/util/OptionalBool.java +++ b/common/src/main/java/gg/moonrise/engine/util/OptionalBool.java @@ -13,7 +13,7 @@ *

The class uses a singleton pattern for the two possible instances ({@code true} and {@code false}) * to ensure memory efficiency and reference equality for instances with the same boolean value.

* - *

Usage Examples:

+ *

Usage Examples:

*
{@code
  * OptionalBool condition = OptionalBool.of(someCondition);
  *
@@ -218,4 +218,3 @@ public String toString() {
         return "OptionalBool[" + value + "]";
     }
 }
-
diff --git a/paper/build.gradle.kts b/paper/build.gradle.kts
index dccac76..a9a25a1 100644
--- a/paper/build.gradle.kts
+++ b/paper/build.gradle.kts
@@ -6,7 +6,7 @@ plugins {
 
 var id = "plugin-engine-paper"
 var domain = "gg.moonrise.engine"
-var apiVersion = "1.1.1"
+var apiVersion = "1.1.2"
 
 repositories {
     mavenCentral()
@@ -39,12 +39,23 @@ dependencies {
     // Lombok
     compileOnly("org.projectlombok:lombok:1.18.32")
     annotationProcessor("org.projectlombok:lombok:1.18.32")
+
+    // Testing
+    testImplementation(platform("org.junit:junit-bom:5.12.2"))
+    testImplementation("org.junit.jupiter:junit-jupiter")
+    testImplementation("org.mockbukkit.mockbukkit:mockbukkit-v1.21:4.76.1")
+    testImplementation("io.papermc.paper:paper-api:1.21.8-R0.1-SNAPSHOT")
+    testImplementation("net.kyori:adventure-text-serializer-plain:4.26.1")
+    testRuntimeOnly("org.junit.platform:junit-platform-launcher")
 }
 
 java {
     toolchain {
         languageVersion.set(JavaLanguageVersion.of(21))
     }
+
+    withSourcesJar()
+    withJavadocJar()
 }
 
 tasks.jar {
@@ -57,12 +68,16 @@ tasks.shadowJar {
     archiveClassifier.set("")
 }
 
+tasks.test {
+    useJUnitPlatform()
+}
+
 publishing {
     publications {
         create("mavenJava") {
-            artifact(tasks.shadowJar) {
-                builtBy(tasks.shadowJar)
-            }
+            from(components["shadow"])
+            artifact(tasks.named("sourcesJar"))
+            artifact(tasks.named("javadocJar"))
 
             groupId = domain
             artifactId = id
diff --git a/paper/src/main/java/gg/moonrise/engine/paper/gui/ChestMenu.java b/paper/src/main/java/gg/moonrise/engine/paper/gui/ChestMenu.java
index 0f8cc1c..606ebf3 100644
--- a/paper/src/main/java/gg/moonrise/engine/paper/gui/ChestMenu.java
+++ b/paper/src/main/java/gg/moonrise/engine/paper/gui/ChestMenu.java
@@ -3,13 +3,16 @@
 import com.google.common.base.Preconditions;
 import gg.moonrise.engine.message.util.MiniMessageUtil;
 import gg.moonrise.engine.paper.gui.button.Button;
+import gg.moonrise.engine.paper.gui.holder.ChestMenuHolder;
 import gg.moonrise.engine.paper.gui.util.MenuInteractionUtil;
 import gg.moonrise.engine.paper.gui.util.SafeUtil;
 import net.kyori.adventure.text.Component;
+import org.bukkit.Bukkit;
 import org.bukkit.entity.Player;
 import org.bukkit.event.inventory.InventoryClickEvent;
 import org.bukkit.event.inventory.InventoryCloseEvent;
 import org.bukkit.event.inventory.InventoryOpenEvent;
+import org.bukkit.inventory.Inventory;
 import org.bukkit.inventory.InventoryView;
 import org.bukkit.inventory.ItemStack;
 import org.bukkit.persistence.PersistentDataType;
@@ -20,8 +23,7 @@
 /**
  * Represents a chest-based GUI menu for players.
  * This class manages button placement, click handling, and automatic refreshing
- * of dynamic buttons. Menus are cached per-player and automatically cleaned up
- * when closed.
+ * of dynamic buttons. Menus are routed through their inventory holder.
  */
 public abstract class ChestMenu implements ChestInterface {
 
@@ -32,7 +34,7 @@ public abstract class ChestMenu implements ChestInterface {
     private final Map buttonById = new HashMap<>();
     private final Map refreshingButtons = new HashMap<>();
 
-    protected InventoryView inventory;
+    protected Inventory inventory;
     private boolean cancelClicks = true;
 
     private final Player player;
@@ -97,7 +99,7 @@ public void onOpen(Player player, InventoryOpenEvent event) {
      */
     @Override
     public void onClose(Player player, InventoryCloseEvent event) {
-        UserInterface.invalidateFromCache(player.getUniqueId());
+        invalidate();
     }
 
     /**
@@ -116,7 +118,7 @@ public void onClick(Player player, InventoryClickEvent event) {
      */
     @Override
     public void open() {
-        MenuInteractionUtil.openMenu(player, this, this::refresh, () -> inventory);
+        MenuInteractionUtil.openMenu(player, this::refresh, () -> inventory);
     }
 
     /**
@@ -135,7 +137,7 @@ public void invalidate() {
     @Override
     public void refresh() {
         if (inventory == null)
-            inventory = typeFromRows(rows).create(player, title);
+            inventory = createInventory();
 
         clearInventory(inventory);
 
@@ -252,13 +254,28 @@ public int getButtonCount() {
         return buttons.size();
     }
 
+    /**
+     * Get the Inventory associated with this ChestMenu
+     * @return The Inventory
+     */
+    @Override
+    public Inventory getInventory() {
+        return inventory;
+    }
+
     /**
      * Get the InventoryView associated with this ChestMenu
      * @return The InventoryView
      */
     @Override
+    @Deprecated(forRemoval = false)
     public InventoryView getView() {
-        return inventory;
+        if (inventory == null) return null;
+
+        InventoryView view = player.getOpenInventory();
+        if (!view.getTopInventory().equals(inventory)) return null;
+
+        return view;
     }
 
     /**
@@ -286,4 +303,11 @@ public boolean checkCancelClick(int slot) {
         return MenuInteractionUtil.checkCancelClick(inventory, buttonById, slot);
     }
 
+    private Inventory createInventory() {
+        ChestMenuHolder holder = new ChestMenuHolder(this);
+        Inventory created = Bukkit.createInventory(holder, rows * 9, title);
+        holder.setInventory(created);
+        return created;
+    }
+
 }
diff --git a/paper/src/main/java/gg/moonrise/engine/paper/gui/HopperMenu.java b/paper/src/main/java/gg/moonrise/engine/paper/gui/HopperMenu.java
index 8176a1a..3d5b128 100644
--- a/paper/src/main/java/gg/moonrise/engine/paper/gui/HopperMenu.java
+++ b/paper/src/main/java/gg/moonrise/engine/paper/gui/HopperMenu.java
@@ -2,16 +2,19 @@
 
 import gg.moonrise.engine.message.util.MiniMessageUtil;
 import gg.moonrise.engine.paper.gui.button.Button;
+import gg.moonrise.engine.paper.gui.holder.HopperMenuHolder;
 import gg.moonrise.engine.paper.gui.util.MenuInteractionUtil;
 import gg.moonrise.engine.paper.gui.util.SafeUtil;
 import net.kyori.adventure.text.Component;
+import org.bukkit.Bukkit;
 import org.bukkit.entity.Player;
 import org.bukkit.event.inventory.InventoryClickEvent;
 import org.bukkit.event.inventory.InventoryCloseEvent;
 import org.bukkit.event.inventory.InventoryOpenEvent;
+import org.bukkit.inventory.Inventory;
 import org.bukkit.inventory.InventoryView;
+import org.bukkit.event.inventory.InventoryType;
 import org.bukkit.inventory.ItemStack;
-import org.bukkit.inventory.MenuType;
 import org.bukkit.persistence.PersistentDataType;
 import org.jetbrains.annotations.Contract;
 
@@ -20,8 +23,7 @@
 /**
  * Represents a chest-based GUI menu for players.
  * This class manages button placement, click handling, and automatic refreshing
- * of dynamic buttons. Menus are cached per-player and automatically cleaned up
- * when closed.
+ * of dynamic buttons. Menus are routed through their inventory holder.
  */
 public abstract class HopperMenu implements UserInterface {
 
@@ -31,7 +33,7 @@ public abstract class HopperMenu implements UserInterface {
     private final Map buttonById = new HashMap<>();
     private final Map refreshingButtons = new HashMap<>();
 
-    protected InventoryView inventory;
+    protected Inventory inventory;
     private boolean cancelClicks = true;
 
     private final Player player;
@@ -81,7 +83,7 @@ public void onOpen(Player player, InventoryOpenEvent event) {
      */
     @Override
     public void onClose(Player player, InventoryCloseEvent event) {
-        UserInterface.invalidateFromCache(player.getUniqueId());
+        invalidate();
     }
 
     /**
@@ -100,7 +102,7 @@ public void onClick(Player player, InventoryClickEvent event) {
      */
     @Override
     public void open() {
-        MenuInteractionUtil.openMenu(player, this, this::refresh, () -> inventory);
+        MenuInteractionUtil.openMenu(player, this::refresh, () -> inventory);
     }
 
     /**
@@ -119,7 +121,7 @@ public void invalidate() {
     @Override
     public void refresh() {
         if (inventory == null)
-            inventory = MenuType.HOPPER.create(player, title);
+            inventory = createInventory();
 
         clearInventory(inventory);
 
@@ -169,7 +171,7 @@ public void addButton(int slot, Button button) {
      * @param button The button to add
      */
     public void addButton(Button button) {
-        for (int i = 0; i < inventory.getTopInventory().getSize(); i++) {
+        for (int i = 0; i < InventoryType.HOPPER.getDefaultSize(); i++) {
             if (buttons.containsKey(i)) continue;
 
             addButton(i, button);
@@ -231,13 +233,28 @@ public int getButtonCount() {
         return buttons.size();
     }
 
+    /**
+     * Get the Inventory associated with this menu
+     * @return The Inventory
+     */
+    @Override
+    public Inventory getInventory() {
+        return inventory;
+    }
+
     /**
      * Get the InventoryView associated with this menu
      * @return The InventoryView
      */
     @Override
+    @Deprecated(forRemoval = false)
     public InventoryView getView() {
-        return inventory;
+        if (inventory == null) return null;
+
+        InventoryView view = player.getOpenInventory();
+        if (!view.getTopInventory().equals(inventory)) return null;
+
+        return view;
     }
 
     /**
@@ -265,4 +282,11 @@ public boolean checkCancelClick(int slot) {
         return MenuInteractionUtil.checkCancelClick(inventory, buttonById, slot);
     }
 
+    private Inventory createInventory() {
+        HopperMenuHolder holder = new HopperMenuHolder(this);
+        Inventory created = Bukkit.createInventory(holder, InventoryType.HOPPER, title);
+        holder.setInventory(created);
+        return created;
+    }
+
 }
diff --git a/paper/src/main/java/gg/moonrise/engine/paper/gui/PaginatedMenu.java b/paper/src/main/java/gg/moonrise/engine/paper/gui/PaginatedMenu.java
index 9936335..a63324d 100644
--- a/paper/src/main/java/gg/moonrise/engine/paper/gui/PaginatedMenu.java
+++ b/paper/src/main/java/gg/moonrise/engine/paper/gui/PaginatedMenu.java
@@ -3,14 +3,17 @@
 import com.google.common.base.Preconditions;
 import gg.moonrise.engine.message.util.MiniMessageUtil;
 import gg.moonrise.engine.paper.gui.button.Button;
+import gg.moonrise.engine.paper.gui.holder.PaginatedMenuHolder;
 import gg.moonrise.engine.paper.gui.util.MenuInteractionUtil;
 import gg.moonrise.engine.paper.gui.util.SafeUtil;
 import lombok.extern.slf4j.Slf4j;
 import net.kyori.adventure.text.Component;
+import org.bukkit.Bukkit;
 import org.bukkit.entity.Player;
 import org.bukkit.event.inventory.InventoryClickEvent;
 import org.bukkit.event.inventory.InventoryCloseEvent;
 import org.bukkit.event.inventory.InventoryOpenEvent;
+import org.bukkit.inventory.Inventory;
 import org.bukkit.inventory.InventoryView;
 import org.bukkit.inventory.ItemStack;
 import org.bukkit.persistence.PersistentDataType;
@@ -22,8 +25,7 @@
 /**
  * Represents a chest-based GUI menu for players.
  * This class manages button placement, click handling, and automatic refreshing
- * of dynamic buttons. Menus are cached per-player and automatically cleaned up
- * when closed.
+ * of dynamic buttons. Menus are routed through their inventory holder.
  */
 @Slf4j
 public abstract class PaginatedMenu implements ChestInterface {
@@ -45,7 +47,7 @@ public abstract class PaginatedMenu implements ChestInterface {
     private final List contentSlots = new ArrayList<>();
     private final Map refreshingContentButtons = new HashMap<>();
 
-    protected InventoryView inventory;
+    protected Inventory inventory;
     private boolean cancelClicks = true;
 
     /**
@@ -183,13 +185,13 @@ public void onOpen(Player player, InventoryOpenEvent event) {
 
     /**
      * Handle the event when a player closes the inventory.
-     * This method invalidates the menu from the cache to free up resources.
+     * This method invalidates the menu to free up resources.
      * @param player The player who closed the inventory.
      * @param event The InventoryCloseEvent triggered by the player closing the inventory.
      */
     @Override
     public void onClose(Player player, InventoryCloseEvent event) {
-        UserInterface.invalidateFromCache(player.getUniqueId());
+        invalidate();
     }
 
     /**
@@ -205,12 +207,11 @@ public void onClick(Player player, InventoryClickEvent event) {
 
     /**
      * Open the chest menu for the player.
-     * This method refreshes the menu, invalidates any existing cached menu for the player,
-     * caches the current menu, and opens the inventory for the player.
+     * This method refreshes the menu and opens the inventory for the player.
      */
     @Override
     public void open() {
-        MenuInteractionUtil.openMenu(player, this, this::refresh, () -> inventory);
+        MenuInteractionUtil.openMenu(player, this::refresh, () -> inventory);
     }
 
     /**
@@ -231,7 +232,7 @@ public void invalidate() {
     @Override
     public void refresh() {
         if (inventory == null)
-            inventory = typeFromRows(rows).create(player, title);
+            inventory = createInventory();
 
         clearInventory(inventory);
 
@@ -451,13 +452,28 @@ public void changePage(int page) {
         refresh();
     }
 
+    /**
+     * Get the Inventory of this menu
+     * @return The Inventory
+     */
+    @Override
+    public Inventory getInventory() {
+        return inventory;
+    }
+
     /**
      * Get the InventoryView of this menu
      * @return The InventoryView
      */
     @Override
+    @Deprecated(forRemoval = false)
     public InventoryView getView() {
-        return inventory;
+        if (inventory == null) return null;
+
+        InventoryView view = player.getOpenInventory();
+        if (!view.getTopInventory().equals(inventory)) return null;
+
+        return view;
     }
 
     /**
@@ -502,4 +518,11 @@ private void renderButtonToSlot(int slot, Button button) {
         button.onAddToInventory(inventory);
     }
 
+    private Inventory createInventory() {
+        PaginatedMenuHolder holder = new PaginatedMenuHolder(this);
+        Inventory created = Bukkit.createInventory(holder, rows * 9, title);
+        holder.setInventory(created);
+        return created;
+    }
+
 }
diff --git a/paper/src/main/java/gg/moonrise/engine/paper/gui/UserInterface.java b/paper/src/main/java/gg/moonrise/engine/paper/gui/UserInterface.java
index 932e520..77c6ec4 100644
--- a/paper/src/main/java/gg/moonrise/engine/paper/gui/UserInterface.java
+++ b/paper/src/main/java/gg/moonrise/engine/paper/gui/UserInterface.java
@@ -4,20 +4,15 @@
 import org.bukkit.event.inventory.InventoryClickEvent;
 import org.bukkit.event.inventory.InventoryCloseEvent;
 import org.bukkit.event.inventory.InventoryOpenEvent;
+import org.bukkit.inventory.Inventory;
 import org.bukkit.inventory.InventoryView;
 
-import java.util.HashMap;
-import java.util.Map;
-import java.util.UUID;
-
 /**
  * Represents the UserInterface interface.
  */
 
 public interface UserInterface {
 
-    Map CACHE = new HashMap<>();
-
     /**
      * Called when a player opens an inventory.
      *
@@ -52,20 +47,28 @@ public interface UserInterface {
      */
     void refresh();
 
+    /**
+     * Gets the Inventory associated with this UserInterface.
+     *
+     * @return The Inventory of this UserInterface.
+     */
+    Inventory getInventory();
+
     /**
      * Gets the InventoryView associated with this UserInterface.
      *
      * @return The InventoryView of this UserInterface.
      */
+    @Deprecated(forRemoval = false)
     InventoryView getView();
 
     /**
-     * Clears the inventory view by setting all items in the top inventory to null.
-     * @param view The InventoryView to clear.
+     * Clears the inventory by setting all items to null.
+     * @param inventory The Inventory to clear.
      */
-    default void clearInventory(InventoryView view) {
-        for (int i = 0; i < view.getTopInventory().getSize(); i++) {
-            view.getTopInventory().setItem(i, null);
+    default void clearInventory(Inventory inventory) {
+        for (int i = 0; i < inventory.getSize(); i++) {
+            inventory.setItem(i, null);
         }
     }
 
@@ -86,11 +89,4 @@ default void clearInventory(InventoryView view) {
      * Invalidate the current state of the UserInterface.
      */
     void invalidate();
-
-    static void invalidateFromCache(UUID uuid) {
-        UserInterface ui = CACHE.remove(uuid);
-        if (ui == null) return;
-
-        ui.invalidate();
-    }
 }
diff --git a/paper/src/main/java/gg/moonrise/engine/paper/gui/button/Button.java b/paper/src/main/java/gg/moonrise/engine/paper/gui/button/Button.java
index e872e18..f21bcd0 100644
--- a/paper/src/main/java/gg/moonrise/engine/paper/gui/button/Button.java
+++ b/paper/src/main/java/gg/moonrise/engine/paper/gui/button/Button.java
@@ -2,8 +2,10 @@
 
 import lombok.RequiredArgsConstructor;
 import org.bukkit.NamespacedKey;
+import org.bukkit.entity.HumanEntity;
 import org.bukkit.entity.Player;
 import org.bukkit.event.inventory.InventoryClickEvent;
+import org.bukkit.inventory.Inventory;
 import org.bukkit.inventory.InventoryView;
 import org.bukkit.inventory.ItemStack;
 import org.jetbrains.annotations.Contract;
@@ -18,7 +20,7 @@
 @RequiredArgsConstructor
 public final class Button {
 
-    public static final NamespacedKey KEY = new NamespacedKey("skylands-ui", "button-uuid");
+    public static final NamespacedKey KEY = new NamespacedKey("engine-ui", "button-uuid");
 
     private final Function displayItem;
     private final ButtonClickAction clickAction;
@@ -26,15 +28,15 @@ public final class Button {
     private final long refreshIntervalTicks;
     private final boolean cancelClicks;
     private long lastRefreshTime = 0L;
-    private InventoryView boundingInventory;
+    private Inventory inventory;
 
 
     /**
      * Executes onAddToInventory.
      * @param inventory the inventory
      */
-    public void onAddToInventory(InventoryView inventory) {
-        this.boundingInventory = inventory;
+    public void onAddToInventory(Inventory inventory) {
+        this.inventory = inventory;
     }
 
     /**
@@ -75,10 +77,26 @@ public ItemStack item(Player player) {
 
     /**
      * Gets the inventory that this button is bound to.
+     * @return the inventory
+     */
+    public Inventory inventory() {
+        return inventory;
+    }
+
+    /**
+     * Gets the inventory view that this button is bound to.
      * @return the bounding inventory
      */
+    @Deprecated(forRemoval = false)
     public InventoryView boundingInventory() {
-        return boundingInventory;
+        if (inventory == null) return null;
+
+        for (HumanEntity viewer : inventory.getViewers()) {
+            InventoryView view = viewer.getOpenInventory();
+            if (view.getTopInventory().equals(inventory)) return view;
+        }
+
+        return null;
     }
 
     /**
diff --git a/paper/src/main/java/gg/moonrise/engine/paper/gui/controller/PlayerInventoryController.java b/paper/src/main/java/gg/moonrise/engine/paper/gui/controller/PlayerInventoryController.java
index 9cee8f6..14b19ff 100644
--- a/paper/src/main/java/gg/moonrise/engine/paper/gui/controller/PlayerInventoryController.java
+++ b/paper/src/main/java/gg/moonrise/engine/paper/gui/controller/PlayerInventoryController.java
@@ -1,6 +1,7 @@
 package gg.moonrise.engine.paper.gui.controller;
 
 import gg.moonrise.engine.paper.gui.UserInterface;
+import gg.moonrise.engine.paper.gui.holder.InteractiveMenuHolder;
 import gg.moonrise.moss.spring.SpringComponent;
 import org.bukkit.entity.HumanEntity;
 import org.bukkit.entity.Player;
@@ -9,9 +10,8 @@
 import org.bukkit.event.Listener;
 import org.bukkit.event.inventory.InventoryClickEvent;
 import org.bukkit.event.inventory.InventoryCloseEvent;
-import org.bukkit.event.inventory.InventoryEvent;
 import org.bukkit.event.inventory.InventoryOpenEvent;
-import org.bukkit.inventory.InventoryView;
+import org.bukkit.inventory.InventoryHolder;
 
 /**
  * Represents the PlayerInventoryController class.
@@ -29,10 +29,10 @@ public class PlayerInventoryController implements Listener {
     public void onOpen(InventoryOpenEvent event) {
         HumanEntity client = event.getPlayer();
 
-        UserInterface ui = getUserInterface(client, event);
-        if (ui == null) return;
+        InteractiveMenuHolder holder = getMenuHolder(event.getInventory().getHolder());
+        if (holder == null) return;
 
-        ui.onOpen((Player) client, event);
+        holder.onOpen((Player) client, event);
     }
 
 
@@ -45,10 +45,10 @@ public void onOpen(InventoryOpenEvent event) {
     public void onClose(InventoryCloseEvent event) {
         HumanEntity client = event.getPlayer();
 
-        UserInterface ui = getUserInterface(client, event);
-        if (ui == null) return;
+        InteractiveMenuHolder holder = getMenuHolder(event.getInventory().getHolder());
+        if (holder == null) return;
 
-        ui.onClose((Player) client, event);
+        holder.onClose((Player) client, event);
     }
 
 
@@ -61,39 +61,22 @@ public void onClose(InventoryCloseEvent event) {
     public void onClick(InventoryClickEvent event) {
         HumanEntity client = event.getWhoClicked();
 
-        UserInterface ui = getUserInterface(client, event);
-        if (ui == null) return;
+        InteractiveMenuHolder holder = getMenuHolder(event.getInventory().getHolder());
+        if (holder == null) return;
+
+        UserInterface ui = holder.getMenu();
 
         if (ui.cancelClicks() || ui.checkCancelClick(event.getSlot())) {
             event.setCancelled(true);
             event.setResult(Event.Result.DENY);
         }
 
-        ui.onClick((Player) client, event);
+        holder.onClick((Player) client, event);
     }
 
-    /**
-     * Retrieves the UserInterface associated with the given HumanEntity and InventoryEvent.
-     *
-     * @param client The HumanEntity (player) involved in the event.
-     * @param event  The InventoryEvent to check against.
-     * @return The UserInterface if it exists and matches the event's inventory view; otherwise, null.
-     */
-    private UserInterface getUserInterface(HumanEntity client, InventoryEvent event) {
-        UserInterface ui = UserInterface.CACHE.get(client.getUniqueId());
-        if (ui == null) return null;
-        if (ui.getView() == null) {
-            UserInterface.invalidateFromCache(client.getUniqueId());
-            return null;
-        }
-
-        InventoryView inventory = event.getView();
-        if (!ui.getView().equals(inventory)) {
-            UserInterface.invalidateFromCache(client.getUniqueId());
-            return null;
-        }
-
-        return ui;
+    private InteractiveMenuHolder getMenuHolder(InventoryHolder holder) {
+        if (!(holder instanceof InteractiveMenuHolder menuHolder)) return null;
+        return menuHolder;
     }
 
 }
diff --git a/paper/src/main/java/gg/moonrise/engine/paper/gui/holder/ChestMenuHolder.java b/paper/src/main/java/gg/moonrise/engine/paper/gui/holder/ChestMenuHolder.java
new file mode 100644
index 0000000..5c67686
--- /dev/null
+++ b/paper/src/main/java/gg/moonrise/engine/paper/gui/holder/ChestMenuHolder.java
@@ -0,0 +1,46 @@
+package gg.moonrise.engine.paper.gui.holder;
+
+import gg.moonrise.engine.paper.gui.ChestMenu;
+import lombok.RequiredArgsConstructor;
+import org.bukkit.entity.Player;
+import org.bukkit.event.inventory.InventoryClickEvent;
+import org.bukkit.event.inventory.InventoryCloseEvent;
+import org.bukkit.event.inventory.InventoryOpenEvent;
+import org.bukkit.inventory.Inventory;
+import org.jetbrains.annotations.NotNull;
+
+@RequiredArgsConstructor
+public final class ChestMenuHolder implements InteractiveMenuHolder {
+
+    private final ChestMenu menu;
+    private Inventory inventory;
+
+    @Override
+    public void onOpen(Player player, InventoryOpenEvent event) {
+        menu.onOpen(player, event);
+    }
+
+    @Override
+    public void onClose(Player player, InventoryCloseEvent event) {
+        menu.onClose(player, event);
+    }
+
+    @Override
+    public void onClick(Player player, InventoryClickEvent event) {
+        menu.onClick(player, event);
+    }
+
+    @Override
+    public ChestMenu getMenu() {
+        return menu;
+    }
+
+    @Override
+    public @NotNull Inventory getInventory() {
+        return inventory;
+    }
+
+    public void setInventory(Inventory inventory) {
+        this.inventory = inventory;
+    }
+}
diff --git a/paper/src/main/java/gg/moonrise/engine/paper/gui/holder/HopperMenuHolder.java b/paper/src/main/java/gg/moonrise/engine/paper/gui/holder/HopperMenuHolder.java
new file mode 100644
index 0000000..f78fc18
--- /dev/null
+++ b/paper/src/main/java/gg/moonrise/engine/paper/gui/holder/HopperMenuHolder.java
@@ -0,0 +1,46 @@
+package gg.moonrise.engine.paper.gui.holder;
+
+import gg.moonrise.engine.paper.gui.HopperMenu;
+import lombok.RequiredArgsConstructor;
+import org.bukkit.entity.Player;
+import org.bukkit.event.inventory.InventoryClickEvent;
+import org.bukkit.event.inventory.InventoryCloseEvent;
+import org.bukkit.event.inventory.InventoryOpenEvent;
+import org.bukkit.inventory.Inventory;
+import org.jetbrains.annotations.NotNull;
+
+@RequiredArgsConstructor
+public final class HopperMenuHolder implements InteractiveMenuHolder {
+
+    private final HopperMenu menu;
+    private Inventory inventory;
+
+    @Override
+    public void onOpen(Player player, InventoryOpenEvent event) {
+        menu.onOpen(player, event);
+    }
+
+    @Override
+    public void onClose(Player player, InventoryCloseEvent event) {
+        menu.onClose(player, event);
+    }
+
+    @Override
+    public void onClick(Player player, InventoryClickEvent event) {
+        menu.onClick(player, event);
+    }
+
+    @Override
+    public HopperMenu getMenu() {
+        return menu;
+    }
+
+    @Override
+    public @NotNull Inventory getInventory() {
+        return inventory;
+    }
+
+    public void setInventory(Inventory inventory) {
+        this.inventory = inventory;
+    }
+}
diff --git a/paper/src/main/java/gg/moonrise/engine/paper/gui/holder/InteractiveMenuHolder.java b/paper/src/main/java/gg/moonrise/engine/paper/gui/holder/InteractiveMenuHolder.java
new file mode 100644
index 0000000..2c2d3dc
--- /dev/null
+++ b/paper/src/main/java/gg/moonrise/engine/paper/gui/holder/InteractiveMenuHolder.java
@@ -0,0 +1,19 @@
+package gg.moonrise.engine.paper.gui.holder;
+
+import gg.moonrise.engine.paper.gui.UserInterface;
+import org.bukkit.entity.Player;
+import org.bukkit.event.inventory.InventoryClickEvent;
+import org.bukkit.event.inventory.InventoryCloseEvent;
+import org.bukkit.event.inventory.InventoryOpenEvent;
+import org.bukkit.inventory.InventoryHolder;
+
+public interface InteractiveMenuHolder extends InventoryHolder {
+
+    void onOpen(Player player, InventoryOpenEvent event);
+
+    void onClose(Player player, InventoryCloseEvent event);
+
+    void onClick(Player player, InventoryClickEvent event);
+
+    T getMenu();
+}
diff --git a/paper/src/main/java/gg/moonrise/engine/paper/gui/holder/PaginatedMenuHolder.java b/paper/src/main/java/gg/moonrise/engine/paper/gui/holder/PaginatedMenuHolder.java
new file mode 100644
index 0000000..bc266da
--- /dev/null
+++ b/paper/src/main/java/gg/moonrise/engine/paper/gui/holder/PaginatedMenuHolder.java
@@ -0,0 +1,46 @@
+package gg.moonrise.engine.paper.gui.holder;
+
+import gg.moonrise.engine.paper.gui.PaginatedMenu;
+import lombok.RequiredArgsConstructor;
+import org.bukkit.entity.Player;
+import org.bukkit.event.inventory.InventoryClickEvent;
+import org.bukkit.event.inventory.InventoryCloseEvent;
+import org.bukkit.event.inventory.InventoryOpenEvent;
+import org.bukkit.inventory.Inventory;
+import org.jetbrains.annotations.NotNull;
+
+@RequiredArgsConstructor
+public final class PaginatedMenuHolder implements InteractiveMenuHolder {
+
+    private final PaginatedMenu menu;
+    private Inventory inventory;
+
+    @Override
+    public void onOpen(Player player, InventoryOpenEvent event) {
+        menu.onOpen(player, event);
+    }
+
+    @Override
+    public void onClose(Player player, InventoryCloseEvent event) {
+        menu.onClose(player, event);
+    }
+
+    @Override
+    public void onClick(Player player, InventoryClickEvent event) {
+        menu.onClick(player, event);
+    }
+
+    @Override
+    public PaginatedMenu getMenu() {
+        return menu;
+    }
+
+    @Override
+    public @NotNull Inventory getInventory() {
+        return inventory;
+    }
+
+    public void setInventory(Inventory inventory) {
+        this.inventory = inventory;
+    }
+}
diff --git a/paper/src/main/java/gg/moonrise/engine/paper/gui/util/MenuInteractionUtil.java b/paper/src/main/java/gg/moonrise/engine/paper/gui/util/MenuInteractionUtil.java
index 5eeddc5..dcda2d5 100644
--- a/paper/src/main/java/gg/moonrise/engine/paper/gui/util/MenuInteractionUtil.java
+++ b/paper/src/main/java/gg/moonrise/engine/paper/gui/util/MenuInteractionUtil.java
@@ -1,12 +1,11 @@
 package gg.moonrise.engine.paper.gui.util;
 
-import gg.moonrise.engine.paper.gui.UserInterface;
 import gg.moonrise.engine.paper.gui.button.Button;
 import gg.moonrise.engine.paper.scheduler.Scheduler;
 import org.bukkit.entity.Player;
 import org.bukkit.event.Event;
 import org.bukkit.event.inventory.InventoryClickEvent;
-import org.bukkit.inventory.InventoryView;
+import org.bukkit.inventory.Inventory;
 import org.bukkit.inventory.ItemStack;
 import org.bukkit.persistence.PersistentDataType;
 
@@ -45,25 +44,22 @@ public static void processClick(
 
     public static void openMenu(
             Player player,
-            UserInterface ui,
             Runnable refreshAction,
-            Supplier inventorySupplier
+            Supplier inventorySupplier
     ) {
         refreshAction.run();
 
-        UserInterface.invalidateFromCache(player.getUniqueId());
-        UserInterface.CACHE.put(player.getUniqueId(), ui);
-
         Scheduler.entity(player).execute(() -> {
-            InventoryView view = inventorySupplier.get();
-            if (view != null) view.open();
+            Inventory inventory = inventorySupplier.get();
+            if (inventory != null) player.openInventory(inventory);
         }, 1);
     }
 
-    public static void refreshButton(InventoryView inventory, int slot, Button button) {
+    public static void refreshButton(Inventory inventory, int slot, Button button) {
         if (inventory == null) return;
+        if (inventory.getViewers().isEmpty()) return;
 
-        Player player = (Player) inventory.getPlayer();
+        Player player = (Player) inventory.getViewers().getFirst();
         ItemStack stack = button.item(player);
         if (stack == null || stack.getType().isAir()) return;
 
@@ -88,7 +84,7 @@ public static void addButton(
         refreshingButtons.put(slot, button);
     }
 
-    public static boolean checkCancelClick(InventoryView inventory, Map buttonById, int slot) {
+    public static boolean checkCancelClick(Inventory inventory, Map buttonById, int slot) {
         if (inventory == null) return false;
 
         ItemStack item = inventory.getItem(slot);
diff --git a/paper/src/main/java/gg/moonrise/engine/paper/gui/util/SafeUtil.java b/paper/src/main/java/gg/moonrise/engine/paper/gui/util/SafeUtil.java
index abfe706..456291d 100644
--- a/paper/src/main/java/gg/moonrise/engine/paper/gui/util/SafeUtil.java
+++ b/paper/src/main/java/gg/moonrise/engine/paper/gui/util/SafeUtil.java
@@ -1,7 +1,7 @@
 package gg.moonrise.engine.paper.gui.util;
 
 import lombok.extern.slf4j.Slf4j;
-import org.bukkit.inventory.InventoryView;
+import org.bukkit.inventory.Inventory;
 import org.bukkit.inventory.ItemStack;
 
 /**
@@ -12,18 +12,18 @@ public class SafeUtil {
 
     /**
      * Sets an item in the inventory at the specified slot safely.
-     * @param view the inventory to set the item in
+     * @param inventory the inventory to set the item in
      * @param slot the slot to set the item in
      * @param item the item to set
      */
-    public static void setInventoryItem(InventoryView view, int slot, ItemStack item) {
-        if (view == null) return;
-        if (slot < 0 || slot >= view.getTopInventory().getSize()) {
-            log.warn("Invalid inventory slot {} for inventory size {}", slot, view.getTopInventory().getSize());
+    public static void setInventoryItem(Inventory inventory, int slot, ItemStack item) {
+        if (inventory == null) return;
+        if (slot < 0 || slot >= inventory.getSize()) {
+            log.warn("Invalid inventory slot {} for inventory size {}", slot, inventory.getSize());
             return;
         }
 
-        view.getTopInventory().setItem(slot, item);
+        inventory.setItem(slot, item);
     }
 
 }
diff --git a/paper/src/test/java/gg/moonrise/engine/paper/cooldown/CooldownsTest.java b/paper/src/test/java/gg/moonrise/engine/paper/cooldown/CooldownsTest.java
new file mode 100644
index 0000000..79dcd1d
--- /dev/null
+++ b/paper/src/test/java/gg/moonrise/engine/paper/cooldown/CooldownsTest.java
@@ -0,0 +1,39 @@
+package gg.moonrise.engine.paper.cooldown;
+
+import gg.moonrise.engine.paper.support.MockBukkitTest;
+import net.kyori.adventure.text.Component;
+import org.bukkit.event.player.PlayerQuitEvent;
+import org.bukkit.event.player.PlayerQuitEvent.QuitReason;
+import org.junit.jupiter.api.Test;
+import org.mockbukkit.mockbukkit.entity.PlayerMock;
+
+import java.time.Duration;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class CooldownsTest extends MockBukkitTest {
+
+    @Test
+    void cooldownsExpireByTime() {
+        PlayerMock player = server.addPlayer();
+        String key = "action";
+
+        Cooldowns.addCooldown(player.getUniqueId(), key, Duration.ofSeconds(1));
+        assertTrue(Cooldowns.isOnCooldown(player.getUniqueId(), key));
+
+        Cooldowns.addCooldown(player.getUniqueId(), key, -1L);
+        assertFalse(Cooldowns.isOnCooldown(player.getUniqueId(), key));
+    }
+
+    @Test
+    void quitClearsPlayerCooldowns() {
+        PlayerMock player = server.addPlayer();
+        String key = "quit-action";
+        Cooldowns.addCooldown(player.getUniqueId(), key, Duration.ofMinutes(1));
+
+        new Cooldowns().onQuit(new PlayerQuitEvent(player, Component.empty(), QuitReason.DISCONNECTED));
+
+        assertFalse(Cooldowns.isOnCooldown(player.getUniqueId(), key));
+    }
+}
diff --git a/paper/src/test/java/gg/moonrise/engine/paper/data/AABBTest.java b/paper/src/test/java/gg/moonrise/engine/paper/data/AABBTest.java
new file mode 100644
index 0000000..06056c0
--- /dev/null
+++ b/paper/src/test/java/gg/moonrise/engine/paper/data/AABBTest.java
@@ -0,0 +1,46 @@
+package gg.moonrise.engine.paper.data;
+
+import org.bukkit.util.Vector;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class AABBTest {
+
+    @Test
+    void convertsToVectorsAndArrays() {
+        AABB box = new AABB(1.0, 2.0, 3.0, 4.0, 5.0, 6.0);
+
+        assertEquals(new Vector(1.0, 2.0, 3.0), box.minVector());
+        assertEquals(new Vector(4.0, 5.0, 6.0), box.maxVector());
+        assertEquals(box.minVector(), box.toVectors().first());
+        assertEquals(box.maxVector(), box.toVectors().second());
+        assertArrayEquals(new double[]{1.0, 2.0, 3.0}, box.minArray());
+        assertArrayEquals(new double[]{4.0, 5.0, 6.0}, box.maxArray());
+        assertArrayEquals(new double[]{1.0, 2.0, 3.0, 4.0, 5.0, 6.0}, box.toArrayPair());
+    }
+
+    @Test
+    void insideCheckIncludesBoundaries() {
+        AABB box = new AABB(1.0, 2.0, 3.0, 4.0, 5.0, 6.0);
+
+        assertTrue(box.isInside(1.0, 2.0, 3.0));
+        assertTrue(box.isInside(new Vector(4.0, 5.0, 6.0)));
+        assertTrue(box.isInside(2.5, 3.5, 4.5));
+        assertFalse(box.isInside(0.99, 3.5, 4.5));
+        assertFalse(box.isInside(2.5, 5.01, 4.5));
+    }
+
+    @Test
+    void createsFromVectorsAndArrayPair() {
+        AABB fromVectors = AABB.fromVectors(new Vector(1.0, 2.0, 3.0), new Vector(4.0, 5.0, 6.0));
+        AABB fromArray = AABB.fromArrayPair(new double[]{1.0, 2.0, 3.0, 4.0, 5.0, 6.0});
+
+        assertEquals(fromVectors, fromArray);
+        assertThrows(IllegalArgumentException.class, () -> AABB.fromArrayPair(new double[]{1.0, 2.0}));
+    }
+}
diff --git a/paper/src/test/java/gg/moonrise/engine/paper/gui/ChestMenuTest.java b/paper/src/test/java/gg/moonrise/engine/paper/gui/ChestMenuTest.java
new file mode 100644
index 0000000..646138c
--- /dev/null
+++ b/paper/src/test/java/gg/moonrise/engine/paper/gui/ChestMenuTest.java
@@ -0,0 +1,72 @@
+package gg.moonrise.engine.paper.gui;
+
+import gg.moonrise.engine.paper.gui.button.Button;
+import gg.moonrise.engine.paper.support.MockBukkitTest;
+import org.bukkit.Material;
+import org.bukkit.entity.Player;
+import org.bukkit.inventory.ItemStack;
+import org.bukkit.persistence.PersistentDataType;
+import org.junit.jupiter.api.Test;
+import org.mockbukkit.mockbukkit.entity.PlayerMock;
+
+import java.util.UUID;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class ChestMenuTest extends MockBukkitTest {
+
+    @Test
+    void addRemoveAndSlotBoundsWork() {
+        PlayerMock player = server.addPlayer();
+        TestChestMenu menu = new TestChestMenu(player, 1);
+        Button button = button(Material.DIAMOND);
+
+        menu.addButton(0, button);
+
+        assertTrue(menu.hasButton(0));
+        assertEquals(1, menu.getButtonCount());
+        assertEquals(button, menu.removeButton(0));
+        assertFalse(menu.hasButton(0));
+        assertNull(button.boundingInventory());
+        assertThrows(IllegalArgumentException.class, () -> menu.addButton(9, button));
+    }
+
+    @Test
+    void refreshWritesButtonItemsAndPersistentIds() {
+        PlayerMock player = server.addPlayer();
+        TestChestMenu menu = new TestChestMenu(player, 1);
+        Button button = Button.builder()
+                .item(viewer -> new ItemStack(Material.EMERALD))
+                .refresh(10L)
+                .build();
+
+        menu.addButton(4, button);
+        menu.refresh();
+
+        ItemStack rendered = menu.getView().getTopInventory().getItem(4);
+        assertNotNull(rendered);
+        assertEquals(Material.EMERALD, rendered.getType());
+        assertEquals(button.uuid().toString(), rendered.getPersistentDataContainer().get(Button.KEY, PersistentDataType.STRING));
+        assertEquals(menu.getView(), button.boundingInventory());
+        assertEquals(1, menu.getRefreshingButtons().size());
+        assertTrue(menu.checkCancelClick(4));
+    }
+
+    private static Button button(Material material) {
+        return Button.builder()
+                .item(player -> new ItemStack(material))
+                .build();
+    }
+
+    private static final class TestChestMenu extends ChestMenu {
+
+        private TestChestMenu(Player player, int rows) {
+            super(player, "Test", rows);
+        }
+    }
+}
diff --git a/paper/src/test/java/gg/moonrise/engine/paper/gui/HopperMenuTest.java b/paper/src/test/java/gg/moonrise/engine/paper/gui/HopperMenuTest.java
new file mode 100644
index 0000000..d93eddc
--- /dev/null
+++ b/paper/src/test/java/gg/moonrise/engine/paper/gui/HopperMenuTest.java
@@ -0,0 +1,55 @@
+package gg.moonrise.engine.paper.gui;
+
+import gg.moonrise.engine.paper.gui.button.Button;
+import gg.moonrise.engine.paper.support.MockBukkitTest;
+import org.bukkit.Material;
+import org.bukkit.entity.Player;
+import org.bukkit.inventory.ItemStack;
+import org.bukkit.persistence.PersistentDataType;
+import org.junit.jupiter.api.Test;
+import org.mockbukkit.mockbukkit.entity.PlayerMock;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class HopperMenuTest extends MockBukkitTest {
+
+    @Test
+    void refreshWritesButtonItemsAndPersistentIds() {
+        PlayerMock player = server.addPlayer();
+        TestHopperMenu menu = new TestHopperMenu(player);
+        Button button = Button.builder()
+                .item(viewer -> new ItemStack(Material.HOPPER))
+                .refresh(5L)
+                .build();
+
+        menu.addButton(2, button);
+        menu.refresh();
+
+        ItemStack rendered = menu.getView().getTopInventory().getItem(2);
+        assertNotNull(rendered);
+        assertEquals(Material.HOPPER, rendered.getType());
+        assertEquals(button.uuid().toString(), rendered.getPersistentDataContainer().get(Button.KEY, PersistentDataType.STRING));
+        assertEquals(menu.getView(), button.boundingInventory());
+        assertEquals(1, menu.getRefreshingButtons().size());
+        assertTrue(menu.checkCancelClick(2));
+    }
+
+    @Test
+    void cancelClicksCanBeDisabled() {
+        TestHopperMenu menu = new TestHopperMenu(server.addPlayer());
+
+        assertTrue(menu.cancelClicks());
+        menu.cancelClicks(false);
+        assertFalse(menu.cancelClicks());
+    }
+
+    private static final class TestHopperMenu extends HopperMenu {
+
+        private TestHopperMenu(Player player) {
+            super(player, "Test");
+        }
+    }
+}
diff --git a/paper/src/test/java/gg/moonrise/engine/paper/gui/button/ButtonTest.java b/paper/src/test/java/gg/moonrise/engine/paper/gui/button/ButtonTest.java
new file mode 100644
index 0000000..4fd4ea1
--- /dev/null
+++ b/paper/src/test/java/gg/moonrise/engine/paper/gui/button/ButtonTest.java
@@ -0,0 +1,56 @@
+package gg.moonrise.engine.paper.gui.button;
+
+import org.bukkit.Material;
+import org.bukkit.inventory.ItemStack;
+import org.junit.jupiter.api.Test;
+
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class ButtonTest {
+
+    @Test
+    void builderRequiresDisplayItem() {
+        assertThrows(IllegalStateException.class, () -> Button.builder().build());
+    }
+
+    @Test
+    void builderStoresDefaultsAndRefreshInterval() {
+        Button button = Button.builder()
+                .item(player -> new ItemStack(Material.DIAMOND))
+                .refresh(20L)
+                .build();
+
+        assertNotNull(button.uuid());
+        assertTrue(button.cancelClick());
+        assertEquals(20L, button.refreshIntervalTicks());
+        button.setLastRefreshTime(40L);
+        assertEquals(40L, button.lastRefreshTime());
+    }
+
+    @Test
+    void customCancelAndClickActionAreApplied() {
+        AtomicBoolean clicked = new AtomicBoolean(false);
+        AtomicReference