Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
291 changes: 234 additions & 57 deletions Yafc.Model/Analysis/CostAnalysis.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<string>();

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

/// <summary>
/// Analyzes recipe crafters to determine optimal fuel selection, emissions, size, and power requirements.
/// </summary>
/// <param name="recipe">The recipe to analyze</param>
/// <param name="shouldInclude">Function to determine if a fuel should be included in analysis</param>
/// <returns>Analysis results including fuel selection and crafter metrics</returns>
private static (Goods? singleUsedFuel, float singleUsedFuelAmount, float minEmissions, int minSize, float minPower) AnalyzeRecipeCrafters(Recipe recipe, Func<Goods, bool>? 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) {
Expand Down
15 changes: 15 additions & 0 deletions Yafc/Widgets/ObjectTooltip.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()) {
Expand Down
6 changes: 5 additions & 1 deletion changelog.txt
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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.
Expand Down
Loading