From f800744e931f5b791fc72059857fee800f42ebe6 Mon Sep 17 00:00:00 2001 From: JohnBraham Date: Fri, 30 Oct 2026 17:44:43 +0000 Subject: [PATCH] Unlock grass slab recipes as their ingredients are collected --- .github/workflows/ci.yml | 6 +- CHANGELOG.md | 5 + build.gradle | 2 + docs/BUILDING.md | 11 ++ docs/RELEASE-1.1.2.112021.md | 6 + gradle.properties | 2 +- gradle/recipe-advancements.gradle | 46 +++++++ .../integrationtest/IntegrationTestMod.java | 1 + .../integrationtest/RecipeBookChecks.java | 127 ++++++++++++++++++ .../mc/skysgrassslabs/SkysGrassSlabs.java | 2 +- .../compat/BuildingBricksDirtSlabRecipe.java | 6 + .../recipe/RecipeBookDiscovery.java | 75 +++++++++++ src/main/recipe-book/recipes.json | 60 +++++++++ .../skysgrassslabs/ProjectContractTest.java | 4 +- .../RecipeAdvancementContractTest.java | 42 ++++++ 15 files changed, 388 insertions(+), 7 deletions(-) create mode 100644 docs/RELEASE-1.1.2.112021.md create mode 100644 gradle/recipe-advancements.gradle create mode 100644 src/integrationTest/java/zone/moddev/mc/skysgrassslabs/integrationtest/RecipeBookChecks.java create mode 100644 src/main/java/zone/moddev/mc/skysgrassslabs/recipe/RecipeBookDiscovery.java create mode 100644 src/main/recipe-book/recipes.json create mode 100644 src/test/java/zone/moddev/mc/skysgrassslabs/RecipeAdvancementContractTest.java diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 07cb2ae..cf10657 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,9 +56,9 @@ jobs: if-no-files-found: error retention-days: 30 path: | - build/libs/SkysGrassSlabs-1.1.1.112021.jar - build/libs/SkysGrassSlabs-1.1.1.112021-sources.jar - build/libs/SkysGrassSlabs-1.1.1.112021-javadoc.jar + build/libs/SkysGrassSlabs-1.1.2.112021.jar + build/libs/SkysGrassSlabs-1.1.2.112021-sources.jar + build/libs/SkysGrassSlabs-1.1.2.112021-javadoc.jar build/release/SHA256SUMS CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c78aa0..ce52362 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 1.1.2.112021 + +- Added the missing recipe-book unlocks for crafting and rebuilding pieces. +- Made the next recipes appear while the crafting table is still open. + ## 1.1.1.112021 - Minecraft 1.12.2 - Added a Config screen with help for each setting. Save your changes and diff --git a/build.gradle b/build.gradle index 0244cbc..dd2c259 100644 --- a/build.gradle +++ b/build.gradle @@ -1393,3 +1393,5 @@ tasks.register('verifyEclipseProductionClasspath') { tasks.named('check') { dependsOn tasks.named('verifyReleaseMetadata') } + +apply from: 'gradle/recipe-advancements.gradle' diff --git a/docs/BUILDING.md b/docs/BUILDING.md index 2f8bccb..279fa4b 100644 --- a/docs/BUILDING.md +++ b/docs/BUILDING.md @@ -35,3 +35,14 @@ those allowances. Unrelated warnings remain visible in the game logs. Cross-version fixtures have separate, reviewed diagnostics tied to their archive checksums. These cover the test-only generator mod and exact vanilla numeric-ID changes. They are not applied to ordinary worlds or used to silence the game. + +## Recipe-book resources + +The build generates unlock advancements from `src/main/recipe-book/recipes.json`. +The runtime checks compare those entries with the registered recipes and exercise +inventory discovery, crafting-table discovery and recipe-book persistence. +Optional catalogue entries load only when their recipe exists. + +Recipe unlocks have no shared advancement parent. Minecraft 1.12 walks that +visibility tree recursively when loading completed progress, which can overflow +with a large catalogue. The inventory and crafting unlock criteria are unchanged. diff --git a/docs/RELEASE-1.1.2.112021.md b/docs/RELEASE-1.1.2.112021.md new file mode 100644 index 0000000..b07633a --- /dev/null +++ b/docs/RELEASE-1.1.2.112021.md @@ -0,0 +1,6 @@ +# Recipe book update for Minecraft 1.12.2 + +Recipes now appear in the recipe book when you obtain their ingredients. +Crafting a piece also reveals its next cuts and recombination recipes while +the crafting table is open. Saved blocks, items and crafting quantities are +unchanged. diff --git a/gradle.properties b/gradle.properties index 1bea53f..3d6e89b 100644 --- a/gradle.properties +++ b/gradle.properties @@ -23,7 +23,7 @@ curseforge_project_id=1677588 mod_id=skysgrassslabs mod_name=Sky's Grass Slabs mod_license=LGPL-2.1-only -mod_version=1.1.1.112021 +mod_version=1.1.2.112021 mod_group=zone.moddev.mc.skysgrassslabs mod_authors=SkyBlade1978 mod_description=Adds dirt, grass and path slabs, turf, natural grass spreading and smoother Overworld terrain. diff --git a/gradle/recipe-advancements.gradle b/gradle/recipe-advancements.gradle new file mode 100644 index 0000000..51bf68d --- /dev/null +++ b/gradle/recipe-advancements.gradle @@ -0,0 +1,46 @@ +import groovy.json.JsonOutput +import groovy.json.JsonSlurper + +// Recipe IDs and exact ingredient variants are checked against the live registry. +def recipeBookManifest = layout.projectDirectory.file('src/main/recipe-book/recipes.json') +def recipeBookOutput = layout.buildDirectory.dir('generated/recipe-advancements') +def generateRecipeAdvancements = tasks.register('generateRecipeAdvancements') { + inputs.file(recipeBookManifest) + outputs.dir(recipeBookOutput) + doLast { + File output = recipeBookOutput.get().asFile + delete output + List entries = new JsonSlurper().parse(recipeBookManifest.asFile) as List + Set ids = new HashSet<>() + entries.each { entry -> + String recipe = entry.recipe + String owner = project.mod_id + if (entry.owner != owner || !ids.add(recipe) || entry.ingredients.isEmpty()) { + throw new GradleException("Invalid recipe-book entry: ${recipe}") + } + String path = recipe.split(':', 2)[1] + Map criteria = [:] + List requirements = [] + entry.ingredients.eachWithIndex { ingredient, index -> + String key = entry.ingredients.size() == 1 ? 'has_ingredient' : 'has_ingredient_' + index + criteria[key] = [trigger: 'minecraft:inventory_changed', conditions: [items: [ingredient]]] + requirements.add(key) + } + criteria.has_the_recipe = [trigger: 'minecraft:recipe_unlocked', conditions: [recipe: recipe]] + requirements.add('has_the_recipe') + // A shared parent makes 1.12 recurse through thousands of learned recipes on login. + // Independent hidden unlocks keep the same recipe-book behaviour without that recursion. + Map advancement = [rewards: [recipes: [recipe]], + criteria: criteria, requirements: [requirements]] + if (project.mod_id != 'skysgrassslabs') { + // Optional catalogues and recovery recipes must not leave phantom unlocks. + advancement.conditions = [[type: 'skysbuildingpieces:recipe_exists', recipe: recipe]] + } + File target = new File(output, "assets/${owner}/advancements/recipes/${path}.json") + target.parentFile.mkdirs() + target.setText(JsonOutput.prettyPrint(JsonOutput.toJson(advancement)) + '\n', 'UTF-8') + } + } +} +sourceSets.main.resources.srcDir(generateRecipeAdvancements) +tasks.named('processResources') { dependsOn generateRecipeAdvancements } diff --git a/src/integrationTest/java/zone/moddev/mc/skysgrassslabs/integrationtest/IntegrationTestMod.java b/src/integrationTest/java/zone/moddev/mc/skysgrassslabs/integrationtest/IntegrationTestMod.java index 66f0b5b..ade6fea 100644 --- a/src/integrationTest/java/zone/moddev/mc/skysgrassslabs/integrationtest/IntegrationTestMod.java +++ b/src/integrationTest/java/zone/moddev/mc/skysgrassslabs/integrationtest/IntegrationTestMod.java @@ -126,6 +126,7 @@ public void serverStarted(FMLServerStartedEvent event) { "56D8B8C1FA7F2289C9F9A3BCF2BEB2D15F0F880373D0647BB9BDBFA7E1D5FE54"); evidence.setProperty(phase.replace('-', '_') + "_complete", "true"); } else if ("fresh".equals(phase)) { + RecipeBookChecks.run(world); int gameplayChecks = verifyGameplay(server, world); int worldgenChecks = verifyWorldgen(world); diff --git a/src/integrationTest/java/zone/moddev/mc/skysgrassslabs/integrationtest/RecipeBookChecks.java b/src/integrationTest/java/zone/moddev/mc/skysgrassslabs/integrationtest/RecipeBookChecks.java new file mode 100644 index 0000000..abe46b2 --- /dev/null +++ b/src/integrationTest/java/zone/moddev/mc/skysgrassslabs/integrationtest/RecipeBookChecks.java @@ -0,0 +1,127 @@ +package zone.moddev.mc.skysgrassslabs.integrationtest; + +import com.google.gson.*; +import com.mojang.authlib.GameProfile; +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.util.*; +import net.minecraft.advancements.*; +import net.minecraft.entity.player.EntityPlayerMP; +import net.minecraft.init.Items; +import net.minecraft.item.ItemStack; +import net.minecraft.item.crafting.*; +import net.minecraft.network.*; +import net.minecraft.server.management.PlayerInteractionManager; +import net.minecraft.util.ResourceLocation; +import net.minecraft.world.WorldServer; +import net.minecraftforge.fml.common.FMLCommonHandler; +import net.minecraftforge.fml.common.gameevent.PlayerEvent; + +/** Checks the real server advancement manager and recipe book, not just the JSON files. */ +public final class RecipeBookChecks { + private static final Set MODS = Collections.singleton("skysgrassslabs"); + + public static void run(WorldServer world) { + SortedMap entries = new TreeMap<>(); + List recipes = new ArrayList<>(); + for (IRecipe recipe : CraftingManager.REGISTRY) { + ResourceLocation id = recipe.getRegistryName(); + if (id == null || !MODS.contains(id.getNamespace()) || recipe.isDynamic()) continue; + Ingredient first = recipe.getIngredients().stream().filter(i -> i != Ingredient.EMPTY) + .findFirst().orElseThrow(() -> new AssertionError("No book ingredients: " + id)); + JsonObject entry = new JsonObject(); + entry.addProperty("recipe", id.toString()); + entry.addProperty("owner", owner(id)); + JsonArray ingredients = new JsonArray(); + Set seen = new HashSet<>(); + for (ItemStack stack : first.getMatchingStacks()) { + if (stack.isEmpty()) continue; + JsonObject predicate = new JsonObject(); + predicate.addProperty("item", stack.getItem().getRegistryName().toString()); + if (stack.getMetadata() != 32767) predicate.addProperty("data", stack.getMetadata()); + if (seen.add(predicate.toString())) ingredients.add(predicate); + } + require(ingredients.size() > 0, "Empty book ingredients: " + id); + entry.add("ingredients", ingredients); + entries.put(id.toString(), entry); + recipes.add(recipe); + } + File snapshot = new File("recipe-book-snapshot.json"); + try (Writer out = new OutputStreamWriter(new FileOutputStream(snapshot), StandardCharsets.UTF_8)) { + new GsonBuilder().setPrettyPrinting().create().toJson(entries.values(), out); + } catch (IOException failure) { + throw new IllegalStateException(failure); + } + if (Boolean.getBoolean("skysbuildingpieces.snapshotRecipeBook")) { + System.out.println("RECIPE_BOOK_SNAPSHOT_ONLY recipes=" + recipes.size()); + return; + } + EntityPlayerMP player = new EntityPlayerMP(world.getMinecraftServer(), world, + new GameProfile(UUID.fromString("c64fa100-ff4c-4615-bcbd-90a552320a82"), "RecipeBookProbe"), + new PlayerInteractionManager(world)); + List> sentPackets = new ArrayList<>(); + NetworkManager network = new NetworkManager(EnumPacketDirection.SERVERBOUND) { + private final io.netty.channel.Channel testChannel = new io.netty.channel.embedded.EmbeddedChannel(); + @Override public io.netty.channel.Channel channel() { return testChannel; } + @Override public void sendPacket(Packet packet) { sentPackets.add(packet); } + }; + player.connection = new NetHandlerPlayServer(world.getMinecraftServer(), network, player); + require(!world.getMinecraftServer().getAdvancementManager().hasErrored(), "Advancement parsing failed"); + int checks = 0; + List verified = new ArrayList<>(); + for (IRecipe recipe : recipes) { + ResourceLocation id = recipe.getRegistryName(); + verified.add(recipe); + Advancement advancement = world.getMinecraftServer().getAdvancementManager() + .getAdvancement(new ResourceLocation(owner(id), "recipes/" + id.getPath())); + require(advancement != null, "Missing recipe advancement: " + id); + require(!recipe.getRecipeOutput().isEmpty(), "Missing book result: " + id); + reset(player, recipe, advancement); + player.inventory.clear(); + player.inventory.setInventorySlotContents(0, new ItemStack(Items.APPLE)); + CriteriaTriggers.INVENTORY_CHANGED.trigger(player, player.inventory); + require(!player.getRecipeBook().isUnlocked(recipe), "Unrelated item unlocked " + id); + Ingredient first = recipe.getIngredients().stream().filter(i -> i != Ingredient.EMPTY).findFirst().get(); + ItemStack input = first.getMatchingStacks()[0].copy(); + player.inventory.setInventorySlotContents(0, input); + CriteriaTriggers.INVENTORY_CHANGED.trigger(player, player.inventory); + require(player.getRecipeBook().isUnlocked(recipe), "Ingredient did not unlock " + id); + if (!id.getNamespace().equals("skysgrassslabs") || !id.getPath().equals("turf")) { + reset(player, recipe, advancement); + player.inventory.clear(); + FMLCommonHandler.instance().bus().post(new PlayerEvent.ItemCraftedEvent(player, input, new net.minecraft.inventory.InventoryCrafting(new net.minecraft.inventory.Container() { + @Override public boolean canInteractWith(net.minecraft.entity.player.EntityPlayer ignored) { return true; } + }, 2, 2))); + require(player.getRecipeBook().isUnlocked(recipe), "Crafted ingredient did not unlock " + id); + } + checks++; + } + player.getAdvancements().save(); + net.minecraft.nbt.NBTTagCompound savedBook = player.getRecipeBook().write(); + net.minecraft.stats.RecipeBookServer reloaded = new net.minecraft.stats.RecipeBookServer(); + reloaded.read(savedBook); + for (IRecipe recipe : verified) require(reloaded.isUnlocked(recipe), "Book reload lost " + recipe.getRegistryName()); + require(sentPackets.stream().anyMatch(packet -> packet instanceof net.minecraft.network.play.server.SPacketRecipeBook), + "Unlocks did not send recipe-book packets"); + player.getAdvancements().dispose(); + network.channel().close(); + System.out.println("GRASS_RECIPE_BOOK_PASS recipes=" + checks + " inventory=true crafting=true reload=true"); + } + + private static void reset(EntityPlayerMP player, IRecipe recipe, Advancement advancement) { + player.resetRecipes(Collections.singletonList(recipe)); + for (String criterion : advancement.getCriteria().keySet()) { + player.getAdvancements().revokeCriterion(advancement, criterion); + } + } + + private static String owner(ResourceLocation recipe) { + return recipe.getNamespace(); + } + + private static void require(boolean condition, String message) { + if (!condition) throw new AssertionError(message); + } + + private RecipeBookChecks() { } +} diff --git a/src/main/java/zone/moddev/mc/skysgrassslabs/SkysGrassSlabs.java b/src/main/java/zone/moddev/mc/skysgrassslabs/SkysGrassSlabs.java index 76fae4e..210b0f2 100644 --- a/src/main/java/zone/moddev/mc/skysgrassslabs/SkysGrassSlabs.java +++ b/src/main/java/zone/moddev/mc/skysgrassslabs/SkysGrassSlabs.java @@ -27,7 +27,7 @@ public final class SkysGrassSlabs { public static final String MOD_ID = "skysgrassslabs"; public static final String NAME = "Sky's Grass Slabs"; - public static final String VERSION = "1.1.1.112021"; + public static final String VERSION = "1.1.2.112021"; @SidedProxy( clientSide = "zone.moddev.mc.skysgrassslabs.proxy.ClientProxy", diff --git a/src/main/java/zone/moddev/mc/skysgrassslabs/compat/BuildingBricksDirtSlabRecipe.java b/src/main/java/zone/moddev/mc/skysgrassslabs/compat/BuildingBricksDirtSlabRecipe.java index 0a804d2..87ffe24 100644 --- a/src/main/java/zone/moddev/mc/skysgrassslabs/compat/BuildingBricksDirtSlabRecipe.java +++ b/src/main/java/zone/moddev/mc/skysgrassslabs/compat/BuildingBricksDirtSlabRecipe.java @@ -47,6 +47,12 @@ public boolean canFit(int width, int height) { return width * height >= 2; } + // Legacy material identities are resolved late and cannot be filled safely by the recipe book. + @Override + public boolean isDynamic() { + return true; + } + @Override public ItemStack getRecipeOutput() { return new ItemStack(ModBlocks.GRASS_SLAB); diff --git a/src/main/java/zone/moddev/mc/skysgrassslabs/recipe/RecipeBookDiscovery.java b/src/main/java/zone/moddev/mc/skysgrassslabs/recipe/RecipeBookDiscovery.java new file mode 100644 index 0000000..6fba0ea --- /dev/null +++ b/src/main/java/zone/moddev/mc/skysgrassslabs/recipe/RecipeBookDiscovery.java @@ -0,0 +1,75 @@ +package zone.moddev.mc.skysgrassslabs.recipe; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import net.minecraft.advancements.Advancement; +import net.minecraft.advancements.Criterion; +import net.minecraft.advancements.ICriterionInstance; +import net.minecraft.advancements.critereon.InventoryChangeTrigger; +import net.minecraft.advancements.critereon.RecipeUnlockedTrigger; +import net.minecraft.entity.player.EntityPlayerMP; +import net.minecraft.entity.player.InventoryPlayer; +import net.minecraft.item.crafting.CraftingManager; +import net.minecraft.item.crafting.IRecipe; +import net.minecraft.util.ResourceLocation; +import net.minecraftforge.fml.common.Mod; +import net.minecraftforge.fml.common.eventhandler.SubscribeEvent; +import net.minecraftforge.fml.common.gameevent.PlayerEvent; + +/** Unlock the next cut while its ingredient is still on the crafting-table cursor. */ +@Mod.EventBusSubscriber(modid = "skysgrassslabs") +public final class RecipeBookDiscovery { + private static final Set OWNERS = new HashSet<>(Arrays.asList("skysgrassslabs")); + + @SubscribeEvent + public static void crafted(PlayerEvent.ItemCraftedEvent event) { + if (!(event.player instanceof EntityPlayerMP) || event.crafting.isEmpty()) { + return; + } + + EntityPlayerMP player = (EntityPlayerMP) event.player; + InventoryPlayer ingredient = new InventoryPlayer(player); + ingredient.setInventorySlotContents(0, event.crafting.copy()); + + for (IRecipe recipe : CraftingManager.REGISTRY) { + ResourceLocation id = recipe.getRegistryName(); + if (id == null || !OWNERS.contains(id.getNamespace()) || recipe.isDynamic()) { + continue; + } + if (recipe.getIngredients().stream().noneMatch(input -> input.apply(event.crafting))) { + continue; + } + + for (String owner : OWNERS) { + Advancement advancement = player.getServer().getAdvancementManager() + .getAdvancement(new ResourceLocation(owner, "recipes/" + id.getPath())); + if (advancement == null) { + continue; + } + + Criterion recipeCriterion = advancement.getCriteria().get("has_the_recipe"); + if (recipeCriterion == null || !(recipeCriterion.getCriterionInstance() + instanceof RecipeUnlockedTrigger.Instance)) { + continue; + } + if (!((RecipeUnlockedTrigger.Instance) recipeCriterion.getCriterionInstance()).test(recipe)) { + continue; + } + + // Evaluate the actual criterion, including metadata, rather than guessing its material. + for (Map.Entry criterion : advancement.getCriteria().entrySet()) { + ICriterionInstance instance = criterion.getValue().getCriterionInstance(); + if (instance instanceof InventoryChangeTrigger.Instance + && ((InventoryChangeTrigger.Instance) instance).test(ingredient)) { + player.getAdvancements().grantCriterion(advancement, criterion.getKey()); + } + } + } + } + } + + private RecipeBookDiscovery() { + } +} diff --git a/src/main/recipe-book/recipes.json b/src/main/recipe-book/recipes.json new file mode 100644 index 0000000..9c107f1 --- /dev/null +++ b/src/main/recipe-book/recipes.json @@ -0,0 +1,60 @@ +[ + { + "recipe": "skysgrassslabs:dirt_slab", + "owner": "skysgrassslabs", + "ingredients": [ + { + "item": "minecraft:dirt", + "data": 0 + } + ] + }, + { + "recipe": "skysgrassslabs:grass_slab", + "owner": "skysgrassslabs", + "ingredients": [ + { + "item": "minecraft:grass", + "data": 0 + } + ] + }, + { + "recipe": "skysgrassslabs:dirt_from_slabs", + "owner": "skysgrassslabs", + "ingredients": [ + { + "item": "skysgrassslabs:dirt_slab" + } + ] + }, + { + "recipe": "skysgrassslabs:grass_from_slabs", + "owner": "skysgrassslabs", + "ingredients": [ + { + "item": "skysgrassslabs:grass_slab" + } + ] + }, + { + "recipe": "skysgrassslabs:grass_block_from_seeds", + "owner": "skysgrassslabs", + "ingredients": [ + { + "item": "minecraft:dirt", + "data": 0 + } + ] + }, + { + "recipe": "skysgrassslabs:grass_slab_from_seeds", + "owner": "skysgrassslabs", + "ingredients": [ + { + "item": "skysgrassslabs:dirt_slab", + "data": 0 + } + ] + } +] diff --git a/src/test/java/zone/moddev/mc/skysgrassslabs/ProjectContractTest.java b/src/test/java/zone/moddev/mc/skysgrassslabs/ProjectContractTest.java index 5ded2c0..4484d09 100644 --- a/src/test/java/zone/moddev/mc/skysgrassslabs/ProjectContractTest.java +++ b/src/test/java/zone/moddev/mc/skysgrassslabs/ProjectContractTest.java @@ -21,7 +21,7 @@ void releaseIdentityAndToolchainArePinned() throws Exception { assertTrue(properties.contains("minecraft_version=1.12.2")); assertTrue(properties.contains("forge_version=14.23.5.2859")); assertTrue(properties.contains("mapping_version=39-1.12")); - assertTrue(properties.contains("mod_version=1.1.1.112021")); + assertTrue(properties.contains("mod_version=1.1.2.112021")); assertTrue(properties.contains("curseforge_project_id=1677588")); assertTrue(properties.contains("java_toolchain_version=8.0.502+7")); } @@ -124,7 +124,7 @@ void releaseTagWorkflowPointsAtTheManualDispatcherInputs() throws Exception { assertFalse(workflow.contains("release_ref:")); assertFalse(workflow.contains("curseforge_channel:")); assertFalse(workflow.contains("confirm_version:")); - assertTrue(new File("docs/RELEASE-1.1.1.112021.md").isFile()); + assertTrue(new File("docs/RELEASE-1.1.2.112021.md").isFile()); } @Test diff --git a/src/test/java/zone/moddev/mc/skysgrassslabs/RecipeAdvancementContractTest.java b/src/test/java/zone/moddev/mc/skysgrassslabs/RecipeAdvancementContractTest.java new file mode 100644 index 0000000..5b91fdd --- /dev/null +++ b/src/test/java/zone/moddev/mc/skysgrassslabs/RecipeAdvancementContractTest.java @@ -0,0 +1,42 @@ +package zone.moddev.mc.skysgrassslabs; + +import com.google.gson.*; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.util.*; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +class RecipeAdvancementContractTest { + @Test + void everyBookEntryRewardsItsRecipeAndUsesAlternativeUnlockCriteria() throws Exception { + JsonArray entries = new JsonParser().parse(read(Paths.get("src/main/recipe-book/recipes.json"))).getAsJsonArray(); + assertTrue(entries.size() > 0); + Set recipes = new HashSet<>(); + for (JsonElement element : entries) { + JsonObject entry = element.getAsJsonObject(); + String recipe = entry.get("recipe").getAsString(); + assertTrue(recipes.add(recipe), recipe); + assertEquals("skysgrassslabs", entry.get("owner").getAsString()); + Path path = Paths.get("build/resources/main/assets/skysgrassslabs/advancements/recipes", + recipe.split(":", 2)[1] + ".json"); + JsonObject advancement = new JsonParser().parse(read(path)).getAsJsonObject(); + assertFalse(advancement.has("parent"), + "Large recipe catalogues must not share a recursive visibility parent: " + recipe); + assertEquals(recipe, advancement.getAsJsonObject("rewards").getAsJsonArray("recipes").get(0).getAsString()); + JsonObject criteria = advancement.getAsJsonObject("criteria"); + assertEquals("minecraft:recipe_unlocked", criteria.getAsJsonObject("has_the_recipe").get("trigger").getAsString()); + JsonArray requirements = advancement.getAsJsonArray("requirements"); + assertEquals(1, requirements.size(), "Unlock criteria must be alternatives: " + recipe); + assertEquals(criteria.size(), requirements.get(0).getAsJsonArray().size()); + for (Map.Entry criterion : criteria.entrySet()) { + if (criterion.getKey().equals("has_the_recipe")) continue; + assertEquals("minecraft:inventory_changed", criterion.getValue().getAsJsonObject().get("trigger").getAsString()); + } + } + } + + private static String read(Path path) throws Exception { + return new String(Files.readAllBytes(path), StandardCharsets.UTF_8); + } +}