diff --git a/Yafc.Model/Analysis/CostAnalysis.cs b/Yafc.Model/Analysis/CostAnalysis.cs index b5e3cb0a..febc7dca 100644 --- a/Yafc.Model/Analysis/CostAnalysis.cs +++ b/Yafc.Model/Analysis/CostAnalysis.cs @@ -119,63 +119,7 @@ public override void Compute(Project project, ErrorCollector warnings) { } // TODO incorporate fuel selection. Now just select fuel if it only uses 1 fuel - Goods? singleUsedFuel = null; - float singleUsedFuelAmount = 0f; - float minEmissions = 100f; - int minSize = 15; - float minPower = 1000f; - - foreach (var crafter in recipe.crafters) { - foreach ((_, float e) in crafter.energy.emissions) { - minEmissions = MathF.Min(e, minEmissions); - } - - if (crafter.energy.type == EntityEnergyType.Heat) { - break; - } - - if (crafter.size < minSize) { - minSize = crafter.size; - } - - float power = crafter.energy.type == EntityEnergyType.Void ? 0f : recipe.time * crafter.basePower / (crafter.baseCraftingSpeed * crafter.energy.effectivity); - - if (power < minPower) { - minPower = power; - } - - foreach (var fuel in crafter.energy.fuels) { - if (!ShouldInclude(fuel)) { - continue; - } - - if (fuel.fuelValue <= 0f) { - singleUsedFuel = null; - break; - } - - float amount = power / fuel.fuelValue; - - if (singleUsedFuel == null) { - singleUsedFuel = fuel; - singleUsedFuelAmount = amount; - } - else if (singleUsedFuel == fuel) { - singleUsedFuelAmount = MathF.Min(singleUsedFuelAmount, amount); - } - else { - singleUsedFuel = null; - break; - } - } - if (singleUsedFuel == null) { - break; - } - } - - if (minPower < 0f) { - minPower = 0f; - } + var (singleUsedFuel, singleUsedFuelAmount, minEmissions, minSize, minPower) = AnalyzeRecipeCrafters(recipe, ShouldInclude); int size = Math.Max(minSize, (recipe.ingredients.Length + recipe.products.Length) / 2); float sizeUsage = CostPerSecond * recipe.time * size; @@ -399,6 +343,239 @@ public static string GetDisplayCost(FactorioObject goods) { return finalCost; } + public static string GetCostBreakdown(FactorioObject goods, bool atCurrentMilestones = false) { + var analysis = Get(atCurrentMilestones); + float totalCost = analysis.cost[goods]; + + if (float.IsPositiveInfinity(totalCost)) { + return "Not automatable"; + } + + // Simple breakdown showing the components that make up the cost + var parts = new List(); + + if (goods is Goods g && g.production.Length > 0) { + // Find the recipe that would actually be used by the solver (lowest total cost including ingredients) + Recipe? currentRecipe = null; + float currentTotalCost = float.PositiveInfinity; + + foreach (var recipe in g.production) { + if (analysis.ShouldInclude(recipe)) { + // Calculate total cost: logistics cost + ingredient costs + float logisticsCost = analysis.recipeCost[recipe]; + float ingredientCost = 0f; + + foreach (var ingredient in recipe.ingredients) { + ingredientCost += analysis.cost[ingredient.goods] * (float)ingredient.amount; + } + + // Calculate cost per unit of the target goods + float totalOutput = 0f; + foreach (var product in recipe.products) { + if (product.goods == goods) { + totalOutput += (float)product.amount; + } + } + + if (totalOutput > 0f) { + float totalCostPerUnit = (logisticsCost + ingredientCost) / totalOutput; + if (totalCostPerUnit < currentTotalCost) { + currentTotalCost = totalCostPerUnit; + currentRecipe = recipe; + } + } + } + } + + if (currentRecipe != null) { + parts.Add($"Recipe: {currentRecipe.locName}"); + + // Calculate ingredient costs + float ingredientCost = 0f; + foreach (var ingredient in currentRecipe.ingredients) { + float ingredientUnitCost = analysis.cost[ingredient.goods]; + float ingredientTotalCost = ingredientUnitCost * (float)ingredient.amount; + ingredientCost += ingredientTotalCost; + parts.Add($" {ingredient.goods.locName}: ¥{DataUtils.FormatAmount(ingredientTotalCost, UnitOfMeasure.None)}"); + } + + // Calculate detailed logistics cost breakdown + var logisticsBreakdown = GetLogisticsCostBreakdown(currentRecipe, Project.current); + float logisticsCost = analysis.recipeCost[currentRecipe]; + + parts.Add($" Logistics: ¥{DataUtils.FormatAmount(logisticsCost, UnitOfMeasure.None)}"); + + // Show base logistics costs before mining penalty + float baseLogisticsCost = logisticsBreakdown.timeCost + logisticsBreakdown.energyCost + logisticsBreakdown.complexityCost + logisticsBreakdown.pollutionCost; + + if (logisticsBreakdown.miningPenalty > 1f) { + parts.Add($" Base cost: ¥{DataUtils.FormatAmount(baseLogisticsCost, UnitOfMeasure.None)}"); + parts.Add($" Mining penalty: ×{DataUtils.FormatAmount(logisticsBreakdown.miningPenalty, UnitOfMeasure.None)}"); + } + else { + parts.Add($" Time: ¥{DataUtils.FormatAmount(logisticsBreakdown.timeCost, UnitOfMeasure.None)}"); + parts.Add($" Energy: ¥{DataUtils.FormatAmount(logisticsBreakdown.energyCost, UnitOfMeasure.None)}"); + parts.Add($" Complexity: ¥{DataUtils.FormatAmount(logisticsBreakdown.complexityCost, UnitOfMeasure.None)}"); + + if (logisticsBreakdown.pollutionCost > 0f) { + parts.Add($" Pollution: ¥{DataUtils.FormatAmount(logisticsBreakdown.pollutionCost, UnitOfMeasure.None)}"); + } + } + + // Calculate final cost per unit + float totalOutput = 0f; + foreach (var product in currentRecipe.products) { + if (product.goods == goods) { + totalOutput += (float)product.amount; + } + } + + if (totalOutput > 0f) { + float costPerUnit = (logisticsCost + ingredientCost) / totalOutput; + parts.Add($"Per unit: ¥{DataUtils.FormatAmount(costPerUnit, UnitOfMeasure.None)}"); + } + } + else { + parts.Add($"Total: ¥{DataUtils.FormatAmount(totalCost, UnitOfMeasure.None)}"); + parts.Add("(No accessible recipe)"); + } + } + else { + parts.Add($"Total: ¥{DataUtils.FormatAmount(totalCost, UnitOfMeasure.None)}"); + if (goods is Goods g2 && g2.miscSources.Length > 0) { + parts.Add("(From misc sources)"); + } + else { + parts.Add("(No recipe available)"); + } + } + + return string.Join("\n", parts); + } + + /// + /// Analyzes recipe crafters to determine optimal fuel selection, emissions, size, and power requirements. + /// + /// The recipe to analyze + /// Function to determine if a fuel should be included in analysis + /// Analysis results including fuel selection and crafter metrics + private static (Goods? singleUsedFuel, float singleUsedFuelAmount, float minEmissions, int minSize, float minPower) AnalyzeRecipeCrafters(Recipe recipe, Func? shouldInclude = null) { + Goods? singleUsedFuel = null; + float singleUsedFuelAmount = 0f; + float minEmissions = 100f; + int minSize = 15; + float minPower = 1000f; + + foreach (var crafter in recipe.crafters) { + foreach ((_, float e) in crafter.energy.emissions) { + minEmissions = MathF.Min(e, minEmissions); + } + + if (crafter.energy.type == EntityEnergyType.Heat) { + break; + } + + if (crafter.size < minSize) { + minSize = crafter.size; + } + + float power = crafter.energy.type == EntityEnergyType.Void ? 0f : recipe.time * crafter.basePower / (crafter.baseCraftingSpeed * crafter.energy.effectivity); + + if (power < minPower) { + minPower = power; + } + + // Fuel analysis - only perform if shouldInclude function is provided + if (shouldInclude != null) { + foreach (var fuel in crafter.energy.fuels) { + if (!shouldInclude(fuel)) { + continue; + } + + if (fuel.fuelValue <= 0f) { + singleUsedFuel = null; + break; + } + + float amount = power / fuel.fuelValue; + + if (singleUsedFuel == null) { + singleUsedFuel = fuel; + singleUsedFuelAmount = amount; + } + else if (singleUsedFuel == fuel) { + singleUsedFuelAmount = MathF.Min(singleUsedFuelAmount, amount); + } + else { + singleUsedFuel = null; + break; + } + } + if (singleUsedFuel == null) { + break; + } + } + } + + if (minPower < 0f) { + minPower = 0f; + } + + return (singleUsedFuel, singleUsedFuelAmount, minEmissions, minSize, minPower); + } + + private static (float timeCost, float energyCost, float complexityCost, float pollutionCost, float miningPenalty) GetLogisticsCostBreakdown(Recipe recipe, Project project) { + // Use the shared analysis method without fuel selection for breakdown display + var (_, _, minEmissions, minSize, minPower) = AnalyzeRecipeCrafters(recipe); + + int size = Math.Max(minSize, (recipe.ingredients.Length + recipe.products.Length) / 2); + float timeCost = CostPerSecond * recipe.time * size; + float energyCost = CostPerMj * minPower; + float complexityCost = timeCost * ((CostPerIngredientPerSize * recipe.ingredients.Length) + (CostPerProductPerSize * recipe.products.Length)); + + // Add item/fluid handling costs to complexity + foreach (var product in recipe.products) { + if (product.goods is Item) { + complexityCost += (float)product.amount * CostPerItem; + } + else if (product.goods is Fluid) { + complexityCost += (float)product.amount * CostPerFluid; + } + } + + foreach (var ingredient in recipe.ingredients) { + if (ingredient.goods is Item) { + complexityCost += (float)ingredient.amount * CostPerItem; + } + else if (ingredient.goods is Fluid) { + complexityCost += (float)ingredient.amount * CostPerFluid; + } + } + + float pollutionCost = 0f; + if (minEmissions >= 0f) { + pollutionCost = minEmissions * CostPerPollution * recipe.time * project.settings.PollutionCostModifier; + } + + float miningPenalty = 1f; + if (recipe.sourceEntity != null && recipe.sourceEntity.mapGenerated) { + float totalMining = 0f; + foreach (var product in recipe.products) { + totalMining += (float)product.amount; + } + + miningPenalty = MiningPenalty; + float totalDensity = recipe.sourceEntity.mapGenDensity / totalMining; + + if (totalDensity < MiningMaxDensityForPenalty) { + float extraPenalty = MathF.Log(MiningMaxDensityForPenalty / totalDensity); + miningPenalty += Math.Min(extraPenalty, MiningMaxExtraPenaltyForRarity); + } + } + + return (timeCost, energyCost, complexityCost, pollutionCost, miningPenalty); + } + public static float GetBuildingHours(Recipe recipe, float flow) => recipe.time * flow * (1000f / 3600f); public string? GetItemAmount(Goods goods) { diff --git a/Yafc/Widgets/ObjectTooltip.cs b/Yafc/Widgets/ObjectTooltip.cs index 745b40be..94f6ebb5 100644 --- a/Yafc/Widgets/ObjectTooltip.cs +++ b/Yafc/Widgets/ObjectTooltip.cs @@ -193,6 +193,21 @@ private void BuildCommon(FactorioObject target, ImGui gui) { } else { gui.BuildText(CostAnalysis.GetDisplayCost(target), TextBlockDisplayStyle.WrappedText); + + // Show cost breakdown if Control is held + if (InputSystem.Instance.control) { + string breakdown = CostAnalysis.GetCostBreakdown(target, true); // Show current milestone costs + if (!string.IsNullOrEmpty(breakdown)) { + gui.BuildText("", TextBlockDisplayStyle.WrappedText); // Add some spacing + gui.BuildText("Cost Breakdown (Current Milestones):", TextBlockDisplayStyle.Default(SchemeColor.BackgroundText)); + + gui.BuildText(breakdown, TextBlockDisplayStyle.WrappedText with { Color = SchemeColor.BackgroundTextFaint }); + } + } + else { + // Show hint about cost breakdown + gui.BuildText("Hold Ctrl for cost breakdown", TextBlockDisplayStyle.HintText); + } } if (target.IsAccessibleWithCurrentMilestones() && !target.IsAutomatableWithCurrentMilestones()) { diff --git a/changelog.txt b/changelog.txt index 2bcadb1c..15856e71 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: + - Show a detailed cost breakdown in an item's tooltip when holding the Ctrl key. Fixes: - Fix icon rendering.