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)
This commit is contained in:
@@ -8,8 +8,31 @@
|
|||||||
"type": "create:mixing",
|
"type": "create:mixing",
|
||||||
"ingredients": [
|
"ingredients": [
|
||||||
{
|
{
|
||||||
"item": "custom_ore_gen:diamondshard",
|
"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"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"results": [
|
"results": [
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user