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; + } +}