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
+4
View File
@@ -39,6 +39,10 @@ Hold an authentic Sorting Stick and right-click:
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.
+7
View File
@@ -38,6 +38,13 @@ description: Chronological record of material decisions affecting the Spigot Inv
- 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-004: Automatically replace broken tools"
description: Move an equivalent inventory tool into the player's hand when their current main-hand tool breaks.
status: in-progress
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);
}
}
@@ -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;
}
}