From 69d231855dccd833829c9f61fbed490551ea5829 Mon Sep 17 00:00:00 2001 From: Daniel Hazelton Date: Sat, 7 Nov 2026 18:13:45 -0500 Subject: [PATCH 1/3] Add CraftTweaker Integration for MMDLib's Crushing Recipes Expose the shared mmdlib:crushing recipe type through CraftTweaker's normal recipe loader so pack authors can configure recipes used by the crackhammer and other mods' crushing machines without writing recipe JSON by hand. Keep this integration in compat/CraftTweaker/crusher, separate from startup material, block, and item definitions. Changelog: - Add a typed CraftTweaker manager for shared mmdlib:crushing recipes under a separate crusher integration package. Reuse standard recipe actions, removals, queries and reload handling; test script-added recipes through the crackhammer. Provide addRecipe(name, output, input) and a native query-result type. Retain inherited JSON additions, queries, and removal operations. Reject empty inputs and outputs, multiplied inputs, and unsupported crafting transformers instead of silently discarding their semantics. Preserve output counts and components through the existing recipe implementation. Add executable scripts and server checks for item/tag inputs, queries, removals, replacement, JSON fallback, invalid arguments, and actual crackhammer consumption. Extend isolated client smoke checks to reload recipes and confirm additions and removals persist without duplicate IDs. Keep CraftTweaker optional and document the separate companion demo recipe pool without changing its behavior. Validation: - ./gradlew --offline check build passed: 190 unit tests, 89 MMDLib GameTests, 52 companion GameTests, packaging and static assets. - Real client with CraftTweaker passed recipe reload verification; a separate client launch without CraftTweaker passed. - Release JAR inspection confirmed integration classes are present and smoke classes/scripts are excluded. - Staged whitespace checks passed. --- CHANGELOG.md | 3 + docs/crafttweaker-api-inventory.md | 10 +++ docs/crafttweaker-crusher.md | 81 +++++++++++++++++++ docs/crafttweaker-verification.md | 11 +++ docs/crafttweaker.md | 3 + docs/roadmap.md | 9 ++- scripts/crafttweaker-smoke.init.gradle | 5 +- src/clientSmoke/README.md | 4 +- .../mc/lib/smoke/CraftTweakerClientSmoke.java | 24 +++++- .../crusher/CrushingRecipeManager.java | 37 +++++++++ .../crusher/ExpandCrushingRecipe.java | 10 +++ .../lib/smoke/CrusherRecipeScriptSmoke.java | 79 ++++++++++++++++++ .../moddev/mc/lib/smoke/MMDLibGameTests.java | 5 ++ src/runtimeTest/scripts/crusher_recipes.zs | 21 +++++ 14 files changed, 295 insertions(+), 7 deletions(-) create mode 100644 docs/crafttweaker-crusher.md create mode 100644 src/main/java/zone/moddev/mc/lib/compat/CraftTweaker/crusher/CrushingRecipeManager.java create mode 100644 src/main/java/zone/moddev/mc/lib/compat/CraftTweaker/crusher/ExpandCrushingRecipe.java create mode 100644 src/runtimeTest/java/zone/moddev/mc/lib/smoke/CrusherRecipeScriptSmoke.java create mode 100644 src/runtimeTest/scripts/crusher_recipes.zs diff --git a/CHANGELOG.md b/CHANGELOG.md index 60f805b1..c9b9df6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,3 +35,6 @@ Changes on `crafttweaker-integration` since `master-2.0-1.21.1-neo`. overrides retain priority; shape-aware default loot remains supported. - Add reproducible isolated client-smoke runs with CraftTweaker present and absent, generated-content rendering captures, and a verification report for the parity work. +- Add a typed CraftTweaker manager for shared `mmdlib:crushing` recipes under a + separate crusher integration package. Reuse standard recipe actions, removals, + queries and reload handling; test script-added recipes through the crackhammer. diff --git a/docs/crafttweaker-api-inventory.md b/docs/crafttweaker-api-inventory.md index 2d0f1d50..2e148d3c 100644 --- a/docs/crafttweaker-api-inventory.md +++ b/docs/crafttweaker-api-inventory.md @@ -562,3 +562,13 @@ world and item-gallery captures were visually inspected. Release JAR inspection confirmed no smoke content remained after restoring normal main outputs. See [the verification report](crafttweaker-verification.md). The existing core per-tool durability-scaling limitation remains deferred. + +## Shared crushing recipe integration + +The startup content inventory above is separate from the ordinary recipe API in +`compat/CraftTweaker/crusher`. `mods.mmdlib.crusher.CrushingRecipeManager` implements +CraftTweaker's IRecipeManager for `mmdlib:crushing` and adds the typed +`addRecipe(string name, IItemStack output, IIngredient input)` method. +`ExpandCrushingRecipe` registers `mods.mmdlib.crusher.CrushingRecipe` as the native +query-result type. Standard removals, queries and JSON additions are inherited. +See [shared crushing recipe scripting](crafttweaker-crusher.md). diff --git a/docs/crafttweaker-crusher.md b/docs/crafttweaker-crusher.md new file mode 100644 index 00000000..8edcc3b3 --- /dev/null +++ b/docs/crafttweaker-crusher.md @@ -0,0 +1,81 @@ +# CraftTweaker crushing recipes + +MMDLib's shared `mmdlib:crushing` recipe type accepts one item ingredient and one +output stack. Its consumers include the crackhammer and crushing machines in +mods that use this type. Changes apply to every consumer of the shared recipe +manager; the integration does not install a separate recipe pool or processor. + +Use ordinary CraftTweaker recipe scripts, with its default loader or the explicit +loader below. These scripts run after registries are available and participate in +CraftTweaker's normal recipe reload lifecycle. Startup content-definition scripts +using the `mmdlib` loader run too early for recipe edits. + +```zenscript +#loader crafttweaker +var crusher = ; +crusher.addRecipe("clay_to_diamonds", * 2, ); +crusher.addRecipe("planks_to_nugget", , ); +``` + +`addRecipe(name, output, input)` uses CraftTweaker's normal recipe ID rules: +`"clay_to_diamonds"` becomes `crafttweaker:clay_to_diamonds`. Outputs retain their +count and item components through the existing recipe's defensive stack copy. +Inputs use CraftTweaker's vanilla ingredient conversion, including tags. The recipe +consumes one input item; multiplied input stacks, empty input/output, and crafting +ingredient transformers are rejected rather than silently changing semantics. +Machine processing time and energy usage are consumer behavior, not fields of this +shared recipe type. + +The manager inherits CraftTweaker's existing operations: + +```zenscript +crusher.removeByName("crafttweaker:clay_to_diamonds"); +crusher.removeByInput(); +crusher.remove(); // remove by output +var recipe = crusher.getRecipeByName("crafttweaker:planks_to_nugget"); // null if absent +var recipes = crusher.allRecipes; +``` + +`getRecipesByOutput`, `removeByModid`, `removeByRegex`, `removeMatching`, and +`removeAll` also use the standard IRecipeManager API. Remove an existing recipe by +its full ID before adding its replacement. Adding the same ID twice without a +removal retains CraftTweaker's normal duplicate handling. + +The existing JSON route remains available: + +```zenscript +crusher.addJsonRecipe("shard_to_emerald", { + ingredient: {item: "minecraft:amethyst_shard"}, + result: {id: "minecraft:emerald", count: 2} +}); +``` + +## Implementation and verification + +The manager and native recipe query type live in +`compat/CraftTweaker/crusher`, separate from the startup definition builders. +CraftTweaker discovers them through its standard annotations; there is no additional +MMDLib startup loader or direct recipe-manager mutation. CraftTweaker remains +optional. The core recipe class, serializer, recipe ID and consumers are unchanged. + +The executable [recipe fixture](../src/runtimeTest/scripts/crusher_recipes.zs) +checks addition, queries, removal by name/input/output, replacement, JSON fallback, +and tag ingredients. [CrusherRecipeScriptSmoke](../src/runtimeTest/java/zone/moddev/mc/lib/smoke/CrusherRecipeScriptSmoke.java) +checks the live recipes, invalid input rejection, and actual crackhammer consumption +with normal durability cost. The isolated client smoke reloads server resources and +checks that additions/removals persist without duplicate IDs. + +The separately packaged companion demo currently has its own +`mmdlib_companion:crushing` generic machine recipes (with time and resource groups). +Those are used by its demo crusher and dual-crusher and are not aliases for the +shared type. This integration targets `mmdlib:crushing`; it does not migrate the +companion demo or change those recipes. + +Verified on the current working tree: `./gradlew --offline check build` passed +(190 unit tests, 89 MMDLib GameTests, 52 companion GameTests, packaging and static +assets). The real integrated client passed a server-resource reload with additions, +removals and unique IDs intact (MMDLIB_CT_RECIPES_RELOAD_PASS). A separate client +launch without CraftTweaker passed. The release JAR includes the recipe manager +and native query type and excludes smoke classes/scripts. EA3/SA3 machines were +not launched in this verification; the live consumer test uses the shared type +through the crackhammer. diff --git a/docs/crafttweaker-verification.md b/docs/crafttweaker-verification.md index c5ce613c..d7af39f5 100644 --- a/docs/crafttweaker-verification.md +++ b/docs/crafttweaker-verification.md @@ -74,3 +74,14 @@ static assets and packaging. Inspection of `build/libs/mmdlib-2.0.0.121012-development.jar` confirmed that the integration classes are included and smoke classes, fixture scripts and fixture resource IDs are absent. No commit or remote publication was performed. + +## Shared crushing recipe follow-up + +The separate recipe integration is documented in [crushing recipe scripting](crafttweaker-crusher.md). +Normal `check build` passed with 190 unit tests, 89 MMDLib GameTests and 52 +companion GameTests. The ordinary recipe fixture covers add/query/remove/replace, +JSON additions, tag ingredients, invalid arguments, and crackhammer consumption. +The client smoke now reloads server resources and verifies recipes are reapplied +without duplicate IDs; both installed and absent-mod client launches passed. +Release JAR inspection confirmed the two crusher integration classes are included +and smoke classes/scripts remain excluded. diff --git a/docs/crafttweaker.md b/docs/crafttweaker.md index c3fe6cb1..fc931bb4 100644 --- a/docs/crafttweaker.md +++ b/docs/crafttweaker.md @@ -1,5 +1,8 @@ # CraftTweaker definitions +For normal shared crushing-recipe edits, see [CraftTweaker crushing recipes](crafttweaker-crusher.md). +Those use the ordinary recipe loader, separately from these startup definitions. + When CraftTweaker is installed, scripts can define MMDLib materials, blocks, items, and reusable block and item modules. Scripts use the `mmdlib` loader and run once at startup, after every mod has constructed and before registry events. What they build is planned, checked, and diff --git a/docs/roadmap.md b/docs/roadmap.md index 1bb5cf70..4e14541e 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -107,11 +107,12 @@ loop, then machines defined entirely in JSON; also the plan for NeoForge 26.1 an builds. Stage 1 is built (internals without GUI, and ingredients, groups, transactions and matching). -### KubeJS and CraftTweaker recipe integrations +### KubeJS recipe integration -Optional polish over the generic support above, each loaded only when that mod is present: a -KubeJS recipe schema (`event.recipes.mmdlib.crushing(output, input)`) and a typed CraftTweaker -recipe manager (`addRecipe(name, output, input)`). +Possible optional polish over generic JSON support: a KubeJS recipe schema +(`event.recipes.mmdlib.crushing(output, input)`). The typed CraftTweaker manager +for shared `mmdlib:crushing` recipes is implemented; see +[crushing recipe scripting](crafttweaker-crusher.md). ### Global data pack loader diff --git a/scripts/crafttweaker-smoke.init.gradle b/scripts/crafttweaker-smoke.init.gradle index 432aa87d..4fee52a2 100644 --- a/scripts/crafttweaker-smoke.init.gradle +++ b/scripts/crafttweaker-smoke.init.gradle @@ -15,7 +15,10 @@ allprojects { smokeDir.mkdirs() new File(smokeDir, 'options.txt').text = 'guiScale:2\nmaxFps:30\nonboardAccessibility:false\npauseOnLostFocus:false\nrenderDistance:5\n' project.delete(new File(smokeDir, 'scripts')) - if (!absent) project.copy { from 'src/clientSmoke/scripts'; into new File(smokeDir, 'scripts') } + if (!absent) { + project.copy { from 'src/clientSmoke/scripts'; into new File(smokeDir, 'scripts') } + project.copy { from 'src/runtimeTest/scripts/crusher_recipes.zs'; into new File(smokeDir, 'scripts') } + } } doLast { def report = new File(smokeDir, 'logs/latest.log') diff --git a/src/clientSmoke/README.md b/src/clientSmoke/README.md index ebaf0d21..72c80a93 100644 --- a/src/clientSmoke/README.md +++ b/src/clientSmoke/README.md @@ -9,7 +9,9 @@ The present-mod run copies only this directory's scripts to an isolated build folder. It checks startup registrations, English labels, baked model/texture availability, and front-face model rotation for four horizontal facings. It opens an integrated world and captures actual world rendering and an item/name gallery. -The absent-mod run copies no scripts and checks the script-only item is absent, +The installed-mod run also copies the ordinary crushing-recipe fixture, reloads +server resources, and verifies recipe additions/removals survive without duplicate +IDs (MMDLIB_CT_RECIPES_RELOAD_PASS). The absent-mod run copies no scripts and checks the script-only item is absent, then creates an integrated world and exits successfully. Both runs use a bounded timeout and explicit pass/fail log markers. diff --git a/src/clientSmoke/java/zone/moddev/mc/lib/smoke/CraftTweakerClientSmoke.java b/src/clientSmoke/java/zone/moddev/mc/lib/smoke/CraftTweakerClientSmoke.java index 71b5e232..5cb6424a 100644 --- a/src/clientSmoke/java/zone/moddev/mc/lib/smoke/CraftTweakerClientSmoke.java +++ b/src/clientSmoke/java/zone/moddev/mc/lib/smoke/CraftTweakerClientSmoke.java @@ -75,7 +75,17 @@ public static void tick(ClientTickEvent.Post event) { } player.teleportTo(.5, 103, 7.5); player.setYRot(180); player.setXRot(22); - ready = true; + if (present()) { + checkRecipes(mc); + var server = mc.getSingleplayerServer(); + server.reloadResources(server.getPackRepository().getSelectedIds()).thenRunAsync(() -> { + try { + checkRecipes(mc); + MMDLib.LOGGER.info("MMDLIB_CT_RECIPES_RELOAD_PASS"); + ready = true; + } catch (Throwable error) { fail(error); } + }, server).exceptionally(error -> { fail(error); return null; }); + } else ready = true; } catch (Throwable error) { fail(error); } }); } @@ -103,6 +113,18 @@ public static void tick(ClientTickEvent.Post event) { } catch (Throwable error) { fail(error); } } + private static void checkRecipes(Minecraft mc) { + var server = mc.getSingleplayerServer(); + var manager = server.getRecipeManager(); + var recipe = manager.byKey(ResourceLocation.parse("crafttweaker:ct_crusher_added")).orElseThrow().value(); + var output = recipe.getResultItem(server.registryAccess()); + require(output.is(net.minecraft.world.item.Items.DIAMOND) && output.getCount() == 2, "recipe survives reload"); + require(manager.byKey(ResourceLocation.parse("crafttweaker:ct_crusher_removed")).isEmpty(), "removal survives reload"); + long count = manager.getAllRecipesFor(zone.moddev.mc.lib.common.recipes.CrushingRecipe.TYPE.get()) + .stream().filter(holder -> holder.id().toString().equals("crafttweaker:ct_crusher_added")).count(); + require(count == 1, "recipe reload does not duplicate IDs"); + } + @SubscribeEvent public static void frame(RenderFrameEvent.Post event) { if (!Boolean.getBoolean("mmdlib.craftTweakerSmoke") || finished || stage == 0) return; diff --git a/src/main/java/zone/moddev/mc/lib/compat/CraftTweaker/crusher/CrushingRecipeManager.java b/src/main/java/zone/moddev/mc/lib/compat/CraftTweaker/crusher/CrushingRecipeManager.java new file mode 100644 index 00000000..4644b025 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/compat/CraftTweaker/crusher/CrushingRecipeManager.java @@ -0,0 +1,37 @@ +package zone.moddev.mc.lib.compat.CraftTweaker.crusher; + +import com.blamejared.crafttweaker.api.CraftTweakerAPI; +import com.blamejared.crafttweaker.api.action.recipe.ActionAddRecipe; +import com.blamejared.crafttweaker.api.annotation.ZenRegister; +import com.blamejared.crafttweaker.api.ingredient.IIngredient; +import com.blamejared.crafttweaker.api.item.IItemStack; +import com.blamejared.crafttweaker.api.recipe.manager.base.IRecipeManager; +import net.minecraft.world.item.crafting.RecipeType; +import org.openzen.zencode.java.ZenCodeType; +import zone.moddev.mc.lib.common.recipes.CrushingRecipe; + +/** Normal recipe-loader integration, independent of the mmdlib startup content loader. */ +@ZenRegister +@ZenCodeType.Name("mods.mmdlib.crusher.CrushingRecipeManager") +public final class CrushingRecipeManager implements IRecipeManager { + /** + * Add a one-input crushing recipe, using standard CraftTweaker recipe IDs and actions. + * Ingredient amounts greater than one are not supported by this recipe type. + */ + @ZenCodeType.Method + public void addRecipe(String name, IItemStack output, IIngredient input) { + if (input.asIIngredientWithAmount().amount() != 1) + throw new IllegalArgumentException("Crushing recipes consume exactly one input item"); + if (!input.transformers().isEmpty()) + throw new IllegalArgumentException("Crushing recipes do not support crafting ingredient transformers"); + var ingredient = input.asVanillaIngredient(); + if (ingredient.isEmpty()) throw new IllegalArgumentException("Crushing input must not be empty"); + var recipe = new CrushingRecipe(ingredient, output.getInternal()); + CraftTweakerAPI.apply(new ActionAddRecipe<>(this, createHolder(fixRecipeId(name), recipe))); + } + + @Override + public RecipeType getRecipeType() { + return CrushingRecipe.TYPE.get(); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/compat/CraftTweaker/crusher/ExpandCrushingRecipe.java b/src/main/java/zone/moddev/mc/lib/compat/CraftTweaker/crusher/ExpandCrushingRecipe.java new file mode 100644 index 00000000..c1f0b903 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/compat/CraftTweaker/crusher/ExpandCrushingRecipe.java @@ -0,0 +1,10 @@ +package zone.moddev.mc.lib.compat.CraftTweaker.crusher; + +import com.blamejared.crafttweaker.api.annotation.ZenRegister; +import com.blamejared.crafttweaker_annotations.annotations.NativeTypeRegistration; +import zone.moddev.mc.lib.common.recipes.CrushingRecipe; + +/** Expose the native recipe type for inherited recipe-manager query results. */ +@ZenRegister +@NativeTypeRegistration(value = CrushingRecipe.class, zenCodeName = "mods.mmdlib.crusher.CrushingRecipe") +public final class ExpandCrushingRecipe {} diff --git a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/CrusherRecipeScriptSmoke.java b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/CrusherRecipeScriptSmoke.java new file mode 100644 index 00000000..5ab9bfe8 --- /dev/null +++ b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/CrusherRecipeScriptSmoke.java @@ -0,0 +1,79 @@ +package zone.moddev.mc.lib.smoke; + +import net.minecraft.core.BlockPos; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.gametest.framework.GameTestHelper; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.item.ItemEntity; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import net.minecraft.world.item.crafting.SingleRecipeInput; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.phys.AABB; +import net.neoforged.neoforge.common.CommonHooks; +import zone.moddev.mc.lib.common.recipes.CrushingRecipe; + +/** Exercise the normal recipe loader and its live recipes, not the startup content loader. */ +final class CrusherRecipeScriptSmoke { + static void check(GameTestHelper helper, BlockPos relative) { + var level = helper.getLevel(); + var manager = level.getRecipeManager(); + for (String absent : new String[] {"removed", "input_removed", "output_removed", "query_failed"}) { + require(manager.byKey(id("ct_crusher_" + absent)).isEmpty(), "recipe should be absent: " + absent); + } + var added = manager.byKey(id("ct_crusher_added")).orElseThrow().value(); + require(added instanceof CrushingRecipe && added.getType() == CrushingRecipe.TYPE.get(), "typed recipe registered"); + require(((CrushingRecipe) added).matches(new SingleRecipeInput(new ItemStack(Items.CLAY_BALL)), level), "input matches"); + var result = added.getResultItem(level.registryAccess()); + require(result.is(Items.DIAMOND) && result.getCount() == 2, "output count preserved"); + result.shrink(1); + require(added.getResultItem(level.registryAccess()).getCount() == 2, "output is defensive copy"); + var replaced = manager.byKey(id("ct_crusher_replaced")).orElseThrow().value().getResultItem(level.registryAccess()); + require(replaced.is(Items.IRON_NUGGET) && replaced.getCount() == 3, "remove then re-add replaces recipe"); + var json = manager.byKey(id("ct_crusher_json")).orElseThrow().value().getResultItem(level.registryAccess()); + require(json.is(Items.EMERALD) && json.getCount() == 2, "inherited JSON recipe API retained"); + + var tagged = (CrushingRecipe) manager.byKey(id("ct_crusher_tag")).orElseThrow().value(); + require(tagged.matches(new SingleRecipeInput(new ItemStack(Items.OAK_PLANKS)), level) + && tagged.matches(new SingleRecipeInput(new ItemStack(Items.BIRCH_PLANKS)), level), "tag ingredients work"); + var typed = new zone.moddev.mc.lib.compat.CraftTweaker.crusher.CrushingRecipeManager(); + var output = com.blamejared.crafttweaker.api.item.IItemStack.of(new ItemStack(Items.DIAMOND)); + var single = com.blamejared.crafttweaker.api.item.IItemStack.of(new ItemStack(Items.CLAY_BALL)); + rejected(() -> typed.addRecipe("bad_amount", output, + com.blamejared.crafttweaker.api.item.IItemStack.of(new ItemStack(Items.CLAY_BALL, 2)))); + rejected(() -> typed.addRecipe("bad_output", + com.blamejared.crafttweaker.api.item.IItemStack.of(ItemStack.EMPTY), single)); + rejected(() -> typed.addRecipe("bad_input", output, + com.blamejared.crafttweaker.api.ingredient.IIngredient.fromIngredient(net.minecraft.world.item.crafting.Ingredient.EMPTY))); + + var floor = helper.absolutePos(relative); + level.setBlock(floor, Blocks.STONE.defaultBlockState(), 3); + level.getEntitiesOfClass(ItemEntity.class, new AABB(floor.above()).inflate(2)).forEach(Entity::discard); + var input = new ItemEntity(level, floor.getX() + .5, floor.getY() + 1.1, floor.getZ() + .5, + new ItemStack(Items.CLAY_BALL)); + level.addFreshEntity(input); + var player = helper.makeMockServerPlayerInLevel(); + player.getAbilities().instabuild = false; + var hammer = new ItemStack(BuiltInRegistries.ITEM.get(ResourceLocation.parse("mmdlib:iron_crackhammer"))); + player.setItemInHand(InteractionHand.MAIN_HAND, hammer); + player.setPos(input.getX(), input.getY() + .1 - player.getEyeHeight(), input.getZ() - 2); + player.setYRot(0); player.setXRot(0); + CommonHooks.onItemRightClick(player, InteractionHand.MAIN_HAND); + var outputs = level.getEntitiesOfClass(ItemEntity.class, new AABB(floor.above()).inflate(1), + entity -> entity.getItem().is(Items.DIAMOND)); + require(!input.isAlive() && outputs.stream().mapToInt(entity -> entity.getItem().getCount()).sum() == 2 + && hammer.getDamageValue() == 1, "crackhammer consumes the script recipe with normal wear"); + helper.succeed(); + } + + private static void rejected(Runnable operation) { + try { operation.run(); } + catch (IllegalArgumentException expected) { return; } + throw new AssertionError("Invalid crushing recipe was accepted"); + } + + private static ResourceLocation id(String path) { return ResourceLocation.parse("crafttweaker:" + path); } + private static void require(boolean condition, String message) { if (!condition) throw new AssertionError(message); } +} diff --git a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MMDLibGameTests.java b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MMDLibGameTests.java index 57daab5e..0ce57f6e 100644 --- a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MMDLibGameTests.java +++ b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MMDLibGameTests.java @@ -58,6 +58,11 @@ public static void processorIdleSearchBackoff(GameTestHelper helper) { private MMDLibGameTests() {} + @GameTest(template = EMPTY) + public static void craftTweakerCrusherRecipes(GameTestHelper helper) { + CrusherRecipeScriptSmoke.check(helper, CENTER); + } + @GameTest(template = EMPTY) public static void scriptGeneratedLoot(GameTestHelper helper) { ScriptHookSmoke.generatedLoot(helper, CENTER); diff --git a/src/runtimeTest/scripts/crusher_recipes.zs b/src/runtimeTest/scripts/crusher_recipes.zs new file mode 100644 index 00000000..31988c20 --- /dev/null +++ b/src/runtimeTest/scripts/crusher_recipes.zs @@ -0,0 +1,21 @@ +#loader crafttweaker +// Ordinary reloadable recipes, separate from startup content definitions. +var crusher = ; +crusher.addRecipe("ct_crusher_added", * 2, ); +if (crusher.getRecipeByName("crafttweaker:ct_crusher_added") == null) { + crusher.addRecipe("ct_crusher_query_failed", , ); +} +crusher.addRecipe("ct_crusher_removed", , ); +crusher.removeByName("crafttweaker:ct_crusher_removed"); +crusher.addRecipe("ct_crusher_input_removed", , ); +crusher.removeByInput(); +crusher.addRecipe("ct_crusher_output_removed", , ); +crusher.remove(); +crusher.addRecipe("ct_crusher_replaced", , ); +crusher.removeByName("crafttweaker:ct_crusher_replaced"); +crusher.addRecipe("ct_crusher_replaced", * 3, ); +crusher.addJsonRecipe("ct_crusher_json", { + ingredient: {item: "minecraft:amethyst_shard"}, + result: {id: "minecraft:emerald", count: 2} +}); +crusher.addRecipe("ct_crusher_tag", , ); From ec581bce8f284ab64abfc9114e5ec26d464daa52 Mon Sep 17 00:00:00 2001 From: Daniel Hazelton Date: Sat, 7 Nov 2026 19:09:22 -0500 Subject: [PATCH 2/3] fix an issue revealed by CI --- .../test/DrawerMemoryClientSmoke.java | 38 +++++++++++++++++-- 1 file changed, 34 insertions(+), 4 deletions(-) diff --git a/src/companionTest/java/zone/moddev/mc/companion/test/DrawerMemoryClientSmoke.java b/src/companionTest/java/zone/moddev/mc/companion/test/DrawerMemoryClientSmoke.java index ba6bbcb8..ebcda1b2 100644 --- a/src/companionTest/java/zone/moddev/mc/companion/test/DrawerMemoryClientSmoke.java +++ b/src/companionTest/java/zone/moddev/mc/companion/test/DrawerMemoryClientSmoke.java @@ -13,6 +13,8 @@ final class DrawerMemoryClientSmoke { private static final BlockPos FIRST = new BlockPos(4, 100, 0), SECOND = FIRST.east(2); private static int step, waitTicks, reported = -1; private static boolean requested; + private static volatile BlockPos reopenAfterClose; + private static volatile String serverMenu = "not sampled"; static boolean run() { if (!BackpackFixture.installed() || step > 13) return true; @@ -21,7 +23,25 @@ static boolean run() { reported = step; zone.moddev.mc.lib.MMDLib.LOGGER.info("DRAWER_MEMORY_STEP {}", step); } - if (++waitTicks > 160) throw new AssertionError("Drawer memory timed out at step " + step); + if (++waitTicks > 160) + throw new AssertionError("Drawer memory timed out at step " + step + + "; reopening=" + reopenAfterClose + "; client=" + describeClient(mc) + + "; server=" + serverMenu); + if (waitTicks == 1 || waitTicks % 20 == 0) + server(player -> serverMenu = player.containerMenu.getClass().getSimpleName() + + "#" + player.containerMenu.containerId); + if (reopenAfterClose != null) { + var target = reopenAfterClose; + server(player -> { + // The client close packet and server.execute use different queues. Never open a + // replacement menu until the server has actually handled the vanilla close. + if (reopenAfterClose == target && player.containerMenu == player.inventoryMenu) { + reopenAfterClose = null; + ScreenRuntime.open(player, player.serverLevel().getBlockEntity(target)); + } + }); + return false; + } if (step == 0) { server( player -> { @@ -161,7 +181,7 @@ static boolean run() { require( mc.screen != screen, "Escape closes screen immediately during dropped item request"); MachineScreen.PendingActions.logout(null); - open(SECOND); + reopenAfterClose = SECOND; advance(); } case 11 -> { @@ -177,7 +197,7 @@ static boolean run() { screen.keyPressed(256, 0, 0); require(mc.screen != screen, "Escape immediately closes a pending redstone request"); } else mc.player.closeContainer(); - open(FIRST); + reopenAfterClose = FIRST; advance(); } case 12 -> { @@ -191,7 +211,7 @@ static boolean run() { screen.keyPressed(256, 0, 0); require( mc.screen != screen, "Escape closes immediately during a pending automation request"); - open(SECOND); + reopenAfterClose = SECOND; advance(); } case 13 -> { @@ -217,6 +237,16 @@ static boolean run() { return false; } + private static String describeClient(Minecraft mc) { + if (!(mc.screen instanceof MachineScreen screen)) return String.valueOf(mc.screen); + var menu = screen.getMenu(); + return "MachineMenu#" + menu.containerId + " at " + menu.blockEntity.getBlockPos() + + ", backpackPending=" + menu.containerDrawer.pending() + + ", curiosPending=" + menu.curiosDrawer.pending() + + ", automation=" + (menu.automation == null ? "absent" + : "open:" + menu.automation.open() + ",pending:" + menu.automation.pending()); + } + private static void advance() { step++; waitTicks = 0; From da946ba82847177605a2d12315a1617336d7b878 Mon Sep 17 00:00:00 2001 From: Daniel Hazelton Date: Sat, 7 Nov 2026 19:27:00 -0500 Subject: [PATCH 3/3] Make the redstone gate game test immune to other tests' gates redstoneGate counted NAND handler calls globally while checking that an unread side's change does not run the handler. Game tests run at the same time in one world, and ContentSmoke places every block item, the test gate included; each of those gates runs its handler once when placed. If that happened during the check, the test failed (CI run 42428485841, while the pull_request run of the same commit passed). Count calls per gate position, and check only the test's own gate. Co-Authored-By: Claude Opus 5.5 --- .../java/zone/moddev/mc/lib/smoke/RedstoneSmoke.java | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/RedstoneSmoke.java b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/RedstoneSmoke.java index 6bcd2ea8..a4f2c761 100644 --- a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/RedstoneSmoke.java +++ b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/RedstoneSmoke.java @@ -18,12 +18,14 @@ public final class RedstoneSmoke { private RedstoneSmoke() {} - private static final java.util.concurrent.atomic.AtomicInteger calls = - new java.util.concurrent.atomic.AtomicInteger(); + // Handler runs per gate position. Other tests place test gates of their own at the same time + // (ContentSmoke places every block), and each runs its handler once when placed. + private static final java.util.Map calls = + new java.util.concurrent.ConcurrentHashMap<>(); /** The gate's {@code redstone-changed} handler: off only while both sides are powered. */ public static void nand(RedstoneChange change) { - calls.incrementAndGet(); + calls.merge(change.position(), 1, Integer::sum); var block = (RedstoneBlock) change.state().getBlock(); boolean left = change.signal(block.toWorld(change.state(), Direction.WEST)) > 0; boolean right = change.signal(block.toWorld(change.state(), Direction.EAST)) > 0; @@ -45,12 +47,12 @@ static void gate(GameTestHelper helper, BlockPos center) { helper .startSequence() .thenExecuteAfter(3, () -> expect(helper, pos, 15, "unpowered gate is on")) - .thenExecute(() -> mark[0] = calls.get()) + .thenExecute(() -> mark[0] = calls.getOrDefault(pos, 0)) .thenExecute(() -> level.setBlock(back, Blocks.REDSTONE_BLOCK.defaultBlockState(), 3)) .thenExecuteAfter(3, () -> expect(helper, pos, 15, "the back side is not read")) .thenExecute( () -> { - if (calls.get() != mark[0]) + if (calls.getOrDefault(pos, 0) != mark[0]) throw new AssertionError("an unread side's change ran the handler"); }) .thenExecute(() -> level.setBlock(left, Blocks.REDSTONE_BLOCK.defaultBlockState(), 3))