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..c5e4456b 100644 --- a/src/main/java/org/cyclops/integratedcrafting/core/CraftingHelpers.java +++ b/src/main/java/org/cyclops/integratedcrafting/core/CraftingHelpers.java @@ -698,11 +698,32 @@ 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, 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 +742,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); + } + } + } + +}