Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
178 changes: 112 additions & 66 deletions Yafc.Model/Analysis/CostAnalysis.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,19 @@

namespace Yafc.Model;

/// <summary>
/// Breakdown of logistics cost components for a recipe.
/// </summary>
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<CostAnalysis>();

Expand All @@ -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;

/// <summary>
/// Computes the logistics cost breakdown for a recipe.
/// </summary>
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<FactorioObject, float> cost;
public Mapping<Recipe, float> recipeCost;
public Mapping<RecipeOrTechnology, float> recipeProductCost;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
18 changes: 18 additions & 0 deletions Yafc/Data/locale/en/yafc.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -508,6 +508,24 @@ 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)
; 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 --
Expand Down
76 changes: 72 additions & 4 deletions Yafc/Widgets/ObjectTooltip.cs
Original file line number Diff line number Diff line change
Expand Up @@ -143,10 +143,17 @@ private static void BuildIconRow(ImGui gui, IReadOnlyList<IFactorioObjectWrapper
}
}

private static void BuildItem(ImGui gui, IFactorioObjectWrapper item, string? extraText = null) {
private static void BuildItem(ImGui gui, IFactorioObjectWrapper item, string? extraText = null, bool showCosts = false, bool atMilestones = false) {
using (gui.EnterRow()) {
gui.BuildFactorioObjectIcon(item);
gui.BuildText(item.text + extraText, TextBlockDisplayStyle.WrappedText);
string costSuffix = "";
if (showCosts && item.target is Goods goods) {
float costPerItem = goods.Cost(atMilestones);
if (!float.IsPositiveInfinity(costPerItem)) {
costSuffix = LSs.RecipeItemCostSuffix.L(DataUtils.FormatAmount(costPerItem * item.amount, UnitOfMeasure.None));
}
}
gui.BuildText(item.text + costSuffix + extraText, TextBlockDisplayStyle.WrappedText);
}
}

Expand Down Expand Up @@ -193,6 +200,9 @@ private void BuildCommon(FactorioObject target, ImGui gui) {
}
else {
gui.BuildText(CostAnalysis.GetDisplayCost(target), TextBlockDisplayStyle.WrappedText);
if (target is Recipe && !InputSystem.Instance.control) {
gui.BuildText(LSs.RecipeCostCtrlHint, TextBlockDisplayStyle.HintText);
}
}

if (target.IsAccessibleWithCurrentMilestones() && !target.IsAutomatableWithCurrentMilestones()) {
Expand Down Expand Up @@ -466,7 +476,60 @@ private void BuildGoods(Goods goods, Quality quality, ImGui gui) {
}
}

private static void BuildRecipeCostBreakdown(Recipe recipe, ImGui gui, bool atMilestones) {
bool isAutomatable = atMilestones ? recipe.IsAutomatableWithCurrentMilestones() : recipe.IsAutomatable();

// Calculate ingredient cost
float ingredientCost = 0f;
bool hasInfiniteIngredient = false;
foreach (var ingredient in recipe.ingredients) {
float itemCost = ingredient.goods.Cost(atMilestones);
if (float.IsPositiveInfinity(itemCost)) {
hasInfiniteIngredient = true;
}
else {
ingredientCost += itemCost * ingredient.amount;
}
}

BuildSubHeader(gui, atMilestones ? LSs.RecipeCostHeaderAtMilestones : LSs.RecipeCostHeader);
using (gui.EnterGroup(contentPadding)) {
if (!atMilestones) {
gui.BuildText(LSs.RecipeCostShiftCtrlHint, TextBlockDisplayStyle.HintText);
}
gui.BuildText(LSs.RecipeCostIngredients.L(hasInfiniteIngredient ? "N/A" : DataUtils.FormatAmount(ingredientCost, UnitOfMeasure.None)));
gui.BuildText(LSs.RecipeCostLogistics.L(isAutomatable ? DataUtils.FormatAmount(recipe.RecipeBaseCost(atMilestones), UnitOfMeasure.None) : "N/A"));
gui.AllocateSpacing(0.1f);
gui.BuildText(LSs.RecipeCostProducts.L(isAutomatable ? DataUtils.FormatAmount(recipe.ProductCost(atMilestones), UnitOfMeasure.None) : "N/A"));
Comment thread
veger marked this conversation as resolved.
Outdated
gui.AllocateSpacing(0.5f);

// Logistics breakdown
gui.BuildText(LSs.RecipeLogisticsHeader, Font.subheader);
var breakdown = CostAnalysis.ComputeLogisticsBreakdown(Project.current, recipe);

gui.BuildText(LSs.RecipeLogisticsTimeSize.L(DataUtils.FormatAmount(breakdown.TimeSizeCost, UnitOfMeasure.None)));
if (breakdown.PowerCost > 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));
Expand All @@ -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);
}
}

Expand Down Expand Up @@ -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);
}
}
}
Expand Down