getSlotGroups();
+
+ /**
+ * This returns the size of the resulting {@link Inventory}.
+ *
+ * @return The {@link Inventory} size
+ */
+ int getSize();
+
+ /**
+ * This returns the title to be set for the resulting {@link Menu}.
+ * If no title was set, this will return null.
+ *
+ * @return The title or null
+ */
+ @Nullable
+ String getTitle();
+
+ /**
+ * This returns the {@link SlotGroup} with the given identifier.
+ *
+ * If no corresponding {@link SlotGroup} was found, it will throw an
+ * {@link IllegalArgumentException}.
+ *
+ * @param identifier
+ * The unique identifier for this {@link SlotGroup}.
+ *
+ * @return The corresponding {@link SlotGroup}
+ */
+ @Nonnull
+ SlotGroup getGroup(char identifier);
+
+ /**
+ * This returns the {@link SlotGroup} present at the given slot.
+ *
+ * If no corresponding {@link SlotGroup} was found, it will throw an
+ * {@link IllegalArgumentException}.
+ *
+ * @param slot
+ * The slot
+ *
+ * @return The corresponding {@link SlotGroup}
+ */
+ @Nonnull
+ SlotGroup getGroup(int slot);
+
+ /**
+ * This returns the {@link SlotGroup} with the given name.
+ *
+ * If no corresponding {@link SlotGroup} was found, it will throw an
+ * {@link IllegalArgumentException}.
+ *
+ * @param name
+ * The unique name of this {@link SlotGroup}.
+ *
+ * @return The corresponding {@link SlotGroup}
+ */
+ @Nonnull
+ SlotGroup getGroup(@Nonnull String name);
+
+}
diff --git a/dough-inventories/src/main/java/io/github/bakedlibs/dough/inventory/SlotGroup.java b/dough-inventories/src/main/java/io/github/bakedlibs/dough/inventory/SlotGroup.java
new file mode 100644
index 00000000..a87ab5dc
--- /dev/null
+++ b/dough-inventories/src/main/java/io/github/bakedlibs/dough/inventory/SlotGroup.java
@@ -0,0 +1,102 @@
+package io.github.bakedlibs.dough.inventory;
+
+import java.util.Iterator;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.bukkit.entity.Player;
+import org.bukkit.inventory.Inventory;
+import org.bukkit.inventory.ItemStack;
+
+import io.github.bakedlibs.dough.inventory.builders.SlotGroupBuilder;
+import io.github.bakedlibs.dough.inventory.handlers.MenuClickHandler;
+
+/**
+ * A {@link SlotGroup} groups slots together and divides an {@link Inventory}
+ * into distinct regions which can be used for easy access.
+ *
+ * @author TheBusyBiscuit
+ *
+ * @see MenuLayout
+ * @see SlotGroupBuilder
+ *
+ */
+public interface SlotGroup extends Iterable {
+
+ /**
+ * This returns the unique identifier for this {@link SlotGroup},
+ * we use a {@link Character} for this.
+ *
+ * @return The unique identifier of this {@link SlotGroup}
+ */
+ @Nonnull
+ char getIdentifier();
+
+ /**
+ * This method returns whether this {@link SlotGroup} is interactable.
+ * An interactable {@link SlotGroup} allows the {@link Player} to take
+ * and store items from/in slots of this group.
+ *
+ * If {@link #isInteractable()} returns false, the click event will
+ * be cancelled.
+ *
+ * @return Whether this {@link SlotGroup} is interactable
+ */
+ boolean isInteractable();
+
+ /**
+ * This returns the name or label of this {@link SlotGroup}, names help
+ * to make a {@link SlotGroup} easier to identify instead of relying on the
+ * {@link Character} identifier all the time.
+ *
+ * @return The name of this {@link SlotGroup}
+ */
+ @Nonnull
+ String getName();
+
+ /**
+ * This method returns an array containing all the individual slots of
+ * this {@link SlotGroup}.
+ *
+ * @return An array with all slots of this {@link SlotGroup}
+ */
+ @Nonnull
+ int[] getSlots();
+
+ /**
+ * This returns the size of this {@link SlotGroup}.
+ *
+ * @return The size of this {@link SlotGroup}
+ */
+ default int size() {
+ return getSlots().length;
+ }
+
+ /**
+ * This returns the default {@link ItemStack} for this {@link SlotGroup}.
+ *
+ * @return The default {@link ItemStack}, can be null
+ */
+ @Nullable
+ ItemStack getDefaultItemStack();
+
+ /**
+ * This method returns the {@link MenuClickHandler} belonging to this {@link SlotGroup}.
+ * If no {@link MenuClickHandler} was added, this will return null.
+ *
+ * @return The {@link MenuClickHandler} or null
+ */
+ @Nullable
+ MenuClickHandler getClickHandler();
+
+ /**
+ * This returns an {@link Iterator} allowing you to iterate through
+ * all slots within this {@link SlotGroup}.
+ */
+ @Override
+ default @Nonnull Iterator iterator() {
+ return new SlotGroupIterator(this);
+ }
+
+}
diff --git a/dough-inventories/src/main/java/io/github/bakedlibs/dough/inventory/SlotGroupIterator.java b/dough-inventories/src/main/java/io/github/bakedlibs/dough/inventory/SlotGroupIterator.java
new file mode 100644
index 00000000..34f71574
--- /dev/null
+++ b/dough-inventories/src/main/java/io/github/bakedlibs/dough/inventory/SlotGroupIterator.java
@@ -0,0 +1,51 @@
+package io.github.bakedlibs.dough.inventory;
+
+import java.util.Iterator;
+import java.util.NoSuchElementException;
+
+import javax.annotation.Nonnull;
+
+/**
+ * This {@link Iterator} implementation iterates through all slots within a {@link SlotGroup}.
+ *
+ * @author TheBusyBiscuit
+ *
+ */
+class SlotGroupIterator implements Iterator {
+
+ private final int[] slots;
+ private int index = 0;
+
+ /**
+ * This creates a new {@link SlotGroupIterator} for the given
+ * {@link SlotGroup}.
+ *
+ * @param slotGroup
+ * The {@link SlotGroup}
+ */
+ SlotGroupIterator(@Nonnull SlotGroup slotGroup) {
+ this.slots = slotGroup.getSlots();
+ }
+
+ @Override
+ public boolean hasNext() {
+ return index < slots.length;
+ }
+
+ @Override
+ public Integer next() {
+ if (index < slots.length) {
+ int slot = slots[index];
+ index++;
+ return slot;
+ } else {
+ throw new NoSuchElementException("No more slots available.");
+ }
+ }
+
+ @Override
+ public void remove() {
+ throw new UnsupportedOperationException("Iterator#remove() is not supported!");
+ }
+
+}
diff --git a/dough-inventories/src/main/java/io/github/bakedlibs/dough/inventory/builders/MenuLayoutBuilder.java b/dough-inventories/src/main/java/io/github/bakedlibs/dough/inventory/builders/MenuLayoutBuilder.java
new file mode 100644
index 00000000..e0d3e79a
--- /dev/null
+++ b/dough-inventories/src/main/java/io/github/bakedlibs/dough/inventory/builders/MenuLayoutBuilder.java
@@ -0,0 +1,81 @@
+package io.github.bakedlibs.dough.inventory.builders;
+
+import java.util.HashSet;
+import java.util.Set;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+import javax.annotation.ParametersAreNonnullByDefault;
+
+import org.apache.commons.lang.Validate;
+
+import io.github.bakedlibs.dough.inventory.Menu;
+import io.github.bakedlibs.dough.inventory.MenuLayout;
+import io.github.bakedlibs.dough.inventory.SlotGroup;
+
+/**
+ * The {@link MenuLayoutBuilder} allows you to construct a {@link MenuLayout}
+ * easily via the builder pattern.
+ *
+ * @author TheBusyBiscuit
+ *
+ */
+public class MenuLayoutBuilder {
+
+ protected final int size;
+ protected final Set groups = new HashSet<>();
+ protected String title;
+
+ /**
+ * This creates a new {@link MenuLayoutBuilder} with the given inventory size.
+ *
+ * @param size
+ * The inventory size for this {@link MenuLayout}
+ */
+ public MenuLayoutBuilder(int size) {
+ Validate.isTrue(size > 0, "The size must be greater than 0.");
+ Validate.isTrue(size % 9 == 0, "The size must be a multiple of 9.");
+ Validate.isTrue(size <= 54, "The size must not be greater than 54.");
+
+ this.size = size;
+ }
+
+ /**
+ * This sets an optional title for the resulting {@link Menu}.
+ *
+ * @param title
+ * The title or null
+ *
+ * @return Our {@link MenuLayoutBuilder} instance
+ */
+ public @Nonnull MenuLayoutBuilder title(@Nullable String title) {
+ this.title = title;
+ return this;
+ }
+
+ /**
+ * This adds the given {@link SlotGroup} to this {@link MenuLayout}.
+ *
+ * @param group
+ * The {@link SlotGroup} to add
+ *
+ * @return Our {@link MenuLayoutBuilder} instance
+ */
+ @ParametersAreNonnullByDefault
+ public @Nonnull MenuLayoutBuilder addSlotGroup(SlotGroup group) {
+ groups.add(group);
+ return this;
+ }
+
+ /**
+ * This creates the final {@link MenuLayout} object from this {@link MenuLayoutBuilder}.
+ *
+ * @return The resulting {@link MenuLayout}
+ */
+ public @Nonnull MenuLayout build() {
+ Validate.notEmpty(groups, "There are no SlotGroups defined.");
+
+ return new MenuLayoutImpl(this);
+ }
+
+}
diff --git a/dough-inventories/src/main/java/io/github/bakedlibs/dough/inventory/builders/MenuLayoutImpl.java b/dough-inventories/src/main/java/io/github/bakedlibs/dough/inventory/builders/MenuLayoutImpl.java
new file mode 100644
index 00000000..a6bec979
--- /dev/null
+++ b/dough-inventories/src/main/java/io/github/bakedlibs/dough/inventory/builders/MenuLayoutImpl.java
@@ -0,0 +1,143 @@
+package io.github.bakedlibs.dough.inventory.builders;
+
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Set;
+import java.util.function.Predicate;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+import javax.annotation.ParametersAreNonnullByDefault;
+
+import org.apache.commons.lang.Validate;
+
+import io.github.bakedlibs.dough.inventory.MenuLayout;
+import io.github.bakedlibs.dough.inventory.SlotGroup;
+
+class MenuLayoutImpl implements MenuLayout {
+
+ private final int size;
+ private final String title;
+
+ private final Set groups = new HashSet<>();
+ private final SlotGroup[] groupsBySlot;
+
+ MenuLayoutImpl(@Nonnull MenuLayoutBuilder builder) {
+ this.size = builder.size;
+ this.title = builder.title;
+ this.groups.addAll(builder.groups);
+ this.groupsBySlot = new SlotGroup[size];
+
+ Set uniqueCharacters = new HashSet<>();
+ Set uniqueNames = new HashSet<>();
+ Set coveredSlots = new HashSet<>();
+
+ for (SlotGroup group : groups) {
+ Validate.notNull(group, "SlotGroups cannot be null.");
+
+ // Check for duplicate identifiers
+ if (!uniqueCharacters.add(group.getIdentifier())) {
+ throw new IllegalStateException("Identifier '" + group.getIdentifier() + "' is used more than once!");
+ }
+
+ // Check for duplicate names
+ if (!uniqueNames.add(group.getName())) {
+ throw new IllegalStateException("Name '" + group.getName() + "' is used more than once!");
+ }
+
+ for (int slot : group) {
+ Validate.isTrue(slot >= 0 && slot < size, "The slot " + slot + " is outside the bounds of this inventory (0 - " + size + ')');
+
+ if (!coveredSlots.add(slot)) {
+ throw new IllegalStateException("Slot " + slot + " is defined by multiple slot groups.");
+ }
+
+ groupsBySlot[slot] = group;
+ }
+ }
+
+ if (coveredSlots.size() != size) {
+ throw new IllegalStateException("Only " + coveredSlots.size() + " / " + size + " slots are covered by slot groups.");
+ }
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public @Nonnull Set getSlotGroups() {
+ return Collections.unmodifiableSet(groups);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public int getSize() {
+ return size;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public @Nullable String getTitle() {
+ return title;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public @Nonnull SlotGroup getGroup(char identifier) {
+ SlotGroup result = findGroup(group -> group.getIdentifier() == identifier);
+
+ if (result != null) {
+ return result;
+ } else {
+ throw new IllegalArgumentException("Could not find a SlotGroup with the identifier '" + identifier + "'");
+ }
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public @Nonnull SlotGroup getGroup(int slot) {
+ Validate.isTrue(slot >= 0, "Slot cannot be a negative number: " + slot);
+ Validate.isTrue(slot < size, "Slot " + slot + " is not within the inventory size of " + size);
+
+ /*
+ * Using an Array makes this much faster.
+ * And since this method will be used for the click events, some
+ * optimization here will be good to have.
+ */
+ return groupsBySlot[slot];
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public @Nonnull SlotGroup getGroup(@Nonnull String name) {
+ SlotGroup result = findGroup(group -> group.getName().equals(name));
+
+ if (result != null) {
+ return result;
+ } else {
+ throw new IllegalArgumentException("Could not find a SlotGroup with the name '" + name + "'");
+ }
+ }
+
+ @ParametersAreNonnullByDefault
+ private @Nullable SlotGroup findGroup(Predicate predicate) {
+ for (SlotGroup group : groups) {
+ if (predicate.test(group)) {
+ return group;
+ }
+ }
+
+ return null;
+ }
+
+}
diff --git a/dough-inventories/src/main/java/io/github/bakedlibs/dough/inventory/builders/SlotGroupBuilder.java b/dough-inventories/src/main/java/io/github/bakedlibs/dough/inventory/builders/SlotGroupBuilder.java
new file mode 100644
index 00000000..aaac098f
--- /dev/null
+++ b/dough-inventories/src/main/java/io/github/bakedlibs/dough/inventory/builders/SlotGroupBuilder.java
@@ -0,0 +1,164 @@
+package io.github.bakedlibs.dough.inventory.builders;
+
+import java.util.HashSet;
+import java.util.Set;
+import java.util.stream.IntStream;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+import javax.annotation.ParametersAreNonnullByDefault;
+
+import org.apache.commons.lang.Validate;
+import org.bukkit.entity.Player;
+import org.bukkit.event.inventory.InventoryClickEvent;
+import org.bukkit.inventory.ItemStack;
+
+import io.github.bakedlibs.dough.inventory.SlotGroup;
+import io.github.bakedlibs.dough.inventory.handlers.MenuClickHandler;
+
+/**
+ * The {@link SlotGroupBuilder} allows you to construct a {@link SlotGroup}
+ * easily via the builder pattern.
+ *
+ * @author TheBusyBiscuit
+ *
+ */
+public class SlotGroupBuilder {
+
+ protected final char identifier;
+ protected final String name;
+ protected final Set slots = new HashSet<>();
+
+ protected MenuClickHandler clickHandler = null;
+ protected ItemStack defaultItem = null;
+ protected boolean interactable = false;
+
+ /**
+ * This creates a new {@link SlotGroupBuilder} of the given name and char id.
+ * You can construct the corresponding {@link SlotGroup} using {@link #build()}.
+ *
+ * @param identifier
+ * The unique {@link Character} id for this {@link SlotGroup}
+ * @param name
+ * A unique name for this {@link SlotGroup}
+ */
+ @ParametersAreNonnullByDefault
+ public SlotGroupBuilder(char identifier, String name) {
+ this.identifier = identifier;
+ this.name = name;
+ }
+
+ /**
+ * This simply returns the name of this {@link SlotGroup}.
+ *
+ * @return The name of this {@link SlotGroup}
+ */
+ public @Nonnull String name() {
+ return name;
+ }
+
+ /**
+ * This marks this {@link SlotGroup} as "interactable" or "non-interactable".
+ * Interactable {@link SlotGroup}s allow the {@link Player} to take items from this
+ * {@link SlotGroup} or place items into those slots.
+ *
+ * Non-interactable {@link SlotGroup}s will cancel any {@link InventoryClickEvent} within
+ * their bounds.
+ *
+ * @param interactable
+ * Whether this {@link SlotGroup} is interactable
+ *
+ * @return The {@link SlotGroupBuilder} instance
+ */
+ public @Nonnull SlotGroupBuilder interactable(boolean interactable) {
+ this.interactable = interactable;
+ return this;
+ }
+
+ /**
+ * This method adds the given slot to this {@link SlotGroup}
+ *
+ * @param slot
+ * The slot to be added
+ *
+ * @return The {@link SlotGroupBuilder} instance
+ */
+ public @Nonnull SlotGroupBuilder withSlot(int slot) {
+ this.slots.add(slot);
+ return this;
+ }
+
+ /**
+ * This method adds all the provided slots to this {@link SlotGroup}
+ *
+ * @param slots
+ * All slots that should be part of this {@link SlotGroup}
+ *
+ * @return The {@link SlotGroupBuilder} instance
+ */
+ public @Nonnull SlotGroupBuilder withSlots(int... slots) {
+ for (int slot : slots) {
+ this.slots.add(slot);
+ }
+
+ return this;
+ }
+
+ /**
+ * This method adds all slots within the given range to this {@link SlotGroup}.
+ * Note that both parameters are inclusive.
+ *
+ * @param from
+ * The start of this slot range (inclusive)
+ * @param to
+ * The end of this slot range (inclusive)
+ *
+ * @return The {@link SlotGroupBuilder} instance
+ */
+ public @Nonnull SlotGroupBuilder withSlotRange(int from, int to) {
+ IntStream.range(from, to + 1).forEach(slots::add);
+ return this;
+ }
+
+ /**
+ * This sets the {@link ItemStack} for this {@link SlotGroup}.
+ * This {@link ItemStack} will be placed into all slots from this {@link SlotGroup}
+ * by default. It can be overridden though.
+ *
+ * @param item
+ * The default {@link ItemStack} for this {@link SlotGroup}
+ *
+ * @return The {@link SlotGroupBuilder} instance
+ */
+ public @Nonnull SlotGroupBuilder withDefaultItem(@Nullable ItemStack item) {
+ this.defaultItem = item;
+ return this;
+ }
+
+ /**
+ * This adds a {@link MenuClickHandler} to this {@link SlotGroup}.
+ *
+ * @param clickHandler
+ * The {@link MenuClickHandler} to fire when a slot from this {@link SlotGroup} was clicked
+ *
+ * @return The {@link SlotGroupBuilder} instance
+ */
+ public @Nonnull SlotGroupBuilder onClick(@Nullable MenuClickHandler clickHandler) {
+ this.clickHandler = clickHandler;
+ return this;
+ }
+
+ /**
+ * This creates the final {@link SlotGroup} object from this {@link SlotGroupBuilder}.
+ *
+ * @return The resulting {@link SlotGroup}
+ */
+ public @Nonnull SlotGroup build() {
+ Validate.notNull(identifier, "The char identifier may not be null.");
+ Validate.notNull(name, "The name may not be null.");
+ Validate.notEmpty(slots, "A SlotGroup must have at least one slot.");
+
+ return new SlotGroupImpl(this);
+ }
+
+}
diff --git a/dough-inventories/src/main/java/io/github/bakedlibs/dough/inventory/builders/SlotGroupImpl.java b/dough-inventories/src/main/java/io/github/bakedlibs/dough/inventory/builders/SlotGroupImpl.java
new file mode 100644
index 00000000..d466a84b
--- /dev/null
+++ b/dough-inventories/src/main/java/io/github/bakedlibs/dough/inventory/builders/SlotGroupImpl.java
@@ -0,0 +1,96 @@
+package io.github.bakedlibs.dough.inventory.builders;
+
+import java.util.Set;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.bukkit.inventory.ItemStack;
+
+import io.github.bakedlibs.dough.inventory.SlotGroup;
+import io.github.bakedlibs.dough.inventory.handlers.MenuClickHandler;
+
+class SlotGroupImpl implements SlotGroup {
+
+ private final char identifier;
+ private final String name;
+ private final boolean interactable;
+ private final ItemStack defaultItem;
+ private final MenuClickHandler clickHandler;
+ private final int[] slots;
+
+ SlotGroupImpl(@Nonnull SlotGroupBuilder builder) {
+ this.identifier = builder.identifier;
+ this.name = builder.name;
+ this.interactable = builder.interactable;
+ this.defaultItem = builder.defaultItem;
+ this.slots = convertSlots(builder.slots);
+ this.clickHandler = builder.clickHandler;
+ }
+
+ /**
+ * This method converts our {@link Set} of slots into a sorted array.
+ *
+ * @param slots
+ * The slots
+ *
+ * @return A sorted array of our slots
+ */
+ private @Nonnull int[] convertSlots(@Nonnull Set slots) {
+ // @formatter:off
+ return slots.stream()
+ .mapToInt(Integer::intValue)
+ .sorted()
+ .toArray();
+ // @formatter:on
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public char getIdentifier() {
+ return identifier;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public boolean isInteractable() {
+ return interactable;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public @Nonnull String getName() {
+ return name;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public @Nonnull int[] getSlots() {
+ return slots;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public @Nullable ItemStack getDefaultItemStack() {
+ return defaultItem;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public @Nonnull MenuClickHandler getClickHandler() {
+ return clickHandler;
+ }
+
+}
diff --git a/dough-inventories/src/main/java/io/github/bakedlibs/dough/inventory/factory/CustomMenu.java b/dough-inventories/src/main/java/io/github/bakedlibs/dough/inventory/factory/CustomMenu.java
new file mode 100644
index 00000000..745cb6df
--- /dev/null
+++ b/dough-inventories/src/main/java/io/github/bakedlibs/dough/inventory/factory/CustomMenu.java
@@ -0,0 +1,141 @@
+package io.github.bakedlibs.dough.inventory.factory;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+import javax.annotation.ParametersAreNonnullByDefault;
+
+import org.apache.commons.lang.Validate;
+import org.bukkit.inventory.Inventory;
+import org.bukkit.inventory.ItemStack;
+
+import io.github.bakedlibs.dough.inventory.Menu;
+import io.github.bakedlibs.dough.inventory.MenuLayout;
+import io.github.bakedlibs.dough.inventory.SlotGroup;
+
+public class CustomMenu implements Menu {
+
+ private final MenuFactory factory;
+ private final MenuLayout layout;
+ private final String title;
+ private Inventory inventory;
+
+ @ParametersAreNonnullByDefault
+ protected CustomMenu(MenuFactory factory, MenuLayout layout) {
+ Validate.notNull(factory, "The factory cannot be null.");
+ Validate.notNull(layout, "The layout cannot be null.");
+
+ this.factory = factory;
+ this.layout = layout;
+ this.title = layout.getTitle();
+ }
+
+ final void setInventory(@Nonnull Inventory inventory) {
+ Validate.notNull(inventory, "The Inventory must not be null.");
+ Validate.isTrue(inventory.getSize() == layout.getSize(), "The inventory has a different size.");
+ Validate.isTrue(inventory.getHolder() == this, "The Inventory does not seem to belong here. Holder: " + inventory.getHolder());
+
+ this.inventory = inventory;
+ }
+
+ private void validate() {
+ if (inventory == null) {
+ throw new UnsupportedOperationException("No inventory found! Menus must be created using MenuFactory#createMenu(...)");
+ }
+ }
+
+ @Override
+ public final @Nonnull MenuFactory getFactory() {
+ validate();
+
+ return factory;
+ }
+
+ @Override
+ public final @Nonnull Inventory getInventory() {
+ validate();
+
+ return inventory;
+ }
+
+ @Override
+ public final @Nonnull MenuLayout getLayout() {
+ validate();
+
+ return layout;
+ }
+
+ @Override
+ public String getTitle() {
+ validate();
+
+ return title;
+ }
+
+ @Override
+ public void setAll(SlotGroup group, ItemStack item) {
+ validate();
+
+ if (group.size() == 1) {
+ // Little optimization, we don't need to create an Iterator for this
+ setItem(group.getSlots()[0], item);
+ } else {
+ for (int slot : group) {
+ setItem(slot, item);
+ }
+ }
+ }
+
+ @Override
+ @ParametersAreNonnullByDefault
+ public @Nullable ItemStack addItem(SlotGroup group, ItemStack item) {
+ Validate.notNull(group, "The Slot group cannot be null!");
+ Validate.notNull(item, "The Item cannot be null!");
+ validate();
+
+ for (int slot : group) {
+ ItemStack itemInSlot = getItem(slot);
+
+ if (itemInSlot == null || itemInSlot.getType().isAir()) {
+ setItem(slot, item);
+ return null;
+ } else {
+ int currentAmount = itemInSlot.getAmount();
+ int maxStackSize = itemInSlot.getType().getMaxStackSize();
+
+ if (currentAmount < maxStackSize && itemInSlot.isSimilar(item)) {
+ int amount = currentAmount + item.getAmount();
+
+ if (amount > maxStackSize) {
+ item.setAmount(amount - maxStackSize);
+ itemInSlot.setAmount(maxStackSize);
+ } else {
+ itemInSlot.setAmount(Math.min(amount, maxStackSize));
+ return null;
+ }
+ }
+ }
+ }
+
+ return item;
+ }
+
+ @Override
+ public void setItem(int slot, @Nullable ItemStack item) {
+ validate();
+
+ inventory.setItem(slot, item);
+ }
+
+ @Override
+ public @Nullable ItemStack getItem(int slot) {
+ validate();
+
+ return inventory.getItem(slot);
+ }
+
+ @Override
+ public @Nonnull String toString() {
+ return getClass().getSimpleName() + " [size: " + layout.getSize() + ", title=" + title + "]";
+ }
+
+}
diff --git a/dough-inventories/src/main/java/io/github/bakedlibs/dough/inventory/factory/MenuFactory.java b/dough-inventories/src/main/java/io/github/bakedlibs/dough/inventory/factory/MenuFactory.java
new file mode 100644
index 00000000..cef5f4d9
--- /dev/null
+++ b/dough-inventories/src/main/java/io/github/bakedlibs/dough/inventory/factory/MenuFactory.java
@@ -0,0 +1,113 @@
+package io.github.bakedlibs.dough.inventory.factory;
+
+import java.util.function.BiFunction;
+import java.util.logging.Logger;
+
+import javax.annotation.Nonnull;
+import javax.annotation.OverridingMethodsMustInvokeSuper;
+import javax.annotation.ParametersAreNonnullByDefault;
+
+import org.apache.commons.lang.Validate;
+import org.bukkit.Bukkit;
+import org.bukkit.Server;
+import org.bukkit.event.Listener;
+import org.bukkit.event.inventory.InventoryClickEvent;
+import org.bukkit.inventory.Inventory;
+import org.bukkit.plugin.Plugin;
+
+import io.github.bakedlibs.dough.inventory.Menu;
+import io.github.bakedlibs.dough.inventory.MenuLayout;
+import io.github.bakedlibs.dough.inventory.SlotGroup;
+
+/**
+ * The {@link MenuFactory} is the core of this system, this is where everything
+ * starts. You can use this {@link MenuFactory} to create {@link Menu}s from a
+ * {@link MenuLayout}.
+ *
+ * This class also handles the registration of our {@link Listener}.
+ *
+ * @author TheBusyBiscuit
+ *
+ */
+public class MenuFactory {
+
+ /**
+ * Our {@link Plugin} instance.
+ */
+ private final Plugin plugin;
+
+ /**
+ * This constructs a new {@link MenuFactory} for the given {@link Plugin}.
+ *
+ * @param plugin
+ * The {@link Plugin} instance
+ */
+ public MenuFactory(@Nonnull Plugin plugin) {
+ Validate.notNull(plugin, "The plugin instance cannot be null.");
+
+ this.plugin = plugin;
+ registerListener();
+ }
+
+ /**
+ * This method registers our {@link MenuListener} to the {@link Server}.
+ * This way, we can listen to and handle {@link InventoryClickEvent}s and alike.
+ *
+ * @return Our registered {@link MenuListener}
+ */
+ private @Nonnull MenuListener registerListener() {
+ MenuListener listener = new MenuListener(this);
+ plugin.getServer().getPluginManager().registerEvents(listener, plugin);
+ return listener;
+ }
+
+ /**
+ * This returns the {@link Plugin} which instantiated this {@link MenuFactory}.
+ *
+ * @return The {@link Plugin} instance
+ */
+ public final @Nonnull Plugin getPlugin() {
+ return plugin;
+ }
+
+ /**
+ * Shortcut method for getting the {@link Logger} from
+ * {@link #getPlugin()}.
+ *
+ * @return The {@link Logger} of our {@link Plugin}
+ */
+ public final @Nonnull Logger getLogger() {
+ return plugin.getLogger();
+ }
+
+ @OverridingMethodsMustInvokeSuper
+ public @Nonnull Menu createMenu(@Nonnull MenuLayout layout) {
+ return createMenu(layout, CustomMenu::new);
+ }
+
+ @ParametersAreNonnullByDefault
+ public @Nonnull T createMenu(MenuLayout layout, BiFunction constructor) {
+ Validate.notNull(layout, "The menu layout cannot be null!");
+ Validate.notNull(constructor, "The provided constructor is not allowed to be null!");
+
+ T menu = constructor.apply(this, layout);
+ String title = layout.getTitle();
+ Inventory inv;
+
+ if (title == null) {
+ inv = Bukkit.createInventory(menu, layout.getSize());
+ } else {
+ inv = Bukkit.createInventory(menu, layout.getSize(), title);
+ }
+
+ menu.setInventory(inv);
+
+ // Set all default items
+ for (SlotGroup group : layout.getSlotGroups()) {
+ menu.setAll(group, group.getDefaultItemStack());
+ }
+
+ return menu;
+ }
+
+}
diff --git a/dough-inventories/src/main/java/io/github/bakedlibs/dough/inventory/factory/MenuListener.java b/dough-inventories/src/main/java/io/github/bakedlibs/dough/inventory/factory/MenuListener.java
new file mode 100644
index 00000000..5b54f03e
--- /dev/null
+++ b/dough-inventories/src/main/java/io/github/bakedlibs/dough/inventory/factory/MenuListener.java
@@ -0,0 +1,98 @@
+package io.github.bakedlibs.dough.inventory.factory;
+
+import java.util.logging.Level;
+
+import javax.annotation.Nonnull;
+
+import org.bukkit.entity.Player;
+import org.bukkit.event.Event;
+import org.bukkit.event.EventHandler;
+import org.bukkit.event.Listener;
+import org.bukkit.event.inventory.InventoryClickEvent;
+import org.bukkit.inventory.Inventory;
+import org.bukkit.inventory.InventoryHolder;
+
+import io.github.bakedlibs.dough.inventory.Menu;
+import io.github.bakedlibs.dough.inventory.SlotGroup;
+import io.github.bakedlibs.dough.inventory.handlers.MenuClickHandler;
+import io.github.bakedlibs.dough.inventory.payloads.MenuPayloads;
+
+/**
+ * The {@link MenuListener} is responsible for handling any
+ * {@link Event} related to our {@link Menu}s.
+ *
+ * It is registered by a {@link MenuFactory} and there should only
+ * be one {@link MenuListener} per {@link MenuFactory}.
+ *
+ * @author TheBusyBiscuit
+ *
+ */
+class MenuListener implements Listener {
+
+ /**
+ * Our {@link MenuFactory} instance.
+ */
+ private final MenuFactory factory;
+
+ /**
+ * This constructs a new {@link MenuListener} for the given
+ * {@link MenuFactory}.
+ *
+ * @param factory
+ * Our {@link MenuFactory} instance
+ */
+ MenuListener(@Nonnull MenuFactory factory) {
+ this.factory = factory;
+ }
+
+ /**
+ * This returns the {@link MenuFactory} which instantiated and
+ * registered this {@link MenuListener}.
+ *
+ * @return The {@link MenuFactory}
+ */
+ public @Nonnull MenuFactory getFactory() {
+ return factory;
+ }
+
+ /**
+ * Here we listen for the {@link InventoryClickEvent}.
+ * This event is fired whenever a {@link Player} clicks a slot
+ * in an {@link Inventory}. We only care for events which happen
+ * within our {@link Menu} though.
+ *
+ * @param e
+ * The {@link InventoryClickEvent} which was fired
+ */
+ @EventHandler
+ public void onClick(InventoryClickEvent e) {
+ InventoryHolder holder = e.getInventory().getHolder();
+
+ // Check if the Inventory is a Menu
+ if (holder instanceof Menu) {
+ Menu inv = (Menu) holder;
+
+ try {
+ // Check if this was created by our factory and the clicked slot is within the upper inventory
+ if (inv.getFactory().equals(factory) && e.getRawSlot() < e.getInventory().getSize()) {
+ SlotGroup slotGroup = inv.getLayout().getGroup(e.getSlot());
+
+ // Cancel the interaction if that slot is not interactable
+ if (!slotGroup.isInteractable()) {
+ e.setCancelled(true);
+ }
+
+ // Fire the click handler
+ MenuClickHandler clickHandler = slotGroup.getClickHandler();
+
+ if (clickHandler != null) {
+ clickHandler.onClick(MenuPayloads.create(inv, e));
+ }
+ }
+ } catch (Exception | LinkageError x) {
+ factory.getLogger().log(Level.SEVERE, x, () -> "Could not pass click event for " + inv + " (slot: " + e.getSlot() + ", player:" + e.getWhoClicked().getName() + ")");
+ }
+ }
+ }
+
+}
diff --git a/dough-inventories/src/main/java/io/github/bakedlibs/dough/inventory/handlers/MenuClickHandler.java b/dough-inventories/src/main/java/io/github/bakedlibs/dough/inventory/handlers/MenuClickHandler.java
new file mode 100644
index 00000000..2c2e785c
--- /dev/null
+++ b/dough-inventories/src/main/java/io/github/bakedlibs/dough/inventory/handlers/MenuClickHandler.java
@@ -0,0 +1,12 @@
+package io.github.bakedlibs.dough.inventory.handlers;
+
+import javax.annotation.Nonnull;
+
+import io.github.bakedlibs.dough.inventory.payloads.MenuClickPayload;
+
+@FunctionalInterface
+public interface MenuClickHandler {
+
+ void onClick(@Nonnull MenuClickPayload e);
+
+}
diff --git a/dough-inventories/src/main/java/io/github/bakedlibs/dough/inventory/json/InvalidLayoutException.java b/dough-inventories/src/main/java/io/github/bakedlibs/dough/inventory/json/InvalidLayoutException.java
new file mode 100644
index 00000000..5c52e750
--- /dev/null
+++ b/dough-inventories/src/main/java/io/github/bakedlibs/dough/inventory/json/InvalidLayoutException.java
@@ -0,0 +1,28 @@
+package io.github.bakedlibs.dough.inventory.json;
+
+import javax.annotation.Nonnull;
+
+import com.google.gson.JsonObject;
+
+import io.github.bakedlibs.dough.inventory.MenuLayout;
+
+/**
+ * An {@link InvalidLayoutException} is thrown when the {@link MenuLayout}
+ * was not successfully read from a {@link JsonObject}.
+ *
+ * @author TheBusyBiscuit
+ *
+ */
+class InvalidLayoutException extends Exception {
+
+ private static final long serialVersionUID = -1891678815608214476L;
+
+ InvalidLayoutException(@Nonnull String message) {
+ super(message);
+ }
+
+ InvalidLayoutException(@Nonnull Exception exception) {
+ super(exception);
+ }
+
+}
diff --git a/dough-inventories/src/main/java/io/github/bakedlibs/dough/inventory/json/LayoutParser.java b/dough-inventories/src/main/java/io/github/bakedlibs/dough/inventory/json/LayoutParser.java
new file mode 100644
index 00000000..a6d2fbba
--- /dev/null
+++ b/dough-inventories/src/main/java/io/github/bakedlibs/dough/inventory/json/LayoutParser.java
@@ -0,0 +1,163 @@
+package io.github.bakedlibs.dough.inventory.json;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.nio.charset.StandardCharsets;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.Consumer;
+import java.util.stream.Collectors;
+
+import javax.annotation.Nonnull;
+import javax.annotation.ParametersAreNonnullByDefault;
+
+import org.apache.commons.lang.Validate;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParseException;
+import com.google.gson.JsonParser;
+
+import io.github.bakedlibs.dough.inventory.MenuLayout;
+import io.github.bakedlibs.dough.inventory.SlotGroup;
+import io.github.bakedlibs.dough.inventory.builders.MenuLayoutBuilder;
+import io.github.bakedlibs.dough.inventory.builders.SlotGroupBuilder;
+
+/**
+ * This class allows you to parse {@link JsonObject}s, {@link String}s or {@link InputStream}s
+ * into a {@link MenuLayout}, given they follow our format.
+ *
+ * @author TheBusyBiscuit
+ *
+ */
+public class LayoutParser {
+
+ private LayoutParser() {}
+
+ @ParametersAreNonnullByDefault
+ public static @Nonnull MenuLayout parseStream(InputStream stream, Consumer slotGroups) throws InvalidLayoutException {
+ Validate.notNull(stream, "InputStream must not be null");
+
+ try (BufferedReader reader = new BufferedReader(new InputStreamReader(stream, StandardCharsets.UTF_8))) {
+ return parseString(reader.lines().collect(Collectors.joining("")), slotGroups);
+ } catch (IOException x) {
+ throw new InvalidLayoutException(x);
+ }
+ }
+
+ @ParametersAreNonnullByDefault
+ public static @Nonnull MenuLayout parseStream(InputStream stream) throws InvalidLayoutException {
+ return parseStream(stream, builder -> {});
+ }
+
+ @ParametersAreNonnullByDefault
+ public static @Nonnull MenuLayout parseString(String string, Consumer slotGroups) throws InvalidLayoutException {
+ Validate.notNull(string, "String must not be null");
+
+ try {
+ JsonParser parser = new JsonParser();
+ JsonObject root = parser.parse(string).getAsJsonObject();
+ return parseJson(root, slotGroups);
+ } catch (IllegalStateException | JsonParseException x) {
+ throw new InvalidLayoutException(x);
+ }
+ }
+
+ @ParametersAreNonnullByDefault
+ public static @Nonnull MenuLayout parseString(String string) throws InvalidLayoutException {
+ return parseString(string, builder -> {});
+ }
+
+ @ParametersAreNonnullByDefault
+ public static @Nonnull MenuLayout parseJson(JsonObject json, Consumer slotGroups) throws InvalidLayoutException {
+ Validate.notNull(json, "JsonObject must not be null");
+
+ try {
+ LayoutShape shape = parseShape(json);
+ MenuLayoutBuilder builder = new MenuLayoutBuilder(shape.getSize());
+
+ for (Map.Entry> entry : shape.getGroups().entrySet()) {
+ builder.addSlotGroup(parseGroup(json, entry.getKey(), entry.getValue(), slotGroups));
+ }
+
+ return builder.build();
+ } catch (Exception x) {
+ throw new InvalidLayoutException(x);
+ }
+ }
+
+ @ParametersAreNonnullByDefault
+ public static @Nonnull MenuLayout parseJson(JsonObject json) throws InvalidLayoutException {
+ return parseJson(json, builder -> {});
+ }
+
+ @ParametersAreNonnullByDefault
+ private static @Nonnull LayoutShape parseShape(JsonObject json) throws InvalidLayoutException {
+ String attribute = "layout";
+
+ if (!json.has(attribute) || !json.get(attribute).isJsonArray()) {
+ throw new InvalidLayoutException("Missing 'layout' child!");
+ }
+
+ JsonArray array = json.getAsJsonArray(attribute);
+ String[] rows = new String[array.size()];
+
+ if (rows.length == 0) {
+ throw new InvalidLayoutException("'layout' is empty!");
+ }
+
+ int i = 0;
+ for (JsonElement row : array) {
+ if (row.isJsonPrimitive() && row.getAsJsonPrimitive().isString()) {
+ rows[i] = row.getAsString();
+ i++;
+ } else {
+ throw new InvalidLayoutException("Expected String in layout, found: " + row);
+ }
+ }
+
+ return new LayoutShape(rows);
+ }
+
+ @ParametersAreNonnullByDefault
+ private static @Nonnull SlotGroup parseGroup(JsonObject json, char identifier, Set slots, Consumer consumer) throws InvalidLayoutException {
+ String attribute = "groups";
+
+ if (!json.has(attribute) || !json.get(attribute).isJsonObject()) {
+ throw new InvalidLayoutException("Missing 'groups' child!");
+ }
+
+ JsonObject groups = json.getAsJsonObject(attribute);
+ String key = String.valueOf(identifier);
+
+ if (!groups.has(key) || !groups.get(key).isJsonObject()) {
+ throw new InvalidLayoutException("Missing 'groups." + identifier + "' child!");
+ }
+
+ JsonObject group = groups.getAsJsonObject(key);
+ JsonElement name = group.get("name");
+
+ if (name == null) {
+ throw new InvalidLayoutException("Slot group '" + identifier + "' has no name!");
+ }
+
+ SlotGroupBuilder builder = new SlotGroupBuilder(identifier, name.getAsString());
+
+ JsonElement interactable = group.get("interactable");
+
+ if (interactable != null && interactable.isJsonPrimitive() && interactable.getAsJsonPrimitive().isBoolean()) {
+ builder.interactable(interactable.getAsBoolean());
+ } else {
+ throw new InvalidLayoutException("Slot group '" + identifier + "' has no valid 'interactable' attribute!");
+ }
+
+ builder.withSlots(slots.stream().mapToInt(Integer::intValue).toArray());
+ consumer.accept(builder);
+
+ return builder.build();
+ }
+
+}
diff --git a/dough-inventories/src/main/java/io/github/bakedlibs/dough/inventory/json/LayoutShape.java b/dough-inventories/src/main/java/io/github/bakedlibs/dough/inventory/json/LayoutShape.java
new file mode 100644
index 00000000..4c73aadb
--- /dev/null
+++ b/dough-inventories/src/main/java/io/github/bakedlibs/dough/inventory/json/LayoutShape.java
@@ -0,0 +1,80 @@
+package io.github.bakedlibs.dough.inventory.json;
+
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+
+import javax.annotation.Nonnull;
+import javax.annotation.ParametersAreNonnullByDefault;
+
+import org.apache.commons.lang.Validate;
+import org.bukkit.inventory.Inventory;
+
+import io.github.bakedlibs.dough.inventory.SlotGroup;
+
+/**
+ * Little helper class to transition a {@link String} array into
+ * a {@link SlotGroup}-like map.
+ *
+ * @author TheBusyBiscuit
+ *
+ */
+class LayoutShape {
+
+ private final Map> groups = new HashMap<>();
+ private final int size;
+
+ LayoutShape(@Nonnull String[] rows) throws InvalidLayoutException {
+ Validate.notNull(rows, "Layout cannot be null.");
+
+ if (rows.length > 0 && rows.length < 7) {
+ this.size = rows.length * 9;
+
+ int i = 0;
+
+ for (String row : rows) {
+ if (row.length() == 9) {
+ addSlots(i, row);
+ i++;
+ } else {
+ throw new InvalidLayoutException("Each row in a layout must have 9 characters.");
+ }
+ }
+ } else {
+ throw new InvalidLayoutException("Layout has " + rows.length + " rows. Must be 1, 2, 3, 4, 5 or 6.");
+ }
+ }
+
+ /**
+ * This returns the size of this {@link LayoutShape}, aka the size of the
+ * corresponding {@link Inventory}.
+ *
+ * @return The size of this {@link LayoutShape}
+ */
+ public int getSize() {
+ return size;
+ }
+
+ /**
+ * This returns the {@link SlotGroup}-ready representation of this shape.
+ * Grouped by their unique character keys.
+ *
+ * @return The "{@link SlotGroup} map".
+ */
+ public @Nonnull Map> getGroups() {
+ return groups;
+ }
+
+ @ParametersAreNonnullByDefault
+ private void addSlots(int i, String row) {
+ int j = 0;
+
+ for (char identifier : row.toCharArray()) {
+ Set slots = groups.computeIfAbsent(identifier, id -> new HashSet<>());
+ slots.add(i * 9 + j);
+ j++;
+ }
+ }
+
+}
diff --git a/dough-inventories/src/main/java/io/github/bakedlibs/dough/inventory/payloads/AbstractMenuPayload.java b/dough-inventories/src/main/java/io/github/bakedlibs/dough/inventory/payloads/AbstractMenuPayload.java
new file mode 100644
index 00000000..3154d2d3
--- /dev/null
+++ b/dough-inventories/src/main/java/io/github/bakedlibs/dough/inventory/payloads/AbstractMenuPayload.java
@@ -0,0 +1,54 @@
+package io.github.bakedlibs.dough.inventory.payloads;
+
+import javax.annotation.Nonnull;
+import javax.annotation.ParametersAreNonnullByDefault;
+
+import org.bukkit.entity.Player;
+
+import io.github.bakedlibs.dough.inventory.Menu;
+
+/**
+ * An abstract super class for our menu event payloads.
+ *
+ * @author TheBusyBiscuit
+ *
+ */
+abstract class AbstractMenuPayload {
+
+ private final Menu menu;
+ private final Player player;
+
+ /**
+ * This constructs a new {@link AbstractMenuPayload} for the given {@link Menu}
+ * and {@link Player}.
+ *
+ * @param menu
+ * The {@link Menu}
+ * @param player
+ * The {@link Player}
+ */
+ @ParametersAreNonnullByDefault
+ AbstractMenuPayload(Menu menu, Player player) {
+ this.menu = menu;
+ this.player = player;
+ }
+
+ /**
+ * This returns the {@link Menu} from this payload.
+ *
+ * @return The {@link Menu}
+ */
+ public @Nonnull Menu getMenu() {
+ return menu;
+ }
+
+ /**
+ * This returns the {@link Player} who triggered this payload.
+ *
+ * @return The {@link Player}
+ */
+ public @Nonnull Player getPlayer() {
+ return player;
+ }
+
+}
diff --git a/dough-inventories/src/main/java/io/github/bakedlibs/dough/inventory/payloads/MenuClickPayload.java b/dough-inventories/src/main/java/io/github/bakedlibs/dough/inventory/payloads/MenuClickPayload.java
new file mode 100644
index 00000000..a437eda0
--- /dev/null
+++ b/dough-inventories/src/main/java/io/github/bakedlibs/dough/inventory/payloads/MenuClickPayload.java
@@ -0,0 +1,35 @@
+package io.github.bakedlibs.dough.inventory.payloads;
+
+import javax.annotation.Nonnull;
+import javax.annotation.ParametersAreNonnullByDefault;
+
+import org.bukkit.entity.Player;
+import org.bukkit.inventory.ItemStack;
+
+import io.github.bakedlibs.dough.inventory.Menu;
+import io.github.bakedlibs.dough.inventory.SlotGroup;
+
+public class MenuClickPayload extends AbstractMenuPayload {
+
+ private final int slot;
+
+ @ParametersAreNonnullByDefault
+ MenuClickPayload(Menu inventory, Player player, int slot) {
+ super(inventory, player);
+
+ this.slot = slot;
+ }
+
+ public int getClickedSlot() {
+ return slot;
+ }
+
+ public @Nonnull ItemStack getClickedItemStack() {
+ return getMenu().getItem(getClickedSlot());
+ }
+
+ public @Nonnull SlotGroup getClickedSlotGroup() {
+ return getMenu().getLayout().getGroup(getClickedSlot());
+ }
+
+}
diff --git a/dough-inventories/src/main/java/io/github/bakedlibs/dough/inventory/payloads/MenuPayloads.java b/dough-inventories/src/main/java/io/github/bakedlibs/dough/inventory/payloads/MenuPayloads.java
new file mode 100644
index 00000000..abec6ff0
--- /dev/null
+++ b/dough-inventories/src/main/java/io/github/bakedlibs/dough/inventory/payloads/MenuPayloads.java
@@ -0,0 +1,44 @@
+package io.github.bakedlibs.dough.inventory.payloads;
+
+import javax.annotation.Nonnull;
+import javax.annotation.ParametersAreNonnullByDefault;
+
+import org.apache.commons.lang.Validate;
+import org.bukkit.entity.Player;
+import org.bukkit.event.inventory.InventoryClickEvent;
+
+import io.github.bakedlibs.dough.inventory.Menu;
+import io.github.bakedlibs.dough.inventory.handlers.MenuClickHandler;
+
+/**
+ * Utility class for constructing an event payload for menu handlers.
+ *
+ * @author TheBusyBiscuit
+ *
+ */
+public class MenuPayloads {
+
+ private MenuPayloads() {}
+
+ /**
+ * This creates our payload for an {@link InventoryClickEvent}.
+ *
+ * @param menu
+ * The {@link Menu} involved in this event.
+ * @param e
+ * The {@link InventoryClickEvent}
+ *
+ * @return A {@link MenuClickPayload} to pass onto the {@link MenuClickHandler}.
+ */
+ @ParametersAreNonnullByDefault
+ public static @Nonnull MenuClickPayload create(Menu menu, InventoryClickEvent e) {
+ Validate.notNull(menu, "The menu cannot be null");
+ Validate.notNull(e, "Cannot create a payload for an event that is null");
+
+ Player player = (Player) e.getWhoClicked();
+ int slot = e.getSlot();
+
+ return new MenuClickPayload(menu, player, slot);
+ }
+
+}
diff --git a/dough-inventories/src/test/java/io/github/bakedlibs/dough/inventory/TestSlotGroupIterator.java b/dough-inventories/src/test/java/io/github/bakedlibs/dough/inventory/TestSlotGroupIterator.java
new file mode 100644
index 00000000..25818c33
--- /dev/null
+++ b/dough-inventories/src/test/java/io/github/bakedlibs/dough/inventory/TestSlotGroupIterator.java
@@ -0,0 +1,41 @@
+package io.github.bakedlibs.dough.inventory;
+
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.util.Iterator;
+import java.util.NoSuchElementException;
+
+import org.junit.jupiter.api.Test;
+
+import io.github.bakedlibs.dough.inventory.builders.SlotGroupBuilder;
+
+class TestSlotGroupIterator {
+
+ @Test
+ void testIteratorEnd() {
+ SlotGroup group = new SlotGroupBuilder('x', "test").withSlots(0, 1, 2).build();
+
+ assertNotNull(group);
+
+ Iterator iterator = group.iterator();
+
+ while (iterator.hasNext()) {
+ iterator.next();
+ }
+
+ assertThrows(NoSuchElementException.class, iterator::next);
+ }
+
+ @Test
+ void testIteratorRemove() {
+ SlotGroup group = new SlotGroupBuilder('x', "test").withSlots(0, 1, 2).build();
+
+ assertNotNull(group);
+
+ Iterator iterator = group.iterator();
+
+ assertThrows(UnsupportedOperationException.class, iterator::remove);
+ }
+
+}
diff --git a/dough-inventories/src/test/java/io/github/bakedlibs/dough/inventory/builders/TestLayoutBuilder.java b/dough-inventories/src/test/java/io/github/bakedlibs/dough/inventory/builders/TestLayoutBuilder.java
new file mode 100644
index 00000000..e60e22e2
--- /dev/null
+++ b/dough-inventories/src/test/java/io/github/bakedlibs/dough/inventory/builders/TestLayoutBuilder.java
@@ -0,0 +1,217 @@
+package io.github.bakedlibs.dough.inventory.builders;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+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;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.IntStream;
+import java.util.stream.Stream;
+
+import javax.annotation.Nonnull;
+
+import org.bukkit.Material;
+import org.bukkit.inventory.ItemStack;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import io.github.bakedlibs.dough.inventory.MenuLayout;
+import io.github.bakedlibs.dough.inventory.SlotGroup;
+
+import be.seeseemelk.mockbukkit.MockBukkit;
+
+class TestLayoutBuilder {
+
+ @BeforeAll
+ static void setup() {
+ MockBukkit.mock();
+ }
+
+ @AfterAll
+ static void teardown() {
+ MockBukkit.unmock();
+ }
+
+ @Test
+ void testSlotGroups() {
+ // @formatter:off
+ MenuLayout layout = new MenuLayoutBuilder(9)
+ .addSlotGroup(
+ new SlotGroupBuilder('x', "test")
+ .interactable(false)
+ .withSlot(1)
+ .withSlot(2)
+ .withSlot(3)
+ .build()
+ )
+ .addSlotGroup(
+ new SlotGroupBuilder('y', "test2")
+ .interactable(true)
+ .withSlots(0, 4, 5, 6, 7, 8)
+ .withDefaultItem(new ItemStack(Material.DIAMOND))
+ .build()
+ )
+ .build();
+ // @formatter:on
+
+ assertNotNull(layout);
+ assertEquals(9, layout.getSize());
+
+ SlotGroup group = layout.getGroup('x');
+
+ assertNotNull(group);
+ assertEquals(3, group.size());
+ assertSame(group, layout.getGroup("test"));
+ assertSame(group, layout.getGroup(1));
+ assertSame(group, layout.getGroup(2));
+ assertSame(group, layout.getGroup(3));
+ assertFalse(group.isInteractable());
+
+ SlotGroup group2 = layout.getGroup("test2");
+
+ assertNotNull(group2);
+ assertSame(group2, layout.getGroup('y'));
+ assertNotEquals(group, group2);
+ assertEquals(6, group2.size());
+ assertSame(group2, layout.getGroup(0));
+ assertEquals(new ItemStack(Material.DIAMOND), group2.getDefaultItemStack());
+
+ Set groups = layout.getSlotGroups();
+ assertEquals(2, groups.size());
+ assertTrue(groups.contains(group));
+ assertTrue(groups.contains(group2));
+ }
+
+ @Test
+ void testSlotGroupOverlapping() {
+ // @formatter:off
+ MenuLayoutBuilder builder = new MenuLayoutBuilder(9)
+ .addSlotGroup(
+ new SlotGroupBuilder('x', "test")
+ .withSlot(1)
+ .build()
+ )
+ .addSlotGroup(
+ new SlotGroupBuilder('y', "test2")
+ .withSlot(1)
+ .build()
+ );
+ // @formatter:on
+
+ assertThrows(IllegalStateException.class, builder::build);
+ }
+
+ @Test
+ void testSlotGroupIdentifierCollision() {
+ // @formatter:off
+ MenuLayoutBuilder builder = new MenuLayoutBuilder(9)
+ .addSlotGroup(
+ new SlotGroupBuilder('x', "test")
+ .withSlotRange(0, 3)
+ .build()
+ )
+ .addSlotGroup(
+ new SlotGroupBuilder('x', "test2")
+ .withSlotRange(4, 8)
+ .build()
+ );
+ // @formatter:on
+
+ assertThrows(IllegalStateException.class, builder::build);
+ }
+
+ @Test
+ void testSlotGroupNameCollision() {
+ // @formatter:off
+ MenuLayoutBuilder builder = new MenuLayoutBuilder(9)
+ .addSlotGroup(
+ new SlotGroupBuilder('x', "test")
+ .withSlotRange(0, 3)
+ .build()
+ )
+ .addSlotGroup(
+ new SlotGroupBuilder('y', "test")
+ .withSlotRange(4, 8)
+ .build()
+ );
+ // @formatter:on
+
+ assertThrows(IllegalStateException.class, builder::build);
+ }
+
+ @ParameterizedTest
+ @ValueSource(ints = { -1, 10 })
+ void testOutsideSlotGroups(int slot) {
+ // @formatter:off
+ MenuLayoutBuilder builder = new MenuLayoutBuilder(9)
+ .addSlotGroup(
+ new SlotGroupBuilder('x', "test")
+ .withSlot(slot)
+ .build()
+ );
+ // @formatter:on
+
+ assertThrows(IllegalArgumentException.class, builder::build);
+ }
+
+ @Test
+ void testUnknownSlotGroups() {
+ // @formatter:off
+ MenuLayout layout = new MenuLayoutBuilder(9)
+ .addSlotGroup(
+ new SlotGroupBuilder('x', "test")
+ .withSlotRange(0, 8)
+ .build()
+ )
+ .build();
+ // @formatter:on
+
+ assertThrows(IllegalArgumentException.class, () -> layout.getGroup(-1));
+ assertThrows(IllegalArgumentException.class, () -> layout.getGroup(10));
+ assertThrows(IllegalArgumentException.class, () -> layout.getGroup('a'));
+ assertThrows(IllegalArgumentException.class, () -> layout.getGroup("Walshy"));
+ assertThrows(IllegalArgumentException.class, () -> layout.getGroup(null));
+ }
+
+ @Test
+ void testIncompleteSlotGroups() {
+ // @formatter:off
+ MenuLayoutBuilder builder = new MenuLayoutBuilder(9)
+ .addSlotGroup(
+ new SlotGroupBuilder('x', "test")
+ .withSlotRange(1, 8)
+ .build()
+ );
+ // @formatter:on
+
+ assertThrows(IllegalStateException.class, builder::build);
+ }
+
+ @ParameterizedTest(name = "{0} is not a valid inventory size.")
+ @MethodSource("getIllegalInventorySizes")
+ void testIllegalInventorySizes(int size) {
+ assertThrows(IllegalArgumentException.class, () -> new MenuLayoutBuilder(size));
+ }
+
+ private static @Nonnull Stream getIllegalInventorySizes() {
+ List validArguments = Arrays.asList(9, 18, 27, 36, 45, 54);
+
+ // @formatter:off
+ return IntStream.range(-10, 60)
+ .filter(arg -> !validArguments.contains(arg))
+ .mapToObj(Arguments::of);
+ // @formatter:on
+ }
+
+}
diff --git a/dough-inventories/src/test/java/io/github/bakedlibs/dough/inventory/factory/MockExceptionHandler.java b/dough-inventories/src/test/java/io/github/bakedlibs/dough/inventory/factory/MockExceptionHandler.java
new file mode 100644
index 00000000..015de120
--- /dev/null
+++ b/dough-inventories/src/test/java/io/github/bakedlibs/dough/inventory/factory/MockExceptionHandler.java
@@ -0,0 +1,32 @@
+package io.github.bakedlibs.dough.inventory.factory;
+
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.logging.Handler;
+import java.util.logging.Level;
+import java.util.logging.LogRecord;
+
+import javax.annotation.ParametersAreNonnullByDefault;
+
+class MockExceptionHandler extends Handler {
+
+ private final AtomicReference ref;
+
+ @ParametersAreNonnullByDefault
+ MockExceptionHandler(AtomicReference ref) {
+ this.ref = ref;
+ }
+
+ @Override
+ public void publish(LogRecord record) {
+ if (record.getLevel() == Level.SEVERE) {
+ ref.set(record.getThrown());
+ }
+ }
+
+ @Override
+ public void flush() {}
+
+ @Override
+ public void close() throws SecurityException {}
+
+}
diff --git a/dough-inventories/src/test/java/io/github/bakedlibs/dough/inventory/factory/MockMenuFactory.java b/dough-inventories/src/test/java/io/github/bakedlibs/dough/inventory/factory/MockMenuFactory.java
new file mode 100644
index 00000000..3c84dd6e
--- /dev/null
+++ b/dough-inventories/src/test/java/io/github/bakedlibs/dough/inventory/factory/MockMenuFactory.java
@@ -0,0 +1,34 @@
+package io.github.bakedlibs.dough.inventory.factory;
+
+import java.io.File;
+
+import javax.annotation.ParametersAreNonnullByDefault;
+
+import org.bukkit.plugin.PluginDescriptionFile;
+import org.bukkit.plugin.java.JavaPlugin;
+import org.bukkit.plugin.java.JavaPluginLoader;
+
+import be.seeseemelk.mockbukkit.MockBukkit;
+
+public class MockMenuFactory extends MenuFactory {
+
+ public MockMenuFactory() {
+ // @formatter:off
+ super(MockBukkit.loadWith(MockJavaPlugin.class, new PluginDescriptionFile(
+ "MockPlugin",
+ "1.0.0",
+ MockJavaPlugin.class.getName())
+ ));
+ // @formatter:on
+ }
+
+ public static class MockJavaPlugin extends JavaPlugin {
+
+ @ParametersAreNonnullByDefault
+ public MockJavaPlugin(JavaPluginLoader loader, PluginDescriptionFile description, File dataFolder, File file) {
+ super(loader, description, dataFolder, file);
+ }
+
+ }
+
+}
diff --git a/dough-inventories/src/test/java/io/github/bakedlibs/dough/inventory/factory/TestMenuClicking.java b/dough-inventories/src/test/java/io/github/bakedlibs/dough/inventory/factory/TestMenuClicking.java
new file mode 100644
index 00000000..f34b9493
--- /dev/null
+++ b/dough-inventories/src/test/java/io/github/bakedlibs/dough/inventory/factory/TestMenuClicking.java
@@ -0,0 +1,240 @@
+package io.github.bakedlibs.dough.inventory.factory;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.logging.Handler;
+
+import javax.annotation.Nonnull;
+import javax.annotation.ParametersAreNonnullByDefault;
+
+import org.bukkit.Bukkit;
+import org.bukkit.Material;
+import org.bukkit.Server;
+import org.bukkit.entity.Player;
+import org.bukkit.event.Listener;
+import org.bukkit.event.inventory.ClickType;
+import org.bukkit.event.inventory.InventoryAction;
+import org.bukkit.event.inventory.InventoryClickEvent;
+import org.bukkit.event.inventory.InventoryType.SlotType;
+import org.bukkit.inventory.Inventory;
+import org.bukkit.inventory.InventoryView;
+import org.bukkit.inventory.ItemStack;
+import org.bukkit.plugin.Plugin;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import io.github.bakedlibs.dough.inventory.Menu;
+import io.github.bakedlibs.dough.inventory.MenuLayout;
+import io.github.bakedlibs.dough.inventory.builders.MenuLayoutBuilder;
+import io.github.bakedlibs.dough.inventory.builders.SlotGroupBuilder;
+import io.github.bakedlibs.dough.inventory.payloads.MenuClickPayload;
+
+import be.seeseemelk.mockbukkit.MockBukkit;
+import be.seeseemelk.mockbukkit.ServerMock;
+
+class TestMenuClicking {
+
+ private static ServerMock server;
+ private static MenuFactory factory;
+ private static MenuFactory factory2;
+
+ @BeforeAll
+ static void setup() {
+ server = MockBukkit.mock();
+ factory = new MockMenuFactory();
+ factory2 = new MockMenuFactory();
+ }
+
+ @AfterAll
+ static void teardown() {
+ MockBukkit.unmock();
+ }
+
+ @ParameterizedTest
+ @ValueSource(booleans = { true, false })
+ void testClickItemInSlot(boolean interactable) {
+ AtomicReference payloadRef = new AtomicReference<>();
+
+ // @formatter:off
+ MenuLayout layout = new MenuLayoutBuilder(9)
+ .addSlotGroup(
+ new SlotGroupBuilder('x', "test")
+ .withSlots(0, 1)
+ .interactable(interactable)
+ .withDefaultItem(new ItemStack(Material.APPLE))
+ .onClick(payloadRef::set)
+ .build()
+ )
+ .addSlotGroup(
+ new SlotGroupBuilder('y', "test2")
+ .withSlotRange(2, 8)
+ .build()
+ )
+ .build();
+ // @formatter:on
+
+ assertListenerRegistered();
+
+ Menu inv = factory.createMenu(layout);
+ Player player = server.addPlayer();
+ int slot = 1;
+
+ InventoryView view = inv.open(player);
+ InventoryClickEvent event = new InventoryClickEvent(view, SlotType.CONTAINER, slot, ClickType.LEFT, InventoryAction.PICKUP_ONE);
+
+ Bukkit.getPluginManager().callEvent(event);
+ MenuClickPayload payload = payloadRef.get();
+
+ assertNotNull(payload);
+ assertSame(inv, payload.getMenu());
+ assertSame(player, payload.getPlayer());
+
+ assertEquals(slot, payload.getClickedSlot());
+ assertEquals(layout.getGroup("test"), payload.getClickedSlotGroup());
+ assertEquals(new ItemStack(Material.APPLE), payload.getClickedItemStack());
+
+ assertNotEquals(interactable, event.isCancelled());
+ }
+
+ @Test
+ void testOtherInventoriesAreIgnored() {
+ AtomicReference payloadRef = new AtomicReference<>();
+
+ // @formatter:off
+ MenuLayout layout = new MenuLayoutBuilder(9)
+ .addSlotGroup(
+ new SlotGroupBuilder('x', "test")
+ .withSlotRange(0, 8)
+ .onClick(payloadRef::set)
+ .build()
+ )
+ .build();
+ // @formatter:on
+
+ factory.createMenu(layout);
+
+ Player player = server.addPlayer();
+ Inventory inv = Bukkit.createInventory(null, 9);
+ InventoryView view = player.openInventory(inv);
+ InventoryClickEvent event = new InventoryClickEvent(view, SlotType.CONTAINER, 1, ClickType.LEFT, InventoryAction.PICKUP_ONE);
+
+ Bukkit.getPluginManager().callEvent(event);
+
+ // Make sure our listener ignored this inventory
+ assertNull(payloadRef.get());
+ }
+
+ @Test
+ void testExceptionHandling() {
+ // @formatter:off
+ MenuLayout layout = new MenuLayoutBuilder(9)
+ .addSlotGroup(
+ new SlotGroupBuilder('x', "test")
+ .withSlotRange(0, 2)
+ .onClick(payload -> {
+ throw new NullPointerException("NPE was thrown | This is expected!");
+ })
+ .build()
+ )
+ .addSlotGroup(
+ new SlotGroupBuilder('y', "test2")
+ .withSlotRange(3, 8)
+ .build()
+ )
+ .build();
+ // @formatter:on
+
+ Menu inv = factory.createMenu(layout);
+ assertExceptionCaughtOnClick(inv);
+ }
+
+ private void assertExceptionCaughtOnClick(@Nonnull Menu inv) {
+ AtomicReference thrownException = new AtomicReference<>();
+ Handler handler = new MockExceptionHandler(thrownException);
+
+ factory.getLogger().addHandler(handler);
+ simulateClickEvents(inv, 1);
+ factory.getLogger().removeHandler(handler);
+
+ assertNotNull(thrownException.get());
+ assertTrue(thrownException.get() instanceof NullPointerException);
+ }
+
+ @Test
+ void testMultipleFactories() {
+ Map eventsFired = new HashMap<>();
+
+ // @formatter:off
+ MenuLayout layout = new MenuLayoutBuilder(9)
+ .addSlotGroup(
+ new SlotGroupBuilder('x', "test")
+ .withSlotRange(0, 8)
+ .interactable(true)
+ .onClick(payload -> {
+ MenuFactory factory = payload.getMenu().getFactory();
+ eventsFired.merge(factory, 1, Integer::sum);
+ })
+ .build()
+ )
+ .build();
+ // @formatter:on
+
+ Menu inv = factory.createMenu(layout);
+ Menu inv2 = factory2.createMenu(layout);
+
+ simulateClickEvents(inv, 2);
+ simulateClickEvents(inv2, 4);
+
+ assertEquals(2, eventsFired.get(factory));
+ assertEquals(4, eventsFired.get(factory2));
+ }
+
+ @ParametersAreNonnullByDefault
+ private void simulateClickEvents(Menu inv, int amount) {
+ for (int i = 0; i < amount; i++) {
+ Player player = server.addPlayer();
+ InventoryView view = inv.open(player);
+ InventoryClickEvent event = new InventoryClickEvent(view, SlotType.CONTAINER, 1, ClickType.LEFT, InventoryAction.PICKUP_ONE);
+
+ Bukkit.getPluginManager().callEvent(event);
+ player.closeInventory();
+ }
+ }
+
+ /**
+ * This method asserts that the {@link Listener} for our {@link MenuFactory}
+ * is properly registered to the {@link Server} and listens to the {@link InventoryClickEvent}.
+ */
+ private void assertListenerRegistered() {
+ assertNotNull(factory.getPlugin());
+ assertNotNull(factory.getLogger());
+
+ // @formatter:off
+ assertEquals(1,Arrays.stream(InventoryClickEvent.getHandlerList().getRegisteredListeners())
+ .filter(listener -> {
+ Plugin plugin = listener.getPlugin();
+ Listener clickListener = listener.getListener();
+
+ if (plugin.equals(factory.getPlugin()) && clickListener instanceof MenuListener) {
+ return factory.equals(((MenuListener) clickListener).getFactory());
+ } else {
+ return false;
+ }
+ }).count()
+ );
+ // @formatter:off
+ }
+
+}
diff --git a/dough-inventories/src/test/java/io/github/bakedlibs/dough/inventory/factory/TestMenuClosing.java b/dough-inventories/src/test/java/io/github/bakedlibs/dough/inventory/factory/TestMenuClosing.java
new file mode 100644
index 00000000..3a3cb458
--- /dev/null
+++ b/dough-inventories/src/test/java/io/github/bakedlibs/dough/inventory/factory/TestMenuClosing.java
@@ -0,0 +1,65 @@
+package io.github.bakedlibs.dough.inventory.factory;
+
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+
+import org.bukkit.entity.Player;
+import org.bukkit.inventory.InventoryView;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import io.github.bakedlibs.dough.inventory.Menu;
+import io.github.bakedlibs.dough.inventory.MenuLayout;
+import io.github.bakedlibs.dough.inventory.builders.MenuLayoutBuilder;
+import io.github.bakedlibs.dough.inventory.builders.SlotGroupBuilder;
+
+import be.seeseemelk.mockbukkit.MockBukkit;
+import be.seeseemelk.mockbukkit.ServerMock;
+
+class TestMenuClosing {
+
+ private static MenuFactory factory;
+ private static ServerMock server;
+
+ @BeforeAll
+ static void setup() {
+ server = MockBukkit.mock();
+ factory = new MockMenuFactory();
+ }
+
+ @AfterAll
+ static void teardown() {
+ MockBukkit.unmock();
+ }
+
+ @Test
+ void testCloseAll() {
+ // @formatter:off
+ MenuLayout layout = new MenuLayoutBuilder(9)
+ .addSlotGroup(
+ new SlotGroupBuilder('x', "test")
+ .withSlotRange(0, 8)
+ .build()
+ )
+ .build();
+ // @formatter:on
+
+ Menu inv = factory.createMenu(layout);
+
+ Player player = server.addPlayer();
+ InventoryView view = inv.open(player);
+
+ Player player2 = server.addPlayer();
+ InventoryView view2 = inv.open(player2);
+
+ assertSame(view, player.getOpenInventory());
+ assertSame(view2, player2.getOpenInventory());
+
+ inv.closeAllViews();
+
+ assertNull(player.getOpenInventory());
+ assertNull(player2.getOpenInventory());
+ }
+
+}
diff --git a/dough-inventories/src/test/java/io/github/bakedlibs/dough/inventory/factory/TestMenuCreation.java b/dough-inventories/src/test/java/io/github/bakedlibs/dough/inventory/factory/TestMenuCreation.java
new file mode 100644
index 00000000..4c89125e
--- /dev/null
+++ b/dough-inventories/src/test/java/io/github/bakedlibs/dough/inventory/factory/TestMenuCreation.java
@@ -0,0 +1,186 @@
+package io.github.bakedlibs.dough.inventory.factory;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import org.bukkit.Bukkit;
+import org.bukkit.Material;
+import org.bukkit.inventory.Inventory;
+import org.bukkit.inventory.ItemStack;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import io.github.bakedlibs.dough.inventory.Menu;
+import io.github.bakedlibs.dough.inventory.MenuLayout;
+import io.github.bakedlibs.dough.inventory.SlotGroup;
+import io.github.bakedlibs.dough.inventory.builders.MenuLayoutBuilder;
+import io.github.bakedlibs.dough.inventory.builders.SlotGroupBuilder;
+
+import be.seeseemelk.mockbukkit.MockBukkit;
+
+class TestMenuCreation {
+
+ private static MenuFactory factory;
+
+ @BeforeAll
+ static void setup() {
+ MockBukkit.mock();
+ factory = new MockMenuFactory();
+ }
+
+ @AfterAll
+ static void teardown() {
+ MockBukkit.unmock();
+ }
+
+ @ParameterizedTest
+ @ValueSource(ints = { 9, 18, 27, 36, 45, 54 })
+ void testCreationWithSize(int size) {
+ // @formatter:off
+ MenuLayout layout = new MenuLayoutBuilder(size)
+ .title("Awesome Inventory!")
+ .addSlotGroup(
+ new SlotGroupBuilder('x', "test")
+ .withSlotRange(0, size - 1)
+ .build()
+ )
+ .build();
+ // @formatter:on
+
+ Menu inv = factory.createMenu(layout);
+
+ assertNotNull(inv);
+ assertEquals(layout, inv.getLayout());
+ assertEquals(factory, inv.getFactory());
+ assertEquals(layout.getTitle(), inv.getTitle());
+ assertEquals(layout.getSize(), inv.getSize());
+
+ assertNotNull(inv.getInventory());
+ assertSame(inv, inv.getInventory().getHolder());
+ }
+
+ @Test
+ void testInventoryValidation() {
+ // @formatter:off
+ MenuLayout layout = new MenuLayoutBuilder(9)
+ .addSlotGroup(
+ new SlotGroupBuilder('x', "test")
+ .withSlotRange(0, 8)
+ .build()
+ )
+ .build();
+ // @formatter:on
+
+ Menu inv = new CustomMenu(factory, layout);
+
+ assertThrows(UnsupportedOperationException.class, () -> inv.getInventory());
+ }
+
+ @Test
+ void testInventoryHolderValidation() {
+ // @formatter:off
+ MenuLayout layout = new MenuLayoutBuilder(9)
+ .addSlotGroup(
+ new SlotGroupBuilder('x', "test")
+ .withSlotRange(0, 8)
+ .build()
+ )
+ .build();
+ // @formatter:on
+
+ CustomMenu inv = new CustomMenu(factory, layout);
+
+ // InventoryHolder == null
+ Inventory inventory = Bukkit.createInventory(null, 9);
+
+ assertThrows(IllegalArgumentException.class, () -> inv.setInventory(inventory));
+
+ // Different inventory size
+ Inventory inventory2 = Bukkit.createInventory(inv, 18);
+
+ assertThrows(IllegalArgumentException.class, () -> inv.setInventory(inventory2));
+ }
+
+ @Test
+ void testDefaultItem() {
+ // @formatter:off
+ MenuLayout layout = new MenuLayoutBuilder(9)
+ .addSlotGroup(
+ new SlotGroupBuilder('x', "test")
+ .withSlots(0, 1)
+ .withDefaultItem(new ItemStack(Material.APPLE))
+ .build()
+ )
+ .addSlotGroup(
+ new SlotGroupBuilder('y', "test2")
+ .withSlotRange(2, 8)
+ .build()
+ )
+ .build();
+ // @formatter:on
+
+ Menu inv = factory.createMenu(layout);
+
+ assertEquals(new ItemStack(Material.APPLE), inv.getItem(0));
+ assertEquals(new ItemStack(Material.APPLE), inv.getItem(1));
+ assertNull(inv.getItem(2));
+ assertNull(inv.getItem(3));
+ assertNull(inv.getItem(4));
+ assertNull(inv.getItem(5));
+ assertNull(inv.getItem(6));
+ assertNull(inv.getItem(7));
+ assertNull(inv.getItem(8));
+ }
+
+ @Test
+ void testAddItem() {
+ // @formatter:off
+ MenuLayout layout = new MenuLayoutBuilder(9)
+ .addSlotGroup(
+ new SlotGroupBuilder('x', "test")
+ .withSlotRange(0, 5)
+ .build()
+ )
+ .addSlotGroup(
+ new SlotGroupBuilder('y', "test2")
+ .withSlotRange(6, 8)
+ .build()
+ )
+ .build();
+ // @formatter:on
+
+ Menu inv = factory.createMenu(layout);
+ SlotGroup group = layout.getGroup('y');
+
+ inv.setItem(6, new ItemStack(Material.AIR));
+
+ ItemStack item = new ItemStack(Material.EMERALD);
+ inv.addItem(group, item);
+
+ assertNull(inv.getItem(0));
+ assertEquals(item, inv.getItem(6));
+
+ inv.addItem(group, item);
+ assertEquals(2, inv.getItem(6).getAmount());
+
+ inv.addItem(group, new ItemStack(Material.EMERALD, 63));
+ assertEquals(64, inv.getItem(6).getAmount());
+ assertEquals(item, inv.getItem(7));
+
+ inv.addItem(group, item);
+ assertEquals(2, inv.getItem(7).getAmount());
+
+ inv.addItem(group, new ItemStack(Material.DIAMOND));
+ assertEquals(new ItemStack(Material.DIAMOND), inv.getItem(8));
+
+ ItemStack cantFit = new ItemStack(Material.GOLDEN_APPLE);
+ assertEquals(cantFit, inv.addItem(group, cantFit));
+ }
+
+}
diff --git a/dough-inventories/src/test/java/io/github/bakedlibs/dough/inventory/json/TestJsonParsing.java b/dough-inventories/src/test/java/io/github/bakedlibs/dough/inventory/json/TestJsonParsing.java
new file mode 100644
index 00000000..571b7d6e
--- /dev/null
+++ b/dough-inventories/src/test/java/io/github/bakedlibs/dough/inventory/json/TestJsonParsing.java
@@ -0,0 +1,136 @@
+package io.github.bakedlibs.dough.inventory.json;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.io.InputStream;
+
+import javax.annotation.Nonnull;
+import javax.annotation.ParametersAreNonnullByDefault;
+
+import org.junit.jupiter.api.Test;
+
+import io.github.bakedlibs.dough.inventory.MenuLayout;
+import io.github.bakedlibs.dough.inventory.SlotGroup;
+
+class TestJsonParsing {
+
+ @Test
+ void testValidFile() {
+ assertDoesNotThrow(() -> parse("valid1"));
+ }
+
+ @Test
+ void testCorrectSlotGroups() throws InvalidLayoutException {
+ MenuLayout layout = parse("valid2");
+
+ assertEquals(27, layout.getSize());
+ assertEquals(3, layout.getSlotGroups().size());
+
+ assertSlotGroup(layout, 0, '*', "background", false);
+ assertSlotGroup(layout, 13, '*', "background", false);
+ assertSlotGroup(layout, 26, '*', "background", false);
+
+ assertSlotGroup(layout, 10, '+', "input", true);
+ assertSlotGroup(layout, 11, '+', "input", true);
+
+ assertSlotGroup(layout, 14, '-', "output", true);
+ assertSlotGroup(layout, 17, '-', "output", true);
+ }
+
+ @ParametersAreNonnullByDefault
+ private void assertSlotGroup(MenuLayout layout, int slot, char identifier, String name, boolean interactable) {
+ SlotGroup group1 = layout.getGroup(slot);
+ SlotGroup group2 = layout.getGroup(identifier);
+ SlotGroup group3 = layout.getGroup(name);
+
+ assertSame(group1, group2);
+ assertSame(group1, group3);
+ assertSame(group2, group3);
+
+ // All three are the same, so this should apply to all of them.
+ assertEquals(interactable, group1.isInteractable());
+ }
+
+ @Test
+ void testNonExistingFile() {
+ assertThrows(IllegalArgumentException.class, () -> parse("I do not exist"));
+ }
+
+ @Test
+ void testMalformedJson() {
+ assertThrows(InvalidLayoutException.class, () -> LayoutParser.parseString("{ Is this \"\" how ] you json...?: false,"));
+ }
+
+ @Test
+ void testTooManyRows() {
+ assertInvalid("invalid1");
+ }
+
+ @Test
+ void testNoRows() {
+ assertInvalid("invalid2");
+ }
+
+ @Test
+ void testMissingLayoutObject() {
+ assertInvalid("invalid3");
+ }
+
+ @Test
+ void testMissingGroupsObject() {
+ assertInvalid("invalid4");
+ }
+
+ @Test
+ void testIncompleteGroupsObject() {
+ assertInvalid("invalid5");
+ }
+
+ @Test
+ void testLayoutWrongType() {
+ assertInvalid("invalid6");
+ }
+
+ @Test
+ void testGroupsWrongType() {
+ assertInvalid("invalid7");
+ }
+
+ @Test
+ void testLayoutRowsWrongType() {
+ assertInvalid("invalid8");
+ }
+
+ @Test
+ void testMissingSlotGroupName() {
+ assertInvalid("invalid9");
+ }
+
+ @Test
+ void testMissingSlotGroupInteractable() {
+ assertInvalid("invalid10");
+ }
+
+ @Test
+ void testSlotGroupInteractableWrongType() {
+ assertInvalid("invalid11");
+ }
+
+ @Test
+ void testTooLongRow() {
+ assertInvalid("invalid12");
+ }
+
+ private void assertInvalid(@Nonnull String name) {
+ assertThrows(InvalidLayoutException.class, () -> parse(name));
+ }
+
+ private @Nonnull MenuLayout parse(@Nonnull String name) throws InvalidLayoutException {
+ InputStream inputStream = getClass().getResourceAsStream("/" + name + ".json");
+ return LayoutParser.parseStream(inputStream);
+ }
+
+}
diff --git a/dough-inventories/src/test/java/io/github/bakedlibs/dough/inventory/json/TestSlotGroupInjection.java b/dough-inventories/src/test/java/io/github/bakedlibs/dough/inventory/json/TestSlotGroupInjection.java
new file mode 100644
index 00000000..933c85ff
--- /dev/null
+++ b/dough-inventories/src/test/java/io/github/bakedlibs/dough/inventory/json/TestSlotGroupInjection.java
@@ -0,0 +1,111 @@
+package io.github.bakedlibs.dough.inventory.json;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.InputStream;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.Consumer;
+
+import javax.annotation.Nonnull;
+import javax.annotation.ParametersAreNonnullByDefault;
+
+import org.bukkit.Bukkit;
+import org.bukkit.Material;
+import org.bukkit.entity.Player;
+import org.bukkit.event.inventory.ClickType;
+import org.bukkit.event.inventory.InventoryAction;
+import org.bukkit.event.inventory.InventoryClickEvent;
+import org.bukkit.event.inventory.InventoryType.SlotType;
+import org.bukkit.inventory.InventoryView;
+import org.bukkit.inventory.ItemStack;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import io.github.bakedlibs.dough.inventory.Menu;
+import io.github.bakedlibs.dough.inventory.MenuLayout;
+import io.github.bakedlibs.dough.inventory.builders.SlotGroupBuilder;
+import io.github.bakedlibs.dough.inventory.factory.MenuFactory;
+import io.github.bakedlibs.dough.inventory.factory.MockMenuFactory;
+
+import be.seeseemelk.mockbukkit.MockBukkit;
+import be.seeseemelk.mockbukkit.ServerMock;
+
+class TestSlotGroupInjection {
+
+ private static ServerMock server;
+ private static MenuFactory factory;
+
+ @BeforeAll
+ static void setup() {
+ server = MockBukkit.mock();
+ factory = new MockMenuFactory();
+ }
+
+ @AfterAll
+ static void teardown() {
+ MockBukkit.unmock();
+ }
+
+ @Test
+ void testClickEvent() throws InvalidLayoutException {
+ AtomicInteger clicks = new AtomicInteger();
+
+ MenuLayout layout = parse("valid3", builder -> {
+ if (!builder.name().equals("background")) {
+ builder.onClick(payload -> clicks.incrementAndGet());
+ }
+ });
+
+ Menu menu = factory.createMenu(layout);
+ Player player = server.addPlayer();
+ InventoryView view = menu.open(player);
+
+ simulateClick(player, view, 1);
+ simulateClick(player, view, 11);
+
+ // Only slot 11 should have triggered this
+ assertEquals(1, clicks.get());
+ }
+
+ @Test
+ void testItemInjection() throws InvalidLayoutException {
+ MenuLayout layout = parse("valid3", builder -> {
+ if (builder.name().equals("background")) {
+ builder.withDefaultItem(new ItemStack(Material.GRAY_STAINED_GLASS_PANE));
+ }
+ });
+
+ Menu menu = factory.createMenu(layout);
+ Player player = server.addPlayer();
+ InventoryView view = menu.open(player);
+
+ InventoryClickEvent clickEvent = simulateClick(player, view, 1);
+
+ assertEquals(new ItemStack(Material.GRAY_STAINED_GLASS_PANE), menu.getItem(clickEvent.getSlot()));
+ assertTrue(clickEvent.isCancelled());
+
+ InventoryClickEvent clickEvent2 = simulateClick(player, view, 11);
+
+ assertEquals(null, menu.getItem(clickEvent2.getSlot()));
+ assertFalse(clickEvent2.isCancelled());
+ }
+
+ @ParametersAreNonnullByDefault
+ private @Nonnull InventoryClickEvent simulateClick(Player player, InventoryView view, int slot) {
+ InventoryClickEvent event = new InventoryClickEvent(view, SlotType.CONTAINER, slot, ClickType.LEFT, InventoryAction.PICKUP_ONE);
+
+ Bukkit.getPluginManager().callEvent(event);
+ player.closeInventory();
+ return event;
+ }
+
+ @ParametersAreNonnullByDefault
+ private @Nonnull MenuLayout parse(String name, Consumer consumer) throws InvalidLayoutException {
+ InputStream inputStream = getClass().getResourceAsStream("/" + name + ".json");
+ return LayoutParser.parseStream(inputStream, consumer);
+ }
+
+}
diff --git a/dough-inventories/src/test/resources/invalid1.json b/dough-inventories/src/test/resources/invalid1.json
new file mode 100644
index 00000000..998124d0
--- /dev/null
+++ b/dough-inventories/src/test/resources/invalid1.json
@@ -0,0 +1,25 @@
+{
+ "layout" : [
+ "*********",
+ "*********",
+ "++++*----",
+ "++++*----",
+ "*********",
+ "*********",
+ "*********"
+ ],
+ "groups" : {
+ "*" : {
+ "name" : "background",
+ "interactable" : false
+ },
+ "+" : {
+ "name" : "test1",
+ "interactable" : false
+ },
+ "-" : {
+ "name" : "test2",
+ "interactable" : false
+ }
+ }
+}
diff --git a/dough-inventories/src/test/resources/invalid10.json b/dough-inventories/src/test/resources/invalid10.json
new file mode 100644
index 00000000..c1b38173
--- /dev/null
+++ b/dough-inventories/src/test/resources/invalid10.json
@@ -0,0 +1,21 @@
+{
+ "layout" : [
+ "*********",
+ "*********",
+ "++++*----",
+ "++++*----",
+ "*********",
+ "*********"
+ ],
+ "groups" : {
+ "*" : {
+ "name" : "background"
+ },
+ "+" : {
+ "name" : "test1"
+ },
+ "-" : {
+ "name" : "test2"
+ }
+ }
+}
diff --git a/dough-inventories/src/test/resources/invalid11.json b/dough-inventories/src/test/resources/invalid11.json
new file mode 100644
index 00000000..f57f7b24
--- /dev/null
+++ b/dough-inventories/src/test/resources/invalid11.json
@@ -0,0 +1,24 @@
+{
+ "layout" : [
+ "*********",
+ "*********",
+ "++++*----",
+ "++++*----",
+ "*********",
+ "*********"
+ ],
+ "groups" : {
+ "*" : {
+ "name" : "background",
+ "interactable" : 1
+ },
+ "+" : {
+ "name" : "test1",
+ "interactable" : 2
+ },
+ "-" : {
+ "name" : "test2",
+ "interactable" : 3
+ }
+ }
+}
diff --git a/dough-inventories/src/test/resources/invalid12.json b/dough-inventories/src/test/resources/invalid12.json
new file mode 100644
index 00000000..3a99c721
--- /dev/null
+++ b/dough-inventories/src/test/resources/invalid12.json
@@ -0,0 +1,21 @@
+{
+ "layout" : [
+ "**************************",
+ "++++*----",
+ "++++*----"
+ ],
+ "groups" : {
+ "*" : {
+ "name" : "background",
+ "interactable" : false
+ },
+ "+" : {
+ "name" : "test1",
+ "interactable" : false
+ },
+ "-" : {
+ "name" : "test2",
+ "interactable" : false
+ }
+ }
+}
diff --git a/dough-inventories/src/test/resources/invalid2.json b/dough-inventories/src/test/resources/invalid2.json
new file mode 100644
index 00000000..a3089366
--- /dev/null
+++ b/dough-inventories/src/test/resources/invalid2.json
@@ -0,0 +1,18 @@
+{
+ "layout" : [
+ ],
+ "groups" : {
+ "*" : {
+ "name" : "background",
+ "interactable" : false
+ },
+ "+" : {
+ "name" : "test1",
+ "interactable" : false
+ },
+ "-" : {
+ "name" : "test2",
+ "interactable" : false
+ }
+ }
+}
diff --git a/dough-inventories/src/test/resources/invalid3.json b/dough-inventories/src/test/resources/invalid3.json
new file mode 100644
index 00000000..5c72b7d6
--- /dev/null
+++ b/dough-inventories/src/test/resources/invalid3.json
@@ -0,0 +1,16 @@
+{
+ "groups" : {
+ "*" : {
+ "name" : "background",
+ "interactable" : false
+ },
+ "+" : {
+ "name" : "test1",
+ "interactable" : false
+ },
+ "-" : {
+ "name" : "test2",
+ "interactable" : false
+ }
+ }
+}
diff --git a/dough-inventories/src/test/resources/invalid4.json b/dough-inventories/src/test/resources/invalid4.json
new file mode 100644
index 00000000..57831e54
--- /dev/null
+++ b/dough-inventories/src/test/resources/invalid4.json
@@ -0,0 +1,10 @@
+{
+ "layout" : [
+ "*********",
+ "*********",
+ "++++*----",
+ "++++*----",
+ "*********",
+ "*********"
+ ]
+}
diff --git a/dough-inventories/src/test/resources/invalid5.json b/dough-inventories/src/test/resources/invalid5.json
new file mode 100644
index 00000000..97497b80
--- /dev/null
+++ b/dough-inventories/src/test/resources/invalid5.json
@@ -0,0 +1,16 @@
+{
+ "layout" : [
+ "*********",
+ "*********",
+ "++++*----",
+ "++++*----",
+ "*********",
+ "*********"
+ ],
+ "groups" : {
+ "*" : {
+ "name" : "background",
+ "interactable" : false
+ }
+ }
+}
diff --git a/dough-inventories/src/test/resources/invalid6.json b/dough-inventories/src/test/resources/invalid6.json
new file mode 100644
index 00000000..502f0e05
--- /dev/null
+++ b/dough-inventories/src/test/resources/invalid6.json
@@ -0,0 +1,17 @@
+{
+ "layout" : true,
+ "groups" : {
+ "*" : {
+ "name" : "background",
+ "interactable" : false
+ },
+ "+" : {
+ "name" : "test1",
+ "interactable" : false
+ },
+ "-" : {
+ "name" : "test2",
+ "interactable" : false
+ }
+ }
+}
diff --git a/dough-inventories/src/test/resources/invalid7.json b/dough-inventories/src/test/resources/invalid7.json
new file mode 100644
index 00000000..90d6e4c2
--- /dev/null
+++ b/dough-inventories/src/test/resources/invalid7.json
@@ -0,0 +1,11 @@
+{
+ "layout" : [
+ "*********",
+ "*********",
+ "++++*----",
+ "++++*----",
+ "*********",
+ "*********"
+ ],
+ "groups" : 6
+}
diff --git a/dough-inventories/src/test/resources/invalid8.json b/dough-inventories/src/test/resources/invalid8.json
new file mode 100644
index 00000000..8dd56ba1
--- /dev/null
+++ b/dough-inventories/src/test/resources/invalid8.json
@@ -0,0 +1,22 @@
+{
+ "layout" : [
+ 1,
+ 2,
+ 3,
+ 4
+ ],
+ "groups" : {
+ "*" : {
+ "name" : "background",
+ "interactable" : false
+ },
+ "+" : {
+ "name" : "test1",
+ "interactable" : false
+ },
+ "-" : {
+ "name" : "test2",
+ "interactable" : false
+ }
+ }
+}
diff --git a/dough-inventories/src/test/resources/invalid9.json b/dough-inventories/src/test/resources/invalid9.json
new file mode 100644
index 00000000..8458deee
--- /dev/null
+++ b/dough-inventories/src/test/resources/invalid9.json
@@ -0,0 +1,21 @@
+{
+ "layout" : [
+ "*********",
+ "*********",
+ "++++*----",
+ "++++*----",
+ "*********",
+ "*********"
+ ],
+ "groups" : {
+ "*" : {
+ "interactable" : false
+ },
+ "+" : {
+ "interactable" : false
+ },
+ "-" : {
+ "interactable" : false
+ }
+ }
+}
diff --git a/dough-inventories/src/test/resources/valid1.json b/dough-inventories/src/test/resources/valid1.json
new file mode 100644
index 00000000..9809def6
--- /dev/null
+++ b/dough-inventories/src/test/resources/valid1.json
@@ -0,0 +1,51 @@
+{
+ "layout" : [
+ "*********",
+ "++++*----",
+ "+ii+P-oo-",
+ "++++*----",
+ "*********"
+ ],
+ "groups" : {
+ "*" : {
+ "name" : "background",
+ "interactable" : false,
+ "item" : {
+ "material" : "minecraft:gray_stained_glass_pane",
+ "name" : ""
+ }
+ },
+ "+" : {
+ "name" : "input_background",
+ "interactable" : false,
+ "item" : {
+ "material" : "minecraft:cyan_stained_glass_pane",
+ "name" : ""
+ }
+ },
+ "-" : {
+ "name" : "output_background",
+ "interactable" : false,
+ "item" : {
+ "material" : "minecraft:orange_stained_glass_pane",
+ "name" : ""
+ }
+ },
+ "P" : {
+ "name" : "progress_bar",
+ "interactable" : false,
+ "item" : {
+ "material" : "minecraft:black_stained_glass_pane",
+ "name" : ""
+ }
+ },
+ "i" : {
+ "name" : "input",
+ "interactable" : true
+ },
+ "o" : {
+ "name" : "output",
+ "interactable" : true
+ }
+ }
+}
diff --git a/dough-inventories/src/test/resources/valid2.json b/dough-inventories/src/test/resources/valid2.json
new file mode 100644
index 00000000..aa9c812b
--- /dev/null
+++ b/dough-inventories/src/test/resources/valid2.json
@@ -0,0 +1,25 @@
+{
+ "layout" : [
+ "*********",
+ "++++*----",
+ "*********"
+ ],
+ "groups" : {
+ "*" : {
+ "name" : "background",
+ "interactable" : false,
+ "item" : {
+ "material" : "minecraft:gray_stained_glass_pane",
+ "name" : ""
+ }
+ },
+ "+" : {
+ "name" : "input",
+ "interactable" : true
+ },
+ "-" : {
+ "name" : "output",
+ "interactable" : true
+ }
+ }
+}
diff --git a/dough-inventories/src/test/resources/valid3.json b/dough-inventories/src/test/resources/valid3.json
new file mode 100644
index 00000000..4674484a
--- /dev/null
+++ b/dough-inventories/src/test/resources/valid3.json
@@ -0,0 +1,21 @@
+{
+ "layout" : [
+ "*********",
+ "++++*----",
+ "*********"
+ ],
+ "groups" : {
+ "*" : {
+ "name" : "background",
+ "interactable" : false
+ },
+ "+" : {
+ "name" : "input",
+ "interactable" : true
+ },
+ "-" : {
+ "name" : "output",
+ "interactable" : true
+ }
+ }
+}
diff --git a/dough-items/pom.xml b/dough-items/pom.xml
index 3bf126c8..359a4685 100644
--- a/dough-items/pom.xml
+++ b/dough-items/pom.xml
@@ -7,7 +7,7 @@
io.github.baked-libs
dough
- 1.1.3
+ 1.2.0
dough-items
diff --git a/dough-protection/pom.xml b/dough-protection/pom.xml
index 16a96d94..bb5b032d 100644
--- a/dough-protection/pom.xml
+++ b/dough-protection/pom.xml
@@ -7,7 +7,7 @@
io.github.baked-libs
dough
- 1.1.3
+ 1.2.0
dough-protection
diff --git a/dough-recipes/pom.xml b/dough-recipes/pom.xml
index a94fdf1e..ee47827a 100644
--- a/dough-recipes/pom.xml
+++ b/dough-recipes/pom.xml
@@ -7,7 +7,7 @@
io.github.baked-libs
dough
- 1.1.3
+ 1.2.0
dough-recipes
diff --git a/dough-reflection/pom.xml b/dough-reflection/pom.xml
index a72e2ab9..495b07a5 100644
--- a/dough-reflection/pom.xml
+++ b/dough-reflection/pom.xml
@@ -7,7 +7,7 @@
io.github.baked-libs
dough
- 1.1.3
+ 1.2.0
dough-reflection
diff --git a/dough-scheduling/pom.xml b/dough-scheduling/pom.xml
index b8ef4efa..3761b599 100644
--- a/dough-scheduling/pom.xml
+++ b/dough-scheduling/pom.xml
@@ -7,7 +7,7 @@
io.github.baked-libs
dough
- 1.1.3
+ 1.2.0
dough-scheduling
diff --git a/dough-skins/pom.xml b/dough-skins/pom.xml
index 49056831..90fc17d9 100644
--- a/dough-skins/pom.xml
+++ b/dough-skins/pom.xml
@@ -7,7 +7,7 @@
io.github.baked-libs
dough
- 1.1.3
+ 1.2.0
dough-skins
diff --git a/dough-updater/pom.xml b/dough-updater/pom.xml
index d91f321a..8369f050 100644
--- a/dough-updater/pom.xml
+++ b/dough-updater/pom.xml
@@ -7,7 +7,7 @@
io.github.baked-libs
dough
- 1.1.3
+ 1.2.0
dough-updater
diff --git a/pom.xml b/pom.xml
index 7d81ea53..909a691d 100644
--- a/pom.xml
+++ b/pom.xml
@@ -6,7 +6,7 @@
io.github.baked-libs
dough
- 1.1.3
+ 1.2.0
pom