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
@@ -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());
}
}