feat(tools): replace broken main-hand tools
Release / release (push) Successful in 2m54s
CI / build (push) Failing after 18m56s

This commit is contained in:
dmg
2026-09-04 12:00:56 -04:00
parent d36ba9da19
commit 3960672104
8 changed files with 294 additions and 10 deletions
@@ -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);
}
}
@@ -22,6 +22,10 @@ public final class SpigotInventoryHelperPlugin extends JavaPlugin {
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,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;
}
}