From 6f75bb1c541f0183ee53615de94c007943838f43 Mon Sep 17 00:00:00 2001 From: Ruben Taelman Date: Wed, 2 Sep 2026 16:06:05 +0000 Subject: [PATCH 01/10] Show a toast when a requested crafting job is completed The crafting plan gui gains a checkbox, enabled by default, with which the player indicates that they want to be notified once the job is completed. Once IntegratedCrafting reports the job as completed, a toast with the crafted output is shown to the initiator, if they are online. The toast itself is generalized from IntegratedTerminals-Compat, so that it can be reused for the auto-craft notifications there. Closes CyclopsMC/IntegratedCrafting#175 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QNDY2pZtkXqJrhztYCvNbh --- .../integratedterminals/GeneralConfig.java | 3 + ...alStorageTabIngredientCraftingHandler.java | 16 ++- ...inerScreenTerminalStorageCraftingPlan.java | 18 +++ .../client/gui/toast/CraftingJobToast.java | 93 +++++++++++++ .../gametest/GameTestCraftingJobNotify.java | 131 ++++++++++++++++++ ...tainerTerminalStorageCraftingPlanBase.java | 14 +- .../CraftingJobFinishedToastListener.java | 81 +++++++++++ ...ntegratedCraftingModCompatInitializer.java | 1 + ...redientCraftingHandlerCraftingNetwork.java | 4 +- .../CraftingJobFinishedToastPacket.java | 88 ++++++++++++ ...nalStorageCraftingPlanSetNotifyPacket.java | 55 ++++++++ .../proxy/CommonProxy.java | 2 + .../integratedterminals/lang/en_us.json | 3 + .../gui/sprites/toast/crafting_job.png | Bin 0 -> 183 bytes .../gui/sprites/toast/crafting_job.png.mcmeta | 11 ++ 15 files changed, 516 insertions(+), 4 deletions(-) create mode 100644 src/main/java/org/cyclops/integratedterminals/client/gui/toast/CraftingJobToast.java create mode 100644 src/main/java/org/cyclops/integratedterminals/gametest/GameTestCraftingJobNotify.java create mode 100644 src/main/java/org/cyclops/integratedterminals/modcompat/integratedcrafting/CraftingJobFinishedToastListener.java create mode 100644 src/main/java/org/cyclops/integratedterminals/network/packet/CraftingJobFinishedToastPacket.java create mode 100644 src/main/java/org/cyclops/integratedterminals/network/packet/TerminalStorageCraftingPlanSetNotifyPacket.java create mode 100644 src/main/resources/assets/integratedterminals/textures/gui/sprites/toast/crafting_job.png create mode 100644 src/main/resources/assets/integratedterminals/textures/gui/sprites/toast/crafting_job.png.mcmeta diff --git a/src/main/java/org/cyclops/integratedterminals/GeneralConfig.java b/src/main/java/org/cyclops/integratedterminals/GeneralConfig.java index 70dd1cd2bf..ff7423594b 100644 --- a/src/main/java/org/cyclops/integratedterminals/GeneralConfig.java +++ b/src/main/java/org/cyclops/integratedterminals/GeneralConfig.java @@ -49,6 +49,9 @@ public class GeneralConfig extends DummyConfig { @ConfigurableProperty(category = "machine", comment = "The update frequency in milliseconds for the crafting jobs gui.", isCommandable = true) public static int guiTerminalCraftingJobsUpdateFrequency = 1000; + @ConfigurableProperty(category = "machine", comment = "If a toast should be shown when a crafting job that you requested has been completed.", isCommandable = true, configLocation = ModConfig.Type.CLIENT) + public static boolean craftingJobFinishedToast = true; + @ConfigurableProperty(category = "core", comment = "The number of threads that the crafting plan calculator can use.", minimalValue = 1, requiresMcRestart = true, configLocation = ModConfig.Type.SERVER) public static int craftingPlannerThreads = 2; diff --git a/src/main/java/org/cyclops/integratedterminals/api/terminalstorage/crafting/ITerminalStorageTabIngredientCraftingHandler.java b/src/main/java/org/cyclops/integratedterminals/api/terminalstorage/crafting/ITerminalStorageTabIngredientCraftingHandler.java index 1eb5d5cc89..385c81e18a 100644 --- a/src/main/java/org/cyclops/integratedterminals/api/terminalstorage/crafting/ITerminalStorageTabIngredientCraftingHandler.java +++ b/src/main/java/org/cyclops/integratedterminals/api/terminalstorage/crafting/ITerminalStorageTabIngredientCraftingHandler.java @@ -157,8 +157,22 @@ public default ITerminalCraftingPlanFlat deserializeCraftingPlanFlat(HolderLo * @param player The player that started the crafting job. * @throws CraftingJobStartException If the crafting job failed to start. */ + public default void startCraftingJob(INetwork network, int channel, ITerminalCraftingPlan craftingPlan, + ServerPlayer player) throws CraftingJobStartException { + startCraftingJob(network, channel, craftingPlan, player, true); + } + + /** + * Start the given crafting plan. + * @param network The network in which the plan should be started. + * @param channel The channel to get the options for. + * @param craftingPlan A crafting plan. + * @param player The player that started the crafting job. + * @param notifyOnCompletion If the player wants to be notified once the crafting job is completed. + * @throws CraftingJobStartException If the crafting job failed to start. + */ public void startCraftingJob(INetwork network, int channel, ITerminalCraftingPlan craftingPlan, - ServerPlayer player) throws CraftingJobStartException; + ServerPlayer player, boolean notifyOnCompletion) throws CraftingJobStartException; /** * @param network The network in which the plan should be started. diff --git a/src/main/java/org/cyclops/integratedterminals/client/gui/container/ContainerScreenTerminalStorageCraftingPlan.java b/src/main/java/org/cyclops/integratedterminals/client/gui/container/ContainerScreenTerminalStorageCraftingPlan.java index 550022e3e6..7df64c3017 100644 --- a/src/main/java/org/cyclops/integratedterminals/client/gui/container/ContainerScreenTerminalStorageCraftingPlan.java +++ b/src/main/java/org/cyclops/integratedterminals/client/gui/container/ContainerScreenTerminalStorageCraftingPlan.java @@ -2,6 +2,8 @@ import net.minecraft.ChatFormatting; import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.Checkbox; +import net.minecraft.client.gui.components.Tooltip; import net.minecraft.nbt.CompoundTag; import net.minecraft.network.chat.Component; import net.minecraft.resources.ResourceLocation; @@ -18,6 +20,7 @@ import org.cyclops.integratedterminals.client.gui.container.component.GuiCraftingPlanToggler; import org.cyclops.integratedterminals.core.client.gui.CraftingOptionGuiData; import org.cyclops.integratedterminals.inventory.container.ContainerTerminalStorageCraftingPlanBase; +import org.cyclops.integratedterminals.network.packet.TerminalStorageCraftingPlanSetNotifyPacket; import org.cyclops.integratedterminals.network.packet.TerminalStorageIngredientOpenCraftingJobAmountGuiPacket; import org.lwjgl.glfw.GLFW; @@ -39,6 +42,7 @@ public class ContainerScreenTerminalStorageCraftingPlan returnToCraftingOptionAmount(), true)); + Checkbox checkboxNotify = Checkbox.builder(Component.empty(), font) + .pos(leftPos + 97, topPos + 202) + .selected(this.notifyOnCompletion) + .tooltip(Tooltip.create(Component.translatable("gui.integratedterminals.terminal_storage.step.craft.notify"))) + .onValueChange((widget, selected) -> setNotifyOnCompletion(selected)) + .build(); + addRenderableWidget(checkboxNotify); + addRenderableWidget(buttonConfirm = new ButtonText(leftPos + 221 + 10 - 50, topPos + 198, 50, 20, Component.translatable("gui.integratedterminals.terminal_storage.step.craft"), Component.translatable("gui.integratedterminals.terminal_storage.step.craft").withStyle(ChatFormatting.YELLOW), @@ -129,6 +141,12 @@ public void init() { buttonConfirm.active = (this.guiCraftingPlan != null && this.guiCraftingPlan.isValid()) || (this.guiCraftingPlanFlat != null && this.guiCraftingPlanFlat.isValid()); } + private void setNotifyOnCompletion(boolean notifyOnCompletion) { + this.notifyOnCompletion = notifyOnCompletion; + IntegratedTerminals._instance.getPacketHandler().sendToServer( + new TerminalStorageCraftingPlanSetNotifyPacket(notifyOnCompletion)); + } + @Override public boolean keyPressed(int typedChar, int keyCode, int modifiers) { if (typedChar == GLFW.GLFW_KEY_ESCAPE) { diff --git a/src/main/java/org/cyclops/integratedterminals/client/gui/toast/CraftingJobToast.java b/src/main/java/org/cyclops/integratedterminals/client/gui/toast/CraftingJobToast.java new file mode 100644 index 0000000000..f1aeec926d --- /dev/null +++ b/src/main/java/org/cyclops/integratedterminals/client/gui/toast/CraftingJobToast.java @@ -0,0 +1,93 @@ +package org.cyclops.integratedterminals.client.gui.toast; + +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.toasts.Toast; +import net.minecraft.client.gui.components.toasts.ToastComponent; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.util.FormattedCharSequence; +import net.minecraft.world.item.ItemStack; +import org.cyclops.integratedterminals.Reference; + +import java.util.List; + +/** + * A toast that shows an item icon alongside a title and a wrapping subtitle. + * + * Toasts with an equal token replace each other instead of being queued, + * so the token determines how toasts are grouped. + * + * @author rubensworks + */ +public class CraftingJobToast implements Toast { + + private static final ResourceLocation BACKGROUND_SPRITE = ResourceLocation.fromNamespaceAndPath(Reference.MOD_ID, "toast/crafting_job"); + private static final int DISPLAY_MILLIS = 5000; + private static final int MARGIN = 7; + private static final int ICON_LEFT = 7; + private static final int ICON_SIZE = 16; + private static final int TEXT_LEFT = ICON_LEFT + ICON_SIZE + 5; + private static final int LINE_SPACING = 12; + + private final Object token; + private final ItemStack icon; + private Component title; + private List subtitleLines; + private long lastChangedAt = Long.MIN_VALUE; + private boolean changed = true; + + public CraftingJobToast(Object token, ItemStack icon, Component title, Component subtitle) { + this.token = token; + this.icon = icon; + this.title = title; + this.subtitleLines = splitSubtitle(subtitle); + } + + /** + * Update the contents of this toast in-place, without queueing a new one. + * @param newTitle The new title. + * @param newSubtitle The new subtitle. + */ + public void reset(Component newTitle, Component newSubtitle) { + this.title = newTitle; + this.subtitleLines = splitSubtitle(newSubtitle); + this.changed = true; + } + + private List splitSubtitle(Component text) { + return Minecraft.getInstance().font.split(text, width() - TEXT_LEFT - MARGIN); + } + + @Override + public int height() { + return 20 + Math.max(1, subtitleLines.size()) * LINE_SPACING; + } + + @Override + public Visibility render(GuiGraphics graphics, ToastComponent toastComponent, long timeSinceLastVisible) { + if (changed) { + lastChangedAt = timeSinceLastVisible; + changed = false; + } + + graphics.blitSprite(BACKGROUND_SPRITE, 0, 0, width(), height()); + graphics.renderItem(icon, ICON_LEFT, 8); + + var font = toastComponent.getMinecraft().font; + graphics.drawString(font, title, TEXT_LEFT, 7, 0xFFFFFF, false); + for (int i = 0; i < subtitleLines.size(); i++) { + graphics.drawString(font, subtitleLines.get(i), TEXT_LEFT, 18 + i * LINE_SPACING, 0xAAAAAA, false); + } + + return timeSinceLastVisible - lastChangedAt < (long) (DISPLAY_MILLIS * toastComponent.getNotificationDisplayTimeMultiplier()) + ? Visibility.SHOW + : Visibility.HIDE; + } + + @Override + public Object getToken() { + return token; + } + +} diff --git a/src/main/java/org/cyclops/integratedterminals/gametest/GameTestCraftingJobNotify.java b/src/main/java/org/cyclops/integratedterminals/gametest/GameTestCraftingJobNotify.java new file mode 100644 index 0000000000..6c8853a986 --- /dev/null +++ b/src/main/java/org/cyclops/integratedterminals/gametest/GameTestCraftingJobNotify.java @@ -0,0 +1,131 @@ +package org.cyclops.integratedterminals.gametest; + +import com.mojang.authlib.GameProfile; +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.server.level.ClientInformation; +import net.minecraft.server.level.ServerPlayer; +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.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.commoncapabilities.api.capability.recipehandler.IRecipeDefinition; +import org.cyclops.integratedcrafting.api.crafting.CraftingJob; +import org.cyclops.integratedcrafting.api.network.ICraftingNetwork; +import org.cyclops.integratedcrafting.core.CraftingHelpers; +import org.cyclops.integratedcrafting.gametest.GameTestHelpersIntegratedCrafting; +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 org.cyclops.integratedterminals.Reference; +import org.cyclops.integratedterminals.api.terminalstorage.crafting.ITerminalCraftingPlan; +import org.cyclops.integratedterminals.modcompat.integratedcrafting.TerminalCraftingOptionRecipeDefinition; +import org.cyclops.integratedterminals.modcompat.integratedcrafting.TerminalStorageTabIngredientCraftingHandlerCraftingNetwork; + +import java.util.Iterator; +import java.util.UUID; + +/** + * Game tests for requesting a notification when a crafting job started from a terminal is completed. + * @author rubensworks + */ +@GameTestHolder(Reference.MOD_ID) +@PrefixGameTestTemplate(false) +public class GameTestCraftingJobNotify { + + public static final BlockPos POS = BlockPos.ZERO.offset(2, 0, 2); + + /** + * A job started with the notify option enabled carries the initiator and the notify flag. + */ + @GameTest(template = "empty10", templateNamespace = Reference.MOD_ID, timeoutTicks = 2000) + public void testStartCraftingJobWithNotify(GameTestHelper helper) { + testStartCraftingJob(helper, true); + } + + /** + * A job started with the notify option disabled carries the initiator, but not the notify flag. + */ + @GameTest(template = "empty10", templateNamespace = Reference.MOD_ID, timeoutTicks = 2000) + public void testStartCraftingJobWithoutNotify(GameTestHelper helper) { + testStartCraftingJob(helper, false); + } + + private void testStartCraftingJob(GameTestHelper helper, boolean notifyOnCompletion) { + prepareNetwork(helper); + + // This player is deliberately not added to the player list, + // so that no notification packet is sent for the completed job. + ServerPlayer player = new ServerPlayer(helper.getLevel().getServer(), helper.getLevel(), + new GameProfile(UUID.randomUUID(), "test-mock-player"), ClientInformation.createDefault()); + + helper.startSequence() + .thenIdle(20) + .thenExecute(() -> { + INetwork network = getNetwork(helper); + int channel = IPositionedAddonsNetworkIngredients.DEFAULT_CHANNEL; + TerminalStorageTabIngredientCraftingHandlerCraftingNetwork handler = + new TerminalStorageTabIngredientCraftingHandlerCraftingNetwork(); + + ITerminalCraftingPlan craftingPlan = handler.calculateCraftingPlan(network, channel, + new TerminalCraftingOptionRecipeDefinition<>(IngredientComponents.ITEMSTACK, + getChestRecipe(helper, network, channel)), 1); + try { + handler.startCraftingJob(network, channel, craftingPlan, player, notifyOnCompletion); + } catch (Exception e) { + helper.fail("The crafting job could not be started: " + e.getMessage()); + } + + CraftingJob craftingJob = getSingleCraftingJob(helper, network, channel); + helper.assertTrue(player.getUUID().toString().equals(craftingJob.getInitiatorUuid()), + "The started job did not carry the initiator"); + helper.assertTrue(craftingJob.isNotifyInitiator() == notifyOnCompletion, + "The started job did not carry the expected notify flag"); + }) + .thenSucceed(); + } + + private static void prepareNetwork(GameTestHelper helper) { + GameTestHelpersIntegratedCrafting.INetworkPositions positions = + GameTestHelpersIntegratedCrafting.createBasicNetwork(helper, POS); + + ChestBlockEntity chest = helper.getBlockEntity(POS.east()); + chest.setItem(0, new ItemStack(Items.OAK_PLANKS, 64)); + + positions.interfaceRecipeAdders().get(0).accept(Triple.of(0, RecipeType.CRAFTING, + ResourceLocation.fromNamespaceAndPath("minecraft", "chest"))); + } + + private static IRecipeDefinition getChestRecipe(GameTestHelper helper, INetwork network, int channel) { + Iterator recipes = CraftingHelpers.getCraftingNetworkChecked(network) + .getRecipeIndex(channel) + .getRecipes(IngredientComponents.ITEMSTACK, new ItemStack(Items.CHEST), ItemMatch.ITEM); + if (!recipes.hasNext()) { + helper.fail("No chest recipe was available in the network"); + } + return recipes.next(); + } + + private static CraftingJob getSingleCraftingJob(GameTestHelper helper, INetwork network, int channel) { + ICraftingNetwork craftingNetwork = CraftingHelpers.getCraftingNetworkChecked(network); + Iterator craftingJobs = craftingNetwork.getCraftingJobs(channel); + if (!craftingJobs.hasNext()) { + helper.fail("No crafting job was scheduled"); + } + return craftingJobs.next(); + } + + private static INetwork getNetwork(GameTestHelper helper) { + return NetworkHelpers.getNetwork(helper.getLevel(), helper.absolutePos(POS), null) + .orElseThrow(() -> new IllegalStateException("Could not find a network")); + } + +} diff --git a/src/main/java/org/cyclops/integratedterminals/inventory/container/ContainerTerminalStorageCraftingPlanBase.java b/src/main/java/org/cyclops/integratedterminals/inventory/container/ContainerTerminalStorageCraftingPlanBase.java index cc6791b0dd..5c10d188ae 100644 --- a/src/main/java/org/cyclops/integratedterminals/inventory/container/ContainerTerminalStorageCraftingPlanBase.java +++ b/src/main/java/org/cyclops/integratedterminals/inventory/container/ContainerTerminalStorageCraftingPlanBase.java @@ -36,6 +36,7 @@ public abstract class ContainerTerminalStorageCraftingPlanBase extends Invent private boolean calculatedCraftingPlan; private ITerminalCraftingPlan craftingPlan; + private boolean notifyOnCompletion = true; public ContainerTerminalStorageCraftingPlanBase(@Nullable MenuType type, int id, Inventory playerInventory, CraftingOptionGuiData craftingOptionGuiData) { @@ -59,6 +60,17 @@ public CraftingOptionGuiData getCraftingOptionGuiData() { return craftingOptionGuiData; } + /** + * @return If the player wants to be notified once the crafting job is completed. + */ + public boolean isNotifyOnCompletion() { + return notifyOnCompletion; + } + + public void setNotifyOnCompletion(boolean notifyOnCompletion) { + this.notifyOnCompletion = notifyOnCompletion; + } + @Override public void broadcastChanges() { super.broadcastChanges(); @@ -123,7 +135,7 @@ private void startCraftingJob() { getNetwork().ifPresent(network -> { try { craftingOptionGuiData.getCraftingOption().getHandler() - .startCraftingJob(network, craftingOptionGuiData.getChannel(), craftingPlan, (ServerPlayer) player); + .startCraftingJob(network, craftingOptionGuiData.getChannel(), craftingPlan, (ServerPlayer) player, this.notifyOnCompletion); // Re-open terminal gui craftingOptionGuiData.getLocation() diff --git a/src/main/java/org/cyclops/integratedterminals/modcompat/integratedcrafting/CraftingJobFinishedToastListener.java b/src/main/java/org/cyclops/integratedterminals/modcompat/integratedcrafting/CraftingJobFinishedToastListener.java new file mode 100644 index 0000000000..f051e6e660 --- /dev/null +++ b/src/main/java/org/cyclops/integratedterminals/modcompat/integratedcrafting/CraftingJobFinishedToastListener.java @@ -0,0 +1,81 @@ +package org.cyclops.integratedterminals.modcompat.integratedcrafting; + +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.item.ItemStack; +import net.neoforged.bus.api.SubscribeEvent; +import net.neoforged.neoforge.common.NeoForge; +import net.neoforged.neoforge.fluids.FluidStack; +import net.neoforged.neoforge.server.ServerLifecycleHooks; +import org.cyclops.commoncapabilities.api.ingredient.IPrototypedIngredient; +import org.cyclops.commoncapabilities.api.ingredient.IngredientComponent; +import org.cyclops.integratedcrafting.api.crafting.CraftingJob; +import org.cyclops.integratedcrafting.api.event.CraftingJobFinishedEvent; +import org.cyclops.integratedcrafting.core.CraftingHelpers; +import org.cyclops.integratedterminals.IntegratedTerminals; +import org.cyclops.integratedterminals.network.packet.CraftingJobFinishedToastPacket; + +import javax.annotation.Nullable; +import java.util.List; +import java.util.UUID; + +/** + * Sends a toast to the player that requested a crafting job once that job is completed. + * @author rubensworks + */ +public class CraftingJobFinishedToastListener { + + public static void register() { + NeoForge.EVENT_BUS.register(CraftingJobFinishedToastListener.class); + } + + @SubscribeEvent + public static void onCraftingJobFinished(CraftingJobFinishedEvent event) { + CraftingJob craftingJob = event.getCraftingJob(); + + // Only notify for the job that was requested, not for its dependencies. + if (!event.isRootJob() || !craftingJob.isNotifyInitiator() || craftingJob.getInitiatorUuid() == null) { + return; + } + + ServerPlayer player = getPlayer(craftingJob.getInitiatorUuid()); + if (player == null) { + // The initiator is not online, so there is nobody to notify. + return; + } + + // Jobs that were scheduled before the initial amount was tracked report an amount of 0 + int amount = Math.max(1, craftingJob.getInitialAmount()); + List> outputs = CraftingHelpers.multiplyPrototypedIngredients( + IntegratedCraftingHelpers.getPrototypesFromIngredients(craftingJob.getRecipe().getOutput()), amount); + if (outputs.isEmpty()) { + return; + } + + IPrototypedIngredient output = outputs.get(0); + ItemStack outputItem = output.getPrototype() instanceof ItemStack itemStack ? itemStack : ItemStack.EMPTY; + IntegratedTerminals._instance.getPacketHandler().sendToPlayer( + new CraftingJobFinishedToastPacket(outputItem, outputItem.isEmpty() ? formatOutput(output) : ""), player); + } + + /** + * Describe an output that can not be sent to the client as an item stack. + */ + protected static String formatOutput(IPrototypedIngredient output) { + T prototype = output.getPrototype(); + if (prototype instanceof FluidStack fluidStack) { + return fluidStack.getAmount() + "x " + fluidStack.getHoverName().getString(); + } + IngredientComponent component = output.getComponent(); + return component.getMatcher().getQuantity(prototype) + "x " + component.getName(); + } + + @Nullable + protected static ServerPlayer getPlayer(String initiatorUuid) { + try { + return ServerLifecycleHooks.getCurrentServer().getPlayerList().getPlayer(UUID.fromString(initiatorUuid)); + } catch (IllegalArgumentException e) { + return null; + } + } + +} diff --git a/src/main/java/org/cyclops/integratedterminals/modcompat/integratedcrafting/IntegratedCraftingModCompatInitializer.java b/src/main/java/org/cyclops/integratedterminals/modcompat/integratedcrafting/IntegratedCraftingModCompatInitializer.java index c7cc6ca949..928775a4d6 100644 --- a/src/main/java/org/cyclops/integratedterminals/modcompat/integratedcrafting/IntegratedCraftingModCompatInitializer.java +++ b/src/main/java/org/cyclops/integratedterminals/modcompat/integratedcrafting/IntegratedCraftingModCompatInitializer.java @@ -11,5 +11,6 @@ public class IntegratedCraftingModCompatInitializer implements ICompatInitialize public void initialize() { TerminalStorageTabIngredientCraftingHandlers.REGISTRY.register( new TerminalStorageTabIngredientCraftingHandlerCraftingNetwork()); + CraftingJobFinishedToastListener.register(); } } diff --git a/src/main/java/org/cyclops/integratedterminals/modcompat/integratedcrafting/TerminalStorageTabIngredientCraftingHandlerCraftingNetwork.java b/src/main/java/org/cyclops/integratedterminals/modcompat/integratedcrafting/TerminalStorageTabIngredientCraftingHandlerCraftingNetwork.java index 36ac8e39ef..85b9b268fc 100644 --- a/src/main/java/org/cyclops/integratedterminals/modcompat/integratedcrafting/TerminalStorageTabIngredientCraftingHandlerCraftingNetwork.java +++ b/src/main/java/org/cyclops/integratedterminals/modcompat/integratedcrafting/TerminalStorageTabIngredientCraftingHandlerCraftingNetwork.java @@ -213,12 +213,12 @@ protected static ITerminalCraftingPlan newCraftingPlanErrorRecursive(Li @Override public void startCraftingJob(INetwork network, int channel, ITerminalCraftingPlan craftingPlan, - ServerPlayer player) throws CraftingJobStartException { + ServerPlayer player, boolean notifyOnCompletion) throws CraftingJobStartException { if (craftingPlan instanceof TerminalCraftingPlanCraftingJobDependencyGraph && craftingPlan.getStatus() == TerminalCraftingJobStatus.UNSTARTED) { CraftingJobDependencyGraph craftingJobDependencyGraph = ((TerminalCraftingPlanCraftingJobDependencyGraph) craftingPlan).getCraftingJobDependencyGraph(); try { - CraftingHelpers.scheduleCraftingJobs(CraftingHelpers.getCraftingNetworkChecked(network), CraftingHelpers.getNetworkStorageGetter(network, channel, false), craftingJobDependencyGraph, true, player.getUUID()); + CraftingHelpers.scheduleCraftingJobs(CraftingHelpers.getCraftingNetworkChecked(network), CraftingHelpers.getNetworkStorageGetter(network, channel, false), craftingJobDependencyGraph, true, player.getUUID(), notifyOnCompletion); } catch (UnavailableCraftingInterfacesException e) { throw new CraftingJobStartException("gui.integratedterminals.terminal_storage.craftingplan.label.failed.insufficient_crafting_interfaces"); } diff --git a/src/main/java/org/cyclops/integratedterminals/network/packet/CraftingJobFinishedToastPacket.java b/src/main/java/org/cyclops/integratedterminals/network/packet/CraftingJobFinishedToastPacket.java new file mode 100644 index 0000000000..e379832eb3 --- /dev/null +++ b/src/main/java/org/cyclops/integratedterminals/network/packet/CraftingJobFinishedToastPacket.java @@ -0,0 +1,88 @@ +package org.cyclops.integratedterminals.network.packet; + +import net.minecraft.ChatFormatting; +import net.minecraft.client.Minecraft; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.chat.Component; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import net.minecraft.world.level.Level; +import net.neoforged.api.distmarker.Dist; +import net.neoforged.api.distmarker.OnlyIn; +import org.cyclops.cyclopscore.network.CodecField; +import org.cyclops.cyclopscore.network.PacketCodec; +import org.cyclops.integratedterminals.GeneralConfig; +import org.cyclops.integratedterminals.Reference; +import org.cyclops.integratedterminals.client.gui.toast.CraftingJobToast; + +/** + * Packet for showing a toast when a crafting job that the player requested has been completed. + * @author rubensworks + */ +public class CraftingJobFinishedToastPacket extends PacketCodec { + + public static final Type ID = new Type<>(ResourceLocation.fromNamespaceAndPath(Reference.MOD_ID, "crafting_job_finished_toast")); + public static final StreamCodec CODEC = getCodec(CraftingJobFinishedToastPacket::new); + + @CodecField + private ItemStack outputItem = ItemStack.EMPTY; + @CodecField + private String outputLabel; + + public CraftingJobFinishedToastPacket() { + super(ID); + } + + /** + * @param outputItem The crafted item, or an empty stack if the job did not output items. + * @param outputLabel A textual description of the output, used when no output item is available. + */ + public CraftingJobFinishedToastPacket(ItemStack outputItem, String outputLabel) { + super(ID); + this.outputItem = outputItem; + this.outputLabel = outputLabel; + } + + @Override + public boolean isAsync() { + return false; + } + + @Override + @OnlyIn(Dist.CLIENT) + public void actionClient(Level world, Player player) { + if (!GeneralConfig.craftingJobFinishedToast) { + return; + } + + boolean hasItem = !this.outputItem.isEmpty(); + Component output = hasItem + ? Component.literal(this.outputItem.getCount() + "x ").append(this.outputItem.getHoverName()) + : Component.literal(this.outputLabel); + Component title = Component.translatable("gui.integratedterminals.crafting_job.finished.title") + .withStyle(ChatFormatting.GREEN); + Component subtitle = Component.translatable("gui.integratedterminals.crafting_job.finished", output); + + // Group toasts by output, so that repeated crafts of the same thing don't pile up. + Object token = hasItem ? this.outputItem.getItem() : this.outputLabel; + ItemStack icon = hasItem ? this.outputItem : new ItemStack(Items.CRAFTING_TABLE); + + var toasts = Minecraft.getInstance().getToasts(); + CraftingJobToast existing = toasts.getToast(CraftingJobToast.class, token); + if (existing != null) { + existing.reset(title, subtitle); + } else { + toasts.addToast(new CraftingJobToast(token, icon, title, subtitle)); + } + } + + @Override + public void actionServer(Level world, ServerPlayer player) { + // Server-to-client only packet + } + +} diff --git a/src/main/java/org/cyclops/integratedterminals/network/packet/TerminalStorageCraftingPlanSetNotifyPacket.java b/src/main/java/org/cyclops/integratedterminals/network/packet/TerminalStorageCraftingPlanSetNotifyPacket.java new file mode 100644 index 0000000000..e2497f28a7 --- /dev/null +++ b/src/main/java/org/cyclops/integratedterminals/network/packet/TerminalStorageCraftingPlanSetNotifyPacket.java @@ -0,0 +1,55 @@ +package org.cyclops.integratedterminals.network.packet; + +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.level.Level; +import net.neoforged.api.distmarker.Dist; +import net.neoforged.api.distmarker.OnlyIn; +import org.cyclops.cyclopscore.network.CodecField; +import org.cyclops.cyclopscore.network.PacketCodec; +import org.cyclops.integratedterminals.Reference; +import org.cyclops.integratedterminals.inventory.container.ContainerTerminalStorageCraftingPlanBase; + +/** + * Packet for telling the server if the player wants to be notified when the planned crafting job is completed. + * @author rubensworks + */ +public class TerminalStorageCraftingPlanSetNotifyPacket extends PacketCodec { + + public static final Type ID = new Type<>(ResourceLocation.fromNamespaceAndPath(Reference.MOD_ID, "terminal_storage_crafting_plan_set_notify")); + public static final StreamCodec CODEC = getCodec(TerminalStorageCraftingPlanSetNotifyPacket::new); + + @CodecField + private boolean notify; + + public TerminalStorageCraftingPlanSetNotifyPacket() { + super(ID); + } + + public TerminalStorageCraftingPlanSetNotifyPacket(boolean notify) { + super(ID); + this.notify = notify; + } + + @Override + public boolean isAsync() { + return false; + } + + @Override + @OnlyIn(Dist.CLIENT) + public void actionClient(Level world, Player player) { + + } + + @Override + public void actionServer(Level world, ServerPlayer player) { + if (player.containerMenu instanceof ContainerTerminalStorageCraftingPlanBase container) { + container.setNotifyOnCompletion(this.notify); + } + } + +} diff --git a/src/main/java/org/cyclops/integratedterminals/proxy/CommonProxy.java b/src/main/java/org/cyclops/integratedterminals/proxy/CommonProxy.java index d654ed546b..cfadb6cf63 100644 --- a/src/main/java/org/cyclops/integratedterminals/proxy/CommonProxy.java +++ b/src/main/java/org/cyclops/integratedterminals/proxy/CommonProxy.java @@ -44,6 +44,8 @@ public void registerPacketHandlers(PacketHandler packetHandler) { packetHandler.register(OpenCraftingJobsPlanGuiPacket.ID, OpenCraftingJobsPlanGuiPacket.CODEC); packetHandler.register(OpenCraftingJobsGuiPacket.ID, OpenCraftingJobsGuiPacket.CODEC); packetHandler.register(CancelCraftingJobPacket.ID, CancelCraftingJobPacket.CODEC); + packetHandler.register(TerminalStorageCraftingPlanSetNotifyPacket.ID, TerminalStorageCraftingPlanSetNotifyPacket.CODEC); + packetHandler.register(CraftingJobFinishedToastPacket.ID, CraftingJobFinishedToastPacket.CODEC); IntegratedDynamics.clog("Registered packet handler."); } diff --git a/src/main/resources/assets/integratedterminals/lang/en_us.json b/src/main/resources/assets/integratedterminals/lang/en_us.json index 3b8c7f4ae0..9a30f30c08 100644 --- a/src/main/resources/assets/integratedterminals/lang/en_us.json +++ b/src/main/resources/assets/integratedterminals/lang/en_us.json @@ -6,6 +6,8 @@ "_comment": "Gui", "gui.integratedterminals.amount": "Amount", "gui.integratedterminals.channel": "Channel", + "gui.integratedterminals.crafting_job.finished.title": "Crafting Job Completed", + "gui.integratedterminals.crafting_job.finished": "Finished crafting %s", "gui.integratedterminals.terminal_storage.tooltip.energy": "Energy", "gui.integratedterminals.terminal_storage.tooltip.energy.amount": "%s FE", "gui.integratedterminals.terminal_storage.tooltip.fluid.amount": "%s mB", @@ -24,6 +26,7 @@ "gui.integratedterminals.terminal_storage.step.next": "Next", "gui.integratedterminals.terminal_storage.step.back": "Back", "gui.integratedterminals.terminal_storage.step.craft": "Craft", + "gui.integratedterminals.terminal_storage.step.craft.notify": "Notify me when this crafting job is completed", "gui.integratedterminals.terminal_storage.step.crafting_plan_calculating": "Calculating crafting plan...", "gui.integratedterminals.terminal_storage.stored": "Stored: %s", "gui.integratedterminals.terminal_storage.to_craft": "To Craft: %s", diff --git a/src/main/resources/assets/integratedterminals/textures/gui/sprites/toast/crafting_job.png b/src/main/resources/assets/integratedterminals/textures/gui/sprites/toast/crafting_job.png new file mode 100644 index 0000000000000000000000000000000000000000..81e467513fa280e8d8eef052f5c39961941dbdb4 GIT binary patch literal 183 zcmeAS@N?(olHy`uVBq!ia0vp^3xHUGgAGVdtuOuxr0P6f978H@y}h%Mv%x^1?c$k< zhgg}CGBuZME}6gP;jXTwd!Mnyf6Qxmy!!u?<3AfTURq_}Ubs8DVQJolUWT7(4TrZJ z$Y$wa5M00@sK6wmz~s^ZL>w0;3n(zLFe*7 R4WLUHJYD@<);T3K0RY4DK5PI0 literal 0 HcmV?d00001 diff --git a/src/main/resources/assets/integratedterminals/textures/gui/sprites/toast/crafting_job.png.mcmeta b/src/main/resources/assets/integratedterminals/textures/gui/sprites/toast/crafting_job.png.mcmeta new file mode 100644 index 0000000000..8e76a6afab --- /dev/null +++ b/src/main/resources/assets/integratedterminals/textures/gui/sprites/toast/crafting_job.png.mcmeta @@ -0,0 +1,11 @@ +{ + "gui": { + "scaling": { + "type": "nine_slice", + "width": 160, + "height": 32, + "border": 4 + } + } +} + From 9ebe94e2c741fca12f6fccea5cbb81efe1ac3dfc Mon Sep 17 00:00:00 2001 From: Ruben Taelman Date: Wed, 2 Sep 2026 16:13:27 +0000 Subject: [PATCH 02/10] Fix generic inference when multiplying the completed job's outputs CraftingHelpers#multiplyPrototypedIngredients can not infer its type variables from a wildcard list, so go through a raw list like the other callers do. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QNDY2pZtkXqJrhztYCvNbh --- .../integratedcrafting/CraftingJobFinishedToastListener.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/cyclops/integratedterminals/modcompat/integratedcrafting/CraftingJobFinishedToastListener.java b/src/main/java/org/cyclops/integratedterminals/modcompat/integratedcrafting/CraftingJobFinishedToastListener.java index f051e6e660..1e8cc0553e 100644 --- a/src/main/java/org/cyclops/integratedterminals/modcompat/integratedcrafting/CraftingJobFinishedToastListener.java +++ b/src/main/java/org/cyclops/integratedterminals/modcompat/integratedcrafting/CraftingJobFinishedToastListener.java @@ -45,8 +45,8 @@ public static void onCraftingJobFinished(CraftingJobFinishedEvent event) { // Jobs that were scheduled before the initial amount was tracked report an amount of 0 int amount = Math.max(1, craftingJob.getInitialAmount()); - List> outputs = CraftingHelpers.multiplyPrototypedIngredients( - IntegratedCraftingHelpers.getPrototypesFromIngredients(craftingJob.getRecipe().getOutput()), amount); + List recipeOutputs = IntegratedCraftingHelpers.getPrototypesFromIngredients(craftingJob.getRecipe().getOutput()); + List> outputs = CraftingHelpers.multiplyPrototypedIngredients(recipeOutputs, amount); if (outputs.isEmpty()) { return; } From 19e3e4f9381a723e043cb07cee54b8222f1d08be Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 17:05:43 +0000 Subject: [PATCH 03/10] Label the crafting job notify checkbox Make room on the crafting plan button row by narrowing the plan view toggle to 62px, which still fits its widest label, and shifting the back and craft buttons right. Also shorten the toast subtitle, as the longer phrasing wrapped onto a second line at the standard toast width. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QNDY2pZtkXqJrhztYCvNbh --- ...tainerScreenTerminalStorageCraftingPlan.java | 17 ++++++++--------- .../assets/integratedterminals/lang/en_us.json | 5 +++-- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/main/java/org/cyclops/integratedterminals/client/gui/container/ContainerScreenTerminalStorageCraftingPlan.java b/src/main/java/org/cyclops/integratedterminals/client/gui/container/ContainerScreenTerminalStorageCraftingPlan.java index 7df64c3017..642a463b00 100644 --- a/src/main/java/org/cyclops/integratedterminals/client/gui/container/ContainerScreenTerminalStorageCraftingPlan.java +++ b/src/main/java/org/cyclops/integratedterminals/client/gui/container/ContainerScreenTerminalStorageCraftingPlan.java @@ -55,7 +55,7 @@ public ContainerScreenTerminalStorageCraftingPlan(C container, Inventory invento addRenderableWidget(this.guiCraftingPlan); if (this.craftingPlanFlat != null) { - addRenderableWidget(new ButtonText(leftPos + 8, topPos + 198, 80, 20, + addRenderableWidget(new ButtonText(leftPos + 8, topPos + 198, 62, 20, Component.translatable("gui.integratedterminals.craftingplan.view.flat"), Component.translatable("gui.integratedterminals.craftingplan.view.flat").withStyle(ChatFormatting.ITALIC), (b) -> { @@ -70,7 +70,7 @@ public ContainerScreenTerminalStorageCraftingPlan(C container, Inventory invento addRenderableWidget(this.guiCraftingPlanFlat); if (this.craftingPlan != null) { - addRenderableWidget(new ButtonText(leftPos + 8, topPos + 198, 80, 20, + addRenderableWidget(new ButtonText(leftPos + 8, topPos + 198, 62, 20, Component.translatable("gui.integratedterminals.craftingplan.view.tree"), Component.translatable("gui.integratedterminals.craftingplan.view.tree").withStyle(ChatFormatting.ITALIC), (b) -> { @@ -119,21 +119,20 @@ public void init() { this.guiCraftingPlanToggler.init(); - addRenderableWidget(new ButtonText(leftPos + 221 + 10 - 50 - 55, topPos + 198, 50, 20, + addRenderableWidget(new ButtonText(leftPos + 132, topPos + 198, 50, 20, Component.translatable("gui.integratedterminals.terminal_storage.step.back"), Component.translatable("gui.integratedterminals.terminal_storage.step.back"), (b) -> returnToCraftingOptionAmount(), true)); - Checkbox checkboxNotify = Checkbox.builder(Component.empty(), font) - .pos(leftPos + 97, topPos + 202) + addRenderableWidget(Checkbox.builder(Component.translatable("gui.integratedterminals.terminal_storage.step.craft.notify"), font) + .pos(leftPos + 75, topPos + 200) .selected(this.notifyOnCompletion) - .tooltip(Tooltip.create(Component.translatable("gui.integratedterminals.terminal_storage.step.craft.notify"))) + .tooltip(Tooltip.create(Component.translatable("gui.integratedterminals.terminal_storage.step.craft.notify.info"))) .onValueChange((widget, selected) -> setNotifyOnCompletion(selected)) - .build(); - addRenderableWidget(checkboxNotify); + .build()); - addRenderableWidget(buttonConfirm = new ButtonText(leftPos + 221 + 10 - 50, topPos + 198, 50, 20, + addRenderableWidget(buttonConfirm = new ButtonText(leftPos + 187, topPos + 198, 50, 20, Component.translatable("gui.integratedterminals.terminal_storage.step.craft"), Component.translatable("gui.integratedterminals.terminal_storage.step.craft").withStyle(ChatFormatting.YELLOW), createServerPressable(ContainerTerminalStorageCraftingPlanBase.BUTTON_START, (b) -> {}), diff --git a/src/main/resources/assets/integratedterminals/lang/en_us.json b/src/main/resources/assets/integratedterminals/lang/en_us.json index 9a30f30c08..9b545da867 100644 --- a/src/main/resources/assets/integratedterminals/lang/en_us.json +++ b/src/main/resources/assets/integratedterminals/lang/en_us.json @@ -7,7 +7,7 @@ "gui.integratedterminals.amount": "Amount", "gui.integratedterminals.channel": "Channel", "gui.integratedterminals.crafting_job.finished.title": "Crafting Job Completed", - "gui.integratedterminals.crafting_job.finished": "Finished crafting %s", + "gui.integratedterminals.crafting_job.finished": "Crafted %s", "gui.integratedterminals.terminal_storage.tooltip.energy": "Energy", "gui.integratedterminals.terminal_storage.tooltip.energy.amount": "%s FE", "gui.integratedterminals.terminal_storage.tooltip.fluid.amount": "%s mB", @@ -26,7 +26,8 @@ "gui.integratedterminals.terminal_storage.step.next": "Next", "gui.integratedterminals.terminal_storage.step.back": "Back", "gui.integratedterminals.terminal_storage.step.craft": "Craft", - "gui.integratedterminals.terminal_storage.step.craft.notify": "Notify me when this crafting job is completed", + "gui.integratedterminals.terminal_storage.step.craft.notify": "Notify", + "gui.integratedterminals.terminal_storage.step.craft.notify.info": "Show a toast when this crafting job is completed", "gui.integratedterminals.terminal_storage.step.crafting_plan_calculating": "Calculating crafting plan...", "gui.integratedterminals.terminal_storage.stored": "Stored: %s", "gui.integratedterminals.terminal_storage.to_craft": "To Craft: %s", From ac2c501fd0693cd5c4b02b1d7a548dec599dd90b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 17:09:54 +0000 Subject: [PATCH 04/10] Keep the crafting plan buttons within the gui The button row's background is narrower than the plan list above it, so the back and craft buttons return to their original positions, and the notify checkbox takes the space freed by the narrower view toggle. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QNDY2pZtkXqJrhztYCvNbh --- .../ContainerScreenTerminalStorageCraftingPlan.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/cyclops/integratedterminals/client/gui/container/ContainerScreenTerminalStorageCraftingPlan.java b/src/main/java/org/cyclops/integratedterminals/client/gui/container/ContainerScreenTerminalStorageCraftingPlan.java index 642a463b00..7d961e3521 100644 --- a/src/main/java/org/cyclops/integratedterminals/client/gui/container/ContainerScreenTerminalStorageCraftingPlan.java +++ b/src/main/java/org/cyclops/integratedterminals/client/gui/container/ContainerScreenTerminalStorageCraftingPlan.java @@ -119,20 +119,20 @@ public void init() { this.guiCraftingPlanToggler.init(); - addRenderableWidget(new ButtonText(leftPos + 132, topPos + 198, 50, 20, + addRenderableWidget(new ButtonText(leftPos + 221 + 10 - 50 - 55, topPos + 198, 50, 20, Component.translatable("gui.integratedterminals.terminal_storage.step.back"), Component.translatable("gui.integratedterminals.terminal_storage.step.back"), (b) -> returnToCraftingOptionAmount(), true)); addRenderableWidget(Checkbox.builder(Component.translatable("gui.integratedterminals.terminal_storage.step.craft.notify"), font) - .pos(leftPos + 75, topPos + 200) + .pos(leftPos + 72, topPos + 200) .selected(this.notifyOnCompletion) .tooltip(Tooltip.create(Component.translatable("gui.integratedterminals.terminal_storage.step.craft.notify.info"))) .onValueChange((widget, selected) -> setNotifyOnCompletion(selected)) .build()); - addRenderableWidget(buttonConfirm = new ButtonText(leftPos + 187, topPos + 198, 50, 20, + addRenderableWidget(buttonConfirm = new ButtonText(leftPos + 221 + 10 - 50, topPos + 198, 50, 20, Component.translatable("gui.integratedterminals.terminal_storage.step.craft"), Component.translatable("gui.integratedterminals.terminal_storage.step.craft").withStyle(ChatFormatting.YELLOW), createServerPressable(ContainerTerminalStorageCraftingPlanBase.BUTTON_START, (b) -> {}), From 2318b42be5f6d7a0bdb2fbbd099a9e97fef723c4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 18:50:47 +0000 Subject: [PATCH 05/10] Use the crafting job's total amount for the completion toast IntegratedCrafting now tracks the total amount on the job itself, so the toast no longer needs its own copy of it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QNDY2pZtkXqJrhztYCvNbh --- .../ITerminalStorageTabIngredientCraftingHandler.java | 1 + .../integratedcrafting/CraftingJobFinishedToastListener.java | 5 ++--- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/cyclops/integratedterminals/api/terminalstorage/crafting/ITerminalStorageTabIngredientCraftingHandler.java b/src/main/java/org/cyclops/integratedterminals/api/terminalstorage/crafting/ITerminalStorageTabIngredientCraftingHandler.java index 385c81e18a..f995c6839f 100644 --- a/src/main/java/org/cyclops/integratedterminals/api/terminalstorage/crafting/ITerminalStorageTabIngredientCraftingHandler.java +++ b/src/main/java/org/cyclops/integratedterminals/api/terminalstorage/crafting/ITerminalStorageTabIngredientCraftingHandler.java @@ -157,6 +157,7 @@ public default ITerminalCraftingPlanFlat deserializeCraftingPlanFlat(HolderLo * @param player The player that started the crafting job. * @throws CraftingJobStartException If the crafting job failed to start. */ + @Deprecated // TODO: rm in next major public default void startCraftingJob(INetwork network, int channel, ITerminalCraftingPlan craftingPlan, ServerPlayer player) throws CraftingJobStartException { startCraftingJob(network, channel, craftingPlan, player, true); diff --git a/src/main/java/org/cyclops/integratedterminals/modcompat/integratedcrafting/CraftingJobFinishedToastListener.java b/src/main/java/org/cyclops/integratedterminals/modcompat/integratedcrafting/CraftingJobFinishedToastListener.java index 1e8cc0553e..9445a05aba 100644 --- a/src/main/java/org/cyclops/integratedterminals/modcompat/integratedcrafting/CraftingJobFinishedToastListener.java +++ b/src/main/java/org/cyclops/integratedterminals/modcompat/integratedcrafting/CraftingJobFinishedToastListener.java @@ -43,10 +43,9 @@ public static void onCraftingJobFinished(CraftingJobFinishedEvent event) { return; } - // Jobs that were scheduled before the initial amount was tracked report an amount of 0 - int amount = Math.max(1, craftingJob.getInitialAmount()); List recipeOutputs = IntegratedCraftingHelpers.getPrototypesFromIngredients(craftingJob.getRecipe().getOutput()); - List> outputs = CraftingHelpers.multiplyPrototypedIngredients(recipeOutputs, amount); + List> outputs = CraftingHelpers.multiplyPrototypedIngredients( + recipeOutputs, craftingJob.getAmountTotal()); if (outputs.isEmpty()) { return; } From 00996d415148748be9a23c46bb0219be704634a6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 19:03:39 +0000 Subject: [PATCH 06/10] Bump IntegratedCrafting to 1.5.0-681 This is the first build that exposes the crafting job completion event. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QNDY2pZtkXqJrhztYCvNbh --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 5f7082b966..3b832c9bda 100644 --- a/gradle.properties +++ b/gradle.properties @@ -34,6 +34,6 @@ org.gradle.caching=true cyclopscore_version=1.26.2-808 integrateddynamics_version=1.32.0-1630 integratedterminalscompat_version=1.0.0-167 -integratedcrafting_version=1.4.1-442 +integratedcrafting_version=1.5.0-681 integratedtunnels_version=1.8.44-484 commoncapabilities_version=2.9.12-263 From adc5d716e9e970076d6e142339473a553f0a91a8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 19:33:10 +0000 Subject: [PATCH 07/10] Restyle the crafting job toast in the mod's colours The background now uses the deep teal that this mod's icons already use, with a menril-coloured bevel, instead of a generic dark panel. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QNDY2pZtkXqJrhztYCvNbh --- .../textures/gui/sprites/toast/crafting_job.png | Bin 183 -> 243 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/src/main/resources/assets/integratedterminals/textures/gui/sprites/toast/crafting_job.png b/src/main/resources/assets/integratedterminals/textures/gui/sprites/toast/crafting_job.png index 81e467513fa280e8d8eef052f5c39961941dbdb4..40df18f58322489c8fc47cf4eefa70654d9159cf 100644 GIT binary patch delta 215 zcmdna_?dBnO8qWR7srr_IdAW5F&C1yWJf<;zLbi*1fdMxKMIuKhv63vyK%PiAfpD_=SdU9 z_}9&|Rh$2aY3+Dt4Mj=&)MJygu7&?U%0-032fPw*7MbQL5mmjo$Z}xD0 oj>955=?{Oy*>zTaww3nuzskmYT5qN=HvXxF zk5~Vna{On5#!IX0+Y5I`H!RJ&(97^Mt>N&N1KBJc41x<71QnP>6qsBZfQaM5WB~;x t7Dgop45}raiRCoMAAJW7Y4s)zb&+i$t6ERrHo3t71fH&bF6*2UngF#QJ2(IU From bc836f9deb051e5837c56ea3005b5fc1774ccd57 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 18:23:41 +0000 Subject: [PATCH 08/10] Show any crafted ingredient in the toast, and default the notify option to off The toast now draws its output through the ingredient component's terminal storage handler, so crafted fluids, energy, and ingredient components from other mods are shown just like items, with their own quantity formatting. The notify option is synchronised through the container's value notifier rather than through a dedicated packet, and is disabled by default. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QNDY2pZtkXqJrhztYCvNbh --- ...inerScreenTerminalStorageCraftingPlan.java | 12 +- .../client/gui/toast/CraftingJobToast.java | 28 +++-- .../gametest/GameTestCraftingJobNotify.java | 111 ++++++++++++++++++ ...tainerTerminalStorageCraftingPlanBase.java | 15 ++- .../CraftingJobFinishedToastListener.java | 22 +--- .../CraftingJobFinishedToastPacket.java | 71 +++++++---- ...nalStorageCraftingPlanSetNotifyPacket.java | 55 --------- .../proxy/CommonProxy.java | 1 - .../integratedterminals/lang/en_us.json | 2 +- 9 files changed, 198 insertions(+), 119 deletions(-) delete mode 100644 src/main/java/org/cyclops/integratedterminals/network/packet/TerminalStorageCraftingPlanSetNotifyPacket.java diff --git a/src/main/java/org/cyclops/integratedterminals/client/gui/container/ContainerScreenTerminalStorageCraftingPlan.java b/src/main/java/org/cyclops/integratedterminals/client/gui/container/ContainerScreenTerminalStorageCraftingPlan.java index 7d961e3521..f02941760d 100644 --- a/src/main/java/org/cyclops/integratedterminals/client/gui/container/ContainerScreenTerminalStorageCraftingPlan.java +++ b/src/main/java/org/cyclops/integratedterminals/client/gui/container/ContainerScreenTerminalStorageCraftingPlan.java @@ -20,7 +20,6 @@ import org.cyclops.integratedterminals.client.gui.container.component.GuiCraftingPlanToggler; import org.cyclops.integratedterminals.core.client.gui.CraftingOptionGuiData; import org.cyclops.integratedterminals.inventory.container.ContainerTerminalStorageCraftingPlanBase; -import org.cyclops.integratedterminals.network.packet.TerminalStorageCraftingPlanSetNotifyPacket; import org.cyclops.integratedterminals.network.packet.TerminalStorageIngredientOpenCraftingJobAmountGuiPacket; import org.lwjgl.glfw.GLFW; @@ -42,7 +41,6 @@ public class ContainerScreenTerminalStorageCraftingPlan setNotifyOnCompletion(selected)) + .onValueChange((widget, selected) -> getMenu().setNotifyOnCompletion(selected)) .build()); addRenderableWidget(buttonConfirm = new ButtonText(leftPos + 221 + 10 - 50, topPos + 198, 50, 20, @@ -140,12 +138,6 @@ public void init() { buttonConfirm.active = (this.guiCraftingPlan != null && this.guiCraftingPlan.isValid()) || (this.guiCraftingPlanFlat != null && this.guiCraftingPlanFlat.isValid()); } - private void setNotifyOnCompletion(boolean notifyOnCompletion) { - this.notifyOnCompletion = notifyOnCompletion; - IntegratedTerminals._instance.getPacketHandler().sendToServer( - new TerminalStorageCraftingPlanSetNotifyPacket(notifyOnCompletion)); - } - @Override public boolean keyPressed(int typedChar, int keyCode, int modifiers) { if (typedChar == GLFW.GLFW_KEY_ESCAPE) { diff --git a/src/main/java/org/cyclops/integratedterminals/client/gui/toast/CraftingJobToast.java b/src/main/java/org/cyclops/integratedterminals/client/gui/toast/CraftingJobToast.java index f1aeec926d..5735686951 100644 --- a/src/main/java/org/cyclops/integratedterminals/client/gui/toast/CraftingJobToast.java +++ b/src/main/java/org/cyclops/integratedterminals/client/gui/toast/CraftingJobToast.java @@ -7,20 +7,24 @@ import net.minecraft.network.chat.Component; import net.minecraft.resources.ResourceLocation; import net.minecraft.util.FormattedCharSequence; -import net.minecraft.world.item.ItemStack; +import org.cyclops.commoncapabilities.api.ingredient.IngredientComponent; +import org.cyclops.integratedterminals.Capabilities; import org.cyclops.integratedterminals.Reference; +import org.cyclops.integratedterminals.client.gui.container.ContainerScreenTerminalStorage; import java.util.List; /** - * A toast that shows an item icon alongside a title and a wrapping subtitle. + * A toast that shows an ingredient icon alongside a title and a wrapping subtitle. * * Toasts with an equal token replace each other instead of being queued, * so the token determines how toasts are grouped. * + * @param The instance type. + * @param The matching condition parameter. * @author rubensworks */ -public class CraftingJobToast implements Toast { +public class CraftingJobToast implements Toast { private static final ResourceLocation BACKGROUND_SPRITE = ResourceLocation.fromNamespaceAndPath(Reference.MOD_ID, "toast/crafting_job"); private static final int DISPLAY_MILLIS = 5000; @@ -31,15 +35,18 @@ public class CraftingJobToast implements Toast { private static final int LINE_SPACING = 12; private final Object token; - private final ItemStack icon; + private final IngredientComponent ingredientComponent; + private final T instance; private Component title; private List subtitleLines; private long lastChangedAt = Long.MIN_VALUE; private boolean changed = true; - public CraftingJobToast(Object token, ItemStack icon, Component title, Component subtitle) { + public CraftingJobToast(Object token, IngredientComponent ingredientComponent, T instance, + Component title, Component subtitle) { this.token = token; - this.icon = icon; + this.ingredientComponent = ingredientComponent; + this.instance = instance; this.title = title; this.subtitleLines = splitSubtitle(subtitle); } @@ -72,7 +79,14 @@ public Visibility render(GuiGraphics graphics, ToastComponent toastComponent, lo } graphics.blitSprite(BACKGROUND_SPRITE, 0, 0, width(), height()); - graphics.renderItem(icon, ICON_LEFT, 8); + + // Drawing through the storage handler keeps this working for items, fluids, energy, and any + // ingredient component that other mods add. No screen is needed, as the background layer + // draws the instance itself and only the foreground layer renders tooltips. + this.ingredientComponent.getCapability(Capabilities.IngredientComponentTerminalStorageHandler.INGREDIENT) + .ifPresent(handler -> handler.drawInstance(graphics, this.instance, + this.ingredientComponent.getMatcher().getQuantity(this.instance), null, null, + ContainerScreenTerminalStorage.DrawLayer.BACKGROUND, 0, ICON_LEFT, 8, 0, 0, null)); var font = toastComponent.getMinecraft().font; graphics.drawString(font, title, TEXT_LEFT, 7, 0xFFFFFF, false); diff --git a/src/main/java/org/cyclops/integratedterminals/gametest/GameTestCraftingJobNotify.java b/src/main/java/org/cyclops/integratedterminals/gametest/GameTestCraftingJobNotify.java index 6c8853a986..b61e71a490 100644 --- a/src/main/java/org/cyclops/integratedterminals/gametest/GameTestCraftingJobNotify.java +++ b/src/main/java/org/cyclops/integratedterminals/gametest/GameTestCraftingJobNotify.java @@ -30,7 +30,13 @@ import org.cyclops.integratedterminals.modcompat.integratedcrafting.TerminalCraftingOptionRecipeDefinition; import org.cyclops.integratedterminals.modcompat.integratedcrafting.TerminalStorageTabIngredientCraftingHandlerCraftingNetwork; +import com.google.common.collect.Lists; +import net.neoforged.bus.api.SubscribeEvent; +import net.neoforged.neoforge.common.NeoForge; +import org.cyclops.integratedcrafting.api.event.CraftingJobFinishedEvent; + import java.util.Iterator; +import java.util.List; import java.util.UUID; /** @@ -123,9 +129,114 @@ private static CraftingJob getSingleCraftingJob(GameTestHelper helper, INetwork return craftingJobs.next(); } + /** + * A job whose dependencies must be crafted first emits exactly one notification, + * for the requested job rather than for each of its dependencies. + */ + @GameTest(template = "empty10", templateNamespace = Reference.MOD_ID, timeoutTicks = 4000) + public void testNestedJobNotifiesOnce(GameTestHelper helper) { + GameTestHelpersIntegratedCrafting.INetworkPositions positions = + GameTestHelpersIntegratedCrafting.createBasicNetwork(helper, POS); + + // Only logs are stored, so the planks needed for the chest have to be crafted first + ChestBlockEntity chest = helper.getBlockEntity(POS.east()); + chest.setItem(0, new ItemStack(Items.OAK_LOG, 64)); + positions.interfaceRecipeAdders().get(0).accept(Triple.of(0, RecipeType.CRAFTING, + ResourceLocation.fromNamespaceAndPath("minecraft", "chest"))); + positions.interfaceRecipeAdders().get(0).accept(Triple.of(1, RecipeType.CRAFTING, + ResourceLocation.fromNamespaceAndPath("minecraft", "oak_planks"))); + + UUID initiator = UUID.randomUUID(); + NotifyCollector collector = NotifyCollector.start(initiator); + helper.startSequence() + .thenIdle(20) + .thenExecute(() -> startJob(helper, initiator, new ItemStack(Items.CHEST, 1))) + .thenWaitUntil(() -> helper.assertTrue(!hasRunningJobs(helper), + "The crafting jobs did not finish")) + .thenExecute(() -> { + helper.assertTrue(collector.dependencies > 0, + "Expected the plank dependency to have been crafted, so that the job was nested"); + helper.assertTrue(collector.notified.size() == 1, + "Expected exactly one notification for a nested job, but got " + + collector.notified.size()); + collector.stop(); + }) + .thenSucceed(); + } + + private static void startJob(GameTestHelper helper, UUID initiator, ItemStack output) { + INetwork network = getNetwork(helper); + int channel = IPositionedAddonsNetworkIngredients.DEFAULT_CHANNEL; + TerminalStorageTabIngredientCraftingHandlerCraftingNetwork handler = + new TerminalStorageTabIngredientCraftingHandlerCraftingNetwork(); + Iterator recipes = CraftingHelpers.getCraftingNetworkChecked(network) + .getRecipeIndex(channel).getRecipes(IngredientComponents.ITEMSTACK, output, ItemMatch.ITEM); + if (!recipes.hasNext()) { + helper.fail("No recipe was available in the network for " + output); + } + ITerminalCraftingPlan craftingPlan = handler.calculateCraftingPlan(network, channel, + new TerminalCraftingOptionRecipeDefinition<>(IngredientComponents.ITEMSTACK, recipes.next()), + output.getCount()); + try { + handler.startCraftingJob(network, channel, craftingPlan, mockPlayer(helper, initiator), true); + } catch (Exception e) { + helper.fail("The crafting job could not be started: " + e.getMessage()); + } + } + + private static ServerPlayer mockPlayer(GameTestHelper helper, UUID initiator) { + // Deliberately not added to the player list, so that no notification packet is sent + return new ServerPlayer(helper.getLevel().getServer(), helper.getLevel(), + new GameProfile(initiator, "test-mock-player"), ClientInformation.createDefault()); + } + + private static boolean hasRunningJobs(GameTestHelper helper) { + Iterator craftingJobs = CraftingHelpers.getCraftingNetworkChecked(getNetwork(helper)) + .getCraftingJobs(IPositionedAddonsNetworkIngredients.WILDCARD_CHANNEL); + return craftingJobs.hasNext(); + } + private static INetwork getNetwork(GameTestHelper helper) { return NetworkHelpers.getNetwork(helper.getLevel(), helper.absolutePos(POS), null) .orElseThrow(() -> new IllegalStateException("Could not find a network")); } + /** + * Counts the completions that the toast listener would act on, for one initiator. + */ + public static class NotifyCollector { + + private final UUID initiator; + private final List notified = Lists.newArrayList(); + private int dependencies = 0; + + public NotifyCollector(UUID initiator) { + this.initiator = initiator; + } + + public static NotifyCollector start(UUID initiator) { + NotifyCollector collector = new NotifyCollector(initiator); + NeoForge.EVENT_BUS.register(collector); + return collector; + } + + public void stop() { + NeoForge.EVENT_BUS.unregister(this); + } + + @SubscribeEvent + public void onCraftingJobFinished(CraftingJobFinishedEvent event) { + CraftingJob craftingJob = event.getCraftingJob(); + if (!this.initiator.toString().equals(craftingJob.getInitiatorUuid())) { + return; + } + // The same filter that CraftingJobFinishedToastListener applies + if (event.isRootJob() && craftingJob.isNotifyInitiator()) { + this.notified.add(craftingJob); + } else { + this.dependencies++; + } + } + } + } diff --git a/src/main/java/org/cyclops/integratedterminals/inventory/container/ContainerTerminalStorageCraftingPlanBase.java b/src/main/java/org/cyclops/integratedterminals/inventory/container/ContainerTerminalStorageCraftingPlanBase.java index 5c10d188ae..3b25d7f073 100644 --- a/src/main/java/org/cyclops/integratedterminals/inventory/container/ContainerTerminalStorageCraftingPlanBase.java +++ b/src/main/java/org/cyclops/integratedterminals/inventory/container/ContainerTerminalStorageCraftingPlanBase.java @@ -7,6 +7,7 @@ import net.minecraft.world.inventory.MenuType; import net.minecraft.world.level.Level; import org.cyclops.cyclopscore.helper.BlockEntityHelpers; +import org.cyclops.cyclopscore.helper.ValueNotifierHelpers; import org.cyclops.cyclopscore.inventory.container.InventoryContainer; import org.cyclops.integrateddynamics.api.network.INetwork; import org.cyclops.integratedterminals.GeneralConfig; @@ -32,11 +33,11 @@ public abstract class ContainerTerminalStorageCraftingPlanBase extends Invent private final CraftingOptionGuiData craftingOptionGuiData; private final int craftingPlanNotifierId; private final int craftingPlanFlatNotifierId; + private final int notifyOnCompletionValueId; private final Level world; private boolean calculatedCraftingPlan; private ITerminalCraftingPlan craftingPlan; - private boolean notifyOnCompletion = true; public ContainerTerminalStorageCraftingPlanBase(@Nullable MenuType type, int id, Inventory playerInventory, CraftingOptionGuiData craftingOptionGuiData) { @@ -45,6 +46,7 @@ public ContainerTerminalStorageCraftingPlanBase(@Nullable MenuType type, int this.craftingOptionGuiData = craftingOptionGuiData; this.craftingPlanNotifierId = getNextValueId(); this.craftingPlanFlatNotifierId = getNextValueId(); + this.notifyOnCompletionValueId = getNextValueId(); this.world = playerInventory.player.level(); putButtonAction(BUTTON_START, (buttonId, container) -> startCraftingJob()); @@ -60,15 +62,20 @@ public CraftingOptionGuiData getCraftingOptionGuiData() { return craftingOptionGuiData; } + public int getNotifyOnCompletionValueId() { + return notifyOnCompletionValueId; + } + /** * @return If the player wants to be notified once the crafting job is completed. + * Disabled by default. */ public boolean isNotifyOnCompletion() { - return notifyOnCompletion; + return ValueNotifierHelpers.getValueBoolean(this, notifyOnCompletionValueId); } public void setNotifyOnCompletion(boolean notifyOnCompletion) { - this.notifyOnCompletion = notifyOnCompletion; + ValueNotifierHelpers.setValue(this, notifyOnCompletionValueId, notifyOnCompletion); } @Override @@ -135,7 +142,7 @@ private void startCraftingJob() { getNetwork().ifPresent(network -> { try { craftingOptionGuiData.getCraftingOption().getHandler() - .startCraftingJob(network, craftingOptionGuiData.getChannel(), craftingPlan, (ServerPlayer) player, this.notifyOnCompletion); + .startCraftingJob(network, craftingOptionGuiData.getChannel(), craftingPlan, (ServerPlayer) player, isNotifyOnCompletion()); // Re-open terminal gui craftingOptionGuiData.getLocation() diff --git a/src/main/java/org/cyclops/integratedterminals/modcompat/integratedcrafting/CraftingJobFinishedToastListener.java b/src/main/java/org/cyclops/integratedterminals/modcompat/integratedcrafting/CraftingJobFinishedToastListener.java index 9445a05aba..a0d076f0ab 100644 --- a/src/main/java/org/cyclops/integratedterminals/modcompat/integratedcrafting/CraftingJobFinishedToastListener.java +++ b/src/main/java/org/cyclops/integratedterminals/modcompat/integratedcrafting/CraftingJobFinishedToastListener.java @@ -1,10 +1,8 @@ package org.cyclops.integratedterminals.modcompat.integratedcrafting; import net.minecraft.server.level.ServerPlayer; -import net.minecraft.world.item.ItemStack; import net.neoforged.bus.api.SubscribeEvent; import net.neoforged.neoforge.common.NeoForge; -import net.neoforged.neoforge.fluids.FluidStack; import net.neoforged.neoforge.server.ServerLifecycleHooks; import org.cyclops.commoncapabilities.api.ingredient.IPrototypedIngredient; import org.cyclops.commoncapabilities.api.ingredient.IngredientComponent; @@ -50,22 +48,14 @@ public static void onCraftingJobFinished(CraftingJobFinishedEvent event) { return; } - IPrototypedIngredient output = outputs.get(0); - ItemStack outputItem = output.getPrototype() instanceof ItemStack itemStack ? itemStack : ItemStack.EMPTY; - IntegratedTerminals._instance.getPacketHandler().sendToPlayer( - new CraftingJobFinishedToastPacket(outputItem, outputItem.isEmpty() ? formatOutput(output) : ""), player); + sendToast(player, outputs.get(0)); } - /** - * Describe an output that can not be sent to the client as an item stack. - */ - protected static String formatOutput(IPrototypedIngredient output) { - T prototype = output.getPrototype(); - if (prototype instanceof FluidStack fluidStack) { - return fluidStack.getAmount() + "x " + fluidStack.getHoverName().getString(); - } - IngredientComponent component = output.getComponent(); - return component.getMatcher().getQuantity(prototype) + "x " + component.getName(); + protected static void sendToast(ServerPlayer player, IPrototypedIngredient output) { + IngredientComponent ingredientComponent = output.getComponent(); + IntegratedTerminals._instance.getPacketHandler().sendToPlayer( + new CraftingJobFinishedToastPacket<>(player.registryAccess(), ingredientComponent, + output.getPrototype()), player); } @Nullable diff --git a/src/main/java/org/cyclops/integratedterminals/network/packet/CraftingJobFinishedToastPacket.java b/src/main/java/org/cyclops/integratedterminals/network/packet/CraftingJobFinishedToastPacket.java index e379832eb3..a1da6c9801 100644 --- a/src/main/java/org/cyclops/integratedterminals/network/packet/CraftingJobFinishedToastPacket.java +++ b/src/main/java/org/cyclops/integratedterminals/network/packet/CraftingJobFinishedToastPacket.java @@ -2,49 +2,57 @@ import net.minecraft.ChatFormatting; import net.minecraft.client.Minecraft; +import net.minecraft.core.HolderLookup; +import net.minecraft.nbt.CompoundTag; import net.minecraft.network.RegistryFriendlyByteBuf; import net.minecraft.network.chat.Component; import net.minecraft.network.codec.StreamCodec; import net.minecraft.resources.ResourceLocation; import net.minecraft.server.level.ServerPlayer; import net.minecraft.world.entity.player.Player; -import net.minecraft.world.item.ItemStack; -import net.minecraft.world.item.Items; import net.minecraft.world.level.Level; import net.neoforged.api.distmarker.Dist; import net.neoforged.api.distmarker.OnlyIn; +import org.cyclops.commoncapabilities.api.ingredient.IIngredientSerializer; +import org.cyclops.commoncapabilities.api.ingredient.IngredientComponent; import org.cyclops.cyclopscore.network.CodecField; import org.cyclops.cyclopscore.network.PacketCodec; +import org.cyclops.integratedterminals.Capabilities; import org.cyclops.integratedterminals.GeneralConfig; import org.cyclops.integratedterminals.Reference; import org.cyclops.integratedterminals.client.gui.toast.CraftingJobToast; /** * Packet for showing a toast when a crafting job that the player requested has been completed. + * @param The instance type. + * @param The matching condition parameter. * @author rubensworks */ -public class CraftingJobFinishedToastPacket extends PacketCodec { +public class CraftingJobFinishedToastPacket extends PacketCodec> { - public static final Type ID = new Type<>(ResourceLocation.fromNamespaceAndPath(Reference.MOD_ID, "crafting_job_finished_toast")); - public static final StreamCodec CODEC = getCodec(CraftingJobFinishedToastPacket::new); + public static final Type> ID = new Type<>(ResourceLocation.fromNamespaceAndPath(Reference.MOD_ID, "crafting_job_finished_toast")); + public static final StreamCodec> CODEC = (StreamCodec) getCodec(CraftingJobFinishedToastPacket::new); @CodecField - private ItemStack outputItem = ItemStack.EMPTY; + private String ingredientName; @CodecField - private String outputLabel; + private CompoundTag instanceData; public CraftingJobFinishedToastPacket() { - super(ID); + super((Type) ID); } /** - * @param outputItem The crafted item, or an empty stack if the job did not output items. - * @param outputLabel A textual description of the output, used when no output item is available. + * @param lookupProvider A lookup provider. + * @param ingredientComponent The component of the crafted output. + * @param instance The crafted output, where the quantity is the total that was crafted. */ - public CraftingJobFinishedToastPacket(ItemStack outputItem, String outputLabel) { - super(ID); - this.outputItem = outputItem; - this.outputLabel = outputLabel; + public CraftingJobFinishedToastPacket(HolderLookup.Provider lookupProvider, + IngredientComponent ingredientComponent, T instance) { + super((Type) ID); + this.ingredientName = ingredientComponent.getName().toString(); + this.instanceData = new CompoundTag(); + this.instanceData.put("i", ingredientComponent.getSerializer().serializeInstance(lookupProvider, instance)); } @Override @@ -59,24 +67,33 @@ public void actionClient(Level world, Player player) { return; } - boolean hasItem = !this.outputItem.isEmpty(); - Component output = hasItem - ? Component.literal(this.outputItem.getCount() + "x ").append(this.outputItem.getHoverName()) - : Component.literal(this.outputLabel); + IngredientComponent ingredientComponent = getComponent(); + if (ingredientComponent == null) { + return; + } + IIngredientSerializer serializer = ingredientComponent.getSerializer(); + T instance = serializer.deserializeInstance(world.registryAccess(), this.instanceData.get("i")); + + // The quantity is formatted by the component's own handler, so that fluids, energy, + // and ingredient components from other mods all read naturally. + String quantity = ingredientComponent + .getCapability(Capabilities.IngredientComponentTerminalStorageHandler.INGREDIENT) + .map(handler -> handler.formatQuantity(instance)) + .orElseGet(() -> String.valueOf(ingredientComponent.getMatcher().getQuantity(instance))); Component title = Component.translatable("gui.integratedterminals.crafting_job.finished.title") .withStyle(ChatFormatting.GREEN); - Component subtitle = Component.translatable("gui.integratedterminals.crafting_job.finished", output); - - // Group toasts by output, so that repeated crafts of the same thing don't pile up. - Object token = hasItem ? this.outputItem.getItem() : this.outputLabel; - ItemStack icon = hasItem ? this.outputItem : new ItemStack(Items.CRAFTING_TABLE); + Component subtitle = Component.translatable("gui.integratedterminals.crafting_job.finished", + quantity, ingredientComponent.getMatcher().getDisplayName(instance)); + // Group by output, so that repeated crafts of the same thing don't pile up + Object token = this.ingredientName + "|" + + ingredientComponent.getMatcher().getDisplayName(instance).getString(); var toasts = Minecraft.getInstance().getToasts(); - CraftingJobToast existing = toasts.getToast(CraftingJobToast.class, token); + CraftingJobToast existing = toasts.getToast(CraftingJobToast.class, token); if (existing != null) { existing.reset(title, subtitle); } else { - toasts.addToast(new CraftingJobToast(token, icon, title, subtitle)); + toasts.addToast(new CraftingJobToast<>(token, ingredientComponent, instance, title, subtitle)); } } @@ -85,4 +102,8 @@ public void actionServer(Level world, ServerPlayer player) { // Server-to-client only packet } + protected IngredientComponent getComponent() { + return (IngredientComponent) IngredientComponent.REGISTRY.get(ResourceLocation.parse(this.ingredientName)); + } + } diff --git a/src/main/java/org/cyclops/integratedterminals/network/packet/TerminalStorageCraftingPlanSetNotifyPacket.java b/src/main/java/org/cyclops/integratedterminals/network/packet/TerminalStorageCraftingPlanSetNotifyPacket.java deleted file mode 100644 index e2497f28a7..0000000000 --- a/src/main/java/org/cyclops/integratedterminals/network/packet/TerminalStorageCraftingPlanSetNotifyPacket.java +++ /dev/null @@ -1,55 +0,0 @@ -package org.cyclops.integratedterminals.network.packet; - -import net.minecraft.network.RegistryFriendlyByteBuf; -import net.minecraft.network.codec.StreamCodec; -import net.minecraft.resources.ResourceLocation; -import net.minecraft.server.level.ServerPlayer; -import net.minecraft.world.entity.player.Player; -import net.minecraft.world.level.Level; -import net.neoforged.api.distmarker.Dist; -import net.neoforged.api.distmarker.OnlyIn; -import org.cyclops.cyclopscore.network.CodecField; -import org.cyclops.cyclopscore.network.PacketCodec; -import org.cyclops.integratedterminals.Reference; -import org.cyclops.integratedterminals.inventory.container.ContainerTerminalStorageCraftingPlanBase; - -/** - * Packet for telling the server if the player wants to be notified when the planned crafting job is completed. - * @author rubensworks - */ -public class TerminalStorageCraftingPlanSetNotifyPacket extends PacketCodec { - - public static final Type ID = new Type<>(ResourceLocation.fromNamespaceAndPath(Reference.MOD_ID, "terminal_storage_crafting_plan_set_notify")); - public static final StreamCodec CODEC = getCodec(TerminalStorageCraftingPlanSetNotifyPacket::new); - - @CodecField - private boolean notify; - - public TerminalStorageCraftingPlanSetNotifyPacket() { - super(ID); - } - - public TerminalStorageCraftingPlanSetNotifyPacket(boolean notify) { - super(ID); - this.notify = notify; - } - - @Override - public boolean isAsync() { - return false; - } - - @Override - @OnlyIn(Dist.CLIENT) - public void actionClient(Level world, Player player) { - - } - - @Override - public void actionServer(Level world, ServerPlayer player) { - if (player.containerMenu instanceof ContainerTerminalStorageCraftingPlanBase container) { - container.setNotifyOnCompletion(this.notify); - } - } - -} diff --git a/src/main/java/org/cyclops/integratedterminals/proxy/CommonProxy.java b/src/main/java/org/cyclops/integratedterminals/proxy/CommonProxy.java index cfadb6cf63..c31de9d2ec 100644 --- a/src/main/java/org/cyclops/integratedterminals/proxy/CommonProxy.java +++ b/src/main/java/org/cyclops/integratedterminals/proxy/CommonProxy.java @@ -44,7 +44,6 @@ public void registerPacketHandlers(PacketHandler packetHandler) { packetHandler.register(OpenCraftingJobsPlanGuiPacket.ID, OpenCraftingJobsPlanGuiPacket.CODEC); packetHandler.register(OpenCraftingJobsGuiPacket.ID, OpenCraftingJobsGuiPacket.CODEC); packetHandler.register(CancelCraftingJobPacket.ID, CancelCraftingJobPacket.CODEC); - packetHandler.register(TerminalStorageCraftingPlanSetNotifyPacket.ID, TerminalStorageCraftingPlanSetNotifyPacket.CODEC); packetHandler.register(CraftingJobFinishedToastPacket.ID, CraftingJobFinishedToastPacket.CODEC); IntegratedDynamics.clog("Registered packet handler."); diff --git a/src/main/resources/assets/integratedterminals/lang/en_us.json b/src/main/resources/assets/integratedterminals/lang/en_us.json index 9b545da867..3dc2260c20 100644 --- a/src/main/resources/assets/integratedterminals/lang/en_us.json +++ b/src/main/resources/assets/integratedterminals/lang/en_us.json @@ -7,7 +7,7 @@ "gui.integratedterminals.amount": "Amount", "gui.integratedterminals.channel": "Channel", "gui.integratedterminals.crafting_job.finished.title": "Crafting Job Completed", - "gui.integratedterminals.crafting_job.finished": "Crafted %s", + "gui.integratedterminals.crafting_job.finished": "Crafted %s %s", "gui.integratedterminals.terminal_storage.tooltip.energy": "Energy", "gui.integratedterminals.terminal_storage.tooltip.energy.amount": "%s FE", "gui.integratedterminals.terminal_storage.tooltip.fluid.amount": "%s mB", From 07ffc2331d3d3a4a214c97f9fe10622b41a386ee Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 19:13:29 +0000 Subject: [PATCH 09/10] Sum the quantities of crafting jobs that complete as one toast A job that is distributed over multiple crafting interfaces completes as several jobs, so their quantities are added into the toast that groups them, rather than the toast showing only the last one that finished. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QNDY2pZtkXqJrhztYCvNbh --- .../client/gui/toast/CraftingJobToast.java | 17 ++++++++- .../CraftingJobFinishedToastPacket.java | 37 ++++++++++++++----- 2 files changed, 42 insertions(+), 12 deletions(-) diff --git a/src/main/java/org/cyclops/integratedterminals/client/gui/toast/CraftingJobToast.java b/src/main/java/org/cyclops/integratedterminals/client/gui/toast/CraftingJobToast.java index 5735686951..5da06c4adc 100644 --- a/src/main/java/org/cyclops/integratedterminals/client/gui/toast/CraftingJobToast.java +++ b/src/main/java/org/cyclops/integratedterminals/client/gui/toast/CraftingJobToast.java @@ -36,7 +36,7 @@ public class CraftingJobToast implements Toast { private final Object token; private final IngredientComponent ingredientComponent; - private final T instance; + private T instance; private Component title; private List subtitleLines; private long lastChangedAt = Long.MIN_VALUE; @@ -51,12 +51,25 @@ public CraftingJobToast(Object token, IngredientComponent ingredientCompon this.subtitleLines = splitSubtitle(subtitle); } + public IngredientComponent getIngredientComponent() { + return ingredientComponent; + } + + /** + * @return The shown output, where the quantity is the total that was crafted. + */ + public T getInstance() { + return instance; + } + /** * Update the contents of this toast in-place, without queueing a new one. + * @param newInstance The new output. * @param newTitle The new title. * @param newSubtitle The new subtitle. */ - public void reset(Component newTitle, Component newSubtitle) { + public void reset(T newInstance, Component newTitle, Component newSubtitle) { + this.instance = newInstance; this.title = newTitle; this.subtitleLines = splitSubtitle(newSubtitle); this.changed = true; diff --git a/src/main/java/org/cyclops/integratedterminals/network/packet/CraftingJobFinishedToastPacket.java b/src/main/java/org/cyclops/integratedterminals/network/packet/CraftingJobFinishedToastPacket.java index a1da6c9801..6888d34f6b 100644 --- a/src/main/java/org/cyclops/integratedterminals/network/packet/CraftingJobFinishedToastPacket.java +++ b/src/main/java/org/cyclops/integratedterminals/network/packet/CraftingJobFinishedToastPacket.java @@ -13,6 +13,7 @@ import net.minecraft.world.level.Level; import net.neoforged.api.distmarker.Dist; import net.neoforged.api.distmarker.OnlyIn; +import org.cyclops.commoncapabilities.api.ingredient.IIngredientMatcher; import org.cyclops.commoncapabilities.api.ingredient.IIngredientSerializer; import org.cyclops.commoncapabilities.api.ingredient.IngredientComponent; import org.cyclops.cyclopscore.network.CodecField; @@ -71,29 +72,45 @@ public void actionClient(Level world, Player player) { if (ingredientComponent == null) { return; } + IIngredientMatcher matcher = ingredientComponent.getMatcher(); IIngredientSerializer serializer = ingredientComponent.getSerializer(); T instance = serializer.deserializeInstance(world.registryAccess(), this.instanceData.get("i")); + // Group by output, so that repeated crafts of the same thing don't pile up. + // A job that was distributed over multiple crafting interfaces completes as several jobs, + // so their quantities are summed into a single toast. + Object token = this.ingredientName + "|" + matcher.getDisplayName(instance).getString(); + var toasts = Minecraft.getInstance().getToasts(); + CraftingJobToast existing = (CraftingJobToast) toasts.getToast(CraftingJobToast.class, token); + if (existing != null) { + instance = matcher.withQuantity(instance, addQuantities(matcher, + matcher.getQuantity(existing.getInstance()), matcher.getQuantity(instance))); + } + // The quantity is formatted by the component's own handler, so that fluids, energy, // and ingredient components from other mods all read naturally. + T shownInstance = instance; String quantity = ingredientComponent .getCapability(Capabilities.IngredientComponentTerminalStorageHandler.INGREDIENT) - .map(handler -> handler.formatQuantity(instance)) - .orElseGet(() -> String.valueOf(ingredientComponent.getMatcher().getQuantity(instance))); + .map(handler -> handler.formatQuantity(shownInstance)) + .orElseGet(() -> String.valueOf(matcher.getQuantity(shownInstance))); Component title = Component.translatable("gui.integratedterminals.crafting_job.finished.title") .withStyle(ChatFormatting.GREEN); Component subtitle = Component.translatable("gui.integratedterminals.crafting_job.finished", - quantity, ingredientComponent.getMatcher().getDisplayName(instance)); + quantity, matcher.getDisplayName(shownInstance)); - // Group by output, so that repeated crafts of the same thing don't pile up - Object token = this.ingredientName + "|" - + ingredientComponent.getMatcher().getDisplayName(instance).getString(); - var toasts = Minecraft.getInstance().getToasts(); - CraftingJobToast existing = toasts.getToast(CraftingJobToast.class, token); if (existing != null) { - existing.reset(title, subtitle); + existing.reset(shownInstance, title, subtitle); } else { - toasts.addToast(new CraftingJobToast<>(token, ingredientComponent, instance, title, subtitle)); + toasts.addToast(new CraftingJobToast<>(token, ingredientComponent, shownInstance, title, subtitle)); + } + } + + protected static long addQuantities(IIngredientMatcher matcher, long quantity, long quantityToAdd) { + try { + return Math.min(matcher.getMaximumQuantity(), Math.addExact(quantity, quantityToAdd)); + } catch (ArithmeticException e) { + return matcher.getMaximumQuantity(); } } From a136074e1fa88cc6388a6b12bb179266b07e9c0c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 15:45:08 +0000 Subject: [PATCH 10/10] Bump IntegratedCrafting to 1.5.0-689 This build keeps the initiator of crafting jobs that are distributed over multiple crafting interfaces, so those jobs can be notified about as well. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QNDY2pZtkXqJrhztYCvNbh --- gradle.properties | 2 +- .../gametest/GameTestCraftingJobNotify.java | 38 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 3b832c9bda..3b2135f720 100644 --- a/gradle.properties +++ b/gradle.properties @@ -34,6 +34,6 @@ org.gradle.caching=true cyclopscore_version=1.26.2-808 integrateddynamics_version=1.32.0-1630 integratedterminalscompat_version=1.0.0-167 -integratedcrafting_version=1.5.0-681 +integratedcrafting_version=1.5.0-689 integratedtunnels_version=1.8.44-484 commoncapabilities_version=2.9.12-263 diff --git a/src/main/java/org/cyclops/integratedterminals/gametest/GameTestCraftingJobNotify.java b/src/main/java/org/cyclops/integratedterminals/gametest/GameTestCraftingJobNotify.java index b61e71a490..1d2b8705ab 100644 --- a/src/main/java/org/cyclops/integratedterminals/gametest/GameTestCraftingJobNotify.java +++ b/src/main/java/org/cyclops/integratedterminals/gametest/GameTestCraftingJobNotify.java @@ -10,6 +10,7 @@ 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.Blocks; import net.minecraft.world.level.block.entity.ChestBlockEntity; import net.neoforged.neoforge.gametest.GameTestHolder; import net.neoforged.neoforge.gametest.PrefixGameTestTemplate; @@ -164,6 +165,43 @@ public void testNestedJobNotifiesOnce(GameTestHelper helper) { .thenSucceed(); } + /** + * A job that is distributed over several crafting interfaces notifies for each of its split jobs, + * together accounting for the full requested amount. The toast groups them into a single one. + */ + @GameTest(template = "empty10", templateNamespace = Reference.MOD_ID, timeoutTicks = 4000) + public void testDistributedJobNotifiesForWholeRequest(GameTestHelper helper) { + GameTestHelpersIntegratedCrafting.INetworkPositions positions = + GameTestHelpersIntegratedCrafting.createBasicNetwork(helper, POS, false, + Blocks.CRAFTING_TABLE, Blocks.CRAFTING_TABLE); + + ChestBlockEntity chest = helper.getBlockEntity(POS.east()); + chest.setItem(0, new ItemStack(Items.OAK_PLANKS, 64)); + // Both interfaces know the recipe, so the job is split over the two of them + for (int i = 0; i < positions.interfaceRecipeAdders().size(); i++) { + positions.interfaceRecipeAdders().get(i).accept(Triple.of(0, RecipeType.CRAFTING, + ResourceLocation.fromNamespaceAndPath("minecraft", "chest"))); + } + + UUID initiator = UUID.randomUUID(); + NotifyCollector collector = NotifyCollector.start(initiator); + helper.startSequence() + .thenIdle(20) + .thenExecute(() -> startJob(helper, initiator, new ItemStack(Items.CHEST, 4))) + .thenWaitUntil(() -> helper.assertTrue(!hasRunningJobs(helper), + "The crafting jobs did not finish")) + .thenExecute(() -> { + helper.assertTrue(collector.notified.size() > 1, + "Expected the job to be split over both crafting interfaces, but got " + + collector.notified.size() + " notification(s)"); + int total = collector.notified.stream().mapToInt(CraftingJob::getAmountTotal).sum(); + helper.assertTrue(total == 4, + "Expected the notifications to account for all 4 crafted chests, but got " + total); + collector.stop(); + }) + .thenSucceed(); + } + private static void startJob(GameTestHelper helper, UUID initiator, ItemStack output) { INetwork network = getNetwork(helper); int channel = IPositionedAddonsNetworkIngredients.DEFAULT_CHANNEL;