From 582359df81c8042cb6fc0cfe8d99057813c7a8ff Mon Sep 17 00:00:00 2001 From: Ruben Taelman Date: Wed, 2 Sep 2026 16:05:20 +0000 Subject: [PATCH 1/2] Emit an event when a crafting job is completed Adds CraftingJobFinishedEvent, which is emitted on the NeoForge event bus for jobs that ran to completion. Cancelled jobs are excluded: cancelling marks the job, because the crafting interface finalizes cancelled jobs through the regular finishing logic a tick later. Crafting jobs now also carry a notifyInitiator flag, so that initiators can indicate that they want to be notified once the job is completed, and an initialAmount, because the regular amount is decremented to zero while the job runs. Related to CyclopsMC/IntegratedCrafting#175 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QNDY2pZtkXqJrhztYCvNbh --- .../api/crafting/CraftingJob.java | 27 +++ .../api/event/CraftingJobFinishedEvent.java | 58 +++++ .../core/CraftingHelpers.java | 21 ++ .../core/network/CraftingNetwork.java | 10 + .../GameTestsCraftingJobFinishedEvent.java | 202 ++++++++++++++++++ 5 files changed, 318 insertions(+) create mode 100644 src/main/java/org/cyclops/integratedcrafting/api/event/CraftingJobFinishedEvent.java create mode 100644 src/main/java/org/cyclops/integratedcrafting/gametest/GameTestsCraftingJobFinishedEvent.java diff --git a/src/main/java/org/cyclops/integratedcrafting/api/crafting/CraftingJob.java b/src/main/java/org/cyclops/integratedcrafting/api/crafting/CraftingJob.java index 2ff64050..f8dde97f 100644 --- a/src/main/java/org/cyclops/integratedcrafting/api/crafting/CraftingJob.java +++ b/src/main/java/org/cyclops/integratedcrafting/api/crafting/CraftingJob.java @@ -43,7 +43,9 @@ public class CraftingJob { private boolean invalidInputs; @Nullable private String initiatorUuid; + private boolean notifyInitiator; private boolean ignoreDependencyCheck; + private boolean cancelled; public CraftingJob(int id, int channel, IRecipeDefinition recipe, int amount, IMixedIngredients ingredientsStorage) { this.id = id; @@ -237,6 +239,29 @@ public void setInitiatorUuid(String initiatorUuid) { this.initiatorUuid = initiatorUuid; } + /** + * @return If the initiator wants to be notified when this job is completed. + */ + public boolean isNotifyInitiator() { + return notifyInitiator; + } + + public void setNotifyInitiator(boolean notifyInitiator) { + this.notifyInitiator = notifyInitiator; + } + + /** + * @return If this job was cancelled instead of running to completion. + * This is not persisted, as cancelled jobs are removed from their network right away. + */ + public boolean isCancelled() { + return cancelled; + } + + public void setCancelled(boolean cancelled) { + this.cancelled = cancelled; + } + public void setIgnoreDependencyCheck(boolean ignoreDependencyCheck) { this.ignoreDependencyCheck = ignoreDependencyCheck; } @@ -262,6 +287,7 @@ public static CompoundTag serialize(HolderLookup.Provider lookupProvider, Crafti if (craftingJob.initiatorUuid != null) { tag.putString("initiatorUuid", craftingJob.initiatorUuid); } + tag.putBoolean("notifyInitiator", craftingJob.notifyInitiator); tag.putBoolean("ignoreDependencyCheck", craftingJob.ignoreDependencyCheck); return tag; } @@ -320,6 +346,7 @@ public static CraftingJob deserialize(HolderLookup.Provider lookupProvider, Comp if (tag.contains("initiatorUuid", Tag.TAG_STRING)) { craftingJob.setInitiatorUuid(tag.getString("initiatorUuid")); } + craftingJob.setNotifyInitiator(tag.getBoolean("notifyInitiator")); craftingJob.setIgnoreDependencyCheck(tag.getBoolean("ignoreDependencyCheck")); craftingJob.setIngredientsStorageBuffer(ingredientsStorageBuffer); return craftingJob; diff --git a/src/main/java/org/cyclops/integratedcrafting/api/event/CraftingJobFinishedEvent.java b/src/main/java/org/cyclops/integratedcrafting/api/event/CraftingJobFinishedEvent.java new file mode 100644 index 00000000..cda09dc2 --- /dev/null +++ b/src/main/java/org/cyclops/integratedcrafting/api/event/CraftingJobFinishedEvent.java @@ -0,0 +1,58 @@ +package org.cyclops.integratedcrafting.api.event; + +import net.neoforged.bus.api.Event; +import net.neoforged.neoforge.common.NeoForge; +import org.cyclops.integratedcrafting.api.crafting.CraftingJob; +import org.cyclops.integratedcrafting.api.network.ICraftingNetwork; + +/** + * An event that is emitted on the NeoForge event bus when a crafting job has been completed. + * + * This is only emitted for jobs that ran to completion, + * so not for jobs that were cancelled or that were removed together with their crafting interface. + * + * This is emitted for every completed job, including the dependencies of a job. + * Jobs that were requested directly can be identified via {@link #isRootJob()}. + * + * @author rubensworks + */ +public class CraftingJobFinishedEvent extends Event { + + private final ICraftingNetwork craftingNetwork; + private final CraftingJob craftingJob; + private final boolean rootJob; + + public CraftingJobFinishedEvent(ICraftingNetwork craftingNetwork, CraftingJob craftingJob) { + this.craftingNetwork = craftingNetwork; + this.craftingJob = craftingJob; + this.rootJob = craftingJob.getDependentCraftingJobs().isEmpty(); + } + + /** + * @return The crafting network in which the job was running. + */ + public ICraftingNetwork getCraftingNetwork() { + return craftingNetwork; + } + + /** + * @return The completed crafting job. + */ + public CraftingJob getCraftingJob() { + return craftingJob; + } + + /** + * @return If the job was requested directly, as opposed to being a dependency of another job. + * This is captured when the event is created, + * as the job's dependency links are cleared once it is removed from its network. + */ + public boolean isRootJob() { + return rootJob; + } + + public static void post(ICraftingNetwork craftingNetwork, CraftingJob craftingJob) { + NeoForge.EVENT_BUS.post(new CraftingJobFinishedEvent(craftingNetwork, craftingJob)); + } + +} diff --git a/src/main/java/org/cyclops/integratedcrafting/core/CraftingHelpers.java b/src/main/java/org/cyclops/integratedcrafting/core/CraftingHelpers.java index 36fede8c..a7e51a10 100644 --- a/src/main/java/org/cyclops/integratedcrafting/core/CraftingHelpers.java +++ b/src/main/java/org/cyclops/integratedcrafting/core/CraftingHelpers.java @@ -703,6 +703,26 @@ public static void scheduleCraftingJobs(ICraftingNetwork craftingNetwork, CraftingJobDependencyGraph craftingJobDependencyGraph, boolean allowDistribution, @Nullable UUID initiator) throws UnavailableCraftingInterfacesException { + scheduleCraftingJobs(craftingNetwork, storageGetter, craftingJobDependencyGraph, allowDistribution, initiator, false); + } + + /** + * Schedule all crafting jobs in the given dependency graph in the given network. + * + * @param craftingNetwork The target crafting network. + * @param storageGetter The storage getter. + * @param craftingJobDependencyGraph The crafting job dependency graph. + * @param allowDistribution If the crafting jobs are allowed to be split over multiple crafting interfaces. + * @param initiator Optional UUID of the initiator. + * @param notifyInitiator If the initiator wants to be notified when the jobs are completed. + * @throws UnavailableCraftingInterfacesException If no crafting interfaces were available. + */ + public static void scheduleCraftingJobs(ICraftingNetwork craftingNetwork, + Function, IIngredientComponentStorage> storageGetter, + CraftingJobDependencyGraph craftingJobDependencyGraph, + boolean allowDistribution, + @Nullable UUID initiator, + boolean notifyInitiator) throws UnavailableCraftingInterfacesException { List startedJobs = Lists.newArrayList(); craftingNetwork.getCraftingJobDependencyGraph().importDependencies(craftingJobDependencyGraph); for (CraftingJob craftingJob : craftingJobDependencyGraph.getCraftingJobs()) { @@ -721,6 +741,7 @@ public static void scheduleCraftingJobs(ICraftingNetwork craftingNetwork, startedJobs.add(craftingJob); if (initiator != null) { craftingJob.setInitiatorUuid(initiator.toString()); + craftingJob.setNotifyInitiator(notifyInitiator); } } } diff --git a/src/main/java/org/cyclops/integratedcrafting/core/network/CraftingNetwork.java b/src/main/java/org/cyclops/integratedcrafting/core/network/CraftingNetwork.java index 2572804d..33d07bc6 100644 --- a/src/main/java/org/cyclops/integratedcrafting/core/network/CraftingNetwork.java +++ b/src/main/java/org/cyclops/integratedcrafting/core/network/CraftingNetwork.java @@ -15,6 +15,7 @@ import org.cyclops.integratedcrafting.api.crafting.CraftingJobDependencyGraph; import org.cyclops.integratedcrafting.api.crafting.ICraftingInterface; import org.cyclops.integratedcrafting.api.crafting.UnavailableCraftingInterfacesException; +import org.cyclops.integratedcrafting.api.event.CraftingJobFinishedEvent; import org.cyclops.integratedcrafting.api.network.ICraftingNetwork; import org.cyclops.integratedcrafting.api.recipe.ICraftingJobIndexModifiable; import org.cyclops.integratedcrafting.api.recipe.IRecipeIndexModifiable; @@ -275,6 +276,11 @@ protected long getCurrentTick() { @Override public void onCraftingJobFinished(CraftingJob craftingJob) { + // Emit the event before removal, as removal clears the job's dependency links. + if (!craftingJob.isCancelled()) { + CraftingJobFinishedEvent.post(this, craftingJob); + } + removeCraftingJob(craftingJob.getChannel(), craftingJob); getCraftingJobDependencyGraph().onCraftingJobFinished(craftingJob); } @@ -290,6 +296,10 @@ public boolean cancelCraftingJob(int channel, int craftingJobId) { } protected void cancelCraftingJob(CraftingJob craftingJob) { + // Mark as cancelled, so that no completion event is emitted for it. + // The crafting interface finalizes cancelled jobs via the regular finishing logic. + craftingJob.setCancelled(true); + // First cancel all dependencies for (CraftingJob dependency : getCraftingJobDependencyGraph().getDependencies(craftingJob)) { cancelCraftingJob(dependency); diff --git a/src/main/java/org/cyclops/integratedcrafting/gametest/GameTestsCraftingJobFinishedEvent.java b/src/main/java/org/cyclops/integratedcrafting/gametest/GameTestsCraftingJobFinishedEvent.java new file mode 100644 index 00000000..5afede28 --- /dev/null +++ b/src/main/java/org/cyclops/integratedcrafting/gametest/GameTestsCraftingJobFinishedEvent.java @@ -0,0 +1,202 @@ +package org.cyclops.integratedcrafting.gametest; + +import com.google.common.collect.Lists; +import net.minecraft.core.BlockPos; +import net.minecraft.gametest.framework.GameTest; +import net.minecraft.gametest.framework.GameTestHelper; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import net.minecraft.world.item.crafting.RecipeType; +import net.minecraft.world.level.block.entity.ChestBlockEntity; +import net.neoforged.bus.api.SubscribeEvent; +import net.neoforged.neoforge.common.NeoForge; +import net.neoforged.neoforge.gametest.GameTestHolder; +import net.neoforged.neoforge.gametest.PrefixGameTestTemplate; +import org.apache.commons.lang3.tuple.Triple; +import org.cyclops.commoncapabilities.IngredientComponents; +import org.cyclops.commoncapabilities.api.capability.itemhandler.ItemMatch; +import org.cyclops.integratedcrafting.Reference; +import org.cyclops.integratedcrafting.api.crafting.CraftingJob; +import org.cyclops.integratedcrafting.api.crafting.CraftingJobDependencyGraph; +import org.cyclops.integratedcrafting.api.event.CraftingJobFinishedEvent; +import org.cyclops.integratedcrafting.core.CraftingHelpers; +import org.cyclops.integratedcrafting.part.PartTypeInterfaceCrafting; +import org.cyclops.integrateddynamics.api.network.INetwork; +import org.cyclops.integrateddynamics.api.network.IPositionedAddonsNetworkIngredients; +import org.cyclops.integrateddynamics.core.helper.NetworkHelpers; + +import java.util.List; +import java.util.UUID; + +/** + * Game tests for {@link CraftingJobFinishedEvent}. + * @author rubensworks + */ +@GameTestHolder(Reference.MOD_ID) +@PrefixGameTestTemplate(false) +public class GameTestsCraftingJobFinishedEvent { + + public static final String TEMPLATE_EMPTY = "empty10"; + public static final int TIMEOUT = 2000; + public static final BlockPos POS = BlockPos.ZERO.offset(2, 0, 2); + + /** + * A job that runs to completion emits an event for the requested job. + */ + @GameTest(template = TEMPLATE_EMPTY, timeoutTicks = TIMEOUT) + public void testEventOnCompletedJob(GameTestHelper helper) { + prepareNetwork(helper); + UUID initiator = UUID.randomUUID(); + EventCollector collector = EventCollector.start(initiator); + + helper.startSequence() + .thenIdle(20) + .thenExecute(() -> scheduleChestJob(helper, initiator, true)) + .thenWaitUntil(() -> helper.assertTrue(collector.getRootJobs().size() == 1, + "Expected exactly one completion event for the requested job, but got " + + collector.getRootJobs().size())) + .thenExecute(() -> { + CraftingJob craftingJob = collector.getRootJobs().get(0); + helper.assertTrue(craftingJob.isNotifyInitiator(), + "The completed job did not carry the notify flag"); + helper.assertFalse(craftingJob.isCancelled(), + "The completed job was marked as cancelled"); + helper.assertTrue(craftingJob.getAmountTotal() == 1, + "The completed job did not retain its total amount, but had " + + craftingJob.getAmountTotal()); + helper.assertTrue(craftingJob.getAmount() == 0, + "The completed job still had a remaining amount"); + collector.stop(); + }) + .thenSucceed(); + } + + /** + * The notify flag is not set when the initiator did not ask to be notified. + */ + @GameTest(template = TEMPLATE_EMPTY, timeoutTicks = TIMEOUT) + public void testEventWithoutNotifyFlag(GameTestHelper helper) { + prepareNetwork(helper); + UUID initiator = UUID.randomUUID(); + EventCollector collector = EventCollector.start(initiator); + + helper.startSequence() + .thenIdle(20) + .thenExecute(() -> scheduleChestJob(helper, initiator, false)) + .thenWaitUntil(() -> helper.assertTrue(collector.getRootJobs().size() == 1, + "Expected exactly one completion event for the requested job")) + .thenExecute(() -> { + helper.assertFalse(collector.getRootJobs().get(0).isNotifyInitiator(), + "The completed job carried the notify flag"); + collector.stop(); + }) + .thenSucceed(); + } + + /** + * A job that is cancelled does not emit a completion event. + */ + @GameTest(template = TEMPLATE_EMPTY, timeoutTicks = TIMEOUT) + public void testNoEventOnCancelledJob(GameTestHelper helper) { + prepareNetwork(helper); + UUID initiator = UUID.randomUUID(); + EventCollector collector = EventCollector.start(initiator); + + helper.startSequence() + .thenIdle(20) + .thenExecute(() -> { + CraftingJob craftingJob = scheduleChestJob(helper, initiator, true); + helper.assertTrue(CraftingHelpers.getCraftingNetworkChecked(getNetwork(helper)) + .cancelCraftingJob(craftingJob.getChannel(), craftingJob.getId()), + "The crafting job could not be cancelled"); + }) + .thenIdle(200) + .thenExecute(() -> { + helper.assertTrue(collector.getAllJobs().isEmpty(), + "A completion event was emitted for a cancelled job"); + collector.stop(); + }) + .thenSucceed(); + } + + private static void prepareNetwork(GameTestHelper helper) { + GameTestHelpersIntegratedCrafting.INetworkPositions positions = + GameTestHelpersIntegratedCrafting.createBasicNetwork(helper, POS); + + // Insert crafting inputs in the interface chest + ChestBlockEntity chest = helper.getBlockEntity(POS.east()); + chest.setItem(0, new ItemStack(Items.OAK_PLANKS, 64)); + + // Add the chest recipe to the crafting interface + positions.interfaceRecipeAdders().get(0).accept(Triple.of(0, RecipeType.CRAFTING, + ResourceLocation.fromNamespaceAndPath("minecraft", "chest"))); + } + + private static CraftingJob scheduleChestJob(GameTestHelper helper, UUID initiator, boolean notifyInitiator) { + INetwork network = getNetwork(helper); + int channel = IPositionedAddonsNetworkIngredients.DEFAULT_CHANNEL; + try { + CraftingJobDependencyGraph dependencyGraph = new CraftingJobDependencyGraph(); + CraftingJob craftingJob = CraftingHelpers.calculateCraftingJobs(network, channel, + IngredientComponents.ITEMSTACK, new ItemStack(Items.CHEST), ItemMatch.ITEM, true, + CraftingHelpers.getGlobalCraftingJobIdentifier(), dependencyGraph, false); + CraftingHelpers.scheduleCraftingJobs(CraftingHelpers.getCraftingNetworkChecked(network), + CraftingHelpers.getNetworkStorageGetter(network, channel, false), dependencyGraph, true, + initiator, notifyInitiator); + return craftingJob; + } catch (Exception e) { + throw new IllegalStateException("Crafting job could not be scheduled", e); + } + } + + private static INetwork getNetwork(GameTestHelper helper) { + return NetworkHelpers.getNetwork(helper.getLevel(), helper.absolutePos(POS), null) + .orElseThrow(() -> new IllegalStateException("Could not find a network")); + } + + /** + * Collects the events of a single initiator, so that concurrently running tests don't interfere. + */ + public static class EventCollector { + + private final UUID initiator; + private final List allJobs = Lists.newArrayList(); + private final List rootJobs = Lists.newArrayList(); + + public EventCollector(UUID initiator) { + this.initiator = initiator; + } + + public static EventCollector start(UUID initiator) { + EventCollector collector = new EventCollector(initiator); + NeoForge.EVENT_BUS.register(collector); + return collector; + } + + public void stop() { + NeoForge.EVENT_BUS.unregister(this); + } + + public List getAllJobs() { + return allJobs; + } + + public List getRootJobs() { + return rootJobs; + } + + @SubscribeEvent + public void onCraftingJobFinished(CraftingJobFinishedEvent event) { + CraftingJob craftingJob = event.getCraftingJob(); + if (!this.initiator.toString().equals(craftingJob.getInitiatorUuid())) { + return; + } + this.allJobs.add(craftingJob); + if (event.isRootJob()) { + this.rootJobs.add(craftingJob); + } + } + } + +} From e7662c1a5681e7cc7e9efdb8b074a7dc17e9e661 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 18:50:23 +0000 Subject: [PATCH 2/2] Deprecate the crafting job scheduling method without notify flag Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QNDY2pZtkXqJrhztYCvNbh --- .../org/cyclops/integratedcrafting/core/CraftingHelpers.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/org/cyclops/integratedcrafting/core/CraftingHelpers.java b/src/main/java/org/cyclops/integratedcrafting/core/CraftingHelpers.java index a7e51a10..c5e4456b 100644 --- a/src/main/java/org/cyclops/integratedcrafting/core/CraftingHelpers.java +++ b/src/main/java/org/cyclops/integratedcrafting/core/CraftingHelpers.java @@ -698,6 +698,7 @@ protected static void addRemainderAsSurplusForComponent(Ingredi * @param initiator Optional UUID of the initiator. * @throws UnavailableCraftingInterfacesException If no crafting interfaces were available. */ + @Deprecated // TODO: rm in next major public static void scheduleCraftingJobs(ICraftingNetwork craftingNetwork, Function, IIngredientComponentStorage> storageGetter, CraftingJobDependencyGraph craftingJobDependencyGraph,