feat(crafting): add cobweb recipe from string
US-007: 69 tests pass; both exact-head CI checks passed.
This commit was merged in pull request #1.
This commit is contained in:
commit
24a220ae4a
4 files changed
+804
-1
No files matched your search
@@ -0,0 +1,70 @@
|
||||
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;
|
||||
import org.bukkit.inventory.recipe.CraftingBookCategory;
|
||||
|
||||
/** Independently owns the fixed, native String-to-Cobweb crafting recipe. */
|
||||
public final class CobwebRecipeRegistrar implements RecipeRegistrar {
|
||||
private final NamespacedKey recipeKey;
|
||||
private Server registeredServer;
|
||||
private boolean owned;
|
||||
|
||||
public CobwebRecipeRegistrar(NamespacedKey recipeKey) {
|
||||
this.recipeKey = Objects.requireNonNull(recipeKey, "recipeKey");
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean register(Server server) {
|
||||
Objects.requireNonNull(server, "server");
|
||||
if (owned) {
|
||||
return true;
|
||||
}
|
||||
registeredServer = server;
|
||||
if (server.getRecipe(recipeKey) != null) {
|
||||
return false;
|
||||
}
|
||||
ShapedRecipe recipe = new ShapedRecipe(recipeKey, new ItemStack(Material.COBWEB));
|
||||
recipe.shape("S S", " S ", "S S");
|
||||
recipe.setIngredient('S', Material.STRING);
|
||||
recipe.setCategory(CraftingBookCategory.BUILDING);
|
||||
try {
|
||||
owned = server.addRecipe(recipe);
|
||||
return owned;
|
||||
} catch (RuntimeException exception) {
|
||||
try {
|
||||
owned = server.getRecipe(recipeKey) != null;
|
||||
unregister();
|
||||
} catch (RuntimeException cleanupFailure) {
|
||||
exception.addSuppressed(cleanupFailure);
|
||||
}
|
||||
throw exception;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<NamespacedKey> recipeKeys() {
|
||||
return List.of(recipeKey);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void unregister() {
|
||||
if (!owned || registeredServer == null) {
|
||||
return;
|
||||
}
|
||||
if (!registeredServer.removeRecipe(recipeKey)) {
|
||||
throw new IllegalStateException("Could not remove owned recipe " + recipeKey);
|
||||
}
|
||||
owned = false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String description() {
|
||||
return "Cobweb crafting recipe";
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,25 @@
|
||||
package games.dmg.spigotinventoryhelper;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.List;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.plugin.PluginDescriptionFile;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
import org.bukkit.plugin.java.JavaPluginLoader;
|
||||
|
||||
public final class SpigotInventoryHelperPlugin extends JavaPlugin {
|
||||
private SortingStickItemFactory sortingSticks;
|
||||
private RecipeLifecycleGroup recipeLifecycles;
|
||||
|
||||
public SpigotInventoryHelperPlugin() {}
|
||||
|
||||
// Bukkit's explicit initialization path keeps native tests on the real enable callback.
|
||||
SpigotInventoryHelperPlugin(
|
||||
JavaPluginLoader loader, PluginDescriptionFile description, File dataFolder, File file
|
||||
) {
|
||||
super(loader, description, dataFolder, file);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onEnable() {
|
||||
sortingSticks = new SortingStickItemFactory(new NamespacedKey(this, "sorting_stick"));
|
||||
@@ -24,7 +36,14 @@ public final class SpigotInventoryHelperPlugin extends JavaPlugin {
|
||||
new DispenserRecipeRegistrar(new NamespacedKey(this, "dropper_dispenser")),
|
||||
getLogger()
|
||||
);
|
||||
recipeLifecycles = new RecipeLifecycleGroup(List.of(sortingRecipes, dispenserRecipe));
|
||||
RecipeLifecycle cobwebRecipe = new RecipeLifecycle(
|
||||
getServer(),
|
||||
this,
|
||||
new CobwebRecipeRegistrar(new NamespacedKey(this, "string_cobweb")),
|
||||
getLogger()
|
||||
);
|
||||
recipeLifecycles = new RecipeLifecycleGroup(
|
||||
List.of(sortingRecipes, dispenserRecipe, cobwebRecipe));
|
||||
recipeLifecycles.enableAll();
|
||||
InventorySortingService sorting = new InventorySortingService(new InventorySorter());
|
||||
getServer().getPluginManager().registerEvents(
|
||||
|
||||
+309
@@ -0,0 +1,309 @@
|
||||
package games.dmg.spigotinventoryhelper;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
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.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.doReturn;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.reset;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import io.papermc.paper.plugin.manager.PaperPluginManagerImpl;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.logging.Handler;
|
||||
import java.util.logging.Level;
|
||||
import java.util.logging.LogRecord;
|
||||
import java.util.logging.Logger;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.Keyed;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.command.SimpleCommandMap;
|
||||
import org.bukkit.craftbukkit.inventory.CraftItemFactory;
|
||||
import org.bukkit.craftbukkit.util.CraftMagicNumbers;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.HandlerList;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.player.PlayerJoinEvent;
|
||||
import org.bukkit.inventory.Recipe;
|
||||
import org.bukkit.plugin.InvalidDescriptionException;
|
||||
import org.bukkit.plugin.PluginDescriptionFile;
|
||||
import org.bukkit.plugin.PluginManager;
|
||||
import org.bukkit.plugin.java.JavaPluginLoader;
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
/** Real enable/disable callbacks using Bukkit's explicit initialization and existing native fixtures. */
|
||||
class NativeCobwebPluginWiringTest {
|
||||
private static final NamespacedKey COBWEB = key("string_cobweb");
|
||||
private static final Set<NamespacedKey> EXISTING = Set.of(key("dropper_dispenser"),
|
||||
key("sorting_stick_recipe_chest"), key("sorting_stick_recipe_barrel"));
|
||||
private static final NamespacedKey VANILLA = new NamespacedKey("minecraft", "stick");
|
||||
private static Server previousServer;
|
||||
private static Server serverUnderTest;
|
||||
@TempDir Path directory;
|
||||
private Map<NamespacedKey, Recipe> registry;
|
||||
private PaperPluginManagerImpl events;
|
||||
private Logger logger;
|
||||
private List<LogRecord> logs;
|
||||
private Handler logHandler;
|
||||
private SpigotInventoryHelperPlugin createdPlugin;
|
||||
|
||||
@BeforeAll
|
||||
static void bootstrap() throws Exception {
|
||||
NativeRuntime.bootstrap();
|
||||
previousServer = Bukkit.getServer();
|
||||
serverUnderTest = mock(Server.class);
|
||||
setServer(serverUnderTest);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void configureServer() {
|
||||
reset(serverUnderTest);
|
||||
when(serverUnderTest.getItemFactory()).thenAnswer(invocation -> CraftItemFactory.instance());
|
||||
when(serverUnderTest.getUnsafe()).thenReturn(CraftMagicNumbers.INSTANCE);
|
||||
when(serverUnderTest.isPrimaryThread()).thenReturn(true);
|
||||
logger = Logger.getAnonymousLogger();
|
||||
logs = new ArrayList<>();
|
||||
logHandler = new Handler() {
|
||||
@Override public void publish(LogRecord record) { logs.add(record); }
|
||||
@Override public void flush() {}
|
||||
@Override public void close() {}
|
||||
};
|
||||
logger.addHandler(logHandler);
|
||||
when(serverUnderTest.getLogger()).thenReturn(logger);
|
||||
registry = new HashMap<>();
|
||||
registry.put(VANILLA, mock(Recipe.class));
|
||||
when(serverUnderTest.getRecipe(any(NamespacedKey.class))).thenAnswer(
|
||||
invocation -> registry.get(invocation.getArgument(0)));
|
||||
when(serverUnderTest.addRecipe(any(Recipe.class))).thenAnswer(invocation -> {
|
||||
Recipe recipe = invocation.getArgument(0);
|
||||
return registry.putIfAbsent(((Keyed) recipe).getKey(), recipe) == null;
|
||||
});
|
||||
when(serverUnderTest.removeRecipe(any(NamespacedKey.class))).thenAnswer(
|
||||
invocation -> registry.remove(invocation.getArgument(0)) != null);
|
||||
events = new PaperPluginManagerImpl(serverUnderTest,
|
||||
new SimpleCommandMap(serverUnderTest, new HashMap<>()), null);
|
||||
when(serverUnderTest.getPluginManager()).thenReturn(events);
|
||||
doReturn(List.of()).when(serverUnderTest).getOnlinePlayers();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void removeLogHandler() {
|
||||
logger.removeHandler(logHandler);
|
||||
if (createdPlugin != null) {
|
||||
createdPlugin.getLogger().removeHandler(logHandler);
|
||||
}
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
static void restoreServer() throws Exception {
|
||||
setServer(previousServer);
|
||||
}
|
||||
|
||||
@Test
|
||||
void actualEnableDiscoversOnlineAndJoinersIdempotentlyAndReenablesWithoutLosingKnowledge() {
|
||||
Set<NamespacedKey> onlineKnown = new LinkedHashSet<>(Set.of(VANILLA));
|
||||
Set<NamespacedKey> joiningKnown = new LinkedHashSet<>(Set.of(VANILLA));
|
||||
AtomicInteger onlineGrants = new AtomicInteger();
|
||||
AtomicInteger joiningGrants = new AtomicInteger();
|
||||
Player online = player("Online", onlineKnown, onlineGrants, new AtomicBoolean());
|
||||
Player joining = player("Joining", joiningKnown, joiningGrants, new AtomicBoolean());
|
||||
doReturn(List.of(online)).when(serverUnderTest).getOnlinePlayers();
|
||||
Recipe vanilla = registry.get(VANILLA);
|
||||
var plugin = plugin();
|
||||
try {
|
||||
plugin.setEnabled(true);
|
||||
assertTrue(registry.keySet().containsAll(EXISTING));
|
||||
assertTrue(registry.containsKey(COBWEB));
|
||||
assertEquals(expectedKnown(), onlineKnown);
|
||||
plugin.setEnabled(true);
|
||||
events.callEvent(new PlayerJoinEvent(joining, "joined"));
|
||||
events.callEvent(new PlayerJoinEvent(joining, "joined again"));
|
||||
assertEquals(expectedKnown(), joiningKnown);
|
||||
assertEquals(1, onlineGrants.get());
|
||||
assertEquals(1, joiningGrants.get());
|
||||
|
||||
plugin.setEnabled(false);
|
||||
assertEquals(Set.of(VANILLA), registry.keySet());
|
||||
assertSame(vanilla, registry.get(VANILLA));
|
||||
assertEquals(expectedKnown(), onlineKnown);
|
||||
assertEquals(expectedKnown(), joiningKnown);
|
||||
Set<NamespacedKey> afterDisable = new LinkedHashSet<>();
|
||||
events.callEvent(new PlayerJoinEvent(player("AfterDisable", afterDisable,
|
||||
new AtomicInteger(), new AtomicBoolean()), "joined"));
|
||||
assertTrue(afterDisable.isEmpty(), "Disabled listeners must not award recipes");
|
||||
|
||||
plugin.setEnabled(true);
|
||||
assertTrue(registry.keySet().containsAll(EXISTING));
|
||||
assertTrue(registry.containsKey(COBWEB));
|
||||
assertEquals(1, onlineGrants.get(), "Re-enable must not rediscover a known recipe");
|
||||
assertNoSevereLogs();
|
||||
} finally {
|
||||
plugin.setEnabled(false);
|
||||
HandlerList.unregisterAll(plugin);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void cobwebKeyConflictPreservesForeignRecipeAndExistingFeaturesWithoutDiscovery() {
|
||||
Recipe conflict = mock(Recipe.class);
|
||||
registry.put(COBWEB, conflict);
|
||||
Set<NamespacedKey> known = new LinkedHashSet<>(Set.of(VANILLA));
|
||||
AtomicInteger grants = new AtomicInteger();
|
||||
doReturn(List.of(player("Online", known, grants, new AtomicBoolean())))
|
||||
.when(serverUnderTest).getOnlinePlayers();
|
||||
var plugin = plugin();
|
||||
try {
|
||||
plugin.setEnabled(true);
|
||||
assertSame(conflict, registry.get(COBWEB));
|
||||
assertTrue(registry.keySet().containsAll(EXISTING));
|
||||
assertTrue(known.containsAll(EXISTING));
|
||||
assertFalse(known.contains(COBWEB));
|
||||
assertEquals(0, grants.get());
|
||||
plugin.setEnabled(false);
|
||||
assertEquals(Set.of(VANILLA, COBWEB), registry.keySet());
|
||||
assertSame(conflict, registry.get(COBWEB));
|
||||
|
||||
registry.remove(COBWEB);
|
||||
plugin.setEnabled(true);
|
||||
assertTrue(registry.containsKey(COBWEB));
|
||||
assertTrue(known.contains(COBWEB));
|
||||
assertEquals(1, grants.get());
|
||||
assertEquals(1, logs.stream().filter(r -> r.getLevel() == Level.SEVERE).count(),
|
||||
"Only the deliberately injected key conflict is severe");
|
||||
} finally {
|
||||
plugin.setEnabled(false);
|
||||
HandlerList.unregisterAll(plugin);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void onePlayersDiscoveryFailureDoesNotBlockOthersAndCanRetryOnJoin() {
|
||||
Set<NamespacedKey> failedKnown = new LinkedHashSet<>(Set.of(VANILLA));
|
||||
Set<NamespacedKey> healthyKnown = new LinkedHashSet<>(Set.of(VANILLA));
|
||||
AtomicBoolean fail = new AtomicBoolean(true);
|
||||
AtomicInteger failedGrants = new AtomicInteger();
|
||||
Player failed = player("Failed", failedKnown, failedGrants, fail);
|
||||
Player healthy = player("Healthy", healthyKnown, new AtomicInteger(), new AtomicBoolean());
|
||||
doReturn(List.of(failed, healthy)).when(serverUnderTest).getOnlinePlayers();
|
||||
var plugin = plugin();
|
||||
try {
|
||||
plugin.setEnabled(true);
|
||||
assertFalse(failedKnown.contains(COBWEB));
|
||||
assertEquals(expectedKnown(), healthyKnown);
|
||||
assertTrue(failedKnown.containsAll(EXISTING));
|
||||
fail.set(false);
|
||||
events.callEvent(new PlayerJoinEvent(failed, "retry"));
|
||||
assertEquals(expectedKnown(), failedKnown);
|
||||
assertEquals(1, failedGrants.get());
|
||||
assertNoSevereLogs();
|
||||
assertEquals(1, logs.stream().filter(r -> r.getLevel() == Level.WARNING).count());
|
||||
} finally {
|
||||
plugin.setEnabled(false);
|
||||
HandlerList.unregisterAll(plugin);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void partialDiscoveryListenerFailureRollsBackOnlyCobwebAndWithholdsJoinDiscovery() {
|
||||
PluginManager throwing = mock(PluginManager.class);
|
||||
AtomicInteger discoveries = new AtomicInteger();
|
||||
doAnswer(invocation -> {
|
||||
Listener listener = invocation.getArgument(0);
|
||||
org.bukkit.plugin.Plugin owner = invocation.getArgument(1);
|
||||
events.registerEvents(listener, owner);
|
||||
if (listener instanceof RecipeDiscovery && discoveries.incrementAndGet() == 3) {
|
||||
throw new IllegalStateException("simulated post-listener-registration failure");
|
||||
}
|
||||
return null;
|
||||
}).when(throwing).registerEvents(any(), any());
|
||||
when(serverUnderTest.getPluginManager()).thenReturn(throwing);
|
||||
var plugin = plugin();
|
||||
try {
|
||||
plugin.setEnabled(true);
|
||||
assertFalse(registry.containsKey(COBWEB));
|
||||
assertTrue(registry.keySet().containsAll(EXISTING));
|
||||
Set<NamespacedKey> known = new LinkedHashSet<>(Set.of(VANILLA));
|
||||
events.callEvent(new PlayerJoinEvent(player("Joining", known, new AtomicInteger(),
|
||||
new AtomicBoolean()), "joined"));
|
||||
assertTrue(known.containsAll(EXISTING));
|
||||
assertFalse(known.contains(COBWEB));
|
||||
assertEquals(1, logs.stream().filter(r -> r.getLevel() == Level.SEVERE).count(),
|
||||
"Only the deliberately injected listener failure is severe");
|
||||
} finally {
|
||||
plugin.setEnabled(false);
|
||||
HandlerList.unregisterAll(plugin);
|
||||
}
|
||||
}
|
||||
|
||||
private SpigotInventoryHelperPlugin plugin() {
|
||||
try (var metadata = SpigotInventoryHelperPlugin.class.getResourceAsStream("/plugin.yml")) {
|
||||
createdPlugin = new SpigotInventoryHelperPlugin(new JavaPluginLoader(serverUnderTest),
|
||||
new PluginDescriptionFile(metadata), directory.resolve("data").toFile(),
|
||||
directory.resolve("plugin.jar").toFile());
|
||||
createdPlugin.getLogger().addHandler(logHandler);
|
||||
return createdPlugin;
|
||||
} catch (java.io.IOException | InvalidDescriptionException exception) {
|
||||
throw new AssertionError("Could not read generated plugin metadata", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static Player player(String name, Set<NamespacedKey> known,
|
||||
AtomicInteger cobwebGrants, AtomicBoolean failCobweb) {
|
||||
Player player = mock(Player.class);
|
||||
when(player.getName()).thenReturn(name);
|
||||
when(player.hasDiscoveredRecipe(any(NamespacedKey.class))).thenAnswer(
|
||||
invocation -> known.contains(invocation.getArgument(0)));
|
||||
when(player.discoverRecipes(any())).thenAnswer(invocation -> {
|
||||
List<NamespacedKey> requested = ((Collection<?>) invocation.getArgument(0)).stream()
|
||||
.map(NamespacedKey.class::cast).toList();
|
||||
if (requested.contains(COBWEB)) {
|
||||
if (failCobweb.get()) {
|
||||
throw new IllegalStateException("simulated player discovery failure");
|
||||
}
|
||||
cobwebGrants.incrementAndGet();
|
||||
}
|
||||
known.addAll(requested);
|
||||
return requested.size();
|
||||
});
|
||||
return player;
|
||||
}
|
||||
|
||||
private void assertNoSevereLogs() {
|
||||
assertEquals(0, logs.stream().filter(r -> r.getLevel() == Level.SEVERE).count());
|
||||
}
|
||||
|
||||
private static Set<NamespacedKey> expectedKnown() {
|
||||
Set<NamespacedKey> expected = new LinkedHashSet<>(EXISTING);
|
||||
expected.add(COBWEB);
|
||||
expected.add(VANILLA);
|
||||
return expected;
|
||||
}
|
||||
|
||||
private static NamespacedKey key(String name) {
|
||||
return new NamespacedKey("spigotinventoryhelper", name);
|
||||
}
|
||||
|
||||
private static void setServer(Server server) throws Exception {
|
||||
var field = Bukkit.class.getDeclaredField("server");
|
||||
field.setAccessible(true);
|
||||
field.set(null, server);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
package games.dmg.spigotinventoryhelper;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.reset;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.OptionalInt;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.craftbukkit.inventory.CraftItemFactory;
|
||||
import org.bukkit.craftbukkit.inventory.CraftItemStack;
|
||||
import org.bukkit.craftbukkit.inventory.CraftRecipe;
|
||||
import org.bukkit.craftbukkit.util.CraftMagicNumbers;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.Recipe;
|
||||
import org.bukkit.inventory.RecipeChoice;
|
||||
import org.bukkit.inventory.ShapedRecipe;
|
||||
import org.bukkit.inventory.recipe.CraftingBookCategory;
|
||||
import org.bukkit.persistence.PersistentDataType;
|
||||
import org.bukkit.plugin.PluginManager;
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
|
||||
/** Registered recipe -> native display/autofill -> ordinary or quick-move result completion. */
|
||||
class NativeCobwebRecipeTest {
|
||||
private static final NamespacedKey KEY = new NamespacedKey("spigotinventoryhelper", "string_cobweb");
|
||||
private static final List<Integer> STRING_SLOTS = List.of(0, 2, 4, 6, 8);
|
||||
private static Server previousServer;
|
||||
private static Server serverUnderTest;
|
||||
|
||||
@BeforeAll
|
||||
static void bootstrap() throws Exception {
|
||||
NativeRuntime.bootstrap();
|
||||
previousServer = Bukkit.getServer();
|
||||
serverUnderTest = mock(Server.class);
|
||||
setServer(serverUnderTest);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void configureServer() {
|
||||
reset(serverUnderTest);
|
||||
when(serverUnderTest.getItemFactory()).thenAnswer(invocation -> CraftItemFactory.instance());
|
||||
when(serverUnderTest.getUnsafe()).thenReturn(CraftMagicNumbers.INSTANCE);
|
||||
when(serverUnderTest.isPrimaryThread()).thenReturn(true);
|
||||
when(serverUnderTest.getPluginManager()).thenReturn(mock(PluginManager.class));
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
static void restoreServer() throws Exception {
|
||||
setServer(previousServer);
|
||||
}
|
||||
|
||||
@Test
|
||||
void registersFiveStringXForOneOrdinaryCobwebInBuildingCategory() {
|
||||
ShapedRecipe recipe = registeredRecipe();
|
||||
assertEquals(KEY, recipe.getKey());
|
||||
assertEquals(List.of("S S", " S ", "S S"), List.of(recipe.getShape()));
|
||||
assertEquals(List.of(Material.STRING),
|
||||
((RecipeChoice.MaterialChoice) recipe.getChoiceMap().get('S')).getChoices());
|
||||
assertEquals(CraftingBookCategory.BUILDING, recipe.getCategory());
|
||||
assertOrdinaryCobweb(recipe.getResult(), 1);
|
||||
var nativeRecipe = nativeRecipe(recipe);
|
||||
assertEquals(net.minecraft.world.item.crafting.RecipeBookCategories.CRAFTING_BUILDING_BLOCKS,
|
||||
nativeRecipe.recipeBookCategory());
|
||||
var display = (net.minecraft.world.item.crafting.display.ShapedCraftingRecipeDisplay)
|
||||
nativeRecipe.display().getFirst();
|
||||
assertEquals(3, display.width());
|
||||
assertEquals(3, display.height());
|
||||
for (int slot = 0; slot < 9; slot++) {
|
||||
if (STRING_SLOTS.contains(slot)) {
|
||||
var expected = CraftRecipe.toIngredient(
|
||||
new RecipeChoice.MaterialChoice(Material.STRING), false).display();
|
||||
assertEquals(expected, display.ingredients().get(slot));
|
||||
} else {
|
||||
assertEquals(net.minecraft.world.item.crafting.display.SlotDisplay.Empty.INSTANCE,
|
||||
display.ingredients().get(slot));
|
||||
}
|
||||
}
|
||||
var result = (net.minecraft.world.item.crafting.display.SlotDisplay.ItemStackSlotDisplay)
|
||||
display.result();
|
||||
assertOrdinaryCobweb(CraftItemStack.asBukkitCopy(result.stack().create()), 1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void incorrectPatternsMissingIngredientsAndTwoByTwoCraftingNeverMatch() {
|
||||
var recipe = nativeRecipe(registeredRecipe());
|
||||
var level = mock(net.minecraft.world.level.Level.class);
|
||||
List<net.minecraft.world.item.ItemStack> valid = pattern();
|
||||
assertTrue(recipe.matches(input(valid), level));
|
||||
for (int slot = 0; slot < 9; slot++) {
|
||||
List<net.minecraft.world.item.ItemStack> wrong = new ArrayList<>(valid);
|
||||
wrong.set(slot, STRING_SLOTS.contains(slot) ? net.minecraft.world.item.ItemStack.EMPTY
|
||||
: CraftItemStack.asNMSCopy(new ItemStack(Material.STRING)));
|
||||
assertFalse(recipe.matches(input(wrong), level), "Wrong occupancy at slot " + slot);
|
||||
}
|
||||
List<net.minecraft.world.item.ItemStack> wrongMaterial = new ArrayList<>(valid);
|
||||
wrongMaterial.set(4, CraftItemStack.asNMSCopy(new ItemStack(Material.DIRT)));
|
||||
assertFalse(recipe.matches(input(wrongMaterial), level));
|
||||
assertFalse(recipe.matches(net.minecraft.world.item.crafting.CraftingInput.of(2, 2,
|
||||
java.util.stream.IntStream.range(0, 4).mapToObj(i ->
|
||||
CraftItemStack.asNMSCopy(new ItemStack(Material.STRING))).toList()), level));
|
||||
}
|
||||
|
||||
@Test
|
||||
void ordinaryNativeResultSlotConsumesFiveStringAndPreservesRemaindersAndMetadata() {
|
||||
var recipe = nativeRecipe(registeredRecipe());
|
||||
var fixture = crafting();
|
||||
ItemStack unrelated = marked(Material.DIAMOND, 7);
|
||||
fixture.inventory().setItem(20, CraftItemStack.asNMSCopy(unrelated));
|
||||
for (int slot : STRING_SLOTS) {
|
||||
fixture.menu().craftSlots.setItem(slot,
|
||||
CraftItemStack.asNMSCopy(marked(Material.STRING, 2)));
|
||||
}
|
||||
var grid = fixture.menu().craftSlots;
|
||||
assertTrue(recipe.matches(nativeInput(grid), fixture.player().level()));
|
||||
List<ItemStack> before = grid.getItems().stream().map(CraftItemStack::asBukkitCopy).toList();
|
||||
var assembled = recipe.assemble(nativeInput(grid));
|
||||
assertOrdinaryCobweb(CraftItemStack.asBukkitCopy(assembled), 1);
|
||||
assertEquals(before, grid.getItems().stream().map(CraftItemStack::asBukkitCopy).toList(),
|
||||
"Preview must neither consume ingredients nor mutate metadata");
|
||||
setResult(fixture.menu(), recipe, assembled);
|
||||
var slot = fixture.menu().getResultSlot();
|
||||
var taken = slot.remove(1);
|
||||
slot.onTake(fixture.player(), taken);
|
||||
assertOrdinaryCobweb(CraftItemStack.asBukkitCopy(taken), 1);
|
||||
assertEquals(5, count(grid, Material.STRING));
|
||||
for (int index : STRING_SLOTS) {
|
||||
assertEquals(marked(Material.STRING, 1), CraftItemStack.asBukkitCopy(grid.getItem(index)));
|
||||
}
|
||||
assertEquals(unrelated, CraftItemStack.asBukkitCopy(fixture.inventory().getItem(20)));
|
||||
assertTrue(fixture.menu().resultSlots.isEmpty());
|
||||
assertEquals(0, count(fixture.inventory(), Material.COBWEB));
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(ints = {1, 3, 64})
|
||||
void nativeAutofillAndQuickMoveBatchConsumeFiveStringPerCobweb(int crafts) {
|
||||
var recipe = nativeRecipe(registeredRecipe());
|
||||
var fixture = crafting();
|
||||
for (int i = 0; i < 5; i++) {
|
||||
fixture.inventory().setItem(i, CraftItemStack.asNMSCopy(new ItemStack(Material.STRING, crafts)));
|
||||
}
|
||||
ItemStack unrelated = marked(Material.DIAMOND, 7);
|
||||
fixture.inventory().setItem(20, CraftItemStack.asNMSCopy(unrelated));
|
||||
fixture.menu().craftSlots.setItem(0, CraftItemStack.asNMSCopy(new ItemStack(Material.DIRT, 2)));
|
||||
var action = autofill(fixture, recipe);
|
||||
assertEquals(net.minecraft.world.inventory.RecipeBookMenu.PostPlaceAction.NOTHING, action);
|
||||
assertEquals(5L * crafts, count(fixture.menu().craftSlots, Material.STRING));
|
||||
assertEquals(0, count(fixture.inventory(), Material.COBWEB), "Autofill creates no output");
|
||||
assertEquals(2, count(fixture.inventory(), Material.DIRT), "Relocated input must be preserved");
|
||||
for (int attempt = 0; attempt < crafts; attempt++) {
|
||||
assertTrue(recipe.matches(nativeInput(fixture.menu().craftSlots), fixture.player().level()));
|
||||
setResult(fixture.menu(), recipe, recipe.assemble(nativeInput(fixture.menu().craftSlots)));
|
||||
var moved = fixture.menu().quickMoveStack(fixture.player(), 0);
|
||||
assertOrdinaryCobweb(CraftItemStack.asBukkitCopy(moved), 1);
|
||||
assertEquals(5L * (crafts - attempt - 1), count(fixture.menu().craftSlots, Material.STRING));
|
||||
}
|
||||
assertTrue(fixture.menu().craftSlots.isEmpty());
|
||||
assertEquals(crafts, count(fixture.inventory(), Material.COBWEB));
|
||||
assertEquals(unrelated, CraftItemStack.asBukkitCopy(fixture.inventory().getItem(20)));
|
||||
assertEquals(2, count(fixture.inventory(), Material.DIRT));
|
||||
}
|
||||
|
||||
@Test
|
||||
void fullInventoryWithholdsQuickMoveOutputWithoutSpendingString() {
|
||||
var recipe = nativeRecipe(registeredRecipe());
|
||||
var fixture = crafting();
|
||||
for (int slot = 0; slot < 36; slot++) {
|
||||
fixture.inventory().setItem(slot, CraftItemStack.asNMSCopy(new ItemStack(Material.DIRT, 64)));
|
||||
}
|
||||
for (int slot : STRING_SLOTS) {
|
||||
fixture.menu().craftSlots.setItem(slot, CraftItemStack.asNMSCopy(new ItemStack(Material.STRING)));
|
||||
}
|
||||
setResult(fixture.menu(), recipe, recipe.assemble(nativeInput(fixture.menu().craftSlots)));
|
||||
assertTrue(fixture.menu().quickMoveStack(fixture.player(), 0).isEmpty());
|
||||
assertEquals(5, count(fixture.menu().craftSlots, Material.STRING));
|
||||
assertEquals(36L * 64, count(fixture.inventory(), Material.DIRT));
|
||||
assertEquals(0, count(fixture.inventory(), Material.COBWEB));
|
||||
assertOrdinaryCobweb(CraftItemStack.asBukkitCopy(fixture.menu().resultSlots.getItem(0)), 1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void insufficientStringShowsGhostRecipeWithoutCreatingOutputOrSpendingItems() {
|
||||
var fixture = crafting();
|
||||
fixture.inventory().setItem(0, CraftItemStack.asNMSCopy(new ItemStack(Material.STRING, 4)));
|
||||
var action = autofill(fixture, nativeRecipe(registeredRecipe()));
|
||||
assertEquals(net.minecraft.world.inventory.RecipeBookMenu.PostPlaceAction.PLACE_GHOST_RECIPE, action);
|
||||
assertEquals(4, count(fixture.inventory(), Material.STRING));
|
||||
assertEquals(0, count(fixture.inventory(), Material.COBWEB));
|
||||
assertTrue(fixture.menu().craftSlots.isEmpty());
|
||||
assertTrue(fixture.menu().resultSlots.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void nativeDiscoveryCancellationWithholdsKnowledgeAndLaterRetryWorks() {
|
||||
var recipe = nativeRecipe(registeredRecipe());
|
||||
var holder = new net.minecraft.world.item.crafting.RecipeHolder<>(nativeKey(), recipe);
|
||||
var book = new TestRecipeBook((key, consumer) -> consumer.accept(
|
||||
new net.minecraft.world.item.crafting.display.RecipeDisplayEntry(
|
||||
new net.minecraft.world.item.crafting.display.RecipeDisplayId(0),
|
||||
recipe.display().getFirst(), OptionalInt.empty(), recipe.recipeBookCategory(),
|
||||
Optional.of(recipe.placementInfo().ingredients()))));
|
||||
var player = mock(net.minecraft.server.level.ServerPlayer.class);
|
||||
when(player.getBukkitEntity()).thenReturn(mock(org.bukkit.craftbukkit.entity.CraftPlayer.class));
|
||||
when(player.getAdvancements()).thenReturn(mock(net.minecraft.server.PlayerAdvancements.class));
|
||||
PluginManager events = mock(PluginManager.class);
|
||||
when(serverUnderTest.getPluginManager()).thenReturn(events);
|
||||
AtomicBoolean cancel = new AtomicBoolean(true);
|
||||
doAnswer(invocation -> {
|
||||
var event = (org.bukkit.event.player.PlayerRecipeDiscoverEvent) invocation.getArgument(0);
|
||||
event.setCancelled(cancel.get());
|
||||
return null;
|
||||
}).when(events).callEvent(any(org.bukkit.event.player.PlayerRecipeDiscoverEvent.class));
|
||||
assertEquals(0, book.addRecipes(List.of(holder), player));
|
||||
assertFalse(book.contains(nativeKey()));
|
||||
cancel.set(false);
|
||||
assertEquals(1, book.addRecipes(List.of(holder), player));
|
||||
assertEquals(0, book.addRecipes(List.of(holder), player));
|
||||
assertTrue(book.contains(nativeKey()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void registrationExceptionAfterInsertionRemovesOnlyTheOwnedCobwebRecipe() {
|
||||
Map<NamespacedKey, Recipe> registry = new HashMap<>();
|
||||
NamespacedKey foreign = new NamespacedKey("minecraft", "stick");
|
||||
Recipe unrelated = mock(Recipe.class);
|
||||
registry.put(foreign, unrelated);
|
||||
when(serverUnderTest.getRecipe(any(NamespacedKey.class))).thenAnswer(
|
||||
invocation -> registry.get(invocation.getArgument(0)));
|
||||
when(serverUnderTest.addRecipe(any(Recipe.class))).thenAnswer(invocation -> {
|
||||
registry.put(KEY, invocation.getArgument(0));
|
||||
throw new IllegalStateException("simulated post-insertion failure");
|
||||
});
|
||||
when(serverUnderTest.removeRecipe(any(NamespacedKey.class))).thenAnswer(
|
||||
invocation -> registry.remove(invocation.getArgument(0)) != null);
|
||||
var registrar = new CobwebRecipeRegistrar(KEY);
|
||||
assertThrows(IllegalStateException.class, () -> registrar.register(serverUnderTest));
|
||||
registrar.unregister();
|
||||
assertEquals(Map.of(foreign, unrelated), registry);
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedRegistrationDoesNotClaimOwnershipAndRemovalFailureCanRetry() {
|
||||
AtomicInteger registrations = new AtomicInteger();
|
||||
AtomicBoolean allow = new AtomicBoolean();
|
||||
when(serverUnderTest.addRecipe(any(Recipe.class))).thenAnswer(invocation -> {
|
||||
registrations.incrementAndGet();
|
||||
return allow.get();
|
||||
});
|
||||
var registrar = new CobwebRecipeRegistrar(KEY);
|
||||
assertFalse(registrar.register(serverUnderTest));
|
||||
registrar.unregister();
|
||||
allow.set(true);
|
||||
assertTrue(registrar.register(serverUnderTest));
|
||||
assertTrue(registrar.register(serverUnderTest));
|
||||
assertEquals(2, registrations.get(), "Owned registration must be idempotent");
|
||||
AtomicInteger removals = new AtomicInteger();
|
||||
when(serverUnderTest.removeRecipe(KEY)).thenAnswer(invocation -> removals.incrementAndGet() > 1);
|
||||
assertThrows(IllegalStateException.class, registrar::unregister);
|
||||
registrar.unregister();
|
||||
registrar.unregister();
|
||||
assertEquals(2, removals.get());
|
||||
}
|
||||
|
||||
private static ShapedRecipe registeredRecipe() {
|
||||
List<Recipe> registered = new ArrayList<>();
|
||||
when(serverUnderTest.addRecipe(any(Recipe.class))).thenAnswer(invocation -> {
|
||||
registered.add(invocation.getArgument(0));
|
||||
return true;
|
||||
});
|
||||
assertTrue(new CobwebRecipeRegistrar(KEY).register(serverUnderTest));
|
||||
assertEquals(1, registered.size());
|
||||
return (ShapedRecipe) registered.getFirst();
|
||||
}
|
||||
|
||||
private static net.minecraft.world.item.crafting.ShapedRecipe nativeRecipe(ShapedRecipe recipe) {
|
||||
Map<Character, net.minecraft.world.item.crafting.Ingredient> ingredients = new HashMap<>();
|
||||
recipe.getChoiceMap().forEach((symbol, choice) -> {
|
||||
if (choice != null) {
|
||||
ingredients.put(symbol, CraftRecipe.toIngredient(choice, false));
|
||||
}
|
||||
});
|
||||
return new net.minecraft.world.item.crafting.ShapedRecipe(
|
||||
new net.minecraft.world.item.crafting.Recipe.CommonInfo(true),
|
||||
new net.minecraft.world.item.crafting.CraftingRecipe.CraftingBookInfo(
|
||||
CraftRecipe.getCategory(recipe.getCategory()), recipe.getGroup()),
|
||||
net.minecraft.world.item.crafting.ShapedRecipePattern.of(ingredients, recipe.getShape()),
|
||||
CraftItemStack.asTemplate(recipe.getResult()));
|
||||
}
|
||||
|
||||
private static CraftingFixture crafting() {
|
||||
var player = mock(net.minecraft.world.entity.player.Player.class);
|
||||
when(player.level()).thenReturn(mock(net.minecraft.world.level.Level.class));
|
||||
when(player.getBukkitEntity()).thenReturn(mock(org.bukkit.craftbukkit.entity.CraftPlayer.class));
|
||||
var inventory = new net.minecraft.world.entity.player.Inventory(
|
||||
player, new net.minecraft.world.entity.EntityEquipment());
|
||||
when(player.getInventory()).thenReturn(inventory);
|
||||
return new CraftingFixture(player, inventory, new net.minecraft.world.inventory.CraftingMenu(0, inventory));
|
||||
}
|
||||
|
||||
private static net.minecraft.world.inventory.RecipeBookMenu.PostPlaceAction autofill(
|
||||
CraftingFixture fixture, net.minecraft.world.item.crafting.ShapedRecipe recipe) {
|
||||
var grid = fixture.menu().craftSlots;
|
||||
var slots = fixture.menu().getInputGridSlots();
|
||||
var access = new net.minecraft.recipebook.ServerPlaceRecipe.CraftingMenuAccess<
|
||||
net.minecraft.world.item.crafting.ShapedRecipe>() {
|
||||
@Override public void fillCraftSlotsStackedContents(
|
||||
net.minecraft.world.entity.player.StackedItemContents contents) {
|
||||
grid.fillStackedContents(contents);
|
||||
}
|
||||
@Override public void clearCraftingContent() { grid.clearContent(); }
|
||||
@Override public boolean recipeMatches(net.minecraft.world.item.crafting.RecipeHolder<
|
||||
net.minecraft.world.item.crafting.ShapedRecipe> candidate) {
|
||||
return candidate.value().matches(nativeInput(grid), fixture.player().level());
|
||||
}
|
||||
};
|
||||
return net.minecraft.recipebook.ServerPlaceRecipe.placeRecipe(access, 3, 3, slots, slots,
|
||||
fixture.inventory(), new net.minecraft.world.item.crafting.RecipeHolder<>(nativeKey(), recipe),
|
||||
true, false);
|
||||
}
|
||||
|
||||
private static void setResult(net.minecraft.world.inventory.CraftingMenu menu,
|
||||
net.minecraft.world.item.crafting.ShapedRecipe recipe, net.minecraft.world.item.ItemStack result) {
|
||||
var holder = new net.minecraft.world.item.crafting.RecipeHolder<
|
||||
net.minecraft.world.item.crafting.CraftingRecipe>(nativeKey(), recipe);
|
||||
menu.craftSlots.setCurrentRecipe(holder);
|
||||
menu.resultSlots.setRecipeUsed(holder);
|
||||
menu.resultSlots.setItem(0, result);
|
||||
}
|
||||
|
||||
private static List<net.minecraft.world.item.ItemStack> pattern() {
|
||||
return java.util.stream.IntStream.range(0, 9).mapToObj(slot -> STRING_SLOTS.contains(slot)
|
||||
? CraftItemStack.asNMSCopy(new ItemStack(Material.STRING))
|
||||
: net.minecraft.world.item.ItemStack.EMPTY).toList();
|
||||
}
|
||||
|
||||
private static net.minecraft.world.item.crafting.CraftingInput nativeInput(net.minecraft.world.Container grid) {
|
||||
return input(java.util.stream.IntStream.range(0, grid.getContainerSize())
|
||||
.mapToObj(grid::getItem).toList());
|
||||
}
|
||||
|
||||
private static net.minecraft.world.item.crafting.CraftingInput input(List<net.minecraft.world.item.ItemStack> items) {
|
||||
return net.minecraft.world.item.crafting.CraftingInput.of(3, 3, items);
|
||||
}
|
||||
|
||||
private static long count(net.minecraft.world.Container container, Material material) {
|
||||
return java.util.stream.IntStream.range(0, container.getContainerSize()).mapToObj(container::getItem)
|
||||
.map(CraftItemStack::asBukkitCopy).filter(item -> item.getType() == material)
|
||||
.mapToInt(ItemStack::getAmount).sum();
|
||||
}
|
||||
|
||||
private static ItemStack marked(Material material, int amount) {
|
||||
ItemStack item = new ItemStack(material, amount);
|
||||
var meta = item.getItemMeta();
|
||||
meta.displayName(Component.text("Keep metadata"));
|
||||
meta.getPersistentDataContainer().set(new NamespacedKey("acceptance", "keep"),
|
||||
PersistentDataType.STRING, "unchanged");
|
||||
item.setItemMeta(meta);
|
||||
return item;
|
||||
}
|
||||
|
||||
private static void assertOrdinaryCobweb(ItemStack item, int amount) {
|
||||
assertEquals(Material.COBWEB, item.getType());
|
||||
assertEquals(amount, item.getAmount());
|
||||
assertFalse(item.hasItemMeta());
|
||||
}
|
||||
|
||||
private static net.minecraft.resources.ResourceKey<net.minecraft.world.item.crafting.Recipe<?>> nativeKey() {
|
||||
return net.minecraft.resources.ResourceKey.create(net.minecraft.core.registries.Registries.RECIPE,
|
||||
net.minecraft.resources.Identifier.fromNamespaceAndPath(KEY.getNamespace(), KEY.getKey()));
|
||||
}
|
||||
|
||||
private static void setServer(Server server) throws Exception {
|
||||
var field = Bukkit.class.getDeclaredField("server");
|
||||
field.setAccessible(true);
|
||||
field.set(null, server);
|
||||
}
|
||||
|
||||
private static final class TestRecipeBook extends net.minecraft.stats.ServerRecipeBook {
|
||||
TestRecipeBook(net.minecraft.stats.ServerRecipeBook.DisplayResolver resolver) { super(resolver); }
|
||||
}
|
||||
|
||||
private record CraftingFixture(net.minecraft.world.entity.player.Player player,
|
||||
net.minecraft.world.entity.player.Inventory inventory, net.minecraft.world.inventory.CraftingMenu menu) {}
|
||||
}
|
||||
Reference in new issue
Block a user