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/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; 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/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)) 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", , );