diff --git a/Yafc.Model.Tests/Model/PercentageIngredientConsumptionTests.cs b/Yafc.Model.Tests/Model/PercentageIngredientConsumptionTests.cs new file mode 100644 index 00000000..68687640 --- /dev/null +++ b/Yafc.Model.Tests/Model/PercentageIngredientConsumptionTests.cs @@ -0,0 +1,261 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Xunit; + +namespace Yafc.Model.Tests.Model; + +[Collection("LuaDependentTests")] +public class PercentageIngredientConsumptionTests { + + [Fact] + public async Task SetIngredientConsumptionPercentage_SingleRecipe_ShouldApplyPercentageCorrectly() { + Project project = LuaDependentTestHelper.GetProjectForLua("Yafc.Model.Tests.Model.ProductionTableContentTests.lua"); + ProjectPage page = new ProjectPage(project, typeof(ProductionTable)); + ProductionTable table = (ProductionTable)page.content; + + // Add a recipe that consumes ingredients + var recipe = Database.recipes.all.First(r => r.ingredients.Length > 0); + table.AddRecipe(recipe.With(Quality.Normal), DataUtils.DeterministicComparer); + RecipeRow row = table.GetAllRecipes().Single(); + + // Get the first ingredient with quality + var ingredient = recipe.ingredients[0].goods.With(Quality.Normal); + + // Set 50% consumption for the ingredient + row.ingredientConsumptionPercentages[ingredient] = 0.5f; + + await table.Solve(page); + + // Verify the percentage is stored correctly + Assert.True(row.ingredientConsumptionPercentages.ContainsKey(ingredient)); + Assert.Equal(0.5f, row.ingredientConsumptionPercentages[ingredient], 0.001f); + + // Verify the ingredient consumption is reduced by 50% + var originalAmount = recipe.ingredients[0].amount; + var actualIngredient = row.Ingredients.First(i => i.Goods == ingredient); + var expectedAmount = originalAmount * 0.5f * row.recipesPerSecond; + Assert.Equal(expectedAmount, actualIngredient.Amount, expectedAmount * 0.001f); + } + + [Fact] + public async Task ClearIngredientConsumptionPercentage_ShouldRemoveConstraint() { + Project project = LuaDependentTestHelper.GetProjectForLua("Yafc.Model.Tests.Model.ProductionTableContentTests.lua"); + ProjectPage page = new ProjectPage(project, typeof(ProductionTable)); + ProductionTable table = (ProductionTable)page.content; + + var recipe = Database.recipes.all.First(r => r.ingredients.Length > 0); + table.AddRecipe(recipe.With(Quality.Normal), DataUtils.DeterministicComparer); + RecipeRow row = table.GetAllRecipes().Single(); + + var ingredient = recipe.ingredients[0].goods.With(Quality.Normal); + + // Set percentage + row.ingredientConsumptionPercentages[ingredient] = 0.5f; + await table.Solve(page); + + // Verify it's set + Assert.True(row.ingredientConsumptionPercentages.ContainsKey(ingredient)); + + // Clear percentage + _ = row.ingredientConsumptionPercentages.Remove(ingredient); + await table.Solve(page); + + // Verify it's removed + Assert.False(row.ingredientConsumptionPercentages.ContainsKey(ingredient)); + + // Verify consumption returns to normal + var originalAmount = recipe.ingredients[0].amount; + var actualIngredient = row.Ingredients.First(i => i.Goods == ingredient); + var expectedAmount = originalAmount * row.recipesPerSecond; + Assert.Equal(expectedAmount, actualIngredient.Amount, expectedAmount * 0.001f); + } + + [Fact] + public async Task AutomaticAlgorithmSwitching_SingleRecipeWithPercentage_ShouldUseAllowOverProduction() { + Project project = LuaDependentTestHelper.GetProjectForLua("Yafc.Model.Tests.Model.ProductionTableContentTests.lua"); + ProjectPage page = new ProjectPage(project, typeof(ProductionTable)); + ProductionTable table = (ProductionTable)page.content; + + var recipe = Database.recipes.all.First(r => r.ingredients.Length > 0); + table.AddRecipe(recipe.With(Quality.Normal), DataUtils.DeterministicComparer); + RecipeRow row = table.GetAllRecipes().Single(); + + var ingredient = recipe.ingredients[0].goods.With(Quality.Normal); + + // Set percentage constraint + row.ingredientConsumptionPercentages[ingredient] = 0.8f; + await table.Solve(page); + + // Verify algorithm automatically switched to AllowOverProduction for single recipe + if (table.linkMap.TryGetValue(ingredient, out var link)) { + Assert.Equal(LinkAlgorithm.AllowOverProduction, link.algorithm); + } + } + + [Fact] + public void IngredientConsumptionPercentages_ShouldBeDictionary() { + Project project = LuaDependentTestHelper.GetProjectForLua("Yafc.Model.Tests.Model.ProductionTableContentTests.lua"); + ProjectPage page = new ProjectPage(project, typeof(ProductionTable)); + ProductionTable table = (ProductionTable)page.content; + + var recipe = Database.recipes.all.First(r => r.ingredients.Length > 0); + table.AddRecipe(recipe.With(Quality.Normal), DataUtils.DeterministicComparer); + RecipeRow row = table.GetAllRecipes().Single(); + + // Verify the property exists and is a dictionary + var percentages = row.ingredientConsumptionPercentages; + Assert.NotNull(percentages); + + // Verify it's a dictionary with the correct key type + Assert.IsAssignableFrom, float>>(percentages); + } + + [Fact] + public void SerializationDeserialization_ShouldPreservePercentages() { + Project project = LuaDependentTestHelper.GetProjectForLua("Yafc.Model.Tests.Model.ProductionTableContentTests.lua"); + ProjectPage page = new ProjectPage(project, typeof(ProductionTable)); + project.pages.Add(page); + ProductionTable table = (ProductionTable)page.content; + + var recipe = Database.recipes.all.First(r => r.ingredients.Length > 0); + table.AddRecipe(recipe.With(Quality.Normal), DataUtils.DeterministicComparer); + RecipeRow row = table.GetAllRecipes().Single(); + + var ingredient = recipe.ingredients[0].goods.With(Quality.Normal); + row.ingredientConsumptionPercentages[ingredient] = 0.75f; + + // Serialize + ErrorCollector collector = new(); + using MemoryStream stream = new(); + project.Save(stream); + + // Deserialize + Project newProject = Project.Read(stream.ToArray(), collector); + + Assert.Equal(ErrorSeverity.None, collector.severity); + + // Verify percentage is preserved + var newTable = (ProductionTable)newProject.pages[0].content; + var newRow = newTable.GetAllRecipes().Single(); + + Assert.True(newRow.ingredientConsumptionPercentages.ContainsKey(ingredient)); + Assert.Equal(0.75f, newRow.ingredientConsumptionPercentages[ingredient], 0.001f); + } + + [Theory] + [InlineData(0.0f)] + [InlineData(0.1f)] + [InlineData(0.5f)] + [InlineData(0.9f)] + [InlineData(1.0f)] + public async Task VariousPercentageValues_ShouldWorkCorrectly(float percentage) { + Project project = LuaDependentTestHelper.GetProjectForLua("Yafc.Model.Tests.Model.ProductionTableContentTests.lua"); + ProjectPage page = new ProjectPage(project, typeof(ProductionTable)); + ProductionTable table = (ProductionTable)page.content; + + var recipe = Database.recipes.all.First(r => r.ingredients.Length > 0); + table.AddRecipe(recipe.With(Quality.Normal), DataUtils.DeterministicComparer); + RecipeRow row = table.GetAllRecipes().Single(); + + var ingredient = recipe.ingredients[0].goods.With(Quality.Normal); + + row.ingredientConsumptionPercentages[ingredient] = percentage; + await table.Solve(page); + + // Verify the percentage is applied correctly + var originalAmount = recipe.ingredients[0].amount; + var actualIngredient = row.Ingredients.First(i => i.Goods == ingredient); + var expectedAmount = originalAmount * percentage * row.recipesPerSecond; + + if (percentage == 0.0f) { + // Special case: 0% should result in no consumption + Assert.Equal(0.0f, actualIngredient.Amount, 0.001f); + } + else { + Assert.Equal(expectedAmount, actualIngredient.Amount, Math.Max(expectedAmount * 0.001f, 0.001f)); + } + } + + [Fact] + public async Task BuildIngredients_WithPercentage_ShouldApplyMultiplier() { + Project project = LuaDependentTestHelper.GetProjectForLua("Yafc.Model.Tests.Model.ProductionTableContentTests.lua"); + ProjectPage page = new ProjectPage(project, typeof(ProductionTable)); + ProductionTable table = (ProductionTable)page.content; + + // Find a recipe with multiple ingredients + var recipe = Database.recipes.all.FirstOrDefault(r => r.ingredients.Length > 1); + if (recipe == null) { + // Skip test if no multi-ingredient recipes available + return; + } + + table.AddRecipe(recipe.With(Quality.Normal), DataUtils.DeterministicComparer); + RecipeRow row = table.GetAllRecipes().Single(); + + var ingredient1 = recipe.ingredients[0].goods.With(Quality.Normal); + var ingredient2 = recipe.ingredients[1].goods.With(Quality.Normal); + + // Set percentage only on first ingredient + row.ingredientConsumptionPercentages[ingredient1] = 0.25f; + + await table.Solve(page); + + // Verify first ingredient has reduced consumption + var actualIngredient1 = row.Ingredients.First(i => i.Goods == ingredient1); + var expectedAmount1 = recipe.ingredients[0].amount * 0.25f * row.recipesPerSecond; + Assert.Equal(expectedAmount1, actualIngredient1.Amount, expectedAmount1 * 0.001f); + + // Verify second ingredient has normal consumption + var actualIngredient2 = row.Ingredients.First(i => i.Goods == ingredient2); + var expectedAmount2 = recipe.ingredients[1].amount * row.recipesPerSecond; + Assert.Equal(expectedAmount2, actualIngredient2.Amount, expectedAmount2 * 0.001f); + } + + [Fact] + public async Task AutomaticRecalculation_WhenSecondRecipeAdded_ShouldTriggerSolverUpdate() { + Project project = LuaDependentTestHelper.GetProjectForLua("Yafc.Model.Tests.Model.ProductionTableContentTests.lua"); + ProjectPage page = new ProjectPage(project, typeof(ProductionTable)); + ProductionTable table = (ProductionTable)page.content; + + // Add first recipe with percentage constraint + var recipe1 = Database.recipes.all.First(r => r.ingredients.Length > 0); + table.AddRecipe(recipe1.With(Quality.Normal), DataUtils.DeterministicComparer); + RecipeRow row1 = table.GetAllRecipes().Single(); + + var sharedIngredient = recipe1.ingredients[0].goods.With(Quality.Normal); + row1.ingredientConsumptionPercentages[sharedIngredient] = 0.5f; + + // Solve to establish initial state + await table.Solve(page); + + // Verify single recipe uses AllowOverProduction + if (table.linkMap.TryGetValue(sharedIngredient, out var initialLink)) { + Assert.Equal(LinkAlgorithm.AllowOverProduction, initialLink.algorithm); + } + + // Add second recipe that shares the same ingredient + var recipe2 = Database.recipes.all.First(r => r != recipe1 && + r.ingredients.Any(i => i.goods.With(Quality.Normal).Equals(sharedIngredient))); + + if (recipe2 != null) { + table.AddRecipe(recipe2.With(Quality.Normal), DataUtils.DeterministicComparer); + var rows = table.GetAllRecipes().ToList(); + Assert.Equal(2, rows.Count); + + RecipeRow row2 = rows.First(r => r != row1); + row2.ingredientConsumptionPercentages[sharedIngredient] = 0.3f; + + // The AddRecipe call should have triggered SetToRecalculate() + // When we solve now, it should automatically switch to Match algorithm + await table.Solve(page); + + // Verify algorithm switched to Match for multiple recipes + if (table.linkMap.TryGetValue(sharedIngredient, out var updatedLink)) { + Assert.Equal(LinkAlgorithm.Match, updatedLink.algorithm); + } + } + } +} \ No newline at end of file diff --git a/Yafc.Model.Tests/Model/PercentageIngredientConsumptionTests.lua b/Yafc.Model.Tests/Model/PercentageIngredientConsumptionTests.lua new file mode 100644 index 00000000..0b4c0d9b --- /dev/null +++ b/Yafc.Model.Tests/Model/PercentageIngredientConsumptionTests.lua @@ -0,0 +1,124 @@ +data = { + raw = { + item = { + ["iron-ore"] = { + type = "item", + name = "iron-ore", + icon = "__base__/graphics/icons/iron-ore.png", + icon_size = 64, + subgroup = "raw-resource", + order = "e[iron-ore]", + stack_size = 50 + }, + ["iron-plate"] = { + type = "item", + name = "iron-plate", + icon = "__base__/graphics/icons/iron-plate.png", + icon_size = 64, + subgroup = "raw-material", + order = "b[iron-plate]", + stack_size = 100 + }, + ["copper-ore"] = { + type = "item", + name = "copper-ore", + icon = "__base__/graphics/icons/copper-ore.png", + icon_size = 64, + subgroup = "raw-resource", + order = "f[copper-ore]", + stack_size = 50 + }, + ["copper-plate"] = { + type = "item", + name = "copper-plate", + icon = "__base__/graphics/icons/copper-plate.png", + icon_size = 64, + subgroup = "raw-material", + order = "c[copper-plate]", + stack_size = 100 + }, + ["steel-plate"] = { + type = "item", + name = "steel-plate", + icon = "__base__/graphics/icons/steel-plate.png", + icon_size = 64, + subgroup = "raw-material", + order = "d[steel-plate]", + stack_size = 100 + }, + coal = { + type = "item", + name = "coal", + icon = "__base__/graphics/icons/coal.png", + icon_size = 64, + fuel_category = "chemical", + fuel_value = "4MJ", + subgroup = "raw-resource", + order = "b[coal]", + stack_size = 50 + } + }, + recipe = { + ["iron-plate"] = { + type = "recipe", + name = "iron-plate", + category = "smelting", + energy_required = 3.2, + ingredients = {{"iron-ore", 1}}, + result = "iron-plate" + }, + ["copper-plate"] = { + type = "recipe", + name = "copper-plate", + category = "smelting", + energy_required = 3.2, + ingredients = {{"copper-ore", 1}}, + result = "copper-plate" + }, + ["steel-plate"] = { + type = "recipe", + name = "steel-plate", + category = "smelting", + energy_required = 16, + ingredients = {{"iron-plate", 5}}, + result = "steel-plate" + }, + ["mixed-recipe"] = { + type = "recipe", + name = "mixed-recipe", + category = "crafting", + energy_required = 5, + ingredients = { + {"iron-plate", 2}, + {"copper-plate", 1} + }, + result = "steel-plate" + } + }, + furnace = { + ["stone-furnace"] = { + type = "furnace", + name = "stone-furnace", + icon = "__base__/graphics/icons/stone-furnace.png", + icon_size = 64, + flags = {"placeable-neutral", "placeable-player", "player-creation"}, + minable = {mining_time = 0.2, result = "stone-furnace"}, + max_health = 200, + collision_box = {{-0.7, -0.7}, {0.7, 0.7}}, + selection_box = {{-0.8, -0.8}, {0.8, 0.8}}, + crafting_categories = {"smelting"}, + result_inventory_size = 1, + energy_usage = "90kW", + crafting_speed = 1, + source_inventory_size = 1, + energy_source = { + type = "burner", + fuel_category = "chemical", + effectivity = 1, + fuel_inventory_size = 1, + emissions_per_minute = 2 + } + } + } + } +} \ No newline at end of file diff --git a/Yafc.Model.Tests/Serialization/SerializationTreeChangeDetection.cs b/Yafc.Model.Tests/Serialization/SerializationTreeChangeDetection.cs index e538244e..05bf4acf 100644 --- a/Yafc.Model.Tests/Serialization/SerializationTreeChangeDetection.cs +++ b/Yafc.Model.Tests/Serialization/SerializationTreeChangeDetection.cs @@ -68,6 +68,7 @@ public class SerializationTreeChangeDetection { [nameof(RecipeRow.showTotalIO)] = typeof(bool), [nameof(RecipeRow.enabled)] = typeof(bool), [nameof(RecipeRow.tag)] = typeof(int), + [nameof(RecipeRow.ingredientConsumptionPercentages)] = typeof(Dictionary, float>), [nameof(RecipeRow.modules)] = typeof(ModuleTemplate), [nameof(RecipeRow.subgroup)] = typeof(ProductionTable), [nameof(RecipeRow.variants)] = typeof(HashSet), @@ -76,6 +77,7 @@ public class SerializationTreeChangeDetection { [nameof(ProductionLink.goods)] = typeof(IObjectWithQuality), [nameof(ProductionLink.amount)] = typeof(float), [nameof(ProductionLink.algorithm)] = typeof(LinkAlgorithm), + [nameof(ProductionLink.splitPercentage)] = typeof(float?), }, [typeof(Project)] = new() { [nameof(Project.settings)] = typeof(ProjectSettings), diff --git a/Yafc.Model.Tests/Yafc.Model.Tests.csproj b/Yafc.Model.Tests/Yafc.Model.Tests.csproj index 5af98338..eca85f6c 100644 --- a/Yafc.Model.Tests/Yafc.Model.Tests.csproj +++ b/Yafc.Model.Tests/Yafc.Model.Tests.csproj @@ -20,15 +20,18 @@ - + PreserveNewest %(Filename)%(Extension) - + PreserveNewest %(Filename)%(Extension) - + PreserveNewest %(Filename)%(Extension) @@ -42,8 +45,10 @@ - + + - + \ No newline at end of file diff --git a/Yafc.Model/Model/ProductionTable.cs b/Yafc.Model/Model/ProductionTable.cs index ce650fd6..f35fc903 100644 --- a/Yafc.Model/Model/ProductionTable.cs +++ b/Yafc.Model/Model/ProductionTable.cs @@ -292,6 +292,13 @@ public void AddRecipe(IObjectWithQuality recipe, IComparer? selectedFuel) => @@ -458,6 +465,7 @@ private static void AddLinkCoefficient(Constraint cst, Variable var, IProduction var link = allLinks[i]; float min = link.algorithm == LinkAlgorithm.AllowOverConsumption ? float.NegativeInfinity : link.amount; float max = link.algorithm == LinkAlgorithm.AllowOverProduction ? float.PositiveInfinity : link.amount; + var constraint = productionTableSolver.MakeConstraint(min, max, link.goods.QualityName() + "_recipe"); constraints[i] = constraint; link.solverIndex = i; @@ -491,7 +499,10 @@ private static void AddLinkCoefficient(Constraint cst, Variable var, IProduction foreach (var ingredient in recipe.IngredientsForSolver) { if (recipe.FindLink(ingredient.Goods, out var link)) { link.flags |= ProductionLink.Flags.HasConsumption; - AddLinkCoefficient(constraints[link.solverIndex], recipeVar, link, recipe, -ingredient.Amount); + float ingredientAmount = -ingredient.Amount; + + + AddLinkCoefficient(constraints[link.solverIndex], recipeVar, link, recipe, ingredientAmount); } links.ingredients[ingredient.LinkIndex] = link as ProductionLink; @@ -539,6 +550,137 @@ private static void AddLinkCoefficient(Constraint cst, Variable var, IProduction objective.SetCoefficient(vars[i], allRecipes[i].BaseCost); } + // Add percentage-based ingredient consumption constraints BEFORE solving + // Group recipes by shared ingredients that have percentage constraints + var ingredientGroups = new Dictionary, List<(IRecipeRow recipe, float percentage)>>(); + + foreach (var recipe in allRecipes) { + if (recipe.RecipeRow?.ingredientConsumptionPercentages != null) { + foreach (var (goods, percentage) in recipe.RecipeRow.ingredientConsumptionPercentages) { + if (!ingredientGroups.ContainsKey(goods)) { + ingredientGroups[goods] = []; + } + ingredientGroups[goods].Add((recipe, percentage)); + } + } + } + + // For each ingredient that has percentage constraints, create proportional constraints + foreach (var (goods, recipesWithPercentages) in ingredientGroups) { + + // Find the link for this goods to manage overproduction setting + var link = allLinks.FirstOrDefault(l => l.goods == goods); + + if (recipesWithPercentages.Count > 1) { + // Multiple recipes with percentage constraints - revert to normal algorithm + // since proportional constraints will handle the distribution properly + if (link is ProductionLink productionLink) { + productionLink.algorithm = LinkAlgorithm.Match; + } + // Multiple recipes share this ingredient with percentage constraints + // Create proportional constraints between them + var firstRecipe = recipesWithPercentages[0]; + var firstIngredient = firstRecipe.recipe.IngredientsForSolver.FirstOrDefault(i => i.Goods == goods); + + if (firstIngredient != null) { + float firstIngredientAmount = (float)Math.Abs(firstIngredient.Amount); + + for (int i = 1; i < recipesWithPercentages.Count; i++) { + var otherRecipe = recipesWithPercentages[i]; + var otherIngredient = otherRecipe.recipe.IngredientsForSolver.FirstOrDefault(ing => ing.Goods == goods); + + if (otherIngredient != null) { + float otherIngredientAmount = (float)Math.Abs(otherIngredient.Amount); + + // Create proportional constraint: + // firstRecipe_var * firstAmount * otherPercentage = otherRecipe_var * otherAmount * firstPercentage + // Rearranged: firstRecipe_var * firstAmount * otherPercentage - otherRecipe_var * otherAmount * firstPercentage = 0 + var proportionConstraint = productionTableSolver.MakeConstraint(0, 0, + $"proportion_{goods.target.name}_{firstRecipe.recipe.GetType().Name}_{otherRecipe.recipe.GetType().Name}"); + + float coeff1 = firstIngredientAmount * otherRecipe.percentage; + float coeff2 = -otherIngredientAmount * firstRecipe.percentage; + + proportionConstraint.SetCoefficient(vars[allRecipes.IndexOf(firstRecipe.recipe)], coeff1); + proportionConstraint.SetCoefficient(vars[allRecipes.IndexOf(otherRecipe.recipe)], coeff2); + + } + } + } + } + else if (recipesWithPercentages.Count == 1) { + // Single recipe with percentage constraint - create consumption limit relative to production + var recipe = recipesWithPercentages[0]; + var ingredient = recipe.recipe.IngredientsForSolver.FirstOrDefault(i => i.Goods == goods); + + if (ingredient != null) { + float ingredientAmountPerRecipe = (float)Math.Abs(ingredient.Amount); + + // Find the link for this goods and change its algorithm to allow over-production + if (link is ProductionLink singleRecipeProductionLink) { + // Change the link algorithm to allow over-production so surplus shows in flow + singleRecipeProductionLink.algorithm = LinkAlgorithm.AllowOverProduction; + } + + // NEW APPROACH: Force the recipe to run by creating a minimum consumption constraint + // This ensures the recipe runs at least enough to consume some of the ingredient + + int consumingRecipeIndex = allRecipes.IndexOf(recipe.recipe); + + // First, modify the link constraint to allow surplus + var targetLink = allLinks.FirstOrDefault(l => l.goods == goods); + if (targetLink != null) { + var constraint = constraints[targetLink.solverIndex]; + if (constraint != null) { + // Change constraint bounds to allow surplus production + constraint.SetBounds(0, double.PositiveInfinity); + } + } + + // Create a constraint that limits consumption to a percentage of total production + // We'll create a constraint that relates consumption directly to production variables + + // Find all recipes that produce this goods + // In YAFC, production is represented as positive amounts in ProductsForSolver, not IngredientsForSolver + var producingRecipes = new List<(IRecipeRow recipe, float productionPerRecipe, int index)>(); + foreach (var prodRecipe in allRecipes) { + var prodProduct = prodRecipe.ProductsForSolver.FirstOrDefault(p => p.Goods == goods); + if (prodProduct != null && prodProduct.Amount > 0) { + int prodRecipeIndex = allRecipes.IndexOf(prodRecipe); + float productionPerRecipe = prodProduct.Amount; + producingRecipes.Add((prodRecipe, productionPerRecipe, prodRecipeIndex)); + + } + } + + if (producingRecipes.Count > 0) { + // Create constraint: consumption = percentage * total_production + // consumption_recipe * consumption_per_recipe = percentage * (sum of production_recipe * production_per_recipe) + // Rearranged: consumption_recipe * consumption_per_recipe - percentage * (sum of production_recipe * production_per_recipe) = 0 + + var percentageConstraint = productionTableSolver.MakeConstraint(0, 0, + $"percentage_exact_{recipe.recipe.SolverName}_{goods.target.name}"); + + // Add consumption term (positive coefficient) + percentageConstraint.SetCoefficient(vars[consumingRecipeIndex], ingredientAmountPerRecipe); + + // Add production terms (negative coefficients scaled by percentage) + foreach (var (prodRecipe, productionPerRecipe, prodIndex) in producingRecipes) { + float prodCoeff = -productionPerRecipe * recipe.percentage; + percentageConstraint.SetCoefficient(vars[prodIndex], prodCoeff); + + } + + } + + // Instead of minimum constraint, add to objective to encourage consumption + // This will make the solver want to run the recipe more (negative cost = benefit) + objective.SetCoefficient(vars[consumingRecipeIndex], -0.001f); + + } + } + } + var result = productionTableSolver.Solve(); if (result is not Solver.ResultStatus.FEASIBLE and not Solver.ResultStatus.OPTIMAL) { @@ -569,8 +711,6 @@ private static void AddLinkCoefficient(Constraint cst, Variable var, IProduction result = productionTableSolver.Solve(); - logger.Information("Solver finished with result {result}", result); - await Ui.EnterMainThread(); if (result is Solver.ResultStatus.OPTIMAL or Solver.ResultStatus.FEASIBLE) { List linkList = []; @@ -655,7 +795,8 @@ private static void AddLinkCoefficient(Constraint cst, Variable var, IProduction for (int i = 0; i < allRecipes.Count; i++) { var recipe = allRecipes[i]; - recipe.recipesPerSecond = vars[i].SolutionValue(); + double solutionValue = vars[i].SolutionValue(); + recipe.recipesPerSecond = solutionValue; } bool builtCountExceeded = CheckBuiltCountExceeded(); diff --git a/Yafc.Model/Model/ProductionTableContent.cs b/Yafc.Model/Model/ProductionTableContent.cs index 1e0095ae..37231bca 100644 --- a/Yafc.Model/Model/ProductionTableContent.cs +++ b/Yafc.Model/Model/ProductionTableContent.cs @@ -396,6 +396,11 @@ public float fixedBuildings { fixedIngredient = null; fixedProduct = null; } + else { + // Clear percentage constraints when setting fixed buildings to non-zero + // These two mechanisms conflict with each other + ingredientConsumptionPercentages.Clear(); + } } } /// @@ -444,6 +449,7 @@ public IObjectWithQuality? fixedProduct { /// public bool hierarchyEnabled { get; internal set; } public int tag { get; set; } + public Dictionary, float> ingredientConsumptionPercentages { get; } = []; public RowHighlighting highlighting => tag switch { @@ -500,7 +506,15 @@ private IEnumerable BuildIngredients(bool forSolver) { for (int i = 0; i < recipe.target.ingredients.Length; i++) { Ingredient ingredient = recipe.target.ingredients[i]; IObjectWithQuality option = (ingredient.variants == null ? ingredient.goods : GetVariant(ingredient.variants)).With(recipe.quality); - yield return (option, ingredient.amount * factor, links.ingredients[i], i, ingredient.variants); + + float amount = ingredient.amount; + + // Apply percentage-based consumption if configured + if (ingredientConsumptionPercentages.TryGetValue(option, out float percentage)) { + amount *= percentage; + } + + yield return (option, amount * factor, links.ingredients[i], i, ingredient.variants); } } @@ -872,6 +886,7 @@ public enum Flags { /// public HashSet capturedRecipes { get; } = []; internal int solverIndex; + public float? splitPercentage { get; set; } // To avoid leaking these variables/methods (or just the setter, for recipesPerSecond) into public context, // these explicit interface implementations connect to internal members, instead of using implicit implementation via public members diff --git a/Yafc/Workspace/ProductionTable/ProductionLinkSummaryScreen.cs b/Yafc/Workspace/ProductionTable/ProductionLinkSummaryScreen.cs index 0ca8007d..10479f3e 100644 --- a/Yafc/Workspace/ProductionTable/ProductionLinkSummaryScreen.cs +++ b/Yafc/Workspace/ProductionTable/ProductionLinkSummaryScreen.cs @@ -42,6 +42,33 @@ private void BuildScrollArea(ImGui gui) { gui.BuildText((link.amount > 0 ? LSs.LinkSummaryRequestedProduction : LSs.LinkSummaryRequestedConsumption).L(DataUtils.FormatAmount(MathF.Abs(link.amount), link.flowUnitOfMeasure)), new TextBlockDisplayStyle(Font.subheader, Color: SchemeColor.GreenAlt)); } + + // Add percentage splitting UI + gui.spacing = 0.5f; + using (gui.EnterRow(0.4f)) { + gui.BuildText("Split Percentage:", Font.text); + gui.AllocateSpacing(0.8f); + + DisplayAmount amount = new(link.splitPercentage ?? 0f, UnitOfMeasure.Percent); + if (gui.BuildFloatInput(amount, TextBoxDisplayStyle.DefaultTextInput)) { + if (amount.Value > 100f) { + link.RecordUndo().splitPercentage = 1f; // Cap at 100% + } + else { + link.RecordUndo().splitPercentage = amount.Value; + } + } + + if (gui.BuildButton("Clear") && link.splitPercentage.HasValue) { + link.RecordUndo().splitPercentage = null; + } + } + + if (link.splitPercentage.HasValue) { + gui.spacing = 0.25f; + gui.BuildText($"This link will receive {link.splitPercentage.Value:F1}% of the total {link.goods.target.locName} flow", + new TextBlockDisplayStyle(Font.text, Color: SchemeColor.Secondary)); + } if (link.flags.HasFlags(ProductionLink.Flags.LinkNotMatched) && totalInput != totalOutput + link.amount) { float amount = totalInput - totalOutput - link.amount; gui.spacing = 0.5f; @@ -120,7 +147,7 @@ private void ShowRelatedLinks(ImGui gui) { return table as ProductionTable; } - private bool isNotRelatedToCurrentLink(RecipeRow? row) => (!row.Ingredients.Any(e => e.Goods == link.goods) + private bool isNotRelatedToCurrentLink(RecipeRow? row) => row == null || (!row.Ingredients.Any(e => e.Goods == link.goods) && !row.Products.Any(e => e.Goods == link.goods) && !(row.fuel is not null && row.fuel == link.goods)); private bool isPartOfCurrentLink(RecipeRow row) => link.capturedRecipes.Any(e => e == row); diff --git a/Yafc/Workspace/ProductionTable/ProductionTableView.cs b/Yafc/Workspace/ProductionTable/ProductionTableView.cs index 64666029..1a15f9f4 100644 --- a/Yafc/Workspace/ProductionTable/ProductionTableView.cs +++ b/Yafc/Workspace/ProductionTable/ProductionTableView.cs @@ -1189,6 +1189,39 @@ void dropDownContent(ImGui gui) { } #endregion + #region Percentage-based ingredient consumption + if (goods != null && recipe != null && recipe.hierarchyEnabled && type == ProductDropdownType.Ingredient) { + bool hasPercentageConstraint = recipe.ingredientConsumptionPercentages.ContainsKey(goods); + + if (!hasPercentageConstraint) { + // Show button to set percentage constraint + if (gui.BuildButton("Set consumption %") && gui.CloseDropdown()) { + var tmpRecipe = recipe.RecordUndo(); + tmpRecipe.ingredientConsumptionPercentages[goods] = 0.5f; // Default to 50% + // Clear fixed buildings when setting percentage constraint + // These two mechanisms conflict with each other + tmpRecipe.fixedBuildings = 0f; + Console.WriteLine($"DEBUG: Set percentage for {goods.target.name} in {recipe.recipe.target.name} to 50%"); + // Trigger solver recalculation when percentage constraint is set + if (recipe.owner is ProductionTable table && table.owner is ProjectPage page) { + page.SetToRecalculate(); + } + } + } + else { + // Show button to clear percentage constraint + if (gui.BuildButton("Clear consumption %") && gui.CloseDropdown()) { + _ = recipe.RecordUndo().ingredientConsumptionPercentages.Remove(goods); + // Trigger solver recalculation when percentage constraint is cleared + if (recipe.owner is ProductionTable table && table.owner is ProjectPage page) { + page.SetToRecalculate(); + } + } + } + targetGui.Rebuild(); + } + #endregion + if (goods is { target: Item item }) { BuildBeltInserterInfo(gui, item, amount, recipe?.buildingCount ?? 0); } @@ -1305,13 +1338,57 @@ private void BuildGoodsIcon(ImGui gui, IObjectWithQuality? goods, IProduc }; } - if (recipe != null && recipe.fixedBuildings > 0 && recipe.hierarchyEnabled + bool isFixedAmount = recipe != null && recipe.fixedBuildings > 0 && recipe.hierarchyEnabled && ((dropdownType == ProductDropdownType.Fuel && recipe.fixedFuel) || (dropdownType == ProductDropdownType.Ingredient && recipe.fixedIngredient == goods) - || (dropdownType == ProductDropdownType.Product && recipe.fixedProduct == goods))) { + || (dropdownType == ProductDropdownType.Product && recipe.fixedProduct == goods)); + bool hasPercentageConstraint = recipe != null && recipe.hierarchyEnabled && goods != null + && dropdownType == ProductDropdownType.Ingredient + && recipe.ingredientConsumptionPercentages.ContainsKey(goods); + + if (isFixedAmount) { + // Show editable amount for fixed amounts evt = gui.BuildFactorioObjectWithEditableAmount(goods, displayAmount, ButtonDisplayStyle.ProductionTableScaled(iconColor, drawTransparent), tooltipOptions: tooltipOptions, - setKeyboardFocus: recipe.ShouldFocusFixedCountThisTime()); + setKeyboardFocus: recipe?.ShouldFocusFixedCountThisTime() ?? SetKeyboardFocus.No); + } + else if (hasPercentageConstraint) { + // For percentage constraints, show the consumption amount with editable percentage underneath + evt = (GoodsWithAmountEvent)gui.BuildFactorioObjectWithAmount(goods, displayAmount, ButtonDisplayStyle.ProductionTableScaled(iconColor, drawTransparent), + TextBlockDisplayStyle.Centered with { Color = textColor }, tooltipOptions: tooltipOptions); + + // Add percentage input field underneath, similar to fixed building count + if (recipe != null && goods != null) { + float currentPercentage = recipe.ingredientConsumptionPercentages[goods]; + DisplayAmount percentageAmount = new DisplayAmount(currentPercentage, UnitOfMeasure.Percent); + + // Show just the percentage text without the icon to avoid duplication + using (gui.EnterRow()) { + gui.spacing = 0.25f; + if (gui.BuildFloatInput(percentageAmount, TextBoxDisplayStyle.FactorioObjectInput)) { + // percentageAmount.Value is already the decimal value (e.g., 0.6 for 60%) + // No need to divide by 100 - DisplayAmount with UnitOfMeasure.Percent handles the conversion + float newPercentage = percentageAmount.Value; + if (newPercentage <= 0f || newPercentage > 1f) { + // Remove percentage setting if set to 0, negative, or over 100% + _ = recipe.RecordUndo().ingredientConsumptionPercentages.Remove(goods); + Console.WriteLine($"DEBUG: Removed percentage constraint for {goods.target.name} in {recipe.recipe.target.name} (value was {newPercentage * 100f}%)"); + } + else { + var undoableRecipe = recipe.RecordUndo(); + undoableRecipe.ingredientConsumptionPercentages[goods] = newPercentage; + // Clear fixed buildings when setting percentage constraint + // These two mechanisms conflict with each other + undoableRecipe.fixedBuildings = 0f; + Console.WriteLine($"DEBUG: Updated percentage for {goods.target.name} in {recipe.recipe.target.name} to {newPercentage * 100f}% (stored as {newPercentage})"); + } + // Trigger solver recalculation when percentage constraint is modified + if (recipe.owner is ProductionTable table && table.owner is ProjectPage page) { + page.SetToRecalculate(); + } + } + } + } } else { evt = (GoodsWithAmountEvent)gui.BuildFactorioObjectWithAmount(goods, displayAmount, ButtonDisplayStyle.ProductionTableScaled(iconColor, drawTransparent), @@ -1329,8 +1406,29 @@ private void BuildGoodsIcon(ImGui gui, IObjectWithQuality? goods, IProduc RebuildIf(pLink.Destroy()); break; case GoodsWithAmountEvent.TextEditing when displayAmount.Value >= 0: - // The amount is always stored in fixedBuildings. Scale it to match the requested change to this item. - recipe!.RecordUndo().fixedBuildings *= displayAmount.Value / amount; + if (hasPercentageConstraint && !isFixedAmount && recipe != null && goods != null) { + // Handle percentage constraint editing + float newPercentage = displayAmount.Value / 100f; // Convert from percentage to decimal + if (newPercentage <= 0f || newPercentage >= 1f) { + // Remove percentage setting if set to 0, negative, or 100% + _ = recipe.RecordUndo().ingredientConsumptionPercentages.Remove(goods); + // Trigger solver recalculation when percentage constraint is removed + if (recipe.owner is ProductionTable table && table.owner is ProjectPage page) { + page.SetToRecalculate(); + } + } + else { + var undoableRecipe = recipe.RecordUndo(); + undoableRecipe.ingredientConsumptionPercentages[goods] = newPercentage; + // Clear fixed buildings when setting percentage constraint + // These two mechanisms conflict with each other + undoableRecipe.fixedBuildings = 0f; + } + } + else if (recipe != null) { + // The amount is always stored in fixedBuildings. Scale it to match the requested change to this item. + recipe.RecordUndo().fixedBuildings *= displayAmount.Value / amount; + } break; } } diff --git a/changelog.txt b/changelog.txt index 2bcadb1c..341895c5 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,3 +1,6 @@ +// If you want to add an entry to the changelog, then please add it to the section without a release date and version. +// If there is no such section, then copypaste the previous version, remove the info, and put the result below the commented section. +// Below is the format and the purpose of each field and section: // The purpose of the changelog is to provide a concise overview of what was changed. // The purpose of the changelog format is to make it more organized. // Versioning follows the x.y.z pattern. Since 0.8.0, the increment has the following meaning: @@ -18,7 +21,8 @@ ---------------------------------------------------------------------------------------------------------------------- Version: Date: - Features: + Features: + - Add the ability to specify ingredient consumption as a percentage. Fixes: - Fix icon rendering.