feat(sort): sort containers and player storage
CI / build (push) Failing after 5m25s
Release / release (push) Failing after 28m43s

This commit is contained in:
dmg
2026-09-04 11:56:19 -04:00
parent 5829d1d1dd
commit 1566437ba3
10 changed files with 504 additions and 13 deletions
@@ -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,11 @@ 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
);
getLogger().info("Spigot Inventory Helper enabled.");
}
@@ -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;
}
}