From 8704c93e9681e0bb2fcd43e0f1e4c40b93e648a8 Mon Sep 17 00:00:00 2001 From: jurrejelle Date: Thu, 18 Jun 2026 16:55:43 +0200 Subject: [PATCH 1/6] Add Recipe failure reasons, score and recipe to RecipeLogic, add tests for that, add display for that --- .../multiblock/MultiblockDisplayText.java | 9 +- .../gtceu/api/machine/trait/RecipeLogic.java | 108 ++++++++++++------ .../gtceu/api/recipe/ActionResult.java | 20 +++- .../gtceu/api/recipe/RecipeHelper.java | 2 +- .../gtceu/api/recipe/RecipeRunner.java | 65 ++++++++++- .../api/machine/trait/RecipeLogicTest.java | 89 +++++++++++++++ .../gtceu/gametest/util/TestUtils.java | 14 +-- 7 files changed, 247 insertions(+), 60 deletions(-) diff --git a/src/main/java/com/gregtechceu/gtceu/api/machine/multiblock/MultiblockDisplayText.java b/src/main/java/com/gregtechceu/gtceu/api/machine/multiblock/MultiblockDisplayText.java index 1b9abeb2afa..aa5b29d6398 100644 --- a/src/main/java/com/gregtechceu/gtceu/api/machine/multiblock/MultiblockDisplayText.java +++ b/src/main/java/com/gregtechceu/gtceu/api/machine/multiblock/MultiblockDisplayText.java @@ -375,10 +375,13 @@ public Builder addCustomProgressLine(RecipeLogic recipeLogic) { public Builder addRecipeFailReasonLine(RecipeLogic recipeLogic) { if (!isStructureFormed || !recipeLogic.isIdle()) return this; - var reasons = recipeLogic.getFailureReasons(); - if (!reasons.isEmpty()) { + var reason = recipeLogic.getBestFailureReason(); + if (reason != null) { textList.add(Component.translatable("gtceu.recipe_logic.setup_fail").withStyle(ChatFormatting.RED)); - for (var reason : reasons) { + var recipe = recipeLogic.getBestFailureRecipe(); + if (recipe != null) { + textList.add(Component.literal(" - ").append(recipe).append(": ").append(reason)); + } else { textList.add(Component.literal(" - ").append(reason)); } } diff --git a/src/main/java/com/gregtechceu/gtceu/api/machine/trait/RecipeLogic.java b/src/main/java/com/gregtechceu/gtceu/api/machine/trait/RecipeLogic.java index 4b0d00c5ed9..c1173b6bfe8 100644 --- a/src/main/java/com/gregtechceu/gtceu/api/machine/trait/RecipeLogic.java +++ b/src/main/java/com/gregtechceu/gtceu/api/machine/trait/RecipeLogic.java @@ -15,7 +15,6 @@ import com.gregtechceu.gtceu.api.recipe.ActionResult; import com.gregtechceu.gtceu.api.recipe.GTRecipe; import com.gregtechceu.gtceu.api.recipe.RecipeHelper; -import com.gregtechceu.gtceu.api.recipe.modifier.ModifierFunction; import com.gregtechceu.gtceu.api.registry.GTRegistries; import com.gregtechceu.gtceu.api.sound.AutoReleasedSound; import com.gregtechceu.gtceu.api.sync_system.ClassSyncData; @@ -91,12 +90,19 @@ public enum Status implements StringRepresentable { @SyncToClient private Component waitingReason = null; + @Nullable + @Getter + @SyncToClient + protected Component bestFailureReason; + + /** Display name (id) of the recipe {@link #bestFailureReason} belongs to. */ + @Nullable @Getter @SyncToClient - protected final List failureReasons = new ArrayList<>(); + protected Component bestFailureRecipe; @Getter - protected final Map failureReasonMap = new HashMap<>(); + protected double bestFailureScore = Double.NEGATIVE_INFINITY; /** * unsafe, it may not be found from {@link RecipeManager}. Do not index it. */ @@ -105,10 +111,7 @@ public enum Status implements StringRepresentable { @SaveField @SyncToClient protected GTRecipe lastRecipe; - @Getter - @SaveField - @SyncToClient - protected int consecutiveRecipes = 0; // Consecutive recipes that have been run + /** * safe, it is the origin recipe before {@link IRecipeLogicMachine#fullModifyRecipe(GTRecipe)}' * which can be found @@ -118,6 +121,12 @@ public enum Status implements StringRepresentable { @Getter @SaveField protected GTRecipe lastOriginRecipe; + + @Getter + @SaveField + @SyncToClient + protected int consecutiveRecipes = 0; // Consecutive recipes that have been run + @SaveField @Getter @SyncToClient @@ -176,7 +185,7 @@ public void resetRecipeLogic() { isActive = false; lastFailedMatches = null; waitingReason = null; - failureReasons.clear(); + clearFailureReason(); if (status != Status.SUSPEND) { setStatus(Status.IDLE); } @@ -250,10 +259,6 @@ public void serverTick() { // No recipes available and the machine wants to unsubscribe until notified unsubscribe = true; } - if (isIdle()) { - failureReasons.clear(); - failureReasons.addAll(failureReasonMap.values()); - } if (unsubscribe && subscription != null) { subscription.unsubscribe(); subscription = null; @@ -278,7 +283,7 @@ public boolean checkMatchedRecipeAvailable(GTRecipe match) { if (recipeMatch.isSuccess()) { setupRecipe(modified); } else { - putFailureReason(this, match, recipeMatch.reason()); + recordFailureReason(match, recipeMatch.reason(), recipeMatch.score()); } if (lastRecipe != null && getStatus() == Status.WORKING) { lastOriginRecipe = match; @@ -326,6 +331,8 @@ public void handleRecipeWorking() { if (getMachine() instanceof MultiblockControllerMachine && !preventPowerFail) { runAttempt = 0; setStatus(Status.SUSPEND); + // Keep showing why it suspended (setStatus cleared waitingReason on leaving WAITING). + recordFailureReason(lastRecipe, handleTick.reason(), Double.POSITIVE_INFINITY); } } runDelay = runAttempt * 60; @@ -351,20 +358,26 @@ public Iterator searchRecipe() { public void findAndHandleRecipe() { lastFailedMatches = null; + clearFailureReason(); // try to execute last recipe if possible - if (!recipeDirty && lastRecipe != null && checkRecipe(lastRecipe).isSuccess()) { - GTRecipe recipe = lastRecipe; - lastRecipe = null; - lastOriginRecipe = null; - setupRecipe(recipe); - } else { - // try to find and handle a new recipe - failureReasonMap.clear(); - lastRecipe = null; - lastOriginRecipe = null; - handleSearchingRecipes(searchRecipe()); + GTRecipe last = lastRecipe; + if (!recipeDirty && last != null) { + var lastCheck = checkRecipe(last); + if (lastCheck.isSuccess()) { + lastRecipe = null; + lastOriginRecipe = null; + setupRecipe(last); + recipeDirty = false; + return; + } + recordFailureReason(last, lastCheck.reason(), Double.POSITIVE_INFINITY); } + + // try to find and handle a new recipe + lastRecipe = null; + lastOriginRecipe = null; + handleSearchingRecipes(searchRecipe()); recipeDirty = false; } @@ -415,7 +428,7 @@ public void setupRecipe(GTRecipe recipe) { if (lastRecipe != null && !recipe.equals(lastRecipe)) { chanceCaches.clear(); } - failureReasonMap.clear(); + clearFailureReason(); recipeDirty = false; lastRecipe = recipe; setStatus(Status.WORKING); @@ -482,6 +495,7 @@ public boolean isWorkingEnabled() { @Override public void setWorkingEnabled(boolean isWorkingAllowed) { if (!isWorkingAllowed && getStatus() == Status.IDLE) { + clearFailureReason(); setStatus(Status.SUSPEND); } else { setSuspendAfterFinish(!isWorkingAllowed); @@ -623,17 +637,24 @@ public IGuiTexture getFancyTooltipIcon() { @Override public List getFancyTooltip() { if (isWaiting() && waitingReason != null) { + Component name = recipeDisplayName(lastRecipe); + if (name != null) { + return List.of(name, waitingReason); + } return List.of(waitingReason); } - if (isIdle() && !failureReasons.isEmpty()) { - return failureReasons; + if ((isIdle() || isSuspend()) && bestFailureReason != null) { + if (bestFailureRecipe != null) { + return List.of(bestFailureRecipe, bestFailureReason); + } + return List.of(bestFailureReason); } return Collections.emptyList(); } @Override public boolean showFancyTooltip() { - return waitingReason != null || !failureReasons.isEmpty(); + return waitingReason != null || bestFailureReason != null; } protected IdentityHashMap, Object2IntMap> makeChanceCaches() { @@ -708,13 +729,32 @@ public static void putFailureReason(Object machine, GTRecipe recipe, Component r } public static void putFailureReason(RecipeLogic logic, GTRecipe recipe, Component reason) { - var map = logic.getFailureReasonMap(); - if (map.containsKey(recipe)) { - if (reason != ModifierFunction.DEFAULT_FAILURE) { - map.put(recipe, reason); + logic.recordFailureReason(recipe, reason, 0.0); + } + + /** + * Record a failure reason as the one to display, along with the recipe it belongs to. It becomes the displayed + * reason only if it's usable and beats the current best score. + */ + protected void recordFailureReason(@Nullable GTRecipe recipe, @Nullable Component reason, double score) { + if (reason != null && !reason.getString().isBlank()) { + if (score > bestFailureScore) { + bestFailureScore = score; + bestFailureReason = reason; + bestFailureRecipe = recipeDisplayName(recipe); } - } else { - map.put(recipe, reason); } } + + /** The display name shown for a recipe in failure tooltips: its id, or {@code null} if the recipe is null. */ + protected static @Nullable Component recipeDisplayName(@Nullable GTRecipe recipe) { + return recipe == null ? null : Component.literal(recipe.id.toString()); + } + + /** Forget the currently-displayed failure reason. */ + protected void clearFailureReason() { + bestFailureReason = null; + bestFailureRecipe = null; + bestFailureScore = Double.NEGATIVE_INFINITY; + } } diff --git a/src/main/java/com/gregtechceu/gtceu/api/recipe/ActionResult.java b/src/main/java/com/gregtechceu/gtceu/api/recipe/ActionResult.java index 9294b66e51e..e3fe08cacf5 100644 --- a/src/main/java/com/gregtechceu/gtceu/api/recipe/ActionResult.java +++ b/src/main/java/com/gregtechceu/gtceu/api/recipe/ActionResult.java @@ -10,19 +10,27 @@ /** * @param isSuccess is action success * @param reason if fail, fail reason + * @param score how close the recipe was to succeeding, in {@code [0, 1]}. {@code 1} means fully satisfied + * (success), {@code 0} means nothing matched. Used to pick the most-relevant failure reason to + * display. Failures that don't measure contents (conditions, missing capabilities) use {@code 0}. */ public record ActionResult(boolean isSuccess, @Nullable Component reason, @Nullable RecipeCapability capability, - @Nullable IO io) { + @Nullable IO io, double score) { - public final static ActionResult SUCCESS = new ActionResult(true, null, null, null); - public final static ActionResult FAIL_NO_REASON = new ActionResult(false, null, null, null); + public final static ActionResult SUCCESS = new ActionResult(true, null, null, null, 1.0); + public final static ActionResult FAIL_NO_REASON = new ActionResult(false, null, null, null, 0.0); public final static ActionResult PASS_NO_CONTENTS = new ActionResult(true, - Component.translatable("gtceu.recipe_logic.no_contents"), null, null); + Component.translatable("gtceu.recipe_logic.no_contents"), null, null, 1.0); public final static ActionResult FAIL_NO_CAPABILITIES = new ActionResult(false, - Component.translatable("gtceu.recipe_logic.no_capabilities"), null, null); + Component.translatable("gtceu.recipe_logic.no_capabilities"), null, null, 0.0); public static ActionResult fail(@Nullable Component component, @Nullable RecipeCapability capability, IO io) { - return new ActionResult(false, component, capability, io); + return fail(component, capability, io, 0.0); + } + + public static ActionResult fail(@Nullable Component component, @Nullable RecipeCapability capability, IO io, + double score) { + return new ActionResult(false, component, capability, io, score); } public Component reason() { diff --git a/src/main/java/com/gregtechceu/gtceu/api/recipe/RecipeHelper.java b/src/main/java/com/gregtechceu/gtceu/api/recipe/RecipeHelper.java index fb84163e48e..ec4f0387bf4 100644 --- a/src/main/java/com/gregtechceu/gtceu/api/recipe/RecipeHelper.java +++ b/src/main/java/com/gregtechceu/gtceu/api/recipe/RecipeHelper.java @@ -233,7 +233,7 @@ public static ActionResult handleRecipe(IRecipeCapabilityHolder holder, GTRecipe } String key = "gtceu.recipe_logic.insufficient_" + (io == IO.IN ? "in" : "out"); return ActionResult.fail(Component.translatable(key) - .append(": ").append(result.capability().getName()), result.capability(), io); + .append(": ").append(result.capability().getName()), result.capability(), io, result.score()); } public static ActionResult matchContents(IRecipeCapabilityHolder holder, GTRecipe recipe) { diff --git a/src/main/java/com/gregtechceu/gtceu/api/recipe/RecipeRunner.java b/src/main/java/com/gregtechceu/gtceu/api/recipe/RecipeRunner.java index d23fc3b5501..aa1e928627c 100644 --- a/src/main/java/com/gregtechceu/gtceu/api/recipe/RecipeRunner.java +++ b/src/main/java/com/gregtechceu/gtceu/api/recipe/RecipeRunner.java @@ -17,6 +17,7 @@ import it.unimi.dsi.fastutil.objects.Reference2ObjectOpenHashMap; import lombok.Getter; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import java.util.ArrayList; import java.util.Collections; @@ -43,6 +44,10 @@ public class RecipeRunner { @Getter private int groupColor; + /** Highest closeness score seen across handler-group attempts, and the leftover map that produced it. */ + private double bestScore = 0; + private @Nullable Map, List> bestLeftover; + public RecipeRunner(GTRecipe recipe, IO io, boolean isTick, IRecipeCapabilityHolder holder, Map, Object2IntMap> chanceCaches, boolean simulated) { @@ -150,9 +155,15 @@ private ActionResult handleContents() { } } if (io == IO.OUT) { - if (hasAnyNonVoidingContents(res)) continue; + if (hasAnyNonVoidingContents(res)) { + recordLeftover(res); + continue; + } } else if (io == IO.IN) { - if (!res.isEmpty()) continue; + if (!res.isEmpty()) { + recordLeftover(res); + continue; + } } if (!simulated) { // Actually consume the contents of this handler and also all the bypassed handlers @@ -202,9 +213,15 @@ private ActionResult handleContents() { } if (io == IO.OUT) { - if (hasAnyNonVoidingContents(copiedRecipeContents)) continue; + if (hasAnyNonVoidingContents(copiedRecipeContents)) { + recordLeftover(copiedRecipeContents); + continue; + } } else if (io == IO.IN) { - if (!copiedRecipeContents.isEmpty()) continue; + if (!copiedRecipeContents.isEmpty()) { + recordLeftover(copiedRecipeContents); + continue; + } } if (simulated) return ActionResult.SUCCESS; // Start actually removing items. @@ -235,7 +252,17 @@ private ActionResult handleContents() { entry.getValue().clear(); } if (entry.getValue() != null && !entry.getValue().isEmpty()) { - return ActionResult.fail(null, entry.getKey(), io); + RecipeCapability failedCap = entry.getKey(); + if (simulated && bestLeftover != null) { + RecipeCapability best = null; + for (var leftoverEntry : bestLeftover.entrySet()) { + if (leftoverEntry.getValue() != null && !leftoverEntry.getValue().isEmpty()) { + best = entry.getKey(); + } + } + if (best != null) failedCap = best; + } + return ActionResult.fail(null, failedCap, io, bestScore); } } @@ -251,7 +278,33 @@ private ActionResult handleContents() { return ActionResult.PASS_NO_CONTENTS; } - return ActionResult.FAIL_NO_REASON; + return ActionResult.fail(null, null, io, bestScore); + } + + /** + * Records a failed handler-group attempt's leftover map if it's the closest match seen so far. Higher score wins; + * ties keep the earlier attempt. The retained map identifies which capabilities the closest attempt was short on. + */ + private void recordLeftover(Map, List> leftover) { + double score = scoreLeftover(leftover); + if (bestLeftover == null || score > bestScore) { + bestScore = score; + bestLeftover = leftover; + } + } + + private double scoreLeftover(Map, List> leftover) { + double sum = 0; + int caps = 0; + for (var entry : searchRecipeContents.entrySet()) { + int total = entry.getValue().size(); + if (total <= 0) continue; + caps++; + var leftList = leftover.get(entry.getKey()); + int leftCount = leftList == null ? 0 : Math.min(leftList.size(), total); + sum += (double) (total - leftCount) / total; + } + return caps == 0 ? 1.0 : sum / caps; } private boolean hasAnyNonVoidingContents(Map, List> contents) { diff --git a/src/test/java/com/gregtechceu/gtceu/api/machine/trait/RecipeLogicTest.java b/src/test/java/com/gregtechceu/gtceu/api/machine/trait/RecipeLogicTest.java index 876a02b1c70..710440ab692 100644 --- a/src/test/java/com/gregtechceu/gtceu/api/machine/trait/RecipeLogicTest.java +++ b/src/test/java/com/gregtechceu/gtceu/api/machine/trait/RecipeLogicTest.java @@ -15,6 +15,7 @@ import net.minecraft.gametest.framework.GameTestHelper; import net.minecraft.server.level.ServerLevel; import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; import net.minecraft.world.level.block.Blocks; import net.minecraft.world.level.block.entity.BlockEntity; import net.minecraftforge.gametest.GameTestHolder; @@ -45,6 +46,28 @@ public static void prepare(ServerLevel level) { .outputItems(new ItemStack(Blocks.STONE)) .EUt(GTValues.VA[GTValues.HV]).duration(1) .buildRawRecipe()); + // Two candidates with disjoint input-item type sets, so both are yielded by the search at once. + // Candidate A: only its 2nd item ends up short -> 1 of 2 contents satisfied (score 0.5). + LCR_RECIPE_TYPE.getAdditionHandler().addStaging(LCR_RECIPE_TYPE + .recipeBuilder(GTCEu.id("test_close_a")) + .inputItems(new ItemStack(Blocks.DIRT, 16), new ItemStack(Blocks.GRAVEL, 16)) + .outputItems(new ItemStack(Blocks.STONE)) + .EUt(GTValues.VA[GTValues.HV]).duration(1) + .buildRawRecipe()); + // Candidate B: both items short -> 0 of 2 contents satisfied (score 0.0). + LCR_RECIPE_TYPE.getAdditionHandler().addStaging(LCR_RECIPE_TYPE + .recipeBuilder(GTCEu.id("test_close_b")) + .inputItems(new ItemStack(Blocks.NETHERRACK, 16), new ItemStack(Blocks.SAND, 16)) + .outputItems(new ItemStack(Blocks.STONE)) + .EUt(GTValues.VA[GTValues.HV]).duration(1) + .buildRawRecipe()); + // Single-ingredient recipe used to test that a stalled "last recipe" keeps top priority. + LCR_RECIPE_TYPE.getAdditionHandler().addStaging(LCR_RECIPE_TYPE + .recipeBuilder(GTCEu.id("test_priority_last")) + .inputItems(new ItemStack(Items.EMERALD, 1)) + .outputItems(new ItemStack(Blocks.STONE)) + .EUt(GTValues.VA[GTValues.HV]).duration(1) + .buildRawRecipe()); LCR_RECIPE_TYPE.getAdditionHandler().completeStaging(); CR_RECIPE_TYPE.getAdditionHandler().beginStaging(); @@ -240,4 +263,70 @@ public static void recipeLogicInTwoStacksTest(GameTestHelper helper) { busHolder.outputBus1.getInventory().getStackInSlot(0).getDisplayName()); }); } + + /** + * When several recipes fail, only the single most-relevant reason should be surfaced: the candidate closest to + * succeeding (highest fraction of contents satisfied), not a wall of every failed recipe. + */ + @GameTest(template = "lcr_input_separation", batch = "RecipeLogic") + public static void recipeLogicClosestFailureReasonTest(GameTestHelper helper) { + RecipeLogicTest.BusHolder busHolder = getBussesAndForm(helper); + RecipeLogic recipeLogic = busHolder.controller.getRecipeLogic(); + + // Candidate A (test_close_a): dirt satisfied, gravel short -> 1 of 2 contents = score 0.5 + busHolder.inputBus1.getInventory().setStackInSlot(0, new ItemStack(Blocks.DIRT, 16)); + busHolder.inputBus1.getInventory().setStackInSlot(1, new ItemStack(Blocks.GRAVEL, 8)); + // Candidate B (test_close_b): both short -> 0 of 2 contents = score 0.0 + busHolder.inputBus2.getInventory().setStackInSlot(0, new ItemStack(Blocks.NETHERRACK, 8)); + busHolder.inputBus2.getInventory().setStackInSlot(1, new ItemStack(Blocks.SAND, 8)); + + recipeLogic.findAndHandleRecipe(); + + helper.assertFalse(recipeLogic.isActive(), "No recipe should run when all candidates are short"); + double score = recipeLogic.getBestFailureScore(); + helper.assertTrue(Math.abs(score - 0.5) < 1e-6, + "Closest candidate (A) score should be 0.5, was " + score); + helper.assertTrue(recipeLogic.getBestFailureReason() != null && + !recipeLogic.getBestFailureReason().getString().isBlank(), + "A single, non-blank failure reason should be surfaced"); + var failureRecipe = recipeLogic.getBestFailureRecipe(); + helper.assertTrue(failureRecipe != null && + failureRecipe.getString().equals(GTCEu.id("test_close_a").toString()), + "The surfaced reason should be attributed to the closest candidate (test_close_a), was " + + (failureRecipe == null ? "null" : failureRecipe.getString())); + + helper.succeed(); + } + + /** + * If the machine was already running a recipe and it can no longer continue, that recipe's reason must win + * unconditionally - even when another candidate is closer to succeeding. + */ + @GameTest(template = "lcr_input_separation", batch = "RecipeLogic") + public static void recipeLogicLastRecipePriorityTest(GameTestHelper helper) { + RecipeLogicTest.BusHolder busHolder = getBussesAndForm(helper); + RecipeLogic recipeLogic = busHolder.controller.getRecipeLogic(); + + // Start the priority recipe so it becomes the "last recipe" (its single emerald is consumed on setup). + busHolder.inputBus1.getInventory().setStackInSlot(0, new ItemStack(Items.EMERALD, 1)); + recipeLogic.findAndHandleRecipe(); + helper.assertFalse(recipeLogic.getLastRecipe() == null, "Priority recipe should have started"); + + // Now stage a closer-scoring (0.5) candidate A. The last recipe can no longer run (no emerald left). + busHolder.inputBus1.getInventory().setStackInSlot(0, new ItemStack(Blocks.DIRT, 16)); + busHolder.inputBus1.getInventory().setStackInSlot(1, new ItemStack(Blocks.GRAVEL, 8)); + + recipeLogic.findAndHandleRecipe(); + + double score = recipeLogic.getBestFailureScore(); + helper.assertTrue(Double.isInfinite(score) && score > 0, + "The stalled last recipe's reason should be locked in with top priority, score was " + score); + var failureRecipe = recipeLogic.getBestFailureRecipe(); + helper.assertTrue(failureRecipe != null && + failureRecipe.getString().equals(GTCEu.id("test_priority_last").toString()), + "The surfaced reason should name the stalled last recipe (test_priority_last), not the closer " + + "candidate, was " + (failureRecipe == null ? "null" : failureRecipe.getString())); + + helper.succeed(); + } } diff --git a/src/test/java/com/gregtechceu/gtceu/gametest/util/TestUtils.java b/src/test/java/com/gregtechceu/gtceu/gametest/util/TestUtils.java index 1b3481957a9..2d39e773045 100644 --- a/src/test/java/com/gregtechceu/gtceu/gametest/util/TestUtils.java +++ b/src/test/java/com/gregtechceu/gtceu/gametest/util/TestUtils.java @@ -225,18 +225,12 @@ public static GTRecipeType createRecipeType(String name, int maxInputs, int maxO } /** - * Fetches the set of Failed Recipes and Reasons from a machine's {@link RecipeLogic} - * Returns a newline-separated string of all failed recipes and their failure reasons + * Fetches the most-relevant failure reason from a machine's {@link RecipeLogic}. + * Returns a newline-separated string of the surfaced failure reason(s). */ public static String getFailures(RecipeLogic recipeLogic) { - var reasons = recipeLogic.getFailureReasonMap(); - StringBuilder failures = new StringBuilder(); - if (!reasons.isEmpty()) { - for (var reason : reasons.entrySet()) { - failures.append(reason.getKey().id).append(" - ").append(reason).append("\n"); - } - } - return failures.toString(); + var reason = recipeLogic.getBestFailureReason(); + return reason == null ? "" : reason.getString() + "\n"; } public static CoverBehavior placeCover(GameTestHelper helper, MetaMachine machine, ItemStack stack, From 41879523e886b5ef0cd4ab9a5d839291abef3506 Mon Sep 17 00:00:00 2001 From: jurrejelle Date: Thu, 18 Jun 2026 17:05:15 +0200 Subject: [PATCH 2/6] Spotless and also simplifying the check in handleContents --- .../com/gregtechceu/gtceu/api/recipe/RecipeRunner.java | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/gregtechceu/gtceu/api/recipe/RecipeRunner.java b/src/main/java/com/gregtechceu/gtceu/api/recipe/RecipeRunner.java index aa1e928627c..ab1f6d33276 100644 --- a/src/main/java/com/gregtechceu/gtceu/api/recipe/RecipeRunner.java +++ b/src/main/java/com/gregtechceu/gtceu/api/recipe/RecipeRunner.java @@ -254,13 +254,12 @@ private ActionResult handleContents() { if (entry.getValue() != null && !entry.getValue().isEmpty()) { RecipeCapability failedCap = entry.getKey(); if (simulated && bestLeftover != null) { - RecipeCapability best = null; for (var leftoverEntry : bestLeftover.entrySet()) { - if (leftoverEntry.getValue() != null && !leftoverEntry.getValue().isEmpty()) { - best = entry.getKey(); + if (leftoverEntry.getValue() != null && !leftoverEntry.getValue().isEmpty() && + leftoverEntry.getKey() != null) { + failedCap = leftoverEntry.getKey(); } } - if (best != null) failedCap = best; } return ActionResult.fail(null, failedCap, io, bestScore); } From e6f8bc915b29f62cdf6178390f1554424636f33e Mon Sep 17 00:00:00 2001 From: jurrejelle Date: Thu, 18 Jun 2026 17:13:59 +0200 Subject: [PATCH 3/6] fix tests bcs I'm doopid --- .../gregtechceu/gtceu/api/machine/trait/RecipeLogicTest.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/test/java/com/gregtechceu/gtceu/api/machine/trait/RecipeLogicTest.java b/src/test/java/com/gregtechceu/gtceu/api/machine/trait/RecipeLogicTest.java index 710440ab692..f5f5cd1c1dd 100644 --- a/src/test/java/com/gregtechceu/gtceu/api/machine/trait/RecipeLogicTest.java +++ b/src/test/java/com/gregtechceu/gtceu/api/machine/trait/RecipeLogicTest.java @@ -291,7 +291,7 @@ public static void recipeLogicClosestFailureReasonTest(GameTestHelper helper) { "A single, non-blank failure reason should be surfaced"); var failureRecipe = recipeLogic.getBestFailureRecipe(); helper.assertTrue(failureRecipe != null && - failureRecipe.getString().equals(GTCEu.id("test_close_a").toString()), + failureRecipe.getString().endsWith("/test_close_a"), "The surfaced reason should be attributed to the closest candidate (test_close_a), was " + (failureRecipe == null ? "null" : failureRecipe.getString())); @@ -323,7 +323,7 @@ public static void recipeLogicLastRecipePriorityTest(GameTestHelper helper) { "The stalled last recipe's reason should be locked in with top priority, score was " + score); var failureRecipe = recipeLogic.getBestFailureRecipe(); helper.assertTrue(failureRecipe != null && - failureRecipe.getString().equals(GTCEu.id("test_priority_last").toString()), + failureRecipe.getString().endsWith("/test_priority_last"), "The surfaced reason should name the stalled last recipe (test_priority_last), not the closer " + "candidate, was " + (failureRecipe == null ? "null" : failureRecipe.getString())); From 4f1b2f768a28680f6b1dd1a681e3063405e871bc Mon Sep 17 00:00:00 2001 From: jurrejelle Date: Thu, 18 Jun 2026 17:22:11 +0200 Subject: [PATCH 4/6] Static colon to save allocations --- .../gtceu/api/machine/multiblock/MultiblockDisplayText.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/gregtechceu/gtceu/api/machine/multiblock/MultiblockDisplayText.java b/src/main/java/com/gregtechceu/gtceu/api/machine/multiblock/MultiblockDisplayText.java index aa5b29d6398..8ee2aa58717 100644 --- a/src/main/java/com/gregtechceu/gtceu/api/machine/multiblock/MultiblockDisplayText.java +++ b/src/main/java/com/gregtechceu/gtceu/api/machine/multiblock/MultiblockDisplayText.java @@ -372,6 +372,7 @@ public Builder addCustomProgressLine(RecipeLogic recipeLogic) { return this; } + static final private Component COLON = Component.literal(": "); public Builder addRecipeFailReasonLine(RecipeLogic recipeLogic) { if (!isStructureFormed || !recipeLogic.isIdle()) return this; @@ -380,7 +381,7 @@ public Builder addRecipeFailReasonLine(RecipeLogic recipeLogic) { textList.add(Component.translatable("gtceu.recipe_logic.setup_fail").withStyle(ChatFormatting.RED)); var recipe = recipeLogic.getBestFailureRecipe(); if (recipe != null) { - textList.add(Component.literal(" - ").append(recipe).append(": ").append(reason)); + textList.add(Component.literal(" - ").append(recipe).append(COLON).append(reason)); } else { textList.add(Component.literal(" - ").append(reason)); } From 8d8e4d483dcbf25e44145f44770fa7f5b0ab7f53 Mon Sep 17 00:00:00 2001 From: jurrejelle Date: Thu, 18 Jun 2026 17:49:25 +0200 Subject: [PATCH 5/6] spotless --- .../gtceu/api/machine/multiblock/MultiblockDisplayText.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/com/gregtechceu/gtceu/api/machine/multiblock/MultiblockDisplayText.java b/src/main/java/com/gregtechceu/gtceu/api/machine/multiblock/MultiblockDisplayText.java index 8ee2aa58717..21027cbe6aa 100644 --- a/src/main/java/com/gregtechceu/gtceu/api/machine/multiblock/MultiblockDisplayText.java +++ b/src/main/java/com/gregtechceu/gtceu/api/machine/multiblock/MultiblockDisplayText.java @@ -373,6 +373,7 @@ public Builder addCustomProgressLine(RecipeLogic recipeLogic) { } static final private Component COLON = Component.literal(": "); + public Builder addRecipeFailReasonLine(RecipeLogic recipeLogic) { if (!isStructureFormed || !recipeLogic.isIdle()) return this; From 0763d517543d479558ccabd7d46d05f63a7abc18 Mon Sep 17 00:00:00 2001 From: jurrejelle Date: Fri, 26 Jun 2026 08:27:22 +0200 Subject: [PATCH 6/6] Update UX for MUI --- .../multiblock/WorkableMultiblockMachine.java | 1 + .../electric/research/DataBankMachine.java | 1 + .../electric/research/HPCAMachine.java | 1 + .../research/ResearchStationMachine.java | 1 + .../common/mui/GTMultiblockTextUtil.java | 44 +++++++++++++++++++ 5 files changed, 48 insertions(+) diff --git a/src/main/java/com/gregtechceu/gtceu/api/machine/multiblock/WorkableMultiblockMachine.java b/src/main/java/com/gregtechceu/gtceu/api/machine/multiblock/WorkableMultiblockMachine.java index 9a43fcfd2df..88527e0237e 100644 --- a/src/main/java/com/gregtechceu/gtceu/api/machine/multiblock/WorkableMultiblockMachine.java +++ b/src/main/java/com/gregtechceu/gtceu/api/machine/multiblock/WorkableMultiblockMachine.java @@ -335,6 +335,7 @@ public List getWidgetsForDisplay(PanelSyncManager syncManager) { widgets.add(GTMultiblockTextUtil.addSubtickParallelsLine(this, syncManager)); widgets.add(GTMultiblockTextUtil.addTotalRunsLine(this, syncManager)); widgets.add(GTMultiblockTextUtil.addOutputLines(this, syncManager)); + widgets.addAll(GTMultiblockTextUtil.addRecipeFailReasonLines(this, syncManager)); return widgets; } } diff --git a/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/electric/research/DataBankMachine.java b/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/electric/research/DataBankMachine.java index f0f5364f47c..3fc796ee456 100644 --- a/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/electric/research/DataBankMachine.java +++ b/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/electric/research/DataBankMachine.java @@ -183,6 +183,7 @@ public List getWidgetsForDisplay(PanelSyncManager syncManager) { widgets.add(GTMultiblockTextUtil.addEnergyUsageExactLine(this, syncManager, energyStoredSyncValue)); widgets.add(GTMultiblockTextUtil.addWorkingStatusLine(this, syncManager, () -> Component.translatable("gtceu.multiblock.data_bank.providing").withStyle(ChatFormatting.GREEN))); + widgets.addAll(GTMultiblockTextUtil.addRecipeFailReasonLines(this, syncManager)); return widgets; } diff --git a/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/electric/research/HPCAMachine.java b/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/electric/research/HPCAMachine.java index 81c3c5fd00c..654db1bc4f4 100644 --- a/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/electric/research/HPCAMachine.java +++ b/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/electric/research/HPCAMachine.java @@ -262,6 +262,7 @@ public List getWidgetsForDisplay(PanelSyncManager syncManager) { widgets.add(GTMultiblockTextUtil.addUnformedWarning(this, syncManager)); widgets.add(GTMultiblockTextUtil.addWorkingStatusLine(this, syncManager)); widgets.add(GTMultiblockTextUtil.addEnergyUsageExactLine(this, syncManager)); + widgets.addAll(GTMultiblockTextUtil.addRecipeFailReasonLines(this, syncManager)); widgets.add(new TextWidget<>(Text.dynamic(text::getValue))); widgets.add(new Grid() .gridOfSizeWidth(9, 3, (x, y, i) -> hpcaHandler.getComponentTexture(i).asWidget() diff --git a/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/electric/research/ResearchStationMachine.java b/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/electric/research/ResearchStationMachine.java index 2828cb85569..11931fed8d8 100644 --- a/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/electric/research/ResearchStationMachine.java +++ b/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/electric/research/ResearchStationMachine.java @@ -138,6 +138,7 @@ public List getWidgetsForDisplay(PanelSyncManager syncManager) { widgets.add(GTMultiblockTextUtil.addOutputLines(this, syncManager)); // .addComputationUsageExactLine(computationProvider.getMaxCWUt()) // TODO: (Onion) widgets.add(GTMultiblockTextUtil.addProgressLinePercentOnly(this, syncManager)); + widgets.addAll(GTMultiblockTextUtil.addRecipeFailReasonLines(this, syncManager)); return widgets; } diff --git a/src/main/java/com/gregtechceu/gtceu/common/mui/GTMultiblockTextUtil.java b/src/main/java/com/gregtechceu/gtceu/common/mui/GTMultiblockTextUtil.java index 99b246a0328..d2884f32e8f 100644 --- a/src/main/java/com/gregtechceu/gtceu/common/mui/GTMultiblockTextUtil.java +++ b/src/main/java/com/gregtechceu/gtceu/common/mui/GTMultiblockTextUtil.java @@ -40,6 +40,7 @@ import org.jetbrains.annotations.Nullable; import java.util.ArrayList; +import java.util.List; import java.util.Optional; import java.util.function.Supplier; @@ -392,6 +393,49 @@ public static TextWidget addWorkingStatusLine(WorkableMultiblockMachine rlMac .setEnabledIf((w) -> isFormed.getBoolValue()); } + @SuppressWarnings("unchecked") + public static List> addRecipeFailReasonLines(WorkableMultiblockMachine rlMachine, + PanelSyncManager syncManager) { + BooleanSyncValue isFormed = syncManager.getOrCreateSyncHandler("isFormed", BooleanSyncValue.class, + () -> new BooleanSyncValue(rlMachine::isFormed)); + + BooleanSyncValue isIdle = syncManager.getOrCreateSyncHandler("isIdle", BooleanSyncValue.class, + () -> new BooleanSyncValue(() -> rlMachine.getRecipeLogic().isIdle())); + GenericSyncValue bestFailureRecipe = (GenericSyncValue) syncManager + .getOrCreateSyncHandler("bestFailureRecipe", GenericSyncValue.class, + () -> GenericSyncValue.builder(Component.class) + .nullable() + .adapter(GTByteBufAdapters.COMPONENT) + .getter(() -> rlMachine.getRecipeLogic().getBestFailureRecipe()) + .build()); + GenericSyncValue bestFailureReason = (GenericSyncValue) syncManager + .getOrCreateSyncHandler("bestFailureReason", GenericSyncValue.class, + () -> GenericSyncValue.builder(Component.class) + .nullable() + .adapter(GTByteBufAdapters.COMPONENT) + .getter(() -> rlMachine.getRecipeLogic().getBestFailureReason()) + .build()); + var lineList = new ArrayList>(); + + lineList.add(Text + .dynamic(() -> { + return Component.translatable("gtceu.recipe_logic.setup_fail").withStyle(ChatFormatting.RED); + }) + .asWidget() + .setEnabledIf((w) -> isFormed.getBoolValue() && isIdle.getBoolValue() && + bestFailureReason.getValue() != null)); + lineList.add(Text + .dynamic(() -> { + var reason = bestFailureReason.getValue(); + if (reason == null) return Component.empty(); + return Component.literal(" - ").append(reason); + }) + .asWidget() + .setEnabledIf((w) -> isFormed.getBoolValue() && isIdle.getBoolValue() && + bestFailureReason.getValue() != null)); + return lineList; + } + @SuppressWarnings("unchecked") public static DynamicWidget addOutputLines(WorkableMultiblockMachine rlmachine, PanelSyncManager syncManager) {