feat(sort): add craftable sorting stick
CI / build (push) Successful in 1m14s
Release / release (push) Successful in 2m11s

This commit is contained in:
dmg
2026-09-04 11:48:09 -04:00
parent ad29db0a79
commit 848f5c643f
9 changed files with 344 additions and 9 deletions
+6
View File
@@ -25,6 +25,12 @@ description: Chronological record of material decisions affecting the Spigot Inv
- Added Gitea CI, conventional-commit validation for pull requests, development artifacts, semantic releases, and release-asset upload.
- Verified `./gradlew clean check jar`, successful main and tag CI runs, and release `v1.0.0` with `spigot-inventory-helper-1.0.0.jar` attached.
## 2026-09-04 — Sorting Stick completed
- Added a gold-named, glinting Sorting Stick authenticated by persistent item metadata.
- 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 — 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-002: Craft a Sorting Stick"
description: Let players craft a recognizable utility item that invokes inventory sorting.
status: in-progress
status: done
---
# US-002: Craft a Sorting Stick
@@ -11,14 +11,14 @@ As a **player**, I want to craft a recognizable Sorting Stick so that I can invo
## 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.
- [x] The plugin registers a vertical shaped recipe with a Chest or Barrel above Redstone Dust and Redstone Dust above a Stick.
- [x] The three ingredients occupy the center column of the 3×3 crafting grid.
- [x] Either the Chest or Barrel recipe variant produces one Sorting Stick.
- [x] The Sorting Stick has a distinct player-facing name and appearance.
- [x] The item is identified using persistent item metadata rather than its display name alone.
- [x] Renamed ordinary sticks cannot trigger sorting.
- [x] Automated tests cover recipe registration, the exact shape, both storage-block variants, output quantity, and resulting item identity.
- [x] The crafting recipe and both accepted variants are documented for players.
## Related
@@ -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.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,29 @@
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.");
}
getLogger().info("Spigot Inventory Helper enabled.");
}
SortingStickItemFactory sortingSticks() {
if (sortingSticks == null) {
throw new IllegalStateException("Sorting Stick service is unavailable");
}
return sortingSticks;
}
}
@@ -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,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());
}
}