Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3960672104 | ||
|
|
d36ba9da19 | ||
|
|
1566437ba3 |
@@ -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 9–35.
|
||||
|
||||
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.
|
||||
|
||||
@@ -31,6 +31,20 @@ description: Chronological record of material decisions affecting the Spigot Inv
|
||||
- Registered equivalent chest and barrel recipe variants matching the approved center-column shape.
|
||||
- Verified recipe specification, registration, output, item appearance, identity, and renamed-stick rejection with automated tests and `./gradlew clean check jar`.
|
||||
|
||||
## 2026-09-04 — Inventory sorting completed
|
||||
|
||||
- Added metadata-preserving stack consolidation and stable alphabetical ordering by Minecraft material.
|
||||
- Added Sorting Stick interactions for single and double chests, barrels, and player main-storage slots while preserving hotbar, armor, and off-hand slots.
|
||||
- Rejected locked containers, cancelled protection events, non-right-click interactions, and unauthenticated sticks without changing inventories.
|
||||
- Added action-bar feedback, documented player behavior, and verified the complete feature with `./gradlew clean check jar`.
|
||||
|
||||
## 2026-09-04 — Automatic tool replacement completed
|
||||
|
||||
- Added next-tick replacement of broken main-hand tools with exact-material matches from player storage.
|
||||
- Main storage is searched before other hotbar slots, and the replacement item is moved intact into the captured selected slot without overwriting a newly occupied slot.
|
||||
- Off-hand breaks and unavailable or mismatched replacements leave inventory unchanged.
|
||||
- Added player feedback and verified matching, source priority, metadata-preserving movement, safety, and off-hand behavior with automated tests and `./gradlew clean check jar`.
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
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: in-progress
|
||||
status: done
|
||||
---
|
||||
|
||||
# US-003: Sort containers and player inventory
|
||||
@@ -11,18 +11,18 @@ As a **player holding a Sorting Stick**, I want to sort storage containers or my
|
||||
|
||||
## 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.
|
||||
- [x] Using the Sorting Stick on a chest sorts the complete chest inventory.
|
||||
- [x] Using it on either half of a double chest sorts the combined inventory.
|
||||
- [x] Using it on a barrel sorts that barrel.
|
||||
- [x] Using it without targeting a supported container sorts the player's main storage slots.
|
||||
- [x] Player hotbar, armor, and off-hand slots are not changed during player-inventory sorting.
|
||||
- [x] Compatible partial stacks are consolidated without exceeding item stack limits.
|
||||
- [x] Remaining stacks are placed in a deterministic, documented order.
|
||||
- [x] Sorting preserves item quantities and all item metadata exactly.
|
||||
- [x] Sorting respects cancelled interactions and normal container-access protections.
|
||||
- [x] Unsupported or inaccessible targets fail safely without changing an inventory.
|
||||
- [x] Sorting provides concise player feedback without opening the target container.
|
||||
- [x] Automated tests cover single chests, double chests, barrels, player inventory boundaries, stack consolidation, metadata preservation, and rejected interactions.
|
||||
|
||||
## Related
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
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
|
||||
status: done
|
||||
---
|
||||
|
||||
# US-004: Automatically replace broken tools
|
||||
@@ -11,15 +11,15 @@ As a **player**, I want an equivalent tool moved into my hand when my current to
|
||||
|
||||
## 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.
|
||||
- [x] When a player's main-hand tool breaks, the plugin searches the player's inventory for a replacement.
|
||||
- [x] A replacement must have the same tool type and material as the broken tool; for example, a diamond pickaxe replaces only a diamond pickaxe.
|
||||
- [x] Eligible replacements may come from the main storage inventory or another hotbar slot.
|
||||
- [x] The replacement is moved into the currently selected hotbar slot.
|
||||
- [x] Existing durability, enchantments, custom names, persistent data, and other metadata on the replacement are preserved.
|
||||
- [x] No unrelated item is moved when an equivalent replacement is unavailable.
|
||||
- [x] Off-hand tool breakage does not replace the main-hand item.
|
||||
- [x] Cancelled damage or break events do not trigger replacement.
|
||||
- [x] Automated tests cover matching, unavailable replacements, hotbar and main-storage candidates, metadata preservation, and off-hand behavior.
|
||||
|
||||
## Related
|
||||
|
||||
|
||||
@@ -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,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;
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,15 @@ public final class SpigotInventoryHelperPlugin extends JavaPlugin {
|
||||
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.");
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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,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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user