diff --git a/FactorioDataWrapper/pom.xml b/FactorioDataWrapper/pom.xml
index d6e9293..f91fc80 100644
--- a/FactorioDataWrapper/pom.xml
+++ b/FactorioDataWrapper/pom.xml
@@ -5,6 +5,7 @@
0.0.1-SNAPSHOT
src
+ test
lua
@@ -40,5 +41,11 @@
guava
21.0
+
+ junit
+ junit
+ 4.13.2
+ test
+
diff --git a/FactorioDataWrapper/src/com/demod/factorio/TotalRawCalculator.java b/FactorioDataWrapper/src/com/demod/factorio/TotalRawCalculator.java
index 1858fcc..ca91c56 100644
--- a/FactorioDataWrapper/src/com/demod/factorio/TotalRawCalculator.java
+++ b/FactorioDataWrapper/src/com/demod/factorio/TotalRawCalculator.java
@@ -1,48 +1,62 @@
package com.demod.factorio;
import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Optional;
+import java.util.Set;
import com.demod.factorio.prototype.RecipePrototype;
public class TotalRawCalculator {
public static final String RAW_TIME = "_TIME_";
private final Map recipes;
- private final Map> recipeTotalRaws = new LinkedHashMap<>();
public TotalRawCalculator(Map recipes) {
this.recipes = recipes;
}
public Map compute(RecipePrototype recipe) {
+ return compute(recipe, new LinkedHashSet<>());
+ }
+
+ private Map compute(RecipePrototype recipe, Set expandedRecipeNames) {
Map totalRaw = new LinkedHashMap<>();
- recipeTotalRaws.put(recipe.getName(), totalRaw);
totalRaw.put(TotalRawCalculator.RAW_TIME, recipe.getEnergyRequired());
+ expandedRecipeNames.add(recipe.getName());
- for (Entry entry : recipe.getInputs().entrySet()) {
- String input = entry.getKey();
- Optional findRecipe = recipes.values().stream()
- // XXX the nutrients-from-fish filter is here to match the bad Factorio behavior
- // of picking nutrients from biter eggs
- .filter(r -> !r.getName().equals("nutrients-from-fish")).filter(RecipePrototype::isHandCraftable)
- .filter(r -> r.getOutputs().keySet().stream().anyMatch(i -> {
- return i.equals(input);
- })).findFirst();
- if (findRecipe.isPresent()) {
- RecipePrototype inputRecipe = findRecipe.get();
- Map inputTotalRaw = compute(inputRecipe);
- Double inputRunYield = inputRecipe.getOutputs().get(input);
- double inputRunCount = entry.getValue() / inputRunYield;
- inputTotalRaw.forEach((k, v) -> {
- totalRaw.put(k, totalRaw.getOrDefault(k, 0.0) + v * inputRunCount);
- });
- } else {
- totalRaw.put(input, totalRaw.getOrDefault(input, 0.0) + entry.getValue());
+ try {
+ for (Entry entry : recipe.getInputs().entrySet()) {
+ String input = entry.getKey();
+ Optional findRecipe = findRecipe(input);
+ if (findRecipe.isPresent() && !expandedRecipeNames.contains(findRecipe.get().getName())) {
+ RecipePrototype inputRecipe = findRecipe.get();
+ Map inputTotalRaw = compute(inputRecipe, expandedRecipeNames);
+ Double inputRunYield = inputRecipe.getOutputs().get(input);
+ double inputRunCount = entry.getValue() / inputRunYield;
+ inputTotalRaw.forEach((k, v) -> {
+ totalRaw.put(k, totalRaw.getOrDefault(k, 0.0) + v * inputRunCount);
+ });
+ } else {
+ totalRaw.put(input, totalRaw.getOrDefault(input, 0.0) + entry.getValue());
+ }
}
+ } finally {
+ expandedRecipeNames.remove(recipe.getName());
}
return totalRaw;
}
-}
\ No newline at end of file
+
+ private Optional findRecipe(String input) {
+ return recipes.values().stream()
+ // Factorio's allow_decomposition controls whether a recipe is expanded for the tooltip's
+ // "Total raw" calculation:
+ // https://lua-api.factorio.com/latest/prototypes/RecipePrototype.html#allow_decomposition
+ .filter(RecipePrototype::isDecomposable)
+ .filter(RecipePrototype::isHandCraftable).filter(r -> !r.isRecycling())
+ .filter(r -> r.getOutputs().containsKey(input))
+ .max(RecipePrototype::compareTo);
+ }
+}
diff --git a/FactorioDataWrapper/src/com/demod/factorio/prototype/RecipePrototype.java b/FactorioDataWrapper/src/com/demod/factorio/prototype/RecipePrototype.java
index 08ed51c..8ad9444 100644
--- a/FactorioDataWrapper/src/com/demod/factorio/prototype/RecipePrototype.java
+++ b/FactorioDataWrapper/src/com/demod/factorio/prototype/RecipePrototype.java
@@ -14,6 +14,7 @@ public class RecipePrototype extends DataPrototype {
private final String category;
private final Map inputs = new LinkedHashMap<>();
private final Map outputs = new LinkedHashMap<>();
+ private final boolean decomposable;
private final double energyRequired;
private final boolean handCraftable;
private final boolean recycling;
@@ -47,6 +48,7 @@ public RecipePrototype(LuaTable lua) {
}
energyRequired = lua.get("energy_required").optdouble(0.5);
+ decomposable = lua.get("allow_decomposition").optboolean(true);
category = lua.get("category").optjstring("crafting");
// FIXME get these from the character prototype
handCraftable = category.equals("crafting") || category.equals("electronics") || category.equals("pressing")
@@ -59,6 +61,10 @@ public String getCategory() {
return category;
}
+ public boolean isDecomposable() {
+ return decomposable;
+ }
+
public double getEnergyRequired() {
return energyRequired;
}
diff --git a/FactorioDataWrapper/test/com/demod/factorio/TotalRawCalculatorTest.java b/FactorioDataWrapper/test/com/demod/factorio/TotalRawCalculatorTest.java
new file mode 100644
index 0000000..e86935f
--- /dev/null
+++ b/FactorioDataWrapper/test/com/demod/factorio/TotalRawCalculatorTest.java
@@ -0,0 +1,158 @@
+package com.demod.factorio;
+
+import static org.junit.Assert.assertEquals;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Optional;
+
+import org.json.JSONArray;
+import org.json.JSONObject;
+import org.junit.Test;
+
+import com.demod.factorio.fakelua.LuaTable;
+import com.demod.factorio.prototype.RecipePrototype;
+
+public class TotalRawCalculatorTest {
+
+ @Test
+ public void ignoresRecipesThatDisallowDecomposition() {
+ RecipePrototype root = recipe("root", "crafting", true, 1, map("intermediate", 2),
+ map("root", 1));
+ RecipePrototype recycling = recipe("intermediate-recycling", "recycling", false, 1,
+ map("intermediate", 1), map("intermediate", 1));
+ RecipePrototype intermediate = recipe("intermediate", "crafting", true, 3, map("ore", 4),
+ map("intermediate", 1));
+ Map recipes = recipes(root, recycling, intermediate);
+
+ Map totalRaw = new TotalRawCalculator(recipes).compute(root);
+
+ assertEquals(Map.of(TotalRawCalculator.RAW_TIME, 7.0, "ore", 8.0), totalRaw);
+ }
+
+ @Test
+ public void treatsIngredientsAsRawWhenTheirRecipesDisallowDecomposition() {
+ RecipePrototype root = recipe("root", "crafting", true, 2, map("asteroid-chunk", 3),
+ map("root", 1));
+ RecipePrototype crushing = recipe("asteroid-crushing", "crafting", false, 5,
+ map("asteroid-chunk", 1), map("asteroid-chunk", 1));
+ Map recipes = recipes(root, crushing);
+
+ Map totalRaw = new TotalRawCalculator(recipes).compute(root);
+
+ assertEquals(Map.of(TotalRawCalculator.RAW_TIME, 2.0, "asteroid-chunk", 3.0), totalRaw);
+ }
+
+ @Test
+ public void treatsIngredientsAsRawWhenTheirRecipesAreNotHandCraftable() {
+ RecipePrototype root = recipe("root", "crafting", true, 2, map("intermediate", 3), map("root", 1));
+ RecipePrototype machineRecipe = recipe("intermediate", "metallurgy", true, 5, map("ore", 4),
+ map("intermediate", 1));
+ Map recipes = recipes(root, machineRecipe);
+
+ Map totalRaw = new TotalRawCalculator(recipes).compute(root);
+
+ assertEquals(Map.of(TotalRawCalculator.RAW_TIME, 2.0, "intermediate", 3.0), totalRaw);
+ }
+
+ @Test
+ public void ignoresRecyclingRecipes() {
+ RecipePrototype root = recipe("root", "crafting", true, 1, map("intermediate", 2), map("root", 1));
+ RecipePrototype recycling = recipe("intermediate-recycling", "recycling", true, 1,
+ map("intermediate", 1), map("intermediate", 1));
+ RecipePrototype intermediate = recipe("intermediate", "crafting", true, 3, map("ore", 4),
+ map("intermediate", 1));
+ Map recipes = recipes(root, recycling, intermediate);
+
+ Map totalRaw = new TotalRawCalculator(recipes).compute(root);
+
+ assertEquals(Map.of(TotalRawCalculator.RAW_TIME, 7.0, "ore", 8.0), totalRaw);
+ }
+
+ @Test
+ public void treatsTheRepeatedIngredientAsRawWhenRecipesFormACycle() {
+ RecipePrototype root = recipe("root", "crafting", true, 1, map("first", 1), map("root", 1));
+ RecipePrototype first = recipe("first", "crafting", true, 2, map("second", 1), map("first", 1));
+ RecipePrototype second = recipe("second", "crafting", true, 3, map("first", 1), map("second", 1));
+ Map recipes = recipes(root, first, second);
+
+ Map totalRaw = new TotalRawCalculator(recipes).compute(root);
+
+ assertEquals(Map.of(TotalRawCalculator.RAW_TIME, 6.0, "first", 1.0), totalRaw);
+ }
+
+ @Test
+ public void decomposesNutrientsUsingTheLastEligibleRecipeInPrototypeOrder() {
+ RecipePrototype root = recipe("root", "crafting", true, 1, map("nutrients", 100), map("root", 1));
+ RecipePrototype fish = recipe("nutrients-from-fish", "c[nutrients-from-fish]", "crafting", true, 2,
+ map("raw-fish", 1), map("nutrients", 20));
+ RecipePrototype biterEgg = recipe("nutrients-from-biter-egg", "d[nutrients-from-biter-egg]", "crafting", true,
+ 2, map("biter-egg", 1), map("nutrients", 20));
+ RecipePrototype biterEggProduction = recipe("biter-egg", "captive-spawner-process", true, 10, Map.of(),
+ map("biter-egg", 5));
+ Map recipes = recipes(root, biterEgg, fish, biterEggProduction);
+
+ Map totalRaw = new TotalRawCalculator(recipes).compute(root);
+
+ assertEquals(Map.of(TotalRawCalculator.RAW_TIME, 11.0, "biter-egg", 5.0), totalRaw);
+ }
+
+ @Test
+ public void decomposesNutrientsThroughFishWhenThatIsTheOnlyEligibleRecipe() {
+ RecipePrototype root = recipe("root", "crafting", true, 1, map("nutrients", 20), map("root", 1));
+ RecipePrototype fish = recipe("nutrients-from-fish", "crafting", true, 2, map("raw-fish", 1),
+ map("nutrients", 20));
+ RecipePrototype bioflux = recipe("nutrients-from-bioflux", "organic", true, 3, map("bioflux", 5),
+ map("nutrients", 20));
+ Map recipes = recipes(root, fish, bioflux);
+
+ Map totalRaw = new TotalRawCalculator(recipes).compute(root);
+
+ assertEquals(Map.of(TotalRawCalculator.RAW_TIME, 3.0, "raw-fish", 1.0), totalRaw);
+ }
+
+ private static Map map(String name, int amount) {
+ return Map.of(name, amount);
+ }
+
+ private static Map recipes(RecipePrototype... recipes) {
+ Map result = new LinkedHashMap<>();
+ for (RecipePrototype recipe : recipes) {
+ result.put(recipe.getName(), recipe);
+ }
+ return result;
+ }
+
+ private static RecipePrototype recipe(String name, String category, boolean decomposable, double energyRequired,
+ Map inputs, Map outputs) {
+ return recipe(name, name, category, decomposable, energyRequired, inputs, outputs);
+ }
+
+ private static RecipePrototype recipe(String name, String order, String category, boolean decomposable,
+ double energyRequired, Map inputs, Map outputs) {
+ JSONObject json = new JSONObject();
+ json.put("type", "recipe");
+ json.put("name", name);
+ json.put("order", order);
+ json.put("category", category);
+ json.put("allow_decomposition", decomposable);
+ json.put("energy_required", energyRequired);
+ json.put("ingredients", ingredients(inputs));
+ json.put("results", results(outputs));
+ RecipePrototype recipe = new RecipePrototype(new LuaTable(json));
+ recipe.setGroup(Optional.empty());
+ return recipe;
+ }
+
+ private static JSONArray ingredients(Map inputs) {
+ JSONArray ingredients = new JSONArray();
+ inputs.forEach((name, amount) -> ingredients.put(new JSONObject().put("name", name).put("amount", amount)));
+ return ingredients;
+ }
+
+ private static JSONArray results(Map outputs) {
+ JSONArray results = new JSONArray();
+ outputs.forEach((name, amount) -> results.put(new JSONObject().put("name", name).put("amount", amount)));
+ return results;
+ }
+}