diff --git a/src/main/java/codechicken/nei/recipe/AutoCraftingManager.java b/src/main/java/codechicken/nei/recipe/AutoCraftingManager.java index 0717c3db5..cc3629226 100644 --- a/src/main/java/codechicken/nei/recipe/AutoCraftingManager.java +++ b/src/main/java/codechicken/nei/recipe/AutoCraftingManager.java @@ -29,6 +29,7 @@ public void execute() { final List initialItems = prepareInitialItems(math, getInventoryItems(guiContainer)); boolean processed = false; boolean changed = false; + final CraftRampThrottle throttle = new CraftRampThrottle(); StackInfo.pauseItemDamageSound(true); @@ -48,9 +49,19 @@ public void execute() { if (handler != null && handler.canCraft(guiContainer)) { long multiplier = entry.getValue(); - while (multiplier > 0 && !interrupted(guiContainer) - && handler.craft(guiContainer, (int) Math.min(64, multiplier))) { - multiplier -= 64; + while (multiplier > 0 && !interrupted(guiContainer)) { + final CraftRampThrottle.Tick tick = throttle.next(entry.getKey()); + sleepInterruptibly(tick.delayMs, guiContainer); + if (interrupted(guiContainer)) break; + + boolean crafted = false; + for (int i = 0; i < tick.crafts && multiplier > 0 && !interrupted(guiContainer); i++) { + if (!handler.craft(guiContainer, 1)) break; + multiplier -= 1; + crafted = true; + } + + if (!crafted) break; // output couldn't be taken (e.g. inventory full) } craft = multiplier != entry.getValue(); @@ -86,6 +97,19 @@ private boolean interrupted(GuiContainer guiContainer) { return interrupted() || guiContainer != NEIClientUtils.getGuiContainer(); } + private void sleepInterruptibly(long delayMs, GuiContainer guiContainer) { + long remaining = delayMs; + while (remaining > 0 && !interrupted(guiContainer)) { + final long chunk = Math.min(20L, remaining); + try { + Thread.sleep(chunk); + } catch (InterruptedException ignored) { + return; + } + remaining -= chunk; + } + } + private List prepareInitialItems(RecipeChainMath math, ItemStackAmount inventory) { final List initialItems = new ArrayList<>(); diff --git a/src/main/java/codechicken/nei/recipe/CraftRampThrottle.java b/src/main/java/codechicken/nei/recipe/CraftRampThrottle.java new file mode 100644 index 000000000..f61c92ee1 --- /dev/null +++ b/src/main/java/codechicken/nei/recipe/CraftRampThrottle.java @@ -0,0 +1,49 @@ +package codechicken.nei.recipe; + +import java.util.Objects; + +import codechicken.nei.recipe.Recipe.RecipeId; + +/** + * Per-craft delay ramp for auto-crafting. Crafting a recipe repeatedly speeds up (delay decays geometrically toward a + * floor). Momentum is global: switching to a different recipe reduces speed by a penalty but does not reset it, so + * going back to a recipe resumes with most of its momentum. At floor (max) speed crafts are batched. Pure logic; does + * not sleep. + */ +public class CraftRampThrottle { + + public static final long START_DELAY_MS = 300L; // delay before the 1st craft + public static final long FLOOR_DELAY_MS = 100L; // fastest allowed (cap) + public static final double DECAY = 0.88D; // per-craft speedup + public static final double SWITCH_PENALTY = 2.0D; // momentum lost on recipe change + public static final int BULK_CRAFTS = 4; // crafts per tick once maxed out + + /** Delay to wait before a craft, and how many crafts that tick covers. */ + public static final class Tick { + + public final long delayMs; + public final int crafts; + + Tick(long delayMs, int crafts) { + this.delayMs = delayMs; + this.crafts = crafts; + } + } + + private RecipeId current; + private long delayMs = START_DELAY_MS; + + /** Next tick for {@code id}: decays on repeat, slows (but keeps momentum) on a recipe change. */ + public Tick next(RecipeId id) { + if (!Objects.equals(id, this.current)) { + if (this.current != null) { + this.delayMs = Math.min(START_DELAY_MS, Math.round(this.delayMs * SWITCH_PENALTY)); + } + this.current = id; + } + + final long delay = this.delayMs; + this.delayMs = Math.max(FLOOR_DELAY_MS, Math.round(delay * DECAY)); + return new Tick(delay, delay <= FLOOR_DELAY_MS ? BULK_CRAFTS : 1); + } +} diff --git a/src/main/java/codechicken/nei/recipe/DefaultOverlayHandler.java b/src/main/java/codechicken/nei/recipe/DefaultOverlayHandler.java index 78cdf7e92..454703a0d 100644 --- a/src/main/java/codechicken/nei/recipe/DefaultOverlayHandler.java +++ b/src/main/java/codechicken/nei/recipe/DefaultOverlayHandler.java @@ -123,6 +123,12 @@ public boolean craft(GuiContainer firstGui, IRecipeHandler handler, int recipeIn if (craftingSlot.getHasStack() && craftingSlot.canTakeStack(firstGui.mc.thePlayer)) { FastTransferManager.clickSlot(firstGui, craftingSlot.slotNumber, 0, 1); + + // Output still present means it wasn't taken (e.g. inventory full); stop. + if (craftingSlot.getHasStack()) { + break; + } + craft = true; } diff --git a/src/test/java/codechicken/nei/recipe/CraftRampThrottleTest.java b/src/test/java/codechicken/nei/recipe/CraftRampThrottleTest.java new file mode 100644 index 000000000..6d7cc2e6d --- /dev/null +++ b/src/test/java/codechicken/nei/recipe/CraftRampThrottleTest.java @@ -0,0 +1,87 @@ +package codechicken.nei.recipe; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import codechicken.nei.recipe.CraftRampThrottle.Tick; +import codechicken.nei.recipe.Recipe.RecipeId; + +class CraftRampThrottleTest { + + private static Tick rampToFloor(CraftRampThrottle throttle, RecipeId id) { + Tick tick = null; + for (int i = 0; i < 100; i++) { + tick = throttle.next(id); + } + return tick; + } + + @Test + @DisplayName("first craft of a recipe uses the start delay and is single") + void firstCraftUsesStartDelay() { + CraftRampThrottle throttle = new CraftRampThrottle(); + RecipeId id = mock(RecipeId.class); + + Tick tick = throttle.next(id); + assertEquals(CraftRampThrottle.START_DELAY_MS, tick.delayMs); + assertEquals(1, tick.crafts); + } + + @Test + @DisplayName("same recipe decays monotonically and clamps at floor") + void rampDecaysToFloor() { + CraftRampThrottle throttle = new CraftRampThrottle(); + RecipeId id = mock(RecipeId.class); + + long prev = Long.MAX_VALUE; + for (int i = 0; i < 5; i++) { + long delay = throttle.next(id).delayMs; + assertTrue(delay <= prev, "delay should not increase while ramping"); + assertTrue(delay >= CraftRampThrottle.FLOOR_DELAY_MS, "delay should not drop below floor"); + prev = delay; + } + + assertEquals(CraftRampThrottle.FLOOR_DELAY_MS, rampToFloor(throttle, id).delayMs); + } + + @Test + @DisplayName("at floor speed, crafts happen in bulk") + void bulkAtFloorSpeed() { + CraftRampThrottle throttle = new CraftRampThrottle(); + RecipeId id = mock(RecipeId.class); + + Tick tick = rampToFloor(throttle, id); + assertEquals(CraftRampThrottle.FLOOR_DELAY_MS, tick.delayMs); + assertEquals(CraftRampThrottle.BULK_CRAFTS, tick.crafts); + } + + @Test + @DisplayName("crafts stay single while still ramping") + void singleWhileRamping() { + CraftRampThrottle throttle = new CraftRampThrottle(); + RecipeId id = mock(RecipeId.class); + + assertEquals(1, throttle.next(id).crafts); + assertEquals(1, throttle.next(id).crafts); + } + + @Test + @DisplayName("recipe change reduces momentum but does not fully reset") + void recipeChangeReducesMomentum() { + CraftRampThrottle throttle = new CraftRampThrottle(); + RecipeId a = mock(RecipeId.class); + RecipeId b = mock(RecipeId.class); + + // Ramp A to full speed (floor). + assertEquals(CraftRampThrottle.FLOOR_DELAY_MS, rampToFloor(throttle, a).delayMs); + + // Switching to B slows down, but keeps momentum: slower than floor, faster than a cold start. + long afterSwitch = throttle.next(b).delayMs; + assertTrue(afterSwitch > CraftRampThrottle.FLOOR_DELAY_MS, "switch should reduce momentum"); + assertTrue(afterSwitch < CraftRampThrottle.START_DELAY_MS, "switch should not fully reset"); + } +}