diff --git a/Yafc.Model/Analysis/CostAnalysis.cs b/Yafc.Model/Analysis/CostAnalysis.cs index b5e3cb0a..207b6d1e 100644 --- a/Yafc.Model/Analysis/CostAnalysis.cs +++ b/Yafc.Model/Analysis/CostAnalysis.cs @@ -10,6 +10,19 @@ namespace Yafc.Model; +/// +/// Breakdown of logistics cost components for a recipe. +/// +public record RecipeLogisticsBreakdown( + float TimeSizeCost, + float PowerCost, + float ItemTransportCost, + float FluidTransportCost, + float PollutionCost, + float MiningPenalty, + float TotalCost +); + public class CostAnalysis(bool onlyCurrentMilestones) : Analysis { private readonly ILogger logger = Logging.GetLogger(); @@ -30,6 +43,100 @@ public class CostAnalysis(bool onlyCurrentMilestones) : Analysis { private const float MiningMaxDensityForPenalty = 2000; // Mining things with less density than this gets extra penalty private const float MiningMaxExtraPenaltyForRarity = 10f; + /// + /// Computes the logistics cost breakdown for a recipe. + /// + public static RecipeLogisticsBreakdown ComputeLogisticsBreakdown(Project project, Recipe recipe) { + // Find minimum crafter stats + 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; + } + } + + if (minPower < 0f) { + minPower = 0f; + } + + // Calculate components + int size = Math.Max(minSize, (recipe.ingredients.Length + recipe.products.Length) / 2); + float sizeUsage = CostPerSecond * recipe.time * size; + float timeSizeCost = sizeUsage * (1f + (CostPerIngredientPerSize * recipe.ingredients.Length) + (CostPerProductPerSize * recipe.products.Length)); + float powerCost = CostPerMj * minPower; + + float itemTransportCost = 0f; + float fluidTransportCost = 0f; + + foreach (var product in recipe.products) { + if (product.goods is Item) { + itemTransportCost += product.amount * CostPerItem; + } + else if (product.goods is Fluid) { + fluidTransportCost += product.amount * CostPerFluid; + } + } + + foreach (var ingredient in recipe.ingredients) { + if (ingredient.goods is Item) { + itemTransportCost += ingredient.amount * CostPerItem; + } + else if (ingredient.goods is Fluid) { + fluidTransportCost += 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 += product.amount; + } + + miningPenalty = MiningPenalty; + float totalDensity = recipe.sourceEntity.mapGenDensity / totalMining; + + if (totalDensity < MiningMaxDensityForPenalty) { + float extraPenalty = MathF.Log(MiningMaxDensityForPenalty / totalDensity); + miningPenalty += Math.Min(extraPenalty, MiningMaxExtraPenaltyForRarity); + } + } + + float totalCost = ((timeSizeCost + powerCost + itemTransportCost + fluidTransportCost) * miningPenalty) + pollutionCost; + + return new RecipeLogisticsBreakdown( + TimeSizeCost: timeSizeCost, + PowerCost: powerCost, + ItemTransportCost: itemTransportCost, + FluidTransportCost: fluidTransportCost, + PollutionCost: pollutionCost, + MiningPenalty: miningPenalty, + TotalCost: totalCost + ); + } + public Mapping cost; public Mapping recipeCost; public Mapping recipeProductCost; @@ -121,29 +228,14 @@ 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; @@ -173,71 +265,25 @@ public override void Compute(Project project, ErrorCollector warnings) { } } - if (minPower < 0f) { - minPower = 0f; - } - - int size = Math.Max(minSize, (recipe.ingredients.Length + recipe.products.Length) / 2); - float sizeUsage = CostPerSecond * recipe.time * size; - float logisticsCost = (sizeUsage * (1f + (CostPerIngredientPerSize * recipe.ingredients.Length) + (CostPerProductPerSize * recipe.products.Length))) + (CostPerMj * minPower); - if (singleUsedFuel == Database.electricity.target || singleUsedFuel == Database.voidEnergy.target || singleUsedFuel == Database.heat.target) { singleUsedFuel = null; } + float logisticsCost = ComputeLogisticsBreakdown(project, recipe).TotalCost; + var constraint = workspaceSolver.MakeConstraint(double.NegativeInfinity, 0, recipe.name); constraints[recipe] = constraint; foreach (var product in recipe.products) { - var var = variables[product.goods]; - float amount = product.amount; - constraint.SetCoefficientCheck(var, amount, ref lastVariable[product.goods]); - - if (product.goods is Item) { - logisticsCost += amount * CostPerItem; - } - else if (product.goods is Fluid) { - logisticsCost += amount * CostPerFluid; - } + constraint.SetCoefficientCheck(variables[product.goods], product.amount, ref lastVariable[product.goods]); } if (singleUsedFuel != null) { - var var = variables[singleUsedFuel]; - constraint.SetCoefficientCheck(var, -singleUsedFuelAmount, ref lastVariable[singleUsedFuel]); + constraint.SetCoefficientCheck(variables[singleUsedFuel], -singleUsedFuelAmount, ref lastVariable[singleUsedFuel]); } foreach (var ingredient in recipe.ingredients) { - var var = variables[ingredient.goods]; // TODO split cost analysis - constraint.SetCoefficientCheck(var, -ingredient.amount, ref lastVariable[ingredient.goods]); - - if (ingredient.goods is Item) { - logisticsCost += ingredient.amount * CostPerItem; - } - else if (ingredient.goods is Fluid) { - logisticsCost += ingredient.amount * CostPerFluid; - } - } - - if (recipe.sourceEntity != null && recipe.sourceEntity.mapGenerated) { - float totalMining = 0f; - - foreach (var product in recipe.products) { - totalMining += product.amount; - } - - float miningPenalty = MiningPenalty; - float totalDensity = recipe.sourceEntity.mapGenDensity / totalMining; - - if (totalDensity < MiningMaxDensityForPenalty) { - float extraPenalty = MathF.Log(MiningMaxDensityForPenalty / totalDensity); - miningPenalty += Math.Min(extraPenalty, MiningMaxExtraPenaltyForRarity); - } - - logisticsCost *= miningPenalty; - } - - if (minEmissions >= 0f) { - logisticsCost += minEmissions * CostPerPollution * recipe.time * project.settings.PollutionCostModifier; + constraint.SetCoefficientCheck(variables[ingredient.goods], -ingredient.amount, ref lastVariable[ingredient.goods]); } constraint.SetUb(logisticsCost); diff --git a/Yafc/Data/locale/en/yafc.cfg b/Yafc/Data/locale/en/yafc.cfg index 1efe3a3b..a94336f8 100644 --- a/Yafc/Data/locale/en/yafc.cfg +++ b/Yafc/Data/locale/en/yafc.cfg @@ -508,6 +508,26 @@ cost-analysis-recipe-cost=YAFC cost per recipe: ¥__1__ cost-analysis-generic-cost=YAFC cost: ¥__1__ ; __1__ is one of the above cost-analysis-*-cost strings cost-analysis-with-current-cost=__1__ (Currently ¥__2__) +; Recipe cost breakdown tooltip +recipe-cost-header=Recipe cost breakdown +recipe-cost-header-at-milestones=Recipe cost breakdown (current) +recipe-cost-ingredients=Ingredient cost: ¥__1__ +recipe-cost-products=Product value: ¥__1__ +recipe-cost-logistics=Logistics cost: ¥__1__ +recipe-cost-ctrl-hint=(Hold Ctrl for details) +recipe-cost-shift-ctrl-hint=(Hold Ctrl+Shift for current milestones) +; Short for "Not Available" when a value cannot be computed +not-available=N/A +; Detailed logistics breakdown +recipe-logistics-header=Logistics breakdown +recipe-logistics-time-size=Time and size: ¥__1__ +recipe-logistics-power=Power: ¥__1__ +recipe-logistics-item-transport=Item transport: ¥__1__ +recipe-logistics-fluid-transport=Fluid transport: ¥__1__ +recipe-logistics-pollution=Pollution: ¥__1__ +recipe-logistics-mining-penalty=Mining penalty: __1__x +; Cost suffix for items in recipe tooltip +recipe-item-cost-suffix= (¥__1__) ; DependencyNode.cs dependency-or-bar=-- OR -- diff --git a/Yafc/Widgets/ObjectTooltip.cs b/Yafc/Widgets/ObjectTooltip.cs index dd9f2c1a..6e8f9429 100644 --- a/Yafc/Widgets/ObjectTooltip.cs +++ b/Yafc/Widgets/ObjectTooltip.cs @@ -143,10 +143,17 @@ private static void BuildIconRow(ImGui gui, IReadOnlyList 0f) { + gui.BuildText(LSs.RecipeLogisticsPower.L(DataUtils.FormatAmount(breakdown.PowerCost, UnitOfMeasure.None))); + } + if (breakdown.ItemTransportCost > 0f) { + gui.BuildText(LSs.RecipeLogisticsItemTransport.L(DataUtils.FormatAmount(breakdown.ItemTransportCost, UnitOfMeasure.None))); + } + if (breakdown.FluidTransportCost > 0f) { + gui.BuildText(LSs.RecipeLogisticsFluidTransport.L(DataUtils.FormatAmount(breakdown.FluidTransportCost, UnitOfMeasure.None))); + } + if (breakdown.PollutionCost > 0f) { + gui.BuildText(LSs.RecipeLogisticsPollution.L(DataUtils.FormatAmount(breakdown.PollutionCost, UnitOfMeasure.None))); + } + if (breakdown.MiningPenalty > 1f) { + gui.BuildText(LSs.RecipeLogisticsMiningPenalty.L(DataUtils.FormatAmount(breakdown.MiningPenalty, UnitOfMeasure.None))); + } + } + } + private static void BuildRecipe(RecipeOrTechnology recipe, ImGui gui) { + bool showCosts = InputSystem.Instance.control; + bool atMilestones = InputSystem.Instance.shift; + using (gui.EnterGroup(contentPadding, RectAllocator.LeftRow)) { gui.BuildIcon(Icon.Time, 2f, SchemeColor.BackgroundText); gui.BuildText(DataUtils.FormatAmount(recipe.time, UnitOfMeasure.Second)); @@ -478,7 +541,7 @@ private static void BuildRecipe(RecipeOrTechnology recipe, ImGui gui) { } else { foreach (Ingredient ingredient in recipe.ingredients) { - BuildItem(gui, ingredient); + BuildItem(gui, ingredient, showCosts: showCosts, atMilestones: atMilestones); } } @@ -511,12 +574,17 @@ private static void BuildRecipe(RecipeOrTechnology recipe, ImGui gui) { } } + // Cost breakdown section for recipes (not technologies) + if (showCosts && recipe is Recipe costRecipe && costRecipe.IsAutomatable()) { + BuildRecipeCostBreakdown(costRecipe, gui, atMilestones); + } + if (recipe is Recipe { products.Length: > 0 } && !(recipe.products.Length == 1 && recipe.products[0].IsSimple)) { BuildSubHeader(gui, LSs.TooltipHeaderRecipeProducts.L(recipe.products.Length)); using (gui.EnterGroup(contentPadding)) { string? extraText = recipe is Recipe { preserveProducts: true } ? LSs.ProductSuffixPreserved : null; foreach (var product in recipe.products) { - BuildItem(gui, product, extraText); + BuildItem(gui, product, extraText, showCosts, atMilestones); } } }