Author SHA1 Message Date
feldenr baa1972289 fix(create): diamond dupe via mixer ignoring ingredient count
Create 6.x deserializes item ingredients of processing recipes with
plain Ingredient.CODEC, silently ignoring the "count" field. The
mixing recipe (9 shards -> 1 diamond, written as one ingredient with
count: 9) only consumed 1 shard per diamond; combined with
crushing/milling (1 diamond -> ~6 shards) this created an infinite
diamond duplication loop.

- List the shard 9 times as single-item ingredients (BasinRecipe
  consumes exactly 1 item per ingredient entry)
- Add CreateCompatRecipesTest guarding the invariants: 9 shards per
  diamond in mixing+crafting, and crushing/milling expected yield
  stays below 9 shards per diamond (loop can never be net-positive)
2026-07-20 23:57:21 +02:00
2 changed files with 165 additions and 3 deletions
@@ -8,8 +8,31 @@
"type": "create:mixing",
"ingredients": [
{
"item": "custom_ore_gen:diamondshard",
"count": 9
"item": "custom_ore_gen:diamondshard"
},
{
"item": "custom_ore_gen:diamondshard"
},
{
"item": "custom_ore_gen:diamondshard"
},
{
"item": "custom_ore_gen:diamondshard"
},
{
"item": "custom_ore_gen:diamondshard"
},
{
"item": "custom_ore_gen:diamondshard"
},
{
"item": "custom_ore_gen:diamondshard"
},
{
"item": "custom_ore_gen:diamondshard"
},
{
"item": "custom_ore_gen:diamondshard"
}
],
"results": [
@@ -19,4 +42,4 @@
}
],
"processing_time": 100
}
}
@@ -0,0 +1,139 @@
package net.mcreator.customoregen;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import static org.junit.jupiter.api.Assertions.*;
/**
* Regression tests for the Create mod compatibility recipes (diamond shard cycle).
*
* Background: Create 6.x deserializes ITEM ingredients of processing recipes with
* plain {@code Ingredient.CODEC} (see ProcessingRecipeParams), which silently
* IGNORES the "count" field. A mixing recipe written as a single ingredient with
* "count": 9 was therefore consuming only 1 shard per diamond. Combined with the
* crushing/milling recipes (1 diamond -> ~6 shards), this created an infinite
* diamond duplication loop.
*
* Fix: the mixing recipe must list the shard 9 times as separate single-item
* ingredients (BasinRecipe consumes exactly 1 item per ingredient entry).
*
* These tests guard the invariant: SHARDS_PER_DIAMOND = 9 everywhere, and the
* expected yield of diamond -> shards processing must stay BELOW 9 so the cycle
* can never be net-positive.
*/
@DisplayName("Create Compat Recipes Tests")
class CreateCompatRecipesTest {
private static final int SHARDS_PER_DIAMOND = 9;
private static final String SHARD_ITEM = "custom_ore_gen:diamondshard";
private static final String DIAMOND_ITEM = "minecraft:diamond";
private static final String MIXING_RECIPE = "/data/create/recipe/mixing/diamond_shard_to_diamond.json";
private static final String CRUSHING_RECIPE = "/data/create/recipe/crushing/diamond_to_shards.json";
private static final String MILLING_RECIPE = "/data/create/recipe/milling/diamond_to_shards.json";
private static final String CRAFTING_RECIPE = "/data/custom_ore_gen/recipe/diamondshardtodiamond.json";
private static JsonObject loadRecipe(String path) {
InputStream stream = CreateCompatRecipesTest.class.getResourceAsStream(path);
assertNotNull(stream, "Recipe resource missing from classpath: " + path);
return JsonParser.parseReader(new InputStreamReader(stream, StandardCharsets.UTF_8)).getAsJsonObject();
}
private static double expectedShardYield(JsonObject recipe) {
double yield = 0.0;
for (JsonElement element : recipe.getAsJsonArray("results")) {
JsonObject result = element.getAsJsonObject();
if (!result.has("id") || !SHARD_ITEM.equals(result.get("id").getAsString())) {
continue;
}
int count = result.has("count") ? result.get("count").getAsInt() : 1;
double chance = result.has("chance") ? result.get("chance").getAsDouble() : 1.0;
yield += count * chance;
}
return yield;
}
@Test
@DisplayName("Mixing recipe consumes exactly 9 shards as 9 single-item ingredients")
void testMixingRecipe_ShouldConsumeNineShardsViaNineEntries() {
JsonObject recipe = loadRecipe(MIXING_RECIPE);
assertEquals("create:mixing", recipe.get("type").getAsString());
JsonArray ingredients = recipe.getAsJsonArray("ingredients");
assertNotNull(ingredients, "Mixing recipe must declare ingredients");
assertEquals(SHARDS_PER_DIAMOND, ingredients.size(),
"Mixing recipe must list the shard 9 times (Create ignores 'count' on item ingredients)");
for (JsonElement element : ingredients) {
JsonObject ingredient = element.getAsJsonObject();
assertEquals(SHARD_ITEM, ingredient.get("item").getAsString(),
"Every mixing ingredient must be a diamond shard");
assertFalse(ingredient.has("count"),
"Never use 'count' on Create item ingredients: it is silently ignored (dupe exploit)");
}
}
@Test
@DisplayName("Mixing recipe outputs exactly 1 diamond")
void testMixingRecipe_ShouldOutputOneDiamond() {
JsonObject recipe = loadRecipe(MIXING_RECIPE);
JsonArray results = recipe.getAsJsonArray("results");
assertEquals(1, results.size(), "Mixing recipe must have a single result");
JsonObject result = results.get(0).getAsJsonObject();
assertEquals(DIAMOND_ITEM, result.get("id").getAsString());
assertEquals(1, result.get("count").getAsInt(), "9 shards must yield exactly 1 diamond");
}
@Test
@DisplayName("Vanilla crafting recipe uses 9 shards for 1 diamond")
void testCraftingRecipe_ShouldUseNineShards() {
JsonObject recipe = loadRecipe(CRAFTING_RECIPE);
assertEquals("minecraft:crafting_shaped", recipe.get("type").getAsString());
JsonObject key = recipe.getAsJsonObject("key");
assertEquals(SHARD_ITEM, key.getAsJsonObject("a").get("item").getAsString());
int shardSlots = 0;
for (JsonElement row : recipe.getAsJsonArray("pattern")) {
for (char c : row.getAsString().toCharArray()) {
if (c == 'a') {
shardSlots++;
}
}
}
assertEquals(SHARDS_PER_DIAMOND, shardSlots, "Crafting pattern must contain 9 shard slots");
JsonObject result = recipe.getAsJsonObject("result");
assertEquals(DIAMOND_ITEM, result.get("id").getAsString());
assertEquals(1, result.get("count").getAsInt());
}
@Test
@DisplayName("Crushing a diamond yields less than 9 shards on average (no dupe loop)")
void testCrushingRecipe_ShouldStayBelowDupeThreshold() {
double yield = expectedShardYield(loadRecipe(CRUSHING_RECIPE));
assertTrue(yield > 0, "Crushing recipe must produce shards");
assertTrue(yield < SHARDS_PER_DIAMOND,
"Crushing yield (" + yield + ") must stay below " + SHARDS_PER_DIAMOND
+ " shards per diamond or the mixer/crusher cycle duplicates diamonds");
}
@Test
@DisplayName("Milling a diamond yields less than 9 shards on average (no dupe loop)")
void testMillingRecipe_ShouldStayBelowDupeThreshold() {
double yield = expectedShardYield(loadRecipe(MILLING_RECIPE));
assertTrue(yield > 0, "Milling recipe must produce shards");
assertTrue(yield < SHARDS_PER_DIAMOND,
"Milling yield (" + yield + ") must stay below " + SHARDS_PER_DIAMOND
+ " shards per diamond or the mixer/millstone cycle duplicates diamonds");
}
}