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.
|
- 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.
|
- 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
|
## 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.
|
- 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
|
type: User Story
|
||||||
title: "US-002: Craft a Sorting Stick"
|
title: "US-002: Craft a Sorting Stick"
|
||||||
description: Let players craft a recognizable utility item that invokes inventory sorting.
|
description: Let players craft a recognizable utility item that invokes inventory sorting.
|
||||||
status: in-progress
|
status: done
|
||||||
---
|
---
|
||||||
|
|
||||||
# US-002: Craft a Sorting Stick
|
# 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
|
## Acceptance criteria
|
||||||
|
|
||||||
- [ ] The plugin registers a vertical shaped recipe with a Chest or Barrel above Redstone Dust and Redstone Dust above a Stick.
|
- [x] 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.
|
- [x] The three ingredients occupy the center column of the 3×3 crafting grid.
|
||||||
- [ ] Either the Chest or Barrel recipe variant produces one Sorting Stick.
|
- [x] Either the Chest or Barrel recipe variant produces one Sorting Stick.
|
||||||
- [ ] The Sorting Stick has a distinct player-facing name and appearance.
|
- [x] 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.
|
- [x] The item is identified using persistent item metadata rather than its display name alone.
|
||||||
- [ ] Renamed ordinary sticks cannot trigger sorting.
|
- [x] Renamed ordinary sticks cannot trigger sorting.
|
||||||
- [ ] Automated tests cover recipe registration, the exact shape, both storage-block variants, output quantity, and resulting item identity.
|
- [x] 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 crafting recipe and both accepted variants are documented for players.
|
||||||
|
|
||||||
## Related
|
## 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;
|
package games.dmg.spigotinventoryhelper;
|
||||||
|
|
||||||
|
import org.bukkit.NamespacedKey;
|
||||||
import org.bukkit.plugin.java.JavaPlugin;
|
import org.bukkit.plugin.java.JavaPlugin;
|
||||||
|
|
||||||
public final class SpigotInventoryHelperPlugin extends JavaPlugin {
|
public final class SpigotInventoryHelperPlugin extends JavaPlugin {
|
||||||
|
private SortingStickItemFactory sortingSticks;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onEnable() {
|
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.");
|
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