diff --git a/gradle.properties b/gradle.properties index 5f7082b96..3b2135f72 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-689 integratedtunnels_version=1.8.44-484 commoncapabilities_version=2.9.12-263 diff --git a/src/main/java/org/cyclops/integratedterminals/GeneralConfig.java b/src/main/java/org/cyclops/integratedterminals/GeneralConfig.java index 70dd1cd2b..ff7423594 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 1eb5d5cc8..f995c6839 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,23 @@ 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); + } + + /** + * 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 550022e3e..f02941760 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; @@ -51,7 +53,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) -> { @@ -66,7 +68,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) -> { @@ -121,6 +123,13 @@ public void init() { (b) -> returnToCraftingOptionAmount(), true)); + addRenderableWidget(Checkbox.builder(Component.translatable("gui.integratedterminals.terminal_storage.step.craft.notify"), font) + .pos(leftPos + 72, topPos + 200) + .selected(getMenu().isNotifyOnCompletion()) + .tooltip(Tooltip.create(Component.translatable("gui.integratedterminals.terminal_storage.step.craft.notify.info"))) + .onValueChange((widget, selected) -> getMenu().setNotifyOnCompletion(selected)) + .build()); + 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), 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 000000000..5da06c4ad --- /dev/null +++ b/src/main/java/org/cyclops/integratedterminals/client/gui/toast/CraftingJobToast.java @@ -0,0 +1,120 @@ +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 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 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 { + + 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 IngredientComponent ingredientComponent; + private T instance; + private Component title; + private List subtitleLines; + private long lastChangedAt = Long.MIN_VALUE; + private boolean changed = true; + + public CraftingJobToast(Object token, IngredientComponent ingredientComponent, T instance, + Component title, Component subtitle) { + this.token = token; + this.ingredientComponent = ingredientComponent; + this.instance = instance; + this.title = title; + 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(T newInstance, Component newTitle, Component newSubtitle) { + this.instance = newInstance; + 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()); + + // 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); + 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 000000000..1d2b8705a --- /dev/null +++ b/src/main/java/org/cyclops/integratedterminals/gametest/GameTestCraftingJobNotify.java @@ -0,0 +1,280 @@ +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.Blocks; +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 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; + +/** + * 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(); + } + + /** + * 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(); + } + + /** + * 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; + 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 cc6791b0d..3b25d7f07 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,6 +33,7 @@ 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; @@ -44,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()); @@ -59,6 +62,22 @@ 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 ValueNotifierHelpers.getValueBoolean(this, notifyOnCompletionValueId); + } + + public void setNotifyOnCompletion(boolean notifyOnCompletion) { + ValueNotifierHelpers.setValue(this, notifyOnCompletionValueId, notifyOnCompletion); + } + @Override public void broadcastChanges() { super.broadcastChanges(); @@ -123,7 +142,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, 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 new file mode 100644 index 000000000..a0d076f0a --- /dev/null +++ b/src/main/java/org/cyclops/integratedterminals/modcompat/integratedcrafting/CraftingJobFinishedToastListener.java @@ -0,0 +1,70 @@ +package org.cyclops.integratedterminals.modcompat.integratedcrafting; + +import net.minecraft.server.level.ServerPlayer; +import net.neoforged.bus.api.SubscribeEvent; +import net.neoforged.neoforge.common.NeoForge; +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; + } + + List recipeOutputs = IntegratedCraftingHelpers.getPrototypesFromIngredients(craftingJob.getRecipe().getOutput()); + List> outputs = CraftingHelpers.multiplyPrototypedIngredients( + recipeOutputs, craftingJob.getAmountTotal()); + if (outputs.isEmpty()) { + return; + } + + sendToast(player, outputs.get(0)); + } + + 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 + 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 c7cc6ca94..928775a4d 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 36ac8e39e..85b9b268f 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 000000000..6888d34f6 --- /dev/null +++ b/src/main/java/org/cyclops/integratedterminals/network/packet/CraftingJobFinishedToastPacket.java @@ -0,0 +1,126 @@ +package org.cyclops.integratedterminals.network.packet; + +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.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; +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 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 String ingredientName; + @CodecField + private CompoundTag instanceData; + + public CraftingJobFinishedToastPacket() { + super((Type) ID); + } + + /** + * @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(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 + public boolean isAsync() { + return false; + } + + @Override + @OnlyIn(Dist.CLIENT) + public void actionClient(Level world, Player player) { + if (!GeneralConfig.craftingJobFinishedToast) { + return; + } + + IngredientComponent ingredientComponent = getComponent(); + 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(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, matcher.getDisplayName(shownInstance)); + + if (existing != null) { + existing.reset(shownInstance, title, subtitle); + } else { + 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(); + } + } + + @Override + 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/proxy/CommonProxy.java b/src/main/java/org/cyclops/integratedterminals/proxy/CommonProxy.java index d654ed546..c31de9d2e 100644 --- a/src/main/java/org/cyclops/integratedterminals/proxy/CommonProxy.java +++ b/src/main/java/org/cyclops/integratedterminals/proxy/CommonProxy.java @@ -44,6 +44,7 @@ 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(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 3b8c7f4ae..3dc2260c2 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": "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", @@ -24,6 +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", + "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", 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 000000000..40df18f58 Binary files /dev/null and b/src/main/resources/assets/integratedterminals/textures/gui/sprites/toast/crafting_job.png differ 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 000000000..8e76a6afa --- /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 + } + } +} +