From a1302c4f268f29c424e093a9370bfc2ab814f005 Mon Sep 17 00:00:00 2001 From: Joshua Ball Date: Sun, 8 Dec 2024 21:24:21 -0800 Subject: [PATCH 1/5] executable gradlew; apparently I'm the only linux dev --- gradlew | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 gradlew diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 From 573ef6e79593ffe7f806df6b2f19d6e24153eadf Mon Sep 17 00:00:00 2001 From: Joshua Ball Date: Sat, 7 Dec 2024 16:59:42 -0800 Subject: [PATCH 2/5] Isolated CostList into CostListService. This is a pure refactoring commit (zero behavior changes). CostList.Calculator was difficult to test because it was entangled with the logic of ILabel and Recipe. This commit moves most of the logic from CostList into AbstractCostListService, which is generic over arbitrary Labels, Recipes, and CostLists. By adding this extra layer of indirection (which should mostly be optimized away by the JVM), we can write tests that are unconcerned about things like locales and ore dictionaries, and which don't manipulate the global recipe list and so can't collide with one another. While I have rearranged things, I have avoided changing any of the fundamental logic that previously existed in CostList. The new calculation code follows the old calculation code line by line, differing only in its use of the indirection layer. MainCostListService is the shim that connects AbstractCostListService to the concrete classes ILabel, Recipe, and CostList, so most code external to the `structure` package should now go through `MainCostListService.INSTANCE`. There are examples of this in `GuiCraft` and `JecaOverlayHandler`. --- .../structure/AbstractCostListService.java | 308 ++++++++++++++++++ .../data/structure/Calculation.java | 14 + .../data/structure/CostList.java | 226 +------------ .../data/structure/MainCostListService.java | 117 +++++++ .../jecalculation/data/structure/Recipe.java | 5 + .../jecalculation/gui/guis/GuiCraft.java | 8 +- .../jecalculation/nei/JecaOverlayHandler.java | 10 +- 7 files changed, 464 insertions(+), 224 deletions(-) create mode 100644 src/main/java/me/towdium/jecalculation/data/structure/AbstractCostListService.java create mode 100644 src/main/java/me/towdium/jecalculation/data/structure/Calculation.java create mode 100644 src/main/java/me/towdium/jecalculation/data/structure/MainCostListService.java diff --git a/src/main/java/me/towdium/jecalculation/data/structure/AbstractCostListService.java b/src/main/java/me/towdium/jecalculation/data/structure/AbstractCostListService.java new file mode 100644 index 000000000..d5d45a163 --- /dev/null +++ b/src/main/java/me/towdium/jecalculation/data/structure/AbstractCostListService.java @@ -0,0 +1,308 @@ +package me.towdium.jecalculation.data.structure; + +import static me.towdium.jecalculation.utils.Utilities.stream; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +import javax.annotation.Nullable; +import javax.annotation.ParametersAreNonnullByDefault; + +import me.towdium.jecalculation.polyfill.MethodsReturnNonnullByDefault; +import me.towdium.jecalculation.utils.Utilities; +import me.towdium.jecalculation.utils.wrappers.Pair; + +// positive => generate; negative => require +@MethodsReturnNonnullByDefault +@ParametersAreNonnullByDefault +public abstract class AbstractCostListService { + + private final Dependencies d; + private final CostLists costLists; + private final Class costListClass; + + AbstractCostListService(Dependencies dependencies, CostLists costLists, + Class costListClass) { + this.d = dependencies; + this.costLists = costLists; + this.costListClass = costListClass; + } + + public CostListT newNegatedCostList(List labels) { + List negativeLabels = labels.stream() + .filter(d::isNotEmptyLabel) + .map(i -> d.multiplyLabel(d.copyLabel(i), -1)) + .collect(Collectors.toList()); + return costLists.newCostList(negativeLabels); + } + + public CostListT newPosNegCostList(List positive, List negative) { + CostListT ret = newNegatedCostList(positive); + multiply(ret, -1); + mergeInplace(ret, newNegatedCostList(negative), false); + return ret; + } + + public CostListT strictMergeCostList(CostListT a, CostListT b) { + return mergeCostLists(a, b, false); + } + + public List getLabels(CostListT costList) { + return costLists.getLabels(costList); + } + + public Calculation calculate(CostListT costList) { + ArrayList> procedure = new ArrayList<>(); + ArrayList catalysts = new ArrayList<>(); + + return new Calculation() { + + private Iterator iterator = d.recipeIterator(); + private int index; + + { + HashSet set = new HashSet<>(); + set.add(costList); + + // reset index & iterator + reset(); + Pair next = find(); + int count = 0; + while (next != null) { + CostListT original = getCurrent(); + List outL = d.getRecipeOutput(next.one) + .stream() + .filter(d::isNotEmptyLabel) + .collect(Collectors.toList()); + CostListT outC = newNegatedCostList(outL); + multiply(outC, -next.two); + List inL = d.getRecipeInput(next.one) + .stream() + .filter(d::isNotEmptyLabel) + .collect(Collectors.toList()); + CostListT inC = newNegatedCostList(inL); + multiply(inC, next.two); + CostListT result = mergeCostLists(original, outC, false); + mergeInplace(result, inC, false); + if (!set.contains(result)) { + set.add(result); + procedure.add(new Pair<>(result, outC)); + addCatalyst(d.getRecipeCatalyst(next.one)); + reset(); + } + next = find(); + if (count++ > 1000) { + d.addMaxLoopChatMessage(); + break; + } + } + } + + @Override + public List getCatalysts() { + return catalysts; + } + + @Override + public List getInputs() { + return getLabels(getCurrent()).stream() + .filter(i -> d.getLabelAmount(i) < 0) + .map(i -> d.multiplyLabel(d.copyLabel(i), -1)) + .collect(Collectors.toList()); + } + + @Override + public List getOutputs(List ignore) { + return getLabels(getCurrent()).stream() + .map(i -> d.multiplyLabel(d.copyLabel(i), -1)) + .map( + i -> ignore.stream() + .flatMap(j -> stream(d.mergeLabels(i, j))) + .findFirst() + .orElse(i)) + .filter(i -> d.isNotEmptyLabel(i) && d.getLabelAmount(i) < 0) + .map(i -> d.multiplyLabel(i, -1)) + .collect(Collectors.toList()); + } + + @Override + public List getSteps() { + List ret = procedure.stream() + .map( + i -> costLists.getLabels(i.two) + .get(0)) + .collect(Collectors.toList()); + Collections.reverse(ret); + CostListT cl = multiply(newNegatedCostList(ret), -1); + CostListT temp = newNegatedCostList(new ArrayList<>()); + mergeInplace(temp, cl, false); + return costLists.getLabels(temp); + } + + private void reset() { + index = 0; + iterator = d.recipeIterator(); + } + + /** + * Find next recipe and its amount + * + * @return pair of the next recipe and its amount + */ + @Nullable + private Pair find() { + List labels = getLabels(getCurrent()); + for (; index < labels.size(); index++) { + LabelT label = labels.get(index); + // Only negative label is required to calculate + if (d.getLabelAmount(label) >= 0) continue; + // Find the recipe for the label. + // Reset or not reset the iterator is a question + while (iterator.hasNext()) { + RecipeT r = iterator.next(); + if (d.recipeOutputMatches(r, label) + .isPresent()) { + return new Pair<>(r, d.multiplier(r, label)); + } + } + iterator = d.recipeIterator(); + } + return null; + } + + private void addCatalyst(List labels) { + labels.stream() + .filter(d::isNotEmptyLabel) + .forEach( + i -> catalysts.stream() + .filter(j -> d.labelMatches(j, i)) + .findAny() + .map(j -> d.setLabelAmount(j, Math.max(d.getLabelAmount(i), d.getLabelAmount(j)))) + .orElseGet(Utilities.fake(() -> catalysts.add(i)))); + } + + private CostListT getCurrent() { + return procedure.isEmpty() ? costList : procedure.get(procedure.size() - 1).one; + } + }; + }; + + interface Dependencies { + + // Labels + LabelT copyLabel(LabelT label); + + LabelT getEmptyLabel(); + + long getLabelAmount(LabelT label); + + boolean isNotEmptyLabel(LabelT label); + + boolean labelMatches(LabelT self, LabelT that); + + Optional mergeLabels(LabelT a, LabelT b); + + LabelT multiplyLabel(LabelT label, float i); + + LabelT setLabelAmount(LabelT label, long amount); + + // Recipes + List getRecipeCatalyst(RecipeT recipe); + + List getRecipeInput(RecipeT recipe); + + List getRecipeOutput(RecipeT recipe); + + Optional recipeOutputMatches(RecipeT recipe, LabelT label); + + long multiplier(RecipeT recipe, LabelT label); + + Iterator recipeIterator(); + + // Utilities + void addMaxLoopChatMessage(); + } + + interface CostLists { + + CostListT newCostList(List labels); + + List getLabels(CostListT self); + + void setLabels(CostListT self, List labels); + } + + private CostListT mergeCostLists(CostListT a, CostListT b, boolean strict) { + CostListT ret = copyCostList(a); + mergeInplace(ret, b, strict); + return ret; + } + + /** + * Merge self to this + * + * @param that cost list to merge + * @param strict if true, only merge same label + */ + private void mergeInplace(CostListT self, CostListT that, boolean strict) { + List thisLabels = getLabels(self); + getLabels(that).forEach(i -> thisLabels.add(d.copyLabel(i))); + for (int i = 0; i < thisLabels.size(); i++) { + for (int j = i + 1; j < thisLabels.size(); j++) { + if (strict) { + LabelT a = thisLabels.get(i); + LabelT b = thisLabels.get(j); + if (d.labelMatches(a, b)) { + thisLabels.set(i, d.setLabelAmount(a, Math.addExact(d.getLabelAmount(a), d.getLabelAmount(b)))); + thisLabels.set(j, d.getEmptyLabel()); + } + } else { + Optional l = d.mergeLabels(thisLabels.get(i), thisLabels.get(j)); + if (l.isPresent()) { + thisLabels.set(i, l.get()); + thisLabels.set(j, d.getEmptyLabel()); + } + } + } + } + costLists.setLabels( + self, + thisLabels.stream() + .filter(d::isNotEmptyLabel) + .collect(Collectors.toList())); + } + + private CostListT multiply(CostListT self, long i) { + costLists.setLabels( + self, + costLists.getLabels(self) + .stream() + .map(j -> d.multiplyLabel(j, i)) + .collect(Collectors.toList())); + return self; + } + + boolean costListEquals(CostListT self, Object obj) { + if (costListClass.isInstance(obj)) { + CostListT c = (CostListT) obj; + CostListT m = multiply(copyCostList(c), -1); + return getLabels(mergeCostLists(self, m, true)).isEmpty(); + } else return false; + } + + protected CostListT copyCostList(CostListT from) { + CostListT ret = newNegatedCostList(Collections.emptyList()); + costLists.setLabels( + ret, + costLists.getLabels(from) + .stream() + .map(d::copyLabel) + .collect(Collectors.toList())); + return ret; + } +} diff --git a/src/main/java/me/towdium/jecalculation/data/structure/Calculation.java b/src/main/java/me/towdium/jecalculation/data/structure/Calculation.java new file mode 100644 index 000000000..e4723ac51 --- /dev/null +++ b/src/main/java/me/towdium/jecalculation/data/structure/Calculation.java @@ -0,0 +1,14 @@ +package me.towdium.jecalculation.data.structure; + +import java.util.List; + +public interface Calculation { + + List getCatalysts(); + + List getInputs(); + + List getOutputs(List ignore); + + List getSteps(); +} diff --git a/src/main/java/me/towdium/jecalculation/data/structure/CostList.java b/src/main/java/me/towdium/jecalculation/data/structure/CostList.java index 99f577153..f8e138ac2 100644 --- a/src/main/java/me/towdium/jecalculation/data/structure/CostList.java +++ b/src/main/java/me/towdium/jecalculation/data/structure/CostList.java @@ -1,18 +1,11 @@ package me.towdium.jecalculation.data.structure; -import static me.towdium.jecalculation.utils.Utilities.stream; +import java.util.List; -import java.util.*; -import java.util.stream.Collectors; - -import javax.annotation.Nullable; import javax.annotation.ParametersAreNonnullByDefault; -import me.towdium.jecalculation.data.Controller; import me.towdium.jecalculation.data.label.ILabel; import me.towdium.jecalculation.polyfill.MethodsReturnNonnullByDefault; -import me.towdium.jecalculation.utils.Utilities; -import me.towdium.jecalculation.utils.wrappers.Pair; // positive => generate; negative => require @MethodsReturnNonnullByDefault @@ -21,97 +14,23 @@ public class CostList { List labels; - public CostList() { - labels = new ArrayList<>(); - } - - public CostList(List labels) { - this.labels = labels.stream() - .filter(i -> i != ILabel.EMPTY) - .map( - i -> i.copy() - .multiply(-1)) - .collect(Collectors.toList()); - } - - public CostList(List positive, List negative) { - this(positive); - multiply(-1); - mergeInplace(new CostList(negative), false); - } - - public static CostList merge(CostList a, CostList b, boolean strict) { - CostList ret = a.copy(); - ret.mergeInplace(b, strict); - return ret; - } - - /** - * Merge that to this - * - * @param that cost list to merge - * @param strict if true, only merge same label - */ - public void mergeInplace(CostList that, boolean strict) { - that.labels.forEach(i -> this.labels.add(i.copy())); - for (int i = 0; i < this.labels.size(); i++) { - for (int j = i + 1; j < this.labels.size(); j++) { - if (strict) { - ILabel a = this.labels.get(i); - ILabel b = this.labels.get(j); - if (a.matches(b)) { - this.labels.set(i, a.setAmount(Math.addExact(a.getAmount(), b.getAmount()))); - this.labels.set(j, ILabel.EMPTY); - } - } else { - Optional l = ILabel.MERGER.merge(this.labels.get(i), this.labels.get(j)); - if (l.isPresent()) { - this.labels.set(i, l.get()); - this.labels.set(j, ILabel.EMPTY); - } - } - } - } - this.labels = this.labels.stream() - .filter(i -> i != ILabel.EMPTY) - .collect(Collectors.toList()); - } - - public CostList multiply(long i) { - labels = labels.stream() - .map(j -> j.multiply(i)) - .collect(Collectors.toList()); - return this; + // External code should probably be calling MainCostListService.INSTANCE.newPosCostList() + // instead of calling this directly + CostList(List labels) { + this.labels = labels; } @Override public boolean equals(Object obj) { - if (obj instanceof CostList) { - CostList c = (CostList) obj; - CostList m = c.copy() - .multiply(-1); - return CostList.merge(this, m, true).labels.isEmpty(); - } else return false; - } - - public CostList copy() { - CostList ret = new CostList(); - ret.labels = labels.stream() - .map(ILabel::copy) - .collect(Collectors.toList()); - return ret; - } - - public boolean isEmpty() { - return labels.isEmpty(); + return MainCostListService.INSTANCE.costListEquals(this, obj); } public List getLabels() { return labels; } - public Calculator calculate() { - return new Calculator(); + public Calculation calculate() { + return MainCostListService.INSTANCE.calculate(this); } @Override @@ -120,133 +39,4 @@ public int hashCode() { for (ILabel i : labels) hash ^= i.hashCode(); return hash; } - - public class Calculator { - - ArrayList> procedure = new ArrayList<>(); - ArrayList catalysts = new ArrayList<>(); - Recipes.RecipeIterator iterator = Controller.recipeIterator(); - private int index; - - public Calculator() throws ArithmeticException { - HashSet set = new HashSet<>(); - set.add(CostList.this); - - // reset index & iterator - reset(); - Pair next = find(); - int count = 0; - while (next != null) { - CostList original = getCurrent(); - List outL = next.one.getOutput() - .stream() - .filter(i -> i != ILabel.EMPTY) - .collect(Collectors.toList()); - CostList outC = new CostList(outL); - outC.multiply(-next.two); - List inL = next.one.getInput() - .stream() - .filter(i -> i != ILabel.EMPTY) - .collect(Collectors.toList()); - CostList inC = new CostList(inL); - inC.multiply(next.two); - CostList result = CostList.merge(original, outC, false); - result.mergeInplace(inC, false); - if (!set.contains(result)) { - set.add(result); - procedure.add(new Pair<>(result, outC)); - addCatalyst(next.one.getCatalyst()); - reset(); - } - next = find(); - if (count++ > 1000) { - Utilities.addChatMessage(Utilities.ChatMessage.MAX_LOOP); - break; - } - } - } - - private void reset() { - index = 0; - iterator = Controller.recipeIterator(); - } - - /** - * Find next recipe and its amount - * - * @return pair of the next recipe and its amount - */ - @Nullable - private Pair find() { - List labels = getCurrent().labels; - for (; index < labels.size(); index++) { - ILabel label = labels.get(index); - // Only negative label is required to calculate - if (label.getAmount() >= 0) continue; - // Find the recipe for the label. - // Reset or not reset the iterator is a question - while (iterator.hasNext()) { - Recipe r = iterator.next(); - if (r.matches(label) - .isPresent()) return new Pair<>(r, r.multiplier(label)); - } - iterator = Controller.recipeIterator(); - } - return null; - } - - private void addCatalyst(List labels) { - labels.stream() - .filter(i -> i != ILabel.EMPTY) - .forEach( - i -> catalysts.stream() - .filter(j -> j.matches(i)) - .findAny() - .map(j -> j.setAmount(Math.max(i.getAmount(), j.getAmount()))) - .orElseGet(Utilities.fake(() -> catalysts.add(i)))); - } - - private CostList getCurrent() { - return procedure.isEmpty() ? CostList.this : procedure.get(procedure.size() - 1).one; - } - - public List getCatalysts() { - return catalysts; - } - - public List getInputs() { - return getCurrent().labels.stream() - .filter(i -> i.getAmount() < 0) - .map( - i -> i.copy() - .multiply(-1)) - .collect(Collectors.toList()); - } - - public List getOutputs(List ignore) { - return getCurrent().labels.stream() - .map( - i -> i.copy() - .multiply(-1)) - .map( - i -> ignore.stream() - .flatMap(j -> stream(ILabel.MERGER.merge(i, j))) - .findFirst() - .orElse(i)) - .filter(i -> i != ILabel.EMPTY && i.getAmount() < 0) - .map(i -> i.multiply(-1)) - .collect(Collectors.toList()); - } - - public List getSteps() { - List ret = procedure.stream() - .map(i -> i.two.labels.get(0)) - .collect(Collectors.toList()); - Collections.reverse(ret); - CostList cl = new CostList(ret).multiply(-1); - CostList temp = new CostList(); - temp.mergeInplace(cl, false); - return temp.labels; - } - } } diff --git a/src/main/java/me/towdium/jecalculation/data/structure/MainCostListService.java b/src/main/java/me/towdium/jecalculation/data/structure/MainCostListService.java new file mode 100644 index 000000000..a935b0620 --- /dev/null +++ b/src/main/java/me/towdium/jecalculation/data/structure/MainCostListService.java @@ -0,0 +1,117 @@ +package me.towdium.jecalculation.data.structure; + +import java.util.Iterator; +import java.util.List; +import java.util.Optional; + +import me.towdium.jecalculation.data.Controller; +import me.towdium.jecalculation.data.label.ILabel; +import me.towdium.jecalculation.utils.Utilities; + +public class MainCostListService extends AbstractCostListService { + + private static final Dependencies DEFAULT_DEPENDENCIES = new Dependencies() { + + @Override + public ILabel copyLabel(ILabel label) { + return label.copy(); + } + + @Override + public ILabel getEmptyLabel() { + return ILabel.EMPTY; + } + + @Override + public long getLabelAmount(ILabel label) { + return label.getAmount(); + } + + @Override + public boolean isNotEmptyLabel(ILabel label) { + return label != ILabel.EMPTY; + } + + @Override + public boolean labelMatches(ILabel self, ILabel that) { + return self.matches(that); + } + + @Override + public Optional mergeLabels(ILabel a, ILabel b) { + return ILabel.MERGER.merge(a, b); + } + + @Override + public ILabel multiplyLabel(ILabel label, float i) { + return label.multiply(i); + } + + @Override + public ILabel setLabelAmount(ILabel label, long amount) { + return label.setAmount(amount); + } + + @Override + public List getRecipeCatalyst(Recipe recipe) { + return recipe.getCatalyst(); + } + + @Override + public List getRecipeInput(Recipe recipe) { + return recipe.getInput(); + } + + @Override + public List getRecipeOutput(Recipe recipe) { + return recipe.getOutput(); + } + + @Override + public Optional recipeOutputMatches(Recipe recipe, ILabel label) { + return recipe.matches(label); + } + + @Override + public long multiplier(Recipe recipe, ILabel label) { + return recipe.multiplier(label); + } + + @Override + public Iterator recipeIterator() { + return Controller.recipeIterator(); + } + + @Override + public void addMaxLoopChatMessage() { + Utilities.addChatMessage(Utilities.ChatMessage.MAX_LOOP); + } + }; + + private static final CostLists DEFAULT_COST_LISTS = new CostLists() { + + @Override + public CostList newCostList(List labels) { + return new CostList(labels); + } + + @Override + public List getLabels(CostList costList) { + return costList.getLabels(); + } + + @Override + public void setLabels(CostList self, List labels) { + self.labels = labels; + } + }; + + // This MUST be defined after DEFAULT_DEPENDENCIES and DEFAULT_COST_LISTS. + // Otherwise, you will get a NullPointerException! + public static MainCostListService INSTANCE = new MainCostListService(); + + private MainCostListService() { + super(DEFAULT_DEPENDENCIES, DEFAULT_COST_LISTS, CostList.class); + } + +} diff --git a/src/main/java/me/towdium/jecalculation/data/structure/Recipe.java b/src/main/java/me/towdium/jecalculation/data/structure/Recipe.java index 8c323d819..0cb7a7b04 100644 --- a/src/main/java/me/towdium/jecalculation/data/structure/Recipe.java +++ b/src/main/java/me/towdium/jecalculation/data/structure/Recipe.java @@ -153,6 +153,11 @@ public Optional matches(ILabel label) { .findAny(); } + @Override + public String toString() { + return "Recipe{" + "output=" + (output.size() == 0 ? "{}" : output.get(0)) + '}'; + } + public long multiplier(ILabel label) { return output.stream() .filter( diff --git a/src/main/java/me/towdium/jecalculation/gui/guis/GuiCraft.java b/src/main/java/me/towdium/jecalculation/gui/guis/GuiCraft.java index 63fb459c4..1819f5e97 100644 --- a/src/main/java/me/towdium/jecalculation/gui/guis/GuiCraft.java +++ b/src/main/java/me/towdium/jecalculation/gui/guis/GuiCraft.java @@ -16,8 +16,9 @@ import cpw.mods.fml.relauncher.SideOnly; import me.towdium.jecalculation.data.Controller; import me.towdium.jecalculation.data.label.ILabel; +import me.towdium.jecalculation.data.structure.Calculation; import me.towdium.jecalculation.data.structure.CostList; -import me.towdium.jecalculation.data.structure.CostList.Calculator; +import me.towdium.jecalculation.data.structure.MainCostListService; import me.towdium.jecalculation.data.structure.RecordCraft; import me.towdium.jecalculation.gui.JecaGui; import me.towdium.jecalculation.gui.Resource; @@ -37,7 +38,7 @@ @SideOnly(Side.CLIENT) public class GuiCraft extends Gui { - Calculator calculator = null; + Calculation calculator = null; RecordCraft record; WLabel label = new WLabel(31, 7, 20, 20, true).setLsnrUpdate((i, v) -> refreshLabel(v, false, true)); WLabelGroup recent = new WLabelGroup(7, 31, 8, 1, false).setLsnrLeftClick((i, v) -> { @@ -157,7 +158,8 @@ void refreshCalculator() { label.getLabel() .copy() .setAmount(i)); - CostList list = record.inventory ? new CostList(getInventory(), dest) : new CostList(dest); + CostList list = record.inventory ? MainCostListService.INSTANCE.newPosNegCostList(getInventory(), dest) + : MainCostListService.INSTANCE.newNegatedCostList(dest); calculator = list.calculate(); } catch (NumberFormatException | ArithmeticException e) { amount.setColor(JecaGui.COLOR_TEXT_RED); diff --git a/src/main/java/me/towdium/jecalculation/nei/JecaOverlayHandler.java b/src/main/java/me/towdium/jecalculation/nei/JecaOverlayHandler.java index 9e36550fa..ce8fa5ee2 100644 --- a/src/main/java/me/towdium/jecalculation/nei/JecaOverlayHandler.java +++ b/src/main/java/me/towdium/jecalculation/nei/JecaOverlayHandler.java @@ -17,6 +17,7 @@ import cpw.mods.fml.relauncher.SideOnly; import me.towdium.jecalculation.data.label.ILabel; import me.towdium.jecalculation.data.structure.CostList; +import me.towdium.jecalculation.data.structure.MainCostListService; import me.towdium.jecalculation.data.structure.Recipe; import me.towdium.jecalculation.gui.JecaGui; import me.towdium.jecalculation.gui.guis.GuiRecipe; @@ -78,17 +79,20 @@ private static void merge(EnumMap new ArrayList<>()) .stream() .filter(p -> { - CostList cl = new CostList(list); + CostList cl = MainCostListService.INSTANCE.newNegatedCostList(list); if (p.three.equals(cl)) { ILabel.MERGER.merge(p.one, fin) .ifPresent(i -> p.one = i); - p.two = CostList.merge(p.two, cl, true); + p.two = MainCostListService.INSTANCE.strictMergeCostList(p.two, cl); return true; } else return false; }) .findAny() .orElseGet(() -> { - Trio ret = new Trio<>(fin, new CostList(list), new CostList(list)); + Trio ret = new Trio<>( + fin, + MainCostListService.INSTANCE.newNegatedCostList(list), + MainCostListService.INSTANCE.newNegatedCostList(list)); dst.get(type) .add(ret); return ret; From 7048dc9fe055d0b72237ce562a4080b3aae1e231 Mon Sep 17 00:00:00 2001 From: Joshua Ball Date: Sat, 7 Dec 2024 16:59:42 -0800 Subject: [PATCH 3/5] Added tests for AbstractCostListService (which used to be CostList.Calculator). All of the tests are in CostListServiceTest. --- build.gradle | 9 + .../AbstractCostListServiceTest.java | 225 ++++++++++++++++++ .../data/structure/CostListServiceTest.java | 98 ++++++++ .../jecalculation/data/structure/TestLbl.java | 45 ++++ .../jecalculation/data/structure/TestRcp.java | 40 ++++ 5 files changed, 417 insertions(+) create mode 100644 src/test/java/me/towdium/jecalculation/data/structure/AbstractCostListServiceTest.java create mode 100644 src/test/java/me/towdium/jecalculation/data/structure/CostListServiceTest.java create mode 100644 src/test/java/me/towdium/jecalculation/data/structure/TestLbl.java create mode 100644 src/test/java/me/towdium/jecalculation/data/structure/TestRcp.java diff --git a/build.gradle b/build.gradle index 50c3291c8..4af8cdaaf 100644 --- a/build.gradle +++ b/build.gradle @@ -1608,3 +1608,12 @@ if (file('addon.late.local.gradle.kts').exists()) { } else if (file('addon.late.local.gradle').exists()) { apply from: 'addon.late.local.gradle' } + +dependencies { + testImplementation 'org.junit.jupiter:junit-jupiter-api:5.9.2' + testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.9.2' +} + +test { + useJUnitPlatform() +} \ No newline at end of file diff --git a/src/test/java/me/towdium/jecalculation/data/structure/AbstractCostListServiceTest.java b/src/test/java/me/towdium/jecalculation/data/structure/AbstractCostListServiceTest.java new file mode 100644 index 000000000..a5170720d --- /dev/null +++ b/src/test/java/me/towdium/jecalculation/data/structure/AbstractCostListServiceTest.java @@ -0,0 +1,225 @@ +package me.towdium.jecalculation.data.structure; + +import static me.towdium.jecalculation.data.structure.TestRcp.rcp; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; +import java.util.Optional; + +public abstract class AbstractCostListServiceTest { + + private static TestLbl EMPTY = new TestLbl("THE_EMPTY_LABEL", 0); + + Calculation calculation; + private List inventory = Collections.emptyList(); + private final List recipes = new ArrayList<>(); + private boolean maxLoopTriggered = false; + + private AbstractCostListService.Dependencies dependencies = new AbstractCostListService.Dependencies() { + + @Override + public TestLbl copyLabel(TestLbl label) { + return label.clone(); + } + + @Override + public TestLbl getEmptyLabel() { + return EMPTY; + } + + @Override + public long getLabelAmount(TestLbl label) { + return label.amount; + } + + @Override + public boolean isNotEmptyLabel(TestLbl label) { + return !"THE_EMPTY_LABEL".equals(label.name); + } + + @Override + public boolean labelMatches(TestLbl self, TestLbl that) { + return self.name.equals(that.name); + } + + @Override + public Optional mergeLabels(TestLbl a, TestLbl b) { + // For some reason this is way more complicated in ILabel, involving multiplying by 100 + // adding 99, and then dividing by 100. Not sure why. Obviously something to do with + // percents, but weirdly, this is what happens when isPercent() returns false. + // I think my naive implementation is still valid for the tests at least. + if (!a.name.equals(b.name)) { + return Optional.empty(); + } + long sum = a.amount + b.amount; + if (sum == 0) { + return Optional.of(EMPTY); + } + return Optional.of(new TestLbl(a.name, sum)); + } + + @Override + public TestLbl multiplyLabel(TestLbl label, float i) { + float amount = i * label.amount; + if (amount > Long.MAX_VALUE) throw new ArithmeticException("Multiply overflow"); + return setLabelAmount(label, (long) amount); + } + + @Override + public TestLbl setLabelAmount(TestLbl label, long amount) { + if (amount == 0) return EMPTY; // consistent with actual implementation, but this feels like a bug in + // waiting to me, since it never actually mutates the input! + label.amount = amount; + return label; + } + + @Override + public List getRecipeCatalyst(TestRcp recipe) { + return recipe.catalysts; + } + + @Override + public List getRecipeInput(TestRcp recipe) { + return recipe.inputs; + } + + @Override + public List getRecipeOutput(TestRcp recipe) { + return recipe.outputs; + } + + @Override + public Optional recipeOutputMatches(TestRcp recipe, TestLbl label) { + for (TestLbl output : recipe.outputs) { + if (mergeLabels(label, output).isPresent()) { + return Optional.of(output); + } + } + return Optional.empty(); + } + + @Override + public long multiplier(TestRcp recipe, TestLbl label) { + for (TestLbl output : recipe.outputs) { + if (mergeLabels(label, output).isPresent()) { + long amountA = Math.multiplyExact(label.amount, 100L); + long amountB = Math.multiplyExact(output.amount, 100L); + return (amountB + Math.abs(amountA) - 1) / amountB; + } + } + return 0L; + } + + @Override + public Iterator recipeIterator() { + return recipes.iterator(); + } + + @Override + public void addMaxLoopChatMessage() { + maxLoopTriggered = true; + } + }; + + private AbstractCostListService.CostLists> costLists = new AbstractCostListService.CostLists>() { + + @Override + public List newCostList(List labels) { + return labels; + } + + @Override + public List getLabels(List self) { + return self; + } + + @Override + public void setLabels(List self, List labels) { + self.clear(); + self.addAll(labels); + } + }; + + private CostListService service = new CostListService(); + + private class CostListService extends AbstractCostListService> { + + CostListService() { + super(dependencies, costLists, (Class) List.class); + } + } + + void inventory(TestLbl... inventory) { + inventory(Arrays.asList(inventory)); + } + + void inventory(List inventory) { + this.inventory = inventory; + } + + void recipe(List outputs, List catalysts, List inputs) { + recipes.add(rcp(outputs, catalysts, inputs)); + } + + void request(TestLbl label) { + calculation = service.calculate(service.newPosNegCostList(inventory, Collections.singletonList(label))); + } + + void assertInputs(TestLbl... inputs) { + assertInputs(Arrays.asList(inputs)); + } + + void assertInputs(List inputs) { + List actualInputs = calculation.getInputs(); + if (!inputs.equals(actualInputs)) { + throw new AssertionError("expectedInputs = " + inputs + ", actualInputs = " + actualInputs); + } + } + + void assertExcessOutputs(TestLbl... outputs) { + assertExcessOutputs(Arrays.asList(outputs)); + } + + void assertExcessOutputs(List outputs) { + List actualOutputs = calculation.getOutputs(inventory); + if (!outputs.equals(actualOutputs)) { + throw new AssertionError("expectedOutputs = " + outputs + ", actualOutputs = " + actualOutputs); + } + } + + void assertCatalysts(TestLbl... outputs) { + assertCatalysts(Arrays.asList(outputs)); + } + + void assertCatalysts(List catalysts) { + List actualCatalysts = calculation.getCatalysts(); + if (!catalysts.equals(actualCatalysts)) { + throw new AssertionError("expectedCatalysts = " + catalysts + ", actualCatalysts = " + actualCatalysts); + } + } + + void assertSteps(TestLbl... steps) { + assertSteps(Arrays.asList(steps)); + } + + void assertSteps(List steps) { + List actualSteps = calculation.getSteps(); + if (!steps.equals(actualSteps)) { + throw new AssertionError("expectedSteps = " + steps + ", actualSteps = " + actualSteps); + } + } + + void assertMaxLoopTriggered() { + assert maxLoopTriggered; + } + + void printCalculation() { + System.out.println("inputs: " + calculation.getInputs()); + System.out.println("excess outputs: " + calculation.getOutputs(inventory)); + System.out.println("catalysts: " + calculation.getCatalysts()); + System.out.println("steps: " + calculation.getSteps()); + } +} diff --git a/src/test/java/me/towdium/jecalculation/data/structure/CostListServiceTest.java b/src/test/java/me/towdium/jecalculation/data/structure/CostListServiceTest.java new file mode 100644 index 000000000..ac0d1a463 --- /dev/null +++ b/src/test/java/me/towdium/jecalculation/data/structure/CostListServiceTest.java @@ -0,0 +1,98 @@ +package me.towdium.jecalculation.data.structure; + +import static me.towdium.jecalculation.data.structure.TestLbl.lbl; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import org.junit.jupiter.api.Test; + +public class CostListServiceTest extends AbstractCostListServiceTest { + + @Test + void oneCobblestone() { + request(lbl("cobblestone")); + assertInputs(lbl("cobblestone")); + assertExcessOutputs(); + assertCatalysts(); + assertSteps(); + } + + @Test + public void threeStone() { + recipe(lst(lbl("stone")), lst(lbl("furnace")), lst(lbl("cobblestone"))); + inventory(lbl("stone")); + request(lbl("stone", 3)); + assertInputs(lbl("cobblestone", 2)); + assertExcessOutputs(); + assertCatalysts(lbl("furnace")); + assertSteps(lbl("stone", 2)); + } + + @Test + void tntPure() { + recipe(Arrays.asList(lbl("tnt")), Arrays.asList(lbl("crafting-table")), Arrays.asList(lbl("gunpowder", 5), lbl("sand", 4))); + request(lbl("tnt")); + assertInputs(lbl("gunpowder", 5), lbl("sand", 4)); + assertExcessOutputs(Collections.emptyList()); + assertCatalysts(lbl("crafting-table")); + assertSteps(lbl("tnt")); + } + + @Test + void tntPartialInventory() { + recipe(Arrays.asList(lbl("tnt")), Arrays.asList(lbl("crafting-table")), Arrays.asList(lbl("gunpowder", 5), lbl("sand", 4))); + inventory(lbl("sand")); + request(lbl("tnt")); + + // The reason the sand is listed before the gunpowder, instead of the order it is in the recipe, is because adding + // 3 sand to your inventory when you've already got one won't increase the size of your inventory, while + // adding 5 gunpowder when you have none actually will. + assertInputs(lbl("sand", 3), lbl("gunpowder", 5)); + + assertExcessOutputs(Collections.emptyList()); + assertCatalysts(lbl("crafting-table")); + assertSteps(lbl("tnt")); + } + + @Test + void basicLoop() { + recipe(lst(lbl("stone")), lst(lbl("furnace")), lst(lbl("cobblestone"))); + recipe(lst(lbl("cobblestone")), lst(lbl("hammer")), lst(lbl("stone"))); + request(lbl("cobblestone", 1)); + + assertExcessOutputs(); + assert calculation.getSteps().size() <= 2000; + } + + @Test + void infiniteLoop() { + recipe(lst(lbl("stone")), lst(lbl("furnace")), lst(lbl("cobblestone"))); + recipe(lst(lbl("cobblestone", 100)), lst(lbl("hammer")), lst(lbl("stone", 101))); + request(lbl("cobblestone", 1)); + + assertCatalysts(lbl("hammer"), lbl("furnace")); + assert calculation.getSteps().size() <= 2000; + } + + @Test + void surplus() { + recipe(lst(lbl("motor")), WORKBENCH, lst(lbl("iron-rod", 2), lbl("magnetic-iron-rod"))); + recipe(lst(lbl("iron-rod", 64), lbl("iron-dust", 128)), lst(lbl("lathe")), lst(lbl("iron-ingot", 64))); + recipe(lst(lbl("magnetic-iron-rod", 64)), lst(lbl("magnetizer")), lst(lbl("iron-rod", 64))); + + request(lbl("motor")); + + assertInputs(lbl("iron-ingot", 128)); + assertExcessOutputs(lbl("iron-rod", 62), lbl("magnetic-iron-rod", 63), lbl("iron-dust", 256)); + assertCatalysts(lbl("crafting-table"), lbl("lathe"), lbl("magnetizer")); + assertSteps(lbl("iron-rod", 128), lbl("magnetic-iron-rod", 64), lbl("motor")); + } + + + private static List WORKBENCH = lst(lbl("crafting-table")); + private static List lst(T... args) { + return Arrays.asList(args); + } +} diff --git a/src/test/java/me/towdium/jecalculation/data/structure/TestLbl.java b/src/test/java/me/towdium/jecalculation/data/structure/TestLbl.java new file mode 100644 index 000000000..a82e33a58 --- /dev/null +++ b/src/test/java/me/towdium/jecalculation/data/structure/TestLbl.java @@ -0,0 +1,45 @@ +package me.towdium.jecalculation.data.structure; + +import java.util.Objects; + +class TestLbl implements Cloneable { + + final String name; + long amount; + + TestLbl(String name, long amount) { + this.name = name; + this.amount = amount; + } + + @Override + protected TestLbl clone() { + return new TestLbl(this.name, this.amount); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof TestLbl)) return false; + TestLbl testLbl = (TestLbl) o; + return amount == testLbl.amount && Objects.equals(name, testLbl.name); + } + + @Override + public int hashCode() { + return Objects.hash(name, amount); + } + + @Override + public String toString() { + return "{" + amount + " " + name + "}"; + } + + static TestLbl lbl(String name) { + return lbl(name, 1); + } + + static TestLbl lbl(String name, int count) { + return new TestLbl(name, count); + } +} diff --git a/src/test/java/me/towdium/jecalculation/data/structure/TestRcp.java b/src/test/java/me/towdium/jecalculation/data/structure/TestRcp.java new file mode 100644 index 000000000..420bfb61c --- /dev/null +++ b/src/test/java/me/towdium/jecalculation/data/structure/TestRcp.java @@ -0,0 +1,40 @@ +package me.towdium.jecalculation.data.structure; + +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +class TestRcp { + + final List outputs; + final List catalysts; + final List inputs; + + TestRcp(List outputs, List catalysts, List inputs) { + this.outputs = Collections.unmodifiableList(outputs); + this.catalysts = Collections.unmodifiableList(catalysts); + this.inputs = Collections.unmodifiableList(inputs); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof TestRcp)) return false; + TestRcp testRcp = (TestRcp) o; + return Objects.equals(outputs, testRcp.outputs) && Objects.equals(catalysts, testRcp.catalysts) + && Objects.equals(inputs, testRcp.inputs); + } + + @Override + public int hashCode() { + return Objects.hash(outputs, catalysts, inputs); + } + + static TestRcp rcp(List outputs, List catalysts, List inputs) { + return new TestRcp(outputs, catalysts, inputs); + } + + static TestRcp rcp(List outputs, List inputs) { + return new TestRcp(outputs, Collections.emptyList(), inputs); + } +} From e195544cf48df379c1d89597c0f4df00420b8274 Mon Sep 17 00:00:00 2001 From: Joshua Ball Date: Sun, 8 Dec 2024 08:40:25 -0800 Subject: [PATCH 4/5] Fixed out of order bug and added appropriate tests --- .../structure/AbstractCostListService.java | 154 ++++++++++++++++-- .../data/structure/Calculation.java | 7 +- .../jecalculation/gui/guis/GuiCraft.java | 2 +- .../data/structure/CostListServiceTest.java | 35 ++++ 4 files changed, 182 insertions(+), 16 deletions(-) diff --git a/src/main/java/me/towdium/jecalculation/data/structure/AbstractCostListService.java b/src/main/java/me/towdium/jecalculation/data/structure/AbstractCostListService.java index d5d45a163..edbc178a7 100644 --- a/src/main/java/me/towdium/jecalculation/data/structure/AbstractCostListService.java +++ b/src/main/java/me/towdium/jecalculation/data/structure/AbstractCostListService.java @@ -57,7 +57,15 @@ public List getLabels(CostListT costList) { } public Calculation calculate(CostListT costList) { - ArrayList> procedure = new ArrayList<>(); + class ProcedureStep { + + CostListT stillNeeded; // mostly negative until the last step. may have some positive if there are excess + // outputs + RecipeT recipe; + long multiplier; + CostListT multipliedRecipeOutputs; // recipe.outputs * multiplier; 1st item is the main output + } + ArrayList procedure = new ArrayList<>(); ArrayList catalysts = new ArrayList<>(); return new Calculation() { @@ -74,6 +82,9 @@ public Calculation calculate(CostListT costList) { Pair next = find(); int count = 0; while (next != null) { + ProcedureStep procedureStep = new ProcedureStep(); + procedureStep.recipe = next.one; + procedureStep.multiplier = next.two; CostListT original = getCurrent(); List outL = d.getRecipeOutput(next.one) .stream() @@ -81,6 +92,7 @@ public Calculation calculate(CostListT costList) { .collect(Collectors.toList()); CostListT outC = newNegatedCostList(outL); multiply(outC, -next.two); + procedureStep.multipliedRecipeOutputs = outC; List inL = d.getRecipeInput(next.one) .stream() .filter(d::isNotEmptyLabel) @@ -89,9 +101,10 @@ public Calculation calculate(CostListT costList) { multiply(inC, next.two); CostListT result = mergeCostLists(original, outC, false); mergeInplace(result, inC, false); + procedureStep.stillNeeded = result; if (!set.contains(result)) { set.add(result); - procedure.add(new Pair<>(result, outC)); + procedure.add(procedureStep); addCatalyst(d.getRecipeCatalyst(next.one)); reset(); } @@ -131,17 +144,130 @@ public List getOutputs(List ignore) { } @Override - public List getSteps() { - List ret = procedure.stream() - .map( - i -> costLists.getLabels(i.two) - .get(0)) - .collect(Collectors.toList()); - Collections.reverse(ret); - CostListT cl = multiply(newNegatedCostList(ret), -1); - CostListT temp = newNegatedCostList(new ArrayList<>()); - mergeInplace(temp, cl, false); - return costLists.getLabels(temp); + public List getSteps(List startingInventory) { + startingInventory = new ArrayList<>(startingInventory); + startingInventory.addAll(getInputs()); + + // First we run a simulated inventory through the procedure backwards, but also look for opportunities + // to combine multiple steps with the same recipe into a single step. We do not pick (or merge) steps + // that would cause the user to have a negative amount of items in their inventory. Our search for merge + // opportunities is greedy, so it is possible to get stuck in a corner, in which case we fall back to + // the simpler solution below. This algorithm is quadratic in the worst case (like the fallback), but is + // close to linear for most realistic inputs. + { + CostListT inventory = costLists.newCostList(startingInventory); + LinkedList queue = new LinkedList<>(procedure); + + // When this counts down to 0 for a recipe, then we know we can terminate the inner loop early. + // Since this is going to start out as 1 for most recipes, we almost never have to search backwards, + // and so this algorithm is closer to linear than quadratic. + Map numStepsStillUsingRecipe = new HashMap<>(); + for (ProcedureStep step : queue) { + numStepsStillUsingRecipe.put(step.recipe, 1 + numStepsStillUsingRecipe.computeIfAbsent(step.recipe, (k) -> 0)); + } + + List> ret = new ArrayList<>(); + + class Remover { + Optional tryApplyingToInventoryAndRemoveIfAllPositive(Iterator iterator, ProcedureStep step, CostListT inventory) { + CostListT candidateInventory = mergeCostLists(inventory, recipeAsCostList(step.recipe, step.multiplier), false); + if (!isAllPositive(candidateInventory)) { + return Optional.empty(); + } + iterator.remove(); + numStepsStillUsingRecipe.put(step.recipe, numStepsStillUsingRecipe.get(step.recipe) - 1); + return Optional.of(candidateInventory); + } + + private CostListT recipeAsCostList(RecipeT recipe, long multiplier) { + // todo: unify with above + List outL = d.getRecipeOutput(recipe) + .stream() + .filter(d::isNotEmptyLabel) + .collect(Collectors.toList()); + CostListT outC = newNegatedCostList(outL); + multiply(outC, -multiplier); + List inL = d.getRecipeInput(recipe) + .stream() + .filter(d::isNotEmptyLabel) + .collect(Collectors.toList()); + CostListT inC = newNegatedCostList(inL); + multiply(inC, multiplier); + return mergeCostLists(inC, outC, false); + } + } + Remover remover = new Remover(); + + dequeuing: + while (!queue.isEmpty()) { + if (!ret.isEmpty()) { + Pair mostRecent = ret.get(ret.size() - 1); + int numOfMostRecentRecipe = numStepsStillUsingRecipe.get(mostRecent.one); + if (numOfMostRecentRecipe > 0) { + for (Iterator iterator = queue.descendingIterator(); iterator.hasNext(); ) { + ProcedureStep step = iterator.next(); + if (step.recipe.equals(mostRecent.one)) { + Optional inv = remover.tryApplyingToInventoryAndRemoveIfAllPositive(iterator, step, inventory); + if (inv.isPresent()) { + inventory = inv.get(); + mostRecent.two = mostRecent.two + step.multiplier; + continue dequeuing; + } + } + } + } + } + // either we just started, or the latest has no (usable) duplicates remaining, so just try from the most recent ones + for (Iterator iterator = queue.descendingIterator(); iterator.hasNext(); ) { + ProcedureStep step = iterator.next(); + Optional inv = remover.tryApplyingToInventoryAndRemoveIfAllPositive(iterator, step, inventory); + if (inv.isPresent()) { + inventory = inv.get(); + ret.add(new Pair<>(step.recipe, step.multiplier)); + continue dequeuing; + } + } + // Stuck in a corner; give up + ret = null; + break; + } + if (ret != null) { + List rete = new ArrayList<>(ret.size()); + for (Pair pair : ret) { + List outL = d.getRecipeOutput(pair.one).stream().filter(d::isNotEmptyLabel).collect(Collectors.toList()); + CostListT outC = newNegatedCostList(outL); + multiply(outC, -pair.two); + rete.add(costLists.getLabels(outC).get(0)); + } + return rete; + } + } + + // If we reach here, then the above approach got trapped in a corner where all paths forward led to + // negative items in the simulated inventory. Here we fall back to a straightforward merge, which + // occasionally gives steps out of order, but 99% of the time gives a right answer. + { + // + List ret = procedure.stream() + .map( + i -> costLists.getLabels(i.multipliedRecipeOutputs) + .get(0)) + .collect(Collectors.toList()); + Collections.reverse(ret); + CostListT cl = multiply(newNegatedCostList(ret), -1); + CostListT temp = newNegatedCostList(new ArrayList<>()); + mergeInplace(temp, cl, false); + return costLists.getLabels(temp); + } + } + + private boolean isAllPositive(CostListT candidateInventory) { + for (LabelT label : costLists.getLabels(candidateInventory)) { + if (d.getLabelAmount(label) < 0) { + return false; + } + } + return true; } private void reset() { @@ -187,7 +313,7 @@ private void addCatalyst(List labels) { } private CostListT getCurrent() { - return procedure.isEmpty() ? costList : procedure.get(procedure.size() - 1).one; + return procedure.isEmpty() ? costList : procedure.get(procedure.size() - 1).stillNeeded; } }; }; diff --git a/src/main/java/me/towdium/jecalculation/data/structure/Calculation.java b/src/main/java/me/towdium/jecalculation/data/structure/Calculation.java index e4723ac51..7459e2f8b 100644 --- a/src/main/java/me/towdium/jecalculation/data/structure/Calculation.java +++ b/src/main/java/me/towdium/jecalculation/data/structure/Calculation.java @@ -1,5 +1,6 @@ package me.towdium.jecalculation.data.structure; +import java.util.Collections; import java.util.List; public interface Calculation { @@ -10,5 +11,9 @@ public interface Calculation { List getOutputs(List ignore); - List getSteps(); + default List getSteps() { + return getSteps(Collections.emptyList()); + } + + List getSteps(List startingInventory); } diff --git a/src/main/java/me/towdium/jecalculation/gui/guis/GuiCraft.java b/src/main/java/me/towdium/jecalculation/gui/guis/GuiCraft.java index 1819f5e97..cdbaf1010 100644 --- a/src/main/java/me/towdium/jecalculation/gui/guis/GuiCraft.java +++ b/src/main/java/me/towdium/jecalculation/gui/guis/GuiCraft.java @@ -194,7 +194,7 @@ void refreshResult() { result.setLabels(calculator.getCatalysts()); break; case STEPS: - result.setLabels(calculator.getSteps()); + result.setLabels(calculator.getSteps(getInventory())); break; } } diff --git a/src/test/java/me/towdium/jecalculation/data/structure/CostListServiceTest.java b/src/test/java/me/towdium/jecalculation/data/structure/CostListServiceTest.java index ac0d1a463..ee634110a 100644 --- a/src/test/java/me/towdium/jecalculation/data/structure/CostListServiceTest.java +++ b/src/test/java/me/towdium/jecalculation/data/structure/CostListServiceTest.java @@ -90,8 +90,43 @@ void surplus() { assertSteps(lbl("iron-rod", 128), lbl("magnetic-iron-rod", 64), lbl("motor")); } + // The next two tests demonstrate the problem with trying to merge repeated crafting steps in a naive + // post-processing step. If the steps are merged in one direction, one of the tests fails. If the steps are merged + // in the other direction, the other test fails. + + @Test + void wiresAndCables1() { + recipe(lst(lbl("superWireAndCable")), WORKBENCH, lst(lbl("cable"), lbl("wire"))); + recipe(lst(lbl("wire", 2)), WORKBENCH, lst(lbl("tin-ingot"))); + recipe(lst(lbl("cable")), WORKBENCH, lst(lbl("wire"))); + + request(lbl("superWireAndCable")); + + assertInputs(lbl("tin-ingot", 1)); + assertExcessOutputs(); + assertCatalysts(WORKBENCH); + assertSteps(lbl("wire", 2), lbl("cable"), lbl("superWireAndCable")); + } + + @Test + void wiresAndCables2() { + recipe(lst(lbl("tv")), WORKBENCH, lst(lbl("cable"), lbl("antenna"))); + recipe(lst(lbl("cable")), WORKBENCH, lst(lbl("wire"))); + recipe(lst(lbl("wire", 2)), WORKBENCH, lst(lbl("tin-ingot"))); + recipe(lst(lbl("antenna")), WORKBENCH, lst(lbl("cable"))); + + request(lbl("tv")); + + assertInputs(lbl("tin-ingot", 1)); + assertExcessOutputs(); + assertCatalysts(WORKBENCH); + + // Cables are made from wires, not the other way around, so it is important that wires appear before cables. + assertSteps(lbl("wire", 2), lbl("cable", 2), lbl("antenna"), lbl("tv")); + } private static List WORKBENCH = lst(lbl("crafting-table")); + private static List lst(T... args) { return Arrays.asList(args); } From 25d3fdb2a501dbb87259ac8ed8325446457c45db Mon Sep 17 00:00:00 2001 From: Joshua Ball Date: Sun, 8 Dec 2024 08:40:25 -0800 Subject: [PATCH 5/5] Optimize steps for small inventories. Tweaked the post-processing of calculate() to order steps in a way that keeps the user's inventory from growing unnecessarily large, which is particularly helpful in GTNH. --- .../structure/AbstractCostListService.java | 190 ++++++++++-------- .../data/structure/CostListServiceTest.java | 31 +++ 2 files changed, 137 insertions(+), 84 deletions(-) diff --git a/src/main/java/me/towdium/jecalculation/data/structure/AbstractCostListService.java b/src/main/java/me/towdium/jecalculation/data/structure/AbstractCostListService.java index edbc178a7..cff15d0f9 100644 --- a/src/main/java/me/towdium/jecalculation/data/structure/AbstractCostListService.java +++ b/src/main/java/me/towdium/jecalculation/data/structure/AbstractCostListService.java @@ -144,102 +144,98 @@ public List getOutputs(List ignore) { } @Override - public List getSteps(List startingInventory) { - startingInventory = new ArrayList<>(startingInventory); - startingInventory.addAll(getInputs()); - - // First we run a simulated inventory through the procedure backwards, but also look for opportunities - // to combine multiple steps with the same recipe into a single step. We do not pick (or merge) steps - // that would cause the user to have a negative amount of items in their inventory. Our search for merge - // opportunities is greedy, so it is possible to get stuck in a corner, in which case we fall back to - // the simpler solution below. This algorithm is quadratic in the worst case (like the fallback), but is - // close to linear for most realistic inputs. + public List getSteps(List givenInventory) { + // First we try running a simulated inventory through the procedure backwards, greedily preferring steps + // that keep a small inventory, although we prohibit steps that are impossible and merge steps that use + // identical recipes whenever possible. There's no backtracking, so it's posssible to get stuck in a + // corner, in which case we fall back to the simpler solution below. As long as the number of labels in + // the simulated inventory stays relatively bounded, this algorithm is close to quadratic (like the + // fallback). It could in theory bump up to cubic if there were lots of excess outputs of many distinct + // labels, but that doesn't seem to be a problem in practice. { - CostListT inventory = costLists.newCostList(startingInventory); - LinkedList queue = new LinkedList<>(procedure); - - // When this counts down to 0 for a recipe, then we know we can terminate the inner loop early. - // Since this is going to start out as 1 for most recipes, we almost never have to search backwards, - // and so this algorithm is closer to linear than quadratic. - Map numStepsStillUsingRecipe = new HashMap<>(); - for (ProcedureStep step : queue) { - numStepsStillUsingRecipe.put(step.recipe, 1 + numStepsStillUsingRecipe.computeIfAbsent(step.recipe, (k) -> 0)); - } + List startingInventory = new ArrayList<>(givenInventory); + startingInventory.addAll(getInputs()); - List> ret = new ArrayList<>(); - - class Remover { - Optional tryApplyingToInventoryAndRemoveIfAllPositive(Iterator iterator, ProcedureStep step, CostListT inventory) { - CostListT candidateInventory = mergeCostLists(inventory, recipeAsCostList(step.recipe, step.multiplier), false); + CostListT inventory = costLists.newCostList(startingInventory); + List remainingProcedureSteps = new ArrayList<>(procedure); + List> optimizedSteps = new ArrayList<>(); + + while (!remainingProcedureSteps.isEmpty()) { + final RecipeT preferredRecipe = optimizedSteps.isEmpty() ? null + : optimizedSteps.get(optimizedSteps.size() - 1).one; + Integer indexOfBestStep = null; + CostListT inventoryAfterBestStep = null; + int sizeOfInventoryAfterBestStep = Integer.MAX_VALUE; + for (int i = remainingProcedureSteps.size() - 1; i >= 0; i--) { + ProcedureStep step = remainingProcedureSteps.get(i); + CostListT candidateInventory = mergeCostLists( + inventory, + recipeAsCostList(step.recipe, step.multiplier), + false); if (!isAllPositive(candidateInventory)) { - return Optional.empty(); + // Don't give the user an impossible plan. + continue; } - iterator.remove(); - numStepsStillUsingRecipe.put(step.recipe, numStepsStillUsingRecipe.get(step.recipe) - 1); - return Optional.of(candidateInventory); + int inventorySize = estimatedNumSlotsTakenBy(candidateInventory); + if (indexOfBestStep == null) { + // First encounter of an option with a non-negative inventory + indexOfBestStep = i; + inventoryAfterBestStep = candidateInventory; + sizeOfInventoryAfterBestStep = inventorySize; + continue; + } + ProcedureStep bestStep = remainingProcedureSteps.get(indexOfBestStep); + if (step.recipe.equals(preferredRecipe) && !bestStep.recipe.equals(preferredRecipe)) { + // First encounter of an option that uses the same recipe as our most recent step; stop + // here + indexOfBestStep = i; + inventoryAfterBestStep = candidateInventory; + break; + } + if (inventorySize < sizeOfInventoryAfterBestStep) { + // No matching recipe found yet, but this step gives the smallest inventory + indexOfBestStep = i; + inventoryAfterBestStep = candidateInventory; + sizeOfInventoryAfterBestStep = inventorySize; + } + } + if (indexOfBestStep == null) { + // Stuck in a corner; give up + optimizedSteps = null; + break; } - private CostListT recipeAsCostList(RecipeT recipe, long multiplier) { - // todo: unify with above - List outL = d.getRecipeOutput(recipe) + // Removal is linear time in the worst case, but + // 1. Java's LinkedList would require a re-traversal anyway + // 2. Most of the time, indexOfBestStep will be near the end. + // 3. Array copies are fast. + // 4. We just finished a linear scan, so this doesn't worsen our complexity. + ProcedureStep bestStep = remainingProcedureSteps.remove((int) indexOfBestStep); + + inventory = inventoryAfterBestStep; + if (!optimizedSteps.isEmpty() && bestStep.recipe.equals(preferredRecipe)) { + // merge with previous step + Pair latest = optimizedSteps.get(optimizedSteps.size() - 1); + latest.two = latest.two + bestStep.multiplier; + } else { + // add new step + optimizedSteps.add(new Pair<>(bestStep.recipe, bestStep.multiplier)); + } + } + if (optimizedSteps != null) { + List retLabels = new ArrayList<>(optimizedSteps.size()); + for (Pair pair : optimizedSteps) { + List outL = d.getRecipeOutput(pair.one) .stream() .filter(d::isNotEmptyLabel) .collect(Collectors.toList()); CostListT outC = newNegatedCostList(outL); - multiply(outC, -multiplier); - List inL = d.getRecipeInput(recipe) - .stream() - .filter(d::isNotEmptyLabel) - .collect(Collectors.toList()); - CostListT inC = newNegatedCostList(inL); - multiply(inC, multiplier); - return mergeCostLists(inC, outC, false); - } - } - Remover remover = new Remover(); - - dequeuing: - while (!queue.isEmpty()) { - if (!ret.isEmpty()) { - Pair mostRecent = ret.get(ret.size() - 1); - int numOfMostRecentRecipe = numStepsStillUsingRecipe.get(mostRecent.one); - if (numOfMostRecentRecipe > 0) { - for (Iterator iterator = queue.descendingIterator(); iterator.hasNext(); ) { - ProcedureStep step = iterator.next(); - if (step.recipe.equals(mostRecent.one)) { - Optional inv = remover.tryApplyingToInventoryAndRemoveIfAllPositive(iterator, step, inventory); - if (inv.isPresent()) { - inventory = inv.get(); - mostRecent.two = mostRecent.two + step.multiplier; - continue dequeuing; - } - } - } - } - } - // either we just started, or the latest has no (usable) duplicates remaining, so just try from the most recent ones - for (Iterator iterator = queue.descendingIterator(); iterator.hasNext(); ) { - ProcedureStep step = iterator.next(); - Optional inv = remover.tryApplyingToInventoryAndRemoveIfAllPositive(iterator, step, inventory); - if (inv.isPresent()) { - inventory = inv.get(); - ret.add(new Pair<>(step.recipe, step.multiplier)); - continue dequeuing; - } - } - // Stuck in a corner; give up - ret = null; - break; - } - if (ret != null) { - List rete = new ArrayList<>(ret.size()); - for (Pair pair : ret) { - List outL = d.getRecipeOutput(pair.one).stream().filter(d::isNotEmptyLabel).collect(Collectors.toList()); - CostListT outC = newNegatedCostList(outL); multiply(outC, -pair.two); - rete.add(costLists.getLabels(outC).get(0)); + retLabels.add( + costLists.getLabels(outC) + .get(0)); } - return rete; + return retLabels; } } @@ -431,4 +427,30 @@ protected CostListT copyCostList(CostListT from) { .collect(Collectors.toList())); return ret; } + + private CostListT recipeAsCostList(RecipeT recipe, long multiplier) { + // todo: unify with body of calculate() + List outL = d.getRecipeOutput(recipe) + .stream() + .filter(d::isNotEmptyLabel) + .collect(Collectors.toList()); + CostListT outC = newNegatedCostList(outL); + multiply(outC, -multiplier); + List inL = d.getRecipeInput(recipe) + .stream() + .filter(d::isNotEmptyLabel) + .collect(Collectors.toList()); + CostListT inC = newNegatedCostList(inL); + multiply(inC, multiplier); + return mergeCostLists(inC, outC, false); + } + + private int estimatedNumSlotsTakenBy(CostListT costLisT) { + int total = 0; + for (LabelT label : costLists.getLabels(costLisT)) { + // Assuming 64 is not valid for snowballs and unstackables, but good enough for an estimate + total += (int) Math.ceil(d.getLabelAmount(label) / 64.0); + } + return total; + } } diff --git a/src/test/java/me/towdium/jecalculation/data/structure/CostListServiceTest.java b/src/test/java/me/towdium/jecalculation/data/structure/CostListServiceTest.java index ee634110a..6d055de57 100644 --- a/src/test/java/me/towdium/jecalculation/data/structure/CostListServiceTest.java +++ b/src/test/java/me/towdium/jecalculation/data/structure/CostListServiceTest.java @@ -56,6 +56,37 @@ void tntPartialInventory() { assertSteps(lbl("tnt")); } + @Test + void minimalInventory1() { + // This request requires 14 iron blocks and 1000 stone. + // We should make the iron blocks before making the stone, to minimize the amount of inventory space we take up. + recipe(lst(lbl("iron-block")), WORKBENCH, lst(lbl("iron-ingot", 9))); + recipe(lst(lbl("stone")), lst(lbl("furnace")), lst(lbl("cobblestone"))); + recipe(lst(lbl("mega-block")), lst(), lst(lbl("stone", 1000), lbl("iron-block", 14))); + request(lbl("mega-block")); + + assertInputs(lbl("cobblestone", 1000), lbl("iron-ingot", 126)); + assertExcessOutputs(); + assertCatalysts(lbl("furnace"), lbl("crafting-table")); + assertSteps(lbl("iron-block", 14), lbl("stone", 1000), lbl("mega-block")); + } + + @Test + void minimalInventory2() { + // Identical to minimalInventory1, except that the mega-block recipe requests iron-blocks before stone. The + // Calculator should choose to make stone before iron-blocks regardless + // of which order they are specified in the recipe. + recipe(lst(lbl("iron-block")), WORKBENCH, lst(lbl("iron-ingot", 9))); + recipe(lst(lbl("stone")), lst(lbl("furnace")), lst(lbl("cobblestone"))); + recipe(lst(lbl("mega-block")), lst(), lst(lbl("iron-block", 14), lbl("stone", 1000))); + request(lbl("mega-block")); + + assertInputs(lbl("iron-ingot", 126), lbl("cobblestone", 1000)); + assertExcessOutputs(); + assertCatalysts(lbl("crafting-table"), lbl("furnace")); + assertSteps(lbl("iron-block", 14), lbl("stone", 1000), lbl("mega-block")); + } + @Test void basicLoop() { recipe(lst(lbl("stone")), lst(lbl("furnace")), lst(lbl("cobblestone")));