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/gradlew b/gradlew old mode 100644 new mode 100755 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..cff15d0f9 --- /dev/null +++ b/src/main/java/me/towdium/jecalculation/data/structure/AbstractCostListService.java @@ -0,0 +1,456 @@ +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) { + 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() { + + 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) { + ProcedureStep procedureStep = new ProcedureStep(); + procedureStep.recipe = next.one; + procedureStep.multiplier = next.two; + CostListT original = getCurrent(); + List outL = d.getRecipeOutput(next.one) + .stream() + .filter(d::isNotEmptyLabel) + .collect(Collectors.toList()); + CostListT outC = newNegatedCostList(outL); + multiply(outC, -next.two); + procedureStep.multipliedRecipeOutputs = outC; + 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); + procedureStep.stillNeeded = result; + if (!set.contains(result)) { + set.add(result); + procedure.add(procedureStep); + 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 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. + { + List startingInventory = new ArrayList<>(givenInventory); + startingInventory.addAll(getInputs()); + + 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)) { + // Don't give the user an impossible plan. + continue; + } + 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; + } + + // 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, -pair.two); + retLabels.add( + costLists.getLabels(outC) + .get(0)); + } + return retLabels; + } + } + + // 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() { + 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).stillNeeded; + } + }; + }; + + 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; + } + + 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/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..7459e2f8b --- /dev/null +++ b/src/main/java/me/towdium/jecalculation/data/structure/Calculation.java @@ -0,0 +1,19 @@ +package me.towdium.jecalculation.data.structure; + +import java.util.Collections; +import java.util.List; + +public interface Calculation { + + List getCatalysts(); + + List getInputs(); + + List getOutputs(List ignore); + + default List getSteps() { + return getSteps(Collections.emptyList()); + } + + List getSteps(List startingInventory); +} 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..cdbaf1010 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); @@ -192,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/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; 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..6d055de57 --- /dev/null +++ b/src/test/java/me/towdium/jecalculation/data/structure/CostListServiceTest.java @@ -0,0 +1,164 @@ +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 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"))); + 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")); + } + + // 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); + } +} 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); + } +}