diff --git a/design/log.md b/design/log.md index df7770d..dc3507e 100644 --- a/design/log.md +++ b/design/log.md @@ -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. diff --git a/design/user-stories/us-002-craft-sorting-stick.md b/design/user-stories/us-002-craft-sorting-stick.md index b3a8264..04218e1 100644 --- a/design/user-stories/us-002-craft-sorting-stick.md +++ b/design/user-stories/us-002-craft-sorting-stick.md @@ -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 diff --git a/src/main/java/games/dmg/spigotinventoryhelper/SortingStickItemFactory.java b/src/main/java/games/dmg/spigotinventoryhelper/SortingStickItemFactory.java new file mode 100644 index 0000000..41186e6 --- /dev/null +++ b/src/main/java/games/dmg/spigotinventoryhelper/SortingStickItemFactory.java @@ -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 itemSupplier; + + public SortingStickItemFactory(NamespacedKey identityKey) { + this(identityKey, () -> new ItemStack(Material.STICK)); + } + + SortingStickItemFactory(NamespacedKey identityKey, Supplier 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; + } +} diff --git a/src/main/java/games/dmg/spigotinventoryhelper/SortingStickRecipeRegistrar.java b/src/main/java/games/dmg/spigotinventoryhelper/SortingStickRecipeRegistrar.java new file mode 100644 index 0000000..d04497e --- /dev/null +++ b/src/main/java/games/dmg/spigotinventoryhelper/SortingStickRecipeRegistrar.java @@ -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); + } +} diff --git a/src/main/java/games/dmg/spigotinventoryhelper/SortingStickRecipeSpec.java b/src/main/java/games/dmg/spigotinventoryhelper/SortingStickRecipeSpec.java new file mode 100644 index 0000000..0b0bf15 --- /dev/null +++ b/src/main/java/games/dmg/spigotinventoryhelper/SortingStickRecipeSpec.java @@ -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 shape, + Map> 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 + ); + } +} diff --git a/src/main/java/games/dmg/spigotinventoryhelper/SpigotInventoryHelperPlugin.java b/src/main/java/games/dmg/spigotinventoryhelper/SpigotInventoryHelperPlugin.java index 5971f80..32ae873 100644 --- a/src/main/java/games/dmg/spigotinventoryhelper/SpigotInventoryHelperPlugin.java +++ b/src/main/java/games/dmg/spigotinventoryhelper/SpigotInventoryHelperPlugin.java @@ -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; + } } diff --git a/src/test/java/games/dmg/spigotinventoryhelper/SortingStickItemFactoryTest.java b/src/test/java/games/dmg/spigotinventoryhelper/SortingStickItemFactoryTest.java new file mode 100644 index 0000000..0a69964 --- /dev/null +++ b/src/test/java/games/dmg/spigotinventoryhelper/SortingStickItemFactoryTest.java @@ -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)); + } +} diff --git a/src/test/java/games/dmg/spigotinventoryhelper/SortingStickRecipeRegistrarTest.java b/src/test/java/games/dmg/spigotinventoryhelper/SortingStickRecipeRegistrarTest.java new file mode 100644 index 0000000..95f0f77 --- /dev/null +++ b/src/test/java/games/dmg/spigotinventoryhelper/SortingStickRecipeRegistrarTest.java @@ -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 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() + ); + } +} diff --git a/src/test/java/games/dmg/spigotinventoryhelper/SortingStickRecipeSpecTest.java b/src/test/java/games/dmg/spigotinventoryhelper/SortingStickRecipeSpecTest.java new file mode 100644 index 0000000..d5d0b13 --- /dev/null +++ b/src/test/java/games/dmg/spigotinventoryhelper/SortingStickRecipeSpecTest.java @@ -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()); + } +}