Skip to content
30 changes: 27 additions & 3 deletions src/main/java/codechicken/nei/recipe/AutoCraftingManager.java
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ public void execute() {
final List<BookmarkItem> initialItems = prepareInitialItems(math, getInventoryItems(guiContainer));
boolean processed = false;
boolean changed = false;
final CraftRampThrottle throttle = new CraftRampThrottle();

StackInfo.pauseItemDamageSound(true);

Expand All @@ -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();
Expand Down Expand Up @@ -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<BookmarkItem> prepareInitialItems(RecipeChainMath math, ItemStackAmount inventory) {
final List<BookmarkItem> initialItems = new ArrayList<>();

Expand Down
49 changes: 49 additions & 0 deletions src/main/java/codechicken/nei/recipe/CraftRampThrottle.java
Original file line number Diff line number Diff line change
@@ -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 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This definitely needs to be config driven

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We are still discussing this in meta-dev

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To be clear not necessarily "these five values" more having more than one option here


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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
87 changes: 87 additions & 0 deletions src/test/java/codechicken/nei/recipe/CraftRampThrottleTest.java
Original file line number Diff line number Diff line change
@@ -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");
}
}