8 Commits
Author SHA1 Message Date
dmg 862c9a8a84 chore(knowledge): move canonical docs to shared SoMC wiki
CI / build (push) Successful in 1m22s
Release / release (push) Successful in 2m5s
2026-09-09 23:18:17 -04:00
dmg 3960672104 feat(tools): replace broken main-hand tools
Release / release (push) Successful in 2m54s
CI / build (push) Failing after 18m56s
2026-09-04 12:00:56 -04:00
dmg d36ba9da19 docs(tools): start broken-tool replacement story
CI / build (push) In progress
Release / release (push) In progress
2026-09-04 11:56:37 -04:00
dmg 1566437ba3 feat(sort): sort containers and player storage
CI / build (push) Failing after 5m25s
Release / release (push) Failing after 28m43s
2026-09-04 11:56:19 -04:00
dmg 5829d1d1dd docs(sort): start inventory sorting story
Release / release (push) Successful in 1m59s
CI / build (push) Successful in 1m1s
2026-09-04 11:48:20 -04:00
dmg 848f5c643f feat(sort): add craftable sorting stick
CI / build (push) Successful in 1m14s
Release / release (push) Successful in 2m11s
2026-09-04 11:48:09 -04:00
dmg ad29db0a79 docs(sort): start sorting stick story
CI / build (push) Successful in 1m7s
Release / release (push) Successful in 1m56s
2026-09-04 11:41:51 -04:00
dmg df5dd43539 docs(build): complete build and release story
CI / build (push) Successful in 1m4s
Release / release (push) Successful in 2m0s
2026-09-04 11:38:30 -04:00
26 changed files with 1096 additions and 194 deletions
+5 -33
View File
@@ -1,37 +1,9 @@
# Project Agent Instructions
# spigot-inventory-helper agent entrypoint
## Project
The canonical stories, engineering guidance, and **all process documents** are in the private [SoMC OKF wiki](https://git.garvis.dev/dmg/somc-okf/src/branch/main/index.md).
- This repository contains the Spigot Inventory Helper plugin.
- Target Purpur `26.2.build.2618-stable` with Java 25, matching `../spigot-base/` unless an approved user story changes the target.
- Use Gradle with Kotlin DSL and the Gradle wrapper.
- Keep compiler lint warnings enabled and fail builds on warnings, excluding only warnings explicitly justified by the supported API.
Before work, read the sibling `../somc-okf/index.md`, `../somc-okf/processes/index.md`, `../somc-okf/projects/spigot-inventory-helper/index.md`, `engineering.md` in that project section, and relevant `../somc-okf/user-stories/spigot-inventory-helper/` stories. Also follow the parent workspace `AGENTS.md` when present.
## Knowledge bundle
For standalone checkouts, start at the [project page](https://git.garvis.dev/dmg/somc-okf/src/branch/main/projects/spigot-inventory-helper/index.md) and [shared process](https://git.garvis.dev/dmg/somc-okf/src/branch/main/processes/development.md). Obtain wiki access before feature work; do not recreate a local knowledge bundle. Source builds do not require private wiki access.
- `design/` is the canonical OKF v0.1 knowledge bundle. Do not create a parallel `knowledge/` bundle.
- Read `design/index.md`, the user-story catalog, relevant stories, and relevant implementation before changing behavior.
- Every feature or behavior change must have a corresponding story under `design/user-stories/`.
- Present proposed story additions or changes for review and wait for explicit approval before modifying stories or code.
- Stories must have OKF frontmatter with `type: User Story`, a descriptive `title`, concise `description`, and one of `backlog`, `in-progress`, or `done` as `status`.
- Stories must contain acceptance criteria using Markdown task-list items.
- Set approved work to `in-progress` when implementation begins. Set it to `done` and check criteria only after implementation and verification.
- Maintain standard Markdown links between the root index, story catalog, related stories, and other concepts.
- Record material design and implementation decisions in `design/log.md`. Verify the current date before adding entries.
## Development
- Use test-driven development where practical: write a failing test, implement the minimum behavior, and refactor only while tests remain green.
- Prefer small, cohesive Java types and server-independent logic that can be unit tested without a running Minecraft server.
- Identify custom plugin items with persistent metadata rather than display names alone.
- Preserve item quantities and metadata during inventory operations.
- Respect Bukkit/Purpur event cancellation and protection-plugin decisions.
- Run `./gradlew clean check jar` before marking implementation criteria complete.
## Delivery
- Model Gradle and Gitea Actions configuration on `../spigot-base/`, adapted to this plugin's names and identifiers.
- Gitea workflows belong in `.gitea/workflows/`.
- Pull requests must validate conventional commits, and main-branch conventional commits drive semantic releases.
- Never commit generated Gradle output or plugin JARs.
- Use conventional commit messages in the form `type(scope): description`, with a concise summary under 72 characters.
Development follows [Development cycle](https://git.garvis.dev/dmg/somc-okf/src/branch/main/runbooks/development-cycle.md): approved stories, failing tests, passing implementation, verification, then source/wiki commit and push. GitOps updates are committed locally **without pushing**; only [Do release](https://git.garvis.dev/dmg/somc-okf/src/branch/main/runbooks/do-release.md) authorizes a reviewed GitOps push.
+15 -1
View File
@@ -2,7 +2,7 @@
A Purpur 26.2 plugin that sorts inventories with a craftable Sorting Stick and automatically replaces broken tools with equivalent tools from the player's inventory.
The approved behavior is specified in the [OKF design bundle](design/index.md).
The approved behavior is specified in the [SoMC OKF wiki](https://git.garvis.dev/dmg/somc-okf/src/branch/main/projects/spigot-inventory-helper/index.md).
## Requirements
@@ -29,6 +29,20 @@ Stick
Either storage-block variant produces one Sorting Stick.
## Sorting inventories
Hold an authentic Sorting Stick and right-click:
- A chest or either half of a double chest to sort its complete inventory.
- A barrel to sort its inventory.
- Air or another block to sort player inventory slots 935.
Player hotbar, armor, and off-hand slots remain unchanged during player sorting. Compatible stacks are consolidated first, then items are ordered alphabetically by Minecraft material. Dissimilar metadata variants of the same material retain their relative order. Locked containers and interactions cancelled by protection plugins are not sorted.
## Automatic tool replacement
When a main-hand tool breaks, an item with the exact same material is moved into the broken tool's selected hotbar slot on the next server tick. Main-storage candidates are preferred before tools in other hotbar slots. The complete replacement item is moved unchanged, preserving durability, enchantments, custom names, and other metadata. Off-hand breaks are ignored, and no unrelated item is moved when a match is unavailable.
## Releases
Gitea Actions checks pushes and pull requests and stores a development JAR. Pull requests validate conventional commits. Main-branch conventional commits drive semantic releases when the repository defines a `RELEASE_TOKEN` with contents-write permission.
-15
View File
@@ -1,15 +0,0 @@
---
type: Index
title: Spigot Inventory Helper Design
description: Entry point for the Spigot Inventory Helper OKF knowledge bundle.
okf_version: "0.1"
---
# Spigot Inventory Helper Design
This bundle documents inventory sorting, custom utility items, automatic tool replacement, and delivery requirements for the Spigot Inventory Helper plugin.
## Explore
- [User stories](user-stories/index.md)
- [Design log](log.md)
-25
View File
@@ -1,25 +0,0 @@
---
type: Log
title: Spigot Inventory Helper Design Log
description: Chronological record of material decisions affecting the Spigot Inventory Helper design.
---
# Spigot Inventory Helper Design Log
## 2026-09-04 — Initial inventory-helper design
- Players will be able to craft a metadata-authenticated Sorting Stick.
- The Sorting Stick will sort chests, double chests, barrels, or the player's main storage inventory depending on the interaction target.
- Player-inventory sorting will leave the hotbar, armor, and off-hand slots unchanged.
- When a main-hand tool breaks, an equivalent tool with the same material and tool type will be moved from the player's inventory into the selected hotbar slot when available.
- The knowledge bundle and eventual Gradle, Purpur, Gitea CI, and semantic-release setup will follow the neighboring Spigot Base project where applicable.
## 2026-09-04 — Sorting Stick recipe
- The Sorting Stick uses a vertical recipe in the crafting grid's center column: a Chest or Barrel above Redstone Dust, with a Stick at the bottom.
- Either storage-block variant produces one Sorting Stick.
## 2026-09-04 — Implementation started
- Approved implementation begins with the tested Gradle and Purpur foundation, followed by the Sorting Stick, inventory sorting, and automatic tool replacement.
- Work proceeds one user story at a time using red-green-refactor cycles and a verified commit and push after each completed story.
-12
View File
@@ -1,12 +0,0 @@
---
type: Index
title: Spigot Inventory Helper User Stories
description: Catalog of user stories for the Spigot Inventory Helper plugin.
---
# Spigot Inventory Helper User Stories
1. [US-001: Build and release the plugin](us-001-build-and-release-plugin.md)
2. [US-002: Craft a Sorting Stick](us-002-craft-sorting-stick.md)
3. [US-003: Sort containers and player inventory](us-003-sort-inventories.md)
4. [US-004: Automatically replace broken tools](us-004-replace-broken-tools.md)
@@ -1,26 +0,0 @@
---
type: User Story
title: "US-001: Build and release the plugin"
description: Give maintainers repeatable Purpur builds, automated verification, and versioned Gitea releases.
status: in-progress
---
# US-001: Build and release the plugin
As a **plugin maintainer**, I want repeatable builds and automated releases so that tested, correctly versioned artifacts can be distributed consistently.
## Acceptance criteria
- [ ] The Gradle project compiles against Purpur API `26.2.build.2618-stable` using a Java 25 toolchain.
- [ ] Compiler lint warnings fail the build.
- [ ] Automated JUnit 5 tests run as part of the Gradle check lifecycle.
- [ ] Pushes and pull requests build and test the plugin in Gitea Actions.
- [ ] Pull requests validate conventional commit messages.
- [ ] CI stores a development JAR as a workflow artifact.
- [ ] Main-branch conventional commits drive semantic versioning.
- [ ] A successful release builds a versioned JAR and attaches it to the corresponding Gitea release.
- [ ] Build files, Gradle wrapper, workflows, and release behavior follow `../spigot-base/` where applicable while using Spigot Inventory Helper names and identifiers.
## Related
- [User-story catalog](index.md)
@@ -1,26 +0,0 @@
---
type: User Story
title: "US-002: Craft a Sorting Stick"
description: Let players craft a recognizable utility item that invokes inventory sorting.
status: backlog
---
# US-002: Craft a Sorting Stick
As a **player**, I want to craft a recognizable Sorting Stick so that I can invoke inventory sorting during normal gameplay.
## Acceptance criteria
- [ ] The plugin registers a vertical shaped recipe with a Chest or Barrel above Redstone Dust and Redstone Dust above a Stick.
- [ ] The three ingredients occupy the center column of the 3×3 crafting grid.
- [ ] Either the Chest or Barrel recipe variant produces one Sorting Stick.
- [ ] The Sorting Stick has a distinct player-facing name and appearance.
- [ ] The item is identified using persistent item metadata rather than its display name alone.
- [ ] Renamed ordinary sticks cannot trigger sorting.
- [ ] Automated tests cover recipe registration, the exact shape, both storage-block variants, output quantity, and resulting item identity.
- [ ] The crafting recipe and both accepted variants are documented for players.
## Related
- [US-003: Sort containers and player inventory](us-003-sort-inventories.md)
- [User-story catalog](index.md)
@@ -1,30 +0,0 @@
---
type: User Story
title: "US-003: Sort containers and player inventory"
description: Let players use a Sorting Stick to consolidate and organize supported storage or their main inventory.
status: backlog
---
# US-003: Sort containers and player inventory
As a **player holding a Sorting Stick**, I want to sort storage containers or my main inventory so that related items are consolidated and organized automatically.
## Acceptance criteria
- [ ] Using the Sorting Stick on a chest sorts the complete chest inventory.
- [ ] Using it on either half of a double chest sorts the combined inventory.
- [ ] Using it on a barrel sorts that barrel.
- [ ] Using it without targeting a supported container sorts the player's main storage slots.
- [ ] Player hotbar, armor, and off-hand slots are not changed during player-inventory sorting.
- [ ] Compatible partial stacks are consolidated without exceeding item stack limits.
- [ ] Remaining stacks are placed in a deterministic, documented order.
- [ ] Sorting preserves item quantities and all item metadata exactly.
- [ ] Sorting respects cancelled interactions and normal container-access protections.
- [ ] Unsupported or inaccessible targets fail safely without changing an inventory.
- [ ] Sorting provides concise player feedback without opening the target container.
- [ ] Automated tests cover single chests, double chests, barrels, player inventory boundaries, stack consolidation, metadata preservation, and rejected interactions.
## Related
- [US-002: Craft a Sorting Stick](us-002-craft-sorting-stick.md)
- [User-story catalog](index.md)
@@ -1,26 +0,0 @@
---
type: User Story
title: "US-004: Automatically replace broken tools"
description: Move an equivalent inventory tool into the player's hand when their current main-hand tool breaks.
status: backlog
---
# US-004: Automatically replace broken tools
As a **player**, I want an equivalent tool moved into my hand when my current tool breaks so that I can continue working without searching my inventory.
## Acceptance criteria
- [ ] When a player's main-hand tool breaks, the plugin searches the player's inventory for a replacement.
- [ ] A replacement must have the same tool type and material as the broken tool; for example, a diamond pickaxe replaces only a diamond pickaxe.
- [ ] Eligible replacements may come from the main storage inventory or another hotbar slot.
- [ ] The replacement is moved into the currently selected hotbar slot.
- [ ] Existing durability, enchantments, custom names, persistent data, and other metadata on the replacement are preserved.
- [ ] No unrelated item is moved when an equivalent replacement is unavailable.
- [ ] Off-hand tool breakage does not replace the main-hand item.
- [ ] Cancelled damage or break events do not trigger replacement.
- [ ] Automated tests cover matching, unavailable replacements, hotbar and main-storage candidates, metadata preservation, and off-hand behavior.
## Related
- [User-story catalog](index.md)
@@ -0,0 +1,58 @@
package games.dmg.spigotinventoryhelper;
import java.util.Objects;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.Material;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerItemBreakEvent;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.PlayerInventory;
import org.bukkit.plugin.Plugin;
public final class BrokenToolListener implements Listener {
private final ToolReplacementService replacements;
private final TaskScheduler scheduler;
public BrokenToolListener(Plugin plugin, ToolReplacementService replacements) {
this(replacements, task -> plugin.getServer().getScheduler().runTask(plugin, task));
}
BrokenToolListener(ToolReplacementService replacements, TaskScheduler scheduler) {
this.replacements = Objects.requireNonNull(replacements, "replacements");
this.scheduler = Objects.requireNonNull(scheduler, "scheduler");
}
@EventHandler(priority = EventPriority.MONITOR)
public void onItemBreak(PlayerItemBreakEvent event) {
Player player = event.getPlayer();
PlayerInventory inventory = player.getInventory();
ItemStack broken = event.getBrokenItem();
if (inventory.getItemInMainHand() != broken) {
return;
}
Material material = broken.getType();
int selectedSlot = inventory.getHeldItemSlot();
scheduler.schedule(() -> {
if (replacements.replace(inventory, material, selectedSlot)) {
player.sendActionBar(Component.text(
"Equipped another " + readableName(material) + ".",
NamedTextColor.GREEN
));
}
});
}
private static String readableName(Material material) {
return material.name().toLowerCase(java.util.Locale.ROOT).replace('_', ' ');
}
@FunctionalInterface
interface TaskScheduler {
void schedule(Runnable task);
}
}
@@ -0,0 +1,58 @@
package games.dmg.spigotinventoryhelper;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.List;
import java.util.Objects;
import org.bukkit.Material;
import org.bukkit.inventory.ItemStack;
public final class InventorySorter {
public ItemStack[] sort(ItemStack[] contents) {
Objects.requireNonNull(contents, "contents");
List<ItemStack> items = Arrays.stream(contents)
.filter(Objects::nonNull)
.filter(item -> item.getType() != Material.AIR)
.filter(item -> item.getAmount() > 0)
.map(ItemStack::clone)
.sorted(Comparator.comparing(item -> item.getType().name()))
.toList();
List<ItemStack> packed = new ArrayList<>();
for (ItemStack item : items) {
pack(item, packed);
}
ItemStack[] sorted = new ItemStack[contents.length];
for (int slot = 0; slot < packed.size(); slot++) {
sorted[slot] = packed.get(slot);
}
return sorted;
}
private static void pack(ItemStack item, List<ItemStack> packed) {
int remaining = item.getAmount();
for (ItemStack existing : packed) {
if (!existing.isSimilar(item) || existing.getAmount() >= existing.getMaxStackSize()) {
continue;
}
int transferred = Math.min(
remaining,
existing.getMaxStackSize() - existing.getAmount()
);
existing.setAmount(existing.getAmount() + transferred);
remaining -= transferred;
if (remaining == 0) {
return;
}
}
while (remaining > 0) {
ItemStack stack = item.clone();
int amount = Math.min(remaining, stack.getMaxStackSize());
stack.setAmount(amount);
packed.add(stack);
remaining -= amount;
}
}
}
@@ -0,0 +1,39 @@
package games.dmg.spigotinventoryhelper;
import java.util.Arrays;
import java.util.Objects;
import org.bukkit.inventory.Inventory;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.PlayerInventory;
public final class InventorySortingService {
private static final int HOTBAR_SIZE = 9;
private static final int PLAYER_STORAGE_SIZE = 36;
private final InventorySorter sorter;
public InventorySortingService(InventorySorter sorter) {
this.sorter = Objects.requireNonNull(sorter, "sorter");
}
public void sortPlayerMainStorage(PlayerInventory inventory) {
Objects.requireNonNull(inventory, "inventory");
ItemStack[] storage = inventory.getStorageContents().clone();
if (storage.length < PLAYER_STORAGE_SIZE) {
throw new IllegalArgumentException("Player storage must contain 36 slots");
}
ItemStack[] mainStorage = Arrays.copyOfRange(
storage,
HOTBAR_SIZE,
PLAYER_STORAGE_SIZE
);
ItemStack[] sorted = sorter.sort(mainStorage);
System.arraycopy(sorted, 0, storage, HOTBAR_SIZE, sorted.length);
inventory.setStorageContents(storage);
}
public void sortContainer(Inventory inventory) {
Objects.requireNonNull(inventory, "inventory");
inventory.setContents(sorter.sort(inventory.getContents()));
}
}
@@ -0,0 +1,58 @@
package games.dmg.spigotinventoryhelper;
import java.util.Objects;
import java.util.function.Supplier;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.Material;
import org.bukkit.NamespacedKey;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.ItemMeta;
import org.bukkit.persistence.PersistentDataType;
public final class SortingStickItemFactory {
private static final byte AUTHENTIC_MARKER = 1;
private final NamespacedKey identityKey;
private final Supplier<ItemStack> itemSupplier;
public SortingStickItemFactory(NamespacedKey identityKey) {
this(identityKey, () -> new ItemStack(Material.STICK));
}
SortingStickItemFactory(NamespacedKey identityKey, Supplier<ItemStack> itemSupplier) {
this.identityKey = Objects.requireNonNull(identityKey, "identityKey");
this.itemSupplier = Objects.requireNonNull(itemSupplier, "itemSupplier");
}
public ItemStack create() {
ItemStack item = itemSupplier.get();
item.setType(Material.STICK);
item.setAmount(1);
ItemMeta metadata = Objects.requireNonNull(item.getItemMeta(), "Stick metadata");
metadata.displayName(Component.text("Sorting Stick", NamedTextColor.GOLD));
metadata.setEnchantmentGlintOverride(true);
metadata.getPersistentDataContainer().set(
identityKey,
PersistentDataType.BYTE,
AUTHENTIC_MARKER
);
item.setItemMeta(metadata);
return item;
}
public boolean isSortingStick(ItemStack item) {
if (item == null || item.getType() != Material.STICK || !item.hasItemMeta()) {
return false;
}
ItemMeta metadata = item.getItemMeta();
if (metadata == null) {
return false;
}
Byte marker = metadata.getPersistentDataContainer().get(
identityKey,
PersistentDataType.BYTE
);
return marker != null && marker == AUTHENTIC_MARKER;
}
}
@@ -0,0 +1,66 @@
package games.dmg.spigotinventoryhelper;
import java.util.Objects;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.block.Barrel;
import org.bukkit.block.Block;
import org.bukkit.block.Chest;
import org.bukkit.block.Container;
import org.bukkit.block.Lockable;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.block.Action;
import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.inventory.EquipmentSlot;
public final class SortingStickListener implements Listener {
private final SortingStickItemFactory sticks;
private final InventorySortingService sorting;
public SortingStickListener(
SortingStickItemFactory sticks,
InventorySortingService sorting
) {
this.sticks = Objects.requireNonNull(sticks, "sticks");
this.sorting = Objects.requireNonNull(sorting, "sorting");
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onInteract(PlayerInteractEvent event) {
if (event.isCancelled()
|| event.getHand() != EquipmentSlot.HAND
|| !isRightClick(event.getAction())
|| !sticks.isSortingStick(event.getItem())) {
return;
}
event.setCancelled(true);
Player player = event.getPlayer();
Block clicked = event.getClickedBlock();
if (clicked != null) {
Object state = clicked.getState();
if (state instanceof Chest || state instanceof Barrel) {
if (((Lockable) state).isLocked()) {
player.sendActionBar(Component.text(
"That container is locked.",
NamedTextColor.RED
));
return;
}
sorting.sortContainer(((Container) state).getInventory());
player.sendActionBar(Component.text("Container sorted.", NamedTextColor.GREEN));
return;
}
}
sorting.sortPlayerMainStorage(player.getInventory());
player.sendActionBar(Component.text("Inventory sorted.", NamedTextColor.GREEN));
}
private static boolean isRightClick(Action action) {
return action == Action.RIGHT_CLICK_AIR || action == Action.RIGHT_CLICK_BLOCK;
}
}
@@ -0,0 +1,66 @@
package games.dmg.spigotinventoryhelper;
import java.util.List;
import java.util.Objects;
import org.bukkit.Material;
import org.bukkit.NamespacedKey;
import org.bukkit.Server;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.ShapedRecipe;
public final class SortingStickRecipeRegistrar {
private final NamespacedKey recipeKey;
private final SortingStickItemFactory itemFactory;
private final SortingStickRecipeSpec recipeSpec;
private final RecipeFactory recipeFactory;
public SortingStickRecipeRegistrar(
NamespacedKey recipeKey,
SortingStickItemFactory itemFactory,
SortingStickRecipeSpec recipeSpec
) {
this(recipeKey, itemFactory, recipeSpec, ShapedRecipe::new);
}
SortingStickRecipeRegistrar(
NamespacedKey recipeKey,
SortingStickItemFactory itemFactory,
SortingStickRecipeSpec recipeSpec,
RecipeFactory recipeFactory
) {
this.recipeKey = Objects.requireNonNull(recipeKey, "recipeKey");
this.itemFactory = Objects.requireNonNull(itemFactory, "itemFactory");
this.recipeSpec = Objects.requireNonNull(recipeSpec, "recipeSpec");
this.recipeFactory = Objects.requireNonNull(recipeFactory, "recipeFactory");
}
public boolean register(Server server) {
Objects.requireNonNull(server, "server");
boolean registered = true;
for (Material storage : List.of(Material.CHEST, Material.BARREL)) {
ShapedRecipe recipe = createRecipe(storage);
registered = server.addRecipe(recipe) && registered;
}
return registered;
}
private ShapedRecipe createRecipe(Material storage) {
ItemStack result = itemFactory.create();
result.setAmount(recipeSpec.outputAmount());
NamespacedKey variantKey = new NamespacedKey(
recipeKey.getNamespace(),
recipeKey.getKey() + "_" + storage.name().toLowerCase(java.util.Locale.ROOT)
);
ShapedRecipe recipe = recipeFactory.create(variantKey, result);
recipe.shape(recipeSpec.shape().toArray(String[]::new));
recipe.setIngredient('C', storage);
recipe.setIngredient('R', Material.REDSTONE);
recipe.setIngredient('S', Material.STICK);
return recipe;
}
@FunctionalInterface
interface RecipeFactory {
ShapedRecipe create(NamespacedKey key, ItemStack result);
}
}
@@ -0,0 +1,29 @@
package games.dmg.spigotinventoryhelper;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.bukkit.Material;
public record SortingStickRecipeSpec(
List<String> shape,
Map<Character, Set<Material>> ingredients,
int outputAmount
) {
public SortingStickRecipeSpec {
shape = List.copyOf(shape);
ingredients = Map.copyOf(ingredients);
}
public static SortingStickRecipeSpec defaultRecipe() {
return new SortingStickRecipeSpec(
List.of(" C ", " R ", " S "),
Map.of(
'C', Set.of(Material.CHEST, Material.BARREL),
'R', Set.of(Material.REDSTONE),
'S', Set.of(Material.STICK)
),
1
);
}
}
@@ -1,10 +1,38 @@
package games.dmg.spigotinventoryhelper;
import org.bukkit.NamespacedKey;
import org.bukkit.plugin.java.JavaPlugin;
public final class SpigotInventoryHelperPlugin extends JavaPlugin {
private SortingStickItemFactory sortingSticks;
@Override
public void onEnable() {
sortingSticks = new SortingStickItemFactory(new NamespacedKey(this, "sorting_stick"));
SortingStickRecipeRegistrar recipes = new SortingStickRecipeRegistrar(
new NamespacedKey(this, "sorting_stick_recipe"),
sortingSticks,
SortingStickRecipeSpec.defaultRecipe()
);
if (!recipes.register(getServer())) {
getLogger().warning("One or more Sorting Stick recipes could not be registered.");
}
InventorySortingService sorting = new InventorySortingService(new InventorySorter());
getServer().getPluginManager().registerEvents(
new SortingStickListener(sortingSticks, sorting),
this
);
getServer().getPluginManager().registerEvents(
new BrokenToolListener(this, new ToolReplacementService()),
this
);
getLogger().info("Spigot Inventory Helper enabled.");
}
SortingStickItemFactory sortingSticks() {
if (sortingSticks == null) {
throw new IllegalStateException("Sorting Stick service is unavailable");
}
return sortingSticks;
}
}
@@ -0,0 +1,61 @@
package games.dmg.spigotinventoryhelper;
import java.util.Objects;
import org.bukkit.Material;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.PlayerInventory;
public final class ToolReplacementService {
private static final int HOTBAR_SIZE = 9;
private static final int PLAYER_STORAGE_SIZE = 36;
public boolean replace(
PlayerInventory inventory,
Material brokenMaterial,
int selectedSlot
) {
Objects.requireNonNull(inventory, "inventory");
Objects.requireNonNull(brokenMaterial, "brokenMaterial");
if (selectedSlot < 0 || selectedSlot >= HOTBAR_SIZE || !isEmpty(inventory.getItem(selectedSlot))) {
return false;
}
ItemStack[] storage = inventory.getStorageContents();
int source = find(storage, brokenMaterial, HOTBAR_SIZE, PLAYER_STORAGE_SIZE, selectedSlot);
if (source < 0) {
source = find(storage, brokenMaterial, 0, HOTBAR_SIZE, selectedSlot);
}
if (source < 0) {
return false;
}
ItemStack replacement = storage[source];
inventory.setItem(selectedSlot, replacement);
inventory.setItem(source, null);
return true;
}
private static int find(
ItemStack[] storage,
Material material,
int from,
int to,
int excludedSlot
) {
int limit = Math.min(to, storage.length);
for (int slot = from; slot < limit; slot++) {
ItemStack candidate = storage[slot];
if (slot != excludedSlot
&& candidate != null
&& candidate.getAmount() > 0
&& candidate.getType() == material) {
return slot;
}
}
return -1;
}
private static boolean isEmpty(ItemStack item) {
return item == null || item.getType() == Material.AIR || item.getAmount() == 0;
}
}
@@ -0,0 +1,59 @@
package games.dmg.spigotinventoryhelper;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import java.util.concurrent.atomic.AtomicReference;
import org.bukkit.Material;
import org.bukkit.entity.Player;
import org.bukkit.event.player.PlayerItemBreakEvent;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.PlayerInventory;
import org.junit.jupiter.api.Test;
final class BrokenToolListenerTest {
@Test
void replacesAMainHandToolOnTheNextServerTick() {
ToolReplacementService replacements = mock(ToolReplacementService.class);
AtomicReference<Runnable> scheduled = new AtomicReference<>();
BrokenToolListener listener = new BrokenToolListener(replacements, scheduled::set);
PlayerItemBreakEvent event = mock(PlayerItemBreakEvent.class);
Player player = mock(Player.class);
PlayerInventory inventory = mock(PlayerInventory.class);
ItemStack broken = mock(ItemStack.class);
when(event.getPlayer()).thenReturn(player);
when(event.getBrokenItem()).thenReturn(broken);
when(player.getInventory()).thenReturn(inventory);
when(inventory.getItemInMainHand()).thenReturn(broken);
when(inventory.getHeldItemSlot()).thenReturn(3);
when(broken.getType()).thenReturn(Material.DIAMOND_PICKAXE);
listener.onItemBreak(event);
scheduled.get().run();
verify(replacements).replace(inventory, Material.DIAMOND_PICKAXE, 3);
}
@Test
void ignoresAnOffHandToolBreak() {
ToolReplacementService replacements = mock(ToolReplacementService.class);
AtomicReference<Runnable> scheduled = new AtomicReference<>();
BrokenToolListener listener = new BrokenToolListener(replacements, scheduled::set);
PlayerItemBreakEvent event = mock(PlayerItemBreakEvent.class);
Player player = mock(Player.class);
PlayerInventory inventory = mock(PlayerInventory.class);
ItemStack mainHand = mock(ItemStack.class);
ItemStack brokenOffHand = mock(ItemStack.class);
when(event.getPlayer()).thenReturn(player);
when(event.getBrokenItem()).thenReturn(brokenOffHand);
when(player.getInventory()).thenReturn(inventory);
when(inventory.getItemInMainHand()).thenReturn(mainHand);
listener.onItemBreak(event);
verifyNoInteractions(replacements);
org.junit.jupiter.api.Assertions.assertNull(scheduled.get());
}
}
@@ -0,0 +1,89 @@
package games.dmg.spigotinventoryhelper;
import static org.junit.jupiter.api.Assertions.assertAll;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.util.concurrent.atomic.AtomicInteger;
import org.bukkit.Material;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.ItemMeta;
import org.junit.jupiter.api.Test;
final class InventorySorterTest {
@Test
void consolidatesStacksAndOrdersMaterialsAlphabetically() {
ItemStack[] contents = {
stack(Material.STONE, 5),
stack(Material.DIRT, 40),
stack(Material.DIRT, 30),
null
};
ItemStack[] sorted = new InventorySorter().sort(contents);
assertAll(
() -> assertEquals(Material.DIRT, sorted[0].getType()),
() -> assertEquals(64, sorted[0].getAmount()),
() -> assertEquals(Material.DIRT, sorted[1].getType()),
() -> assertEquals(6, sorted[1].getAmount()),
() -> assertEquals(Material.STONE, sorted[2].getType()),
() -> assertEquals(5, sorted[2].getAmount()),
() -> assertNull(sorted[3])
);
}
@Test
void preservesMetadataAndStableOrderForDissimilarVariants() {
ItemMeta named = mock(ItemMeta.class, "named");
ItemMeta enchanted = mock(ItemMeta.class, "enchanted");
ItemStack[] contents = {
stack(Material.STONE, 5, named),
stack(Material.STONE, 2, enchanted),
stack(Material.STONE, 4, named)
};
ItemStack[] sorted = new InventorySorter().sort(contents);
assertAll(
() -> assertSame(named, sorted[0].getItemMeta()),
() -> assertEquals(9, sorted[0].getAmount()),
() -> assertSame(enchanted, sorted[1].getItemMeta()),
() -> assertEquals(2, sorted[1].getAmount()),
() -> assertNull(sorted[2])
);
}
private static ItemStack stack(Material material, int initialAmount) {
return stack(material, initialAmount, null);
}
private static ItemStack stack(
Material material,
int initialAmount,
ItemMeta metadata
) {
AtomicInteger amount = new AtomicInteger(initialAmount);
ItemStack item = mock(ItemStack.class);
when(item.getType()).thenReturn(material);
when(item.getAmount()).thenAnswer(ignored -> amount.get());
when(item.getItemMeta()).thenReturn(metadata);
doAnswer(invocation -> {
amount.set(invocation.getArgument(0));
return null;
}).when(item).setAmount(anyInt());
when(item.getMaxStackSize()).thenReturn(64);
when(item.isSimilar(any(ItemStack.class))).thenAnswer(invocation -> {
ItemStack other = invocation.getArgument(0);
return other.getType() == material && other.getItemMeta() == metadata;
});
when(item.clone()).thenAnswer(ignored -> stack(material, amount.get(), metadata));
return item;
}
}
@@ -0,0 +1,44 @@
package games.dmg.spigotinventoryhelper;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.Arrays;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.PlayerInventory;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
final class InventorySortingServiceTest {
@Test
void sortsOnlyPlayerMainStorageSlots() {
PlayerInventory inventory = mock(PlayerInventory.class);
InventorySorter sorter = mock(InventorySorter.class);
ItemStack[] storage = stacks(36);
ItemStack[] mainStorage = Arrays.copyOfRange(storage, 9, 36);
ItemStack[] sortedMain = mainStorage.clone();
ItemStack first = sortedMain[0];
sortedMain[0] = sortedMain[26];
sortedMain[26] = first;
when(inventory.getStorageContents()).thenReturn(storage);
when(sorter.sort(mainStorage)).thenReturn(sortedMain);
new InventorySortingService(sorter).sortPlayerMainStorage(inventory);
ArgumentCaptor<ItemStack[]> result = ArgumentCaptor.forClass(ItemStack[].class);
verify(inventory).setStorageContents(result.capture());
assertArrayEquals(Arrays.copyOfRange(storage, 0, 9),
Arrays.copyOfRange(result.getValue(), 0, 9));
assertArrayEquals(sortedMain, Arrays.copyOfRange(result.getValue(), 9, 36));
}
private static ItemStack[] stacks(int size) {
ItemStack[] stacks = new ItemStack[size];
for (int slot = 0; slot < size; slot++) {
stacks[slot] = mock(ItemStack.class, "slot-" + slot);
}
return stacks;
}
}
@@ -0,0 +1,74 @@
package games.dmg.spigotinventoryhelper;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.Material;
import org.bukkit.NamespacedKey;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.ItemMeta;
import org.bukkit.persistence.PersistentDataContainer;
import org.bukkit.persistence.PersistentDataType;
import org.junit.jupiter.api.Test;
final class SortingStickItemFactoryTest {
@Test
void createsOneDistinctMetadataAuthenticatedStick() {
NamespacedKey key = new NamespacedKey("test", "sorting_stick");
ItemStack item = mock(ItemStack.class);
ItemMeta metadata = mock(ItemMeta.class);
PersistentDataContainer data = mock(PersistentDataContainer.class);
when(item.getItemMeta()).thenReturn(metadata);
when(metadata.getPersistentDataContainer()).thenReturn(data);
SortingStickItemFactory factory = new SortingStickItemFactory(key, () -> item);
assertSame(item, factory.create());
verify(item).setAmount(1);
verify(metadata).displayName(Component.text("Sorting Stick", NamedTextColor.GOLD));
verify(metadata).setEnchantmentGlintOverride(true);
verify(data).set(key, PersistentDataType.BYTE, (byte) 1);
verify(item).setItemMeta(metadata);
verify(item).getItemMeta();
verify(item).setType(Material.STICK);
}
@Test
void rejectsARegularRenamedStickWithoutPersistentIdentity() {
NamespacedKey key = new NamespacedKey("test", "sorting_stick");
ItemStack item = mock(ItemStack.class);
ItemMeta metadata = mock(ItemMeta.class);
PersistentDataContainer data = mock(PersistentDataContainer.class);
when(item.getType()).thenReturn(Material.STICK);
when(item.hasItemMeta()).thenReturn(true);
when(item.getItemMeta()).thenReturn(metadata);
when(metadata.getPersistentDataContainer()).thenReturn(data);
SortingStickItemFactory factory = new SortingStickItemFactory(key, () -> item);
assertFalse(factory.isSortingStick(item));
}
@Test
void acceptsAStickWithTheAuthenticPersistentMarker() {
NamespacedKey key = new NamespacedKey("test", "sorting_stick");
ItemStack item = mock(ItemStack.class);
ItemMeta metadata = mock(ItemMeta.class);
PersistentDataContainer data = mock(PersistentDataContainer.class);
when(item.getType()).thenReturn(Material.STICK);
when(item.hasItemMeta()).thenReturn(true);
when(item.getItemMeta()).thenReturn(metadata);
when(metadata.getPersistentDataContainer()).thenReturn(data);
when(data.get(key, PersistentDataType.BYTE)).thenReturn((byte) 1);
SortingStickItemFactory factory = new SortingStickItemFactory(key, () -> item);
assertTrue(factory.isSortingStick(item));
}
}
@@ -0,0 +1,173 @@
package games.dmg.spigotinventoryhelper;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import org.bukkit.block.Barrel;
import org.bukkit.block.Block;
import org.bukkit.block.Chest;
import org.bukkit.entity.Player;
import org.bukkit.event.block.Action;
import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.inventory.DoubleChestInventory;
import org.bukkit.inventory.EquipmentSlot;
import org.bukkit.inventory.Inventory;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.PlayerInventory;
import org.junit.jupiter.api.Test;
final class SortingStickListenerTest {
@Test
void sortsAChestWithoutOpeningIt() {
SortingStickItemFactory sticks = mock(SortingStickItemFactory.class);
InventorySortingService sorting = mock(InventorySortingService.class);
PlayerInteractEvent event = mock(PlayerInteractEvent.class);
Player player = mock(Player.class);
ItemStack stick = mock(ItemStack.class);
Block block = mock(Block.class);
Chest chest = mock(Chest.class);
Inventory inventory = mock(Inventory.class);
when(event.getHand()).thenReturn(EquipmentSlot.HAND);
when(event.getAction()).thenReturn(Action.RIGHT_CLICK_BLOCK);
when(event.getItem()).thenReturn(stick);
when(event.getPlayer()).thenReturn(player);
when(event.getClickedBlock()).thenReturn(block);
when(block.getState()).thenReturn(chest);
when(chest.getInventory()).thenReturn(inventory);
when(sticks.isSortingStick(stick)).thenReturn(true);
new SortingStickListener(sticks, sorting).onInteract(event);
verify(sorting).sortContainer(inventory);
verify(event).setCancelled(true);
}
@Test
void sortsABarrelWithoutOpeningIt() {
SortingStickItemFactory sticks = mock(SortingStickItemFactory.class);
InventorySortingService sorting = mock(InventorySortingService.class);
PlayerInteractEvent event = mock(PlayerInteractEvent.class);
ItemStack stick = mock(ItemStack.class);
Block block = mock(Block.class);
Barrel barrel = mock(Barrel.class);
Inventory inventory = mock(Inventory.class);
when(event.getHand()).thenReturn(EquipmentSlot.HAND);
when(event.getAction()).thenReturn(Action.RIGHT_CLICK_BLOCK);
when(event.getItem()).thenReturn(stick);
when(event.getPlayer()).thenReturn(mock(Player.class));
when(event.getClickedBlock()).thenReturn(block);
when(block.getState()).thenReturn(barrel);
when(barrel.getInventory()).thenReturn(inventory);
when(sticks.isSortingStick(stick)).thenReturn(true);
new SortingStickListener(sticks, sorting).onInteract(event);
verify(sorting).sortContainer(inventory);
verify(event).setCancelled(true);
}
@Test
void refusesToSortALockedContainer() {
SortingStickItemFactory sticks = mock(SortingStickItemFactory.class);
InventorySortingService sorting = mock(InventorySortingService.class);
PlayerInteractEvent event = mock(PlayerInteractEvent.class);
ItemStack stick = mock(ItemStack.class);
Block block = mock(Block.class);
Chest chest = mock(Chest.class);
when(event.getHand()).thenReturn(EquipmentSlot.HAND);
when(event.getAction()).thenReturn(Action.RIGHT_CLICK_BLOCK);
when(event.getItem()).thenReturn(stick);
when(event.getPlayer()).thenReturn(mock(Player.class));
when(event.getClickedBlock()).thenReturn(block);
when(block.getState()).thenReturn(chest);
when(chest.isLocked()).thenReturn(true);
when(sticks.isSortingStick(stick)).thenReturn(true);
new SortingStickListener(sticks, sorting).onInteract(event);
verifyNoInteractions(sorting);
verify(event).setCancelled(true);
}
@Test
void sortsTheCombinedInventoryReturnedByEitherHalfOfADoubleChest() {
SortingStickItemFactory sticks = mock(SortingStickItemFactory.class);
InventorySortingService sorting = mock(InventorySortingService.class);
PlayerInteractEvent event = authenticBlockInteraction(sticks);
Block block = event.getClickedBlock();
Chest chest = mock(Chest.class);
DoubleChestInventory combined = mock(DoubleChestInventory.class);
when(block.getState()).thenReturn(chest);
when(chest.getInventory()).thenReturn(combined);
new SortingStickListener(sticks, sorting).onInteract(event);
verify(sorting).sortContainer(combined);
}
@Test
void rightClickingAirSortsOnlyThePlayerMainStorage() {
SortingStickItemFactory sticks = mock(SortingStickItemFactory.class);
InventorySortingService sorting = mock(InventorySortingService.class);
PlayerInteractEvent event = mock(PlayerInteractEvent.class);
Player player = mock(Player.class);
PlayerInventory inventory = mock(PlayerInventory.class);
ItemStack stick = mock(ItemStack.class);
when(event.getHand()).thenReturn(EquipmentSlot.HAND);
when(event.getAction()).thenReturn(Action.RIGHT_CLICK_AIR);
when(event.getItem()).thenReturn(stick);
when(event.getPlayer()).thenReturn(player);
when(player.getInventory()).thenReturn(inventory);
when(sticks.isSortingStick(stick)).thenReturn(true);
new SortingStickListener(sticks, sorting).onInteract(event);
verify(sorting).sortPlayerMainStorage(inventory);
verify(event).setCancelled(true);
}
@Test
void respectsAnInteractionCancelledByAnotherPlugin() {
SortingStickItemFactory sticks = mock(SortingStickItemFactory.class);
InventorySortingService sorting = mock(InventorySortingService.class);
PlayerInteractEvent event = mock(PlayerInteractEvent.class);
when(event.isCancelled()).thenReturn(true);
new SortingStickListener(sticks, sorting).onInteract(event);
verifyNoInteractions(sticks, sorting);
verify(event, never()).setCancelled(true);
}
@Test
void ignoresAnOrdinaryStick() {
SortingStickItemFactory sticks = mock(SortingStickItemFactory.class);
InventorySortingService sorting = mock(InventorySortingService.class);
PlayerInteractEvent event = mock(PlayerInteractEvent.class);
when(event.getHand()).thenReturn(EquipmentSlot.HAND);
when(event.getAction()).thenReturn(Action.RIGHT_CLICK_AIR);
when(event.getItem()).thenReturn(mock(ItemStack.class));
new SortingStickListener(sticks, sorting).onInteract(event);
verifyNoInteractions(sorting);
verify(event, never()).setCancelled(true);
}
private static PlayerInteractEvent authenticBlockInteraction(
SortingStickItemFactory sticks
) {
PlayerInteractEvent event = mock(PlayerInteractEvent.class);
ItemStack stick = mock(ItemStack.class);
when(event.getHand()).thenReturn(EquipmentSlot.HAND);
when(event.getAction()).thenReturn(Action.RIGHT_CLICK_BLOCK);
when(event.getItem()).thenReturn(stick);
when(event.getPlayer()).thenReturn(mock(Player.class));
when(event.getClickedBlock()).thenReturn(mock(Block.class));
when(sticks.isSortingStick(stick)).thenReturn(true);
return event;
}
}
@@ -0,0 +1,62 @@
package games.dmg.spigotinventoryhelper;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.List;
import org.bukkit.Material;
import org.bukkit.NamespacedKey;
import org.bukkit.Server;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.ShapedRecipe;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
final class SortingStickRecipeRegistrarTest {
@Test
void registersChestAndBarrelVariants() {
Server server = mock(Server.class);
SortingStickItemFactory items = mock(SortingStickItemFactory.class);
ItemStack result = mock(ItemStack.class);
ShapedRecipe chestRecipe = mock(ShapedRecipe.class);
ShapedRecipe barrelRecipe = mock(ShapedRecipe.class);
SortingStickRecipeRegistrar.RecipeFactory recipes =
mock(SortingStickRecipeRegistrar.RecipeFactory.class);
when(items.create()).thenReturn(result);
when(recipes.create(any(NamespacedKey.class), any(ItemStack.class)))
.thenReturn(chestRecipe, barrelRecipe);
when(server.addRecipe(chestRecipe)).thenReturn(true);
when(server.addRecipe(barrelRecipe)).thenReturn(true);
SortingStickRecipeRegistrar registrar = new SortingStickRecipeRegistrar(
new NamespacedKey("test", "sorting_stick_recipe"),
items,
SortingStickRecipeSpec.defaultRecipe(),
recipes
);
assertTrue(registrar.register(server));
verify(chestRecipe).shape(" C ", " R ", " S ");
verify(chestRecipe).setIngredient('C', Material.CHEST);
verify(chestRecipe).setIngredient('R', Material.REDSTONE);
verify(chestRecipe).setIngredient('S', Material.STICK);
verify(barrelRecipe).shape(" C ", " R ", " S ");
verify(barrelRecipe).setIngredient('C', Material.BARREL);
verify(barrelRecipe).setIngredient('R', Material.REDSTONE);
verify(barrelRecipe).setIngredient('S', Material.STICK);
verify(server).addRecipe(chestRecipe);
verify(server).addRecipe(barrelRecipe);
verify(result, times(2)).setAmount(1);
ArgumentCaptor<NamespacedKey> keys = ArgumentCaptor.forClass(NamespacedKey.class);
verify(recipes, times(2)).create(keys.capture(), any(ItemStack.class));
assertEquals(
List.of("sorting_stick_recipe_chest", "sorting_stick_recipe_barrel"),
keys.getAllValues().stream().map(NamespacedKey::getKey).toList()
);
}
}
@@ -0,0 +1,21 @@
package games.dmg.spigotinventoryhelper;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.util.List;
import java.util.Set;
import org.bukkit.Material;
import org.junit.jupiter.api.Test;
final class SortingStickRecipeSpecTest {
@Test
void definesBothStorageVariantsInTheCenterColumn() {
SortingStickRecipeSpec recipe = SortingStickRecipeSpec.defaultRecipe();
assertEquals(List.of(" C ", " R ", " S "), recipe.shape());
assertEquals(Set.of(Material.CHEST, Material.BARREL), recipe.ingredients().get('C'));
assertEquals(Set.of(Material.REDSTONE), recipe.ingredients().get('R'));
assertEquals(Set.of(Material.STICK), recipe.ingredients().get('S'));
assertEquals(1, recipe.outputAmount());
}
}
@@ -0,0 +1,91 @@
package games.dmg.spigotinventoryhelper;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import org.bukkit.Material;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.PlayerInventory;
import org.junit.jupiter.api.Test;
final class ToolReplacementServiceTest {
@Test
void movesAnExactMainStorageReplacementIntoTheBrokenSlot() {
PlayerInventory inventory = mock(PlayerInventory.class);
ItemStack[] storage = new ItemStack[36];
ItemStack hotbarCandidate = tool(Material.DIAMOND_PICKAXE);
ItemStack mainCandidate = tool(Material.DIAMOND_PICKAXE);
storage[1] = hotbarCandidate;
storage[10] = mainCandidate;
when(inventory.getStorageContents()).thenReturn(storage);
boolean replaced = new ToolReplacementService().replace(
inventory,
Material.DIAMOND_PICKAXE,
0
);
assertTrue(replaced);
verify(inventory).setItem(0, mainCandidate);
verify(inventory).setItem(10, null);
verify(inventory, never()).setItem(1, null);
}
@Test
void fallsBackToAnotherHotbarSlot() {
PlayerInventory inventory = mock(PlayerInventory.class);
ItemStack[] storage = new ItemStack[36];
ItemStack replacement = tool(Material.IRON_AXE);
storage[7] = replacement;
when(inventory.getStorageContents()).thenReturn(storage);
assertTrue(new ToolReplacementService().replace(
inventory,
Material.IRON_AXE,
2
));
verify(inventory).setItem(2, replacement);
verify(inventory).setItem(7, null);
}
@Test
void leavesInventoryUnchangedWithoutAnExactMaterialMatch() {
PlayerInventory inventory = mock(PlayerInventory.class);
ItemStack[] storage = new ItemStack[36];
storage[10] = tool(Material.IRON_PICKAXE);
when(inventory.getStorageContents()).thenReturn(storage);
assertFalse(new ToolReplacementService().replace(
inventory,
Material.DIAMOND_PICKAXE,
0
));
verify(inventory, never()).setItem(0, storage[10]);
verify(inventory, never()).setItem(10, null);
}
@Test
void doesNotOverwriteAnItemPlacedIntoTheBrokenSlot() {
PlayerInventory inventory = mock(PlayerInventory.class);
ItemStack occupied = tool(Material.COBBLESTONE);
when(inventory.getItem(0)).thenReturn(occupied);
assertFalse(new ToolReplacementService().replace(
inventory,
Material.DIAMOND_PICKAXE,
0
));
verify(inventory, never()).getStorageContents();
}
private static ItemStack tool(Material material) {
ItemStack item = mock(ItemStack.class);
when(item.getType()).thenReturn(material);
when(item.getAmount()).thenReturn(1);
return item;
}
}