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