Compare commits

...
Author SHA1 Message Date
Mickey42302 e5fe71723e [ci skip] Undeprecate Bukkit.setSpawnRadius (#14235) 2026-09-11 18:23:51 +02:00
Charles Richards 50018799f2 Assign BlockProjectileSource before dispenser projectiles spawn (#14201) 2026-09-09 19:31:52 +02:00
Amine Kacimi a3b2cb162a Fix zombie fly when trampling after cancelling EntityInteractEvent (#14225) 2026-09-09 19:12:36 +02:00
Creeam a2a42c5b12 Correctly consume chunkStatus in loadChunksAsync (#14220)
Reintroduce a missing patch hunk from moonrise which consumes the added chunk status parameter in ServerLevel#loadChunksAsync.
2026-08-29 12:31:38 +01:00
CPieterandJake Potrebic 1797fbccf1 Add PredicateChoice (#12017)
Co-authored-by: Jake Potrebic <jake.m.potrebic@gmail.com>
2026-08-28 20:55:17 +01:00
Pedro bb09b431d2 Bump spark integration to version 1.10.180 (#14214) 2026-08-25 14:57:53 +01:00
Spottedleaf 5d4f9bd0e4 Sync DataConverter
- https://github.com/PaperMC/DataConverter/commit/72494376717b1a7912fad592a6cf9a203ff66109
- https://github.com/PaperMC/DataConverter/commit/01cfab62813b3200c9f9e94a5d8feccbc6875d43
2026-08-25 06:05:47 -07:00
Pedro bd0c804a19 [ci-skip] Fix badge version filter syntax in README.md [ci skip] (#14140) 2026-08-24 17:49:23 +01:00
Shane Freeder 27af5dde55 Bump velocity-natives to 4.1.0 release 2026-08-24 17:42:26 +01:00
Spottedleaf 37dc5450d9 Update Leafpile to 1.1.0
This is mainly to include a fix for MultiThreadedQueue iterator
removal and SWMR hash table equality comparison.
2026-08-23 01:56:34 -07:00
Warrior de518f79b5 Bump spark-paper to the latest version (#14205) 2026-08-22 18:15:46 +01:00
Strokkur24 0bdc80db32 Remove name field in Event class (#14198) 2026-08-20 21:05:12 +02:00
Spottedleaf c9e894d3cc Do not re-order ClientboundLoginCompressionPacket
Using Connection#scheduleNettyTask will schedule a task on the netty thread
in the same manner that Connection#send would. However,
Connection#send will append to the pending actions if it is not
empty - causing a re-order if used during the task scheduled with
Connection#scheduleNettyTask.
2026-08-10 22:12:14 -07:00
26 changed files with 4679 additions and 3931 deletions

No files matched your search

+1 -1
View File
@@ -1,4 +1,4 @@
Paper [![Version](https://img.shields.io/maven-metadata/v?metadataUrl=https%3A%2F%2Fartifactory.papermc.io%2Fartifactory%2Funiverse%2Fio%2Fpapermc%2Fpaper%2Fpaper-api%2Fmaven-metadata.xml&strategy=highestVersion&filter=26.2*&label=version&color=%23344ceb
Paper [![Version](https://img.shields.io/maven-metadata/v?metadataUrl=https%3A%2F%2Fartifactory.papermc.io%2Fartifactory%2Funiverse%2Fio%2Fpapermc%2Fpaper%2Fpaper-api%2Fmaven-metadata.xml&strategy=highestVersion&filter=26.2.*&label=version&color=%23344ceb
)](https://papermc.io/downloads/paper)
[![Paper Build Status](https://img.shields.io/github/actions/workflow/status/PaperMC/Paper/build.yml?branch=main)](https://github.com/PaperMC/Paper/actions)
[![Discord](https://img.shields.io/discord/289587909051416579.svg?label=&logo=discord&logoColor=ffffff&color=7389D8&labelColor=6A7EC2)](https://discord.gg/papermc)
@@ -5,6 +5,7 @@ import java.util.function.Predicate;
import org.bukkit.Keyed;
import org.bukkit.NamespacedKey;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.ItemType;
import org.bukkit.inventory.RecipeChoice;
import org.jetbrains.annotations.Contract;
import org.jspecify.annotations.NullMarked;
@@ -41,10 +42,12 @@ public final class PotionMix implements Keyed {
*
* @param stackPredicate a predicate for an itemstack.
* @return a new RecipeChoice
* @deprecated use {@link RecipeChoice#predicateChoice(Predicate, ItemStack)}
*/
@Contract(value = "_ -> new", pure = true)
@Deprecated(since = "26.2")
public static RecipeChoice createPredicateChoice(final Predicate<? super ItemStack> stackPredicate) {
return new PredicateRecipeChoice(stackPredicate);
return RecipeChoice.predicateChoice(stackPredicate, ItemType.STONE.createItemStack());
}
@Override
@@ -1367,11 +1367,7 @@ public final class Bukkit {
* Sets the radius, in blocks, around each worlds spawn point to protect.
*
* @param value new spawn radius, or 0 if none
* @deprecated has not functioned for a long time as the spawn radius is defined by the server.properties file.
* There is no API replacement for this method. It is generally recommended to implement "protection"-like behaviour
* via events or third-party plugin APIs.
*/
@Deprecated(since = "1.21.4", forRemoval = true)
public static void setSpawnRadius(int value) {
server.setSpawnRadius(value);
}
@@ -15,7 +15,6 @@ import org.jetbrains.annotations.NotNull;
*/
public abstract class Event {
private String name;
private final boolean isAsync;
/**
@@ -60,10 +59,7 @@ public abstract class Event {
*/
@NotNull
public String getEventName() {
if (this.name == null) {
this.name = this.getClass().getSimpleName();
}
return this.name;
return this.getClass().getSimpleName();
}
@NotNull
@@ -0,0 +1,38 @@
package org.bukkit.inventory;
import com.google.common.base.Preconditions;
import java.util.function.Predicate;
import org.jspecify.annotations.NullMarked;
@NullMarked
record PredicateRecipeChoiceImpl(Predicate<? super ItemStack> stackPredicate, ItemStack exampleStack) implements RecipeChoice.PredicateChoice {
public PredicateRecipeChoiceImpl {
Preconditions.checkArgument(stackPredicate != null, "The item predicate cannot be null");
Preconditions.checkArgument(exampleStack != null, "The example stack cannot be null");
Preconditions.checkArgument(!exampleStack.isEmpty(), "Cannot have empty/air example stack");
exampleStack = exampleStack.clone();
}
@Override
public ItemStack getItemStack() {
return this.exampleStack.clone();
}
@SuppressWarnings({"MethodDoesntCallSuperMethod", "FunctionalExpressionCanBeFolded"})
@Override
public PredicateRecipeChoiceImpl clone() {
return new PredicateRecipeChoiceImpl(this.stackPredicate::test, this.exampleStack);
}
@Override
public boolean test(final ItemStack itemStack) {
return this.stackPredicate.test(itemStack);
}
@Override
public RecipeChoice validate(final boolean allowEmptyRecipes) {
return this;
}
}
@@ -70,6 +70,53 @@ public interface RecipeChoice extends Predicate<ItemStack>, Cloneable {
return new ItemTypeRecipeChoiceImpl(itemTypes);
}
/**
* Creates a choice that will be valid only if one of the stacks is
* exactly matched (aside from stack size).
*
* @param first an ItemStack to match against.
* Cannot be null or empty/air.
* @param others additional ItemStacks to match against.
* @return a new ExactChoice
*/
@Contract(value = "_, _ -> new", pure = true)
static ExactChoice exactChoice(ItemStack first, ItemStack... others) {
List<ItemStack> stacks = new ArrayList<>(others.length + 1);
stacks.add(first);
Collections.addAll(stacks, others);
return new ExactChoice(stacks);
}
/**
* Creates a choice that will be valid only if one of the stacks is
* exactly matched (aside from stack size).
*
* @param stacks the ItemStacks to match against.
* Cannot be empty or contain empty/air stacks.
* @return a new ExactChoice
*/
@Contract(value = "_ -> new", pure = true)
static ExactChoice exactChoice(List<ItemStack> stacks) {
return new ExactChoice(stacks);
}
/**
* Creates a recipe choice that will be valid only if an item matches the
* given predicate.
* <p>
* <b>Note:</b> Mutating the {@link ItemStack} within the predicate is not
* supported.
*
* @param stackPredicate the predicate to match against.
* @param exampleStack an example {@link ItemStack} to be shown in the
* recipe book. Cannot be empty or air.
* @return a new PredicateChoice
*/
@Contract(value = "_, _ -> new", pure = true)
static PredicateChoice predicateChoice(Predicate<? super ItemStack> stackPredicate, ItemStack exampleStack) {
return new PredicateRecipeChoiceImpl(stackPredicate, exampleStack);
}
/**
* Gets a single item stack representative of this stack choice.
*
@@ -228,14 +275,26 @@ public interface RecipeChoice extends Predicate<ItemStack>, Cloneable {
private List<ItemStack> choices;
/**
* @deprecated Use {@link RecipeChoice#exactChoice(ItemStack, ItemStack...)} instead
*/
@Deprecated(since = "26.2", forRemoval = true)
public ExactChoice(ItemStack stack) {
this(Arrays.asList(stack));
}
/**
* @deprecated Use {@link RecipeChoice#exactChoice(ItemStack, ItemStack...)} instead
*/
@Deprecated(since = "26.2", forRemoval = true)
public ExactChoice(ItemStack... stacks) {
this(Arrays.asList(stacks));
}
/**
* @deprecated Use {@link RecipeChoice#exactChoice(List)} instead
*/
@Deprecated(since = "26.2", forRemoval = true)
public ExactChoice(List<ItemStack> choices) {
Preconditions.checkArgument(choices != null, "choices");
Preconditions.checkArgument(!choices.isEmpty(), "Must have at least one choice");
@@ -341,4 +400,13 @@ public interface RecipeChoice extends Predicate<ItemStack>, Cloneable {
*/
RegistryKeySet<ItemType> itemTypes();
}
/**
* Represents a choice that will be valid only if an item matches the
* given predicate.
*/
@ApiStatus.NonExtendable
interface PredicateChoice extends RecipeChoice {
}
}
@@ -180,7 +180,7 @@ public class ShapedRecipe extends CraftingRecipe {
@NotNull
public ShapedRecipe setIngredient(char key, @NotNull ItemStack item) {
Preconditions.checkArgument(!item.getType().isAir(), "Item cannot be air"); // Paper
return setIngredient(key, new RecipeChoice.ExactChoice(item.clone())); // Paper
return setIngredient(key, RecipeChoice.exactChoice(item.clone())); // Paper
}
/**
@@ -143,7 +143,7 @@ public class ShapelessRecipe extends CraftingRecipe {
Preconditions.checkArgument(!item.getType().isAir(), "Item cannot be air"); // Paper
item = item.clone(); // Paper
while (count-- > 0) {
this.ingredients.add(new RecipeChoice.ExactChoice(item));
this.ingredients.add(RecipeChoice.exactChoice(item));
}
return this;
}
+3 -3
View File
@@ -110,7 +110,7 @@ abstract class MockitoAgentProvider : CommandLineArgumentProvider {
dependencies {
implementation(project(":paper-api"))
implementation("ca.spottedleaf:leafpile:1.0.0")
implementation("ca.spottedleaf:leafpile:1.2.0")
implementation("org.jline:jline-terminal-ffm:3.27.1") // use ffm on java 22+
implementation("org.jline:jline-terminal-jni:3.27.1") // fall back to jni on java 21
implementation("net.minecrell:terminalconsoleappender:1.3.0")
@@ -127,7 +127,7 @@ dependencies {
runtimeOnly(log4jPlugins.output)
alsoShade(log4jPlugins.output)
implementation("com.velocitypowered:velocity-native:3.4.0-SNAPSHOT") {
implementation("com.velocitypowered:velocity-native:4.1.0") {
isTransitive = false
}
implementation("io.netty:netty-codec-haproxy:4.2.15.Final") // Add support for proxy protocol
@@ -156,7 +156,7 @@ dependencies {
// Spark
implementation("me.lucko:spark-api:0.1-20240720.200737-2")
implementation("me.lucko:spark-paper:1.10.172")
implementation("me.lucko:spark-paper:1.10.180")
}
tasks.jar {
@@ -15996,10 +15996,10 @@ index 0000000000000000000000000000000000000000..1414f25ef770b0fe73ea618a450ade2f
+}
diff --git a/ca/spottedleaf/moonrise/patches/collisions/CollisionUtil.java b/ca/spottedleaf/moonrise/patches/collisions/CollisionUtil.java
new file mode 100644
index 0000000000000000000000000000000000000000..15568f598b8c61cf92563b6a7f8cae526b84484e
index 0000000000000000000000000000000000000000..54b2492c3ab6bf9ff52cec53dc4635f725f9eb4f
--- /dev/null
+++ b/ca/spottedleaf/moonrise/patches/collisions/CollisionUtil.java
@@ -0,0 +1,2199 @@
@@ -0,0 +1,2200 @@
+package ca.spottedleaf.moonrise.patches.collisions;
+
+import ca.spottedleaf.moonrise.common.util.WorldUtil;
@@ -18048,6 +18048,7 @@ index 0000000000000000000000000000000000000000..15568f598b8c61cf92563b6a7f8cae52
+ continue;
+ }
+
+ // TODO perform intersection check using moved aabb instead of moved blockCollision so we can avoid the call?
+ final VoxelShape blockCollisionOffset = blockCollision.move((double)blockX, (double)blockY, (double)blockZ);
+
+ if (!voxelShapeIntersectNoEmpty(blockCollisionOffset, aabb)) {
@@ -27402,7 +27403,7 @@ index f11c5ea1ee9381e7bf65305b959bd7da67423ece..26822e1211194b411dd8ea75ce523133
List<Entity> passengers = this.entity.getPassengers();
if (!passengers.equals(this.lastPassengers)) {
diff --git a/net/minecraft/server/level/ServerLevel.java b/net/minecraft/server/level/ServerLevel.java
index 473feb1aa6ccf0f03ce0bc895de367b3e554e1bc..e5328f08d6a323aa1307ed791a88916b4588939c 100644
index 473feb1aa6ccf0f03ce0bc895de367b3e554e1bc..4fce291cb7cd6eb2f4e47237fe247a5d666da0ad 100644
--- a/net/minecraft/server/level/ServerLevel.java
+++ b/net/minecraft/server/level/ServerLevel.java
@@ -182,7 +182,7 @@ import net.minecraft.world.waypoints.WaypointTransmitter;
@@ -27625,7 +27626,13 @@ index 473feb1aa6ccf0f03ce0bc895de367b3e554e1bc..e5328f08d6a323aa1307ed791a88916b
}
}
}
@@ -340,16 +476,135 @@ public class ServerLevel extends Level implements WorldGenLevel, ServerEntityGet
@@ -335,21 +471,140 @@ public class ServerLevel extends Level implements WorldGenLevel, ServerEntityGet
for (int cx = minChunkX; cx <= maxChunkX; ++cx) {
for (int cz = minChunkZ; cz <= maxChunkZ; ++cz) {
ca.spottedleaf.moonrise.common.PlatformHooks.get().scheduleChunkLoad(
- this, cx, cz, net.minecraft.world.level.chunk.status.ChunkStatus.FULL, true, priority, consumer
+ this, cx, cz, chunkStatus, true, priority, consumer
);
}
}
}
File diff suppressed because it is too large. Load diff
@@ -28,7 +28,7 @@ and then catch exceptions and close if they fire.
Part of this commit was authored by: Spottedleaf, sandtechnology
diff --git a/net/minecraft/network/Connection.java b/net/minecraft/network/Connection.java
index 709f2c6b47f78920cd9432da4c75e0d1ab2bffbc..3f10ea62d2ef70e7e4f01c83942cd9051249954c 100644
index f46c55fd212ea68386342bc9ce71c04108758a8e..0b65c06e43fd22b2c30d9b8c5ef5b5e99301cec1 100644
--- a/net/minecraft/network/Connection.java
+++ b/net/minecraft/network/Connection.java
@@ -65,7 +65,7 @@ public class Connection extends SimpleChannelInboundHandler<Packet<?>> {
@@ -111,7 +111,7 @@ index 709f2c6b47f78920cd9432da4c75e0d1ab2bffbc..3f10ea62d2ef70e7e4f01c83942cd905
this.flushQueue();
}
}
@@ -444,21 +475,42 @@ public class Connection extends SimpleChannelInboundHandler<Packet<?>> {
@@ -461,21 +492,42 @@ public class Connection extends SimpleChannelInboundHandler<Packet<?>> {
}
private void doSendPacket(final Packet<?> packet, final @Nullable ChannelFutureListener listener, final boolean flush) {
@@ -158,7 +158,7 @@ index 709f2c6b47f78920cd9432da4c75e0d1ab2bffbc..3f10ea62d2ef70e7e4f01c83942cd905
}
}
@@ -470,16 +522,57 @@ public class Connection extends SimpleChannelInboundHandler<Packet<?>> {
@@ -487,16 +539,57 @@ public class Connection extends SimpleChannelInboundHandler<Packet<?>> {
}
}
@@ -221,7 +221,7 @@ index 709f2c6b47f78920cd9432da4c75e0d1ab2bffbc..3f10ea62d2ef70e7e4f01c83942cd905
private static final int MAX_PER_TICK = io.papermc.paper.configuration.GlobalConfiguration.get().misc.maxJoinsPerTick; // Paper - Buffer joins to world
private static int joinAttemptsThisTick; // Paper - Buffer joins to world
@@ -549,6 +642,7 @@ public class Connection extends SimpleChannelInboundHandler<Packet<?>> {
@@ -566,6 +659,7 @@ public class Connection extends SimpleChannelInboundHandler<Packet<?>> {
public void disconnect(final DisconnectionDetails details) {
this.preparing = false; // Spigot
@@ -229,7 +229,7 @@ index 709f2c6b47f78920cd9432da4c75e0d1ab2bffbc..3f10ea62d2ef70e7e4f01c83942cd905
if (this.channel == null) {
this.delayedDisconnect = details;
}
@@ -738,7 +832,7 @@ public class Connection extends SimpleChannelInboundHandler<Packet<?>> {
@@ -755,7 +849,7 @@ public class Connection extends SimpleChannelInboundHandler<Packet<?>> {
public void handleDisconnection() {
if (this.channel != null && !this.channel.isOpen()) {
if (this.disconnectionHandled) {
@@ -238,7 +238,7 @@ index 709f2c6b47f78920cd9432da4c75e0d1ab2bffbc..3f10ea62d2ef70e7e4f01c83942cd905
} else {
this.disconnectionHandled = true;
PacketListener packetListener = this.getPacketListener();
@@ -749,7 +843,7 @@ public class Connection extends SimpleChannelInboundHandler<Packet<?>> {
@@ -766,7 +860,7 @@ public class Connection extends SimpleChannelInboundHandler<Packet<?>> {
);
disconnectListener.onDisconnect(details);
}
@@ -247,7 +247,7 @@ index 709f2c6b47f78920cd9432da4c75e0d1ab2bffbc..3f10ea62d2ef70e7e4f01c83942cd905
// Paper start - Add PlayerConnectionCloseEvent
if (packetListener instanceof net.minecraft.server.network.ServerCommonPacketListenerImpl commonPacketListener) {
/* Player was logged in, either game listener or configuration listener */
@@ -792,4 +886,96 @@ public class Connection extends SimpleChannelInboundHandler<Packet<?>> {
@@ -809,4 +903,96 @@ public class Connection extends SimpleChannelInboundHandler<Packet<?>> {
public @Nullable UUID getIntendedProfileId() {
return this.intendedProfileId;
}
@@ -267,10 +267,10 @@ index 595db570b0ade9cad7de77d2ab7b10d9e68699df..ac095f59d9f17d8239d6493d1c1e3942
+ // Paper end - Use Velocity cipher
}
diff --git a/net/minecraft/network/Connection.java b/net/minecraft/network/Connection.java
index 3f10ea62d2ef70e7e4f01c83942cd9051249954c..9a04309584a5b9b55d0d383619113835190ad8f9 100644
index 0b65c06e43fd22b2c30d9b8c5ef5b5e99301cec1..9fbc7e93712672dbab67f931e2aee447d803607b 100644
--- a/net/minecraft/network/Connection.java
+++ b/net/minecraft/network/Connection.java
@@ -768,10 +768,24 @@ public class Connection extends SimpleChannelInboundHandler<Packet<?>> {
@@ -785,10 +785,24 @@ public class Connection extends SimpleChannelInboundHandler<Packet<?>> {
return connection;
}
@@ -298,7 +298,7 @@ index 3f10ea62d2ef70e7e4f01c83942cd9051249954c..9a04309584a5b9b55d0d383619113835
public boolean isConnected() {
return this.channel != null && this.channel.isOpen();
@@ -805,16 +819,17 @@ public class Connection extends SimpleChannelInboundHandler<Packet<?>> {
@@ -822,16 +836,17 @@ public class Connection extends SimpleChannelInboundHandler<Packet<?>> {
public void setupCompression(final int threshold, final boolean validateDecompressed) {
if (threshold >= 0) {
@@ -320,7 +320,7 @@ index 3f10ea62d2ef70e7e4f01c83942cd9051249954c..9a04309584a5b9b55d0d383619113835
this.channel.pipeline().fireUserEventTriggered(io.papermc.paper.network.ConnectionEvent.COMPRESSION_THRESHOLD_SET); // Paper - Add Channel initialization listeners
} else {
diff --git a/net/minecraft/server/network/ServerConnectionListener.java b/net/minecraft/server/network/ServerConnectionListener.java
index 1a60071f32a0c92c69f60c75035e5324aa144fb4..e84e243f35b09691d03e9a9cf3de0c093b1d5cfb 100644
index fa2467ff885e039f861bb9b1ae076a5a64b891ed..8a2f1ebcf06bb0c1e8b352652de69b6c1d74daf3 100644
--- a/net/minecraft/server/network/ServerConnectionListener.java
+++ b/net/minecraft/server/network/ServerConnectionListener.java
@@ -80,6 +80,10 @@ public class ServerConnectionListener {
@@ -335,10 +335,10 @@ index 1a60071f32a0c92c69f60c75035e5324aa144fb4..e84e243f35b09691d03e9a9cf3de0c09
.add(
new ServerBootstrap()
diff --git a/net/minecraft/server/network/ServerLoginPacketListenerImpl.java b/net/minecraft/server/network/ServerLoginPacketListenerImpl.java
index d6b6c6e7dce618c3f4aecc0969b7423771eab3ba..b94e01aa54b932b29370d1742b00ed73254fe4dc 100644
index 24bcd1067db20fc09b87b6e300615e3fa590049a..1d892a8c905496af9d56cec86941e82ec48505db 100644
--- a/net/minecraft/server/network/ServerLoginPacketListenerImpl.java
+++ b/net/minecraft/server/network/ServerLoginPacketListenerImpl.java
@@ -257,9 +257,7 @@ public class ServerLoginPacketListenerImpl implements ServerLoginPacketListener,
@@ -259,9 +259,7 @@ public class ServerLoginPacketListenerImpl implements ServerLoginPacketListener,
SecretKey secretKey = packet.getSecretKey(serverPrivateKey);
digest = new BigInteger(Crypt.digestData("", this.server.getKeyPair().getPublic(), secretKey)).toString(16);
this.state = ServerLoginPacketListenerImpl.State.AUTHENTICATING;
@@ -78,10 +78,10 @@ index 0000000000000000000000000000000000000000..9e621873454cc2edc2e59e6c7554d811
+}
diff --git a/io/papermc/paper/inventory/recipe/StackedContentsExtrasMap.java b/io/papermc/paper/inventory/recipe/StackedContentsExtrasMap.java
new file mode 100644
index 0000000000000000000000000000000000000000..f47c12e9dd6cfa857ca07a764edc22de372e25b6
index 0000000000000000000000000000000000000000..a9dcccefb19e6610076e1153547d2f9156042313
--- /dev/null
+++ b/io/papermc/paper/inventory/recipe/StackedContentsExtrasMap.java
@@ -0,0 +1,68 @@
@@ -0,0 +1,81 @@
+package io.papermc.paper.inventory.recipe;
+
+import it.unimi.dsi.fastutil.objects.Object2IntMap;
@@ -94,12 +94,16 @@ index 0000000000000000000000000000000000000000..f47c12e9dd6cfa857ca07a764edc22de
+import net.minecraft.world.item.crafting.CraftingInput;
+import net.minecraft.world.item.crafting.Ingredient;
+import net.minecraft.world.item.crafting.Recipe;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.Predicate;
+
+public final class StackedContentsExtrasMap {
+
+ private final StackedContents<ItemOrExact> contents;
+ public Object2IntMap<ItemOrExact.Item> regularRemoved = new Object2IntOpenHashMap<>(); // needed for re-using the regular contents (for ShapelessRecipe)
+ public final ObjectSet<ItemStack> exactIngredients = new ObjectOpenCustomHashSet<>(ItemStackLinkedSet.TYPE_AND_TAG);
+ public final List<Predicate<ItemStack>> predicateIngredients = new ArrayList<>();
+
+ public StackedContentsExtrasMap(final StackedContents<ItemOrExact> contents) {
+ this.contents = contents;
@@ -107,8 +111,11 @@ index 0000000000000000000000000000000000000000..f47c12e9dd6cfa857ca07a764edc22de
+
+ public void initialize(final Recipe<?> recipe) {
+ this.exactIngredients.clear();
+ this.predicateIngredients.clear();
+ for (final Ingredient ingredient : recipe.placementInfo().ingredients()) {
+ if (ingredient.isExact()) {
+ if (ingredient.stackPredicate != null) {
+ this.predicateIngredients.add(ingredient.stackPredicate);
+ } else if (ingredient.isExact()) {
+ this.exactIngredients.addAll(ingredient.itemStacks());
+ }
+ }
@@ -138,11 +145,17 @@ index 0000000000000000000000000000000000000000..f47c12e9dd6cfa857ca07a764edc22de
+ for (final Object2IntMap.Entry<ItemOrExact.Item> entry : this.regularRemoved.object2IntEntrySet()) {
+ this.contents.amounts.addTo(entry.getKey(), entry.getIntValue());
+ }
+ this.predicateIngredients.clear();
+ this.exactIngredients.clear();
+ this.regularRemoved.clear();
+ }
+
+ public boolean accountStack(final ItemStack stack, final int count) {
+ for (Predicate<ItemStack> stackPredicate : this.predicateIngredients) {
+ if (!stackPredicate.test(stack)) continue;
+ this.contents.account(new ItemOrExact.Exact(stack), count);
+ return true;
+ }
+ if (this.exactIngredients.contains(stack)) {
+ this.contents.account(new ItemOrExact.Exact(stack), count);
+ return true;
@@ -344,7 +357,7 @@ index ffaa8e450a11a01060b6847d1b66a76323f9d8ba..1ace17d8fc7e7b8b4722a1889d49233c
}
diff --git a/net/minecraft/world/item/crafting/Ingredient.java b/net/minecraft/world/item/crafting/Ingredient.java
index 1a6870fd5dd02022d6b0df88e8f55f8ba9cf07c9..4f3697573fbfec3865751037de00a562ad81cddb 100644
index 218d56fa066513fd8d9761af36bcc2873a34406b..778b38911b0d244738fd3db3b275b0c78a17339f 100644
--- a/net/minecraft/world/item/crafting/Ingredient.java
+++ b/net/minecraft/world/item/crafting/Ingredient.java
@@ -22,7 +22,7 @@ import net.minecraft.world.item.Items;
@@ -356,9 +369,9 @@ index 1a6870fd5dd02022d6b0df88e8f55f8ba9cf07c9..4f3697573fbfec3865751037de00a562
public static final StreamCodec<RegistryFriendlyByteBuf, Ingredient> CONTENTS_STREAM_CODEC = ByteBufCodecs.holderSet(Registries.ITEM)
.map(Ingredient::new, i -> i.values);
public static final StreamCodec<RegistryFriendlyByteBuf, Optional<Ingredient>> OPTIONAL_CONTENTS_STREAM_CODEC = ByteBufCodecs.holderSet(Registries.ITEM)
@@ -34,19 +34,23 @@ public final class Ingredient implements Predicate<ItemStack>, StackedContents.I
public static final Codec<Ingredient> CODEC = ExtraCodecs.nonEmptyHolderSet(NON_AIR_HOLDER_SET_CODEC).xmap(Ingredient::new, i -> i.values);
@@ -35,19 +35,23 @@ public final class Ingredient implements Predicate<ItemStack>, StackedContents.I
public final HolderSet<Item> values;
public java.util.function.@org.jspecify.annotations.Nullable Predicate<ItemStack> stackPredicate; // Paper - add PredicateChoice
// CraftBukkit start
- private java.util.@org.jspecify.annotations.Nullable List<ItemStack> itemStacks;
+ private java.util.@org.jspecify.annotations.Nullable Set<ItemStack> itemStacks; // Paper - Improve exact choice recipe ingredients
@@ -383,8 +396,8 @@ index 1a6870fd5dd02022d6b0df88e8f55f8ba9cf07c9..4f3697573fbfec3865751037de00a562
return ingredient;
}
// CraftBukkit end
@@ -81,21 +85,22 @@ public final class Ingredient implements Predicate<ItemStack>, StackedContents.I
public boolean test(final ItemStack input) {
@@ -87,21 +91,32 @@ public final class Ingredient implements Predicate<ItemStack>, StackedContents.I
// Paper end - add PredicateChoice
// CraftBukkit start
if (this.isExact()) {
- for (ItemStack item : this.itemStacks()) {
@@ -408,14 +421,24 @@ index 1a6870fd5dd02022d6b0df88e8f55f8ba9cf07c9..4f3697573fbfec3865751037de00a562
+ return switch (itemOrExact) {
+ case io.papermc.paper.inventory.recipe.ItemOrExact.Item(final Holder<Item> item) ->
+ !this.isExact() && this.values.contains(item);
+ case io.papermc.paper.inventory.recipe.ItemOrExact.Exact(final ItemStack exact) ->
+ this.isExact() && this.itemStacks.contains(exact);
+ case io.papermc.paper.inventory.recipe.ItemOrExact.Exact(final ItemStack exact) -> {
+ if (this.stackPredicate != null) {
+ yield this.stackPredicate.test(exact);
+ }
+ if (this.isExact()) {
+ yield this.itemStacks.contains(exact);
+ }
+ // A plain, usable item can be routed to the Exact pool because it also matched a
+ // predicate/exact ingredient in the same recipe; a regular ingredient must still be
+ // able to draw from that shared pool (the picker's per-key count keeps it double-count free).
+ yield net.minecraft.world.entity.player.Inventory.isUsableForCrafting(exact) && exact.is(this.values);
+ }
+ };
+ // Paper end - Improve exact choice recipe ingredients
}
@Override
@@ -125,6 +130,11 @@ public final class Ingredient implements Predicate<ItemStack>, StackedContents.I
@@ -131,6 +146,11 @@ public final class Ingredient implements Predicate<ItemStack>, StackedContents.I
}
public SlotDisplay display() {
@@ -4,43 +4,6 @@ Date: Sat, 7 Jun 2025 15:05:05 -0400
Subject: [PATCH] DataConverter, Moonrise & co fixes
diff --git a/ca/spottedleaf/dataconverter/minecraft/converters/attributes/ConverterAbstractOldAttributesRename.java b/ca/spottedleaf/dataconverter/minecraft/converters/attributes/ConverterAbstractOldAttributesRename.java
index 1a6d02df13f3461402fed429f7fbdb5acee877c4..712cca21a6b4736a15d49dceaf7e6d641096b0a9 100644
--- a/ca/spottedleaf/dataconverter/minecraft/converters/attributes/ConverterAbstractOldAttributesRename.java
+++ b/ca/spottedleaf/dataconverter/minecraft/converters/attributes/ConverterAbstractOldAttributesRename.java
@@ -38,7 +38,13 @@ public final class ConverterAbstractOldAttributesRename {
MCTypeRegistry.ITEM_STACK.addStructureConverter(new DataConverter<>(version, versionStep) {
@Override
public MapType convert(final MapType data, final long sourceVersion, final long toVersion) {
- final ListType attributes = data.getList("AttributeModifiers", ObjectType.MAP);
+ final MapType tag = data.getMap("tag");
+
+ if (tag == null) {
+ return null;
+ }
+
+ final ListType attributes = tag.getList("AttributeModifiers", ObjectType.MAP);
if (attributes == null) {
return null;
diff --git a/ca/spottedleaf/dataconverter/minecraft/versions/V4307.java b/ca/spottedleaf/dataconverter/minecraft/versions/V4307.java
index d2877c20f389d0131e1dd208b464f590671e5d82..27bdc70d861ca39487ad16cb3afb89d604b462c8 100644
--- a/ca/spottedleaf/dataconverter/minecraft/versions/V4307.java
+++ b/ca/spottedleaf/dataconverter/minecraft/versions/V4307.java
@@ -87,10 +87,10 @@ public final class V4307 {
@Override
public MapType convert(final MapType root, final long sourceVersion, final long toVersion) {
- final Set<String> hiddenComponents = new LinkedHashSet<>();
-
- unwrapBlockPredicates(root, "minecraft:can_break", hiddenComponents);
+ // Don't use a linked hash set, and ensure that it is added in the same (undefined) order as the vanilla datafixer
+ final Set<String> hiddenComponents = new java.util.HashSet<>();
unwrapBlockPredicates(root, "minecraft:can_place_on", hiddenComponents);
+ unwrapBlockPredicates(root, "minecraft:can_break", hiddenComponents);
updateComponent(root, "minecraft:trim", hiddenComponents);
updateComponent(root, "minecraft:unbreakable", hiddenComponents);
diff --git a/net/minecraft/server/dedicated/DedicatedServer.java b/net/minecraft/server/dedicated/DedicatedServer.java
index 3b28b39ebf252d8de46f7d0ce27037cdb041f1b5..0b6d7f99edde802cb464baa5a4b2b4a4dcce684a 100644
--- a/net/minecraft/server/dedicated/DedicatedServer.java
@@ -5,16 +5,16 @@ Subject: [PATCH] Call AsyncServerDataFixerRemoveBlockEntityEvent from DC
diff --git a/ca/spottedleaf/dataconverter/minecraft/converters/tileentity/ConverterAbstractTileEntityRemove.java b/ca/spottedleaf/dataconverter/minecraft/converters/tileentity/ConverterAbstractTileEntityRemove.java
index a74821562154c99ee3a8571ff8dbf8fe5eacf416..8d47addc3cf6d0c551809463992da8551c2835f6 100644
index 6aef3311ca59220e8d7c97a4a5c1f14e96d08117..e70408061c8a495af319c810ee93d51a2644e465 100644
--- a/ca/spottedleaf/dataconverter/minecraft/converters/tileentity/ConverterAbstractTileEntityRemove.java
+++ b/ca/spottedleaf/dataconverter/minecraft/converters/tileentity/ConverterAbstractTileEntityRemove.java
@@ -61,8 +61,43 @@ public final class ConverterAbstractTileEntityRemove {
@@ -63,8 +63,43 @@ public final class ConverterAbstractTileEntityRemove {
return null;
}
+ // Paper start - call event on block entity removal
+ final java.util.function.Supplier<net.kyori.adventure.key.Key> worldKeySupplier = com.google.common.base.Suppliers.memoize(() -> {
+ final ca.spottedleaf.dataconverter.types.MapType context = data.getMap(net.minecraft.util.datafix.fixes.ChunkHeightAndBiomeFix.DATAFIXER_CONTEXT_TAG);
+ final MapType context = data.getMap(net.minecraft.util.datafix.fixes.ChunkHeightAndBiomeFix.DATAFIXER_CONTEXT_TAG);
+ if (context == null) return null;
+
+ final @org.intellij.lang.annotations.Subst("minecraft:overworld") String levelIdentifier = context.getString("level_identifier");
@@ -1,6 +1,6 @@
--- a/net/minecraft/core/dispenser/ProjectileDispenseBehavior.java
+++ b/net/minecraft/core/dispenser/ProjectileDispenseBehavior.java
@@ -27,17 +_,36 @@
@@ -27,17 +_,38 @@
ServerLevel level = source.level();
Direction direction = source.state().getValue(DispenserBlock.FACING);
Position position = this.dispenseConfig.positionFunction().getDispensePosition(source, direction);
@@ -40,8 +40,10 @@
+
+ // SPIGOT-7923: Avoid create projectiles with empty item
+ if (!singleItemStack.isEmpty()) {
+ Projectile projectile = Projectile.spawnProjectileUsingShoot(this.projectileItem.asProjectile(level, position, org.bukkit.craftbukkit.inventory.CraftItemStack.unwrap(event.getItem()), direction), level, singleItemStack, event.getVelocity().getX(), event.getVelocity().getY(), event.getVelocity().getZ(), this.dispenseConfig.power(), this.dispenseConfig.uncertainty()); // Paper - track changed items in the dispense event; unwrap is safe here because all uses of the stack make their own copies
+ Projectile projectile = this.projectileItem.asProjectile(level, position, org.bukkit.craftbukkit.inventory.CraftItemStack.unwrap(event.getItem()), direction); // Paper - track changed items in the dispense event; unwrap is safe here because all uses of the stack make their own copies
+ Projectile.Delayed<Projectile> delayedProjectile = Projectile.spawnProjectileUsingShootDelayed(projectile, level, singleItemStack, event.getVelocity().getX(), event.getVelocity().getY(), event.getVelocity().getZ(), this.dispenseConfig.power(), this.dispenseConfig.uncertainty());
+ projectile.projectileSource = new org.bukkit.craftbukkit.projectiles.CraftBlockProjectileSource(source.blockEntity());
+ delayedProjectile.spawn();
+ }
+ if (shrink) dispensed.shrink(1);
+ // CraftBukkit end
@@ -182,7 +182,7 @@
} catch (RejectedExecutionException ignored) {
this.disconnect(Component.translatable("multiplayer.disconnect.server_shutdown"));
} catch (ClassCastException exception) {
@@ -297,6 +_,30 @@
@@ -297,6 +_,47 @@
}
}
@@ -195,7 +195,7 @@
+ *
+ * @see <a href="https://mojira.dev/MC-308621">MC-308621</a>
+ */
+ public void scheduleNettyTask(final Runnable task) {
+ private void scheduleNettyTask(final Runnable task) {
+ final boolean connected = this.isConnected();
+ if (!connected && !this.preparing) {
+ return;
@@ -208,6 +208,23 @@
+ this.flushQueue();
+ }
+ }
+
+ /**
+ * Schedules a task on the channel's event loop after all currently queued packets have been submitted for writing.
+ * This allows the compression packet to be written and compression to be enabled in the same event-loop task.
+ * Vanilla enables compression from a write-future listener, which may run after another packet has already been
+ * queued without compression.
+ *
+ * @see <a href="https://mojira.dev/MC-308621">MC-308621</a>
+ */
+ public void sendPacketAndScheduleNettyTask(final Packet<?> packet, final boolean flush, final Runnable after) {
+ this.scheduleNettyTask(() -> {
+ Connection.this.sendPacket(packet, null, flush);
+ if (after != null) {
+ after.run();
+ }
+ });
+ }
+ // Paper end - Add netty task scheduling helper
+
private void sendPacket(final Packet<?> packet, final @Nullable ChannelFutureListener listener, final boolean flush) {
@@ -143,7 +143,7 @@
}
}
}
@@ -139,21 +_,23 @@
@@ -139,21 +_,25 @@
private void verifyLoginAndFinishConnectionSetup(final GameProfile profile) {
PlayerList playerList = this.server.getPlayerList();
@@ -164,10 +164,12 @@
- );
+ // Paper start - Keep compression packet write and setup in the same Netty task
+ final int compressionThreshold = this.server.getCompressionThreshold();
+ this.connection.scheduleNettyTask(() -> {
+ this.connection.send(new ClientboundLoginCompressionPacket(compressionThreshold));
+ this.connection.setupCompression(compressionThreshold, true);
+ });
+ this.connection.sendPacketAndScheduleNettyTask(
+ new ClientboundLoginCompressionPacket(compressionThreshold), true,
+ () -> {
+ this.connection.setupCompression(compressionThreshold, true);
+ }
+ );
+ // Paper end - Keep compression packet write and setup in the same Netty task
}
@@ -1,11 +1,12 @@
--- a/net/minecraft/world/entity/ai/goal/RemoveBlockGoal.java
+++ b/net/minecraft/world/entity/ai/goal/RemoveBlockGoal.java
@@ -101,6 +_,11 @@
@@ -101,6 +_,12 @@
}
if (this.ticksSinceReachedGoal > 60) {
+ // CraftBukkit start - Step on eggs
+ if (!org.bukkit.craftbukkit.event.CraftEventFactory.callEntityInteractEvent(this.removerMob, org.bukkit.craftbukkit.block.CraftBlock.at(level, eatPos))) {
+ this.ticksSinceReachedGoal++;
+ return;
+ }
+ // CraftBukkit end
@@ -1,9 +1,10 @@
--- a/net/minecraft/world/item/crafting/Ingredient.java
+++ b/net/minecraft/world/item/crafting/Ingredient.java
@@ -33,6 +_,23 @@
@@ -33,6 +_,24 @@
public static final Codec<HolderSet<Item>> NON_AIR_HOLDER_SET_CODEC = HolderSetCodec.create(Registries.ITEM, Item.CODEC, false);
public static final Codec<Ingredient> CODEC = ExtraCodecs.nonEmptyHolderSet(NON_AIR_HOLDER_SET_CODEC).xmap(Ingredient::new, i -> i.values);
public final HolderSet<Item> values;
+ public java.util.function.@org.jspecify.annotations.Nullable Predicate<ItemStack> stackPredicate; // Paper - add PredicateChoice
+ // CraftBukkit start
+ private java.util.@org.jspecify.annotations.Nullable List<ItemStack> itemStacks;
+
@@ -24,10 +25,15 @@
private Ingredient(final HolderSet<Item> values) {
values.unwrap().ifRight(directValues -> {
@@ -62,6 +_,17 @@
@@ -62,6 +_,22 @@
@Override
public boolean test(final ItemStack input) {
+ // Paper start - add PredicateChoice
+ if (this.stackPredicate != null) {
+ return this.stackPredicate.test(input);
+ }
+ // Paper end - add PredicateChoice
+ // CraftBukkit start
+ if (this.isExact()) {
+ for (ItemStack item : this.itemStacks()) {
@@ -4,18 +4,12 @@ import java.util.function.Predicate;
import net.minecraft.world.item.ItemStack;
import org.bukkit.craftbukkit.inventory.CraftItemStack;
import org.bukkit.craftbukkit.inventory.CraftRecipe;
import org.bukkit.inventory.RecipeChoice;
import org.jspecify.annotations.NullMarked;
@NullMarked
public record PaperPotionMix(ItemStack result, Predicate<ItemStack> input, Predicate<ItemStack> ingredient) {
public PaperPotionMix(PotionMix potionMix) {
this(CraftItemStack.asNMSCopy(potionMix.getResult()), convert(potionMix.getInput()), convert(potionMix.getIngredient()));
}
static Predicate<ItemStack> convert(final RecipeChoice choice) {
if (choice instanceof PredicateRecipeChoice predicateRecipeChoice) {
return stack -> predicateRecipeChoice.test(CraftItemStack.asBukkitCopy(stack));
}
return CraftRecipe.toIngredient(choice, true);
public PaperPotionMix(final PotionMix potionMix) {
this(CraftItemStack.asNMSCopy(potionMix.getResult()), CraftRecipe.toIngredient(potionMix.getInput(), true), CraftRecipe.toIngredient(potionMix.getIngredient(), true));
}
}
@@ -6,8 +6,10 @@ import io.papermc.paper.registry.data.util.Conversions;
import io.papermc.paper.registry.set.PaperRegistrySets;
import io.papermc.paper.registry.set.RegistryKeySet;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import java.util.function.Predicate;
import net.minecraft.core.registries.Registries;
import net.minecraft.world.item.crafting.Ingredient;
import org.bukkit.inventory.ItemType;
@@ -32,6 +34,9 @@ public interface CraftRecipe extends Recipe {
stack = Ingredient.of();
} else if (bukkit instanceof final RecipeChoice.ItemTypeChoice itemTypeChoice) {
stack = Ingredient.of(PaperRegistrySets.convertToNms(Registries.ITEM, Conversions.global().lookup(), itemTypeChoice.itemTypes()));
} else if (bukkit instanceof final RecipeChoice.PredicateChoice predicateChoice) {
stack = Ingredient.ofStacks(Collections.singletonList(CraftItemStack.asNMSCopy(predicateChoice.getItemStack())));
stack.stackPredicate = nmsStack -> predicateChoice.test(CraftItemStack.asBukkitCopy(nmsStack));
} else if (bukkit instanceof RecipeChoice.MaterialChoice) {
stack = Ingredient.of(((RecipeChoice.MaterialChoice) bukkit).getChoices().stream().map(CraftItemType::bukkitToMinecraft));
} else if (bukkit instanceof RecipeChoice.ExactChoice) {
@@ -63,13 +68,19 @@ public interface CraftRecipe extends Recipe {
return RecipeChoice.empty(); // Paper - null breaks API contracts
}
if (ingredient.stackPredicate != null) {
net.minecraft.world.item.ItemStack stack = ingredient.itemStacks().iterator().next();
Predicate<org.bukkit.inventory.ItemStack> predicate = bukkitStack -> ingredient.stackPredicate.test(CraftItemStack.asNMSCopy(bukkitStack));
return RecipeChoice.predicateChoice(predicate, CraftItemStack.asBukkitCopy(stack));
}
if (ingredient.isExact()) {
List<org.bukkit.inventory.ItemStack> choices = new ArrayList<>(ingredient.itemStacks().size());
for (net.minecraft.world.item.ItemStack i : ingredient.itemStacks()) {
choices.add(CraftItemStack.asBukkitCopy(i));
}
return new RecipeChoice.ExactChoice(choices);
return RecipeChoice.exactChoice(choices);
} else {
final RegistryKeySet<ItemType> itemTypes = PaperRegistrySets.convertToApi(RegistryKey.ITEM, ingredient.values);
return RecipeChoice.itemType(itemTypes);
@@ -0,0 +1,221 @@
package io.papermc.paper.inventory.recipe;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Predicate;
import net.minecraft.world.entity.player.StackedContents;
import net.minecraft.world.entity.player.StackedItemContents;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
import net.minecraft.world.item.crafting.Ingredient;
import net.minecraft.world.item.crafting.PlacementInfo;
import net.minecraft.world.item.crafting.Recipe;
import org.bukkit.support.environment.VanillaFeature;
import org.junit.jupiter.api.Test;
@VanillaFeature
class PredicateChoiceMatchingTest {
private static Ingredient regular(final net.minecraft.world.item.Item item) {
return Ingredient.of(item);
}
private static Ingredient exact(final ItemStack... stacks) {
return Ingredient.ofStacks(List.of(stacks));
}
private static Ingredient predicate(final Predicate<ItemStack> pred, final ItemStack example) {
final Ingredient ingredient = Ingredient.ofStacks(List.of(example));
ingredient.stackPredicate = pred;
return ingredient;
}
private static ItemStack damaged(final net.minecraft.world.item.Item item, final int damage) {
final ItemStack stack = new ItemStack(item);
stack.setDamageValue(damage);
return stack;
}
private record Outcome(boolean canCraftOne, int biggestStack, int effectiveBatch, List<ItemOrExact> itemsUsed) {}
private static Outcome run(final List<Ingredient> ingredients, final List<ItemStack> inventory) {
final StackedItemContents contents = new StackedItemContents();
final PlacementInfo placementInfo = PlacementInfo.create(ingredients);
final Recipe<?> recipe = mock(Recipe.class);
when(recipe.placementInfo()).thenReturn(placementInfo);
contents.initializeExtras(recipe, null);
for (final ItemStack stack : inventory) {
contents.accountSimpleStack(stack);
}
final boolean canCraftOne = contents.canCraft(recipe, null);
final int biggest = contents.getBiggestCraftableStack(recipe, null);
final List<ItemOrExact> itemsUsed = new ArrayList<>();
int effectiveBatch = 0;
if (biggest > 0) {
contents.canCraft(recipe, biggest, itemsUsed::add);
effectiveBatch = biggest;
for (final ItemOrExact used : itemsUsed) {
effectiveBatch = Math.min(effectiveBatch, used.getMaxStackSize());
}
}
return new Outcome(canCraftOne, biggest, effectiveBatch, itemsUsed);
}
@Test
void regularIngredient_countsNormally() {
final Outcome o = run(List.of(regular(Items.STONE)), List.of(new ItemStack(Items.STONE, 64)));
assertTrue(o.canCraftOne(), "one stone should satisfy a stone ingredient");
assertEquals(64, o.biggestStack(), "64 stone -> 64 craftable");
}
@Test
void exactIngredient_matchesByComponents() {
final ItemStack named = new ItemStack(Items.STONE);
named.setDamageValue(0);
final Outcome o = run(List.of(exact(new ItemStack(Items.DIAMOND, 1))), List.of(new ItemStack(Items.DIAMOND, 5)));
assertTrue(o.canCraftOne());
assertEquals(5, o.biggestStack());
}
@Test
void predicate_singleIngredient_stackableItems() {
final Outcome o = run(
List.of(predicate(s -> s.is(Items.STONE), new ItemStack(Items.STONE))),
List.of(new ItemStack(Items.STONE, 64))
);
assertTrue(o.canCraftOne(), "stone matches the predicate");
assertEquals(64, o.biggestStack(), "64 stackable matching items -> 64 craftable");
assertEquals(64, o.effectiveBatch());
}
@Test
void predicate_distinctNonStackingItems() {
// Two DIFFERENT damaged pickaxes (different components -> distinct Exact keys, maxStack 1).
final Outcome o = run(
List.of(predicate(s -> s.is(Items.DIAMOND_PICKAXE), new ItemStack(Items.DIAMOND_PICKAXE))),
List.of(damaged(Items.DIAMOND_PICKAXE, 10), damaged(Items.DIAMOND_PICKAXE, 20))
);
assertTrue(o.canCraftOne(), "either pickaxe can satisfy the single ingredient");
// Two separate single crafts are possible, but never as one stacked batch (maxStack 1).
assertEquals(1, o.effectiveBatch(), "distinct non-stacking items cannot be crafted as a batch");
}
@Test
void predicate_identicalNonStackingItems_summing() {
// Three IDENTICAL damaged pickaxes: same components -> ONE Exact key with count 3,
// even though they are physically non-stackable (maxStack 1). This is the "it thinks it
// can stack all the matching items together" case.
final Outcome o = run(
List.of(predicate(s -> s.is(Items.DIAMOND_PICKAXE), new ItemStack(Items.DIAMOND_PICKAXE))),
List.of(damaged(Items.DIAMOND_PICKAXE, 100), damaged(Items.DIAMOND_PICKAXE, 100), damaged(Items.DIAMOND_PICKAXE, 100))
);
assertTrue(o.canCraftOne());
// getBiggestCraftableStack ignores per-item max stack size, so it reports 3 ...
assertEquals(3, o.biggestStack(), "raw picker sums the identical items");
// ... but the effective (placeable) batch must clamp to the real max stack size of 1.
assertEquals(1, o.effectiveBatch(), "cannot actually place a batch bigger than maxStackSize");
}
@Test
void predicateAndRegular_shareItem_shouldStillCraft() {
// Shapeless recipe: [ any-stone predicate , stone ]. Two stone in inventory is enough:
// one satisfies the predicate slot, one satisfies the regular slot.
final Outcome o = run(
List.of(predicate(s -> s.is(Items.STONE), new ItemStack(Items.STONE)), regular(Items.STONE)),
List.of(new ItemStack(Items.STONE, 2))
);
assertTrue(o.canCraftOne(), "2 stone should satisfy a predicate-stone + regular-stone recipe");
}
@Test
void predicateAndRegular_singleItem_correctlyUncraftable() {
final Outcome o = run(
List.of(predicate(s -> s.is(Items.STONE), new ItemStack(Items.STONE)), regular(Items.STONE)),
List.of(new ItemStack(Items.STONE, 1))
);
assertFalse(o.canCraftOne(), "one stone cannot fill both a predicate and a regular slot");
}
@Test
void twoPredicates_sharedPool_halveCorrectly() {
final Predicate<ItemStack> isStone = s -> s.is(Items.STONE);
final Outcome o = run(
List.of(predicate(isStone, new ItemStack(Items.STONE)), predicate(isStone, new ItemStack(Items.STONE))),
List.of(new ItemStack(Items.STONE, 64))
);
assertTrue(o.canCraftOne());
assertEquals(32, o.biggestStack(), "64 stone across two ingredient slots -> 32 crafts");
}
@Test
void initialize_clearsStalePredicateIngredients() {
// initialize() rebuilds the extras from a recipe. It clears exactIngredients but must ALSO
// clear predicateIngredients, otherwise a reused extras map (e.g. a cached
// CraftingInput#stackedContents whose resetExtras was skipped because amounts was empty)
// carries a previous recipe's predicates into the next recipe's matching.
final StackedContents<ItemOrExact> raw = new StackedContents<>();
final StackedContentsExtrasMap extras = new StackedContentsExtrasMap(raw);
final Recipe<?> withPredicate = mock(Recipe.class);
when(withPredicate.placementInfo()).thenReturn(PlacementInfo.create(List.of(
predicate(s -> s.is(Items.STONE), new ItemStack(Items.STONE))
)));
extras.initialize(withPredicate);
assertEquals(1, extras.predicateIngredients.size(), "the predicate was registered");
// Re-initialize for a predicate-free recipe, mirroring a reuse where resetExtras did not run.
final Recipe<?> noPredicate = mock(Recipe.class);
when(noPredicate.placementInfo()).thenReturn(PlacementInfo.create(List.of(regular(Items.DIRT))));
extras.initialize(noPredicate);
assertEquals(0, extras.predicateIngredients.size(), "initialize() must drop the stale predicate");
}
@Test
void twoPredicates_notEnough() {
final Predicate<ItemStack> isStone = s -> s.is(Items.STONE);
final Outcome o = run(
List.of(predicate(isStone, new ItemStack(Items.STONE)), predicate(isStone, new ItemStack(Items.STONE))),
List.of(new ItemStack(Items.STONE, 1))
);
assertFalse(o.canCraftOne(), "one stone cannot fill two predicate slots");
}
// ---- lifecycle / leak ----------------------------------------------------------------------
@Test
void predicateIngredients_doNotLeakAcrossReuse() {
// Reuse ONE StackedItemContents across two recipe checks, mirroring how a cached
// CraftingInput#stackedContents() is reused. initialize() clears exactIngredients but not
// predicateIngredients, and resetExtras() is skipped when amounts is empty.
final StackedItemContents contents = new StackedItemContents();
// Recipe 1: predicate matching DIAMOND. No matching items accounted -> amounts stays empty.
final Recipe<?> recipe1 = mock(Recipe.class);
when(recipe1.placementInfo()).thenReturn(PlacementInfo.create(List.of(
predicate(s -> s.is(Items.DIAMOND), new ItemStack(Items.DIAMOND))
)));
contents.initializeExtras(recipe1, null);
contents.resetExtras(); // amounts empty -> this is a no-op, predicate list NOT cleared
contents.clear();
// Recipe 2: a plain regular DIRT recipe, no predicates at all.
final Recipe<?> recipe2 = mock(Recipe.class);
when(recipe2.placementInfo()).thenReturn(PlacementInfo.create(List.of(regular(Items.DIRT))));
contents.initializeExtras(recipe2, null);
// Account a DIAMOND. For recipe2 it should be irrelevant regular content. If the stale
// DIAMOND predicate leaked, the diamond gets mis-routed into an Exact key.
contents.accountSimpleStack(new ItemStack(Items.DIAMOND, 1));
// Recipe2 needs DIRT which we do not have -> must be uncraftable regardless.
assertFalse(contents.canCraft(recipe2, null), "a diamond must not craft a dirt recipe");
}
}
@@ -0,0 +1,804 @@
package io.papermc.paper.inventory.recipe;
import static org.junit.jupiter.api.Assertions.assertEquals;
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.when;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.function.Predicate;
import net.minecraft.core.component.DataComponents;
import net.minecraft.world.SimpleContainer;
import net.minecraft.world.entity.player.Inventory;
import net.minecraft.world.entity.player.PlayerEquipment;
import net.minecraft.world.entity.player.StackedItemContents;
import net.minecraft.world.inventory.RecipeBookMenu;
import net.minecraft.world.inventory.Slot;
import net.minecraft.world.item.Item;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
import net.minecraft.world.item.crafting.AbstractCookingRecipe;
import net.minecraft.world.item.crafting.CraftingRecipe;
import net.minecraft.world.item.crafting.Ingredient;
import net.minecraft.world.item.crafting.PlacementInfo;
import net.minecraft.world.item.crafting.RecipeHolder;
import net.minecraft.world.item.crafting.ShapedRecipe;
import net.minecraft.recipebook.ServerPlaceRecipe;
import org.bukkit.support.environment.VanillaFeature;
import org.junit.jupiter.api.Test;
@VanillaFeature
class RecipeAutofillPlacementTest {
private static final boolean CRAFT_ONE = false;
private static final boolean CRAFT_MAX = true;
// ---- ingredient builders -------------------------------------------------------------------
private static Ingredient regular(final Item item) {
return Ingredient.of(item);
}
private static Ingredient exact(final ItemStack... stacks) {
return Ingredient.ofStacks(List.of(stacks));
}
private static Ingredient predicate(final Predicate<ItemStack> pred, final ItemStack example) {
final Ingredient ingredient = Ingredient.ofStacks(List.of(example));
ingredient.stackPredicate = pred;
return ingredient;
}
private static ItemStack stack(final Item item, final int count) {
return new ItemStack(item, count);
}
private static ItemStack damaged(final Item item, final int damage) {
final ItemStack s = new ItemStack(item);
s.setDamageValue(damage);
return s;
}
private static ItemStack renamed(final Item item, final int count, final String name) {
final ItemStack s = new ItemStack(item, count);
s.set(DataComponents.CUSTOM_NAME, net.minecraft.network.chat.Component.literal(name));
return s;
}
private record PlaceResult(RecipeBookMenu.PostPlaceAction action, List<ItemStack> grid, Inventory inventory) {
boolean placed() {
return this.action == RecipeBookMenu.PostPlaceAction.NOTHING;
}
boolean ghost() {
return this.action == RecipeBookMenu.PostPlaceAction.PLACE_GHOST_RECIPE;
}
int gridCount(final Item item) {
int total = 0;
for (final ItemStack s : this.grid) {
if (s.is(item)) {
total += s.getCount();
}
}
return total;
}
int inventoryCount(final Item item) {
int total = 0;
for (final ItemStack s : this.inventory.getNonEquipmentItems()) {
if (s.is(item)) {
total += s.getCount();
}
}
return total;
}
long filledSlots() {
return this.grid.stream().filter(s -> !s.isEmpty()).count();
}
}
private static PlaceResult autofill(
final List<Ingredient> ingredients, final int gridW, final int gridH,
final List<ItemStack> inventory, final boolean useMaxItems
) {
final CraftingRecipe recipe = mock(CraftingRecipe.class);
when(recipe.placementInfo()).thenReturn(PlacementInfo.create(ingredients));
return place(recipe, gridW, gridH, inventory, useMaxItems, List.of(), false);
}
private static PlaceResult autofillShaped(
final List<Ingredient> ingredients, final int recipeW, final int recipeH,
final int gridW, final int gridH, final List<ItemStack> inventory, final boolean useMaxItems
) {
final ShapedRecipe recipe = mock(ShapedRecipe.class);
when(recipe.getWidth()).thenReturn(recipeW);
when(recipe.getHeight()).thenReturn(recipeH);
when(recipe.placementInfo()).thenReturn(PlacementInfo.create(ingredients));
return place(recipe, gridW, gridH, inventory, useMaxItems, List.of(), false);
}
private static PlaceResult autofillPattern(
final List<Optional<Ingredient>> pattern, final int recipeW, final int recipeH,
final int gridW, final int gridH, final List<ItemStack> inventory, final boolean useMaxItems
) {
final ShapedRecipe recipe = mock(ShapedRecipe.class);
when(recipe.getWidth()).thenReturn(recipeW);
when(recipe.getHeight()).thenReturn(recipeH);
when(recipe.placementInfo()).thenReturn(PlacementInfo.createFromOptionals(pattern));
return place(recipe, gridW, gridH, inventory, useMaxItems, List.of(), false);
}
private static PlaceResult autofillFurnace(
final Ingredient ingredient, final List<ItemStack> inventory, final boolean useMaxItems
) {
final Inventory inv = new Inventory(null, new PlayerEquipment(null));
for (int i = 0; i < inventory.size(); i++) {
inv.setItem(i, inventory.get(i).copy());
}
final SimpleContainer furnace = new SimpleContainer(3); // input=0, fuel=1, result=2
final Slot inputSlot = new Slot(furnace, 0, 0, 0);
final Slot resultSlot = new Slot(furnace, 2, 0, 0);
final List<Slot> clear = List.of(inputSlot, resultSlot);
final AbstractCookingRecipe recipe = mock(AbstractCookingRecipe.class);
when(recipe.placementInfo()).thenReturn(PlacementInfo.create(List.of(ingredient)));
final RecipeHolder<AbstractCookingRecipe> holder = new RecipeHolder<>(null, recipe);
final ServerPlaceRecipe.CraftingMenuAccess<AbstractCookingRecipe> menu = new ServerPlaceRecipe.CraftingMenuAccess<>() {
@Override
public void fillCraftSlotsStackedContents(final StackedItemContents stackedContents) {
if (!inputSlot.getItem().isEmpty()) {
stackedContents.accountSimpleStack(inputSlot.getItem());
}
}
@Override
public void clearCraftingContent() {
clear.forEach(s -> s.set(ItemStack.EMPTY));
}
@Override
public boolean recipeMatches(final RecipeHolder<AbstractCookingRecipe> recipe) {
return false;
}
};
final RecipeBookMenu.PostPlaceAction action = ServerPlaceRecipe.placeRecipe(
menu, 1, 1, List.of(inputSlot), clear, inv, holder, useMaxItems, true
);
return new PlaceResult(action, List.of(inputSlot.getItem()), inv);
}
private static PlaceResult place(
final CraftingRecipe recipe, final int gridW, final int gridH,
final List<ItemStack> inventory, final boolean useMaxItems,
final List<ItemStack> presetGrid, final boolean recipeMatches
) {
final Inventory inv = new Inventory(null, new PlayerEquipment(null));
for (int i = 0; i < inventory.size(); i++) {
inv.setItem(i, inventory.get(i).copy());
}
final SimpleContainer gridContainer = new SimpleContainer(gridW * gridH);
for (int i = 0; i < presetGrid.size(); i++) {
gridContainer.setItem(i, presetGrid.get(i).copy());
}
final List<Slot> slots = new ArrayList<>();
for (int i = 0; i < gridW * gridH; i++) {
slots.add(new Slot(gridContainer, i, 0, 0));
}
final RecipeHolder<CraftingRecipe> holder = new RecipeHolder<>(null, recipe);
final ServerPlaceRecipe.CraftingMenuAccess<CraftingRecipe> menu = new ServerPlaceRecipe.CraftingMenuAccess<>() {
@Override
public void fillCraftSlotsStackedContents(final StackedItemContents stackedContents) {
for (final Slot slot : slots) {
if (!slot.getItem().isEmpty()) {
stackedContents.accountSimpleStack(slot.getItem());
}
}
}
@Override
public void clearCraftingContent() {
gridContainer.clearContent();
}
@Override
public boolean recipeMatches(final RecipeHolder<CraftingRecipe> recipe) {
return recipeMatches;
}
};
final RecipeBookMenu.PostPlaceAction action = ServerPlaceRecipe.placeRecipe(
menu, gridW, gridH, slots, slots, inv, holder, useMaxItems, true
);
final List<ItemStack> gridOut = new ArrayList<>();
for (final Slot slot : slots) {
gridOut.add(slot.getItem());
}
return new PlaceResult(action, gridOut, inv);
}
// ============================================================================================
// Category A — regular ingredients (baseline; must always pass)
// ============================================================================================
@Test
void regular_single_craftOne() {
final PlaceResult r = autofill(List.of(regular(Items.STONE)), 1, 1, List.of(stack(Items.STONE, 64)), CRAFT_ONE);
assertTrue(r.placed());
assertEquals(1, r.gridCount(Items.STONE), "one placed in the grid");
assertEquals(63, r.inventoryCount(Items.STONE), "one consumed from inventory");
}
@Test
void regular_single_craftMax_fillsStack() {
final PlaceResult r = autofill(List.of(regular(Items.STONE)), 1, 1, List.of(stack(Items.STONE, 64)), CRAFT_MAX);
assertTrue(r.placed());
assertEquals(64, r.gridCount(Items.STONE), "whole stack moved into the single grid slot");
assertEquals(0, r.inventoryCount(Items.STONE));
}
@Test
void regular_twoDistinctIngredients_craftMax() {
final PlaceResult r = autofill(
List.of(regular(Items.STONE), regular(Items.DIRT)), 2, 1,
List.of(stack(Items.STONE, 64), stack(Items.DIRT, 64)), CRAFT_MAX
);
assertTrue(r.placed());
assertEquals(64, r.gridCount(Items.STONE));
assertEquals(64, r.gridCount(Items.DIRT));
}
@Test
void regular_sharedPool_twoSlotsSameItem_craftMax_halves() {
// 64 stone across two ingredient slots -> 32 per slot (32 crafts).
final PlaceResult r = autofill(
List.of(regular(Items.STONE), regular(Items.STONE)), 2, 1,
List.of(stack(Items.STONE, 64)), CRAFT_MAX
);
assertTrue(r.placed());
assertEquals(2, r.filledSlots());
assertEquals(32, r.grid.get(0).getCount());
assertEquals(32, r.grid.get(1).getCount());
}
@Test
void regular_notEnough_ghost() {
final PlaceResult r = autofill(
List.of(regular(Items.STONE), regular(Items.DIRT)), 2, 1,
List.of(stack(Items.STONE, 1)), CRAFT_ONE
);
assertTrue(r.ghost(), "missing dirt -> ghost recipe, nothing placed");
assertEquals(0, r.filledSlots());
assertEquals(1, r.inventoryCount(Items.STONE), "inventory untouched");
}
@Test
void regular_nonStackableItem_craftMax_clampsToOne() {
// Undamaged pickaxe is usable for crafting; maxStack 1 -> batch clamps to 1.
final PlaceResult r = autofill(
List.of(regular(Items.DIAMOND_PICKAXE)), 1, 1,
List.of(stack(Items.DIAMOND_PICKAXE, 1), stack(Items.DIAMOND_PICKAXE, 1)), CRAFT_MAX
);
assertTrue(r.placed());
assertEquals(1, r.gridCount(Items.DIAMOND_PICKAXE));
assertEquals(1, r.inventoryCount(Items.DIAMOND_PICKAXE), "only one pickaxe consumed");
}
@Test
void regular_multiStackInventory_craftMax() {
// Stone spread across three inventory slots (64+64+32) -> up to 64 into one grid slot.
final PlaceResult r = autofill(
List.of(regular(Items.STONE)), 1, 1,
List.of(stack(Items.STONE, 64), stack(Items.STONE, 64), stack(Items.STONE, 32)), CRAFT_MAX
);
assertTrue(r.placed());
assertEquals(64, r.gridCount(Items.STONE), "one full stack into the slot");
assertEquals(96, r.inventoryCount(Items.STONE), "160 - 64 remain");
}
@Test
void regular_fullThreeByThree() {
final List<Ingredient> ing = new ArrayList<>();
for (int i = 0; i < 9; i++) {
ing.add(regular(Items.STONE));
}
final PlaceResult r = autofill(ing, 3, 3, List.of(stack(Items.STONE, 9)), CRAFT_ONE);
assertTrue(r.placed());
assertEquals(9, r.filledSlots());
assertEquals(9, r.gridCount(Items.STONE));
}
@Test
void regular_damagedItem_notUsable_ghost() {
// Damaged items are excluded from regular recipe-book crafting.
final PlaceResult r = autofill(
List.of(regular(Items.DIAMOND_PICKAXE)), 1, 1,
List.of(damaged(Items.DIAMOND_PICKAXE, 50)), CRAFT_ONE
);
assertTrue(r.ghost(), "a damaged pickaxe is not usable for a regular ingredient");
}
// ============================================================================================
// Category B — exact ingredients (baseline; user says these already work)
// ============================================================================================
@Test
void exact_matchesOnlySameComponents() {
final ItemStack named = renamed(Items.DIAMOND, 1, "Special");
final PlaceResult r = autofill(
List.of(exact(named.copy())), 1, 1,
List.of(stack(Items.DIAMOND, 5), named.copy()), CRAFT_MAX
);
assertTrue(r.placed());
assertEquals(1, r.gridCount(Items.DIAMOND), "only the named diamond matches; maxStack irrelevant here");
// The plain diamonds must be left untouched.
assertTrue(r.grid.get(0).has(DataComponents.CUSTOM_NAME), "the exact (named) item was placed");
}
@Test
void exact_stackable_craftMax() {
final PlaceResult r = autofill(
List.of(exact(stack(Items.DIAMOND, 1))), 1, 1,
List.of(stack(Items.DIAMOND, 40)), CRAFT_MAX
);
assertTrue(r.placed());
assertEquals(40, r.gridCount(Items.DIAMOND));
}
@Test
void exact_multipleAllowedStacks() {
final PlaceResult r = autofill(
List.of(exact(stack(Items.DIAMOND, 1), stack(Items.EMERALD, 1))), 1, 1,
List.of(stack(Items.EMERALD, 3)), CRAFT_MAX
);
assertTrue(r.placed());
assertEquals(3, r.gridCount(Items.EMERALD));
}
@Test
void exact_nonStackable_craftMax_clampsToOne() {
final ItemStack tool = damaged(Items.DIAMOND_PICKAXE, 7);
final PlaceResult r = autofill(
List.of(exact(tool.copy())), 1, 1,
List.of(tool.copy(), tool.copy()), CRAFT_MAX
);
assertTrue(r.placed());
assertEquals(1, r.gridCount(Items.DIAMOND_PICKAXE), "non-stackable exact -> one per slot");
assertEquals(1, r.inventoryCount(Items.DIAMOND_PICKAXE));
}
@Test
void exact_twoSlotsSharedPool_craftMax_halves() {
final PlaceResult r = autofill(
List.of(exact(stack(Items.DIAMOND, 1)), exact(stack(Items.DIAMOND, 1))), 2, 1,
List.of(stack(Items.DIAMOND, 10)), CRAFT_MAX
);
assertTrue(r.placed());
assertEquals(5, r.grid.get(0).getCount());
assertEquals(5, r.grid.get(1).getCount());
}
@Test
void exact_notPresent_ghost() {
final PlaceResult r = autofill(
List.of(exact(renamed(Items.DIAMOND, 1, "Special"))), 1, 1,
List.of(stack(Items.DIAMOND, 5)), CRAFT_ONE
);
assertTrue(r.ghost(), "plain diamonds do not satisfy an exact named-diamond ingredient");
}
// ============================================================================================
// Category C — mixed regular + exact (baseline)
// ============================================================================================
@Test
void mixed_regularAndExact_distinctItems() {
final PlaceResult r = autofill(
List.of(regular(Items.STICK), exact(stack(Items.DIAMOND, 1))), 2, 1,
List.of(stack(Items.STICK, 8), stack(Items.DIAMOND, 8)), CRAFT_MAX
);
assertTrue(r.placed());
assertEquals(8, r.gridCount(Items.STICK));
assertEquals(8, r.gridCount(Items.DIAMOND));
}
// ============================================================================================
// Category D — predicate ingredients (bug-hunting; document current behaviour)
// ============================================================================================
@Test
void predicate_single_stackable_craftMax() {
final PlaceResult r = autofill(
List.of(predicate(s -> s.is(Items.STONE), stack(Items.STONE, 1))), 1, 1,
List.of(stack(Items.STONE, 64)), CRAFT_MAX
);
assertTrue(r.placed());
assertEquals(64, r.gridCount(Items.STONE));
}
@Test
void predicate_single_craftOne_pullsMatchingItem() {
final PlaceResult r = autofill(
List.of(predicate(s -> s.is(Items.DIAMOND_PICKAXE), stack(Items.DIAMOND_PICKAXE, 1))), 1, 1,
List.of(damaged(Items.DIAMOND_PICKAXE, 10)), CRAFT_ONE
);
assertTrue(r.placed(), "the damaged pickaxe satisfies the predicate even though it is not usable for regular crafting");
assertEquals(1, r.gridCount(Items.DIAMOND_PICKAXE));
assertEquals(0, r.inventoryCount(Items.DIAMOND_PICKAXE));
}
@Test
void predicate_distinctNonStacking_craftMax_placesOne() {
final PlaceResult r = autofill(
List.of(predicate(s -> s.is(Items.DIAMOND_PICKAXE), stack(Items.DIAMOND_PICKAXE, 1))), 1, 1,
List.of(damaged(Items.DIAMOND_PICKAXE, 10), damaged(Items.DIAMOND_PICKAXE, 20)), CRAFT_MAX
);
assertTrue(r.placed());
assertEquals(1, r.gridCount(Items.DIAMOND_PICKAXE), "distinct non-stacking items cannot be batched");
assertEquals(1, r.inventoryCount(Items.DIAMOND_PICKAXE), "only one consumed");
}
@Test
void predicate_identicalNonStacking_craftMax_clampsToOne() {
// Same components (same damage) -> one Exact key summing to 3, but maxStack 1 clamps the batch.
final PlaceResult r = autofill(
List.of(predicate(s -> s.is(Items.DIAMOND_PICKAXE), stack(Items.DIAMOND_PICKAXE, 1))), 1, 1,
List.of(damaged(Items.DIAMOND_PICKAXE, 100), damaged(Items.DIAMOND_PICKAXE, 100), damaged(Items.DIAMOND_PICKAXE, 100)),
CRAFT_MAX
);
assertTrue(r.placed());
assertEquals(1, r.gridCount(Items.DIAMOND_PICKAXE), "clamped to a single non-stackable item");
assertEquals(2, r.inventoryCount(Items.DIAMOND_PICKAXE));
}
@Test
void predicate_matchesMultipleItems_placesSomethingValid() {
// Predicate matches any of several stackable items; whichever is chosen must be placed.
final PlaceResult r = autofill(
List.of(predicate(s -> s.is(Items.OAK_PLANKS) || s.is(Items.BIRCH_PLANKS), stack(Items.OAK_PLANKS, 1))), 1, 1,
List.of(stack(Items.BIRCH_PLANKS, 5)), CRAFT_MAX
);
assertTrue(r.placed());
assertEquals(5, r.gridCount(Items.BIRCH_PLANKS), "the matching birch planks were placed");
}
@Test
void predicate_twoSlots_sharedPool_craftMax_halves() {
final Predicate<ItemStack> isStone = s -> s.is(Items.STONE);
final PlaceResult r = autofill(
List.of(predicate(isStone, stack(Items.STONE, 1)), predicate(isStone, stack(Items.STONE, 1))), 2, 1,
List.of(stack(Items.STONE, 64)), CRAFT_MAX
);
assertTrue(r.placed());
assertEquals(32, r.grid.get(0).getCount());
assertEquals(32, r.grid.get(1).getCount());
}
@Test
void predicate_notEnough_ghost() {
final Predicate<ItemStack> isStone = s -> s.is(Items.STONE);
final PlaceResult r = autofill(
List.of(predicate(isStone, stack(Items.STONE, 1)), predicate(isStone, stack(Items.STONE, 1))), 2, 1,
List.of(stack(Items.STONE, 1)), CRAFT_ONE
);
assertTrue(r.ghost(), "one stone cannot fill two predicate slots");
}
// ---- known bugs (kept disabled so the suite stays green; flip when the algorithm is fixed) --
@Test
void predicateAndRegular_shareItem_shouldPlace() {
final PlaceResult r = autofill(
List.of(predicate(s -> s.is(Items.STONE), stack(Items.STONE, 1)), regular(Items.STONE)), 2, 1,
List.of(stack(Items.STONE, 2)), CRAFT_ONE
);
assertTrue(r.placed(), "2 stone should satisfy predicate-stone + regular-stone");
assertEquals(2, r.gridCount(Items.STONE));
}
// ============================================================================================
// Category E — more mixed combinations
// ============================================================================================
@Test
void predicateAndExact_distinctItems() {
final PlaceResult r = autofill(
List.of(predicate(s -> s.is(Items.STONE), stack(Items.STONE, 1)), exact(stack(Items.DIAMOND, 1))), 2, 1,
List.of(stack(Items.STONE, 8), stack(Items.DIAMOND, 8)), CRAFT_MAX
);
assertTrue(r.placed());
assertEquals(8, r.gridCount(Items.STONE));
assertEquals(8, r.gridCount(Items.DIAMOND));
}
@Test
void predicate_ignoresNonMatchingInventory() {
// Predicate matches only stone; unrelated dirt in the inventory must be left alone.
final PlaceResult r = autofill(
List.of(predicate(s -> s.is(Items.STONE), stack(Items.STONE, 1))), 1, 1,
List.of(stack(Items.DIRT, 64), stack(Items.STONE, 3)), CRAFT_MAX
);
assertTrue(r.placed());
assertEquals(3, r.gridCount(Items.STONE));
assertEquals(64, r.inventoryCount(Items.DIRT), "non-matching dirt untouched");
}
@Test
void exact_leavesNonMatchingComponentsUntouched() {
final ItemStack special = renamed(Items.DIAMOND, 1, "Special");
final PlaceResult r = autofill(
List.of(exact(special.copy())), 1, 1,
List.of(stack(Items.DIAMOND, 10), special.copy()), CRAFT_MAX
);
assertTrue(r.placed());
assertEquals(1, r.gridCount(Items.DIAMOND), "only the named diamond was placed");
assertEquals(10, r.inventoryCount(Items.DIAMOND), "the 10 plain diamonds are untouched");
}
// ============================================================================================
// Category F — edge cases
// ============================================================================================
@Test
void emptyInventory_ghost() {
final PlaceResult r = autofill(List.of(regular(Items.STONE)), 1, 1, List.of(), CRAFT_ONE);
assertTrue(r.ghost());
assertEquals(0, r.filledSlots());
}
@Test
void fewerIngredientsThanGrid_leavesRemainderEmpty() {
final PlaceResult r = autofill(
List.of(regular(Items.STONE), regular(Items.DIRT)), 3, 3,
List.of(stack(Items.STONE, 1), stack(Items.DIRT, 1)), CRAFT_ONE
);
assertTrue(r.placed());
assertEquals(2, r.filledSlots(), "only two slots filled in a 3x3 grid");
}
@Test
void customMaxStackSizeComponent_regular_clampsBatch() {
// A stone whose stack was given a MAX_STACK_SIZE=16 component. As a regular (Item-key)
// ingredient the picker uses the item's DEFAULT max stack; this test locks in current
// behaviour so a change to that assumption is caught.
final ItemStack capped = stack(Items.STONE, 16);
capped.set(DataComponents.MAX_STACK_SIZE, 16);
final PlaceResult r = autofill(List.of(regular(Items.STONE)), 1, 1, List.of(capped), CRAFT_MAX);
assertTrue(r.placed());
assertEquals(16, r.gridCount(Items.STONE), "all 16 available were placed");
}
// ============================================================================================
// Category G — shaped-recipe centering (exercises the ShapedRecipe branch of PlaceRecipeHelper)
// ============================================================================================
@Test
void shaped_twoWide_centeredInThreeByThree() {
// A 2x1 recipe placed into a 3x3 grid: PlaceRecipeHelper centers it vertically (row 1).
final PlaceResult r = autofillShaped(
List.of(regular(Items.STONE), regular(Items.DIRT)), 2, 1, 3, 3,
List.of(stack(Items.STONE, 1), stack(Items.DIRT, 1)), CRAFT_ONE
);
assertTrue(r.placed());
assertEquals(2, r.filledSlots());
assertEquals(1, r.gridCount(Items.STONE));
assertEquals(1, r.gridCount(Items.DIRT));
// Centered vertically onto the middle row (slots 3 & 4), not the top-left.
assertTrue(r.grid.get(0).isEmpty(), "top-left slot should be empty after centering");
assertSame(Items.STONE, r.grid.get(3).getItem());
assertSame(Items.DIRT, r.grid.get(4).getItem());
}
@Test
void shaped_fullThreeByThree_noCentering() {
final List<Ingredient> ing = new ArrayList<>();
for (int i = 0; i < 9; i++) {
ing.add(regular(Items.STONE));
}
final PlaceResult r = autofillShaped(ing, 3, 3, 3, 3, List.of(stack(Items.STONE, 9)), CRAFT_ONE);
assertTrue(r.placed());
assertEquals(9, r.filledSlots());
}
// ============================================================================================
// Category H — top-up path (recipe already placed in the grid, recipeMatches == true)
// ============================================================================================
@Test
void topUp_existingGrid_addsOneMore() {
// Grid already holds 1 stone in each of two slots; a matching craft-one should top each to 2.
final CraftingRecipe recipe = mock(CraftingRecipe.class);
when(recipe.placementInfo()).thenReturn(PlacementInfo.create(List.of(regular(Items.STONE), regular(Items.STONE))));
final PlaceResult r = place(
recipe, 2, 1, List.of(stack(Items.STONE, 64)), CRAFT_ONE,
List.of(stack(Items.STONE, 1), stack(Items.STONE, 1)), true
);
assertTrue(r.placed());
assertEquals(2, r.grid.get(0).getCount(), "topped up to 2");
assertEquals(2, r.grid.get(1).getCount());
}
// ============================================================================================
// Category I — shaped recipes WITH gaps (L-shapes, tool shapes, ...)
// ============================================================================================
@Test
void shapedWithGap_placesIntoCorrectCells_leavesGapEmpty() {
// 2x2 recipe box, bottom-right cell is a gap: [A B / C .]
final List<Optional<Ingredient>> pattern = List.of(
Optional.of(regular(Items.STONE)), // (0,0)
Optional.of(regular(Items.DIRT)), // (1,0)
Optional.of(regular(Items.DIAMOND)), // (0,1)
Optional.empty() // (1,1) gap
);
final PlaceResult r = autofillPattern(
pattern, 2, 2, 3, 3,
List.of(stack(Items.STONE, 1), stack(Items.DIRT, 1), stack(Items.DIAMOND, 1)), CRAFT_ONE
);
assertTrue(r.placed());
assertEquals(3, r.filledSlots(), "only the three non-gap cells are filled");
// 2x2 box is not centered in 3x3, so it maps to grid slots 0,1 (top row) and 3,4 (next row).
assertSame(Items.STONE, r.grid.get(0).getItem());
assertSame(Items.DIRT, r.grid.get(1).getItem());
assertSame(Items.DIAMOND, r.grid.get(3).getItem());
assertTrue(r.grid.get(4).isEmpty(), "the gap cell (slot 4) stays empty");
}
@Test
void shapedWithGap_notEnoughForOneIngredient_ghost() {
final List<Optional<Ingredient>> pattern = List.of(
Optional.of(regular(Items.STONE)),
Optional.empty(),
Optional.of(regular(Items.DIRT))
);
// 3x1 box [stone, gap, dirt] but no dirt in inventory.
final PlaceResult r = autofillPattern(pattern, 3, 1, 3, 3, List.of(stack(Items.STONE, 5)), CRAFT_ONE);
assertTrue(r.ghost());
}
// ============================================================================================
// Category J — furnace / smoker / blast-furnace recipe book (AbstractFurnaceMenu)
// ============================================================================================
@Test
void furnace_regular_craftOne() {
final PlaceResult r = autofillFurnace(regular(Items.RAW_IRON), List.of(stack(Items.RAW_IRON, 10)), CRAFT_ONE);
assertTrue(r.placed());
assertEquals(1, r.gridCount(Items.RAW_IRON), "one raw iron placed into the input slot");
assertEquals(9, r.inventoryCount(Items.RAW_IRON));
}
@Test
void furnace_regular_craftMax_fillsInputSlot() {
final PlaceResult r = autofillFurnace(regular(Items.RAW_IRON), List.of(stack(Items.RAW_IRON, 64)), CRAFT_MAX);
assertTrue(r.placed());
assertEquals(64, r.gridCount(Items.RAW_IRON), "whole stack moved into the furnace input");
}
@Test
void furnace_exactIngredient() {
final ItemStack special = renamed(Items.RAW_IRON, 1, "Ore");
final PlaceResult r = autofillFurnace(exact(special.copy()), List.of(stack(Items.RAW_IRON, 5), special.copy()), CRAFT_MAX);
assertTrue(r.placed());
assertEquals(1, r.gridCount(Items.RAW_IRON), "only the exact named ore matches");
assertTrue(r.grid.get(0).has(DataComponents.CUSTOM_NAME));
}
@Test
void furnace_predicateIngredient() {
final PlaceResult r = autofillFurnace(
predicate(s -> s.is(Items.RAW_IRON), stack(Items.RAW_IRON, 1)),
List.of(stack(Items.RAW_IRON, 3)), CRAFT_MAX
);
assertTrue(r.placed());
assertEquals(3, r.gridCount(Items.RAW_IRON));
}
@Test
void furnace_notEnough_ghost() {
final PlaceResult r = autofillFurnace(regular(Items.RAW_IRON), List.of(), CRAFT_ONE);
assertTrue(r.ghost());
assertEquals(0, r.filledSlots());
}
// ============================================================================================
// Category K — predicate/exact combined with every other choice type (regression for the fix)
// ============================================================================================
@Test
void exactAndRegular_shareItem_shouldPlace() {
// The exact-choice analog of the predicate bug: a plain item routed to the Exact pool
// (because it matches an exact ingredient) must still satisfy a regular ingredient too.
final PlaceResult r = autofill(
List.of(exact(stack(Items.STONE, 1)), regular(Items.STONE)), 2, 1,
List.of(stack(Items.STONE, 2)), CRAFT_ONE
);
assertTrue(r.placed(), "2 stone should satisfy exact-stone + regular-stone");
assertEquals(2, r.gridCount(Items.STONE));
}
@Test
void predicateAndRegular_shareItem_craftMax() {
final PlaceResult r = autofill(
List.of(predicate(s -> s.is(Items.STONE), stack(Items.STONE, 1)), regular(Items.STONE)), 2, 1,
List.of(stack(Items.STONE, 64)), CRAFT_MAX
);
assertTrue(r.placed());
assertEquals(32, r.grid.get(0).getCount(), "64 stone / 2 slots -> 32 per slot");
assertEquals(32, r.grid.get(1).getCount());
}
@Test
void predicateAndExact_overlappingItem() {
// A plain diamond matches both the predicate and the exact ingredient; it is pooled once
// (Exact) and shared by both slots. Two diamonds -> one craft.
final PlaceResult r = autofill(
List.of(predicate(s -> s.is(Items.DIAMOND), stack(Items.DIAMOND, 1)), exact(stack(Items.DIAMOND, 1))), 2, 1,
List.of(stack(Items.DIAMOND, 2)), CRAFT_ONE
);
assertTrue(r.placed());
assertEquals(2, r.gridCount(Items.DIAMOND));
}
@Test
void predicateAndExact_overlappingItem_onlyOne_ghost() {
final PlaceResult r = autofill(
List.of(predicate(s -> s.is(Items.DIAMOND), stack(Items.DIAMOND, 1)), exact(stack(Items.DIAMOND, 1))), 2, 1,
List.of(stack(Items.DIAMOND, 1)), CRAFT_ONE
);
assertTrue(r.ghost(), "one diamond cannot fill both overlapping slots");
}
@Test
void predicateAndRegular_renamedItem_regularCannotUseIt() {
// A renamed stone matches the predicate but is NOT usable for the regular ingredient. With a
// plain stone available for the regular slot, the recipe still crafts (predicate<-renamed,
// regular<-plain).
final PlaceResult r = autofill(
List.of(predicate(s -> s.is(Items.STONE), stack(Items.STONE, 1)), regular(Items.STONE)), 2, 1,
List.of(renamed(Items.STONE, 1, "Fancy"), stack(Items.STONE, 1)), CRAFT_ONE
);
assertTrue(r.placed());
assertEquals(2, r.filledSlots());
assertEquals(2, r.gridCount(Items.STONE));
}
@Test
void predicateAndRegular_onlyRenamedItem_ghost() {
// Only a renamed stone (matches predicate but not usable for the regular slot) -> cannot fill
// both slots.
final PlaceResult r = autofill(
List.of(predicate(s -> s.is(Items.STONE), stack(Items.STONE, 1)), regular(Items.STONE)), 2, 1,
List.of(renamed(Items.STONE, 1, "Fancy")), CRAFT_ONE
);
assertTrue(r.ghost(), "a renamed stone cannot satisfy the regular slot");
}
@Test
void threeWayMix_predicateExactRegular() {
final ItemStack named = renamed(Items.DIAMOND, 1, "Gem");
final PlaceResult r = autofill(
List.of(
predicate(s -> s.is(Items.STONE), stack(Items.STONE, 1)),
exact(named.copy()),
regular(Items.STICK)
), 3, 1,
List.of(stack(Items.STONE, 1), named.copy(), stack(Items.STICK, 1)), CRAFT_ONE
);
assertTrue(r.placed());
assertEquals(1, r.gridCount(Items.STONE));
assertEquals(1, r.gridCount(Items.DIAMOND));
assertEquals(1, r.gridCount(Items.STICK));
}
}
@@ -0,0 +1,194 @@
package io.papermc.paper.inventory.recipe;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.List;
import java.util.function.Predicate;
import net.minecraft.core.component.DataComponents;
import net.minecraft.world.item.Item;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.ItemStackTemplate;
import net.minecraft.world.item.Items;
import net.minecraft.world.item.crafting.CraftingBookCategory;
import net.minecraft.world.item.crafting.CraftingInput;
import net.minecraft.world.item.crafting.CraftingRecipe;
import net.minecraft.world.item.crafting.Ingredient;
import net.minecraft.world.item.crafting.Recipe;
import net.minecraft.world.item.crafting.ShapelessRecipe;
import org.bukkit.support.environment.VanillaFeature;
import org.junit.jupiter.api.Test;
/**
* Tests the <b>actual shapeless crafting</b> path: {@link ShapelessRecipe#matches} over a real
* {@link ShapelessRecipe}. This exercises a different accounting route than the recipe-book placer
* ({@code CraftingInput} ctor + {@code StackedContentsExtrasMap#accountInput}/{@code regularRemoved}),
* plus the single-ingredient {@code Ingredient#test} shortcut. The same {@link ShapelessRecipe#matches}
* is what the Crafter block and normal crafting menus use to decide whether a grid produces a result.
*
* <p>Regular/exact ingredients are the must-pass baseline; predicate + combination cases guard the
* predicate integration fix.
*/
@VanillaFeature
class ShapelessRecipeMatchTest {
private static Ingredient regular(final Item item) {
return Ingredient.of(item);
}
private static Ingredient exact(final ItemStack... stacks) {
return Ingredient.ofStacks(List.of(stacks));
}
private static Ingredient predicate(final Predicate<ItemStack> pred, final ItemStack example) {
final Ingredient ingredient = Ingredient.ofStacks(List.of(example));
ingredient.stackPredicate = pred;
return ingredient;
}
private static ItemStack renamed(final Item item, final String name) {
final ItemStack s = new ItemStack(item);
s.set(DataComponents.CUSTOM_NAME, net.minecraft.network.chat.Component.literal(name));
return s;
}
private static ShapelessRecipe shapeless(final Ingredient... ingredients) {
return new ShapelessRecipe(
new Recipe.CommonInfo(true),
new CraftingRecipe.CraftingBookInfo(CraftingBookCategory.MISC, ""),
new ItemStackTemplate(Items.DIAMOND), // result is irrelevant to matches()
List.of(ingredients)
);
}
/** Runs the real matches() over a grid sized to hold exactly the given non-empty items. */
private static boolean matches(final ShapelessRecipe recipe, final ItemStack... grid) {
final CraftingInput input = CraftingInput.of(grid.length, 1, List.of(grid));
return recipe.matches(input, null); // shapeless matching ignores the level
}
// ---- regular ingredients (baseline) --------------------------------------------------------
@Test
void regular_exactMatch() {
assertTrue(matches(shapeless(regular(Items.STONE), regular(Items.DIRT)),
new ItemStack(Items.STONE), new ItemStack(Items.DIRT)));
}
@Test
void regular_orderIndependent() {
// Shapeless: item order in the grid must not matter.
assertTrue(matches(shapeless(regular(Items.STONE), regular(Items.DIRT)),
new ItemStack(Items.DIRT), new ItemStack(Items.STONE)));
}
@Test
void regular_wrongItem_noMatch() {
assertFalse(matches(shapeless(regular(Items.STONE), regular(Items.DIRT)),
new ItemStack(Items.STONE), new ItemStack(Items.COBBLESTONE)));
}
@Test
void regular_missingItem_noMatch() {
assertFalse(matches(shapeless(regular(Items.STONE), regular(Items.DIRT)),
new ItemStack(Items.STONE), new ItemStack(Items.STONE)));
}
@Test
void regular_extraItem_noMatch() {
// Three grid items for a two-ingredient recipe -> ingredientCount mismatch.
assertFalse(matches(shapeless(regular(Items.STONE), regular(Items.DIRT)),
new ItemStack(Items.STONE), new ItemStack(Items.DIRT), new ItemStack(Items.DIAMOND)));
}
@Test
void regular_duplicateIngredient() {
assertTrue(matches(shapeless(regular(Items.STONE), regular(Items.STONE)),
new ItemStack(Items.STONE), new ItemStack(Items.STONE)));
assertFalse(matches(shapeless(regular(Items.STONE), regular(Items.STONE)),
new ItemStack(Items.STONE), new ItemStack(Items.DIRT)));
}
@Test
void regular_singleIngredientShortcut() {
assertTrue(matches(shapeless(regular(Items.STONE)), new ItemStack(Items.STONE)));
assertFalse(matches(shapeless(regular(Items.STONE)), new ItemStack(Items.DIRT)));
}
// ---- exact ingredients ---------------------------------------------------------------------
@Test
void exact_matchesOnlySameComponents() {
assertTrue(matches(shapeless(exact(renamed(Items.DIAMOND, "Special"))), renamed(Items.DIAMOND, "Special")));
assertFalse(matches(shapeless(exact(renamed(Items.DIAMOND, "Special"))), new ItemStack(Items.DIAMOND)));
}
@Test
void exact_plusRegular() {
assertTrue(matches(shapeless(exact(renamed(Items.DIAMOND, "Special")), regular(Items.STONE)),
renamed(Items.DIAMOND, "Special"), new ItemStack(Items.STONE)));
}
// ---- predicate ingredients -----------------------------------------------------------------
@Test
void predicate_singleShortcut() {
assertTrue(matches(shapeless(predicate(s -> s.is(Items.STONE), new ItemStack(Items.STONE))), new ItemStack(Items.STONE)));
assertFalse(matches(shapeless(predicate(s -> s.is(Items.STONE), new ItemStack(Items.STONE))), new ItemStack(Items.DIRT)));
}
@Test
void predicate_twoPredicates() {
final Predicate<ItemStack> isStone = s -> s.is(Items.STONE);
assertTrue(matches(shapeless(predicate(isStone, new ItemStack(Items.STONE)), predicate(isStone, new ItemStack(Items.STONE))),
new ItemStack(Items.STONE), new ItemStack(Items.STONE)));
}
@Test
void predicate_plusRegular_shareItem() {
// The fix: grid accounting routes both stone to the Exact pool; the regular stone ingredient
// must still be satisfiable. (This is the actual-crafting analog of the recipe-book bug.)
assertTrue(matches(shapeless(predicate(s -> s.is(Items.STONE), new ItemStack(Items.STONE)), regular(Items.STONE)),
new ItemStack(Items.STONE), new ItemStack(Items.STONE)));
}
@Test
void predicate_plusRegular_distinctItems_orderIndependent() {
final ShapelessRecipe recipe = shapeless(predicate(s -> s.is(Items.DIAMOND), new ItemStack(Items.DIAMOND)), regular(Items.STONE));
assertTrue(matches(recipe, new ItemStack(Items.DIAMOND), new ItemStack(Items.STONE)));
assertTrue(matches(recipe, new ItemStack(Items.STONE), new ItemStack(Items.DIAMOND)));
}
@Test
void predicate_plusRegular_noMatchingRegularItem() {
// grid stone + dirt: dirt matches neither ingredient -> no match.
assertFalse(matches(shapeless(predicate(s -> s.is(Items.STONE), new ItemStack(Items.STONE)), regular(Items.STONE)),
new ItemStack(Items.STONE), new ItemStack(Items.DIRT)));
}
@Test
void predicate_plusExact() {
assertTrue(matches(shapeless(predicate(s -> s.is(Items.DIAMOND), new ItemStack(Items.DIAMOND)), exact(renamed(Items.EMERALD, "Gem"))),
new ItemStack(Items.DIAMOND), renamed(Items.EMERALD, "Gem")));
}
@Test
void predicate_plusRegular_renamedSatisfiesPredicateOnly() {
// renamed stone -> predicate slot; plain stone -> regular slot. Order-independent.
final ShapelessRecipe recipe = shapeless(predicate(s -> s.is(Items.STONE), new ItemStack(Items.STONE)), regular(Items.STONE));
assertTrue(matches(recipe, renamed(Items.STONE, "Fancy"), new ItemStack(Items.STONE)));
// Two renamed stones: the regular slot cannot use a renamed (non-usable) stone -> no match.
assertFalse(matches(recipe, renamed(Items.STONE, "Fancy"), renamed(Items.STONE, "Fancy2")));
}
@Test
void threeWayMix_predicateExactRegular() {
final ShapelessRecipe recipe = shapeless(
predicate(s -> s.is(Items.STONE), new ItemStack(Items.STONE)),
exact(renamed(Items.DIAMOND, "Gem")),
regular(Items.STICK)
);
assertTrue(matches(recipe, new ItemStack(Items.STICK), renamed(Items.DIAMOND, "Gem"), new ItemStack(Items.STONE)));
assertFalse(matches(recipe, new ItemStack(Items.STICK), new ItemStack(Items.DIAMOND), new ItemStack(Items.STONE)));
}
}