feat(sort): add craftable sorting stick
This commit is contained in:
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user