From 9ce2aa40bb7db5dfbe0d65527995346000434464 Mon Sep 17 00:00:00 2001 From: Caedis Date: Fri, 17 Jul 2026 13:58:14 -0500 Subject: [PATCH 1/7] feat(autocraft): add CraftRampThrottle ramp math Pure per-craft delay ramp for throttling auto-craft: first craft instant, then geometric decay from 500ms toward a 50ms floor, resetting when the RecipeId changes. Wired into AutoCrafting in a later change. --- .../nei/recipe/CraftRampThrottle.java | 33 ++++++++++++ .../nei/recipe/CraftRampThrottleTest.java | 52 +++++++++++++++++++ 2 files changed, 85 insertions(+) create mode 100644 src/main/java/codechicken/nei/recipe/CraftRampThrottle.java create mode 100644 src/test/java/codechicken/nei/recipe/CraftRampThrottleTest.java 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..378240c26 --- /dev/null +++ b/src/main/java/codechicken/nei/recipe/CraftRampThrottle.java @@ -0,0 +1,33 @@ +package codechicken.nei.recipe; + +import java.util.Objects; + +import codechicken.nei.recipe.Recipe.RecipeId; + +/** + * Per-craft delay ramp for auto-crafting. First craft of a recipe is instant, + * then the delay decays geometrically toward a floor. Changing the recipe + * resets the ramp. Pure logic; does not sleep. + */ +public class CraftRampThrottle { + + public static final long START_DELAY_MS = 500L; // delay before the 2nd craft + public static final long FLOOR_DELAY_MS = 50L; // fastest allowed (cap) + public static final double DECAY = 0.85D; + + private RecipeId current; + private long delayMs; + + /** Delay to wait before the next craft of {@code id}; 0 for the first / after a change. */ + public long nextDelayMs(RecipeId id) { + if (!Objects.equals(id, this.current)) { + this.current = id; + this.delayMs = START_DELAY_MS; + return 0L; + } + + final long delay = this.delayMs; + this.delayMs = Math.max(FLOOR_DELAY_MS, Math.round(this.delayMs * DECAY)); + return delay; + } +} 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..e4b048a9a --- /dev/null +++ b/src/test/java/codechicken/nei/recipe/CraftRampThrottleTest.java @@ -0,0 +1,52 @@ +package codechicken.nei.recipe; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import codechicken.nei.recipe.Recipe.RecipeId; + +class CraftRampThrottleTest { + + @Test + @DisplayName("first craft of a recipe is instant") + void firstCraftInstant() { + CraftRampThrottle throttle = new CraftRampThrottle(); + RecipeId id = mock(RecipeId.class); + assertEquals(0L, throttle.nextDelayMs(id)); + } + + @Test + @DisplayName("same recipe decays geometrically and clamps at floor") + void rampDecaysToFloor() { + CraftRampThrottle throttle = new CraftRampThrottle(); + RecipeId id = mock(RecipeId.class); + + assertEquals(0L, throttle.nextDelayMs(id)); // craft 1: instant + assertEquals(500L, throttle.nextDelayMs(id)); // craft 2: START + assertEquals(425L, throttle.nextDelayMs(id)); // 500 * 0.85 + assertEquals(361L, throttle.nextDelayMs(id)); // round(425 * 0.85) + + long last = 361L; + for (int i = 0; i < 50; i++) { + last = throttle.nextDelayMs(id); + } + assertEquals(CraftRampThrottle.FLOOR_DELAY_MS, last); // clamped at floor + } + + @Test + @DisplayName("changing recipe resets the ramp to instant") + void recipeChangeResets() { + CraftRampThrottle throttle = new CraftRampThrottle(); + RecipeId a = mock(RecipeId.class); + RecipeId b = mock(RecipeId.class); + + assertEquals(0L, throttle.nextDelayMs(a)); // a craft 1 + assertEquals(500L, throttle.nextDelayMs(a)); // a craft 2 + assertEquals(0L, throttle.nextDelayMs(b)); // switch -> instant + assertEquals(500L, throttle.nextDelayMs(b)); // b craft 2 + assertEquals(0L, throttle.nextDelayMs(a)); // switch back -> instant again + } +} From a40289ca65a05aaf5d65aa3d71d2a9c505fa6647 Mon Sep 17 00:00:00 2001 From: Caedis Date: Fri, 17 Jul 2026 13:59:31 -0500 Subject: [PATCH 2/7] feat(autocraft): throttle crafting with speed ramp Crafts now run one item at a time instead of in batches of 64, with an interruptible sleep between each craft driven by CraftRampThrottle. The delay starts high and decays toward a floor as long as the same recipe keeps crafting, and resets to instant whenever the recipe changes, giving auto-craft a visible slow-start-then-speed-up feel instead of instant bulk crafting. --- .../nei/recipe/AutoCraftingManager.java | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/src/main/java/codechicken/nei/recipe/AutoCraftingManager.java b/src/main/java/codechicken/nei/recipe/AutoCraftingManager.java index 0717c3db5..d56ef8944 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,10 @@ 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)) { + sleepInterruptibly(throttle.nextDelayMs(entry.getKey()), guiContainer); + if (interrupted(guiContainer) || !handler.craft(guiContainer, 1)) break; + multiplier -= 1; } craft = multiplier != entry.getValue(); @@ -86,6 +88,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<>(); From cbaef9cee03ec783f45434195a5275bddf73a401 Mon Sep 17 00:00:00 2001 From: Caedis Date: Sat, 18 Jul 2026 03:02:31 -0500 Subject: [PATCH 3/7] fix(autocraft): stop when crafted output cannot be taken craft() reported success from the result slot having a stack, even when a full inventory blocked the shift-click pickup. That made the auto-craft loop treat it as progress and never terminate. Check the result slot after the click: if the output is still there it wasn't taken, so break. --- .../java/codechicken/nei/recipe/DefaultOverlayHandler.java | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/main/java/codechicken/nei/recipe/DefaultOverlayHandler.java b/src/main/java/codechicken/nei/recipe/DefaultOverlayHandler.java index 8198111b3..bafe6cad6 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; } From 502a688a7530227dfbd564f6bd55b3720eb61614 Mon Sep 17 00:00:00 2001 From: Caedis Date: Sat, 18 Jul 2026 03:10:19 -0500 Subject: [PATCH 4/7] feat(autocraft): per-recipe speed memory and bulk at max Track each recipe's ramp in a per-run map so switching away and back resumes its accumulated speed instead of restarting slow. At floor speed, run crafts in bulk (8 per tick) so large orders finish quickly. Single-craft accounting is kept so the inventory-full stop still holds. --- .../nei/recipe/AutoCraftingManager.java | 16 ++++- .../nei/recipe/CraftRampThrottle.java | 43 ++++++++----- .../nei/recipe/CraftRampThrottleTest.java | 62 ++++++++++++++----- 3 files changed, 88 insertions(+), 33 deletions(-) diff --git a/src/main/java/codechicken/nei/recipe/AutoCraftingManager.java b/src/main/java/codechicken/nei/recipe/AutoCraftingManager.java index d56ef8944..6c9293987 100644 --- a/src/main/java/codechicken/nei/recipe/AutoCraftingManager.java +++ b/src/main/java/codechicken/nei/recipe/AutoCraftingManager.java @@ -50,9 +50,19 @@ public void execute() { long multiplier = entry.getValue(); while (multiplier > 0 && !interrupted(guiContainer)) { - sleepInterruptibly(throttle.nextDelayMs(entry.getKey()), guiContainer); - if (interrupted(guiContainer) || !handler.craft(guiContainer, 1)) break; - multiplier -= 1; + 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(); diff --git a/src/main/java/codechicken/nei/recipe/CraftRampThrottle.java b/src/main/java/codechicken/nei/recipe/CraftRampThrottle.java index 378240c26..9fcc9f282 100644 --- a/src/main/java/codechicken/nei/recipe/CraftRampThrottle.java +++ b/src/main/java/codechicken/nei/recipe/CraftRampThrottle.java @@ -1,33 +1,48 @@ package codechicken.nei.recipe; -import java.util.Objects; +import java.util.HashMap; +import java.util.Map; import codechicken.nei.recipe.Recipe.RecipeId; /** * Per-craft delay ramp for auto-crafting. First craft of a recipe is instant, - * then the delay decays geometrically toward a floor. Changing the recipe - * resets the ramp. Pure logic; does not sleep. + * then the delay decays geometrically toward a floor. Each recipe keeps its own + * ramp for the run, so switching away and back resumes rather than restarts. At + * floor (max) speed crafts are batched. Pure logic; does not sleep. */ public class CraftRampThrottle { public static final long START_DELAY_MS = 500L; // delay before the 2nd craft public static final long FLOOR_DELAY_MS = 50L; // fastest allowed (cap) public static final double DECAY = 0.85D; + public static final int BULK_CRAFTS = 8; // crafts per tick once maxed out - private RecipeId current; - private long delayMs; + /** Delay to wait before a craft, and how many crafts that tick covers. */ + public static final class Tick { - /** Delay to wait before the next craft of {@code id}; 0 for the first / after a change. */ - public long nextDelayMs(RecipeId id) { - if (!Objects.equals(id, this.current)) { - this.current = id; - this.delayMs = START_DELAY_MS; - return 0L; + public final long delayMs; + public final int crafts; + + Tick(long delayMs, int crafts) { + this.delayMs = delayMs; + this.crafts = crafts; + } + } + + private final Map delays = new HashMap<>(); + + /** Next tick for {@code id}: instant single craft the first time, then a decaying delay. */ + public Tick next(RecipeId id) { + final Long stored = this.delays.get(id); + + if (stored == null) { + this.delays.put(id, START_DELAY_MS); + return new Tick(0L, 1); } - final long delay = this.delayMs; - this.delayMs = Math.max(FLOOR_DELAY_MS, Math.round(this.delayMs * DECAY)); - return delay; + final long delay = stored; + this.delays.put(id, Math.max(FLOOR_DELAY_MS, Math.round(delay * DECAY))); + return new Tick(delay, delay == FLOOR_DELAY_MS ? BULK_CRAFTS : 1); } } diff --git a/src/test/java/codechicken/nei/recipe/CraftRampThrottleTest.java b/src/test/java/codechicken/nei/recipe/CraftRampThrottleTest.java index e4b048a9a..1855d6e5f 100644 --- a/src/test/java/codechicken/nei/recipe/CraftRampThrottleTest.java +++ b/src/test/java/codechicken/nei/recipe/CraftRampThrottleTest.java @@ -6,16 +6,20 @@ 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 { @Test - @DisplayName("first craft of a recipe is instant") + @DisplayName("first craft of a recipe is instant and single") void firstCraftInstant() { CraftRampThrottle throttle = new CraftRampThrottle(); RecipeId id = mock(RecipeId.class); - assertEquals(0L, throttle.nextDelayMs(id)); + + Tick tick = throttle.next(id); + assertEquals(0L, tick.delayMs); + assertEquals(1, tick.crafts); } @Test @@ -24,29 +28,55 @@ void rampDecaysToFloor() { CraftRampThrottle throttle = new CraftRampThrottle(); RecipeId id = mock(RecipeId.class); - assertEquals(0L, throttle.nextDelayMs(id)); // craft 1: instant - assertEquals(500L, throttle.nextDelayMs(id)); // craft 2: START - assertEquals(425L, throttle.nextDelayMs(id)); // 500 * 0.85 - assertEquals(361L, throttle.nextDelayMs(id)); // round(425 * 0.85) + assertEquals(0L, throttle.next(id).delayMs); // craft 1: instant + assertEquals(500L, throttle.next(id).delayMs); // craft 2: START + assertEquals(425L, throttle.next(id).delayMs); // 500 * 0.85 + assertEquals(361L, throttle.next(id).delayMs); // round(425 * 0.85) + + Tick last = null; + for (int i = 0; i < 50; i++) { + last = throttle.next(id); + } + assertEquals(CraftRampThrottle.FLOOR_DELAY_MS, last.delayMs); // clamped at floor + } + + @Test + @DisplayName("at floor speed, crafts happen in bulk") + void bulkAtFloorSpeed() { + CraftRampThrottle throttle = new CraftRampThrottle(); + RecipeId id = mock(RecipeId.class); - long last = 361L; + Tick tick = throttle.next(id); for (int i = 0; i < 50; i++) { - last = throttle.nextDelayMs(id); + tick = throttle.next(id); } - assertEquals(CraftRampThrottle.FLOOR_DELAY_MS, last); // clamped at floor + + assertEquals(CraftRampThrottle.FLOOR_DELAY_MS, tick.delayMs); + assertEquals(CraftRampThrottle.BULK_CRAFTS, tick.crafts); // batched once maxed out + } + + @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); // instant + assertEquals(1, throttle.next(id).crafts); // 500 + assertEquals(1, throttle.next(id).crafts); // 425 } @Test - @DisplayName("changing recipe resets the ramp to instant") - void recipeChangeResets() { + @DisplayName("each recipe keeps its own ramp; returning resumes, not resets") + void perRecipeMemoryResumes() { CraftRampThrottle throttle = new CraftRampThrottle(); RecipeId a = mock(RecipeId.class); RecipeId b = mock(RecipeId.class); - assertEquals(0L, throttle.nextDelayMs(a)); // a craft 1 - assertEquals(500L, throttle.nextDelayMs(a)); // a craft 2 - assertEquals(0L, throttle.nextDelayMs(b)); // switch -> instant - assertEquals(500L, throttle.nextDelayMs(b)); // b craft 2 - assertEquals(0L, throttle.nextDelayMs(a)); // switch back -> instant again + assertEquals(0L, throttle.next(a).delayMs); // a craft 1 + assertEquals(500L, throttle.next(a).delayMs); // a craft 2 + assertEquals(0L, throttle.next(b).delayMs); // b starts fresh + assertEquals(425L, throttle.next(a).delayMs); // a resumes where it left off + assertEquals(500L, throttle.next(b).delayMs); // b resumes its own ramp } } From 540caeaae3135d8cb67d27a0fd38b02d4034c5a4 Mon Sep 17 00:00:00 2001 From: Caedis Date: Sat, 18 Jul 2026 03:18:36 -0500 Subject: [PATCH 5/7] feat(autocraft): drop instant first craft A recipe now starts at the full start delay instead of a free instant craft, so crafting begins slow. Collapses the first-craft special case into the default ramp value. --- .../nei/recipe/CraftRampThrottle.java | 21 +++++++------------ .../nei/recipe/CraftRampThrottleTest.java | 21 +++++++++---------- 2 files changed, 17 insertions(+), 25 deletions(-) diff --git a/src/main/java/codechicken/nei/recipe/CraftRampThrottle.java b/src/main/java/codechicken/nei/recipe/CraftRampThrottle.java index 9fcc9f282..45436ad21 100644 --- a/src/main/java/codechicken/nei/recipe/CraftRampThrottle.java +++ b/src/main/java/codechicken/nei/recipe/CraftRampThrottle.java @@ -6,14 +6,14 @@ import codechicken.nei.recipe.Recipe.RecipeId; /** - * Per-craft delay ramp for auto-crafting. First craft of a recipe is instant, - * then the delay decays geometrically toward a floor. Each recipe keeps its own - * ramp for the run, so switching away and back resumes rather than restarts. At - * floor (max) speed crafts are batched. Pure logic; does not sleep. + * Per-craft delay ramp for auto-crafting. A recipe starts at the start delay and + * decays geometrically toward a floor. Each recipe keeps its own ramp for the + * run, so switching away and back resumes rather than restarts. At floor (max) + * speed crafts are batched. Pure logic; does not sleep. */ public class CraftRampThrottle { - public static final long START_DELAY_MS = 500L; // delay before the 2nd craft + public static final long START_DELAY_MS = 500L; // delay before the 1st craft public static final long FLOOR_DELAY_MS = 50L; // fastest allowed (cap) public static final double DECAY = 0.85D; public static final int BULK_CRAFTS = 8; // crafts per tick once maxed out @@ -32,16 +32,9 @@ public static final class Tick { private final Map delays = new HashMap<>(); - /** Next tick for {@code id}: instant single craft the first time, then a decaying delay. */ + /** Next tick for {@code id}: the start delay the first time, then a decaying delay. */ public Tick next(RecipeId id) { - final Long stored = this.delays.get(id); - - if (stored == null) { - this.delays.put(id, START_DELAY_MS); - return new Tick(0L, 1); - } - - final long delay = stored; + final long delay = this.delays.getOrDefault(id, START_DELAY_MS); this.delays.put(id, Math.max(FLOOR_DELAY_MS, Math.round(delay * DECAY))); return new Tick(delay, delay == FLOOR_DELAY_MS ? BULK_CRAFTS : 1); } diff --git a/src/test/java/codechicken/nei/recipe/CraftRampThrottleTest.java b/src/test/java/codechicken/nei/recipe/CraftRampThrottleTest.java index 1855d6e5f..b699315f4 100644 --- a/src/test/java/codechicken/nei/recipe/CraftRampThrottleTest.java +++ b/src/test/java/codechicken/nei/recipe/CraftRampThrottleTest.java @@ -12,13 +12,13 @@ class CraftRampThrottleTest { @Test - @DisplayName("first craft of a recipe is instant and single") - void firstCraftInstant() { + @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(0L, tick.delayMs); + assertEquals(CraftRampThrottle.START_DELAY_MS, tick.delayMs); assertEquals(1, tick.crafts); } @@ -28,8 +28,7 @@ void rampDecaysToFloor() { CraftRampThrottle throttle = new CraftRampThrottle(); RecipeId id = mock(RecipeId.class); - assertEquals(0L, throttle.next(id).delayMs); // craft 1: instant - assertEquals(500L, throttle.next(id).delayMs); // craft 2: START + assertEquals(500L, throttle.next(id).delayMs); // craft 1: START assertEquals(425L, throttle.next(id).delayMs); // 500 * 0.85 assertEquals(361L, throttle.next(id).delayMs); // round(425 * 0.85) @@ -61,9 +60,9 @@ void singleWhileRamping() { CraftRampThrottle throttle = new CraftRampThrottle(); RecipeId id = mock(RecipeId.class); - assertEquals(1, throttle.next(id).crafts); // instant assertEquals(1, throttle.next(id).crafts); // 500 assertEquals(1, throttle.next(id).crafts); // 425 + assertEquals(1, throttle.next(id).crafts); // 361 } @Test @@ -73,10 +72,10 @@ void perRecipeMemoryResumes() { RecipeId a = mock(RecipeId.class); RecipeId b = mock(RecipeId.class); - assertEquals(0L, throttle.next(a).delayMs); // a craft 1 - assertEquals(500L, throttle.next(a).delayMs); // a craft 2 - assertEquals(0L, throttle.next(b).delayMs); // b starts fresh - assertEquals(425L, throttle.next(a).delayMs); // a resumes where it left off - assertEquals(500L, throttle.next(b).delayMs); // b resumes its own ramp + assertEquals(500L, throttle.next(a).delayMs); // a craft 1 + assertEquals(425L, throttle.next(a).delayMs); // a craft 2 + assertEquals(500L, throttle.next(b).delayMs); // b starts fresh + assertEquals(361L, throttle.next(a).delayMs); // a resumes where it left off + assertEquals(425L, throttle.next(b).delayMs); // b resumes its own ramp } } From 6ae40a1b360a0a89035e443e2a4be668bd07eb99 Mon Sep 17 00:00:00 2001 From: Caedis Date: Sat, 18 Jul 2026 03:47:29 -0500 Subject: [PATCH 6/7] feat(autocraft): global momentum with switch penalty Replace per-recipe speed memory with a single global momentum. A recipe change now reduces speed by a penalty instead of resetting, so returning keeps most momentum. Retune for feel: faster start (300ms), calmer top (100ms floor, bulk 4), gentler decay (0.88). --- .../nei/recipe/CraftRampThrottle.java | 39 ++++++++----- .../nei/recipe/CraftRampThrottleTest.java | 56 ++++++++++--------- 2 files changed, 55 insertions(+), 40 deletions(-) diff --git a/src/main/java/codechicken/nei/recipe/CraftRampThrottle.java b/src/main/java/codechicken/nei/recipe/CraftRampThrottle.java index 45436ad21..5abd7e035 100644 --- a/src/main/java/codechicken/nei/recipe/CraftRampThrottle.java +++ b/src/main/java/codechicken/nei/recipe/CraftRampThrottle.java @@ -1,22 +1,23 @@ package codechicken.nei.recipe; -import java.util.HashMap; -import java.util.Map; +import java.util.Objects; import codechicken.nei.recipe.Recipe.RecipeId; /** - * Per-craft delay ramp for auto-crafting. A recipe starts at the start delay and - * decays geometrically toward a floor. Each recipe keeps its own ramp for the - * run, so switching away and back resumes rather than restarts. At floor (max) - * speed crafts are batched. Pure logic; does not sleep. + * 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 = 500L; // delay before the 1st craft - public static final long FLOOR_DELAY_MS = 50L; // fastest allowed (cap) - public static final double DECAY = 0.85D; - public static final int BULK_CRAFTS = 8; // crafts per tick once maxed out + 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 { @@ -30,12 +31,20 @@ public static final class Tick { } } - private final Map delays = new HashMap<>(); + private RecipeId current; + private long delayMs = START_DELAY_MS; - /** Next tick for {@code id}: the start delay the first time, then a decaying delay. */ + /** Next tick for {@code id}: decays on repeat, slows (but keeps momentum) on a recipe change. */ public Tick next(RecipeId id) { - final long delay = this.delays.getOrDefault(id, START_DELAY_MS); - this.delays.put(id, Math.max(FLOOR_DELAY_MS, Math.round(delay * DECAY))); - return new Tick(delay, delay == FLOOR_DELAY_MS ? BULK_CRAFTS : 1); + 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/test/java/codechicken/nei/recipe/CraftRampThrottleTest.java b/src/test/java/codechicken/nei/recipe/CraftRampThrottleTest.java index b699315f4..6d7cc2e6d 100644 --- a/src/test/java/codechicken/nei/recipe/CraftRampThrottleTest.java +++ b/src/test/java/codechicken/nei/recipe/CraftRampThrottleTest.java @@ -1,6 +1,7 @@ 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; @@ -11,6 +12,14 @@ 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() { @@ -23,20 +32,20 @@ void firstCraftUsesStartDelay() { } @Test - @DisplayName("same recipe decays geometrically and clamps at floor") + @DisplayName("same recipe decays monotonically and clamps at floor") void rampDecaysToFloor() { CraftRampThrottle throttle = new CraftRampThrottle(); RecipeId id = mock(RecipeId.class); - assertEquals(500L, throttle.next(id).delayMs); // craft 1: START - assertEquals(425L, throttle.next(id).delayMs); // 500 * 0.85 - assertEquals(361L, throttle.next(id).delayMs); // round(425 * 0.85) - - Tick last = null; - for (int i = 0; i < 50; i++) { - last = throttle.next(id); + 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, last.delayMs); // clamped at floor + + assertEquals(CraftRampThrottle.FLOOR_DELAY_MS, rampToFloor(throttle, id).delayMs); } @Test @@ -45,13 +54,9 @@ void bulkAtFloorSpeed() { CraftRampThrottle throttle = new CraftRampThrottle(); RecipeId id = mock(RecipeId.class); - Tick tick = throttle.next(id); - for (int i = 0; i < 50; i++) { - tick = throttle.next(id); - } - + Tick tick = rampToFloor(throttle, id); assertEquals(CraftRampThrottle.FLOOR_DELAY_MS, tick.delayMs); - assertEquals(CraftRampThrottle.BULK_CRAFTS, tick.crafts); // batched once maxed out + assertEquals(CraftRampThrottle.BULK_CRAFTS, tick.crafts); } @Test @@ -60,22 +65,23 @@ void singleWhileRamping() { CraftRampThrottle throttle = new CraftRampThrottle(); RecipeId id = mock(RecipeId.class); - assertEquals(1, throttle.next(id).crafts); // 500 - assertEquals(1, throttle.next(id).crafts); // 425 - assertEquals(1, throttle.next(id).crafts); // 361 + assertEquals(1, throttle.next(id).crafts); + assertEquals(1, throttle.next(id).crafts); } @Test - @DisplayName("each recipe keeps its own ramp; returning resumes, not resets") - void perRecipeMemoryResumes() { + @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); - assertEquals(500L, throttle.next(a).delayMs); // a craft 1 - assertEquals(425L, throttle.next(a).delayMs); // a craft 2 - assertEquals(500L, throttle.next(b).delayMs); // b starts fresh - assertEquals(361L, throttle.next(a).delayMs); // a resumes where it left off - assertEquals(425L, throttle.next(b).delayMs); // b resumes its own ramp + // 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"); } } From 30eec77a20e15b8ebf8cc0f6285f4edb57a93268 Mon Sep 17 00:00:00 2001 From: Caedis Date: Mon, 27 Jul 2026 14:04:52 -0500 Subject: [PATCH 7/7] spotless --- .../codechicken/nei/recipe/AutoCraftingManager.java | 3 +-- .../codechicken/nei/recipe/CraftRampThrottle.java | 13 ++++++------- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/src/main/java/codechicken/nei/recipe/AutoCraftingManager.java b/src/main/java/codechicken/nei/recipe/AutoCraftingManager.java index 6c9293987..cc3629226 100644 --- a/src/main/java/codechicken/nei/recipe/AutoCraftingManager.java +++ b/src/main/java/codechicken/nei/recipe/AutoCraftingManager.java @@ -55,8 +55,7 @@ public void execute() { if (interrupted(guiContainer)) break; boolean crafted = false; - for (int i = 0; i < tick.crafts && multiplier > 0 - && !interrupted(guiContainer); i++) { + for (int i = 0; i < tick.crafts && multiplier > 0 && !interrupted(guiContainer); i++) { if (!handler.craft(guiContainer, 1)) break; multiplier -= 1; crafted = true; diff --git a/src/main/java/codechicken/nei/recipe/CraftRampThrottle.java b/src/main/java/codechicken/nei/recipe/CraftRampThrottle.java index 5abd7e035..f61c92ee1 100644 --- a/src/main/java/codechicken/nei/recipe/CraftRampThrottle.java +++ b/src/main/java/codechicken/nei/recipe/CraftRampThrottle.java @@ -5,19 +5,18 @@ 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. + * 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 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 + 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 {